Compare commits
3
Commits
main
..
0ea57b3f82
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ea57b3f82 | ||
|
|
7c868ae073 | ||
|
|
283ab2f733 |
+1
-1
@@ -15,7 +15,7 @@
|
|||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
"build": "node version.js && npm run env -s && ng build --configuration production --output-hashing=none",
|
"build": "node version.js && npm run env -s && ng build --configuration production --output-hashing=none",
|
||||||
"build:prod": "node version.js && node --max-old-space-size=16384 ./node_modules/@angular/cli/bin/ng build --configuration production --output-hashing=none --base-href=/web-app/",
|
"build:prod": "node version.js && node --max-old-space-size=16384 ./node_modules/@angular/cli/bin/ng build --configuration production --output-hashing=none --base-href=/web-app/",
|
||||||
"start": "npm run env -s && cross-env NODE_OPTIONS=\"--max_old_space_size=4096\" ng serve --proxy-config proxy.localhost.conf.js",
|
"start": "npm run env -s && cross-env NODE_OPTIONS=\"--max_old_space_size=16384\" ng serve --proxy-config proxy.localhost.conf.js",
|
||||||
"start:share": "npm run env -s && cross-env NODE_OPTIONS=\"--max_old_space_size=16384\" ng serve --host 0.0.0.0 --proxy-config proxy.localhost.conf.js",
|
"start:share": "npm run env -s && cross-env NODE_OPTIONS=\"--max_old_space_size=16384\" ng serve --host 0.0.0.0 --proxy-config proxy.localhost.conf.js",
|
||||||
"start:demo": "npm run env -s && cross-env NODE_OPTIONS=\"--max_old_space_size=16384\" ng serve --proxy-config proxy.conf.js",
|
"start:demo": "npm run env -s && cross-env NODE_OPTIONS=\"--max_old_space_size=16384\" ng serve --proxy-config proxy.conf.js",
|
||||||
"serve:dev": "npm run env -s && cross-env NODE_OPTIONS=\"--max_old_space_size=16384\" ng serve --source-map --proxy-config proxy.localhost.conf.js",
|
"serve:dev": "npm run env -s && cross-env NODE_OPTIONS=\"--max_old_space_size=16384\" ng serve --source-map --proxy-config proxy.localhost.conf.js",
|
||||||
|
|||||||
@@ -279,7 +279,7 @@
|
|||||||
<div class="gl-fgroup">
|
<div class="gl-fgroup">
|
||||||
<label class="gl-flabel"><fa-icon icon="calendar-alt"></fa-icon> From Date</label>
|
<label class="gl-flabel"><fa-icon icon="calendar-alt"></fa-icon> From Date</label>
|
||||||
<mat-form-field appearance="outline" class="gl-ffield">
|
<mat-form-field appearance="outline" class="gl-ffield">
|
||||||
<input matInput [matDatepicker]="dpFrom" [min]="minDate" [formControl]="transactionDateFrom" />
|
<input matInput [matDatepicker]="dpFrom" [min]="minDate" [max]="maxDate" [formControl]="transactionDateFrom" />
|
||||||
<mat-datepicker-toggle matSuffix [for]="dpFrom"></mat-datepicker-toggle>
|
<mat-datepicker-toggle matSuffix [for]="dpFrom"></mat-datepicker-toggle>
|
||||||
<mat-datepicker #dpFrom></mat-datepicker>
|
<mat-datepicker #dpFrom></mat-datepicker>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
@@ -288,7 +288,7 @@
|
|||||||
<div class="gl-fgroup">
|
<div class="gl-fgroup">
|
||||||
<label class="gl-flabel"><fa-icon icon="calendar-alt"></fa-icon> To Date</label>
|
<label class="gl-flabel"><fa-icon icon="calendar-alt"></fa-icon> To Date</label>
|
||||||
<mat-form-field appearance="outline" class="gl-ffield">
|
<mat-form-field appearance="outline" class="gl-ffield">
|
||||||
<input matInput [matDatepicker]="dpTo" [min]="minDate" [formControl]="transactionDateTo" />
|
<input matInput [matDatepicker]="dpTo" [min]="minDate" [max]="maxDate" [formControl]="transactionDateTo" />
|
||||||
<mat-datepicker-toggle matSuffix [for]="dpTo"></mat-datepicker-toggle>
|
<mat-datepicker-toggle matSuffix [for]="dpTo"></mat-datepicker-toggle>
|
||||||
<mat-datepicker #dpTo></mat-datepicker>
|
<mat-datepicker #dpTo></mat-datepicker>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
@@ -353,7 +353,13 @@
|
|||||||
<div class="gl-fgroup">
|
<div class="gl-fgroup">
|
||||||
<label class="gl-flabel"><fa-icon icon="calendar-check"></fa-icon> Submitted From</label>
|
<label class="gl-flabel"><fa-icon icon="calendar-check"></fa-icon> Submitted From</label>
|
||||||
<mat-form-field appearance="outline" class="gl-ffield">
|
<mat-form-field appearance="outline" class="gl-ffield">
|
||||||
<input matInput [matDatepicker]="dpSubFrom" [min]="minDate" [formControl]="submittedOnDateFrom" />
|
<input
|
||||||
|
matInput
|
||||||
|
[matDatepicker]="dpSubFrom"
|
||||||
|
[min]="minDate"
|
||||||
|
[max]="maxDate"
|
||||||
|
[formControl]="submittedOnDateFrom"
|
||||||
|
/>
|
||||||
<mat-datepicker-toggle matSuffix [for]="dpSubFrom"></mat-datepicker-toggle>
|
<mat-datepicker-toggle matSuffix [for]="dpSubFrom"></mat-datepicker-toggle>
|
||||||
<mat-datepicker #dpSubFrom></mat-datepicker>
|
<mat-datepicker #dpSubFrom></mat-datepicker>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
@@ -362,7 +368,7 @@
|
|||||||
<div class="gl-fgroup">
|
<div class="gl-fgroup">
|
||||||
<label class="gl-flabel"><fa-icon icon="calendar-check"></fa-icon> Submitted To</label>
|
<label class="gl-flabel"><fa-icon icon="calendar-check"></fa-icon> Submitted To</label>
|
||||||
<mat-form-field appearance="outline" class="gl-ffield">
|
<mat-form-field appearance="outline" class="gl-ffield">
|
||||||
<input matInput [matDatepicker]="dpSubTo" [min]="minDate" [formControl]="submittedOnDateTo" />
|
<input matInput [matDatepicker]="dpSubTo" [min]="minDate" [max]="maxDate" [formControl]="submittedOnDateTo" />
|
||||||
<mat-datepicker-toggle matSuffix [for]="dpSubTo"></mat-datepicker-toggle>
|
<mat-datepicker-toggle matSuffix [for]="dpSubTo"></mat-datepicker-toggle>
|
||||||
<mat-datepicker #dpSubTo></mat-datepicker>
|
<mat-datepicker #dpSubTo></mat-datepicker>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
@@ -533,7 +539,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="gl-table-shell">
|
<div class="gl-table-shell">
|
||||||
<mat-table [dataSource]="dataSource" matSort matSortActive="id" matSortDirection="desc" class="gl-mat-table">
|
<mat-table [dataSource]="dataSource" matSort class="gl-mat-table">
|
||||||
<!-- #ID -->
|
<!-- #ID -->
|
||||||
<ng-container matColumnDef="id">
|
<ng-container matColumnDef="id">
|
||||||
<mat-header-cell *matHeaderCellDef mat-sort-header class="gl-th">#</mat-header-cell>
|
<mat-header-cell *matHeaderCellDef mat-sort-header class="gl-th">#</mat-header-cell>
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export class SearchJournalEntryComponent implements OnInit, OnDestroy, AfterView
|
|||||||
|
|
||||||
// ── Misc ──────────────────────────────────────────────────────────────────
|
// ── Misc ──────────────────────────────────────────────────────────────────
|
||||||
minDate = new Date(2000, 0, 1);
|
minDate = new Date(2000, 0, 1);
|
||||||
maxDate: Date = null;
|
maxDate = new Date();
|
||||||
/** Accounting features switched off for this installation — see disabled-features.ts. */
|
/** Accounting features switched off for this installation — see disabled-features.ts. */
|
||||||
disabledFeatures = DISABLED_ACCOUNTING_FEATURES;
|
disabledFeatures = DISABLED_ACCOUNTING_FEATURES;
|
||||||
|
|
||||||
@@ -208,7 +208,7 @@ export class SearchJournalEntryComponent implements OnInit, OnDestroy, AfterView
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
this.maxDate = null;
|
this.maxDate = this.settingsService.businessDate;
|
||||||
this.paginator.pageSize = 10;
|
this.paginator.pageSize = 10;
|
||||||
this.restoreState();
|
this.restoreState();
|
||||||
this.setFilteredOffices();
|
this.setFilteredOffices();
|
||||||
|
|||||||
@@ -74,22 +74,6 @@ export class AccountsService {
|
|||||||
return this.http.get('/fixeddepositaccounts', { params: httpParams });
|
return this.http.get('/fixeddepositaccounts', { params: httpParams });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* FD Dashboard's data source: every ACTIVE FD, id/account/client/product identity, maturity date
|
|
||||||
* and deposit/maturity amounts only. Deliberately a separate, narrow endpoint rather than
|
|
||||||
* getFixedDepositAccounts() above -- that one's backend query joins in six more tables (audit
|
|
||||||
* who-did-what-when, currency, tax group) an unbounded dashboard fetch does not need, over columns
|
|
||||||
* that had no index until this feature added one (see backend migration 0571).
|
|
||||||
*/
|
|
||||||
getFixedDepositMaturityDashboard(): Observable<any> {
|
|
||||||
return this.http.get('/fixeddepositaccounts/maturity-dashboard');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** RD Dashboard: running RDs with their installment position, closures and penalty income for the month. */
|
|
||||||
getRecurringDepositDashboard(): Observable<any> {
|
|
||||||
return this.http.get('/recurringdepositaccounts/dashboard');
|
|
||||||
}
|
|
||||||
|
|
||||||
getRecurringDepositAccounts(params: AccountListParams = {}): Observable<any> {
|
getRecurringDepositAccounts(params: AccountListParams = {}): Observable<any> {
|
||||||
let httpParams = new HttpParams()
|
let httpParams = new HttpParams()
|
||||||
.set('paged', 'true')
|
.set('paged', 'true')
|
||||||
|
|||||||
@@ -63,8 +63,6 @@ import { CfcfrmsModule } from './cfcfrms/cfcfrms.module';
|
|||||||
import { BankCalendarModule } from './bank-calendar/bank-calendar.module';
|
import { BankCalendarModule } from './bank-calendar/bank-calendar.module';
|
||||||
import { DeafModule } from './deaf/deaf.module';
|
import { DeafModule } from './deaf/deaf.module';
|
||||||
import { SavingsDailyInterestModule } from './savings-daily-interest/savings-daily-interest.module';
|
import { SavingsDailyInterestModule } from './savings-daily-interest/savings-daily-interest.module';
|
||||||
import { FdDashboardModule } from './fd-dashboard/fd-dashboard.module';
|
|
||||||
import { RdDashboardModule } from './rd-dashboard/rd-dashboard.module';
|
|
||||||
import { DicgcModule } from './dicgc/dicgc.module';
|
import { DicgcModule } from './dicgc/dicgc.module';
|
||||||
import { FiuModule } from './fiu/fiu.module';
|
import { FiuModule } from './fiu/fiu.module';
|
||||||
import { GlReportsModule } from './gl-reports/gl-reports.module';
|
import { GlReportsModule } from './gl-reports/gl-reports.module';
|
||||||
@@ -178,8 +176,6 @@ export function HttpLoaderFactory(http: HttpClient) {
|
|||||||
BankCalendarModule,
|
BankCalendarModule,
|
||||||
DeafModule,
|
DeafModule,
|
||||||
SavingsDailyInterestModule,
|
SavingsDailyInterestModule,
|
||||||
FdDashboardModule,
|
|
||||||
RdDashboardModule,
|
|
||||||
DicgcModule,
|
DicgcModule,
|
||||||
FiuModule,
|
FiuModule,
|
||||||
GlReportsModule,
|
GlReportsModule,
|
||||||
|
|||||||
@@ -17,12 +17,7 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button class="bor-btn bor-btn--xls" type="button" [disabled]="exporting || !filtered.length" (click)="exportExcel()">
|
||||||
class="bor-btn bor-btn--xls"
|
|
||||||
type="button"
|
|
||||||
[disabled]="exporting || !filtered.length"
|
|
||||||
(click)="exportExcel()"
|
|
||||||
>
|
|
||||||
<fa-icon icon="file-excel"></fa-icon>
|
<fa-icon icon="file-excel"></fa-icon>
|
||||||
{{ exporting ? 'Exporting…' : 'Export' }}
|
{{ exporting ? 'Exporting…' : 'Export' }}
|
||||||
</button>
|
</button>
|
||||||
@@ -137,9 +132,7 @@
|
|||||||
<td class="num">{{ r.approvedShares | number }}</td>
|
<td class="num">{{ r.approvedShares | number }}</td>
|
||||||
<td class="num strong">{{ r.shareValue | number: '1.2-2' }}</td>
|
<td class="num strong">{{ r.shareValue | number: '1.2-2' }}</td>
|
||||||
<td class="num">{{ r.trancheCount }}</td>
|
<td class="num">{{ r.trancheCount }}</td>
|
||||||
<td>
|
<td><span [class]="statusClass(r.statusEnum)">{{ statusText(r.statusEnum) }}</span></td>
|
||||||
<span [class]="statusClass(r.statusEnum)">{{ statusText(r.statusEnum) }}</span>
|
|
||||||
</td>
|
|
||||||
<td class="mono">{{ r.activatedDate || '—' }}</td>
|
<td class="mono">{{ r.activatedDate || '—' }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
} @empty {
|
} @empty {
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import { FormsModule } from '@angular/forms';
|
|||||||
import { MatIconModule } from '@angular/material/icon';
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
import { catchError, of } from 'rxjs';
|
import { catchError, of } from 'rxjs';
|
||||||
import { CashierService } from '../cashier.service';
|
import { CashierService } from '../cashier.service';
|
||||||
import { businessToday } from 'app/core/utils/business-today';
|
|
||||||
|
|
||||||
interface RegisterRow {
|
interface RegisterRow {
|
||||||
accountId: number;
|
accountId: number;
|
||||||
@@ -84,7 +83,7 @@ export class AccountRegisterComponent implements OnInit {
|
|||||||
|
|
||||||
/** Local date — toISOString() would shift the day in any timezone behind UTC. */
|
/** Local date — toISOString() would shift the day in any timezone behind UTC. */
|
||||||
private today(): string {
|
private today(): string {
|
||||||
const d = businessToday();
|
const d = new Date();
|
||||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -107,26 +107,11 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@if (canOperate) {
|
@if (canOperate) {
|
||||||
<button
|
<button type="button" class="pg-btn pg-btn--primary" (click)="startClose(openDay)">
|
||||||
type="button"
|
|
||||||
class="pg-btn pg-btn--primary"
|
|
||||||
(click)="startClose(openDay)"
|
|
||||||
[disabled]="interestPending.length > 0"
|
|
||||||
>
|
|
||||||
<mat-icon>lock</mat-icon> Close Day
|
<mat-icon>lock</mat-icon> Close Day
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@if (interestPending.length > 0) {
|
|
||||||
<div class="bd-interest-alert">
|
|
||||||
<mat-icon>warning</mat-icon>
|
|
||||||
<span>
|
|
||||||
Savings interest posting report is not approved for <strong>{{ interestPending.join(', ') }}</strong
|
|
||||||
>. Approve it in <a routerLink="/reports/interest-posting">Reports → Interest Posting Reports</a> before
|
|
||||||
closing the day.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
} @else if (loaded && canOperate) {
|
} @else if (loaded && canOperate) {
|
||||||
<div class="bd-current bd-current--shut">
|
<div class="bd-current bd-current--shut">
|
||||||
<div class="bd-current__main">
|
<div class="bd-current__main">
|
||||||
|
|||||||
@@ -372,30 +372,3 @@
|
|||||||
color: #d97706;
|
color: #d97706;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shown under the open day while a savings interest posting report due today is unapproved.
|
|
||||||
.bd-interest-alert {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 8px;
|
|
||||||
margin: 8px 0 12px;
|
|
||||||
padding: 10px 14px;
|
|
||||||
border: 1px solid #f59e0b;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: #fef3c7;
|
|
||||||
color: #92400e;
|
|
||||||
font-size: 13px;
|
|
||||||
|
|
||||||
mat-icon {
|
|
||||||
flex-shrink: 0;
|
|
||||||
font-size: 20px;
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
color: inherit;
|
|
||||||
font-weight: 600;
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -189,23 +189,6 @@ export class BranchDayComponent implements OnInit {
|
|||||||
return this.totalOf(this.closeCounts);
|
return this.totalOf(this.closeCounts);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Savings products whose interest posting report is due today and not yet approved. The close is
|
|
||||||
* refused by the backend while any is pending; this only says so before the user tries.
|
|
||||||
*/
|
|
||||||
interestPending: string[] = [];
|
|
||||||
|
|
||||||
private checkInterestReports(): void {
|
|
||||||
if (!this.openDay?.businessDate) {
|
|
||||||
this.interestPending = [];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.service
|
|
||||||
.interestDayCloseCheck(this.openDay.businessDate, this.openDay.officeId)
|
|
||||||
.pipe(catchError(() => of(null)))
|
|
||||||
.subscribe((res) => (this.interestPending = res?.pendingProducts ?? []));
|
|
||||||
}
|
|
||||||
|
|
||||||
load(): void {
|
load(): void {
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
this.errorMsg = '';
|
this.errorMsg = '';
|
||||||
@@ -227,7 +210,6 @@ export class BranchDayComponent implements OnInit {
|
|||||||
this.canOperate = !!res?.canOperate;
|
this.canOperate = !!res?.canOperate;
|
||||||
this.openDate = this.today;
|
this.openDate = this.today;
|
||||||
this.checkFirstOpenCashMigration();
|
this.checkFirstOpenCashMigration();
|
||||||
this.checkInterestReports();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
this.service
|
this.service
|
||||||
|
|||||||
+1
-2
@@ -11,7 +11,6 @@ import { CommonModule } from '@angular/common';
|
|||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { AuthenticationService } from '../../core/authentication/authentication.service';
|
import { AuthenticationService } from '../../core/authentication/authentication.service';
|
||||||
import { ExportSheetSpec, exportWorkbook } from '../cashier-export';
|
import { ExportSheetSpec, exportWorkbook } from '../cashier-export';
|
||||||
import { businessToday } from 'app/core/utils/business-today';
|
|
||||||
|
|
||||||
interface SummaryRow {
|
interface SummaryRow {
|
||||||
code: string;
|
code: string;
|
||||||
@@ -1317,7 +1316,7 @@ export class CashClearingTransferSummaryComponent {
|
|||||||
private dateTag = '2607';
|
private dateTag = '2607';
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
const d = businessToday();
|
const d = new Date();
|
||||||
this.asOnDate = `${String(d.getDate()).padStart(2, '0')}-${String(d.getMonth() + 1).padStart(2, '0')}-${d.getFullYear()}`;
|
this.asOnDate = `${String(d.getDate()).padStart(2, '0')}-${String(d.getMonth() + 1).padStart(2, '0')}-${d.getFullYear()}`;
|
||||||
this.dateTag = `${String(d.getFullYear()).slice(-2)}${String(d.getMonth() + 1).padStart(2, '0')}`;
|
this.dateTag = `${String(d.getFullYear()).slice(-2)}${String(d.getMonth() + 1).padStart(2, '0')}`;
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import { FormsModule } from '@angular/forms';
|
|||||||
import { MatIconModule } from '@angular/material/icon';
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||||
import { CashierService } from '../cashier.service';
|
import { CashierService } from '../cashier.service';
|
||||||
import { businessToday } from 'app/core/utils/business-today';
|
|
||||||
|
|
||||||
interface Denom {
|
interface Denom {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -64,7 +63,7 @@ export class CashExchangeEntryComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
const d = businessToday();
|
const d = new Date();
|
||||||
this.exchangeDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
this.exchangeDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||||
this.service.getActiveDenominations().subscribe({
|
this.service.getActiveDenominations().subscribe({
|
||||||
next: (list) => {
|
next: (list) => {
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import { MatInputModule } from '@angular/material/input';
|
|||||||
import { forkJoin, of } from 'rxjs';
|
import { forkJoin, of } from 'rxjs';
|
||||||
import { catchError } from 'rxjs/operators';
|
import { catchError } from 'rxjs/operators';
|
||||||
import { CashierService } from '../cashier.service';
|
import { CashierService } from '../cashier.service';
|
||||||
import { businessToday } from 'app/core/utils/business-today';
|
|
||||||
|
|
||||||
type PageState = 'loading' | 'no-cashier' | 'needs-reconciliation' | 'already-settled' | 'form';
|
type PageState = 'loading' | 'no-cashier' | 'needs-reconciliation' | 'already-settled' | 'form';
|
||||||
|
|
||||||
@@ -91,7 +90,7 @@ export class CashSettlementComponent implements OnInit {
|
|||||||
saving = false;
|
saving = false;
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
const d = businessToday();
|
const d = new Date();
|
||||||
this.settlementDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
this.settlementDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||||
this.service
|
this.service
|
||||||
.getTellers()
|
.getTellers()
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import { MatInputModule } from '@angular/material/input';
|
|||||||
import { of } from 'rxjs';
|
import { of } from 'rxjs';
|
||||||
import { catchError } from 'rxjs/operators';
|
import { catchError } from 'rxjs/operators';
|
||||||
import { CashierService } from '../cashier.service';
|
import { CashierService } from '../cashier.service';
|
||||||
import { businessToday } from 'app/core/utils/business-today';
|
|
||||||
|
|
||||||
interface Denom {
|
interface Denom {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -83,7 +82,7 @@ export class CashTransferComponent implements OnInit {
|
|||||||
loadingVault = false;
|
loadingVault = false;
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
const d = businessToday();
|
const d = new Date();
|
||||||
this.transferDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
this.transferDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||||
this.service.getActiveDenominations().subscribe({
|
this.service.getActiveDenominations().subscribe({
|
||||||
next: (list) => {
|
next: (list) => {
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import { forkJoin, of } from 'rxjs';
|
|||||||
import { catchError } from 'rxjs/operators';
|
import { catchError } from 'rxjs/operators';
|
||||||
import { CashierService } from '../cashier.service';
|
import { CashierService } from '../cashier.service';
|
||||||
import { AuthenticationService } from '../../core/authentication/authentication.service';
|
import { AuthenticationService } from '../../core/authentication/authentication.service';
|
||||||
import { businessToday } from 'app/core/utils/business-today';
|
|
||||||
|
|
||||||
interface DenomRow {
|
interface DenomRow {
|
||||||
denominationId: number;
|
denominationId: number;
|
||||||
@@ -72,7 +71,7 @@ export class CashierPositionComponent implements OnInit {
|
|||||||
isCashierMode = false;
|
isCashierMode = false;
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
const d = businessToday();
|
const d = new Date();
|
||||||
this.asOfDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
this.asOfDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||||
|
|
||||||
this.isCashierMode = this.authService.isCashierUser();
|
this.isCashierMode = this.authService.isCashierUser();
|
||||||
|
|||||||
@@ -205,23 +205,6 @@ export class CashierService {
|
|||||||
return this.http.get('/branchday', { params });
|
return this.http.get('/branchday', { params });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The savings products whose interest posting report is due for the date and not yet approved —
|
|
||||||
* the backend refuses to close any branch's day while one is pending.
|
|
||||||
*/
|
|
||||||
interestDayCloseCheck(
|
|
||||||
date?: string,
|
|
||||||
officeId?: number
|
|
||||||
): Observable<{ businessDate: string; canClose: boolean; pendingProducts: string[] }> {
|
|
||||||
let params = new HttpParams();
|
|
||||||
if (date) params = params.set('date', date);
|
|
||||||
if (officeId) params = params.set('officeId', String(officeId));
|
|
||||||
return this.http.get<{ businessDate: string; canClose: boolean; pendingProducts: string[] }>(
|
|
||||||
'/savingsinterestpostingreports/day-close-check',
|
|
||||||
{ params }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Days opened and never closed — today's own open day excluded. */
|
/** Days opened and never closed — today's own open day excluded. */
|
||||||
branchDayMissed(officeId?: number): Observable<any> {
|
branchDayMissed(officeId?: number): Observable<any> {
|
||||||
let params = new HttpParams();
|
let params = new HttpParams();
|
||||||
|
|||||||
@@ -51,12 +51,6 @@ export class ClerkShareWithdrawalService {
|
|||||||
return this.http.post(`/clerk/share-withdrawal-requests/${id}/validate`, {});
|
return this.http.post(`/clerk/share-withdrawal-requests/${id}/validate`, {});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Runs the exact same checklist approve() will run, against a draft that hasn't been submitted
|
|
||||||
* yet — so the clerk sees it in the confirm dialog, before committing to the request. */
|
|
||||||
previewValidate(data: any): Observable<any> {
|
|
||||||
return this.http.post('/clerk/share-withdrawal-requests/preview-validate', data);
|
|
||||||
}
|
|
||||||
|
|
||||||
approve(id: number): Observable<any> {
|
approve(id: number): Observable<any> {
|
||||||
return this.http.post(`/clerk/share-withdrawal-requests/${id}/approve`, {});
|
return this.http.post(`/clerk/share-withdrawal-requests/${id}/approve`, {});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import { FormsModule } from '@angular/forms';
|
|||||||
import { MatIconModule } from '@angular/material/icon';
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
|
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
|
||||||
import { CashierService } from '../cashier.service';
|
import { CashierService } from '../cashier.service';
|
||||||
import { businessToday } from 'app/core/utils/business-today';
|
|
||||||
|
|
||||||
/** Day-wise denomination-wise closing cash collected from deposits. */
|
/** Day-wise denomination-wise closing cash collected from deposits. */
|
||||||
@Component({
|
@Component({
|
||||||
@@ -205,7 +204,7 @@ export class ClosingCashReportComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
const d = businessToday();
|
const d = new Date();
|
||||||
this.date = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
this.date = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||||
this.load();
|
this.load();
|
||||||
this.service.getTellers().subscribe({ next: (t: any[]) => (this.tellers = t || []), error: () => {} });
|
this.service.getTellers().subscribe({ next: (t: any[]) => (this.tellers = t || []), error: () => {} });
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import { MatIconModule } from '@angular/material/icon';
|
|||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { catchError, of } from 'rxjs';
|
import { catchError, of } from 'rxjs';
|
||||||
import { CashierService } from '../cashier.service';
|
import { CashierService } from '../cashier.service';
|
||||||
import { businessToday } from 'app/core/utils/business-today';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Which part of the day's work a row came from. Set by the server, one per source register.
|
* Which part of the day's work a row came from. Set by the server, one per source register.
|
||||||
@@ -96,7 +95,7 @@ export class DayBookComponent implements OnInit {
|
|||||||
|
|
||||||
/** Local date as yyyy-MM-dd — toISOString() would shift the day in any timezone behind UTC. */
|
/** Local date as yyyy-MM-dd — toISOString() would shift the day in any timezone behind UTC. */
|
||||||
private today(): string {
|
private today(): string {
|
||||||
const d = businessToday();
|
const d = new Date();
|
||||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import { MatInputModule } from '@angular/material/input';
|
|||||||
import { of } from 'rxjs';
|
import { of } from 'rxjs';
|
||||||
import { catchError } from 'rxjs/operators';
|
import { catchError } from 'rxjs/operators';
|
||||||
import { CashierService } from '../cashier.service';
|
import { CashierService } from '../cashier.service';
|
||||||
import { businessToday } from 'app/core/utils/business-today';
|
|
||||||
|
|
||||||
interface Denom {
|
interface Denom {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -80,7 +79,7 @@ export class InterCashierTransferComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
const d = businessToday();
|
const d = new Date();
|
||||||
this.transferDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
this.transferDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||||
this.service.getActiveDenominations().subscribe({
|
this.service.getActiveDenominations().subscribe({
|
||||||
next: (list) => {
|
next: (list) => {
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
|||||||
import { catchError, finalize, of } from 'rxjs';
|
import { catchError, finalize, of } from 'rxjs';
|
||||||
import { CashierService } from '../cashier.service';
|
import { CashierService } from '../cashier.service';
|
||||||
import { VoucherDetailDialogComponent } from './voucher-detail-dialog.component';
|
import { VoucherDetailDialogComponent } from './voucher-detail-dialog.component';
|
||||||
import { businessToday } from 'app/core/utils/business-today';
|
|
||||||
|
|
||||||
/** What the signed-in user's part in a voucher was. */
|
/** What the signed-in user's part in a voucher was. */
|
||||||
export type MyRole = 'RAISED' | 'VERIFIED' | 'RAISED_AND_VERIFIED';
|
export type MyRole = 'RAISED' | 'VERIFIED' | 'RAISED_AND_VERIFIED';
|
||||||
@@ -358,7 +357,7 @@ export class MyWorkComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private todayIso(): string {
|
private todayIso(): string {
|
||||||
const d = businessToday();
|
const d = new Date();
|
||||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import { MatTabsModule } from '@angular/material/tabs';
|
|||||||
import { forkJoin, of } from 'rxjs';
|
import { forkJoin, of } from 'rxjs';
|
||||||
import { catchError } from 'rxjs/operators';
|
import { catchError } from 'rxjs/operators';
|
||||||
import { CashierService } from '../cashier.service';
|
import { CashierService } from '../cashier.service';
|
||||||
import { businessToday } from 'app/core/utils/business-today';
|
|
||||||
|
|
||||||
interface DenomRow {
|
interface DenomRow {
|
||||||
denominationId: number;
|
denominationId: number;
|
||||||
@@ -86,7 +85,7 @@ export class ReconciliationComponent implements OnInit {
|
|||||||
loadingHistory = false;
|
loadingHistory = false;
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
const d = businessToday();
|
const d = new Date();
|
||||||
this.reconDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
this.reconDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||||
this.service
|
this.service
|
||||||
.getTellers()
|
.getTellers()
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { CommonModule } from '@angular/common';
|
|||||||
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
|
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
import { catchError, debounceTime, distinctUntilChanged, map, timeout } from 'rxjs/operators';
|
import { catchError, debounceTime, distinctUntilChanged, map } from 'rxjs/operators';
|
||||||
import { forkJoin, of, Subject } from 'rxjs';
|
import { forkJoin, of, Subject } from 'rxjs';
|
||||||
import { MatDialog, MatDialogModule, MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
import { MatDialog, MatDialogModule, MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||||
import { MatIconModule } from '@angular/material/icon';
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
@@ -33,7 +33,6 @@ import {
|
|||||||
import { SponsorDDService } from '../../sponsor-dd/sponsor-dd.service';
|
import { SponsorDDService } from '../../sponsor-dd/sponsor-dd.service';
|
||||||
import { OrganizationService } from '../../organization/organization.service';
|
import { OrganizationService } from '../../organization/organization.service';
|
||||||
import { CashierIssueDdDialogComponent } from './cashier-issue-dd-dialog.component';
|
import { CashierIssueDdDialogComponent } from './cashier-issue-dd-dialog.component';
|
||||||
import { businessToday, businessTodayIso } from 'app/core/utils/business-today';
|
|
||||||
|
|
||||||
interface TxnRow {
|
interface TxnRow {
|
||||||
typ: string;
|
typ: string;
|
||||||
@@ -622,7 +621,7 @@ export class AccountStatementDialogComponent implements OnInit {
|
|||||||
|
|
||||||
@ViewChild('statementContent') statementContent!: ElementRef;
|
@ViewChild('statementContent') statementContent!: ElementRef;
|
||||||
|
|
||||||
today = businessTodayIso();
|
today = new Date().toISOString().slice(0, 10);
|
||||||
fromDate = '';
|
fromDate = '';
|
||||||
toDate = this.today;
|
toDate = this.today;
|
||||||
|
|
||||||
@@ -1871,7 +1870,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
saving = false;
|
saving = false;
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
const d = businessToday();
|
const d = new Date();
|
||||||
this.today = `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}/${d.getFullYear()}`;
|
this.today = `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}/${d.getFullYear()}`;
|
||||||
this.addRow();
|
this.addRow();
|
||||||
this.loadDenominations();
|
this.loadDenominations();
|
||||||
@@ -2104,7 +2103,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private loadOpeningPosition(): void {
|
private loadOpeningPosition(): void {
|
||||||
const d = businessToday();
|
const d = new Date();
|
||||||
const asOfDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
const asOfDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||||
this.cashierService
|
this.cashierService
|
||||||
.getCashierPositionForMe(asOfDate)
|
.getCashierPositionForMe(asOfDate)
|
||||||
@@ -2838,7 +2837,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
fundingAccountId: number,
|
fundingAccountId: number,
|
||||||
fundingClientId: number | null
|
fundingClientId: number | null
|
||||||
): void {
|
): void {
|
||||||
const d = businessToday();
|
const d = new Date();
|
||||||
const issuedDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
const issuedDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||||
const isCurrent = row.acTyp === 'CA' || row.acTyp === 'CB';
|
const isCurrent = row.acTyp === 'CA' || row.acTyp === 'CB';
|
||||||
const isGl = row.acTyp === 'GL';
|
const isGl = row.acTyp === 'GL';
|
||||||
@@ -2970,7 +2969,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const d = businessToday();
|
const d = new Date();
|
||||||
const issuedDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
const issuedDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||||
const isCurrent = row.acTyp === 'CA' || row.acTyp === 'CB';
|
const isCurrent = row.acTyp === 'CA' || row.acTyp === 'CB';
|
||||||
|
|
||||||
@@ -3069,7 +3068,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const d = businessToday();
|
const d = new Date();
|
||||||
const issuedDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
const issuedDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
@@ -3392,7 +3391,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const today = businessToday();
|
const today = new Date();
|
||||||
const requestedDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
const requestedDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
||||||
|
|
||||||
// Full snapshot of the CR (settlement) row exactly as the clerk filled it in — saved verbatim
|
// Full snapshot of the CR (settlement) row exactly as the clerk filled it in — saved verbatim
|
||||||
@@ -3426,7 +3425,6 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
meetingDate: shareRow.meetingDate,
|
meetingDate: shareRow.meetingDate,
|
||||||
description: shareRow.description || null,
|
description: shareRow.description || null,
|
||||||
settlementPaymentTypeId: savingsRow.paymentTypeId,
|
settlementPaymentTypeId: savingsRow.paymentTypeId,
|
||||||
settlementMode: 'SAVINGS',
|
|
||||||
settlementRowSnapshot
|
settlementRowSnapshot
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -3457,60 +3455,11 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
? 'Pending Manager approval — redemption, settlement and account closure all post together on approval.'
|
? 'Pending Manager approval — redemption, settlement and account closure all post together on approval.'
|
||||||
: 'Pending Manager approval — redemption and settlement both post together on approval.'
|
: 'Pending Manager approval — redemption and settlement both post together on approval.'
|
||||||
];
|
];
|
||||||
this.showShareWithdrawalConfirm(payload, confirmLines, () =>
|
this.showConfirmAlert('info', 'Confirm Share Withdrawal Request', confirmLines, 'Submit for Approval', () =>
|
||||||
this.doSubmitShareRedemptionTransfer(payload, shareRow, shares, linkedAcNo)
|
this.doSubmitShareRedemptionTransfer(payload, shareRow, shares, linkedAcNo)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Shows the confirm dialog with the same checklist the Manager will see when approving this
|
|
||||||
* request — fetched live from the backend (`previewValidate`) rather than re-implemented here,
|
|
||||||
* so it can never show the clerk something different from what the approver actually sees. A
|
|
||||||
* failing check is shown, not blocked: approve() enforces its own hard rules separately (e.g.
|
|
||||||
* Share Advance Collection applied), this list is advisory so problems surface to whoever can
|
|
||||||
* still fix them, before a manager's time is spent on a request that was always going to fail.
|
|
||||||
*/
|
|
||||||
private showShareWithdrawalConfirm(
|
|
||||||
payload: Record<string, unknown>,
|
|
||||||
baseLines: string[],
|
|
||||||
onConfirm: () => void
|
|
||||||
): void {
|
|
||||||
this.clerkShareWithdrawalService
|
|
||||||
.previewValidate(payload)
|
|
||||||
.pipe(
|
|
||||||
// A request that never resolves (slow network, server busy) must not leave the Submit
|
|
||||||
// button looking dead — the dialog still has to open, just without the checklist.
|
|
||||||
timeout(5000),
|
|
||||||
catchError(() => of({ __previewFailed: true }))
|
|
||||||
)
|
|
||||||
.subscribe((res: any) => {
|
|
||||||
const checks: { label: string; pass: boolean; detail: string }[] = res?.checks ?? [];
|
|
||||||
// A failed call (most likely the backend not yet carrying the /preview-validate endpoint)
|
|
||||||
// says so explicitly rather than just omitting the section — a silently empty checklist
|
|
||||||
// looks identical to "everything passed, nothing to show", which is the one reading this
|
|
||||||
// must never give.
|
|
||||||
const checkLines = res?.__previewFailed ? [
|
|
||||||
'',
|
|
||||||
'⚠ Validation checklist unavailable right now — the request can still be submitted.'
|
|
||||||
] : checks.length ? [
|
|
||||||
'',
|
|
||||||
'Validation Checks (same as Manager approval):',
|
|
||||||
...checks.map((c) => `${c.pass ? '✓' : '⚠'} ${c.label}: ${c.detail}`)
|
|
||||||
] : [];
|
|
||||||
// baseLines ends with a blank line followed by the "Pending Manager approval…" footer
|
|
||||||
// sentence, which must stay last so the modal renders it as a footnote rather than a
|
|
||||||
// key/value row — so the checklist is spliced in just before that pair, not appended after.
|
|
||||||
const footerStart =
|
|
||||||
baseLines.length >= 2 && baseLines[baseLines.length - 2] === '' ? baseLines.length - 2 : baseLines.length;
|
|
||||||
const lines = [
|
|
||||||
...baseLines.slice(0, footerStart),
|
|
||||||
...checkLines,
|
|
||||||
...baseLines.slice(footerStart)
|
|
||||||
];
|
|
||||||
this.showConfirmAlert('info', 'Confirm Share Withdrawal Request', lines, 'Submit for Approval', onConfirm);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Share redemption settled to a GL account the clerk picks on the CR row, instead of the
|
* Share redemption settled to a GL account the clerk picks on the CR row, instead of the
|
||||||
* member's linked savings account — mirrors submitShareRedemptionTransfer() above, but the
|
* member's linked savings account — mirrors submitShareRedemptionTransfer() above, but the
|
||||||
@@ -3563,7 +3512,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const today = businessToday();
|
const today = new Date();
|
||||||
const requestedDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
const requestedDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
||||||
|
|
||||||
// Full snapshot of the CR (settlement) row exactly as the clerk filled it in — saved verbatim
|
// Full snapshot of the CR (settlement) row exactly as the clerk filled it in — saved verbatim
|
||||||
@@ -3622,7 +3571,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
? 'Pending Manager approval — redemption, settlement and account closure all post together on approval.'
|
? 'Pending Manager approval — redemption, settlement and account closure all post together on approval.'
|
||||||
: 'Pending Manager approval — redemption and settlement both post together on approval.'
|
: 'Pending Manager approval — redemption and settlement both post together on approval.'
|
||||||
];
|
];
|
||||||
this.showShareWithdrawalConfirm(payload, confirmLines, () =>
|
this.showConfirmAlert('info', 'Confirm Share Withdrawal Request', confirmLines, 'Submit for Approval', () =>
|
||||||
this.doSubmitShareGlSettlementTransfer(payload, shareRow, shares, glRow.acNo)
|
this.doSubmitShareGlSettlementTransfer(payload, shareRow, shares, glRow.acNo)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -4224,7 +4173,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
): void {
|
): void {
|
||||||
const accountUrl = `${cfg.detail}/${accountId}${cfg.detailParams ?? '?associations=all'}`;
|
const accountUrl = `${cfg.detail}/${accountId}${cfg.detailParams ?? '?associations=all'}`;
|
||||||
const todayStr = (() => {
|
const todayStr = (() => {
|
||||||
const d = businessToday();
|
const d = new Date();
|
||||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
@@ -4600,7 +4549,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get todayIso(): string {
|
get todayIso(): string {
|
||||||
const d = businessToday();
|
const d = new Date();
|
||||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5122,7 +5071,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
this.sameDayChequeClearing = 0;
|
this.sameDayChequeClearing = 0;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const today = businessToday();
|
const today = new Date();
|
||||||
const sameDay = (d: any) => {
|
const sameDay = (d: any) => {
|
||||||
if (!d) return false;
|
if (!d) return false;
|
||||||
const t = Array.isArray(d) && d.length >= 3 ? new Date(d[0], d[1] - 1, d[2]) : new Date(d);
|
const t = Array.isArray(d) && d.length >= 3 ? new Date(d[0], d[1] - 1, d[2]) : new Date(d);
|
||||||
@@ -5246,8 +5195,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
this.showAlert('error', 'Invalid Account Type', [
|
this.showAlert('error', 'Invalid Account Type', [
|
||||||
`Account type(s) not allowed in Bank Transfer: ${invalidAccounts.map((r) => r.acTyp + ' (' + r.acNo + ')').join(', ')}`,
|
`Account type(s) not allowed in Bank Transfer: ${invalidAccounts.map((r) => r.acTyp + ' (' + r.acNo + ')').join(', ')}`,
|
||||||
'Only Savings (SD) and Current (CA) accounts are permitted.',
|
'Only Savings (SD) and Current (CA) accounts are permitted.',
|
||||||
'GL, FD and SHR accounts are not allowed — use Disbursement/Collection instead.',
|
'GL, FD, RD, SHR accounts are not allowed — use Disbursement/Collection instead.'
|
||||||
'RD installments are paid through a cashier Receipt or Auto Debit.'
|
|
||||||
]);
|
]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -5301,19 +5249,6 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
this.rows.length >= 2 &&
|
this.rows.length >= 2 &&
|
||||||
this.rows.every((r) => r.typ === 'Disbursement' || r.typ === 'Fund Transfer')
|
this.rows.every((r) => r.typ === 'Disbursement' || r.typ === 'Fund Transfer')
|
||||||
) {
|
) {
|
||||||
// A Recurring Deposit is never paid this way: these requests post straight into the account, so the
|
|
||||||
// installment schedule never hears of the money -- installments stay unpaid, Auto Debit collects them
|
|
||||||
// again and the maturity amount comes out wrong. RD installments come in through the cashier's
|
|
||||||
// Receipt or the RD's own Auto Debit.
|
|
||||||
const rdRows = this.rows.filter((r) => r.acTyp === 'RD');
|
|
||||||
if (rdRows.length > 0) {
|
|
||||||
this.showAlert('error', 'Recurring Deposit Not Allowed', [
|
|
||||||
`RD account(s) ${rdRows.map((r) => r.acNo).join(', ')} cannot be used in a Disbursement, Collection or Fund Transfer.`,
|
|
||||||
'Pay RD installments through a cashier Receipt, or by Auto Debit from the linked savings account.'
|
|
||||||
]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const drRows = this.rows.filter((r) => r.dbCr === 'Dr');
|
const drRows = this.rows.filter((r) => r.dbCr === 'Dr');
|
||||||
const crRows = this.rows.filter((r) => r.dbCr === 'Cr');
|
const crRows = this.rows.filter((r) => r.dbCr === 'Cr');
|
||||||
|
|
||||||
@@ -6024,7 +5959,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const today = businessToday();
|
const today = new Date();
|
||||||
const txnDate = `${String(today.getDate()).padStart(2, '0')}/${String(today.getMonth() + 1).padStart(2, '0')}/${today.getFullYear()}`;
|
const txnDate = `${String(today.getDate()).padStart(2, '0')}/${String(today.getMonth() + 1).padStart(2, '0')}/${today.getFullYear()}`;
|
||||||
|
|
||||||
const payload: any = {
|
const payload: any = {
|
||||||
@@ -6106,12 +6041,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
this.editRequest = req;
|
this.editRequest = req;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Same bug, same fix as the token-driven withdrawal-slip lookup above (see its comment): the
|
const acTyp = 'SD';
|
||||||
// request carries its own real account_type (SAVINGS, CURRENT, FIXED_DEPOSIT or
|
|
||||||
// RECURRING_DEPOSIT), and hardcoding 'SD' here searched every non-savings account on the
|
|
||||||
// wrong endpoint, so reviewing a Current-account withdrawal always failed with "Account not
|
|
||||||
// found".
|
|
||||||
const acTyp = TransactionEntryComponent.SEARCH_ACCOUNT_TYPE_TO_AC_TYP[req.account_type || ''] || 'SD';
|
|
||||||
const row = this.rows[0];
|
const row = this.rows[0];
|
||||||
row.acNo = req.account_no ?? '';
|
row.acNo = req.account_no ?? '';
|
||||||
row.acTyp = acTyp;
|
row.acTyp = acTyp;
|
||||||
@@ -6219,7 +6149,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
/** Build the withdrawal-request payload from the current row + loaded account. */
|
/** Build the withdrawal-request payload from the current row + loaded account. */
|
||||||
private buildWithdrawalPayload(row: TxnRow, amount: number): any {
|
private buildWithdrawalPayload(row: TxnRow, amount: number): any {
|
||||||
const today = businessToday();
|
const today = new Date();
|
||||||
const txnDateIso = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
const txnDateIso = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
||||||
const selectedPt = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId);
|
const selectedPt = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId);
|
||||||
|
|
||||||
@@ -6324,7 +6254,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
/** Build the deposit-request payload from the current row + loaded account. */
|
/** Build the deposit-request payload from the current row + loaded account. */
|
||||||
private buildDepositPayload(row: TxnRow, amount: number): any {
|
private buildDepositPayload(row: TxnRow, amount: number): any {
|
||||||
const today = businessToday();
|
const today = new Date();
|
||||||
const txnDateIso = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
const txnDateIso = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
||||||
const selectedPt = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId);
|
const selectedPt = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId);
|
||||||
// Send denominations only for cash payments — stale counts on a cheque
|
// Send denominations only for cash payments — stale counts on a cheque
|
||||||
@@ -6435,7 +6365,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const today = businessToday();
|
const today = new Date();
|
||||||
const txnDate = `${String(today.getDate()).padStart(2, '0')}/${String(today.getMonth() + 1).padStart(2, '0')}/${today.getFullYear()}`;
|
const txnDate = `${String(today.getDate()).padStart(2, '0')}/${String(today.getMonth() + 1).padStart(2, '0')}/${today.getFullYear()}`;
|
||||||
const txnDateIso = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
const txnDateIso = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import { MatInputModule } from '@angular/material/input';
|
|||||||
import { of } from 'rxjs';
|
import { of } from 'rxjs';
|
||||||
import { catchError } from 'rxjs/operators';
|
import { catchError } from 'rxjs/operators';
|
||||||
import { CashierService } from '../cashier.service';
|
import { CashierService } from '../cashier.service';
|
||||||
import { businessToday } from 'app/core/utils/business-today';
|
|
||||||
|
|
||||||
interface DenomRow {
|
interface DenomRow {
|
||||||
denominationId: number;
|
denominationId: number;
|
||||||
@@ -68,7 +67,7 @@ export class VaultPositionComponent implements OnInit {
|
|||||||
errorMsg = '';
|
errorMsg = '';
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
const d = businessToday();
|
const d = new Date();
|
||||||
this.asOfDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
this.asOfDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||||
|
|
||||||
this.service
|
this.service
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import { ActivatedRoute } from '@angular/router';
|
|||||||
import { MatIconModule } from '@angular/material/icon';
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
import { catchError, of } from 'rxjs';
|
import { catchError, of } from 'rxjs';
|
||||||
import { CashierService } from '../cashier.service';
|
import { CashierService } from '../cashier.service';
|
||||||
import { businessToday } from 'app/core/utils/business-today';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Which side of the register a screen shows.
|
* Which side of the register a screen shows.
|
||||||
@@ -228,7 +227,7 @@ export class VoucherReportComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private todayIso(): string {
|
private todayIso(): string {
|
||||||
const now = businessToday();
|
const now = new Date();
|
||||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -129,31 +129,6 @@
|
|||||||
<span>* Date of Meeting</span>
|
<span>* Date of Meeting</span>
|
||||||
<input type="date" [(ngModel)]="meetingDate" [disabled]="isReview" />
|
<input type="date" [(ngModel)]="meetingDate" [disabled]="isReview" />
|
||||||
</label>
|
</label>
|
||||||
<label class="inv-form-field">
|
|
||||||
<span>* Settlement Destination</span>
|
|
||||||
@if (!isReview) {
|
|
||||||
<select [ngModel]="settlementMode" (ngModelChange)="onSettlementModeChange($event)">
|
|
||||||
<option value="SAVINGS">Member's Linked Savings Account</option>
|
|
||||||
<option value="GL">GL Account</option>
|
|
||||||
</select>
|
|
||||||
} @else {
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
[value]="reviewRequest?.settlement_mode === 'GL' ? 'GL Account' : 'Linked Savings Account'"
|
|
||||||
readonly
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
</label>
|
|
||||||
@if (!isReview && settlementMode === 'GL') {
|
|
||||||
<label class="inv-form-field">
|
|
||||||
<span>* Settlement GL Account</span>
|
|
||||||
<mifosx-gl-account-selector
|
|
||||||
[inputFormControl]="toGlAccountControl"
|
|
||||||
[glAccountList]="glAccountsData"
|
|
||||||
[required]="true"
|
|
||||||
></mifosx-gl-account-selector>
|
|
||||||
</label>
|
|
||||||
}
|
|
||||||
<label class="inv-form-field">
|
<label class="inv-form-field">
|
||||||
<span>* Settlement Payment Type</span>
|
<span>* Settlement Payment Type</span>
|
||||||
@if (!isReview) {
|
@if (!isReview) {
|
||||||
@@ -411,25 +386,14 @@
|
|||||||
<td>Date of Meeting</td>
|
<td>Date of Meeting</td>
|
||||||
<td>{{ meetingDate }}</td>
|
<td>{{ meetingDate }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
|
||||||
<td>Settlement Destination</td>
|
|
||||||
<td>{{ settlementMode === 'GL' ? 'GL Account' : 'Linked Savings Account' }}</td>
|
|
||||||
</tr>
|
|
||||||
@if (settlementMode === 'GL') {
|
|
||||||
<tr>
|
|
||||||
<td>Settlement GL Account</td>
|
|
||||||
<td>{{ selectedSettlementGlName }}</td>
|
|
||||||
</tr>
|
|
||||||
} @else {
|
|
||||||
<tr>
|
|
||||||
<td>Linked Savings Account</td>
|
|
||||||
<td>{{ account?.savingsAccountNumber || 'None — settlement will not run automatically' }}</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
<tr>
|
<tr>
|
||||||
<td>Settlement Payment Type</td>
|
<td>Settlement Payment Type</td>
|
||||||
<td>{{ settlementPaymentTypeName }}</td>
|
<td>{{ settlementPaymentTypeName }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Linked Savings Account</td>
|
||||||
|
<td>{{ account?.savingsAccountNumber || 'None — settlement will not run automatically' }}</td>
|
||||||
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
import { Component, EventEmitter, Input, OnInit, Output, inject } from '@angular/core';
|
import { Component, EventEmitter, Input, OnInit, Output, inject } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule, UntypedFormControl } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||||
import { MatIconModule } from '@angular/material/icon';
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||||
@@ -19,9 +19,6 @@ import { SharesService } from '../../shares/shares.service';
|
|||||||
import { ClerkShareWithdrawalService } from '../../cashier/clerk-share-withdrawal.service';
|
import { ClerkShareWithdrawalService } from '../../cashier/clerk-share-withdrawal.service';
|
||||||
import { CashierService } from '../../cashier/cashier.service';
|
import { CashierService } from '../../cashier/cashier.service';
|
||||||
import { SearchSelectComponent, SearchSelectOption } from '../../shared/search-select/search-select.component';
|
import { SearchSelectComponent, SearchSelectOption } from '../../shared/search-select/search-select.component';
|
||||||
import { GlAccountSelectorComponent } from '../../shared/accounting/gl-account-selector/gl-account-selector.component';
|
|
||||||
import { AccountingService } from '../../accounting/accounting.service';
|
|
||||||
import { GLAccount } from '../../shared/models/general.model';
|
|
||||||
|
|
||||||
export type ShareWdMode = 'create' | 'review';
|
export type ShareWdMode = 'create' | 'review';
|
||||||
|
|
||||||
@@ -54,8 +51,7 @@ interface ActivityEntry {
|
|||||||
RouterModule,
|
RouterModule,
|
||||||
MatIconModule,
|
MatIconModule,
|
||||||
MatSnackBarModule,
|
MatSnackBarModule,
|
||||||
SearchSelectComponent,
|
SearchSelectComponent
|
||||||
GlAccountSelectorComponent
|
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class ShareWithdrawalRequestComponent implements OnInit {
|
export class ShareWithdrawalRequestComponent implements OnInit {
|
||||||
@@ -64,7 +60,6 @@ export class ShareWithdrawalRequestComponent implements OnInit {
|
|||||||
private sharesService = inject(SharesService);
|
private sharesService = inject(SharesService);
|
||||||
private wdService = inject(ClerkShareWithdrawalService);
|
private wdService = inject(ClerkShareWithdrawalService);
|
||||||
private cashierService = inject(CashierService);
|
private cashierService = inject(CashierService);
|
||||||
private accountingService = inject(AccountingService);
|
|
||||||
private snackBar = inject(MatSnackBar);
|
private snackBar = inject(MatSnackBar);
|
||||||
|
|
||||||
/** When embedded (e.g. opened as a popup from a list screen instead of routed to directly), mode
|
/** When embedded (e.g. opened as a popup from a list screen instead of routed to directly), mode
|
||||||
@@ -96,17 +91,6 @@ export class ShareWithdrawalRequestComponent implements OnInit {
|
|||||||
paymentTypes: any[] = [];
|
paymentTypes: any[] = [];
|
||||||
settlementPaymentTypeId: number | null = null;
|
settlementPaymentTypeId: number | null = null;
|
||||||
|
|
||||||
/** Where the redeemed value goes. 'SAVINGS' (the long-standing default) credits the member's
|
|
||||||
* linked savings account. 'GL' lets the clerk pick any postable GL account directly — same
|
|
||||||
* option the Cashier's Transaction Entry screen already offers on its CR row, now available
|
|
||||||
* here too so both entry points behave the same way. Naming the product's own Share Sundry GL
|
|
||||||
* here is still fine: the backend recognises it and books straight to Sundry with no further
|
|
||||||
* settlement leg (see ClerkShareWithdrawalRequestApiResource#submit). */
|
|
||||||
settlementMode: 'SAVINGS' | 'GL' = 'SAVINGS';
|
|
||||||
settlementGlAccountId: number | null = null;
|
|
||||||
glAccountsData: GLAccount[] = [];
|
|
||||||
toGlAccountControl = new UntypedFormControl('');
|
|
||||||
|
|
||||||
activityLog: ActivityEntry[] = [];
|
activityLog: ActivityEntry[] = [];
|
||||||
reviewRequest: any = null;
|
reviewRequest: any = null;
|
||||||
showRejectDialog = false;
|
showRejectDialog = false;
|
||||||
@@ -148,17 +132,6 @@ export class ShareWithdrawalRequestComponent implements OnInit {
|
|||||||
return this.selectedSettlementPaymentType?.name || '—';
|
return this.selectedSettlementPaymentType?.name || '—';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The chosen GL account's display name, for the confirm dialog and the checklist. Falls back to
|
|
||||||
* the raw id while the GL account list is still loading. */
|
|
||||||
get selectedSettlementGlName(): string {
|
|
||||||
if (!this.settlementGlAccountId) {
|
|
||||||
return '—';
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
this.glAccountsData.find((g) => g.id === this.settlementGlAccountId)?.name || `GL #${this.settlementGlAccountId}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
get isFormValid(): boolean {
|
get isFormValid(): boolean {
|
||||||
if (!this.account) return false;
|
if (!this.account) return false;
|
||||||
if (!this.requestedShares || this.requestedShares <= 0) return false;
|
if (!this.requestedShares || this.requestedShares <= 0) return false;
|
||||||
@@ -168,7 +141,6 @@ export class ShareWithdrawalRequestComponent implements OnInit {
|
|||||||
if (!this.resolutionNumber.trim()) return false;
|
if (!this.resolutionNumber.trim()) return false;
|
||||||
if (!this.meetingDate) return false;
|
if (!this.meetingDate) return false;
|
||||||
if (!this.settlementPaymentTypeId) return false;
|
if (!this.settlementPaymentTypeId) return false;
|
||||||
if (this.settlementMode === 'GL' && !this.settlementGlAccountId) return false;
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,18 +160,11 @@ export class ShareWithdrawalRequestComponent implements OnInit {
|
|||||||
pass: shares > 0 && shares <= held,
|
pass: shares > 0 && shares <= held,
|
||||||
detail: `${shares} of ${held} shares held`
|
detail: `${shares} of ${held} shares held`
|
||||||
},
|
},
|
||||||
this.settlementMode === 'GL'
|
{
|
||||||
? {
|
label: 'Linked savings account confirmed',
|
||||||
label: 'Settlement GL Account selected',
|
pass: !!this.account?.savingsAccountNumber,
|
||||||
pass: !!this.settlementGlAccountId,
|
detail: this.account?.savingsAccountNumber || 'No linked savings account — settlement cannot run automatically'
|
||||||
detail: this.settlementGlAccountId ? this.selectedSettlementGlName : 'Missing'
|
},
|
||||||
}
|
|
||||||
: {
|
|
||||||
label: 'Linked savings account confirmed',
|
|
||||||
pass: !!this.account?.savingsAccountNumber,
|
|
||||||
detail:
|
|
||||||
this.account?.savingsAccountNumber || 'No linked savings account — settlement cannot run automatically'
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
label: 'Settlement Payment Type selected (non-cash)',
|
label: 'Settlement Payment Type selected (non-cash)',
|
||||||
pass: this.settlementPaymentTypeName !== '—',
|
pass: this.settlementPaymentTypeName !== '—',
|
||||||
@@ -249,37 +214,11 @@ export class ShareWithdrawalRequestComponent implements OnInit {
|
|||||||
.pipe(catchError(() => of([])))
|
.pipe(catchError(() => of([])))
|
||||||
.subscribe((types: any[]) => (this.paymentTypes = types ?? []));
|
.subscribe((types: any[]) => (this.paymentTypes = types ?? []));
|
||||||
|
|
||||||
this.toGlAccountControl.valueChanges.subscribe((id: any) => {
|
|
||||||
this.settlementGlAccountId = id ? Number(id) : null;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (this.requestId && this.mode === 'review') {
|
if (this.requestId && this.mode === 'review') {
|
||||||
this.loadReviewRequest(this.requestId);
|
this.loadReviewRequest(this.requestId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Flips the settlement destination. Switching away from GL clears whatever was picked so a
|
|
||||||
* stale GL id can never ride along on a SAVINGS submission. */
|
|
||||||
onSettlementModeChange(mode: 'SAVINGS' | 'GL'): void {
|
|
||||||
this.settlementMode = mode;
|
|
||||||
if (mode === 'GL') {
|
|
||||||
this.loadGlAccounts();
|
|
||||||
} else {
|
|
||||||
this.settlementGlAccountId = null;
|
|
||||||
this.toGlAccountControl.setValue('');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fetched on first use rather than with the page, since most withdrawals settle to savings. */
|
|
||||||
private loadGlAccounts(): void {
|
|
||||||
if (this.glAccountsData.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.accountingService.getGlAccounts().subscribe((accounts: GLAccount[]) => {
|
|
||||||
this.glAccountsData = accounts ?? [];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
onAccountSelected(accountId: string): void {
|
onAccountSelected(accountId: string): void {
|
||||||
this.selectedAccountId = accountId;
|
this.selectedAccountId = accountId;
|
||||||
if (!accountId) {
|
if (!accountId) {
|
||||||
@@ -307,11 +246,6 @@ export class ShareWithdrawalRequestComponent implements OnInit {
|
|||||||
this.resolutionNumber = req.resolution_number ?? '';
|
this.resolutionNumber = req.resolution_number ?? '';
|
||||||
this.meetingDate = req.meeting_date ? String(req.meeting_date).split('T')[0].split(' ')[0] : this.meetingDate;
|
this.meetingDate = req.meeting_date ? String(req.meeting_date).split('T')[0].split(' ')[0] : this.meetingDate;
|
||||||
this.description = req.description ?? '';
|
this.description = req.description ?? '';
|
||||||
this.settlementMode = req.settlement_mode === 'GL' ? 'GL' : 'SAVINGS';
|
|
||||||
this.settlementGlAccountId = req.settlement_gl_account_id ? Number(req.settlement_gl_account_id) : null;
|
|
||||||
if (this.settlementMode === 'GL') {
|
|
||||||
this.loadGlAccounts();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.activity_log) {
|
if (req.activity_log) {
|
||||||
try {
|
try {
|
||||||
@@ -355,9 +289,7 @@ export class ShareWithdrawalRequestComponent implements OnInit {
|
|||||||
resolutionNumber: this.resolutionNumber.trim(),
|
resolutionNumber: this.resolutionNumber.trim(),
|
||||||
meetingDate: this.meetingDate,
|
meetingDate: this.meetingDate,
|
||||||
description: this.description,
|
description: this.description,
|
||||||
settlementPaymentTypeId: this.settlementPaymentTypeId,
|
settlementPaymentTypeId: this.settlementPaymentTypeId
|
||||||
settlementMode: this.settlementMode,
|
|
||||||
...(this.settlementMode === 'GL' ? { settlementGlAccountId: this.settlementGlAccountId } : {})
|
|
||||||
};
|
};
|
||||||
this.wdService.submit(payload).subscribe({
|
this.wdService.submit(payload).subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
|
|||||||
@@ -643,9 +643,8 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
this.createClientForm.get('degreeQualificationId')?.setValue('');
|
this.createClientForm.get('degreeQualificationId')?.setValue('');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
// Constitution options by code NAME, never by id: code ids differ per tenant (on abc_bank id 17 is the empty
|
// Fetch all constitution options from code ID 17
|
||||||
// ClientClassification code and Constitution is id 24), so a hardcoded id left the dropdown empty.
|
this.clientService.getCodeValuesById(17).subscribe((options) => {
|
||||||
this.clientService.getCodeValuesByName('Constitution').subscribe((options) => {
|
|
||||||
if (options?.length) {
|
if (options?.length) {
|
||||||
this.constitutionOptions = options;
|
this.constitutionOptions = options;
|
||||||
// Sort master by value length descending so longer/more-specific keys match first
|
// Sort master by value length descending so longer/more-specific keys match first
|
||||||
|
|||||||
@@ -1321,11 +1321,6 @@
|
|||||||
kycForm.get('kycDocumentNumber')?.hasError('pattern') && kycForm.get('kycDocumentNumber')?.touched
|
kycForm.get('kycDocumentNumber')?.hasError('pattern') && kycForm.get('kycDocumentNumber')?.touched
|
||||||
) {
|
) {
|
||||||
<mat-error>Only letters, numbers, hyphens and slashes allowed</mat-error>
|
<mat-error>Only letters, numbers, hyphens and slashes allowed</mat-error>
|
||||||
} @else if (
|
|
||||||
kycForm.get('kycDocumentNumber')?.hasError('aadhaarLast4Mismatch') &&
|
|
||||||
kycForm.get('kycDocumentNumber')?.touched
|
|
||||||
) {
|
|
||||||
<mat-error>Last 4 digits do not match the Aadhaar number entered above</mat-error>
|
|
||||||
}
|
}
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
|
|
||||||
|
|||||||
@@ -8,14 +8,7 @@
|
|||||||
|
|
||||||
import { Component, Input, OnInit, OnChanges, OnDestroy, SimpleChanges, inject } from '@angular/core';
|
import { Component, Input, OnInit, OnChanges, OnDestroy, SimpleChanges, inject } from '@angular/core';
|
||||||
import { forkJoin, catchError, of } from 'rxjs';
|
import { forkJoin, catchError, of } from 'rxjs';
|
||||||
import {
|
import { UntypedFormBuilder, UntypedFormGroup, Validators, FormsModule } from '@angular/forms';
|
||||||
UntypedFormBuilder,
|
|
||||||
UntypedFormGroup,
|
|
||||||
Validators,
|
|
||||||
FormsModule,
|
|
||||||
AbstractControl,
|
|
||||||
ValidationErrors
|
|
||||||
} from '@angular/forms';
|
|
||||||
import {
|
import {
|
||||||
MatDialog,
|
MatDialog,
|
||||||
MAT_DIALOG_DATA,
|
MAT_DIALOG_DATA,
|
||||||
@@ -574,28 +567,6 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
/** Existing signature document ID (for re-upload/delete) */
|
/** Existing signature document ID (for re-upload/delete) */
|
||||||
existingSignatureDocId: string = '';
|
existingSignatureDocId: string = '';
|
||||||
|
|
||||||
/**
|
|
||||||
* Cross-field check for the OVD "Document Number" field (kycDocumentNumber): when Aadhaar Card
|
|
||||||
* is the selected KYC document, its value must identify the same Aadhaar as the aadhaarNumber
|
|
||||||
* field captured earlier in this step - the last 4 digits are what's compared, since
|
|
||||||
* aadhaarNumber may hold either the raw 12-digit number or a masked value (masking only ever
|
|
||||||
* obscures the first 8 digits - see aadhaarMasked in the kycData getter below - so the last 4
|
|
||||||
* characters are always the real digits either way). Not applicable to any other document type,
|
|
||||||
* where kycDocumentNumber is unrelated to Aadhaar entirely.
|
|
||||||
*/
|
|
||||||
private aadhaarLast4MatchValidator = (control: AbstractControl): ValidationErrors | null => {
|
|
||||||
if (this.selectedKycDoc?.code !== 'AADHAAR') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const docNumber = (control.value || '').toString().trim();
|
|
||||||
const aadhaarValue = (control.parent?.get('aadhaarNumber')?.value || '').toString().trim();
|
|
||||||
if (docNumber.length < 4 || aadhaarValue.length < 4) {
|
|
||||||
// Required/pattern validators already flag an empty or too-short value on their own.
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return docNumber.slice(-4) === aadhaarValue.slice(-4) ? null : { aadhaarLast4Mismatch: true };
|
|
||||||
};
|
|
||||||
|
|
||||||
/** KYC Form */
|
/** KYC Form */
|
||||||
kycForm: UntypedFormGroup = this.formBuilder.group({
|
kycForm: UntypedFormGroup = this.formBuilder.group({
|
||||||
hasPan: [true],
|
hasPan: [true],
|
||||||
@@ -645,8 +616,7 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
[
|
[
|
||||||
Validators.required,
|
Validators.required,
|
||||||
Validators.pattern(/^[A-Za-z0-9\-\/\s]+$/),
|
Validators.pattern(/^[A-Za-z0-9\-\/\s]+$/),
|
||||||
Validators.maxLength(30),
|
Validators.maxLength(30)
|
||||||
this.aadhaarLast4MatchValidator
|
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
kycDocIssueDate: [''],
|
kycDocIssueDate: [''],
|
||||||
@@ -769,14 +739,6 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
this.kycForm.get('ecgcSalFlag')?.disable();
|
this.kycForm.get('ecgcSalFlag')?.disable();
|
||||||
this.kycForm.get('dormantFlag')?.disable();
|
this.kycForm.get('dormantFlag')?.disable();
|
||||||
}
|
}
|
||||||
|
|
||||||
// aadhaarLast4MatchValidator (on kycDocumentNumber) reads this control's value directly rather
|
|
||||||
// than through its own valueChanges, so a re-check has to be triggered explicitly whenever the
|
|
||||||
// Aadhaar field itself changes - otherwise fixing aadhaarNumber after kycDocumentNumber was
|
|
||||||
// already entered would leave a stale mismatch error in place.
|
|
||||||
this.kycForm.get('aadhaarNumber')?.valueChanges.subscribe(() => {
|
|
||||||
this.kycForm.get('kycDocumentNumber')?.updateValueAndValidity({ onlySelf: true });
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Seed the document-type picker from defaults (entity RBI docs vs. individual OVDs,
|
/** Seed the document-type picker from defaults (entity RBI docs vs. individual OVDs,
|
||||||
@@ -1047,9 +1009,6 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
// Reset uploads + merged preview when document type changes
|
// Reset uploads + merged preview when document type changes
|
||||||
this.kycDocFiles = { front: null, back: null };
|
this.kycDocFiles = { front: null, back: null };
|
||||||
this.kycDocMergedPreview = null;
|
this.kycDocMergedPreview = null;
|
||||||
// aadhaarLast4MatchValidator only applies while Aadhaar Card is selected - re-check so switching
|
|
||||||
// to/away from it immediately picks up (or clears) the mismatch error.
|
|
||||||
this.kycForm.get('kycDocumentNumber')?.updateValueAndValidity({ onlySelf: true });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
isKycDocSelected(proof: ProofType): boolean {
|
isKycDocSelected(proof: ProofType): boolean {
|
||||||
@@ -1230,9 +1189,6 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
// Sync chip-picker selection with the prefilled document name
|
// Sync chip-picker selection with the prefilled document name
|
||||||
if (data.kycDocumentName) {
|
if (data.kycDocumentName) {
|
||||||
this.selectedKycDoc = this.findKycDocProofByName(data.kycDocumentName);
|
this.selectedKycDoc = this.findKycDocProofByName(data.kycDocumentName);
|
||||||
// patchValue above ran kycDocumentNumber's validators before selectedKycDoc was known,
|
|
||||||
// so aadhaarLast4MatchValidator needs a fresh check now that it is.
|
|
||||||
this.kycForm.get('kycDocumentNumber')?.updateValueAndValidity({ onlySelf: true });
|
|
||||||
}
|
}
|
||||||
// Set verification statuses from existing data
|
// Set verification statuses from existing data
|
||||||
if (data.panVerified) {
|
if (data.panVerified) {
|
||||||
|
|||||||
@@ -61,7 +61,6 @@ import { KycTabComponent } from './clients-view/kyc-tab/kyc-tab.component';
|
|||||||
import { CkycTabComponent } from './clients-view/ckyc-tab/ckyc-tab.component';
|
import { CkycTabComponent } from './clients-view/ckyc-tab/ckyc-tab.component';
|
||||||
import { Form60TabComponent } from './clients-view/form60-tab/form60-tab.component';
|
import { Form60TabComponent } from './clients-view/form60-tab/form60-tab.component';
|
||||||
import { Form121TabComponent } from './clients-view/form121-tab/form121-tab.component';
|
import { Form121TabComponent } from './clients-view/form121-tab/form121-tab.component';
|
||||||
import { tdsConfigResolver } from '../letter-mgmt/certificates/form-15gh/tds-config.service';
|
|
||||||
import { EntityClassificationTabComponent } from './clients-view/entity-classification-tab/entity-classification-tab.component';
|
import { EntityClassificationTabComponent } from './clients-view/entity-classification-tab/entity-classification-tab.component';
|
||||||
import { AuthorizedSignatoriesTabComponent } from './clients-view/authorized-signatories-tab/authorized-signatories-tab.component';
|
import { AuthorizedSignatoriesTabComponent } from './clients-view/authorized-signatories-tab/authorized-signatories-tab.component';
|
||||||
import { familyAllowedGuard, nomineeAllowedGuard } from './utils/family-nominee.guard';
|
import { familyAllowedGuard, nomineeAllowedGuard } from './utils/family-nominee.guard';
|
||||||
@@ -199,7 +198,6 @@ const routes: Routes = [
|
|||||||
{
|
{
|
||||||
path: 'form121',
|
path: 'form121',
|
||||||
component: Form121TabComponent,
|
component: Form121TabComponent,
|
||||||
resolve: { tdsConfig: tdsConfigResolver },
|
|
||||||
data: { title: 'Form 121 / TDS', breadcrumb: 'Form 121 / TDS', routeParamBreadcrumb: false }
|
data: { title: 'Form 121 / TDS', breadcrumb: 'Form 121 / TDS', routeParamBreadcrumb: false }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -202,14 +202,9 @@
|
|||||||
<div>
|
<div>
|
||||||
<strong>Already available</strong>
|
<strong>Already available</strong>
|
||||||
<span class="ckyc-existing__source">from {{ existing.ADDRESS_PROOF.source }}</span>
|
<span class="ckyc-existing__source">from {{ existing.ADDRESS_PROOF.source }}</span>
|
||||||
@if (existing.ADDRESS_PROOF.previewUnavailable) {
|
|
||||||
<span class="ckyc-existing__warn">stored file unavailable</span>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@if (
|
@if (existing.ADDRESS_PROOF.previewUrl || existing.ADDRESS_PROOF.previewUrlBack) {
|
||||||
(existing.ADDRESS_PROOF.previewUrl || existing.ADDRESS_PROOF.previewUrlBack) && isImage('ADDRESS_PROOF')
|
|
||||||
) {
|
|
||||||
<div class="ckyc-existing__thumbs">
|
<div class="ckyc-existing__thumbs">
|
||||||
@if (existing.ADDRESS_PROOF.previewUrl) {
|
@if (existing.ADDRESS_PROOF.previewUrl) {
|
||||||
<img
|
<img
|
||||||
@@ -227,17 +222,6 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if (existing.ADDRESS_PROOF.previewUrl) {
|
|
||||||
<button
|
|
||||||
mat-stroked-button
|
|
||||||
type="button"
|
|
||||||
class="ckyc-existing__view"
|
|
||||||
(click)="openDocument('ADDRESS_PROOF')"
|
|
||||||
>
|
|
||||||
<fa-icon icon="eye"></fa-icon>
|
|
||||||
View document
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
<div class="ckyc-existing__meta">
|
<div class="ckyc-existing__meta">
|
||||||
<fa-icon icon="file-alt"></fa-icon>
|
<fa-icon icon="file-alt"></fa-icon>
|
||||||
<span>{{ existing.ADDRESS_PROOF.name }}</span>
|
<span>{{ existing.ADDRESS_PROOF.name }}</span>
|
||||||
@@ -315,12 +299,9 @@
|
|||||||
<div>
|
<div>
|
||||||
<strong>Already available</strong>
|
<strong>Already available</strong>
|
||||||
<span class="ckyc-existing__source">from {{ existing.PHOTO_PROOF.source }}</span>
|
<span class="ckyc-existing__source">from {{ existing.PHOTO_PROOF.source }}</span>
|
||||||
@if (existing.PHOTO_PROOF.previewUnavailable) {
|
|
||||||
<span class="ckyc-existing__warn">stored file unavailable</span>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@if ((existing.PHOTO_PROOF.previewUrl || existing.PHOTO_PROOF.previewUrlBack) && isImage('PHOTO_PROOF')) {
|
@if (existing.PHOTO_PROOF.previewUrl || existing.PHOTO_PROOF.previewUrlBack) {
|
||||||
<div class="ckyc-existing__thumbs">
|
<div class="ckyc-existing__thumbs">
|
||||||
@if (existing.PHOTO_PROOF.previewUrl) {
|
@if (existing.PHOTO_PROOF.previewUrl) {
|
||||||
<img
|
<img
|
||||||
@@ -338,17 +319,6 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if (existing.PHOTO_PROOF.previewUrl) {
|
|
||||||
<button
|
|
||||||
mat-stroked-button
|
|
||||||
type="button"
|
|
||||||
class="ckyc-existing__view"
|
|
||||||
(click)="openDocument('PHOTO_PROOF')"
|
|
||||||
>
|
|
||||||
<fa-icon icon="eye"></fa-icon>
|
|
||||||
View document
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
<div class="ckyc-existing__meta">
|
<div class="ckyc-existing__meta">
|
||||||
<fa-icon icon="id-card"></fa-icon>
|
<fa-icon icon="id-card"></fa-icon>
|
||||||
<span>{{ existing.PHOTO_PROOF.name }}</span>
|
<span>{{ existing.PHOTO_PROOF.name }}</span>
|
||||||
@@ -423,24 +393,10 @@
|
|||||||
<div>
|
<div>
|
||||||
<strong>Already available</strong>
|
<strong>Already available</strong>
|
||||||
<span class="ckyc-existing__source">from {{ existing.CLIENT_IMAGE.source }}</span>
|
<span class="ckyc-existing__source">from {{ existing.CLIENT_IMAGE.source }}</span>
|
||||||
@if (existing.CLIENT_IMAGE.previewUnavailable) {
|
|
||||||
<span class="ckyc-existing__warn">stored file unavailable</span>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@if (existing.CLIENT_IMAGE.previewUrl && isImage('CLIENT_IMAGE')) {
|
|
||||||
<img [src]="existing.CLIENT_IMAGE.previewUrl" alt="Client photo" class="ckyc-existing__thumb" />
|
|
||||||
}
|
|
||||||
@if (existing.CLIENT_IMAGE.previewUrl) {
|
@if (existing.CLIENT_IMAGE.previewUrl) {
|
||||||
<button
|
<img [src]="existing.CLIENT_IMAGE.previewUrl" alt="Client photo" class="ckyc-existing__thumb" />
|
||||||
mat-stroked-button
|
|
||||||
type="button"
|
|
||||||
class="ckyc-existing__view"
|
|
||||||
(click)="openDocument('CLIENT_IMAGE')"
|
|
||||||
>
|
|
||||||
<fa-icon icon="eye"></fa-icon>
|
|
||||||
View document
|
|
||||||
</button>
|
|
||||||
}
|
}
|
||||||
<button
|
<button
|
||||||
mat-stroked-button
|
mat-stroked-button
|
||||||
@@ -492,29 +448,15 @@
|
|||||||
<div>
|
<div>
|
||||||
<strong>Already available</strong>
|
<strong>Already available</strong>
|
||||||
<span class="ckyc-existing__source">from {{ existing.CLIENT_SIGNATURE.source }}</span>
|
<span class="ckyc-existing__source">from {{ existing.CLIENT_SIGNATURE.source }}</span>
|
||||||
@if (existing.CLIENT_SIGNATURE.previewUnavailable) {
|
|
||||||
<span class="ckyc-existing__warn">stored file unavailable</span>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@if (existing.CLIENT_SIGNATURE.previewUrl && isImage('CLIENT_SIGNATURE')) {
|
@if (existing.CLIENT_SIGNATURE.previewUrl) {
|
||||||
<img
|
<img
|
||||||
[src]="existing.CLIENT_SIGNATURE.previewUrl"
|
[src]="existing.CLIENT_SIGNATURE.previewUrl"
|
||||||
alt="Client signature"
|
alt="Client signature"
|
||||||
class="ckyc-existing__thumb ckyc-existing__thumb--wide"
|
class="ckyc-existing__thumb ckyc-existing__thumb--wide"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
@if (existing.CLIENT_SIGNATURE.previewUrl) {
|
|
||||||
<button
|
|
||||||
mat-stroked-button
|
|
||||||
type="button"
|
|
||||||
class="ckyc-existing__view"
|
|
||||||
(click)="openDocument('CLIENT_SIGNATURE')"
|
|
||||||
>
|
|
||||||
<fa-icon icon="eye"></fa-icon>
|
|
||||||
View document
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
<div class="ckyc-existing__meta">
|
<div class="ckyc-existing__meta">
|
||||||
<fa-icon icon="file-signature"></fa-icon>
|
<fa-icon icon="file-signature"></fa-icon>
|
||||||
<span>{{ existing.CLIENT_SIGNATURE.name }}</span>
|
<span>{{ existing.CLIENT_SIGNATURE.name }}</span>
|
||||||
|
|||||||
@@ -623,35 +623,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Opens the uploaded artefact full size in a new tab. Sits directly under the thumbnail, and is
|
|
||||||
the only way to inspect a PDF - which cannot render as an inline <img>. */
|
|
||||||
.ckyc-existing__view {
|
|
||||||
align-self: flex-start;
|
|
||||||
color: #1d6fd0;
|
|
||||||
border-color: #bcd8f5;
|
|
||||||
background: #fff;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: #eef6ff;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Shown when a document row exists but its file could not be fetched from the content store,
|
|
||||||
so "Already available" is not silently misleading. */
|
|
||||||
.ckyc-existing__warn {
|
|
||||||
display: inline-block;
|
|
||||||
margin-left: 8px;
|
|
||||||
padding: 1px 8px;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: #fdecec;
|
|
||||||
border: 1px solid #f3c2c2;
|
|
||||||
color: #a12b2b;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ckyc-existing {
|
.ckyc-existing {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -36,41 +36,11 @@ interface ExistingArtefact {
|
|||||||
* already writes/reads for address proof; now shared by the photo/identity proof too. */
|
* already writes/reads for address proof; now shared by the photo/identity proof too. */
|
||||||
description?: string;
|
description?: string;
|
||||||
fileCount?: number;
|
fileCount?: number;
|
||||||
/** Set when the stored file could not be fetched - the row exists but its content is gone. */
|
|
||||||
previewUnavailable?: boolean;
|
|
||||||
/** Stored MIME type, so the card knows whether it can render an inline thumbnail. */
|
|
||||||
mimeType?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProofKind = 'ADDRESS_PROOF' | 'PHOTO_PROOF';
|
type ProofKind = 'ADDRESS_PROOF' | 'PHOTO_PROOF';
|
||||||
type CkycDocKind = ProofKind | 'CLIENT_IMAGE' | 'CLIENT_SIGNATURE';
|
type CkycDocKind = ProofKind | 'CLIENT_IMAGE' | 'CLIENT_SIGNATURE';
|
||||||
|
|
||||||
/**
|
|
||||||
* Client-side upload guards, kept deliberately in step with the backend so a file that passes here
|
|
||||||
* is not rejected server-side:
|
|
||||||
* - 5 MB matches `spring.servlet.multipart.max-file-size`
|
|
||||||
* - the extension list is the intersection of the backend `fineract.content.regex-whitelist`
|
|
||||||
* with what these cards can actually render.
|
|
||||||
* The dialog's `accept=` is only a file-picker hint and is trivially bypassed, so the real check
|
|
||||||
* runs here, on the file the dialog hands back, before anything is sent.
|
|
||||||
*/
|
|
||||||
const MAX_UPLOAD_BYTES = 5 * 1024 * 1024;
|
|
||||||
const IMAGE_EXTENSIONS = [
|
|
||||||
'png',
|
|
||||||
'jpeg',
|
|
||||||
'jpg'
|
|
||||||
];
|
|
||||||
const DOCUMENT_EXTENSIONS = [
|
|
||||||
...IMAGE_EXTENSIONS,
|
|
||||||
'pdf'
|
|
||||||
];
|
|
||||||
|
|
||||||
/** Cards that render an inline <img>, so a PDF would not display in them. */
|
|
||||||
const IMAGE_ONLY_KINDS: CkycDocKind[] = [
|
|
||||||
'CLIENT_IMAGE',
|
|
||||||
'CLIENT_SIGNATURE'
|
|
||||||
];
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'mifosx-ckyc-tab',
|
selector: 'mifosx-ckyc-tab',
|
||||||
templateUrl: './ckyc-tab.component.html',
|
templateUrl: './ckyc-tab.component.html',
|
||||||
@@ -180,11 +150,6 @@ export class CkycTabComponent implements OnInit, OnDestroy {
|
|||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.route.parent.params.subscribe((params: any) => {
|
this.route.parent.params.subscribe((params: any) => {
|
||||||
this.clientId = params['clientId'];
|
this.clientId = params['clientId'];
|
||||||
// Angular reuses this component when only the clientId in the route changes, and
|
|
||||||
// loadExistingArtefacts() only ever *assigns* what it finds - it has no "not found" branch.
|
|
||||||
// Without clearing first, a client with no photo (or no signature, or no proof) keeps showing
|
|
||||||
// the previous client's card.
|
|
||||||
this.resetArtefactState();
|
|
||||||
this.loadClientInfo();
|
this.loadClientInfo();
|
||||||
this.loadAccounts();
|
this.loadAccounts();
|
||||||
this.loadCkycData();
|
this.loadCkycData();
|
||||||
@@ -192,46 +157,6 @@ export class CkycTabComponent implements OnInit, OnDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns every per-client card to its empty state, so nothing from the client just viewed can
|
|
||||||
* survive into the next one. Covers all four cards plus the details staged for an upload - a file
|
|
||||||
* or document number left over from another client must never be submitted against this one.
|
|
||||||
*/
|
|
||||||
private resetArtefactState(): void {
|
|
||||||
this.revokePreviewUrls();
|
|
||||||
this.existing = {
|
|
||||||
ADDRESS_PROOF: { present: false },
|
|
||||||
PHOTO_PROOF: { present: false },
|
|
||||||
CLIENT_IMAGE: { present: false },
|
|
||||||
CLIENT_SIGNATURE: { present: false }
|
|
||||||
};
|
|
||||||
this.selectedProofType = { ADDRESS_PROOF: null, PHOTO_PROOF: null };
|
|
||||||
this.docFiles = {
|
|
||||||
ADDRESS_PROOF: { front: null, back: null },
|
|
||||||
PHOTO_PROOF: { front: null, back: null }
|
|
||||||
};
|
|
||||||
this.documentDetails = {
|
|
||||||
ADDRESS_PROOF: { documentKey: '', issueDate: null, expiryDate: null },
|
|
||||||
PHOTO_PROOF: { documentKey: '', issueDate: null, expiryDate: null }
|
|
||||||
};
|
|
||||||
this.clientImageFile = null;
|
|
||||||
this.clientSignatureFile = null;
|
|
||||||
this.uploadErrors = {
|
|
||||||
ADDRESS_PROOF: null,
|
|
||||||
PHOTO_PROOF: null,
|
|
||||||
CLIENT_IMAGE: null,
|
|
||||||
CLIENT_SIGNATURE: null
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Frees the blob URLs held by the current cards before their references are dropped. */
|
|
||||||
private revokePreviewUrls(): void {
|
|
||||||
Object.values(this.existing).forEach((e) => {
|
|
||||||
if (e.previewUrl?.startsWith('blob:')) URL.revokeObjectURL(e.previewUrl);
|
|
||||||
if (e.previewUrlBack?.startsWith('blob:')) URL.revokeObjectURL(e.previewUrlBack);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private loadClientInfo(): void {
|
private loadClientInfo(): void {
|
||||||
this.route.parent.data.subscribe((data: any) => {
|
this.route.parent.data.subscribe((data: any) => {
|
||||||
const cv = data?.clientViewData;
|
const cv = data?.clientViewData;
|
||||||
@@ -355,29 +280,19 @@ export class CkycTabComponent implements OnInit, OnDestroy {
|
|||||||
present: true,
|
present: true,
|
||||||
source: 'Client registration',
|
source: 'Client registration',
|
||||||
name: signatureDoc.fileName || 'Client signature',
|
name: signatureDoc.fileName || 'Client signature',
|
||||||
documentId: signatureDoc.id,
|
documentId: signatureDoc.id
|
||||||
mimeType: signatureDoc.type
|
|
||||||
};
|
};
|
||||||
// Same endpoint getClientSignatureImage() uses, but opted out of the global error alert:
|
// Fetch blob for preview
|
||||||
// a m_document row whose file is no longer in the content store returns 500, and the card
|
|
||||||
// below reports that itself. Without `silent` the interceptor pops an "Internal Server
|
|
||||||
// Error" modal on every page load, for a failure the user cannot act on.
|
|
||||||
this.clientsService
|
this.clientsService
|
||||||
.downloadClientDocument(this.clientId, String(signatureDoc.id), true)
|
.getClientSignatureImage(this.clientId, signatureDoc.id)
|
||||||
.pipe(takeUntil(this.destroy$))
|
.pipe(takeUntil(this.destroy$))
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: (blob: any) => {
|
next: (blob: any) => {
|
||||||
if (blob && blob.size > 0) {
|
if (blob) {
|
||||||
this.existing.CLIENT_SIGNATURE.previewUrl = URL.createObjectURL(blob);
|
this.existing.CLIENT_SIGNATURE.previewUrl = URL.createObjectURL(blob);
|
||||||
} else {
|
|
||||||
// A 200 with an empty body still means there is nothing to show. Without this the
|
|
||||||
// card would claim "Already available" with no thumbnail and no explanation.
|
|
||||||
this.existing.CLIENT_SIGNATURE.previewUnavailable = true;
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
error: () => {
|
error: () => undefined
|
||||||
this.existing.CLIENT_SIGNATURE.previewUnavailable = true;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -449,7 +364,6 @@ export class CkycTabComponent implements OnInit, OnDestroy {
|
|||||||
const list = Array.isArray(docs) ? docs : [];
|
const list = Array.isArray(docs) ? docs : [];
|
||||||
if (!list.length) return;
|
if (!list.length) return;
|
||||||
this.existing[kind].fileCount = list.length;
|
this.existing[kind].fileCount = list.length;
|
||||||
this.existing[kind].previewUnavailable = false;
|
|
||||||
|
|
||||||
const byNewest = (a: any, b: any) => b.id - a.id;
|
const byNewest = (a: any, b: any) => b.id - a.id;
|
||||||
const label = (d: any) => (d?.name || d?.fileName || '').toString();
|
const label = (d: any) => (d?.name || d?.fileName || '').toString();
|
||||||
@@ -458,9 +372,6 @@ export class CkycTabComponent implements OnInit, OnDestroy {
|
|||||||
[...list].filter((d: any) => /front/i.test(label(d))).sort(byNewest)[0] ||
|
[...list].filter((d: any) => /front/i.test(label(d))).sort(byNewest)[0] ||
|
||||||
[...list].filter((d: any) => d.id !== back?.id).sort(byNewest)[0];
|
[...list].filter((d: any) => d.id !== back?.id).sort(byNewest)[0];
|
||||||
|
|
||||||
// A proof can be scanned to PDF, which no <img> can render - remember what was stored so the
|
|
||||||
// card offers "View document" instead of a broken thumbnail.
|
|
||||||
if (front) this.existing[kind].mimeType = front.type;
|
|
||||||
if (front) this.downloadIdentifierDocPreview(identifierId, front.id, kind, 'front');
|
if (front) this.downloadIdentifierDocPreview(identifierId, front.id, kind, 'front');
|
||||||
if (back && back.id !== front?.id) this.downloadIdentifierDocPreview(identifierId, back.id, kind, 'back');
|
if (back && back.id !== front?.id) this.downloadIdentifierDocPreview(identifierId, back.id, kind, 'back');
|
||||||
});
|
});
|
||||||
@@ -477,19 +388,12 @@ export class CkycTabComponent implements OnInit, OnDestroy {
|
|||||||
.pipe(takeUntil(this.destroy$))
|
.pipe(takeUntil(this.destroy$))
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: (blob: any) => {
|
next: (blob: any) => {
|
||||||
if (!blob || blob.size === 0) {
|
if (!blob) return;
|
||||||
this.existing[kind].previewUnavailable = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
if (slot === 'front') this.existing[kind].previewUrl = url;
|
if (slot === 'front') this.existing[kind].previewUrl = url;
|
||||||
else this.existing[kind].previewUrlBack = url;
|
else this.existing[kind].previewUrlBack = url;
|
||||||
},
|
},
|
||||||
// The row exists but its file is gone from the content store. Saying so on the card beats
|
error: () => undefined
|
||||||
// showing "Already available" next to a blank space.
|
|
||||||
error: () => {
|
|
||||||
this.existing[kind].previewUnavailable = true;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -548,18 +452,6 @@ export class CkycTabComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
ref.afterClosed().subscribe((result: CkycUploadDialogResult | null) => {
|
ref.afterClosed().subscribe((result: CkycUploadDialogResult | null) => {
|
||||||
if (!result) return;
|
if (!result) return;
|
||||||
|
|
||||||
// The dialog's `accept=` filter is only a hint to the file picker and is trivially bypassed,
|
|
||||||
// so the size/type guard runs here on what actually came back. Catching it now turns a raw
|
|
||||||
// server rejection into a message on the card.
|
|
||||||
const rejection =
|
|
||||||
this.validationError(result.front, kind) ?? (result.back ? this.validationError(result.back, kind) : null);
|
|
||||||
if (rejection) {
|
|
||||||
this.uploadErrors[kind] = rejection;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.uploadErrors[kind] = null;
|
|
||||||
|
|
||||||
if (isProofKind) {
|
if (isProofKind) {
|
||||||
const proofKind = kind as ProofKind;
|
const proofKind = kind as ProofKind;
|
||||||
this.selectedProofType[proofKind] = result.proofType || null;
|
this.selectedProofType[proofKind] = result.proofType || null;
|
||||||
@@ -578,66 +470,6 @@ export class CkycTabComponent implements OnInit, OnDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Extensions a given card can both store and render. */
|
|
||||||
private allowedExtensions(kind: CkycDocKind): string[] {
|
|
||||||
return IMAGE_ONLY_KINDS.includes(kind) ? IMAGE_EXTENSIONS : DOCUMENT_EXTENSIONS;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rejects a file the backend would refuse (or the card could not render) before it is uploaded,
|
|
||||||
* so the user gets a clear message instead of a raw server error. Returns null when the file is
|
|
||||||
* acceptable.
|
|
||||||
*/
|
|
||||||
private validationError(file: File | null | undefined, kind: CkycDocKind): string | null {
|
|
||||||
if (!file) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const allowed = this.allowedExtensions(kind);
|
|
||||||
const extension = (file.name.split('.').pop() || '').toLowerCase();
|
|
||||||
|
|
||||||
if (!allowed.includes(extension)) {
|
|
||||||
return `Unsupported file type ".${extension}". Allowed: ${allowed.map((e) => e.toUpperCase()).join(', ')}.`;
|
|
||||||
}
|
|
||||||
if (file.size > MAX_UPLOAD_BYTES) {
|
|
||||||
const mb = (file.size / (1024 * 1024)).toFixed(1);
|
|
||||||
return `File is ${mb} MB. The maximum allowed size is 5 MB.`;
|
|
||||||
}
|
|
||||||
if (file.size === 0) {
|
|
||||||
return 'The selected file is empty.';
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* True when the stored file can be shown as an inline thumbnail. A PDF cannot, so its card falls
|
|
||||||
* back to the "View document" button alone rather than rendering a broken image.
|
|
||||||
*/
|
|
||||||
isImage(kind: CkycDocKind): boolean {
|
|
||||||
const entry = this.existing[kind];
|
|
||||||
// The profile image arrives as a `data:image/...;base64,` URL, which declares its own type -
|
|
||||||
// it has neither a stored MIME type nor a file extension for the checks below to read.
|
|
||||||
if (entry?.previewUrl?.startsWith('data:')) {
|
|
||||||
return entry.previewUrl.startsWith('data:image/');
|
|
||||||
}
|
|
||||||
if (entry?.mimeType) {
|
|
||||||
return entry.mimeType.startsWith('image/');
|
|
||||||
}
|
|
||||||
// Older rows can have no stored MIME type - fall back to the file extension.
|
|
||||||
const extension = (entry?.name?.split('.').pop() || '').toLowerCase();
|
|
||||||
return IMAGE_EXTENSIONS.includes(extension);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Opens the uploaded document full size in a new tab. Uses the blob already fetched for the
|
|
||||||
* thumbnail, so it works for PDFs too and costs no extra request.
|
|
||||||
*/
|
|
||||||
openDocument(kind: CkycDocKind): void {
|
|
||||||
const url = this.existing[kind]?.previewUrl;
|
|
||||||
if (url) {
|
|
||||||
window.open(url, '_blank', 'noopener');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Reads back the "Issue date: yyyy-MM-dd, Expiry date: yyyy-MM-dd" convention this tab (and
|
/** Reads back the "Issue date: yyyy-MM-dd, Expiry date: yyyy-MM-dd" convention this tab (and
|
||||||
* client-india-address-step, for address proof) writes into the identifier's description. */
|
* client-india-address-step, for address proof) writes into the identifier's description. */
|
||||||
private parseDescriptionDates(description: string | undefined): { issueDate: Date | null; expiryDate: Date | null } {
|
private parseDescriptionDates(description: string | undefined): { issueDate: Date | null; expiryDate: Date | null } {
|
||||||
@@ -701,10 +533,6 @@ export class CkycTabComponent implements OnInit, OnDestroy {
|
|||||||
request$.pipe(takeUntil(this.destroy$)).subscribe({
|
request$.pipe(takeUntil(this.destroy$)).subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
this.uploading[documentType] = false;
|
this.uploading[documentType] = false;
|
||||||
// The card is gated on `present`; without this a successful upload left it reading
|
|
||||||
// "Not uploaded yet". uploadProofDocument() sets the same flag for the proof cards.
|
|
||||||
this.existing[key].present = true;
|
|
||||||
this.existing[key].source = this.existing[key].source || 'CKYC upload';
|
|
||||||
this.alertService.alert({ type: 'success', message: 'Document uploaded successfully.' });
|
this.alertService.alert({ type: 'success', message: 'Document uploaded successfully.' });
|
||||||
this.refreshPreview(documentType);
|
this.refreshPreview(documentType);
|
||||||
},
|
},
|
||||||
@@ -825,22 +653,13 @@ export class CkycTabComponent implements OnInit, OnDestroy {
|
|||||||
if (this.existing[staleKey].previewUrlBack?.startsWith('blob:')) {
|
if (this.existing[staleKey].previewUrlBack?.startsWith('blob:')) {
|
||||||
URL.revokeObjectURL(this.existing[staleKey].previewUrlBack!);
|
URL.revokeObjectURL(this.existing[staleKey].previewUrlBack!);
|
||||||
}
|
}
|
||||||
// The upload that triggered this refresh is what repairs a previously missing file, so the
|
|
||||||
// warning has to be cleared before the re-read - otherwise a fixed card keeps the old badge.
|
|
||||||
this.existing[staleKey].previewUrl = undefined;
|
|
||||||
this.existing[staleKey].previewUrlBack = undefined;
|
|
||||||
this.existing[staleKey].previewUnavailable = false;
|
|
||||||
switch (documentType) {
|
switch (documentType) {
|
||||||
case 'CLIENT_IMAGE':
|
case 'CLIENT_IMAGE':
|
||||||
this.clientsService
|
this.clientsService
|
||||||
.getClientProfileImage(this.clientId)
|
.getClientProfileImage(this.clientId)
|
||||||
.pipe(takeUntil(this.destroy$))
|
.pipe(takeUntil(this.destroy$))
|
||||||
.subscribe((photo: any) => {
|
.subscribe((photo: any) => {
|
||||||
if (photo) {
|
if (photo) this.existing.CLIENT_IMAGE.previewUrl = photo;
|
||||||
this.existing.CLIENT_IMAGE.previewUrl = photo;
|
|
||||||
} else {
|
|
||||||
this.existing.CLIENT_IMAGE.previewUnavailable = true;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case 'CLIENT_SIGNATURE':
|
case 'CLIENT_SIGNATURE':
|
||||||
@@ -853,21 +672,11 @@ export class CkycTabComponent implements OnInit, OnDestroy {
|
|||||||
?.sort((a: any, b: any) => b.id - a.id)?.[0];
|
?.sort((a: any, b: any) => b.id - a.id)?.[0];
|
||||||
if (!doc) return;
|
if (!doc) return;
|
||||||
this.existing.CLIENT_SIGNATURE.documentId = doc.id;
|
this.existing.CLIENT_SIGNATURE.documentId = doc.id;
|
||||||
this.existing.CLIENT_SIGNATURE.mimeType = doc.type;
|
|
||||||
this.clientsService
|
this.clientsService
|
||||||
.downloadClientDocument(this.clientId, String(doc.id), true)
|
.getClientSignatureImage(this.clientId, doc.id)
|
||||||
.pipe(takeUntil(this.destroy$))
|
.pipe(takeUntil(this.destroy$))
|
||||||
.subscribe({
|
.subscribe((blob: any) => {
|
||||||
next: (blob: any) => {
|
if (blob) this.existing.CLIENT_SIGNATURE.previewUrl = URL.createObjectURL(blob);
|
||||||
if (blob && blob.size > 0) {
|
|
||||||
this.existing.CLIENT_SIGNATURE.previewUrl = URL.createObjectURL(blob);
|
|
||||||
} else {
|
|
||||||
this.existing.CLIENT_SIGNATURE.previewUnavailable = true;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
error: () => {
|
|
||||||
this.existing.CLIENT_SIGNATURE.previewUnavailable = true;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
@@ -881,6 +690,10 @@ export class CkycTabComponent implements OnInit, OnDestroy {
|
|||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.destroy$.next();
|
this.destroy$.next();
|
||||||
this.destroy$.complete();
|
this.destroy$.complete();
|
||||||
this.revokePreviewUrls();
|
// Release object URLs
|
||||||
|
Object.values(this.existing).forEach((e) => {
|
||||||
|
if (e.previewUrl?.startsWith('blob:')) URL.revokeObjectURL(e.previewUrl);
|
||||||
|
if (e.previewUrlBack?.startsWith('blob:')) URL.revokeObjectURL(e.previewUrlBack);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,10 +55,7 @@
|
|||||||
<div class="f121__note">
|
<div class="f121__note">
|
||||||
<mat-icon>info_outline</mat-icon>
|
<mat-icon>info_outline</mat-icon>
|
||||||
<div>
|
<div>
|
||||||
@if (configMissing) {
|
@if (!dateOfBirth) {
|
||||||
TDS rates for FY {{ fy }} are not configured, so the tax cannot be computed. Set them up under Organization
|
|
||||||
› TDS Configuration.
|
|
||||||
} @else if (!dateOfBirth) {
|
|
||||||
Date of birth is not on record, so the senior-citizen threshold cannot be determined. Add it on the Personal
|
Date of birth is not on record, so the senior-citizen threshold cannot be determined. Add it on the Personal
|
||||||
Data tab.
|
Data tab.
|
||||||
} @else {
|
} @else {
|
||||||
@@ -116,8 +113,8 @@
|
|||||||
<div class="f121__note f121__note--warn">
|
<div class="f121__note f121__note--warn">
|
||||||
<mat-icon>warning_amber</mat-icon>
|
<mat-icon>warning_amber</mat-icon>
|
||||||
<div>
|
<div>
|
||||||
A declaration is on record for this year, but one or more deposits still carry a tax group. Those accounts
|
A Form 121 declaration is on record for this year, but withholding is still switched on for one or more
|
||||||
deduct on their own and ignore the declaration — remove the tax group from them.
|
deposits. Turn it off on those accounts, or tax will keep being deducted.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -192,90 +189,20 @@
|
|||||||
}
|
}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@if (declarationRecord; as d) {
|
@if (declarationOnRecord && declarationFy) {
|
||||||
<div class="f121__kv-row">
|
<div class="f121__kv-row">
|
||||||
<span>Declared for</span>
|
<span>Declared for</span>
|
||||||
<span>FY {{ d.fy }} · Form {{ d.formType }}</span>
|
<span>FY {{ declarationFy }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="f121__kv-row">
|
|
||||||
<span>Accepted on</span>
|
|
||||||
<span>
|
|
||||||
{{ d.acceptedOn | date: 'd MMM y' }} — no TDS on interest credited from this day to 31 March. Interest
|
|
||||||
credited before it is taxed as usual.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
@if (d.uin) {
|
|
||||||
<div class="f121__kv-row">
|
|
||||||
<span>UIN</span>
|
|
||||||
<span class="f121__mono">{{ d.uin }}</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
<div class="f121__kv-row">
|
|
||||||
<span></span>
|
|
||||||
<span>
|
|
||||||
<button type="button" class="f121__btn f121__btn--warn" (click)="revokeDeclaration()">
|
|
||||||
Withdraw declaration
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
} @else if (fy && !configMissing) {
|
|
||||||
<div class="f121__kv-row">
|
|
||||||
<span></span>
|
|
||||||
<span>
|
|
||||||
@if (!showDeclarationForm) {
|
|
||||||
<button type="button" class="f121__btn" (click)="openDeclarationForm()" [disabled]="!panWellFormed">
|
|
||||||
Record {{ formLabel }} for FY {{ fy }}
|
|
||||||
</button>
|
|
||||||
@if (!panWellFormed) {
|
|
||||||
<div class="muted">A valid PAN must be on the KYC first — a declaration without one is void.</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
@if (showDeclarationForm) {
|
|
||||||
<div class="f121__decl-form">
|
|
||||||
<label>
|
|
||||||
<span>Accepted on *</span>
|
|
||||||
<input type="date" [(ngModel)]="newDeclaration.acceptedOn" />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span>UIN</span>
|
|
||||||
<input type="text" [(ngModel)]="newDeclaration.uin" placeholder="Allotted on acceptance" />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span>Estimated income for the year (₹)</span>
|
|
||||||
<input type="number" min="0" [(ngModel)]="newDeclaration.estimatedIncome" />
|
|
||||||
</label>
|
|
||||||
@if (declarationError) {
|
|
||||||
<div class="f121__note f121__note--warn">
|
|
||||||
<mat-icon>error_outline</mat-icon>
|
|
||||||
<div>{{ declarationError }}</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
<div class="f121__decl-actions">
|
|
||||||
<button type="button" class="f121__btn" (click)="showDeclarationForm = false">Cancel</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="f121__btn f121__btn--primary"
|
|
||||||
(click)="saveDeclaration()"
|
|
||||||
[disabled]="savingDeclaration"
|
|
||||||
>
|
|
||||||
{{ savingDeclaration ? 'Saving…' : 'Save declaration' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@for (old of declarations; track old.id) {
|
@if (declarationOnRecord && !acceptanceDateKnown) {
|
||||||
@if (old.revoked && old.fy === fy) {
|
<div class="f121__kv-row f121__kv-row--muted">
|
||||||
<div class="f121__kv-row f121__kv-row--muted">
|
<span>Acceptance date</span>
|
||||||
<span>Withdrawn</span>
|
<span>
|
||||||
<span>
|
Not captured — the declaration is treated as covering the whole year. A mid-year acceptance would change
|
||||||
Form {{ old.formType }} accepted {{ old.acceptedOn | date: 'd MMM y' }}, withdrawn
|
the figures below.
|
||||||
{{ old.revokedOn | date: 'd MMM y' }} — {{ old.revokeReason }}
|
</span>
|
||||||
</span>
|
</div>
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@if (eligibility) {
|
@if (eligibility) {
|
||||||
@for (reason of eligibility.reasons; track reason) {
|
@for (reason of eligibility.reasons; track reason) {
|
||||||
|
|||||||
@@ -332,61 +332,3 @@
|
|||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Recording a declaration ── */
|
|
||||||
.f121__btn {
|
|
||||||
padding: 6px 14px;
|
|
||||||
border: 1px solid #d1d5db;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: #fff;
|
|
||||||
font: inherit;
|
|
||||||
font-size: 12.5px;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&:disabled {
|
|
||||||
opacity: 0.55;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--primary {
|
|
||||||
border-color: #1d4ed8;
|
|
||||||
background: #1d4ed8;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--warn {
|
|
||||||
border-color: #b45309;
|
|
||||||
color: #b45309;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.f121__decl-form {
|
|
||||||
display: grid;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 12px 16px;
|
|
||||||
|
|
||||||
label {
|
|
||||||
display: grid;
|
|
||||||
gap: 4px;
|
|
||||||
font-size: 12px;
|
|
||||||
|
|
||||||
span {
|
|
||||||
color: #6b7280;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
input {
|
|
||||||
max-width: 280px;
|
|
||||||
padding: 6px 8px;
|
|
||||||
border: 1px solid #d1d5db;
|
|
||||||
border-radius: 6px;
|
|
||||||
font: inherit;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.f121__decl-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -23,8 +23,6 @@ import { SavingsService } from 'app/savings/savings.service';
|
|||||||
import { FixedDepositsService } from 'app/deposits/fixed-deposits/fixed-deposits.service';
|
import { FixedDepositsService } from 'app/deposits/fixed-deposits/fixed-deposits.service';
|
||||||
import { RecurringDepositsService } from 'app/deposits/recurring-deposits/recurring-deposits.service';
|
import { RecurringDepositsService } from 'app/deposits/recurring-deposits/recurring-deposits.service';
|
||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
import { TdsDeclarationRecord, TdsReportsService } from 'app/tds-reports/tds-reports.service';
|
|
||||||
import { TdsDeductee } from 'app/tds-reports/tds-reports-data';
|
|
||||||
|
|
||||||
/** Domain logic */
|
/** Domain logic */
|
||||||
import {
|
import {
|
||||||
@@ -113,7 +111,6 @@ export class Form121TabComponent implements OnInit, OnDestroy {
|
|||||||
private savingsService = inject(SavingsService);
|
private savingsService = inject(SavingsService);
|
||||||
private fixedDepositsService = inject(FixedDepositsService);
|
private fixedDepositsService = inject(FixedDepositsService);
|
||||||
private recurringDepositsService = inject(RecurringDepositsService);
|
private recurringDepositsService = inject(RecurringDepositsService);
|
||||||
private tdsService = inject(TdsReportsService);
|
|
||||||
|
|
||||||
private destroy$ = new Subject<void>();
|
private destroy$ = new Subject<void>();
|
||||||
|
|
||||||
@@ -127,25 +124,23 @@ export class Form121TabComponent implements OnInit, OnDestroy {
|
|||||||
pan = '';
|
pan = '';
|
||||||
panWellFormed = false;
|
panWellFormed = false;
|
||||||
|
|
||||||
// ── Declarations on record ──
|
// ── Declaration on record ──
|
||||||
/**
|
declarationOnRecord = false;
|
||||||
* Every declaration the customer has given, each for one year and from the day the branch
|
|
||||||
* accepted it. The KYC record only ever held a type and a year, which made a September
|
|
||||||
* declaration shield interest back to April and let last year's form run on.
|
|
||||||
*/
|
|
||||||
declarations: TdsDeclarationRecord[] = [];
|
|
||||||
/**
|
/**
|
||||||
* The form actually in force for the selected year. From FY 2026-27 a single
|
* The form actually in force for the selected year. From FY 2026-27 a single
|
||||||
* Form 121 replaced the 15G/15H split, so this is derived from the year rather
|
* Form 121 replaced the 15G/15H split, so this is derived from the year rather
|
||||||
* than from the payee's age.
|
* than from the payee's age.
|
||||||
*/
|
*/
|
||||||
declarationForm: DeclarationForm = '121';
|
declarationForm: DeclarationForm = '121';
|
||||||
|
/** Kind recorded at KYC, meaningful only for a pre-2026 year. */
|
||||||
// ── Recording a declaration ──
|
declarationKind: '15G' | '15H' = '15G';
|
||||||
showDeclarationForm = false;
|
declarationFy = '';
|
||||||
savingDeclaration = false;
|
/**
|
||||||
declarationError = '';
|
* The KYC step records only the year, not the date the branch accepted the
|
||||||
newDeclaration = { acceptedOn: '', uin: '', estimatedIncome: null as number | null };
|
* declaration, so it has to be treated as covering the whole year. Surfaced on
|
||||||
|
* screen rather than hidden — a mid-year acceptance would change the figures.
|
||||||
|
*/
|
||||||
|
acceptanceDateKnown = false;
|
||||||
|
|
||||||
eligibility: EligibilityResult | null = null;
|
eligibility: EligibilityResult | null = null;
|
||||||
|
|
||||||
@@ -158,13 +153,11 @@ export class Form121TabComponent implements OnInit, OnDestroy {
|
|||||||
schedule: TdsSchedule | null = null;
|
schedule: TdsSchedule | null = null;
|
||||||
actualTds = 0;
|
actualTds = 0;
|
||||||
tdsSaved = 0;
|
tdsSaved = 0;
|
||||||
/** The selected year has no entry on Organization › TDS Configuration. */
|
|
||||||
configMissing = false;
|
|
||||||
|
|
||||||
/** Raw account inputs, kept so the year selector can recompute cheaply. */
|
/** Raw account inputs, kept so the year selector can recompute cheaply. */
|
||||||
private accountInputs: (TdsAccountInput & { meta: Form121AccountRow; taxLines: InterestCredit[] })[] = [];
|
private accountInputs: (TdsAccountInput & { meta: Form121AccountRow })[] = [];
|
||||||
/** This customer's FD/RD interest received in savings, and TDS taken there, per year. */
|
/** Withheld-tax postings by FY, from the ledger. */
|
||||||
private platformByFy = new Map<string, TdsDeductee | null>();
|
private actualTdsByFy = new Map<string, number>();
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.route.parent.params.pipe(takeUntil(this.destroy$)).subscribe((params: any) => {
|
this.route.parent.params.pipe(takeUntil(this.destroy$)).subscribe((params: any) => {
|
||||||
@@ -187,15 +180,13 @@ export class Form121TabComponent implements OnInit, OnDestroy {
|
|||||||
forkJoin({
|
forkJoin({
|
||||||
client: this.clientsService.getClientData(this.clientId, true).pipe(catchError(() => of(null))),
|
client: this.clientsService.getClientData(this.clientId, true).pipe(catchError(() => of(null))),
|
||||||
kyc: this.clientsService.getClientKyc(this.clientId, true).pipe(catchError(() => of(null))),
|
kyc: this.clientsService.getClientKyc(this.clientId, true).pipe(catchError(() => of(null))),
|
||||||
accounts: this.clientsService.getClientAccountData(this.clientId).pipe(catchError(() => of(null))),
|
accounts: this.clientsService.getClientAccountData(this.clientId).pipe(catchError(() => of(null)))
|
||||||
declarations: this.tdsService.getDeclarations(Number(this.clientId)).pipe(catchError(() => of([])))
|
|
||||||
})
|
})
|
||||||
.pipe(
|
.pipe(
|
||||||
takeUntil(this.destroy$),
|
takeUntil(this.destroy$),
|
||||||
switchMap(({ client, kyc, accounts, declarations }) => {
|
switchMap(({ client, kyc, accounts }) => {
|
||||||
this.applyClient(client);
|
this.applyClient(client);
|
||||||
this.applyKyc(kyc);
|
this.applyKyc(kyc);
|
||||||
this.declarations = declarations || [];
|
|
||||||
const deposits = this.depositAccountsOf(accounts);
|
const deposits = this.depositAccountsOf(accounts);
|
||||||
return deposits.length ? forkJoin(deposits.map((a) => this.fetchAccount(a))) : of([] as any[]);
|
return deposits.length ? forkJoin(deposits.map((a) => this.fetchAccount(a))) : of([] as any[]);
|
||||||
}),
|
}),
|
||||||
@@ -219,8 +210,11 @@ export class Form121TabComponent implements OnInit, OnDestroy {
|
|||||||
private applyKyc(kyc: any): void {
|
private applyKyc(kyc: any): void {
|
||||||
this.pan = (kyc?.panNumber ?? '').toUpperCase();
|
this.pan = (kyc?.panNumber ?? '').toUpperCase();
|
||||||
this.panWellFormed = isWellFormedPan(this.pan);
|
this.panWellFormed = isWellFormedPan(this.pan);
|
||||||
this.declarationForm =
|
this.declarationOnRecord = !!kyc?.form15Applicable;
|
||||||
this.fy && FY_TAX_CONFIG[normaliseFy(this.fy)] ? declarationFormFor(this.fy, this.dateOfBirth) : '121';
|
this.declarationKind = kyc?.form15Type === '15H' ? '15H' : '15G';
|
||||||
|
this.declarationForm = this.fy ? declarationFormFor(this.fy, this.dateOfBirth) : '121';
|
||||||
|
this.declarationFy = kyc?.form15FY ?? '';
|
||||||
|
this.acceptanceDateKnown = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Savings, FD and RD accounts — everything that can credit interest. */
|
/** Savings, FD and RD accounts — everything that can credit interest. */
|
||||||
@@ -254,7 +248,7 @@ export class Form121TabComponent implements OnInit, OnDestroy {
|
|||||||
*/
|
*/
|
||||||
private ingest(details: any[]): void {
|
private ingest(details: any[]): void {
|
||||||
this.accountInputs = [];
|
this.accountInputs = [];
|
||||||
this.platformByFy = new Map<string, TdsDeductee | null>();
|
this.actualTdsByFy = new Map<string, number>();
|
||||||
const fys = new Set<string>();
|
const fys = new Set<string>();
|
||||||
const clientIdNum = Number(this.clientId);
|
const clientIdNum = Number(this.clientId);
|
||||||
|
|
||||||
@@ -275,7 +269,6 @@ export class Form121TabComponent implements OnInit, OnDestroy {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const credits: InterestCredit[] = [];
|
const credits: InterestCredit[] = [];
|
||||||
const taxLines: InterestCredit[] = [];
|
|
||||||
let tdsDeducted = 0;
|
let tdsDeducted = 0;
|
||||||
|
|
||||||
for (const t of d.transactions ?? []) {
|
for (const t of d.transactions ?? []) {
|
||||||
@@ -293,7 +286,8 @@ export class Form121TabComponent implements OnInit, OnDestroy {
|
|||||||
} else if (t.transactionType?.withholdTax) {
|
} else if (t.transactionType?.withholdTax) {
|
||||||
const amount = t.amount ?? 0;
|
const amount = t.amount ?? 0;
|
||||||
tdsDeducted += amount;
|
tdsDeducted += amount;
|
||||||
taxLines.push({ date: iso, amount, accountNo: ref.accountNo });
|
const txnFy = fyOf(iso);
|
||||||
|
this.actualTdsByFy.set(txnFy, (this.actualTdsByFy.get(txnFy) ?? 0) + amount);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,12 +310,12 @@ export class Form121TabComponent implements OnInit, OnDestroy {
|
|||||||
deducteeClientId: deductee
|
deducteeClientId: deductee
|
||||||
};
|
};
|
||||||
|
|
||||||
this.accountInputs.push({ ...ref, credits, taxLines, meta });
|
this.accountInputs.push({ ...ref, credits, meta });
|
||||||
}
|
}
|
||||||
|
|
||||||
this.availableFys = [...fys].sort().reverse();
|
this.availableFys = [...fys].sort().reverse();
|
||||||
if (!this.availableFys.includes(this.fy)) {
|
if (!this.availableFys.includes(this.fy)) {
|
||||||
this.fy = this.availableFys[0] ?? this.declarations[0]?.fy ?? Object.keys(FY_TAX_CONFIG).sort().reverse()[0] ?? '';
|
this.fy = this.availableFys[0] ?? (this.declarationFy ? normaliseFy(this.declarationFy) : '');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -339,39 +333,14 @@ export class Form121TabComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const year = normaliseFy(this.fy);
|
const year = normaliseFy(this.fy);
|
||||||
this.configMissing = !FY_TAX_CONFIG[year];
|
|
||||||
|
|
||||||
// TDS is tracked on FD/RD interest once it reaches savings, and deducted there. Those figures live
|
|
||||||
// on the platform, not in the deposit's own transactions, so they are fetched once per year and
|
|
||||||
// the computation waits for them.
|
|
||||||
if (!this.platformByFy.has(year)) {
|
|
||||||
this.loadPlatformYear(year);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const platform = this.platformByFy.get(year);
|
|
||||||
for (const a of this.accountInputs) {
|
|
||||||
if (!a.meta.tdsApplicable) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const match = platform?.accounts.find((p) => p.accountNo === a.accountNo);
|
|
||||||
a.credits = match?.credits ?? [];
|
|
||||||
a.taxLines = match?.deductions ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Per-account interest for the selected year, for the three lists.
|
// Per-account interest for the selected year, for the three lists.
|
||||||
for (const a of this.accountInputs) {
|
for (const a of this.accountInputs) {
|
||||||
a.meta.interest = a.credits.filter((c) => fyOf(c.date) === year).reduce((s, c) => s + c.amount, 0);
|
a.meta.interest = a.credits.filter((c) => fyOf(c.date) === year).reduce((s, c) => s + c.amount, 0);
|
||||||
// The selected year only - this used to total every year the account had ever been taxed in.
|
|
||||||
a.meta.tdsDeducted = a.meta.tdsApplicable
|
|
||||||
? a.taxLines.filter((c) => fyOf(c.date) === year).reduce((s, c) => s + c.amount, 0)
|
|
||||||
: 0;
|
|
||||||
}
|
}
|
||||||
// What was actually debited for this customer as deductee, wherever the savings account sits.
|
this.actualTds = this.actualTdsByFy.get(year) ?? 0;
|
||||||
this.actualTds = this.accountInputs
|
|
||||||
.filter((a) => a.meta.tdsApplicable)
|
|
||||||
.reduce((s, a) => s + a.meta.tdsDeducted, 0);
|
|
||||||
|
|
||||||
if (!this.dateOfBirth || this.configMissing) {
|
if (!this.dateOfBirth) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -409,95 +378,21 @@ export class Form121TabComponent implements OnInit, OnDestroy {
|
|||||||
return applicable.some((a) => a.meta.mode === 'QUARTERLY') ? 'QUARTERLY' : 'ANNUAL';
|
return applicable.some((a) => a.meta.mode === 'QUARTERLY') ? 'QUARTERLY' : 'ANNUAL';
|
||||||
}
|
}
|
||||||
|
|
||||||
private loadPlatformYear(year: string): void {
|
/** The declaration, but only when it was filed for the year being shown. */
|
||||||
this.tdsService
|
private declarationForSelectedFy(): Declaration | null {
|
||||||
.getDepositInterest(Number(year.slice(0, 4)), Number(this.clientId))
|
if (!this.declarationOnRecord || !this.fy) {
|
||||||
.pipe(
|
|
||||||
catchError(() => of([])),
|
|
||||||
takeUntil(this.destroy$)
|
|
||||||
)
|
|
||||||
.subscribe((rows) => {
|
|
||||||
this.platformByFy.set(year, rows.find((r) => `${r.clientId}` === `${this.clientId}`) ?? null);
|
|
||||||
this.recompute();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The live declaration recorded for the year being shown, if any. */
|
|
||||||
get declarationRecord(): TdsDeclarationRecord | null {
|
|
||||||
if (!this.fy) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const year = normaliseFy(this.fy);
|
const year = normaliseFy(this.fy);
|
||||||
return this.declarations.find((d) => d.fy === year && !d.revoked) ?? null;
|
if (this.declarationFy && normaliseFy(this.declarationFy) !== year) {
|
||||||
}
|
|
||||||
|
|
||||||
get declarationOnRecord(): boolean {
|
|
||||||
return !!this.declarationRecord;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The declaration as the engine takes it — operative from the day it was accepted. */
|
|
||||||
private declarationForSelectedFy(): Declaration | null {
|
|
||||||
const d = this.declarationRecord;
|
|
||||||
if (!d) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const kind = d.formType === '15H' ? '15H' : d.formType === '15G' ? '15G' : formKindFor(this.dateOfBirth, d.fy);
|
return {
|
||||||
return { kind, acceptedOn: d.acceptedOn, fy: d.fy, uin: d.uin || undefined };
|
kind: this.declarationKind,
|
||||||
}
|
// No acceptance date on record — treated as covering the whole year.
|
||||||
|
acceptedOn: `${year.slice(0, 4)}-04-01`,
|
||||||
openDeclarationForm(): void {
|
fy: year
|
||||||
this.newDeclaration = { acceptedOn: new Date().toISOString().slice(0, 10), uin: '', estimatedIncome: null };
|
};
|
||||||
this.declarationError = '';
|
|
||||||
this.showDeclarationForm = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
saveDeclaration(): void {
|
|
||||||
if (!this.newDeclaration.acceptedOn) {
|
|
||||||
this.declarationError = 'Enter the date the branch accepted the form.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.savingDeclaration = true;
|
|
||||||
this.declarationError = '';
|
|
||||||
this.tdsService
|
|
||||||
.createDeclaration({
|
|
||||||
clientId: Number(this.clientId),
|
|
||||||
fy: normaliseFy(this.fy),
|
|
||||||
formType: declarationFormFor(this.fy, this.dateOfBirth),
|
|
||||||
acceptedOn: this.newDeclaration.acceptedOn,
|
|
||||||
uin: this.newDeclaration.uin || undefined,
|
|
||||||
estimatedIncome: this.newDeclaration.estimatedIncome
|
|
||||||
})
|
|
||||||
.subscribe({
|
|
||||||
next: (rows) => {
|
|
||||||
this.savingDeclaration = false;
|
|
||||||
this.showDeclarationForm = false;
|
|
||||||
this.declarations = rows || [];
|
|
||||||
this.recompute();
|
|
||||||
},
|
|
||||||
error: (err: any) => {
|
|
||||||
this.savingDeclaration = false;
|
|
||||||
this.declarationError =
|
|
||||||
err?.error?.errors?.[0]?.defaultUserMessage || err?.error?.defaultUserMessage || 'Could not save.';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
revokeDeclaration(): void {
|
|
||||||
const d = this.declarationRecord;
|
|
||||||
if (!d) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const reason = prompt('Why is this declaration being withdrawn? From today, TDS will be deducted again.');
|
|
||||||
if (!reason) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.tdsService.revokeDeclaration(d.id, reason).subscribe({
|
|
||||||
next: (rows) => {
|
|
||||||
this.declarations = rows || [];
|
|
||||||
this.recompute();
|
|
||||||
},
|
|
||||||
error: (err: any) => alert(err?.error?.defaultUserMessage || 'Could not revoke the declaration.')
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ───────────────────────── Template helpers ─────────────────────────
|
// ───────────────────────── Template helpers ─────────────────────────
|
||||||
@@ -516,7 +411,7 @@ export class Form121TabComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
/** The form the payee would file for the selected year. */
|
/** The form the payee would file for the selected year. */
|
||||||
get formLabel(): string {
|
get formLabel(): string {
|
||||||
if (!this.fy || this.configMissing) {
|
if (!this.fy) {
|
||||||
return '—';
|
return '—';
|
||||||
}
|
}
|
||||||
const form = declarationFormFor(this.fy, this.dateOfBirth);
|
const form = declarationFormFor(this.fy, this.dateOfBirth);
|
||||||
@@ -575,12 +470,11 @@ export class Form121TabComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A declaration is on record but a deposit still carries a stock Fineract tax group. Those
|
* A declaration is on record but deposit accounts are still set to withhold —
|
||||||
* accounts withhold on their own rules and never consult the declaration record, so the form
|
* the branch has not switched them off.
|
||||||
* does not stop their deduction until the tax group is removed.
|
|
||||||
*/
|
*/
|
||||||
get declarationNotAppliedToAccounts(): boolean {
|
get declarationNotAppliedToAccounts(): boolean {
|
||||||
return !!this.declarationForSelectedFy() && this.liableAccounts.some((a) => !!a.taxGroup);
|
return !!this.declarationForSelectedFy() && this.liableAccounts.some((a) => a.withHoldTax);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -591,13 +591,16 @@
|
|||||||
<i class="fa fa-calendar section-header__icon"></i>
|
<i class="fa fa-calendar section-header__icon"></i>
|
||||||
<h3 class="section-header__title">{{ 'labels.heading.Recurring Deposit Accounts' | translate }}</h3>
|
<h3 class="section-header__title">{{ 'labels.heading.Recurring Deposit Accounts' | translate }}</h3>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="section-header__right">
|
||||||
|
<button mat-raised-button color="primary" (click)="toggleRecurringAccountsOverview()">
|
||||||
|
{{ viewAccountsLabel(showClosedRecurringAccounts) | translate }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Active and ended recurring deposits share one table; the Status column says how each one ended (matured or
|
@if (!showClosedRecurringAccounts) {
|
||||||
closed early), so the client's RD history stays visible without switching views. -->
|
|
||||||
@if (savingAccounts) {
|
|
||||||
<div class="table-wrapper">
|
<div class="table-wrapper">
|
||||||
<table mat-table [dataSource]="savingAccounts | accountsFilter: 'saving' : 'all' : 'isRecurring'">
|
<table mat-table [dataSource]="savingAccounts | accountsFilter: 'saving' : 'open' : 'isRecurring'">
|
||||||
<ng-container matColumnDef="Account No">
|
<ng-container matColumnDef="Account No">
|
||||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Account No' | translate }}</th>
|
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Account No' | translate }}</th>
|
||||||
<td mat-cell *matCellDef="let element">
|
<td mat-cell *matCellDef="let element">
|
||||||
@@ -611,15 +614,6 @@
|
|||||||
<mifosx-long-text textValue="{{ element.productName }}" chars="35"></mifosx-long-text>
|
<mifosx-long-text textValue="{{ element.productName }}" chars="35"></mifosx-long-text>
|
||||||
</td>
|
</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
<ng-container matColumnDef="Status">
|
|
||||||
<th mat-header-cell *matHeaderCellDef>Status</th>
|
|
||||||
<td mat-cell *matCellDef="let element">
|
|
||||||
<span class="rd-status" [class.rd-status--ended]="element.timeline?.closedOnDate">{{ rdStatusLabel(element) }}</span>
|
|
||||||
@if (element.timeline?.closedOnDate) {
|
|
||||||
<div class="rd-status__date">on {{ element.timeline.closedOnDate | dateFormat }}</div>
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
</ng-container>
|
|
||||||
<ng-container matColumnDef="Last Active">
|
<ng-container matColumnDef="Last Active">
|
||||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Last Active' | translate }}</th>
|
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Last Active' | translate }}</th>
|
||||||
<td mat-cell *matCellDef="let element">{{ element.lastActiveTransactionDate | dateFormat }}</td>
|
<td mat-cell *matCellDef="let element">{{ element.lastActiveTransactionDate | dateFormat }}</td>
|
||||||
@@ -644,7 +638,7 @@
|
|||||||
<i class="fa fa-check"></i>
|
<i class="fa fa-check"></i>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
@if (element.status.approved) {
|
@if (!element.status.submittedAndPendingApproval && !element.status.active) {
|
||||||
<button
|
<button
|
||||||
class="account-action-button"
|
class="account-action-button"
|
||||||
mat-raised-button
|
mat-raised-button
|
||||||
@@ -670,7 +664,7 @@
|
|||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<ng-container matColumnDef="no-data">
|
<ng-container matColumnDef="no-data">
|
||||||
<td mat-footer-cell *matFooterCellDef [attr.colspan]="recurringColumns.length">
|
<td mat-footer-cell *matFooterCellDef [attr.colspan]="openSavingsColumns.length">
|
||||||
<div class="table-empty-state">
|
<div class="table-empty-state">
|
||||||
<i class="fa fa-folder-open-o table-empty-state__icon"></i>
|
<i class="fa fa-folder-open-o table-empty-state__icon"></i>
|
||||||
<p class="table-empty-state__text">
|
<p class="table-empty-state__text">
|
||||||
@@ -680,17 +674,67 @@
|
|||||||
</td>
|
</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<tr mat-header-row *matHeaderRowDef="recurringColumns"></tr>
|
<tr mat-header-row *matHeaderRowDef="openSavingsColumns"></tr>
|
||||||
<tr
|
<tr
|
||||||
mat-row
|
mat-row
|
||||||
*matRowDef="let row; columns: recurringColumns"
|
*matRowDef="let row; columns: openSavingsColumns"
|
||||||
[routerLink]="['../', 'recurring-deposits-accounts', row.id, 'general']"
|
[routerLink]="['../', 'recurring-deposits-accounts', row.id, 'general']"
|
||||||
class="select-row"
|
class="select-row"
|
||||||
></tr>
|
></tr>
|
||||||
<tr
|
<tr
|
||||||
mat-footer-row
|
mat-footer-row
|
||||||
*matFooterRowDef="
|
*matFooterRowDef="
|
||||||
(savingAccounts | accountsFilter: 'saving' : 'all' : 'isRecurring')?.length === 0 ? ['no-data'] : []
|
(savingAccounts | accountsFilter: 'saving' : 'open' : 'isRecurring')?.length === 0 ? ['no-data'] : []
|
||||||
|
"
|
||||||
|
></tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Closed Recurring Deposit Accounts -->
|
||||||
|
@if (showClosedRecurringAccounts) {
|
||||||
|
<div class="table-wrapper">
|
||||||
|
<table mat-table [dataSource]="savingAccounts | accountsFilter: 'saving' : 'closed' : 'isRecurring'">
|
||||||
|
<ng-container matColumnDef="Account No">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Account No' | translate }}</th>
|
||||||
|
<td mat-cell *matCellDef="let element">
|
||||||
|
<i class="fa fa-stop" [ngClass]="element.status.code | statusLookup"></i>
|
||||||
|
<mifosx-account-number accountNo="{{ element.accountNo }}"></mifosx-account-number>
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
<ng-container matColumnDef="Saving Account">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Recurring Deposit Product' | translate }}</th>
|
||||||
|
<td mat-cell *matCellDef="let element">
|
||||||
|
<mifosx-long-text textValue="{{ element.productName }}" chars="35"></mifosx-long-text>
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
<ng-container matColumnDef="Closed Date">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Closed Date' | translate }}</th>
|
||||||
|
<td mat-cell *matCellDef="let element">{{ element.timeline.closedOnDate | dateFormat }}</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="no-data">
|
||||||
|
<td mat-footer-cell *matFooterCellDef [attr.colspan]="closedSavingsColumns.length">
|
||||||
|
<div class="table-empty-state">
|
||||||
|
<i class="fa fa-folder-open-o table-empty-state__icon"></i>
|
||||||
|
<p class="table-empty-state__text">
|
||||||
|
{{ 'labels.messages.No Closed Recurring Deposit Accounts Available' | translate }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<tr mat-header-row *matHeaderRowDef="closedSavingsColumns"></tr>
|
||||||
|
<tr
|
||||||
|
mat-row
|
||||||
|
*matRowDef="let row; columns: closedSavingsColumns"
|
||||||
|
[routerLink]="['../', 'recurring-deposits-accounts', row.id, 'general']"
|
||||||
|
class="select-row"
|
||||||
|
></tr>
|
||||||
|
<tr
|
||||||
|
mat-footer-row
|
||||||
|
*matFooterRowDef="
|
||||||
|
(savingAccounts | accountsFilter: 'saving' : 'closed' : 'isRecurring')?.length === 0 ? ['no-data'] : []
|
||||||
"
|
"
|
||||||
></tr>
|
></tr>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -419,24 +419,3 @@ table[mat-table] {
|
|||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rd-status {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 10px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
background: #dcfce7;
|
|
||||||
color: #166534;
|
|
||||||
|
|
||||||
&--ended {
|
|
||||||
background: #e2e8f0;
|
|
||||||
color: #475569;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.rd-status__date {
|
|
||||||
margin-top: 2px;
|
|
||||||
font-size: 11px;
|
|
||||||
color: #64748b;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -187,15 +187,6 @@ export class GeneralTabComponent implements OnDestroy {
|
|||||||
'Saving Account',
|
'Saving Account',
|
||||||
'Closed Date'
|
'Closed Date'
|
||||||
];
|
];
|
||||||
/** Closed Recurring Deposit Accounts Columns - with how each one ended, for tracking. */
|
|
||||||
recurringColumns: string[] = [
|
|
||||||
'Account No',
|
|
||||||
'Saving Account',
|
|
||||||
'Status',
|
|
||||||
'Last Active',
|
|
||||||
'Balance',
|
|
||||||
'Actions'
|
|
||||||
];
|
|
||||||
/** Open Shares Accounts Columns */
|
/** Open Shares Accounts Columns */
|
||||||
openSharesColumns: string[] = [
|
openSharesColumns: string[] = [
|
||||||
'Account No',
|
'Account No',
|
||||||
@@ -484,30 +475,6 @@ export class GeneralTabComponent implements OnDestroy {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Where a recurring deposit stands - for one that has ended, how it ended. */
|
|
||||||
rdStatusLabel(account: any): string {
|
|
||||||
switch (account?.status?.code) {
|
|
||||||
case 'savingsAccountStatusType.active':
|
|
||||||
return 'Active';
|
|
||||||
case 'savingsAccountStatusType.matured':
|
|
||||||
return 'Matured (not closed)';
|
|
||||||
case 'savingsAccountStatusType.submitted.and.pending.approval':
|
|
||||||
return 'Pending Approval';
|
|
||||||
case 'savingsAccountStatusType.approved':
|
|
||||||
return 'Approved';
|
|
||||||
case 'savingsAccountStatusType.closed':
|
|
||||||
return 'Matured & Closed';
|
|
||||||
case 'savingsAccountStatusType.pre.mature.closure':
|
|
||||||
return 'Premature Closed';
|
|
||||||
case 'savingsAccountStatusType.rejected':
|
|
||||||
return 'Rejected';
|
|
||||||
case 'savingsAccountStatusType.withdrawn.by.applicant':
|
|
||||||
return 'Withdrawn by Client';
|
|
||||||
default:
|
|
||||||
return account?.status?.value ?? '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
viewAccountsLabel(closed: boolean): string {
|
viewAccountsLabel(closed: boolean): string {
|
||||||
if (closed) {
|
if (closed) {
|
||||||
return 'labels.buttons.View Active Accounts';
|
return 'labels.buttons.View Active Accounts';
|
||||||
|
|||||||
@@ -1247,7 +1247,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (!docScansLoading && !docScans.front && !docScans.back) {
|
@if (!docScansLoading && !docScans.front && !docScans.back) {
|
||||||
<div class="kyc-doc-scans__empty">No scan is on file for this document. Upload one below.</div>
|
<div class="kyc-doc-scans__empty">
|
||||||
|
No scan is on file for this document. Upload one below.
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="kyc-doc-scans__sides">
|
<div class="kyc-doc-scans__sides">
|
||||||
|
|||||||
@@ -182,10 +182,7 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
|||||||
docScans: { front: DocScanSide | null; back: DocScanSide | null } = { front: null, back: null };
|
docScans: { front: DocScanSide | null; back: DocScanSide | null } = { front: null, back: null };
|
||||||
|
|
||||||
/** The two sides, in display order, so the template loops rather than repeating itself. */
|
/** The two sides, in display order, so the template loops rather than repeating itself. */
|
||||||
readonly docScanSlots: ('front' | 'back')[] = [
|
readonly docScanSlots: ('front' | 'back')[] = ['front', 'back'];
|
||||||
'front',
|
|
||||||
'back'
|
|
||||||
];
|
|
||||||
|
|
||||||
docScanOf(slot: 'front' | 'back') {
|
docScanOf(slot: 'front' | 'back') {
|
||||||
return this.docScans[slot];
|
return this.docScans[slot];
|
||||||
@@ -477,8 +474,7 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
|||||||
adverseMediaMatch: !!v.adverseMediaMatch,
|
adverseMediaMatch: !!v.adverseMediaMatch,
|
||||||
// Blank stays null rather than becoming zero: an income nobody asked for is unknown, and a zero would read as a
|
// Blank stays null rather than becoming zero: an income nobody asked for is unknown, and a zero would read as a
|
||||||
// customer who declared no income at all.
|
// customer who declared no income at all.
|
||||||
declaredAnnualIncome:
|
declaredAnnualIncome: v.declaredAnnualIncome === '' || v.declaredAnnualIncome == null ? null : v.declaredAnnualIncome,
|
||||||
v.declaredAnnualIncome === '' || v.declaredAnnualIncome == null ? null : v.declaredAnnualIncome,
|
|
||||||
declaredAnnualTurnover:
|
declaredAnnualTurnover:
|
||||||
v.declaredAnnualTurnover === '' || v.declaredAnnualTurnover == null ? null : v.declaredAnnualTurnover
|
v.declaredAnnualTurnover === '' || v.declaredAnnualTurnover == null ? null : v.declaredAnnualTurnover
|
||||||
};
|
};
|
||||||
@@ -523,7 +519,7 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
|||||||
dateFormat: 'dd MMMM yyyy',
|
dateFormat: 'dd MMMM yyyy',
|
||||||
...this.buildDocumentFields(),
|
...this.buildDocumentFields(),
|
||||||
...this.buildRiskProfileFields(),
|
...this.buildRiskProfileFields(),
|
||||||
...this.buildExceptionFields()
|
...this.buildExceptionFields()
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!this.kycId) {
|
if (!this.kycId) {
|
||||||
@@ -770,7 +766,7 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
|||||||
riskCategory: this.kycForm.value.riskCategory || 1,
|
riskCategory: this.kycForm.value.riskCategory || 1,
|
||||||
...declarationFields,
|
...declarationFields,
|
||||||
...this.buildRiskProfileFields(),
|
...this.buildRiskProfileFields(),
|
||||||
...this.buildExceptionFields()
|
...this.buildExceptionFields()
|
||||||
};
|
};
|
||||||
|
|
||||||
this.clientsService
|
this.clientsService
|
||||||
@@ -931,7 +927,7 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
|||||||
riskCategory: this.kycForm.value.riskCategory || 1,
|
riskCategory: this.kycForm.value.riskCategory || 1,
|
||||||
...receiptFields,
|
...receiptFields,
|
||||||
...this.buildRiskProfileFields(),
|
...this.buildRiskProfileFields(),
|
||||||
...this.buildExceptionFields()
|
...this.buildExceptionFields()
|
||||||
};
|
};
|
||||||
|
|
||||||
this.clientsService
|
this.clientsService
|
||||||
@@ -1459,10 +1455,7 @@ I further confirm that:
|
|||||||
this.docScansLoading = false;
|
this.docScansLoading = false;
|
||||||
// This runs again after every replace, so the previews being thrown away are revoked
|
// This runs again after every replace, so the previews being thrown away are revoked
|
||||||
// rather than left holding their blobs.
|
// rather than left holding their blobs.
|
||||||
for (const old of [
|
for (const old of [this.docScans.front, this.docScans.back]) {
|
||||||
this.docScans.front,
|
|
||||||
this.docScans.back
|
|
||||||
]) {
|
|
||||||
if (old?.previewUrl) {
|
if (old?.previewUrl) {
|
||||||
URL.revokeObjectURL(old.previewUrl);
|
URL.revokeObjectURL(old.previewUrl);
|
||||||
}
|
}
|
||||||
@@ -1504,10 +1497,7 @@ I further confirm that:
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.docScans = sides;
|
this.docScans = sides;
|
||||||
for (const key of [
|
for (const key of ['front', 'back'] as const) {
|
||||||
'front',
|
|
||||||
'back'
|
|
||||||
] as const) {
|
|
||||||
if (sides[key]) {
|
if (sides[key]) {
|
||||||
this.loadScanPreview(sides[key]!);
|
this.loadScanPreview(sides[key]!);
|
||||||
}
|
}
|
||||||
@@ -1568,9 +1558,7 @@ I further confirm that:
|
|||||||
error: (err: any) => {
|
error: (err: any) => {
|
||||||
this.docScanUploading = null;
|
this.docScanUploading = null;
|
||||||
this.docScanError =
|
this.docScanError =
|
||||||
err?.error?.defaultUserMessage ||
|
err?.error?.defaultUserMessage || err?.error?.developerMessage || `Could not replace the ${label.toLowerCase()} side.`;
|
||||||
err?.error?.developerMessage ||
|
|
||||||
`Could not replace the ${label.toLowerCase()} side.`;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1753,10 +1741,7 @@ I further confirm that:
|
|||||||
this.destroy$.next();
|
this.destroy$.next();
|
||||||
this.destroy$.complete();
|
this.destroy$.complete();
|
||||||
// Object URLs are held by the browser until revoked, so the scan blobs would outlive the tab.
|
// Object URLs are held by the browser until revoked, so the scan blobs would outlive the tab.
|
||||||
for (const side of [
|
for (const side of [this.docScans.front, this.docScans.back]) {
|
||||||
this.docScans.front,
|
|
||||||
this.docScans.back
|
|
||||||
]) {
|
|
||||||
if (side?.previewUrl) {
|
if (side?.previewUrl) {
|
||||||
URL.revokeObjectURL(side.previewUrl);
|
URL.revokeObjectURL(side.previewUrl);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -569,19 +569,8 @@ export class ClientsService {
|
|||||||
return this.http.get(`/clients/${clientId}/documents`);
|
return this.http.get(`/clients/${clientId}/documents`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
downloadClientDocument(parentEntityId: string, documentId: string) {
|
||||||
* Fetches a client document's stored file.
|
return this.http.get(`/clients/${parentEntityId}/documents/${documentId}/attachment`, { responseType: 'blob' });
|
||||||
*
|
|
||||||
* `silent` opts the request out of ErrorHandlerInterceptor's alert. Pass it when the caller
|
|
||||||
* renders its own failure state - notably thumbnail fetches, where a `m_document` row whose file
|
|
||||||
* is no longer in the content store returns 500 and would otherwise pop a modal the user can do
|
|
||||||
* nothing about. The interceptor still rethrows, so the caller's error callback continues to run.
|
|
||||||
*/
|
|
||||||
downloadClientDocument(parentEntityId: string, documentId: string, silent = false) {
|
|
||||||
return this.http.get(`/clients/${parentEntityId}/documents/${documentId}/attachment`, {
|
|
||||||
responseType: 'blob',
|
|
||||||
...(silent ? { context: new HttpContext().set(SILENT_HTTP_ERROR, true) } : {})
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
uploadClientDocument(clientId: string, documentData: any) {
|
uploadClientDocument(clientId: string, documentData: any) {
|
||||||
@@ -1003,28 +992,12 @@ export class ClientsService {
|
|||||||
return this.http.post(`/clients/${clientId}/entityclassification/${category}`, data);
|
return this.http.post(`/clients/${clientId}/entityclassification/${category}`, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
getClientCkyc(clientId: string): Observable<any> {
|
||||||
* Uploads a single CKYC artefact against the client, using the platform's generic document
|
return this.http.get(`/clients/${clientId}/ckyc`);
|
||||||
* endpoint (DocumentApiResource, POST /v1/{entityType}/{entityId}/documents). There is no
|
|
||||||
* CKYC-specific document API on the backend - the earlier `/clients/{id}/ckyc/documents` path
|
|
||||||
* was never implemented and returned 404.
|
|
||||||
*
|
|
||||||
* The multipart body must carry `file`, `name` and `description`; the caller builds those so the
|
|
||||||
* stored document stays identifiable by CKYC artefact type. See ckyc-tab.component.ts.
|
|
||||||
*/
|
|
||||||
uploadCkycDocument(clientId: string, formData: FormData): Observable<any> {
|
|
||||||
return this.http.post(`/clients/${clientId}/documents`, formData);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
uploadCkycDocument(clientId: string, formData: FormData): Observable<any> {
|
||||||
* Replaces the content of an existing client document in place (DocumentApiResource PUT
|
return this.http.post(`/clients/${clientId}/ckyc/documents`, formData);
|
||||||
* /v1/{entityType}/{entityId}/documents/{documentId}), keeping the same document id.
|
|
||||||
*
|
|
||||||
* Used by the CKYC screen's "Upload new version" so a re-upload updates the existing artefact
|
|
||||||
* rather than adding another row. Takes the same multipart body as the create call.
|
|
||||||
*/
|
|
||||||
replaceClientDocument(clientId: string, documentId: string, formData: FormData): Observable<any> {
|
|
||||||
return this.http.put(`/clients/${clientId}/documents/${documentId}`, formData);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -377,8 +377,6 @@
|
|||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
@if (selectedTabIndex < totalTabs - 1) {
|
@if (selectedTabIndex < totalTabs - 1) {
|
||||||
<!-- Tooltip sits on a wrapper: a disabled button fires no mouse events, so it can't host it. -->
|
|
||||||
<span [matTooltip]="currentStepBlockerMessage" [matTooltipDisabled]="!currentStepBlockerMessage">
|
|
||||||
<button mat-raised-button color="primary" (click)="onNext()" [disabled]="savingClient || !currentStepValid">
|
<button mat-raised-button color="primary" (click)="onNext()" [disabled]="savingClient || !currentStepValid">
|
||||||
@if (savingClient) {
|
@if (savingClient) {
|
||||||
<mat-progress-spinner diameter="18" mode="indeterminate" class="m-r-10"></mat-progress-spinner>
|
<mat-progress-spinner diameter="18" mode="indeterminate" class="m-r-10"></mat-progress-spinner>
|
||||||
@@ -392,7 +390,6 @@
|
|||||||
<fa-icon icon="arrow-right" class="m-l-10"></fa-icon>
|
<fa-icon icon="arrow-right" class="m-l-10"></fa-icon>
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
</span>
|
|
||||||
}
|
}
|
||||||
@if (selectedTabIndex === totalTabs - 1) {
|
@if (selectedTabIndex === totalTabs - 1) {
|
||||||
<button mat-raised-button color="primary" [routerLink]="['../']">
|
<button mat-raised-button color="primary" [routerLink]="['../']">
|
||||||
|
|||||||
@@ -393,12 +393,6 @@ export class EditClientComponent {
|
|||||||
if (this.selectedTabIndex === 0 && this.clientGeneralForm?.valid && !this.contactVerificationComplete) {
|
if (this.selectedTabIndex === 0 && this.clientGeneralForm?.valid && !this.contactVerificationComplete) {
|
||||||
return 'Verify the OTP sent to both the old and new mobile number/email before saving.';
|
return 'Verify the OTP sent to both the old and new mobile number/email before saving.';
|
||||||
}
|
}
|
||||||
if (this.selectedTabIndex === 0 && this.clientGeneralForm && !this.clientGeneralForm.valid) {
|
|
||||||
const labels = this.invalidGeneralFieldLabels();
|
|
||||||
if (labels.length) {
|
|
||||||
return `Please fill/correct: ${labels.join(', ')}.`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (this.selectedTabIndex === 1) {
|
if (this.selectedTabIndex === 1) {
|
||||||
const labels = this.clientKycStep?.invalidFieldLabels || [];
|
const labels = this.clientKycStep?.invalidFieldLabels || [];
|
||||||
if (labels.length) {
|
if (labels.length) {
|
||||||
@@ -414,42 +408,6 @@ export class EditClientComponent {
|
|||||||
return 'Please fill all mandatory fields on this tab to continue.';
|
return 'Please fill all mandatory fields on this tab to continue.';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Readable names of the General-tab controls that are invalid. Older clients can be missing
|
|
||||||
* fields that are now mandatory (e.g. mobile/email), which leaves Save disabled with nothing
|
|
||||||
* on screen saying why — this feeds the button tooltip.
|
|
||||||
*/
|
|
||||||
private invalidGeneralFieldLabels(): string[] {
|
|
||||||
const labels: Record<string, string> = {
|
|
||||||
officeId: 'Office',
|
|
||||||
legalFormId: 'Legal Form',
|
|
||||||
genderId: 'Gender',
|
|
||||||
mobileNo: 'Mobile No',
|
|
||||||
alternateMobileNo: 'Alternate Mobile No',
|
|
||||||
emailAddress: 'Email',
|
|
||||||
dateOfBirth: 'Date of Birth',
|
|
||||||
submittedOnDate: 'Submitted On',
|
|
||||||
activationDate: 'Activation Date',
|
|
||||||
savingsProductId: 'Savings Product',
|
|
||||||
firstname: 'First Name',
|
|
||||||
middlename: 'Middle Name',
|
|
||||||
lastname: 'Last Name',
|
|
||||||
fullname: 'Name',
|
|
||||||
monthlyIncome: 'Monthly Income',
|
|
||||||
annualIncome: 'Annual Income',
|
|
||||||
monthlyTurnover: 'Monthly Turnover',
|
|
||||||
annualTurnover: 'Yearly Turnover',
|
|
||||||
agricultureIncome: 'Agriculture Income',
|
|
||||||
nonAgricultureIncome: 'Non-Agriculture Income',
|
|
||||||
guardianDetails: 'Guardian',
|
|
||||||
clientNonPersonDetails: 'Entity Details'
|
|
||||||
};
|
|
||||||
const controls = this.clientGeneralForm?.controls || {};
|
|
||||||
return Object.keys(controls)
|
|
||||||
.filter((name) => controls[name].invalid)
|
|
||||||
.map((name) => labels[name] || name);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whether the current tab has unsaved edits */
|
/** Whether the current tab has unsaved edits */
|
||||||
isCurrentTabEdited(): boolean {
|
isCurrentTabEdited(): boolean {
|
||||||
return this.tabEditMode[this.selectedTabIndex] === true;
|
return this.tabEditMode[this.selectedTabIndex] === true;
|
||||||
|
|||||||
+7
-1
@@ -109,7 +109,13 @@
|
|||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<input #sideInput type="file" hidden accept="image/*,application/pdf" (change)="onFilePicked($event, slot)" />
|
<input
|
||||||
|
#sideInput
|
||||||
|
type="file"
|
||||||
|
hidden
|
||||||
|
accept="image/*,application/pdf"
|
||||||
|
(change)="onFilePicked($event, slot)"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -25,41 +25,6 @@ import { environment } from '../../../environments/environment';
|
|||||||
export class CacheInterceptor implements HttpInterceptor {
|
export class CacheInterceptor implements HttpInterceptor {
|
||||||
private forceUpdate = false;
|
private forceUpdate = false;
|
||||||
|
|
||||||
/**
|
|
||||||
* Endpoints that compute a live figure from the current ledger/configuration and must never be
|
|
||||||
* served from cache. Caching them meant a report kept showing a stale response after its
|
|
||||||
* underlying GL mapping changed — the request was answered from cachedData and the backend was
|
|
||||||
* never hit again. Matched as a substring of the request URL.
|
|
||||||
*/
|
|
||||||
private static readonly NEVER_CACHE = [
|
|
||||||
'/rbireservereturn',
|
|
||||||
'/rbireserveconfig',
|
|
||||||
'/tdsconfig',
|
|
||||||
'/tds/deposit-interest',
|
|
||||||
// Declarations and challans are entered on the very screens that read them back.
|
|
||||||
'/tds/declarations',
|
|
||||||
'/tds/challans',
|
|
||||||
'/tds/deducted',
|
|
||||||
'/savingsinterestpostingreports',
|
|
||||||
// Cash in vaults and drawers changes with every transaction; a cached teller list or position
|
|
||||||
// kept showing a branch's old tellers and figures at day close.
|
|
||||||
'/tellers',
|
|
||||||
'/cashreports',
|
|
||||||
'/cashreconciliation',
|
|
||||||
'/cashsettlement',
|
|
||||||
'/cashtransfer',
|
|
||||||
'/branchday',
|
|
||||||
// A deposit's lien moves with every repayment of the loan it secures, and decides whether the
|
|
||||||
// deposit may be closed at all - a stale copy would show a released lien as still blocking.
|
|
||||||
'/liens',
|
|
||||||
// The RD Dashboard counts late installments as of today; a cached copy would hide a payment just taken.
|
|
||||||
'/recurringdepositaccounts/dashboard'
|
|
||||||
];
|
|
||||||
|
|
||||||
private isCacheable(url: string): boolean {
|
|
||||||
return !CacheInterceptor.NEVER_CACHE.some((fragment) => url.includes(fragment));
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line @angular-eslint/prefer-inject
|
// eslint-disable-next-line @angular-eslint/prefer-inject
|
||||||
constructor(private httpCacheService: HttpCacheService) {}
|
constructor(private httpCacheService: HttpCacheService) {}
|
||||||
|
|
||||||
@@ -77,7 +42,7 @@ export class CacheInterceptor implements HttpInterceptor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
|
intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
|
||||||
if (request.method !== 'GET' || !this.isCacheable(request.urlWithParams)) {
|
if (request.method !== 'GET') {
|
||||||
return next.handle(request);
|
return next.handle(request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -67,18 +67,12 @@ export class ErrorHandlerInterceptor implements HttpInterceptor {
|
|||||||
// endpoint isn't available on a given backend deployment, the client-general-step
|
// endpoint isn't available on a given backend deployment, the client-general-step
|
||||||
// component already falls back to riskRules = [] silently; no toast needed here either.
|
// component already falls back to riskRules = [] silently; no toast needed here either.
|
||||||
const isKycRiskRulesRequest = request.url.includes('/kyc/risk-rules');
|
const isKycRiskRulesRequest = request.url.includes('/kyc/risk-rules');
|
||||||
// A document's own file can be missing from the content store (a database copied between
|
|
||||||
// environments without its file store is the usual cause) even though its m_document row
|
|
||||||
// survives — the backend reports that as a 404, same as a client with no photo, and every
|
|
||||||
// caller that fetches a signature/attachment image already recovers with its own catchError.
|
|
||||||
const isDocumentAttachmentRequest = request.url.match(/\/documents\/\d+\/attachment$/);
|
|
||||||
const isSilentRequest =
|
const isSilentRequest =
|
||||||
isClientImageRequest ||
|
isClientImageRequest ||
|
||||||
isClientKycRequest ||
|
isClientKycRequest ||
|
||||||
isDirectorRequest ||
|
isDirectorRequest ||
|
||||||
isClientPendingEditRequest ||
|
isClientPendingEditRequest ||
|
||||||
isKycRiskRulesRequest ||
|
isKycRiskRulesRequest;
|
||||||
isDocumentAttachmentRequest;
|
|
||||||
|
|
||||||
// Explicit per-request opt-out (e.g. per-row enrichment fetches on a list page whose
|
// Explicit per-request opt-out (e.g. per-row enrichment fetches on a list page whose
|
||||||
// component already recovers via its own catchError) — silenced for every status, since
|
// component already recovers via its own catchError) — silenced for every status, since
|
||||||
@@ -99,8 +93,8 @@ export class ErrorHandlerInterceptor implements HttpInterceptor {
|
|||||||
throw response;
|
throw response;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolved once and emitted in one place below — see the severity decision there for which of
|
// Resolved once and emitted in one place below, so every failure here is consistently tagged
|
||||||
// these surfaces as a centred modal versus a corner toast.
|
// as an error and therefore surfaces as a centred modal rather than a corner toast.
|
||||||
let alert: { type: string; message: string } | null = null;
|
let alert: { type: string; message: string } | null = null;
|
||||||
if (status === 401 || (environment.oauth.enabled && status === 400)) {
|
if (status === 401 || (environment.oauth.enabled && status === 400)) {
|
||||||
alert = { type: 'Authentication Error', message: 'Invalid User Details. Please try again!' };
|
alert = { type: 'Authentication Error', message: 'Invalid User Details. Please try again!' };
|
||||||
@@ -125,13 +119,7 @@ export class ErrorHandlerInterceptor implements HttpInterceptor {
|
|||||||
message: errorMessage || 'This action is not allowed in the current state.'
|
message: errorMessage || 'This action is not allowed in the current state.'
|
||||||
};
|
};
|
||||||
} else if (status === 500) {
|
} else if (status === 500) {
|
||||||
// errorMessage carries the backend's actual explanation (defaultUserMessage/developerMessage)
|
alert = { type: 'Internal Server Error', message: 'Internal Server Error. Please try again later.' };
|
||||||
// when one is present; the generic sentence is only a last resort for a response with no body
|
|
||||||
// at all (a proxy timeout, a stack trace the server never wrapped into JSON).
|
|
||||||
alert = {
|
|
||||||
type: 'Internal Server Error',
|
|
||||||
message: errorMessage || 'Internal Server Error. Please try again later.'
|
|
||||||
};
|
|
||||||
} else if (status === 501) {
|
} else if (status === 501) {
|
||||||
alert = {
|
alert = {
|
||||||
type: this.translate.instant('error.resource.notImplemented.type'),
|
type: this.translate.instant('error.resource.notImplemented.type'),
|
||||||
@@ -145,17 +133,7 @@ export class ErrorHandlerInterceptor implements HttpInterceptor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (alert) {
|
if (alert) {
|
||||||
// A GET is the page reading data it did not ask the user for, in the background — a photo,
|
this.alertService.alert({ ...alert, severity: 'error' });
|
||||||
// an attachment, a notes list, a standing-instructions panel. Every one of those already has
|
|
||||||
// its own catchError fallback at the call site (see transaction-entry.component.ts and
|
|
||||||
// similar account-overview loaders), so the page keeps working regardless. Blocking the whole
|
|
||||||
// screen on a dialog the user has to click "OK" on, for data that was never more than a
|
|
||||||
// secondary panel, is the "irritate na ho" complaint this guards against — it goes to the
|
|
||||||
// corner toast instead, same place a non-error alert already goes. A write (POST/PUT/PATCH/
|
|
||||||
// DELETE) is different: that is the one action the user was waiting on, so its failure still
|
|
||||||
// needs the centred, must-acknowledge dialog.
|
|
||||||
const isBackgroundRead = request.method === 'GET' && (status === 500 || status === 501 || status === 503);
|
|
||||||
this.alertService.alert({ ...alert, severity: isBackgroundRead ? undefined : 'error' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
throw response;
|
throw response;
|
||||||
|
|||||||
@@ -65,14 +65,6 @@
|
|||||||
<span class="cashier-nav__esc-hint" (click)="onEscapeKey()"> <kbd>ESC</kbd> Show Header </span>
|
<span class="cashier-nav__esc-hint" (click)="onEscapeKey()"> <kbd>ESC</kbd> Show Header </span>
|
||||||
</nav>
|
</nav>
|
||||||
}
|
}
|
||||||
<!-- FD Dashboard fullscreen — ESC hint bar -->
|
|
||||||
@else if (currentUrl.includes('/fd-dashboard')) {
|
|
||||||
<nav class="cashier-nav cashier-nav--election">
|
|
||||||
<span class="cashier-nav__esc-hint" (click)="onEscapeKey()">
|
|
||||||
<kbd>ESC</kbd> Exit Fullscreen (Show Header & Footer)
|
|
||||||
</span>
|
|
||||||
</nav>
|
|
||||||
}
|
|
||||||
<!-- Clerk Dashboard fullscreen nav — only visible at /clerk/* -->
|
<!-- Clerk Dashboard fullscreen nav — only visible at /clerk/* -->
|
||||||
@else if (currentUrl.includes('/clerk/')) {
|
@else if (currentUrl.includes('/clerk/')) {
|
||||||
<nav class="cashier-nav cashier-nav--clerk">
|
<nav class="cashier-nav cashier-nav--clerk">
|
||||||
@@ -663,12 +655,6 @@
|
|||||||
<button mat-menu-item routerLink="/reports/savings-interest" tabindex="0">
|
<button mat-menu-item routerLink="/reports/savings-interest" tabindex="0">
|
||||||
<i class="fa fa-percentage menu-item-icon"></i> Savings Interest Posted
|
<i class="fa fa-percentage menu-item-icon"></i> Savings Interest Posted
|
||||||
</button>
|
</button>
|
||||||
<button mat-menu-item routerLink="/reports/interest-posting" tabindex="0">
|
|
||||||
<i class="fa fa-clipboard-check menu-item-icon"></i> Interest Posting Reports
|
|
||||||
</button>
|
|
||||||
<button mat-menu-item routerLink="/reports/interest-posting-summary" tabindex="0">
|
|
||||||
<i class="fa fa-table menu-item-icon"></i> Interest Posting Summary (Branch-wise)
|
|
||||||
</button>
|
|
||||||
<!--
|
<!--
|
||||||
The Principal Officer clears flagged UNSC screenings here. It is not a runnable report,
|
The Principal Officer clears flagged UNSC screenings here. It is not a runnable report,
|
||||||
so it never shows up under the report categories above and had no nav entry at all.
|
so it never shows up under the report categories above and had no nav entry at all.
|
||||||
@@ -1217,11 +1203,8 @@
|
|||||||
<button mat-menu-item tabindex="0">
|
<button mat-menu-item tabindex="0">
|
||||||
<fa-icon icon="clock" class="menu-item-icon"></fa-icon> Account Expiry
|
<fa-icon icon="clock" class="menu-item-icon"></fa-icon> Account Expiry
|
||||||
</button>
|
</button>
|
||||||
<button mat-menu-item tabindex="0" [routerLink]="['/fd-dashboard']">
|
<button mat-menu-item tabindex="0">
|
||||||
<fa-icon icon="calendar-check" class="menu-item-icon"></fa-icon> FD Dashboard
|
<fa-icon icon="file-alt" class="menu-item-icon"></fa-icon> FD Maturity Report
|
||||||
</button>
|
|
||||||
<button mat-menu-item tabindex="0" [routerLink]="['/rd-dashboard']">
|
|
||||||
<fa-icon icon="piggy-bank" class="menu-item-icon"></fa-icon> RD Dashboard
|
|
||||||
</button>
|
</button>
|
||||||
<button mat-menu-item tabindex="0">
|
<button mat-menu-item tabindex="0">
|
||||||
<fa-icon icon="chart-line" class="menu-item-icon"></fa-icon> FD Interest Report
|
<fa-icon icon="chart-line" class="menu-item-icon"></fa-icon> FD Interest Report
|
||||||
@@ -2080,35 +2063,6 @@
|
|||||||
<fa-icon icon="users" class="gl-icon" style="color: #4f46e5"></fa-icon> Members & Share Capital
|
<fa-icon icon="users" class="gl-icon" style="color: #4f46e5"></fa-icon> Members & Share Capital
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<!-- Reserve Returns — CRR and SLR, computed live from the ledger. Grouped apart from the
|
|
||||||
OSS returns because they are filed on their own cycles: Form 1 twice a month, Form 9
|
|
||||||
at month end. -->
|
|
||||||
<div class="gl-group">
|
|
||||||
<div class="gl-group-title">Reserve Returns</div>
|
|
||||||
<!-- The statutory form is a tab on Form 1 rather than an entry of its own: two menu
|
|
||||||
items for one return had a filer guessing which one they were looking at. -->
|
|
||||||
<a
|
|
||||||
class="gl-link"
|
|
||||||
[class.hidden]="!matchesRbiSearch('Form 1 CRR Cash Reserve Ratio')"
|
|
||||||
routerLink="/rbi/form1"
|
|
||||||
(click)="rbiMenuOpen = false"
|
|
||||||
>
|
|
||||||
<fa-icon icon="percentage" class="gl-icon" style="color: #b45309"></fa-icon> Form 1 — CRR (fortnightly)
|
|
||||||
</a>
|
|
||||||
<!-- Form 9 (SLR) has no entry of its own. Section 24 is settled on the statutory Form 1,
|
|
||||||
at Part C, on the same liabilities Part B charges the cash reserve on — so the SLR
|
|
||||||
position is read there rather than on a second screen that computed it from a
|
|
||||||
differently drawn base and disagreed. -->
|
|
||||||
<a
|
|
||||||
class="gl-link"
|
|
||||||
[class.hidden]="!matchesRbiSearch('Form IX R408 Return on Assets and Liabilities balance sheet')"
|
|
||||||
routerLink="/rbi/form9-return"
|
|
||||||
(click)="rbiMenuOpen = false"
|
|
||||||
>
|
|
||||||
<fa-icon icon="percentage" class="gl-icon" style="color: #b45309"></fa-icon> Form IX — Assets &
|
|
||||||
Liabilities (R408)
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<!-- CIMS / XBRL Returns -->
|
<!-- CIMS / XBRL Returns -->
|
||||||
<div class="gl-group">
|
<div class="gl-group">
|
||||||
<div class="gl-group-title">CIMS / XBRL Returns</div>
|
<div class="gl-group-title">CIMS / XBRL Returns</div>
|
||||||
|
|||||||
@@ -167,9 +167,7 @@ export class ShellComponent implements OnInit, OnDestroy {
|
|||||||
url.includes('/clerk/withdrawal-request') ||
|
url.includes('/clerk/withdrawal-request') ||
|
||||||
url.includes('/clerk/my-disbursement-requests');
|
url.includes('/clerk/my-disbursement-requests');
|
||||||
const isElectionRoute = url.includes('/election');
|
const isElectionRoute = url.includes('/election');
|
||||||
const isFdDashboardRoute = url.includes('/fd-dashboard');
|
this.hideShellLayout = isCashierOnly || isClerkRoute || isElectionRoute || this.isWithdrawalReviewUrl;
|
||||||
this.hideShellLayout =
|
|
||||||
isCashierOnly || isClerkRoute || isElectionRoute || isFdDashboardRoute || this.isWithdrawalReviewUrl;
|
|
||||||
this.cdr.detectChanges();
|
this.cdr.detectChanges();
|
||||||
};
|
};
|
||||||
this.routeSub = this.router.events.pipe(filter((e) => e instanceof NavigationEnd)).subscribe(() => checkLayout());
|
this.routeSub = this.router.events.pipe(filter((e) => e instanceof NavigationEnd)).subscribe(() => checkLayout());
|
||||||
@@ -182,7 +180,10 @@ export class ShellComponent implements OnInit, OnDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Toggles the current collapsed state of sidenav according to the emitted event. */
|
/**
|
||||||
|
* Toggles the current collapsed state of sidenav according to the emitted event.
|
||||||
|
* @param {boolean} event denotes state of sidenav
|
||||||
|
*/
|
||||||
toggleCollapse($event: boolean) {
|
toggleCollapse($event: boolean) {
|
||||||
this.sidenavCollapsed = $event;
|
this.sidenavCollapsed = $event;
|
||||||
this.cdr.detectChanges();
|
this.cdr.detectChanges();
|
||||||
@@ -203,15 +204,12 @@ export class ShellComponent implements OnInit, OnDestroy {
|
|||||||
return /\/checker-inbox-and-tasks\/(share-)?withdrawal-requests\/\d+\/review/.test(this.router.url);
|
return /\/checker-inbox-and-tasks\/(share-)?withdrawal-requests\/\d+\/review/.test(this.router.url);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** ESC key — exit fullscreen at Clerk Dashboard / Election / FD Dashboard / review screen, restoring full shell header. */
|
/** ESC key — exit fullscreen at Clerk Dashboard / Election / review screen, restoring full shell header. */
|
||||||
@HostListener('document:keydown.escape')
|
@HostListener('document:keydown.escape')
|
||||||
onEscapeKey(): void {
|
onEscapeKey(): void {
|
||||||
if (
|
if (
|
||||||
this.hideShellLayout &&
|
this.hideShellLayout &&
|
||||||
(this.router.url.includes('/clerk/') ||
|
(this.router.url.includes('/clerk/') || this.router.url.includes('/election') || this.isWithdrawalReviewUrl)
|
||||||
this.router.url.includes('/election') ||
|
|
||||||
this.router.url.includes('/fd-dashboard') ||
|
|
||||||
this.isWithdrawalReviewUrl)
|
|
||||||
) {
|
) {
|
||||||
this.hideShellLayout = false;
|
this.hideShellLayout = false;
|
||||||
this.cdr.detectChanges();
|
this.cdr.detectChanges();
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
<div class="t-live"></div>
|
<div class="t-live"></div>
|
||||||
LIVE
|
LIVE
|
||||||
</div>
|
</div>
|
||||||
<div class="t-chip">Branch: <strong>{{ officeLabel }}</strong></div>
|
<div class="t-chip">Branch: <strong>HO</strong></div>
|
||||||
<div class="t-chip">
|
<div class="t-chip">
|
||||||
Date: <strong>{{ today | date: 'dd MMM yyyy' }}</strong>
|
Date: <strong>{{ today | date: 'dd MMM yyyy' }}</strong>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ import { MatIcon } from '@angular/material/icon';
|
|||||||
import { NotificationsTrayComponent as NotificationsTrayComponent_1 } from '../../../shared/notifications-tray/notifications-tray.component';
|
import { NotificationsTrayComponent as NotificationsTrayComponent_1 } from '../../../shared/notifications-tray/notifications-tray.component';
|
||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
import { DocumentationLinksService } from 'app/shared/services/documentation-links.service';
|
import { DocumentationLinksService } from 'app/shared/services/documentation-links.service';
|
||||||
import { businessToday } from 'app/core/utils/business-today';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Toolbar component.
|
* Toolbar component.
|
||||||
@@ -93,14 +92,6 @@ export class ToolbarComponent implements OnInit, AfterViewInit, AfterContentChec
|
|||||||
@ViewChild('templateAppMenu') templateAppMenu: TemplateRef<any>;
|
@ViewChild('templateAppMenu') templateAppMenu: TemplateRef<any>;
|
||||||
@ViewChild('notificationsTray') notificationsTray: NotificationsTrayComponent;
|
@ViewChild('notificationsTray') notificationsTray: NotificationsTrayComponent;
|
||||||
|
|
||||||
/** The signed-in user's office — this chip read "HO" for every login, branch staff included. */
|
|
||||||
get officeLabel(): string {
|
|
||||||
if (this.authenticationService.isHeadOfficeUser()) {
|
|
||||||
return 'HO';
|
|
||||||
}
|
|
||||||
return this.authenticationService.getCredentials()?.officeName || '—';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Subscription to breakpoint observer for handset. */
|
/** Subscription to breakpoint observer for handset. */
|
||||||
isHandset$: Observable<boolean> = this.breakpointObserver
|
isHandset$: Observable<boolean> = this.breakpointObserver
|
||||||
.observe(Breakpoints.Handset)
|
.observe(Breakpoints.Handset)
|
||||||
@@ -109,10 +100,8 @@ export class ToolbarComponent implements OnInit, AfterViewInit, AfterContentChec
|
|||||||
/** Sets the initial state of sidenav as collapsed. Not collapsed if false. */
|
/** Sets the initial state of sidenav as collapsed. Not collapsed if false. */
|
||||||
sidenavCollapsed = true;
|
sidenavCollapsed = true;
|
||||||
|
|
||||||
/** The bank's "today" for the top bar — the business date when enabled, else the server date. */
|
/** Current date for top bar display */
|
||||||
get today(): Date {
|
today = new Date();
|
||||||
return businessToday();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Last login time display */
|
/** Last login time display */
|
||||||
lastLoginTime = this.formatLastLogin();
|
lastLoginTime = this.formatLastLogin();
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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/.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The bank's "today": the business date while `enable-business-date` is on, the server's own date
|
|
||||||
* while it is off. The server decides which (GET /branchday/today); the web app stores the answer
|
|
||||||
* in `mifosXServerDate` at login and whenever the business date changes. Before that has happened
|
|
||||||
* the browser clock is the only date there is, so it is the fallback.
|
|
||||||
*/
|
|
||||||
export function businessToday(): Date {
|
|
||||||
const stored = localStorage.getItem('mifosXServerDate');
|
|
||||||
const match = stored ? /^(\d{4})-(\d{2})-(\d{2})$/.exec(stored) : null;
|
|
||||||
if (!match) {
|
|
||||||
return new Date();
|
|
||||||
}
|
|
||||||
// Built from parts so it is local midnight — new Date('yyyy-MM-dd') would be UTC midnight and
|
|
||||||
// land on the previous day in any timezone behind UTC.
|
|
||||||
return new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** {@link businessToday} as yyyy-MM-dd. */
|
|
||||||
export function businessTodayIso(): string {
|
|
||||||
const d = businessToday();
|
|
||||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
||||||
}
|
|
||||||
@@ -8,10 +8,7 @@
|
|||||||
|
|
||||||
<div class="cur-view">
|
<div class="cur-view">
|
||||||
<!-- ─── Dormancy risk alert (only when subStatus has left Active) ──── -->
|
<!-- ─── Dormancy risk alert (only when subStatus has left Active) ──── -->
|
||||||
<!-- Shown only when the account's product has Dormancy Alert = Yes -->
|
<mifosx-dormancy-alert-panel [accountData]="currentAccountData"></mifosx-dormancy-alert-panel>
|
||||||
@if (currentAccountData?.dormancyAlert) {
|
|
||||||
<mifosx-dormancy-alert-panel [accountData]="currentAccountData"></mifosx-dormancy-alert-panel>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- ─── Gradient hero header ─────────────────────────────────────── -->
|
<!-- ─── Gradient hero header ─────────────────────────────────────── -->
|
||||||
<header class="cur-hero">
|
<header class="cur-hero">
|
||||||
|
|||||||
+153
-71
@@ -59,7 +59,16 @@
|
|||||||
<span class="nx-mono">[{{ currentAccountData.currency.code }}]</span>
|
<span class="nx-mono">[{{ currentAccountData.currency.code }}]</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- A current account earns no interest, so no interest rate is shown here; OD interest is under Overdraft. -->
|
<div class="nx-row">
|
||||||
|
<span class="nx-row__label">{{ 'labels.inputs.Nominal Interest Rate' | translate }}</span>
|
||||||
|
<span class="nx-row__value nx-row__value--amount">
|
||||||
|
@if (currentAccountData.nominalAnnualInterestRate) {
|
||||||
|
{{ currentAccountData.nominalAnnualInterestRate | formatNumber }} %
|
||||||
|
} @else {
|
||||||
|
0 %
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<div class="nx-row">
|
<div class="nx-row">
|
||||||
<span class="nx-row__label">{{ 'labels.inputs.Field Officer' | translate }}</span>
|
<span class="nx-row__label">{{ 'labels.inputs.Field Officer' | translate }}</span>
|
||||||
@@ -93,17 +102,17 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if (currentAccountData.summary.totalWithdrawals) {
|
@if (currentAccountData.summary.totalInterestEarned >= 0) {
|
||||||
<div class="nx-row">
|
<div class="nx-row">
|
||||||
<span class="nx-row__label">{{ 'labels.inputs.Total Withdrawls' | translate }}</span>
|
<span class="nx-row__label">{{ 'labels.inputs.Total Interest Earned' | translate }}</span>
|
||||||
<span class="nx-row__value nx-row__value--amount nx-row__value--negative">
|
<span class="nx-row__value nx-row__value--amount">
|
||||||
{{
|
{{
|
||||||
currentAccountData.summary.totalWithdrawals | currency: currency.code : 'symbol-narrow' : '1.2-2'
|
currentAccountData.summary.totalInterestEarned | currency: currency.code : 'symbol-narrow' : '1.2-2'
|
||||||
}}
|
}}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if (!currentAccountData.summary.totalDeposits && !currentAccountData.summary.totalWithdrawals) {
|
@if (!currentAccountData.summary.totalDeposits && !(currentAccountData.summary.totalInterestEarned >= 0)) {
|
||||||
<div class="nx-empty">
|
<div class="nx-empty">
|
||||||
<fa-icon icon="inbox" class="nx-empty__ico"></fa-icon>
|
<fa-icon icon="inbox" class="nx-empty__ico"></fa-icon>
|
||||||
<span>No activity yet.</span>
|
<span>No activity yet.</span>
|
||||||
@@ -123,7 +132,16 @@
|
|||||||
<h3 class="nx-card__title">{{ 'labels.heading.Account Summary' | translate }}</h3>
|
<h3 class="nx-card__title">{{ 'labels.heading.Account Summary' | translate }}</h3>
|
||||||
</header>
|
</header>
|
||||||
<div class="nx-card__body">
|
<div class="nx-card__body">
|
||||||
<!-- A current account earns no interest: no interest earned/posted rows. OD interest has its own card. -->
|
@if (currentAccountData.summary.totalWithdrawals) {
|
||||||
|
<div class="nx-row">
|
||||||
|
<span class="nx-row__label">{{ 'labels.inputs.Total Withdrawls' | translate }}</span>
|
||||||
|
<span class="nx-row__value nx-row__value--amount nx-row__value--negative">
|
||||||
|
{{
|
||||||
|
currentAccountData.summary.totalWithdrawals | currency: currency.code : 'symbol-narrow' : '1.2-2'
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@if (currentAccountData.summary.totalWithdrawalFees) {
|
@if (currentAccountData.summary.totalWithdrawalFees) {
|
||||||
<div class="nx-row">
|
<div class="nx-row">
|
||||||
<span class="nx-row__label">{{ 'labels.inputs.Withdrawals Fees' | translate }}</span>
|
<span class="nx-row__label">{{ 'labels.inputs.Withdrawals Fees' | translate }}</span>
|
||||||
@@ -142,6 +160,89 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
@if (currentAccountData.summary.totalInterestEarned >= 0) {
|
||||||
|
<div class="nx-row">
|
||||||
|
<span class="nx-row__label">{{ 'labels.inputs.Interest Earned' | translate }}</span>
|
||||||
|
<span class="nx-row__value nx-row__value--amount">
|
||||||
|
{{
|
||||||
|
currentAccountData.summary.totalInterestEarned | currency: currency.code : 'symbol-narrow' : '1.2-2'
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (currentAccountData.summary.totalInterestPosted) {
|
||||||
|
<div class="nx-row">
|
||||||
|
<span class="nx-row__label">{{ 'labels.inputs.Interest Posted' | translate }}</span>
|
||||||
|
<span class="nx-row__value nx-row__value--amount">
|
||||||
|
{{
|
||||||
|
currentAccountData.summary.totalInterestPosted | currency: currency.code : 'symbol-narrow' : '1.2-2'
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (currentAccountData.summary.interestNotPosted >= 0) {
|
||||||
|
<div class="nx-row">
|
||||||
|
<span class="nx-row__label">{{ 'labels.inputs.Interest Earned Not Posted' | translate }}</span>
|
||||||
|
<span class="nx-row__value nx-row__value--amount">
|
||||||
|
{{
|
||||||
|
currentAccountData.summary.interestNotPosted | currency: currency.code : 'symbol-narrow' : '1.2-2'
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (currentAccountData.summary.totalOverdraftInterestDerived) {
|
||||||
|
<div class="nx-row">
|
||||||
|
<span class="nx-row__label">{{ 'labels.inputs.Interest On Overdraft' | translate }}</span>
|
||||||
|
<span class="nx-row__value nx-row__value--amount">
|
||||||
|
{{
|
||||||
|
currentAccountData.summary.totalOverdraftInterestDerived
|
||||||
|
| currency: currency.code : 'symbol-narrow' : '1.2-2'
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (currentAccountData.summary.interestNotPosted < 0) {
|
||||||
|
<div class="nx-row">
|
||||||
|
<span class="nx-row__label">{{ 'labels.inputs.Overdraft Interest Not Posted' | translate }}</span>
|
||||||
|
<span class="nx-row__value nx-row__value--amount">
|
||||||
|
{{
|
||||||
|
currentAccountData.summary.interestNotPosted | currency: currency.code : 'symbol-narrow' : '1.2-2'
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (currentAccountData.nominalAnnualInterestRate) {
|
||||||
|
<div class="nx-row">
|
||||||
|
<span class="nx-row__label">{{ 'labels.inputs.Nominal Interest Rate' | translate }}</span>
|
||||||
|
<span class="nx-row__value nx-row__value--amount">
|
||||||
|
{{ currentAccountData.nominalAnnualInterestRate | formatNumber }} %
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="nx-row">
|
||||||
|
<span class="nx-row__label">{{ 'labels.inputs.Interest Compounding Period' | translate }}</span>
|
||||||
|
<span class="nx-row__value">
|
||||||
|
{{ currentAccountData.interestCompoundingPeriodType.value | translateKey: 'catalogs' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="nx-row">
|
||||||
|
<span class="nx-row__label">{{ 'labels.inputs.Interest Posting Period' | translate }}</span>
|
||||||
|
<span class="nx-row__value">
|
||||||
|
{{ currentAccountData.interestPostingPeriodType.value | translateKey: 'catalogs' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="nx-row">
|
||||||
|
<span class="nx-row__label">{{ 'labels.inputs.Interest Calculated Using' | translate }}</span>
|
||||||
|
<span class="nx-row__value">
|
||||||
|
{{ currentAccountData.interestCalculationType.value | translateKey: 'catalogs' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="nx-row">
|
||||||
|
<span class="nx-row__label">{{ 'labels.inputs.Days in Year' | translate }}</span>
|
||||||
|
<span class="nx-row__value">
|
||||||
|
{{ currentAccountData.interestCalculationDaysInYearType.value | translateKey: 'catalogs' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
@if (currentAccountData.withdrawalFee) {
|
@if (currentAccountData.withdrawalFee) {
|
||||||
<div class="nx-row">
|
<div class="nx-row">
|
||||||
<span class="nx-row__label">{{ 'labels.inputs.Withdrawal Fee' | translate }}</span>
|
<span class="nx-row__label">{{ 'labels.inputs.Withdrawal Fee' | translate }}</span>
|
||||||
@@ -188,6 +289,37 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
@if (currentAccountData.allowOverdraft) {
|
||||||
|
<div class="nx-row">
|
||||||
|
<span class="nx-row__label">{{ 'labels.inputs.Overdraft Limit' | translate }}</span>
|
||||||
|
<span class="nx-row__value nx-row__value--amount">
|
||||||
|
{{ currentAccountData.overdraftLimit | currency: currency.code : 'symbol-narrow' : '1.2-2' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (currentAccountData.allowOverdraft) {
|
||||||
|
<div class="nx-row">
|
||||||
|
<span class="nx-row__label">
|
||||||
|
{{ 'labels.inputs.Minimum Overdraft Required for Interest Calculation' | translate }}
|
||||||
|
</span>
|
||||||
|
<span class="nx-row__value">
|
||||||
|
{{
|
||||||
|
currentAccountData.minOverdraftForInterestCalculation
|
||||||
|
| currency: currency.code : 'symbol-narrow' : '1.2-2'
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (currentAccountData.minBalanceForInterestCalculation) {
|
||||||
|
<div class="nx-row">
|
||||||
|
<span class="nx-row__label">
|
||||||
|
{{ 'labels.inputs.Min Balance Required for Interest Calculation' | translate }}
|
||||||
|
</span>
|
||||||
|
<span class="nx-row__value">
|
||||||
|
{{ currentAccountData.minBalanceForInterestCalculation | formatNumber }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@if (currentAccountData.minRequiredBalance) {
|
@if (currentAccountData.minRequiredBalance) {
|
||||||
<div class="nx-row">
|
<div class="nx-row">
|
||||||
<span class="nx-row__label">{{ 'labels.inputs.Minimum Required Balance' | translate }}</span>
|
<span class="nx-row__label">{{ 'labels.inputs.Minimum Required Balance' | translate }}</span>
|
||||||
@@ -200,6 +332,14 @@
|
|||||||
<span class="nx-row__value">{{ currentAccountData.enforceMinRequiredBalance | yesNo }}</span>
|
<span class="nx-row__value">{{ currentAccountData.enforceMinRequiredBalance | yesNo }}</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
@if (currentAccountData.summary.lastInterestCalculationDate) {
|
||||||
|
<div class="nx-row">
|
||||||
|
<span class="nx-row__label">{{ 'labels.inputs.Interest Recalculation Date' | translate }}</span>
|
||||||
|
<span class="nx-row__value">
|
||||||
|
{{ currentAccountData.summary.lastInterestCalculationDate | dateFormat }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@if (currentAccountData.onHoldFunds) {
|
@if (currentAccountData.onHoldFunds) {
|
||||||
<div class="nx-row">
|
<div class="nx-row">
|
||||||
<span class="nx-row__label">{{ 'labels.inputs.On Hold Funds' | translate }}</span>
|
<span class="nx-row__label">{{ 'labels.inputs.On Hold Funds' | translate }}</span>
|
||||||
@@ -210,72 +350,14 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
@if (currentAccountData.withHoldTax) {
|
||||||
|
<div class="nx-row">
|
||||||
|
<span class="nx-row__label">{{ 'labels.inputs.Withhold Tax Group' | translate }}</span>
|
||||||
|
<span class="nx-row__value">{{ currentAccountData.taxGroup.name }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Overdraft: the only interest on a current account is what the customer pays on an OD. -->
|
|
||||||
@if (currentAccountData.allowOverdraft) {
|
|
||||||
<section class="nx-card">
|
|
||||||
<header class="nx-card__head">
|
|
||||||
<fa-icon icon="hand-holding-usd" class="nx-card__ico"></fa-icon>
|
|
||||||
<h3 class="nx-card__title">Overdraft (OD)</h3>
|
|
||||||
</header>
|
|
||||||
<div class="nx-card__body">
|
|
||||||
<div class="nx-row">
|
|
||||||
<span class="nx-row__label">Maximum OD Limit (per product)</span>
|
|
||||||
<span class="nx-row__value nx-row__value--amount">
|
|
||||||
{{ currentAccountData.overdraftLimit | currency: currency.code : 'symbol-narrow' : '1.2-2' }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
@if (currentAccountData.nominalAnnualInterestRateOverdraft) {
|
|
||||||
<div class="nx-row">
|
|
||||||
<span class="nx-row__label">OD Interest Rate</span>
|
|
||||||
<span class="nx-row__value">
|
|
||||||
{{ currentAccountData.nominalAnnualInterestRateOverdraft | formatNumber }} % p.a.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@if (currentAccountData.minOverdraftForInterestCalculation) {
|
|
||||||
<div class="nx-row">
|
|
||||||
<span class="nx-row__label">Minimum OD for Interest</span>
|
|
||||||
<span class="nx-row__value">
|
|
||||||
{{
|
|
||||||
currentAccountData.minOverdraftForInterestCalculation
|
|
||||||
| currency: currency.code : 'symbol-narrow' : '1.2-2'
|
|
||||||
}}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
<div class="nx-row">
|
|
||||||
<span class="nx-row__label">OD Interest Posting</span>
|
|
||||||
<span class="nx-row__value">
|
|
||||||
{{ currentAccountData.interestPostingPeriodType?.value | translateKey: 'catalogs' }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="nx-row">
|
|
||||||
<span class="nx-row__label">{{ 'labels.inputs.Days in Year' | translate }}</span>
|
|
||||||
<span class="nx-row__value">
|
|
||||||
{{ currentAccountData.interestCalculationDaysInYearType?.value | translateKey: 'catalogs' }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
@if (currentAccountData.validForPeriod) {
|
|
||||||
<div class="nx-row">
|
|
||||||
<span class="nx-row__label">Sanction Validity</span>
|
|
||||||
<span class="nx-row__value">{{ currentAccountData.validForPeriod }}</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
<div class="nx-row">
|
|
||||||
<span class="nx-row__label">OD Interest Charged</span>
|
|
||||||
<span class="nx-row__value nx-row__value--amount">
|
|
||||||
{{
|
|
||||||
currentAccountData.summary.totalOverdraftInterestDerived || 0
|
|
||||||
| currency: currency.code : 'symbol-narrow' : '1.2-2'
|
|
||||||
}}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
}
|
|
||||||
} @else {
|
} @else {
|
||||||
<section class="nx-card">
|
<section class="nx-card">
|
||||||
<header class="nx-card__head">
|
<header class="nx-card__head">
|
||||||
|
|||||||
+2
-21
@@ -54,8 +54,8 @@
|
|||||||
<mat-icon>account_balance</mat-icon>
|
<mat-icon>account_balance</mat-icon>
|
||||||
</div>
|
</div>
|
||||||
<div class="bank-name-block">
|
<div class="bank-name-block">
|
||||||
<div class="bank-title">{{ bankName }}</div>
|
<div class="bank-title">LincPay Co-operative Bank Ltd.</div>
|
||||||
<div class="bank-subtitle">{{ bankSubtitle }}</div>
|
<div class="bank-subtitle">Current Account Statement</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="statement-label">
|
<div class="statement-label">
|
||||||
@@ -64,25 +64,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Branch line — IFSC/MICR are how a UCB customer identifies which branch issued this, and are
|
|
||||||
needed for NEFT/RTGS/cheque printing, so a real passbook always carries them. -->
|
|
||||||
@if (branchName || ifscCode || micrCode) {
|
|
||||||
<div class="branch-line">
|
|
||||||
@if (branchName) {
|
|
||||||
<span>{{ branchName }} Branch</span>
|
|
||||||
}
|
|
||||||
@if (branchAddress) {
|
|
||||||
<span>{{ branchAddress }}</span>
|
|
||||||
}
|
|
||||||
@if (ifscCode) {
|
|
||||||
<span>IFSC: {{ ifscCode }}</span>
|
|
||||||
}
|
|
||||||
@if (micrCode) {
|
|
||||||
<span>MICR: {{ micrCode }}</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- Account Details -->
|
<!-- Account Details -->
|
||||||
<div class="account-details-band">
|
<div class="account-details-band">
|
||||||
<div class="account-details-grid">
|
<div class="account-details-grid">
|
||||||
|
|||||||
-12
@@ -127,18 +127,6 @@ $header-text: #fff;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Branch Identification Line ──────────────────────────────────────────────
|
|
||||||
.branch-line {
|
|
||||||
background: #e8edf5;
|
|
||||||
border-bottom: 1px solid $border-color;
|
|
||||||
padding: 6px 24px;
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 4px 18px;
|
|
||||||
font-size: 11px;
|
|
||||||
color: #4a5a7a;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Account Details Band ────────────────────────────────────────────────────
|
// ─── Account Details Band ────────────────────────────────────────────────────
|
||||||
.account-details-band {
|
.account-details-band {
|
||||||
background: #f0f3f9;
|
background: #f0f3f9;
|
||||||
|
|||||||
+1
-53
@@ -11,12 +11,11 @@ import { Component, OnInit, OnDestroy, inject, ElementRef, ViewChild } from '@an
|
|||||||
import { ActivatedRoute } from '@angular/router';
|
import { ActivatedRoute } from '@angular/router';
|
||||||
import { UntypedFormBuilder } from '@angular/forms';
|
import { UntypedFormBuilder } from '@angular/forms';
|
||||||
import { CurrencyPipe } from '@angular/common';
|
import { CurrencyPipe } from '@angular/common';
|
||||||
import { Subject, takeUntil, catchError, forkJoin, of } from 'rxjs';
|
import { Subject, takeUntil } from 'rxjs';
|
||||||
|
|
||||||
/** Custom Services */
|
/** Custom Services */
|
||||||
import { SettingsService } from 'app/settings/settings.service';
|
import { SettingsService } from 'app/settings/settings.service';
|
||||||
import { Dates } from 'app/core/utils/dates';
|
import { Dates } from 'app/core/utils/dates';
|
||||||
import { OrganizationService } from '../../../organization/organization.service';
|
|
||||||
import { Currency } from 'app/shared/models/general.model';
|
import { Currency } from 'app/shared/models/general.model';
|
||||||
import {
|
import {
|
||||||
CurrentAccountTransaction,
|
CurrentAccountTransaction,
|
||||||
@@ -45,7 +44,6 @@ export class PassbookTabComponent implements OnInit, OnDestroy {
|
|||||||
private formBuilder = inject(UntypedFormBuilder);
|
private formBuilder = inject(UntypedFormBuilder);
|
||||||
private settingsService = inject(SettingsService);
|
private settingsService = inject(SettingsService);
|
||||||
private dateUtils = inject(Dates);
|
private dateUtils = inject(Dates);
|
||||||
private organizationService = inject(OrganizationService);
|
|
||||||
|
|
||||||
private destroy$ = new Subject<void>();
|
private destroy$ = new Subject<void>();
|
||||||
|
|
||||||
@@ -62,17 +60,6 @@ export class PassbookTabComponent implements OnInit, OnDestroy {
|
|||||||
availableBalance: number = 0;
|
availableBalance: number = 0;
|
||||||
activatedOnDate: any = null;
|
activatedOnDate: any = null;
|
||||||
|
|
||||||
/** Bank/branch letterhead — real values from Institution Profile + Branch Profile (Organization
|
|
||||||
* module) rather than a hardcoded name, so a printed passbook actually identifies which bank and
|
|
||||||
* branch issued it, the way a real UCB's core banking system does. Falls back to a generic label
|
|
||||||
* if the profile was never filled in, rather than showing a fabricated bank name. */
|
|
||||||
bankName = 'Bank';
|
|
||||||
bankSubtitle = 'Current Account Statement';
|
|
||||||
branchName = '';
|
|
||||||
branchAddress = '';
|
|
||||||
ifscCode = '';
|
|
||||||
micrCode = '';
|
|
||||||
|
|
||||||
/** Transactions */
|
/** Transactions */
|
||||||
allTransactions: CurrentAccountTransaction[] = [];
|
allTransactions: CurrentAccountTransaction[] = [];
|
||||||
filteredTransactions: CurrentAccountTransaction[] = [];
|
filteredTransactions: CurrentAccountTransaction[] = [];
|
||||||
@@ -105,33 +92,6 @@ export class PassbookTabComponent implements OnInit, OnDestroy {
|
|||||||
fromDate: [''],
|
fromDate: [''],
|
||||||
toDate: ['']
|
toDate: ['']
|
||||||
});
|
});
|
||||||
|
|
||||||
this.loadLetterhead();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Institution Profile is the bank's own single record; Branch Profile is per-office. There is no
|
|
||||||
* office id on the current account data reaching this tab, and this deployment runs a single
|
|
||||||
* branch, so the first branch profile on record is the correct one here — a genuinely
|
|
||||||
* multi-branch deployment would need the account's own office id threaded through instead. */
|
|
||||||
private loadLetterhead(): void {
|
|
||||||
forkJoin({
|
|
||||||
institution: this.organizationService.getInstitutionProfile().pipe(catchError(() => of(null))),
|
|
||||||
branches: this.organizationService.getBranchProfiles().pipe(catchError(() => of([])))
|
|
||||||
}).subscribe(({ institution, branches }: any) => {
|
|
||||||
if (institution) {
|
|
||||||
this.bankName = institution.passbookHeaderName || institution.bankName || institution.legalName || 'Bank';
|
|
||||||
this.bankSubtitle = institution.licenseNumber
|
|
||||||
? `Lic. No. ${institution.licenseNumber}`
|
|
||||||
: 'Current Account Statement';
|
|
||||||
}
|
|
||||||
const branch = Array.isArray(branches) ? branches[0] : null;
|
|
||||||
if (branch) {
|
|
||||||
this.branchName = branch.officeName || '';
|
|
||||||
this.branchAddress = branch.address || '';
|
|
||||||
this.ifscCode = branch.ifscCode || '';
|
|
||||||
this.micrCode = branch.micrCode || '';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private computeRunningBalances(transactions: CurrentAccountTransaction[]): CurrentAccountTransaction[] {
|
private computeRunningBalances(transactions: CurrentAccountTransaction[]): CurrentAccountTransaction[] {
|
||||||
@@ -319,18 +279,6 @@ export class PassbookTabComponent implements OnInit, OnDestroy {
|
|||||||
margin-top: 3px;
|
margin-top: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Branch identification line */
|
|
||||||
.branch-line {
|
|
||||||
background: #e8edf5;
|
|
||||||
border-bottom: 1px solid #d0d7e3;
|
|
||||||
padding: 5px 20px;
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 4px 16px;
|
|
||||||
font-size: 9.5px;
|
|
||||||
color: #4a5a7a;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Account details band */
|
/* Account details band */
|
||||||
.account-details-band {
|
.account-details-band {
|
||||||
background: #f0f3f9;
|
background: #f0f3f9;
|
||||||
|
|||||||
+7
-15
@@ -334,18 +334,17 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
|
|||||||
const frequencyId = this.resolveFrequencyId(this.tenurePeriodUnit);
|
const frequencyId = this.resolveFrequencyId(this.tenurePeriodUnit);
|
||||||
|
|
||||||
if (this.principalAmount && this.principalAmount > 0) {
|
if (this.principalAmount && this.principalAmount > 0) {
|
||||||
// Years and Months convert exactly; a Days tenure is sent as days and never converted.
|
// Convert tenure to months for backend API. A Days tenure under 30 days rounds down to 0
|
||||||
|
// months, which the backend rejects -- send it as tenureInDays instead in that case.
|
||||||
let tenureInMonths: number | undefined;
|
let tenureInMonths: number | undefined;
|
||||||
let tenureInDays: number | undefined;
|
let tenureInDays: number | undefined;
|
||||||
if (this.tenurePeriodUnit === 'Years') {
|
if (this.tenurePeriodUnit === 'Years') {
|
||||||
tenureInMonths = this.tenureValue * 12;
|
tenureInMonths = this.tenureValue * 12;
|
||||||
} else if (this.tenurePeriodUnit === 'Months') {
|
} else if (this.tenurePeriodUnit === 'Months') {
|
||||||
tenureInMonths = this.tenureValue;
|
tenureInMonths = this.tenureValue;
|
||||||
|
} else if (enteredDays >= 30) {
|
||||||
|
tenureInMonths = Math.floor(enteredDays / 30);
|
||||||
} else {
|
} else {
|
||||||
// Sent as days, whatever the count. Converting to whole months silently dropped the
|
|
||||||
// remainder — a 200-day deposit quoted as six months lost 20 days of interest, and the
|
|
||||||
// screen still said "200 Days" above the figure. The backend takes days directly and
|
|
||||||
// works the calendar out itself.
|
|
||||||
tenureInDays = enteredDays;
|
tenureInDays = enteredDays;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -424,17 +423,10 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
|
|||||||
} else {
|
} else {
|
||||||
const n = 12 / compMonths;
|
const n = 12 / compMonths;
|
||||||
const effectiveMonths = tenureInMonths ?? enteredDays / 30;
|
const effectiveMonths = tenureInMonths ?? enteredDays / 30;
|
||||||
|
const totalCompoundings = Math.floor(effectiveMonths / compMonths);
|
||||||
const rPerCompounding = rate / (n * 100);
|
const rPerCompounding = rate / (n * 100);
|
||||||
// Whole compounding periods, then simple interest on the days left over. Flooring the
|
result.maturityAmount =
|
||||||
// periods and stopping there dropped the remainder entirely: a 200-day deposit
|
Math.round(this.principalAmount * Math.pow(1 + rPerCompounding, totalCompoundings) * 100) / 100;
|
||||||
// compounding monthly quoted only six months of interest and lost the other twenty
|
|
||||||
// days, which is the depositor's money.
|
|
||||||
const wholePeriods = Math.floor(effectiveMonths / compMonths);
|
|
||||||
const compounded = this.principalAmount * Math.pow(1 + rPerCompounding, wholePeriods);
|
|
||||||
const leftoverDays = enteredDays - wholePeriods * compMonths * 30;
|
|
||||||
const leftoverInterest =
|
|
||||||
leftoverDays > 0 ? (compounded * rate * leftoverDays) / (100 * daysInYearVal) : 0;
|
|
||||||
result.maturityAmount = Math.round((compounded + leftoverInterest) * 100) / 100;
|
|
||||||
}
|
}
|
||||||
result.interestEarned = Math.round((result.maturityAmount - this.principalAmount) * 100) / 100;
|
result.interestEarned = Math.round((result.maturityAmount - this.principalAmount) * 100) / 100;
|
||||||
this.matchResult = { ...result };
|
this.matchResult = { ...result };
|
||||||
|
|||||||
+5
-19
@@ -7,29 +7,17 @@
|
|||||||
-->
|
-->
|
||||||
|
|
||||||
<form [formGroup]="closureSettingsForm" class="layout-row-wrap gap-2px responsive-column">
|
<form [formGroup]="closureSettingsForm" class="layout-row-wrap gap-2px responsive-column">
|
||||||
<h4 class="mat-h4 flex-98">{{ 'labels.heading.Maturity Instructions' | translate }}</h4>
|
<h4 class="mat-h4 flex-98">{{ 'labels.heading.For Pre-mature closure' | translate }}</h4>
|
||||||
|
|
||||||
<mat-form-field class="flex-48">
|
<mat-form-field class="flex-48">
|
||||||
<mat-label>{{ 'labels.inputs.Maturity Instructions' | translate }}</mat-label>
|
<mat-label>{{ 'labels.inputs.Maturity Instructions' | translate }}</mat-label>
|
||||||
<mat-select formControlName="maturityInstructionId">
|
<mat-select formControlName="maturityInstructionId">
|
||||||
<mat-option [value]="''">-- Not selected (decide later via FD Dashboard) --</mat-option>
|
|
||||||
@for (maturityInstruction of maturityInstructionOptions; track maturityInstruction) {
|
@for (maturityInstruction of maturityInstructionOptions; track maturityInstruction) {
|
||||||
<mat-option [value]="maturityInstruction.id">
|
<mat-option [value]="maturityInstruction.id">
|
||||||
{{ maturityInstruction.value | translateKey: 'catalogs' }}
|
{{ maturityInstruction.value | translateKey: 'catalogs' }}
|
||||||
</mat-option>
|
</mat-option>
|
||||||
}
|
}
|
||||||
</mat-select>
|
</mat-select>
|
||||||
@if (closureSettingsForm.value.maturityInstructionId) {
|
|
||||||
<button
|
|
||||||
mat-icon-button
|
|
||||||
matSuffix
|
|
||||||
type="button"
|
|
||||||
title="Clear -- decide later via FD Dashboard"
|
|
||||||
(click)="$event.stopPropagation(); closureSettingsForm.get('maturityInstructionId')?.setValue('')"
|
|
||||||
>
|
|
||||||
<mat-icon>close</mat-icon>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
|
|
||||||
@if (closureSettingsForm.contains('transferToSavingsId')) {
|
@if (closureSettingsForm.contains('transferToSavingsId')) {
|
||||||
@@ -40,8 +28,8 @@
|
|||||||
<mat-icon>savings</mat-icon>
|
<mat-icon>savings</mat-icon>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 class="dal-card__title">{{ transferAccountCardTitle }}</h3>
|
<h3 class="dal-card__title">{{ 'labels.inputs.Transfer to savings account' | translate }}</h3>
|
||||||
<span class="dal-card__sub">{{ transferAccountCardSubtitle }}</span>
|
<span class="dal-card__sub">Maturity proceeds will be credited to this account</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -81,7 +69,7 @@
|
|||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<mat-form-field appearance="outline" class="flex-98">
|
<mat-form-field appearance="outline" class="flex-98">
|
||||||
<mat-label>{{ transferAccountCardTitle }}</mat-label>
|
<mat-label>{{ 'labels.inputs.Transfer to savings account' | translate }}</mat-label>
|
||||||
<mat-select formControlName="transferToSavingsId" required>
|
<mat-select formControlName="transferToSavingsId" required>
|
||||||
@for (account of savingsAccountsData; track account) {
|
@for (account of savingsAccountsData; track account) {
|
||||||
<mat-option [value]="account.id">
|
<mat-option [value]="account.id">
|
||||||
@@ -89,15 +77,13 @@
|
|||||||
</mat-option>
|
</mat-option>
|
||||||
}
|
}
|
||||||
</mat-select>
|
</mat-select>
|
||||||
<mat-error>{{ transferAccountCardTitle }} is required</mat-error>
|
<mat-error>Transfer to savings account is required</mat-error>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
<h4 class="mat-h4 flex-98">{{ 'labels.heading.For Pre-mature closure' | translate }}</h4>
|
|
||||||
|
|
||||||
<mat-checkbox class="flex-98 margin-v" labelPosition="before" formControlName="preClosurePenalApplicable">
|
<mat-checkbox class="flex-98 margin-v" labelPosition="before" formControlName="preClosurePenalApplicable">
|
||||||
{{ 'labels.inputs.Apply Penal Interest (less)' | translate }}
|
{{ 'labels.inputs.Apply Penal Interest (less)' | translate }}
|
||||||
</mat-checkbox>
|
</mat-checkbox>
|
||||||
|
|||||||
+5
-43
@@ -11,7 +11,6 @@ import { Component, Input, OnChanges, SimpleChanges, inject } from '@angular/cor
|
|||||||
import { UntypedFormGroup, UntypedFormBuilder, UntypedFormControl, Validators } from '@angular/forms';
|
import { UntypedFormGroup, UntypedFormBuilder, UntypedFormControl, Validators } from '@angular/forms';
|
||||||
import { MatCheckbox } from '@angular/material/checkbox';
|
import { MatCheckbox } from '@angular/material/checkbox';
|
||||||
import { MatIcon } from '@angular/material/icon';
|
import { MatIcon } from '@angular/material/icon';
|
||||||
import { MatIconButton } from '@angular/material/button';
|
|
||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -26,8 +25,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
|||||||
imports: [
|
imports: [
|
||||||
...STANDALONE_SHARED_IMPORTS,
|
...STANDALONE_SHARED_IMPORTS,
|
||||||
MatCheckbox,
|
MatCheckbox,
|
||||||
MatIcon,
|
MatIcon
|
||||||
MatIconButton
|
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class FixedDepositAccountMaturityTermsStepComponent implements OnChanges {
|
export class FixedDepositAccountMaturityTermsStepComponent implements OnChanges {
|
||||||
@@ -55,12 +53,9 @@ export class FixedDepositAccountMaturityTermsStepComponent implements OnChanges
|
|||||||
preClosurePenalInterestOnTypeId: ['']
|
preClosurePenalInterestOnTypeId: ['']
|
||||||
});
|
});
|
||||||
this.closureSettingsForm.get('maturityInstructionId').valueChanges.subscribe((value: number) => {
|
this.closureSettingsForm.get('maturityInstructionId').valueChanges.subscribe((value: number) => {
|
||||||
// 200 = Transfer to Savings -- the whole matured balance goes here. 400 = Reinvest Principal
|
// 200 = Transfer to Savings -- Withdraw Deposit (100) needs no target account, and both
|
||||||
// Only -- the principal rolls into a new FD, but the leftover interest still needs the same
|
// Reinvest options (300/400) open a brand-new FD instead, so they ignore this field too.
|
||||||
// kind of target so it isn't left stranded on the closed account (a real gap the account used
|
if (value === 200) {
|
||||||
// to silently hit before this field also asked for it here). Reinvest Principal + Interest
|
|
||||||
// (300) rolls the whole balance into the same account, so it never needs a target at all.
|
|
||||||
if (value === 200 || value === 400) {
|
|
||||||
this.closureSettingsForm.addControl(
|
this.closureSettingsForm.addControl(
|
||||||
'transferToSavingsId',
|
'transferToSavingsId',
|
||||||
new UntypedFormControl(this.linkedSavingsAccountId ?? '', Validators.required)
|
new UntypedFormControl(this.linkedSavingsAccountId ?? '', Validators.required)
|
||||||
@@ -96,48 +91,15 @@ export class FixedDepositAccountMaturityTermsStepComponent implements OnChanges
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Editing an existing account: its own already-saved Maturity Instructions choice lives on
|
|
||||||
// `fixedDepositsAccountTemplate` (the real account+template GET, FixedDepositAccountData's
|
|
||||||
// `onAccountClosure`) -- NOT on `fixedDepositsAccountProductTemplate`, which is only the blank
|
|
||||||
// per-product default template refetched from the Product dropdown's own productId (via
|
|
||||||
// getFixedDepositsAccountTemplate(clientId, productId)) and carries no account-specific data at
|
|
||||||
// all. This was never read from the right place before, so Edit always opened onto a blank
|
|
||||||
// picker even for an account that really does have an instruction saved. `.dirty` guards it the
|
|
||||||
// same way the penal-interest fields above do, so a maker's own in-progress edit here is never
|
|
||||||
// silently overwritten if this input fires again mid-edit.
|
|
||||||
if (this.fixedDepositsAccountTemplate && !this.closureSettingsForm.get('maturityInstructionId').dirty) {
|
|
||||||
this.closureSettingsForm
|
|
||||||
.get('maturityInstructionId')
|
|
||||||
?.setValue(this.fixedDepositsAccountTemplate.onAccountClosure?.id ?? '');
|
|
||||||
const transferControl = this.closureSettingsForm.get('transferToSavingsId');
|
|
||||||
if (transferControl) {
|
|
||||||
transferControl.setValue(
|
|
||||||
this.fixedDepositsAccountTemplate.transferToSavingsId ?? this.linkedSavingsAccountId ?? ''
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The linked-savings account for the transfer-to-savings / reinvest-principal-only-interest
|
/** The linked-savings account for the transfer-to-savings maturity instruction, once picked. */
|
||||||
* maturity instruction, once picked. */
|
|
||||||
get selectedTransferAccount(): any {
|
get selectedTransferAccount(): any {
|
||||||
const id = this.closureSettingsForm.value.transferToSavingsId;
|
const id = this.closureSettingsForm.value.transferToSavingsId;
|
||||||
if (!id) return null;
|
if (!id) return null;
|
||||||
return this.savingsAccountsData.find((account: any) => account.id === id) ?? null;
|
return this.savingsAccountsData.find((account: any) => account.id === id) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
get transferAccountCardTitle(): string {
|
|
||||||
return this.closureSettingsForm.value.maturityInstructionId === 400
|
|
||||||
? 'Interest payout account'
|
|
||||||
: 'Transfer to savings account';
|
|
||||||
}
|
|
||||||
|
|
||||||
get transferAccountCardSubtitle(): string {
|
|
||||||
return this.closureSettingsForm.value.maturityInstructionId === 400
|
|
||||||
? 'The principal renews into a new FD; interest earned is paid out to this account.'
|
|
||||||
: 'Maturity proceeds will be credited to this account.';
|
|
||||||
}
|
|
||||||
|
|
||||||
clearTransferAccount(): void {
|
clearTransferAccount(): void {
|
||||||
this.closureSettingsForm.get('transferToSavingsId')?.setValue('');
|
this.closureSettingsForm.get('transferToSavingsId')?.setValue('');
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-112
@@ -8,24 +8,11 @@
|
|||||||
|
|
||||||
<div class="pmt">
|
<div class="pmt">
|
||||||
<div class="pmt__header">
|
<div class="pmt__header">
|
||||||
<div class="pmt__header-left">
|
<span class="pmt__header-ico"><fa-icon icon="sync-alt"></fa-icon></span>
|
||||||
<span class="pmt__header-ico"><fa-icon icon="sync-alt"></fa-icon></span>
|
<div class="pmt__header-text">
|
||||||
<div class="pmt__header-text">
|
<h2 class="pmt__title">{{ 'labels.heading.Payment' | translate }}</h2>
|
||||||
<h2 class="pmt__title">
|
<p class="pmt__subtitle">Process this Fixed Deposit now -- works whether it's Active or Matured.</p>
|
||||||
{{ isDashboardRenewalMode ? 'Renew Fixed Deposit' : ('labels.heading.Payment' | translate) }}
|
|
||||||
</h2>
|
|
||||||
<p class="pmt__subtitle">
|
|
||||||
{{
|
|
||||||
isDashboardRenewalMode
|
|
||||||
? 'Renew ' + (dashboardRenewalRow?.accountNo ?? 'this matured FD') + '.'
|
|
||||||
: 'Process this matured Fixed Deposit now.'
|
|
||||||
}}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="pmt__header-close" mat-dialog-close [disabled]="submitting" title="Close">
|
|
||||||
<fa-icon icon="times"></fa-icon>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div mat-dialog-content class="pmt__content">
|
<div mat-dialog-content class="pmt__content">
|
||||||
@@ -40,92 +27,37 @@
|
|||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<form [formGroup]="paymentForm">
|
<form [formGroup]="paymentForm">
|
||||||
@if (isMaturedRenewal) {
|
|
||||||
<p class="pmt__hint">
|
|
||||||
<fa-icon icon="info-circle" class="m-r-5"></fa-icon>
|
|
||||||
{{ maturedRenewalTimingText }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
@if (renewalPreviewLoading) {
|
|
||||||
<p class="pmt__hint">
|
|
||||||
<fa-icon icon="spinner" [class.fa-spin]="true" class="m-r-5"></fa-icon>
|
|
||||||
Calculating gap-period interest...
|
|
||||||
</p>
|
|
||||||
} @else if (renewalPreview && !renewalPreview.withinGrace) {
|
|
||||||
@if (!renewalPreview.linkedAccountId) {
|
|
||||||
<p class="pmt__hint pmt__hint--warn" role="alert">
|
|
||||||
<fa-icon icon="info-circle" class="m-r-5"></fa-icon>
|
|
||||||
This FD has no Deposit Account Link, so there is no account to read a gap-interest rate from. Link a
|
|
||||||
Savings account to it before renewing late.
|
|
||||||
</p>
|
|
||||||
} @else {
|
|
||||||
<div class="pmt__preview">
|
|
||||||
<div class="pmt__preview-row">
|
|
||||||
<span class="pmt__preview-label">FD Maturity Balance (renews as-is below)</span>
|
|
||||||
<span class="pmt__preview-value">{{ renewalPreview.currentBalance | formatNumber }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="pmt__preview-row">
|
|
||||||
<span class="pmt__preview-label">Gap Days ({{ renewalPreview.maturityDate }} → today)</span>
|
|
||||||
<span class="pmt__preview-value">{{ renewalPreview.gapDays }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="pmt__preview-row">
|
|
||||||
<span class="pmt__preview-label">Deposit Account Link</span>
|
|
||||||
<span class="pmt__preview-value">{{ renewalPreview.linkedAccountNo }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="pmt__preview-row">
|
|
||||||
<span class="pmt__preview-label">Its Interest Rate</span>
|
|
||||||
<span class="pmt__preview-value">{{ renewalPreview.linkedAccountRate }}%</span>
|
|
||||||
</div>
|
|
||||||
<div class="pmt__preview-row pmt__preview-row--highlight pmt__preview-row--total">
|
|
||||||
<span class="pmt__preview-label"
|
|
||||||
>Gap-Period Interest → paid to {{ renewalPreview.linkedAccountNo }}</span
|
|
||||||
>
|
|
||||||
<span class="pmt__preview-value">+ {{ renewalPreview.gapInterestAmount | formatNumber }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p class="pmt__hint">
|
|
||||||
<fa-icon icon="info-circle" class="m-r-5"></fa-icon>
|
|
||||||
This gap interest was earned at the linked account's own rate, not the FD's -- it is credited straight
|
|
||||||
to account {{ renewalPreview.linkedAccountNo }} (the FD's Deposit Account Link), separately from
|
|
||||||
whatever you choose below. The FD itself renews from its own maturity balance, unchanged.
|
|
||||||
</p>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (reinvestCap !== null && reinvestCap !== undefined) {
|
@if (reinvestCap !== null && reinvestCap !== undefined) {
|
||||||
<div class="pmt__stat">
|
<div class="pmt__stat">
|
||||||
<span class="pmt__stat-label">
|
<span class="pmt__stat-label">
|
||||||
{{
|
{{
|
||||||
isMaturedRenewal || !remainderIsApproximate
|
remainderIsApproximate
|
||||||
? ('labels.inputs.Maturity Amount' | translate)
|
? 'Amount available on the selected date'
|
||||||
: 'Amount available on the selected date'
|
: ('labels.inputs.Maturity Amount' | translate)
|
||||||
}}
|
}}
|
||||||
</span>
|
</span>
|
||||||
<span class="pmt__stat-value">{{ reinvestCap | formatNumber }}</span>
|
<span class="pmt__stat-value">{{ reinvestCap | formatNumber }}</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (!isMaturedRenewal) {
|
<mat-form-field appearance="outline" class="pmt__date" (click)="closedOnDatePicker.open()">
|
||||||
<mat-form-field appearance="outline" class="pmt__date" (click)="closedOnDatePicker.open()">
|
<mat-label>{{ 'labels.inputs.Closed On Date' | translate }}</mat-label>
|
||||||
<mat-label>{{ 'labels.inputs.Closed On Date' | translate }}</mat-label>
|
<input
|
||||||
<input
|
matInput
|
||||||
matInput
|
[max]="maxDate"
|
||||||
[max]="maxDate"
|
[matDatepicker]="closedOnDatePicker"
|
||||||
[matDatepicker]="closedOnDatePicker"
|
required
|
||||||
required
|
formControlName="closedOnDate"
|
||||||
formControlName="closedOnDate"
|
/>
|
||||||
/>
|
<mat-datepicker-toggle matSuffix [for]="closedOnDatePicker"></mat-datepicker-toggle>
|
||||||
<mat-datepicker-toggle matSuffix [for]="closedOnDatePicker"></mat-datepicker-toggle>
|
<mat-datepicker #closedOnDatePicker></mat-datepicker>
|
||||||
<mat-datepicker #closedOnDatePicker></mat-datepicker>
|
@if (paymentForm.controls.closedOnDate.hasError('required')) {
|
||||||
@if (paymentForm.controls.closedOnDate.hasError('required')) {
|
<mat-error>
|
||||||
<mat-error>
|
{{ 'labels.inputs.Closed On Date' | translate }} {{ 'labels.commons.is' | translate }}
|
||||||
{{ 'labels.inputs.Closed On Date' | translate }} {{ 'labels.commons.is' | translate }}
|
<strong>{{ 'labels.commons.required' | translate }}</strong>
|
||||||
<strong>{{ 'labels.commons.required' | translate }}</strong>
|
</mat-error>
|
||||||
</mat-error>
|
}
|
||||||
}
|
</mat-form-field>
|
||||||
</mat-form-field>
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="pmt__section-label" id="pmt-action-label">{{ 'labels.inputs.Action' | translate }}</div>
|
<div class="pmt__section-label" id="pmt-action-label">{{ 'labels.inputs.Action' | translate }}</div>
|
||||||
<div class="pmt__options" role="radiogroup" aria-labelledby="pmt-action-label">
|
<div class="pmt__options" role="radiogroup" aria-labelledby="pmt-action-label">
|
||||||
@@ -230,12 +162,10 @@
|
|||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (!isMaturedRenewal) {
|
<mat-form-field appearance="outline">
|
||||||
<mat-form-field appearance="outline">
|
<mat-label>{{ 'labels.inputs.Note' | translate }}</mat-label>
|
||||||
<mat-label>{{ 'labels.inputs.Note' | translate }}</mat-label>
|
<textarea matInput formControlName="note" cdkTextareaAutosize cdkAutosizeMinRows="2"></textarea>
|
||||||
<textarea matInput formControlName="note" cdkTextareaAutosize cdkAutosizeMinRows="2"></textarea>
|
</mat-form-field>
|
||||||
</mat-form-field>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (submitError) {
|
@if (submitError) {
|
||||||
<p class="pmt__error" role="alert">
|
<p class="pmt__error" role="alert">
|
||||||
@@ -248,25 +178,14 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div mat-dialog-actions align="end" class="pmt__actions">
|
<div mat-dialog-actions align="end" class="pmt__actions">
|
||||||
<button mat-button mat-dialog-close [disabled]="submitting" class="pmt__btn-cancel">
|
<button mat-button mat-dialog-close [disabled]="submitting">{{ 'labels.buttons.Cancel' | translate }}</button>
|
||||||
{{ 'labels.buttons.Cancel' | translate }}
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
mat-raised-button
|
mat-raised-button
|
||||||
color="primary"
|
color="primary"
|
||||||
class="pmt__btn-submit"
|
|
||||||
[disabled]="!paymentForm?.valid || submitting || loadFailed"
|
[disabled]="!paymentForm?.valid || submitting || loadFailed"
|
||||||
(click)="submit()"
|
(click)="submit()"
|
||||||
>
|
>
|
||||||
{{
|
{{ submitting ? ('labels.buttons.Processing' | translate) : ('labels.buttons.Confirm' | translate) }}
|
||||||
submitting
|
|
||||||
? isDashboardRenewalMode
|
|
||||||
? 'Renewing...'
|
|
||||||
: ('labels.buttons.Processing' | translate)
|
|
||||||
: isDashboardRenewalMode
|
|
||||||
? 'Confirm & Renew'
|
|
||||||
: ('labels.buttons.Confirm' | translate)
|
|
||||||
}}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+204
-379
@@ -6,466 +6,291 @@
|
|||||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
$navy: #0d1b2a;
|
||||||
* Payment / Renewal Dialog Styles
|
$slate: #475569;
|
||||||
* CBSNexora Core Banking System
|
$muted: #64748b;
|
||||||
*/
|
$border: #e2e8f0;
|
||||||
|
$soft: #f8fafc;
|
||||||
|
$blue: #1e40af;
|
||||||
|
$blue-soft: #eff6ff;
|
||||||
|
$green: #166534;
|
||||||
|
$green-soft: #f0fdf4;
|
||||||
|
|
||||||
.pmt {
|
.pmt {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100%;
|
min-height: 320px;
|
||||||
max-height: 92vh;
|
max-height: 85vh;
|
||||||
box-sizing: border-box;
|
|
||||||
font-family:
|
|
||||||
'DM Sans',
|
|
||||||
system-ui,
|
|
||||||
-apple-system,
|
|
||||||
sans-serif;
|
|
||||||
color: #0f172a;
|
|
||||||
border-radius: 16px;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow: 0 24px 48px -12px rgb(15 23 42 / 25%);
|
margin: -24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pmt__header {
|
.pmt__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
justify-content: space-between;
|
gap: 14px;
|
||||||
gap: 16px;
|
padding: 22px 28px 18px;
|
||||||
padding: 26px 30px;
|
background: linear-gradient(135deg, #1e3a8a 0%, #1e40af 55%, #4338ca 100%);
|
||||||
background: linear-gradient(135deg, #1e3a8a 0%, #1d4ed8 55%, #2563eb 100%);
|
|
||||||
color: #fff;
|
color: #fff;
|
||||||
flex-shrink: 0;
|
flex: 0 0 auto;
|
||||||
position: relative;
|
}
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
&::after {
|
.pmt__header-ico {
|
||||||
content: '';
|
width: 40px;
|
||||||
position: absolute;
|
height: 40px;
|
||||||
inset: 0;
|
min-width: 40px;
|
||||||
background: radial-gradient(circle at 88% -20%, rgb(255 255 255 / 22%), transparent 55%);
|
border-radius: 10px;
|
||||||
pointer-events: none;
|
background: rgb(255 255 255 / 15%);
|
||||||
}
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
&-left {
|
justify-content: center;
|
||||||
display: flex;
|
font-size: 18px;
|
||||||
align-items: center;
|
|
||||||
gap: 16px;
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
&-ico {
|
|
||||||
width: 48px;
|
|
||||||
height: 48px;
|
|
||||||
border-radius: 14px;
|
|
||||||
background: rgb(255 255 255 / 16%);
|
|
||||||
border: 1px solid rgb(255 255 255 / 25%);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 20px;
|
|
||||||
color: #fff;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&-text {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
&-close {
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
background: rgb(255 255 255 / 14%);
|
|
||||||
border: none;
|
|
||||||
color: #fff;
|
|
||||||
width: 34px;
|
|
||||||
height: 34px;
|
|
||||||
border-radius: 50%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
flex-shrink: 0;
|
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
|
||||||
background: rgb(255 255 255 / 28%);
|
|
||||||
transform: rotate(90deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.pmt__title {
|
.pmt__title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 20px;
|
font-size: 19px;
|
||||||
font-weight: 800;
|
font-weight: 700;
|
||||||
color: #fff;
|
|
||||||
line-height: 1.25;
|
|
||||||
letter-spacing: -0.01em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.pmt__subtitle {
|
.pmt__subtitle {
|
||||||
margin: 5px 0 0;
|
margin: 4px 0 0;
|
||||||
font-size: 13px;
|
font-size: 12.5px;
|
||||||
color: rgb(255 255 255 / 88%);
|
color: rgb(255 255 255 / 82%);
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pmt__content {
|
.pmt__content {
|
||||||
padding: 26px 30px !important;
|
padding: 22px 28px 8px !important;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
background: #fbfcfe;
|
|
||||||
|
|
||||||
mat-form-field {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.pmt__loading {
|
.pmt__loading {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
min-height: 240px;
|
min-height: 260px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pmt__stat {
|
.pmt__stat {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
gap: 2px;
|
||||||
background: linear-gradient(135deg, #eff6ff 0%, #e0e9ff 100%);
|
background: $blue-soft;
|
||||||
border: 1px solid #bfdbfe;
|
border: 1px solid #dbeafe;
|
||||||
border-radius: 14px;
|
border-radius: 10px;
|
||||||
padding: 18px 22px;
|
padding: 12px 16px;
|
||||||
margin-bottom: 18px;
|
margin-bottom: 18px;
|
||||||
|
|
||||||
&-label {
|
|
||||||
font-size: 11.5px;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.07em;
|
|
||||||
color: #1d4ed8;
|
|
||||||
}
|
|
||||||
|
|
||||||
&-value {
|
|
||||||
font-family: 'JetBrains Mono', 'DM Sans', monospace;
|
|
||||||
font-size: 28px;
|
|
||||||
font-weight: 800;
|
|
||||||
color: #0f172a;
|
|
||||||
letter-spacing: -0.01em;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.pmt__preview {
|
.pmt__stat-label {
|
||||||
display: flex;
|
font-size: 11px;
|
||||||
flex-direction: column;
|
font-weight: 700;
|
||||||
background: #fff;
|
text-transform: uppercase;
|
||||||
border: 1px solid #e2e8f0;
|
letter-spacing: 0.05em;
|
||||||
border-radius: 14px;
|
color: $blue;
|
||||||
padding: 4px 18px;
|
}
|
||||||
margin: 0 0 18px;
|
|
||||||
|
|
||||||
&-row {
|
.pmt__stat-value {
|
||||||
display: flex;
|
font-size: 22px;
|
||||||
align-items: center;
|
font-weight: 800;
|
||||||
justify-content: space-between;
|
color: $navy;
|
||||||
gap: 12px;
|
font-variant-numeric: tabular-nums;
|
||||||
padding: 11px 0;
|
}
|
||||||
border-bottom: 1px solid #f1f5f9;
|
|
||||||
|
|
||||||
&:last-child {
|
.pmt__date {
|
||||||
border-bottom: none;
|
width: 100%;
|
||||||
}
|
|
||||||
|
|
||||||
&--highlight {
|
|
||||||
.pmt__preview-value {
|
|
||||||
color: #0d9488;
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&--total {
|
|
||||||
margin: 0 -18px;
|
|
||||||
padding: 14px 18px;
|
|
||||||
background: linear-gradient(135deg, #eff6ff 0%, #e0e9ff 100%);
|
|
||||||
border-bottom: none;
|
|
||||||
border-radius: 0 0 12px 12px;
|
|
||||||
|
|
||||||
.pmt__preview-label {
|
|
||||||
color: #1d4ed8;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pmt__preview-value {
|
|
||||||
color: #0f172a;
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&-label {
|
|
||||||
font-size: 12.5px;
|
|
||||||
color: #64748b;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
&-value {
|
|
||||||
font-family: 'JetBrains Mono', 'DM Sans', monospace;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #0f172a;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.pmt__section-label {
|
.pmt__section-label {
|
||||||
font-size: 12px;
|
font-size: 11.5px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.05em;
|
||||||
color: #475569;
|
color: $slate;
|
||||||
margin: 10px 0 12px;
|
margin: 4px 0 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Action Options Grid - 2 Equal Columns, comfortably wide now that the dialog itself is wider */
|
|
||||||
.pmt__options {
|
.pmt__options {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
|
||||||
gap: 14px;
|
gap: 10px;
|
||||||
margin-bottom: 18px;
|
margin-bottom: 6px;
|
||||||
|
|
||||||
@media (width <= 560px) {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.pmt__option {
|
.pmt__option {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 14px;
|
gap: 10px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border: 1.5px solid #e2e8f0;
|
border: 1.5px solid $border;
|
||||||
border-radius: 14px;
|
border-radius: 10px;
|
||||||
padding: 16px;
|
padding: 12px 14px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
transition:
|
||||||
position: relative;
|
border-color 0.15s,
|
||||||
|
background 0.15s,
|
||||||
|
box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
&:hover {
|
.pmt__option:hover {
|
||||||
border-color: #93c5fd;
|
border-color: #93c5fd;
|
||||||
background: #f8fafc;
|
background: $blue-soft;
|
||||||
box-shadow: 0 6px 16px rgb(15 23 42 / 6%);
|
}
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
&--active {
|
.pmt__option--active {
|
||||||
border-color: #2563eb;
|
border-color: $blue;
|
||||||
background: #eff6ff;
|
background: $blue-soft;
|
||||||
box-shadow: 0 0 0 1.5px #2563eb;
|
box-shadow: 0 0 0 1px $blue;
|
||||||
|
}
|
||||||
|
|
||||||
.pmt__option-ico {
|
.pmt__option-ico {
|
||||||
background: #2563eb;
|
width: 30px;
|
||||||
color: #fff;
|
height: 30px;
|
||||||
border-color: #2563eb;
|
min-width: 30px;
|
||||||
}
|
border-radius: 8px;
|
||||||
}
|
background: $soft;
|
||||||
|
color: $blue;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
&-ico {
|
.pmt__option--active .pmt__option-ico {
|
||||||
width: 38px;
|
background: $blue;
|
||||||
height: 38px;
|
color: #fff;
|
||||||
min-width: 38px;
|
}
|
||||||
border-radius: 10px;
|
|
||||||
background: #f1f5f9;
|
|
||||||
border: 1px solid #e2e8f0;
|
|
||||||
color: #2563eb;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 15px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Distinct accent per option while unselected, purely presentational -- helps the four cards read
|
.pmt__option-text {
|
||||||
// apart at a glance. Selecting a card always fills its icon blue (see &--active above), matching the
|
display: flex;
|
||||||
// blue selection border/check regardless of which option it is, rather than mixing selection-blue
|
flex-direction: column;
|
||||||
// with the option's own accent.
|
gap: 2px;
|
||||||
&:not(.pmt__option--active):nth-child(2) .pmt__option-ico {
|
}
|
||||||
color: #7c3aed;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:not(.pmt__option--active):nth-child(3) .pmt__option-ico {
|
.pmt__option-title {
|
||||||
color: #0d9488;
|
font-size: 13px;
|
||||||
}
|
font-weight: 700;
|
||||||
|
color: $navy;
|
||||||
|
}
|
||||||
|
|
||||||
&:not(.pmt__option--active):nth-child(4) .pmt__option-ico {
|
.pmt__option-desc {
|
||||||
color: #d97706;
|
font-size: 11px;
|
||||||
}
|
color: $muted;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
&-text {
|
.pmt__option:focus-visible {
|
||||||
display: flex;
|
outline: 2px solid $blue;
|
||||||
flex-direction: column;
|
outline-offset: 2px;
|
||||||
gap: 4px;
|
}
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
&-title {
|
.pmt__option-check {
|
||||||
font-size: 14px;
|
margin-left: auto;
|
||||||
font-weight: 700;
|
color: $blue;
|
||||||
color: #0f172a;
|
font-size: 13px;
|
||||||
line-height: 1.3;
|
|
||||||
}
|
|
||||||
|
|
||||||
&-desc {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #64748b;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
&-check {
|
|
||||||
color: #2563eb;
|
|
||||||
font-size: 17px;
|
|
||||||
position: absolute;
|
|
||||||
top: 14px;
|
|
||||||
right: 14px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.pmt__option-error {
|
.pmt__option-error {
|
||||||
color: #b91c1c;
|
color: #b91c1c;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
margin: 4px 0 14px;
|
||||||
margin: -10px 0 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pmt__split {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 16px;
|
|
||||||
background: #f8fafc;
|
|
||||||
border: 1px dashed #cbd5e1;
|
|
||||||
border-radius: 14px;
|
|
||||||
padding: 18px;
|
|
||||||
margin: 10px 0 18px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
|
|
||||||
&-input {
|
|
||||||
flex: 1 1 220px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&-arrow {
|
|
||||||
color: #94a3b8;
|
|
||||||
font-size: 17px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&-remainder {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 3px;
|
|
||||||
background: #f0fdf4;
|
|
||||||
border: 1px solid #bbf7d0;
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 12px 16px;
|
|
||||||
flex: 1 1 220px;
|
|
||||||
|
|
||||||
&-label {
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
color: #166534;
|
|
||||||
}
|
|
||||||
|
|
||||||
&-value {
|
|
||||||
font-family: 'JetBrains Mono', 'DM Sans', monospace;
|
|
||||||
font-size: 19px;
|
|
||||||
font-weight: 800;
|
|
||||||
color: #166534;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.pmt__hint {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 10px;
|
|
||||||
background: #f0f9ff;
|
|
||||||
border: 1px solid #bae6fd;
|
|
||||||
border-left: 4px solid #0284c7;
|
|
||||||
border-radius: 10px;
|
|
||||||
color: #0369a1;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.5;
|
|
||||||
padding: 12px 16px;
|
|
||||||
margin: 0 0 18px;
|
|
||||||
|
|
||||||
&--warn {
|
|
||||||
background: #fffbeb;
|
|
||||||
border-color: #fde68a;
|
|
||||||
border-left-color: #d97706;
|
|
||||||
color: #b45309;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.pmt__error {
|
.pmt__error {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 10px;
|
gap: 4px;
|
||||||
background: #fef2f2;
|
background: #fef2f2;
|
||||||
border: 1px solid #fecaca;
|
border: 1px solid #fecaca;
|
||||||
border-left: 4px solid #ef4444;
|
border-radius: 8px;
|
||||||
border-radius: 10px;
|
|
||||||
color: #b91c1c;
|
color: #b91c1c;
|
||||||
font-size: 13px;
|
font-size: 12.5px;
|
||||||
padding: 12px 16px;
|
line-height: 1.45;
|
||||||
margin: 10px 0;
|
padding: 10px 14px;
|
||||||
|
margin: 4px 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pmt__hint {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 4px;
|
||||||
|
background: $soft;
|
||||||
|
border: 1px solid $border;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: $slate;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
padding: 9px 13px;
|
||||||
|
margin: 0 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pmt__hint--warn {
|
||||||
|
background: #fffbeb;
|
||||||
|
border-color: #fde68a;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pmt__split {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
background: $soft;
|
||||||
|
border: 1px dashed #cbd5e1;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 14px 16px 0;
|
||||||
|
margin: 4px 0 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pmt__split-input {
|
||||||
|
flex: 1 1 180px;
|
||||||
|
min-width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pmt__split-arrow {
|
||||||
|
color: $muted;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pmt__split-remainder {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
background: $green-soft;
|
||||||
|
border: 1px solid #bbf7d0;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
flex: 1 1 180px;
|
||||||
|
min-width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pmt__split-remainder-label {
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: $green;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pmt__split-remainder-value {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: $green;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pmt__content mat-form-field {
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pmt__actions {
|
.pmt__actions {
|
||||||
padding: 18px 30px !important;
|
padding: 14px 28px 20px !important;
|
||||||
border-top: 1px solid #e2e8f0;
|
border-top: 1px solid $border;
|
||||||
background: #fff;
|
flex: 0 0 auto;
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 14px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
|
|
||||||
.pmt__btn-cancel {
|
|
||||||
border-radius: 10px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #475569;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pmt__btn-submit {
|
|
||||||
border-radius: 10px;
|
|
||||||
font-weight: 700;
|
|
||||||
padding: 0 26px;
|
|
||||||
height: 42px;
|
|
||||||
background: linear-gradient(135deg, #1d4ed8 0%, #2563eb 100%);
|
|
||||||
color: #fff;
|
|
||||||
box-shadow: 0 8px 20px rgb(37 99 235 / 30%);
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
|
||||||
box-shadow: 0 10px 24px rgb(37 99 235 / 40%);
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:disabled {
|
|
||||||
background: #cbd5e1;
|
|
||||||
box-shadow: none;
|
|
||||||
color: #64748b;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-117
@@ -24,26 +24,15 @@ import { UntypedFormBuilder, UntypedFormGroup, UntypedFormControl, ValidatorFn,
|
|||||||
/** Custom Services */
|
/** Custom Services */
|
||||||
import { FixedDepositsService } from '../../../fixed-deposits.service';
|
import { FixedDepositsService } from '../../../fixed-deposits.service';
|
||||||
import { SettingsService } from 'app/settings/settings.service';
|
import { SettingsService } from 'app/settings/settings.service';
|
||||||
import { AlertService } from 'app/core/alert/alert.service';
|
|
||||||
import { Dates } from 'app/core/utils/dates';
|
import { Dates } from 'app/core/utils/dates';
|
||||||
import { FormatNumberPipe } from '../../../../../pipes/format-number.pipe';
|
import { FormatNumberPipe } from '../../../../../pipes/format-number.pipe';
|
||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
import { FdMaturityRow } from 'app/fd-dashboard/fd-dashboard.compute';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* "Payment" dialog -- an always-available alternative to Premature Close (Active-only, no reinvest) and
|
* "Payment" dialog -- an always-available alternative to Premature Close (Active-only, no reinvest) and
|
||||||
* Close (Matured-only, no partial option). Works on an FD in either state and offers a fourth choice,
|
* Close (Matured-only, no partial option). Works on an FD in either state and offers a fourth choice,
|
||||||
* Partial Reinvest, alongside the ones the backend's `DepositAccountOnClosureType` already had: the
|
* Partial Reinvest, alongside the ones the backend's `DepositAccountOnClosureType` already had: the
|
||||||
* entered amount renews on this same account, the rest transfers to a savings account picked here.
|
* entered amount renews on this same account, the rest transfers to a savings account picked here.
|
||||||
*
|
|
||||||
* Doubles as the FD Dashboard's Renew action when opened with `data.dashboardRenewal` set: same form,
|
|
||||||
* same full set of closure types (nothing removed -- Transfer to Savings and Partial Reinvest work from
|
|
||||||
* the Dashboard too, not just the two Reinvest options). The only two differences in that mode: the
|
|
||||||
* Closed On Date is computed from the FD's grace window instead of picked freely (backdated to the
|
|
||||||
* original maturity date within grace, today once it's passed -- the picker is hidden, not just
|
|
||||||
* defaulted, so it can't accidentally be moved off that date), and submit() first calls
|
|
||||||
* `creditGapInterestIfPastGrace` when past grace, crediting the gap-period interest (at the linked
|
|
||||||
* account's rate) onto the FD before the normal `payment` command runs and reads its balance.
|
|
||||||
*/
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'mifosx-payment-dialog',
|
selector: 'mifosx-payment-dialog',
|
||||||
@@ -87,18 +76,11 @@ export class PaymentDialogComponent implements OnInit {
|
|||||||
};
|
};
|
||||||
|
|
||||||
dialogRef = inject<MatDialogRef<PaymentDialogComponent>>(MatDialogRef);
|
dialogRef = inject<MatDialogRef<PaymentDialogComponent>>(MatDialogRef);
|
||||||
data: {
|
data: { accountId: number | string; accountBalance?: number; maturityDate?: any } = inject(MAT_DIALOG_DATA);
|
||||||
accountId: number | string;
|
|
||||||
accountBalance?: number;
|
|
||||||
maturityDate?: any;
|
|
||||||
/** Present only when opened from the FD Dashboard's Renew action. */
|
|
||||||
dashboardRenewal?: { row: FdMaturityRow };
|
|
||||||
} = inject(MAT_DIALOG_DATA);
|
|
||||||
private formBuilder = inject(UntypedFormBuilder);
|
private formBuilder = inject(UntypedFormBuilder);
|
||||||
private fixedDepositsService = inject(FixedDepositsService);
|
private fixedDepositsService = inject(FixedDepositsService);
|
||||||
private settingsService = inject(SettingsService);
|
private settingsService = inject(SettingsService);
|
||||||
private dateUtils = inject(Dates);
|
private dateUtils = inject(Dates);
|
||||||
private alertService = inject(AlertService);
|
|
||||||
|
|
||||||
paymentForm: UntypedFormGroup;
|
paymentForm: UntypedFormGroup;
|
||||||
/** Loaded once, on open, from the same closure template the existing Close action already uses. */
|
/** Loaded once, on open, from the same closure template the existing Close action already uses. */
|
||||||
@@ -111,16 +93,6 @@ export class PaymentDialogComponent implements OnInit {
|
|||||||
submitError: string = null;
|
submitError: string = null;
|
||||||
maxDate: Date;
|
maxDate: Date;
|
||||||
|
|
||||||
/** Whether gap-interest still needs crediting before this submits -- only ever true once, right
|
|
||||||
* after open; cleared as soon as `submit()` has credited it, so a retried submit doesn't double-post. */
|
|
||||||
private gapInterestPending = false;
|
|
||||||
|
|
||||||
/** Read-only numbers (current balance, linked account's rate, gap days, gap-interest amount,
|
|
||||||
* projected new balance) fetched once on open in dashboard-renewal mode, shown above the Action
|
|
||||||
* cards so staff see exactly what a late renewal is about to credit before confirming anything. */
|
|
||||||
renewalPreview: any = null;
|
|
||||||
renewalPreviewLoading = false;
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.maxDate = this.settingsService.businessDate;
|
this.maxDate = this.settingsService.businessDate;
|
||||||
this.paymentForm = this.formBuilder.group({
|
this.paymentForm = this.formBuilder.group({
|
||||||
@@ -134,34 +106,6 @@ export class PaymentDialogComponent implements OnInit {
|
|||||||
],
|
],
|
||||||
note: ['']
|
note: ['']
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetched every time this dialog opens, not just from the FD Dashboard -- Payment is now only ever
|
|
||||||
// offered once an FD has Matured (see FixedDepositsButtonsConfiguration), so a plain Payment on it
|
|
||||||
// is exactly the same "late renewal" scenario the Dashboard's Renew action handles, and needs the
|
|
||||||
// exact same date-locking/gap-interest treatment, or an FD closed this way instead of from the
|
|
||||||
// Dashboard would silently skip crediting interest it genuinely earned.
|
|
||||||
this.renewalPreviewLoading = true;
|
|
||||||
this.fixedDepositsService.getFdRenewalPreview(this.data.accountId).subscribe({
|
|
||||||
next: (preview: any) => {
|
|
||||||
this.renewalPreview = preview;
|
|
||||||
this.renewalPreviewLoading = false;
|
|
||||||
if (preview?.isMatured) {
|
|
||||||
// Computed from the grace window, never picked -- the date field stays in the form (the
|
|
||||||
// generic submit path and reinvestCap/processingOnOrAfterMaturity both key off it) but its
|
|
||||||
// picker is hidden once matured (see the template), so it can't be moved off this value.
|
|
||||||
this.paymentForm
|
|
||||||
.get('closedOnDate')
|
|
||||||
.setValue(preview.withinGrace ? new Date(preview.maturityDate) : this.maxDate);
|
|
||||||
this.gapInterestPending = !preview.withinGrace;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
// Non-fatal -- Confirm still works from here (backend re-derives the same numbers on submit),
|
|
||||||
// only the numeric preview and its date-locking are unavailable, not the action itself.
|
|
||||||
error: () => {
|
|
||||||
this.renewalPreviewLoading = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.paymentForm.get('onAccountClosureId').valueChanges.subscribe((id: number) => this.onClosureTypeChange(id));
|
this.paymentForm.get('onAccountClosureId').valueChanges.subscribe((id: number) => this.onClosureTypeChange(id));
|
||||||
// The cap depends on whether the chosen date is on or after maturity, so a date change can make an
|
// The cap depends on whether the chosen date is on or after maturity, so a date change can make an
|
||||||
// already-entered amount valid or invalid. Without this the amount keeps its verdict from the old date.
|
// already-entered amount valid or invalid. Without this the amount keeps its verdict from the old date.
|
||||||
@@ -192,31 +136,6 @@ export class PaymentDialogComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
get isDashboardRenewalMode(): boolean {
|
|
||||||
return !!this.data.dashboardRenewal;
|
|
||||||
}
|
|
||||||
|
|
||||||
get dashboardRenewalRow(): FdMaturityRow | null {
|
|
||||||
return this.data.dashboardRenewal?.row ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** True once the loaded preview confirms this FD has actually Matured -- which, since Payment is
|
|
||||||
* only ever offered on a Matured account, is every real open of this dialog now, from either entry
|
|
||||||
* point. Drives the date-locking and gap-interest preview/crediting identically either way. */
|
|
||||||
get isMaturedRenewal(): boolean {
|
|
||||||
return !!this.renewalPreview?.isMatured;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** What clicking Confirm is actually about to do -- shown above the (hidden-picker) date once
|
|
||||||
* matured, since the date itself is no longer visible to explain it. */
|
|
||||||
get maturedRenewalTimingText(): string {
|
|
||||||
if (!this.renewalPreview) return '';
|
|
||||||
return this.renewalPreview.withinGrace
|
|
||||||
? `Backdated to its original maturity date (${this.renewalPreview.maturityDate}), at the currently active interest rate.`
|
|
||||||
: `This FD's grace period has passed -- gap-period interest (for the days since maturity) will be ` +
|
|
||||||
`credited at the linked account's rate first, then this renews from today.`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The amount being split, and the bound the entered partial amount is checked against.
|
* The amount being split, and the bound the entered partial amount is checked against.
|
||||||
* <p>
|
* <p>
|
||||||
@@ -340,31 +259,6 @@ export class PaymentDialogComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
submit(): void {
|
submit(): void {
|
||||||
if (this.gapInterestPending) {
|
|
||||||
this.submitting = true;
|
|
||||||
this.submitError = null;
|
|
||||||
this.fixedDepositsService.creditGapInterestIfPastGrace(this.data.accountId).subscribe({
|
|
||||||
next: () => {
|
|
||||||
this.gapInterestPending = false;
|
|
||||||
this.submitGenericPayment();
|
|
||||||
},
|
|
||||||
error: (error: any) => {
|
|
||||||
this.submitting = false;
|
|
||||||
this.submitError =
|
|
||||||
error?.error?.defaultUserMessage ??
|
|
||||||
error?.error?.errors?.[0]?.defaultUserMessage ??
|
|
||||||
'Could not credit gap-period interest. Please review the details and try again.';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.submitGenericPayment();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The same `command=payment` submission every non-Dashboard call to this dialog already used --
|
|
||||||
* Dashboard-renewal mode reaches it too, once any gap-interest pre-step above has cleared, so every
|
|
||||||
* closure type behaves identically regardless of where the dialog was opened from. */
|
|
||||||
private submitGenericPayment(): void {
|
|
||||||
const formValue = { ...this.paymentForm.value };
|
const formValue = { ...this.paymentForm.value };
|
||||||
const locale = this.settingsService.language.code;
|
const locale = this.settingsService.language.code;
|
||||||
const dateFormat = this.settingsService.dateFormat;
|
const dateFormat = this.settingsService.dateFormat;
|
||||||
@@ -377,16 +271,7 @@ export class PaymentDialogComponent implements OnInit {
|
|||||||
this.fixedDepositsService
|
this.fixedDepositsService
|
||||||
.executeFixedDepositsAccountCommand(this.data.accountId as string, 'payment', data)
|
.executeFixedDepositsAccountCommand(this.data.accountId as string, 'payment', data)
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: (response: any) => {
|
next: () => {
|
||||||
// Parked in the Checker Inbox rather than executed -- see the same handling in
|
|
||||||
// PrematureCloseDialogComponent for why an HTTP 200 here can still mean "not applied yet".
|
|
||||||
if (response?.rollbackTransaction) {
|
|
||||||
this.alertService.alert({
|
|
||||||
type: 'Success',
|
|
||||||
message:
|
|
||||||
'This payment has been submitted for approval. The deposit is unchanged until a checker approves it.'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
this.dialogRef.close({ confirm: true });
|
this.dialogRef.close({ confirm: true });
|
||||||
},
|
},
|
||||||
// Keep the dialog open with the entered values and say what went wrong, rather than leaving a
|
// Keep the dialog open with the entered values and say what went wrong, rather than leaving a
|
||||||
|
|||||||
-282
@@ -1,282 +0,0 @@
|
|||||||
<!--
|
|
||||||
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="pcd">
|
|
||||||
<div class="pcd__header">
|
|
||||||
<div class="pcd__header-left">
|
|
||||||
<span class="pcd__header-ico"><fa-icon icon="times-circle"></fa-icon></span>
|
|
||||||
<div class="pcd__header-text">
|
|
||||||
<h2 class="pcd__title">{{ 'labels.heading.Premature Close' | translate }}</h2>
|
|
||||||
<p class="pcd__subtitle">
|
|
||||||
Close {{ data.accountNo ?? 'this account' }} before maturity -- interest is recalculated as of the date below,
|
|
||||||
usually at a lower, penalty rate.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button type="button" class="pcd__header-close" mat-dialog-close [disabled]="submitting" title="Close">
|
|
||||||
<fa-icon icon="times"></fa-icon>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div mat-dialog-content class="pcd__content">
|
|
||||||
@if (loadFailed) {
|
|
||||||
<div class="pcd__error" role="alert">
|
|
||||||
<fa-icon icon="times-circle" class="m-r-10"></fa-icon>
|
|
||||||
Could not calculate the premature-closure amount for this account. Close this dialog and try again.
|
|
||||||
</div>
|
|
||||||
} @else {
|
|
||||||
<form [formGroup]="prematureCloseForm">
|
|
||||||
<mat-form-field appearance="outline" class="pcd__date" (click)="closedOnDatePicker.open()">
|
|
||||||
<mat-label>{{ 'labels.inputs.Premature Close Date' | translate }}</mat-label>
|
|
||||||
<input
|
|
||||||
matInput
|
|
||||||
[min]="minDate"
|
|
||||||
[max]="maxDate"
|
|
||||||
[matDatepicker]="closedOnDatePicker"
|
|
||||||
required
|
|
||||||
formControlName="closedOnDate"
|
|
||||||
/>
|
|
||||||
<mat-datepicker-toggle matSuffix [for]="closedOnDatePicker"></mat-datepicker-toggle>
|
|
||||||
<mat-datepicker #closedOnDatePicker></mat-datepicker>
|
|
||||||
@if (prematureCloseForm.controls.closedOnDate.hasError('required')) {
|
|
||||||
<mat-error>
|
|
||||||
{{ 'labels.inputs.Premature Close Date' | translate }} {{ 'labels.commons.is' | translate }}
|
|
||||||
<strong>{{ 'labels.commons.required' | translate }}</strong>
|
|
||||||
</mat-error>
|
|
||||||
}
|
|
||||||
</mat-form-field>
|
|
||||||
|
|
||||||
<mat-form-field appearance="outline">
|
|
||||||
<mat-label>Closure Reason</mat-label>
|
|
||||||
<mat-select formControlName="prematureClosureReasonId">
|
|
||||||
@for (reason of closureReasonOptions; track reason.id) {
|
|
||||||
<mat-option [value]="reason.id">{{ reason.label }}</mat-option>
|
|
||||||
}
|
|
||||||
</mat-select>
|
|
||||||
</mat-form-field>
|
|
||||||
|
|
||||||
@if (loadingAmount) {
|
|
||||||
<div class="pcd__loading">
|
|
||||||
<mat-spinner diameter="28"></mat-spinner>
|
|
||||||
<span>Calculating premature-closure amount...</span>
|
|
||||||
</div>
|
|
||||||
} @else {
|
|
||||||
@if (data.activatedOnDate || data.maturityDate || data.currentBalance !== undefined) {
|
|
||||||
<div class="pcd__preview pcd__preview--tenure">
|
|
||||||
@if (data.activatedOnDate) {
|
|
||||||
<div class="pcd__preview-row">
|
|
||||||
<span class="pcd__preview-label">Current Term Started</span>
|
|
||||||
<span class="pcd__preview-value">{{ activatedOnDateDisplay }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="pcd__preview-row">
|
|
||||||
<span class="pcd__preview-label">Tenure Completed (this term)</span>
|
|
||||||
<span class="pcd__preview-value"
|
|
||||||
>{{ tenureElapsedDisplay }} <span class="pcd__preview-sub">({{ daysElapsed }} days)</span></span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@if (data.maturityDate) {
|
|
||||||
<div class="pcd__preview-row">
|
|
||||||
<span class="pcd__preview-label">Scheduled Maturity Date</span>
|
|
||||||
<span class="pcd__preview-value"
|
|
||||||
>{{ maturityDateDisplay }}
|
|
||||||
<span class="pcd__preview-sub">({{ daysRemainingToMaturity }} days left)</span></span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@if (data.currentBalance !== undefined && data.currentBalance !== null) {
|
|
||||||
<div class="pcd__preview-row">
|
|
||||||
<span class="pcd__preview-label">Current Balance (as of today)</span>
|
|
||||||
<span class="pcd__preview-value">{{ data.currentBalance | formatNumber }}</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (maturityAmount !== null && maturityAmount !== undefined) {
|
|
||||||
<div class="pcd__preview">
|
|
||||||
@if (data.depositAmount) {
|
|
||||||
<div class="pcd__preview-row">
|
|
||||||
<span class="pcd__preview-label">Deposit Amount</span>
|
|
||||||
<span class="pcd__preview-value">{{ data.depositAmount | formatNumber }}</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@if (data.nominalAnnualInterestRate) {
|
|
||||||
<div class="pcd__preview-row">
|
|
||||||
<span class="pcd__preview-label">Contracted Interest Rate</span>
|
|
||||||
<span class="pcd__preview-value">{{ data.nominalAnnualInterestRate }}%</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@if (effectiveInterestRate !== null && effectiveInterestRate !== undefined) {
|
|
||||||
<div class="pcd__preview-row pcd__preview-row--highlight">
|
|
||||||
<span class="pcd__preview-label">Effective Rate for this closure</span>
|
|
||||||
<span class="pcd__preview-value">{{ effectiveInterestRate }}%</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@if (data.preClosurePenalApplicable) {
|
|
||||||
<div
|
|
||||||
class="pcd__preview-row"
|
|
||||||
[class.pcd__preview-row--waived]="prematureCloseForm.get('waivePreClosurePenalty').value"
|
|
||||||
>
|
|
||||||
<span class="pcd__preview-label"
|
|
||||||
>Pre-Closure Penalty (on {{ data.preClosurePenalInterestOnType?.value }})</span
|
|
||||||
>
|
|
||||||
<span class="pcd__preview-value">
|
|
||||||
@if (prematureCloseForm.get('waivePreClosurePenalty').value) {
|
|
||||||
<span class="pcd__penalty-waived-tag">Waived</span>
|
|
||||||
} @else {
|
|
||||||
− {{ data.preClosurePenalInterest }}%
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@if (interestEarned !== null) {
|
|
||||||
<div class="pcd__preview-row pcd__preview-row--highlight">
|
|
||||||
<span class="pcd__preview-label"
|
|
||||||
>Interest Earned (to {{ 'labels.inputs.Premature Close Date' | translate }})</span
|
|
||||||
>
|
|
||||||
<span class="pcd__preview-value"
|
|
||||||
>{{ interestEarned >= 0 ? '+' : '' }} {{ interestEarned | formatNumber }}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
<div class="pcd__preview-row pcd__preview-row--total">
|
|
||||||
<span class="pcd__preview-label">{{ 'labels.inputs.Maturity Amount' | translate }}</span>
|
|
||||||
<span class="pcd__preview-value">{{ maturityAmount | formatNumber }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (data.preClosurePenalApplicable) {
|
|
||||||
@if (isAutoWaivedReason) {
|
|
||||||
<div class="pcd__waive-locked">
|
|
||||||
<fa-icon icon="check-circle" class="m-r-10"></fa-icon>
|
|
||||||
<span>
|
|
||||||
Penalty automatically waived -- <strong>{{ selectedReasonLabel }}</strong> always waives the
|
|
||||||
pre-closure penalty, regardless of this checkbox.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
} @else {
|
|
||||||
<mat-checkbox formControlName="waivePreClosurePenalty" class="pcd__waive-checkbox">
|
|
||||||
Waive the pre-closure penalty for this closure
|
|
||||||
<span class="pcd__waive-hint">
|
|
||||||
Interest recalculates at the full contracted rate instead of the penalty rate above. This does not
|
|
||||||
change the product's own penalty setting -- it applies to this one closure only.
|
|
||||||
</span>
|
|
||||||
</mat-checkbox>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="pcd__section-label" id="pcd-action-label">{{ 'labels.inputs.Action' | translate }}</div>
|
|
||||||
<div class="pcd__options" role="radiogroup" aria-labelledby="pcd-action-label">
|
|
||||||
@for (closureOption of onAccountClosureOptions; track closureOption) {
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="pcd__option"
|
|
||||||
role="radio"
|
|
||||||
[attr.aria-checked]="prematureCloseForm.get('onAccountClosureId').value === closureOption.id"
|
|
||||||
[class.pcd__option--active]="prematureCloseForm.get('onAccountClosureId').value === closureOption.id"
|
|
||||||
(click)="selectClosureType(closureOption.id)"
|
|
||||||
>
|
|
||||||
<span class="pcd__option-ico"><fa-icon [icon]="iconFor(closureOption.id)"></fa-icon></span>
|
|
||||||
<span class="pcd__option-text">
|
|
||||||
<span class="pcd__option-title">{{ closureOption.value }}</span>
|
|
||||||
<span class="pcd__option-desc">{{ descriptionFor(closureOption.id) }}</span>
|
|
||||||
</span>
|
|
||||||
@if (prematureCloseForm.get('onAccountClosureId').value === closureOption.id) {
|
|
||||||
<fa-icon icon="check-circle" class="pcd__option-check"></fa-icon>
|
|
||||||
}
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
@if (
|
|
||||||
prematureCloseForm.controls.onAccountClosureId?.hasError('required') &&
|
|
||||||
prematureCloseForm.controls.onAccountClosureId?.touched
|
|
||||||
) {
|
|
||||||
<div class="pcd__option-error">
|
|
||||||
{{ 'labels.inputs.Account action' | translate }} {{ 'labels.commons.is' | translate }}
|
|
||||||
{{ 'labels.commons.required' | translate }}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (noDestinationAccounts) {
|
|
||||||
<p class="pcd__hint pcd__hint--warn" role="alert">
|
|
||||||
<fa-icon icon="info-circle" class="m-r-5"></fa-icon>
|
|
||||||
This customer has no active savings account to transfer into. Premature closure has to be paid into a
|
|
||||||
savings account, so open one for this customer before closing this deposit.
|
|
||||||
</p>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (prematureCloseForm.contains('toSavingsAccountId')) {
|
|
||||||
<mat-form-field appearance="outline">
|
|
||||||
<mat-label>{{ 'labels.inputs.Transfer to Savings' | translate }}</mat-label>
|
|
||||||
<mat-select required formControlName="toSavingsAccountId">
|
|
||||||
@for (account of savingsAccountsData; track account) {
|
|
||||||
<mat-option [value]="account.id">
|
|
||||||
{{ account.accountNo }}
|
|
||||||
</mat-option>
|
|
||||||
}
|
|
||||||
</mat-select>
|
|
||||||
@if (prematureCloseForm.controls.toSavingsAccountId?.hasError('required')) {
|
|
||||||
<mat-error>
|
|
||||||
{{ 'labels.inputs.Transfer to savings account' | translate }} {{ 'labels.commons.is' | translate }}
|
|
||||||
<strong>{{ 'labels.commons.required' | translate }}</strong>
|
|
||||||
</mat-error>
|
|
||||||
}
|
|
||||||
</mat-form-field>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (prematureCloseForm.contains('toGlAccountId')) {
|
|
||||||
<mifosx-gl-account-selector
|
|
||||||
[inputFormControl]="toGlAccountControl"
|
|
||||||
[glAccountList]="glAccountsData"
|
|
||||||
[required]="true"
|
|
||||||
[inputLabel]="'Transfer to GL Account'"
|
|
||||||
>
|
|
||||||
</mifosx-gl-account-selector>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (prematureCloseForm.contains('transferDescription')) {
|
|
||||||
<mat-form-field appearance="outline">
|
|
||||||
<mat-label>{{ 'labels.inputs.Transfer Description' | translate }}</mat-label>
|
|
||||||
<input matInput formControlName="transferDescription" />
|
|
||||||
</mat-form-field>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (prematureCloseForm.contains('note')) {
|
|
||||||
<mat-form-field appearance="outline">
|
|
||||||
<mat-label>{{ 'labels.inputs.Note' | translate }}</mat-label>
|
|
||||||
<textarea matInput formControlName="note" cdkTextareaAutosize cdkAutosizeMinRows="2"></textarea>
|
|
||||||
</mat-form-field>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (submitError) {
|
|
||||||
<p class="pcd__error" role="alert">
|
|
||||||
<fa-icon icon="times-circle" class="m-r-5"></fa-icon>
|
|
||||||
{{ submitError }}
|
|
||||||
</p>
|
|
||||||
}
|
|
||||||
</form>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div mat-dialog-actions align="end" class="pcd__actions">
|
|
||||||
<button mat-button mat-dialog-close [disabled]="submitting" class="pcd__btn-cancel">
|
|
||||||
{{ 'labels.buttons.Cancel' | translate }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
mat-raised-button
|
|
||||||
class="pcd__btn-submit"
|
|
||||||
[disabled]="!prematureCloseForm?.valid || submitting || loadFailed || loadingAmount"
|
|
||||||
(click)="submit()"
|
|
||||||
>
|
|
||||||
{{ submitting ? ('labels.buttons.Processing' | translate) : ('labels.buttons.Confirm' | translate) }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
-468
@@ -1,468 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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/.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Premature Close Dialog Styles
|
|
||||||
* CBSNexora Core Banking System
|
|
||||||
*
|
|
||||||
* Same shape/spacing/typography as the Payment dialog (.pmt__*), in this Danger Zone action's own red
|
|
||||||
* accent on the header and Confirm button, rather than Payment's blue -- an irreversible, usually
|
|
||||||
* penalty-bearing action reads differently from a routine one, everywhere else stays identical so the
|
|
||||||
* two dialogs feel like the same product.
|
|
||||||
*/
|
|
||||||
|
|
||||||
.pcd {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100%;
|
|
||||||
max-height: 92vh;
|
|
||||||
box-sizing: border-box;
|
|
||||||
font-family:
|
|
||||||
'DM Sans',
|
|
||||||
system-ui,
|
|
||||||
-apple-system,
|
|
||||||
sans-serif;
|
|
||||||
color: #0f172a;
|
|
||||||
border-radius: 16px;
|
|
||||||
overflow: hidden;
|
|
||||||
box-shadow: 0 24px 48px -12px rgb(15 23 42 / 25%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pcd__header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 16px;
|
|
||||||
padding: 26px 30px;
|
|
||||||
background: linear-gradient(135deg, #7f1d1d 0%, #b91c1c 55%, #dc2626 100%);
|
|
||||||
color: #fff;
|
|
||||||
flex-shrink: 0;
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
&::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
background: radial-gradient(circle at 88% -20%, rgb(255 255 255 / 22%), transparent 55%);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
&-left {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 16px;
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
&-ico {
|
|
||||||
width: 48px;
|
|
||||||
height: 48px;
|
|
||||||
border-radius: 14px;
|
|
||||||
background: rgb(255 255 255 / 16%);
|
|
||||||
border: 1px solid rgb(255 255 255 / 25%);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 20px;
|
|
||||||
color: #fff;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&-text {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
&-close {
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
background: rgb(255 255 255 / 14%);
|
|
||||||
border: none;
|
|
||||||
color: #fff;
|
|
||||||
width: 34px;
|
|
||||||
height: 34px;
|
|
||||||
border-radius: 50%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
flex-shrink: 0;
|
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
|
||||||
background: rgb(255 255 255 / 28%);
|
|
||||||
transform: rotate(90deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.pcd__title {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 20px;
|
|
||||||
font-weight: 800;
|
|
||||||
color: #fff;
|
|
||||||
line-height: 1.25;
|
|
||||||
letter-spacing: -0.01em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pcd__subtitle {
|
|
||||||
margin: 5px 0 0;
|
|
||||||
font-size: 13px;
|
|
||||||
color: rgb(255 255 255 / 88%);
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pcd__content {
|
|
||||||
padding: 26px 30px !important;
|
|
||||||
overflow-y: auto;
|
|
||||||
flex: 1 1 auto;
|
|
||||||
background: #fbfcfe;
|
|
||||||
|
|
||||||
mat-form-field {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.pcd__loading {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
color: #64748b;
|
|
||||||
font-size: 13px;
|
|
||||||
padding: 14px 0 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pcd__preview {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
background: #fff;
|
|
||||||
border: 1px solid #e2e8f0;
|
|
||||||
border-radius: 14px;
|
|
||||||
padding: 4px 18px;
|
|
||||||
margin: 0 0 18px;
|
|
||||||
|
|
||||||
&-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 11px 0;
|
|
||||||
border-bottom: 1px solid #f1f5f9;
|
|
||||||
|
|
||||||
&:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--highlight {
|
|
||||||
.pcd__preview-value {
|
|
||||||
color: #b91c1c;
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&--total {
|
|
||||||
margin: 0 -18px;
|
|
||||||
padding: 14px 18px;
|
|
||||||
background: linear-gradient(135deg, #fef2f2 0%, #fee2e2 100%);
|
|
||||||
border-bottom: none;
|
|
||||||
border-radius: 0 0 12px 12px;
|
|
||||||
|
|
||||||
.pcd__preview-label {
|
|
||||||
color: #b91c1c;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pcd__preview-value {
|
|
||||||
color: #0f172a;
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&-label {
|
|
||||||
font-size: 12.5px;
|
|
||||||
color: #64748b;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
&-value {
|
|
||||||
font-family: 'JetBrains Mono', 'DM Sans', monospace;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #0f172a;
|
|
||||||
}
|
|
||||||
|
|
||||||
&-sub {
|
|
||||||
font-family: 'DM Sans', sans-serif;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #94a3b8;
|
|
||||||
margin-left: 4px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.pcd__preview--tenure {
|
|
||||||
margin-bottom: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pcd__preview-row--waived {
|
|
||||||
.pcd__preview-label {
|
|
||||||
color: #16a34a;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.pcd__penalty-waived-tag {
|
|
||||||
font-family: 'DM Sans', sans-serif;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
color: #15803d;
|
|
||||||
background: #dcfce7;
|
|
||||||
border: 1px solid #86efac;
|
|
||||||
border-radius: 999px;
|
|
||||||
padding: 3px 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pcd__waive-checkbox {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
background: #fff;
|
|
||||||
border: 1px solid #e2e8f0;
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 14px 16px;
|
|
||||||
margin: 0 0 18px;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #0f172a;
|
|
||||||
|
|
||||||
::ng-deep .mdc-form-field {
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
::ng-deep .mdc-checkbox {
|
|
||||||
margin-top: -9px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.pcd__waive-locked {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 10px;
|
|
||||||
background: #f0fdf4;
|
|
||||||
border: 1px solid #bbf7d0;
|
|
||||||
border-left: 4px solid #16a34a;
|
|
||||||
border-radius: 10px;
|
|
||||||
color: #15803d;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.5;
|
|
||||||
padding: 12px 16px;
|
|
||||||
margin: 0 0 18px;
|
|
||||||
|
|
||||||
strong {
|
|
||||||
color: #166534;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.pcd__waive-hint {
|
|
||||||
display: block;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #64748b;
|
|
||||||
margin-top: 3px;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pcd__section-label {
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.06em;
|
|
||||||
color: #475569;
|
|
||||||
margin: 10px 0 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Action Options Grid - 2 Equal Columns */
|
|
||||||
.pcd__options {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, 1fr);
|
|
||||||
gap: 14px;
|
|
||||||
margin-bottom: 18px;
|
|
||||||
|
|
||||||
@media (width <= 560px) {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.pcd__option {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 14px;
|
|
||||||
text-align: left;
|
|
||||||
background: #fff;
|
|
||||||
border: 1.5px solid #e2e8f0;
|
|
||||||
border-radius: 14px;
|
|
||||||
padding: 16px;
|
|
||||||
cursor: pointer;
|
|
||||||
font: inherit;
|
|
||||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
border-color: #fca5a5;
|
|
||||||
background: #f8fafc;
|
|
||||||
box-shadow: 0 6px 16px rgb(15 23 42 / 6%);
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
&--active {
|
|
||||||
border-color: #dc2626;
|
|
||||||
background: #fef2f2;
|
|
||||||
box-shadow: 0 0 0 1.5px #dc2626;
|
|
||||||
|
|
||||||
.pcd__option-ico {
|
|
||||||
background: #dc2626;
|
|
||||||
color: #fff;
|
|
||||||
border-color: #dc2626;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&-ico {
|
|
||||||
width: 38px;
|
|
||||||
height: 38px;
|
|
||||||
min-width: 38px;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: #f1f5f9;
|
|
||||||
border: 1px solid #e2e8f0;
|
|
||||||
color: #dc2626;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 15px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
&-text {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
&-title {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #0f172a;
|
|
||||||
line-height: 1.3;
|
|
||||||
}
|
|
||||||
|
|
||||||
&-desc {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #64748b;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
&-check {
|
|
||||||
color: #dc2626;
|
|
||||||
font-size: 17px;
|
|
||||||
position: absolute;
|
|
||||||
top: 14px;
|
|
||||||
right: 14px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.pcd__option-error {
|
|
||||||
color: #b91c1c;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
margin: -10px 0 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pcd__hint {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 10px;
|
|
||||||
background: #f0f9ff;
|
|
||||||
border: 1px solid #bae6fd;
|
|
||||||
border-left: 4px solid #0284c7;
|
|
||||||
border-radius: 10px;
|
|
||||||
color: #0369a1;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.5;
|
|
||||||
padding: 12px 16px;
|
|
||||||
margin: 0 0 18px;
|
|
||||||
|
|
||||||
&--warn {
|
|
||||||
background: #fffbeb;
|
|
||||||
border-color: #fde68a;
|
|
||||||
border-left-color: #d97706;
|
|
||||||
color: #b45309;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.pcd__error {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 10px;
|
|
||||||
background: #fef2f2;
|
|
||||||
border: 1px solid #fecaca;
|
|
||||||
border-left: 4px solid #ef4444;
|
|
||||||
border-radius: 10px;
|
|
||||||
color: #b91c1c;
|
|
||||||
font-size: 13px;
|
|
||||||
padding: 12px 16px;
|
|
||||||
margin: 10px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pcd__actions {
|
|
||||||
padding: 18px 30px !important;
|
|
||||||
border-top: 1px solid #e2e8f0;
|
|
||||||
background: #fff;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 14px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
|
|
||||||
.pcd__btn-cancel {
|
|
||||||
border-radius: 10px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #475569;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pcd__btn-submit {
|
|
||||||
border-radius: 10px;
|
|
||||||
font-weight: 700;
|
|
||||||
padding: 0 26px;
|
|
||||||
height: 42px;
|
|
||||||
background: linear-gradient(135deg, #b91c1c 0%, #dc2626 100%);
|
|
||||||
color: #fff;
|
|
||||||
box-shadow: 0 8px 20px rgb(220 38 38 / 30%);
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
|
||||||
box-shadow: 0 10px 24px rgb(220 38 38 / 40%);
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:disabled {
|
|
||||||
background: #cbd5e1;
|
|
||||||
box-shadow: none;
|
|
||||||
color: #64748b;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-446
@@ -1,446 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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, OnInit, inject } from '@angular/core';
|
|
||||||
import {
|
|
||||||
MAT_DIALOG_DATA,
|
|
||||||
MatDialogRef,
|
|
||||||
MatDialogContent,
|
|
||||||
MatDialogActions,
|
|
||||||
MatDialogClose
|
|
||||||
} from '@angular/material/dialog';
|
|
||||||
import { CdkScrollable } from '@angular/cdk/scrolling';
|
|
||||||
import { CdkTextareaAutosize } from '@angular/cdk/text-field';
|
|
||||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
|
||||||
import { UntypedFormBuilder, UntypedFormGroup, UntypedFormControl, Validators } from '@angular/forms';
|
|
||||||
|
|
||||||
/** Custom Services */
|
|
||||||
import { FixedDepositsService } from '../../../fixed-deposits.service';
|
|
||||||
import { SettingsService } from 'app/settings/settings.service';
|
|
||||||
import { AccountingService } from 'app/accounting/accounting.service';
|
|
||||||
import { AlertService } from 'app/core/alert/alert.service';
|
|
||||||
import { Dates } from 'app/core/utils/dates';
|
|
||||||
import { FormatNumberPipe } from '../../../../../pipes/format-number.pipe';
|
|
||||||
import { GlAccountSelectorComponent } from 'app/shared/accounting/gl-account-selector/gl-account-selector.component';
|
|
||||||
import { GLAccount } from 'app/shared/models/general.model';
|
|
||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* "Premature Close" dialog -- Active-only, no reinvest option (the deposit hasn't matured, so there's
|
|
||||||
* nothing to renew): transfer the pre-maturity balance to a savings account, or post it to a GL account.
|
|
||||||
* There is deliberately no cash payout -- the backend does not offer or accept one for a premature
|
|
||||||
* closure, so the proceeds always stay traceable to an account. Same
|
|
||||||
* `.pcd__*` visual language as the Payment dialog ({@link PaymentDialogComponent}), in this Danger Zone
|
|
||||||
* action's own red accent rather than Payment's blue, since it's an irreversible, usually
|
|
||||||
* penalty-bearing action rather than a routine one.
|
|
||||||
* <p>
|
|
||||||
* The premature amount is date-dependent (unlike Payment's one-shot template load): every change to
|
|
||||||
* Closed On Date re-calls `calculatePrematureAmount`, same as the full-page form this replaces did.
|
|
||||||
*/
|
|
||||||
@Component({
|
|
||||||
selector: 'mifosx-premature-close-dialog',
|
|
||||||
templateUrl: './premature-close-dialog.component.html',
|
|
||||||
styleUrls: ['./premature-close-dialog.component.scss'],
|
|
||||||
imports: [
|
|
||||||
...STANDALONE_SHARED_IMPORTS,
|
|
||||||
CdkScrollable,
|
|
||||||
MatDialogContent,
|
|
||||||
MatDialogActions,
|
|
||||||
MatDialogClose,
|
|
||||||
CdkTextareaAutosize,
|
|
||||||
FaIconComponent,
|
|
||||||
FormatNumberPipe,
|
|
||||||
GlAccountSelectorComponent
|
|
||||||
]
|
|
||||||
})
|
|
||||||
export class PrematureCloseDialogComponent implements OnInit {
|
|
||||||
/** The `onAccountClosureId` codes Premature Close can offer -- same codes Payment/Close use. */
|
|
||||||
private static readonly WITHDRAW_DEPOSIT_ID = 100;
|
|
||||||
private static readonly TRANSFER_TO_SAVINGS_ID = 200;
|
|
||||||
private static readonly TRANSFER_TO_GL_ID = 600;
|
|
||||||
|
|
||||||
private static readonly ACTION_ICONS: Record<number, string> = {
|
|
||||||
[PrematureCloseDialogComponent.WITHDRAW_DEPOSIT_ID]: 'money-bill-wave',
|
|
||||||
[PrematureCloseDialogComponent.TRANSFER_TO_SAVINGS_ID]: 'wallet',
|
|
||||||
[PrematureCloseDialogComponent.TRANSFER_TO_GL_ID]: 'book'
|
|
||||||
};
|
|
||||||
|
|
||||||
private static readonly ACTION_DESCRIPTIONS: Record<number, string> = {
|
|
||||||
[PrematureCloseDialogComponent.WITHDRAW_DEPOSIT_ID]: 'Pay out the full balance in cash',
|
|
||||||
[PrematureCloseDialogComponent.TRANSFER_TO_SAVINGS_ID]: 'Move the full balance to another savings account',
|
|
||||||
[PrematureCloseDialogComponent.TRANSFER_TO_GL_ID]: 'Post the full balance to a GL account'
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mirrors the backend's PrematureClosureReasonType values exactly -- see that enum for why these
|
|
||||||
* four (everything but Normal) always waive the penalty by RBI policy rather than at the maker's
|
|
||||||
* discretion. `gatedBy` is the product-facility flag (from `data`) that must be true for the option
|
|
||||||
* to even be offered; `null` means always offered. Deceased Settlement / Nominee-Legal Heir Closure
|
|
||||||
* are gated because they are the product's own opt-in facilities (see SavingsProduct
|
|
||||||
* deceasedSettlement/noPenaltyNomineeHeirClosure) -- a product that never enabled one shouldn't let
|
|
||||||
* a maker pick it to bypass a penalty that product's terms don't actually exempt.
|
|
||||||
*/
|
|
||||||
private static readonly NORMAL_REASON_ID = 100;
|
|
||||||
private static readonly ALL_CLOSURE_REASONS: {
|
|
||||||
id: number;
|
|
||||||
label: string;
|
|
||||||
autoWaived: boolean;
|
|
||||||
gatedBy: 'deceasedSettlement' | 'noPenaltyNomineeHeirClosure' | null;
|
|
||||||
}[] = [
|
|
||||||
{ id: 100, label: 'Normal Premature Closure', autoWaived: false, gatedBy: null },
|
|
||||||
{ id: 200, label: 'Premature Extension / Renewal', autoWaived: true, gatedBy: null },
|
|
||||||
{ id: 300, label: 'Conversion to Another Term Deposit', autoWaived: true, gatedBy: null },
|
|
||||||
{ id: 400, label: 'Deceased Settlement', autoWaived: true, gatedBy: 'deceasedSettlement' },
|
|
||||||
{ id: 500, label: 'Nominee / Legal Heir Closure', autoWaived: true, gatedBy: 'noPenaltyNomineeHeirClosure' }
|
|
||||||
];
|
|
||||||
|
|
||||||
dialogRef = inject<MatDialogRef<PrematureCloseDialogComponent>>(MatDialogRef);
|
|
||||||
data: {
|
|
||||||
accountId: number | string;
|
|
||||||
accountNo?: string;
|
|
||||||
depositAmount?: number;
|
|
||||||
nominalAnnualInterestRate?: number;
|
|
||||||
preClosurePenalApplicable?: boolean;
|
|
||||||
preClosurePenalInterest?: number;
|
|
||||||
preClosurePenalInterestOnType?: { value: string };
|
|
||||||
deceasedSettlement?: boolean;
|
|
||||||
noPenaltyNomineeHeirClosure?: boolean;
|
|
||||||
activatedOnDate?: any;
|
|
||||||
maturityDate?: any;
|
|
||||||
currentBalance?: number;
|
|
||||||
} = inject(MAT_DIALOG_DATA);
|
|
||||||
private formBuilder = inject(UntypedFormBuilder);
|
|
||||||
private fixedDepositsService = inject(FixedDepositsService);
|
|
||||||
private settingsService = inject(SettingsService);
|
|
||||||
private accountingService = inject(AccountingService);
|
|
||||||
private alertService = inject(AlertService);
|
|
||||||
private dateUtils = inject(Dates);
|
|
||||||
|
|
||||||
prematureCloseForm: UntypedFormGroup;
|
|
||||||
onAccountClosureOptions: any[] = [];
|
|
||||||
savingsAccountsData: any[] = [];
|
|
||||||
/** Postable GL accounts, loaded once -- only read when the GL action is chosen. */
|
|
||||||
glAccountsData: GLAccount[] = [];
|
|
||||||
maturityAmount: number = null;
|
|
||||||
/** The rate actually resolved for THIS preview by the backend (actual-period vs contracted,
|
|
||||||
* penalty-deducted or not) -- distinct from data.nominalAnnualInterestRate, which is always the
|
|
||||||
* account's unmodified contracted rate. Read fresh on every recalculation. */
|
|
||||||
effectiveInterestRate: number = null;
|
|
||||||
loadingAmount = true;
|
|
||||||
loadFailed = false;
|
|
||||||
submitting = false;
|
|
||||||
submitError: string = null;
|
|
||||||
minDate = new Date(2000, 0, 1);
|
|
||||||
maxDate: Date;
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
|
||||||
this.maxDate = this.settingsService.businessDate;
|
|
||||||
this.prematureCloseForm = this.formBuilder.group({
|
|
||||||
closedOnDate: [
|
|
||||||
this.maxDate,
|
|
||||||
Validators.required
|
|
||||||
],
|
|
||||||
// Both always present (not added conditionally like onAccountClosureId below) so submit()
|
|
||||||
// always carries a value, even for a product with no penalty configured at all -- the backend
|
|
||||||
// ignores them there anyway, since there is nothing to waive.
|
|
||||||
prematureClosureReasonId: [PrematureCloseDialogComponent.NORMAL_REASON_ID],
|
|
||||||
waivePreClosurePenalty: [false]
|
|
||||||
});
|
|
||||||
|
|
||||||
this.prematureCloseForm
|
|
||||||
.get('closedOnDate')
|
|
||||||
.valueChanges.subscribe((date: Date) => this.calculatePrematureAmount(date));
|
|
||||||
// Waiving/un-waiving recalculates the preview the same way changing the date does -- the maker
|
|
||||||
// should see the actual amount they're about to confirm, not the penalty-applied one, the moment
|
|
||||||
// they tick the box.
|
|
||||||
this.prematureCloseForm
|
|
||||||
.get('waivePreClosurePenalty')
|
|
||||||
.valueChanges.subscribe(() => this.calculatePrematureAmount(this.prematureCloseForm.get('closedOnDate').value));
|
|
||||||
// Picking an RBI-mandated waiver reason forces the checkbox on (see onClosureReasonChange) and,
|
|
||||||
// like it, recalculates the preview so the amount shown always matches the reason selected.
|
|
||||||
this.prematureCloseForm
|
|
||||||
.get('prematureClosureReasonId')
|
|
||||||
.valueChanges.subscribe((id: number) => this.onClosureReasonChange(id));
|
|
||||||
// Same date the form already starts with -- without this the amount/options never load unless the
|
|
||||||
// maker happens to touch the date picker, which the full-page form this replaces required too.
|
|
||||||
this.calculatePrematureAmount(this.maxDate);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The reason list this account's product actually allows -- see ALL_CLOSURE_REASONS' `gatedBy`. */
|
|
||||||
get closureReasonOptions(): { id: number; label: string; autoWaived: boolean }[] {
|
|
||||||
return PrematureCloseDialogComponent.ALL_CLOSURE_REASONS.filter(
|
|
||||||
(reason) => !reason.gatedBy || this.data[reason.gatedBy]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** True when the selected reason waives the penalty by policy, not maker choice -- the checkbox is
|
|
||||||
* hidden in favour of a plain statement of that fact when this is true (see the template). */
|
|
||||||
get isAutoWaivedReason(): boolean {
|
|
||||||
return !!this.selectedReason?.autoWaived;
|
|
||||||
}
|
|
||||||
|
|
||||||
get selectedReasonLabel(): string {
|
|
||||||
return this.selectedReason?.label ?? '';
|
|
||||||
}
|
|
||||||
|
|
||||||
private get selectedReason(): { id: number; label: string; autoWaived: boolean } | undefined {
|
|
||||||
const id = this.prematureCloseForm?.get('prematureClosureReasonId')?.value;
|
|
||||||
return PrematureCloseDialogComponent.ALL_CLOSURE_REASONS.find((reason) => reason.id === id);
|
|
||||||
}
|
|
||||||
|
|
||||||
private onClosureReasonChange(reasonId: number): void {
|
|
||||||
const reason = PrematureCloseDialogComponent.ALL_CLOSURE_REASONS.find((r) => r.id === reasonId);
|
|
||||||
if (reason?.autoWaived) {
|
|
||||||
// emitEvent: false -- the explicit calculatePrematureAmount call below already covers this
|
|
||||||
// change; without it, this setValue would also fire waivePreClosurePenalty's own subscription
|
|
||||||
// and the amount would be calculated twice for one reason change.
|
|
||||||
this.prematureCloseForm.get('waivePreClosurePenalty').setValue(true, { emitEvent: false });
|
|
||||||
}
|
|
||||||
this.calculatePrematureAmount(this.prematureCloseForm.get('closedOnDate').value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private calculatePrematureAmount(date: Date): void {
|
|
||||||
this.loadingAmount = true;
|
|
||||||
this.loadFailed = false;
|
|
||||||
const locale = this.settingsService.language.code;
|
|
||||||
const dateFormat = this.settingsService.dateFormat;
|
|
||||||
const data = {
|
|
||||||
closedOnDate: this.dateUtils.formatDate(date, dateFormat),
|
|
||||||
dateFormat,
|
|
||||||
locale,
|
|
||||||
prematureClosureReasonId: this.prematureCloseForm.get('prematureClosureReasonId')?.value,
|
|
||||||
waivePreClosurePenalty: !!this.prematureCloseForm.get('waivePreClosurePenalty')?.value
|
|
||||||
};
|
|
||||||
this.fixedDepositsService
|
|
||||||
.executeFixedDepositsAccountCommand(this.data.accountId as string, 'calculatePrematureAmount', data)
|
|
||||||
.subscribe({
|
|
||||||
next: (response: any) => {
|
|
||||||
this.savingsAccountsData = response.savingsAccounts ?? [];
|
|
||||||
this.onAccountClosureOptions = response.onAccountClosureOptions ?? [];
|
|
||||||
this.maturityAmount = response.maturityAmount;
|
|
||||||
this.effectiveInterestRate = response.nominalAnnualInterestRate ?? null;
|
|
||||||
this.loadingAmount = false;
|
|
||||||
if (!this.prematureCloseForm.contains('onAccountClosureId')) {
|
|
||||||
this.prematureCloseForm.addControl('onAccountClosureId', new UntypedFormControl('', Validators.required));
|
|
||||||
this.prematureCloseForm.addControl('note', new UntypedFormControl(''));
|
|
||||||
this.prematureCloseForm
|
|
||||||
.get('onAccountClosureId')
|
|
||||||
.valueChanges.subscribe((id: number) => this.onClosureTypeChange(id));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
// Without this the spinner would run forever on a failed load, leaving Cancel as the only way out.
|
|
||||||
error: () => {
|
|
||||||
this.loadingAmount = false;
|
|
||||||
this.loadFailed = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Adds/removes the destination control to match what the chosen type needs. */
|
|
||||||
private onClosureTypeChange(id: number): void {
|
|
||||||
const toSavings = id === PrematureCloseDialogComponent.TRANSFER_TO_SAVINGS_ID;
|
|
||||||
const toGl = id === PrematureCloseDialogComponent.TRANSFER_TO_GL_ID;
|
|
||||||
|
|
||||||
if (toSavings && !this.prematureCloseForm.contains('toSavingsAccountId')) {
|
|
||||||
this.prematureCloseForm.addControl('toSavingsAccountId', new UntypedFormControl('', Validators.required));
|
|
||||||
} else if (!toSavings) {
|
|
||||||
this.prematureCloseForm.removeControl('toSavingsAccountId');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (toGl) {
|
|
||||||
if (!this.prematureCloseForm.contains('toGlAccountId')) {
|
|
||||||
this.prematureCloseForm.addControl('toGlAccountId', new UntypedFormControl('', Validators.required));
|
|
||||||
}
|
|
||||||
this.loadGlAccounts();
|
|
||||||
} else {
|
|
||||||
this.prematureCloseForm.removeControl('toGlAccountId');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Both destinations carry the description through -- to the account transfer, or to the narration on
|
|
||||||
// the journal entry that credits the GL account.
|
|
||||||
if ((toSavings || toGl) && !this.prematureCloseForm.contains('transferDescription')) {
|
|
||||||
this.prematureCloseForm.addControl('transferDescription', new UntypedFormControl(''));
|
|
||||||
} else if (!toSavings && !toGl) {
|
|
||||||
this.prematureCloseForm.removeControl('transferDescription');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fetched on first use rather than with the dialog, since most closures never pick the GL action. */
|
|
||||||
private loadGlAccounts(): void {
|
|
||||||
if (this.glAccountsData.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.accountingService.getGlAccounts().subscribe((accounts: GLAccount[]) => {
|
|
||||||
this.glAccountsData = accounts ?? [];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The GL selector takes a control instance, not a control name. */
|
|
||||||
get toGlAccountControl(): UntypedFormControl {
|
|
||||||
return this.prematureCloseForm.get('toGlAccountId') as UntypedFormControl;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The gap between the deposit and the computed maturity amount -- the actual interest this premature
|
|
||||||
* closure earns, at whatever rate (contracted, or the lower pre-closure-penalty one below) applied. */
|
|
||||||
get interestEarned(): number | null {
|
|
||||||
if (this.maturityAmount === null || this.maturityAmount === undefined || !this.data.depositAmount) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return Number(this.maturityAmount) - Number(this.data.depositAmount);
|
|
||||||
}
|
|
||||||
|
|
||||||
get activatedOnDateDisplay(): string {
|
|
||||||
return this.formatFullDate(this.data.activatedOnDate);
|
|
||||||
}
|
|
||||||
|
|
||||||
get maturityDateDisplay(): string {
|
|
||||||
return this.formatFullDate(this.data.maturityDate);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The date every "as of" figure in this dialog is measured against -- the maker's own chosen Premature
|
|
||||||
* Close Date, falling back to Fineract's configured Business Date. Deliberately NOT the real-world
|
|
||||||
* clock (`new Date()`): Fineract's business date can lag behind the calendar (a bank's own admin
|
|
||||||
* advances it), and the backend's interest calculation is measured from THIS date, not today's actual
|
|
||||||
* date. Using the real clock here previously showed a "days elapsed" figure that disagreed with the
|
|
||||||
* interest the backend actually computed for the same closure -- both must read off the same clock.
|
|
||||||
*/
|
|
||||||
private get referenceDate(): Date {
|
|
||||||
const picked = this.prematureCloseForm?.get('closedOnDate')?.value;
|
|
||||||
return picked instanceof Date ? picked : this.maxDate;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Days from the current term's start to the chosen closure date -- 0 if the date is missing or the
|
|
||||||
* closure date is somehow before it, rather than a negative. */
|
|
||||||
get daysElapsed(): number | null {
|
|
||||||
const start = this.data.activatedOnDate ? this.dateUtils.parseDate(this.data.activatedOnDate) : null;
|
|
||||||
if (!start) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return Math.max(0, this.dateUtils.calculateDiff(start, this.referenceDate));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Days from the chosen closure date to the scheduled maturity date -- 0 once matured/overdue rather
|
|
||||||
* than negative, since this dialog is for a still-Active account closing early, not a matured one. */
|
|
||||||
get daysRemainingToMaturity(): number | null {
|
|
||||||
const maturity = this.data.maturityDate ? this.dateUtils.parseDate(this.data.maturityDate) : null;
|
|
||||||
if (!maturity) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return Math.max(0, this.dateUtils.calculateDiff(this.referenceDate, maturity));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Elapsed tenure as Years/Months/Days, the way a real passbook states it, rather than a raw day
|
|
||||||
* count that is hard to read for a multi-year FD. */
|
|
||||||
get tenureElapsedDisplay(): string {
|
|
||||||
const start = this.data.activatedOnDate ? this.dateUtils.parseDate(this.data.activatedOnDate) : null;
|
|
||||||
if (!start) {
|
|
||||||
return '—';
|
|
||||||
}
|
|
||||||
const { years, months, days } = PrematureCloseDialogComponent.yearsMonthsDaysBetween(start, this.referenceDate);
|
|
||||||
const parts: string[] = [];
|
|
||||||
if (years > 0) {
|
|
||||||
parts.push(`${years} yr${years === 1 ? '' : 's'}`);
|
|
||||||
}
|
|
||||||
if (months > 0) {
|
|
||||||
parts.push(`${months} mo${months === 1 ? '' : 's'}`);
|
|
||||||
}
|
|
||||||
if (days > 0 || parts.length === 0) {
|
|
||||||
parts.push(`${days} day${days === 1 ? '' : 's'}`);
|
|
||||||
}
|
|
||||||
return parts.join(', ');
|
|
||||||
}
|
|
||||||
|
|
||||||
private formatFullDate(value: any): string {
|
|
||||||
if (!value) {
|
|
||||||
return '—';
|
|
||||||
}
|
|
||||||
const parsed = this.dateUtils.parseDate(value);
|
|
||||||
// "28 September 2026" -- day/month-name/year, so this reads unambiguously regardless of the
|
|
||||||
// account's own configured short date format elsewhere in the app.
|
|
||||||
return this.dateUtils.formatDate(parsed, 'd MMMM y');
|
|
||||||
}
|
|
||||||
|
|
||||||
private static yearsMonthsDaysBetween(start: Date, end: Date): { years: number; months: number; days: number } {
|
|
||||||
let years = end.getFullYear() - start.getFullYear();
|
|
||||||
let months = end.getMonth() - start.getMonth();
|
|
||||||
let days = end.getDate() - start.getDate();
|
|
||||||
if (days < 0) {
|
|
||||||
months -= 1;
|
|
||||||
const daysInPreviousMonth = new Date(end.getFullYear(), end.getMonth(), 0).getDate();
|
|
||||||
days += daysInPreviousMonth;
|
|
||||||
}
|
|
||||||
if (months < 0) {
|
|
||||||
years -= 1;
|
|
||||||
months += 12;
|
|
||||||
}
|
|
||||||
return { years: Math.max(0, years), months: Math.max(0, months), days: Math.max(0, days) };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** True when the chosen action needs a destination account but the customer has none to pick. */
|
|
||||||
get noDestinationAccounts(): boolean {
|
|
||||||
return (
|
|
||||||
!this.loadingAmount &&
|
|
||||||
this.prematureCloseForm?.contains('toSavingsAccountId') &&
|
|
||||||
this.savingsAccountsData.length === 0
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
iconFor(id: number): string {
|
|
||||||
return PrematureCloseDialogComponent.ACTION_ICONS[id] ?? 'circle';
|
|
||||||
}
|
|
||||||
|
|
||||||
descriptionFor(id: number): string {
|
|
||||||
return PrematureCloseDialogComponent.ACTION_DESCRIPTIONS[id] ?? '';
|
|
||||||
}
|
|
||||||
|
|
||||||
selectClosureType(id: number): void {
|
|
||||||
this.prematureCloseForm.get('onAccountClosureId').setValue(id);
|
|
||||||
this.prematureCloseForm.get('onAccountClosureId').markAsTouched();
|
|
||||||
}
|
|
||||||
|
|
||||||
submit(): void {
|
|
||||||
const formValue = { ...this.prematureCloseForm.value };
|
|
||||||
const locale = this.settingsService.language.code;
|
|
||||||
const dateFormat = this.settingsService.dateFormat;
|
|
||||||
if (formValue.closedOnDate instanceof Date) {
|
|
||||||
formValue.closedOnDate = this.dateUtils.formatDate(formValue.closedOnDate, dateFormat);
|
|
||||||
}
|
|
||||||
const data = { ...formValue, dateFormat, locale };
|
|
||||||
this.submitting = true;
|
|
||||||
this.submitError = null;
|
|
||||||
this.fixedDepositsService
|
|
||||||
.executeFixedDepositsAccountCommand(this.data.accountId as string, 'prematureClose', data)
|
|
||||||
.subscribe({
|
|
||||||
next: (response: any) => {
|
|
||||||
// A command on a maker-checker permission is parked in the Checker Inbox, not executed:
|
|
||||||
// the platform rolls its transaction back and still answers HTTP 200 with
|
|
||||||
// rollbackTransaction: true. Without saying so, the dialog closes and the account reloads
|
|
||||||
// unchanged -- which reads as "nothing happened" rather than "waiting for approval".
|
|
||||||
if (response?.rollbackTransaction) {
|
|
||||||
this.alertService.alert({
|
|
||||||
type: 'Success',
|
|
||||||
message:
|
|
||||||
'Premature closure has been submitted for approval. The deposit is unchanged until a checker approves it.'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
this.dialogRef.close({ confirm: true });
|
|
||||||
},
|
|
||||||
// Keep the dialog open with the entered values and say what went wrong, rather than leaving a
|
|
||||||
// permanently disabled Confirm behind whatever modal the global interceptor threw up.
|
|
||||||
error: (error: any) => {
|
|
||||||
this.submitting = false;
|
|
||||||
this.submitError =
|
|
||||||
error?.error?.defaultUserMessage ??
|
|
||||||
error?.error?.errors?.[0]?.defaultUserMessage ??
|
|
||||||
'This account could not be prematurely closed. Please review the details and try again.';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+5
-75
@@ -17,7 +17,6 @@ import { CalculateInterestDialogComponent } from './custom-dialogs/calculate-int
|
|||||||
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';
|
||||||
import { PaymentDialogComponent } from './custom-dialogs/payment-dialog/payment-dialog.component';
|
import { PaymentDialogComponent } from './custom-dialogs/payment-dialog/payment-dialog.component';
|
||||||
import { PrematureCloseDialogComponent } from './custom-dialogs/premature-close-dialog/premature-close-dialog.component';
|
|
||||||
import { DepositCertificateDialogComponent } from 'app/shared/deposit-certificate-dialog/deposit-certificate-dialog.component';
|
import { DepositCertificateDialogComponent } from 'app/shared/deposit-certificate-dialog/deposit-certificate-dialog.component';
|
||||||
|
|
||||||
/** Custom Button Config. */
|
/** Custom Button Config. */
|
||||||
@@ -30,7 +29,6 @@ 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 { SettingsService } from 'app/settings/settings.service';
|
||||||
import { Dates } from 'app/core/utils/dates';
|
import { Dates } from 'app/core/utils/dates';
|
||||||
import { AlertService } from 'app/core/alert/alert.service';
|
|
||||||
import { AuthenticationService } from '../../../core/authentication/authentication.service';
|
import { AuthenticationService } from '../../../core/authentication/authentication.service';
|
||||||
import { MatTooltip } from '@angular/material/tooltip';
|
import { MatTooltip } from '@angular/material/tooltip';
|
||||||
import { NgClass, CurrencyPipe } from '@angular/common';
|
import { NgClass, CurrencyPipe } from '@angular/common';
|
||||||
@@ -75,7 +73,6 @@ import { DormancyAlertPanelComponent } from 'app/shared/dormancy-alert-panel/dor
|
|||||||
export class FixedDepositAccountViewComponent implements OnInit {
|
export class FixedDepositAccountViewComponent implements OnInit {
|
||||||
private route = inject(ActivatedRoute);
|
private route = inject(ActivatedRoute);
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
private alertService = inject(AlertService);
|
|
||||||
private fixedDepositsService = inject(FixedDepositsService);
|
private fixedDepositsService = inject(FixedDepositsService);
|
||||||
private savingsService = inject(SavingsService);
|
private savingsService = inject(SavingsService);
|
||||||
private accountsService = inject(AccountsService);
|
private accountsService = inject(AccountsService);
|
||||||
@@ -241,6 +238,7 @@ export class FixedDepositAccountViewComponent implements OnInit {
|
|||||||
case 'Undo Activation':
|
case 'Undo Activation':
|
||||||
case 'Add Charge':
|
case 'Add Charge':
|
||||||
case 'Withdrawn by Client':
|
case 'Withdrawn by Client':
|
||||||
|
case 'Premature Close':
|
||||||
case 'Deposit':
|
case 'Deposit':
|
||||||
case 'Withdrawal':
|
case 'Withdrawal':
|
||||||
this.router.navigate([`actions/${name}`], { relativeTo: this.route });
|
this.router.navigate([`actions/${name}`], { relativeTo: this.route });
|
||||||
@@ -269,21 +267,16 @@ export class FixedDepositAccountViewComponent implements OnInit {
|
|||||||
case 'Payment':
|
case 'Payment':
|
||||||
this.openPaymentDialog();
|
this.openPaymentDialog();
|
||||||
break;
|
break;
|
||||||
case 'Premature Close':
|
|
||||||
this.openPrematureCloseDialog();
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Opens the Payment action -- only offered once the FD has actually Matured (see
|
* Opens the Payment action -- unlike Premature Close/Close, works whether the account is Active or
|
||||||
* FixedDepositsButtonsConfiguration), and the only place a Partial Reinvest can be done. Also the
|
* Matured, and is the only place a Partial Reinvest can be done.
|
||||||
* exact same dialog the FD Dashboard's Renew action opens (see fd-dashboard.component.ts's
|
|
||||||
* openRenewDialog), just without dashboardRenewal data, so its date picker stays visible.
|
|
||||||
*/
|
*/
|
||||||
private openPaymentDialog(): void {
|
private openPaymentDialog(): void {
|
||||||
const paymentDialogRef = this.dialog.open(PaymentDialogComponent, {
|
const paymentDialogRef = this.dialog.open(PaymentDialogComponent, {
|
||||||
width: '720px',
|
width: '640px',
|
||||||
maxWidth: '95vw',
|
maxWidth: '95vw',
|
||||||
data: {
|
data: {
|
||||||
accountId: this.fixedDepositsAccountData.id,
|
accountId: this.fixedDepositsAccountData.id,
|
||||||
@@ -300,60 +293,6 @@ export class FixedDepositAccountViewComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The start date of the term currently running -- the last entry in renewalHistory, which is ordered
|
|
||||||
* oldest-first (see RenewalHistoryTabComponent). Falls back to the account's own activation date for
|
|
||||||
* an FD that has never been renewed (or whose renewalHistory hasn't loaded), which is the same date
|
|
||||||
* in that case anyway.
|
|
||||||
*/
|
|
||||||
private get currentTermStartDate(): any {
|
|
||||||
const history = this.fixedDepositsAccountData?.renewalHistory;
|
|
||||||
const latestTerm = Array.isArray(history) && history.length > 0 ? history[history.length - 1] : null;
|
|
||||||
return latestTerm?.activatedondate ?? this.fixedDepositsAccountData?.timeline?.activatedOnDate;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Opens the Premature Close action -- Active-only, no reinvest option, since the deposit hasn't
|
|
||||||
* matured yet.
|
|
||||||
*/
|
|
||||||
private openPrematureCloseDialog(): void {
|
|
||||||
const prematureCloseDialogRef = this.dialog.open(PrematureCloseDialogComponent, {
|
|
||||||
width: '720px',
|
|
||||||
maxWidth: '95vw',
|
|
||||||
data: {
|
|
||||||
accountId: this.fixedDepositsAccountData.id,
|
|
||||||
accountNo: this.fixedDepositsAccountData.fdAccountNumber || this.fixedDepositsAccountData.accountNo,
|
|
||||||
// calculatePrematureAmount only ever returns the final maturityAmount, not how it was reached --
|
|
||||||
// these come from the account's own data (already loaded for this page) so the dialog can show
|
|
||||||
// the breakdown (deposit, contracted rate, penalty, interest earned) behind that one number.
|
|
||||||
depositAmount: this.fixedDepositsAccountData.depositAmount,
|
|
||||||
nominalAnnualInterestRate: this.fixedDepositsAccountData.nominalAnnualInterestRate,
|
|
||||||
preClosurePenalApplicable: this.fixedDepositsAccountData.preClosurePenalApplicable,
|
|
||||||
preClosurePenalInterest: this.fixedDepositsAccountData.preClosurePenalInterest,
|
|
||||||
preClosurePenalInterestOnType: this.fixedDepositsAccountData.preClosurePenalInterestOnType,
|
|
||||||
// Gate which RBI-mandated waiver reasons the dialog offers -- these two are the product's own
|
|
||||||
// facility toggles (Deceased Settlement / No Penalty Nominee-Heir Closure), off by default, so
|
|
||||||
// a product that never enabled them shouldn't offer a reason implying it did.
|
|
||||||
deceasedSettlement: this.fixedDepositsAccountData.deceasedSettlement,
|
|
||||||
noPenaltyNomineeHeirClosure: this.fixedDepositsAccountData.noPenaltyNomineeHeirClosure,
|
|
||||||
// Tenure context (current term's start date, maturity date, current balance) so the dialog can
|
|
||||||
// show how far into THIS term the account is and how much is actually sitting in it today, not
|
|
||||||
// just the penalty-adjusted amount calculatePrematureAmount returns. Deliberately the current
|
|
||||||
// term's own start (see currentTermStartDate), not the account's original activation -- a
|
|
||||||
// renewed FD's interest/tenure runs from the renewal, and showing the original activation date
|
|
||||||
// here would make an account renewed days ago look like it has been running for months.
|
|
||||||
activatedOnDate: this.currentTermStartDate,
|
|
||||||
maturityDate: this.fixedDepositsAccountData.maturityDate,
|
|
||||||
currentBalance: this.fixedDepositsAccountData.summary?.accountBalance
|
|
||||||
}
|
|
||||||
});
|
|
||||||
prematureCloseDialogRef.afterClosed().subscribe((response: any) => {
|
|
||||||
if (response?.confirm) {
|
|
||||||
this.reload();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deletes Fixed Deposits Account.
|
* Deletes Fixed Deposits Account.
|
||||||
*/
|
*/
|
||||||
@@ -421,16 +360,7 @@ export class FixedDepositAccountViewComponent implements OnInit {
|
|||||||
if (response.confirm) {
|
if (response.confirm) {
|
||||||
this.fixedDepositsService
|
this.fixedDepositsService
|
||||||
.executeFixedDepositsAccountCommand(this.fixedDepositsAccountData.id, 'postInterest', {})
|
.executeFixedDepositsAccountCommand(this.fixedDepositsAccountData.id, 'postInterest', {})
|
||||||
.subscribe((commandResponse: any) => {
|
.subscribe(() => {
|
||||||
// Parked in the Checker Inbox rather than executed (HTTP 200 with
|
|
||||||
// rollbackTransaction: true), so the reload below shows no new interest posting.
|
|
||||||
if (commandResponse?.rollbackTransaction) {
|
|
||||||
this.alertService.alert({
|
|
||||||
type: 'Success',
|
|
||||||
message:
|
|
||||||
'Interest posting has been sent for checker approval. No interest is posted until a checker approves it.'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
this.reload();
|
this.reload();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-16
@@ -57,12 +57,11 @@ export class FixedDepositsButtonsConfiguration {
|
|||||||
];
|
];
|
||||||
break;
|
break;
|
||||||
case 'Matured':
|
case 'Matured':
|
||||||
// 'Close' (the generic Fineract apply-maturity-instructions action) is deliberately left out
|
|
||||||
// here -- what happens to a matured FD is now handled either automatically by the nightly job
|
|
||||||
// (an account opened with an explicit instruction) or manually via the FD Dashboard's Renew
|
|
||||||
// action (an account left without one, backdated within grace / gap-interest after). Closing
|
|
||||||
// from here would go around both of those paths.
|
|
||||||
this.buttonsArray = [
|
this.buttonsArray = [
|
||||||
|
{
|
||||||
|
name: 'Close',
|
||||||
|
icon: 'fa fa-arrow-right'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'Calculate Interest',
|
name: 'Calculate Interest',
|
||||||
icon: 'fa fa-table'
|
icon: 'fa fa-table'
|
||||||
@@ -110,18 +109,7 @@ export class FixedDepositsButtonsConfiguration {
|
|||||||
setOptions(status: string) {
|
setOptions(status: string) {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'Active':
|
case 'Active':
|
||||||
this.optionArray = [
|
|
||||||
{
|
|
||||||
name: 'Post Interest'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Add Charge'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
break;
|
|
||||||
case 'Matured':
|
case 'Matured':
|
||||||
// Payment only makes sense once the deposit has actually matured -- an Active FD that needs
|
|
||||||
// closing early already has Premature Close for that.
|
|
||||||
this.optionArray = [
|
this.optionArray = [
|
||||||
{
|
{
|
||||||
name: 'Post Interest'
|
name: 'Post Interest'
|
||||||
|
|||||||
+10
-8
@@ -41,8 +41,8 @@
|
|||||||
) {
|
) {
|
||||||
<div class="rh-node">
|
<div class="rh-node">
|
||||||
<div class="rh-node__rail">
|
<div class="rh-node__rail">
|
||||||
<span class="rh-node__dot" [class.rh-node__dot--active]="isTermActive(term, last)">
|
<span class="rh-node__dot" [class.rh-node__dot--active]="term.renewalstatus === 'ACTIVE'">
|
||||||
@if (isTermActive(term, last)) {
|
@if (term.renewalstatus === 'ACTIVE') {
|
||||||
<span class="rh-node__pulse"></span>
|
<span class="rh-node__pulse"></span>
|
||||||
}
|
}
|
||||||
</span>
|
</span>
|
||||||
@@ -51,15 +51,15 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rh-card" [class.rh-card--active]="isTermActive(term, last)">
|
<div class="rh-card" [class.rh-card--active]="term.renewalstatus === 'ACTIVE'">
|
||||||
<div class="rh-card__head">
|
<div class="rh-card__head">
|
||||||
<div class="rh-card__title-group">
|
<div class="rh-card__title-group">
|
||||||
<h4 class="rh-card__title">
|
<h4 class="rh-card__title">
|
||||||
{{ term.renewalcycleno === 0 ? 'Original Term' : 'Renewal ' + term.renewalcycleno }}
|
{{ term.renewalcycleno === 0 ? 'Original Term' : 'Renewal ' + term.renewalcycleno }}
|
||||||
</h4>
|
</h4>
|
||||||
<span class="rh-badge" [class.rh-badge--active]="isTermActive(term, last)">
|
<span class="rh-badge" [class.rh-badge--active]="term.renewalstatus === 'ACTIVE'">
|
||||||
<fa-icon [icon]="isTermActive(term, last) ? 'check-circle' : 'clock'"></fa-icon>
|
<fa-icon [icon]="term.renewalstatus === 'ACTIVE' ? 'check-circle' : 'clock'"></fa-icon>
|
||||||
{{ termStatusLabel(term, last) }}
|
{{ term.renewalstatus === 'ACTIVE' ? 'Active' : 'Matured' }}
|
||||||
</span>
|
</span>
|
||||||
@if (renewalTypeLabel(term)) {
|
@if (renewalTypeLabel(term)) {
|
||||||
<span class="rh-tag">
|
<span class="rh-tag">
|
||||||
@@ -95,7 +95,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="rh-stat">
|
<div class="rh-stat">
|
||||||
<span class="rh-stat__label">{{
|
<span class="rh-stat__label">{{
|
||||||
isTermActive(term, last) ? 'Interest So Far' : 'Interest Earned'
|
term.renewalstatus === 'ACTIVE' ? 'Interest So Far' : 'Interest Earned'
|
||||||
}}</span>
|
}}</span>
|
||||||
<span class="rh-stat__value rh-stat__value--positive">
|
<span class="rh-stat__value rh-stat__value--positive">
|
||||||
{{
|
{{
|
||||||
@@ -106,7 +106,9 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="rh-stat rh-stat--highlight">
|
<div class="rh-stat rh-stat--highlight">
|
||||||
<span class="rh-stat__label">{{ maturityLabel(term, last) }}</span>
|
<span class="rh-stat__label">{{
|
||||||
|
term.maturedamount === null || term.maturedamount === undefined ? 'Projected Maturity' : 'Matured At'
|
||||||
|
}}</span>
|
||||||
<span class="rh-stat__value">
|
<span class="rh-stat__value">
|
||||||
{{ maturityFor(term) | currency: currency.code : 'symbol-narrow' }}
|
{{ maturityFor(term) | currency: currency.code : 'symbol-narrow' }}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
-45
@@ -183,51 +183,6 @@ export class RenewalHistoryTabComponent implements OnInit {
|
|||||||
return term?.renewaltype === 'MANUAL' ? 'user-cog' : 'robot';
|
return term?.renewaltype === 'MANUAL' ? 'user-cog' : 'robot';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether one term's card should show as "still running". `term.renewalstatus` only ever flips from
|
|
||||||
* ACTIVE to MATURED when a term rolls into its successor (see FixedDepositRenewalHistory#markMatured
|
|
||||||
* on the backend) -- a premature closure never touches that row, so the latest term's archived status
|
|
||||||
* would read ACTIVE forever even after the whole account has closed. For the LAST term only, this
|
|
||||||
* cross-checks the account's own live status instead of trusting the archived flag; every earlier
|
|
||||||
* term is reliably matured already (something renewed past it), so its own renewalstatus is
|
|
||||||
* trustworthy as-is and doesn't need the live account status at all.
|
|
||||||
*/
|
|
||||||
isTermActive(term: any, isLast: boolean): boolean {
|
|
||||||
if (isLast && this.fixedDepositsAccountData?.status) {
|
|
||||||
return !!this.fixedDepositsAccountData.status.active;
|
|
||||||
}
|
|
||||||
return term?.renewalstatus === 'ACTIVE';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Badge text for one term's card -- see {@link isTermActive} for why the last term needs its own check. */
|
|
||||||
termStatusLabel(term: any, isLast: boolean): string {
|
|
||||||
if (this.isTermActive(term, isLast)) {
|
|
||||||
return 'Active';
|
|
||||||
}
|
|
||||||
if (isLast && this.fixedDepositsAccountData?.status?.prematureClosed) {
|
|
||||||
return 'Premature Closed';
|
|
||||||
}
|
|
||||||
return 'Matured';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Label for the maturity/value figure -- "Projected Maturity" only makes sense while the term is
|
|
||||||
* still genuinely running towards that projection. `term.maturedamount` (the archived settled value)
|
|
||||||
* is only ever written when a term rolls into its successor, same gap as {@link isTermActive}: a
|
|
||||||
* premature closure settles the account without ever touching that column, so the last term would
|
|
||||||
* keep reading as a live projection forever even though the figure showing is already the final,
|
|
||||||
* settled one (see {@link maturityFor}).
|
|
||||||
*/
|
|
||||||
maturityLabel(term: any, isLast: boolean): string {
|
|
||||||
if (term?.maturedamount != null) {
|
|
||||||
return 'Matured At';
|
|
||||||
}
|
|
||||||
if (isLast && !this.isTermActive(term, isLast)) {
|
|
||||||
return 'Closed At';
|
|
||||||
}
|
|
||||||
return 'Projected Maturity';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The rate-by-tenure chart for one term of this FD's history, normalised to one shape regardless of source.
|
* The rate-by-tenure chart for one term of this FD's history, normalised to one shape regardless of source.
|
||||||
*
|
*
|
||||||
|
|||||||
+11
-27
@@ -11,7 +11,6 @@ import { Component, OnInit, inject } from '@angular/core';
|
|||||||
import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
|
import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
|
||||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||||
import { Dates } from 'app/core/utils/dates';
|
import { Dates } from 'app/core/utils/dates';
|
||||||
import { AlertService } from 'app/core/alert/alert.service';
|
|
||||||
|
|
||||||
/** Custom Services */
|
/** Custom Services */
|
||||||
import { FixedDepositsService } from 'app/deposits/fixed-deposits/fixed-deposits.service';
|
import { FixedDepositsService } from 'app/deposits/fixed-deposits/fixed-deposits.service';
|
||||||
@@ -38,7 +37,6 @@ export class ApproveFixedDepositsAccountComponent implements OnInit {
|
|||||||
private route = inject(ActivatedRoute);
|
private route = inject(ActivatedRoute);
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
private settingsService = inject(SettingsService);
|
private settingsService = inject(SettingsService);
|
||||||
private alertService = inject(AlertService);
|
|
||||||
|
|
||||||
/** Minimum date allowed. */
|
/** Minimum date allowed. */
|
||||||
minDate = new Date(2000, 0, 1);
|
minDate = new Date(2000, 0, 1);
|
||||||
@@ -100,31 +98,17 @@ export class ApproveFixedDepositsAccountComponent implements OnInit {
|
|||||||
dateFormat,
|
dateFormat,
|
||||||
locale
|
locale
|
||||||
};
|
};
|
||||||
this.fixedDepositsService
|
this.fixedDepositsService.executeFixedDepositsAccountCommand(this.accountId, 'approve', data).subscribe(() => {
|
||||||
.executeFixedDepositsAccountCommand(this.accountId, 'approve', data)
|
const activationData = {
|
||||||
.subscribe((response: any) => {
|
activatedOnDate: approveFixedDepositsAccountFormData.approvedOnDate,
|
||||||
// Parked in the Checker Inbox rather than executed (HTTP 200 with rollbackTransaction: true).
|
dateFormat,
|
||||||
// The account is therefore still pending, so chaining activation onto it would be applied to an
|
locale
|
||||||
// unapproved account -- stop here and say what is actually waiting.
|
};
|
||||||
if (response?.rollbackTransaction) {
|
this.fixedDepositsService
|
||||||
this.alertService.alert({
|
.executeFixedDepositsAccountCommand(this.accountId, 'activate', activationData)
|
||||||
type: 'Success',
|
.subscribe(() => {
|
||||||
message:
|
|
||||||
'This approval has been sent for checker approval. The account stays pending until a checker approves it.'
|
|
||||||
});
|
|
||||||
this.router.navigate(['../../'], { relativeTo: this.route });
|
this.router.navigate(['../../'], { relativeTo: this.route });
|
||||||
return;
|
});
|
||||||
}
|
});
|
||||||
const activationData = {
|
|
||||||
activatedOnDate: approveFixedDepositsAccountFormData.approvedOnDate,
|
|
||||||
dateFormat,
|
|
||||||
locale
|
|
||||||
};
|
|
||||||
this.fixedDepositsService
|
|
||||||
.executeFixedDepositsAccountCommand(this.accountId, 'activate', activationData)
|
|
||||||
.subscribe(() => {
|
|
||||||
this.router.navigate(['../../'], { relativeTo: this.route });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-15
@@ -21,7 +21,6 @@ import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
|||||||
import { FixedDepositsService } from '../../fixed-deposits.service';
|
import { FixedDepositsService } from '../../fixed-deposits.service';
|
||||||
import { SettingsService } from 'app/settings/settings.service';
|
import { SettingsService } from 'app/settings/settings.service';
|
||||||
import { Dates } from 'app/core/utils/dates';
|
import { Dates } from 'app/core/utils/dates';
|
||||||
import { AlertService } from 'app/core/alert/alert.service';
|
|
||||||
import { CdkTextareaAutosize } from '@angular/cdk/text-field';
|
import { CdkTextareaAutosize } from '@angular/cdk/text-field';
|
||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
|
||||||
@@ -44,7 +43,6 @@ export class CloseFixedDepositsAccountComponent implements OnInit {
|
|||||||
private route = inject(ActivatedRoute);
|
private route = inject(ActivatedRoute);
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
private settingsService = inject(SettingsService);
|
private settingsService = inject(SettingsService);
|
||||||
private alertService = inject(AlertService);
|
|
||||||
|
|
||||||
/** Minimum date allowed. */
|
/** Minimum date allowed. */
|
||||||
minDate = new Date(2000, 0, 1);
|
minDate = new Date(2000, 0, 1);
|
||||||
@@ -141,18 +139,8 @@ export class CloseFixedDepositsAccountComponent implements OnInit {
|
|||||||
dateFormat,
|
dateFormat,
|
||||||
locale
|
locale
|
||||||
};
|
};
|
||||||
this.fixedDepositsService
|
this.fixedDepositsService.executeFixedDepositsAccountCommand(this.accountId, 'close', data).subscribe(() => {
|
||||||
.executeFixedDepositsAccountCommand(this.accountId, 'close', data)
|
this.router.navigate(['../../'], { relativeTo: this.route });
|
||||||
.subscribe((response: any) => {
|
});
|
||||||
// Parked in the Checker Inbox rather than executed (HTTP 200 with rollbackTransaction: true),
|
|
||||||
// so the deposit reloads unchanged -- say so instead of letting that read as "nothing happened".
|
|
||||||
if (response?.rollbackTransaction) {
|
|
||||||
this.alertService.alert({
|
|
||||||
type: 'Success',
|
|
||||||
message: 'Closure has been sent for checker approval. The deposit is unchanged until a checker approves it.'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
this.router.navigate(['../../'], { relativeTo: this.route });
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
@@ -24,6 +24,9 @@
|
|||||||
@if (actions['Add Charge']) {
|
@if (actions['Add Charge']) {
|
||||||
<mifosx-add-charge-fixed-deposits-account></mifosx-add-charge-fixed-deposits-account>
|
<mifosx-add-charge-fixed-deposits-account></mifosx-add-charge-fixed-deposits-account>
|
||||||
}
|
}
|
||||||
|
@if (actions['Premature Close']) {
|
||||||
|
<mifosx-premature-close-fixed-deposits-account></mifosx-premature-close-fixed-deposits-account>
|
||||||
|
}
|
||||||
@if (actions['Close']) {
|
@if (actions['Close']) {
|
||||||
<mifosx-close-fixed-deposits-account></mifosx-close-fixed-deposits-account>
|
<mifosx-close-fixed-deposits-account></mifosx-close-fixed-deposits-account>
|
||||||
}
|
}
|
||||||
|
|||||||
+4
@@ -15,6 +15,7 @@ import { ActivateFixedDepositsAccountComponent } from './activate-fixed-deposits
|
|||||||
import { UndoApprovalFixedDepositsAccountComponent } from './undo-approval-fixed-deposits-account/undo-approval-fixed-deposits-account.component';
|
import { UndoApprovalFixedDepositsAccountComponent } from './undo-approval-fixed-deposits-account/undo-approval-fixed-deposits-account.component';
|
||||||
import { WithdrawByClientFixedDepositsAccountComponent } from './withdraw-by-client-fixed-deposits-account/withdraw-by-client-fixed-deposits-account.component';
|
import { WithdrawByClientFixedDepositsAccountComponent } from './withdraw-by-client-fixed-deposits-account/withdraw-by-client-fixed-deposits-account.component';
|
||||||
import { AddChargeFixedDepositsAccountComponent } from './add-charge-fixed-deposits-account/add-charge-fixed-deposits-account.component';
|
import { AddChargeFixedDepositsAccountComponent } from './add-charge-fixed-deposits-account/add-charge-fixed-deposits-account.component';
|
||||||
|
import { PrematureCloseFixedDepositsAccountComponent } from './premature-close-fixed-deposits-account/premature-close-fixed-deposits-account.component';
|
||||||
import { CloseFixedDepositsAccountComponent } from './close-fixed-deposits-account/close-fixed-deposits-account.component';
|
import { CloseFixedDepositsAccountComponent } from './close-fixed-deposits-account/close-fixed-deposits-account.component';
|
||||||
import { FixedDepositsCashTransactionComponent } from './fixed-deposits-cash-transaction/fixed-deposits-cash-transaction.component';
|
import { FixedDepositsCashTransactionComponent } from './fixed-deposits-cash-transaction/fixed-deposits-cash-transaction.component';
|
||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
@@ -34,6 +35,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
|||||||
UndoApprovalFixedDepositsAccountComponent,
|
UndoApprovalFixedDepositsAccountComponent,
|
||||||
WithdrawByClientFixedDepositsAccountComponent,
|
WithdrawByClientFixedDepositsAccountComponent,
|
||||||
AddChargeFixedDepositsAccountComponent,
|
AddChargeFixedDepositsAccountComponent,
|
||||||
|
PrematureCloseFixedDepositsAccountComponent,
|
||||||
CloseFixedDepositsAccountComponent,
|
CloseFixedDepositsAccountComponent,
|
||||||
FixedDepositsCashTransactionComponent
|
FixedDepositsCashTransactionComponent
|
||||||
]
|
]
|
||||||
@@ -50,6 +52,7 @@ export class FixedDepositsAccountActionsComponent {
|
|||||||
'Undo Approval': boolean;
|
'Undo Approval': boolean;
|
||||||
'Undo Activation': boolean;
|
'Undo Activation': boolean;
|
||||||
'Add Charge': boolean;
|
'Add Charge': boolean;
|
||||||
|
'Premature Close': boolean;
|
||||||
'Withdrawn by Client': boolean;
|
'Withdrawn by Client': boolean;
|
||||||
Withdrawal: boolean;
|
Withdrawal: boolean;
|
||||||
Deposit: boolean;
|
Deposit: boolean;
|
||||||
@@ -61,6 +64,7 @@ export class FixedDepositsAccountActionsComponent {
|
|||||||
'Undo Approval': false,
|
'Undo Approval': false,
|
||||||
'Undo Activation': false,
|
'Undo Activation': false,
|
||||||
'Add Charge': false,
|
'Add Charge': false,
|
||||||
|
'Premature Close': false,
|
||||||
'Withdrawn by Client': false,
|
'Withdrawn by Client': false,
|
||||||
Withdrawal: false,
|
Withdrawal: false,
|
||||||
Deposit: false
|
Deposit: false
|
||||||
|
|||||||
+105
@@ -0,0 +1,105 @@
|
|||||||
|
<!--
|
||||||
|
Copyright since 2025 Mifos Initiative
|
||||||
|
|
||||||
|
This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<mat-card>
|
||||||
|
<form [formGroup]="prematureCloseAccountForm" (ngSubmit)="submit()">
|
||||||
|
<mat-card-content>
|
||||||
|
<div class="layout-column">
|
||||||
|
<mat-form-field (click)="closedOnDatePicker.open()">
|
||||||
|
<mat-label>{{ 'labels.inputs.Premature Close Date' | translate }}</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
[min]="minDate"
|
||||||
|
[max]="maxDate"
|
||||||
|
[matDatepicker]="closedOnDatePicker"
|
||||||
|
required
|
||||||
|
formControlName="closedOnDate"
|
||||||
|
/>
|
||||||
|
<mat-datepicker-toggle matSuffix [for]="closedOnDatePicker"></mat-datepicker-toggle>
|
||||||
|
<mat-datepicker #closedOnDatePicker></mat-datepicker>
|
||||||
|
@if (prematureCloseAccountForm.controls.closedOnDate.hasError('required')) {
|
||||||
|
<mat-error>
|
||||||
|
{{ 'labels.inputs.Premature Close Date' | translate }} {{ 'labels.commons.is' | translate }}
|
||||||
|
<strong>{{ 'labels.commons.required' | translate }}</strong>
|
||||||
|
</mat-error>
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
@if (prematureCloseAccountForm.controls.maturityAmount) {
|
||||||
|
<mat-form-field>
|
||||||
|
<mat-label>{{ 'labels.inputs.Maturity Amount' | translate }}</mat-label>
|
||||||
|
<input type="number" matInput formControlName="maturityAmount" />
|
||||||
|
</mat-form-field>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (prematureCloseAccountForm.contains('onAccountClosureId')) {
|
||||||
|
<mat-form-field>
|
||||||
|
<mat-label>{{ 'labels.inputs.Action' | translate }}</mat-label>
|
||||||
|
<mat-select required formControlName="onAccountClosureId">
|
||||||
|
@for (closureOption of onAccountClosureOptions; track closureOption) {
|
||||||
|
<mat-option [value]="closureOption.id">
|
||||||
|
{{ closureOption.value }}
|
||||||
|
</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
@if (prematureCloseAccountForm.controls.onAccountClosureId.hasError('required')) {
|
||||||
|
<mat-error>
|
||||||
|
{{ 'labels.inputs.Account action' | translate }} {{ 'labels.commons.is' | translate }}
|
||||||
|
<strong>{{ 'labels.commons.required' | translate }}</strong>
|
||||||
|
</mat-error>
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (prematureCloseAccountForm.contains('toSavingsAccountId')) {
|
||||||
|
<mat-form-field>
|
||||||
|
<mat-label>{{ 'labels.inputs.Transfer to Savings' | translate }}</mat-label>
|
||||||
|
<mat-select required formControlName="toSavingsAccountId">
|
||||||
|
@for (account of savingsAccountsData; track account) {
|
||||||
|
<mat-option [value]="account.id">
|
||||||
|
{{ account.accountNo }}
|
||||||
|
</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
@if (prematureCloseAccountForm.controls.toSavingsAccountId.hasError('required')) {
|
||||||
|
<mat-error>
|
||||||
|
{{ 'labels.inputs.Transfer to savings account' | translate }} {{ 'labels.commons.is' | translate }}
|
||||||
|
<strong>{{ 'labels.commons.required' | translate }}</strong>
|
||||||
|
</mat-error>
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (prematureCloseAccountForm.contains('transferDescription')) {
|
||||||
|
<mat-form-field>
|
||||||
|
<mat-label>{{ 'labels.inputs.Transfer Description' | translate }}</mat-label>
|
||||||
|
<input matInput formControlName="transferDescription" />
|
||||||
|
</mat-form-field>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (prematureCloseAccountForm.contains('note')) {
|
||||||
|
<mat-form-field>
|
||||||
|
<mat-label>{{ 'labels.inputs.Note' | translate }}</mat-label>
|
||||||
|
<textarea matInput formControlName="note" cdkTextareaAutosize cdkAutosizeMinRows="2"></textarea>
|
||||||
|
</mat-form-field>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</mat-card-content>
|
||||||
|
|
||||||
|
<mat-card-actions class="layout-row align-center gap-5px responsive-column">
|
||||||
|
<button type="button" mat-raised-button [routerLink]="['../../']">
|
||||||
|
{{ 'labels.buttons.Cancel' | translate }}
|
||||||
|
</button>
|
||||||
|
<button mat-raised-button color="warn" [disabled]="!prematureCloseAccountForm.valid">
|
||||||
|
{{ 'labels.buttons.Confirm' | translate }}
|
||||||
|
</button>
|
||||||
|
</mat-card-actions>
|
||||||
|
</form>
|
||||||
|
</mat-card>
|
||||||
|
</div>
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 37rem;
|
||||||
|
}
|
||||||
+179
@@ -0,0 +1,179 @@
|
|||||||
|
/**
|
||||||
|
* 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, OnInit, inject } from '@angular/core';
|
||||||
|
import {
|
||||||
|
UntypedFormGroup,
|
||||||
|
UntypedFormBuilder,
|
||||||
|
Validators,
|
||||||
|
UntypedFormControl,
|
||||||
|
ReactiveFormsModule
|
||||||
|
} from '@angular/forms';
|
||||||
|
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||||
|
|
||||||
|
/** Custom Services */
|
||||||
|
import { FixedDepositsService } from '../../fixed-deposits.service';
|
||||||
|
import { SettingsService } from 'app/settings/settings.service';
|
||||||
|
import { Dates } from 'app/core/utils/dates';
|
||||||
|
import { CdkTextareaAutosize } from '@angular/cdk/text-field';
|
||||||
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Premature Close Fixed Deposits Account Component
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-premature-close-fixed-deposits-account',
|
||||||
|
templateUrl: './premature-close-fixed-deposits-account.component.html',
|
||||||
|
styleUrls: ['./premature-close-fixed-deposits-account.component.scss'],
|
||||||
|
imports: [
|
||||||
|
...STANDALONE_SHARED_IMPORTS,
|
||||||
|
CdkTextareaAutosize
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class PrematureCloseFixedDepositsAccountComponent implements OnInit {
|
||||||
|
private formBuilder = inject(UntypedFormBuilder);
|
||||||
|
private fixedDepositsService = inject(FixedDepositsService);
|
||||||
|
private dateUtils = inject(Dates);
|
||||||
|
private route = inject(ActivatedRoute);
|
||||||
|
private router = inject(Router);
|
||||||
|
private settingsService = inject(SettingsService);
|
||||||
|
|
||||||
|
/** Minimum date allowed. */
|
||||||
|
minDate = new Date(2000, 0, 1);
|
||||||
|
/** Maximum date allowed. */
|
||||||
|
maxDate = new Date();
|
||||||
|
/** Premature Close FD Account form. */
|
||||||
|
prematureCloseAccountForm: UntypedFormGroup;
|
||||||
|
/** Savings Account Data */
|
||||||
|
savingsAccountsData: any;
|
||||||
|
/** On account Closure Options */
|
||||||
|
onAccountClosureOptions: any;
|
||||||
|
/** Fixed Deposits Account Id */
|
||||||
|
accountId: any;
|
||||||
|
/** Form submission event */
|
||||||
|
isSubmitted = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {FormBuilder} formBuilder Form Builder
|
||||||
|
* @param {FixedDepositsService} fixedDepositsService Fixed Deposits Service
|
||||||
|
* @param {Dates} dateUtils Date Utils
|
||||||
|
* @param {ActivatedRoute} route Activated Route
|
||||||
|
* @param {Router} router Router
|
||||||
|
* @param {SettingsService} settingsService Settings Service
|
||||||
|
*/
|
||||||
|
constructor() {
|
||||||
|
this.accountId = this.route.parent.snapshot.params['fixedDepositAccountId'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the premature close fd account form.
|
||||||
|
*/
|
||||||
|
ngOnInit() {
|
||||||
|
this.maxDate = this.settingsService.businessDate;
|
||||||
|
this.createPrematureCloseAccountForm();
|
||||||
|
this.buildDependencies();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the premature close fd account form.
|
||||||
|
*/
|
||||||
|
createPrematureCloseAccountForm() {
|
||||||
|
this.prematureCloseAccountForm = this.formBuilder.group({
|
||||||
|
closedOnDate: [
|
||||||
|
'',
|
||||||
|
Validators.required
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribes to value changes of parent control.
|
||||||
|
*/
|
||||||
|
buildDependencies() {
|
||||||
|
this.prematureCloseAccountForm.get('closedOnDate').valueChanges.subscribe((value: Date) => {
|
||||||
|
if (!this.isSubmitted) {
|
||||||
|
this.calculatePrematureAmount(value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates prematue amount based on closed on date.
|
||||||
|
* @param {Date} date Premature Close Date
|
||||||
|
*/
|
||||||
|
calculatePrematureAmount(date: Date) {
|
||||||
|
const locale = this.settingsService.language.code;
|
||||||
|
const dateFormat = this.settingsService.dateFormat;
|
||||||
|
const data = {
|
||||||
|
closedOnDate: this.dateUtils.formatDate(date, dateFormat),
|
||||||
|
dateFormat,
|
||||||
|
locale
|
||||||
|
};
|
||||||
|
this.fixedDepositsService
|
||||||
|
.executeFixedDepositsAccountCommand(this.accountId, 'calculatePrematureAmount', data)
|
||||||
|
.subscribe((response: any) => {
|
||||||
|
this.savingsAccountsData = response.savingsAccounts;
|
||||||
|
this.onAccountClosureOptions = response.onAccountClosureOptions;
|
||||||
|
this.prematureCloseAccountForm.addControl(
|
||||||
|
'maturityAmount',
|
||||||
|
new UntypedFormControl({ value: '', disabled: true })
|
||||||
|
);
|
||||||
|
this.prematureCloseAccountForm.addControl(
|
||||||
|
'onAccountClosureId',
|
||||||
|
new UntypedFormControl('', Validators.required)
|
||||||
|
);
|
||||||
|
this.prematureCloseAccountForm.addControl('note', new UntypedFormControl(''));
|
||||||
|
this.prematureCloseAccountForm.get('maturityAmount').patchValue(response.maturityAmount);
|
||||||
|
this.addTransferDetails();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribes to value changes of `onAccountClosureId` adds and removes transfer details accordingly.
|
||||||
|
*/
|
||||||
|
addTransferDetails() {
|
||||||
|
this.prematureCloseAccountForm.get('onAccountClosureId').valueChanges.subscribe((id: any) => {
|
||||||
|
if (id === 200) {
|
||||||
|
this.prematureCloseAccountForm.addControl(
|
||||||
|
'toSavingsAccountId',
|
||||||
|
new UntypedFormControl('', Validators.required)
|
||||||
|
);
|
||||||
|
this.prematureCloseAccountForm.addControl('transferDescription', new UntypedFormControl(''));
|
||||||
|
} else {
|
||||||
|
this.prematureCloseAccountForm.removeControl('toSavingsAccountId');
|
||||||
|
this.prematureCloseAccountForm.removeControl('transferDescription');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submits the form and premature closes the fd account,
|
||||||
|
* if successful redirects to the fd account.
|
||||||
|
*/
|
||||||
|
submit() {
|
||||||
|
const prematureCloseAccountFormData = this.prematureCloseAccountForm.value;
|
||||||
|
this.isSubmitted = true;
|
||||||
|
const locale = this.settingsService.language.code;
|
||||||
|
const dateFormat = this.settingsService.dateFormat;
|
||||||
|
const prevClosedDate: Date = this.prematureCloseAccountForm.value.closedOnDate;
|
||||||
|
if (prematureCloseAccountFormData.closedOnDate instanceof Date) {
|
||||||
|
prematureCloseAccountFormData.closedOnDate = this.dateUtils.formatDate(prevClosedDate, dateFormat);
|
||||||
|
}
|
||||||
|
const data = {
|
||||||
|
...prematureCloseAccountFormData,
|
||||||
|
dateFormat,
|
||||||
|
locale
|
||||||
|
};
|
||||||
|
this.fixedDepositsService
|
||||||
|
.executeFixedDepositsAccountCommand(this.accountId, 'prematureClose', data)
|
||||||
|
.subscribe(() => {
|
||||||
|
this.router.navigate(['../../'], { relativeTo: this.route });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-16
@@ -11,7 +11,6 @@ import { Component, OnInit, inject } from '@angular/core';
|
|||||||
import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
|
import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
|
||||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||||
import { Dates } from 'app/core/utils/dates';
|
import { Dates } from 'app/core/utils/dates';
|
||||||
import { AlertService } from 'app/core/alert/alert.service';
|
|
||||||
|
|
||||||
/** Custom Services */
|
/** Custom Services */
|
||||||
import { FixedDepositsService } from 'app/deposits/fixed-deposits/fixed-deposits.service';
|
import { FixedDepositsService } from 'app/deposits/fixed-deposits/fixed-deposits.service';
|
||||||
@@ -38,7 +37,6 @@ export class RejectFixedDepositsAccountComponent implements OnInit {
|
|||||||
private route = inject(ActivatedRoute);
|
private route = inject(ActivatedRoute);
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
private settingsService = inject(SettingsService);
|
private settingsService = inject(SettingsService);
|
||||||
private alertService = inject(AlertService);
|
|
||||||
|
|
||||||
/** Minimum date allowed. */
|
/** Minimum date allowed. */
|
||||||
minDate = new Date(2000, 0, 1);
|
minDate = new Date(2000, 0, 1);
|
||||||
@@ -99,19 +97,8 @@ export class RejectFixedDepositsAccountComponent implements OnInit {
|
|||||||
dateFormat,
|
dateFormat,
|
||||||
locale
|
locale
|
||||||
};
|
};
|
||||||
this.fixedDepositsService
|
this.fixedDepositsService.executeFixedDepositsAccountCommand(this.accountId, 'reject', data).subscribe(() => {
|
||||||
.executeFixedDepositsAccountCommand(this.accountId, 'reject', data)
|
this.router.navigate(['../../'], { relativeTo: this.route });
|
||||||
.subscribe((response: any) => {
|
});
|
||||||
// Parked in the Checker Inbox rather than executed (HTTP 200 with rollbackTransaction: true),
|
|
||||||
// so the application is still open -- say so instead of letting that read as "nothing happened".
|
|
||||||
if (response?.rollbackTransaction) {
|
|
||||||
this.alertService.alert({
|
|
||||||
type: 'Success',
|
|
||||||
message:
|
|
||||||
'Rejection has been sent for checker approval. The application is unchanged until a checker approves it.'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
this.router.navigate(['../../'], { relativeTo: this.route });
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-23
@@ -12,7 +12,6 @@ import { UntypedFormGroup, UntypedFormBuilder, ReactiveFormsModule } from '@angu
|
|||||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||||
|
|
||||||
/** Custom Services */
|
/** Custom Services */
|
||||||
import { AlertService } from 'app/core/alert/alert.service';
|
|
||||||
import { SavingsService } from 'app/savings/savings.service';
|
import { SavingsService } from 'app/savings/savings.service';
|
||||||
import { FixedDepositsService } from '../../fixed-deposits.service';
|
import { FixedDepositsService } from '../../fixed-deposits.service';
|
||||||
import { CdkTextareaAutosize } from '@angular/cdk/text-field';
|
import { CdkTextareaAutosize } from '@angular/cdk/text-field';
|
||||||
@@ -36,7 +35,6 @@ export class UndoApprovalFixedDepositsAccountComponent implements OnInit {
|
|||||||
private fixedDepositsService = inject(FixedDepositsService);
|
private fixedDepositsService = inject(FixedDepositsService);
|
||||||
private route = inject(ActivatedRoute);
|
private route = inject(ActivatedRoute);
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
private alertService = inject(AlertService);
|
|
||||||
|
|
||||||
/** Undo Approval Fixed Deposits Account form. */
|
/** Undo Approval Fixed Deposits Account form. */
|
||||||
undoApprovalFixedDepositsAccountForm: UntypedFormGroup;
|
undoApprovalFixedDepositsAccountForm: UntypedFormGroup;
|
||||||
@@ -89,30 +87,12 @@ export class UndoApprovalFixedDepositsAccountComponent implements OnInit {
|
|||||||
if (this.undoAction === 'Undo Activation') {
|
if (this.undoAction === 'Undo Activation') {
|
||||||
this.fixedDepositsService
|
this.fixedDepositsService
|
||||||
.executeFixedDepositsAccountCommand(this.accountId, this.undoCommand, data)
|
.executeFixedDepositsAccountCommand(this.accountId, this.undoCommand, data)
|
||||||
.subscribe((response: any) => {
|
.subscribe(() => {
|
||||||
this.notifyIfSentForApproval(response);
|
|
||||||
this.router.navigate(['../../'], { relativeTo: this.route });
|
this.router.navigate(['../../'], { relativeTo: this.route });
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
this.savingsService
|
this.savingsService.executeSavingsAccountCommand(this.accountId, this.undoCommand, data).subscribe(() => {
|
||||||
.executeSavingsAccountCommand(this.accountId, this.undoCommand, data)
|
this.router.navigate(['../../'], { relativeTo: this.route });
|
||||||
.subscribe((response: any) => {
|
|
||||||
this.notifyIfSentForApproval(response);
|
|
||||||
this.router.navigate(['../../'], { relativeTo: this.route });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Both undo commands sit on maker-checker permissions, so either can come back parked in the
|
|
||||||
* Checker Inbox rather than executed -- HTTP 200 with rollbackTransaction: true, and the account
|
|
||||||
* reloaded unchanged. Without this the navigation away reads as "it worked".
|
|
||||||
*/
|
|
||||||
private notifyIfSentForApproval(response: any): void {
|
|
||||||
if (response?.rollbackTransaction) {
|
|
||||||
this.alertService.alert({
|
|
||||||
type: 'Success',
|
|
||||||
message: `${this.undoAction} has been sent for checker approval. The account is unchanged until a checker approves it.`
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-12
@@ -11,7 +11,6 @@ import { Component, OnInit, inject } from '@angular/core';
|
|||||||
import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
|
import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
|
||||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||||
import { Dates } from 'app/core/utils/dates';
|
import { Dates } from 'app/core/utils/dates';
|
||||||
import { AlertService } from 'app/core/alert/alert.service';
|
|
||||||
|
|
||||||
/** Custom Services */
|
/** Custom Services */
|
||||||
import { FixedDepositsService } from 'app/deposits/fixed-deposits/fixed-deposits.service';
|
import { FixedDepositsService } from 'app/deposits/fixed-deposits/fixed-deposits.service';
|
||||||
@@ -38,7 +37,6 @@ export class WithdrawByClientFixedDepositsAccountComponent implements OnInit {
|
|||||||
private route = inject(ActivatedRoute);
|
private route = inject(ActivatedRoute);
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
private settingsService = inject(SettingsService);
|
private settingsService = inject(SettingsService);
|
||||||
private alertService = inject(AlertService);
|
|
||||||
|
|
||||||
/** Minimum date allowed. */
|
/** Minimum date allowed. */
|
||||||
minDate = new Date(2000, 0, 1);
|
minDate = new Date(2000, 0, 1);
|
||||||
@@ -101,16 +99,7 @@ export class WithdrawByClientFixedDepositsAccountComponent implements OnInit {
|
|||||||
};
|
};
|
||||||
this.fixedDepositsService
|
this.fixedDepositsService
|
||||||
.executeFixedDepositsAccountCommand(this.accountId, 'withdrawnByApplicant', data)
|
.executeFixedDepositsAccountCommand(this.accountId, 'withdrawnByApplicant', data)
|
||||||
.subscribe((response: any) => {
|
.subscribe(() => {
|
||||||
// Parked in the Checker Inbox rather than executed (HTTP 200 with rollbackTransaction: true),
|
|
||||||
// so the application is still open -- say so instead of letting that read as "nothing happened".
|
|
||||||
if (response?.rollbackTransaction) {
|
|
||||||
this.alertService.alert({
|
|
||||||
type: 'Success',
|
|
||||||
message:
|
|
||||||
'Withdrawal has been sent for checker approval. The application is unchanged until a checker approves it.'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
this.router.navigate(['../../'], { relativeTo: this.route });
|
this.router.navigate(['../../'], { relativeTo: this.route });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import { ViewTransactionComponent } from './fixed-deposit-account-view/view-tran
|
|||||||
import { WaiveChargeDialogComponent } from './fixed-deposit-account-view/custom-dialogs/waive-charge-dialog/waive-charge-dialog.component';
|
import { WaiveChargeDialogComponent } from './fixed-deposit-account-view/custom-dialogs/waive-charge-dialog/waive-charge-dialog.component';
|
||||||
import { InactivateChargeDialogComponent } from './fixed-deposit-account-view/custom-dialogs/inactivate-charge-dialog/inactivate-charge-dialog.component';
|
import { InactivateChargeDialogComponent } from './fixed-deposit-account-view/custom-dialogs/inactivate-charge-dialog/inactivate-charge-dialog.component';
|
||||||
import { AddChargeFixedDepositsAccountComponent } from './fixed-deposits-account-actions/add-charge-fixed-deposits-account/add-charge-fixed-deposits-account.component';
|
import { AddChargeFixedDepositsAccountComponent } from './fixed-deposits-account-actions/add-charge-fixed-deposits-account/add-charge-fixed-deposits-account.component';
|
||||||
|
import { PrematureCloseFixedDepositsAccountComponent } from './fixed-deposits-account-actions/premature-close-fixed-deposits-account/premature-close-fixed-deposits-account.component';
|
||||||
import { CloseFixedDepositsAccountComponent } from './fixed-deposits-account-actions/close-fixed-deposits-account/close-fixed-deposits-account.component';
|
import { CloseFixedDepositsAccountComponent } from './fixed-deposits-account-actions/close-fixed-deposits-account/close-fixed-deposits-account.component';
|
||||||
import { CreateFixedDepositAccountComponent } from './create-fixed-deposit-account/create-fixed-deposit-account.component';
|
import { CreateFixedDepositAccountComponent } from './create-fixed-deposit-account/create-fixed-deposit-account.component';
|
||||||
import { FixedDepositAccountDetailsStepComponent } from './fixed-deposit-account-stepper/fixed-deposit-account-details-step/fixed-deposit-account-details-step.component';
|
import { FixedDepositAccountDetailsStepComponent } from './fixed-deposit-account-stepper/fixed-deposit-account-details-step/fixed-deposit-account-details-step.component';
|
||||||
@@ -77,6 +78,7 @@ import { FixedDepositsCashTransactionComponent } from './fixed-deposits-account-
|
|||||||
WaiveChargeDialogComponent,
|
WaiveChargeDialogComponent,
|
||||||
InactivateChargeDialogComponent,
|
InactivateChargeDialogComponent,
|
||||||
AddChargeFixedDepositsAccountComponent,
|
AddChargeFixedDepositsAccountComponent,
|
||||||
|
PrematureCloseFixedDepositsAccountComponent,
|
||||||
CloseFixedDepositsAccountComponent,
|
CloseFixedDepositsAccountComponent,
|
||||||
CreateFixedDepositAccountComponent,
|
CreateFixedDepositAccountComponent,
|
||||||
FixedDepositAccountDetailsStepComponent,
|
FixedDepositAccountDetailsStepComponent,
|
||||||
|
|||||||
@@ -154,33 +154,6 @@ export class FixedDepositsService {
|
|||||||
return this.http.get(`/fixeddepositaccounts/calculate-fd-interest`, { params: httpParams });
|
return this.http.get(`/fixeddepositaccounts/calculate-fd-interest`, { params: httpParams });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* FD Dashboard's "Renew" action on a matured FD whose product has a renewal grace period
|
|
||||||
* configured: backdated to the original maturity date within the grace window, or from today
|
|
||||||
* with a gap-period interest credit (at the linked Savings account's rate) once it has passed.
|
|
||||||
* @param accountId Fixed Deposit Account Id
|
|
||||||
* @returns {Observable<any>} { renewedAccountId, maturityDate, maturityAmount, backdated, gapInterestAmount? }
|
|
||||||
*/
|
|
||||||
/** `reinvestType` is the staff's choice made at renewal time: 300 = Reinvest Principal + Interest,
|
|
||||||
* 400 = Reinvest Principal Only (rest of the balance goes to the FD's linked Savings account). */
|
|
||||||
renewFixedDepositAccount(accountId: string | number, reinvestType: 300 | 400): Observable<any> {
|
|
||||||
return this.http.post(`/fixeddepositaccounts/${accountId}/renew`, { reinvestType });
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Pre-step the Payment dialog calls, from the FD Dashboard, before submitting the generic `payment`
|
|
||||||
* command -- credits gap-period interest (at the linked account's rate) if the product's renewal
|
|
||||||
* grace window has passed; a no-op (`gapInterestAmount: 0`) if still within it. */
|
|
||||||
creditGapInterestIfPastGrace(accountId: string | number): Observable<any> {
|
|
||||||
return this.http.post(`/fixeddepositaccounts/${accountId}/credit-gap-interest`, {});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Read-only -- what a Renew is about to do (current balance, linked account's rate, gap days,
|
|
||||||
* gap-interest amount, projected new balance), shown in the Payment dialog before anything is
|
|
||||||
* confirmed. Posts nothing; safe to call as often as the dialog needs. */
|
|
||||||
getFdRenewalPreview(accountId: string | number): Observable<any> {
|
|
||||||
return this.http.get(`/fixeddepositaccounts/${accountId}/renewal-preview`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param fixedDepositAccount Fixed Deposit Account
|
* @param fixedDepositAccount Fixed Deposit Account
|
||||||
* @returns {Observable<any>}
|
* @returns {Observable<any>}
|
||||||
|
|||||||
+2
-50
@@ -195,13 +195,6 @@ export class CreateRecurringDepositsAccountComponent {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (this.recurringDepositsAccountProductTemplate?.introducerRequired && !detailsStep?.introducerId) {
|
|
||||||
this.alertService.alert({
|
|
||||||
type: 'Error',
|
|
||||||
message: 'This product requires an introducer. Please choose an existing customer as introducer before submitting.'
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!this.selectedTenureValue || this.selectedTenureFrequencyId === null) {
|
if (!this.selectedTenureValue || this.selectedTenureFrequencyId === null) {
|
||||||
this.alertService.alert({
|
this.alertService.alert({
|
||||||
type: 'Error',
|
type: 'Error',
|
||||||
@@ -216,39 +209,6 @@ export class CreateRecurringDepositsAccountComponent {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Same limits the server enforces - checked here so the message names the numbers before anything is sent.
|
|
||||||
const minInstallment = Number(this.recurringDepositsAccountProductTemplate?.minDepositAmount) || 0;
|
|
||||||
const maxInstallment = Number(this.recurringDepositsAccountProductTemplate?.maxDepositAmount) || 0;
|
|
||||||
if (minInstallment > 0 && this.selectedDepositAmount < minInstallment) {
|
|
||||||
this.alertService.alert({
|
|
||||||
type: 'Error',
|
|
||||||
message: `Installment ${this.selectedDepositAmount} is below this product's minimum of ${minInstallment}.`
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (maxInstallment > 0 && this.selectedDepositAmount > maxInstallment) {
|
|
||||||
this.alertService.alert({
|
|
||||||
type: 'Error',
|
|
||||||
message: `Installment ${this.selectedDepositAmount} is above this product's maximum of ${maxInstallment}.`
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
this.recurringDepositsAccountCurrencyStep?.autoDebitFacility &&
|
|
||||||
detailsStep?.selectedLinkedAccount?.accountType !== 'SAVINGS'
|
|
||||||
) {
|
|
||||||
this.alertService.alert({
|
|
||||||
type: 'Error',
|
|
||||||
message:
|
|
||||||
'Auto Debit collects each installment from the linked account. Link a Savings account under Deposit Account Link, or turn Auto Debit off.'
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const lienError = this.recurringDepositsAccountCurrencyStep?.lienPercentageError;
|
|
||||||
if (lienError) {
|
|
||||||
this.alertService.alert({ type: 'Error', message: `Loan against this RD: ${lienError}` });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!detailsStep?.selectedCustomerSegment) {
|
if (!detailsStep?.selectedCustomerSegment) {
|
||||||
this.alertService.alert({ type: 'Error', message: 'Please select a Customer Segment before submitting.' });
|
this.alertService.alert({ type: 'Error', message: 'Please select a Customer Segment before submitting.' });
|
||||||
return;
|
return;
|
||||||
@@ -295,16 +255,8 @@ export class CreateRecurringDepositsAccountComponent {
|
|||||||
isCalendarInherited: pt?.isCalendarInherited ?? false,
|
isCalendarInherited: pt?.isCalendarInherited ?? false,
|
||||||
isMandatoryDeposit: pt?.isMandatoryDeposit ?? false,
|
isMandatoryDeposit: pt?.isMandatoryDeposit ?? false,
|
||||||
allowWithdrawal: pt?.allowWithdrawal ?? false,
|
allowWithdrawal: pt?.allowWithdrawal ?? false,
|
||||||
// Money paid ahead of a due date counts towards the next installments. Without this an advance cash
|
adjustAdvanceTowardsFuturePayments: pt?.adjustAdvanceTowardsFuturePayments ?? false,
|
||||||
// payment stayed unallocated: the schedule kept showing the installment unpaid, so Auto Debit collected it
|
autoDebitFacility: currencyStep?.autoDebitFacility ?? pt?.autoDebitFacility ?? false
|
||||||
// a second time and the missed-installment penalty charged it. The RD product form has no such option, so
|
|
||||||
// the product's value was always false.
|
|
||||||
adjustAdvanceTowardsFuturePayments: true,
|
|
||||||
autoDebitFacility: currencyStep?.autoDebitFacility ?? pt?.autoDebitFacility ?? false,
|
|
||||||
// Lien Marked products only - the server ignores it otherwise and caps it at the product's limit.
|
|
||||||
...(currencyStep?.lienAllowed && currencyStep?.lienPercentage != null
|
|
||||||
? { lienPercentage: Number(currencyStep.lienPercentage) }
|
|
||||||
: {})
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Charges from product template
|
// Charges from product template
|
||||||
|
|||||||
+2
-12
@@ -233,11 +233,6 @@ export class EditRecurringDepositAccountComponent {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const lienError = this.recurringDepositsAccountCurrencyStep?.lienPercentageError;
|
|
||||||
if (lienError) {
|
|
||||||
this.alertService.alert({ type: 'Error', message: `Loan against this RD: ${lienError}` });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!detailsStep?.selectedCustomerSegment) {
|
if (!detailsStep?.selectedCustomerSegment) {
|
||||||
this.alertService.alert({ type: 'Error', message: 'Please select a Customer Segment before submitting.' });
|
this.alertService.alert({ type: 'Error', message: 'Please select a Customer Segment before submitting.' });
|
||||||
return;
|
return;
|
||||||
@@ -280,13 +275,8 @@ export class EditRecurringDepositAccountComponent {
|
|||||||
isCalendarInherited: pt?.isCalendarInherited ?? false,
|
isCalendarInherited: pt?.isCalendarInherited ?? false,
|
||||||
isMandatoryDeposit: pt?.isMandatoryDeposit ?? false,
|
isMandatoryDeposit: pt?.isMandatoryDeposit ?? false,
|
||||||
allowWithdrawal: pt?.allowWithdrawal ?? false,
|
allowWithdrawal: pt?.allowWithdrawal ?? false,
|
||||||
// See create-recurring-deposits-account: advance payments must count towards the next installments.
|
adjustAdvanceTowardsFuturePayments: pt?.adjustAdvanceTowardsFuturePayments ?? false,
|
||||||
adjustAdvanceTowardsFuturePayments: true,
|
autoDebitFacility: currencyStep?.autoDebitFacility ?? pt?.autoDebitFacility ?? false
|
||||||
autoDebitFacility: currencyStep?.autoDebitFacility ?? pt?.autoDebitFacility ?? false,
|
|
||||||
// Lien Marked products only - the server ignores it otherwise and caps it at the product's limit.
|
|
||||||
...(currencyStep?.lienAllowed && currencyStep?.lienPercentage != null
|
|
||||||
? { lienPercentage: Number(currencyStep.lienPercentage) }
|
|
||||||
: {})
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const rawCharges: any[] = pt?.charges ?? [];
|
const rawCharges: any[] = pt?.charges ?? [];
|
||||||
|
|||||||
+1
-2
@@ -70,9 +70,8 @@ export class ActivateRecurringDepositsAccountComponent implements OnInit {
|
|||||||
*/
|
*/
|
||||||
createActivateRecurringDepositsAccountForm() {
|
createActivateRecurringDepositsAccountForm() {
|
||||||
this.activateRecurringDepositsAccountForm = this.formBuilder.group({
|
this.activateRecurringDepositsAccountForm = this.formBuilder.group({
|
||||||
// Defaults to the business date (the branch's working day), which is what the account is normally activated on.
|
|
||||||
activatedOnDate: [
|
activatedOnDate: [
|
||||||
this.settingsService.businessDate,
|
'',
|
||||||
Validators.required
|
Validators.required
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|||||||
+1
-2
@@ -72,9 +72,8 @@ export class ApproveRecurringDepositsAccountComponent implements OnInit {
|
|||||||
*/
|
*/
|
||||||
createApproveRecurringDepositsAccountForm() {
|
createApproveRecurringDepositsAccountForm() {
|
||||||
this.approveRecurringDepositsAccountForm = this.formBuilder.group({
|
this.approveRecurringDepositsAccountForm = this.formBuilder.group({
|
||||||
// Defaults to the business date (the branch's working day), which is what the account is normally approved on.
|
|
||||||
approvedOnDate: [
|
approvedOnDate: [
|
||||||
this.settingsService.businessDate,
|
'',
|
||||||
Validators.required
|
Validators.required
|
||||||
],
|
],
|
||||||
note: ['']
|
note: ['']
|
||||||
|
|||||||
+1
-11
@@ -13,16 +13,6 @@
|
|||||||
<div class="layout-column">
|
<div class="layout-column">
|
||||||
<h3 class="mat-h3 flex-fill">{{ title }}</h3>
|
<h3 class="mat-h3 flex-fill">{{ title }}</h3>
|
||||||
|
|
||||||
@if (totalLienAmount > 0) {
|
|
||||||
<p class="lien-warn">
|
|
||||||
This deposit is pledged as loan security (lien {{ totalLienAmount | number: '1.2-2' }} -
|
|
||||||
@for (lien of liens; track lien.loanId; let last = $last) {
|
|
||||||
loan {{ lien.loanAccountNo }}{{ last ? '' : ', ' }}
|
|
||||||
}
|
|
||||||
). It cannot be closed until the loan is repaid or the guarantee is released.
|
|
||||||
</p>
|
|
||||||
}
|
|
||||||
|
|
||||||
<mat-form-field (click)="closedOnDatePicker.open()">
|
<mat-form-field (click)="closedOnDatePicker.open()">
|
||||||
<mat-label>{{ 'labels.inputs.Closed On' | translate }}</mat-label>
|
<mat-label>{{ 'labels.inputs.Closed On' | translate }}</mat-label>
|
||||||
<input
|
<input
|
||||||
@@ -164,7 +154,7 @@
|
|||||||
<button type="button" mat-raised-button [routerLink]="['../general']">
|
<button type="button" mat-raised-button [routerLink]="['../general']">
|
||||||
{{ 'labels.buttons.Cancel' | translate }}
|
{{ 'labels.buttons.Cancel' | translate }}
|
||||||
</button>
|
</button>
|
||||||
<button mat-raised-button color="primary" [disabled]="!closeRecurringDepositForm.valid || totalLienAmount > 0">
|
<button mat-raised-button color="primary" [disabled]="!closeRecurringDepositForm.valid">
|
||||||
{{ 'labels.buttons.Submit' | translate }}
|
{{ 'labels.buttons.Submit' | translate }}
|
||||||
</button>
|
</button>
|
||||||
</mat-card-actions>
|
</mat-card-actions>
|
||||||
|
|||||||
-6
@@ -10,12 +10,6 @@
|
|||||||
max-width: 37rem;
|
max-width: 37rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lien-warn {
|
|
||||||
margin: 0 0 16px;
|
|
||||||
font-size: 13px;
|
|
||||||
color: #c62828;
|
|
||||||
}
|
|
||||||
|
|
||||||
.expandcollapsebutton {
|
.expandcollapsebutton {
|
||||||
margin-top: -7px;
|
margin-top: -7px;
|
||||||
margin-left: 2%;
|
margin-left: 2%;
|
||||||
|
|||||||
+1
-15
@@ -64,9 +64,6 @@ export class CloseRecurringDepositsAccountComponent implements OnInit {
|
|||||||
maxDate = new Date();
|
maxDate = new Date();
|
||||||
/** Close Recurring Deposit Account form. */
|
/** Close Recurring Deposit Account form. */
|
||||||
closeRecurringDepositForm: UntypedFormGroup;
|
closeRecurringDepositForm: UntypedFormGroup;
|
||||||
/** Loans this deposit secures. While any remain, the server refuses to close it. */
|
|
||||||
liens: any[] = [];
|
|
||||||
totalLienAmount = 0;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieves action details template data from `resolve`
|
* Retrieves action details template data from `resolve`
|
||||||
@@ -82,11 +79,6 @@ export class CloseRecurringDepositsAccountComponent implements OnInit {
|
|||||||
this.maturityAmount = data.recurringDepositsAccountActionData.maturityAmount;
|
this.maturityAmount = data.recurringDepositsAccountActionData.maturityAmount;
|
||||||
this.onAccountClosureOptions = data.recurringDepositsAccountActionData.onAccountClosureOptions;
|
this.onAccountClosureOptions = data.recurringDepositsAccountActionData.onAccountClosureOptions;
|
||||||
this.paymentTypes = data.recurringDepositsAccountActionData.paymentTypeOptions;
|
this.paymentTypes = data.recurringDepositsAccountActionData.paymentTypeOptions;
|
||||||
// A matured deposit closes on or after its maturity date - the server refuses anything earlier.
|
|
||||||
const maturityDate = data.recurringDepositsAccountActionData.maturityDate;
|
|
||||||
if (Array.isArray(maturityDate) && maturityDate.length >= 3) {
|
|
||||||
this.minDate = new Date(maturityDate[0], maturityDate[1] - 1, maturityDate[2]);
|
|
||||||
}
|
|
||||||
if (data.recurringDepositsAccountActionData.maturityAmount) {
|
if (data.recurringDepositsAccountActionData.maturityAmount) {
|
||||||
this.title = 'Recurring Deposit Closure on Maturity';
|
this.title = 'Recurring Deposit Closure on Maturity';
|
||||||
} else {
|
} else {
|
||||||
@@ -100,10 +92,6 @@ export class CloseRecurringDepositsAccountComponent implements OnInit {
|
|||||||
this.maxDate = this.settingsService.businessDate;
|
this.maxDate = this.settingsService.businessDate;
|
||||||
this.createcloseRecurringDepositForm();
|
this.createcloseRecurringDepositForm();
|
||||||
this.addTransferDetails();
|
this.addTransferDetails();
|
||||||
this.recurringDepositsService.getRecurringDepositLiens(this.accountId).subscribe((response: any) => {
|
|
||||||
this.liens = response?.liens ?? [];
|
|
||||||
this.totalLienAmount = Number(response?.totalLienAmount) || 0;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -111,10 +99,8 @@ export class CloseRecurringDepositsAccountComponent implements OnInit {
|
|||||||
*/
|
*/
|
||||||
createcloseRecurringDepositForm() {
|
createcloseRecurringDepositForm() {
|
||||||
this.closeRecurringDepositForm = this.formBuilder.group({
|
this.closeRecurringDepositForm = this.formBuilder.group({
|
||||||
// The business date, not the computer's clock: the branch's working day can differ from the calendar day,
|
|
||||||
// and closing on the clock's date (before maturity, or before the deposit's last installment) fails.
|
|
||||||
closedOnDate: [
|
closedOnDate: [
|
||||||
this.settingsService.businessDate,
|
new Date(),
|
||||||
Validators.required
|
Validators.required
|
||||||
],
|
],
|
||||||
maturityAmount: [{ value: this.maturityAmount, disabled: true }],
|
maturityAmount: [{ value: this.maturityAmount, disabled: true }],
|
||||||
|
|||||||
+2
-131
@@ -11,51 +11,6 @@
|
|||||||
<form [formGroup]="prematureCloseRecurringDepositsAccountForm" (ngSubmit)="submit()">
|
<form [formGroup]="prematureCloseRecurringDepositsAccountForm" (ngSubmit)="submit()">
|
||||||
<mat-card-content>
|
<mat-card-content>
|
||||||
<div class="layout-column">
|
<div class="layout-column">
|
||||||
@if (account) {
|
|
||||||
<div class="pc-context">
|
|
||||||
<div class="pc-row">
|
|
||||||
<span>Account</span><b>{{ account.accountNo }}</b>
|
|
||||||
</div>
|
|
||||||
<div class="pc-row">
|
|
||||||
<span>Current balance</span><b>{{ currencySymbol }} {{ balance | number: '1.2-2' }}</b>
|
|
||||||
</div>
|
|
||||||
@if (account.maturityDate) {
|
|
||||||
<div class="pc-row">
|
|
||||||
<span>Maturity date</span><b>{{ account.maturityDate | dateFormat }}</b>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@if (account.maturityAmount != null) {
|
|
||||||
<div class="pc-row">
|
|
||||||
<span>Amount at maturity</span><b>{{ currencySymbol }} {{ account.maturityAmount | number: '1.2-2' }}</b>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
<div class="pc-row">
|
|
||||||
<span>Pre-closure penalty</span>
|
|
||||||
<b>
|
|
||||||
@if (account.preClosurePenalApplicable) {
|
|
||||||
{{ account.preClosurePenalInterest }}% less interest
|
|
||||||
@if (account.preClosurePenalInterestOnType?.value) {
|
|
||||||
({{ account.preClosurePenalInterestOnType.value }})
|
|
||||||
}
|
|
||||||
} @else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
</b>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (totalLienAmount > 0) {
|
|
||||||
<p class="pc-warn">
|
|
||||||
This deposit is pledged as loan security (lien {{ currencySymbol }}
|
|
||||||
{{ totalLienAmount | number: '1.2-2' }} -
|
|
||||||
@for (lien of liens; track lien.loanId; let last = $last) {
|
|
||||||
loan {{ lien.loanAccountNo }}{{ last ? '' : ', ' }}
|
|
||||||
}
|
|
||||||
). It cannot be closed until the loan is repaid or the guarantee is released.
|
|
||||||
</p>
|
|
||||||
}
|
|
||||||
|
|
||||||
<mat-form-field (click)="closedOnDatePicker.open()">
|
<mat-form-field (click)="closedOnDatePicker.open()">
|
||||||
<mat-label>{{ 'labels.inputs.Closed On' | translate }}</mat-label>
|
<mat-label>{{ 'labels.inputs.Closed On' | translate }}</mat-label>
|
||||||
<input
|
<input
|
||||||
@@ -68,7 +23,6 @@
|
|||||||
/>
|
/>
|
||||||
<mat-datepicker-toggle matSuffix [for]="closedOnDatePicker"></mat-datepicker-toggle>
|
<mat-datepicker-toggle matSuffix [for]="closedOnDatePicker"></mat-datepicker-toggle>
|
||||||
<mat-datepicker #closedOnDatePicker></mat-datepicker>
|
<mat-datepicker #closedOnDatePicker></mat-datepicker>
|
||||||
<mat-hint>Must be before the maturity date - on or after it, use Close instead.</mat-hint>
|
|
||||||
@if (prematureCloseRecurringDepositsAccountForm.controls.closedOnDate.hasError('required')) {
|
@if (prematureCloseRecurringDepositsAccountForm.controls.closedOnDate.hasError('required')) {
|
||||||
<mat-error>
|
<mat-error>
|
||||||
{{ 'labels.inputs.Closed On Date' | translate }} {{ 'labels.commons.is' | translate }}
|
{{ 'labels.inputs.Closed On Date' | translate }} {{ 'labels.commons.is' | translate }}
|
||||||
@@ -76,89 +30,6 @@
|
|||||||
</mat-error>
|
</mat-error>
|
||||||
}
|
}
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
|
|
||||||
@if (reasonOptions.length > 1) {
|
|
||||||
<mat-form-field subscriptSizing="dynamic">
|
|
||||||
<mat-label>Closure reason</mat-label>
|
|
||||||
<mat-select formControlName="prematureClosureReasonId">
|
|
||||||
@for (reason of reasonOptions; track reason.id) {
|
|
||||||
<mat-option [value]="reason.id">{{ reason.label }}</mat-option>
|
|
||||||
}
|
|
||||||
</mat-select>
|
|
||||||
<mat-hint>{{ selectedReasonHint }}</mat-hint>
|
|
||||||
</mat-form-field>
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="pc-calc">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
mat-stroked-button
|
|
||||||
color="primary"
|
|
||||||
(click)="calculate()"
|
|
||||||
[disabled]="
|
|
||||||
calculating || totalLienAmount > 0 || !prematureCloseRecurringDepositsAccountForm.value.closedOnDate
|
|
||||||
"
|
|
||||||
>
|
|
||||||
{{ calculating ? 'Calculating…' : 'Calculate Payout' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (preview) {
|
|
||||||
<div class="pc-result">
|
|
||||||
<div class="pc-row pc-row--total">
|
|
||||||
<span>Payable on premature closure</span>
|
|
||||||
<b>{{ currencySymbol }} {{ preview.payoutAmount | number: '1.2-2' }}</b>
|
|
||||||
</div>
|
|
||||||
@if (shortfallVsMaturity != null && shortfallVsMaturity > 0) {
|
|
||||||
<div class="pc-row pc-row--muted">
|
|
||||||
<span>Less than at maturity by</span>
|
|
||||||
<b>{{ currencySymbol }} {{ shortfallVsMaturity | number: '1.2-2' }}</b>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
<p class="pc-note">
|
|
||||||
Paid by transfer to the client's savings account - a recurring deposit cannot be broken for cash.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (destinationAccounts.length === 0) {
|
|
||||||
<p class="pc-warn">
|
|
||||||
This client has no active savings account to receive the proceeds. Open one first, then close this
|
|
||||||
deposit.
|
|
||||||
</p>
|
|
||||||
} @else {
|
|
||||||
<mat-form-field>
|
|
||||||
<mat-label>{{ 'labels.inputs.Transfer to Savings' | translate }}</mat-label>
|
|
||||||
<mat-select required formControlName="toSavingsAccountId">
|
|
||||||
@for (savings of destinationAccounts; track savings.id) {
|
|
||||||
<mat-option [value]="savings.id">
|
|
||||||
{{ savings.accountNo }} - {{ savings.productName }}
|
|
||||||
@if (savings.owner) {
|
|
||||||
(nominee: {{ savings.owner }})
|
|
||||||
} @else if (savings.id === account?.linkedAccountId) {
|
|
||||||
(linked)
|
|
||||||
}
|
|
||||||
</mat-option>
|
|
||||||
}
|
|
||||||
</mat-select>
|
|
||||||
@if (prematureCloseRecurringDepositsAccountForm.controls.toSavingsAccountId.hasError('required')) {
|
|
||||||
<mat-error>
|
|
||||||
{{ 'labels.inputs.Transfer to savings account' | translate }} {{ 'labels.commons.is' | translate }}
|
|
||||||
<strong>{{ 'labels.commons.required' | translate }}</strong>
|
|
||||||
</mat-error>
|
|
||||||
}
|
|
||||||
</mat-form-field>
|
|
||||||
|
|
||||||
<mat-form-field>
|
|
||||||
<mat-label>{{ 'labels.inputs.Transfer Description' | translate }}</mat-label>
|
|
||||||
<input matInput formControlName="transferDescription" />
|
|
||||||
</mat-form-field>
|
|
||||||
|
|
||||||
<mat-form-field>
|
|
||||||
<mat-label>{{ 'labels.inputs.Note' | translate }}</mat-label>
|
|
||||||
<textarea matInput cdkTextareaAutosize formControlName="note"></textarea>
|
|
||||||
</mat-form-field>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
</mat-card-content>
|
</mat-card-content>
|
||||||
|
|
||||||
@@ -166,8 +37,8 @@
|
|||||||
<button type="button" mat-raised-button [routerLink]="['../../']">
|
<button type="button" mat-raised-button [routerLink]="['../../']">
|
||||||
{{ 'labels.buttons.Cancel' | translate }}
|
{{ 'labels.buttons.Cancel' | translate }}
|
||||||
</button>
|
</button>
|
||||||
<button mat-raised-button color="primary" [disabled]="!canConfirm">
|
<button mat-raised-button color="primary" [disabled]="!prematureCloseRecurringDepositsAccountForm">
|
||||||
{{ submitting ? 'Closing…' : ('labels.buttons.Confirm' | translate) }}
|
{{ 'labels.buttons.Confirm' | translate }}
|
||||||
</button>
|
</button>
|
||||||
</mat-card-actions>
|
</mat-card-actions>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
-42
@@ -9,45 +9,3 @@
|
|||||||
.container {
|
.container {
|
||||||
max-width: 37rem;
|
max-width: 37rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pc-context,
|
|
||||||
.pc-result {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 12px 14px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
border: 1px solid rgb(128 128 128 / 30%);
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pc-row {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
font-size: 13px;
|
|
||||||
|
|
||||||
&--total {
|
|
||||||
font-size: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--muted {
|
|
||||||
opacity: 0.75;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.pc-calc {
|
|
||||||
margin: 4px 0 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pc-note {
|
|
||||||
margin: 4px 0 0;
|
|
||||||
font-size: 12px;
|
|
||||||
opacity: 0.75;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pc-warn {
|
|
||||||
margin: 0 0 16px;
|
|
||||||
font-size: 13px;
|
|
||||||
color: #c62828;
|
|
||||||
}
|
|
||||||
|
|||||||
+43
-250
@@ -8,41 +8,25 @@
|
|||||||
|
|
||||||
/** Angular Imports */
|
/** Angular Imports */
|
||||||
import { Component, OnInit, inject } from '@angular/core';
|
import { Component, OnInit, inject } from '@angular/core';
|
||||||
import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms';
|
import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||||
import { CdkTextareaAutosize } from '@angular/cdk/text-field';
|
|
||||||
|
|
||||||
/** Custom Services */
|
/** Custom Services */
|
||||||
import { RecurringDepositsService } from '../../recurring-deposits.service';
|
import { RecurringDepositsService } from '../../recurring-deposits.service';
|
||||||
import { ClientsService } from 'app/clients/clients.service';
|
|
||||||
import { SettingsService } from 'app/settings/settings.service';
|
import { SettingsService } from 'app/settings/settings.service';
|
||||||
import { Dates } from 'app/core/utils/dates';
|
import { Dates } from 'app/core/utils/dates';
|
||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
|
||||||
/** DepositAccountOnClosureType.TRANSFER_TO_SAVINGS - the only way a broken RD may be paid out. */
|
|
||||||
const TRANSFER_TO_SAVINGS = 200;
|
|
||||||
|
|
||||||
/** PrematureClosureReasonType values the RD closure understands. */
|
|
||||||
const REASON_NORMAL = 100;
|
|
||||||
const REASON_DECEASED = 400;
|
|
||||||
const REASON_NOMINEE_HEIR = 500;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Premature Close Recurring Deposits Account Component
|
* Premature Close Recurring Deposits Account Component
|
||||||
*
|
|
||||||
* The server will only break a recurring deposit early by transferring the proceeds to one of the client's savings
|
|
||||||
* accounts (no cash payout, no reinvest), and it needs to be told so: onAccountClosureId and toSavingsAccountId are
|
|
||||||
* mandatory. This screen used to send only the closure date, so every premature closure was rejected. It now asks the
|
|
||||||
* server what the deposit would pay on the chosen date (calculatePrematureAmount, which applies the product's
|
|
||||||
* pre-closure penalty) and only lets the closure be confirmed against that exact figure.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'mifosx-premature-close-recurring-deposit-account',
|
selector: 'mifosx-premature-close-recurring-deposit-account',
|
||||||
templateUrl: './premature-close-recurring-deposit-account.component.html',
|
templateUrl: './premature-close-recurring-deposit-account.component.html',
|
||||||
styleUrls: ['./premature-close-recurring-deposit-account.component.scss'],
|
styleUrls: ['./premature-close-recurring-deposit-account.component.scss'],
|
||||||
imports: [
|
imports: [
|
||||||
...STANDALONE_SHARED_IMPORTS,
|
...STANDALONE_SHARED_IMPORTS
|
||||||
CdkTextareaAutosize
|
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class PrematureCloseRecurringDepositAccountComponent implements OnInit {
|
export class PrematureCloseRecurringDepositAccountComponent implements OnInit {
|
||||||
@@ -52,263 +36,72 @@ export class PrematureCloseRecurringDepositAccountComponent implements OnInit {
|
|||||||
private route = inject(ActivatedRoute);
|
private route = inject(ActivatedRoute);
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
private settingsService = inject(SettingsService);
|
private settingsService = inject(SettingsService);
|
||||||
private clientsService = inject(ClientsService);
|
|
||||||
|
|
||||||
/** Savings accounts of this deposit's nominees - offered as destinations only for a deceased holder. */
|
/** Minimum date allowed. */
|
||||||
nomineeSavingsAccounts: any[] = [];
|
|
||||||
|
|
||||||
/** Minimum date allowed: the deposit's activation date once loaded. */
|
|
||||||
minDate = new Date(2000, 0, 1);
|
minDate = new Date(2000, 0, 1);
|
||||||
/** Maximum date allowed: the business date, and never the maturity date or later (that is a normal Close). */
|
/** Maximum date allowed. */
|
||||||
maxDate = new Date();
|
maxDate = new Date();
|
||||||
/** premature close Recurring Deposits Account form. */
|
/** premature close Recurring Deposits Account form. */
|
||||||
prematureCloseRecurringDepositsAccountForm: UntypedFormGroup;
|
prematureCloseRecurringDepositsAccountForm: UntypedFormGroup;
|
||||||
/** Recurring Deposits Account Id */
|
/** Recurring Deposits Account Id */
|
||||||
accountId: any;
|
accountId: any;
|
||||||
/** The deposit being closed, for the context panel and the default destination account. */
|
|
||||||
account: any;
|
|
||||||
|
|
||||||
/** Result of the last calculatePrematureAmount call, and the date it was calculated for. */
|
|
||||||
preview: { payoutAmount: number; savingsAccounts: any[]; closureOptionId: number } | null = null;
|
|
||||||
previewedFor: string | null = null;
|
|
||||||
calculating = false;
|
|
||||||
submitting = false;
|
|
||||||
/** Loans this deposit secures. While any remain, the server refuses to close it. */
|
|
||||||
liens: any[] = [];
|
|
||||||
totalLienAmount = 0;
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {FormBuilder} formBuilder Form Builder
|
||||||
|
* @param {RecurringDepositsService} recurringDepositsService Recurring Deposits Service
|
||||||
|
* @param {Dates} dateUtils Date Utils
|
||||||
|
* @param {ActivatedRoute} route Activated Route
|
||||||
|
* @param {Router} router Router
|
||||||
|
* @param {SettingsService} settingsService Settings Service
|
||||||
|
*/
|
||||||
constructor() {
|
constructor() {
|
||||||
this.accountId = this.route.parent.snapshot.params['recurringDepositAccountId'];
|
this.accountId = this.route.parent.snapshot.params['recurringDepositAccountId'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the premature close recurring deposits form.
|
||||||
|
*/
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
this.maxDate = this.settingsService.businessDate;
|
this.maxDate = this.settingsService.businessDate;
|
||||||
this.createprematureCloseRecurringDepositsAccountForm();
|
this.createprematureCloseRecurringDepositsAccountForm();
|
||||||
this.recurringDepositsService.getRecurringDepositsAccountData(this.accountId).subscribe((account: any) => {
|
|
||||||
this.account = account;
|
|
||||||
const activatedOn = this.toDate(account?.timeline?.activatedOnDate);
|
|
||||||
if (activatedOn) {
|
|
||||||
this.minDate = activatedOn;
|
|
||||||
}
|
|
||||||
const maturity = this.toDate(account?.maturityDate);
|
|
||||||
if (maturity) {
|
|
||||||
const dayBeforeMaturity = new Date(maturity);
|
|
||||||
dayBeforeMaturity.setDate(dayBeforeMaturity.getDate() - 1);
|
|
||||||
if (dayBeforeMaturity < this.maxDate) {
|
|
||||||
this.maxDate = dayBeforeMaturity;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
this.recurringDepositsService.getRecurringDepositLiens(this.accountId).subscribe((response: any) => {
|
|
||||||
this.liens = response?.liens ?? [];
|
|
||||||
this.totalLienAmount = Number(response?.totalLienAmount) || 0;
|
|
||||||
});
|
|
||||||
// A different date or reason is a different payout - the old preview no longer describes what would happen.
|
|
||||||
this.prematureCloseRecurringDepositsAccountForm.get('closedOnDate').valueChanges.subscribe(() => {
|
|
||||||
this.preview = null;
|
|
||||||
this.previewedFor = null;
|
|
||||||
});
|
|
||||||
this.prematureCloseRecurringDepositsAccountForm.get('prematureClosureReasonId').valueChanges.subscribe((reason: number) => {
|
|
||||||
this.preview = null;
|
|
||||||
this.previewedFor = null;
|
|
||||||
if (reason === REASON_DECEASED) {
|
|
||||||
this.loadNomineeSavingsAccounts();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Reasons this deposit's product allows; Normal is always there. */
|
|
||||||
get reasonOptions(): Array<{ id: number; label: string; hint: string }> {
|
|
||||||
const options = [{ id: REASON_NORMAL, label: 'Normal', hint: "Pre-closure penalty as per the deposit's terms." }];
|
|
||||||
if (this.account?.noPenaltyNomineeHeirClosure) {
|
|
||||||
options.push({ id: REASON_NOMINEE_HEIR, label: 'Nominee / legal heir claim', hint: 'No pre-closure penalty.' });
|
|
||||||
}
|
|
||||||
if (this.account?.deceasedSettlement) {
|
|
||||||
options.push({
|
|
||||||
id: REASON_DECEASED,
|
|
||||||
label: 'Account holder deceased',
|
|
||||||
hint: "No penalty, allowed inside lock-in; may be paid to a nominee's savings account."
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return options;
|
|
||||||
}
|
|
||||||
|
|
||||||
get selectedReasonHint(): string {
|
|
||||||
const reason = this.prematureCloseRecurringDepositsAccountForm.value.prematureClosureReasonId;
|
|
||||||
return this.reasonOptions.find((option) => option.id === reason)?.hint ?? '';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The client's own savings accounts, plus - for a deceased holder - the nominees' savings accounts. */
|
|
||||||
get destinationAccounts(): any[] {
|
|
||||||
const own = (this.preview?.savingsAccounts ?? []).map((acc: any) => ({ ...acc, owner: null }));
|
|
||||||
if (this.prematureCloseRecurringDepositsAccountForm.value.prematureClosureReasonId !== REASON_DECEASED) {
|
|
||||||
return own;
|
|
||||||
}
|
|
||||||
const ownIds = new Set(own.map((acc: any) => acc.id));
|
|
||||||
return [
|
|
||||||
...own,
|
|
||||||
...this.nomineeSavingsAccounts.filter((acc: any) => !ownIds.has(acc.id))
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private loadNomineeSavingsAccounts() {
|
|
||||||
this.nomineeSavingsAccounts = [];
|
|
||||||
const clientId = this.account?.clientId;
|
|
||||||
if (!clientId || !this.account?.accountNo) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.clientsService.getClientNominees(clientId, this.account.accountNo, 'RD').subscribe((nominees: any) => {
|
|
||||||
const list: any[] = Array.isArray(nominees) ? nominees : (nominees?.pageItems ?? []);
|
|
||||||
list
|
|
||||||
.filter((nominee: any) => nominee?.nomineeClientId && nominee.isActive !== false)
|
|
||||||
.forEach((nominee: any) => {
|
|
||||||
this.clientsService.getClientAccountData(nominee.nomineeClientId).subscribe((accounts: any) => {
|
|
||||||
const savings = (accounts?.savingsAccounts ?? []).filter(
|
|
||||||
(acc: any) => acc?.depositType?.id === 100 && acc?.status?.active
|
|
||||||
);
|
|
||||||
this.nomineeSavingsAccounts = [
|
|
||||||
...this.nomineeSavingsAccounts,
|
|
||||||
...savings.map((acc: any) => ({
|
|
||||||
id: acc.id,
|
|
||||||
accountNo: acc.accountNo,
|
|
||||||
productName: acc.productName,
|
|
||||||
owner: nominee.nomineeDisplayName
|
|
||||||
}))
|
|
||||||
];
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the premature close recurring deposits account form.
|
||||||
|
*/
|
||||||
createprematureCloseRecurringDepositsAccountForm() {
|
createprematureCloseRecurringDepositsAccountForm() {
|
||||||
this.prematureCloseRecurringDepositsAccountForm = this.formBuilder.group({
|
this.prematureCloseRecurringDepositsAccountForm = this.formBuilder.group({
|
||||||
closedOnDate: [
|
closedOnDate: [
|
||||||
this.settingsService.businessDate,
|
'',
|
||||||
Validators.required
|
Validators.required
|
||||||
],
|
]
|
||||||
prematureClosureReasonId: [REASON_NORMAL],
|
|
||||||
toSavingsAccountId: [
|
|
||||||
null,
|
|
||||||
Validators.required
|
|
||||||
],
|
|
||||||
transferDescription: ['Premature closure of recurring deposit'],
|
|
||||||
note: ['']
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
get balance(): number | null {
|
/**
|
||||||
return this.account?.summary?.accountBalance ?? null;
|
* Submits the form and premature closes the recurring deposit account,
|
||||||
}
|
* if successful redirects to the recurring deposit account.
|
||||||
|
*/
|
||||||
get currencySymbol(): string {
|
|
||||||
return this.account?.currency?.displaySymbol || this.account?.currency?.code || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** What breaking early costs compared with letting the deposit run to maturity, when both are known. */
|
|
||||||
get shortfallVsMaturity(): number | null {
|
|
||||||
const maturityAmount = this.account?.maturityAmount;
|
|
||||||
if (!this.preview || maturityAmount == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return Number(maturityAmount) - Number(this.preview.payoutAmount);
|
|
||||||
}
|
|
||||||
|
|
||||||
get canConfirm(): boolean {
|
|
||||||
return (
|
|
||||||
this.totalLienAmount <= 0 &&
|
|
||||||
!!this.preview &&
|
|
||||||
this.previewedFor === this.formattedClosedOnDate &&
|
|
||||||
this.prematureCloseRecurringDepositsAccountForm.valid &&
|
|
||||||
!this.submitting
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private get formattedClosedOnDate(): string | null {
|
|
||||||
const value = this.prematureCloseRecurringDepositsAccountForm.value.closedOnDate;
|
|
||||||
if (!value) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return value instanceof Date ? this.dateUtils.formatDate(value, this.settingsService.dateFormat) : value;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Asks the server what the deposit pays if broken on the chosen date. */
|
|
||||||
calculate() {
|
|
||||||
const closedOnDate = this.formattedClosedOnDate;
|
|
||||||
if (!closedOnDate) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.calculating = true;
|
|
||||||
const data = {
|
|
||||||
closedOnDate,
|
|
||||||
prematureClosureReasonId: this.prematureCloseRecurringDepositsAccountForm.value.prematureClosureReasonId,
|
|
||||||
dateFormat: this.settingsService.dateFormat,
|
|
||||||
locale: this.settingsService.language.code
|
|
||||||
};
|
|
||||||
this.recurringDepositsService.executeRecurringDepositsAccountCommand(this.accountId, 'calculatePrematureAmount', data).subscribe({
|
|
||||||
next: (response: any) => {
|
|
||||||
const savingsAccounts = response?.savingsAccounts ?? [];
|
|
||||||
const closureOptionId =
|
|
||||||
(response?.onAccountClosureOptions ?? []).find((option: any) => option.id === TRANSFER_TO_SAVINGS)?.id ??
|
|
||||||
TRANSFER_TO_SAVINGS;
|
|
||||||
this.preview = { payoutAmount: response?.maturityAmount, savingsAccounts, closureOptionId };
|
|
||||||
this.previewedFor = closedOnDate;
|
|
||||||
this.calculating = false;
|
|
||||||
this.selectDefaultDestination(savingsAccounts);
|
|
||||||
},
|
|
||||||
error: () => {
|
|
||||||
this.calculating = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The deposit's own linked savings account, when it has one the client can still receive into. */
|
|
||||||
private selectDefaultDestination(savingsAccounts: any[]) {
|
|
||||||
const control = this.prematureCloseRecurringDepositsAccountForm.get('toSavingsAccountId');
|
|
||||||
if (control.value && savingsAccounts.some((acc: any) => acc.id === control.value)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const linkedId = this.account?.linkedAccountId;
|
|
||||||
const linked = savingsAccounts.find((acc: any) => acc.id === linkedId);
|
|
||||||
control.setValue(linked?.id ?? (savingsAccounts.length === 1 ? savingsAccounts[0].id : null));
|
|
||||||
}
|
|
||||||
|
|
||||||
submit() {
|
submit() {
|
||||||
if (!this.canConfirm) {
|
const prematureCloseRecurringDepositsAccountFormData = this.prematureCloseRecurringDepositsAccountForm.value;
|
||||||
return;
|
const locale = this.settingsService.language.code;
|
||||||
|
const dateFormat = this.settingsService.dateFormat;
|
||||||
|
const prevClosedOnDate: Date = this.prematureCloseRecurringDepositsAccountForm.value.closedOnDate;
|
||||||
|
if (prematureCloseRecurringDepositsAccountFormData.closedOnDate instanceof Date) {
|
||||||
|
prematureCloseRecurringDepositsAccountFormData.closedOnDate = this.dateUtils.formatDate(
|
||||||
|
prevClosedOnDate,
|
||||||
|
dateFormat
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const formValue = this.prematureCloseRecurringDepositsAccountForm.value;
|
const data = {
|
||||||
const data: any = {
|
...prematureCloseRecurringDepositsAccountFormData,
|
||||||
closedOnDate: this.formattedClosedOnDate,
|
dateFormat,
|
||||||
onAccountClosureId: this.preview.closureOptionId,
|
locale
|
||||||
prematureClosureReasonId: formValue.prematureClosureReasonId,
|
|
||||||
toSavingsAccountId: formValue.toSavingsAccountId,
|
|
||||||
transferDescription: formValue.transferDescription,
|
|
||||||
dateFormat: this.settingsService.dateFormat,
|
|
||||||
locale: this.settingsService.language.code
|
|
||||||
};
|
};
|
||||||
if (formValue.note) {
|
this.recurringDepositsService
|
||||||
data.note = formValue.note;
|
.executeRecurringDepositsAccountCommand(this.accountId, 'prematureClose', data)
|
||||||
}
|
.subscribe(() => {
|
||||||
this.submitting = true;
|
|
||||||
this.recurringDepositsService.executeRecurringDepositsAccountCommand(this.accountId, 'prematureClose', data).subscribe({
|
|
||||||
next: () => {
|
|
||||||
this.router.navigate(['../../'], { relativeTo: this.route });
|
this.router.navigate(['../../'], { relativeTo: this.route });
|
||||||
},
|
});
|
||||||
error: () => {
|
|
||||||
this.submitting = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** API dates arrive as [yyyy, m, d] arrays or ISO strings. */
|
|
||||||
private toDate(value: any): Date | null {
|
|
||||||
if (!value) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
return new Date(value[0], value[1] - 1, value[2]);
|
|
||||||
}
|
|
||||||
const parsed = new Date(value);
|
|
||||||
return isNaN(parsed.getTime()) ? null : parsed;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-9
@@ -30,14 +30,6 @@
|
|||||||
@if (actions['Close']) {
|
@if (actions['Close']) {
|
||||||
<mifosx-close-recurring-deposits-account></mifosx-close-recurring-deposits-account>
|
<mifosx-close-recurring-deposits-account></mifosx-close-recurring-deposits-account>
|
||||||
}
|
}
|
||||||
<!-- A direct deposit from the account screen skips the manager's approval; RD installments come in through the
|
@if (actions['Deposit'] || actions['Withdrawal']) {
|
||||||
cashier's Receipt or Auto Debit instead, so the Deposit action is not offered here even by URL. -->
|
|
||||||
@if (actions['Deposit']) {
|
|
||||||
<p>
|
|
||||||
RD installments cannot be deposited from the account screen. Use a cashier Receipt, or Auto Debit from the linked
|
|
||||||
savings account.
|
|
||||||
</p>
|
|
||||||
}
|
|
||||||
@if (actions['Withdrawal']) {
|
|
||||||
<mifosx-deposit-recurring-deposits-account [currency]="currency"></mifosx-deposit-recurring-deposits-account>
|
<mifosx-deposit-recurring-deposits-account [currency]="currency"></mifosx-deposit-recurring-deposits-account>
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-41
@@ -30,50 +30,11 @@
|
|||||||
<div class="racc-alert racc-alert--editable">
|
<div class="racc-alert racc-alert--editable">
|
||||||
<div class="racc-alert__content">
|
<div class="racc-alert__content">
|
||||||
<h4>Auto Debit Facility</h4>
|
<h4>Auto Debit Facility</h4>
|
||||||
@if (recurringDepositsAccountProductTemplate?.autoDebitFacility) {
|
<p>Enable automatic deduction of funds for recurring payments, utility bills, or EMI obligations.</p>
|
||||||
<p>
|
|
||||||
Collect each installment automatically on its due date from the linked Savings account (Deposit Account
|
|
||||||
Link, Details tab). A short balance leaves it overdue and it is retried on the next run.
|
|
||||||
</p>
|
|
||||||
} @else {
|
|
||||||
<p>This product does not offer Auto Debit.</p>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
<mat-slide-toggle
|
<mat-slide-toggle [(ngModel)]="autoDebitFacility" color="primary"></mat-slide-toggle>
|
||||||
[(ngModel)]="autoDebitFacility"
|
|
||||||
[disabled]="!recurringDepositsAccountProductTemplate?.autoDebitFacility"
|
|
||||||
color="primary"
|
|
||||||
></mat-slide-toggle>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (lienAllowed) {
|
|
||||||
<div class="racc-alert racc-alert--editable">
|
|
||||||
<div class="racc-alert__content">
|
|
||||||
<h4>Loan against this RD (Lien)</h4>
|
|
||||||
<p>
|
|
||||||
How much of the RD balance can be given as loan, checked on the balance at the time the loan is taken.
|
|
||||||
Product limit: {{ productLienLimit }}%.
|
|
||||||
</p>
|
|
||||||
@if (lienPercentageError) {
|
|
||||||
<p class="racc-lien-error">{{ lienPercentageError }}</p>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<mat-form-field appearance="outline" class="racc-lien-field" subscriptSizing="dynamic">
|
|
||||||
<mat-label>Max loan (%)</mat-label>
|
|
||||||
<input
|
|
||||||
matInput
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
[max]="productLienLimit"
|
|
||||||
step="0.01"
|
|
||||||
[(ngModel)]="lienPercentage"
|
|
||||||
[ngModelOptions]="{ standalone: true }"
|
|
||||||
/>
|
|
||||||
<span matTextSuffix>%</span>
|
|
||||||
</mat-form-field>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="racc-alert">
|
<div class="racc-alert">
|
||||||
<div class="racc-alert__content">
|
<div class="racc-alert__content">
|
||||||
<h4>Dormancy Alert</h4>
|
<h4>Dormancy Alert</h4>
|
||||||
|
|||||||
-9
@@ -126,15 +126,6 @@ $gray-900: #202124;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.racc-lien-field {
|
|
||||||
width: 140px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.racc-lien-error {
|
|
||||||
color: #c62828;
|
|
||||||
}
|
|
||||||
|
|
||||||
.racc-alert--editable {
|
.racc-alert--editable {
|
||||||
border-color: #bae6fd;
|
border-color: #bae6fd;
|
||||||
background: #f0f9ff;
|
background: #f0f9ff;
|
||||||
|
|||||||
+2
-42
@@ -6,9 +6,8 @@
|
|||||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Component, Input, OnChanges, SimpleChanges, ViewChild, inject } from '@angular/core';
|
import { Component, Input, OnChanges, SimpleChanges, ViewChild } from '@angular/core';
|
||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
import { RecurringDepositsService } from '../../recurring-deposits.service';
|
|
||||||
import { NomineeFormComponent } from 'app/shared/nominee-form/nominee-form.component';
|
import { NomineeFormComponent } from 'app/shared/nominee-form/nominee-form.component';
|
||||||
import { DormancyAlertPanelComponent } from 'app/shared/dormancy-alert-panel/dormancy-alert-panel.component';
|
import { DormancyAlertPanelComponent } from 'app/shared/dormancy-alert-panel/dormancy-alert-panel.component';
|
||||||
import { MatSlideToggle } from '@angular/material/slide-toggle';
|
import { MatSlideToggle } from '@angular/material/slide-toggle';
|
||||||
@@ -37,14 +36,7 @@ export class RecurringDepositsAccountCurrencyStepComponent implements OnChanges
|
|||||||
showNomineeForm = false;
|
showNomineeForm = false;
|
||||||
nomineeError = '';
|
nomineeError = '';
|
||||||
|
|
||||||
private recurringDepositsService = inject(RecurringDepositsService);
|
|
||||||
|
|
||||||
autoDebitFacility = false;
|
autoDebitFacility = false;
|
||||||
/**
|
|
||||||
* Lien Marked products only: how much of this RD may be given as loan, as % of its balance when the loan is taken.
|
|
||||||
* Starts at the product's percentage; may be lowered, never raised above it.
|
|
||||||
*/
|
|
||||||
lienPercentage: number | null = null;
|
|
||||||
|
|
||||||
private existingAccountApplied = false;
|
private existingAccountApplied = false;
|
||||||
|
|
||||||
@@ -58,44 +50,13 @@ export class RecurringDepositsAccountCurrencyStepComponent 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];
|
||||||
}
|
}
|
||||||
// The account response does not carry the deposit's own lien percentage; the lien endpoint does.
|
|
||||||
if (ea.id) {
|
|
||||||
this.recurringDepositsService.getRecurringDepositLiens(ea.id).subscribe((response: any) => {
|
|
||||||
this.lienPercentage = response?.lienPercentage ?? response?.productLienPercentage ?? null;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (changes['recurringDepositsAccountProductTemplate']?.currentValue && !this.existingAccountApplied) {
|
if (changes['recurringDepositsAccountProductTemplate']?.currentValue && !this.existingAccountApplied) {
|
||||||
const pt = changes['recurringDepositsAccountProductTemplate'].currentValue;
|
const pt = changes['recurringDepositsAccountProductTemplate'].currentValue;
|
||||||
this.autoDebitFacility = !!pt.autoDebitFacility;
|
this.autoDebitFacility = !!pt.autoDebitFacility;
|
||||||
this.lienPercentage = pt.lienMarked ? (pt.lienPercentage ?? 100) : null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
get lienAllowed(): boolean {
|
|
||||||
return !!this.recurringDepositsAccountProductTemplate?.lienMarked;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The product's ceiling; a product with no percentage allows the whole balance. */
|
|
||||||
get productLienLimit(): number {
|
|
||||||
return Number(this.recurringDepositsAccountProductTemplate?.lienPercentage) || 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Why the entered percentage would be refused, or null when it is fine. */
|
|
||||||
get lienPercentageError(): string | null {
|
|
||||||
if (!this.lienAllowed) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (this.lienPercentage === null || this.lienPercentage === undefined || `${this.lienPercentage}` === '') {
|
|
||||||
return 'Enter how much of the RD balance can be given as loan.';
|
|
||||||
}
|
|
||||||
const value = Number(this.lienPercentage);
|
|
||||||
if (!(value > 0) || value > this.productLienLimit) {
|
|
||||||
return `Must be more than 0% and at most ${this.productLienLimit}% (product limit).`;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
get totalShare(): number {
|
get totalShare(): number {
|
||||||
return this.nominees.reduce((sum, n) => sum + (+n.percentageShare || 0), 0);
|
return this.nominees.reduce((sum, n) => sum + (+n.percentageShare || 0), 0);
|
||||||
}
|
}
|
||||||
@@ -151,8 +112,7 @@ export class RecurringDepositsAccountCurrencyStepComponent implements OnChanges
|
|||||||
|
|
||||||
get currencyStepData() {
|
get currencyStepData() {
|
||||||
return {
|
return {
|
||||||
autoDebitFacility: this.autoDebitFacility,
|
autoDebitFacility: this.autoDebitFacility
|
||||||
...(this.lienAllowed && this.lienPercentage != null ? { lienPercentage: Number(this.lienPercentage) } : {})
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-20
@@ -121,30 +121,11 @@
|
|||||||
{{ productTemplate.shareLinkingEnabled ? 'Yes' : 'No' }}
|
{{ productTemplate.shareLinkingEnabled ? 'Yes' : 'No' }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rad-field rad-info-row">
|
|
||||||
<span class="rad-info-label">Introducer Required</span>
|
|
||||||
<span class="rad-badge" [class.rad-badge--yes]="productTemplate.introducerRequired">
|
|
||||||
{{ productTemplate.introducerRequired ? 'Yes' : 'No' }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- The product requires an introducer: an existing customer, never the depositor themselves. -->
|
|
||||||
@if (productTemplate?.introducerRequired) {
|
|
||||||
<mifosx-introducer-search
|
|
||||||
[excludedClientIds]="primaryClientId ? [primaryClientId] : []"
|
|
||||||
[preselectedIntroducer]="
|
|
||||||
introducerId ? { clientId: introducerId, displayName: introducerName, cifNumber: introducerCif } : null
|
|
||||||
"
|
|
||||||
(introducerChange)="onIntroducerChange($event)"
|
|
||||||
></mifosx-introducer-search>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- ══ Deposit Account Link ══════════════════════════════════════════ -->
|
<!-- ══ Deposit Account Link ══════════════════════════════════════════ -->
|
||||||
<!-- Also offered when the product allows Auto Debit: that is the account installments are collected from. -->
|
@if (productTemplate?.shareLinkingEnabled) {
|
||||||
@if (productTemplate?.shareLinkingEnabled || productTemplate?.autoDebitFacility) {
|
|
||||||
<div class="dal-card">
|
<div class="dal-card">
|
||||||
<div class="dal-card__header">
|
<div class="dal-card__header">
|
||||||
<div class="dal-card__header-left">
|
<div class="dal-card__header-left">
|
||||||
|
|||||||
+1
-27
@@ -24,7 +24,6 @@ import { Dates } from 'app/core/utils/dates';
|
|||||||
import { MatDialog } from '@angular/material/dialog';
|
import { MatDialog } from '@angular/material/dialog';
|
||||||
|
|
||||||
/** Custom Services */
|
/** Custom Services */
|
||||||
import { ProductsService } from 'app/products/products.service';
|
|
||||||
import { RecurringDepositsService } from '../../recurring-deposits.service';
|
import { RecurringDepositsService } from '../../recurring-deposits.service';
|
||||||
import { ClientsService } from 'app/clients/clients.service';
|
import { ClientsService } from 'app/clients/clients.service';
|
||||||
import {
|
import {
|
||||||
@@ -63,7 +62,6 @@ import { IntroducerSearchComponent } from 'app/shared/introducer-search/introduc
|
|||||||
export class RecurringDepositsAccountDetailsStepComponent implements OnInit, OnChanges {
|
export class RecurringDepositsAccountDetailsStepComponent implements OnInit, OnChanges {
|
||||||
private formBuilder = inject(UntypedFormBuilder);
|
private formBuilder = inject(UntypedFormBuilder);
|
||||||
private recurringDepositsService = inject(RecurringDepositsService);
|
private recurringDepositsService = inject(RecurringDepositsService);
|
||||||
private productsService = inject(ProductsService);
|
|
||||||
private settingsService = inject(SettingsService);
|
private settingsService = inject(SettingsService);
|
||||||
private dateUtils = inject(Dates);
|
private dateUtils = inject(Dates);
|
||||||
private cdr = inject(ChangeDetectorRef);
|
private cdr = inject(ChangeDetectorRef);
|
||||||
@@ -119,10 +117,6 @@ export class RecurringDepositsAccountDetailsStepComponent implements OnInit, OnC
|
|||||||
primaryClientName = '';
|
primaryClientName = '';
|
||||||
primaryClientCif = '';
|
primaryClientCif = '';
|
||||||
primaryClientId: number | null = null;
|
primaryClientId: number | null = null;
|
||||||
/** Introducer - asked for only when the product has "Introducer Required" on. */
|
|
||||||
introducerId: number | null = null;
|
|
||||||
introducerName: string | null = null;
|
|
||||||
introducerCif: string | null = null;
|
|
||||||
primaryHolderShare = 50;
|
primaryHolderShare = 50;
|
||||||
|
|
||||||
// ── Status of Account (RBI) ────────────────────────────────────────────
|
// ── Status of Account (RBI) ────────────────────────────────────────────
|
||||||
@@ -459,14 +453,6 @@ export class RecurringDepositsAccountDetailsStepComponent implements OnInit, OnC
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.productTemplate = response;
|
this.productTemplate = response;
|
||||||
// The account template leaves out the product's installment limits; the calculator and the submit check
|
|
||||||
// read them from this template, so they are taken from the product itself.
|
|
||||||
if (response.minDepositAmount == null && response.maxDepositAmount == null) {
|
|
||||||
this.productsService.getRecurringDepositProduct(String(productId)).subscribe((product: any) => {
|
|
||||||
response.minDepositAmount = product?.minDepositAmount ?? null;
|
|
||||||
response.maxDepositAmount = product?.maxDepositAmount ?? null;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
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;
|
||||||
@@ -477,7 +463,7 @@ export class RecurringDepositsAccountDetailsStepComponent implements OnInit, OnC
|
|||||||
this.recurringDepositsAccountProductTemplate.emit(response);
|
this.recurringDepositsAccountProductTemplate.emit(response);
|
||||||
this.fieldOfficerData = response.fieldOfficerOptions;
|
this.fieldOfficerData = response.fieldOfficerOptions;
|
||||||
this.isProductSelected = true;
|
this.isProductSelected = true;
|
||||||
if (response.shareLinkingEnabled || response.autoDebitFacility) {
|
if (response.shareLinkingEnabled) {
|
||||||
this.loadClientAccounts();
|
this.loadClientAccounts();
|
||||||
}
|
}
|
||||||
this.recurringDepositAccountDetailsForm.patchValue({
|
this.recurringDepositAccountDetailsForm.patchValue({
|
||||||
@@ -624,12 +610,6 @@ export class RecurringDepositsAccountDetailsStepComponent implements OnInit, OnC
|
|||||||
return this.dateUtils.formatDate(dateValue, 'M/d/yyyy') || '';
|
return this.dateUtils.formatDate(dateValue, 'M/d/yyyy') || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
onIntroducerChange(event: { clientId: number; displayName: string; cifNumber?: string } | null): void {
|
|
||||||
this.introducerId = event?.clientId ?? null;
|
|
||||||
this.introducerName = event?.displayName ?? null;
|
|
||||||
this.introducerCif = event?.cifNumber ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
onGuardianChange(event: { clientId: number; displayName: string; cifNumber?: string } | null): void {
|
onGuardianChange(event: { clientId: number; displayName: string; cifNumber?: string } | null): void {
|
||||||
this.guardianClientId = event?.clientId ?? null;
|
this.guardianClientId = event?.clientId ?? null;
|
||||||
this.guardianName = event?.displayName ?? '';
|
this.guardianName = event?.displayName ?? '';
|
||||||
@@ -815,12 +795,6 @@ export class RecurringDepositsAccountDetailsStepComponent implements OnInit, OnC
|
|||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.productTemplate?.introducerRequired && this.introducerId) {
|
|
||||||
value.introducerId = this.introducerId;
|
|
||||||
value.introducerName = this.introducerName;
|
|
||||||
value.introducerCifNumber = this.introducerCif;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.selectedLinkedAccount) {
|
if (this.selectedLinkedAccount) {
|
||||||
value.linkedAccountId = this.selectedLinkedAccount.id;
|
value.linkedAccountId = this.selectedLinkedAccount.id;
|
||||||
value.linkedAccountType = this.selectedLinkedAccount.accountType;
|
value.linkedAccountType = this.selectedLinkedAccount.accountType;
|
||||||
|
|||||||
+1
-39
@@ -93,13 +93,7 @@
|
|||||||
<div class="fdrc-calc-field">
|
<div class="fdrc-calc-field">
|
||||||
<label class="fdrc-calc-label">
|
<label class="fdrc-calc-label">
|
||||||
Monthly Installment
|
Monthly Installment
|
||||||
<span class="fdrc-calc-optional"
|
<span class="fdrc-calc-optional">(optional — for maturity estimation)</span>
|
||||||
>(required@if (minInstallment > 0) {
|
|
||||||
— min ₹{{ minInstallment }}
|
|
||||||
}@if (maxInstallment > 0) {
|
|
||||||
, max ₹{{ maxInstallment }}
|
|
||||||
})</span
|
|
||||||
>
|
|
||||||
</label>
|
</label>
|
||||||
<div class="fdrc-amount-row">
|
<div class="fdrc-amount-row">
|
||||||
<span class="fdrc-amount-prefix">₹</span>
|
<span class="fdrc-amount-prefix">₹</span>
|
||||||
@@ -263,21 +257,6 @@
|
|||||||
</span>
|
</span>
|
||||||
<span class="fdrc-maturity-row__value">{{ matchResult.appliedRate | number: '1.2-2' }}% p.a.</span>
|
<span class="fdrc-maturity-row__value">{{ matchResult.appliedRate | number: '1.2-2' }}% p.a.</span>
|
||||||
</div>
|
</div>
|
||||||
@if (matchResult.interestCalculation) {
|
|
||||||
<div class="fdrc-maturity-row">
|
|
||||||
<span class="fdrc-maturity-row__label">Interest Type</span>
|
|
||||||
<span class="fdrc-maturity-row__value">
|
|
||||||
{{ matchResult.interestCalculation }}
|
|
||||||
@if (matchResult.interestCalculation === 'Compounding') {
|
|
||||||
({{ interestPeriodLabel(matchResult.compoundingPeriod) }})
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="fdrc-maturity-row">
|
|
||||||
<span class="fdrc-maturity-row__label">Interest Credited</span>
|
|
||||||
<span class="fdrc-maturity-row__value">{{ interestPeriodLabel(matchResult.postingPeriod) }}</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
<div class="fdrc-maturity-row fdrc-maturity-row--interest">
|
<div class="fdrc-maturity-row fdrc-maturity-row--interest">
|
||||||
<span class="fdrc-maturity-row__label">Estimated Interest Earned</span>
|
<span class="fdrc-maturity-row__label">Estimated Interest Earned</span>
|
||||||
<span class="fdrc-maturity-row__value">₹ {{ matchResult.interestEarned | number: '1.2-2' }}</span>
|
<span class="fdrc-maturity-row__value">₹ {{ matchResult.interestEarned | number: '1.2-2' }}</span>
|
||||||
@@ -288,23 +267,6 @@
|
|||||||
>₹ {{ matchResult.maturityAmount | number: '1.2-2' }}</span
|
>₹ {{ matchResult.maturityAmount | number: '1.2-2' }}</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@if (matchResult.postingSchedule?.length) {
|
|
||||||
<details class="fdrc-credits">
|
|
||||||
<summary>Interest credits to the RD ({{ matchResult.postingSchedule.length }})</summary>
|
|
||||||
<table class="fdrc-credits__table">
|
|
||||||
<tr>
|
|
||||||
<th>Credited on</th>
|
|
||||||
<th class="text-right">Interest</th>
|
|
||||||
</tr>
|
|
||||||
@for (p of matchResult.postingSchedule; track p.date) {
|
|
||||||
<tr>
|
|
||||||
<td>{{ p.date }}</td>
|
|
||||||
<td class="text-right">₹ {{ p.interest | number: '1.2-2' }}</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</table>
|
|
||||||
</details>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user