Compare commits

..
4 Commits
Author SHA1 Message Date
tarunmalviya 885b39d970 DDS: approvals tab, vouchers, payout & close, Sundry GL search, closure tab, fixes 2026-10-01 13:08:52 +05:30
tarunmalviya d47e0860f6 Merge remote-tracking branch 'origin/dev-latest' into feature/dds-agent-settlement-reports
# Conflicts:
#	src/app/core/shell/shell.component.html
#	src/app/organization/organization-routing.module.ts
#	src/environments/.env.ts
2026-09-23 12:11:12 +05:30
tarunmalviyaandClaude Opus 5 e2eff9914f DDS: agent collection entry screen, per-agent GL account, maturity auto close
The Daily Transaction Entry screen no longer composes a Party Tran. An agent records their own round
elsewhere; this screen reads back what they wrote down, shows what it adds up to, and settles the
day once a clerk has counted the cash against it. Nothing is typed here and nothing is edited - what
settles is what the entries say, to the rupee.

Agent Master now asks which ledger account the agent's collections pass through, one agent to an
account, and offers only the accounts that can carry them. The DDS scheme product is kept out of the
agent's own savings list for the same reason the server refuses it: debits are blocked on those
accounts, so commission paid into one would go in and never come out.

The DDS product carries a Maturity Auto Close setting, asked as Yes/No to match the Auto Interest
Post question directly above it, and Batch Processing offers the run that applies it.

Also: the report viewer stops printing two money columns ungrouped and stops totalling a column that
holds a word, and an agent's birth date cannot be picked later than eighteen years ago.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 11:54:49 +05:30
tarunmalviya 57a61b3057 DDS: Agent settlement (Agent Tran), Close Party account UI, reports UI, Party-Savings link field 2026-08-25 10:56:03 +05:30
380 changed files with 21563 additions and 30300 deletions
+2 -2
View File
@@ -69,8 +69,8 @@
"budgets": [
{
"type": "initial",
"maximumWarning": "15mb",
"maximumError": "17mb"
"maximumWarning": "18mb",
"maximumError": "20mb"
},
{
"type": "anyComponentStyle",
+74 -5363
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -15,7 +15,7 @@
"ng": "ng",
"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/",
"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: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",
@@ -279,7 +279,7 @@
<div class="gl-fgroup">
<label class="gl-flabel"><fa-icon icon="calendar-alt"></fa-icon> From Date</label>
<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 #dpFrom></mat-datepicker>
</mat-form-field>
@@ -288,7 +288,7 @@
<div class="gl-fgroup">
<label class="gl-flabel"><fa-icon icon="calendar-alt"></fa-icon> To Date</label>
<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 #dpTo></mat-datepicker>
</mat-form-field>
@@ -353,7 +353,13 @@
<div class="gl-fgroup">
<label class="gl-flabel"><fa-icon icon="calendar-check"></fa-icon> Submitted From</label>
<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 #dpSubFrom></mat-datepicker>
</mat-form-field>
@@ -362,7 +368,7 @@
<div class="gl-fgroup">
<label class="gl-flabel"><fa-icon icon="calendar-check"></fa-icon> Submitted To</label>
<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 #dpSubTo></mat-datepicker>
</mat-form-field>
@@ -533,7 +539,7 @@
</div>
<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 -->
<ng-container matColumnDef="id">
<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 ──────────────────────────────────────────────────────────────────
minDate = new Date(2000, 0, 1);
maxDate: Date = null;
maxDate = new Date();
/** Accounting features switched off for this installation — see disabled-features.ts. */
disabledFeatures = DISABLED_ACCOUNTING_FEATURES;
@@ -208,7 +208,7 @@ export class SearchJournalEntryComponent implements OnInit, OnDestroy, AfterView
}
ngOnInit() {
this.maxDate = null;
this.maxDate = this.settingsService.businessDate;
this.paginator.pageSize = 10;
this.restoreState();
this.setFilteredOffices();
@@ -11,6 +11,14 @@ import { Routes, RouterModule } from '@angular/router';
import { Route } from '../core/route/route.service';
import { AccountsListComponent } from './accounts-list/accounts-list.component';
import { PartyMasterComponent } from '../deposits/party-master/party-master.component';
import { CreatePartyComponent } from '../deposits/party-master/create-party/create-party.component';
import { DueForClosureComponent } from '../deposits/party-master/due-for-closure/due-for-closure.component';
import { DailyTransactionEntryComponent } from '../deposits/daily-transaction-entry/daily-transaction-entry.component';
import { DdsReportsComponent } from '../deposits/dds-reports/dds-reports.component';
import { ReportViewerComponent } from '../deposits/dds-reports/report-viewer/report-viewer.component';
import { DdsAccountsComponent } from '../deposits/dds-accounts/dds-accounts.component';
import { DdsAccountViewComponent } from '../deposits/dds-accounts/dds-account-view/dds-account-view.component';
const routes: Routes = [
Route.withShell([
@@ -41,6 +49,59 @@ const routes: Routes = [
title: 'Recurring Deposit Accounts',
breadcrumb: 'Recurring Deposit Accounts'
}
},
{
path: 'dds',
data: { title: 'Daily Deposit Scheme — Party Master', breadcrumb: 'Party Master' },
children: [
{ path: '', component: PartyMasterComponent },
{
path: 'create',
component: CreatePartyComponent,
data: { title: 'Create Party', breadcrumb: 'Create' }
},
{
// Ahead of the :partyId routes, so "dueforclosure" is not read as a party id.
path: 'dueforclosure',
component: DueForClosureComponent,
data: { title: 'Accounts Due for Closure', breadcrumb: 'Due for Closure' }
},
{
path: ':partyId/edit',
component: CreatePartyComponent,
data: { title: 'Edit Party', breadcrumb: 'Edit' }
}
]
},
{
// The DDS accounts listed like Savings Accounts, each opening onto its transactions.
path: 'dds-accounts',
data: { title: 'DDS Accounts', breadcrumb: 'DDS Accounts' },
children: [
{ path: '', component: DdsAccountsComponent },
{
path: ':partyId',
component: DdsAccountViewComponent,
data: { title: 'DDS Account', breadcrumb: 'Account', routeParamBreadcrumb: false }
}
]
},
{
path: 'dds-collection',
component: DailyTransactionEntryComponent,
data: { title: 'Daily Transaction Entry', breadcrumb: 'Daily Transaction Entry' }
},
{
path: 'dds-reports',
data: { title: 'DDS Reports', breadcrumb: 'DDS Reports' },
children: [
{ path: '', component: DdsReportsComponent },
{
path: 'view/:reportName',
component: ReportViewerComponent,
data: { title: 'DDS Report', breadcrumb: 'reportName', routeParamBreadcrumb: 'reportName' }
}
]
}
]
}
-16
View File
@@ -74,22 +74,6 @@ export class AccountsService {
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> {
let httpParams = new HttpParams()
.set('paged', 'true')
-4
View File
@@ -63,8 +63,6 @@ import { CfcfrmsModule } from './cfcfrms/cfcfrms.module';
import { BankCalendarModule } from './bank-calendar/bank-calendar.module';
import { DeafModule } from './deaf/deaf.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 { FiuModule } from './fiu/fiu.module';
import { GlReportsModule } from './gl-reports/gl-reports.module';
@@ -178,8 +176,6 @@ export function HttpLoaderFactory(http: HttpClient) {
BankCalendarModule,
DeafModule,
SavingsDailyInterestModule,
FdDashboardModule,
RdDashboardModule,
DicgcModule,
FiuModule,
GlReportsModule,
@@ -17,12 +17,7 @@
</p>
</div>
</div>
<button
class="bor-btn bor-btn--xls"
type="button"
[disabled]="exporting || !filtered.length"
(click)="exportExcel()"
>
<button class="bor-btn bor-btn--xls" type="button" [disabled]="exporting || !filtered.length" (click)="exportExcel()">
<fa-icon icon="file-excel"></fa-icon>
{{ exporting ? 'Exporting…' : 'Export' }}
</button>
@@ -137,9 +132,7 @@
<td class="num">{{ r.approvedShares | number }}</td>
<td class="num strong">{{ r.shareValue | number: '1.2-2' }}</td>
<td class="num">{{ r.trancheCount }}</td>
<td>
<span [class]="statusClass(r.statusEnum)">{{ statusText(r.statusEnum) }}</span>
</td>
<td><span [class]="statusClass(r.statusEnum)">{{ statusText(r.statusEnum) }}</span></td>
<td class="mono">{{ r.activatedDate || '—' }}</td>
</tr>
} @empty {
@@ -12,7 +12,6 @@ import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { catchError, of } from 'rxjs';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
interface RegisterRow {
accountId: number;
@@ -84,7 +83,7 @@ export class AccountRegisterComponent implements OnInit {
/** Local date — toISOString() would shift the day in any timezone behind UTC. */
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')}`;
}
@@ -107,26 +107,11 @@
</span>
</div>
@if (canOperate) {
<button
type="button"
class="pg-btn pg-btn--primary"
(click)="startClose(openDay)"
[disabled]="interestPending.length > 0"
>
<button type="button" class="pg-btn pg-btn--primary" (click)="startClose(openDay)">
<mat-icon>lock</mat-icon> Close Day
</button>
}
</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) {
<div class="bd-current bd-current--shut">
<div class="bd-current__main">
@@ -372,30 +372,3 @@
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);
}
/**
* 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 {
this.loading = true;
this.errorMsg = '';
@@ -227,7 +210,6 @@ export class BranchDayComponent implements OnInit {
this.canOperate = !!res?.canOperate;
this.openDate = this.today;
this.checkFirstOpenCashMigration();
this.checkInterestReports();
});
this.service
@@ -11,7 +11,6 @@ import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { AuthenticationService } from '../../core/authentication/authentication.service';
import { ExportSheetSpec, exportWorkbook } from '../cashier-export';
import { businessToday } from 'app/core/utils/business-today';
interface SummaryRow {
code: string;
@@ -1317,7 +1316,7 @@ export class CashClearingTransferSummaryComponent {
private dateTag = '2607';
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.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 { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
interface Denom {
id: number;
@@ -64,7 +63,7 @@ export class CashExchangeEntryComponent implements OnInit {
}
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.service.getActiveDenominations().subscribe({
next: (list) => {
@@ -18,7 +18,6 @@ import { MatInputModule } from '@angular/material/input';
import { forkJoin, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
type PageState = 'loading' | 'no-cashier' | 'needs-reconciliation' | 'already-settled' | 'form';
@@ -91,7 +90,7 @@ export class CashSettlementComponent implements OnInit {
saving = false;
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.service
.getTellers()
@@ -17,7 +17,6 @@ import { MatInputModule } from '@angular/material/input';
import { of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
interface Denom {
id: number;
@@ -83,7 +82,7 @@ export class CashTransferComponent implements OnInit {
loadingVault = false;
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.service.getActiveDenominations().subscribe({
next: (list) => {
@@ -17,7 +17,6 @@ import { forkJoin, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
import { AuthenticationService } from '../../core/authentication/authentication.service';
import { businessToday } from 'app/core/utils/business-today';
interface DenomRow {
denominationId: number;
@@ -72,7 +71,7 @@ export class CashierPositionComponent implements OnInit {
isCashierMode = false;
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.isCashierMode = this.authService.isCashierUser();
-17
View File
@@ -205,23 +205,6 @@ export class CashierService {
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. */
branchDayMissed(officeId?: number): Observable<any> {
let params = new HttpParams();
@@ -51,12 +51,6 @@ export class ClerkShareWithdrawalService {
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> {
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 { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
/** Day-wise denomination-wise closing cash collected from deposits. */
@Component({
@@ -205,7 +204,7 @@ export class ClosingCashReportComponent implements OnInit {
}
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.load();
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 { catchError, of } from 'rxjs';
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.
@@ -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. */
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')}`;
}
@@ -17,7 +17,6 @@ import { MatInputModule } from '@angular/material/input';
import { of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
interface Denom {
id: number;
@@ -80,7 +79,7 @@ export class InterCashierTransferComponent implements OnInit {
}
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.service.getActiveDenominations().subscribe({
next: (list) => {
+1 -2
View File
@@ -14,7 +14,6 @@ import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { catchError, finalize, of } from 'rxjs';
import { CashierService } from '../cashier.service';
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. */
export type MyRole = 'RAISED' | 'VERIFIED' | 'RAISED_AND_VERIFIED';
@@ -358,7 +357,7 @@ export class MyWorkComponent implements OnInit {
}
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')}`;
}
}
@@ -18,7 +18,6 @@ import { MatTabsModule } from '@angular/material/tabs';
import { forkJoin, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
interface DenomRow {
denominationId: number;
@@ -86,7 +85,7 @@ export class ReconciliationComponent implements OnInit {
loadingHistory = false;
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.service
.getTellers()
@@ -11,7 +11,7 @@ import { CommonModule } from '@angular/common';
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
import { FormsModule } from '@angular/forms';
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 { MatDialog, MatDialogModule, MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
@@ -33,7 +33,6 @@ import {
import { SponsorDDService } from '../../sponsor-dd/sponsor-dd.service';
import { OrganizationService } from '../../organization/organization.service';
import { CashierIssueDdDialogComponent } from './cashier-issue-dd-dialog.component';
import { businessToday, businessTodayIso } from 'app/core/utils/business-today';
interface TxnRow {
typ: string;
@@ -622,7 +621,7 @@ export class AccountStatementDialogComponent implements OnInit {
@ViewChild('statementContent') statementContent!: ElementRef;
today = businessTodayIso();
today = new Date().toISOString().slice(0, 10);
fromDate = '';
toDate = this.today;
@@ -1871,7 +1870,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
saving = false;
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.addRow();
this.loadDenominations();
@@ -2104,7 +2103,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}
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')}`;
this.cashierService
.getCashierPositionForMe(asOfDate)
@@ -2838,7 +2837,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
fundingAccountId: number,
fundingClientId: number | null
): 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 isCurrent = row.acTyp === 'CA' || row.acTyp === 'CB';
const isGl = row.acTyp === 'GL';
@@ -2970,7 +2969,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
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 isCurrent = row.acTyp === 'CA' || row.acTyp === 'CB';
@@ -3069,7 +3068,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
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 payload = {
@@ -3392,7 +3391,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
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')}`;
// 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,
description: shareRow.description || null,
settlementPaymentTypeId: savingsRow.paymentTypeId,
settlementMode: 'SAVINGS',
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 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)
);
}
/**
* 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
* member's linked savings account — mirrors submitShareRedemptionTransfer() above, but the
@@ -3563,7 +3512,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
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')}`;
// 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 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)
);
}
@@ -4224,7 +4173,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
): void {
const accountUrl = `${cfg.detail}/${accountId}${cfg.detailParams ?? '?associations=all'}`;
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')}`;
})();
@@ -4600,7 +4549,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}
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')}`;
}
@@ -5122,7 +5071,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
this.sameDayChequeClearing = 0;
return;
}
const today = businessToday();
const today = new Date();
const sameDay = (d: any) => {
if (!d) return false;
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', [
`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.',
'GL, FD and SHR accounts are not allowed — use Disbursement/Collection instead.',
'RD installments are paid through a cashier Receipt or Auto Debit.'
'GL, FD, RD, SHR accounts are not allowed — use Disbursement/Collection instead.'
]);
return;
}
@@ -5301,19 +5249,6 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
this.rows.length >= 2 &&
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 crRows = this.rows.filter((r) => r.dbCr === 'Cr');
@@ -5869,21 +5804,13 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
// Cashier deposit (savings/current/FD/RD/GL) → route through maker-checker (create PENDING request,
// no direct credit). FD and RD are their own account-type codes (see creditEntryAccountType()) and
// belong here just as much as a plain savings deposit -- a cashier crediting an FD/RD must not be
// able to move money in without a manager's approval either.
// Cashier deposit (savings/current/GL) → route through maker-checker (create PENDING request, no direct credit)
// Use the dialog-confirmed amount — for cash that is the denomination total
if (
!this.isClerk &&
!this.reviewMode &&
isDeposit &&
(row.acTyp === 'SD' ||
row.acTyp === 'CA' ||
row.acTyp === 'CB' ||
row.acTyp === 'FD' ||
row.acTyp === 'RD' ||
row.acTyp === 'GL')
(row.acTyp === 'SD' || row.acTyp === 'CA' || row.acTyp === 'CB' || row.acTyp === 'GL')
) {
this.submitCashierDepositRequest(row, this.confirmData?.amount ?? amount);
return;
@@ -6024,7 +5951,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
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 payload: any = {
@@ -6106,12 +6033,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
this.editRequest = req;
}
// Same bug, same fix as the token-driven withdrawal-slip lookup above (see its comment): the
// 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 acTyp = 'SD';
const row = this.rows[0];
row.acNo = req.account_no ?? '';
row.acTyp = acTyp;
@@ -6219,7 +6141,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
/** Build the withdrawal-request payload from the current row + loaded account. */
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 selectedPt = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId);
@@ -6324,7 +6246,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
/** Build the deposit-request payload from the current row + loaded account. */
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 selectedPt = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId);
// Send denominations only for cash payments — stale counts on a cheque
@@ -6435,7 +6357,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
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 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 { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
interface DenomRow {
denominationId: number;
@@ -68,7 +67,7 @@ export class VaultPositionComponent implements OnInit {
errorMsg = '';
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.service
@@ -13,7 +13,6 @@ import { ActivatedRoute } from '@angular/router';
import { MatIconModule } from '@angular/material/icon';
import { catchError, of } from 'rxjs';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
/**
* Which side of the register a screen shows.
@@ -228,7 +227,7 @@ export class VoucherReportComponent implements OnInit {
}
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')}`;
}
@@ -129,31 +129,6 @@
<span>* Date of Meeting</span>
<input type="date" [(ngModel)]="meetingDate" [disabled]="isReview" />
</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">
<span>* Settlement Payment Type</span>
@if (!isReview) {
@@ -411,25 +386,14 @@
<td>Date of Meeting</td>
<td>{{ meetingDate }}</td>
</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>
<td>Settlement Payment Type</td>
<td>{{ settlementPaymentTypeName }}</td>
</tr>
<tr>
<td>Linked Savings Account</td>
<td>{{ account?.savingsAccountNumber || 'None — settlement will not run automatically' }}</td>
</tr>
</tbody>
</table>
@@ -8,7 +8,7 @@
import { Component, EventEmitter, Input, OnInit, Output, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule, UntypedFormControl } from '@angular/forms';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { MatIconModule } from '@angular/material/icon';
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 { CashierService } from '../../cashier/cashier.service';
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';
@@ -54,8 +51,7 @@ interface ActivityEntry {
RouterModule,
MatIconModule,
MatSnackBarModule,
SearchSelectComponent,
GlAccountSelectorComponent
SearchSelectComponent
]
})
export class ShareWithdrawalRequestComponent implements OnInit {
@@ -64,7 +60,6 @@ export class ShareWithdrawalRequestComponent implements OnInit {
private sharesService = inject(SharesService);
private wdService = inject(ClerkShareWithdrawalService);
private cashierService = inject(CashierService);
private accountingService = inject(AccountingService);
private snackBar = inject(MatSnackBar);
/** 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[] = [];
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[] = [];
reviewRequest: any = null;
showRejectDialog = false;
@@ -148,17 +132,6 @@ export class ShareWithdrawalRequestComponent implements OnInit {
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 {
if (!this.account) 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.meetingDate) return false;
if (!this.settlementPaymentTypeId) return false;
if (this.settlementMode === 'GL' && !this.settlementGlAccountId) return false;
return true;
}
@@ -188,18 +160,11 @@ export class ShareWithdrawalRequestComponent implements OnInit {
pass: shares > 0 && shares <= held,
detail: `${shares} of ${held} shares held`
},
this.settlementMode === 'GL'
? {
label: 'Settlement GL Account selected',
pass: !!this.settlementGlAccountId,
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: '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)',
pass: this.settlementPaymentTypeName !== '—',
@@ -249,37 +214,11 @@ export class ShareWithdrawalRequestComponent implements OnInit {
.pipe(catchError(() => of([])))
.subscribe((types: any[]) => (this.paymentTypes = types ?? []));
this.toGlAccountControl.valueChanges.subscribe((id: any) => {
this.settlementGlAccountId = id ? Number(id) : null;
});
if (this.requestId && this.mode === 'review') {
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 {
this.selectedAccountId = accountId;
if (!accountId) {
@@ -307,11 +246,6 @@ export class ShareWithdrawalRequestComponent implements OnInit {
this.resolutionNumber = req.resolution_number ?? '';
this.meetingDate = req.meeting_date ? String(req.meeting_date).split('T')[0].split(' ')[0] : this.meetingDate;
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) {
try {
@@ -355,9 +289,7 @@ export class ShareWithdrawalRequestComponent implements OnInit {
resolutionNumber: this.resolutionNumber.trim(),
meetingDate: this.meetingDate,
description: this.description,
settlementPaymentTypeId: this.settlementPaymentTypeId,
settlementMode: this.settlementMode,
...(this.settlementMode === 'GL' ? { settlementGlAccountId: this.settlementGlAccountId } : {})
settlementPaymentTypeId: this.settlementPaymentTypeId
};
this.wdService.submit(payload).subscribe({
next: () => {
@@ -643,9 +643,8 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
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
// ClientClassification code and Constitution is id 24), so a hardcoded id left the dropdown empty.
this.clientService.getCodeValuesByName('Constitution').subscribe((options) => {
// Fetch all constitution options from code ID 17
this.clientService.getCodeValuesById(17).subscribe((options) => {
if (options?.length) {
this.constitutionOptions = options;
// 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
) {
<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>
@@ -8,14 +8,7 @@
import { Component, Input, OnInit, OnChanges, OnDestroy, SimpleChanges, inject } from '@angular/core';
import { forkJoin, catchError, of } from 'rxjs';
import {
UntypedFormBuilder,
UntypedFormGroup,
Validators,
FormsModule,
AbstractControl,
ValidationErrors
} from '@angular/forms';
import { UntypedFormBuilder, UntypedFormGroup, Validators, FormsModule } from '@angular/forms';
import {
MatDialog,
MAT_DIALOG_DATA,
@@ -574,28 +567,6 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
/** Existing signature document ID (for re-upload/delete) */
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 */
kycForm: UntypedFormGroup = this.formBuilder.group({
hasPan: [true],
@@ -645,8 +616,7 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
[
Validators.required,
Validators.pattern(/^[A-Za-z0-9\-\/\s]+$/),
Validators.maxLength(30),
this.aadhaarLast4MatchValidator
Validators.maxLength(30)
]
],
kycDocIssueDate: [''],
@@ -769,14 +739,6 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
this.kycForm.get('ecgcSalFlag')?.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,
@@ -1047,9 +1009,6 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
// Reset uploads + merged preview when document type changes
this.kycDocFiles = { front: null, back: 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 {
@@ -1230,9 +1189,6 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
// Sync chip-picker selection with the prefilled document name
if (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
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 { Form60TabComponent } from './clients-view/form60-tab/form60-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 { AuthorizedSignatoriesTabComponent } from './clients-view/authorized-signatories-tab/authorized-signatories-tab.component';
import { familyAllowedGuard, nomineeAllowedGuard } from './utils/family-nominee.guard';
@@ -199,7 +198,6 @@ const routes: Routes = [
{
path: 'form121',
component: Form121TabComponent,
resolve: { tdsConfig: tdsConfigResolver },
data: { title: 'Form 121 / TDS', breadcrumb: 'Form 121 / TDS', routeParamBreadcrumb: false }
},
{
@@ -202,14 +202,9 @@
<div>
<strong>Already available</strong>
<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>
@if (
(existing.ADDRESS_PROOF.previewUrl || existing.ADDRESS_PROOF.previewUrlBack) && isImage('ADDRESS_PROOF')
) {
@if (existing.ADDRESS_PROOF.previewUrl || existing.ADDRESS_PROOF.previewUrlBack) {
<div class="ckyc-existing__thumbs">
@if (existing.ADDRESS_PROOF.previewUrl) {
<img
@@ -227,17 +222,6 @@
}
</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>
&nbsp;View document
</button>
}
<div class="ckyc-existing__meta">
<fa-icon icon="file-alt"></fa-icon>
<span>{{ existing.ADDRESS_PROOF.name }}</span>
@@ -315,12 +299,9 @@
<div>
<strong>Already available</strong>
<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>
@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">
@if (existing.PHOTO_PROOF.previewUrl) {
<img
@@ -338,17 +319,6 @@
}
</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>
&nbsp;View document
</button>
}
<div class="ckyc-existing__meta">
<fa-icon icon="id-card"></fa-icon>
<span>{{ existing.PHOTO_PROOF.name }}</span>
@@ -423,24 +393,10 @@
<div>
<strong>Already available</strong>
<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>
@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) {
<button
mat-stroked-button
type="button"
class="ckyc-existing__view"
(click)="openDocument('CLIENT_IMAGE')"
>
<fa-icon icon="eye"></fa-icon>
&nbsp;View document
</button>
<img [src]="existing.CLIENT_IMAGE.previewUrl" alt="Client photo" class="ckyc-existing__thumb" />
}
<button
mat-stroked-button
@@ -492,29 +448,15 @@
<div>
<strong>Already available</strong>
<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>
@if (existing.CLIENT_SIGNATURE.previewUrl && isImage('CLIENT_SIGNATURE')) {
@if (existing.CLIENT_SIGNATURE.previewUrl) {
<img
[src]="existing.CLIENT_SIGNATURE.previewUrl"
alt="Client signature"
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>
&nbsp;View document
</button>
}
<div class="ckyc-existing__meta">
<fa-icon icon="file-signature"></fa-icon>
<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 {
display: flex;
flex-direction: column;
@@ -36,41 +36,11 @@ interface ExistingArtefact {
* already writes/reads for address proof; now shared by the photo/identity proof too. */
description?: string;
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 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({
selector: 'mifosx-ckyc-tab',
templateUrl: './ckyc-tab.component.html',
@@ -180,11 +150,6 @@ export class CkycTabComponent implements OnInit, OnDestroy {
ngOnInit(): void {
this.route.parent.params.subscribe((params: any) => {
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.loadAccounts();
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 {
this.route.parent.data.subscribe((data: any) => {
const cv = data?.clientViewData;
@@ -355,29 +280,19 @@ export class CkycTabComponent implements OnInit, OnDestroy {
present: true,
source: 'Client registration',
name: signatureDoc.fileName || 'Client signature',
documentId: signatureDoc.id,
mimeType: signatureDoc.type
documentId: signatureDoc.id
};
// Same endpoint getClientSignatureImage() uses, but opted out of the global error alert:
// 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.
// Fetch blob for preview
this.clientsService
.downloadClientDocument(this.clientId, String(signatureDoc.id), true)
.getClientSignatureImage(this.clientId, signatureDoc.id)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (blob: any) => {
if (blob && blob.size > 0) {
if (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: () => {
this.existing.CLIENT_SIGNATURE.previewUnavailable = true;
}
error: () => undefined
});
}
@@ -449,7 +364,6 @@ export class CkycTabComponent implements OnInit, OnDestroy {
const list = Array.isArray(docs) ? docs : [];
if (!list.length) return;
this.existing[kind].fileCount = list.length;
this.existing[kind].previewUnavailable = false;
const byNewest = (a: any, b: any) => b.id - a.id;
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) => 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 (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$))
.subscribe({
next: (blob: any) => {
if (!blob || blob.size === 0) {
this.existing[kind].previewUnavailable = true;
return;
}
if (!blob) return;
const url = URL.createObjectURL(blob);
if (slot === 'front') this.existing[kind].previewUrl = 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
// showing "Already available" next to a blank space.
error: () => {
this.existing[kind].previewUnavailable = true;
}
error: () => undefined
});
}
@@ -548,18 +452,6 @@ export class CkycTabComponent implements OnInit, OnDestroy {
ref.afterClosed().subscribe((result: CkycUploadDialogResult | null) => {
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) {
const proofKind = kind as ProofKind;
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
* client-india-address-step, for address proof) writes into the identifier's description. */
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({
next: () => {
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.refreshPreview(documentType);
},
@@ -825,22 +653,13 @@ export class CkycTabComponent implements OnInit, OnDestroy {
if (this.existing[staleKey].previewUrlBack?.startsWith('blob:')) {
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) {
case 'CLIENT_IMAGE':
this.clientsService
.getClientProfileImage(this.clientId)
.pipe(takeUntil(this.destroy$))
.subscribe((photo: any) => {
if (photo) {
this.existing.CLIENT_IMAGE.previewUrl = photo;
} else {
this.existing.CLIENT_IMAGE.previewUnavailable = true;
}
if (photo) this.existing.CLIENT_IMAGE.previewUrl = photo;
});
break;
case 'CLIENT_SIGNATURE':
@@ -853,21 +672,11 @@ export class CkycTabComponent implements OnInit, OnDestroy {
?.sort((a: any, b: any) => b.id - a.id)?.[0];
if (!doc) return;
this.existing.CLIENT_SIGNATURE.documentId = doc.id;
this.existing.CLIENT_SIGNATURE.mimeType = doc.type;
this.clientsService
.downloadClientDocument(this.clientId, String(doc.id), true)
.getClientSignatureImage(this.clientId, doc.id)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (blob: any) => {
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;
}
.subscribe((blob: any) => {
if (blob) this.existing.CLIENT_SIGNATURE.previewUrl = URL.createObjectURL(blob);
});
});
break;
@@ -881,6 +690,10 @@ export class CkycTabComponent implements OnInit, OnDestroy {
ngOnDestroy(): void {
this.destroy$.next();
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">
<mat-icon>info_outline</mat-icon>
<div>
@if (configMissing) {
TDS rates for FY {{ fy }} are not configured, so the tax cannot be computed. Set them up under Organization
› TDS Configuration.
} @else if (!dateOfBirth) {
@if (!dateOfBirth) {
Date of birth is not on record, so the senior-citizen threshold cannot be determined. Add it on the Personal
Data tab.
} @else {
@@ -116,8 +113,8 @@
<div class="f121__note f121__note--warn">
<mat-icon>warning_amber</mat-icon>
<div>
A declaration is on record for this year, but one or more deposits still carry a tax group. Those accounts
deduct on their own and ignore the declaration — remove the tax group from them.
A Form 121 declaration is on record for this year, but withholding is still switched on for one or more
deposits. Turn it off on those accounts, or tax will keep being deducted.
</div>
</div>
}
@@ -192,90 +189,20 @@
}
</span>
</div>
@if (declarationRecord; as d) {
@if (declarationOnRecord && declarationFy) {
<div class="f121__kv-row">
<span>Declared for</span>
<span>FY {{ d.fy }} · Form {{ d.formType }}</span>
<span>FY {{ declarationFy }}</span>
</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 (old.revoked && old.fy === fy) {
<div class="f121__kv-row f121__kv-row--muted">
<span>Withdrawn</span>
<span>
Form {{ old.formType }} accepted {{ old.acceptedOn | date: 'd MMM y' }}, withdrawn
{{ old.revokedOn | date: 'd MMM y' }} — {{ old.revokeReason }}
</span>
</div>
}
@if (declarationOnRecord && !acceptanceDateKnown) {
<div class="f121__kv-row f121__kv-row--muted">
<span>Acceptance date</span>
<span>
Not captured — the declaration is treated as covering the whole year. A mid-year acceptance would change
the figures below.
</span>
</div>
}
@if (eligibility) {
@for (reason of eligibility.reasons; track reason) {
@@ -332,61 +332,3 @@
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
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 { RecurringDepositsService } from 'app/deposits/recurring-deposits/recurring-deposits.service';
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 */
import {
@@ -113,7 +111,6 @@ export class Form121TabComponent implements OnInit, OnDestroy {
private savingsService = inject(SavingsService);
private fixedDepositsService = inject(FixedDepositsService);
private recurringDepositsService = inject(RecurringDepositsService);
private tdsService = inject(TdsReportsService);
private destroy$ = new Subject<void>();
@@ -127,25 +124,23 @@ export class Form121TabComponent implements OnInit, OnDestroy {
pan = '';
panWellFormed = false;
// ── Declarations on record ──
/**
* 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[] = [];
// ── Declaration on record ──
declarationOnRecord = false;
/**
* 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
* than from the payee's age.
*/
declarationForm: DeclarationForm = '121';
// ── Recording a declaration ──
showDeclarationForm = false;
savingDeclaration = false;
declarationError = '';
newDeclaration = { acceptedOn: '', uin: '', estimatedIncome: null as number | null };
/** Kind recorded at KYC, meaningful only for a pre-2026 year. */
declarationKind: '15G' | '15H' = '15G';
declarationFy = '';
/**
* The KYC step records only the year, not the date the branch accepted the
* 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;
@@ -158,13 +153,11 @@ export class Form121TabComponent implements OnInit, OnDestroy {
schedule: TdsSchedule | null = null;
actualTds = 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. */
private accountInputs: (TdsAccountInput & { meta: Form121AccountRow; taxLines: InterestCredit[] })[] = [];
/** This customer's FD/RD interest received in savings, and TDS taken there, per year. */
private platformByFy = new Map<string, TdsDeductee | null>();
private accountInputs: (TdsAccountInput & { meta: Form121AccountRow })[] = [];
/** Withheld-tax postings by FY, from the ledger. */
private actualTdsByFy = new Map<string, number>();
ngOnInit(): void {
this.route.parent.params.pipe(takeUntil(this.destroy$)).subscribe((params: any) => {
@@ -187,15 +180,13 @@ export class Form121TabComponent implements OnInit, OnDestroy {
forkJoin({
client: this.clientsService.getClientData(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))),
declarations: this.tdsService.getDeclarations(Number(this.clientId)).pipe(catchError(() => of([])))
accounts: this.clientsService.getClientAccountData(this.clientId).pipe(catchError(() => of(null)))
})
.pipe(
takeUntil(this.destroy$),
switchMap(({ client, kyc, accounts, declarations }) => {
switchMap(({ client, kyc, accounts }) => {
this.applyClient(client);
this.applyKyc(kyc);
this.declarations = declarations || [];
const deposits = this.depositAccountsOf(accounts);
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 {
this.pan = (kyc?.panNumber ?? '').toUpperCase();
this.panWellFormed = isWellFormedPan(this.pan);
this.declarationForm =
this.fy && FY_TAX_CONFIG[normaliseFy(this.fy)] ? declarationFormFor(this.fy, this.dateOfBirth) : '121';
this.declarationOnRecord = !!kyc?.form15Applicable;
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. */
@@ -254,7 +248,7 @@ export class Form121TabComponent implements OnInit, OnDestroy {
*/
private ingest(details: any[]): void {
this.accountInputs = [];
this.platformByFy = new Map<string, TdsDeductee | null>();
this.actualTdsByFy = new Map<string, number>();
const fys = new Set<string>();
const clientIdNum = Number(this.clientId);
@@ -275,7 +269,6 @@ export class Form121TabComponent implements OnInit, OnDestroy {
};
const credits: InterestCredit[] = [];
const taxLines: InterestCredit[] = [];
let tdsDeducted = 0;
for (const t of d.transactions ?? []) {
@@ -293,7 +286,8 @@ export class Form121TabComponent implements OnInit, OnDestroy {
} else if (t.transactionType?.withholdTax) {
const amount = t.amount ?? 0;
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
};
this.accountInputs.push({ ...ref, credits, taxLines, meta });
this.accountInputs.push({ ...ref, credits, meta });
}
this.availableFys = [...fys].sort().reverse();
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);
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.
for (const a of this.accountInputs) {
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.accountInputs
.filter((a) => a.meta.tdsApplicable)
.reduce((s, a) => s + a.meta.tdsDeducted, 0);
this.actualTds = this.actualTdsByFy.get(year) ?? 0;
if (!this.dateOfBirth || this.configMissing) {
if (!this.dateOfBirth) {
return;
}
@@ -409,95 +378,21 @@ export class Form121TabComponent implements OnInit, OnDestroy {
return applicable.some((a) => a.meta.mode === 'QUARTERLY') ? 'QUARTERLY' : 'ANNUAL';
}
private loadPlatformYear(year: string): void {
this.tdsService
.getDepositInterest(Number(year.slice(0, 4)), Number(this.clientId))
.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) {
/** The declaration, but only when it was filed for the year being shown. */
private declarationForSelectedFy(): Declaration | null {
if (!this.declarationOnRecord || !this.fy) {
return null;
}
const year = normaliseFy(this.fy);
return this.declarations.find((d) => d.fy === year && !d.revoked) ?? null;
}
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) {
if (this.declarationFy && normaliseFy(this.declarationFy) !== year) {
return null;
}
const kind = d.formType === '15H' ? '15H' : d.formType === '15G' ? '15G' : formKindFor(this.dateOfBirth, d.fy);
return { kind, acceptedOn: d.acceptedOn, fy: d.fy, uin: d.uin || undefined };
}
openDeclarationForm(): void {
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.')
});
return {
kind: this.declarationKind,
// No acceptance date on record — treated as covering the whole year.
acceptedOn: `${year.slice(0, 4)}-04-01`,
fy: year
};
}
// ───────────────────────── Template helpers ─────────────────────────
@@ -516,7 +411,7 @@ export class Form121TabComponent implements OnInit, OnDestroy {
/** The form the payee would file for the selected year. */
get formLabel(): string {
if (!this.fy || this.configMissing) {
if (!this.fy) {
return '—';
}
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
* accounts withhold on their own rules and never consult the declaration record, so the form
* does not stop their deduction until the tax group is removed.
* A declaration is on record but deposit accounts are still set to withhold —
* the branch has not switched them off.
*/
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>
<h3 class="section-header__title">{{ 'labels.heading.Recurring Deposit Accounts' | translate }}</h3>
</div>
<div class="section-header__right">
<button mat-raised-button color="primary" (click)="toggleRecurringAccountsOverview()">
{{ viewAccountsLabel(showClosedRecurringAccounts) | translate }}
</button>
</div>
</div>
<!-- Active and ended recurring deposits share one table; the Status column says how each one ended (matured or
closed early), so the client's RD history stays visible without switching views. -->
@if (savingAccounts) {
@if (!showClosedRecurringAccounts) {
<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">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Account No' | translate }}</th>
<td mat-cell *matCellDef="let element">
@@ -611,15 +614,6 @@
<mifosx-long-text textValue="{{ element.productName }}" chars="35"></mifosx-long-text>
</td>
</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">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Last Active' | translate }}</th>
<td mat-cell *matCellDef="let element">{{ element.lastActiveTransactionDate | dateFormat }}</td>
@@ -644,7 +638,7 @@
<i class="fa fa-check"></i>
</button>
}
@if (element.status.approved) {
@if (!element.status.submittedAndPendingApproval && !element.status.active) {
<button
class="account-action-button"
mat-raised-button
@@ -670,7 +664,7 @@
</ng-container>
<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">
<i class="fa fa-folder-open-o table-empty-state__icon"></i>
<p class="table-empty-state__text">
@@ -680,17 +674,67 @@
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="recurringColumns"></tr>
<tr mat-header-row *matHeaderRowDef="openSavingsColumns"></tr>
<tr
mat-row
*matRowDef="let row; columns: recurringColumns"
*matRowDef="let row; columns: openSavingsColumns"
[routerLink]="['../', 'recurring-deposits-accounts', row.id, 'general']"
class="select-row"
></tr>
<tr
mat-footer-row
*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>
</table>
@@ -419,24 +419,3 @@ table[mat-table] {
font-size: 24px;
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',
'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 */
openSharesColumns: string[] = [
'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 {
if (closed) {
return 'labels.buttons.View Active Accounts';
@@ -1247,7 +1247,9 @@
</div>
@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">
@@ -182,10 +182,7 @@ export class KycTabComponent implements OnInit, OnDestroy {
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. */
readonly docScanSlots: ('front' | 'back')[] = [
'front',
'back'
];
readonly docScanSlots: ('front' | 'back')[] = ['front', 'back'];
docScanOf(slot: 'front' | 'back') {
return this.docScans[slot];
@@ -477,8 +474,7 @@ export class KycTabComponent implements OnInit, OnDestroy {
adverseMediaMatch: !!v.adverseMediaMatch,
// 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.
declaredAnnualIncome:
v.declaredAnnualIncome === '' || v.declaredAnnualIncome == null ? null : v.declaredAnnualIncome,
declaredAnnualIncome: v.declaredAnnualIncome === '' || v.declaredAnnualIncome == null ? null : v.declaredAnnualIncome,
declaredAnnualTurnover:
v.declaredAnnualTurnover === '' || v.declaredAnnualTurnover == null ? null : v.declaredAnnualTurnover
};
@@ -523,7 +519,7 @@ export class KycTabComponent implements OnInit, OnDestroy {
dateFormat: 'dd MMMM yyyy',
...this.buildDocumentFields(),
...this.buildRiskProfileFields(),
...this.buildExceptionFields()
...this.buildExceptionFields()
};
if (!this.kycId) {
@@ -770,7 +766,7 @@ export class KycTabComponent implements OnInit, OnDestroy {
riskCategory: this.kycForm.value.riskCategory || 1,
...declarationFields,
...this.buildRiskProfileFields(),
...this.buildExceptionFields()
...this.buildExceptionFields()
};
this.clientsService
@@ -931,7 +927,7 @@ export class KycTabComponent implements OnInit, OnDestroy {
riskCategory: this.kycForm.value.riskCategory || 1,
...receiptFields,
...this.buildRiskProfileFields(),
...this.buildExceptionFields()
...this.buildExceptionFields()
};
this.clientsService
@@ -1459,10 +1455,7 @@ I further confirm that:
this.docScansLoading = false;
// This runs again after every replace, so the previews being thrown away are revoked
// rather than left holding their blobs.
for (const old of [
this.docScans.front,
this.docScans.back
]) {
for (const old of [this.docScans.front, this.docScans.back]) {
if (old?.previewUrl) {
URL.revokeObjectURL(old.previewUrl);
}
@@ -1504,10 +1497,7 @@ I further confirm that:
}
this.docScans = sides;
for (const key of [
'front',
'back'
] as const) {
for (const key of ['front', 'back'] as const) {
if (sides[key]) {
this.loadScanPreview(sides[key]!);
}
@@ -1568,9 +1558,7 @@ I further confirm that:
error: (err: any) => {
this.docScanUploading = null;
this.docScanError =
err?.error?.defaultUserMessage ||
err?.error?.developerMessage ||
`Could not replace the ${label.toLowerCase()} side.`;
err?.error?.defaultUserMessage || err?.error?.developerMessage || `Could not replace the ${label.toLowerCase()} side.`;
}
});
}
@@ -1753,10 +1741,7 @@ I further confirm that:
this.destroy$.next();
this.destroy$.complete();
// Object URLs are held by the browser until revoked, so the scan blobs would outlive the tab.
for (const side of [
this.docScans.front,
this.docScans.back
]) {
for (const side of [this.docScans.front, this.docScans.back]) {
if (side?.previewUrl) {
URL.revokeObjectURL(side.previewUrl);
}
+6 -33
View File
@@ -569,19 +569,8 @@ export class ClientsService {
return this.http.get(`/clients/${clientId}/documents`);
}
/**
* Fetches a client document's stored file.
*
* `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) } : {})
});
downloadClientDocument(parentEntityId: string, documentId: string) {
return this.http.get(`/clients/${parentEntityId}/documents/${documentId}/attachment`, { responseType: 'blob' });
}
uploadClientDocument(clientId: string, documentData: any) {
@@ -1003,28 +992,12 @@ export class ClientsService {
return this.http.post(`/clients/${clientId}/entityclassification/${category}`, data);
}
/**
* Uploads a single CKYC artefact against the client, using the platform's generic document
* 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);
getClientCkyc(clientId: string): Observable<any> {
return this.http.get(`/clients/${clientId}/ckyc`);
}
/**
* Replaces the content of an existing client document in place (DocumentApiResource PUT
* /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);
uploadCkycDocument(clientId: string, formData: FormData): Observable<any> {
return this.http.post(`/clients/${clientId}/ckyc/documents`, formData);
}
/**
@@ -377,8 +377,6 @@
</button>
}
@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">
@if (savingClient) {
<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>
}
</button>
</span>
}
@if (selectedTabIndex === totalTabs - 1) {
<button mat-raised-button color="primary" [routerLink]="['../']">
@@ -393,12 +393,6 @@ export class EditClientComponent {
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.';
}
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) {
const labels = this.clientKycStep?.invalidFieldLabels || [];
if (labels.length) {
@@ -414,42 +408,6 @@ export class EditClientComponent {
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 */
isCurrentTabEdited(): boolean {
return this.tabEditMode[this.selectedTabIndex] === true;
@@ -109,7 +109,13 @@
</button>
}
</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>
+1 -36
View File
@@ -25,41 +25,6 @@ import { environment } from '../../../environments/environment';
export class CacheInterceptor implements HttpInterceptor {
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
constructor(private httpCacheService: HttpCacheService) {}
@@ -77,7 +42,7 @@ export class CacheInterceptor implements HttpInterceptor {
}
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);
}
+5 -27
View File
@@ -67,18 +67,12 @@ export class ErrorHandlerInterceptor implements HttpInterceptor {
// 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.
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 =
isClientImageRequest ||
isClientKycRequest ||
isDirectorRequest ||
isClientPendingEditRequest ||
isKycRiskRulesRequest ||
isDocumentAttachmentRequest;
isKycRiskRulesRequest;
// 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
@@ -99,8 +93,8 @@ export class ErrorHandlerInterceptor implements HttpInterceptor {
throw response;
}
// Resolved once and emitted in one place below — see the severity decision there for which of
// these surfaces as a centred modal versus a corner toast.
// Resolved once and emitted in one place below, so every failure here is consistently tagged
// as an error and therefore surfaces as a centred modal rather than a corner toast.
let alert: { type: string; message: string } | null = null;
if (status === 401 || (environment.oauth.enabled && status === 400)) {
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.'
};
} else if (status === 500) {
// errorMessage carries the backend's actual explanation (defaultUserMessage/developerMessage)
// 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.'
};
alert = { type: 'Internal Server Error', message: 'Internal Server Error. Please try again later.' };
} else if (status === 501) {
alert = {
type: this.translate.instant('error.resource.notImplemented.type'),
@@ -145,17 +133,7 @@ export class ErrorHandlerInterceptor implements HttpInterceptor {
}
if (alert) {
// A GET is the page reading data it did not ask the user for, in the background — a photo,
// 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' });
this.alertService.alert({ ...alert, severity: 'error' });
}
throw response;
+35 -52
View File
@@ -65,14 +65,6 @@
<span class="cashier-nav__esc-hint" (click)="onEscapeKey()"> <kbd>ESC</kbd> Show Header </span>
</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 &amp; Footer)
</span>
</nav>
}
<!-- Clerk Dashboard fullscreen nav — only visible at /clerk/* -->
@else if (currentUrl.includes('/clerk/')) {
<nav class="cashier-nav cashier-nav--clerk">
@@ -663,12 +655,6 @@
<button mat-menu-item routerLink="/reports/savings-interest" tabindex="0">
<i class="fa fa-percentage menu-item-icon"></i> Savings Interest Posted
</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,
so it never shows up under the report categories above and had no nav entry at all.
@@ -897,7 +883,11 @@
(click)="subLedgerOpen = false"
><fa-icon icon="balance-scale" class="gl-icon"></fa-icon> Suit File Entry</a
>
<a class="gl-link" [class.hidden]="!matchesSubLedgerSearch('Locker Entry')" (click)="subLedgerOpen = false"
<a
class="gl-link"
[class.hidden]="!matchesSubLedgerSearch('Locker Entry')"
routerLink="/locker"
(click)="subLedgerOpen = false"
><fa-icon icon="lock" class="gl-icon"></fa-icon> Locker Entry</a
>
<a
@@ -1189,14 +1179,29 @@
<button mat-menu-item tabindex="0" [routerLink]="['/accounts/recurring-deposits']">
<fa-icon icon="sync" class="menu-item-icon"></fa-icon> Recurring
</button>
<button mat-menu-item tabindex="0"><fa-icon icon="coins" class="menu-item-icon"></fa-icon> DDS</button>
<button mat-menu-item tabindex="0" [routerLink]="['/accounts/dds']" (click)="depositsMenu.closed.emit()">
<fa-icon icon="coins" class="menu-item-icon"></fa-icon> DDS Party Master
</button>
<button
mat-menu-item
tabindex="0"
[routerLink]="['/accounts/dds-accounts']"
(click)="depositsMenu.closed.emit()"
>
<fa-icon icon="list-alt" class="menu-item-icon"></fa-icon> DDS Accounts
</button>
</div>
<div class="deposit-menu-group">
<div class="deposit-group-title">Agent</div>
<button mat-menu-item tabindex="0">
<fa-icon icon="hand-holding-usd" class="menu-item-icon"></fa-icon> Agent Commission
</button>
<button mat-menu-item tabindex="0">
<button
mat-menu-item
tabindex="0"
[routerLink]="['/accounts/dds-collection']"
(click)="depositsMenu.closed.emit()"
>
<fa-icon icon="calendar-check" class="menu-item-icon"></fa-icon> Today Collection
</button>
<button mat-menu-item tabindex="0">
@@ -1211,17 +1216,22 @@
</div>
<div class="deposit-menu-group">
<div class="deposit-group-title">Reports</div>
<button
mat-menu-item
tabindex="0"
[routerLink]="['/accounts/dds-reports']"
(click)="depositsMenu.closed.emit()"
>
<fa-icon icon="coins" class="menu-item-icon"></fa-icon> DDS Reports
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/savings-daily-interest']">
<fa-icon icon="chart-line" class="menu-item-icon"></fa-icon> Savings Daily Interest
</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="clock" class="menu-item-icon"></fa-icon> Account Expiry
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/fd-dashboard']">
<fa-icon icon="calendar-check" class="menu-item-icon"></fa-icon> FD Dashboard
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/rd-dashboard']">
<fa-icon icon="piggy-bank" class="menu-item-icon"></fa-icon> RD Dashboard
<button mat-menu-item tabindex="0">
<fa-icon icon="file-alt" class="menu-item-icon"></fa-icon> FD Maturity Report
</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="chart-line" class="menu-item-icon"></fa-icon> FD Interest Report
@@ -1411,7 +1421,9 @@
<button mat-menu-item tabindex="0">
<fa-icon icon="user-slash" class="menu-item-icon" style="color: #c2410c"></fa-icon> Non-Customer Entry
</button>
<button mat-menu-item tabindex="0"><fa-icon icon="lock" class="menu-item-icon"></fa-icon> Locker</button>
<button mat-menu-item routerLink="/locker" tabindex="0">
<fa-icon icon="lock" class="menu-item-icon"></fa-icon> Locker
</button>
<button mat-menu-item routerLink="/investment/bonds" tabindex="0">
<fa-icon icon="chart-line" class="menu-item-icon"></fa-icon> SLR and Non SLR Investment Report
</button>
@@ -2080,35 +2092,6 @@
<fa-icon icon="users" class="gl-icon" style="color: #4f46e5"></fa-icon> Members &amp; Share Capital
</a>
</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 &amp;
Liabilities (R408)
</a>
</div>
<!-- CIMS / XBRL Returns -->
<div class="gl-group">
<div class="gl-group-title">CIMS / XBRL Returns</div>
+7 -9
View File
@@ -167,9 +167,7 @@ export class ShellComponent implements OnInit, OnDestroy {
url.includes('/clerk/withdrawal-request') ||
url.includes('/clerk/my-disbursement-requests');
const isElectionRoute = url.includes('/election');
const isFdDashboardRoute = url.includes('/fd-dashboard');
this.hideShellLayout =
isCashierOnly || isClerkRoute || isElectionRoute || isFdDashboardRoute || this.isWithdrawalReviewUrl;
this.hideShellLayout = isCashierOnly || isClerkRoute || isElectionRoute || this.isWithdrawalReviewUrl;
this.cdr.detectChanges();
};
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) {
this.sidenavCollapsed = $event;
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);
}
/** 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')
onEscapeKey(): void {
if (
this.hideShellLayout &&
(this.router.url.includes('/clerk/') ||
this.router.url.includes('/election') ||
this.router.url.includes('/fd-dashboard') ||
this.isWithdrawalReviewUrl)
(this.router.url.includes('/clerk/') || this.router.url.includes('/election') || this.isWithdrawalReviewUrl)
) {
this.hideShellLayout = false;
this.cdr.detectChanges();
@@ -20,7 +20,7 @@
<div class="t-live"></div>
&nbsp;LIVE
</div>
<div class="t-chip">Branch:&nbsp;<strong>{{ officeLabel }}</strong></div>
<div class="t-chip">Branch:&nbsp;<strong>HO</strong></div>
<div class="t-chip">
Date:&nbsp;<strong>{{ today | date: 'dd MMM yyyy' }}</strong>
</div>
@@ -49,7 +49,6 @@ import { MatIcon } from '@angular/material/icon';
import { NotificationsTrayComponent as NotificationsTrayComponent_1 } from '../../../shared/notifications-tray/notifications-tray.component';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { DocumentationLinksService } from 'app/shared/services/documentation-links.service';
import { businessToday } from 'app/core/utils/business-today';
/**
* Toolbar component.
@@ -93,14 +92,6 @@ export class ToolbarComponent implements OnInit, AfterViewInit, AfterContentChec
@ViewChild('templateAppMenu') templateAppMenu: TemplateRef<any>;
@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. */
isHandset$: Observable<boolean> = this.breakpointObserver
.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. */
sidenavCollapsed = true;
/** The bank's "today" for the top bar — the business date when enabled, else the server date. */
get today(): Date {
return businessToday();
}
/** Current date for top bar display */
today = new Date();
/** Last login time display */
lastLoginTime = this.formatLastLogin();
-30
View File
@@ -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">
<!-- ─── Dormancy risk alert (only when subStatus has left Active) ──── -->
<!-- Shown only when the account's product has Dormancy Alert = Yes -->
@if (currentAccountData?.dormancyAlert) {
<mifosx-dormancy-alert-panel [accountData]="currentAccountData"></mifosx-dormancy-alert-panel>
}
<mifosx-dormancy-alert-panel [accountData]="currentAccountData"></mifosx-dormancy-alert-panel>
<!-- ─── Gradient hero header ─────────────────────────────────────── -->
<header class="cur-hero">
@@ -59,7 +59,16 @@
<span class="nx-mono">[{{ currentAccountData.currency.code }}]</span>
</span>
</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 {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Field Officer' | translate }}</span>
@@ -93,17 +102,17 @@
</span>
</div>
}
@if (currentAccountData.summary.totalWithdrawals) {
@if (currentAccountData.summary.totalInterestEarned >= 0) {
<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">
<span class="nx-row__label">{{ 'labels.inputs.Total Interest Earned' | translate }}</span>
<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>
</div>
}
@if (!currentAccountData.summary.totalDeposits && !currentAccountData.summary.totalWithdrawals) {
@if (!currentAccountData.summary.totalDeposits && !(currentAccountData.summary.totalInterestEarned >= 0)) {
<div class="nx-empty">
<fa-icon icon="inbox" class="nx-empty__ico"></fa-icon>
<span>No activity yet.</span>
@@ -123,7 +132,16 @@
<h3 class="nx-card__title">{{ 'labels.heading.Account Summary' | translate }}</h3>
</header>
<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) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Withdrawals Fees' | translate }}</span>
@@ -142,6 +160,89 @@
</span>
</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) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Withdrawal Fee' | translate }}</span>
@@ -188,6 +289,37 @@
</span>
</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) {
<div class="nx-row">
<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>
</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) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.On Hold Funds' | translate }}</span>
@@ -210,72 +350,14 @@
</span>
</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>
</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 {
<section class="nx-card">
<header class="nx-card__head">
@@ -54,8 +54,8 @@
<mat-icon>account_balance</mat-icon>
</div>
<div class="bank-name-block">
<div class="bank-title">{{ bankName }}</div>
<div class="bank-subtitle">{{ bankSubtitle }}</div>
<div class="bank-title">LincPay Co-operative Bank Ltd.</div>
<div class="bank-subtitle">Current Account Statement</div>
</div>
</div>
<div class="statement-label">
@@ -64,25 +64,6 @@
</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 -->
<div class="account-details-band">
<div class="account-details-grid">
@@ -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 {
background: #f0f3f9;
@@ -11,12 +11,11 @@ import { Component, OnInit, OnDestroy, inject, ElementRef, ViewChild } from '@an
import { ActivatedRoute } from '@angular/router';
import { UntypedFormBuilder } from '@angular/forms';
import { CurrencyPipe } from '@angular/common';
import { Subject, takeUntil, catchError, forkJoin, of } from 'rxjs';
import { Subject, takeUntil } from 'rxjs';
/** Custom Services */
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
import { OrganizationService } from '../../../organization/organization.service';
import { Currency } from 'app/shared/models/general.model';
import {
CurrentAccountTransaction,
@@ -45,7 +44,6 @@ export class PassbookTabComponent implements OnInit, OnDestroy {
private formBuilder = inject(UntypedFormBuilder);
private settingsService = inject(SettingsService);
private dateUtils = inject(Dates);
private organizationService = inject(OrganizationService);
private destroy$ = new Subject<void>();
@@ -62,17 +60,6 @@ export class PassbookTabComponent implements OnInit, OnDestroy {
availableBalance: number = 0;
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 */
allTransactions: CurrentAccountTransaction[] = [];
filteredTransactions: CurrentAccountTransaction[] = [];
@@ -105,33 +92,6 @@ export class PassbookTabComponent implements OnInit, OnDestroy {
fromDate: [''],
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[] {
@@ -319,18 +279,6 @@ export class PassbookTabComponent implements OnInit, OnDestroy {
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 {
background: #f0f3f9;
@@ -0,0 +1,381 @@
<!--
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="dialog-wrapper">
<div class="dialog-header">
<div class="header-icon">
<mat-icon>account_balance_wallet</mat-icon>
</div>
<div class="header-text">
<h2>Apply DDS Receipt</h2>
<p>
Agent <strong class="mono">{{ data.agentCode }}</strong> · {{ data.agentName }}
@if (data.glCode) {
· GL <strong class="mono">{{ data.glCode }}</strong>
}
</p>
</div>
<button type="button" mat-icon-button class="header-close" [mat-dialog-close]="false" aria-label="Close">
<mat-icon>close</mat-icon>
</button>
</div>
<mat-dialog-content class="dialog-body">
@if (loading) {
<div class="state-block">
<mat-spinner diameter="30"></mat-spinner>
<span>Loading entries and receipts…</span>
</div>
} @else {
<!-- What the picked vouchers have to cover, and whether they do. -->
<div class="sc-card">
<div class="sc-row" [class.sc-ok]="canApply" [class.sc-bad]="pickedReceipts.size > 0 && difference !== 0">
<div class="sc-left">
<span class="sc-label">Collections up to {{ data.entryDate | date: 'dd/MM/yyyy' }}</span>
<span class="sc-sub"
>{{ pickedEntryCount }} {{ pickedEntryCount === 1 ? 'entry' : 'entries' }} to settle</span
>
</div>
<div class="sc-right">
<span class="sc-val">{{ required | number: '1.2-2' }}</span>
</div>
</div>
<div
class="sc-row sc-sep"
[class.sc-ok]="canApply"
[class.sc-bad]="pickedReceipts.size > 0 && difference !== 0"
>
<div class="sc-left">
<span class="sc-label">Vouchers picked</span>
<span class="sc-sub"
>{{ pickedReceipts.size }} {{ pickedReceipts.size === 1 ? 'voucher' : 'vouchers' }}</span
>
</div>
<div class="sc-right">
<span
class="sc-val"
[class.sc-val-ok]="canApply"
[class.sc-val-bad]="pickedReceipts.size > 0 && difference !== 0"
>
{{ received | number: '1.2-2' }}
</span>
@if (pickedReceipts.size > 0 && pickedDays.size > 0 && difference < 0) {
<span class="sc-tag sc-tag-bad">Short {{ -difference | number: '1.2-2' }}</span>
} @else if (pickedReceipts.size > 0 && pickedDays.size > 0 && difference > 0) {
<span class="sc-tag sc-tag-bad">Over {{ difference | number: '1.2-2' }}</span>
} @else if (canApply) {
<span class="sc-tag sc-tag-ok">✓ Matches</span>
}
</div>
</div>
<div class="sc-total-row" [class.sc-total-ok]="canApply">
<span class="sc-total-lbl">Total to Apply</span>
<div class="sc-total-nums">
<span class="sc-total-req mono">{{ required | number: '1.2-2' }}</span>
<span class="sc-arrow">→</span>
<span class="sc-total-got mono" [class.sc-total-got-ok]="canApply">{{ received | number: '1.2-2' }}</span>
<span class="sc-status-pill" [class.sc-pill-ok]="canApply" [class.sc-pill-pending]="!canApply">
{{ canApply ? '✓ Ready' : 'Pending' }}
</span>
</div>
</div>
</div>
<!-- ── 1: the days the agent wrote down ──────────────────────────────────── -->
<section class="slot">
<header class="slot-head">
<span class="slot-step">1</span>
<div class="slot-title">
<h3>Outstanding up to {{ data.entryDate | date: 'dd/MM/yyyy' }}</h3>
<p>Everything the agent wrote down and has not handed in, day by day. Open a day to see its lines.</p>
</div>
</header>
@if (days.length) {
<ul class="options">
@for (day of days; track day.date) {
<li class="option" (click)="toggleOpen(day, $event)">
<div class="option-main">
<span class="option-voucher mono">{{ day.date | date: 'dd/MM/yyyy' }}</span>
<span class="option-meta"
>{{ day.entries.length }} {{ day.entries.length === 1 ? 'entry' : 'entries' }}</span
>
</div>
<span class="option-amount mono">{{ day.total | number: '1.2-2' }}</span>
<button
type="button"
class="option-info"
(click)="toggleOpen(day, $event)"
aria-label="Show the day's lines"
>
<mat-icon>{{ openDay === day.date ? 'expand_less' : 'expand_more' }}</mat-icon>
</button>
</li>
@if (openDay === day.date) {
<li class="day-lines">
@if (day.entries.length > 8) {
<input
class="day-search"
type="text"
[(ngModel)]="daySearch"
placeholder="Search party code or name"
autocomplete="off"
/>
}
<div class="day-scroll">
<table class="entries">
<tbody>
@for (entry of visibleEntries(day); track entry.id) {
<tr>
<td class="entry-code">{{ entry.partyCode }}</td>
<td class="entry-name">{{ entry.partyName }}</td>
<td>
{{ entry.transactionMode }}
@if (entry.advanceDays) {
· {{ entry.advanceDays }}d advance
}
@if (entry.arrearsDays) {
· {{ entry.arrearsDays }}d previous
}
</td>
<td class="num mono">{{ entry.amount | number: '1.2-2' }}</td>
</tr>
} @empty {
<tr>
<td colspan="4" class="entry-name">No line matches.</td>
</tr>
}
</tbody>
</table>
</div>
</li>
}
}
</ul>
} @else {
<p class="slot-empty">
{{ data.agentName }} has nothing outstanding for {{ data.entryDate | date: 'dd/MM/yyyy' }}. Change the date
on the screen to the day the entries were written.
</p>
}
</section>
<!-- ── 2: the cashier's receipts ─────────────────────────────────────────── -->
<section class="slot">
<header class="slot-head">
<span class="slot-step">2</span>
<div class="slot-title">
<h3>DDS Cash Receipts</h3>
<p>Tick the vouchers. Posts Dr Agent GL / Cr DDS Customer Deposits.</p>
</div>
<button type="button" class="slot-search" (click)="openSearch()">
<mat-icon>search</mat-icon>
<span>Find by voucher</span>
</button>
</header>
@if (available.length) {
<ul class="options">
@for (option of available; track option.id) {
<li class="option" [class.option-picked]="pickedReceipts.has(option.id)" (click)="toggleReceipt(option)">
<mat-icon class="option-check">{{
pickedReceipts.has(option.id) ? 'check_box' : 'check_box_outline_blank'
}}</mat-icon>
<div class="option-main">
<span class="option-voucher mono">{{ option.voucherNo }}</span>
<span class="option-meta">{{ option.txnDate }} · {{ glLabel(option) }}</span>
@if (option.description) {
<span class="option-desc">{{ option.description }}</span>
}
</div>
<span class="option-amount mono">{{ option.amount | number: '1.2-2' }}</span>
<button type="button" class="option-info" (click)="openDetail(option, $event)" aria-label="GL entries">
<mat-icon>receipt_long</mat-icon>
</button>
</li>
}
</ul>
} @else {
<p class="slot-empty">
No DDS receipt is waiting on this agent's GL account. Collect the cash at
<em>Cashier › Transaction Entry</em> with entry type <strong>DDS</strong> and have the manager approve it,
or find an earlier voucher by number.
</p>
}
@if (pickedReceipts.size > 0 && pickedDays.size > 0 && difference !== 0) {
<p class="slot-note note-bad">
The vouchers ({{ received | number: '1.2-2' }}) have to match the day's entries ({{
required | number: '1.2-2'
}}) exactly, so every customer is credited what the agent wrote down.
</p>
}
</section>
@if (errorMsg && !searchOpen) {
<div class="alert">
<mat-icon>error_outline</mat-icon>
<span>{{ errorMsg }}</span>
</div>
}
}
</mat-dialog-content>
<mat-dialog-actions class="dialog-actions">
<button type="button" mat-stroked-button [mat-dialog-close]="false" [disabled]="submitting">Cancel</button>
<button type="button" mat-flat-button color="primary" [disabled]="!canApply" (click)="apply()">
@if (submitting) {
<mat-spinner diameter="16"></mat-spinner>
<span>Applying…</span>
} @else {
<mat-icon>check</mat-icon>
<span>Apply Receipt</span>
}
</button>
</mat-dialog-actions>
</div>
<!-- ── Find a voucher the list does not carry ───────────────────────────────── -->
@if (searchOpen) {
<div class="overlay" (click)="closeSearch()">
<div class="popup" (click)="$event.stopPropagation()">
<header class="popup-head">
<h3>Find a DDS receipt</h3>
<button type="button" mat-icon-button (click)="closeSearch()" aria-label="Close">
<mat-icon>close</mat-icon>
</button>
</header>
<div class="popup-body">
<label class="field">
<span>GL Account</span>
<div class="field-readonly mono">{{ data.glCode || '—' }} · {{ data.agentCode }}</div>
</label>
<label class="field">
<span>Voucher No.</span>
<input
type="text"
[(ngModel)]="searchVoucherNo"
placeholder="CR/24092026/0001"
(keyup.enter)="runSearch()"
autocomplete="off"
/>
</label>
@if (errorMsg) {
<div class="alert">
<mat-icon>error_outline</mat-icon>
<span>{{ errorMsg }}</span>
</div>
}
</div>
<footer class="popup-foot">
<button type="button" mat-stroked-button (click)="closeSearch()">Cancel</button>
<button
type="button"
mat-flat-button
color="primary"
[disabled]="!searchVoucherNo.trim() || searching"
(click)="runSearch()"
>
@if (searching) {
<mat-spinner diameter="16"></mat-spinner>
} @else {
<span>Find &amp; Use</span>
}
</button>
</footer>
</div>
</div>
}
<!-- ── A receipt's own GL entries ───────────────────────────────────────────── -->
@if (viewing) {
<div class="overlay" (click)="closeDetail()">
<div class="popup" (click)="$event.stopPropagation()">
<header class="popup-head">
<h3>
Voucher <span class="mono">{{ viewing.voucherNo }}</span>
</h3>
<button type="button" mat-icon-button (click)="closeDetail()" aria-label="Close">
<mat-icon>close</mat-icon>
</button>
</header>
<div class="popup-body">
<dl class="detail-list">
<div>
<dt>Date</dt>
<dd class="mono">{{ viewing.txnDate }}</dd>
</div>
<div>
<dt>Amount</dt>
<dd class="mono">{{ viewing.amount | number: '1.2-2' }}</dd>
</div>
<div>
<dt>GL Account</dt>
<dd>{{ glLabel(viewing) }}</dd>
</div>
@if (viewing.cashierName) {
<div>
<dt>Collected By</dt>
<dd>{{ viewing.cashierName }}</dd>
</div>
}
@if (viewing.managerName) {
<div>
<dt>Approved By</dt>
<dd>{{ viewing.managerName }}</dd>
</div>
}
@if (viewing.description) {
<div>
<dt>Description</dt>
<dd>{{ viewing.description }}</dd>
</div>
}
</dl>
<h4 class="detail-heading">Journal Entries</h4>
@if (loadingGlEntries) {
<div class="state-block"><mat-spinner diameter="22"></mat-spinner></div>
} @else if (viewingGlEntries?.length) {
<table class="gl-table">
<thead>
<tr>
<th>Account</th>
<th class="num">Debit</th>
<th class="num">Credit</th>
</tr>
</thead>
<tbody>
@for (entry of viewingGlEntries; track entry.id) {
<tr>
<td>
<span class="mono">{{ entry.glCode }}</span> {{ entry.glName }}
</td>
<td class="num mono">{{ entry.drCr === 'DR' ? (entry.amount | number: '1.2-2') : '' }}</td>
<td class="num mono">{{ entry.drCr === 'CR' ? (entry.amount | number: '1.2-2') : '' }}</td>
</tr>
}
</tbody>
</table>
} @else {
<p class="slot-empty">No journal entries are recorded against this receipt.</p>
}
</div>
<footer class="popup-foot">
<button type="button" mat-stroked-button (click)="closeDetail()">Close</button>
</footer>
</div>
</div>
}
@@ -0,0 +1,95 @@
/**
* 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 same look as Apply Additional Shares, on purpose: both apply a cashier's receipt to what it was
// collected for, and the counter should meet one screen, not two. Used as it is, never changed here.
@use '../../../shares/share-dialog.shared' as *;
:host {
display: block;
width: 100%;
}
// The agent's outstanding lines - read only, what the receipt has to cover.
.entries {
width: 100%;
border-collapse: collapse;
font-size: 13px;
td {
padding: 7px 4px;
border-bottom: 1px solid #eef2f7;
}
tr:last-child td {
border-bottom: none;
}
.entry-date {
color: #64748b;
white-space: nowrap;
}
.entry-code {
font-weight: 600;
white-space: nowrap;
}
.entry-name {
color: #475569;
}
.num {
text-align: right;
white-space: nowrap;
}
}
// Days and vouchers are ticked, any number of each - a check box where the share dialog has a radio.
.option-check {
color: #94a3b8;
flex-shrink: 0;
}
.option-picked {
border-color: #2563eb !important;
background: #eff6ff !important;
.option-check {
color: #2563eb;
}
}
// A day's own lines, opened under it. Scrolls inside itself, so a thousand customers do not make the
// dialog a thousand lines tall.
.day-lines {
list-style: none;
padding: 6px 10px 10px 42px;
}
.day-search {
width: 100%;
margin-bottom: 6px;
padding: 6px 10px;
border: 1px solid #e2e8f0;
border-radius: 6px;
font: inherit;
font-size: 13px;
}
.day-scroll {
max-height: 240px;
overflow-y: auto;
}
.field-readonly {
padding: 8px 10px;
border: 1px solid #e2e8f0;
border-radius: 6px;
background: #f8fafc;
}
@@ -0,0 +1,351 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MAT_DIALOG_DATA, MatDialogRef, MatDialogModule } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { catchError } from 'rxjs/operators';
import { of } from 'rxjs';
import { OrganizationService } from 'app/organization/organization.service';
import { SettingsService } from 'app/settings/settings.service';
/** One day of an agent's outstanding entries, as the dialog lists them. */
export interface EntryDay {
date: string;
entries: any[];
total: number;
}
/**
* Applies the cashier's DDS receipts to an agent's outstanding collections.
*
* The agent's cash is already in the bank: the cashier took it at the counter into the agent's own GL
* account, tagged DDS, and the manager approved it. Applying it here hands the money on to the
* customers the agent wrote down - Dr the agent's GL, Cr DDS Customer Deposits - and each entry is
* settled against the application. The same screen as Apply Additional Shares.
*
* Everything outstanding up to the day chosen on the screen, shown a day at a time - nothing to tick,
* because what the agent wrote down is settled whole, and an agent who comes in after several days
* brings several days' cash. The vouchers are ticked, since a day's
* cash can come in more than one visit. They have to agree exactly: what the agent wrote down is what
* each customer is owed, so vouchers short of it leave somebody uncredited and vouchers over it leave
* money nobody can place.
*
* The day is shown totalled and its lines opened only when asked for - an agent with a thousand
* customers is a thousand lines, and the day's total is what the counter is checking.
*/
@Component({
selector: 'mifosx-apply-dds-receipt-dialog',
templateUrl: './apply-dds-receipt-dialog.component.html',
styleUrls: ['./apply-dds-receipt-dialog.component.scss'],
standalone: true,
imports: [
CommonModule,
FormsModule,
MatDialogModule,
MatButtonModule,
MatIconModule,
MatProgressSpinnerModule
]
})
export class ApplyDdsReceiptDialogComponent implements OnInit {
private organizationService = inject(OrganizationService);
private settingsService = inject(SettingsService);
public dialogRef = inject(MatDialogRef<ApplyDdsReceiptDialogComponent>);
public data = inject<{
agentId: number;
agentCode: string;
agentName: string;
glCode: string | null;
entryDate: string;
}>(MAT_DIALOG_DATA);
loadingEntries = true;
loadingReceipts = true;
submitting = false;
errorMsg = '';
/** The chosen day's outstanding entries - one day, or none when nothing is outstanding on it. */
days: EntryDay[] = [];
/** DDS receipts on the agent's GL account, approved and not yet applied. */
available: any[] = [];
/**
* The day is settled whole, so it is always included. The vouchers are the clerk's choice, and none
* is picked for them even when only one is listed.
*/
pickedDays = new Set<string>();
pickedReceipts = new Set<number>();
/** The day whose lines are open, and the search within it. */
openDay: string | null = null;
daySearch = '';
get loading(): boolean {
return this.loadingEntries || this.loadingReceipts;
}
ngOnInit(): void {
// Everything outstanding up to the chosen date, whatever day it was written - an agent's cash for
// several days arrives in one visit, and all of it is settled together.
this.organizationService
.getAgentPendingCollections(this.data.agentId)
.pipe(
catchError((err) => {
this.errorMsg = this.readError(err, "Could not load the agent's entries.");
return of(null);
})
)
.subscribe((res: any) => {
this.days = groupByDay(
(res?.entries || []).filter(
(entry: any) => entry.status === 'PENDING' && String(entry.entryDate) <= this.data.entryDate
)
);
this.pickedDays = new Set(this.days.map((day) => day.date));
this.loadingEntries = false;
});
this.organizationService
.getAgentCashReceipts(this.data.agentId)
.pipe(
catchError((err) => {
this.errorMsg = this.readError(err, 'Could not load the available receipts.');
return of([]);
})
)
.subscribe((rows: any) => {
this.available = Array.isArray(rows) ? rows : [];
this.loadingReceipts = false;
});
}
// ── Picking ─────────────────────────────────────────────────────────────────────────────────
toggleReceipt(receipt: any): void {
if (this.submitting) {
return;
}
this.pickedReceipts.has(receipt.id) ? this.pickedReceipts.delete(receipt.id) : this.pickedReceipts.add(receipt.id);
}
toggleOpen(day: EntryDay, event: Event): void {
event.stopPropagation();
this.openDay = this.openDay === day.date ? null : day.date;
this.daySearch = '';
}
/** The open day's lines, narrowed by party code or name. */
visibleEntries(day: EntryDay): any[] {
const term = this.daySearch.trim().toLowerCase();
if (!term) {
return day.entries;
}
return day.entries.filter(
(entry: any) =>
String(entry.partyCode || '')
.toLowerCase()
.includes(term) ||
String(entry.partyName || '')
.toLowerCase()
.includes(term)
);
}
// ── What has to be covered ──────────────────────────────────────────────────────────────────
get entryCount(): number {
return this.days.reduce((sum, day) => sum + day.entries.length, 0);
}
get outstandingTotal(): number {
return this.days.reduce((sum, day) => sum + day.total, 0);
}
get pickedEntryCount(): number {
return this.days.filter((day) => this.pickedDays.has(day.date)).reduce((sum, day) => sum + day.entries.length, 0);
}
get required(): number {
return round2(this.days.filter((day) => this.pickedDays.has(day.date)).reduce((sum, day) => sum + day.total, 0));
}
get received(): number {
return round2(
this.available.filter((r) => this.pickedReceipts.has(r.id)).reduce((sum, r) => sum + Number(r.amount || 0), 0)
);
}
/** Vouchers less entries: below zero they are short, above zero they are over. */
get difference(): number {
return round2(this.received - this.required);
}
get canApply(): boolean {
return this.pickedDays.size > 0 && this.pickedReceipts.size > 0 && this.difference === 0 && !this.submitting;
}
glLabel(option: any): string {
return [
option?.glCode,
option?.glName
]
.filter(Boolean)
.join(' — ');
}
// ── Voucher search, for a receipt the list does not carry ───────────────────────────────────
searching = false;
searchOpen = false;
searchVoucherNo = '';
openSearch(): void {
this.searchOpen = true;
this.searchVoucherNo = '';
this.errorMsg = '';
}
closeSearch(): void {
this.searchOpen = false;
this.errorMsg = '';
}
/**
* Finds one voucher on the agent's GL account and, when it cannot be used, says why - not taken as
* DDS, not approved yet, or already applied - rather than simply not finding it.
*/
runSearch(): void {
const voucher = this.searchVoucherNo.trim();
if (!voucher || this.searching) {
return;
}
this.searching = true;
this.errorMsg = '';
this.organizationService
.getAgentCashReceipts(this.data.agentId, voucher)
.pipe(
catchError((err) => {
this.errorMsg = this.readError(err, `Voucher ${voucher} could not be looked up.`);
return of(null);
})
)
.subscribe((rows: any) => {
this.searching = false;
if (rows === null) {
return;
}
const found = (Array.isArray(rows) ? rows : [])[0];
if (!found) {
this.errorMsg = `Voucher ${voucher} is not on ${this.data.agentCode}'s GL account ${this.data.glCode || ''}.`;
} else if (found.entryType !== 'DDS') {
this.errorMsg = `Voucher ${voucher} was taken as "${found.entryType || 'untagged'}", not DDS. Ask the cashier to correct the entry type.`;
} else if (found.status !== 'APPROVED') {
this.errorMsg = `Voucher ${voucher} is ${found.status}. It can be applied once the manager approves it.`;
} else if (found.applied) {
this.errorMsg = `Voucher ${voucher} has already been applied.`;
} else {
this.available = [
found,
...this.available.filter((entry) => entry.id !== found.id)
];
this.pickedReceipts.add(found.id);
this.searchOpen = false;
}
});
}
// ── A receipt's own GL entries ──────────────────────────────────────────────────────────────
viewing: any = null;
viewingGlEntries: any[] | null = null;
loadingGlEntries = false;
openDetail(option: any, event: Event): void {
event.stopPropagation();
this.viewing = option;
this.viewingGlEntries = null;
this.loadingGlEntries = true;
this.organizationService
.getCashReceiptGlEntries(option.id)
.pipe(catchError(() => of([])))
.subscribe((rows: any) => {
this.viewingGlEntries = Array.isArray(rows) ? rows : [];
this.loadingGlEntries = false;
});
}
closeDetail(): void {
this.viewing = null;
this.viewingGlEntries = null;
}
// ── Submission ──────────────────────────────────────────────────────────────────────────────
apply(): void {
if (!this.canApply) {
return;
}
this.submitting = true;
this.errorMsg = '';
const vouchers = this.available.filter((r) => this.pickedReceipts.has(r.id)).map((r) => r.voucherNo);
const entries = this.pickedEntryCount;
const amount = this.required;
this.organizationService
.settleAgentCollections({
agentId: Number(this.data.agentId),
cashierDepositRequestIds: Array.from(this.pickedReceipts),
entryDates: Array.from(this.pickedDays),
locale: this.settingsService.language.code
})
.pipe(
catchError((err) => {
this.errorMsg = this.readError(err, 'The receipts could not be applied.');
this.submitting = false;
return of(null);
})
)
.subscribe((res: any) => {
if (res) {
this.dialogRef.close({ vouchers, entries, amount, response: res });
}
});
}
private readError(err: any, fallback: string): string {
return (
err?.error?.errors?.[0]?.defaultUserMessage ??
err?.error?.defaultUserMessage ??
err?.error?.developerMessage ??
fallback
);
}
}
/** Entries grouped by the day they were collected, oldest first, each day totalled. */
export function groupByDay(entries: any[]): EntryDay[] {
const byDay = new Map<string, EntryDay>();
for (const entry of entries) {
const date = String(entry.entryDate);
const day = byDay.get(date) ?? { date, entries: [], total: 0 };
day.entries.push(entry);
day.total = round2(day.total + Number(entry.amount || 0));
byDay.set(date, day);
}
return Array.from(byDay.values()).sort((a, b) => a.date.localeCompare(b.date));
}
function round2(value: number): number {
return Math.round(value * 100) / 100;
}
@@ -0,0 +1,468 @@
<!--
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="dte-container">
<div class="dte-header">
<button mat-stroked-button [routerLink]="['../']" class="dte-back">
<i class="fas fa-arrow-left"></i> Deposits
</button>
<h2 class="dte-header__title">Daily Transaction Detail</h2>
</div>
<div class="dte-card">
<form [formGroup]="entryForm">
<div class="dte-info-panels">
<div class="dte-info-panel">
<div class="dte-info-panel__title">Agent Information</div>
<dl>
<dt>Name</dt>
<dd>{{ agentInfo.name || '—' }}</dd>
<dt>Address</dt>
<dd>{{ agentInfo.address || '—' }}</dd>
<dt>City</dt>
<dd>{{ agentInfo.city || '—' }}</dd>
<dt>A/C</dt>
<dd>{{ agentInfo.accountNo || '—' }}</dd>
<dt>Guarantor</dt>
<dd>{{ agentInfo.guarantor1 || '—' }}</dd>
<dt>Guarantor</dt>
<dd>{{ agentInfo.guarantor2 || '—' }}</dd>
<dt>Curr Bal</dt>
<dd>{{ agentInfo.currentBalance | number: '1.2-2' }}</dd>
</dl>
</div>
<div class="dte-info-panel">
<div class="dte-info-panel__title">Party Information</div>
@if (partyInfo) {
<dl>
<dt>Client Id</dt>
<dd>{{ partyInfo.clientId }}</dd>
<dt>Name</dt>
<dd>{{ partyInfo.partyName }}</dd>
<dt>Address</dt>
<dd>{{ partyInfo.address1 || '—' }}</dd>
<dt>City</dt>
<dd>{{ partyInfo.city || '—' }}</dd>
<dt>Op. Date</dt>
<dd>{{ partyInfo.openingDate ? (partyInfo.openingDate | dateFormat) : '—' }}</dd>
<dt>Maturity</dt>
<dd>{{ partyInfo.maturityDate ? (partyInfo.maturityDate | dateFormat) : '—' }}</dd>
<dt>Daily Depo</dt>
<dd>{{ partyInfo.dailyDepositAmount | number: '1.2-2' }}</dd>
<dt>Ledger</dt>
<dd>{{ partyInfo.ledgerNo || '—' }}</dd>
<dt>Aadhar No</dt>
<dd>{{ partyInfo.aadharNo || '—' }}</dd>
<dt>Curr Bal</dt>
<dd>{{ partyInfo.currentBalance | number: '1.2-2' }}</dd>
<!--
What should have been collected by today against what has been. Beside the balance
rather than instead of it: the balance is what the customer holds, this is whether
they have kept to the scheme.
-->
<dt>Due So Far</dt>
<dd>{{ partyInfo.expectedToDate | number: '1.2-2' }}</dd>
<dt>Collected</dt>
<dd>{{ partyInfo.collectedToDate | number: '1.2-2' }}</dd>
@if (schedule(partyInfo); as sched) {
<dt [class]="'dte-sched dte-sched--' + sched.state">Schedule</dt>
<dd [class]="'dte-sched dte-sched--' + sched.state">{{ sched.text }}</dd>
}
<dt class="dte-after">Bal. After This Tr</dt>
<dd class="dte-after">{{ partyBalanceAfter | number: '1.2-2' }}</dd>
</dl>
} @else {
<p class="dte-info-panel__hint">
Pick a party in a collection row below — this panel shows whose account it is and what the balance
becomes.
</p>
}
</div>
</div>
<div class="dte-controls">
<mat-form-field appearance="outline" class="dte-field dte-field--agent">
<mat-label>Agent</mat-label>
<mat-select required formControlName="agentId" (selectionChange)="loadAgent()">
@for (agent of selectableAgents; track agent.id) {
<mat-option [value]="agent.id">
{{ agent.agentCode }} · {{ agent.agentNameId }}
@if (!agent.active) {
<span class="dte-agent-off">— not collecting</span>
}
</mat-option>
}
</mat-select>
@if (selectableAgents.length === 0) {
<mat-hint class="dte-hint--missing">No active agent on file</mat-hint>
} @else if (pickedAgentIsInactive) {
<!-- Only reachable on a settlement: this agent no longer collects, but the cash they
already hold still has to be able to reach a branch. -->
<mat-hint class="dte-hint--missing">This agent no longer collects — settling held cash only</mat-hint>
} @else if (agentInfo.name) {
<mat-hint class="dte-hint--found">
{{ agentParties.length }} {{ agentParties.length === 1 ? 'party' : 'parties' }} to collect from
</mat-hint>
}
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Date</mat-label>
<!-- The date picks out a round, not just a posting date: a Party Tran settles whatever
the agent wrote down for that day, so changing it has to fetch that day's list. -->
<input
matInput
required
[matDatepicker]="txnDatePicker"
formControlName="transactionDate"
(dateChange)="loadPendingEntries(); loadAgentMoney()"
/>
<mat-datepicker-toggle matIconSuffix [for]="txnDatePicker"></mat-datepicker-toggle>
<mat-datepicker #txnDatePicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Tran. Type</mat-label>
<mat-select required formControlName="transactionType">
@for (type of transactionTypes; track type.label) {
<mat-option [value]="type.label" [disabled]="!type.supported">
{{ type.label }}
@if (!type.supported) {
<span class="dte-soon"> — not available yet</span>
}
</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<!--
What the agent wrote down on their round, waiting to be counted.
This used to be an empty grid the clerk typed the round into, and typing it posted it: a
customer was credited the moment a line was keyed, whether or not the cash behind it ever
reached the branch. The agent records their own collections now, and what the clerk does here
is agree with them - so there is nothing to type, only a list to check against a bag of cash.
-->
@if (isPartyTran && agentInfo.name) {
<div class="dte-section">
<div class="dte-section__header">
<i class="fas fa-list"></i>
<h3>Collections up to {{ entryForm.get('transactionDate')?.value | dateFormat }}</h3>
<button mat-stroked-button type="button" class="dte-add-row" (click)="loadPendingEntries()">
<i class="fas fa-sync"></i> Refresh
</button>
</div>
@if (pendingLoading) {
<p class="dte-empty">Reading this agent's entries...</p>
} @else if (pendingEntries.length === 0) {
<p class="dte-empty">{{ agentInfo.name }} has nothing outstanding up to this date.</p>
} @else {
@for (entry of pendingEntries; track entry.id) {
<div class="dte-pending-row">
<!-- The day the agent took it, on the line itself. All the lines carry the date
chosen above, but a row that says so can be read, printed or shown to the agent
on its own without the header to explain it. -->
<span class="dte-pending-row__date">{{ entry.entryDate | dateFormat }}</span>
<span class="dte-pending-row__code">{{ entry.partyCode }}</span>
<span class="dte-pending-row__name">{{ entry.partyName }}</span>
<span class="dte-pending-row__mode"
>{{ entry.transactionMode }}
@if (entry.advanceDays) {
· {{ entry.advanceDays }}d advance
}
@if (entry.arrearsDays) {
· {{ entry.arrearsDays }}d previous
}
</span>
<span class="dte-pending-row__amount">{{ entry.amount | number: '1.2-2' }}</span>
</div>
}
<div class="dte-total">
<span>{{ pendingEntries.length }} {{ pendingEntries.length === 1 ? 'entry' : 'entries' }}</span>
<strong>{{ pendingTotal | number: '1.2-2' }}</strong>
</div>
<!--
The figure the clerk counts against, said in words rather than left as a column total -
it is the one number they are acting on, and the rest of the panel is working out.
It says what the agent wrote down and stops there. The money side - what is owed, what the
cashier has taken in, and applying it - is Agent Tran, so each type has one job.
-->
<p class="dte-expected dte-expected--note">
Everything {{ agentInfo.name }} wrote down and has not handed in yet, up to this date, customer by
customer. The cash and its receipts are under Agent Tran.
</p>
}
</div>
}
<!--
The agent's money: what they owe on everything written down, what the cashier has taken in
against it, and what is left.
This used to be a form the clerk typed an amount into to take the agent's cash in here. The
cash is taken at the cashier's counter now, into the agent's own GL account, and one way to take
the same cash in is the only safe number of ways - so there is nothing to type. What is done here
is applying the cashier's receipts to the customers, in Apply DDS Receipt.
-->
@if (isAgentTran && agentInfo.name) {
<div class="dte-section">
<div class="dte-section__header">
<i class="fas fa-hand-holding-usd"></i>
<h3>Agent's cash</h3>
<button mat-stroked-button type="button" class="dte-add-row" (click)="loadAgentMoney()">
<i class="fas fa-sync"></i> Refresh
</button>
<button
mat-raised-button
color="primary"
type="button"
class="dte-apply-receipt"
(click)="openApplyReceipt()"
>
<i class="fas fa-receipt"></i> Apply DDS Receipt
</button>
</div>
@if (!agentGlCode) {
<p class="dte-expected dte-expected--note">
{{ agentInfo.name }} has no GL account, so the cashier has nowhere to take their cash. Set one on Agent
Master.
</p>
} @else {
<!-- The GL the cashier takes this agent's cash into - the code to key at Transaction Entry. -->
<p class="dte-expected">
Agent GL: <strong>{{ agentGlCode }}{{ agentGlName ? ' · ' + agentGlName : '' }}</strong> — the cashier
takes {{ agentInfo.name }}'s cash into this GL (Entry Type DDS).
</p>
}
<div class="dte-money">
<div class="dte-money__box">
<span class="dte-money__label">To take</span>
<strong>{{ agentOwed | number: '1.2-2' }}</strong>
</div>
<div class="dte-money__box">
<span class="dte-money__label">Received (approved)</span>
<strong>{{ agentReceived | number: '1.2-2' }}</strong>
</div>
<div class="dte-money__box" [class.dte-money__box--due]="agentBalance > 0">
<span class="dte-money__label">Still to take</span>
<strong>{{ agentBalance | number: '1.2-2' }}</strong>
</div>
</div>
@if (agentReceivedBeyondDay > 0) {
<p class="dte-expected dte-expected--note">
{{ agentReceivedBeyondDay | number: '1.2-2' }} received at the counter is more than everything outstanding
up to {{ entryForm.get('transactionDate')?.value | dateFormat }}. Check the voucher was taken for the
right agent.
</p>
}
@if (agentWaiting > 0) {
<p class="dte-expected dte-expected--note">
{{ agentWaiting | number: '1.2-2' }} more is at the counter awaiting the manager's approval.
</p>
}
<h4 class="dte-subhead">
Outstanding entries up to {{ entryForm.get('transactionDate')?.value | dateFormat }}
</h4>
@if (agentMoneyLoading) {
<p class="dte-empty">Reading this agent's entries...</p>
} @else if (agentDays.length === 0) {
<p class="dte-empty">{{ agentInfo.name }} has nothing outstanding.</p>
} @else {
@for (day of agentDays; track day.date) {
<div class="dte-pending-row dte-pending-row--day" (click)="toggleAgentDay(day.date)">
<span class="dte-pending-row__date">{{ day.date | dateFormat }}</span>
<span class="dte-pending-row__name">
{{ day.entries.length }} {{ day.entries.length === 1 ? 'entry' : 'entries' }}
</span>
<span class="dte-pending-row__amount">{{ day.total | number: '1.2-2' }}</span>
<i [class]="agentOpenDay === day.date ? 'fas fa-chevron-up' : 'fas fa-chevron-down'"></i>
</div>
@if (agentOpenDay === day.date) {
<div class="dte-day-lines">
@for (entry of day.entries; track entry.id) {
<div class="dte-pending-row">
<span class="dte-pending-row__code">{{ entry.partyCode }}</span>
<span class="dte-pending-row__name">{{ entry.partyName }}</span>
<span class="dte-pending-row__mode"
>{{ entry.transactionMode }}
@if (entry.advanceDays) {
· {{ entry.advanceDays }}d advance
}
@if (entry.arrearsDays) {
· {{ entry.arrearsDays }}d previous
}
</span>
<span class="dte-pending-row__amount">{{ entry.amount | number: '1.2-2' }}</span>
</div>
}
</div>
}
}
}
<h4 class="dte-subhead">Cashier receipts</h4>
@if (agentReceipts.length === 0) {
<p class="dte-empty">
No DDS receipt on {{ agentInfo.name }}'s GL account{{ agentGlCode ? ' ' + agentGlCode : '' }} waiting to
be applied.
</p>
} @else {
@for (receipt of agentReceipts; track receipt.id) {
<div class="dte-pending-row">
<span class="dte-pending-row__code">{{ receipt.voucherNo }}</span>
<span class="dte-pending-row__date">{{ receipt.txnDate | dateFormat }}</span>
<span class="dte-pending-row__name">{{ receipt.cashierName || '—' }}</span>
<span class="dte-status" [class.dte-status--ok]="receipt.status === 'APPROVED'">
{{ receipt.status === 'APPROVED' ? 'Approved' : 'Awaiting manager' }}
</span>
<span class="dte-pending-row__amount">{{ receipt.amount | number: '1.2-2' }}</span>
</div>
}
}
</div>
}
@if (isWithdrawDeposite) {
<div class="dte-section">
<div class="dte-section__header">
<i class="fas fa-exchange-alt"></i>
<h3>Withdraw Deposite</h3>
</div>
<div class="dte-grid">
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Creditor Party Code</mat-label>
<input
matInput
formControlName="creditorPartyCode"
[matAutocomplete]="creditorAuto"
#creditorTrigger="matAutocompleteTrigger"
(click)="creditorTrigger.openPanel()"
(focus)="creditorTrigger.openPanel()"
(blur)="settleTransferParty('creditor')"
placeholder="Search by code or name…"
autocomplete="off"
/>
<mat-icon matSuffix class="dte-list-arrow" (click)="$event.stopPropagation(); creditorTrigger.openPanel()"
>arrow_drop_down</mat-icon
>
<mat-autocomplete #creditorAuto="matAutocomplete" panelWidth="min(30rem, 92vw)">
@for (party of transferPartyOptions('creditor'); track party.id) {
<mat-option [value]="party.partyCode">
{{ party.partyCode }} · {{ party.partyName }} · ₹{{ party.currentBalance | number: '1.2-2' }}
</mat-option>
}
@if (transferPartyOptions('creditor').length === 0) {
<mat-option disabled>No open DDS account matches</mat-option>
}
</mat-autocomplete>
<mat-hint>Account the money goes into</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Creditor Party Name</mat-label>
<input matInput formControlName="creditorPartyName" readonly />
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Creditor Curr Bal</mat-label>
<input matInput formControlName="creditorPartyBalance" readonly />
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Debit Party Code</mat-label>
<input
matInput
formControlName="debitPartyCode"
[matAutocomplete]="debitAuto"
#debitTrigger="matAutocompleteTrigger"
(click)="debitTrigger.openPanel()"
(focus)="debitTrigger.openPanel()"
(blur)="settleTransferParty('debit')"
placeholder="Search by code or name…"
autocomplete="off"
/>
<mat-icon matSuffix class="dte-list-arrow" (click)="$event.stopPropagation(); debitTrigger.openPanel()"
>arrow_drop_down</mat-icon
>
<mat-autocomplete #debitAuto="matAutocomplete" panelWidth="min(30rem, 92vw)">
@for (party of transferPartyOptions('debit'); track party.id) {
<mat-option [value]="party.partyCode">
{{ party.partyCode }} · {{ party.partyName }} · ₹{{ party.currentBalance | number: '1.2-2' }}
</mat-option>
}
@if (transferPartyOptions('debit').length === 0) {
<mat-option disabled>No open DDS account matches</mat-option>
}
</mat-autocomplete>
@if (transferToItself) {
<mat-hint class="dte-same-party">Pick a different account - it cannot transfer to itself</mat-hint>
} @else {
<mat-hint>Account the money is taken from</mat-hint>
}
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Debit Party Name</mat-label>
<input matInput formControlName="debitPartyName" readonly />
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Debit Curr Bal</mat-label>
<input matInput formControlName="debitPartyBalance" readonly />
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Amount</mat-label>
<input matInput type="number" formControlName="withdrawAmount" />
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field dte-field--wide">
<mat-label>Description</mat-label>
<input matInput formControlName="withdrawDescription" />
</mat-form-field>
</div>
</div>
}
@if (!isPartyTran && !isWithdrawDeposite && !isAgentTran) {
<div class="dte-section">
<p class="dte-empty">
"{{ entryForm.get('transactionType').value }}" entry screen — same shape as Party Tran, to be filled in once
the backend distinguishes these transaction types.
</p>
</div>
}
<div class="dte-action-bar">
<button mat-raised-button type="button" (click)="exit()">Exit</button>
<button mat-stroked-button type="button" (click)="cancel()">Cancel</button>
<button mat-stroked-button type="button" (click)="retrieve()">Retrieve</button>
<button mat-stroked-button type="button" (click)="viewLedger()">View Ledger</button>
<button mat-stroked-button type="button" (click)="signature()">Signature</button>
<!-- Party Tran and Agent Tran are read here, never saved: the agent writes the round down on
their own screen and the cash is taken in at the cashier's counter. Agent Tran applies the
cashier's receipts from its own Apply DDS Receipt. Only Withdraw Deposite still saves. -->
@if (!isPartyTran && !isAgentTran) {
<button
mat-raised-button
color="primary"
type="button"
[disabled]="entryForm.invalid || transferToItself"
(click)="save()"
>
Save
</button>
}
</div>
</form>
</div>
</div>
@@ -0,0 +1,488 @@
/**
* 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/.
*/
$primary: #0f766e;
.dte-container {
// Wider than a normal form page: a collection row carries six fields side by side, and a clerk
// keying a round should see the whole of it.
max-width: 1280px;
margin: 0 auto;
padding: 20px 16px;
}
.dte-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 20px;
&__title {
margin: 0;
font-size: 22px;
font-weight: 500;
}
}
.dte-back {
color: $primary;
}
.dte-card {
background: var(--md-sys-color-surface, #fff);
border-radius: 8px;
box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
padding: 20px 24px 24px;
}
.dte-info-panels {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 20px;
}
.dte-info-panel {
border: 1px solid rgb(0 0 0 / 10%);
border-radius: 8px;
padding: 12px 16px;
background: rgb(15 118 110 / 4%);
&__title {
font-weight: 600;
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: $primary;
margin-bottom: 8px;
}
&__hint {
margin: 0;
color: rgb(0 0 0 / 50%);
font-size: 13px;
}
dl {
display: grid;
grid-template-columns: auto 1fr;
gap: 4px 12px;
margin: 0;
}
dt {
font-weight: 500;
color: rgb(0 0 0 / 60%);
font-size: 13px;
}
dd {
margin: 0;
font-size: 13px;
}
}
.dte-controls {
display: flex;
align-items: flex-start;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 12px;
}
// A sensible floor for the fields laid out with flex in .dte-controls, where there is room to spare.
.dte-field {
min-width: 180px;
&--wide {
grid-column: span 2;
}
}
// Inside the collection grid the track decides the width, not the field. Left to its own 180px
// floor a field refuses to shrink into a narrower track and overruns the one beside it.
.dte-collection-row .dte-field {
width: 100%;
min-width: 0;
}
.dte-section {
padding: 16px 0;
border-top: 1px solid rgb(0 0 0 / 8%);
// A very narrow window scrolls the grid sideways rather than the whole page.
overflow-x: auto;
&__header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
i {
color: $primary;
}
h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
flex: 1;
}
}
}
.dte-add-row {
color: $primary;
}
.dte-empty {
color: rgb(0 0 0 / 50%);
font-size: 14px;
margin: 0;
}
.dte-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px 16px;
}
/*
* One collection per row. Fixed columns rather than an even split, so the row holds its shape as
* fields come and go - Cheque No and Token No only appear for a non-cash entry - and the party
* picker gets the width its "code · name · daily amount" needs.
*/
.dte-collection-row {
display: grid;
grid-template-columns:
minmax(0, 2.2fr) /* Party */
minmax(0, 1fr) /* TRTP */
minmax(0, 1fr) /* AC Type */
minmax(0, 1.4fr) /* A/C No. */
minmax(0, 1fr) /* Amount */
minmax(0, 1.6fr) /* Description */
auto; /* remove */
gap: 8px 12px;
align-items: start;
padding: 10px 0;
border-top: 1px dashed rgb(0 0 0 / 10%);
&:first-of-type {
border-top: none;
}
// Cheque No and Token No push Description and the remove button onto a second line, which is
// what a non-cash entry should look like rather than a row running off the screen.
&:has(.dte-field--instrument) {
grid-template-columns:
minmax(0, 2.2fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1.4fr) minmax(0, 1fr)
minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1.6fr) auto;
}
}
.dte-collection-row .dte-field--wide {
grid-column: span 2;
}
@media (width <= 1200px) {
.dte-collection-row,
.dte-collection-row:has(.dte-field--instrument) {
grid-template-columns: repeat(3, minmax(0, 1fr)) auto;
}
}
.dte-remove-row {
color: #b91c1c;
margin-top: 8px;
}
.dte-action-bar {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid rgb(0 0 0 / 8%);
flex-wrap: wrap;
}
@media (width <= 1000px) {
.dte-info-panels {
grid-template-columns: 1fr;
}
.dte-grid {
grid-template-columns: repeat(2, 1fr);
}
.dte-collection-row {
grid-template-columns: repeat(2, 1fr);
}
}
/* Resolved name under an Agent / Party Code field — the check that the right record was found. */
.dte-hint--found {
color: #15803d;
font-weight: 500;
}
.dte-hint--missing {
color: #b91c1c;
font-weight: 500;
}
/* Marks an agent in the list who is no longer collecting - only offered for settlement. */
.dte-agent-off {
color: #b45309;
font-size: 12px;
font-style: italic;
margin-left: 6px;
}
/* The figure the clerk actually verifies against the passbook before saving. */
.dte-after {
font-weight: 600;
color: #0f766e;
}
/* What the agent should be handing over for this round. */
.dte-total {
display: flex;
justify-content: flex-end;
align-items: baseline;
gap: 14px;
padding: 10px 4px 2px;
border-top: 1px solid rgb(0 0 0 / 10%);
font-size: 0.88rem;
color: rgb(0 0 0 / 60%);
strong {
font-size: 1.1rem;
color: rgb(0 0 0 / 85%);
font-variant-numeric: tabular-nums;
}
}
/* Marks a transaction type the system does not handle yet, so it reads as pending rather than broken. */
.dte-soon {
font-size: 0.78rem;
font-style: italic;
color: rgb(0 0 0 / 45%);
}
/*
* Marks a party this agent has already collected from today, on the row where it is picked.
*
* Amber, not red: a customer paying twice in a day is legitimate - arrears, or two days at once -
* so this is a second look, not an error. Blocking it would stop real work; saying nothing lets a
* whole round be credited twice.
*/
.dte-hint--again {
color: #b45309;
font-weight: 500;
}
/*
* Where a party stands against its instalments. Three states rather than one colour, because they
* call for three different things from the clerk: ahead means take nothing, behind means take more
* than the daily amount, level means take the daily amount as usual.
*
* Behind is amber, not red - falling a few days behind is ordinary in daily collection and is the
* agent's job to recover, not an error. Red here would cry wolf on most rows.
*/
.dte-hint--ahead,
.dte-sched--ahead {
color: #15803d;
font-weight: 500;
}
.dte-hint--behind,
.dte-sched--behind {
color: #b45309;
font-weight: 500;
}
.dte-hint--level,
.dte-sched--level {
color: rgb(0 0 0 / 55%);
}
/* In the party panel the schedule line carries more weight than the fields around it. */
.dte-sched {
font-weight: 500;
}
/* Says why a party in the list cannot be picked, beside the party rather than in a tooltip. */
.dte-covered {
color: #15803d;
font-size: 0.8rem;
}
/*
* An amount that looks like a slip. Red rather than the amber used for arrears: arrears are ordinary
* and the agent's job to recover, while this is a figure that probably was not handed over at all,
* and once saved it cannot be taken back. It still does not block - the clerk decides.
*/
.dte-hint--overpay {
color: #b91c1c;
font-weight: 500;
}
/*
The agent's written-down round, as the counter reads it.
Laid out as lines rather than as the input grid this replaced: there is nothing to fill in here,
only a list to check a bag of cash against, and controls would invite editing something the clerk
has no business editing.
*/
.dte-pending-row {
display: flex;
align-items: baseline;
gap: 12px;
padding: 10px 2px;
border-bottom: 1px solid rgb(0 0 0 / 8%);
&:last-of-type {
border-bottom: none;
}
/* First, and in the quieter weight: the date is context for the line, not what is being read. */
&__date {
min-width: 96px;
opacity: 0.7;
font-variant-numeric: tabular-nums;
}
&__code {
font-weight: 600;
min-width: 84px;
}
&__name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
opacity: 0.75;
}
&__mode {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
opacity: 0.6;
}
/* Amounts are read down the column and compared, so the digits have to line up. */
&__amount {
font-variant-numeric: tabular-nums;
font-weight: 600;
min-width: 96px;
text-align: right;
}
}
/* The one figure the clerk acts on, said as a sentence so it is not read as another column total. */
.dte-expected {
margin: 14px 2px 0;
font-size: 14px;
line-height: 1.5;
strong {
font-variant-numeric: tabular-nums;
}
}
/* Quieter than the figure above it: a note about how the screen behaves, not part of the count. */
.dte-expected--note {
font-size: 13px;
opacity: 0.65;
margin-top: 4px;
}
.dte-apply-receipt {
margin-left: 8px;
}
/* Agent Tran: what the agent owes, what the cashier has taken in, and what is left. */
.dte-money {
display: flex;
gap: 12px;
margin: 8px 0 12px;
flex-wrap: wrap;
&__box {
flex: 1;
min-width: 160px;
padding: 10px 14px;
border: 1px solid rgb(0 0 0 / 10%);
border-radius: 8px;
display: flex;
flex-direction: column;
gap: 4px;
font-variant-numeric: tabular-nums;
strong {
font-size: 18px;
}
&--due {
border-color: rgb(198 40 40 / 40%);
background: rgb(198 40 40 / 4%);
}
}
&__label {
font-size: 12px;
opacity: 0.7;
text-transform: uppercase;
letter-spacing: 0.04em;
}
}
.dte-subhead {
margin: 16px 0 4px;
font-size: 14px;
font-weight: 600;
}
.dte-pending-row--day {
cursor: pointer;
}
.dte-day-lines {
margin-left: 24px;
max-height: 280px;
overflow-y: auto;
border-left: 3px solid rgb(25 118 210 / 30%);
padding-left: 10px;
}
.dte-status {
font-size: 12px;
padding: 2px 8px;
border-radius: 10px;
background: rgb(237 108 2 / 12%);
color: #b45309;
&--ok {
background: rgb(46 125 50 / 12%);
color: #2e7d32;
}
}
.dte-list-arrow {
cursor: pointer;
}
.dte-same-party {
color: #c62828;
font-weight: 600;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,58 @@
<!--
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/.
-->
<h2 mat-dialog-title>{{ title }}</h2>
<mat-dialog-content>
<mat-form-field appearance="outline" class="dcd-field">
<mat-label>Party</mat-label>
<mat-select [(ngModel)]="partyCode" (selectionChange)="partyChanged()">
@for (p of eligibleParties; track p.id) {
<mat-option [value]="p.partyCode">
{{ p.partyCode }} · {{ p.partyName }} · Daily {{ p.dailyDepositAmount | number: '1.0-0' }}
</mat-option>
}
</mat-select>
@if (eligibleParties.length === 0) {
<!-- Only reachable on the arrears side: nobody is behind within the window. -->
<mat-hint class="dcd-none">No party has recoverable days</mat-hint>
}
</mat-form-field>
<mat-form-field appearance="outline" class="dcd-field">
<mat-label>Days</mat-label>
<mat-select [(ngModel)]="days" [disabled]="!partyCode">
@for (d of dayOptions; track d) {
<mat-option [value]="d">{{ d }} {{ d === 1 ? 'day' : 'days' }}</mat-option>
}
</mat-select>
@if (isAdvance) {
<mat-hint>Up to 5 days</mat-hint>
} @else if (partyCode) {
<mat-hint>Only days still within the catch-up window</mat-hint>
}
</mat-form-field>
<!--
What it comes to, shown before it is added rather than after. The clerk quotes this to the
customer, so it has to be the scheme's own figure and has to be visible while deciding.
-->
@if (amount > 0) {
<div class="dcd-amount">
<span class="dcd-amount__figure">{{ amount | number: '1.2-2' }}</span>
<span class="dcd-amount__basis">
{{ days }} × {{ party?.dailyDepositAmount | number: '1.2-2' }} daily
</span>
</div>
}
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button mat-dialog-close type="button">Cancel</button>
<button mat-raised-button color="primary" type="button" [disabled]="!canAdd" (click)="add()">Add</button>
</mat-dialog-actions>
@@ -0,0 +1,42 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
.dcd-field {
width: 100%;
}
.dcd-none {
color: #b45309;
}
/*
* The figure the clerk quotes at the counter. Larger than the fields above it because it is the one
* thing the customer is told, and it is shown with its basis so the clerk can say where it came from
* rather than just naming a number.
*/
.dcd-amount {
display: flex;
align-items: baseline;
gap: 10px;
margin-top: 6px;
padding: 12px 14px;
border-radius: 6px;
background: rgb(15 118 110 / 8%);
&__figure {
font-size: 1.3rem;
font-weight: 600;
color: #0f766e;
font-variant-numeric: tabular-nums;
}
&__basis {
font-size: 0.8rem;
color: rgb(0 0 0 / 60%);
}
}
@@ -0,0 +1,117 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/** Angular Imports */
import { Component, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { MatSelectModule } from '@angular/material/select';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
/**
* Collecting for days other than today - either paid ahead, or owed from days gone by.
*
* Both are the same act from the counter's side: the customer is paying for a number of days, and
* what that comes to is the scheme's business, not the clerk's. So the clerk picks days and the
* amount follows. Typing an amount instead would let the daily figure be worked around a little at a
* time, and would quote the customer something the scheme does not charge.
*
* The two differ only in how many days are on offer. Paying ahead is capped by the bank's allowance.
* Paying arrears is capped by what is still inside the catch-up window - older instalments are gone,
* and offering them would have the clerk asking for money the bank has already written off.
*/
@Component({
selector: 'mifosx-day-collection-dialog',
standalone: true,
imports: [
CommonModule,
FormsModule,
MatDialogModule,
MatButtonModule,
MatSelectModule,
MatFormFieldModule,
MatInputModule
],
templateUrl: './day-collection-dialog.component.html',
styleUrls: ['./day-collection-dialog.component.scss']
})
export class DayCollectionDialogComponent {
private dialogRef = inject(MatDialogRef<DayCollectionDialogComponent>);
/** { mode: 'advance' | 'previous', parties: [] } - the agent's parties, as the screen already has them. */
data = inject<any>(MAT_DIALOG_DATA);
partyCode = '';
days: number | '' = '';
get isAdvance(): boolean {
return this.data?.mode === 'advance';
}
get title(): string {
return this.isAdvance ? 'Collect in advance' : 'Collect previous days';
}
get party(): any {
return (this.data?.parties || []).find((p: any) => String(p.partyCode) === this.partyCode) || null;
}
/**
* The parties this can be used on.
*
* Paying ahead is offered to anyone. Paying arrears is offered only where there are arrears still
* inside the window - on an account that is up to date there is nothing to collect for past days,
* and listing it would invite an entry that is really an advance in disguise.
*/
get eligibleParties(): any[] {
const parties = this.data?.parties || [];
return this.isAdvance ? parties : parties.filter((p: any) => Number(p.recoverableAmount || 0) > 0);
}
/**
* How many days may be picked. Five ahead, matching the bank's allowance and the server's own
* check; for arrears, only what has not yet fallen out of the catch-up window.
*/
get dayOptions(): number[] {
const daily = Number(this.party?.dailyDepositAmount || 0);
if (!this.party || daily <= 0) {
return [];
}
const limit = this.isAdvance
? 5
: Math.floor(Number(this.party.recoverableAmount || 0) / daily);
return Array.from({ length: Math.max(0, limit) }, (_, index) => index + 1);
}
get amount(): number {
return Number(this.party?.dailyDepositAmount || 0) * Number(this.days || 0);
}
/** Clearing the days when the party changes - a count valid for one account is not for another. */
partyChanged(): void {
this.days = '';
}
get canAdd(): boolean {
return !!this.partyCode && Number(this.days) > 0 && this.amount > 0;
}
add(): void {
this.dialogRef.close({
partyCode: this.partyCode,
days: Number(this.days),
amount: this.amount,
// Sent as days, not just an amount: the server recomputes what they come to and checks it
// against this account, so the figure quoted here is the one that gets posted.
advanceDays: this.isAdvance ? Number(this.days) : undefined,
arrearsDays: this.isAdvance ? undefined : Number(this.days)
});
}
}
@@ -0,0 +1,21 @@
<!--
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="nd-head">
<span class="nd-head__ic"><fa-icon icon="info-circle"></fa-icon></span>
<h2 class="nd-head__title">{{ data.heading }}</h2>
</div>
<mat-dialog-content>
<p class="nd-message">{{ data.message }}</p>
</mat-dialog-content>
<!-- One button, because there is nothing here to agree to or call off. -->
<mat-dialog-actions align="end">
<button mat-flat-button color="primary" mat-dialog-close>OK</button>
</mat-dialog-actions>
@@ -0,0 +1,44 @@
/**
* 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/.
*/
.nd-head {
display: flex;
align-items: center;
gap: 12px;
padding: 18px 24px 4px;
/*
* Teal rather than the amber or red the platform's other dialogs use. Nothing has gone wrong here
* and nothing is at risk - the clerk is being told a fact about the agent they picked, and a
* warning colour would send them looking for a fault that is not there.
*/
&__ic {
display: grid;
place-items: center;
width: 34px;
height: 34px;
border-radius: 8px;
background: rgb(15 118 110 / 10%);
color: #0f766e;
flex: none;
}
&__title {
margin: 0;
font-size: 1.05rem;
font-weight: 600;
}
}
.nd-message {
margin: 0;
max-width: 44ch;
font-size: 0.9rem;
line-height: 1.55;
color: rgb(0 0 0 / 70%);
}
@@ -0,0 +1,39 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/** Angular Imports */
import { Component, inject } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
/**
* Something the clerk needs told, with nothing to decide.
*
* The platform's confirmation dialog asks a question - Cancel or Confirm - and its buttons imply a
* choice. Used for a plain statement it leaves the clerk hunting for what they are agreeing to, and
* "Cancel" reads as though the thing being described could be undone. The error dialog has the right
* single button but dresses the message in red, which turns "this agent has no parties" into a fault.
*
* So: one button, and the message stands on its own.
*/
@Component({
selector: 'mifosx-notice-dialog',
standalone: true,
imports: [
MatDialogModule,
MatButtonModule,
FaIconComponent
],
templateUrl: './notice-dialog.component.html',
styleUrls: ['./notice-dialog.component.scss']
})
export class NoticeDialogComponent {
/** { heading, message } */
data = inject<any>(MAT_DIALOG_DATA);
}
@@ -0,0 +1,557 @@
<!--
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="dav-page">
@if (isLoading) {
<div class="dav-card dav-state">
<mat-spinner diameter="32"></mat-spinner>
<span>Loading account…</span>
</div>
} @else if (!account) {
<div class="dav-card dav-state">
<fa-icon icon="inbox" size="2x"></fa-icon>
<span>DDS account not found</span>
<button class="dav-btn" (click)="back()"><fa-icon icon="arrow-left"></fa-icon> DDS Accounts</button>
</div>
} @else {
<!-- Banner, as the savings account page opens -->
<div class="dav-banner">
<div class="dav-banner__icon"><fa-icon icon="coins"></fa-icon></div>
<div class="dav-banner__text">
<span class="dav-pill" [ngClass]="'dav-pill--' + (account.status || '').toLowerCase()"
>● {{ account.status }}</span
>
<h2 class="dav-banner__title">{{ account.subType }}</h2>
<div class="dav-banner__meta">
<span class="dav-banner__label">Account Number</span>
<span class="dav-banner__value dav-mono">{{ account.accountNo }}</span>
<span class="dav-banner__label">Client Name</span>
<span class="dav-banner__value">{{ account.clientName || account.partyName }}</span>
</div>
</div>
<div class="dav-banner__boxes">
<div class="dav-box">
<span class="dav-box__label">Current Balance</span>
<span class="dav-box__value">₹{{ account.currentBalance || 0 | formatNumber }}</span>
</div>
<div class="dav-box">
<span class="dav-box__label">Maturity Amount</span>
<span class="dav-box__value">₹{{ account.maturityAmount || 0 | formatNumber }}</span>
</div>
</div>
<div class="dav-banner__actions">
<button class="dav-icon-btn" (click)="back()" matTooltip="Back to DDS Accounts">
<fa-icon icon="arrow-left"></fa-icon>
</button>
<button class="dav-icon-btn" (click)="openInPartyMaster()" matTooltip="Open in DDS Party Master">
<fa-icon icon="pen"></fa-icon>
</button>
</div>
</div>
<!-- Tabs -->
<div class="dav-tabs">
<button class="dav-tab" [class.dav-tab--on]="tab === 'general'" (click)="tab = 'general'">
<fa-icon icon="info-circle"></fa-icon> General
</button>
<button class="dav-tab" [class.dav-tab--on]="tab === 'transactions'" (click)="tab = 'transactions'">
<fa-icon icon="exchange-alt"></fa-icon> Transactions
</button>
<button class="dav-tab" [class.dav-tab--on]="tab === 'passbook'" (click)="tab = 'passbook'">
<fa-icon icon="book"></fa-icon> Passbook
</button>
<button class="dav-tab" [class.dav-tab--on]="tab === 'nominees'" (click)="tab = 'nominees'">
<fa-icon icon="user"></fa-icon> Nominees
</button>
@if (account.status === 'CLOSED') {
<button class="dav-tab" [class.dav-tab--on]="tab === 'closure'" (click)="tab = 'closure'">
<fa-icon icon="lock"></fa-icon> Closure
</button>
}
</div>
<!-- Closure: how a closed account was closed and where its money went -->
@if (tab === 'closure') {
@if (!closure) {
<div class="dav-card">
<div class="dav-row"><span>Loading closure details…</span></div>
</div>
} @else {
<div class="dav-columns">
<div class="dav-column">
<div class="dav-card">
<div class="dav-card__head"><span class="dav-card__dot"></span> How It Closed</div>
<div class="dav-row">
<span>Type</span>
<strong>
{{ closureLabels[closure.closureType] || closure.closureType }}
@if (closure.withinLockin) {
<em class="dav-closure-tag">within lock-in</em>
}
</strong>
</div>
<div class="dav-row">
<span>Closed On</span><strong>{{ closure.closeDate | date: 'dd MMMM yyyy' }}</strong>
</div>
@if (closure.paidOutBy) {
<div class="dav-row">
<span>Paid Out By</span><strong>{{ closure.paidOutBy }}</strong>
</div>
}
<div class="dav-row">
<span>Closed By</span><strong>{{ closure.closedBy || '—' }}</strong>
</div>
<div class="dav-row">
<span>Approved By</span><strong>{{ closure.approvedBy || '—' }}</strong>
</div>
@if (closure.lockinOverrideReason) {
<div class="dav-row">
<span>Reason</span><strong>{{ closure.lockinOverrideReason }}</strong>
</div>
}
@if (closure.shortfallBorneBy) {
<div class="dav-row">
<span>Cost Borne By</span>
<strong>{{
closure.shortfallBorneBy === 'AGENT' ? 'Agent — ' + closure.agentCode : 'Customer'
}}</strong>
</div>
}
</div>
<div class="dav-card">
<div class="dav-card__head"><span class="dav-card__dot"></span> The Money</div>
<div class="dav-row">
<span>Balance at Close</span><strong>₹{{ closure.balanceAtClose || 0 | formatNumber }}</strong>
</div>
@if (closure.interestRecovered > 0) {
<div class="dav-row">
<span>Interest Recovered</span
><strong class="dav-dr">− ₹{{ closure.interestRecovered | formatNumber }}</strong>
</div>
}
@if (closure.interestToppedUpByAgent > 0) {
<div class="dav-row">
<span>Interest Funded by Agent</span
><strong class="dav-cr">+ ₹{{ closure.interestToppedUpByAgent | formatNumber }}</strong>
</div>
}
<div class="dav-row dav-closure-total">
<span>Paid Out</span><strong>₹{{ closure.paidOut || 0 | formatNumber }}</strong>
</div>
@if (closure.closureType === 'PREMATURE') {
<div class="dav-row">
<span>Agent Penalty ({{ closure.agentCode }})</span
><strong>₹{{ closure.agentCommissionPenalty || 0 | formatNumber }}</strong>
</div>
}
</div>
</div>
<div class="dav-column">
<div class="dav-card">
<div class="dav-card__head"><span class="dav-card__dot"></span> Where It Went</div>
@if (closure.destination === 'OWN_ACCOUNT') {
<div class="dav-row"><span>Paid Into</span><strong>Customer's own account</strong></div>
<div class="dav-row">
<span>Account</span><strong class="dav-mono">{{ closure.creditedAccount?.accountNo }}</strong>
</div>
@if (closure.creditedAccount?.productName) {
<div class="dav-row">
<span>Product</span><strong>{{ closure.creditedAccount.productName }}</strong>
</div>
}
@if (closure.creditedAccount?.owner) {
<div class="dav-row">
<span>Holder</span><strong>{{ closure.creditedAccount.owner }}</strong>
</div>
}
} @else if (closure.destination === 'SUNDRY') {
<div class="dav-row"><span>Parked In</span><strong>Sundry (customer has no account here)</strong></div>
<div class="dav-row">
<span>GL Account</span
><strong class="dav-mono">{{ closure.sundryGl?.glCode }} · {{ closure.sundryGl?.glName }}</strong>
</div>
} @else {
<div class="dav-row"><span>Paid Out</span><strong>Nothing - the balance was nil</strong></div>
}
@if (closure.voucherNo) {
<div class="dav-row">
<span>Voucher</span><strong class="dav-mono">{{ closure.voucherNo }}</strong>
</div>
}
@if (closure.payoutDate) {
<div class="dav-row">
<span>Paid On</span><strong>{{ closure.payoutDate | date: 'dd MMMM yyyy' }}</strong>
</div>
}
@if (closure.passbookNote) {
<div class="dav-row">
<span>Passbook Note</span><strong>{{ closure.passbookNote }}</strong>
</div>
}
</div>
@if (closure.ledgerEntries?.length) {
<div class="dav-card">
<div class="dav-card__head"><span class="dav-card__dot"></span> Ledger Entry</div>
<div class="dav-row">
<span>Transaction ID</span><strong class="dav-mono">{{ closure.ledgerTransactionId }}</strong>
</div>
@for (leg of closure.ledgerEntries; track $index) {
<div class="dav-row">
<span>{{ leg.glCode }} · {{ leg.glName }}</span>
<strong [class.dav-dr]="leg.side === 'DR'" [class.dav-cr]="leg.side === 'CR'">
{{ leg.side }} ₹{{ leg.amount | formatNumber }}
</strong>
</div>
}
</div>
}
</div>
</div>
}
}
<!-- General -->
@if (tab === 'general') {
<div class="dav-columns">
<div class="dav-column">
<div class="dav-card">
<div class="dav-card__head"><span class="dav-card__dot"></span> DDS Details</div>
<div class="dav-row">
<span>Account Number</span><strong class="dav-mono">{{ account.accountNo }}</strong>
</div>
<div class="dav-row">
<span>Party Code</span><strong>{{ account.partyCode }}</strong>
</div>
<div class="dav-row">
<span>Client Name</span><strong>{{ account.clientName }} ({{ account.clientId }})</strong>
</div>
<div class="dav-row">
<span>Agent</span><strong>{{ account.agentCode }} · {{ account.agentName }}</strong>
</div>
<div class="dav-row">
<span>Branch</span><strong>{{ account.officeName }}</strong>
</div>
<div class="dav-row">
<span>Product</span><strong>{{ account.subType }}</strong>
</div>
<div class="dav-row">
<span>Opened On</span><strong>{{ account.openingDate | date: 'dd MMMM yyyy' }}</strong>
</div>
<div class="dav-row">
<span>Matures On</span><strong>{{ account.maturityDate | date: 'dd MMMM yyyy' }}</strong>
</div>
<div class="dav-row">
<span>Daily Deposit</span><strong>₹{{ account.dailyDepositAmount || 0 | formatNumber }}</strong>
</div>
<div class="dav-row">
<span>Term</span><strong>{{ account.month }} months · {{ account.days }} days</strong>
</div>
<div class="dav-row">
<span>Interest Rate</span><strong>{{ account.interestRate }} %</strong>
</div>
<div class="dav-row">
<span>Currency</span><strong>Indian Rupee <em class="dav-mono">[INR]</em></strong>
</div>
</div>
<div class="dav-card">
<div class="dav-card__head"><span class="dav-card__dot"></span> Performance History</div>
<div class="dav-row">
<span>Total Collected</span><strong class="dav-cr">₹{{ totalOf('PARTY_TRAN') | formatNumber }}</strong>
</div>
<div class="dav-row">
<span>Total Interest Earned</span><strong>₹{{ interestEarned | formatNumber }}</strong>
</div>
<div class="dav-row">
<span>Total Paid Out</span><strong class="dav-dr">₹{{ totalDebitsAll | formatNumber }}</strong>
</div>
</div>
</div>
<div class="dav-column">
<div class="dav-card">
<div class="dav-card__head"><span class="dav-card__dot"></span> Account Summary</div>
<div class="dav-row">
<span>Collected to Date</span><strong>₹{{ account.collectedToDate || 0 | formatNumber }}</strong>
</div>
<div class="dav-row">
<span>Expected to Date</span><strong>₹{{ account.expectedToDate || 0 | formatNumber }}</strong>
</div>
<div class="dav-row">
<span>Standing</span>
<strong>
@if (account.advanceDays > 0) {
{{ account.advanceDays }} days ahead
} @else if (account.arrearsDays > 0) {
<span class="dav-dr">{{ account.arrearsDays }} days behind</span>
} @else {
Up to date
}
</strong>
</div>
<div class="dav-row">
<span>Instalments Due</span><strong>{{ account.instalmentsDue ?? 0 }}</strong>
</div>
<div class="dav-row">
<span>Interest Earned</span><strong>₹{{ interestEarned | formatNumber }}</strong>
</div>
<div class="dav-row">
<span>Interest Calculated, Not Posted</span
><strong>₹{{ account.interestCalc || 0 | formatNumber }}</strong>
</div>
<div class="dav-row"><span>Interest Calculated Using</span><strong>Daily Balance</strong></div>
<div class="dav-row"><span>Days in Year</span><strong>365 Days</strong></div>
<div class="dav-row">
<span>Last Interest Date</span
><strong>{{ account.interestDate ? (account.interestDate | date: 'dd MMMM yyyy') : '—' }}</strong>
</div>
<div class="dav-row">
<span>Last Transaction Date</span
><strong>{{ lastTransactionDate ? (lastTransactionDate | date: 'dd MMMM yyyy') : '—' }}</strong>
</div>
<div class="dav-row">
<span>Maturity Amount</span><strong>₹{{ account.maturityAmount || 0 | formatNumber }}</strong>
</div>
<div class="dav-row">
<span>Lien</span>
<strong>{{ account.lien ? 'Yes — ₹' + (account.amountHeldUnderLien || 0) : 'No' }}</strong>
</div>
<div class="dav-row">
<span>Freeze</span><strong>{{ account.freezeCode || 'Normal' }}</strong>
</div>
@if (account.closeDate) {
<div class="dav-row">
<span>Closed On</span><strong>{{ account.closeDate | date: 'dd MMMM yyyy' }}</strong>
</div>
}
</div>
</div>
</div>
}
<!-- Transactions -->
@if (tab === 'transactions') {
<div class="dav-card">
<div class="dav-toolbar">
<div class="dav-datepick">
<span>From</span>
<input type="date" [formControl]="fromDateControl" [max]="today" />
</div>
<div class="dav-datepick">
<span>To</span>
<input type="date" [formControl]="toDateControl" [max]="today" />
</div>
<button class="dav-btn" (click)="showToday()"><fa-icon icon="clock"></fa-icon> Today</button>
@if (hasDateFilters) {
<button class="dav-btn" (click)="clearDateFilters()"><fa-icon icon="times"></fa-icon> Clear</button>
}
<div class="dav-totals">
<span class="dav-cr">Credits ₹{{ totalCredits | formatNumber }}</span>
<span class="dav-dr">Debits ₹{{ totalDebits | formatNumber }}</span>
</div>
<button class="dav-btn dav-btn--primary" (click)="exportCsv()" [disabled]="shownTransactions.length === 0">
Export
</button>
</div>
@if (shownTransactions.length === 0) {
<div class="dav-state">
<fa-icon icon="inbox" size="2x"></fa-icon>
<span>{{
hasDateFilters
? 'Nothing moved on this account in these dates'
: 'Nothing has been credited to this account yet'
}}</span>
</div>
} @else {
<div class="dav-table-wrap">
<table class="dav-table">
<thead>
<tr>
<th>#</th>
<th>ID</th>
<th>Transaction Date</th>
<th>Transaction Type</th>
<th>Voucher</th>
<th>Description</th>
<th class="dav-right">Debit</th>
<th class="dav-right">Credit</th>
<th class="dav-right">Balance</th>
</tr>
</thead>
<tbody>
@for (txn of shownTransactions; track txn.id; let i = $index) {
<tr>
<td>{{ i + 1 }}</td>
<td>{{ txn.id }}</td>
<td>{{ txn.transactionDate | date: 'dd MMMM yyyy' }}</td>
<td>{{ label(txn.transactionType) }}</td>
<td class="dav-mono">{{ txn.voucherNo || '—' }}</td>
<td class="dav-desc">{{ txn.description }}</td>
<td class="dav-right">
{{ txn.direction === 'DEBIT' ? '₹' + (txn.amount | formatNumber) : 'N/A' }}
</td>
<td class="dav-right">
{{ txn.direction === 'CREDIT' ? '₹' + (txn.amount | formatNumber) : 'N/A' }}
</td>
<td class="dav-right">
<strong>₹{{ txn.balanceAfterTransaction || 0 | formatNumber }}</strong>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
}
<!-- Passbook: the same entries oldest first, the way a passbook is written up, and printable -->
@if (tab === 'passbook') {
<div class="dav-card">
<div class="dav-toolbar dav-noprint">
<div class="dav-datepick">
<span>From</span>
<input type="date" [formControl]="fromDateControl" [max]="today" />
</div>
<div class="dav-datepick">
<span>To</span>
<input type="date" [formControl]="toDateControl" [max]="today" />
</div>
@if (hasDateFilters) {
<button class="dav-btn" (click)="clearDateFilters()"><fa-icon icon="times"></fa-icon> Clear</button>
}
<button class="dav-btn dav-btn--primary dav-push" (click)="printPassbook()">
<fa-icon icon="print"></fa-icon> Print
</button>
</div>
<div class="dav-passbook" id="dds-passbook">
<div class="dav-pb-head">
<div>
<div class="dav-pb-title">DDS Passbook</div>
<div class="dav-pb-sub">{{ account.officeName }}</div>
</div>
<div class="dav-pb-grid">
<span>Account No</span><strong class="dav-mono">{{ account.accountNo }}</strong> <span>Party Code</span
><strong>{{ account.partyCode }}</strong> <span>Name</span
><strong>{{ account.clientName || account.partyName }}</strong> <span>Product</span
><strong>{{ account.subType }} · {{ account.interestRate }} %</strong> <span>Opened On</span
><strong>{{ account.openingDate | date: 'dd/MM/yyyy' }}</strong> <span>Agent</span
><strong>{{ account.agentCode }}</strong>
</div>
</div>
@if (passbookEntries.length === 0) {
<div class="dav-state">
<span>{{ hasDateFilters ? 'No entries in these dates' : 'No entries yet' }}</span>
</div>
} @else {
<table class="dav-table dav-pb-table">
<thead>
<tr>
<th>Date</th>
<th>Voucher</th>
<th>Particulars</th>
<th class="dav-right">Withdrawals</th>
<th class="dav-right">Deposits</th>
<th class="dav-right">Balance</th>
</tr>
</thead>
<tbody>
@for (txn of passbookEntries; track txn.id) {
<tr>
<td>{{ txn.transactionDate | date: 'dd/MM/yyyy' }}</td>
<td class="dav-mono">{{ txn.voucherNo || '' }}</td>
<td>
{{ label(txn.transactionType) }}
@if (txn.description) {
<span class="dav-pb-note">{{ txn.description }}</span>
}
</td>
<td class="dav-right">{{ txn.direction === 'DEBIT' ? (txn.amount | formatNumber) : '' }}</td>
<td class="dav-right">{{ txn.direction === 'CREDIT' ? (txn.amount | formatNumber) : '' }}</td>
<td class="dav-right">
<strong>{{ txn.balanceAfterTransaction || 0 | formatNumber }}</strong>
</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="3"><strong>Total</strong></td>
<td class="dav-right">
<strong>{{ totalDebits | formatNumber }}</strong>
</td>
<td class="dav-right">
<strong>{{ totalCredits | formatNumber }}</strong>
</td>
<td class="dav-right">
<strong>{{ passbookClosingBalance | formatNumber }}</strong>
</td>
</tr>
</tfoot>
</table>
}
</div>
</div>
}
<!-- Nominees -->
@if (tab === 'nominees') {
<div class="dav-card">
@if (!account.nominees || account.nominees.length === 0) {
<div class="dav-state">
<fa-icon icon="user" size="2x"></fa-icon>
<span>No nominee on this account</span>
</div>
} @else {
<div class="dav-table-wrap">
<table class="dav-table">
<thead>
<tr>
<th>#</th>
<th>Name</th>
<th>Relation</th>
<th>Gender</th>
<th>Mobile</th>
<th>Birth Date</th>
<th>Nominated On</th>
<th>Guardian</th>
<th class="dav-right">{{ account.nomineeSplitMode === 'Sequence' ? 'Order' : 'Share %' }}</th>
</tr>
</thead>
<tbody>
@for (nominee of account.nominees; track nominee.id; let i = $index) {
<tr>
<td>{{ i + 1 }}</td>
<td>
<strong>{{ nominee.name }}</strong>
</td>
<td>{{ nominee.relation }}</td>
<td>{{ nominee.gender }}</td>
<td>{{ nominee.mobileNo || '—' }}</td>
<td>{{ nominee.birthDate ? (nominee.birthDate | date: 'dd MMM yyyy') : '—' }}</td>
<td>{{ nominee.nominationDate ? (nominee.nominationDate | date: 'dd MMM yyyy') : '—' }}</td>
<td>{{ nominee.guardianName || '—' }}</td>
<td class="dav-right">
{{ account.nomineeSplitMode === 'Sequence' ? nominee.sequenceNo : (nominee.sharePercent ?? '—') }}
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
}
}
</div>
@@ -0,0 +1,459 @@
/**
* 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/.
*/
/* One DDS account's page, laid out the way the savings account page is: a banner, tabs, cards. */
.dav-page {
display: flex;
flex-direction: column;
gap: 16px;
padding: 24px 32px;
}
.dav-mono {
font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', monospace;
font-style: normal;
}
.dav-cr {
color: #047857;
}
.dav-dr {
color: #b91c1c;
}
.dav-right {
text-align: right !important;
}
/* ── Banner ───────────────────────────────────────────────────────────── */
.dav-banner {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 16px;
padding: 16px 24px;
border-radius: 16px;
color: #fff;
background: linear-gradient(90deg, #1e3a8a 0%, #2d3fb4 55%, #5b47d6 100%);
box-shadow: 0 8px 24px rgb(30 58 138 / 25%);
}
.dav-banner__icon {
width: 48px;
height: 48px;
border-radius: 12px;
display: grid;
place-items: center;
font-size: 1.3rem;
background: rgb(255 255 255 / 16%);
border: 1px solid rgb(255 255 255 / 25%);
}
.dav-banner__text {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.dav-pill {
align-self: flex-start;
padding: 2px 10px;
border-radius: 999px;
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.04em;
background: rgb(16 185 129 / 25%);
color: #a7f3d0;
border: 1px solid rgb(167 243 208 / 40%);
}
.dav-pill--closed {
background: rgb(148 163 184 / 25%);
color: #e2e8f0;
border-color: rgb(226 232 240 / 40%);
}
.dav-banner__title {
margin: 0;
font-size: 1.25rem;
font-weight: 700;
}
.dav-banner__meta {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 6px 12px;
}
.dav-banner__label {
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: rgb(255 255 255 / 60%);
}
.dav-banner__value {
font-size: 0.9rem;
font-weight: 600;
}
.dav-banner__boxes {
margin-left: auto;
display: flex;
gap: 12px;
}
.dav-box {
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px 18px;
border-radius: 12px;
background: rgb(255 255 255 / 12%);
border: 1px solid rgb(255 255 255 / 25%);
}
.dav-box__label {
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: rgb(255 255 255 / 70%);
}
.dav-box__value {
font-size: 1.15rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.dav-banner__actions {
display: flex;
gap: 8px;
}
.dav-icon-btn {
width: 38px;
height: 38px;
border-radius: 50%;
border: 1px solid rgb(255 255 255 / 35%);
background: rgb(255 255 255 / 12%);
color: #fff;
cursor: pointer;
&:hover {
background: rgb(255 255 255 / 22%);
}
}
/* ── Tabs ─────────────────────────────────────────────────────────────── */
.dav-tabs {
display: flex;
gap: 8px;
padding: 8px;
border-radius: 14px;
background: #fff;
border: 1px solid rgb(0 0 0 / 7%);
}
.dav-tab {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 10px 16px;
border: 0;
border-radius: 10px;
background: transparent;
font-size: 0.95rem;
font-weight: 600;
color: #475569;
cursor: pointer;
&:hover {
background: #f1f5f9;
}
}
.dav-tab--on {
color: #fff;
background: linear-gradient(90deg, #1d4ed8, #2563eb);
box-shadow: 0 4px 12px rgb(37 99 235 / 30%);
&:hover {
background: linear-gradient(90deg, #1d4ed8, #2563eb);
}
}
/* ── Cards ────────────────────────────────────────────────────────────── */
.dav-columns {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(380px, 1fr));
gap: 16px;
align-items: start;
}
.dav-column {
display: flex;
flex-direction: column;
gap: 16px;
}
.dav-card {
border-radius: 14px;
background: #fff;
border: 1px solid rgb(0 0 0 / 7%);
overflow: hidden;
}
.dav-card__head {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 20px;
font-size: 1rem;
font-weight: 700;
background: #f8fafc;
border-bottom: 1px solid rgb(0 0 0 / 7%);
}
.dav-card__dot {
width: 30px;
height: 30px;
border-radius: 8px;
background: linear-gradient(135deg, #2563eb, #1e40af);
}
.dav-row {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 12px 20px;
border-bottom: 1px dashed rgb(0 0 0 / 7%);
font-size: 0.92rem;
&:last-child {
border-bottom: 0;
}
span {
color: #334155;
}
strong {
text-align: right;
font-weight: 600;
}
}
.dav-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 48px 16px;
color: rgb(0 0 0 / 50%);
}
/* ── Transactions ─────────────────────────────────────────────────────── */
.dav-toolbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
padding: 14px 20px;
background: #f8fafc;
border-bottom: 1px solid rgb(0 0 0 / 7%);
}
.dav-datepick {
display: flex;
flex-direction: column;
gap: 2px;
span {
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: rgb(0 0 0 / 45%);
}
input {
padding: 6px 10px;
border-radius: 8px;
border: 1px solid rgb(0 0 0 / 15%);
font: inherit;
}
}
.dav-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
border-radius: 8px;
border: 1px solid rgb(0 0 0 / 15%);
background: #fff;
font-weight: 600;
cursor: pointer;
&:disabled {
opacity: 0.5;
cursor: default;
}
}
.dav-btn--primary {
color: #fff;
border-color: transparent;
background: linear-gradient(90deg, #1d4ed8, #2563eb);
}
.dav-totals {
margin-left: auto;
display: flex;
gap: 16px;
font-size: 0.88rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.dav-table-wrap {
overflow-x: auto;
}
.dav-table {
width: 100%;
border-collapse: collapse;
font-size: 0.92rem;
th {
padding: 14px 16px;
text-align: left;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #475569;
border-bottom: 1px solid rgb(0 0 0 / 8%);
white-space: nowrap;
}
td {
padding: 16px;
border-bottom: 1px solid rgb(0 0 0 / 5%);
font-variant-numeric: tabular-nums;
}
tbody tr:nth-child(odd) {
background: #f8fafc;
}
}
.dav-desc {
max-width: 320px;
color: rgb(0 0 0 / 65%);
}
@media (width <= 720px) {
.dav-page {
padding: 16px;
}
.dav-banner__boxes {
margin-left: 0;
width: 100%;
}
.dav-tab {
font-size: 0.85rem;
padding: 8px;
}
}
/* ── Passbook ─────────────────────────────────────────────────────────── */
.dav-push {
margin-left: auto;
}
.dav-passbook {
padding: 20px;
}
.dav-pb-head {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
gap: 16px 32px;
margin-bottom: 16px;
padding-bottom: 16px;
border-bottom: 2px solid #1e3a8a;
}
.dav-pb-title {
font-size: 1.2rem;
font-weight: 700;
color: #1e3a8a;
}
.dav-pb-sub {
font-size: 0.85rem;
color: rgb(0 0 0 / 55%);
}
.dav-pb-grid {
display: grid;
grid-template-columns: auto auto;
gap: 4px 16px;
font-size: 0.88rem;
span {
color: rgb(0 0 0 / 55%);
}
}
.dav-pb-table {
tfoot td {
padding: 14px 16px;
border-top: 2px solid rgb(0 0 0 / 15%);
}
}
.dav-pb-note {
display: block;
font-size: 0.78rem;
color: rgb(0 0 0 / 50%);
}
/* Closure tab */
.dav-closure-tag {
margin-left: 8px;
padding: 1px 8px;
border-radius: 999px;
background: #fff3e0;
color: #e65100;
font-size: 0.75rem;
font-style: normal;
}
.dav-closure-total {
border-top: 1px solid rgb(0 0 0 / 10%);
font-weight: 600;
}
@@ -0,0 +1,297 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { NgClass } from '@angular/common';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { forkJoin, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FormatNumberPipe } from 'app/pipes/format-number.pipe';
import { OrganizationService } from 'app/organization/organization.service';
/** What each DDS transaction type is called on screen. */
const TRANSACTION_LABELS: Record<string, string> = {
PARTY_TRAN: 'Collection',
INTEREST_POST: 'Interest',
OD_INTEREST_POST: 'OD Interest',
MATURITY_PAYOUT: 'Maturity Payout',
PREMATURE_PAYOUT: 'Premature Payout',
PREMATURE_INT_RECOVERY: 'Interest Recovered (Premature)',
PREMATURE_AGENT_TOP_UP: 'Interest Funded by Agent',
WITHDRAW_DEPOSITE: 'Transfer (Withdraw Deposite)',
TRANSFER: 'Transfer'
};
/**
* One DDS account and everything that has moved on it, as the savings account page shows a savings
* account: the account's terms and where it stands, then its transactions with the balance after
* each, narrowed to a day or a range.
*
* Read-only. The account is changed from DDS Party Master; its money moves through collections, the
* cashier and the batch runs.
*/
@Component({
selector: 'mifosx-dds-account-view',
templateUrl: './dds-account-view.component.html',
styleUrls: ['./dds-account-view.component.scss'],
standalone: true,
imports: [
...STANDALONE_SHARED_IMPORTS,
ReactiveFormsModule,
NgClass,
FaIconComponent,
MatTooltipModule,
MatProgressSpinnerModule,
FormatNumberPipe
]
})
export class DdsAccountViewComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private organizationService = inject(OrganizationService);
isLoading = true;
account: any = null;
private transactions: any[] = [];
/** The page's tabs, as the savings account page has them - only those that apply to DDS. */
tab: 'general' | 'transactions' | 'passbook' | 'nominees' | 'closure' = 'general';
/** How a closed account was closed - read only for closed accounts, for the Closure tab. */
closure: any = null;
/** What each kind of closure is called on screen. */
readonly closureLabels: Record<string, string> = {
PREMATURE: 'Premature Close',
MATURITY_PAYOUT_AND_CLOSE: 'Maturity Payout & Close',
MATURITY_PAYOUT_THEN_CLOSE: 'Maturity Payout, then Closed',
MATURITY_AUTO_CLOSE: 'Maturity Auto Close (night job)',
CLOSED_NIL_BALANCE: 'Closed with nil balance'
};
/** The passbook reads oldest first, as it is written up. */
get passbookEntries(): any[] {
return [...this.shownTransactions].reverse();
}
/** The balance after the last entry shown. */
get passbookClosingBalance(): number {
const entries = this.passbookEntries;
return entries.length > 0 ? Number(entries[entries.length - 1].balanceAfterTransaction || 0) : 0;
}
/**
* Prints the passbook alone. It is copied into a window of its own with only its own styles, so the
* page's menus, banner and tabs do not come out on the paper.
*/
printPassbook(): void {
const passbook = document.getElementById('dds-passbook');
const popup = window.open('', '_blank', 'width=900,height=700');
if (!passbook || !popup) {
return;
}
popup.document.write(`<html><head><title>${this.account?.accountNo || 'DDS'} Passbook</title><style>
body { font-family: Arial, sans-serif; margin: 24px; color: #111; }
.dav-pb-head { display: flex; justify-content: space-between; gap: 24px; margin-bottom: 16px; }
.dav-pb-title { font-size: 20px; font-weight: 700; }
.dav-pb-sub { color: #555; }
.dav-pb-grid { display: grid; grid-template-columns: auto auto; gap: 2px 12px; font-size: 13px; }
.dav-pb-grid span { color: #555; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th, td { border: 1px solid #999; padding: 6px 8px; text-align: left; }
.dav-right { text-align: right; }
.dav-pb-note { display: block; font-size: 11px; color: #555; }
</style></head><body>${passbook.innerHTML}</body></html>`);
popup.document.close();
popup.focus();
popup.print();
}
fromDateControl = new FormControl('');
toDateControl = new FormControl('');
readonly today = new Date().toISOString().split('T')[0];
ngOnInit(): void {
const partyId = this.route.snapshot.paramMap.get('partyId');
if (!partyId) {
this.isLoading = false;
return;
}
forkJoin({
account: this.organizationService.getParty(partyId).pipe(catchError(() => of(null))),
transactions: this.organizationService.getPartyTransactionsOfParty(partyId).pipe(catchError(() => of([])))
}).subscribe(({ account, transactions }) => {
this.account = account;
if (account?.status === 'CLOSED') {
this.organizationService.getPartyClosure(partyId).subscribe({
next: (closure: any) => (this.closure = closure),
error: () => (this.closure = null)
});
}
// Newest first, and within a day the later entry first - the order a passbook is read back in.
this.transactions = (Array.isArray(transactions) ? transactions : []).sort(
(a: any, b: any) =>
String(b.transactionDate).localeCompare(String(a.transactionDate)) || Number(b.id) - Number(a.id)
);
this.isLoading = false;
});
}
/** The transactions in the chosen range - all of them when no range is set. */
get shownTransactions(): any[] {
const from = this.fromDateControl.value;
const to = this.toDateControl.value;
return this.transactions.filter((txn: any) => {
const day = String(txn.transactionDate || '');
if (from && day < String(from)) {
return false;
}
if (to && day > String(to)) {
return false;
}
return true;
});
}
get totalCredits(): number {
return this.sumOf('CREDIT');
}
get totalDebits(): number {
return this.sumOf('DEBIT');
}
private sumOf(direction: string): number {
return this.shownTransactions
.filter((txn: any) => txn.direction === direction)
.reduce((sum: number, txn: any) => sum + Number(txn.amount || 0), 0);
}
/** Everything of one type ever credited, whatever the date filter says - for the summary cards. */
totalOf(type: string): number {
return this.transactions
.filter((txn: any) => txn.transactionType === type && txn.direction === 'CREDIT')
.reduce((sum: number, txn: any) => sum + Number(txn.amount || 0), 0);
}
/** Interest credited, less any taken back on an early closure. */
get interestEarned(): number {
const recovered = this.transactions
.filter((txn: any) => txn.transactionType === 'PREMATURE_INT_RECOVERY')
.reduce((sum: number, txn: any) => sum + Number(txn.amount || 0), 0);
return (
this.totalOf('INTEREST_POST') +
this.totalOf('OD_INTEREST_POST') +
this.totalOf('PREMATURE_AGENT_TOP_UP') -
recovered
);
}
/** Money that has left the account - payouts, withdrawals, transfers out. */
get totalDebitsAll(): number {
return this.transactions
.filter((txn: any) => txn.direction === 'DEBIT' && txn.transactionType !== 'PREMATURE_INT_RECOVERY')
.reduce((sum: number, txn: any) => sum + Number(txn.amount || 0), 0);
}
get lastTransactionDate(): string | null {
return this.transactions.length > 0 ? this.transactions[0].transactionDate : null;
}
/** The rows on screen, as a spreadsheet the branch can keep or send on. */
exportCsv(): void {
const header = [
'#',
'ID',
'Transaction Date',
'Transaction Type',
'Voucher',
'Description',
'Account',
'Debit',
'Credit',
'Balance'
];
const quote = (value: any) => `"${String(value ?? '').replace(/"/g, '""')}"`;
const lines = this.shownTransactions.map((txn: any, index: number) =>
[
index + 1,
txn.id,
txn.transactionDate,
this.label(txn.transactionType),
txn.voucherNo,
txn.description,
txn.accountNo,
txn.direction === 'DEBIT' ? txn.amount : '',
txn.direction === 'CREDIT' ? txn.amount : '',
txn.balanceAfterTransaction
]
.map(quote)
.join(',')
);
const blob = new Blob(
[[
header.map(quote).join(','),
...lines
].join('\r\n')],
{ type: 'text/csv;charset=utf-8' }
);
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `${this.account?.accountNo || 'dds-account'}-transactions.csv`;
link.click();
URL.revokeObjectURL(link.href);
}
get hasDateFilters(): boolean {
return !!(this.fromDateControl.value || this.toDateControl.value);
}
showToday(): void {
this.fromDateControl.setValue(this.today);
this.toDateControl.setValue(this.today);
}
clearDateFilters(): void {
this.fromDateControl.setValue('');
this.toDateControl.setValue('');
}
label(type: string): string {
return TRANSACTION_LABELS[type] || String(type || '').replace(/_/g, ' ');
}
statusClass(): string {
switch (this.account?.status) {
case 'ACTIVE':
return 'status--active';
case 'CLOSED':
return 'status--closed';
default:
return 'status--default';
}
}
back(): void {
this.router.navigate(['/accounts/dds-accounts']);
}
openInPartyMaster(): void {
this.router.navigate([
'/accounts/dds',
this.account.id,
'edit'
]);
}
}
@@ -0,0 +1,211 @@
<!--
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="acl-page">
<!-- Header -->
<div class="acl-header accent--rd">
<div class="acl-header__icon">
<fa-icon icon="coins"></fa-icon>
</div>
<div class="acl-header__text">
<h2 class="acl-header__title">DDS Accounts</h2>
<span class="acl-header__count">
@if (isLoading) {
Loading...
} @else {
{{ allCount }} accounts · {{ activeCount }} active · {{ closedCount }} closed
}
</span>
</div>
</div>
<!-- Search Bar -->
<div class="acl-search-wrap">
<div class="acl-search">
<fa-icon icon="search" class="acl-search__icon"></fa-icon>
<input
[formControl]="searchControl"
class="acl-search__input"
[placeholder]="
searchType === 'accountNo' ? 'Search by account number or party code…' : 'Search by client name…'
"
/>
@if (searchControl.value) {
<button class="acl-search__clear" (click)="clearSearch()">
<fa-icon icon="times"></fa-icon>
</button>
}
</div>
<button
class="acl-btn acl-btn--toggle"
(click)="toggleSearchType()"
[matTooltip]="'Switch to ' + (searchType === 'accountNo' ? 'client name' : 'account number') + ' search'"
>
<fa-icon icon="exchange-alt"></fa-icon>
{{ searchType === 'accountNo' ? 'Acct No' : 'Client' }}
</button>
<button class="acl-btn acl-btn--icon" (click)="loadAccounts()" matTooltip="Refresh">
<fa-icon icon="sync-alt"></fa-icon>
</button>
</div>
<!-- Status: all accounts, only the open ones, or only the closed -->
<div class="dds-status-filter" role="tablist">
<button
type="button"
class="dds-status-filter__btn"
[class.is-on]="statusFilter === 'ALL'"
(click)="setStatusFilter('ALL')"
>
All <span class="dds-status-filter__count">{{ allCount }}</span>
</button>
<button
type="button"
class="dds-status-filter__btn dds-status-filter__btn--active"
[class.is-on]="statusFilter === 'ACTIVE'"
(click)="setStatusFilter('ACTIVE')"
>
Active <span class="dds-status-filter__count">{{ activeCount }}</span>
</button>
<button
type="button"
class="dds-status-filter__btn dds-status-filter__btn--closed"
[class.is-on]="statusFilter === 'CLOSED'"
(click)="setStatusFilter('CLOSED')"
>
Closed <span class="dds-status-filter__count">{{ closedCount }}</span>
</button>
</div>
<!-- Date Filters: by the day the account was opened -->
<div class="acl-filters">
<div class="acl-filters__group">
<div class="acl-datepick">
<span class="acl-datepick__label">Opened From</span>
<input type="date" class="acl-datepick__input" [formControl]="fromDateControl" [max]="today" />
</div>
<div class="acl-filters__arrow">
<fa-icon icon="arrow-right"></fa-icon>
</div>
<div class="acl-datepick">
<span class="acl-datepick__label">To</span>
<input type="date" class="acl-datepick__input" [formControl]="toDateControl" [max]="today" />
</div>
</div>
<div class="acl-filters__sep"></div>
<div class="acl-datepick acl-datepick--ason">
<span class="acl-datepick__label">
<fa-icon icon="clock"></fa-icon>
As On Date
</span>
<input type="date" class="acl-datepick__input" [formControl]="asOnDateControl" [max]="today" />
</div>
@if (hasDateFilters) {
<button class="acl-filters__clear" (click)="clearDateFilters()">
<fa-icon icon="times"></fa-icon>
Clear
</button>
}
</div>
<!-- Table -->
<div class="acl-card">
@if (isLoading) {
<div class="acl-state acl-state--loading">
<mat-spinner diameter="32"></mat-spinner>
<span>Loading accounts…</span>
</div>
}
@if (!isLoading && rows.length === 0) {
<div class="acl-state acl-state--empty">
<div class="acl-state__icon">
<fa-icon icon="inbox"></fa-icon>
</div>
<p class="acl-state__title">No DDS accounts found</p>
<p class="acl-state__sub">Try adjusting your search</p>
</div>
}
@if (!isLoading && rows.length > 0) {
<div class="acl-table-wrap">
<table mat-table [dataSource]="rows" class="acl-table">
<ng-container matColumnDef="accountNo">
<th mat-header-cell *matHeaderCellDef class="acl-th">Account No</th>
<td mat-cell *matCellDef="let row" class="acl-td">
<span class="acl-acct-no">{{ row.accountNo }}</span>
</td>
</ng-container>
<ng-container matColumnDef="depositType">
<th mat-header-cell *matHeaderCellDef class="acl-th acl-th--center">Type</th>
<td mat-cell *matCellDef="let row" class="acl-td acl-td--center">
<span class="acl-type-badge dtype--rd">DDS</span>
</td>
</ng-container>
<ng-container matColumnDef="clientName">
<th mat-header-cell *matHeaderCellDef class="acl-th">Client</th>
<td mat-cell *matCellDef="let row" class="acl-td">
<div class="acl-client">
<div class="acl-client__avatar">{{ (row.clientName || row.partyName || '?')[0] }}</div>
<span class="acl-client__name">{{ row.clientName || row.partyName }}</span>
</div>
</td>
</ng-container>
<ng-container matColumnDef="productName">
<th mat-header-cell *matHeaderCellDef class="acl-th">Product</th>
<td mat-cell *matCellDef="let row" class="acl-td">
<span class="acl-product">{{ row.subType }}</span>
</td>
</ng-container>
<ng-container matColumnDef="balance">
<th mat-header-cell *matHeaderCellDef class="acl-th acl-th--right">Balance</th>
<td mat-cell *matCellDef="let row" class="acl-td acl-td--right">
<span class="acl-balance">₹ {{ row.currentBalance || 0 | formatNumber }}</span>
</td>
</ng-container>
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef class="acl-th acl-th--center">Status</th>
<td mat-cell *matCellDef="let row" class="acl-td acl-td--center">
<span class="acl-status" [ngClass]="getStatusClass(row)">{{ row.status }}</span>
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef class="acl-th acl-th--center">Actions</th>
<td mat-cell *matCellDef="let row" class="acl-td acl-td--center">
<button class="acl-view-btn" (click)="$event.stopPropagation(); viewAccount(row)" matTooltip="View">
<fa-icon icon="eye"></fa-icon>
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns" class="acl-header-row"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns" class="acl-row" (click)="viewAccount(row)"></tr>
</table>
</div>
<mat-paginator
[length]="totalRecords"
[pageSize]="pageSize"
[pageIndex]="pageIndex"
[pageSizeOptions]="[10, 20, 50, 100]"
(page)="onPageChange($event)"
class="acl-paginator"
showFirstLastButtons
></mat-paginator>
}
</div>
</div>
@@ -0,0 +1,56 @@
/**
* 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/.
*/
/* All / Active / Closed - which DDS accounts the list shows. */
.dds-status-filter {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 0 0 12px;
&__btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 14px;
border: 1px solid rgb(0 0 0 / 12%);
border-radius: 999px;
background: #fff;
color: rgb(0 0 0 / 70%);
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
&.is-on {
border-color: #1565c0;
background: #e3f2fd;
color: #0d47a1;
}
&--active.is-on {
border-color: #2e7d32;
background: #e8f5e9;
color: #1b5e20;
}
&--closed.is-on {
border-color: #c62828;
background: #ffebee;
color: #b71c1c;
}
}
&__count {
min-width: 22px;
padding: 0 6px;
border-radius: 999px;
background: rgb(0 0 0 / 8%);
font-size: 0.75rem;
text-align: center;
}
}
@@ -0,0 +1,265 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnDestroy, OnInit, inject } from '@angular/core';
import { Router } from '@angular/router';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { NgClass } from '@angular/common';
import { MatPaginator, PageEvent } from '@angular/material/paginator';
import {
MatCell,
MatCellDef,
MatColumnDef,
MatHeaderCell,
MatHeaderCellDef,
MatHeaderRow,
MatHeaderRowDef,
MatRow,
MatRowDef,
MatTable
} from '@angular/material/table';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { Subject, merge } from 'rxjs';
import { debounceTime, distinctUntilChanged, takeUntil } from 'rxjs/operators';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FormatNumberPipe } from 'app/pipes/format-number.pipe';
import { OrganizationService } from 'app/organization/organization.service';
/**
* The DDS accounts, listed the way Savings Accounts lists savings: account number, client, product,
* balance and status, searched by account number or client and narrowed by opening date. Each opens
* onto the account's own page and its transactions.
*
* Read-only. Accounts are opened and changed from DDS Party Master; money moves through collections,
* the cashier and the batch runs.
*/
@Component({
selector: 'mifosx-dds-accounts',
templateUrl: './dds-accounts.component.html',
// The Savings Accounts list's own look, so the two screens read as one family.
styleUrls: [
'../../accounts/accounts-list/accounts-list.component.scss',
'./dds-accounts.component.scss'
],
standalone: true,
imports: [
...STANDALONE_SHARED_IMPORTS,
ReactiveFormsModule,
NgClass,
FaIconComponent,
MatTable,
MatColumnDef,
MatHeaderCellDef,
MatHeaderCell,
MatCellDef,
MatCell,
MatHeaderRowDef,
MatHeaderRow,
MatRowDef,
MatRow,
MatPaginator,
MatTooltipModule,
MatProgressSpinnerModule,
FormatNumberPipe
]
})
export class DdsAccountsComponent implements OnInit, OnDestroy {
private organizationService = inject(OrganizationService);
private router = inject(Router);
private destroy$ = new Subject<void>();
displayedColumns = [
'accountNo',
'depositType',
'clientName',
'productName',
'balance',
'status',
'actions'
];
/** Every DDS account, fetched once; search, dates and paging work on this. */
private allAccounts: any[] = [];
/** The accounts matching the search and dates, whatever their status - what the status counts read. */
private beforeStatus: any[] = [];
/** The accounts matching the search, dates and status, before paging. */
private matching: any[] = [];
/** Which accounts are shown: all of them, only the open ones, or only the closed. */
statusFilter: 'ALL' | 'ACTIVE' | 'CLOSED' = 'ALL';
/** The page on screen. */
rows: any[] = [];
pageSize = 20;
pageIndex = 0;
isLoading = false;
searchControl = new FormControl('');
searchType: 'accountNo' | 'clientName' = 'accountNo';
fromDateControl = new FormControl('');
toDateControl = new FormControl('');
asOnDateControl = new FormControl('');
readonly today = new Date().toISOString().split('T')[0];
get totalRecords(): number {
return this.matching.length;
}
get activeCount(): number {
return this.beforeStatus.filter((account) => account.status === 'ACTIVE').length;
}
get closedCount(): number {
return this.beforeStatus.filter((account) => account.status === 'CLOSED').length;
}
get allCount(): number {
return this.beforeStatus.length;
}
setStatusFilter(status: 'ALL' | 'ACTIVE' | 'CLOSED'): void {
this.statusFilter = status;
this.pageIndex = 0;
this.applyFilters();
}
get hasDateFilters(): boolean {
return !!((this.fromDateControl.value && this.toDateControl.value) || this.asOnDateControl.value);
}
ngOnInit(): void {
this.loadAccounts();
this.searchControl.valueChanges
.pipe(debounceTime(300), distinctUntilChanged(), takeUntil(this.destroy$))
.subscribe(() => {
this.pageIndex = 0;
this.applyFilters();
});
merge(this.fromDateControl.valueChanges, this.toDateControl.valueChanges, this.asOnDateControl.valueChanges)
.pipe(debounceTime(300), takeUntil(this.destroy$))
.subscribe(() => {
this.pageIndex = 0;
this.applyFilters();
});
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
loadAccounts(): void {
this.isLoading = true;
this.organizationService
.getParties()
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (parties: any) => {
// Newest first, as the savings list shows them.
this.allAccounts = (Array.isArray(parties) ? parties : parties?.pageItems || []).sort(
(a: any, b: any) => Number(b.id) - Number(a.id)
);
this.isLoading = false;
this.applyFilters();
},
error: () => {
this.allAccounts = [];
this.isLoading = false;
this.applyFilters();
}
});
}
/** Search by account number or client, and by the day the account was opened. */
private applyFilters(): void {
const term = String(this.searchControl.value || '')
.trim()
.toLowerCase();
const fromVal = this.fromDateControl.value;
const toVal = this.toDateControl.value;
const bothDates = !!(fromVal && toVal);
const asOn = this.asOnDateControl.value;
this.beforeStatus = this.allAccounts.filter((account: any) => {
if (term) {
const haystack =
this.searchType === 'accountNo'
? `${account.accountNo || ''} ${account.partyCode || ''}`
: `${account.clientName || ''} ${account.partyName || ''}`;
if (!haystack.toLowerCase().includes(term)) {
return false;
}
}
const opened = String(account.openingDate || '');
if (bothDates && opened && (opened < String(fromVal) || opened > String(toVal))) {
return false;
}
// As on a date: the accounts that had been opened by then.
if (asOn && opened && opened > String(asOn)) {
return false;
}
return true;
});
this.matching =
this.statusFilter === 'ALL'
? this.beforeStatus
: this.beforeStatus.filter((account: any) => account.status === this.statusFilter);
this.showPage();
}
private showPage(): void {
const start = this.pageIndex * this.pageSize;
this.rows = this.matching.slice(start, start + this.pageSize);
}
onPageChange(event: PageEvent): void {
this.pageIndex = event.pageIndex;
this.pageSize = event.pageSize;
this.showPage();
}
toggleSearchType(): void {
this.searchType = this.searchType === 'accountNo' ? 'clientName' : 'accountNo';
this.pageIndex = 0;
this.applyFilters();
}
clearSearch(): void {
this.searchControl.setValue('');
}
clearDateFilters(): void {
this.fromDateControl.setValue('', { emitEvent: false });
this.toDateControl.setValue('', { emitEvent: false });
this.asOnDateControl.setValue('', { emitEvent: false });
this.pageIndex = 0;
this.applyFilters();
}
viewAccount(account: any): void {
this.router.navigate([
'/accounts/dds-accounts',
account.id
]);
}
getStatusClass(account: any): string {
switch (account?.status) {
case 'ACTIVE':
return 'status--active';
case 'CLOSED':
return 'status--closed';
default:
return 'status--default';
}
}
}
@@ -0,0 +1,46 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { OrganizationService } from 'app/organization/organization.service';
import { AlertService } from 'app/core/alert/alert.service';
/**
* Whether the server held a DDS entry for a checker rather than carrying it out. The reply then
* names the entry (commandId) and asks for the work to be rolled back, and carries no result of its own.
*/
export function heldForApproval(response: any): boolean {
return !!response?.rollbackTransaction || (!!response?.commandId && response?.resourceId == null);
}
/**
* Tells the clerk their entry went to a checker - with the voucher it will move under - instead of
* saying it was done. Nothing has moved yet, and a message that read as though it had would send them
* looking for money that is still waiting on a manager.
*/
export function announceHeldForApproval(
response: any,
what: string,
organizationService: OrganizationService,
alertService: AlertService
): void {
const say = (voucherNo?: string) =>
alertService.alert({
type: 'Sent for Approval',
message:
`${what} sent for approval${voucherNo ? ' under voucher ' + voucherNo : ''}. ` +
'It takes effect once a checker approves it in Approval Center › DDS Approvals.'
});
if (!response?.commandId) {
say();
return;
}
organizationService.getDdsApproval(response.commandId).subscribe({
next: (entry: any) => say(entry?.voucherNo),
error: () => say()
});
}
@@ -0,0 +1,265 @@
<!--
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/.
-->
<!-- Laid out as Disbursement & Transfers reviews its requests, and styled by the same sheet. -->
<div class="dri dri--dialog">
<nav class="cashier-nav cashier-nav--clerk">
<span class="dri-header-title">DDS {{ entry.type }} Review</span>
<span class="cashier-nav__esc-hint" (click)="close()">✕ Close</span>
</nav>
@if (loading) {
<div class="dri-loading">
<mat-icon class="dri-loading__icon">hourglass_empty</mat-icon>
<span>Loading entry…</span>
</div>
} @else {
<div class="dri-body">
<!-- Header card: the voucher it moves under, its status, who made it and when -->
<div class="dri-card dri-card--header">
<div class="dri-card-header">
<div class="dri-card-header__left">
<span class="dri-voucher">{{ voucherNo || '—' }}</span>
<span class="dri-status" [ngClass]="statusClass(entry.status)">
{{ entry.status }}{{ entry.voucherCancelled ? ' · VOUCHER CANCELLED' : '' }}
</span>
</div>
<div class="dri-card-header__meta">
<span class="dri-meta-item">
<mat-icon>person</mat-icon>
{{ entry.makerName || entry.maker }} ({{ entry.maker }})
</span>
<span class="dri-meta-item">
<mat-icon>schedule</mat-icon>
{{ formatDateTime(entry.madeOn) }}
</span>
@if (entry.status !== 'PENDING' && entry.checker) {
<span class="dri-meta-item">
<mat-icon>check_circle</mat-icon>
Actioned by: {{ entry.checkerName || entry.checker }}
</span>
}
</div>
</div>
</div>
<!-- Summary -->
<div class="dri-card">
<div class="dri-section-title">
<mat-icon>swap_horiz</mat-icon>
{{ entry.type }} Summary
</div>
<div class="dri-detail-grid">
<div class="dri-detail-item">
<span class="dri-detail-label">Total Amount</span>
<span class="dri-detail-value dri-amount">
{{ entry.amount !== null && entry.amount !== undefined ? '₹' + (entry.amount | number: '1.2-2') : '—' }}
</span>
</div>
<div class="dri-detail-item">
<span class="dri-detail-label">DR Accounts</span>
<span class="dri-detail-value"
>{{ debitLines.length }} account{{ debitLines.length !== 1 ? 's' : '' }}</span
>
</div>
<div class="dri-detail-item">
<span class="dri-detail-label">CR Accounts</span>
<span class="dri-detail-value"
>{{ creditLines.length }} account{{ creditLines.length !== 1 ? 's' : '' }}</span
>
</div>
<div class="dri-detail-item dri-detail-item--full">
<span class="dri-detail-label">Details</span>
<span class="dri-detail-value">{{ entry.details || '—' }}</span>
</div>
@if (detail?.description) {
<div class="dri-detail-item dri-detail-item--full">
<span class="dri-detail-label">Description</span>
<span class="dri-detail-value">{{ detail.description }}</span>
</div>
}
</div>
</div>
<!-- Debit entries -->
@if (debitLines.length) {
<div class="dri-card">
<div class="dri-section-title">
<mat-icon>arrow_upward</mat-icon>
Debit Entries ({{ debitLines.length }} account{{ debitLines.length !== 1 ? 's' : '' }} — funds out)
</div>
<table class="dri-cr-table">
<thead>
<tr>
<th>#</th>
<th>Account No</th>
<th>Account Name</th>
<th>Type</th>
<th class="num">Amount (DR)</th>
</tr>
</thead>
<tbody>
@for (line of debitLines; track $index; let i = $index) {
<tr>
<td class="dri-cr-idx">{{ i + 1 }}</td>
<td class="cell-mono">{{ line.accountNo || '—' }}</td>
<td>{{ line.accountName || '—' }}</td>
<td>
<span class="dri-type-badge">{{ line.type || '—' }}</span>
</td>
<td class="num dri-cr-amount">₹{{ line.amount | number: '1.2-2' }}</td>
</tr>
}
<tr class="dri-cr-total-row">
<td colspan="4" class="dri-cr-total-label">Total Debit</td>
<td class="num dri-cr-total-amount">₹{{ total(debitLines) | number: '1.2-2' }}</td>
</tr>
</tbody>
</table>
</div>
}
<!-- Credit entries -->
@if (creditLines.length) {
<div class="dri-card">
<div class="dri-section-title">
<mat-icon>arrow_downward</mat-icon>
Credit Entries ({{ creditLines.length }} account{{ creditLines.length !== 1 ? 's' : '' }} — funds in)
</div>
<table class="dri-cr-table">
<thead>
<tr>
<th>#</th>
<th>Account No</th>
<th>Account Name</th>
<th>Type</th>
<th class="num">Amount (CR)</th>
</tr>
</thead>
<tbody>
@for (line of creditLines; track $index; let i = $index) {
<tr>
<td class="dri-cr-idx">{{ i + 1 }}</td>
<td class="cell-mono">{{ line.accountNo || '—' }}</td>
<td>{{ line.accountName || '—' }}</td>
<td>
<span class="dri-type-badge">{{ line.type || '—' }}</span>
</td>
<td class="num dri-cr-amount">₹{{ line.amount | number: '1.2-2' }}</td>
</tr>
}
<tr class="dri-cr-total-row">
<td colspan="4" class="dri-cr-total-label">Total Credit</td>
<td class="num dri-cr-total-amount">₹{{ total(creditLines) | number: '1.2-2' }}</td>
</tr>
</tbody>
</table>
</div>
}
@if (entry.status === 'PENDING' && isMine && !done) {
<div class="dri-card dri-card--processing">
<div class="dri-section-title">
<mat-icon>info</mat-icon>
Your own entry
</div>
<p class="dri-processing-text">
You made this entry, so another user with checker rights has to approve or reject it.
</p>
</div>
}
@if (entry.status === 'REJECTED') {
<div class="dri-card dri-card--rejected">
<div class="dri-section-title">
<mat-icon>cancel</mat-icon>
Rejected
</div>
<p class="dri-rejection-text">
Nothing was done{{ voucherNo ? '. Voucher ' + voucherNo + ' is cancelled and will not be used' : '' }}.
</p>
@if (entry.checker) {
<div class="dri-meta-item" style="margin-top: 8px">
<mat-icon>manage_accounts</mat-icon>
{{ entry.checker }}
</div>
}
</div>
}
@if (entry.status === 'APPROVED') {
<div class="dri-card dri-card--approved">
<div class="dri-section-title">
<mat-icon>check_circle</mat-icon>
Approved and carried out
</div>
<p class="dri-txn-refs cell-mono">
{{
voucherNo
? 'Voucher ' + voucherNo
: entry.entity === 'DDSBATCH'
? 'Posting is running - refresh for its voucher'
: '—'
}}
</p>
<div class="dri-meta-item" style="margin-top: 8px">
<mat-icon>manage_accounts</mat-icon>
Approved by: {{ entry.checker || '—' }}
</div>
</div>
}
@if (error) {
<div class="dri-card dri-card--failed">
<div class="dri-section-title">
<mat-icon>error</mat-icon>
Could not complete
</div>
<p class="dri-rejection-text">{{ error }}</p>
</div>
}
@if (entry.status === 'PENDING' && !done) {
<div class="dri-actions">
@if (!confirmReject) {
<button type="button" class="dri-btn dri-btn--approve" [disabled]="!canAct" (click)="act('approve')">
<mat-icon>check_circle</mat-icon>
{{ working ? 'Processing…' : 'Approve ' + entry.type }}
</button>
<button type="button" class="dri-btn dri-btn--reject" [disabled]="!canAct" (click)="confirmReject = true">
<mat-icon>cancel</mat-icon>
Reject
</button>
} @else {
<div class="dri-reject-form">
<label class="dri-reject-label">
Reject this entry? Nothing will be done{{
voucherNo ? ' and voucher ' + voucherNo + ' will be cancelled' : ''
}}.
</label>
<div class="dri-reject-actions">
<button type="button" class="dri-btn dri-btn--reject" [disabled]="!canAct" (click)="act('reject')">
<mat-icon>cancel</mat-icon>
{{ working ? 'Processing…' : 'Confirm Rejection' }}
</button>
<button
type="button"
class="dri-btn dri-btn--ghost"
[disabled]="working"
(click)="confirmReject = false"
>
Cancel
</button>
</div>
</div>
}
</div>
}
</div>
}
</div>
@@ -0,0 +1,148 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { OrganizationService } from 'app/organization/organization.service';
/**
* One DDS entry, laid out the way Disbursement & Transfers reviews a request - voucher and status on
* top, who made it and when, then the debit and credit entries it moves - and styled by the same
* sheet, so a checker reads both queues the same way.
*
* The checker approves it, which carries it out under the voucher numbered when it was sent, or
* rejects it, which leaves nothing done and cancels that number. Neither is offered to the user who
* made the entry, and the server refuses it as well.
*/
@Component({
selector: 'mifosx-dds-approval-review',
templateUrl: './dds-approval-review.component.html',
styleUrls: ['../../../clerk/disbursement-review/disbursement-review.component.scss'],
standalone: true,
imports: [
CommonModule,
MatDialogModule,
MatIconModule
]
})
export class DdsApprovalReviewComponent implements OnInit {
private organizationService = inject(OrganizationService);
private dialogRef = inject(MatDialogRef<DdsApprovalReviewComponent>);
data = inject<any>(MAT_DIALOG_DATA);
entry: any = this.data.entry;
/** The entry in full, with the debit and credit sides it moves. */
detail: any = null;
loading = true;
working = false;
confirmReject = false;
done = false;
error = '';
/** Set by an approval, in case the list row did not carry the number yet. */
approvedVoucher = '';
ngOnInit(): void {
this.load();
}
private load(): void {
this.organizationService.getDdsApproval(this.entry.id).subscribe({
next: (detail: any) => {
this.detail = detail;
this.entry = { ...this.entry, ...detail };
this.loading = false;
},
error: () => (this.loading = false)
});
}
get voucherNo(): string {
return this.approvedVoucher || this.entry?.voucherNo || '';
}
get debitLines(): any[] {
return (this.detail?.lines || []).filter((line: any) => line.side === 'DR');
}
get creditLines(): any[] {
return (this.detail?.lines || []).filter((line: any) => line.side === 'CR');
}
total(lines: any[]): number {
return lines.reduce((sum: number, line: any) => sum + Number(line.amount || 0), 0);
}
get isMine(): boolean {
return !!this.data.currentUserId && Number(this.entry.makerId) === Number(this.data.currentUserId);
}
get canAct(): boolean {
return this.entry.status === 'PENDING' && !this.isMine && !this.done && !this.working;
}
statusClass(status: string): string {
const map: Record<string, string> = {
PENDING: 'dri-status--pending',
APPROVED: 'dri-status--approved',
REJECTED: 'dri-status--rejected'
};
return map[status] ?? '';
}
act(command: 'approve' | 'reject'): void {
this.working = true;
this.error = '';
this.organizationService.actOnDdsApproval(this.entry.id, command).subscribe({
next: (result: any) => {
this.working = false;
this.done = true;
this.approvedVoucher = result?.voucherNo || '';
this.entry = {
...this.entry,
status: command === 'approve' ? 'APPROVED' : 'REJECTED',
voucherCancelled: command === 'reject' && !!this.voucherNo
};
// The approved entry's sides are now the voucher's own lines.
this.load();
},
error: (err: any) => {
this.working = false;
const body = err?.error;
this.error =
body?.errors?.[0]?.defaultUserMessage ||
body?.defaultUserMessage ||
body?.developerMessage ||
'Could not complete this.';
}
});
}
/** The same form Disbursement & Transfers prints its times in: 29 Sept 2026, 01:25 pm. */
formatDateTime(val: any): string {
if (!val) {
return '—';
}
const d = new Date(String(val).replace(' ', 'T'));
return isNaN(d.getTime())
? String(val)
: d.toLocaleString('en-IN', {
day: '2-digit',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
close(): void {
this.dialogRef.close(this.done);
}
}
@@ -0,0 +1,176 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="cv-tab-content wri-tab">
<div class="wri-action-bar">
<div class="wri-action-bar__left">
<mat-icon class="wri-action-bar__icon">savings</mat-icon>
<div>
<div class="wri-action-bar__title">DDS Approvals</div>
<div class="wri-action-bar__sub">
Review, approve or reject DDS entries - transfers, payouts, early closures, receipts applied and postings.
</div>
</div>
</div>
</div>
<div class="wri-filter-bar">
<select class="wri-filter-select" [(ngModel)]="filterStatus" (change)="onFilterChange()">
@for (opt of statusOptions; track opt.value) {
<option [value]="opt.value">{{ opt.label }}</option>
}
</select>
<span class="wri-fb-sep"></span>
<div class="wri-presets">
@for (p of datePresets; track p.key) {
<button
type="button"
class="wri-preset-chip"
[class.wri-preset-chip--active]="activePreset === p.key"
(click)="applyPreset(p.key)"
>
{{ p.label }}
</button>
}
</div>
<span class="wri-fb-sep"></span>
<div class="wri-date-range">
<div class="wri-date-box">
<span class="wri-date-box__lbl">From</span>
<input
class="wri-date-box__input"
[matDatepicker]="fromPicker"
[(ngModel)]="fromDate"
[max]="maxDate"
(dateChange)="onFromDateChange()"
readonly
/>
<button type="button" class="wri-date-box__btn" (click)="fromPicker.open()">
<mat-icon>event</mat-icon>
</button>
<mat-datepicker #fromPicker></mat-datepicker>
</div>
<span class="wri-date-range__sep">—</span>
<div class="wri-date-box">
<span class="wri-date-box__lbl">To</span>
<input
class="wri-date-box__input"
[matDatepicker]="toPicker"
[(ngModel)]="toDate"
[min]="fromDate"
[max]="maxDate"
(dateChange)="onToDateChange()"
readonly
/>
<button type="button" class="wri-date-box__btn" (click)="toPicker.open()">
<mat-icon>event</mat-icon>
</button>
<mat-datepicker #toPicker></mat-datepicker>
</div>
</div>
<button type="button" class="wri-btn wri-btn--refresh" (click)="load()" [disabled]="loading">
<mat-icon>refresh</mat-icon>
</button>
</div>
@if (loading) {
<div class="empty-state">
<p class="empty-title">Loading…</p>
</div>
} @else if (loaded && entries.length === 0) {
<div class="empty-state">
<i class="fas fa-inbox empty-icon"></i>
<p class="empty-title">No DDS entries</p>
<p class="empty-text">
@if (filterStatus === 'PENDING') {
There are no DDS entries waiting for approval.
} @else {
No {{ filterStatus | lowercase }} DDS entries found.
}
</p>
</div>
} @else {
<div class="wri-table-wrap">
<table class="wri-table">
<thead>
<tr>
<th class="wri-th-id">Voucher No</th>
<th>Type</th>
<th>Account / Details</th>
<th class="num">Amount</th>
<th>Entries</th>
<th>Clerk</th>
<th>Reviewed By</th>
<th>Submitted At</th>
<th>Status</th>
<th class="wri-th-action">Action</th>
</tr>
</thead>
<tbody>
@for (e of pagedEntries; track e.id) {
<tr class="wri-row" (click)="review(e)">
<td class="cell-mono wri-voucher">
{{ e.voucherNo || '—' }}
@if (e.voucherCancelled) {
<span class="wri-gl-name">(cancelled)</span>
}
</td>
<td>
<span class="wri-credit-badge" [style.background]="typeColor(e)">{{ e.type }}</span>
</td>
<td>
<span class="wri-gl-name">{{ e.details || '—' }}</span>
</td>
<td class="num wri-amount">
{{ e.amount !== null && e.amount !== undefined ? '₹' + (e.amount | number: '1.2-2') : '—' }}
</td>
<td class="wri-credit-count">
@if (e.entries) {
<span class="wri-credit-badge">{{ e.entries }} account{{ e.entries !== 1 ? 's' : '' }}</span>
} @else {
—
}
</td>
<td>
{{ e.makerName || e.maker || '—' }}
@if (isMine(e) && e.status === 'PENDING') {
<span class="wri-gl-name">(you)</span>
}
</td>
<td>{{ e.status === 'PENDING' ? '—' : e.checkerName || e.checker || '—' }}</td>
<td class="wri-dt">{{ formatDateTime(e.madeOn) }}</td>
<td>
<span class="wri-status" [ngClass]="statusClass(e.status)">{{ e.status | titlecase }}</span>
</td>
<td class="wri-action-cell" (click)="$event.stopPropagation()">
<button type="button" class="wri-btn wri-btn--review" (click)="review(e)">
<mat-icon>rate_review</mat-icon>
Review
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
<mat-paginator
[length]="entries.length"
[pageSize]="pageSize"
[pageSizeOptions]="pageSizeOptions"
[pageIndex]="pageIndex"
showFirstLastButtons
(page)="onPageChange($event)"
></mat-paginator>
}
</div>
@@ -0,0 +1,215 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { MatDialog } from '@angular/material/dialog';
import { MatNativeDateModule } from '@angular/material/core';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { catchError } from 'rxjs/operators';
import { of } from 'rxjs';
import { OrganizationService } from 'app/organization/organization.service';
import { SettingsService } from 'app/settings/settings.service';
import { AuthenticationService } from 'app/core/authentication/authentication.service';
import { DdsApprovalReviewComponent } from './dds-approval-review/dds-approval-review.component';
/**
* Approval Center › DDS Approvals: every DDS entry waiting on a checker, in the clerk's terms - what
* it does, on which accounts, for how much - and, once approved, the voucher it moved under.
*
* Laid out like Disbursement & Transfers beside it, and sharing its styles, so the two queues read the
* same way. The entries are the platform's own maker-checker entries; approving one here carries it out.
*/
@Component({
selector: 'mifosx-dds-approvals',
templateUrl: './dds-approvals.component.html',
styleUrls: ['../../tasks/checker-inbox-and-tasks-tabs/disbursement-requests/disbursement-requests.component.scss'],
standalone: true,
imports: [
CommonModule,
FormsModule,
MatIconModule,
MatPaginatorModule,
MatNativeDateModule,
MatDatepickerModule
]
})
export class DdsApprovalsComponent implements OnInit {
private organizationService = inject(OrganizationService);
private settingsService = inject(SettingsService);
private authenticationService = inject(AuthenticationService);
private dialog = inject(MatDialog);
entries: any[] = [];
pageSize = 20;
pageIndex = 0;
readonly pageSizeOptions = [
10,
20,
50
];
/** The page of entries on screen. */
get pagedEntries(): any[] {
const start = this.pageIndex * this.pageSize;
return this.entries.slice(start, start + this.pageSize);
}
onPageChange(event: PageEvent): void {
this.pageSize = event.pageSize;
this.pageIndex = event.pageIndex;
}
filterStatus = 'PENDING';
fromDate: Date | null = null;
toDate: Date | null = null;
maxDate!: Date;
activePreset = 'today';
loading = false;
loaded = false;
readonly currentUserId = this.authenticationService.getCredentials()?.userId;
readonly statusOptions = [
{ value: 'PENDING', label: 'Pending' },
{ value: 'APPROVED', label: 'Approved' },
{ value: 'REJECTED', label: 'Rejected' }
];
readonly datePresets = [
{ key: 'today', label: 'Today' },
{ key: 'week', label: 'This Week' },
{ key: 'month', label: 'This Month' },
{ key: 'all', label: 'All' }
];
ngOnInit(): void {
const today = new Date(this.settingsService.businessDate || new Date());
today.setHours(0, 0, 0, 0);
this.maxDate = today;
this.applyPreset('today');
}
load(): void {
this.loading = true;
this.organizationService
.getDdsApprovals(this.filterStatus, this.isoDate(this.fromDate), this.isoDate(this.toDate))
.pipe(catchError(() => of([])))
.subscribe((rows: any) => {
this.entries = Array.isArray(rows) ? rows : [];
this.pageIndex = 0;
this.loading = false;
this.loaded = true;
});
}
onFilterChange(): void {
this.load();
}
onFromDateChange(): void {
this.activePreset = '';
if (this.fromDate && this.toDate && this.fromDate > this.toDate) {
this.toDate = new Date(this.fromDate);
}
this.load();
}
onToDateChange(): void {
this.activePreset = '';
this.load();
}
applyPreset(key: string): void {
this.activePreset = key;
const today = new Date(this.settingsService.businessDate || new Date());
today.setHours(0, 0, 0, 0);
if (key === 'today') {
this.fromDate = new Date(today);
this.toDate = new Date(today);
} else if (key === 'week') {
const start = new Date(today);
start.setDate(today.getDate() - today.getDay());
this.fromDate = start;
this.toDate = new Date(today);
} else if (key === 'month') {
this.fromDate = new Date(today.getFullYear(), today.getMonth(), 1);
this.toDate = new Date(today);
} else {
this.fromDate = null;
this.toDate = null;
}
this.load();
}
review(entry: any): void {
this.dialog
.open(DdsApprovalReviewComponent, { data: { entry, currentUserId: this.currentUserId }, autoFocus: false })
.afterClosed()
// However the dialog was closed - its Close button, a click outside, Esc - the list is read
// again, so an entry just approved or rejected leaves the Pending list at once.
.subscribe(() => this.load());
}
/** Whose entry it is - a checker cannot pass their own. */
isMine(entry: any): boolean {
return !!this.currentUserId && Number(entry.makerId) === Number(this.currentUserId);
}
statusClass(status: string): string {
const map: Record<string, string> = {
PENDING: 'wri-status--pending',
APPROVED: 'wri-status--approved',
REJECTED: 'wri-status--rejected'
};
return map[status] ?? '';
}
typeColor(entry: any): string {
const map: Record<string, string> = {
Transfer: '#e3f2fd',
'Apply Receipt': '#fce4ec'
};
if (String(entry.type).startsWith('Premature')) {
return '#fff3e0';
}
if (String(entry.type).startsWith('Maturity')) {
return '#e8f5e9';
}
if (String(entry.type).startsWith('Post')) {
return '#ede7f6';
}
return map[entry.type] ?? '#f5f5f5';
}
formatDateTime(val: any): string {
if (!val) {
return '—';
}
const d = new Date(String(val).replace(' ', 'T'));
return isNaN(d.getTime())
? String(val)
: d.toLocaleString('en-IN', {
day: '2-digit',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
private isoDate(d: Date | null): string | undefined {
if (!d) {
return undefined;
}
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
}
@@ -0,0 +1,33 @@
<!--
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="dr-container">
<div class="dr-header">
<button mat-stroked-button [routerLink]="['../']" class="dr-back">
<i class="fas fa-arrow-left"></i> Deposits
</button>
<h2 class="dr-header__title">DDS Reports</h2>
</div>
@for (group of reportGroups; track group.title) {
<div class="dr-group">
<div class="dr-group__title">{{ group.title }}</div>
<div class="dr-grid">
@for (report of group.reports; track report) {
<a class="dr-card" [routerLink]="['view', report]">
<div class="dr-card__icon">
<i class="fas fa-file-alt"></i>
</div>
<div class="dr-card__title">{{ report }}</div>
<i class="fas fa-chevron-right dr-card__arrow"></i>
</a>
}
</div>
</div>
}
</div>
@@ -0,0 +1,97 @@
/**
* 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/.
*/
$primary: #0f766e;
.dr-container {
max-width: 1080px;
margin: 0 auto;
padding: 24px 16px;
}
.dr-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 24px;
&__title {
margin: 0;
font-size: 22px;
font-weight: 500;
}
}
.dr-back {
color: $primary;
}
.dr-group {
margin-bottom: 24px;
&__title {
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: $primary;
font-weight: 700;
margin-bottom: 10px;
}
}
.dr-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
.dr-card {
display: flex;
align-items: center;
gap: 12px;
background: var(--md-sys-color-surface, #fff);
border: 1px solid rgb(0 0 0 / 8%);
border-radius: 8px;
padding: 12px 14px;
text-decoration: none;
color: inherit;
transition: box-shadow 0.15s ease;
&:hover {
box-shadow: 0 2px 8px rgb(0 0 0 / 10%);
}
&__icon {
color: $primary;
font-size: 16px;
flex: none;
}
&__title {
flex: 1;
font-size: 13px;
font-weight: 500;
}
&__arrow {
color: rgb(0 0 0 / 30%);
font-size: 12px;
}
}
@media (width <= 900px) {
.dr-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (width <= 600px) {
.dr-grid {
grid-template-columns: 1fr;
}
}
@@ -0,0 +1,73 @@
/**
* 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 } from '@angular/core';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* DDS Reports hub component.
*
* Lists every DDS report from the legacy Reports menu, grouped the same
* way (As-On-Date, Interest, Daily/Balance/Master, Transaction). Each
* links to the shared report viewer, parameterised by report name.
*
* UI only for now: no report actually queries the backend yet — see
* ReportViewerComponent.
*/
@Component({
selector: 'mifosx-dds-reports',
templateUrl: './dds-reports.component.html',
styleUrls: ['./dds-reports.component.scss'],
imports: [...STANDALONE_SHARED_IMPORTS]
})
export class DdsReportsComponent {
reportGroups = [
{
title: 'As On Date',
reports: [
'Supplementary-DDS (As On)',
'Agent Check List',
'Party Check List-As On',
'Party Balance',
'Agent Wise Balance',
'Agent Wise Monthly Collection',
'Commission Register',
'Collection Summary Agent Wise',
'Close Party Ac Register',
'Agent Partywise Daily Collection',
'Daily Collection Balance',
'Interest Certificate',
'Mature Date Wise Report'
]
},
{
title: 'Interest',
reports: [
'Party Int. Register',
'Commission Register'
]
},
{
title: 'Daily / Balance / Master',
reports: [
'Daily Collection Report',
'Balance Summary',
'Master Data Export'
]
},
{
title: 'Transaction',
reports: ['Transaction Register']
},
{
title: 'Statements',
reports: ['Agent Commission Statement']
}
];
}
@@ -0,0 +1,148 @@
<!--
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="rv-container">
<div class="rv-header">
<button mat-stroked-button [routerLink]="['../../']" class="rv-back">
<i class="fas fa-arrow-left"></i> DDS Reports
</button>
<h2 class="rv-header__title">{{ reportName }}</h2>
</div>
<div class="rv-card">
<form [formGroup]="filterForm">
<!-- Only the filters this report's query actually reads are offered. One that changed nothing
would read as a report ignoring what it was asked. -->
@if (!hasNoFilters) {
<div class="rv-grid">
@if (usesDate) {
<mat-form-field appearance="outline" class="rv-field">
<mat-label>{{ dateLabel }} — from</mat-label>
<input matInput required [matDatepicker]="fromPicker" formControlName="fromDate" />
<mat-datepicker-toggle matIconSuffix [for]="fromPicker"></mat-datepicker-toggle>
<mat-datepicker #fromPicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="rv-field">
<mat-label>{{ dateLabel }} — to</mat-label>
<input matInput required [matDatepicker]="toPicker" formControlName="toDate" />
<mat-datepicker-toggle matIconSuffix [for]="toPicker"></mat-datepicker-toggle>
<mat-datepicker #toPicker></mat-datepicker>
</mat-form-field>
}
@if (usesAgent) {
<mat-form-field appearance="outline" class="rv-field">
<mat-label>Agent From</mat-label>
<input matInput type="number" formControlName="agentFrom" />
</mat-form-field>
<mat-form-field appearance="outline" class="rv-field">
<mat-label>Agent To</mat-label>
<input matInput type="number" formControlName="agentTo" />
</mat-form-field>
}
@if (usesParty) {
<mat-form-field appearance="outline" class="rv-field">
<mat-label>Party From</mat-label>
<input matInput type="number" formControlName="partyFrom" />
</mat-form-field>
<mat-form-field appearance="outline" class="rv-field">
<mat-label>Party To</mat-label>
<input matInput type="number" formControlName="partyTo" />
</mat-form-field>
}
</div>
}
<div class="rv-action-bar">
<button mat-raised-button type="button" (click)="exit()">Exit</button>
<!-- A report with nothing to fill in has already run itself; the button then only offers to
fetch the figures again, which is what it is called. -->
<button mat-raised-button color="primary" type="button" [disabled]="filterForm.invalid" (click)="runReport()">
<i class="fas" [class.fa-play]="!hasNoFilters" [class.fa-rotate-right]="hasNoFilters"></i>
{{ hasNoFilters ? 'Refresh' : 'Run Report' }}
</button>
</div>
@if (hasRun) {
<div class="rv-results">
@if (!available) {
<p class="rv-empty">{{ unavailableMessage }}</p>
} @else if (rows.length === 0) {
<p class="rv-empty">No rows matched these filters.</p>
} @else {
<!-- Laid out as the legacy statement printed it: a heading naming the report and the
period, numbered rows, and a total under every money column. -->
<div class="rv-statement">
<div class="rv-statement__head">
<h3 class="rv-statement__title">{{ reportName }}</h3>
@if (usesDate) {
<p class="rv-statement__period">
{{ dateLabel | lowercase }} {{ ranFromDate | dateFormat }} and
{{ ranToDate | dateFormat }}
</p>
} @else {
<p class="rv-statement__period">as on {{ ranOnDate | dateFormat }}</p>
}
<button mat-stroked-button type="button" class="rv-print" (click)="print()">
<i class="fas fa-print"></i> Print
</button>
</div>
<div class="rv-table-scroll">
<table class="rv-table">
<thead>
<tr>
<th class="rv-sr">#</th>
@for (column of columns; track column) {
<th [class.rv-num]="isAmountColumn(column)">{{ column }}</th>
}
</tr>
</thead>
<tbody>
@for (row of rows; track $index) {
<tr>
<td class="rv-sr">{{ $index + 1 }}</td>
@for (column of columns; track column) {
<td [class.rv-num]="isAmountColumn(column)">{{ cell(row, column) }}</td>
}
</tr>
}
</tbody>
@if (hasTotalledColumn) {
<tfoot>
<tr>
<td class="rv-sr"></td>
@for (column of columns; track column; let first = $first) {
<td [class.rv-num]="isAmountColumn(column)">
@if (first) {
Total :
} @else if (isTotalledColumn(column)) {
{{ columnTotal(column) }}
}
</td>
}
</tr>
</tfoot>
}
</table>
</div>
<p class="rv-statement__count">
{{ rows.length }} {{ rows.length === 1 ? 'row' : 'rows' }}
</p>
</div>
}
</div>
}
</form>
</div>
</div>
@@ -0,0 +1,190 @@
/**
* 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/.
*/
$primary: #0f766e;
.rv-container {
max-width: 1100px;
margin: 0 auto;
padding: 24px 16px;
}
.rv-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 20px;
&__title {
margin: 0;
font-size: 22px;
font-weight: 500;
}
}
.rv-back {
color: $primary;
}
.rv-card {
background: var(--md-sys-color-surface, #fff);
border-radius: 8px;
box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
padding: 20px 24px 24px;
}
.rv-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px 16px;
}
.rv-field {
width: 100%;
}
.rv-action-bar {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid rgb(0 0 0 / 8%);
}
.rv-results {
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid rgb(0 0 0 / 8%);
}
.rv-empty {
color: rgb(0 0 0 / 50%);
font-size: 14px;
margin: 0;
text-align: center;
padding: 24px 0;
}
.rv-table-scroll {
overflow-x: auto;
}
.rv-table {
width: 100%;
border-collapse: collapse;
white-space: nowrap;
th {
text-align: left;
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: rgb(0 0 0 / 55%);
padding: 8px 10px;
border-bottom: 2px solid rgb(0 0 0 / 10%);
}
td {
padding: 8px 10px;
border-bottom: 1px solid rgb(0 0 0 / 6%);
}
/* Figures line up column-wise so a wrong order of magnitude is visible at a glance. */
.rv-num {
text-align: right;
font-variant-numeric: tabular-nums;
}
/* The row number down the left, as the printed statement carried it. */
.rv-sr {
width: 1%;
color: rgb(0 0 0 / 45%);
font-style: italic;
font-variant-numeric: tabular-nums;
}
tfoot td {
border-bottom: none;
border-top: 2px solid rgb(0 0 0 / 20%);
font-weight: 600;
padding-top: 10px;
}
}
/* ---- the statement block: heading, table, footer count ---- */
.rv-action-bar i {
margin-right: 6px;
}
.rv-statement__head {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 4px 12px;
margin-bottom: 14px;
}
.rv-statement__title {
margin: 0;
font-size: 17px;
font-weight: 600;
flex: 0 0 auto;
}
.rv-statement__period {
margin: 0;
font-size: 13px;
color: rgb(0 0 0 / 55%);
}
.rv-print {
margin-left: auto;
i {
margin-right: 6px;
}
}
.rv-statement__count {
margin: 10px 0 0;
font-size: 12px;
color: rgb(0 0 0 / 45%);
text-align: right;
}
/* Printing a statement should give the statement, not the screen around it. */
@media print {
.rv-header,
.rv-grid,
.rv-action-bar,
.rv-print {
display: none !important;
}
.rv-card {
box-shadow: none;
padding: 0;
}
.rv-table-scroll {
overflow-x: visible;
}
.rv-table {
white-space: normal;
font-size: 11px;
}
}
@media (width <= 900px) {
.rv-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@@ -0,0 +1,346 @@
/**
* 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 { ActivatedRoute, Router } from '@angular/router';
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Custom Services */
import { OrganizationService } from 'app/organization/organization.service';
import { Dates } from 'app/core/utils/dates';
/**
* Generic DDS report viewer.
*
* One shared screen for all 13+ DDS reports — the report name comes from
* the route and is mapped to a backend report key. Every report in the
* legacy system shared the same basic filter shape (date, agent range,
* party range), so this avoids building a bespoke screen per report - the
* backend returns a generic {columns, rows} shape this renders as one
* table regardless of which report it is.
*
* One legacy report (Close Party Ac Register) has no corresponding data in
* the backend yet - Party accounts have no closed/open status at all. Run
* Report still calls through, and the backend's `available: false` +
* message is shown instead of a table.
*/
@Component({
selector: 'mifosx-dds-report-viewer',
templateUrl: './report-viewer.component.html',
styleUrls: ['./report-viewer.component.scss'],
imports: [...STANDALONE_SHARED_IMPORTS]
})
export class ReportViewerComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private formBuilder = inject(UntypedFormBuilder);
private organizationService = inject(OrganizationService);
private dateUtils = inject(Dates);
private static readonly REPORT_KEYS: Record<string, string> = {
'Supplementary-DDS (As On)': 'SUPPLEMENTARY_DDS',
'Agent Check List': 'AGENT_CHECK_LIST',
'Party Check List-As On': 'PARTY_CHECK_LIST',
'Party Balance': 'PARTY_BALANCE',
'Agent Wise Balance': 'AGENT_WISE_BALANCE',
'Agent Wise Monthly Collection': 'AGENT_WISE_MONTHLY_COLLECTION',
'Commission Register': 'COMMISSION_REGISTER',
'Collection Summary Agent Wise': 'COLLECTION_SUMMARY_AGENT_WISE',
'Close Party Ac Register': 'CLOSE_PARTY_AC_REGISTER',
'Agent Partywise Daily Collection': 'AGENT_PARTYWISE_DAILY_COLLECTION',
'Daily Collection Balance': 'DAILY_COLLECTION_REPORT',
'Interest Certificate': 'INTEREST_CERTIFICATE',
'Mature Date Wise Report': 'MATURE_DATE_WISE',
'Party Int. Register': 'PARTY_INTEREST_REGISTER',
'Daily Collection Report': 'DAILY_COLLECTION_REPORT',
'Balance Summary': 'BALANCE_SUMMARY',
'Master Data Export': 'MASTER_DATA_EXPORT',
'Transaction Register': 'TRANSACTION_REGISTER',
'Agent Commission Statement': 'AGENT_COMMISSION_STATEMENT'
};
/**
* Which filters each report actually uses, and what its date range means.
*
* Every report used to be offered the same six filters whether it read them or not — so a date
* range could be typed on the Agent Check List, which has no date in its query at all, and the
* same rows would come back however it was set. A filter that changes nothing is worse than no
* filter: it reads as a report that ignores what it was asked.
*
* Kept in step with the backend by hand; the source of truth is the switch in
* `DdsReportReadPlatformServiceImpl.runReport`.
*/
private static readonly REPORT_FILTERS: Record<string, { date?: string; agent?: boolean; party?: boolean }> = {
AGENT_CHECK_LIST: {},
PARTY_CHECK_LIST: { agent: true, party: true },
PARTY_BALANCE: { agent: true, party: true },
AGENT_WISE_BALANCE: { agent: true },
BALANCE_SUMMARY: { agent: true, party: true },
MASTER_DATA_EXPORT: { agent: true, party: true },
AGENT_WISE_MONTHLY_COLLECTION: { date: 'Collected between', agent: true },
COLLECTION_SUMMARY_AGENT_WISE: { date: 'Collected between', agent: true },
DAILY_COLLECTION_REPORT: { date: 'Collected between', agent: true },
AGENT_PARTYWISE_DAILY_COLLECTION: { date: 'Collected between', agent: true, party: true },
SUPPLEMENTARY_DDS: { date: 'Collected between', agent: true, party: true },
COMMISSION_REGISTER: { date: 'Posted between', agent: true },
AGENT_COMMISSION_STATEMENT: { date: 'Posted between' },
PARTY_INTEREST_REGISTER: { date: 'Posted between', party: true },
INTEREST_CERTIFICATE: { date: 'Interest earned between', agent: true, party: true },
TRANSACTION_REGISTER: { date: 'Transacted between', agent: true, party: true },
CLOSE_PARTY_AC_REGISTER: { date: 'Closed between', agent: true, party: true },
MATURE_DATE_WISE: { date: 'Maturing between', agent: true, party: true }
};
reportName: string = this.route.snapshot.paramMap.get('reportName') || 'Report';
hasRun = false;
available = true;
unavailableMessage = '';
columns: string[] = [];
rows: Array<Record<string, any>> = [];
/** The range the rows on screen were actually fetched for — not whatever the filter says now. */
ranFromDate: Date | null = null;
ranToDate: Date | null = null;
/** When a report carries no date range, the moment it was run is what dates its figures. */
ranOnDate: Date | null = null;
private static readonly MONEY = new Intl.NumberFormat('en-IN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
});
private static readonly DATE = new Intl.DateTimeFormat('en-GB', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
});
/**
* One cell, rendered the way a printed statement shows it.
*
* Dates arrive from the platform as `[yyyy, m, d]` and numbers as raw decimals, so printing them
* as they come gives "2026,9,2" and "10052.6" — unreadable on a statement someone checks figures
* against. Amounts get Indian grouping and two decimals; identifiers that merely look numeric
* (account numbers, codes) are left alone, since grouping them would corrupt them.
*/
cell(row: Record<string, any>, column: string): string {
const value = row[column];
if (value === null || value === undefined || value === '') {
return '—';
}
if (Array.isArray(value) && value.length >= 3) {
return ReportViewerComponent.DATE.format(new Date(value[0], value[1] - 1, value[2]));
}
if (this.isAmountColumn(column) && typeof value === 'number') {
return ReportViewerComponent.MONEY.format(value);
}
return String(value);
}
/**
* Whether a column holds money, and so should be right-aligned with two decimals.
*
* Decided from the column's name rather than its values: a count column and an account-number
* column are both numbers, and neither reads as an amount.
*/
isAmountColumn(column: string): boolean {
const name = column.toLowerCase();
// Ruled out first, because several of these also contain a money word: a rate is a percentage,
// "Total Accounts" is a count that happens to say total, and an account number is an identifier
// that would be corrupted by grouping.
//
// "agents" is matched exactly rather than as a substring: the two count columns are named
// "Total Agents" and "Active Agents", but "Cash With Agents" and "Commission Due To Agents"
// are money, and a substring test swept those two up and printed them ungrouped.
//
// A "type" column names a kind, never a quantity — "Interest Type" holds the word "Interest",
// which is enough for the money test below to claim it and put a meaningless total under it.
if (
name.includes('a/c no') ||
name.includes('code') ||
name.includes('rate') ||
name.includes('%') ||
name.includes('count') ||
name.includes('entries') ||
name.includes('accounts') ||
name === 'total agents' ||
name === 'active agents' ||
name.includes('type') ||
name.includes('pan')
) {
return false;
}
return (
name.includes('amount') ||
name.includes('balance') ||
name.includes('collect') ||
name.includes('commission') ||
name.includes('deduction') ||
name.includes('deposit') ||
name.includes('advance') ||
name.includes('interest') ||
name.includes('tds') ||
name.includes('total') ||
name.includes('diff') ||
name.includes('cash') ||
name.includes('debit') ||
name.includes('credit') ||
name.includes('settled') ||
name.includes('shortfall') ||
name.includes('due')
);
}
/**
* Whether a total under this column would mean anything.
*
* Not every amount is worth adding up. A "Balance After" on a transaction register is the running
* balance *at that moment*, so its column holds several snapshots of the same account at different
* times — summing them produces a figure that corresponds to nothing, and a reader would sooner or
* later try to reconcile it against something.
*
* A plain balance on a listing report is different: there each row is one account's position at
* one moment, and the sum is the real "total held", which is exactly what a branch wants to see.
*/
isTotalledColumn(column: string): boolean {
if (!this.isAmountColumn(column)) {
return false;
}
return !column.toLowerCase().includes('balance after');
}
/** The figure printed under a column, as the legacy statement's "Total :" row does. */
columnTotal(column: string): string {
const sum = this.rows.reduce((running, row) => {
const value = row[column];
return running + (typeof value === 'number' ? value : 0);
}, 0);
return ReportViewerComponent.MONEY.format(sum);
}
/**
* Whether to print a Total row at all.
*
* A single-row report is already its own total - Balance Summary is one line of figures for the
* whole scheme, and repeating it underneath itself only invites the reader to look for a difference
* between the two.
*/
get hasTotalledColumn(): boolean {
return this.rows.length > 1 && this.columns.some((column) => this.isTotalledColumn(column));
}
print(): void {
window.print();
}
filterForm: UntypedFormGroup = this.formBuilder.group({
fromDate: [''],
toDate: [''],
agentFrom: [1],
agentTo: [99999],
partyFrom: [1],
partyTo: [99999999]
});
private get filters(): { date?: string; agent?: boolean; party?: boolean } {
return ReportViewerComponent.REPORT_FILTERS[ReportViewerComponent.REPORT_KEYS[this.reportName]] ?? {};
}
/** True when this report's query actually reads a date range - only then is one asked for. */
get usesDate(): boolean {
return !!this.filters.date;
}
/** What this report's date range means, e.g. "Posted between" rather than a bare "From Date". */
get dateLabel(): string {
return this.filters.date ?? '';
}
get usesAgent(): boolean {
return !!this.filters.agent;
}
get usesParty(): boolean {
return !!this.filters.party;
}
/** True when this report takes no filters at all, so there is nothing for anyone to fill in. */
get hasNoFilters(): boolean {
return !this.usesDate && !this.usesAgent && !this.usesParty;
}
ngOnInit(): void {
// A date is required exactly for the reports that read one, so a report without dates can be run
// straight away instead of demanding two values it will ignore.
if (this.usesDate) {
this.filterForm.get('fromDate')?.setValidators([Validators.required]);
this.filterForm.get('toDate')?.setValidators([Validators.required]);
this.filterForm.get('fromDate')?.updateValueAndValidity();
this.filterForm.get('toDate')?.updateValueAndValidity();
}
// Nothing to narrow it by means nothing to decide, so the report simply opens showing its
// figures. Making someone press Run on an empty form asks a question with only one answer.
if (this.hasNoFilters) {
this.runReport();
}
}
runReport(): void {
const reportKey = ReportViewerComponent.REPORT_KEYS[this.reportName];
if (!reportKey) {
return;
}
const raw = this.filterForm.getRawValue();
// Only what this report reads is sent. The backend defaults the rest to the full range, so a
// report that ignores a filter is never handed a value that looks like it was applied.
const params: Record<string, any> = {};
if (this.usesDate) {
params['fromDate'] = this.dateUtils.getDate(raw.fromDate);
params['toDate'] = this.dateUtils.getDate(raw.toDate);
}
if (this.usesAgent) {
params['agentFrom'] = raw.agentFrom;
params['agentTo'] = raw.agentTo;
}
if (this.usesParty) {
params['partyFrom'] = raw.partyFrom;
params['partyTo'] = raw.partyTo;
}
this.organizationService
.runDdsReport(reportKey, params)
.subscribe((result: any) => {
this.available = result.available;
this.unavailableMessage = result.message || '';
this.columns = result.columns || [];
this.rows = result.rows || [];
// Captured here so the printed heading names the range these rows came from, and does not
// silently change if the filter is edited without re-running.
this.ranFromDate = raw.fromDate;
this.ranToDate = raw.toDate;
this.ranOnDate = new Date();
this.hasRun = true;
});
}
exit(): void {
this.router.navigate(['../../'], { relativeTo: this.route });
}
}
@@ -334,19 +334,14 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
const frequencyId = this.resolveFrequencyId(this.tenurePeriodUnit);
if (this.principalAmount && this.principalAmount > 0) {
// Years and Months convert exactly; a Days tenure is sent as days and never converted.
let tenureInMonths: number | undefined;
let tenureInDays: number | undefined;
// Convert tenure to months for backend API
let tenureInMonths = 0;
if (this.tenurePeriodUnit === 'Years') {
tenureInMonths = this.tenureValue * 12;
} else if (this.tenurePeriodUnit === 'Months') {
tenureInMonths = this.tenureValue;
} 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;
tenureInMonths = Math.floor(enteredDays / 30);
}
// Map compounding period type id → months (0=Daily, 1=Daily, 4=Monthly, 5=Quarterly, 6=SemiAnnual, 7=Annual)
@@ -400,7 +395,6 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
principalAmount: this.principalAmount,
annualInterestRate: rate,
tenureInMonths,
tenureInDays,
interestCompoundingPeriodInMonths: compMonths, // 0 = daily, now supported by backend
interestPostingPeriodInMonths: postMonths,
submittedOnDate: submittedOnDateStr,
@@ -423,18 +417,10 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
Math.round(this.principalAmount * Math.pow(1 + r / daysInYearVal, enteredDays) * 100) / 100;
} else {
const n = 12 / compMonths;
const effectiveMonths = tenureInMonths ?? enteredDays / 30;
const totalCompoundings = Math.floor(tenureInMonths / compMonths);
const rPerCompounding = rate / (n * 100);
// Whole compounding periods, then simple interest on the days left over. Flooring the
// periods and stopping there dropped the remainder entirely: a 200-day deposit
// 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.maturityAmount =
Math.round(this.principalAmount * Math.pow(1 + rPerCompounding, totalCompoundings) * 100) / 100;
}
result.interestEarned = Math.round((result.maturityAmount - this.principalAmount) * 100) / 100;
this.matchResult = { ...result };
@@ -7,29 +7,17 @@
-->
<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-label>{{ 'labels.inputs.Maturity Instructions' | translate }}</mat-label>
<mat-select formControlName="maturityInstructionId">
<mat-option [value]="''">-- Not selected (decide later via FD Dashboard) --</mat-option>
@for (maturityInstruction of maturityInstructionOptions; track maturityInstruction) {
<mat-option [value]="maturityInstruction.id">
{{ maturityInstruction.value | translateKey: 'catalogs' }}
</mat-option>
}
</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>
@if (closureSettingsForm.contains('transferToSavingsId')) {
@@ -40,8 +28,8 @@
<mat-icon>savings</mat-icon>
</div>
<div>
<h3 class="dal-card__title">{{ transferAccountCardTitle }}</h3>
<span class="dal-card__sub">{{ transferAccountCardSubtitle }}</span>
<h3 class="dal-card__title">{{ 'labels.inputs.Transfer to savings account' | translate }}</h3>
<span class="dal-card__sub">Maturity proceeds will be credited to this account</span>
</div>
</div>
</div>
@@ -81,7 +69,7 @@
</div>
} @else {
<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>
@for (account of savingsAccountsData; track account) {
<mat-option [value]="account.id">
@@ -89,15 +77,13 @@
</mat-option>
}
</mat-select>
<mat-error>{{ transferAccountCardTitle }} is required</mat-error>
<mat-error>Transfer to savings account is required</mat-error>
</mat-form-field>
}
</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">
{{ 'labels.inputs.Apply Penal Interest (less)' | translate }}
</mat-checkbox>
@@ -11,7 +11,6 @@ import { Component, Input, OnChanges, SimpleChanges, inject } from '@angular/cor
import { UntypedFormGroup, UntypedFormBuilder, UntypedFormControl, Validators } from '@angular/forms';
import { MatCheckbox } from '@angular/material/checkbox';
import { MatIcon } from '@angular/material/icon';
import { MatIconButton } from '@angular/material/button';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
@@ -26,8 +25,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
imports: [
...STANDALONE_SHARED_IMPORTS,
MatCheckbox,
MatIcon,
MatIconButton
MatIcon
]
})
export class FixedDepositAccountMaturityTermsStepComponent implements OnChanges {
@@ -55,12 +53,9 @@ export class FixedDepositAccountMaturityTermsStepComponent implements OnChanges
preClosurePenalInterestOnTypeId: ['']
});
this.closureSettingsForm.get('maturityInstructionId').valueChanges.subscribe((value: number) => {
// 200 = Transfer to Savings -- the whole matured balance goes here. 400 = Reinvest Principal
// Only -- the principal rolls into a new FD, but the leftover interest still needs the same
// kind of target so it isn't left stranded on the closed account (a real gap the account used
// 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) {
// 200 = Transfer to Savings -- Withdraw Deposit (100) needs no target account, and both
// Reinvest options (300/400) open a brand-new FD instead, so they ignore this field too.
if (value === 200) {
this.closureSettingsForm.addControl(
'transferToSavingsId',
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
* maturity instruction, once picked. */
/** The linked-savings account for the transfer-to-savings maturity instruction, once picked. */
get selectedTransferAccount(): any {
const id = this.closureSettingsForm.value.transferToSavingsId;
if (!id) return 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 {
this.closureSettingsForm.get('transferToSavingsId')?.setValue('');
}
@@ -8,24 +8,11 @@
<div class="pmt">
<div class="pmt__header">
<div class="pmt__header-left">
<span class="pmt__header-ico"><fa-icon icon="sync-alt"></fa-icon></span>
<div class="pmt__header-text">
<h2 class="pmt__title">
{{ 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>
<span class="pmt__header-ico"><fa-icon icon="sync-alt"></fa-icon></span>
<div class="pmt__header-text">
<h2 class="pmt__title">{{ 'labels.heading.Payment' | translate }}</h2>
<p class="pmt__subtitle">Process this Fixed Deposit now -- works whether it's Active or Matured.</p>
</div>
<button type="button" class="pmt__header-close" mat-dialog-close [disabled]="submitting" title="Close">
<fa-icon icon="times"></fa-icon>
</button>
</div>
<div mat-dialog-content class="pmt__content">
@@ -40,92 +27,37 @@
</div>
} @else {
<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 }} &rarr; 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 &rarr; 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) {
<div class="pmt__stat">
<span class="pmt__stat-label">
{{
isMaturedRenewal || !remainderIsApproximate
? ('labels.inputs.Maturity Amount' | translate)
: 'Amount available on the selected date'
remainderIsApproximate
? 'Amount available on the selected date'
: ('labels.inputs.Maturity Amount' | translate)
}}
</span>
<span class="pmt__stat-value">{{ reinvestCap | formatNumber }}</span>
</div>
}
@if (!isMaturedRenewal) {
<mat-form-field appearance="outline" class="pmt__date" (click)="closedOnDatePicker.open()">
<mat-label>{{ 'labels.inputs.Closed On Date' | translate }}</mat-label>
<input
matInput
[max]="maxDate"
[matDatepicker]="closedOnDatePicker"
required
formControlName="closedOnDate"
/>
<mat-datepicker-toggle matSuffix [for]="closedOnDatePicker"></mat-datepicker-toggle>
<mat-datepicker #closedOnDatePicker></mat-datepicker>
@if (paymentForm.controls.closedOnDate.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Closed On Date' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
}
<mat-form-field appearance="outline" class="pmt__date" (click)="closedOnDatePicker.open()">
<mat-label>{{ 'labels.inputs.Closed On Date' | translate }}</mat-label>
<input
matInput
[max]="maxDate"
[matDatepicker]="closedOnDatePicker"
required
formControlName="closedOnDate"
/>
<mat-datepicker-toggle matSuffix [for]="closedOnDatePicker"></mat-datepicker-toggle>
<mat-datepicker #closedOnDatePicker></mat-datepicker>
@if (paymentForm.controls.closedOnDate.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Closed On Date' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
<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">
@@ -230,12 +162,10 @@
</mat-form-field>
}
@if (!isMaturedRenewal) {
<mat-form-field appearance="outline">
<mat-label>{{ 'labels.inputs.Note' | translate }}</mat-label>
<textarea matInput formControlName="note" cdkTextareaAutosize cdkAutosizeMinRows="2"></textarea>
</mat-form-field>
}
<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="pmt__error" role="alert">
@@ -248,25 +178,14 @@
</div>
<div mat-dialog-actions align="end" class="pmt__actions">
<button mat-button mat-dialog-close [disabled]="submitting" class="pmt__btn-cancel">
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button mat-button mat-dialog-close [disabled]="submitting">{{ 'labels.buttons.Cancel' | translate }}</button>
<button
mat-raised-button
color="primary"
class="pmt__btn-submit"
[disabled]="!paymentForm?.valid || submitting || loadFailed"
(click)="submit()"
>
{{
submitting
? isDashboardRenewalMode
? 'Renewing...'
: ('labels.buttons.Processing' | translate)
: isDashboardRenewalMode
? 'Confirm & Renew'
: ('labels.buttons.Confirm' | translate)
}}
{{ submitting ? ('labels.buttons.Processing' | translate) : ('labels.buttons.Confirm' | translate) }}
</button>
</div>
</div>
@@ -6,466 +6,291 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/**
* Payment / Renewal Dialog Styles
* CBSNexora Core Banking System
*/
$navy: #0d1b2a;
$slate: #475569;
$muted: #64748b;
$border: #e2e8f0;
$soft: #f8fafc;
$blue: #1e40af;
$blue-soft: #eff6ff;
$green: #166534;
$green-soft: #f0fdf4;
.pmt {
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;
min-height: 320px;
max-height: 85vh;
overflow: hidden;
box-shadow: 0 24px 48px -12px rgb(15 23 42 / 25%);
margin: -24px;
}
.pmt__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 26px 30px;
background: linear-gradient(135deg, #1e3a8a 0%, #1d4ed8 55%, #2563eb 100%);
align-items: flex-start;
gap: 14px;
padding: 22px 28px 18px;
background: linear-gradient(135deg, #1e3a8a 0%, #1e40af 55%, #4338ca 100%);
color: #fff;
flex-shrink: 0;
position: relative;
overflow: hidden;
flex: 0 0 auto;
}
&::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;
}
}
.pmt__header-ico {
width: 40px;
height: 40px;
min-width: 40px;
border-radius: 10px;
background: rgb(255 255 255 / 15%);
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
}
.pmt__title {
margin: 0;
font-size: 20px;
font-weight: 800;
color: #fff;
line-height: 1.25;
letter-spacing: -0.01em;
font-size: 19px;
font-weight: 700;
}
.pmt__subtitle {
margin: 5px 0 0;
font-size: 13px;
color: rgb(255 255 255 / 88%);
margin: 4px 0 0;
font-size: 12.5px;
color: rgb(255 255 255 / 82%);
line-height: 1.4;
}
.pmt__content {
padding: 26px 30px !important;
padding: 22px 28px 8px !important;
overflow-y: auto;
flex: 1 1 auto;
background: #fbfcfe;
mat-form-field {
width: 100%;
}
}
.pmt__loading {
display: flex;
justify-content: center;
align-items: center;
min-height: 240px;
min-height: 260px;
}
.pmt__stat {
display: flex;
flex-direction: column;
gap: 4px;
background: linear-gradient(135deg, #eff6ff 0%, #e0e9ff 100%);
border: 1px solid #bfdbfe;
border-radius: 14px;
padding: 18px 22px;
gap: 2px;
background: $blue-soft;
border: 1px solid #dbeafe;
border-radius: 10px;
padding: 12px 16px;
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 {
display: flex;
flex-direction: column;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 14px;
padding: 4px 18px;
margin: 0 0 18px;
.pmt__stat-label {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: $blue;
}
&-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 11px 0;
border-bottom: 1px solid #f1f5f9;
.pmt__stat-value {
font-size: 22px;
font-weight: 800;
color: $navy;
font-variant-numeric: tabular-nums;
}
&:last-child {
border-bottom: none;
}
&--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__date {
width: 100%;
}
.pmt__section-label {
font-size: 12px;
font-size: 11.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #475569;
margin: 10px 0 12px;
letter-spacing: 0.05em;
color: $slate;
margin: 4px 0 10px;
}
/* Action Options Grid - 2 Equal Columns, comfortably wide now that the dialog itself is wider */
.pmt__options {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 14px;
margin-bottom: 18px;
@media (width <= 560px) {
grid-template-columns: 1fr;
}
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
gap: 10px;
margin-bottom: 6px;
}
.pmt__option {
display: flex;
align-items: flex-start;
gap: 14px;
gap: 10px;
text-align: left;
background: #fff;
border: 1.5px solid #e2e8f0;
border-radius: 14px;
padding: 16px;
border: 1.5px solid $border;
border-radius: 10px;
padding: 12px 14px;
cursor: pointer;
font: inherit;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
transition:
border-color 0.15s,
background 0.15s,
box-shadow 0.15s;
}
&:hover {
border-color: #93c5fd;
background: #f8fafc;
box-shadow: 0 6px 16px rgb(15 23 42 / 6%);
transform: translateY(-1px);
}
.pmt__option:hover {
border-color: #93c5fd;
background: $blue-soft;
}
&--active {
border-color: #2563eb;
background: #eff6ff;
box-shadow: 0 0 0 1.5px #2563eb;
.pmt__option--active {
border-color: $blue;
background: $blue-soft;
box-shadow: 0 0 0 1px $blue;
}
.pmt__option-ico {
background: #2563eb;
color: #fff;
border-color: #2563eb;
}
}
.pmt__option-ico {
width: 30px;
height: 30px;
min-width: 30px;
border-radius: 8px;
background: $soft;
color: $blue;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
}
&-ico {
width: 38px;
height: 38px;
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;
}
.pmt__option--active .pmt__option-ico {
background: $blue;
color: #fff;
}
// Distinct accent per option while unselected, purely presentational -- helps the four cards read
// apart at a glance. Selecting a card always fills its icon blue (see &--active above), matching the
// blue selection border/check regardless of which option it is, rather than mixing selection-blue
// with the option's own accent.
&:not(.pmt__option--active):nth-child(2) .pmt__option-ico {
color: #7c3aed;
}
.pmt__option-text {
display: flex;
flex-direction: column;
gap: 2px;
}
&:not(.pmt__option--active):nth-child(3) .pmt__option-ico {
color: #0d9488;
}
.pmt__option-title {
font-size: 13px;
font-weight: 700;
color: $navy;
}
&:not(.pmt__option--active):nth-child(4) .pmt__option-ico {
color: #d97706;
}
.pmt__option-desc {
font-size: 11px;
color: $muted;
line-height: 1.35;
}
&-text {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
}
.pmt__option:focus-visible {
outline: 2px solid $blue;
outline-offset: 2px;
}
&-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: #2563eb;
font-size: 17px;
position: absolute;
top: 14px;
right: 14px;
}
.pmt__option-check {
margin-left: auto;
color: $blue;
font-size: 13px;
}
.pmt__option-error {
color: #b91c1c;
font-size: 12px;
font-weight: 600;
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;
}
margin: 4px 0 14px;
}
.pmt__error {
display: flex;
align-items: flex-start;
gap: 10px;
gap: 4px;
background: #fef2f2;
border: 1px solid #fecaca;
border-left: 4px solid #ef4444;
border-radius: 10px;
border-radius: 8px;
color: #b91c1c;
font-size: 13px;
padding: 12px 16px;
margin: 10px 0;
font-size: 12.5px;
line-height: 1.45;
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 {
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;
.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;
}
}
padding: 14px 28px 20px !important;
border-top: 1px solid $border;
flex: 0 0 auto;
}
@@ -24,26 +24,15 @@ import { UntypedFormBuilder, UntypedFormGroup, UntypedFormControl, ValidatorFn,
/** Custom Services */
import { FixedDepositsService } from '../../../fixed-deposits.service';
import { SettingsService } from 'app/settings/settings.service';
import { AlertService } from 'app/core/alert/alert.service';
import { Dates } from 'app/core/utils/dates';
import { FormatNumberPipe } from '../../../../../pipes/format-number.pipe';
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
* 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
* 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({
selector: 'mifosx-payment-dialog',
@@ -87,18 +76,11 @@ export class PaymentDialogComponent implements OnInit {
};
dialogRef = inject<MatDialogRef<PaymentDialogComponent>>(MatDialogRef);
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);
data: { accountId: number | string; accountBalance?: number; maturityDate?: any } = inject(MAT_DIALOG_DATA);
private formBuilder = inject(UntypedFormBuilder);
private fixedDepositsService = inject(FixedDepositsService);
private settingsService = inject(SettingsService);
private dateUtils = inject(Dates);
private alertService = inject(AlertService);
paymentForm: UntypedFormGroup;
/** 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;
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 {
this.maxDate = this.settingsService.businessDate;
this.paymentForm = this.formBuilder.group({
@@ -134,34 +106,6 @@ export class PaymentDialogComponent implements OnInit {
],
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));
// 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.
@@ -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.
* <p>
@@ -340,31 +259,6 @@ export class PaymentDialogComponent implements OnInit {
}
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 locale = this.settingsService.language.code;
const dateFormat = this.settingsService.dateFormat;
@@ -377,16 +271,7 @@ export class PaymentDialogComponent implements OnInit {
this.fixedDepositsService
.executeFixedDepositsAccountCommand(this.data.accountId as string, 'payment', data)
.subscribe({
next: (response: any) => {
// 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.'
});
}
next: () => {
this.dialogRef.close({ confirm: true });
},
// Keep the dialog open with the entered values and say what went wrong, rather than leaving a
@@ -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 {
&minus; {{ 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>
@@ -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;
}
}
}
@@ -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.';
}
});
}
}
@@ -17,7 +17,6 @@ import { CalculateInterestDialogComponent } from './custom-dialogs/calculate-int
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 { 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';
/** Custom Button Config. */
@@ -30,7 +29,6 @@ import { AccountsService } from 'app/accounts/accounts.service';
import { Currency } from 'app/shared/models/general.model';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
import { AlertService } from 'app/core/alert/alert.service';
import { AuthenticationService } from '../../../core/authentication/authentication.service';
import { MatTooltip } from '@angular/material/tooltip';
import { NgClass, CurrencyPipe } from '@angular/common';
@@ -75,7 +73,6 @@ import { DormancyAlertPanelComponent } from 'app/shared/dormancy-alert-panel/dor
export class FixedDepositAccountViewComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private alertService = inject(AlertService);
private fixedDepositsService = inject(FixedDepositsService);
private savingsService = inject(SavingsService);
private accountsService = inject(AccountsService);
@@ -241,6 +238,7 @@ export class FixedDepositAccountViewComponent implements OnInit {
case 'Undo Activation':
case 'Add Charge':
case 'Withdrawn by Client':
case 'Premature Close':
case 'Deposit':
case 'Withdrawal':
this.router.navigate([`actions/${name}`], { relativeTo: this.route });
@@ -269,21 +267,16 @@ export class FixedDepositAccountViewComponent implements OnInit {
case 'Payment':
this.openPaymentDialog();
break;
case 'Premature Close':
this.openPrematureCloseDialog();
break;
}
}
/**
* Opens the Payment action -- only offered once the FD has actually Matured (see
* FixedDepositsButtonsConfiguration), and the only place a Partial Reinvest can be done. Also the
* 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.
* Opens the Payment action -- unlike Premature Close/Close, works whether the account is Active or
* Matured, and is the only place a Partial Reinvest can be done.
*/
private openPaymentDialog(): void {
const paymentDialogRef = this.dialog.open(PaymentDialogComponent, {
width: '720px',
width: '640px',
maxWidth: '95vw',
data: {
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.
*/
@@ -421,16 +360,7 @@ export class FixedDepositAccountViewComponent implements OnInit {
if (response.confirm) {
this.fixedDepositsService
.executeFixedDepositsAccountCommand(this.fixedDepositsAccountData.id, 'postInterest', {})
.subscribe((commandResponse: any) => {
// 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.'
});
}
.subscribe(() => {
this.reload();
});
}

Some files were not shown because too many files have changed in this diff Show More