Merge remote-tracking branch 'origin/demo' into demo
This commit is contained in:
+1
-1
@@ -70,7 +70,7 @@
|
|||||||
{
|
{
|
||||||
"type": "initial",
|
"type": "initial",
|
||||||
"maximumWarning": "15mb",
|
"maximumWarning": "15mb",
|
||||||
"maximumError": "17mb"
|
"maximumError": "16mb"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "anyComponentStyle",
|
"type": "anyComponentStyle",
|
||||||
|
|||||||
@@ -327,8 +327,11 @@ export class SearchJournalEntryComponent implements OnInit, OnDestroy, AfterView
|
|||||||
},
|
},
|
||||||
error: (err: any) => {
|
error: (err: any) => {
|
||||||
this.marking.delete(entry.id);
|
this.marking.delete(entry.id);
|
||||||
|
// Fineract wraps the real reason in errors[0]; the outer defaultUserMessage
|
||||||
|
// is the generic "Errors contain reason for domain rule violation", which
|
||||||
|
// tells the reviewer nothing.
|
||||||
this.markError =
|
this.markError =
|
||||||
err?.error?.defaultUserMessage || err?.error?.errors?.[0]?.defaultUserMessage || 'Could not mark this entry.';
|
err?.error?.errors?.[0]?.defaultUserMessage || err?.error?.defaultUserMessage || 'Could not mark this entry.';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,22 @@ const routes: Routes = [
|
|||||||
),
|
),
|
||||||
data: { title: 'My Disbursement Requests', breadcrumb: 'My Disbursement Requests' }
|
data: { title: 'My Disbursement Requests', breadcrumb: 'My Disbursement Requests' }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'share-withdrawal-request',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('../clerk/share-withdrawal-request/share-withdrawal-request.component').then(
|
||||||
|
(m) => m.ShareWithdrawalRequestComponent
|
||||||
|
),
|
||||||
|
data: { title: 'New Share Withdrawal Request', breadcrumb: 'New Share Withdrawal Request', mode: 'create' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'share-withdrawal-requests',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('../clerk/my-share-withdrawal-requests/my-share-withdrawal-requests.component').then(
|
||||||
|
(m) => m.MyShareWithdrawalRequestsComponent
|
||||||
|
),
|
||||||
|
data: { title: 'My Share Requests', breadcrumb: 'My Share Requests' }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'account-facilities',
|
path: 'account-facilities',
|
||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
|
||||||
|
/** HTTP service for the Share Withdrawal (redeem shares) maker-checker workflow — mirrors
|
||||||
|
* ClerkWithdrawalService (cash withdrawal), adapted for shares. */
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class ClerkShareWithdrawalService {
|
||||||
|
private http = inject(HttpClient);
|
||||||
|
|
||||||
|
submit(data: any): Observable<any> {
|
||||||
|
return this.http.post('/clerk/share-withdrawal-requests', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
getMyRequests(status?: string, offset = 0, limit = 20, fromDate?: string, toDate?: string): Observable<any> {
|
||||||
|
let params = new HttpParams().set('offset', String(offset)).set('limit', String(limit));
|
||||||
|
if (status) params = params.set('status', status);
|
||||||
|
if (fromDate) params = params.set('fromDate', fromDate);
|
||||||
|
if (toDate) params = params.set('toDate', toDate);
|
||||||
|
return this.http.get('/clerk/share-withdrawal-requests/my', { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
getPendingRequests(status = 'PENDING', offset = 0, limit = 20, fromDate?: string, toDate?: string): Observable<any> {
|
||||||
|
let params = new HttpParams().set('status', status).set('offset', String(offset)).set('limit', String(limit));
|
||||||
|
if (fromDate) params = params.set('fromDate', fromDate);
|
||||||
|
if (toDate) params = params.set('toDate', toDate);
|
||||||
|
return this.http.get('/clerk/share-withdrawal-requests', { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
getById(id: number): Observable<any> {
|
||||||
|
return this.http.get(`/clerk/share-withdrawal-requests/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
validate(id: number): Observable<any> {
|
||||||
|
return this.http.post(`/clerk/share-withdrawal-requests/${id}/validate`, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
approve(id: number): Observable<any> {
|
||||||
|
return this.http.post(`/clerk/share-withdrawal-requests/${id}/approve`, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
reject(id: number, reason: string): Observable<any> {
|
||||||
|
return this.http.post(`/clerk/share-withdrawal-requests/${id}/reject`, { reason });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -146,7 +146,7 @@
|
|||||||
[class.ws-f__ctl--req]="!row.paymentTypeId"
|
[class.ws-f__ctl--req]="!row.paymentTypeId"
|
||||||
>
|
>
|
||||||
<option [ngValue]="null">— Select —</option>
|
<option [ngValue]="null">— Select —</option>
|
||||||
@for (pt of paymentTypes; track pt.id) {
|
@for (pt of paymentTypesForRow(row); track pt.id) {
|
||||||
<option [ngValue]="pt.id">{{ pt.name }}</option>
|
<option [ngValue]="pt.id">{{ pt.name }}</option>
|
||||||
}
|
}
|
||||||
</select>
|
</select>
|
||||||
@@ -231,8 +231,11 @@
|
|||||||
<span class="ws-caption">Payment instrument</span>
|
<span class="ws-caption">Payment instrument</span>
|
||||||
<div class="ws-group__fields">
|
<div class="ws-group__fields">
|
||||||
<div class="ws-f ws-f--amt">
|
<div class="ws-f ws-f--amt">
|
||||||
<label class="ws-f__lbl" title="Transaction Amount">
|
<label
|
||||||
AMOUNT
|
class="ws-f__lbl"
|
||||||
|
[title]="row.acTyp === 'SHR' ? 'Number of Shares' : 'Transaction Amount'"
|
||||||
|
>
|
||||||
|
{{ row.acTyp === 'SHR' ? 'SHARE NO.' : 'AMOUNT' }}
|
||||||
@if (!isClerk && activeWithdrawalRequest) {
|
@if (!isClerk && activeWithdrawalRequest) {
|
||||||
<span class="ws-amt-locked-badge" title="Amount locked — approved withdrawal">🔒</span>
|
<span class="ws-amt-locked-badge" title="Amount locked — approved withdrawal">🔒</span>
|
||||||
}
|
}
|
||||||
@@ -242,20 +245,48 @@
|
|||||||
[class.ws-amt-wrap--cash]="isCashPayment && selectedRow === row"
|
[class.ws-amt-wrap--cash]="isCashPayment && selectedRow === row"
|
||||||
[class.ws-amt-wrap--locked]="!isClerk && !!activeWithdrawalRequest"
|
[class.ws-amt-wrap--locked]="!isClerk && !!activeWithdrawalRequest"
|
||||||
>
|
>
|
||||||
|
@if (row.acTyp !== 'SHR') {
|
||||||
<span class="ws-amt-prefix">₹</span>
|
<span class="ws-amt-prefix">₹</span>
|
||||||
|
}
|
||||||
<input
|
<input
|
||||||
class="ws-f__ctl ws-amt-input"
|
class="ws-f__ctl ws-amt-input"
|
||||||
type="number"
|
type="number"
|
||||||
[value]="row.amount ?? ''"
|
[value]="row.amount ?? ''"
|
||||||
(input)="row.amount = +$any($event.target).value"
|
(input)="row.amount = +$any($event.target).value; syncShareLinkedAmounts()"
|
||||||
(click)="$event.stopPropagation()"
|
(click)="$event.stopPropagation()"
|
||||||
placeholder="0.00"
|
[placeholder]="row.acTyp === 'SHR' ? '0' : '0.00'"
|
||||||
[readonly]="!isClerk && !!activeWithdrawalRequest"
|
[readonly]="!isClerk && !!activeWithdrawalRequest"
|
||||||
[class.ws-amt-input--locked]="!isClerk && !!activeWithdrawalRequest"
|
[class.ws-amt-input--locked]="!isClerk && !!activeWithdrawalRequest"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@if (row.acTyp === 'SHR' && rowSharePrices[row.acNo]) {
|
||||||
|
<span class="ws-f__hint"
|
||||||
|
>= ₹{{ (row.amount ?? 0) * rowSharePrices[row.acNo] | number: '1.2-2' }} @ ₹{{
|
||||||
|
rowSharePrices[row.acNo] | number: '1.2-2'
|
||||||
|
}}/share</span
|
||||||
|
>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
@if (row.typ !== 'Withdrawal Slip') {
|
@if (row.acTyp === 'SHR' && row.typ === 'Enter Bank Transfer') {
|
||||||
|
<div class="ws-f ws-f--cheqno">
|
||||||
|
<label class="ws-f__lbl" title="Board Resolution Number">RESOLUTION NO.</label>
|
||||||
|
<input
|
||||||
|
class="ws-f__ctl"
|
||||||
|
[(ngModel)]="row.resolutionNumber"
|
||||||
|
(click)="$event.stopPropagation()"
|
||||||
|
placeholder="e.g. BR-2026-014"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="ws-f ws-f--cheqdt">
|
||||||
|
<label class="ws-f__lbl" title="Date of Meeting">MEETING DATE</label>
|
||||||
|
<input
|
||||||
|
class="ws-f__ctl"
|
||||||
|
type="date"
|
||||||
|
[(ngModel)]="row.meetingDate"
|
||||||
|
(click)="$event.stopPropagation()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
} @else if (row.typ !== 'Withdrawal Slip') {
|
||||||
<div class="ws-f ws-f--cheqno">
|
<div class="ws-f ws-f--cheqno">
|
||||||
<label class="ws-f__lbl" title="Cheque Number">CHEQUE NO.</label>
|
<label class="ws-f__lbl" title="Cheque Number">CHEQUE NO.</label>
|
||||||
<input
|
<input
|
||||||
@@ -712,6 +743,32 @@
|
|||||||
'—'
|
'—'
|
||||||
}}</span>
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
|
@if (selectedRow?.acTyp === 'SHR') {
|
||||||
|
<div class="te-field">
|
||||||
|
<span class="te-label">No. of Shares Held</span
|
||||||
|
><span class="te-value te-mono">{{ accountDetails.summary?.totalApprovedShares ?? 0 }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="te-field">
|
||||||
|
<span class="te-label">Market Price / Share</span
|
||||||
|
><span class="te-value te-mono"
|
||||||
|
>{{ currencySymbol }}{{ accountDetails.currentMarketPrice | number: '1.2-2' }}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="te-field">
|
||||||
|
<span class="te-label">Resolution No.</span
|
||||||
|
><span class="te-value te-mono">{{ accountDetails.resolutionNumber || '—' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="te-field">
|
||||||
|
<span class="te-label">Linked Savings A/C No.</span
|
||||||
|
><span class="te-value te-mono">{{ accountDetails.savingsAccountNumber || '—' }}</span>
|
||||||
|
</div>
|
||||||
|
@if (accountDetails.linkedLoanAccountNo) {
|
||||||
|
<div class="te-field">
|
||||||
|
<span class="te-label">Linked Loan A/C No.</span
|
||||||
|
><span class="te-value te-mono">{{ accountDetails.linkedLoanAccountNo }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="te-panel__header te-panel__header--mt">
|
<div class="te-panel__header te-panel__header--mt">
|
||||||
@@ -724,7 +781,13 @@
|
|||||||
<div class="te-panel__body">
|
<div class="te-panel__body">
|
||||||
<div class="te-balance-row">
|
<div class="te-balance-row">
|
||||||
<span class="te-bal-num">1.</span>
|
<span class="te-bal-num">1.</span>
|
||||||
<span class="te-bal-label">{{ accountDetails?.glCode ? 'GL Balance' : 'Current Balance' }}</span>
|
<span class="te-bal-label">{{
|
||||||
|
selectedRow?.acTyp === 'SHR'
|
||||||
|
? 'Share Balance'
|
||||||
|
: accountDetails?.glCode
|
||||||
|
? 'GL Balance'
|
||||||
|
: 'Current Balance'
|
||||||
|
}}</span>
|
||||||
<span class="te-bal-value te-bal-value--cr"
|
<span class="te-bal-value te-bal-value--cr"
|
||||||
>{{ currencySymbol }}{{ currentBalance | number: '1.2-2' }}</span
|
>{{ currencySymbol }}{{ currentBalance | number: '1.2-2' }}</span
|
||||||
>
|
>
|
||||||
@@ -1281,7 +1344,7 @@
|
|||||||
[class.ws-f__ctl--req]="!row.paymentTypeId"
|
[class.ws-f__ctl--req]="!row.paymentTypeId"
|
||||||
>
|
>
|
||||||
<option [ngValue]="null">— Select —</option>
|
<option [ngValue]="null">— Select —</option>
|
||||||
@for (pt of paymentTypes; track pt.id) {
|
@for (pt of paymentTypesForRow(row); track pt.id) {
|
||||||
<option [ngValue]="pt.id">{{ pt.name }}</option>
|
<option [ngValue]="pt.id">{{ pt.name }}</option>
|
||||||
}
|
}
|
||||||
</select>
|
</select>
|
||||||
@@ -1360,7 +1423,7 @@
|
|||||||
<div class="ws-group__fields">
|
<div class="ws-group__fields">
|
||||||
<div class="ws-f ws-f--amt">
|
<div class="ws-f ws-f--amt">
|
||||||
<label class="ws-f__lbl">
|
<label class="ws-f__lbl">
|
||||||
AMOUNT
|
{{ row.acTyp === 'SHR' ? 'SHARE NO.' : 'AMOUNT' }}
|
||||||
@if (!isClerk && activeWithdrawalRequest) {
|
@if (!isClerk && activeWithdrawalRequest) {
|
||||||
<span class="ws-amt-locked-badge" title="Amount locked — approved withdrawal">🔒</span>
|
<span class="ws-amt-locked-badge" title="Amount locked — approved withdrawal">🔒</span>
|
||||||
}
|
}
|
||||||
@@ -1370,19 +1433,42 @@
|
|||||||
[class.ws-amt-wrap--cash]="isCashPayment && selectedRow === row"
|
[class.ws-amt-wrap--cash]="isCashPayment && selectedRow === row"
|
||||||
[class.ws-amt-wrap--locked]="!isClerk && !!activeWithdrawalRequest"
|
[class.ws-amt-wrap--locked]="!isClerk && !!activeWithdrawalRequest"
|
||||||
>
|
>
|
||||||
|
@if (row.acTyp !== 'SHR') {
|
||||||
<span class="ws-amt-prefix">₹</span>
|
<span class="ws-amt-prefix">₹</span>
|
||||||
|
}
|
||||||
<input
|
<input
|
||||||
class="ws-f__ctl ws-amt-input"
|
class="ws-f__ctl ws-amt-input"
|
||||||
type="number"
|
type="number"
|
||||||
[value]="row.amount ?? ''"
|
[value]="row.amount ?? ''"
|
||||||
(input)="row.amount = +$any($event.target).value"
|
(input)="row.amount = +$any($event.target).value; syncShareLinkedAmounts()"
|
||||||
(click)="$event.stopPropagation()"
|
(click)="$event.stopPropagation()"
|
||||||
placeholder="0.00"
|
[placeholder]="row.acTyp === 'SHR' ? '0' : '0.00'"
|
||||||
[readonly]="!isClerk && !!activeWithdrawalRequest"
|
[readonly]="!isClerk && !!activeWithdrawalRequest"
|
||||||
[class.ws-amt-input--locked]="!isClerk && !!activeWithdrawalRequest"
|
[class.ws-amt-input--locked]="!isClerk && !!activeWithdrawalRequest"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@if (row.acTyp === 'SHR' && rowSharePrices[row.acNo]) {
|
||||||
|
<span class="ws-f__hint"
|
||||||
|
>= ₹{{ (row.amount ?? 0) * rowSharePrices[row.acNo] | number: '1.2-2' }} @ ₹{{
|
||||||
|
rowSharePrices[row.acNo] | number: '1.2-2'
|
||||||
|
}}/share</span
|
||||||
|
>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
@if (row.acTyp === 'SHR' && row.typ === 'Enter Bank Transfer') {
|
||||||
|
<div class="ws-f ws-f--cheqno">
|
||||||
|
<label class="ws-f__lbl">RESOLUTION NO.</label>
|
||||||
|
<input
|
||||||
|
class="ws-f__ctl"
|
||||||
|
[(ngModel)]="row.resolutionNumber"
|
||||||
|
placeholder="e.g. BR-2026-014"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="ws-f ws-f--cheqdt">
|
||||||
|
<label class="ws-f__lbl">MEETING DATE</label>
|
||||||
|
<input class="ws-f__ctl" type="date" [(ngModel)]="row.meetingDate" />
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
<div class="ws-f ws-f--cheqno">
|
<div class="ws-f ws-f--cheqno">
|
||||||
<label class="ws-f__lbl">CHEQUE NO.</label>
|
<label class="ws-f__lbl">CHEQUE NO.</label>
|
||||||
<input class="ws-f__ctl ws-f__ctl--mono" [(ngModel)]="row.chequeNo" placeholder="—" />
|
<input class="ws-f__ctl ws-f__ctl--mono" [(ngModel)]="row.chequeNo" placeholder="—" />
|
||||||
@@ -1391,6 +1477,7 @@
|
|||||||
<label class="ws-f__lbl">CHEQ. DATE</label>
|
<label class="ws-f__lbl">CHEQ. DATE</label>
|
||||||
<input class="ws-f__ctl" [(ngModel)]="row.cheqDate" placeholder="dd-mm-yyyy" />
|
<input class="ws-f__ctl" [(ngModel)]="row.cheqDate" placeholder="dd-mm-yyyy" />
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
<div class="ws-f ws-f--tok">
|
<div class="ws-f ws-f--tok">
|
||||||
<label class="ws-f__lbl">TOK. NO.</label>
|
<label class="ws-f__lbl">TOK. NO.</label>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -961,6 +961,14 @@ input.ws-amt-input--locked {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ws-f__hint {
|
||||||
|
display: block;
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: $ws-muted;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
.ws-amt-input {
|
.ws-amt-input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { AuthenticationService } from '../../core/authentication/authentication.
|
|||||||
import { ClerkUiStateService, ClerkSavedAccount } from '../clerk-ui-state.service';
|
import { ClerkUiStateService, ClerkSavedAccount } from '../clerk-ui-state.service';
|
||||||
import { ClerkWithdrawalService } from '../clerk-withdrawal.service';
|
import { ClerkWithdrawalService } from '../clerk-withdrawal.service';
|
||||||
import { ClerkDisbursementService } from '../clerk-disbursement.service';
|
import { ClerkDisbursementService } from '../clerk-disbursement.service';
|
||||||
|
import { ClerkShareWithdrawalService } from '../clerk-share-withdrawal.service';
|
||||||
import { CashierDepositService } from '../cashier-deposit.service';
|
import { CashierDepositService } from '../cashier-deposit.service';
|
||||||
import { SettingsService } from '../../settings/settings.service';
|
import { SettingsService } from '../../settings/settings.service';
|
||||||
import {
|
import {
|
||||||
@@ -47,6 +48,10 @@ interface TxnRow {
|
|||||||
routingCode: string;
|
routingCode: string;
|
||||||
/** DD issuance charges/commission — only used when TYPE=DD, TXN TP=Cash, AC.TP=GL */
|
/** DD issuance charges/commission — only used when TYPE=DD, TXN TP=Cash, AC.TP=GL */
|
||||||
ddCharges: number | null;
|
ddCharges: number | null;
|
||||||
|
/** Only used when this row is AC.TP=SHR inside an Enter Bank Transfer to its linked savings
|
||||||
|
* account — governs the share redemption (see submitShareRedemptionTransfer()). */
|
||||||
|
resolutionNumber?: string;
|
||||||
|
meetingDate?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Denomination {
|
interface Denomination {
|
||||||
@@ -753,6 +758,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
private clerkUiState = inject(ClerkUiStateService);
|
private clerkUiState = inject(ClerkUiStateService);
|
||||||
private clerkWithdrawalService = inject(ClerkWithdrawalService);
|
private clerkWithdrawalService = inject(ClerkWithdrawalService);
|
||||||
private clerkDisbursementService = inject(ClerkDisbursementService);
|
private clerkDisbursementService = inject(ClerkDisbursementService);
|
||||||
|
private clerkShareWithdrawalService = inject(ClerkShareWithdrawalService);
|
||||||
private cashierDepositService = inject(CashierDepositService);
|
private cashierDepositService = inject(CashierDepositService);
|
||||||
readonly router = inject(Router);
|
readonly router = inject(Router);
|
||||||
private settingsService = inject(SettingsService);
|
private settingsService = inject(SettingsService);
|
||||||
@@ -769,9 +775,38 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
loadingAccount = false;
|
loadingAccount = false;
|
||||||
accountError = '';
|
accountError = '';
|
||||||
|
|
||||||
|
/** Share redemption settlement is always a book-transfer to the linked savings account — never a
|
||||||
|
* cash handover. A Share (SHR) row's own TXN TP. never offers a cash payment type, and neither
|
||||||
|
* does its paired CR (savings) row in "Enter Bank Transfer" mode — that CR row's payment type is
|
||||||
|
* the one that actually governs how the settlement credit posts (see submitShareRedemptionTransfer()).
|
||||||
|
* Every other account type/mode keeps the full, unfiltered list. */
|
||||||
|
paymentTypesForRow(row: TxnRow): any[] {
|
||||||
|
if (row.acTyp === 'SHR') {
|
||||||
|
return this.paymentTypes.filter((pt: any) => !pt.isCashPayment);
|
||||||
|
}
|
||||||
|
const isShareSettlementCredit =
|
||||||
|
row.typ === 'Enter Bank Transfer' &&
|
||||||
|
row.dbCr === 'Cr' &&
|
||||||
|
this.rows.some((r) => r.typ === 'Enter Bank Transfer' && r.dbCr === 'Dr' && r.acTyp === 'SHR');
|
||||||
|
if (isShareSettlementCredit) {
|
||||||
|
return this.paymentTypes.filter((pt: any) => !pt.isCashPayment);
|
||||||
|
}
|
||||||
|
return this.paymentTypes;
|
||||||
|
}
|
||||||
|
|
||||||
// Per-row account id/name map: acNo → Fineract id/name (used for multi-row disbursement)
|
// Per-row account id/name map: acNo → Fineract id/name (used for multi-row disbursement)
|
||||||
rowAccountIds: Record<string, number> = {};
|
rowAccountIds: Record<string, number> = {};
|
||||||
rowAccountNames: Record<string, string> = {};
|
rowAccountNames: Record<string, string> = {};
|
||||||
|
// Per-row Share account market price, keyed by acNo — lets a Share row's "SHARE NO." field
|
||||||
|
// auto-fill the settlement amount on any other row added alongside it (calculator convenience
|
||||||
|
// only; a Share row still can never itself be saved from this screen — see accountTypeConfig).
|
||||||
|
rowSharePrices: Record<string, number> = {};
|
||||||
|
// Per-row client id, keyed by acNo — needed to submit a Share Withdrawal Request from a row
|
||||||
|
// that isn't necessarily the currently "selected" one.
|
||||||
|
rowClientIds: Record<string, number> = {};
|
||||||
|
// Per-row linked savings account number, keyed by the Share account's own acNo — used to make
|
||||||
|
// sure a Share-to-linked-account Bank Transfer actually credits the right account.
|
||||||
|
rowLinkedSavingsAcNo: Record<string, string> = {};
|
||||||
|
|
||||||
// Full account + client data
|
// Full account + client data
|
||||||
accountDetails: any = null;
|
accountDetails: any = null;
|
||||||
@@ -1205,6 +1240,9 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
};
|
};
|
||||||
this.rows.push(mirror);
|
this.rows.push(mirror);
|
||||||
this.selectedRow = mirror;
|
this.selectedRow = mirror;
|
||||||
|
if (active.acTyp === 'SHR') {
|
||||||
|
this.syncShareLinkedAmounts();
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const row: TxnRow = {
|
const row: TxnRow = {
|
||||||
@@ -1499,6 +1537,182 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One-step Share redemption from Bank Transfer mode: DR row is the Share account (its
|
||||||
|
* "SHARE NO." field), CR row must be that account's own linked savings account. Submits a
|
||||||
|
* governed Share Withdrawal Request (same maker-checker table/flow as the dedicated screen) —
|
||||||
|
* approval both redeems the shares and settles the linked account automatically. */
|
||||||
|
private submitShareRedemptionTransfer(shareRow: TxnRow, savingsRow: TxnRow): void {
|
||||||
|
// Belt-and-braces: the TXN TP. dropdown already hides cash payment types for the CR (savings)
|
||||||
|
// row (see paymentTypesForRow()), but re-check here too in case its payment type was set before
|
||||||
|
// that filter applied — settlement is always a book-transfer to the linked savings account, and
|
||||||
|
// it's the CR row's payment type that actually governs how that credit posts, never the DR
|
||||||
|
// (Share) row's, which has no real payment-type concept.
|
||||||
|
const settlementPaymentType = this.paymentTypes.find((p: any) => p.id === savingsRow.paymentTypeId);
|
||||||
|
if (!savingsRow.paymentTypeId) {
|
||||||
|
this.showAlert('error', 'Settlement Payment Type Required', [
|
||||||
|
'Select a Payment Type on the CR (savings account) row — it governs how the settlement credit posts.'
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (settlementPaymentType?.isCashPayment) {
|
||||||
|
this.showAlert('error', 'Cash Not Allowed for Share Transactions', [
|
||||||
|
'Share redemption settlement is always a book-transfer to the linked savings account, never cash.',
|
||||||
|
'Select a non-cash Payment Type on the CR row (e.g. Transfer or Share Redemption Transfer).'
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shareAccountId = this.rowAccountIds[shareRow.acNo];
|
||||||
|
if (!shareAccountId) {
|
||||||
|
this.showAlert('error', 'Share Account Not Loaded', [`Please load account details for: ${shareRow.acNo}`]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.rowAccountIds[savingsRow.acNo]) {
|
||||||
|
this.showAlert('error', 'Savings Account Not Loaded', [`Please load account details for: ${savingsRow.acNo}`]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const linkedAcNo = this.rowLinkedSavingsAcNo[shareRow.acNo];
|
||||||
|
const enteredAcNo = savingsRow.acNo?.trim();
|
||||||
|
const matchesLinked =
|
||||||
|
!!linkedAcNo &&
|
||||||
|
(linkedAcNo === enteredAcNo || linkedAcNo.endsWith(enteredAcNo) || savingsRow.ecsAcNo === linkedAcNo);
|
||||||
|
if (linkedAcNo && !matchesLinked) {
|
||||||
|
this.showAlert('error', 'Wrong Credit Account', [
|
||||||
|
`The CR account must be this Share account's own linked savings account: ${linkedAcNo}`,
|
||||||
|
`You entered: ${enteredAcNo}`
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!linkedAcNo) {
|
||||||
|
this.showAlert('error', 'No Linked Savings Account', [
|
||||||
|
'This Share account has no linked savings account configured — settlement has nowhere to go.'
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shares = Number(shareRow.amount) || 0;
|
||||||
|
if (shares <= 0) {
|
||||||
|
this.showAlert('error', 'Share No. Required', ['Enter the number of shares to withdraw (greater than zero).']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!shareRow.resolutionNumber?.trim()) {
|
||||||
|
this.showAlert('error', 'Board Resolution Number Required', [
|
||||||
|
'A share withdrawal must be authorised by a Board Resolution.'
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!shareRow.meetingDate) {
|
||||||
|
this.showAlert('error', 'Date of Meeting Required', ['Enter the Board meeting date that passed the resolution.']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
// for audit purposes. Business logic still only uses settlementPaymentTypeId above; the rest of
|
||||||
|
// these fields are not re-read by the backend, they are the audit record of what was on screen.
|
||||||
|
const settlementRowSnapshot = {
|
||||||
|
branch: savingsRow.br,
|
||||||
|
accountType: savingsRow.acTyp,
|
||||||
|
accountNo: savingsRow.acNo,
|
||||||
|
ecsAccountNo: savingsRow.ecsAcNo || null,
|
||||||
|
dbCr: savingsRow.dbCr,
|
||||||
|
amount: savingsRow.amount ?? null,
|
||||||
|
chequeNo: savingsRow.chequeNo || null,
|
||||||
|
chequeDate: savingsRow.cheqDate || null,
|
||||||
|
tokenNo: savingsRow.tokNo || null,
|
||||||
|
description: savingsRow.description || null,
|
||||||
|
bankName: savingsRow.bankName || null,
|
||||||
|
routingCode: savingsRow.routingCode || null,
|
||||||
|
paymentTypeId: savingsRow.paymentTypeId,
|
||||||
|
paymentTypeName: settlementPaymentType?.name ?? null
|
||||||
|
};
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
shareAccountId,
|
||||||
|
accountNo: shareRow.acNo,
|
||||||
|
clientId: this.rowClientIds[shareRow.acNo] ?? null,
|
||||||
|
clientName: this.rowAccountNames[shareRow.acNo] ?? null,
|
||||||
|
requestedShares: shares,
|
||||||
|
requestedDate,
|
||||||
|
resolutionNumber: shareRow.resolutionNumber.trim(),
|
||||||
|
meetingDate: shareRow.meetingDate,
|
||||||
|
description: shareRow.description || null,
|
||||||
|
settlementPaymentTypeId: savingsRow.paymentTypeId,
|
||||||
|
settlementRowSnapshot
|
||||||
|
};
|
||||||
|
|
||||||
|
const sharePrice = this.rowSharePrices[shareRow.acNo] ?? 0;
|
||||||
|
const confirmLines = [
|
||||||
|
`Share Account: ${shareRow.acNo} · Client: ${this.rowAccountNames[shareRow.acNo] ?? '—'}`,
|
||||||
|
`Shares to Withdraw: ${shares} @ ₹${sharePrice.toLocaleString('en-IN', { minimumFractionDigits: 2 })} = ₹${(shares * sharePrice).toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
|
||||||
|
`Board Resolution No.: ${payload.resolutionNumber}`,
|
||||||
|
`Date of Meeting: ${payload.meetingDate}`,
|
||||||
|
`Settlement Credit To: ${linkedAcNo}`,
|
||||||
|
`Settlement Payment Type: ${settlementPaymentType?.name ?? '—'}`,
|
||||||
|
...(settlementRowSnapshot.ecsAccountNo ? [`ECS A/C No.: ${settlementRowSnapshot.ecsAccountNo}`] : []),
|
||||||
|
...(settlementRowSnapshot.chequeNo ? [`Cheque No.: ${settlementRowSnapshot.chequeNo}`] : []),
|
||||||
|
...(settlementRowSnapshot.chequeDate ? [`Cheque Date: ${settlementRowSnapshot.chequeDate}`] : []),
|
||||||
|
...(settlementRowSnapshot.tokenNo ? [`Token No.: ${settlementRowSnapshot.tokenNo}`] : []),
|
||||||
|
...(settlementRowSnapshot.description ? [`CR Row Remarks: ${settlementRowSnapshot.description}`] : []),
|
||||||
|
'',
|
||||||
|
'Validation checks:',
|
||||||
|
` ✓ Share account and linked savings account both loaded`,
|
||||||
|
` ✓ CR account matches this Share account's linked savings account`,
|
||||||
|
` ✓ Settlement Payment Type is non-cash`,
|
||||||
|
` ✓ Board Resolution Number and Date of Meeting provided`,
|
||||||
|
'',
|
||||||
|
'Pending Manager approval — redemption and settlement both post together on approval.'
|
||||||
|
];
|
||||||
|
this.showConfirmAlert('info', 'Confirm Share Withdrawal Request', confirmLines, 'Submit for Approval', () =>
|
||||||
|
this.doSubmitShareRedemptionTransfer(payload, shareRow, shares, linkedAcNo)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private doSubmitShareRedemptionTransfer(
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
shareRow: TxnRow,
|
||||||
|
shares: number,
|
||||||
|
linkedAcNo: string
|
||||||
|
): void {
|
||||||
|
this.saving = true;
|
||||||
|
this.clerkShareWithdrawalService
|
||||||
|
.submit(payload)
|
||||||
|
.pipe(
|
||||||
|
catchError((err: any) => {
|
||||||
|
const msg =
|
||||||
|
err?.error?.errors?.[0]?.defaultUserMessage ??
|
||||||
|
err?.error?.developerMessage ??
|
||||||
|
err?.error?.defaultUserMessage ??
|
||||||
|
'Share withdrawal submission failed. Please try again.';
|
||||||
|
this.showAlert('error', 'Submission Failed', [msg]);
|
||||||
|
this.saving = false;
|
||||||
|
return of(null);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.subscribe((res: any) => {
|
||||||
|
this.saving = false;
|
||||||
|
if (res) {
|
||||||
|
this.showAlert('success', 'Share Withdrawal Submitted', [
|
||||||
|
`${shares} share(s) from ${shareRow.acNo} → settlement to ${linkedAcNo} on approval.`,
|
||||||
|
'Pending Manager approval. Track status in "My Share Requests".'
|
||||||
|
]);
|
||||||
|
this.rows = [];
|
||||||
|
this.rowAccountIds = {};
|
||||||
|
this.rowAccountNames = {};
|
||||||
|
this.rowSharePrices = {};
|
||||||
|
this.rowClientIds = {};
|
||||||
|
this.rowLinkedSavingsAcNo = {};
|
||||||
|
this.addRow();
|
||||||
|
this.selectedRow = this.rows[0];
|
||||||
|
this.accountDetails = null;
|
||||||
|
this.clientDetails = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Cashier enters a token number → look up the APPROVED withdrawal request for auto-fill. */
|
/** Cashier enters a token number → look up the APPROVED withdrawal request for auto-fill. */
|
||||||
onTokNoBlur(row: TxnRow): void {
|
onTokNoBlur(row: TxnRow): void {
|
||||||
if (this.isClerk || !row.tokNo?.trim()) return;
|
if (this.isClerk || !row.tokNo?.trim()) return;
|
||||||
@@ -1535,6 +1749,25 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** When exactly one row on screen is a Share account with a known market price, auto-fills every
|
||||||
|
* OTHER row's amount as (shares entered on that row) × (its market price) — a calculator
|
||||||
|
* convenience for filling the paired settlement entry. The Share row itself still can never be
|
||||||
|
* saved from this screen (see the SHR guard in confirmSubmit()) — this only helps compute the
|
||||||
|
* correct amount to carry over into the separate manual Deposit Slip entry. */
|
||||||
|
syncShareLinkedAmounts(): void {
|
||||||
|
const shareRows = this.rows.filter((r) => r.acTyp?.toUpperCase() === 'SHR' && this.rowSharePrices[r.acNo]);
|
||||||
|
if (shareRows.length !== 1) return;
|
||||||
|
const shareRow = shareRows[0];
|
||||||
|
const price = this.rowSharePrices[shareRow.acNo] ?? 0;
|
||||||
|
const shares = Number(shareRow.amount) || 0;
|
||||||
|
const total = Math.round(shares * price * 100) / 100;
|
||||||
|
this.rows.forEach((r) => {
|
||||||
|
if (r !== shareRow) {
|
||||||
|
r.amount = total;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
onAcNoBlur(row: TxnRow, index: number): void {
|
onAcNoBlur(row: TxnRow, index: number): void {
|
||||||
if (!row.acNo.trim()) return;
|
if (!row.acNo.trim()) return;
|
||||||
this.selectedRow = row;
|
this.selectedRow = row;
|
||||||
@@ -1633,11 +1866,13 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
if (token !== this.searchToken) return; // stale result — newer search started
|
if (token !== this.searchToken) return; // stale result — newer search started
|
||||||
const allItems: any[] = res?.pageItems ?? (Array.isArray(res) ? res : []);
|
const allItems: any[] = res?.pageItems ?? (Array.isArray(res) ? res : []);
|
||||||
const f = cfg.matchField;
|
const f = cfg.matchField;
|
||||||
// Exact match first, then suffix/partial match (backend may return by last-N-digits)
|
// Exact match first, then suffix/partial match. Some account types (e.g. Share — its
|
||||||
|
// list endpoint only accepts offset/limit, no accountNo filter at all) always return the
|
||||||
|
// FULL unfiltered list rather than a server-side-narrowed one, so a partial match can't be
|
||||||
|
// gated on "exactly one row came back" — it never would be for those types.
|
||||||
const exactMatch = allItems.find((x: any) => x[f] === acNo);
|
const exactMatch = allItems.find((x: any) => x[f] === acNo);
|
||||||
const partialMatch =
|
const partialMatch = !exactMatch
|
||||||
!exactMatch && allItems.length === 1
|
? (allItems.find((x: any) => x[f]?.endsWith(acNo)) ?? allItems.find((x: any) => x[f]?.includes(acNo)))
|
||||||
? allItems.find((x: any) => x[f]?.endsWith(acNo) || x[f]?.includes(acNo))
|
|
||||||
: null;
|
: null;
|
||||||
const match = exactMatch ?? partialMatch ?? null;
|
const match = exactMatch ?? partialMatch ?? null;
|
||||||
if (match?.id) {
|
if (match?.id) {
|
||||||
@@ -1659,6 +1894,17 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
siAccountType: number;
|
siAccountType: number;
|
||||||
txnBase: string;
|
txnBase: string;
|
||||||
txnSearchSupported: boolean;
|
txnSearchSupported: boolean;
|
||||||
|
/** True when this account type has no separate REST transactions sub-resource at all (e.g.
|
||||||
|
* Share accounts — Fineract returns transactions embedded on the account detail response
|
||||||
|
* itself via ?associations=all, never at /accounts/share/{id}/transactions). Skips the
|
||||||
|
* "today's transactions" HTTP call entirely instead of hitting a 404. */
|
||||||
|
txnFetchUnsupported?: boolean;
|
||||||
|
/** True when this account type has no standing-instruction "from account type" mapping on
|
||||||
|
* the backend at all (PortfolioAccountType only defines LOAN/SAVINGS/CURRENT — Share isn't
|
||||||
|
* a valid standing-instruction source account). Skips that HTTP call entirely instead of
|
||||||
|
* sending an fromAccountType value the backend's query-builder silently can't match,
|
||||||
|
* which otherwise produces a malformed "WHERE <empty>" SQL statement server-side. */
|
||||||
|
siFetchUnsupported?: boolean;
|
||||||
maxLimit: number;
|
maxLimit: number;
|
||||||
skipGlobalSearch: boolean;
|
skipGlobalSearch: boolean;
|
||||||
listQueryParam: string;
|
listQueryParam: string;
|
||||||
@@ -1693,6 +1939,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
siAccountType: 4,
|
siAccountType: 4,
|
||||||
txnBase: '/accounts/share',
|
txnBase: '/accounts/share',
|
||||||
txnSearchSupported: false,
|
txnSearchSupported: false,
|
||||||
|
txnFetchUnsupported: true,
|
||||||
|
siFetchUnsupported: true,
|
||||||
maxLimit: 500,
|
maxLimit: 500,
|
||||||
skipGlobalSearch: true,
|
skipGlobalSearch: true,
|
||||||
listQueryParam: 'accountNo',
|
listQueryParam: 'accountNo',
|
||||||
@@ -1805,6 +2053,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
siAccountType: number;
|
siAccountType: number;
|
||||||
txnBase: string;
|
txnBase: string;
|
||||||
txnSearchSupported: boolean;
|
txnSearchSupported: boolean;
|
||||||
|
txnFetchUnsupported?: boolean;
|
||||||
|
siFetchUnsupported?: boolean;
|
||||||
detailParams?: string;
|
detailParams?: string;
|
||||||
},
|
},
|
||||||
token: number,
|
token: number,
|
||||||
@@ -1837,13 +2087,29 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
this.rowAccountIds[this.selectedRow.acNo] = acc.id;
|
this.rowAccountIds[this.selectedRow.acNo] = acc.id;
|
||||||
this.rowAccountNames[this.selectedRow.acNo] =
|
this.rowAccountNames[this.selectedRow.acNo] =
|
||||||
acc.name ?? acc.displayName ?? acc.glAccountName ?? acc.accountNo ?? '';
|
acc.name ?? acc.displayName ?? acc.glAccountName ?? acc.accountNo ?? '';
|
||||||
|
this.rowClientIds[this.selectedRow.acNo] = acc.clientId ?? null;
|
||||||
}
|
}
|
||||||
if (this.selectedRow) {
|
if (this.selectedRow) {
|
||||||
this.selectedRow.ecsAcNo = acc.glCode ?? acc.savingsAccountNumber ?? acc.accountNo ?? '';
|
// Share accounts: ECS field is the share account's own number — acc.savingsAccountNumber
|
||||||
|
// is a different, unrelated account (the linked settlement savings account) and must
|
||||||
|
// never be shown here.
|
||||||
|
this.selectedRow.ecsAcNo =
|
||||||
|
acTyp?.toUpperCase() === 'SHR'
|
||||||
|
? (acc.accountNo ?? '')
|
||||||
|
: (acc.glCode ?? acc.savingsAccountNumber ?? acc.accountNo ?? '');
|
||||||
}
|
}
|
||||||
if (this.isClerk) this.clerkUiState.setFacilities(this.facilities, acNo, acTyp);
|
if (this.isClerk) this.clerkUiState.setFacilities(this.facilities, acNo, acTyp);
|
||||||
this.computeSameDayNeftRtgs(acc.transactions);
|
this.computeSameDayNeftRtgs(acc.transactions);
|
||||||
this.computeSameDayChequeClearing(acc.transactions);
|
this.computeSameDayChequeClearing(acc.transactions);
|
||||||
|
if (acTyp?.toUpperCase() === 'SHR' && this.selectedRow?.acNo) {
|
||||||
|
// Keyed by the row's own typed acNo (e.g. "0011"), not the resolved full account number
|
||||||
|
// (e.g. "000000011") — rowAccountIds/rowAccountNames above use the same key, and every
|
||||||
|
// lookup elsewhere (syncShareLinkedAmounts, submitShareRedemptionTransfer, the template's
|
||||||
|
// rowSharePrices[row.acNo]) reads via row.acNo, so this must match or every lookup misses.
|
||||||
|
this.rowSharePrices[this.selectedRow.acNo] = Number(acc.currentMarketPrice) || 0;
|
||||||
|
this.rowLinkedSavingsAcNo[this.selectedRow.acNo] = acc.savingsAccountNumber ?? '';
|
||||||
|
this.syncShareLinkedAmounts();
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1913,10 +2179,14 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
documents: this.http.get<any[]>(`/clients/${clientId}/documents`).pipe(catchError(() => of([]))),
|
documents: this.http.get<any[]>(`/clients/${clientId}/documents`).pipe(catchError(() => of([]))),
|
||||||
notes: this.http.get<any>(`/savings/${accountId}/notes`).pipe(catchError(() => of([]))),
|
notes: this.http.get<any>(`/savings/${accountId}/notes`).pipe(catchError(() => of([]))),
|
||||||
linkedAcc: this.http.get<any>(`/clients/${clientId}/accounts`).pipe(catchError(() => of(null))),
|
linkedAcc: this.http.get<any>(`/clients/${clientId}/accounts`).pipe(catchError(() => of(null))),
|
||||||
si: this.http
|
si: cfg.siFetchUnsupported
|
||||||
|
? of({ pageItems: [] })
|
||||||
|
: this.http
|
||||||
.get<any>(`/standinginstructions?fromAccountType=${cfg.siAccountType}&fromAccountId=${accountId}&limit=10`)
|
.get<any>(`/standinginstructions?fromAccountType=${cfg.siAccountType}&fromAccountId=${accountId}&limit=10`)
|
||||||
.pipe(catchError(() => of({ pageItems: [] }))),
|
.pipe(catchError(() => of({ pageItems: [] }))),
|
||||||
todayTxns: cfg.txnSearchSupported
|
todayTxns: cfg.txnFetchUnsupported
|
||||||
|
? of({ pageItems: [] })
|
||||||
|
: cfg.txnSearchSupported
|
||||||
? this.http
|
? this.http
|
||||||
.get<any>(
|
.get<any>(
|
||||||
`${cfg.txnBase}/${accountId}/transactions/search?fromDate=${todayStr}&toDate=${todayStr}&limit=20`
|
`${cfg.txnBase}/${accountId}/transactions/search?fromDate=${todayStr}&toDate=${todayStr}&limit=20`
|
||||||
@@ -2089,6 +2359,11 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
// ── Balance getters ────────────────────────────────────────────────────────
|
// ── Balance getters ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
get currentBalance(): number {
|
get currentBalance(): number {
|
||||||
|
if (this.selectedRow?.acTyp === 'SHR') {
|
||||||
|
const shares = Number(this.accountDetails?.summary?.totalApprovedShares ?? 0);
|
||||||
|
const unitPrice = Number(this.accountDetails?.currentMarketPrice ?? 0);
|
||||||
|
return shares * unitPrice;
|
||||||
|
}
|
||||||
return this.accountDetails?.summary?.accountBalance ?? this.accountDetails?.organizationRunningBalance ?? 0;
|
return this.accountDetails?.summary?.accountBalance ?? this.accountDetails?.organizationRunningBalance ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2502,6 +2777,23 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Share redemption via linked-account transfer: DR=Share account, CR=its linked savings
|
||||||
|
// account. A Share account can never be debited by a plain ledger transfer (no such Fineract
|
||||||
|
// command exists) — so this exact one-DR/one-CR shape is routed to submitShareRedemptionTransfer()
|
||||||
|
// instead, which submits a governed Share Withdrawal Request through the same maker-checker
|
||||||
|
// table as the dedicated screen; approval both redeems the shares and settles the linked account.
|
||||||
|
const shareDrRows = drRows.filter((r) => r.acTyp === 'SHR');
|
||||||
|
if (shareDrRows.length > 0) {
|
||||||
|
if (drRows.length !== 1 || crRows.length !== 1) {
|
||||||
|
this.showAlert('error', 'Unsupported Share Transfer', [
|
||||||
|
'A Share redemption transfer supports exactly one DR row (the Share account) and one CR row (its linked savings account).'
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.submitShareRedemptionTransfer(shareDrRows[0], crRows[0]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Bank transfer: only savings (SD) or current (CA/CB) accounts allowed
|
// Bank transfer: only savings (SD) or current (CA/CB) accounts allowed
|
||||||
const allowedTypes = new Set([
|
const allowedTypes = new Set([
|
||||||
'SD',
|
'SD',
|
||||||
@@ -3004,6 +3296,24 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
const accountId = this.accountDetails.id;
|
const accountId = this.accountDetails.id;
|
||||||
const cfg = this.accountTypeConfig(row.acTyp);
|
const cfg = this.accountTypeConfig(row.acTyp);
|
||||||
|
|
||||||
|
// Share accounts must never go through this generic Deposit/Withdrawal Slip flow — both
|
||||||
|
// buildDepositPayload() and buildWithdrawalPayload() hardcode accountType: 'SAVINGS' and send
|
||||||
|
// savingsAccountId: this.accountDetails.id, which for a Share account is the SHARE account's
|
||||||
|
// own numeric id, not a real savings account id. Approving that request would call
|
||||||
|
// savingsAccountWithdrawal/Deposit against whatever savings account happens to share that same
|
||||||
|
// numeric id — a completely unrelated account. Share purchase/redemption must go through the
|
||||||
|
// dedicated Buy Shares / Share Withdrawal Request screens, which carry the correct governance
|
||||||
|
// (resolution number, meeting date, minimum-tenure and linked-loan checks).
|
||||||
|
if (row.acTyp === 'SHR') {
|
||||||
|
this.showAlert('error', 'Not Supported for Share Accounts', [
|
||||||
|
isDeposit
|
||||||
|
? 'Share purchases must be made from the Buy Shares screen on the Share Account itself.'
|
||||||
|
: 'Share withdrawal (redeem shares) must be submitted from "New Share Withdrawal Request" in the Clerk Dashboard.',
|
||||||
|
'This generic Deposit/Withdrawal Slip does not carry the Board Resolution Number or the governance checks required for shares.'
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// GL withdrawal → direct journal entry (no maker-checker)
|
// GL withdrawal → direct journal entry (no maker-checker)
|
||||||
// GL deposit → falls through to maker-checker block below
|
// GL deposit → falls through to maker-checker block below
|
||||||
if (row.acTyp === 'GL' && !isDeposit) {
|
if (row.acTyp === 'GL' && !isDeposit) {
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Small shared layout/status-pill classes for the Share Withdrawal maker-checker screens —
|
||||||
|
* self-contained (this branch has no separate "investment" module to borrow them from). */
|
||||||
|
|
||||||
|
.inv-form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inv-form-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #64748b;
|
||||||
|
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #0d1b2a;
|
||||||
|
font-family: inherit;
|
||||||
|
background: #fff;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
height: auto;
|
||||||
|
padding: 8px 10px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.inv-form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 16px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inv-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #475569;
|
||||||
|
|
||||||
|
&--active {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--matured {
|
||||||
|
background: #dcfce7;
|
||||||
|
color: #166534;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--written_off {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
}
|
||||||
+73
@@ -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/.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<div class="pg">
|
||||||
|
<header class="pg-head">
|
||||||
|
<div>
|
||||||
|
<div class="pg-eyebrow">CLERK · SHARE WITHDRAWAL</div>
|
||||||
|
<h1 class="pg-title">My Share Requests</h1>
|
||||||
|
<p class="pg-sub">Share withdrawal requests you have submitted, and their approval status.</p>
|
||||||
|
</div>
|
||||||
|
<div class="pg-toolbar">
|
||||||
|
<button type="button" class="pg-btn pg-btn--primary" (click)="newRequest()">New Share Withdrawal</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="inv-form-grid" style="padding: 12px 16px">
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Status</span>
|
||||||
|
<select [(ngModel)]="filterStatus" (change)="onFilterChange()">
|
||||||
|
@for (opt of statusOptions; track opt.value) {
|
||||||
|
<option [value]="opt.value">{{ opt.label }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<table class="pg-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Account No</th>
|
||||||
|
<th>Client</th>
|
||||||
|
<th class="num">Shares</th>
|
||||||
|
<th>Resolution No.</th>
|
||||||
|
<th>Meeting Date</th>
|
||||||
|
<th>Submitted At</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@if (loading) {
|
||||||
|
<tr>
|
||||||
|
<td colspan="7" class="pg-empty">Loading…</td>
|
||||||
|
</tr>
|
||||||
|
} @else if (loaded && requests.length === 0) {
|
||||||
|
<tr>
|
||||||
|
<td colspan="7" class="pg-empty">No share withdrawal requests found.</td>
|
||||||
|
</tr>
|
||||||
|
} @else {
|
||||||
|
@for (r of requests; track r.id) {
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<span class="pg-chip">{{ r.account_no }}</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ r.client_name }}</td>
|
||||||
|
<td class="num">{{ r.requested_shares }}</td>
|
||||||
|
<td>{{ r.resolution_number }}</td>
|
||||||
|
<td>{{ formatDateTime(r.meeting_date) }}</td>
|
||||||
|
<td>{{ formatDateTime(r.submitted_at) }}</td>
|
||||||
|
<td>
|
||||||
|
<span [class]="statusClass(r.status)">{{ statusLabel(r.status) }}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
/**
|
||||||
|
* 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 { Router, RouterModule } from '@angular/router';
|
||||||
|
import { catchError } from 'rxjs/operators';
|
||||||
|
import { of } from 'rxjs';
|
||||||
|
|
||||||
|
import { ClerkShareWithdrawalService } from '../../cashier/clerk-share-withdrawal.service';
|
||||||
|
|
||||||
|
/** Clerk Dashboard "My Share Requests" — lists the share withdrawal requests this clerk has
|
||||||
|
* submitted and their approval status. Mirrors MyRequestsComponent/MyDisbursementRequestsComponent. */
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-my-share-withdrawal-requests',
|
||||||
|
templateUrl: './my-share-withdrawal-requests.component.html',
|
||||||
|
styleUrls: [
|
||||||
|
'../../cashier/cashier-shared.scss',
|
||||||
|
'../clerk-shared.scss'
|
||||||
|
],
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
RouterModule
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class MyShareWithdrawalRequestsComponent implements OnInit {
|
||||||
|
private service = inject(ClerkShareWithdrawalService);
|
||||||
|
private router = inject(Router);
|
||||||
|
|
||||||
|
requests: any[] = [];
|
||||||
|
totalRecords = 0;
|
||||||
|
loading = false;
|
||||||
|
loaded = false;
|
||||||
|
|
||||||
|
filterStatus = '';
|
||||||
|
|
||||||
|
get statusOptions(): { value: string; label: string }[] {
|
||||||
|
return [
|
||||||
|
{ value: '', label: 'All' },
|
||||||
|
{ value: 'PENDING', label: 'Pending' },
|
||||||
|
{ value: 'APPROVED', label: 'Approved' },
|
||||||
|
{ value: 'REJECTED', label: 'Rejected' }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
load(): void {
|
||||||
|
this.loading = true;
|
||||||
|
this.service
|
||||||
|
.getMyRequests(this.filterStatus || undefined, 0, 50)
|
||||||
|
.pipe(catchError(() => of({ pageItems: [], totalFilteredRecords: 0 })))
|
||||||
|
.subscribe((res: any) => {
|
||||||
|
this.requests = res?.pageItems ?? [];
|
||||||
|
this.totalRecords = Number(res?.totalFilteredRecords ?? 0);
|
||||||
|
this.loading = false;
|
||||||
|
this.loaded = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onFilterChange(): void {
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
newRequest(): void {
|
||||||
|
this.router.navigate(['/clerk/share-withdrawal-request']);
|
||||||
|
}
|
||||||
|
|
||||||
|
statusClass(status: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
PENDING: 'inv-badge--active',
|
||||||
|
APPROVED: 'inv-badge--matured',
|
||||||
|
REJECTED: 'inv-badge--written_off'
|
||||||
|
};
|
||||||
|
return `inv-badge ${map[status] ?? ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
statusLabel(status: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
PENDING: 'Pending Approval',
|
||||||
|
APPROVED: 'Approved',
|
||||||
|
REJECTED: 'Rejected'
|
||||||
|
};
|
||||||
|
return map[status] ?? status;
|
||||||
|
}
|
||||||
|
|
||||||
|
formatDateTime(val: any): string {
|
||||||
|
if (!val) return '—';
|
||||||
|
try {
|
||||||
|
const d = new Date(val);
|
||||||
|
return d.toLocaleString('en-IN', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return String(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,431 @@
|
|||||||
|
<!--
|
||||||
|
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="pg">
|
||||||
|
<header class="pg-head">
|
||||||
|
<div>
|
||||||
|
<div class="pg-eyebrow">{{ isReview ? 'CHECKER · APPROVAL' : 'CLERK · SHARE WITHDRAWAL' }}</div>
|
||||||
|
<h1 class="pg-title">{{ pageTitle }}</h1>
|
||||||
|
<p class="pg-sub">{{ pageSubtitle }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="pg-toolbar">
|
||||||
|
<button type="button" class="pg-btn" (click)="goBack()">
|
||||||
|
<mat-icon>arrow_back</mat-icon>
|
||||||
|
{{ isReview ? 'Back to Requests' : 'My Share Requests' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="pos-section-label">Share Account</div>
|
||||||
|
<div class="inv-form-grid" style="padding: 16px">
|
||||||
|
@if (!isReview) {
|
||||||
|
<label class="inv-form-field" style="grid-column: span 2">
|
||||||
|
<span>* Search Account No. / Client Name</span>
|
||||||
|
<mifosx-search-select
|
||||||
|
[options]="accountOptions"
|
||||||
|
[(ngModel)]="selectedAccountId"
|
||||||
|
(ngModelChange)="onAccountSelected($event)"
|
||||||
|
placeholder="Search share account no. or client name…"
|
||||||
|
></mifosx-search-select>
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
@if (account) {
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Account No</span>
|
||||||
|
<input type="text" [value]="account.accountNo" readonly />
|
||||||
|
</label>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Client</span>
|
||||||
|
<input type="text" [value]="account.clientName" readonly />
|
||||||
|
</label>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Shares Held</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
[value]="account.summary?.totalApprovedShares ?? account.totalApprovedShares ?? 0"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Unit Price</span>
|
||||||
|
<input type="text" [value]="'₹' + (unitPrice | number: '1.2-2')" readonly />
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
@if (loadingAccount) {
|
||||||
|
<div class="pg-empty">Loading account…</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (isReview && reviewRequest) {
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="pos-section-label">Client Compliance</div>
|
||||||
|
<div class="inv-form-grid" style="padding: 16px">
|
||||||
|
<div class="inv-form-field">
|
||||||
|
<span>KYC Status</span>
|
||||||
|
<span
|
||||||
|
class="swr-chip"
|
||||||
|
[class.swr-chip--ok]="reviewRequest.client_kyc_verified"
|
||||||
|
[class.swr-chip--warn]="!reviewRequest.client_kyc_verified"
|
||||||
|
>
|
||||||
|
{{ reviewRequest.client_kyc_status_label }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="inv-form-field">
|
||||||
|
<span>Risk Category</span>
|
||||||
|
<span
|
||||||
|
class="swr-chip"
|
||||||
|
[class.swr-chip--ok]="!reviewRequest.client_risk_high"
|
||||||
|
[class.swr-chip--warn]="reviewRequest.client_risk_high"
|
||||||
|
>
|
||||||
|
{{ reviewRequest.client_risk_category_label }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="inv-form-field">
|
||||||
|
<span>Open AML Alerts</span>
|
||||||
|
<span
|
||||||
|
class="swr-chip"
|
||||||
|
[class.swr-chip--ok]="!reviewRequest.client_open_aml_alerts"
|
||||||
|
[class.swr-chip--warn]="reviewRequest.client_open_aml_alerts > 0"
|
||||||
|
>
|
||||||
|
{{ reviewRequest.client_open_aml_alerts || 0 }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (account) {
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="pos-section-label">Withdrawal Details</div>
|
||||||
|
<div class="inv-form-grid" style="padding: 16px">
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>* Shares to Withdraw</span>
|
||||||
|
<input type="number" [(ngModel)]="requestedShares" min="1" [disabled]="isReview" />
|
||||||
|
</label>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>* Requested Date</span>
|
||||||
|
<input type="date" [(ngModel)]="requestedDate" [disabled]="isReview" />
|
||||||
|
</label>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Withdrawal Value</span>
|
||||||
|
<input type="text" [value]="'₹' + (withdrawalValue | number: '1.2-2')" readonly />
|
||||||
|
</label>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Remaining Shares After Withdrawal</span>
|
||||||
|
<input type="text" [value]="remainingShares" readonly />
|
||||||
|
</label>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>* Board Resolution Number</span>
|
||||||
|
<input type="text" [(ngModel)]="resolutionNumber" [disabled]="isReview" placeholder="e.g. BR-2026-014" />
|
||||||
|
</label>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>* Date of Meeting</span>
|
||||||
|
<input type="date" [(ngModel)]="meetingDate" [disabled]="isReview" />
|
||||||
|
</label>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>* Settlement Payment Type</span>
|
||||||
|
@if (!isReview) {
|
||||||
|
<select [(ngModel)]="settlementPaymentTypeId">
|
||||||
|
<option [ngValue]="null">Select…</option>
|
||||||
|
@for (pt of paymentTypeOptions; track pt.id) {
|
||||||
|
<option [ngValue]="pt.id">{{ pt.name }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
} @else {
|
||||||
|
<input type="text" [value]="settlementPaymentTypeName" readonly />
|
||||||
|
}
|
||||||
|
</label>
|
||||||
|
<label class="inv-form-field" style="grid-column: span 2">
|
||||||
|
<span>Description / Remarks</span>
|
||||||
|
<input type="text" [(ngModel)]="description" [disabled]="isReview" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!isReview) {
|
||||||
|
<div class="inv-form-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pg-btn pg-btn--primary"
|
||||||
|
[disabled]="!isFormValid || submitting"
|
||||||
|
(click)="openConfirmDialog()"
|
||||||
|
>
|
||||||
|
{{ submitting ? 'Submitting…' : 'Review & Submit' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (isReview && reviewRequest?.status === 'PENDING') {
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="pos-section-label">Verification</div>
|
||||||
|
<div class="swrv-trigger">
|
||||||
|
<div class="swrv-trigger__icon">
|
||||||
|
<mat-icon>fact_check</mat-icon>
|
||||||
|
</div>
|
||||||
|
<div class="swrv-trigger__text">
|
||||||
|
<div class="swrv-trigger__title">Run a live backend validation</div>
|
||||||
|
<div class="swrv-trigger__sub">
|
||||||
|
Re-checks account status, share balance, linked savings account and GL mapping before you approve.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="pg-btn pg-btn--primary" (click)="openValidateModal()">
|
||||||
|
<mat-icon>play_circle</mat-icon>
|
||||||
|
Validate Request
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="inv-form-actions">
|
||||||
|
<button type="button" class="pg-btn pg-btn--primary" (click)="approveReview()">
|
||||||
|
<mat-icon>check_circle</mat-icon>
|
||||||
|
Approve & Process Withdrawal
|
||||||
|
</button>
|
||||||
|
<button type="button" class="pg-btn pg-btn--danger" (click)="openRejectDialog()">
|
||||||
|
<mat-icon>cancel</mat-icon>
|
||||||
|
Reject
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (showValidateModal) {
|
||||||
|
<div class="swrv-modal">
|
||||||
|
<div class="swrv-modal__card">
|
||||||
|
<div class="swrv-modal__header">
|
||||||
|
<div class="swrv-modal__title">
|
||||||
|
<span class="swrv-modal__title-icon"><mat-icon>fact_check</mat-icon></span>
|
||||||
|
Validate Share Withdrawal Request
|
||||||
|
</div>
|
||||||
|
<button type="button" class="swrv-modal__close" [disabled]="validating" (click)="closeValidateModal()">
|
||||||
|
<mat-icon>close</mat-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="swrv-modal__body">
|
||||||
|
<p class="swrv-modal__intro">
|
||||||
|
Re-checking <strong>{{ requestedShares }} share(s)</strong> withdrawal from
|
||||||
|
<strong>{{ account?.accountNo }}</strong> ({{ account?.clientName }}) against the live account, savings link
|
||||||
|
and GL configuration — not just what was captured at submission time.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
@if (backendChecks && !validating && !validateAllPassed) {
|
||||||
|
<div class="swrv-modal__warn">
|
||||||
|
<mat-icon>warning</mat-icon>
|
||||||
|
<div>
|
||||||
|
<strong>Some checks need review before approving.</strong>
|
||||||
|
Scroll down to see which ones failed and why.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<ol class="swrv-steps">
|
||||||
|
@for (step of validateSteps; track step.label; let i = $index) {
|
||||||
|
<li
|
||||||
|
class="swrv-step"
|
||||||
|
[class.swrv-step--active]="step.status === 'active'"
|
||||||
|
[class.swrv-step--done]="step.status === 'done'"
|
||||||
|
[class.swrv-step--failed]="step.status === 'failed'"
|
||||||
|
>
|
||||||
|
<div class="swrv-step__indicator">
|
||||||
|
@if (step.status === 'done') {
|
||||||
|
<mat-icon>check</mat-icon>
|
||||||
|
} @else if (step.status === 'active') {
|
||||||
|
<mat-icon class="swrv-spin">autorenew</mat-icon>
|
||||||
|
} @else if (step.status === 'failed') {
|
||||||
|
<mat-icon>close</mat-icon>
|
||||||
|
} @else {
|
||||||
|
<span>{{ i + 1 }}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="swrv-step__content">
|
||||||
|
<div class="swrv-step__label">{{ step.label }}</div>
|
||||||
|
<div class="swrv-step__msg">{{ step.detail }}</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
@if (validating && validateSteps.length === 0) {
|
||||||
|
<div class="pg-empty">Connecting to server…</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (backendChecks && !validating && validateAllPassed) {
|
||||||
|
<div class="swrv-modal__result">
|
||||||
|
<mat-icon>verified</mat-icon>
|
||||||
|
<span>All checks passed — safe to approve.</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="swrv-modal__footer">
|
||||||
|
@if (!validating && backendChecks) {
|
||||||
|
<button type="button" class="pg-btn" (click)="runBackendValidation()">
|
||||||
|
<mat-icon>refresh</mat-icon>
|
||||||
|
Re-run
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
@if (!validating && backendChecks && validateAllPassed) {
|
||||||
|
<button type="button" class="pg-btn pg-btn--primary" (click)="closeValidateModal(); approveReview()">
|
||||||
|
<mat-icon>check_circle</mat-icon>
|
||||||
|
Approve & Process Withdrawal
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
<button type="button" class="pg-btn" [disabled]="validating" (click)="closeValidateModal()">
|
||||||
|
{{ validating ? 'Validating…' : 'Close' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (isReview && reviewRequest?.status === 'APPROVED') {
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="pos-section-label">Settlement</div>
|
||||||
|
<div class="inv-form-grid" style="padding: 16px">
|
||||||
|
@if (reviewRequest?.settlement_amount) {
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Amount Credited</span>
|
||||||
|
<input type="text" [value]="'₹' + (reviewRequest.settlement_amount | number: '1.2-2')" readonly />
|
||||||
|
</label>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Credited To (Linked Savings A/C)</span>
|
||||||
|
<input type="text" [value]="account?.savingsAccountNumber || '—'" readonly />
|
||||||
|
</label>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Savings Transaction Ref.</span>
|
||||||
|
<input type="text" [value]="reviewRequest.settlement_savings_transaction_id" readonly />
|
||||||
|
</label>
|
||||||
|
} @else {
|
||||||
|
<div class="pg-empty" style="grid-column: span 2">
|
||||||
|
Settlement is processed automatically on approval. Details aren't available when reopening an
|
||||||
|
already-approved request — check the linked savings account's transaction history to confirm.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (isReview && activityLog.length > 0) {
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="pos-section-label">Activity Log</div>
|
||||||
|
<table class="pg-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Timestamp</th>
|
||||||
|
<th>Action</th>
|
||||||
|
<th>User</th>
|
||||||
|
<th>Note</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (entry of activityLog; track entry.timestamp) {
|
||||||
|
<tr>
|
||||||
|
<td>{{ entry.timestamp | date: 'medium' }}</td>
|
||||||
|
<td>{{ entry.action }}</td>
|
||||||
|
<td>{{ entry.user }}</td>
|
||||||
|
<td>{{ entry.note || '—' }}</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (showConfirmDialog) {
|
||||||
|
<div class="swr-modal-backdrop">
|
||||||
|
<div class="swr-modal">
|
||||||
|
<h3>Confirm Share Withdrawal Request</h3>
|
||||||
|
<p class="pg-sub">Review the details below before submitting for Bank Manager approval.</p>
|
||||||
|
<table class="pg-table">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>Account No / Client</td>
|
||||||
|
<td>{{ account?.accountNo }} — {{ account?.clientName }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Shares to Withdraw</td>
|
||||||
|
<td>
|
||||||
|
{{ requestedShares }} @ ₹{{ unitPrice | number: '1.2-2' }} = ₹{{ withdrawalValue | number: '1.2-2' }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Remaining Shares After Withdrawal</td>
|
||||||
|
<td>{{ remainingShares }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Requested Date</td>
|
||||||
|
<td>{{ requestedDate }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Board Resolution Number</td>
|
||||||
|
<td>{{ resolutionNumber }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Date of Meeting</td>
|
||||||
|
<td>{{ meetingDate }}</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>
|
||||||
|
|
||||||
|
<div class="pos-section-label" style="margin-top: 12px">Validation Checks</div>
|
||||||
|
<table class="pg-table">
|
||||||
|
<tbody>
|
||||||
|
@for (check of verificationChecks; track check.label) {
|
||||||
|
<tr>
|
||||||
|
<td>{{ check.label }}</td>
|
||||||
|
<td [class.swr-check-pass]="check.pass" [class.swr-check-fail]="!check.pass">
|
||||||
|
{{ check.pass ? 'Pass — ' : 'Review — ' }}{{ check.detail }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="inv-form-actions">
|
||||||
|
<button type="button" class="pg-btn pg-btn--primary" [disabled]="submitting" (click)="confirmSubmit()">
|
||||||
|
{{ submitting ? 'Submitting…' : 'Confirm & Submit' }}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="pg-btn" (click)="cancelConfirmDialog()">Back to Edit</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (showRejectDialog) {
|
||||||
|
<div class="swr-modal-backdrop">
|
||||||
|
<div class="swr-modal">
|
||||||
|
<h3>Reject Share Withdrawal Request</h3>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>* Rejection Reason</span>
|
||||||
|
<textarea rows="3" [(ngModel)]="rejectionReason"></textarea>
|
||||||
|
</label>
|
||||||
|
<div class="inv-form-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pg-btn pg-btn--danger"
|
||||||
|
[disabled]="!rejectionReason.trim()"
|
||||||
|
(click)="confirmRejectReview()"
|
||||||
|
>
|
||||||
|
Confirm Reject
|
||||||
|
</button>
|
||||||
|
<button type="button" class="pg-btn" (click)="showRejectDialog = false">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,433 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.swr-modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgb(13 27 42 / 55%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swr-modal {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
width: 420px;
|
||||||
|
max-width: 90vw;
|
||||||
|
max-height: 85vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
box-shadow: 0 12px 32px rgb(15 23 42 / 20%);
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #0d1b2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
resize: vertical;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:has(table) {
|
||||||
|
width: 560px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.swr-check-pass {
|
||||||
|
color: #1a7f37;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swr-check-fail {
|
||||||
|
color: #b42318;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swr-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
width: fit-content;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
&--ok {
|
||||||
|
background: #eafaf0;
|
||||||
|
color: #1a7f37;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--warn {
|
||||||
|
background: #fdecea;
|
||||||
|
color: #b42318;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Verification trigger card ---------- */
|
||||||
|
.swrv-trigger {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
&__icon {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: linear-gradient(135deg, #3457d5, #6c8cff);
|
||||||
|
color: #fff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-shadow: 0 4px 12px rgb(52 87 213 / 30%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__text {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0d1b2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__sub {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #64748b;
|
||||||
|
margin-top: 2px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Validate pipeline modal ---------- */
|
||||||
|
@keyframes swrv-fade {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes swrv-zoom {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.95) translateY(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1) translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes swrv-spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.swrv-spin {
|
||||||
|
animation: swrv-spin 0.9s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swrv-modal {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
// No backdrop-filter: it would make this element a containing block for any position:fixed
|
||||||
|
// descendant (e.g. if this modal is ever nested inside another fixed popup, or gains one of its
|
||||||
|
// own later) — see the identical fix in share-withdrawal-requests.component.scss for the bug this
|
||||||
|
// caused. Extra opacity compensates for the loss of the blur look.
|
||||||
|
background: rgb(15 25 45 / 65%);
|
||||||
|
z-index: 1200;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
animation: swrv-fade 0.2s ease-out;
|
||||||
|
|
||||||
|
&__card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 520px;
|
||||||
|
max-height: 90vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 24px 60px rgb(0 0 0 / 25%);
|
||||||
|
animation: swrv-zoom 0.25s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 18px 22px;
|
||||||
|
border-bottom: 1px solid #eef1f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0d1b2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title-icon {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: linear-gradient(135deg, #3457d5, #6c8cff);
|
||||||
|
color: #fff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__close {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
background: #fff;
|
||||||
|
color: #64748b;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.12s;
|
||||||
|
|
||||||
|
&:not(:disabled):hover {
|
||||||
|
color: #b42318;
|
||||||
|
border-color: #b42318;
|
||||||
|
background: #fdecea;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__body {
|
||||||
|
padding: 20px 22px;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__intro {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #475569;
|
||||||
|
line-height: 1.55;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
color: #0d1b2a;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__warn {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: #fef3e2;
|
||||||
|
border: 1px solid rgb(217 119 6 / 30%);
|
||||||
|
border-left: 3px solid #d97706;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #633806;
|
||||||
|
line-height: 1.5;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
color: #d97706;
|
||||||
|
font-size: 18px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
strong {
|
||||||
|
display: block;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #4d2a05;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__result {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 6px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: #eafaf0;
|
||||||
|
border: 1px solid rgb(26 127 55 / 25%);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a7f37;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__footer {
|
||||||
|
padding: 16px 22px;
|
||||||
|
border-top: 1px solid #eef1f5;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.swrv-steps {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swrv-step {
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 10px 0;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&:not(:last-child)::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 15px;
|
||||||
|
top: 38px;
|
||||||
|
bottom: -4px;
|
||||||
|
width: 2px;
|
||||||
|
background: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__indicator {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #fff;
|
||||||
|
border: 2px solid #e2e8f0;
|
||||||
|
color: #64748b;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
transition: all 0.25s;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__content {
|
||||||
|
padding-top: 5px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__msg {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #94a3b8;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--active {
|
||||||
|
.swrv-step__indicator {
|
||||||
|
background: #3457d5;
|
||||||
|
border-color: #3457d5;
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 0 0 4px rgb(52 87 213 / 15%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.swrv-step__label {
|
||||||
|
color: #3457d5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--done {
|
||||||
|
.swrv-step__indicator {
|
||||||
|
background: #1a7f37;
|
||||||
|
border-color: #1a7f37;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
background: #1a7f37;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swrv-step__label {
|
||||||
|
color: #0d1b2a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--failed {
|
||||||
|
.swrv-step__indicator {
|
||||||
|
background: #b42318;
|
||||||
|
border-color: #b42318;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swrv-step__label {
|
||||||
|
color: #b42318;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.swrv-spin {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swrv-modal,
|
||||||
|
.swrv-modal__card {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,413 @@
|
|||||||
|
/**
|
||||||
|
* 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, EventEmitter, Input, OnInit, Output, inject } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
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';
|
||||||
|
import { catchError } from 'rxjs/operators';
|
||||||
|
import { of } from 'rxjs';
|
||||||
|
|
||||||
|
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';
|
||||||
|
|
||||||
|
export type ShareWdMode = 'create' | 'review';
|
||||||
|
|
||||||
|
interface ActivityEntry {
|
||||||
|
timestamp: string;
|
||||||
|
action: string;
|
||||||
|
user: string;
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Share Withdrawal (redeem shares) request — maker screen in the Clerk Dashboard. A clerk finds
|
||||||
|
* the member's Share Account, enters how many shares to withdraw plus the governing Board
|
||||||
|
* Resolution Number / Date of Meeting, and submits for Bank Manager approval. This is the ONLY
|
||||||
|
* entry point a share withdrawal should go through — approval server-side executes the real
|
||||||
|
* "redeemshares" command (see ClerkShareWithdrawalRequestApiResource on the backend).
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-share-withdrawal-request',
|
||||||
|
templateUrl: './share-withdrawal-request.component.html',
|
||||||
|
styleUrls: [
|
||||||
|
'../../cashier/cashier-shared.scss',
|
||||||
|
'../clerk-shared.scss',
|
||||||
|
'./share-withdrawal-request.component.scss'
|
||||||
|
],
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
RouterModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatSnackBarModule,
|
||||||
|
SearchSelectComponent
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class ShareWithdrawalRequestComponent implements OnInit {
|
||||||
|
private route = inject(ActivatedRoute);
|
||||||
|
private router = inject(Router);
|
||||||
|
private sharesService = inject(SharesService);
|
||||||
|
private wdService = inject(ClerkShareWithdrawalService);
|
||||||
|
private cashierService = inject(CashierService);
|
||||||
|
private snackBar = inject(MatSnackBar);
|
||||||
|
|
||||||
|
/** When embedded (e.g. opened as a popup from a list screen instead of routed to directly), mode
|
||||||
|
* and requestId come from these inputs rather than the route, and success/close actions emit
|
||||||
|
* events instead of navigating away. */
|
||||||
|
@Input() embedded = false;
|
||||||
|
@Input() overrideMode: ShareWdMode | null = null;
|
||||||
|
@Input() overrideRequestId: number | null = null;
|
||||||
|
@Output() closeRequested = new EventEmitter<void>();
|
||||||
|
@Output() actionCompleted = new EventEmitter<void>();
|
||||||
|
|
||||||
|
mode: ShareWdMode = 'create';
|
||||||
|
requestId: number | null = null;
|
||||||
|
|
||||||
|
submitting = false;
|
||||||
|
loadingAccount = false;
|
||||||
|
|
||||||
|
selectedAccountId = '';
|
||||||
|
accountOptions: SearchSelectOption[] = [];
|
||||||
|
|
||||||
|
account: any = null;
|
||||||
|
|
||||||
|
requestedShares: number | null = null;
|
||||||
|
requestedDate = '';
|
||||||
|
resolutionNumber = '';
|
||||||
|
meetingDate = '';
|
||||||
|
description = '';
|
||||||
|
|
||||||
|
paymentTypes: any[] = [];
|
||||||
|
settlementPaymentTypeId: number | null = null;
|
||||||
|
|
||||||
|
activityLog: ActivityEntry[] = [];
|
||||||
|
reviewRequest: any = null;
|
||||||
|
showRejectDialog = false;
|
||||||
|
rejectionReason = '';
|
||||||
|
showConfirmDialog = false;
|
||||||
|
|
||||||
|
showValidateModal = false;
|
||||||
|
validating = false;
|
||||||
|
validateAllPassed = false;
|
||||||
|
backendChecks: { label: string; pass: boolean; detail: string }[] | null = null;
|
||||||
|
validateSteps: { label: string; detail: string; pass: boolean; status: 'pending' | 'active' | 'done' | 'failed' }[] =
|
||||||
|
[];
|
||||||
|
|
||||||
|
get remainingShares(): number {
|
||||||
|
const held = Number(this.account?.summary?.totalApprovedShares ?? this.account?.totalApprovedShares ?? 0);
|
||||||
|
return held - (this.requestedShares || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
get unitPrice(): number {
|
||||||
|
return Number(this.account?.currentMarketPrice ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
get withdrawalValue(): number {
|
||||||
|
return (this.requestedShares || 0) * this.unitPrice;
|
||||||
|
}
|
||||||
|
|
||||||
|
get paymentTypeOptions(): any[] {
|
||||||
|
return this.paymentTypes.filter((pt: any) => !pt.isCashPayment);
|
||||||
|
}
|
||||||
|
|
||||||
|
get selectedSettlementPaymentType(): any {
|
||||||
|
return this.paymentTypes.find((pt: any) => pt.id === this.settlementPaymentTypeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
get settlementPaymentTypeName(): string {
|
||||||
|
if (this.isReview) {
|
||||||
|
return this.reviewRequest?.settlement_payment_type_name || '—';
|
||||||
|
}
|
||||||
|
return this.selectedSettlementPaymentType?.name || '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
get isFormValid(): boolean {
|
||||||
|
if (!this.account) return false;
|
||||||
|
if (!this.requestedShares || this.requestedShares <= 0) return false;
|
||||||
|
const held = Number(this.account?.summary?.totalApprovedShares ?? this.account?.totalApprovedShares ?? 0);
|
||||||
|
if (this.requestedShares > held) return false;
|
||||||
|
if (!this.requestedDate) return false;
|
||||||
|
if (!this.resolutionNumber.trim()) return false;
|
||||||
|
if (!this.meetingDate) return false;
|
||||||
|
if (!this.settlementPaymentTypeId) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shown to the clerk before submit and to the manager before approval, so both can verify the same checks. */
|
||||||
|
get verificationChecks(): { label: string; pass: boolean; detail: string }[] {
|
||||||
|
const held = Number(this.account?.summary?.totalApprovedShares ?? this.account?.totalApprovedShares ?? 0);
|
||||||
|
const shares = this.requestedShares || 0;
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: 'Board Resolution Number provided',
|
||||||
|
pass: !!this.resolutionNumber?.trim(),
|
||||||
|
detail: this.resolutionNumber?.trim() || 'Missing'
|
||||||
|
},
|
||||||
|
{ label: 'Date of Meeting provided', pass: !!this.meetingDate, detail: this.meetingDate || 'Missing' },
|
||||||
|
{
|
||||||
|
label: 'Shares requested within shares held',
|
||||||
|
pass: shares > 0 && shares <= held,
|
||||||
|
detail: `${shares} of ${held} shares held`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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 !== '—',
|
||||||
|
detail: this.settlementPaymentTypeName
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
get isReview(): boolean {
|
||||||
|
return this.mode === 'review';
|
||||||
|
}
|
||||||
|
|
||||||
|
get pageTitle(): string {
|
||||||
|
return this.isReview ? 'Review Share Withdrawal Request' : 'New Share Withdrawal Request';
|
||||||
|
}
|
||||||
|
|
||||||
|
get pageSubtitle(): string {
|
||||||
|
return this.isReview
|
||||||
|
? "Approve or reject the clerk's share withdrawal request."
|
||||||
|
: 'Submit a share withdrawal (redeem shares) request for Bank Manager approval.';
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.mode = this.overrideMode ?? (this.route.snapshot.data?.['mode'] as ShareWdMode) ?? 'create';
|
||||||
|
const idParam = this.overrideRequestId ?? this.route.snapshot.params?.['id'];
|
||||||
|
this.requestId = idParam ? Number(idParam) : null;
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
this.requestedDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
||||||
|
this.meetingDate = this.requestedDate;
|
||||||
|
|
||||||
|
this.sharesService
|
||||||
|
.getShareAccountSummaries()
|
||||||
|
.pipe(catchError(() => of([])))
|
||||||
|
.subscribe((rows: any[]) => {
|
||||||
|
this.accountOptions = (rows || [])
|
||||||
|
.filter((r) => (r.status?.value || '').toLowerCase() === 'active')
|
||||||
|
.map((r) => ({
|
||||||
|
value: String(r.id),
|
||||||
|
label: `${r.accountNo} — ${r.clientName} (${r.totalApprovedShares} shares)`,
|
||||||
|
searchText: `${r.accountNo} ${r.clientName}`
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
this.cashierService
|
||||||
|
.getPaymentTypes()
|
||||||
|
.pipe(catchError(() => of([])))
|
||||||
|
.subscribe((types: any[]) => (this.paymentTypes = types ?? []));
|
||||||
|
|
||||||
|
if (this.requestId && this.mode === 'review') {
|
||||||
|
this.loadReviewRequest(this.requestId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onAccountSelected(accountId: string): void {
|
||||||
|
this.selectedAccountId = accountId;
|
||||||
|
if (!accountId) {
|
||||||
|
this.account = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.loadingAccount = true;
|
||||||
|
this.sharesService
|
||||||
|
.getSharesAccountData(accountId, false)
|
||||||
|
.pipe(catchError(() => of(null)))
|
||||||
|
.subscribe((acc: any) => {
|
||||||
|
this.account = acc;
|
||||||
|
this.loadingAccount = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadReviewRequest(id: number): void {
|
||||||
|
this.wdService.getById(id).subscribe({
|
||||||
|
next: (req: any) => {
|
||||||
|
this.reviewRequest = req;
|
||||||
|
this.requestedShares = Number(req.requested_shares);
|
||||||
|
this.requestedDate = req.requested_date
|
||||||
|
? String(req.requested_date).split('T')[0].split(' ')[0]
|
||||||
|
: this.requestedDate;
|
||||||
|
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 ?? '';
|
||||||
|
|
||||||
|
if (req.activity_log) {
|
||||||
|
try {
|
||||||
|
this.activityLog = JSON.parse(req.activity_log);
|
||||||
|
} catch {
|
||||||
|
this.activityLog = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.sharesService
|
||||||
|
.getSharesAccountData(String(req.share_account_id), false)
|
||||||
|
.pipe(catchError(() => of(null)))
|
||||||
|
.subscribe((acc: any) => (this.account = acc));
|
||||||
|
},
|
||||||
|
error: () => this.snackBar.open('Failed to load share withdrawal request.', 'Close', { duration: 5000 })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Review before Save" per requirement — opens the confirm dialog instead of submitting directly. */
|
||||||
|
openConfirmDialog(): void {
|
||||||
|
if (!this.isFormValid || this.submitting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.showConfirmDialog = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelConfirmDialog(): void {
|
||||||
|
this.showConfirmDialog = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmSubmit(): void {
|
||||||
|
this.showConfirmDialog = false;
|
||||||
|
this.submitting = true;
|
||||||
|
const payload = {
|
||||||
|
shareAccountId: Number(this.selectedAccountId),
|
||||||
|
accountNo: this.account?.accountNo,
|
||||||
|
clientId: this.account?.clientId,
|
||||||
|
clientName: this.account?.clientName,
|
||||||
|
requestedShares: this.requestedShares,
|
||||||
|
requestedDate: this.requestedDate,
|
||||||
|
resolutionNumber: this.resolutionNumber.trim(),
|
||||||
|
meetingDate: this.meetingDate,
|
||||||
|
description: this.description,
|
||||||
|
settlementPaymentTypeId: this.settlementPaymentTypeId
|
||||||
|
};
|
||||||
|
this.wdService.submit(payload).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.snackBar.open('Share withdrawal request submitted for approval.', 'Close', { duration: 4000 });
|
||||||
|
if (this.embedded) {
|
||||||
|
this.actionCompleted.emit();
|
||||||
|
} else {
|
||||||
|
this.router.navigate(['/clerk/dashboard']);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.submitting = false;
|
||||||
|
this.snackBar.open(err?.error?.defaultUserMessage || 'Could not submit the request.', 'Close', {
|
||||||
|
duration: 6000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
openValidateModal(): void {
|
||||||
|
if (!this.reviewRequest) return;
|
||||||
|
this.showValidateModal = true;
|
||||||
|
this.runBackendValidation();
|
||||||
|
}
|
||||||
|
|
||||||
|
closeValidateModal(): void {
|
||||||
|
if (this.validating) return;
|
||||||
|
this.showValidateModal = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-checks live backend state (account still active, GL mapping still configured, etc.) rather
|
||||||
|
* than relying on data already loaded in the browser — catches drift since submission. Results
|
||||||
|
* are revealed one step at a time (see revealValidateSteps()) for the pipeline-style animation. */
|
||||||
|
runBackendValidation(): void {
|
||||||
|
if (!this.reviewRequest || this.validating) return;
|
||||||
|
this.validating = true;
|
||||||
|
this.backendChecks = null;
|
||||||
|
this.validateSteps = [];
|
||||||
|
this.wdService.validate(this.reviewRequest.id).subscribe({
|
||||||
|
next: (res: any) => {
|
||||||
|
const checks = (res?.checks ?? []) as { label: string; pass: boolean; detail: string }[];
|
||||||
|
this.backendChecks = checks;
|
||||||
|
this.validateAllPassed = !!res?.allPassed;
|
||||||
|
this.validateSteps = checks.map((c) => ({ ...c, status: 'pending' as const }));
|
||||||
|
this.revealValidateSteps(0);
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.validating = false;
|
||||||
|
this.snackBar.open(err?.error?.defaultUserMessage || 'Could not run backend validation.', 'Close', {
|
||||||
|
duration: 6000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private revealValidateSteps(index: number): void {
|
||||||
|
if (index >= this.validateSteps.length) {
|
||||||
|
this.validating = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.validateSteps[index].status = 'active';
|
||||||
|
setTimeout(() => {
|
||||||
|
this.validateSteps[index].status = this.validateSteps[index].pass ? 'done' : 'failed';
|
||||||
|
this.revealValidateSteps(index + 1);
|
||||||
|
}, 320);
|
||||||
|
}
|
||||||
|
|
||||||
|
approveReview(): void {
|
||||||
|
if (!this.reviewRequest) return;
|
||||||
|
this.wdService.approve(this.reviewRequest.id).subscribe({
|
||||||
|
next: (res: any) => {
|
||||||
|
const message =
|
||||||
|
res?.settlementAmount != null
|
||||||
|
? `Share withdrawal approved. ₹${Number(res.settlementAmount).toLocaleString('en-IN', { minimumFractionDigits: 2 })} credited to the linked savings account.`
|
||||||
|
: (res?.settlementWarning ?? 'Share withdrawal approved and processed.');
|
||||||
|
this.snackBar.open(message, 'Close', { duration: 6000 });
|
||||||
|
if (this.embedded) {
|
||||||
|
this.actionCompleted.emit();
|
||||||
|
} else {
|
||||||
|
this.router.navigate(['/checker-inbox-and-tasks/share-withdrawal-requests']);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: (err) =>
|
||||||
|
this.snackBar.open(err?.error?.defaultUserMessage || 'Could not approve the request.', 'Close', {
|
||||||
|
duration: 6000
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
openRejectDialog(): void {
|
||||||
|
this.showRejectDialog = true;
|
||||||
|
this.rejectionReason = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmRejectReview(): void {
|
||||||
|
if (!this.reviewRequest || !this.rejectionReason.trim()) return;
|
||||||
|
this.wdService.reject(this.reviewRequest.id, this.rejectionReason.trim()).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.showRejectDialog = false;
|
||||||
|
this.snackBar.open('Share withdrawal request rejected.', 'Close', { duration: 4000 });
|
||||||
|
if (this.embedded) {
|
||||||
|
this.actionCompleted.emit();
|
||||||
|
} else {
|
||||||
|
this.router.navigate(['/checker-inbox-and-tasks/share-withdrawal-requests']);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: (err) =>
|
||||||
|
this.snackBar.open(err?.error?.defaultUserMessage || 'Could not reject the request.', 'Close', {
|
||||||
|
duration: 6000
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
goBack(): void {
|
||||||
|
if (this.embedded) {
|
||||||
|
this.closeRequested.emit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.router.navigate(this.isReview ? ['/checker-inbox-and-tasks/share-withdrawal-requests'] : ['/clerk/dashboard']);
|
||||||
|
}
|
||||||
|
}
|
||||||
+98
@@ -0,0 +1,98 @@
|
|||||||
|
<!--
|
||||||
|
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="fm-dialog">
|
||||||
|
<div class="fm-dialog__header" style="display: flex; align-items: center; gap: 8px;">
|
||||||
|
<mat-icon class="fm-dialog__icon">people</mat-icon>
|
||||||
|
<span style="font-weight: 500; font-size: 18px;">CHILDREN</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<mat-dialog-content>
|
||||||
|
<form [formGroup]="childrenForm">
|
||||||
|
<div formArrayName="children" style="margin-top: 12px; background: #fff; padding: 12px;">
|
||||||
|
@for (child of children.controls; track $index) {
|
||||||
|
<div [formGroupName]="$index"
|
||||||
|
style="position: relative; padding: 24px 16px 16px; margin-top: 16px; margin-bottom: 24px; border: 1px solid #e0e0e0; border-radius: 8px; background: #fff;">
|
||||||
|
<div class="fm-grid">
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Child Full Name*</mat-label>
|
||||||
|
<input matInput formControlName="fullName" required placeholder="Child Full Name*" />
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Gender*</mat-label>
|
||||||
|
<mat-select formControlName="genderId" required>
|
||||||
|
@for (g of genderIdOptions; track g.id) {
|
||||||
|
<mat-option [value]="g.id">{{ g.name }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" (click)="childDobPicker.open()">
|
||||||
|
<mat-label>Date of Birth</mat-label>
|
||||||
|
<input matInput [max]="maxDate" [matDatepicker]="childDobPicker" formControlName="dateOfBirth" />
|
||||||
|
<mat-datepicker-toggle matSuffix [for]="childDobPicker"></mat-datepicker-toggle>
|
||||||
|
<mat-datepicker #childDobPicker></mat-datepicker>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Nationality</mat-label>
|
||||||
|
<mat-select formControlName="nationality">
|
||||||
|
<mat-option value="INDIAN">Indian</mat-option>
|
||||||
|
<mat-option value="NRI">NRI</mat-option>
|
||||||
|
<mat-option value="FOREIGN_NATIONAL">Foreign National</mat-option>
|
||||||
|
<mat-option value="OTHER">Other</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Aadhaar Number (Optional)</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
formControlName="aadhaarNumber"
|
||||||
|
maxlength="12"
|
||||||
|
placeholder="12-digit Aadhaar"
|
||||||
|
(keypress)="$event.charCode >= 48 && $event.charCode <= 57 ? true : $event.preventDefault()"
|
||||||
|
/>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>PAN Number (Optional)</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
formControlName="panNumber"
|
||||||
|
maxlength="10"
|
||||||
|
placeholder="ABCDE1234F"
|
||||||
|
style="text-transform: uppercase"
|
||||||
|
(input)="
|
||||||
|
$any(children.at($index)).get('panNumber')
|
||||||
|
?.setValue($any($event.target).value.toUpperCase(), { emitEvent: false })
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
@if (
|
||||||
|
$any(children.at($index)).get('panNumber')?.hasError('pattern') && $any(children.at($index)).get('panNumber')?.touched
|
||||||
|
) {
|
||||||
|
<mat-error>Invalid PAN format</mat-error>
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@if ($index < children.controls.length - 1) { <div style="border-bottom: 1px dashed #ccc; margin-bottom: 16px;">
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
<div style="border-bottom: 1px dashed #ccc; margin-bottom: 16px;"></div>
|
||||||
|
<button mat-stroked-button type="button" color="primary" (click)="addChild()">
|
||||||
|
<mat-icon>add</mat-icon> Add Another Child
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</mat-dialog-content>
|
||||||
|
|
||||||
|
<mat-dialog-actions align="end">
|
||||||
|
<button mat-stroked-button mat-dialog-close type="button">Cancel</button>
|
||||||
|
<button mat-flat-button color="primary" type="button" [disabled]="childrenForm.invalid" (click)="submit()">
|
||||||
|
Add Member
|
||||||
|
</button>
|
||||||
|
</mat-dialog-actions>
|
||||||
|
</div>
|
||||||
+106
@@ -0,0 +1,106 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, Inject, OnInit } from '@angular/core';
|
||||||
|
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||||
|
import { UntypedFormBuilder, UntypedFormGroup, UntypedFormArray, Validators } from '@angular/forms';
|
||||||
|
|
||||||
|
import { MatIcon } from '@angular/material/icon';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatInputModule } from '@angular/material/input';
|
||||||
|
import { MatSelectModule } from '@angular/material/select';
|
||||||
|
import { MatDatepickerModule } from '@angular/material/datepicker';
|
||||||
|
import { MatDialogModule } from '@angular/material/dialog';
|
||||||
|
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||||
|
import { ReactiveFormsModule } from '@angular/forms';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'cbs-client-family-member-child-dialog',
|
||||||
|
templateUrl: './client-family-member-child-dialog.component.html',
|
||||||
|
styleUrls: ['../client-family-member-dialog/client-family-member-dialog.component.scss'],
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
MatDialogModule,
|
||||||
|
MatIcon,
|
||||||
|
MatButtonModule,
|
||||||
|
MatInputModule,
|
||||||
|
MatSelectModule,
|
||||||
|
MatDatepickerModule,
|
||||||
|
MatTooltipModule
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class ClientFamilyMemberChildDialogComponent implements OnInit {
|
||||||
|
|
||||||
|
childrenForm: UntypedFormGroup;
|
||||||
|
genderIdOptions: any[];
|
||||||
|
maxDate: Date = new Date();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public dialogRef: MatDialogRef<ClientFamilyMemberChildDialogComponent>,
|
||||||
|
@Inject(MAT_DIALOG_DATA) public data: any,
|
||||||
|
private formBuilder: UntypedFormBuilder
|
||||||
|
) {
|
||||||
|
this.genderIdOptions = data.genderIdOptions;
|
||||||
|
|
||||||
|
this.childrenForm = this.formBuilder.group({
|
||||||
|
children: this.formBuilder.array([])
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
if (this.data.children && this.data.children.length > 0) {
|
||||||
|
this.data.children.forEach((child: any) => {
|
||||||
|
this.children.push(this.formBuilder.group({
|
||||||
|
id: [child.id],
|
||||||
|
fullName: [child.fullName, Validators.required],
|
||||||
|
genderId: [child.genderId, Validators.required],
|
||||||
|
dateOfBirth: [child.dateOfBirth ? new Date(child.dateOfBirth) : ''],
|
||||||
|
nationality: [child.nationality || 'INDIAN'],
|
||||||
|
aadhaarNumber: [child.aadhaarNumber || ''],
|
||||||
|
panNumber: [child.panNumber || '', Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)]
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.addChild();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get children(): UntypedFormArray {
|
||||||
|
return this.childrenForm.get('children') as UntypedFormArray;
|
||||||
|
}
|
||||||
|
|
||||||
|
createChildFormGroup(): UntypedFormGroup {
|
||||||
|
return this.formBuilder.group({
|
||||||
|
id: [''],
|
||||||
|
fullName: ['', Validators.required],
|
||||||
|
genderId: ['', Validators.required],
|
||||||
|
dateOfBirth: [''],
|
||||||
|
nationality: ['INDIAN'],
|
||||||
|
aadhaarNumber: [''],
|
||||||
|
panNumber: ['', Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
addChild(): void {
|
||||||
|
this.children.push(this.createChildFormGroup());
|
||||||
|
}
|
||||||
|
|
||||||
|
removeChild(index: number): void {
|
||||||
|
this.children.removeAt(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
submit(): void {
|
||||||
|
if (this.childrenForm.valid) {
|
||||||
|
this.dialogRef.close(this.children.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
+28
@@ -139,6 +139,21 @@
|
|||||||
<span class="fm-readonly-value">{{ familyMemberForm.get('remarks')?.value }}</span>
|
<span class="fm-readonly-value">{{ familyMemberForm.get('remarks')?.value }}</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
@if (isSpouse && familyMemberForm.get('children')?.value?.length > 0) {
|
||||||
|
<div class="fm-readonly-remarks">
|
||||||
|
<span class="fm-readonly-label">Children</span>
|
||||||
|
<div class="fm-children-list">
|
||||||
|
@for (child of familyMemberForm.get('children')?.value; track $index) {
|
||||||
|
<div class="fm-child-item" style="font-size: 13px; color: #555; margin-top: 4px;">
|
||||||
|
<span>{{ child.fullName }}</span>
|
||||||
|
@if (child.dateOfBirth) {
|
||||||
|
<span> · DOB: {{ child.dateOfBirth | date: 'dd MMM yyyy' }}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- ═══ ADD / EDIT MODE ═══ -->
|
<!-- ═══ ADD / EDIT MODE ═══ -->
|
||||||
@@ -447,6 +462,19 @@
|
|||||||
<mat-label>Remarks</mat-label>
|
<mat-label>Remarks</mat-label>
|
||||||
<textarea matInput formControlName="remarks" rows="2"></textarea>
|
<textarea matInput formControlName="remarks" rows="2"></textarea>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
|
|
||||||
|
<!-- Children Section -->
|
||||||
|
@if (isSpouse) {
|
||||||
|
<div class="fm-section-label" style="margin-top: 16px;">Children</div>
|
||||||
|
<div class="fm-checkbox-row" style="display: flex; align-items: center; justify-content: space-between;">
|
||||||
|
<mat-checkbox formControlName="hasChildren">Does this family member have a child/children?</mat-checkbox>
|
||||||
|
@if (familyMemberForm.get('hasChildren')?.value) {
|
||||||
|
<button mat-stroked-button type="button" color="primary" (click)="openChildrenDialog()" style="margin-left: 16px;">
|
||||||
|
<mat-icon>edit</mat-icon> Edit Children Details ({{ children.length }})
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</form>
|
</form>
|
||||||
</mat-dialog-content>
|
</mat-dialog-content>
|
||||||
|
|||||||
+115
-2
@@ -17,7 +17,7 @@ import {
|
|||||||
MatDialogClose,
|
MatDialogClose,
|
||||||
MatDialogContent
|
MatDialogContent
|
||||||
} from '@angular/material/dialog';
|
} from '@angular/material/dialog';
|
||||||
import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms';
|
import { UntypedFormGroup, UntypedFormBuilder, UntypedFormArray, Validators } from '@angular/forms';
|
||||||
import { SettingsService } from 'app/settings/settings.service';
|
import { SettingsService } from 'app/settings/settings.service';
|
||||||
import { Dates } from 'app/core/utils/dates';
|
import { Dates } from 'app/core/utils/dates';
|
||||||
import { ClientsService } from 'app/clients/clients.service';
|
import { ClientsService } from 'app/clients/clients.service';
|
||||||
@@ -58,6 +58,8 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
|
|||||||
private dialog = inject(MatDialog);
|
private dialog = inject(MatDialog);
|
||||||
|
|
||||||
maxDate = new Date();
|
maxDate = new Date();
|
||||||
|
|
||||||
|
hasChildrenChecked = false;
|
||||||
familyMemberForm: UntypedFormGroup;
|
familyMemberForm: UntypedFormGroup;
|
||||||
|
|
||||||
/** Client search */
|
/** Client search */
|
||||||
@@ -197,6 +199,23 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
|
|||||||
// Re-fetch linked client details (profile, signature, KYC status)
|
// Re-fetch linked client details (profile, signature, KYC status)
|
||||||
this.fetchLinkedClientData(m.linkedClientId.toString());
|
this.fetchLinkedClientData(m.linkedClientId.toString());
|
||||||
}
|
}
|
||||||
|
if (m.children && m.children.length > 0) {
|
||||||
|
this.hasChildrenChecked = true;
|
||||||
|
this.familyMemberForm.patchValue({ hasChildren: true });
|
||||||
|
m.children.forEach((child: any) => {
|
||||||
|
this.children.push(
|
||||||
|
this.formBuilder.group({
|
||||||
|
id: [child.id],
|
||||||
|
fullName: [child.fullName, Validators.required],
|
||||||
|
genderId: [child.genderId, Validators.required],
|
||||||
|
dateOfBirth: [child.dateOfBirth ? new Date(child.dateOfBirth) : ''],
|
||||||
|
nationality: [child.nationality || 'INDIAN'],
|
||||||
|
aadhaarNumber: [child.aadhaarNumber || ''],
|
||||||
|
panNumber: [child.panNumber || '', Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)]
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
if (this.isViewMode) {
|
if (this.isViewMode) {
|
||||||
this.familyMemberForm.disable();
|
this.familyMemberForm.disable();
|
||||||
}
|
}
|
||||||
@@ -225,8 +244,83 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
|
|||||||
isAlive: [true],
|
isAlive: [true],
|
||||||
marriageDate: [''],
|
marriageDate: [''],
|
||||||
marriageAuthority: [''],
|
marriageAuthority: [''],
|
||||||
linkedClientId: ['']
|
linkedClientId: [''],
|
||||||
|
hasChildren: [false],
|
||||||
|
children: this.formBuilder.array([])
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.familyMemberForm.get('hasChildren')?.valueChanges.subscribe(value => {
|
||||||
|
if (value && !this.hasChildrenChecked) {
|
||||||
|
this.openChildrenDialog();
|
||||||
|
} else if (!value) {
|
||||||
|
this.hasChildrenChecked = false;
|
||||||
|
this.children.clear();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get children(): UntypedFormArray {
|
||||||
|
return this.familyMemberForm.get('children') as UntypedFormArray;
|
||||||
|
}
|
||||||
|
|
||||||
|
async openChildrenDialog() {
|
||||||
|
// dynamically import the dialog to avoid circular dependency issues if any
|
||||||
|
const { ClientFamilyMemberChildDialogComponent } = await import('../client-family-member-child-dialog/client-family-member-child-dialog.component');
|
||||||
|
|
||||||
|
const dialogRef = this.dialog.open(ClientFamilyMemberChildDialogComponent, {
|
||||||
|
data: {
|
||||||
|
children: this.children.value,
|
||||||
|
genderIdOptions: this.data.options.genderIdOptions
|
||||||
|
},
|
||||||
|
width: '500px'
|
||||||
|
});
|
||||||
|
|
||||||
|
dialogRef.afterClosed().subscribe((result: any[]) => {
|
||||||
|
if (result) {
|
||||||
|
this.children.clear();
|
||||||
|
result.forEach(child => {
|
||||||
|
this.children.push(this.formBuilder.group({
|
||||||
|
id: [child.id],
|
||||||
|
fullName: [child.fullName, Validators.required],
|
||||||
|
genderId: [child.genderId, Validators.required],
|
||||||
|
dateOfBirth: [child.dateOfBirth],
|
||||||
|
nationality: [child.nationality || 'INDIAN'],
|
||||||
|
aadhaarNumber: [child.aadhaarNumber || ''],
|
||||||
|
panNumber: [child.panNumber || '', Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)]
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
this.hasChildrenChecked = true;
|
||||||
|
this.familyMemberForm.patchValue({ hasChildren: true }, { emitEvent: false });
|
||||||
|
} else {
|
||||||
|
if (this.children.length === 0) {
|
||||||
|
this.hasChildrenChecked = false;
|
||||||
|
this.familyMemberForm.patchValue({ hasChildren: false }, { emitEvent: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
createChildFormGroup(): UntypedFormGroup {
|
||||||
|
return this.formBuilder.group({
|
||||||
|
id: [''],
|
||||||
|
fullName: ['', Validators.required],
|
||||||
|
genderId: ['', Validators.required],
|
||||||
|
dateOfBirth: [''],
|
||||||
|
nationality: ['INDIAN'],
|
||||||
|
aadhaarNumber: [''],
|
||||||
|
panNumber: ['', Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
addChild(): void {
|
||||||
|
this.children.push(this.createChildFormGroup());
|
||||||
|
}
|
||||||
|
|
||||||
|
removeChild(index: number): void {
|
||||||
|
this.children.removeAt(index);
|
||||||
|
if (this.children.length === 0) {
|
||||||
|
this.familyMemberForm.patchValue({ hasChildren: false });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Handle marriage certificate file selection */
|
/** Handle marriage certificate file selection */
|
||||||
@@ -481,6 +575,25 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
|
|||||||
member._linkedClient = this.selectedClient;
|
member._linkedClient = this.selectedClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Format children dates
|
||||||
|
if (this.isSpouse && member.children && member.children.length > 0) {
|
||||||
|
member.children = member.children.map((child: any) => {
|
||||||
|
const c = { ...child };
|
||||||
|
if (c.dateOfBirth instanceof Date) {
|
||||||
|
c.dateOfBirth = this.dateUtils.formatDate(c.dateOfBirth, dateFormat);
|
||||||
|
} else if (!c.dateOfBirth) {
|
||||||
|
delete c.dateOfBirth;
|
||||||
|
}
|
||||||
|
if (c.panNumber) {
|
||||||
|
c.panNumber = c.panNumber.toUpperCase();
|
||||||
|
}
|
||||||
|
return c;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
delete member.children;
|
||||||
|
}
|
||||||
|
delete member.hasChildren;
|
||||||
|
|
||||||
// Attach marriage certificate file if present
|
// Attach marriage certificate file if present
|
||||||
if (this.marriageCertFile) {
|
if (this.marriageCertFile) {
|
||||||
member._marriageCertFile = this.marriageCertFile;
|
member._marriageCertFile = this.marriageCertFile;
|
||||||
|
|||||||
+38
-15
@@ -533,8 +533,6 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
screeningLogId: number | null = null;
|
screeningLogId: number | null = null;
|
||||||
/** Snapshot of the name that was last verified — used to invalidate verification when name changes */
|
/** Snapshot of the name that was last verified — used to invalidate verification when name changes */
|
||||||
private verifiedName: string = '';
|
private verifiedName: string = '';
|
||||||
/** True while prefillForEdit() is patching historical monthly/annual values — see linkMonthlyAnnual(). */
|
|
||||||
private suppressMonthlyAnnualLink = false;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {FormBuilder} formBuilder Form Builder
|
* @param {FormBuilder} formBuilder Form Builder
|
||||||
@@ -711,12 +709,6 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
const data = this.clientData;
|
const data = this.clientData;
|
||||||
this.loadExistingSeniorPwdDocuments();
|
this.loadExistingSeniorPwdDocuments();
|
||||||
|
|
||||||
// Loading an existing client's independently-saved monthly/annual values must not run
|
|
||||||
// through the auto-calc in linkMonthlyAnnual() — patching monthlyIncome then annualIncome
|
|
||||||
// (or vice versa) would otherwise have each overwrite the other via its valueChanges,
|
|
||||||
// instead of keeping the exact historical values as saved.
|
|
||||||
this.suppressMonthlyAnnualLink = true;
|
|
||||||
|
|
||||||
// Patch all base controls that exist on the form
|
// Patch all base controls that exist on the form
|
||||||
this.createClientForm.patchValue({
|
this.createClientForm.patchValue({
|
||||||
officeId: data.officeId,
|
officeId: data.officeId,
|
||||||
@@ -751,7 +743,6 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
? new Date(data.timeline.submittedOnDate)
|
? new Date(data.timeline.submittedOnDate)
|
||||||
: this.settingsService.businessDate
|
: this.settingsService.businessDate
|
||||||
});
|
});
|
||||||
this.suppressMonthlyAnnualLink = false;
|
|
||||||
|
|
||||||
// Patch name fields (already created by buildDependencies via legalFormId subscription)
|
// Patch name fields (already created by buildDependencies via legalFormId subscription)
|
||||||
const legalFormId = data.legalForm?.id || LegalFormId.PERSON;
|
const legalFormId = data.legalForm?.id || LegalFormId.PERSON;
|
||||||
@@ -966,6 +957,7 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
|
|
||||||
this.linkMonthlyAnnual('monthlyIncome', 'annualIncome');
|
this.linkMonthlyAnnual('monthlyIncome', 'annualIncome');
|
||||||
this.linkMonthlyAnnual('monthlyTurnover', 'annualTurnover');
|
this.linkMonthlyAnnual('monthlyTurnover', 'annualTurnover');
|
||||||
|
this.linkIncomeToTurnover();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1002,6 +994,37 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-calculates turnover based on income (doubling monthly income for monthly turnover,
|
||||||
|
* and annual income for annual/yearly turnover).
|
||||||
|
*/
|
||||||
|
private linkIncomeToTurnover(): void {
|
||||||
|
const monthlyIncome = this.createClientForm.get('monthlyIncome');
|
||||||
|
const monthlyTurnover = this.createClientForm.get('monthlyTurnover');
|
||||||
|
const annualIncome = this.createClientForm.get('annualIncome');
|
||||||
|
const annualTurnover = this.createClientForm.get('annualTurnover');
|
||||||
|
|
||||||
|
if (monthlyIncome && monthlyTurnover) {
|
||||||
|
monthlyIncome.valueChanges.subscribe((value) => {
|
||||||
|
if (this.suppressMonthlyAnnualLink) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const num = parseFloat(value);
|
||||||
|
monthlyTurnover.setValue(!isNaN(num) && num >= 0 ? this.round2(num * 2) : '', { emitEvent: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (annualIncome && annualTurnover) {
|
||||||
|
annualIncome.valueChanges.subscribe((value) => {
|
||||||
|
if (this.suppressMonthlyAnnualLink) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const num = parseFloat(value);
|
||||||
|
annualTurnover.setValue(!isNaN(num) && num >= 0 ? this.round2(num * 2) : '', { emitEvent: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets select dropdown options.
|
* Sets select dropdown options.
|
||||||
*/
|
*/
|
||||||
@@ -2236,26 +2259,26 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Match monthly income ranges
|
// Match monthly income ranges
|
||||||
if (rule.minMonthlyIncome != null && rule.minMonthlyIncome > 0 && monthlyIncome < rule.minMonthlyIncome) {
|
if (rule.minMonthlyIncome != null && monthlyIncome < rule.minMonthlyIncome) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (rule.maxMonthlyIncome != null && rule.maxMonthlyIncome > 0 && monthlyIncome > rule.maxMonthlyIncome) {
|
if (rule.maxMonthlyIncome != null && monthlyIncome > rule.maxMonthlyIncome) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Match annual income ranges (evaluated against totalAnnualIncome)
|
// Match annual income ranges (evaluated against totalAnnualIncome)
|
||||||
if (rule.minAnnualIncome != null && rule.minAnnualIncome > 0 && totalAnnualIncome < rule.minAnnualIncome) {
|
if (rule.minAnnualIncome != null && totalAnnualIncome < rule.minAnnualIncome) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (rule.maxAnnualIncome != null && rule.maxAnnualIncome > 0 && totalAnnualIncome > rule.maxAnnualIncome) {
|
if (rule.maxAnnualIncome != null && totalAnnualIncome > rule.maxAnnualIncome) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Match turnover ranges
|
// Match turnover ranges
|
||||||
if (rule.minAnnualTurnover != null && rule.minAnnualTurnover > 0 && annualTurnover < rule.minAnnualTurnover) {
|
if (rule.minAnnualTurnover != null && annualTurnover < rule.minAnnualTurnover) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (rule.maxAnnualTurnover != null && rule.maxAnnualTurnover > 0 && annualTurnover > rule.maxAnnualTurnover) {
|
if (rule.maxAnnualTurnover != null && annualTurnover > rule.maxAnnualTurnover) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -723,6 +723,33 @@
|
|||||||
<span>PAN verification requires client to be created first. Complete General tab and click Next to save.</span>
|
<span>PAN verification requires client to be created first. Complete General tab and click Next to save.</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<!-- KYC Exception Flag -->
|
||||||
|
<div class="exception-flag-box" style="margin-top: 20px; padding: 16px; border: 1px solid #f59e0b; background: #fffbeb; border-radius: 8px; clear: both;">
|
||||||
|
<h5 style="margin: 0 0 12px 0; font-size: 14px; font-weight: 600; color: #b45309; display: flex; align-items: center; gap: 8px;">
|
||||||
|
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||||
|
KYC Exception Flag
|
||||||
|
</h5>
|
||||||
|
<div style="display: flex; align-items: center; gap: 20px; flex-wrap: wrap;">
|
||||||
|
<mat-checkbox formControlName="isException">Mark as KYC Exception</mat-checkbox>
|
||||||
|
|
||||||
|
@if (kycForm.get('isException')?.value) {
|
||||||
|
<mat-form-field appearance="outline" style="flex: 1; min-width: 280px; margin-bottom: -1.25em;">
|
||||||
|
<mat-label>Exception Reasons</mat-label>
|
||||||
|
<mat-select formControlName="exceptionReason" multiple>
|
||||||
|
@if (!isNonIndividual) {
|
||||||
|
<mat-option value="PAN Card Verification Failed">PAN Card Verification Failed</mat-option>
|
||||||
|
<mat-option value="Aadhaar Verification Failed">Aadhaar Verification Failed</mat-option>
|
||||||
|
} @else {
|
||||||
|
<mat-option value="Enterprise PAN Verification Failed">Enterprise PAN Verification Failed</mat-option>
|
||||||
|
<mat-option value="GSTIN Verification Failed">GSTIN Verification Failed</mat-option>
|
||||||
|
<mat-option value="CIN Verification Failed">CIN Verification Failed</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ═══ SECTION: KYC Document Management ═══ -->
|
<!-- ═══ SECTION: KYC Document Management ═══ -->
|
||||||
@@ -1410,6 +1437,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<!-- ═══ SECTION: Photograph & Signature ═══ -->
|
<!-- ═══ SECTION: Photograph & Signature ═══ -->
|
||||||
<div class="section-block">
|
<div class="section-block">
|
||||||
<h4 class="section-title">
|
<h4 class="section-title">
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import { ClientsService } from '../../clients.service';
|
|||||||
import { SettingsService } from 'app/settings/settings.service';
|
import { SettingsService } from 'app/settings/settings.service';
|
||||||
import { Dates } from 'app/core/utils/dates';
|
import { Dates } from 'app/core/utils/dates';
|
||||||
import { ProofType, normalizeProofType, groupProofsByCategory } from '../../shared/kyc-proof-types';
|
import { ProofType, normalizeProofType, groupProofsByCategory } from '../../shared/kyc-proof-types';
|
||||||
|
import { deriveKycExceptionFields } from '../../kyc-exception.util';
|
||||||
import { KycUploadHelperService } from '../../shared/kyc-upload-helper.service';
|
import { KycUploadHelperService } from '../../shared/kyc-upload-helper.service';
|
||||||
import { CaptureImageDialogComponent } from '../../clients-view/custom-dialogs/capture-image-dialog/capture-image-dialog.component';
|
import { CaptureImageDialogComponent } from '../../clients-view/custom-dialogs/capture-image-dialog/capture-image-dialog.component';
|
||||||
import { DrawSignatureDialogComponent } from '../../clients-view/custom-dialogs/draw-signature-dialog/draw-signature-dialog.component';
|
import { DrawSignatureDialogComponent } from '../../clients-view/custom-dialogs/draw-signature-dialog/draw-signature-dialog.component';
|
||||||
@@ -152,7 +153,8 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
kycDocumentName: 'KYC Document Type',
|
kycDocumentName: 'KYC Document Type',
|
||||||
kycDocumentNumber: 'KYC Document Number',
|
kycDocumentNumber: 'KYC Document Number',
|
||||||
amlCheckStatus: 'AML Check Status',
|
amlCheckStatus: 'AML Check Status',
|
||||||
bannedEntityMatch: 'Banned Entity List Match'
|
bannedEntityMatch: 'Banned Entity List Match',
|
||||||
|
remarks: 'Reason for Re-edit'
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Names every currently-invalid `kycForm` control (required-but-empty, or an entered value
|
/** Names every currently-invalid `kycForm` control (required-but-empty, or an entered value
|
||||||
@@ -274,6 +276,14 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
riskCalculationReason = '';
|
riskCalculationReason = '';
|
||||||
showRiskRulesTable = false;
|
showRiskRulesTable = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exception state as last read back from the server. Needed to tell "this client never had an
|
||||||
|
* exception" from "this client had one and has now cleared it" — only the latter should be
|
||||||
|
* stamped resolved and surface in the Resolved tab.
|
||||||
|
*/
|
||||||
|
persistedIsException = false;
|
||||||
|
persistedExceptionReason: string | null = null;
|
||||||
|
|
||||||
/** PAN verification */
|
/** PAN verification */
|
||||||
panVerifyStatus: 'NONE' | 'VERIFIED' | 'FAILED' = 'NONE';
|
panVerifyStatus: 'NONE' | 'VERIFIED' | 'FAILED' = 'NONE';
|
||||||
panVerifying = false;
|
panVerifying = false;
|
||||||
@@ -423,8 +433,8 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
aadhaarNumber: [
|
aadhaarNumber: [
|
||||||
'',
|
'',
|
||||||
[
|
[
|
||||||
Validators.pattern(/^\d{12}$/),
|
Validators.pattern(/^(\d{12}|[X•*\s\d]{12,14})$/),
|
||||||
Validators.maxLength(12)
|
Validators.maxLength(14)
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
panAadhaarSeedingStatus: [''],
|
panAadhaarSeedingStatus: [''],
|
||||||
@@ -468,6 +478,8 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
allowRiskOverride: [false],
|
allowRiskOverride: [false],
|
||||||
allowAmlOverride: [false],
|
allowAmlOverride: [false],
|
||||||
remarks: [''],
|
remarks: [''],
|
||||||
|
isException: [false],
|
||||||
|
exceptionReason: [[]],
|
||||||
amlCheckStatus: [
|
amlCheckStatus: [
|
||||||
'CLEAR',
|
'CLEAR',
|
||||||
Validators.required
|
Validators.required
|
||||||
@@ -610,8 +622,12 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
dormantFlag: 'ACTIVE',
|
dormantFlag: 'ACTIVE',
|
||||||
accountStatus: 'ACTIVE',
|
accountStatus: 'ACTIVE',
|
||||||
form60Type: 'FORM_60',
|
form60Type: 'FORM_60',
|
||||||
form15Type: '15G'
|
form15Type: '15G',
|
||||||
|
isException: false,
|
||||||
|
exceptionReason: []
|
||||||
});
|
});
|
||||||
|
this.persistedIsException = false;
|
||||||
|
this.persistedExceptionReason = null;
|
||||||
this.kycId = '';
|
this.kycId = '';
|
||||||
this.kycLoaded = false;
|
this.kycLoaded = false;
|
||||||
this.kycStatus = 0;
|
this.kycStatus = 0;
|
||||||
@@ -942,8 +958,18 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
form60Date: parseDate(data.form60Date),
|
form60Date: parseDate(data.form60Date),
|
||||||
form15Applicable: data.form15Applicable || false,
|
form15Applicable: data.form15Applicable || false,
|
||||||
form15Type: data.form15Type || '15G',
|
form15Type: data.form15Type || '15G',
|
||||||
form15FY: data.form15FY || ''
|
form15FY: data.form15FY || '',
|
||||||
|
isException: data.isException || false,
|
||||||
|
exceptionReason: data.exceptionReason ? data.exceptionReason.split(', ') : []
|
||||||
});
|
});
|
||||||
|
// Set remarks as required if KYC is verified
|
||||||
|
if (this.isKycVerified) {
|
||||||
|
this.kycForm.get('remarks')?.setValidators(Validators.required);
|
||||||
|
this.kycForm.get('remarks')?.updateValueAndValidity();
|
||||||
|
this.kycForm.get('remarks')?.markAsTouched(); // Force red underline so user knows it's blocking
|
||||||
|
}
|
||||||
|
this.persistedIsException = data.isException === true;
|
||||||
|
this.persistedExceptionReason = data.exceptionReason || null;
|
||||||
// Sync chip-picker selection with the prefilled document name
|
// Sync chip-picker selection with the prefilled document name
|
||||||
if (data.kycDocumentName) {
|
if (data.kycDocumentName) {
|
||||||
this.selectedKycDoc = this.kycDocProofs.find((p) => p.name === data.kycDocumentName) || null;
|
this.selectedKycDoc = this.kycDocProofs.find((p) => p.name === data.kycDocumentName) || null;
|
||||||
@@ -1843,7 +1869,21 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
form15Applicable: v.form15Applicable || false,
|
form15Applicable: v.form15Applicable || false,
|
||||||
form15Type: v.form15Type || null,
|
form15Type: v.form15Type || null,
|
||||||
form15FY: v.form15FY || null,
|
form15FY: v.form15FY || null,
|
||||||
remarks: v.remarks || null
|
remarks: v.remarks || null,
|
||||||
|
// Exception state is DERIVED from verification status, not from a checkbox — an unverified
|
||||||
|
// or blank PAN/Aadhaar is an exception by definition, so the officer cannot forget to flag
|
||||||
|
// it. See deriveKycExceptionFields() for the raise / narrow / close rules.
|
||||||
|
...deriveKycExceptionFields({
|
||||||
|
panVerified: this.isNonIndividual ? this.entPanVerifyStatus === 'VERIFIED' : this.panVerifyStatus === 'VERIFIED',
|
||||||
|
aadhaarVerified: this.aadhaarVerifyStatus === 'VERIFIED',
|
||||||
|
// Non-individual clients submit a dummy masked Aadhaar, so checking it would saddle every
|
||||||
|
// company with a permanent exception.
|
||||||
|
aadhaarApplicable: !this.isNonIndividual,
|
||||||
|
hadException: this.persistedIsException,
|
||||||
|
previousReason: this.persistedExceptionReason,
|
||||||
|
manualReasons: Array.isArray(v.exceptionReason) ? v.exceptionReason : [],
|
||||||
|
todayFormatted: this.dateUtils.formatDate(new Date(), dateFormat)
|
||||||
|
})
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1856,7 +1896,8 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
v.enterprisePan ||
|
v.enterprisePan ||
|
||||||
v.cinNumber ||
|
v.cinNumber ||
|
||||||
v.kycDocumentName ||
|
v.kycDocumentName ||
|
||||||
v.form15Applicable
|
v.form15Applicable ||
|
||||||
|
v.isException
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ import { ClientCollateralResolver } from './common-resolvers/client-collateral.r
|
|||||||
import { KycTabComponent } from './clients-view/kyc-tab/kyc-tab.component';
|
import { KycTabComponent } from './clients-view/kyc-tab/kyc-tab.component';
|
||||||
import { CkycTabComponent } from './clients-view/ckyc-tab/ckyc-tab.component';
|
import { CkycTabComponent } from './clients-view/ckyc-tab/ckyc-tab.component';
|
||||||
import { Form60TabComponent } from './clients-view/form60-tab/form60-tab.component';
|
import { Form60TabComponent } from './clients-view/form60-tab/form60-tab.component';
|
||||||
|
import { Form121TabComponent } from './clients-view/form121-tab/form121-tab.component';
|
||||||
import { EntityClassificationTabComponent } from './clients-view/entity-classification-tab/entity-classification-tab.component';
|
import { EntityClassificationTabComponent } from './clients-view/entity-classification-tab/entity-classification-tab.component';
|
||||||
import { familyAllowedGuard, nomineeAllowedGuard } from './utils/family-nominee.guard';
|
import { familyAllowedGuard, nomineeAllowedGuard } from './utils/family-nominee.guard';
|
||||||
|
|
||||||
@@ -193,6 +194,11 @@ const routes: Routes = [
|
|||||||
component: Form60TabComponent,
|
component: Form60TabComponent,
|
||||||
data: { title: 'Form 97', breadcrumb: 'Form 97', routeParamBreadcrumb: false }
|
data: { title: 'Form 97', breadcrumb: 'Form 97', routeParamBreadcrumb: false }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'form121',
|
||||||
|
component: Form121TabComponent,
|
||||||
|
data: { title: 'Form 121 / TDS', breadcrumb: 'Form 121 / TDS', routeParamBreadcrumb: false }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'documents',
|
path: 'documents',
|
||||||
component: DocumentsTabComponent,
|
component: DocumentsTabComponent,
|
||||||
|
|||||||
@@ -545,6 +545,15 @@
|
|||||||
>
|
>
|
||||||
<i class="fa fa-file-text cv-tab-icon"></i> {{ 'labels.inputs.Form 60' | translate }}
|
<i class="fa fa-file-text cv-tab-icon"></i> {{ 'labels.inputs.Form 60' | translate }}
|
||||||
</a>
|
</a>
|
||||||
|
<a
|
||||||
|
mat-tab-link
|
||||||
|
[routerLink]="['./form121']"
|
||||||
|
routerLinkActive
|
||||||
|
#form121="routerLinkActive"
|
||||||
|
[active]="form121.isActive"
|
||||||
|
>
|
||||||
|
<i class="fa fa-percent cv-tab-icon"></i> Form 121 / TDS
|
||||||
|
</a>
|
||||||
<a
|
<a
|
||||||
mat-tab-link
|
mat-tab-link
|
||||||
[routerLink]="['./documents']"
|
[routerLink]="['./documents']"
|
||||||
|
|||||||
+27
@@ -36,7 +36,34 @@
|
|||||||
<strong>Status:</strong>
|
<strong>Status:</strong>
|
||||||
<span class="status-badge status-{{ dispatch.status | lowercase }}">{{ dispatch.status }}</span>
|
<span class="status-badge status-{{ dispatch.status | lowercase }}">{{ dispatch.status }}</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<!-- Action buttons: only shown for DISPATCHED / IN_TRANSIT, only for users with UPDATE_WELCOMEKIT permission -->
|
||||||
|
<ng-container *mifosxHasPermission="'UPDATE_WELCOMEKIT'">
|
||||||
|
<div
|
||||||
|
*ngIf="canUpdateStatus(dispatch.status)"
|
||||||
|
style="margin-top: 12px; display: flex; gap: 8px; flex-wrap: wrap;"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
mat-stroked-button
|
||||||
|
color="primary"
|
||||||
|
type="button"
|
||||||
|
[disabled]="isLoading"
|
||||||
|
(click)="updateStatus(dispatch, 'DELIVERED')"
|
||||||
|
>
|
||||||
|
✅ Mark as Delivered
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
mat-stroked-button
|
||||||
|
color="warn"
|
||||||
|
type="button"
|
||||||
|
[disabled]="isLoading"
|
||||||
|
(click)="updateStatus(dispatch, 'RETURNED')"
|
||||||
|
>
|
||||||
|
↩ Mark as Returned
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
</div><!-- end dispatch-tracking-panel -->
|
||||||
|
|
||||||
<hr *ngIf="activeDispatches.length > 0 && welcomeKitsData?.canDispatchNew" />
|
<hr *ngIf="activeDispatches.length > 0 && welcomeKitsData?.canDispatchNew" />
|
||||||
|
|
||||||
|
|||||||
+24
@@ -165,6 +165,30 @@ export class WelcomeKitDialogComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
canUpdateStatus(status: string): boolean {
|
||||||
|
return status === 'DISPATCHED' || status === 'IN_TRANSIT';
|
||||||
|
}
|
||||||
|
|
||||||
|
updateStatus(dispatch: any, newStatus: string) {
|
||||||
|
const label = newStatus === 'DELIVERED' ? 'Delivered' : 'Returned';
|
||||||
|
const confirmed = confirm(`Are you sure you want to mark this as ${label}? This action cannot be undone from the UI.`);
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isLoading = true;
|
||||||
|
this.clientsService.updateWelcomeKitStatus(dispatch.id, newStatus).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.snackBar.open(`Welcome Kit marked as ${label} successfully`, 'Close', { duration: 3000 });
|
||||||
|
this.fetchData();
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.isLoading = false;
|
||||||
|
this.snackBar.open(`Failed to mark Welcome Kit as ${label}`, 'Close', { duration: 3000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
getDisplayAddress(dispatch: any): string {
|
getDisplayAddress(dispatch: any): string {
|
||||||
const address = dispatch.deliveryAddress || '';
|
const address = dispatch.deliveryAddress || '';
|
||||||
const name = this.welcomeKitsData?.clientName || '';
|
const name = this.welcomeKitsData?.clientName || '';
|
||||||
|
|||||||
@@ -0,0 +1,428 @@
|
|||||||
|
<!--
|
||||||
|
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="f121">
|
||||||
|
@if (loading) {
|
||||||
|
<div class="f121__loading">
|
||||||
|
<mat-spinner [diameter]="36"></mat-spinner>
|
||||||
|
<span>Reading the customer's deposit ledger…</span>
|
||||||
|
</div>
|
||||||
|
} @else if (loadError) {
|
||||||
|
<div class="f121__note f121__note--warn">
|
||||||
|
<mat-icon>error_outline</mat-icon>
|
||||||
|
<div>Could not load the customer's accounts. Try again, or check the deposit service.</div>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<!-- ── Header ── -->
|
||||||
|
<div class="f121__head">
|
||||||
|
<div>
|
||||||
|
<h3 class="f121__title">Form 121 & TDS on deposit interest</h3>
|
||||||
|
<div class="f121__sub">
|
||||||
|
Tax deducted at source under {{ statute.section }} on fixed and recurring deposit interest.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="f121__controls">
|
||||||
|
@if (availableFys.length) {
|
||||||
|
<label class="f121__field">
|
||||||
|
<span>Financial year</span>
|
||||||
|
<select [(ngModel)]="fy" (ngModelChange)="recompute()">
|
||||||
|
@for (year of availableFys; track year) {
|
||||||
|
<option [value]="year">FY {{ year }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
<div class="f121__field">
|
||||||
|
<span>Settlement</span>
|
||||||
|
<div
|
||||||
|
class="f121__mode"
|
||||||
|
[matTooltip]="'Set by the deposit product\'s interest-posting frequency: tax falls due when interest is credited, so the branch cannot defer it. Not a customer preference.'"
|
||||||
|
>
|
||||||
|
<mat-icon>schedule</mat-icon>
|
||||||
|
{{ mode === 'QUARTERLY' ? 'Quarterly' : 'At year end' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!schedule) {
|
||||||
|
<div class="f121__note">
|
||||||
|
<mat-icon>info_outline</mat-icon>
|
||||||
|
<div>
|
||||||
|
@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 {
|
||||||
|
No interest has been credited to this customer's deposits yet.
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<!-- ── KPI strip ── -->
|
||||||
|
<div class="f121__kpis">
|
||||||
|
<div class="f121__kpi">
|
||||||
|
<div class="f121__kpi-n">₹{{ schedule.totalInterest | number: '1.0-0' }}</div>
|
||||||
|
<div class="f121__kpi-l">Interest earned (FY {{ schedule.fy }})</div>
|
||||||
|
</div>
|
||||||
|
<div class="f121__kpi" [class.f121__kpi--warn]="actualTds > 0">
|
||||||
|
<div class="f121__kpi-n">₹{{ actualTds | number: '1.0-0' }}</div>
|
||||||
|
<div class="f121__kpi-l">TDS deducted</div>
|
||||||
|
</div>
|
||||||
|
<div class="f121__kpi">
|
||||||
|
<div class="f121__kpi-n">₹{{ schedule.threshold | number: '1.0-0' }}</div>
|
||||||
|
<div class="f121__kpi-l">Threshold ({{ schedule.senior ? 'senior citizen' : 'general' }})</div>
|
||||||
|
</div>
|
||||||
|
@if (thresholdCrossed) {
|
||||||
|
<div class="f121__kpi f121__kpi--warn">
|
||||||
|
<div class="f121__kpi-n">Crossed</div>
|
||||||
|
<div class="f121__kpi-l">TDS applies this year</div>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="f121__kpi f121__kpi--ok">
|
||||||
|
<div class="f121__kpi-n">₹{{ headroom | number: '1.0-0' }}</div>
|
||||||
|
<div class="f121__kpi-l">More interest before TDS starts</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (tdsSaved > 0) {
|
||||||
|
<div class="f121__kpi f121__kpi--ok">
|
||||||
|
<div class="f121__kpi-n">₹{{ tdsSaved | number: '1.0-0' }}</div>
|
||||||
|
<div class="f121__kpi-l">Saved by Form 121</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Alerts ── -->
|
||||||
|
@if (variance !== 0) {
|
||||||
|
<div class="f121__note f121__note--warn">
|
||||||
|
<mat-icon>warning_amber</mat-icon>
|
||||||
|
<div>
|
||||||
|
<strong>Ledger and computed tax differ by ₹{{ variance | number: '1.0-0' }}.</strong>
|
||||||
|
The ledger shows ₹{{ actualTds | number: '1.0-0' }} withheld; {{ statute.section }} on this year's interest
|
||||||
|
comes to ₹{{ schedule.totalTds | number: '1.0-0' }}. Investigate before the quarterly return is filed.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (declarationNotAppliedToAccounts) {
|
||||||
|
<div class="f121__note f121__note--warn">
|
||||||
|
<mat-icon>warning_amber</mat-icon>
|
||||||
|
<div>
|
||||||
|
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>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (declarationUselessOnJointAccounts) {
|
||||||
|
<div class="f121__note f121__note--warn">
|
||||||
|
<mat-icon>warning_amber</mat-icon>
|
||||||
|
<div>
|
||||||
|
This customer has filed Form 121, but tax on
|
||||||
|
{{ jointHolderAccounts.length }} joint deposit{{ jointHolderAccounts.length === 1 ? '' : 's' }} is deducted
|
||||||
|
against the first-named holder. Their declaration does not cover those — the primary holder must file their
|
||||||
|
own.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (shouldSuggestDeclaration) {
|
||||||
|
<div class="f121__note f121__note--tip">
|
||||||
|
<mat-icon>lightbulb_outline</mat-icon>
|
||||||
|
<div>
|
||||||
|
₹{{ schedule.totalTds | number: '1.0-0' }} of tax falls due this year. If the customer's total tax liability
|
||||||
|
for the year is nil, a {{ formLabel }} declaration would stop it being deducted at all — better than
|
||||||
|
reclaiming it later as a refund.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!panWellFormed) {
|
||||||
|
<div class="f121__note f121__note--warn">
|
||||||
|
<mat-icon>error_outline</mat-icon>
|
||||||
|
<div>
|
||||||
|
No valid PAN on record, so tax is deducted at the higher section 206AA rate of
|
||||||
|
{{ schedule.rateApplied * 100 | number: '1.0-0' }}%. A Form 121 declaration is void without a valid PAN.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@for (note of schedule.notes; track note) {
|
||||||
|
<div class="f121__note">
|
||||||
|
<mat-icon>info_outline</mat-icon>
|
||||||
|
<div>{{ note }}</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- ── Declaration panel ── -->
|
||||||
|
<div class="f121__card">
|
||||||
|
<div class="f121__card-head">
|
||||||
|
<mat-icon>assignment_turned_in</mat-icon>
|
||||||
|
<span>Declaration</span>
|
||||||
|
</div>
|
||||||
|
<div class="f121__kv">
|
||||||
|
<div class="f121__kv-row">
|
||||||
|
<span>Status</span>
|
||||||
|
<span>
|
||||||
|
@if (declarationOnRecord) {
|
||||||
|
<span class="f121__pill f121__pill--ok">On record — {{ formLabel }}</span>
|
||||||
|
} @else {
|
||||||
|
<span class="f121__pill">Not filed</span>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="f121__kv-row">
|
||||||
|
<span>Form & threshold band</span>
|
||||||
|
<span>{{ formLabel }} · {{ bandLabel }} band · {{ age }} yrs in this FY</span>
|
||||||
|
</div>
|
||||||
|
<div class="f121__kv-row">
|
||||||
|
<span>PAN</span>
|
||||||
|
<span class="f121__mono">
|
||||||
|
{{ pan || '—' }}
|
||||||
|
@if (pan && !panWellFormed) {
|
||||||
|
<span class="f121__pill f121__pill--warn">invalid</span>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
@if (declarationOnRecord && declarationFy) {
|
||||||
|
<div class="f121__kv-row">
|
||||||
|
<span>Declared for</span>
|
||||||
|
<span>FY {{ declarationFy }}</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) {
|
||||||
|
<div class="f121__kv-row f121__kv-row--warn">
|
||||||
|
<span>Eligibility</span>
|
||||||
|
<span>{{ reason }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@for (note of eligibility.unassessed; track note) {
|
||||||
|
<div class="f121__kv-row f121__kv-row--muted">
|
||||||
|
<span>Not assessed</span>
|
||||||
|
<span>{{ note }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Quarter-wise ── -->
|
||||||
|
<div class="f121__card">
|
||||||
|
<div class="f121__card-head">
|
||||||
|
<mat-icon>table_chart</mat-icon>
|
||||||
|
<span>Quarter by quarter</span>
|
||||||
|
</div>
|
||||||
|
<div class="f121__table-wrap">
|
||||||
|
<table class="f121__table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Quarter</th>
|
||||||
|
<th class="num">Interest</th>
|
||||||
|
<th class="num">Cumulative</th>
|
||||||
|
<th class="num">Liability to date</th>
|
||||||
|
<th class="num">TDS this quarter</th>
|
||||||
|
<th>Return due</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (line of schedule.lines; track trackLine($index, line)) {
|
||||||
|
<tr [class.f121__row--warn]="line.thresholdCrossedHere">
|
||||||
|
<td>
|
||||||
|
{{ line.label }}
|
||||||
|
@if (line.thresholdCrossedHere) {
|
||||||
|
<span class="f121__pill f121__pill--warn">threshold crossed</span>
|
||||||
|
}
|
||||||
|
@if (line.declarationActive) {
|
||||||
|
<span class="f121__pill f121__pill--ok">Form 121 active</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td class="num">₹{{ line.interest | number: '1.0-0' }}</td>
|
||||||
|
<td class="num">₹{{ line.cumInterest | number: '1.0-0' }}</td>
|
||||||
|
<td class="num">₹{{ line.cumLiability | number: '1.0-0' }}</td>
|
||||||
|
<td class="num">
|
||||||
|
@if (line.tds !== 0) {
|
||||||
|
<strong>₹{{ line.tds | number: '1.0-0' }}</strong>
|
||||||
|
} @else {
|
||||||
|
<span class="muted">—</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td class="muted">{{ line.dueDate }}</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
<tr class="f121__row--total">
|
||||||
|
<td><strong>Total</strong></td>
|
||||||
|
<td class="num">
|
||||||
|
<strong>₹{{ schedule.totalInterest | number: '1.0-0' }}</strong>
|
||||||
|
</td>
|
||||||
|
<td></td>
|
||||||
|
<td></td>
|
||||||
|
<td class="num">
|
||||||
|
<strong>₹{{ schedule.totalTds | number: '1.0-0' }}</strong>
|
||||||
|
</td>
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
@if (schedule.shieldedInterest > 0) {
|
||||||
|
<div class="f121__foot">
|
||||||
|
₹{{ schedule.shieldedInterest | number: '1.0-0' }} was paid without deduction on the strength of the
|
||||||
|
declaration. This amount is still reportable in the quarterly {{ statute.statement }} return.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Section 1: accounts that count ── -->
|
||||||
|
<div class="f121__card">
|
||||||
|
<div class="f121__card-head">
|
||||||
|
<mat-icon>account_balance</mat-icon>
|
||||||
|
<span>Deposits counted towards this customer's TDS</span>
|
||||||
|
</div>
|
||||||
|
@if (liableAccounts.length) {
|
||||||
|
<div class="f121__table-wrap">
|
||||||
|
<table class="f121__table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Account</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th class="num">Interest (FY)</th>
|
||||||
|
<th class="num">TDS deducted</th>
|
||||||
|
<th>Withholding</th>
|
||||||
|
<th>Settles</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (a of liableAccounts; track trackAccount($index, a)) {
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div class="f121__mono">{{ a.accountNo }}</div>
|
||||||
|
<div class="muted">{{ a.productName }}</div>
|
||||||
|
</td>
|
||||||
|
<td>{{ a.kind }}</td>
|
||||||
|
<td class="num">₹{{ a.interest | number: '1.0-0' }}</td>
|
||||||
|
<td class="num">₹{{ a.tdsDeducted | number: '1.0-0' }}</td>
|
||||||
|
<td>
|
||||||
|
@if (a.withHoldTax) {
|
||||||
|
<span class="f121__pill f121__pill--warn">On</span>
|
||||||
|
@if (a.taxGroup) {
|
||||||
|
<div class="muted">{{ a.taxGroup }}</div>
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
<span class="f121__pill">Off</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>{{ a.mode === 'QUARTERLY' ? 'Quarterly' : 'Year end' }}</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="f121__foot">
|
||||||
|
Aggregated across every branch — a bank on core banking computes the deduction threshold for the bank as a
|
||||||
|
whole, not branch by branch.
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="f121__empty">No fixed or recurring deposits where this customer is the deductee.</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Section 2: joint holdings ── -->
|
||||||
|
@if (jointHolderAccounts.length) {
|
||||||
|
<div class="f121__card">
|
||||||
|
<div class="f121__card-head">
|
||||||
|
<mat-icon>group</mat-icon>
|
||||||
|
<span>Deposits where this customer is a joint holder</span>
|
||||||
|
</div>
|
||||||
|
<div class="f121__table-wrap">
|
||||||
|
<table class="f121__table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Account</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th class="num">Interest (FY)</th>
|
||||||
|
<th>Deducted against</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (a of jointHolderAccounts; track trackAccount($index, a)) {
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div class="f121__mono">{{ a.accountNo }}</div>
|
||||||
|
<div class="muted">{{ a.productName }}</div>
|
||||||
|
</td>
|
||||||
|
<td>{{ a.kind }}</td>
|
||||||
|
<td class="num">₹{{ a.interest | number: '1.0-0' }}</td>
|
||||||
|
<td class="muted">
|
||||||
|
First-named holder
|
||||||
|
@if (a.deducteeClientId) {
|
||||||
|
(client {{ a.deducteeClientId }})
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="f121__foot">
|
||||||
|
Tax on a joint deposit is deducted against the first-named holder's PAN, so this interest is
|
||||||
|
<strong>not</strong> counted in the figures above and does not use up this customer's threshold.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- ── Section 3: exempt accounts ── -->
|
||||||
|
@if (exemptAccounts.length) {
|
||||||
|
<div class="f121__card">
|
||||||
|
<div class="f121__card-head">
|
||||||
|
<mat-icon>savings</mat-icon>
|
||||||
|
<span>Savings and current accounts</span>
|
||||||
|
</div>
|
||||||
|
<div class="f121__table-wrap">
|
||||||
|
<table class="f121__table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Account</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th class="num">Interest (FY)</th>
|
||||||
|
<th>TDS</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (a of exemptAccounts; track trackAccount($index, a)) {
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div class="f121__mono">{{ a.accountNo }}</div>
|
||||||
|
<div class="muted">{{ a.productName }}</div>
|
||||||
|
</td>
|
||||||
|
<td>{{ a.kind }}</td>
|
||||||
|
<td class="num">₹{{ a.interest | number: '1.0-0' }}</td>
|
||||||
|
<td><span class="f121__pill">Not applicable</span></td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="f121__foot">
|
||||||
|
The charge applies to time deposits. Savings bank interest is outside it and is excluded from the threshold
|
||||||
|
— the customer claims the 80TTA or 80TTB deduction on it in their own return instead.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.f121 {
|
||||||
|
--f121-line: #e5e7eb;
|
||||||
|
--f121-bg: #f9fafb;
|
||||||
|
--f121-ink: #111827;
|
||||||
|
--f121-muted: #6b7280;
|
||||||
|
--f121-blue: #1d4ed8;
|
||||||
|
--f121-green: #15803d;
|
||||||
|
--f121-amber: #b45309;
|
||||||
|
|
||||||
|
padding: 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--f121-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.f121__loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 40px 16px;
|
||||||
|
color: var(--f121-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Header ── */
|
||||||
|
.f121__head {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f121__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f121__sub {
|
||||||
|
margin-top: 4px;
|
||||||
|
color: var(--f121-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f121__controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f121__field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
> span {
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--f121-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
padding: 7px 10px;
|
||||||
|
border: 1px solid var(--f121-line);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.f121__mode {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
border: 1px solid var(--f121-line);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--f121-bg);
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: help;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
color: var(--f121-muted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── KPI strip ── */
|
||||||
|
.f121__kpis {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f121__kpi {
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid var(--f121-line);
|
||||||
|
border-left: 3px solid var(--f121-blue);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
|
||||||
|
&--ok {
|
||||||
|
border-left-color: var(--f121-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--warn {
|
||||||
|
border-left-color: var(--f121-amber);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.f121__kpi-n {
|
||||||
|
font-size: 19px;
|
||||||
|
font-weight: 800;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f121__kpi-l {
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--f121-muted);
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Notes ── */
|
||||||
|
.f121__note {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding: 11px 13px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border-left: 3px solid var(--f121-blue);
|
||||||
|
background: color-mix(in srgb, var(--f121-blue) 6%, transparent);
|
||||||
|
line-height: 1.5;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
color: var(--f121-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--warn {
|
||||||
|
border-left-color: var(--f121-amber);
|
||||||
|
background: color-mix(in srgb, var(--f121-amber) 8%, transparent);
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
color: var(--f121-amber);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--tip {
|
||||||
|
border-left-color: var(--f121-green);
|
||||||
|
background: color-mix(in srgb, var(--f121-green) 7%, transparent);
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
color: var(--f121-green);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Cards ── */
|
||||||
|
.f121__card {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
border: 1px solid var(--f121-line);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fff;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f121__card-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 11px 14px;
|
||||||
|
background: var(--f121-bg);
|
||||||
|
border-bottom: 1px solid var(--f121-line);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 12.5px;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
color: var(--f121-muted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.f121__foot {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-top: 1px solid var(--f121-line);
|
||||||
|
background: var(--f121-bg);
|
||||||
|
color: var(--f121-muted);
|
||||||
|
font-size: 11.5px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f121__empty {
|
||||||
|
padding: 20px 14px;
|
||||||
|
color: var(--f121-muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Key/value list ── */
|
||||||
|
.f121__kv-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 190px 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-bottom: 1px solid #f3f4f6;
|
||||||
|
line-height: 1.5;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
> span:first-child {
|
||||||
|
color: var(--f121-muted);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--muted > span:last-child {
|
||||||
|
color: var(--f121-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--warn > span:last-child {
|
||||||
|
color: var(--f121-amber);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Tables ── */
|
||||||
|
.f121__table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f121__table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 12.5px;
|
||||||
|
|
||||||
|
th {
|
||||||
|
text-align: left;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: #fff;
|
||||||
|
border-bottom: 1px solid var(--f121-line);
|
||||||
|
color: var(--f121-muted);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 10.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-bottom: 1px solid #f3f4f6;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:last-child td {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.num {
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: var(--f121-muted);
|
||||||
|
font-size: 11.5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.f121__row--warn > td {
|
||||||
|
background: color-mix(in srgb, var(--f121-amber) 7%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.f121__row--total > td {
|
||||||
|
background: var(--f121-bg);
|
||||||
|
border-top: 1px solid var(--f121-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Bits ── */
|
||||||
|
.f121__pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px 9px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--f121-line);
|
||||||
|
background: var(--f121-bg);
|
||||||
|
color: var(--f121-muted);
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
& + & {
|
||||||
|
margin-left: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--ok {
|
||||||
|
border-color: color-mix(in srgb, var(--f121-green) 35%, transparent);
|
||||||
|
background: color-mix(in srgb, var(--f121-green) 10%, transparent);
|
||||||
|
color: var(--f121-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--warn {
|
||||||
|
border-color: color-mix(in srgb, var(--f121-amber) 35%, transparent);
|
||||||
|
background: color-mix(in srgb, var(--f121-amber) 12%, transparent);
|
||||||
|
color: var(--f121-amber);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.f121__mono {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
@@ -0,0 +1,500 @@
|
|||||||
|
/**
|
||||||
|
* 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, OnDestroy, OnInit, inject } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { ActivatedRoute } from '@angular/router';
|
||||||
|
import { MatIcon } from '@angular/material/icon';
|
||||||
|
import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
||||||
|
import { MatTooltip } from '@angular/material/tooltip';
|
||||||
|
import { Observable, Subject, forkJoin, of } from 'rxjs';
|
||||||
|
import { catchError, switchMap, takeUntil } from 'rxjs/operators';
|
||||||
|
|
||||||
|
/** Custom Services */
|
||||||
|
import { ClientsService } from '../../clients.service';
|
||||||
|
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';
|
||||||
|
|
||||||
|
/** Domain logic */
|
||||||
|
import {
|
||||||
|
Declaration,
|
||||||
|
DeclarationForm,
|
||||||
|
EligibilityResult,
|
||||||
|
FY_TAX_CONFIG,
|
||||||
|
InterestCredit,
|
||||||
|
TdsAccountInput,
|
||||||
|
TdsMode,
|
||||||
|
TdsQuarterLine,
|
||||||
|
TdsSchedule,
|
||||||
|
ageInFy,
|
||||||
|
buildSchedule,
|
||||||
|
checkEligibility,
|
||||||
|
collectTdsCredits,
|
||||||
|
declarationFormFor,
|
||||||
|
formKindFor,
|
||||||
|
fyOf,
|
||||||
|
isTdsApplicable,
|
||||||
|
isWellFormedPan,
|
||||||
|
normaliseFy,
|
||||||
|
tdsDeducteeOf,
|
||||||
|
tdsModeFor
|
||||||
|
} from 'app/letter-mgmt/certificates/form-15gh/tds-15gh.compute';
|
||||||
|
|
||||||
|
/** One of the customer's interest-bearing accounts, with its FY figures. */
|
||||||
|
export interface Form121AccountRow {
|
||||||
|
accountNo: string;
|
||||||
|
productName: string;
|
||||||
|
/** 'Fixed Deposit', 'Recurring Deposit' or 'Savings'. */
|
||||||
|
kind: string;
|
||||||
|
/** Interest credited to this account inside the selected FY. */
|
||||||
|
interest: number;
|
||||||
|
/** Tax Fineract has actually withheld on this account inside the FY. */
|
||||||
|
tdsDeducted: number;
|
||||||
|
/** Whether Fineract is currently withholding tax on this account. */
|
||||||
|
withHoldTax: boolean;
|
||||||
|
taxGroup: string | null;
|
||||||
|
/** When tax on this account settles — from the product, not a preference. */
|
||||||
|
mode: TdsMode;
|
||||||
|
/** Interest here attracts TDS at all (a time deposit, not a savings account). */
|
||||||
|
tdsApplicable: boolean;
|
||||||
|
/** This customer is the deductee — false when they are a joint holder only. */
|
||||||
|
isDeductee: boolean;
|
||||||
|
/** Who the tax is deducted against, when it is not this customer. */
|
||||||
|
deducteeClientId: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fineract hands dates back as [yyyy, m, d]; normalise to ISO. */
|
||||||
|
function toIso(value: unknown): string | null {
|
||||||
|
if (Array.isArray(value) && value.length >= 3) {
|
||||||
|
const [
|
||||||
|
y,
|
||||||
|
m,
|
||||||
|
d
|
||||||
|
] = value as number[];
|
||||||
|
return `${y}-${`${m}`.padStart(2, '0')}-${`${d}`.padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
if (typeof value === 'string' && value.trim()) {
|
||||||
|
const parsed = new Date(value);
|
||||||
|
if (!isNaN(parsed.getTime())) {
|
||||||
|
return parsed.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-form121-tab',
|
||||||
|
templateUrl: './form121-tab.component.html',
|
||||||
|
styleUrls: ['./form121-tab.component.scss'],
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
...STANDALONE_SHARED_IMPORTS,
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
MatIcon,
|
||||||
|
MatProgressSpinner,
|
||||||
|
MatTooltip
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class Form121TabComponent implements OnInit, OnDestroy {
|
||||||
|
private route = inject(ActivatedRoute);
|
||||||
|
private clientsService = inject(ClientsService);
|
||||||
|
private savingsService = inject(SavingsService);
|
||||||
|
private fixedDepositsService = inject(FixedDepositsService);
|
||||||
|
private recurringDepositsService = inject(RecurringDepositsService);
|
||||||
|
|
||||||
|
private destroy$ = new Subject<void>();
|
||||||
|
|
||||||
|
clientId = '';
|
||||||
|
loading = true;
|
||||||
|
loadError = false;
|
||||||
|
|
||||||
|
// ── Customer ──
|
||||||
|
clientName = '';
|
||||||
|
dateOfBirth = '';
|
||||||
|
pan = '';
|
||||||
|
panWellFormed = false;
|
||||||
|
|
||||||
|
// ── 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';
|
||||||
|
/** 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;
|
||||||
|
|
||||||
|
// ── Selection ──
|
||||||
|
availableFys: string[] = [];
|
||||||
|
fy = '';
|
||||||
|
|
||||||
|
// ── Computed ──
|
||||||
|
accounts: Form121AccountRow[] = [];
|
||||||
|
schedule: TdsSchedule | null = null;
|
||||||
|
actualTds = 0;
|
||||||
|
tdsSaved = 0;
|
||||||
|
|
||||||
|
/** Raw account inputs, kept so the year selector can recompute cheaply. */
|
||||||
|
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) => {
|
||||||
|
this.clientId = params['clientId'];
|
||||||
|
this.load();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.destroy$.next();
|
||||||
|
this.destroy$.complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────── Loading ──────────────────────────────
|
||||||
|
|
||||||
|
private load(): void {
|
||||||
|
this.loading = true;
|
||||||
|
this.loadError = false;
|
||||||
|
|
||||||
|
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)))
|
||||||
|
})
|
||||||
|
.pipe(
|
||||||
|
takeUntil(this.destroy$),
|
||||||
|
switchMap(({ client, kyc, accounts }) => {
|
||||||
|
this.applyClient(client);
|
||||||
|
this.applyKyc(kyc);
|
||||||
|
const deposits = this.depositAccountsOf(accounts);
|
||||||
|
return deposits.length ? forkJoin(deposits.map((a) => this.fetchAccount(a))) : of([] as any[]);
|
||||||
|
}),
|
||||||
|
catchError(() => {
|
||||||
|
this.loadError = true;
|
||||||
|
return of([] as any[]);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.subscribe((details: any[]) => {
|
||||||
|
this.loading = false;
|
||||||
|
this.ingest(details.filter(Boolean));
|
||||||
|
this.recompute();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyClient(client: any): void {
|
||||||
|
this.clientName = client?.displayName ?? '';
|
||||||
|
this.dateOfBirth = toIso(client?.dateOfBirth) ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyKyc(kyc: any): void {
|
||||||
|
this.pan = (kyc?.panNumber ?? '').toUpperCase();
|
||||||
|
this.panWellFormed = isWellFormedPan(this.pan);
|
||||||
|
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. */
|
||||||
|
private depositAccountsOf(accounts: any): any[] {
|
||||||
|
return (accounts?.savingsAccounts ?? []).filter(
|
||||||
|
(a: any) => a.status?.active || a.status?.matured || a.status?.closed
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private fetchAccount(a: any): Observable<any> {
|
||||||
|
const id = `${a.id}`;
|
||||||
|
const code = a.depositType?.code ?? '';
|
||||||
|
let call: Observable<any>;
|
||||||
|
if (code.includes('fixedDeposit')) {
|
||||||
|
call = this.fixedDepositsService.getFixedDepositsAccountData(id);
|
||||||
|
} else if (code.includes('recurringDeposit')) {
|
||||||
|
call = this.recurringDepositsService.getRecurringDepositsAccountData(id);
|
||||||
|
} else {
|
||||||
|
call = this.savingsService.getSavingsAccountData(id);
|
||||||
|
}
|
||||||
|
// One unreadable account must not blank the whole tab.
|
||||||
|
return call.pipe(catchError(() => of(null)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns each account payload into an engine input.
|
||||||
|
*
|
||||||
|
* Joint and minor attributes are read defensively: they are captured on the
|
||||||
|
* account stepper but may not surface on every read endpoint, and an absent
|
||||||
|
* field must default to a single-name account rather than throw.
|
||||||
|
*/
|
||||||
|
private ingest(details: any[]): void {
|
||||||
|
this.accountInputs = [];
|
||||||
|
this.actualTdsByFy = new Map<string, number>();
|
||||||
|
const fys = new Set<string>();
|
||||||
|
const clientIdNum = Number(this.clientId);
|
||||||
|
|
||||||
|
for (const d of details) {
|
||||||
|
const code: string = d.depositType?.code ?? '';
|
||||||
|
const extra = d.extraAttributes ?? d.externalAttributes ?? d;
|
||||||
|
|
||||||
|
const ref = {
|
||||||
|
id: d.id,
|
||||||
|
accountNo: d.accountNo ?? `${d.id}`,
|
||||||
|
depositTypeCode: code,
|
||||||
|
accountStatus: extra.accountStatus ?? d.accountStatus ?? 'SINGLE',
|
||||||
|
clientId: d.clientId ?? clientIdNum,
|
||||||
|
primaryClientId: extra.primaryClientId ?? d.primaryClientId ?? null,
|
||||||
|
guardianClientId: extra.guardianClientId ?? d.guardianClientId ?? null,
|
||||||
|
jointHolders: extra.jointHolders ?? d.jointHolders ?? [],
|
||||||
|
interestPostingPeriodType: d.interestPostingPeriodType ?? null
|
||||||
|
};
|
||||||
|
|
||||||
|
const credits: InterestCredit[] = [];
|
||||||
|
let tdsDeducted = 0;
|
||||||
|
|
||||||
|
for (const t of d.transactions ?? []) {
|
||||||
|
if (t.reversed || t.isReversal) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const iso = toIso(t.date);
|
||||||
|
if (!iso) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
fys.add(fyOf(iso));
|
||||||
|
|
||||||
|
if (t.transactionType?.interestPosting) {
|
||||||
|
credits.push({ date: iso, amount: t.amount ?? 0, accountNo: ref.accountNo });
|
||||||
|
} else if (t.transactionType?.withholdTax) {
|
||||||
|
const amount = t.amount ?? 0;
|
||||||
|
tdsDeducted += amount;
|
||||||
|
const txnFy = fyOf(iso);
|
||||||
|
this.actualTdsByFy.set(txnFy, (this.actualTdsByFy.get(txnFy) ?? 0) + amount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deductee = tdsDeducteeOf(ref);
|
||||||
|
const meta: Form121AccountRow = {
|
||||||
|
accountNo: ref.accountNo,
|
||||||
|
productName: d.savingsProductName ?? d.depositProductName ?? d.productName ?? '—',
|
||||||
|
kind: code.includes('fixedDeposit')
|
||||||
|
? 'Fixed Deposit'
|
||||||
|
: code.includes('recurringDeposit')
|
||||||
|
? 'Recurring Deposit'
|
||||||
|
: 'Savings',
|
||||||
|
interest: 0,
|
||||||
|
tdsDeducted,
|
||||||
|
withHoldTax: !!d.withHoldTax,
|
||||||
|
taxGroup: d.taxGroup?.name ?? null,
|
||||||
|
mode: tdsModeFor(ref),
|
||||||
|
tdsApplicable: isTdsApplicable(ref),
|
||||||
|
isDeductee: deductee === clientIdNum,
|
||||||
|
deducteeClientId: deductee
|
||||||
|
};
|
||||||
|
|
||||||
|
this.accountInputs.push({ ...ref, credits, meta });
|
||||||
|
}
|
||||||
|
|
||||||
|
this.availableFys = [...fys].sort().reverse();
|
||||||
|
if (!this.availableFys.includes(this.fy)) {
|
||||||
|
this.fy = this.availableFys[0] ?? (this.declarationFy ? normaliseFy(this.declarationFy) : '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────────────────────── Computation ─────────────────────────────
|
||||||
|
|
||||||
|
/** Re-runs the engine for the selected year. */
|
||||||
|
recompute(): void {
|
||||||
|
this.schedule = null;
|
||||||
|
this.eligibility = null;
|
||||||
|
this.tdsSaved = 0;
|
||||||
|
this.accounts = this.accountInputs.map((a) => a.meta);
|
||||||
|
|
||||||
|
if (!this.fy) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const year = normaliseFy(this.fy);
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
this.actualTds = this.actualTdsByFy.get(year) ?? 0;
|
||||||
|
|
||||||
|
if (!this.dateOfBirth) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The one place rules 8 to 12 are applied: savings interest dropped, joint
|
||||||
|
// deposits attributed to the primary holder, every branch aggregated.
|
||||||
|
const credits = collectTdsCredits(Number(this.clientId), this.accountInputs, year);
|
||||||
|
const ctx = { fy: year, dateOfBirth: this.dateOfBirth, pan: this.pan, resident: true };
|
||||||
|
const declaration = this.declarationForSelectedFy();
|
||||||
|
|
||||||
|
this.schedule = buildSchedule(this.overallMode, credits, { ...ctx, declaration });
|
||||||
|
|
||||||
|
if (declaration) {
|
||||||
|
const without = buildSchedule(this.overallMode, credits, { ...ctx, declaration: null });
|
||||||
|
this.tdsSaved = Math.max(0, without.totalTds - this.schedule.totalTds);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The branch cannot compute income earned outside the bank, so the income
|
||||||
|
// limb is reported as unassessed rather than silently passed.
|
||||||
|
this.eligibility = checkEligibility(ctx, {
|
||||||
|
estimatedInterest: this.schedule.totalInterest,
|
||||||
|
estimatedIncome: 0,
|
||||||
|
incomeOnRecord: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When tax settles for this customer overall.
|
||||||
|
*
|
||||||
|
* Timing is per account, but the customer needs one view. A quarterly account
|
||||||
|
* anywhere in the book means a deduction falls due at the first quarter end, so
|
||||||
|
* that is the honest headline; the per-account list carries the detail.
|
||||||
|
*/
|
||||||
|
private get overallMode(): TdsMode {
|
||||||
|
const applicable = this.accountInputs.filter((a) => a.meta.tdsApplicable && a.meta.isDeductee);
|
||||||
|
return applicable.some((a) => a.meta.mode === 'QUARTERLY') ? 'QUARTERLY' : 'ANNUAL';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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);
|
||||||
|
if (this.declarationFy && normaliseFy(this.declarationFy) !== year) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
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 ─────────────────────────
|
||||||
|
|
||||||
|
get mode(): TdsMode {
|
||||||
|
return this.schedule?.mode ?? 'QUARTERLY';
|
||||||
|
}
|
||||||
|
|
||||||
|
get age(): number {
|
||||||
|
return this.dateOfBirth && this.fy ? ageInFy(this.dateOfBirth, this.fy) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
get formKind(): string {
|
||||||
|
return this.dateOfBirth && this.fy ? formKindFor(this.dateOfBirth, this.fy) : '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The form the payee would file for the selected year. */
|
||||||
|
get formLabel(): string {
|
||||||
|
if (!this.fy) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
const form = declarationFormFor(this.fy, this.dateOfBirth);
|
||||||
|
return form === '121' ? 'Form 121' : `Form ${form}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Which deduction trigger applies, now that the forms no longer split by age. */
|
||||||
|
get bandLabel(): string {
|
||||||
|
return this.schedule?.senior ? 'Senior citizen' : 'General';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provision and return references for the selected year. Both changed on
|
||||||
|
* 01 April 2026, so the template reads them rather than hard-coding them.
|
||||||
|
*/
|
||||||
|
get statute(): { section: string; statement: string; rule: string; frequency: string } {
|
||||||
|
const cfg = this.fy ? FY_TAX_CONFIG[normaliseFy(this.fy)] : undefined;
|
||||||
|
return {
|
||||||
|
section: cfg?.interestTdsSection ?? '—',
|
||||||
|
statement: cfg?.tdsStatementForm ?? '—',
|
||||||
|
rule: cfg?.declarationRule ?? '—',
|
||||||
|
frequency: cfg?.declarationStatementFrequency === 'MONTHLY' ? 'monthly' : 'quarterly'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Accounts whose interest counts towards this customer's deduction aggregate. */
|
||||||
|
get liableAccounts(): Form121AccountRow[] {
|
||||||
|
return this.accounts.filter((a) => a.tdsApplicable && a.isDeductee);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deposits where this customer is a joint holder but not the deductee. */
|
||||||
|
get jointHolderAccounts(): Form121AccountRow[] {
|
||||||
|
return this.accounts.filter((a) => a.tdsApplicable && !a.isDeductee);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Savings and current accounts — interest outside the charge on time deposits. */
|
||||||
|
get exemptAccounts(): Form121AccountRow[] {
|
||||||
|
return this.accounts.filter((a) => !a.tdsApplicable);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Engine figure less what the ledger shows was actually withheld. */
|
||||||
|
get variance(): number {
|
||||||
|
return (this.schedule?.totalTds ?? 0) - this.actualTds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How much more interest before TDS starts. */
|
||||||
|
get headroom(): number {
|
||||||
|
if (!this.schedule) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return Math.max(0, this.schedule.threshold - this.schedule.totalInterest);
|
||||||
|
}
|
||||||
|
|
||||||
|
get thresholdCrossed(): boolean {
|
||||||
|
return !!this.schedule && this.schedule.totalInterest > this.schedule.threshold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.withHoldTax);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The customer filed a declaration but holds joint deposits where someone else
|
||||||
|
* is the deductee — their form does nothing for those.
|
||||||
|
*/
|
||||||
|
get declarationUselessOnJointAccounts(): boolean {
|
||||||
|
return !!this.declarationForSelectedFy() && this.jointHolderAccounts.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Worth prompting the branch to collect a declaration. */
|
||||||
|
get shouldSuggestDeclaration(): boolean {
|
||||||
|
return !this.declarationForSelectedFy() && (this.schedule?.totalTds ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
trackLine(_: number, line: TdsQuarterLine): number {
|
||||||
|
return line.quarter;
|
||||||
|
}
|
||||||
|
|
||||||
|
trackAccount(_: number, account: Form121AccountRow): string {
|
||||||
|
return account.accountNo;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,6 +42,19 @@
|
|||||||
|
|
||||||
<!-- KYC Summary (read-only when not in edit mode) -->
|
<!-- KYC Summary (read-only when not in edit mode) -->
|
||||||
@if (!isEditMode && kycData) {
|
@if (!isEditMode && kycData) {
|
||||||
|
@if (kycData.isException && !kycData.exceptionResolved) {
|
||||||
|
<div class="kyc-exception-banner" style="background: #fef3c7; border: 1px solid #f59e0b; border-radius: 8px; padding: 16px; margin-bottom: 20px; display: flex; align-items: flex-start; gap: 12px;">
|
||||||
|
<mat-icon style="color: #d97706; margin-top: 2px;">warning</mat-icon>
|
||||||
|
<div>
|
||||||
|
<strong style="color: #92400e; font-size: 14px;">KYC Exception Active</strong>
|
||||||
|
<p style="color: #b45309; font-size: 13px; margin: 4px 0 0 0;">
|
||||||
|
Account is open under a temporary KYC Exception. Reason: <strong>{{ kycData.exceptionReason || 'Other' }}</strong>.
|
||||||
|
Please contact the customer and complete verification as soon as possible.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<!-- Verification Table -->
|
<!-- Verification Table -->
|
||||||
<div class="kv-table">
|
<div class="kv-table">
|
||||||
<div class="kv-table__header">
|
<div class="kv-table__header">
|
||||||
@@ -740,11 +753,33 @@
|
|||||||
</mat-select>
|
</mat-select>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
|
|
||||||
|
<!-- KYC Exception Flag -->
|
||||||
|
<div class="full-span exception-controls-row" style="display: flex; align-items: center; gap: 20px; margin-bottom: 12px; flex-wrap: wrap;">
|
||||||
|
<mat-checkbox formControlName="isException" style="margin-top: 8px;">Mark as KYC Exception</mat-checkbox>
|
||||||
|
|
||||||
|
@if (kycForm.get('isException')?.value) {
|
||||||
|
<mat-form-field appearance="outline" style="flex: 1; min-width: 250px;">
|
||||||
|
<mat-label>Exception Reasons</mat-label>
|
||||||
|
<mat-select formControlName="exceptionReason" multiple>
|
||||||
|
@if (!isNonIndividual) {
|
||||||
|
<mat-option value="PAN Card Verification Failed">PAN Card Verification Failed</mat-option>
|
||||||
|
<mat-option value="Aadhaar Verification Failed">Aadhaar Verification Failed</mat-option>
|
||||||
|
} @else {
|
||||||
|
<mat-option value="Enterprise PAN Verification Failed">Enterprise PAN Verification Failed</mat-option>
|
||||||
|
<mat-option value="GSTIN Verification Failed">GSTIN Verification Failed</mat-option>
|
||||||
|
<mat-option value="CIN Verification Failed">CIN Verification Failed</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Remarks -->
|
<!-- Remarks -->
|
||||||
<mat-form-field appearance="outline" class="full-span">
|
<mat-form-field appearance="outline" class="full-span">
|
||||||
<mat-label>{{ 'labels.inputs.Remarks' | translate }}</mat-label>
|
<mat-label>{{ 'labels.inputs.Remarks' | translate }}</mat-label>
|
||||||
<textarea matInput formControlName="remarks" rows="3"></textarea>
|
<textarea matInput formControlName="remarks" rows="3"></textarea>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (errorMessage) {
|
@if (errorMessage) {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { catchError, of, Subject, takeUntil } from 'rxjs';
|
|||||||
|
|
||||||
/** Custom Services */
|
/** Custom Services */
|
||||||
import { ClientsService } from '../../clients.service';
|
import { ClientsService } from '../../clients.service';
|
||||||
|
import { deriveKycExceptionFields } from '../../kyc-exception.util';
|
||||||
import { Dates } from 'app/core/utils/dates';
|
import { Dates } from 'app/core/utils/dates';
|
||||||
import { jsPDF } from 'jspdf';
|
import { jsPDF } from 'jspdf';
|
||||||
import { MatIcon } from '@angular/material/icon';
|
import { MatIcon } from '@angular/material/icon';
|
||||||
@@ -121,7 +122,9 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
|||||||
'',
|
'',
|
||||||
[Validators.pattern(/^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z]{1}[1-9A-Z]{1}Z[0-9A-Z]{1}$/)]
|
[Validators.pattern(/^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z]{1}[1-9A-Z]{1}Z[0-9A-Z]{1}$/)]
|
||||||
],
|
],
|
||||||
remarks: ['']
|
remarks: [''],
|
||||||
|
isException: [false],
|
||||||
|
exceptionReason: [[]]
|
||||||
});
|
});
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
@@ -157,7 +160,9 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
|||||||
// the full 12-digit number to re-verify, so don't pre-fill.
|
// the full 12-digit number to re-verify, so don't pre-fill.
|
||||||
aadhaarNumber: '',
|
aadhaarNumber: '',
|
||||||
gstinNumber: data.gstinNumber || '',
|
gstinNumber: data.gstinNumber || '',
|
||||||
remarks: data.remarks || ''
|
remarks: data.remarks || '',
|
||||||
|
isException: data.isException || false,
|
||||||
|
exceptionReason: data.exceptionReason ? data.exceptionReason.split(', ') : []
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -195,6 +200,28 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exception columns derived from verification status — see `deriveKycExceptionFields()`.
|
||||||
|
*
|
||||||
|
* A field counts as verified if it was verified in this session OR is already verified on the
|
||||||
|
* stored record; either way the customer has cleared that check. Verifying one of two
|
||||||
|
* outstanding items narrows the exception to the remaining one rather than closing it.
|
||||||
|
*/
|
||||||
|
private buildExceptionFields() {
|
||||||
|
const panVerified = this.verificationStatus.pan === 'VERIFIED' || this.kycData?.panVerified === true;
|
||||||
|
const aadhaarVerified = this.verificationStatus.aadhaar === 'VERIFIED' || this.kycData?.aadhaarVerified === true;
|
||||||
|
|
||||||
|
return deriveKycExceptionFields({
|
||||||
|
panVerified,
|
||||||
|
aadhaarVerified,
|
||||||
|
aadhaarApplicable: !this.isNonIndividual,
|
||||||
|
hadException: this.kycData?.isException === true,
|
||||||
|
previousReason: this.kycData?.exceptionReason || null,
|
||||||
|
manualReasons: Array.isArray(this.kycForm.value.exceptionReason) ? this.kycForm.value.exceptionReason : [],
|
||||||
|
todayFormatted: this.dateUtils.formatDate(new Date(), 'dd MMMM yyyy')
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
saveKyc(): void {
|
saveKyc(): void {
|
||||||
if (this.kycForm.invalid) {
|
if (this.kycForm.invalid) {
|
||||||
return;
|
return;
|
||||||
@@ -210,7 +237,10 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
|||||||
panNumber: this.kycForm.value.panNumber || null,
|
panNumber: this.kycForm.value.panNumber || null,
|
||||||
aadhaarNumberMasked: aadhaarMasked,
|
aadhaarNumberMasked: aadhaarMasked,
|
||||||
gstinNumber: this.kycForm.value.gstinNumber || null,
|
gstinNumber: this.kycForm.value.gstinNumber || null,
|
||||||
remarks: this.kycForm.value.remarks || null
|
remarks: this.kycForm.value.remarks || null,
|
||||||
|
// dateFormat is required so the backend can parse exceptionResolvedOn when an exception closes.
|
||||||
|
dateFormat: 'dd MMMM yyyy',
|
||||||
|
...this.buildExceptionFields()
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!this.kycId) {
|
if (!this.kycId) {
|
||||||
@@ -244,6 +274,37 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private saveKycBeforeVerify(callback: () => void, field: 'pan' | 'aadhaar' | 'gstin'): void {
|
||||||
|
if (this.isEditMode) {
|
||||||
|
const aadhaarRaw = this.kycForm.value.aadhaarNumber || '';
|
||||||
|
const aadhaarMasked = aadhaarRaw.length === 12 ? 'XXXXXXXX' + aadhaarRaw.slice(-4) : aadhaarRaw || null;
|
||||||
|
const payload: any = {
|
||||||
|
locale: 'en',
|
||||||
|
panNumber: this.kycForm.value.panNumber || null,
|
||||||
|
aadhaarNumberMasked: aadhaarMasked,
|
||||||
|
gstinNumber: this.kycForm.value.gstinNumber || null,
|
||||||
|
remarks: this.kycForm.value.remarks || null,
|
||||||
|
riskCategory: this.kycForm.value.riskCategory || 1,
|
||||||
|
dateFormat: 'dd MMMM yyyy',
|
||||||
|
...this.buildExceptionFields()
|
||||||
|
};
|
||||||
|
|
||||||
|
this.clientsService.updateClientKyc(this.clientId, this.kycId, payload).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.loadKycData();
|
||||||
|
callback();
|
||||||
|
},
|
||||||
|
error: (err: any) => {
|
||||||
|
this.verifying[field] = false;
|
||||||
|
this.verificationStatus[field] = 'FAILED';
|
||||||
|
this.verificationError[field] = err?.error?.defaultUserMessage || `Failed to update ${field.toUpperCase()} number before verification.`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
verifyPAN(): void {
|
verifyPAN(): void {
|
||||||
if (!this.kycId) {
|
if (!this.kycId) {
|
||||||
this.verificationError.pan = 'KYC record not found. Save KYC details first.';
|
this.verificationError.pan = 'KYC record not found. Save KYC details first.';
|
||||||
@@ -254,6 +315,7 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
|||||||
this.verificationError.pan = null;
|
this.verificationError.pan = null;
|
||||||
this.verificationDetails.pan = null;
|
this.verificationDetails.pan = null;
|
||||||
|
|
||||||
|
this.saveKycBeforeVerify(() => {
|
||||||
this.clientsService
|
this.clientsService
|
||||||
.verifyPAN(this.clientId, this.kycId)
|
.verifyPAN(this.clientId, this.kycId)
|
||||||
.pipe(takeUntil(this.destroy$))
|
.pipe(takeUntil(this.destroy$))
|
||||||
@@ -279,6 +341,7 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
|||||||
'PAN verification failed. Please try again.';
|
'PAN verification failed. Please try again.';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}, 'pan');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Show Aadhaar input in summary view for re-verification */
|
/** Show Aadhaar input in summary view for re-verification */
|
||||||
@@ -305,6 +368,7 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
|||||||
this.aadhaarOtpSent = false;
|
this.aadhaarOtpSent = false;
|
||||||
this.aadhaarOtp = '';
|
this.aadhaarOtp = '';
|
||||||
|
|
||||||
|
this.saveKycBeforeVerify(() => {
|
||||||
this.clientsService
|
this.clientsService
|
||||||
.sendAadhaarOtp(this.clientId, this.kycId, aadhaar)
|
.sendAadhaarOtp(this.clientId, this.kycId, aadhaar)
|
||||||
.pipe(takeUntil(this.destroy$))
|
.pipe(takeUntil(this.destroy$))
|
||||||
@@ -330,6 +394,7 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
|||||||
'Failed to send OTP. Please try again.';
|
'Failed to send OTP. Please try again.';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}, 'aadhaar');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Step 2: Verify OTP entered by user */
|
/** Step 2: Verify OTP entered by user */
|
||||||
@@ -380,6 +445,7 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
|||||||
this.verificationError.gstin = null;
|
this.verificationError.gstin = null;
|
||||||
this.verificationDetails.gstin = null;
|
this.verificationDetails.gstin = null;
|
||||||
|
|
||||||
|
this.saveKycBeforeVerify(() => {
|
||||||
this.clientsService
|
this.clientsService
|
||||||
.verifyGSTIN(this.clientId, this.kycId)
|
.verifyGSTIN(this.clientId, this.kycId)
|
||||||
.pipe(takeUntil(this.destroy$))
|
.pipe(takeUntil(this.destroy$))
|
||||||
@@ -405,6 +471,7 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
|||||||
'GSTIN verification failed. Please try again.';
|
'GSTIN verification failed. Please try again.';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}, 'gstin');
|
||||||
}
|
}
|
||||||
|
|
||||||
formatDate(date: any): string {
|
formatDate(date: any): string {
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ import { ClientFamilyMembersStepComponent } from './client-stepper/client-family
|
|||||||
import { ClientPreviewStepComponent } from './client-stepper/client-preview-step/client-preview-step.component';
|
import { ClientPreviewStepComponent } from './client-stepper/client-preview-step/client-preview-step.component';
|
||||||
import { ClientAddressStepComponent } from './client-stepper/client-address-step/client-address-step.component';
|
import { ClientAddressStepComponent } from './client-stepper/client-address-step/client-address-step.component';
|
||||||
import { ClientFamilyMemberDialogComponent } from './client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component';
|
import { ClientFamilyMemberDialogComponent } from './client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component';
|
||||||
|
import { ClientFamilyMemberChildDialogComponent } from './client-stepper/client-family-members-step/client-family-member-child-dialog/client-family-member-child-dialog.component';
|
||||||
import { CaptureImageDialogComponent } from './clients-view/custom-dialogs/capture-image-dialog/capture-image-dialog.component';
|
import { CaptureImageDialogComponent } from './clients-view/custom-dialogs/capture-image-dialog/capture-image-dialog.component';
|
||||||
import { AddClientCollateralComponent } from './clients-view/client-actions/add-client-collateral/add-client-collateral.component';
|
import { AddClientCollateralComponent } from './clients-view/client-actions/add-client-collateral/add-client-collateral.component';
|
||||||
import { ClientDatatableStepComponent } from './client-stepper/client-datatable-step/client-datatable-step.component';
|
import { ClientDatatableStepComponent } from './client-stepper/client-datatable-step/client-datatable-step.component';
|
||||||
@@ -127,6 +128,7 @@ import { Form60TabComponent } from './clients-view/form60-tab/form60-tab.compone
|
|||||||
ClientPreviewStepComponent,
|
ClientPreviewStepComponent,
|
||||||
ClientAddressStepComponent,
|
ClientAddressStepComponent,
|
||||||
ClientFamilyMemberDialogComponent,
|
ClientFamilyMemberDialogComponent,
|
||||||
|
ClientFamilyMemberChildDialogComponent,
|
||||||
CaptureImageDialogComponent,
|
CaptureImageDialogComponent,
|
||||||
AddClientCollateralComponent,
|
AddClientCollateralComponent,
|
||||||
ClientDatatableStepComponent,
|
ClientDatatableStepComponent,
|
||||||
|
|||||||
@@ -123,6 +123,20 @@ export class ClientsService {
|
|||||||
return this.http.post(`/clients/${clientId}/edit/reject`, { rejectReason: rejectReason || '' });
|
return this.http.post(`/clients/${clientId}/edit/reject`, { rejectReason: rejectReason || '' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Fetches the exact email content (subject, body, sentAt, openedAt) for a given pending edit.
|
||||||
|
* Only populated after the Checker has approved and the send succeeded. */
|
||||||
|
getClientEditEmailPreview(clientId: string | number, pendingEditId: string | number) {
|
||||||
|
return this.http.get<{
|
||||||
|
emailSubject: string | null;
|
||||||
|
emailBody: string | null;
|
||||||
|
emailSent: boolean;
|
||||||
|
emailSentAt: string | null;
|
||||||
|
emailOpenedAt: string | null;
|
||||||
|
emailFrom: string | null;
|
||||||
|
emailTo: string | null;
|
||||||
|
}>(`/clients/${clientId}/edit/${pendingEditId}/email-preview`);
|
||||||
|
}
|
||||||
|
|
||||||
getClientDataAndTemplate(clientId: string) {
|
getClientDataAndTemplate(clientId: string) {
|
||||||
const httpParams = new HttpParams().set('template', 'true').set('staffInSelectedOfficeOnly', 'true');
|
const httpParams = new HttpParams().set('template', 'true').set('staffInSelectedOfficeOnly', 'true');
|
||||||
return this.http.get(`/clients/${clientId}`, { params: httpParams });
|
return this.http.get(`/clients/${clientId}`, { params: httpParams });
|
||||||
@@ -958,4 +972,33 @@ export class ClientsService {
|
|||||||
dispatchWelcomeKit(clientId: string | number, data: any): Observable<any> {
|
dispatchWelcomeKit(clientId: string | number, data: any): Observable<any> {
|
||||||
return this.http.post(`/clients/${clientId}/welcomekit`, data);
|
return this.http.post(`/clients/${clientId}/welcomekit`, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateWelcomeKitStatus(welcomeKitId: number, status: string): Observable<any> {
|
||||||
|
return this.http.put(`/welcomekit/${welcomeKitId}`, { status });
|
||||||
|
}
|
||||||
|
|
||||||
|
getKycExceptions(resolved = false): Observable<any[]> {
|
||||||
|
return this.http.get<any[]>(`/kyc/exceptions?resolved=${resolved}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records a KYC-exception outreach attempt against a customer, and — for the SMS, EMAIL and
|
||||||
|
* WHATSAPP channels — actually transmits the notice.
|
||||||
|
*
|
||||||
|
* The destination is resolved server-side from the client record; it is deliberately not part of
|
||||||
|
* this payload, so a tampered request cannot redirect a KYC notice elsewhere.
|
||||||
|
*
|
||||||
|
* Resolves with HTTP 200 even when delivery fails — inspect `deliveryStatus` / `delivered` on the
|
||||||
|
* response rather than assuming success, since the log row is written either way.
|
||||||
|
*/
|
||||||
|
saveKycContactLog(
|
||||||
|
clientId: string | number,
|
||||||
|
data: { channel: string; notes: string; officerName?: string }
|
||||||
|
): Observable<any> {
|
||||||
|
return this.http.post(`/kyc/exceptions/${clientId}/contact-log`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
getKycContactLog(clientId: string | number): Observable<any[]> {
|
||||||
|
return this.http.get<any[]>(`/kyc/exceptions/${clientId}/contact-log`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -430,8 +430,22 @@ export class CreateClientComponent {
|
|||||||
}
|
}
|
||||||
this.validationError = '';
|
this.validationError = '';
|
||||||
// Save KYC
|
// Save KYC
|
||||||
if (this.clientKycStep?.hasKycData && !this.clientKycStep.kycId) {
|
if (this.clientKycStep?.hasKycData) {
|
||||||
this.clientsService.saveClientKyc(clientId, this.clientKycStep.kycData).subscribe({ error: () => {} });
|
if (!this.clientKycStep.kycId) {
|
||||||
|
this.clientsService.saveClientKyc(clientId, this.clientKycStep.kycData).subscribe({
|
||||||
|
next: (res: any) => {
|
||||||
|
if (this.clientKycStep) {
|
||||||
|
this.clientKycStep.kycId = res?.resourceId?.toString() || res?.entityId?.toString() || '';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: () => {}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const { kycType, ...updatePayload } = this.clientKycStep.kycData;
|
||||||
|
this.clientsService
|
||||||
|
.updateClientKyc(clientId, this.clientKycStep.kycId, updatePayload)
|
||||||
|
.subscribe({ error: () => {} });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Upload photo & signature
|
// Upload photo & signature
|
||||||
if (this.clientKycStep?.photographFile) {
|
if (this.clientKycStep?.photographFile) {
|
||||||
@@ -696,12 +710,20 @@ export class CreateClientComponent {
|
|||||||
// Persist the KYC document picker selection + uploaded files as a client identifier
|
// Persist the KYC document picker selection + uploaded files as a client identifier
|
||||||
this.saveKycDocProof(clientId);
|
this.saveKycDocProof(clientId);
|
||||||
|
|
||||||
// Save KYC only if it wasn't already created during the KYC step
|
// Save/update KYC to ensure latest exception fields are persisted
|
||||||
if (this.clientKycStep?.hasKycData && !this.clientKycStep.kycId) {
|
if (this.clientKycStep?.hasKycData) {
|
||||||
|
if (!this.clientKycStep.kycId) {
|
||||||
this.clientsService.saveClientKyc(clientId, this.clientKycStep.kycData).subscribe({
|
this.clientsService.saveClientKyc(clientId, this.clientKycStep.kycData).subscribe({
|
||||||
next: () => navigateToClient(),
|
next: () => navigateToClient(),
|
||||||
error: () => navigateToClient()
|
error: () => navigateToClient()
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
const { kycType, ...updatePayload } = this.clientKycStep.kycData;
|
||||||
|
this.clientsService.updateClientKyc(clientId, this.clientKycStep.kycId, updatePayload).subscribe({
|
||||||
|
next: () => navigateToClient(),
|
||||||
|
error: () => navigateToClient()
|
||||||
|
});
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
navigateToClient();
|
navigateToClient();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,7 +84,17 @@
|
|||||||
<mat-icon>phone_iphone</mat-icon>
|
<mat-icon>phone_iphone</mat-icon>
|
||||||
<span>Mobile Number</span>
|
<span>Mobile Number</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="contact-verify-panel__exception-row" style="margin-bottom: 12px">
|
||||||
|
<mat-checkbox
|
||||||
|
[checked]="oldMobileNotAccessible"
|
||||||
|
(change)="onOldMobileNotAccessibleToggle($event.checked)"
|
||||||
|
color="primary"
|
||||||
|
>
|
||||||
|
Old mobile number not accessible (lost / deactivated SIM)
|
||||||
|
</mat-checkbox>
|
||||||
|
</div>
|
||||||
<div class="contact-verify-panel__grid">
|
<div class="contact-verify-panel__grid">
|
||||||
|
@if (!oldMobileNotAccessible) {
|
||||||
<mifosx-client-contact-otp-block
|
<mifosx-client-contact-otp-block
|
||||||
[clientId]="clientId"
|
[clientId]="clientId"
|
||||||
channel="MOBILE"
|
channel="MOBILE"
|
||||||
@@ -92,6 +102,52 @@
|
|||||||
label="Old Mobile Number"
|
label="Old Mobile Number"
|
||||||
(stateChange)="onOldMobileOtpState($event)"
|
(stateChange)="onOldMobileOtpState($event)"
|
||||||
></mifosx-client-contact-otp-block>
|
></mifosx-client-contact-otp-block>
|
||||||
|
} @else {
|
||||||
|
<div
|
||||||
|
class="fir-upload-box"
|
||||||
|
style="padding: 12px; border: 1px dashed #cbd5e1; border-radius: 8px; background: #f8fafc"
|
||||||
|
>
|
||||||
|
<div style="font-weight: 500; font-size: 13px; margin-bottom: 6px; color: #475569">
|
||||||
|
Upload Police Complaint / FIR Copy for Lost Mobile
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; align-items: center; gap: 12px">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
(change)="onFirFileSelected($event)"
|
||||||
|
accept=".pdf,.jpg,.jpeg,.png"
|
||||||
|
#firFileInput
|
||||||
|
hidden
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
mat-stroked-button
|
||||||
|
type="button"
|
||||||
|
(click)="firFileInput.click()"
|
||||||
|
[disabled]="uploadingFir"
|
||||||
|
>
|
||||||
|
<mat-icon>upload_file</mat-icon>
|
||||||
|
{{ firFileName ? firFileName : 'Select FIR Document' }}
|
||||||
|
</button>
|
||||||
|
@if (uploadingFir) {
|
||||||
|
<mat-progress-spinner mode="indeterminate" diameter="20"></mat-progress-spinner>
|
||||||
|
}
|
||||||
|
@if (firDocumentId && !uploadingFir) {
|
||||||
|
<span
|
||||||
|
style="
|
||||||
|
color: #16a34a;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<mat-icon style="font-size: 16px; width: 16px; height: 16px">check_circle</mat-icon> FIR
|
||||||
|
Uploaded Successfully
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
<mifosx-client-contact-otp-block
|
<mifosx-client-contact-otp-block
|
||||||
[clientId]="clientId"
|
[clientId]="clientId"
|
||||||
channel="MOBILE"
|
channel="MOBILE"
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import { ContactOtpBlockComponent, ContactOtpState } from './contact-otp-block/c
|
|||||||
|
|
||||||
import { MatTabsModule } from '@angular/material/tabs';
|
import { MatTabsModule } from '@angular/material/tabs';
|
||||||
import { MatIconModule } from '@angular/material/icon';
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||||
import { MatDivider } from '@angular/material/divider';
|
import { MatDivider } from '@angular/material/divider';
|
||||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
||||||
@@ -51,6 +52,7 @@ import { ORIGINAL_VALUES_PAYLOAD_KEY, buildOriginalValuesSnapshot } from '../sha
|
|||||||
...STANDALONE_SHARED_IMPORTS,
|
...STANDALONE_SHARED_IMPORTS,
|
||||||
MatTabsModule,
|
MatTabsModule,
|
||||||
MatIconModule,
|
MatIconModule,
|
||||||
|
MatCheckboxModule,
|
||||||
MatDivider,
|
MatDivider,
|
||||||
FaIconComponent,
|
FaIconComponent,
|
||||||
MatProgressSpinner,
|
MatProgressSpinner,
|
||||||
@@ -108,6 +110,13 @@ export class EditClientComponent {
|
|||||||
newMobileOtpId: number | null = null;
|
newMobileOtpId: number | null = null;
|
||||||
oldMobileVerified = false;
|
oldMobileVerified = false;
|
||||||
newMobileVerified = false;
|
newMobileVerified = false;
|
||||||
|
|
||||||
|
// FIR exception flow properties for inaccessible old mobile number
|
||||||
|
oldMobileNotAccessible = false;
|
||||||
|
firFileName = '';
|
||||||
|
firDocumentId: number | null = null;
|
||||||
|
uploadingFir = false;
|
||||||
|
|
||||||
oldEmailOtpId: number | null = null;
|
oldEmailOtpId: number | null = null;
|
||||||
newEmailOtpId: number | null = null;
|
newEmailOtpId: number | null = null;
|
||||||
oldEmailVerified = false;
|
oldEmailVerified = false;
|
||||||
@@ -215,18 +224,56 @@ export class EditClientComponent {
|
|||||||
return this.detectSensitiveFieldChanges(this.clientDataAndTemplate);
|
return this.detectSensitiveFieldChanges(this.clientDataAndTemplate);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether every OTP block required by the fields that actually changed has been verified. */
|
/** Whether every OTP block required by the fields that actually changed has been verified (or FIR uploaded for lost mobile). */
|
||||||
get contactVerificationComplete(): boolean {
|
get contactVerificationComplete(): boolean {
|
||||||
const changes = this.contactChanges;
|
const changes = this.contactChanges;
|
||||||
if (changes.mobileChanged && !(this.oldMobileVerified && this.newMobileVerified)) {
|
if (changes.mobileChanged) {
|
||||||
|
const isOldSatisfied = this.oldMobileNotAccessible ? this.firDocumentId != null : this.oldMobileVerified;
|
||||||
|
if (!isOldSatisfied || !this.newMobileVerified) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if (changes.emailChanged && !(this.oldEmailVerified && this.newEmailVerified)) {
|
if (changes.emailChanged && !(this.oldEmailVerified && this.newEmailVerified)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onOldMobileNotAccessibleToggle(checked?: boolean): void {
|
||||||
|
if (checked !== undefined) {
|
||||||
|
this.oldMobileNotAccessible = checked;
|
||||||
|
}
|
||||||
|
if (this.oldMobileNotAccessible) {
|
||||||
|
this.oldMobileOtpId = null;
|
||||||
|
this.oldMobileVerified = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onFirFileSelected(event: any): void {
|
||||||
|
const file: File = event.target?.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
this.firFileName = file.name;
|
||||||
|
this.uploadingFir = true;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
formData.append('name', 'FIR - Lost Mobile Number');
|
||||||
|
formData.append('description', 'FIR police complaint copy for lost mobile number verification exception');
|
||||||
|
|
||||||
|
this.clientsService.uploadClientDocument(this.clientId, formData).subscribe({
|
||||||
|
next: (res: any) => {
|
||||||
|
this.uploadingFir = false;
|
||||||
|
this.firDocumentId = res?.resourceId || res?.id || null;
|
||||||
|
this.snackBar.open('FIR document uploaded successfully', 'Close', { duration: 3000 });
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.uploadingFir = false;
|
||||||
|
this.firDocumentId = null;
|
||||||
|
this.snackBar.open('Failed to upload FIR document', 'Close', { duration: 4000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
onOldMobileOtpState(state: ContactOtpState): void {
|
onOldMobileOtpState(state: ContactOtpState): void {
|
||||||
this.oldMobileOtpId = state.otpId;
|
this.oldMobileOtpId = state.otpId;
|
||||||
this.oldMobileVerified = state.verified;
|
this.oldMobileVerified = state.verified;
|
||||||
@@ -254,6 +301,10 @@ export class EditClientComponent {
|
|||||||
this.newMobileOtpId = null;
|
this.newMobileOtpId = null;
|
||||||
this.oldMobileVerified = false;
|
this.oldMobileVerified = false;
|
||||||
this.newMobileVerified = false;
|
this.newMobileVerified = false;
|
||||||
|
this.oldMobileNotAccessible = false;
|
||||||
|
this.firFileName = '';
|
||||||
|
this.firDocumentId = null;
|
||||||
|
this.uploadingFir = false;
|
||||||
this.oldEmailOtpId = null;
|
this.oldEmailOtpId = null;
|
||||||
this.newEmailOtpId = null;
|
this.newEmailOtpId = null;
|
||||||
this.oldEmailVerified = false;
|
this.oldEmailVerified = false;
|
||||||
@@ -439,7 +490,11 @@ export class EditClientComponent {
|
|||||||
// email's when BOTH changed in the same save. See the class-level JSDoc above the OTP
|
// email's when BOTH changed in the same save. See the class-level JSDoc above the OTP
|
||||||
// state fields for the full naming rationale.
|
// state fields for the full naming rationale.
|
||||||
if (changedFields.mobileChanged) {
|
if (changedFields.mobileChanged) {
|
||||||
|
if (this.oldMobileNotAccessible && this.firDocumentId) {
|
||||||
|
(clientData as any).oldMobileVerificationExceptionDocumentId = this.firDocumentId;
|
||||||
|
} else {
|
||||||
clientData.mobileOldOtpId = this.oldMobileOtpId;
|
clientData.mobileOldOtpId = this.oldMobileOtpId;
|
||||||
|
}
|
||||||
clientData.mobileNewOtpId = this.newMobileOtpId;
|
clientData.mobileNewOtpId = this.newMobileOtpId;
|
||||||
}
|
}
|
||||||
if (changedFields.emailChanged) {
|
if (changedFields.emailChanged) {
|
||||||
@@ -630,25 +685,34 @@ export class EditClientComponent {
|
|||||||
|
|
||||||
private detectSensitiveFieldChanges(originalData: any) {
|
private detectSensitiveFieldChanges(originalData: any) {
|
||||||
const form = this.clientGeneralForm;
|
const form = this.clientGeneralForm;
|
||||||
// Trimmed so browser autofill adding invisible leading/trailing whitespace never reads as
|
if (!form || !originalData) {
|
||||||
// a "change" — the masked preview shown in the Verify Contact Change panel only reveals the
|
return {
|
||||||
// first couple of characters, so a whitespace-only difference is otherwise invisible to the
|
nameChanged: false,
|
||||||
// user and just looks like the panel appeared for no reason. These trimmed-but-original-case
|
mobileChanged: false,
|
||||||
// values are what get returned/sent as the actual OTP destination and saved value — only the
|
emailChanged: false,
|
||||||
// *comparison* (below) is further case-normalized for email, so we never silently rewrite the
|
dobChanged: false,
|
||||||
// customer's email casing into the saved record or the OTP destination.
|
oldName: '',
|
||||||
const newMobile = (form?.get('mobileNo')?.value || '').trim();
|
newName: '',
|
||||||
const newEmail = (form?.get('emailAddress')?.value || '').trim();
|
oldMobile: '',
|
||||||
const newDobRaw = form?.get('dateOfBirth')?.value;
|
newMobile: '',
|
||||||
// Local calendar date, NOT `.toISOString()` — that converts to UTC first, which silently
|
oldEmail: '',
|
||||||
// shifts the date by a day in any positive-UTC-offset timezone (e.g. India, UTC+5:30:
|
newEmail: '',
|
||||||
// midnight local on the 15th becomes 18:30 UTC on the 14th). That made this comparison
|
oldDob: '',
|
||||||
// report a DOB "change" — falsely triggering the Re-KYC dialog — even when the user never
|
newDob: ''
|
||||||
// touched the date field, purely because the Date object round-tripped through the picker.
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const mobileControl = form.get('mobileNo');
|
||||||
|
const emailControl = form.get('emailAddress');
|
||||||
|
|
||||||
|
// Trimmed so browser autofill adding invisible leading/trailing whitespace never reads as a change
|
||||||
|
const newMobile = (mobileControl?.value || '').toString().trim();
|
||||||
|
const newEmail = (emailControl?.value || '').toString().trim();
|
||||||
|
const newDobRaw = form.get('dateOfBirth')?.value;
|
||||||
const newDob = newDobRaw instanceof Date ? this.toLocalDateString(newDobRaw) : newDobRaw || '';
|
const newDob = newDobRaw instanceof Date ? this.toLocalDateString(newDobRaw) : newDobRaw || '';
|
||||||
|
|
||||||
const oldMobile = (originalData.mobileNo || '').trim();
|
const oldMobile = (originalData.mobileNo || '').toString().trim();
|
||||||
const oldEmail = (originalData.emailAddress || '').trim();
|
const oldEmail = (originalData.emailAddress || '').toString().trim();
|
||||||
const oldDobRaw = originalData.dateOfBirth;
|
const oldDobRaw = originalData.dateOfBirth;
|
||||||
const oldDob = Array.isArray(oldDobRaw)
|
const oldDob = Array.isArray(oldDobRaw)
|
||||||
? `${oldDobRaw[0]}-${String(oldDobRaw[1]).padStart(2, '0')}-${String(oldDobRaw[2]).padStart(2, '0')}`
|
? `${oldDobRaw[0]}-${String(oldDobRaw[1]).padStart(2, '0')}-${String(oldDobRaw[2]).padStart(2, '0')}`
|
||||||
@@ -669,10 +733,10 @@ export class EditClientComponent {
|
|||||||
).trim();
|
).trim();
|
||||||
|
|
||||||
const newName = buildName({
|
const newName = buildName({
|
||||||
firstname: form?.get('firstname')?.value || '',
|
firstname: form.get('firstname')?.value || '',
|
||||||
middlename: form?.get('middlename')?.value || '',
|
middlename: form.get('middlename')?.value || '',
|
||||||
lastname: form?.get('lastname')?.value || '',
|
lastname: form.get('lastname')?.value || '',
|
||||||
fullname: form?.get('fullname')?.value || ''
|
fullname: form.get('fullname')?.value || ''
|
||||||
});
|
});
|
||||||
const oldName = buildName({
|
const oldName = buildName({
|
||||||
firstname: originalData.firstname || '',
|
firstname: originalData.firstname || '',
|
||||||
@@ -681,12 +745,21 @@ export class EditClientComponent {
|
|||||||
fullname: originalData.fullname || ''
|
fullname: originalData.fullname || ''
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Normalize phone numbers by stripping non-digit characters (+91 9009009009 -> 9009009009)
|
||||||
|
const normPhone = (val: string) => val.replace(/\D/g, '');
|
||||||
|
const newMobileNorm = normPhone(newMobile);
|
||||||
|
const oldMobileNorm = normPhone(oldMobile);
|
||||||
|
|
||||||
|
// Mobile/Email are considered changed ONLY when the control has been edited (dirty) AND normalized values differ
|
||||||
|
const mobileChanged =
|
||||||
|
!!oldMobileNorm && !!newMobileNorm && !!mobileControl?.dirty && newMobileNorm !== oldMobileNorm;
|
||||||
|
const emailChanged =
|
||||||
|
!!oldEmail && !!newEmail && !!emailControl?.dirty && newEmail.toLowerCase() !== oldEmail.toLowerCase();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
nameChanged: newName !== oldName,
|
nameChanged: newName !== oldName,
|
||||||
mobileChanged: newMobile !== oldMobile,
|
mobileChanged,
|
||||||
// Case-insensitive: emails are not case-sensitive in practice, and comparing raw case
|
emailChanged,
|
||||||
// would flag a change (surfacing the dual-OTP panel) even when nothing meaningful changed.
|
|
||||||
emailChanged: newEmail.toLowerCase() !== oldEmail.toLowerCase(),
|
|
||||||
dobChanged: newDob !== oldDob,
|
dobChanged: newDob !== oldDob,
|
||||||
oldName,
|
oldName,
|
||||||
newName,
|
newName,
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
/**
|
||||||
|
* 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 {
|
||||||
|
deriveKycExceptionFields,
|
||||||
|
KycExceptionInput,
|
||||||
|
KYC_EXCEPTION_AADHAAR,
|
||||||
|
KYC_EXCEPTION_PAN
|
||||||
|
} from './kyc-exception.util';
|
||||||
|
|
||||||
|
const TODAY = '05 August 2026';
|
||||||
|
|
||||||
|
/** Individual client, nothing previously recorded — the client-creation case. */
|
||||||
|
const newIndividual = (panVerified: boolean, aadhaarVerified: boolean): KycExceptionInput => ({
|
||||||
|
panVerified,
|
||||||
|
aadhaarVerified,
|
||||||
|
aadhaarApplicable: true,
|
||||||
|
hadException: false,
|
||||||
|
previousReason: null,
|
||||||
|
todayFormatted: TODAY
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Individual client that already carries an open exception. */
|
||||||
|
const existingException = (
|
||||||
|
panVerified: boolean,
|
||||||
|
aadhaarVerified: boolean,
|
||||||
|
previousReason: string
|
||||||
|
): KycExceptionInput => ({
|
||||||
|
panVerified,
|
||||||
|
aadhaarVerified,
|
||||||
|
aadhaarApplicable: true,
|
||||||
|
hadException: true,
|
||||||
|
previousReason,
|
||||||
|
todayFormatted: TODAY
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deriveKycExceptionFields', () => {
|
||||||
|
describe('at client creation', () => {
|
||||||
|
it('marks both PAN and Aadhaar when neither is entered or verified', () => {
|
||||||
|
expect(deriveKycExceptionFields(newIndividual(false, false))).toEqual({
|
||||||
|
isException: true,
|
||||||
|
exceptionReason: `${KYC_EXCEPTION_PAN}, ${KYC_EXCEPTION_AADHAAR}`,
|
||||||
|
exceptionResolved: false,
|
||||||
|
exceptionResolvedOn: null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks only PAN when Aadhaar verified but PAN did not', () => {
|
||||||
|
expect(deriveKycExceptionFields(newIndividual(false, true))).toEqual({
|
||||||
|
isException: true,
|
||||||
|
exceptionReason: KYC_EXCEPTION_PAN,
|
||||||
|
exceptionResolved: false,
|
||||||
|
exceptionResolvedOn: null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks only Aadhaar when PAN verified but Aadhaar did not', () => {
|
||||||
|
expect(deriveKycExceptionFields(newIndividual(true, false))).toEqual({
|
||||||
|
isException: true,
|
||||||
|
exceptionReason: KYC_EXCEPTION_AADHAAR,
|
||||||
|
exceptionResolved: false,
|
||||||
|
exceptionResolvedOn: null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('raises nothing when both verify, and does not mark the client resolved', () => {
|
||||||
|
// A clean client never had an exception, so it must not appear in the Resolved tab either.
|
||||||
|
expect(deriveKycExceptionFields(newIndividual(true, true))).toEqual({
|
||||||
|
isException: false,
|
||||||
|
exceptionReason: null,
|
||||||
|
exceptionResolved: false,
|
||||||
|
exceptionResolvedOn: null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not raise an Aadhaar exception for a non-individual client', () => {
|
||||||
|
// Companies submit a dummy masked Aadhaar; checking it would mean a permanent exception.
|
||||||
|
expect(
|
||||||
|
deriveKycExceptionFields({
|
||||||
|
panVerified: true,
|
||||||
|
aadhaarVerified: false,
|
||||||
|
aadhaarApplicable: false,
|
||||||
|
hadException: false,
|
||||||
|
previousReason: null,
|
||||||
|
todayFormatted: TODAY
|
||||||
|
}).isException
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolving', () => {
|
||||||
|
it('closes the exception once both checks pass, keeping the reason for the audit trail', () => {
|
||||||
|
const both = `${KYC_EXCEPTION_PAN}, ${KYC_EXCEPTION_AADHAAR}`;
|
||||||
|
expect(deriveKycExceptionFields(existingException(true, true, both))).toEqual({
|
||||||
|
isException: false,
|
||||||
|
exceptionReason: both,
|
||||||
|
exceptionResolved: true,
|
||||||
|
exceptionResolvedOn: TODAY
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('narrows to the outstanding item when only one of two verifies', () => {
|
||||||
|
const both = `${KYC_EXCEPTION_PAN}, ${KYC_EXCEPTION_AADHAAR}`;
|
||||||
|
// PAN verified, Aadhaar still outstanding — stays open, listing Aadhaar alone.
|
||||||
|
expect(deriveKycExceptionFields(existingException(true, false, both))).toEqual({
|
||||||
|
isException: true,
|
||||||
|
exceptionReason: KYC_EXCEPTION_AADHAAR,
|
||||||
|
exceptionResolved: false,
|
||||||
|
exceptionResolvedOn: null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('narrows the other way round too', () => {
|
||||||
|
const both = `${KYC_EXCEPTION_PAN}, ${KYC_EXCEPTION_AADHAAR}`;
|
||||||
|
expect(deriveKycExceptionFields(existingException(false, true, both)).exceptionReason).toBe(KYC_EXCEPTION_PAN);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('re-opening', () => {
|
||||||
|
it('clears a prior resolution so a new exception returns to Pending', () => {
|
||||||
|
// Regression: without exceptionResolved=false the record still matched the Resolved query
|
||||||
|
// and a brand-new exception appeared as already closed.
|
||||||
|
const result = deriveKycExceptionFields({
|
||||||
|
panVerified: false,
|
||||||
|
aadhaarVerified: true,
|
||||||
|
aadhaarApplicable: true,
|
||||||
|
hadException: false,
|
||||||
|
previousReason: 'PAN Card Verification Failed',
|
||||||
|
todayFormatted: TODAY
|
||||||
|
});
|
||||||
|
expect(result.exceptionResolved).toBe(false);
|
||||||
|
expect(result.exceptionResolvedOn).toBeNull();
|
||||||
|
expect(result.isException).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('officer-entered reasons', () => {
|
||||||
|
it('keeps a manual reason alongside the automatic ones', () => {
|
||||||
|
const result = deriveKycExceptionFields({
|
||||||
|
...newIndividual(true, true),
|
||||||
|
manualReasons: ['Address proof pending']
|
||||||
|
});
|
||||||
|
expect(result).toEqual({
|
||||||
|
isException: true,
|
||||||
|
exceptionReason: 'Address proof pending',
|
||||||
|
exceptionResolved: false,
|
||||||
|
exceptionResolvedOn: null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops a stale automatic reason left in the form once the check passes', () => {
|
||||||
|
// The form still holds 'PAN Card Verification Failed' from a previous load; PAN has since
|
||||||
|
// verified, so it must not survive into the payload.
|
||||||
|
const result = deriveKycExceptionFields({
|
||||||
|
...newIndividual(true, true),
|
||||||
|
manualReasons: [KYC_EXCEPTION_PAN]
|
||||||
|
});
|
||||||
|
expect(result.isException).toBe(false);
|
||||||
|
expect(result.exceptionReason).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not duplicate a manual reason that matches an outstanding automatic one', () => {
|
||||||
|
const result = deriveKycExceptionFields({
|
||||||
|
...newIndividual(false, true),
|
||||||
|
manualReasons: [KYC_EXCEPTION_PAN]
|
||||||
|
});
|
||||||
|
expect(result.exceptionReason).toBe(KYC_EXCEPTION_PAN);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derives a client's KYC exception state from their verification status.
|
||||||
|
*
|
||||||
|
* A KYC exception is not something an officer should have to remember to tick — it is simply the
|
||||||
|
* fact that a mandatory identity check has not passed. Deriving it means the exception list can
|
||||||
|
* never drift out of step with reality: raising, narrowing and closing all fall out of whether PAN
|
||||||
|
* and Aadhaar are verified at the moment of the save.
|
||||||
|
*
|
||||||
|
* Shared by the client-creation stepper and the client-view KYC tab so both write identical state.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const KYC_EXCEPTION_PAN = 'PAN Card Verification Failed';
|
||||||
|
export const KYC_EXCEPTION_AADHAAR = 'Aadhaar Verification Failed';
|
||||||
|
|
||||||
|
/** The two reasons this module owns. Anything else on a record was added by an officer by hand. */
|
||||||
|
export const AUTO_MANAGED_KYC_EXCEPTION_REASONS: string[] = [
|
||||||
|
KYC_EXCEPTION_PAN,
|
||||||
|
KYC_EXCEPTION_AADHAAR
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface KycExceptionInput {
|
||||||
|
/** PAN verified against the Income Tax database. Unverified OR simply not entered → exception. */
|
||||||
|
panVerified: boolean;
|
||||||
|
/** Aadhaar verified against UIDAI. Unverified OR simply not entered → exception. */
|
||||||
|
aadhaarVerified: boolean;
|
||||||
|
/**
|
||||||
|
* False for non-individual clients, where no Aadhaar is collected (the payload sends a dummy
|
||||||
|
* masked value). Without this guard every company would carry a permanent Aadhaar exception.
|
||||||
|
*/
|
||||||
|
aadhaarApplicable: boolean;
|
||||||
|
/** Whether an exception is already recorded — decides raise vs. resolve on a clean save. */
|
||||||
|
hadException: boolean;
|
||||||
|
/** Reason currently persisted, kept verbatim when closing so the audit trail survives. */
|
||||||
|
previousReason: string | null;
|
||||||
|
/** Officer-entered reasons beyond the two automatic ones; carried through untouched. */
|
||||||
|
manualReasons?: string[];
|
||||||
|
/** Today in the tenant's date format — stamped as exceptionResolvedOn when closing. */
|
||||||
|
todayFormatted: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KycExceptionFields {
|
||||||
|
isException: boolean;
|
||||||
|
exceptionReason: string | null;
|
||||||
|
exceptionResolved: boolean;
|
||||||
|
exceptionResolvedOn: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps verification status to the four exception columns the API expects.
|
||||||
|
*
|
||||||
|
* Three outcomes:
|
||||||
|
* - **Something outstanding** → raise/keep the exception, listing only what is still failing. A
|
||||||
|
* record that had both PAN and Aadhaar outstanding and has since verified PAN comes back with
|
||||||
|
* Aadhaar alone, which is what narrows the entry rather than closing it.
|
||||||
|
* - **Nothing outstanding, exception existed** → close it. `exceptionReason` is retained on
|
||||||
|
* purpose: the Resolved tab shows what was resolved, and clearing it would erase the record of
|
||||||
|
* why the exception was ever raised.
|
||||||
|
* - **Nothing outstanding, no exception** → leave every flag off, so a clean client never appears
|
||||||
|
* in either tab.
|
||||||
|
*/
|
||||||
|
export function deriveKycExceptionFields(input: KycExceptionInput): KycExceptionFields {
|
||||||
|
const outstanding: string[] = [];
|
||||||
|
|
||||||
|
if (!input.panVerified) {
|
||||||
|
outstanding.push(KYC_EXCEPTION_PAN);
|
||||||
|
}
|
||||||
|
if (input.aadhaarApplicable && !input.aadhaarVerified) {
|
||||||
|
outstanding.push(KYC_EXCEPTION_AADHAAR);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Officer-added reasons ride along, but the two automatic ones are filtered out first — a stale
|
||||||
|
// 'PAN Card Verification Failed' left in the form must not survive PAN actually being verified.
|
||||||
|
for (const reason of input.manualReasons || []) {
|
||||||
|
const trimmed = (reason || '').trim();
|
||||||
|
if (trimmed && !AUTO_MANAGED_KYC_EXCEPTION_REASONS.includes(trimmed) && !outstanding.includes(trimmed)) {
|
||||||
|
outstanding.push(trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (outstanding.length > 0) {
|
||||||
|
return {
|
||||||
|
isException: true,
|
||||||
|
exceptionReason: outstanding.join(', '),
|
||||||
|
exceptionResolved: false,
|
||||||
|
exceptionResolvedOn: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.hadException) {
|
||||||
|
return {
|
||||||
|
isException: false,
|
||||||
|
exceptionReason: input.previousReason || null,
|
||||||
|
exceptionResolved: true,
|
||||||
|
exceptionResolvedOn: input.todayFormatted
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isException: false,
|
||||||
|
exceptionReason: null,
|
||||||
|
exceptionResolved: false,
|
||||||
|
exceptionResolvedOn: null
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -99,6 +99,19 @@
|
|||||||
</svg>
|
</svg>
|
||||||
My Disbursements
|
My Disbursements
|
||||||
</a>
|
</a>
|
||||||
|
<a
|
||||||
|
class="cashier-nav__item cashier-nav__item--sec"
|
||||||
|
[class.cashier-nav__item--active]="currentUrl.includes('/clerk/share-withdrawal-request')"
|
||||||
|
[routerLink]="['/clerk/share-withdrawal-requests']"
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="12" cy="12" r="9" />
|
||||||
|
<path
|
||||||
|
d="M9.5 9a2.5 2.5 0 0 1 2.5-1.5c1.4 0 2.5.8 2.5 2s-1 1.5-2.5 1.9c-1.5.4-2.5.9-2.5 2.1s1.1 2 2.5 2 2.5-.7 2.5-1.6"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
My Share Requests
|
||||||
|
</a>
|
||||||
@if (clerkAccountLoaded) {
|
@if (clerkAccountLoaded) {
|
||||||
<div class="cashier-nav__item-wrap">
|
<div class="cashier-nav__item-wrap">
|
||||||
<a
|
<a
|
||||||
@@ -350,10 +363,6 @@
|
|||||||
<fa-icon icon="exclamation-triangle" class="ni-ico"></fa-icon>NPA
|
<fa-icon icon="exclamation-triangle" class="ni-ico"></fa-icon>NPA
|
||||||
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
|
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
|
||||||
</a>
|
</a>
|
||||||
<a class="ni" tabindex="0" [matMenuTriggerFor]="loanOriginationMenu" *mifosxHasPermission="'MENU_ADVANCES'">
|
|
||||||
<fa-icon icon="clipboard-check" class="ni-ico"></fa-icon>Loan Origination
|
|
||||||
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
|
|
||||||
</a>
|
|
||||||
<a class="ni" tabindex="0" [matMenuTriggerFor]="advancesMenu" *mifosxHasPermission="'MENU_ADVANCES'">
|
<a class="ni" tabindex="0" [matMenuTriggerFor]="advancesMenu" *mifosxHasPermission="'MENU_ADVANCES'">
|
||||||
<fa-icon icon="hand-holding-usd" class="ni-ico"></fa-icon>Advances
|
<fa-icon icon="hand-holding-usd" class="ni-ico"></fa-icon>Advances
|
||||||
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
|
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
|
||||||
@@ -366,10 +375,6 @@
|
|||||||
<fa-icon icon="university" class="ni-ico"></fa-icon>RBI
|
<fa-icon icon="university" class="ni-ico"></fa-icon>RBI
|
||||||
<fa-icon [icon]="rbiMenuOpen ? 'caret-up' : 'caret-down'" class="ni-caret"></fa-icon>
|
<fa-icon [icon]="rbiMenuOpen ? 'caret-up' : 'caret-down'" class="ni-caret"></fa-icon>
|
||||||
</a>
|
</a>
|
||||||
<a class="ni" tabindex="0" (click)="investmentMenuOpen = !investmentMenuOpen; rbiMenuOpen = false; glMenuOpen = false" *mifosxHasPermission="'MENU_RBI'">
|
|
||||||
<fa-icon icon="chart-line" class="ni-ico"></fa-icon>Investment
|
|
||||||
<fa-icon [icon]="investmentMenuOpen ? 'caret-up' : 'caret-down'" class="ni-caret"></fa-icon>
|
|
||||||
</a>
|
|
||||||
<a class="ni" tabindex="0" [matMenuTriggerFor]="digitalMenu" *mifosxHasPermission="'MENU_DIGITAL'">
|
<a class="ni" tabindex="0" [matMenuTriggerFor]="digitalMenu" *mifosxHasPermission="'MENU_DIGITAL'">
|
||||||
<fa-icon icon="mobile-alt" class="ni-ico"></fa-icon>Digital
|
<fa-icon icon="mobile-alt" class="ni-ico"></fa-icon>Digital
|
||||||
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
|
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
|
||||||
@@ -446,10 +451,6 @@
|
|||||||
<fa-icon icon="exclamation-triangle" class="ni-ico"></fa-icon>NPA
|
<fa-icon icon="exclamation-triangle" class="ni-ico"></fa-icon>NPA
|
||||||
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
|
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
|
||||||
</a>
|
</a>
|
||||||
<a class="ni" tabindex="0" [matMenuTriggerFor]="loanOriginationMenu" *mifosxHasPermission="'MENU_ADVANCES'">
|
|
||||||
<fa-icon icon="clipboard-check" class="ni-ico"></fa-icon>Loan Origination
|
|
||||||
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
|
|
||||||
</a>
|
|
||||||
<a
|
<a
|
||||||
class="ni"
|
class="ni"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
@@ -504,15 +505,6 @@
|
|||||||
<fa-icon icon="university" class="ni-ico"></fa-icon>RBI
|
<fa-icon icon="university" class="ni-ico"></fa-icon>RBI
|
||||||
<fa-icon [icon]="rbiMenuOpen ? 'caret-up' : 'caret-down'" class="ni-caret"></fa-icon>
|
<fa-icon [icon]="rbiMenuOpen ? 'caret-up' : 'caret-down'" class="ni-caret"></fa-icon>
|
||||||
</a>
|
</a>
|
||||||
<a
|
|
||||||
class="ni"
|
|
||||||
tabindex="0"
|
|
||||||
(click)="investmentMenuOpen = !investmentMenuOpen; rbiMenuOpen = false; glMenuOpen = false"
|
|
||||||
*mifosxHasPermission="'MENU_RBI'"
|
|
||||||
>
|
|
||||||
<fa-icon icon="chart-line" class="ni-ico"></fa-icon>Investment
|
|
||||||
<fa-icon [icon]="investmentMenuOpen ? 'caret-up' : 'caret-down'" class="ni-caret"></fa-icon>
|
|
||||||
</a>
|
|
||||||
<a class="ni" tabindex="0" [matMenuTriggerFor]="reportsMenu" *mifosxHasPermission="'MENU_REPORTS'">
|
<a class="ni" tabindex="0" [matMenuTriggerFor]="reportsMenu" *mifosxHasPermission="'MENU_REPORTS'">
|
||||||
<fa-icon icon="chart-bar" class="ni-ico"></fa-icon>Reports
|
<fa-icon icon="chart-bar" class="ni-ico"></fa-icon>Reports
|
||||||
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
|
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
|
||||||
@@ -594,6 +586,12 @@
|
|||||||
<button mat-menu-item routerLink="/reports/risk-review" tabindex="0">
|
<button mat-menu-item routerLink="/reports/risk-review" tabindex="0">
|
||||||
<i class="fa fa-user-shield menu-item-icon"></i> Risk Review Report
|
<i class="fa fa-user-shield menu-item-icon"></i> Risk Review Report
|
||||||
</button>
|
</button>
|
||||||
|
<button mat-menu-item routerLink="/reports/tds-interest" tabindex="0">
|
||||||
|
<i class="fa fa-percent menu-item-icon"></i> Deposit Interest & TDS
|
||||||
|
</button>
|
||||||
|
<button mat-menu-item routerLink="/reports/tds-remittance" tabindex="0">
|
||||||
|
<i class="fa fa-landmark menu-item-icon"></i> TDS Remitted to Government
|
||||||
|
</button>
|
||||||
</mat-menu>
|
</mat-menu>
|
||||||
|
|
||||||
<!-- Admin Mega Menu — 3-column rectangular panel grouping Setup,
|
<!-- Admin Mega Menu — 3-column rectangular panel grouping Setup,
|
||||||
@@ -681,7 +679,6 @@
|
|||||||
<a
|
<a
|
||||||
class="gl-link"
|
class="gl-link"
|
||||||
[class.hidden]="!matchesSubLedgerSearch('Cash / Clearing / Transfer')"
|
[class.hidden]="!matchesSubLedgerSearch('Cash / Clearing / Transfer')"
|
||||||
routerLink="/cashier/cash-clearing-transfer-summary"
|
|
||||||
(click)="subLedgerOpen = false"
|
(click)="subLedgerOpen = false"
|
||||||
><fa-icon icon="money-bill" class="gl-icon"></fa-icon> Cash / Clearing / Transfer</a
|
><fa-icon icon="money-bill" class="gl-icon"></fa-icon> Cash / Clearing / Transfer</a
|
||||||
>
|
>
|
||||||
@@ -730,11 +727,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="gl-group">
|
<div class="gl-group">
|
||||||
<div class="gl-group-title">Deposits</div>
|
<div class="gl-group-title">Deposits</div>
|
||||||
<a
|
<a class="gl-link" [class.hidden]="!matchesSubLedgerSearch('FD / RD Entry')" (click)="subLedgerOpen = false"
|
||||||
class="gl-link"
|
><fa-icon icon="piggy-bank" class="gl-icon"></fa-icon> FD / RD Entry</a
|
||||||
[class.hidden]="!matchesSubLedgerSearch('FD / RD Entry')"
|
|
||||||
(click)="subLedgerOpen = false; openFdRdEntryDialog()"
|
|
||||||
><fa-icon icon="piggy-bank" class="gl-icon" style="color: #0f766e"></fa-icon> FD / RD Entry</a
|
|
||||||
>
|
>
|
||||||
<a class="gl-link" [class.hidden]="!matchesSubLedgerSearch('DDS Entry')" (click)="subLedgerOpen = false"
|
<a class="gl-link" [class.hidden]="!matchesSubLedgerSearch('DDS Entry')" (click)="subLedgerOpen = false"
|
||||||
><fa-icon icon="coins" class="gl-icon"></fa-icon> DDS Entry</a
|
><fa-icon icon="coins" class="gl-icon"></fa-icon> DDS Entry</a
|
||||||
@@ -840,72 +834,50 @@
|
|||||||
<div class="gl-columns">
|
<div class="gl-columns">
|
||||||
<div class="gl-group">
|
<div class="gl-group">
|
||||||
<div class="gl-group-title">Financial Statements</div>
|
<div class="gl-group-title">Financial Statements</div>
|
||||||
<a
|
<a class="gl-link" [class.hidden]="!matchesGlSearch('Trial Balance')" (click)="glMenuOpen = false"
|
||||||
class="gl-link"
|
><fa-icon icon="balance-scale" class="gl-icon"></fa-icon> Trial Balance</a
|
||||||
[class.hidden]="!matchesGlSearch('Trial Balance')"
|
|
||||||
routerLink="/gl/reports/trial-balance"
|
|
||||||
(click)="glMenuOpen = false"
|
|
||||||
><fa-icon icon="balance-scale" class="gl-icon" style="color: #b45309"></fa-icon> Trial Balance</a
|
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
class="gl-link"
|
class="gl-link"
|
||||||
[class.hidden]="!matchesGlSearch('Profit and Loss')"
|
[class.hidden]="!matchesGlSearch('Profit and Loss')"
|
||||||
routerLink="/gl/reports/profit-and-loss"
|
(click)="glMenuOpen = false; openAccountingReportsDialog('PNL')"
|
||||||
(click)="glMenuOpen = false"
|
|
||||||
><fa-icon icon="chart-line" class="gl-icon" style="color: #b45309"></fa-icon> Profit and Loss</a
|
><fa-icon icon="chart-line" class="gl-icon" style="color: #b45309"></fa-icon> Profit and Loss</a
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
class="gl-link"
|
class="gl-link"
|
||||||
[class.hidden]="!matchesGlSearch('Consolidate Balance Sheet')"
|
[class.hidden]="!matchesGlSearch('Consolidate Balance Sheet')"
|
||||||
routerLink="/gl/reports/balance-sheet"
|
(click)="glMenuOpen = false; openAccountingReportsDialog('BALANCE-SHEET')"
|
||||||
(click)="glMenuOpen = false"
|
><fa-icon icon="file-alt" class="gl-icon" style="color: #b45309"></fa-icon> Consolidate Balance Sheet</a
|
||||||
><fa-icon icon="file-alt" class="gl-icon" style="color: #b45309"></fa-icon> Consolidated Balance Sheet</a
|
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
class="gl-link"
|
class="gl-link"
|
||||||
[class.hidden]="!matchesGlSearch('Comparative Balance Sheet')"
|
[class.hidden]="!matchesGlSearch('Comparative Balance Sheet')"
|
||||||
routerLink="/gl/reports/comparative-balance-sheet"
|
(click)="glMenuOpen = false; openAccountingReportsDialog('COMPARATIVE-BS')"
|
||||||
(click)="glMenuOpen = false"
|
|
||||||
><fa-icon icon="balance-scale" class="gl-icon" style="color: #b45309"></fa-icon> Comparative Balance
|
><fa-icon icon="balance-scale" class="gl-icon" style="color: #b45309"></fa-icon> Comparative Balance
|
||||||
Sheet</a
|
Sheet</a
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="gl-group">
|
<div class="gl-group">
|
||||||
<div class="gl-group-title">GL Reports</div>
|
<div class="gl-group-title">GL Reports</div>
|
||||||
<a
|
<a class="gl-link" [class.hidden]="!matchesGlSearch('GL Average Balance')" (click)="glMenuOpen = false"
|
||||||
class="gl-link"
|
><fa-icon icon="calculator" class="gl-icon"></fa-icon> GL Average Balance</a
|
||||||
[class.hidden]="!matchesGlSearch('GL Average Balance')"
|
|
||||||
routerLink="/gl/reports/gl-average-balance"
|
|
||||||
(click)="glMenuOpen = false"
|
|
||||||
><fa-icon icon="calculator" class="gl-icon" style="color: #b45309"></fa-icon> GL Average Balance</a
|
|
||||||
>
|
>
|
||||||
<a
|
<a class="gl-link" [class.hidden]="!matchesGlSearch('GL Mapping for Report')" (click)="glMenuOpen = false"
|
||||||
class="gl-link"
|
><fa-icon icon="project-diagram" class="gl-icon"></fa-icon> GL Mapping for Report</a
|
||||||
[class.hidden]="!matchesGlSearch('GL Mapping for Report')"
|
|
||||||
routerLink="/gl/reports/gl-mapping"
|
|
||||||
(click)="glMenuOpen = false"
|
|
||||||
><fa-icon icon="project-diagram" class="gl-icon" style="color: #b45309"></fa-icon> GL Mapping for
|
|
||||||
Report</a
|
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
class="gl-link"
|
class="gl-link"
|
||||||
[class.hidden]="!matchesGlSearch('Account wise Balance List')"
|
[class.hidden]="!matchesGlSearch('Account wise Balance List')"
|
||||||
routerLink="/gl/reports/account-wise-balance"
|
|
||||||
(click)="glMenuOpen = false"
|
(click)="glMenuOpen = false"
|
||||||
><fa-icon icon="list" class="gl-icon" style="color: #b45309"></fa-icon> Account wise Balance List</a
|
><fa-icon icon="list" class="gl-icon"></fa-icon> Account wise Balance List</a
|
||||||
>
|
>
|
||||||
<a
|
<a class="gl-link" [class.hidden]="!matchesGlSearch('Distribution of Balance')" (click)="glMenuOpen = false"
|
||||||
class="gl-link"
|
><fa-icon icon="chart-pie" class="gl-icon"></fa-icon> Distribution of Balance</a
|
||||||
[class.hidden]="!matchesGlSearch('Distribution of Balance')"
|
|
||||||
routerLink="/gl/reports/distribution-of-balance"
|
|
||||||
(click)="glMenuOpen = false"
|
|
||||||
><fa-icon icon="chart-pie" class="gl-icon" style="color: #b45309"></fa-icon> Distribution of Balance</a
|
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
class="gl-link"
|
class="gl-link"
|
||||||
[class.hidden]="!matchesGlSearch('GL Wise DR CR')"
|
[class.hidden]="!matchesGlSearch('GL Wise DR CR')"
|
||||||
routerLink="/gl/reports/gl-wise-dr-cr"
|
(click)="glMenuOpen = false; openAccountingReportsDialog('GL-DR-CR')"
|
||||||
(click)="glMenuOpen = false"
|
|
||||||
><fa-icon icon="project-diagram" class="gl-icon" style="color: #b45309"></fa-icon> GL Wise DR / CR
|
><fa-icon icon="project-diagram" class="gl-icon" style="color: #b45309"></fa-icon> GL Wise DR / CR
|
||||||
Report</a
|
Report</a
|
||||||
>
|
>
|
||||||
@@ -915,47 +887,28 @@
|
|||||||
<a
|
<a
|
||||||
class="gl-link"
|
class="gl-link"
|
||||||
[class.hidden]="!matchesGlSearch('Client wise Balance Report')"
|
[class.hidden]="!matchesGlSearch('Client wise Balance Report')"
|
||||||
routerLink="/gl/reports/client-wise-balance"
|
|
||||||
(click)="glMenuOpen = false"
|
(click)="glMenuOpen = false"
|
||||||
><fa-icon icon="users" class="gl-icon" style="color: #b45309"></fa-icon> Client wise Balance Report</a
|
><fa-icon icon="users" class="gl-icon"></fa-icon> Client wise Balance Report</a
|
||||||
>
|
>
|
||||||
<a
|
<a class="gl-link" [class.hidden]="!matchesGlSearch('Contingent Expense')" (click)="glMenuOpen = false"
|
||||||
class="gl-link"
|
><fa-icon icon="exclamation-circle" class="gl-icon"></fa-icon> Contingent Expense</a
|
||||||
[class.hidden]="!matchesGlSearch('Contingent Liabilities')"
|
|
||||||
routerLink="/gl/reports/contingent-liabilities"
|
|
||||||
(click)="glMenuOpen = false"
|
|
||||||
><fa-icon icon="exclamation-circle" class="gl-icon" style="color: #b45309"></fa-icon> Contingent
|
|
||||||
Liabilities</a
|
|
||||||
>
|
>
|
||||||
<a
|
<a class="gl-link" [class.hidden]="!matchesGlSearch('Banker Head Report')" (click)="glMenuOpen = false"
|
||||||
class="gl-link"
|
><fa-icon icon="university" class="gl-icon"></fa-icon> Banker Head Report</a
|
||||||
[class.hidden]="!matchesGlSearch('Banker Head Report')"
|
|
||||||
routerLink="/gl/reports/banker-head"
|
|
||||||
(click)="glMenuOpen = false"
|
|
||||||
><fa-icon icon="university" class="gl-icon" style="color: #b45309"></fa-icon> Banker Head Report</a
|
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="gl-group">
|
<div class="gl-group">
|
||||||
<div class="gl-group-title">Registers</div>
|
<div class="gl-group-title">Registers</div>
|
||||||
<a
|
<a class="gl-link" [class.hidden]="!matchesGlSearch('Sundry Register')" (click)="glMenuOpen = false"
|
||||||
class="gl-link"
|
><fa-icon icon="clipboard-list" class="gl-icon"></fa-icon> Sundry Register</a
|
||||||
[class.hidden]="!matchesGlSearch('Sundry Register')"
|
|
||||||
routerLink="/gl/reports/sundry-register"
|
|
||||||
(click)="glMenuOpen = false"
|
|
||||||
><fa-icon icon="clipboard-list" class="gl-icon" style="color: #b45309"></fa-icon> Sundry Register</a
|
|
||||||
>
|
>
|
||||||
<a
|
<a class="gl-link" [class.hidden]="!matchesGlSearch('Voucher Register')" (click)="glMenuOpen = false"
|
||||||
class="gl-link"
|
><fa-icon icon="receipt" class="gl-icon"></fa-icon> Voucher Register</a
|
||||||
[class.hidden]="!matchesGlSearch('Voucher Register')"
|
|
||||||
routerLink="/gl/reports/voucher-register"
|
|
||||||
(click)="glMenuOpen = false"
|
|
||||||
><fa-icon icon="receipt" class="gl-icon" style="color: #b45309"></fa-icon> Voucher Register</a
|
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
class="gl-link"
|
class="gl-link"
|
||||||
[class.hidden]="!matchesGlSearch('Misc Expense Ledger')"
|
[class.hidden]="!matchesGlSearch('Misc Expense Ledger')"
|
||||||
routerLink="/gl/reports/misc-expense-ledger"
|
(click)="glMenuOpen = false; openAccountingReportsDialog('MISC-EXP')"
|
||||||
(click)="glMenuOpen = false"
|
|
||||||
><fa-icon icon="receipt" class="gl-icon" style="color: #b45309"></fa-icon> Misc Expense Ledger</a
|
><fa-icon icon="receipt" class="gl-icon" style="color: #b45309"></fa-icon> Misc Expense Ledger</a
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -1228,7 +1181,7 @@
|
|||||||
<fa-icon icon="user-slash" class="menu-item-icon" style="color: #c2410c"></fa-icon> Non-Customer Entry
|
<fa-icon icon="user-slash" class="menu-item-icon" style="color: #c2410c"></fa-icon> Non-Customer Entry
|
||||||
</button>
|
</button>
|
||||||
<button mat-menu-item tabindex="0"><fa-icon icon="lock" class="menu-item-icon"></fa-icon> Locker</button>
|
<button mat-menu-item tabindex="0"><fa-icon icon="lock" class="menu-item-icon"></fa-icon> Locker</button>
|
||||||
<button mat-menu-item routerLink="/investment/bonds" tabindex="0">
|
<button mat-menu-item tabindex="0">
|
||||||
<fa-icon icon="chart-line" class="menu-item-icon"></fa-icon> SLR and Non SLR Investment Report
|
<fa-icon icon="chart-line" class="menu-item-icon"></fa-icon> SLR and Non SLR Investment Report
|
||||||
</button>
|
</button>
|
||||||
<button mat-menu-item tabindex="0" (click)="openCicReportingDialog()">
|
<button mat-menu-item tabindex="0" (click)="openCicReportingDialog()">
|
||||||
@@ -1244,13 +1197,13 @@
|
|||||||
<!-- Investment -->
|
<!-- Investment -->
|
||||||
<div class="deposit-menu-group">
|
<div class="deposit-menu-group">
|
||||||
<div class="deposit-group-title">Investment</div>
|
<div class="deposit-group-title">Investment</div>
|
||||||
<button mat-menu-item routerLink="/investment/dashboard" tabindex="0">
|
<button mat-menu-item tabindex="0">
|
||||||
<fa-icon icon="calendar-alt" class="menu-item-icon"></fa-icon> Due Date Diary for All Investment
|
<fa-icon icon="calendar-alt" class="menu-item-icon"></fa-icon> Due Date Diary for All Investment
|
||||||
</button>
|
</button>
|
||||||
<button mat-menu-item routerLink="/investment/reports" tabindex="0">
|
<button mat-menu-item tabindex="0">
|
||||||
<fa-icon icon="dollar-sign" class="menu-item-icon"></fa-icon> Total Int Received & Receivable Report
|
<fa-icon icon="dollar-sign" class="menu-item-icon"></fa-icon> Total Int Received & Receivable Report
|
||||||
</button>
|
</button>
|
||||||
<button mat-menu-item routerLink="/investment/reports" tabindex="0">
|
<button mat-menu-item tabindex="0">
|
||||||
<fa-icon icon="piggy-bank" class="menu-item-icon"></fa-icon> Bucket Wise Investment
|
<fa-icon icon="piggy-bank" class="menu-item-icon"></fa-icon> Bucket Wise Investment
|
||||||
</button>
|
</button>
|
||||||
<button mat-menu-item routerLink="/deaf" tabindex="0">
|
<button mat-menu-item routerLink="/deaf" tabindex="0">
|
||||||
@@ -1281,96 +1234,39 @@
|
|||||||
<!-- NPA Reports -->
|
<!-- NPA Reports -->
|
||||||
<div class="deposit-menu-group">
|
<div class="deposit-menu-group">
|
||||||
<div class="deposit-group-title">NPA Reports</div>
|
<div class="deposit-group-title">NPA Reports</div>
|
||||||
<button mat-menu-item tabindex="0" routerLink="/npa/reports/interest">
|
<button mat-menu-item tabindex="0">
|
||||||
<fa-icon icon="chart-line" class="menu-item-icon"></fa-icon> NPA Report
|
<fa-icon icon="chart-line" class="menu-item-icon"></fa-icon> NPA Interest
|
||||||
</button>
|
</button>
|
||||||
<button mat-menu-item tabindex="0" routerLink="/npa/reports/status">
|
<button mat-menu-item tabindex="0">
|
||||||
<fa-icon icon="file-alt" class="menu-item-icon"></fa-icon> NPA Status Report
|
<fa-icon icon="file-alt" class="menu-item-icon"></fa-icon> NPA Status Report
|
||||||
</button>
|
</button>
|
||||||
<button mat-menu-item tabindex="0" routerLink="/npa/reports/recovery">
|
<button mat-menu-item tabindex="0">
|
||||||
<fa-icon icon="undo" class="menu-item-icon"></fa-icon> NPA Recovery Report
|
<fa-icon icon="undo" class="menu-item-icon"></fa-icon> NPA Recovery Report
|
||||||
</button>
|
</button>
|
||||||
<button mat-menu-item tabindex="0" routerLink="/npa/reports/summary">
|
<button mat-menu-item tabindex="0">
|
||||||
<fa-icon icon="clipboard-list" class="menu-item-icon"></fa-icon> NPA Summary Report
|
<fa-icon icon="clipboard-list" class="menu-item-icon"></fa-icon> NPA Summary Report
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<!-- NPA Analysis -->
|
<!-- NPA Analysis -->
|
||||||
<div class="deposit-menu-group">
|
<div class="deposit-menu-group">
|
||||||
<div class="deposit-group-title">NPA Analysis</div>
|
<div class="deposit-group-title">NPA Analysis</div>
|
||||||
<button mat-menu-item tabindex="0" routerLink="/npa/reports/account-detail">
|
<button mat-menu-item tabindex="0">
|
||||||
<fa-icon icon="list-alt" class="menu-item-icon"></fa-icon> NPA Account Detail Report
|
<fa-icon icon="list-alt" class="menu-item-icon"></fa-icon> NPA Account Detail Report
|
||||||
</button>
|
</button>
|
||||||
<button mat-menu-item tabindex="0" routerLink="/npa/reports/movement">
|
<button mat-menu-item tabindex="0">
|
||||||
<fa-icon icon="exchange" class="menu-item-icon"></fa-icon> NPA Movement Report
|
<fa-icon icon="exchange" class="menu-item-icon"></fa-icon> NPA Movement Report
|
||||||
</button>
|
</button>
|
||||||
<button mat-menu-item tabindex="0" routerLink="/npa/reports/provision">
|
<button mat-menu-item tabindex="0">
|
||||||
<fa-icon icon="hand-holding-usd" class="menu-item-icon"></fa-icon> NPA with Provision and Required Provision
|
<fa-icon icon="hand-holding-usd" class="menu-item-icon"></fa-icon> NPA with Provision and Required Provision
|
||||||
</button>
|
</button>
|
||||||
<button mat-menu-item tabindex="0" routerLink="/npa/reports/undebited-interest">
|
|
||||||
<fa-icon icon="coins" class="menu-item-icon"></fa-icon> Undebited Interest Recovery Report
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<!-- NPA Classification -->
|
<!-- NPA Classification -->
|
||||||
<div class="deposit-menu-group">
|
<div class="deposit-menu-group">
|
||||||
<div class="deposit-group-title">NPA Classification</div>
|
<div class="deposit-group-title">NPA Classification</div>
|
||||||
<button mat-menu-item tabindex="0" routerLink="/npa/reports/probable">
|
<button mat-menu-item tabindex="0">
|
||||||
<fa-icon icon="exclamation-circle" class="menu-item-icon"></fa-icon> Probable NPA
|
<fa-icon icon="exclamation-circle" class="menu-item-icon"></fa-icon> Probable NPA
|
||||||
</button>
|
</button>
|
||||||
<button mat-menu-item tabindex="0" routerLink="/npa/reports/sma">
|
<button mat-menu-item tabindex="0"><fa-icon icon="flag" class="menu-item-icon"></fa-icon> SMA Report</button>
|
||||||
<fa-icon icon="flag" class="menu-item-icon"></fa-icon> SMA Report
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<!-- Recovery Management -->
|
|
||||||
<div class="deposit-menu-group">
|
|
||||||
<div class="deposit-group-title">Recovery Management</div>
|
|
||||||
<button mat-menu-item tabindex="0" routerLink="/npa/reports/recovery-dashboard">
|
|
||||||
<fa-icon icon="tachometer-alt" class="menu-item-icon"></fa-icon> Recovery Management Dashboard
|
|
||||||
</button>
|
|
||||||
<button mat-menu-item tabindex="0" routerLink="/npa/reports/daywise-overdue">
|
|
||||||
<fa-icon icon="calendar-alt" class="menu-item-icon"></fa-icon> Day-wise Overdue Report
|
|
||||||
</button>
|
|
||||||
<button mat-menu-item tabindex="0" routerLink="/npa/reports/daywise-overdrawn">
|
|
||||||
<fa-icon icon="credit-card" class="menu-item-icon"></fa-icon> Day-wise Overdrawn Report
|
|
||||||
</button>
|
|
||||||
<button mat-menu-item tabindex="0" routerLink="/npa/reports/agri-gold-value">
|
|
||||||
<fa-icon icon="crop" class="menu-item-icon"></fa-icon> Agriculture / Gold Market Value Report
|
|
||||||
</button>
|
|
||||||
<button mat-menu-item tabindex="0" routerLink="/npa/reports/exception-check">
|
|
||||||
<fa-icon icon="balance-scale" class="menu-item-icon"></fa-icon> Exception Check — Security Value vs Loan
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</mat-menu>
|
|
||||||
|
|
||||||
<mat-menu #loanOriginationMenu="matMenu" class="npa-mega-panel" [overlapTrigger]="false">
|
|
||||||
<div class="deposit-menu-grid npa-grid" (click)="$event.stopPropagation()">
|
|
||||||
<!-- Loan Origination -->
|
|
||||||
<div class="deposit-menu-group">
|
|
||||||
<div class="deposit-group-title">Loan Origination</div>
|
|
||||||
<button mat-menu-item tabindex="0" routerLink="/loan-origination/pipeline">
|
|
||||||
<fa-icon icon="stream" class="menu-item-icon"></fa-icon> Application Pipeline
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<!-- Reports -->
|
|
||||||
<div class="deposit-menu-group">
|
|
||||||
<div class="deposit-group-title">Reports</div>
|
|
||||||
<button
|
|
||||||
mat-menu-item
|
|
||||||
tabindex="0"
|
|
||||||
routerLink="/loan-origination/pipeline"
|
|
||||||
[queryParams]="{ view: 'channel' }"
|
|
||||||
>
|
|
||||||
<fa-icon icon="chart-pie" class="menu-item-icon"></fa-icon> Channel-wise Report
|
|
||||||
</button>
|
|
||||||
<button mat-menu-item tabindex="0" routerLink="/loan-origination/pipeline" [queryParams]="{ view: 'bc' }">
|
|
||||||
<fa-icon icon="mobile-alt" class="menu-item-icon"></fa-icon> BC-wise Report
|
|
||||||
</button>
|
|
||||||
<button mat-menu-item tabindex="0" routerLink="/loan-origination/pipeline" [queryParams]="{ view: 'share' }">
|
|
||||||
<fa-icon icon="coins" class="menu-item-icon"></fa-icon> Share Member Report
|
|
||||||
</button>
|
|
||||||
<button mat-menu-item tabindex="0" routerLink="/loan-origination/pipeline" [queryParams]="{ view: 'target' }">
|
|
||||||
<fa-icon icon="chart-column" class="menu-item-icon"></fa-icon> Target vs Actual
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</mat-menu>
|
</mat-menu>
|
||||||
@@ -1403,27 +1299,27 @@
|
|||||||
<!-- Core / Books -->
|
<!-- Core / Books -->
|
||||||
<div class="deposit-menu-group">
|
<div class="deposit-menu-group">
|
||||||
<div class="deposit-group-title">Core & Books</div>
|
<div class="deposit-group-title">Core & Books</div>
|
||||||
<button mat-menu-item tabindex="0" routerLink="/reconciliation/voucher">
|
<button mat-menu-item tabindex="0">
|
||||||
<fa-icon icon="receipt" class="menu-item-icon" style="color: #0d9488"></fa-icon>
|
<fa-icon icon="receipt" class="menu-item-icon" style="color: #0d9488"></fa-icon>
|
||||||
Voucher Reconciliation
|
Voucher Reconciliation
|
||||||
</button>
|
</button>
|
||||||
<button mat-menu-item tabindex="0" routerLink="/reconciliation/sundry-dr-cr">
|
<button mat-menu-item tabindex="0">
|
||||||
<fa-icon icon="random" class="menu-item-icon" style="color: #0d9488"></fa-icon>
|
<fa-icon icon="random" class="menu-item-icon" style="color: #0d9488"></fa-icon>
|
||||||
Sundry Dr / Cr Reconciliation
|
Sundry Dr / Cr Reconciliation
|
||||||
</button>
|
</button>
|
||||||
<button mat-menu-item tabindex="0" routerLink="/reconciliation/pnl-impact">
|
<button mat-menu-item tabindex="0">
|
||||||
<fa-icon icon="chart-line" class="menu-item-icon" style="color: #0d9488"></fa-icon>
|
<fa-icon icon="chart-line" class="menu-item-icon" style="color: #0d9488"></fa-icon>
|
||||||
P&L Impact Reconciliation
|
P&L Impact Reconciliation
|
||||||
</button>
|
</button>
|
||||||
<button mat-menu-item tabindex="0" routerLink="/reconciliation/gl-reconciliation">
|
<button mat-menu-item tabindex="0">
|
||||||
<fa-icon icon="book-open" class="menu-item-icon" style="color: #0d9488"></fa-icon>
|
<fa-icon icon="book-open" class="menu-item-icon" style="color: #0d9488"></fa-icon>
|
||||||
GL Reconciliation
|
GL Reconciliation
|
||||||
</button>
|
</button>
|
||||||
<button mat-menu-item tabindex="0" routerLink="/reconciliation/fixed-assets">
|
<button mat-menu-item tabindex="0">
|
||||||
<fa-icon icon="cube" class="menu-item-icon" style="color: #0d9488"></fa-icon>
|
<fa-icon icon="cube" class="menu-item-icon" style="color: #0d9488"></fa-icon>
|
||||||
Fixed Assets Reconciliation
|
Fixed Assets Reconciliation
|
||||||
</button>
|
</button>
|
||||||
<button mat-menu-item tabindex="0" routerLink="/reconciliation/inter-company">
|
<button mat-menu-item tabindex="0">
|
||||||
<fa-icon icon="sitemap" class="menu-item-icon" style="color: #0d9488"></fa-icon>
|
<fa-icon icon="sitemap" class="menu-item-icon" style="color: #0d9488"></fa-icon>
|
||||||
Inter-Company Reconciliation
|
Inter-Company Reconciliation
|
||||||
</button>
|
</button>
|
||||||
@@ -1432,19 +1328,19 @@
|
|||||||
<!-- Bank & Clearing -->
|
<!-- Bank & Clearing -->
|
||||||
<div class="deposit-menu-group">
|
<div class="deposit-menu-group">
|
||||||
<div class="deposit-group-title">Bank & Clearing</div>
|
<div class="deposit-group-title">Bank & Clearing</div>
|
||||||
<button mat-menu-item tabindex="0" routerLink="/reconciliation/bank-reco">
|
<button mat-menu-item tabindex="0">
|
||||||
<fa-icon icon="university" class="menu-item-icon" style="color: #0d9488"></fa-icon>
|
<fa-icon icon="university" class="menu-item-icon" style="color: #0d9488"></fa-icon>
|
||||||
Bank Reconciliation
|
Bank Reconciliation
|
||||||
</button>
|
</button>
|
||||||
<button mat-menu-item tabindex="0" routerLink="/reconciliation/api-bank-reco">
|
<button mat-menu-item tabindex="0">
|
||||||
<fa-icon icon="code-branch" class="menu-item-icon" style="color: #0d9488"></fa-icon>
|
<fa-icon icon="code-branch" class="menu-item-icon" style="color: #0d9488"></fa-icon>
|
||||||
API Based Bank Reconciliation
|
API Based Bank Reconciliation
|
||||||
</button>
|
</button>
|
||||||
<button mat-menu-item tabindex="0" routerLink="/reconciliation/neft-rtgs">
|
<button mat-menu-item tabindex="0">
|
||||||
<fa-icon icon="exchange" class="menu-item-icon" style="color: #0d9488"></fa-icon>
|
<fa-icon icon="exchange" class="menu-item-icon" style="color: #0d9488"></fa-icon>
|
||||||
NEFT / RTGS Reconciliation
|
NEFT / RTGS Reconciliation
|
||||||
</button>
|
</button>
|
||||||
<button mat-menu-item tabindex="0" routerLink="/reconciliation/clearing-reco">
|
<button mat-menu-item tabindex="0">
|
||||||
<fa-icon icon="hand-holding-usd" class="menu-item-icon" style="color: #0d9488"></fa-icon>
|
<fa-icon icon="hand-holding-usd" class="menu-item-icon" style="color: #0d9488"></fa-icon>
|
||||||
Clearing Reconciliation
|
Clearing Reconciliation
|
||||||
</button>
|
</button>
|
||||||
@@ -1453,15 +1349,15 @@
|
|||||||
<!-- Digital / Payments -->
|
<!-- Digital / Payments -->
|
||||||
<div class="deposit-menu-group">
|
<div class="deposit-menu-group">
|
||||||
<div class="deposit-group-title">Digital & Payments</div>
|
<div class="deposit-group-title">Digital & Payments</div>
|
||||||
<button mat-menu-item tabindex="0" routerLink="/reconciliation/udir">
|
<button mat-menu-item tabindex="0">
|
||||||
<fa-icon icon="mobile-alt" class="menu-item-icon" style="color: #0d9488"></fa-icon>
|
<fa-icon icon="mobile-alt" class="menu-item-icon" style="color: #0d9488"></fa-icon>
|
||||||
UDIR (IMPS / UPI / ATM)
|
UDIR (IMPS / UPI / ATM)
|
||||||
</button>
|
</button>
|
||||||
<button mat-menu-item tabindex="0" routerLink="/reconciliation/three-way-digital">
|
<button mat-menu-item tabindex="0">
|
||||||
<fa-icon icon="project-diagram" class="menu-item-icon" style="color: #0d9488"></fa-icon>
|
<fa-icon icon="project-diagram" class="menu-item-icon" style="color: #0d9488"></fa-icon>
|
||||||
Three-way Digital Reconciliation
|
Three-way Digital Reconciliation
|
||||||
</button>
|
</button>
|
||||||
<button mat-menu-item tabindex="0" routerLink="/reconciliation/nach-reco">
|
<button mat-menu-item tabindex="0">
|
||||||
<fa-icon icon="sync-alt" class="menu-item-icon" style="color: #0d9488"></fa-icon>
|
<fa-icon icon="sync-alt" class="menu-item-icon" style="color: #0d9488"></fa-icon>
|
||||||
NACH Reconciliation
|
NACH Reconciliation
|
||||||
</button>
|
</button>
|
||||||
@@ -1474,7 +1370,7 @@
|
|||||||
<fa-icon icon="money-check-alt" class="menu-item-icon" style="color: #0d9488"></fa-icon>
|
<fa-icon icon="money-check-alt" class="menu-item-icon" style="color: #0d9488"></fa-icon>
|
||||||
DD / Pay Order Reconciliation
|
DD / Pay Order Reconciliation
|
||||||
</button>
|
</button>
|
||||||
<button mat-menu-item tabindex="0" routerLink="/reconciliation/share-member">
|
<button mat-menu-item tabindex="0">
|
||||||
<fa-icon icon="coins" class="menu-item-icon" style="color: #0d9488"></fa-icon>
|
<fa-icon icon="coins" class="menu-item-icon" style="color: #0d9488"></fa-icon>
|
||||||
Share Member Reconciliation
|
Share Member Reconciliation
|
||||||
</button>
|
</button>
|
||||||
@@ -1483,11 +1379,11 @@
|
|||||||
<!-- Compliance & Financials -->
|
<!-- Compliance & Financials -->
|
||||||
<div class="deposit-menu-group">
|
<div class="deposit-menu-group">
|
||||||
<div class="deposit-group-title">Compliance & Financials</div>
|
<div class="deposit-group-title">Compliance & Financials</div>
|
||||||
<button mat-menu-item tabindex="0" routerLink="/reconciliation/invoice-payment">
|
<button mat-menu-item tabindex="0">
|
||||||
<fa-icon icon="file-invoice" class="menu-item-icon" style="color: #0d9488"></fa-icon>
|
<fa-icon icon="file-invoice" class="menu-item-icon" style="color: #0d9488"></fa-icon>
|
||||||
Invoice Payment
|
Invoice Payment
|
||||||
</button>
|
</button>
|
||||||
<button mat-menu-item tabindex="0" routerLink="/reconciliation/tax-reco">
|
<button mat-menu-item tabindex="0">
|
||||||
<fa-icon icon="percentage" class="menu-item-icon" style="color: #0d9488"></fa-icon>
|
<fa-icon icon="percentage" class="menu-item-icon" style="color: #0d9488"></fa-icon>
|
||||||
Tax Reconciliation
|
Tax Reconciliation
|
||||||
</button>
|
</button>
|
||||||
@@ -1599,12 +1495,17 @@
|
|||||||
<button mat-menu-item tabindex="0">
|
<button mat-menu-item tabindex="0">
|
||||||
<fa-icon icon="file-signature" class="menu-item-icon" style="color: #15803d"></fa-icon> Form 98
|
<fa-icon icon="file-signature" class="menu-item-icon" style="color: #15803d"></fa-icon> Form 98
|
||||||
</button>
|
</button>
|
||||||
<button mat-menu-item routerLink="/reports/form-97-98" tabindex="0">
|
|
||||||
<fa-icon icon="file-invoice" class="menu-item-icon" style="color: #15803d"></fa-icon> Form 97/98 Txn Report
|
|
||||||
</button>
|
|
||||||
<button mat-menu-item routerLink="/letter-mgmt/form-15gh" tabindex="0">
|
<button mat-menu-item routerLink="/letter-mgmt/form-15gh" tabindex="0">
|
||||||
<fa-icon icon="file-signature" class="menu-item-icon" style="color: #15803d"></fa-icon> Form 121
|
<fa-icon icon="file-signature" class="menu-item-icon" style="color: #15803d"></fa-icon> Form 121
|
||||||
</button>
|
</button>
|
||||||
|
<button mat-menu-item routerLink="/reports/tds-interest" tabindex="0">
|
||||||
|
<fa-icon icon="percentage" class="menu-item-icon" style="color: #15803d"></fa-icon> Deposit Interest &
|
||||||
|
TDS
|
||||||
|
</button>
|
||||||
|
<button mat-menu-item routerLink="/reports/tds-remittance" tabindex="0">
|
||||||
|
<fa-icon icon="building-columns" class="menu-item-icon" style="color: #15803d"></fa-icon> TDS Remitted to
|
||||||
|
Govt
|
||||||
|
</button>
|
||||||
<button mat-menu-item routerLink="/letter-mgmt/hypothecation-certificate" tabindex="0">
|
<button mat-menu-item routerLink="/letter-mgmt/hypothecation-certificate" tabindex="0">
|
||||||
<fa-icon icon="file-alt" class="menu-item-icon" style="color: #15803d"></fa-icon>
|
<fa-icon icon="file-alt" class="menu-item-icon" style="color: #15803d"></fa-icon>
|
||||||
Hypothecation Certificate
|
Hypothecation Certificate
|
||||||
@@ -1806,41 +1707,28 @@
|
|||||||
>
|
>
|
||||||
<fa-icon icon="shield-alt" class="gl-icon"></fa-icon> AML
|
<fa-icon icon="shield-alt" class="gl-icon"></fa-icon> AML
|
||||||
</a>
|
</a>
|
||||||
<a
|
|
||||||
class="gl-link"
|
|
||||||
[class.hidden]="!matchesRbiSearch('Risk Review Report')"
|
|
||||||
routerLink="/reports/risk-review"
|
|
||||||
(click)="rbiMenuOpen = false"
|
|
||||||
>
|
|
||||||
<fa-icon icon="user-shield" class="gl-icon"></fa-icon> Risk Review Report
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
<!-- OSS Reports -->
|
<!-- OSS Reports -->
|
||||||
<div class="gl-group">
|
<div class="gl-group">
|
||||||
<div class="gl-group-title">OSS Reports</div>
|
<div class="gl-group-title">OSS Reports</div>
|
||||||
<a
|
<a class="gl-link" [class.hidden]="!matchesRbiSearch('OSS 1 XBRL/CIMS')" (click)="rbiMenuOpen = false">
|
||||||
class="gl-link"
|
<fa-icon icon="file-alt" class="gl-icon"></fa-icon> OSS 1 XBRL/CIMS
|
||||||
[class.hidden]="!matchesRbiSearch('OSS 1 XBRL/CIMS')"
|
|
||||||
routerLink="/rbi/oss1"
|
|
||||||
(click)="rbiMenuOpen = false"
|
|
||||||
>
|
|
||||||
<fa-icon icon="file-alt" class="gl-icon" style="color: #0891b2"></fa-icon> OSS 1 XBRL/CIMS
|
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
class="gl-link"
|
class="gl-link"
|
||||||
[class.hidden]="!matchesRbiSearch('OSS 2 XBRL')"
|
[class.hidden]="!matchesRbiSearch('OSS 2 XBRL')"
|
||||||
routerLink="/rbi/oss2"
|
(click)="rbiMenuOpen = false; openCimsXbrlDialog('OSS-2')"
|
||||||
(click)="rbiMenuOpen = false"
|
|
||||||
>
|
>
|
||||||
<fa-icon icon="file-alt" class="gl-icon" style="color: #0891b2"></fa-icon> OSS 2 XBRL
|
<fa-icon icon="file-alt" class="gl-icon" style="color: #0891b2"></fa-icon> OSS 2 XBRL
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a class="gl-link" [class.hidden]="!matchesRbiSearch('OSS 3 XBRL/CIMS')" (click)="rbiMenuOpen = false">
|
||||||
class="gl-link"
|
<fa-icon icon="file-alt" class="gl-icon"></fa-icon> OSS 3 XBRL/CIMS
|
||||||
[class.hidden]="!matchesRbiSearch('OSS 3 XBRL/CIMS')"
|
</a>
|
||||||
routerLink="/rbi/oss3"
|
<a class="gl-link" [class.hidden]="!matchesRbiSearch('OSS 4/OSS 5/OSS 6')" (click)="rbiMenuOpen = false">
|
||||||
(click)="rbiMenuOpen = false"
|
<fa-icon icon="file-alt" class="gl-icon"></fa-icon> OSS 4 / OSS 5 / OSS 6
|
||||||
>
|
</a>
|
||||||
<fa-icon icon="file-alt" class="gl-icon" style="color: #0891b2"></fa-icon> OSS 3 XBRL/CIMS
|
<a class="gl-link" [class.hidden]="!matchesRbiSearch('OSS 7')" (click)="rbiMenuOpen = false">
|
||||||
|
<fa-icon icon="file-alt" class="gl-icon"></fa-icon> OSS 7
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<!-- CIMS / XBRL Returns -->
|
<!-- CIMS / XBRL Returns -->
|
||||||
@@ -1971,161 +1859,6 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- Investment (Treasury) Mega Menu -->
|
|
||||||
@if (investmentMenuOpen) {
|
|
||||||
<div class="gl-backdrop" (click)="investmentMenuOpen = false"></div>
|
|
||||||
<div class="gl-mega-menu rbi-mega-menu">
|
|
||||||
<div class="gl-mega-menu-header">
|
|
||||||
<fa-icon icon="chart-line" class="gl-mega-menu-header-icon"></fa-icon>
|
|
||||||
<span>Investment</span>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
class="gl-search"
|
|
||||||
placeholder="Search Investment..."
|
|
||||||
(input)="investmentSearch = $any($event.target).value"
|
|
||||||
/>
|
|
||||||
<div class="rbi-columns">
|
|
||||||
<!-- Dashboard -->
|
|
||||||
<div class="gl-group">
|
|
||||||
<div class="gl-group-title">Dashboard</div>
|
|
||||||
<a
|
|
||||||
class="gl-link"
|
|
||||||
[class.hidden]="!matchesInvestmentSearch('Investment Dashboard')"
|
|
||||||
routerLink="/investment/dashboard"
|
|
||||||
(click)="investmentMenuOpen = false"
|
|
||||||
>
|
|
||||||
<fa-icon icon="chart-pie" class="gl-icon"></fa-icon> Portfolio Dashboard
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
class="gl-link"
|
|
||||||
[class.hidden]="!matchesInvestmentSearch('Due Date Diary for All Investment')"
|
|
||||||
routerLink="/investment/dashboard"
|
|
||||||
(click)="investmentMenuOpen = false"
|
|
||||||
>
|
|
||||||
<fa-icon icon="calendar-check" class="gl-icon"></fa-icon> Due Date Diary for All Investment
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
class="gl-link"
|
|
||||||
[class.hidden]="!matchesInvestmentSearch('Bucket Wise Investment')"
|
|
||||||
routerLink="/investment/reports"
|
|
||||||
(click)="investmentMenuOpen = false"
|
|
||||||
>
|
|
||||||
<fa-icon icon="layer-group" class="gl-icon"></fa-icon> Bucket Wise Investment
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<!-- Entry -->
|
|
||||||
<div class="gl-group">
|
|
||||||
<div class="gl-group-title">Entry</div>
|
|
||||||
<a
|
|
||||||
class="gl-link"
|
|
||||||
[class.hidden]="!matchesInvestmentSearch('FD Investment')"
|
|
||||||
routerLink="/investment/fd"
|
|
||||||
(click)="investmentMenuOpen = false"
|
|
||||||
>
|
|
||||||
<fa-icon icon="piggy-bank" class="gl-icon"></fa-icon> FD Investment
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
class="gl-link"
|
|
||||||
[class.hidden]="!matchesInvestmentSearch('Govt Security / Non-SLR / Bond / Mutual Fund')"
|
|
||||||
routerLink="/investment/bonds"
|
|
||||||
(click)="investmentMenuOpen = false"
|
|
||||||
>
|
|
||||||
<fa-icon icon="university" class="gl-icon"></fa-icon> Govt Security / Non-SLR / Bond / Mutual Fund
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
class="gl-link"
|
|
||||||
[class.hidden]="!matchesInvestmentSearch('SLR and Non SLR Investment Report')"
|
|
||||||
routerLink="/investment/bonds"
|
|
||||||
(click)="investmentMenuOpen = false"
|
|
||||||
>
|
|
||||||
<fa-icon icon="balance-scale" class="gl-icon"></fa-icon> SLR and Non SLR Investment Report
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
class="gl-link"
|
|
||||||
[class.hidden]="!matchesInvestmentSearch('Share Entry')"
|
|
||||||
routerLink="/investment/share"
|
|
||||||
(click)="investmentMenuOpen = false"
|
|
||||||
>
|
|
||||||
<fa-icon icon="chart-line" class="gl-icon"></fa-icon> Share Entry
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<!-- Masters -->
|
|
||||||
<div class="gl-group">
|
|
||||||
<div class="gl-group-title">Masters</div>
|
|
||||||
<a
|
|
||||||
class="gl-link"
|
|
||||||
[class.hidden]="
|
|
||||||
!matchesInvestmentSearch(
|
|
||||||
'Bank Party Script Broker SubType Control Parameter Daily Script LIEN Reason Mutual Fund Product SGL Bank Script Market Detail Masters'
|
|
||||||
)
|
|
||||||
"
|
|
||||||
routerLink="/investment/masters"
|
|
||||||
(click)="investmentMenuOpen = false"
|
|
||||||
>
|
|
||||||
<fa-icon icon="cogs" class="gl-icon"></fa-icon> Bank/Party, Script, Broker & Other Masters
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<!-- Interest -->
|
|
||||||
<div class="gl-group">
|
|
||||||
<div class="gl-group-title">Interest</div>
|
|
||||||
<a
|
|
||||||
class="gl-link"
|
|
||||||
[class.hidden]="!matchesInvestmentSearch('FD Interest Calculation')"
|
|
||||||
routerLink="/investment/interest"
|
|
||||||
(click)="investmentMenuOpen = false"
|
|
||||||
>
|
|
||||||
<fa-icon icon="calculator" class="gl-icon"></fa-icon> FD Interest Calculation
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
class="gl-link"
|
|
||||||
[class.hidden]="!matchesInvestmentSearch('Govt Bond Interest Calculation')"
|
|
||||||
routerLink="/investment/interest"
|
|
||||||
(click)="investmentMenuOpen = false"
|
|
||||||
>
|
|
||||||
<fa-icon icon="calculator" class="gl-icon"></fa-icon> Govt./Bond Int. Calculation
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
class="gl-link"
|
|
||||||
[class.hidden]="!matchesInvestmentSearch('Government Bond Int Register FD Interest Register FD Register')"
|
|
||||||
routerLink="/investment/interest"
|
|
||||||
(click)="investmentMenuOpen = false"
|
|
||||||
>
|
|
||||||
<fa-icon icon="list-alt" class="gl-icon"></fa-icon> Bond Int / FD Int / FD Registers
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<!-- Reports -->
|
|
||||||
<div class="gl-group">
|
|
||||||
<div class="gl-group-title">Reports</div>
|
|
||||||
<a
|
|
||||||
class="gl-link"
|
|
||||||
[class.hidden]="!matchesInvestmentSearch('Balance Ledger Maturity Investment Reports')"
|
|
||||||
routerLink="/investment/reports"
|
|
||||||
(click)="investmentMenuOpen = false"
|
|
||||||
>
|
|
||||||
<fa-icon icon="file-invoice" class="gl-icon"></fa-icon> Balance / Ledger / Maturity Reports
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
class="gl-link"
|
|
||||||
[class.hidden]="!matchesInvestmentSearch('Total Int Received & Receivable Report')"
|
|
||||||
routerLink="/investment/reports"
|
|
||||||
(click)="investmentMenuOpen = false"
|
|
||||||
>
|
|
||||||
<fa-icon icon="hand-holding-usd" class="gl-icon"></fa-icon> Total Int Received & Receivable Report
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
class="gl-link"
|
|
||||||
[class.hidden]="!matchesInvestmentSearch('Investment Portfolio')"
|
|
||||||
routerLink="/investment/reports"
|
|
||||||
(click)="investmentMenuOpen = false"
|
|
||||||
>
|
|
||||||
<fa-icon icon="briefcase" class="gl-icon"></fa-icon> Investment Portfolio
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- Progress Bar -->
|
<!-- Progress Bar -->
|
||||||
@if (progressBarMode !== 'none') {
|
@if (progressBarMode !== 'none') {
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -188,9 +188,9 @@ export class ShellComponent implements OnInit, OnDestroy {
|
|||||||
return this.router.url;
|
return this.router.url;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** True on the manager's full-screen withdrawal-request review screen. */
|
/** True on the manager's full-screen withdrawal-request (cash or share) review screen. */
|
||||||
get isWithdrawalReviewUrl(): boolean {
|
get isWithdrawalReviewUrl(): boolean {
|
||||||
return /\/checker-inbox-and-tasks\/withdrawal-requests\/\d+\/review/.test(this.router.url);
|
return /\/checker-inbox-and-tasks\/(share-)?withdrawal-requests\/\d+\/review/.test(this.router.url);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** ESC key — exit fullscreen at Clerk Dashboard / Election / review screen, restoring full shell header. */
|
/** ESC key — exit fullscreen at Clerk Dashboard / Election / review screen, restoring full shell header. */
|
||||||
|
|||||||
@@ -29,6 +29,11 @@
|
|||||||
Account Status
|
Account Status
|
||||||
<span class="km-cnt km-cnt--red">34</span>
|
<span class="km-cnt km-cnt--red">34</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="km-itab" [class.active]="activeSection === 'exceptions'" (click)="showSection('exceptions')">
|
||||||
|
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||||
|
KYC Exceptions
|
||||||
|
<span class="km-cnt km-cnt--amb">{{ exceptionRows.length }}</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="km-main">
|
<div class="km-main">
|
||||||
@@ -722,6 +727,284 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<!-- KYC EXCEPTIONS -->
|
||||||
|
@if (activeSection === 'exceptions') {
|
||||||
|
<div class="km-page">
|
||||||
|
<div class="km-page-head">
|
||||||
|
<span class="km-page-title">KYC Exceptions Dashboard</span>
|
||||||
|
<div class="km-actions">
|
||||||
|
<button class="km-btn km-btn-g" (click)="toast('Exporting exception report...')">
|
||||||
|
<fa-icon icon="download"></fa-icon> Export Exceptions
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="km-stat-row">
|
||||||
|
<div class="km-stat">
|
||||||
|
<div class="km-stat-n" style="color: var(--km-amber)">{{ exceptionRows.length }}</div>
|
||||||
|
<div class="km-stat-l">Pending Exceptions</div>
|
||||||
|
<div class="km-stat-t" style="color: var(--km-amber)">Immediate attention</div>
|
||||||
|
</div>
|
||||||
|
<div class="km-stat">
|
||||||
|
<div class="km-stat-n" style="color: var(--km-red)">
|
||||||
|
{{ notYetContactedCount }}
|
||||||
|
</div>
|
||||||
|
<div class="km-stat-l">Not Yet Contacted</div>
|
||||||
|
<div class="km-stat-t" style="color: var(--km-red)">Action required</div>
|
||||||
|
</div>
|
||||||
|
<div class="km-stat">
|
||||||
|
<div class="km-stat-n" style="color: var(--km-orange)">
|
||||||
|
{{ panMismatchesCount }}
|
||||||
|
</div>
|
||||||
|
<div class="km-stat-l">PAN Mismatches</div>
|
||||||
|
<div class="km-stat-t" style="color: var(--km-orange)">Requires physical review</div>
|
||||||
|
</div>
|
||||||
|
<div class="km-stat">
|
||||||
|
<div class="km-stat-n" style="color: var(--km-blue)">
|
||||||
|
{{ aadhaarFailuresCount }}
|
||||||
|
</div>
|
||||||
|
<div class="km-stat-l">Aadhaar Failures</div>
|
||||||
|
<div class="km-stat-t" style="color: var(--km-blue)">Re-KYC OTP needed</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="km-card km-mt">
|
||||||
|
<!-- Tab Toggle Header -->
|
||||||
|
<div
|
||||||
|
class="km-ch"
|
||||||
|
style="
|
||||||
|
border-bottom: 1px solid var(--km-g200);
|
||||||
|
padding-bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div style="display: flex; gap: 8px; align-items: center">
|
||||||
|
<div class="km-ci" style="background: #fef3c7; color: #d97706">
|
||||||
|
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||||
|
</div>
|
||||||
|
<div class="km-ct">Exceptions Register</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="km-inner-nav" style="width: 100%; border: none; margin: 10px 0 0 0; padding: 0; gap: 8px">
|
||||||
|
<button
|
||||||
|
class="km-itab"
|
||||||
|
[class.active]="activeExceptionSubTab === 'pending'"
|
||||||
|
(click)="activeExceptionSubTab = 'pending'"
|
||||||
|
style="padding: 8px 16px; border-radius: 4px; font-weight: 600; font-size: 13px"
|
||||||
|
>
|
||||||
|
<fa-icon icon="clock"></fa-icon>
|
||||||
|
Pending Exceptions ({{ pendingExceptions.length }})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="km-itab"
|
||||||
|
[class.active]="activeExceptionSubTab === 'resolved'"
|
||||||
|
(click)="activeExceptionSubTab = 'resolved'"
|
||||||
|
style="padding: 8px 16px; border-radius: 4px; font-weight: 600; font-size: 13px"
|
||||||
|
>
|
||||||
|
<fa-icon icon="check-circle"></fa-icon>
|
||||||
|
Resolved Exceptions ({{ resolvedExceptions.length }})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- PENDING EXCEPTIONS TAB -->
|
||||||
|
@if (activeExceptionSubTab === 'pending') {
|
||||||
|
@if (pendingExceptions.length === 0) {
|
||||||
|
<div class="km-empty" style="padding: 40px; text-align: center">
|
||||||
|
<div class="km-empty-i">🎉</div>
|
||||||
|
<div style="font-weight: 500; font-size: 16px; margin-top: 12px">All exceptions resolved!</div>
|
||||||
|
<div class="km-empty-s" style="color: var(--km-g500); font-size: 13px; margin-top: 4px">
|
||||||
|
There are no active client KYC exceptions on record.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<table class="km-tbl">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Client / Account</th>
|
||||||
|
<th>Branch</th>
|
||||||
|
<th>Exception Reason & Date</th>
|
||||||
|
<th>Risk</th>
|
||||||
|
<th>Contact Status</th>
|
||||||
|
<th>Contact Logs</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (c of pendingExceptions; track c.id) {
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div
|
||||||
|
class="km-an"
|
||||||
|
[routerLink]="['/clients', c.id, 'kyc']"
|
||||||
|
style="cursor: pointer; color: var(--km-blue); text-decoration: underline"
|
||||||
|
>
|
||||||
|
{{ c.name }}
|
||||||
|
</div>
|
||||||
|
<div class="km-ai">{{ c.account }}</div>
|
||||||
|
</td>
|
||||||
|
<td>{{ c.contact.branch }}</td>
|
||||||
|
<td>
|
||||||
|
<div style="font-weight: 500; color: #b45309">{{ c.exceptionReason }}</div>
|
||||||
|
<div class="km-small">Opened: {{ c.exceptionDate }}</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="km-b" [ngClass]="riskClass(c.risk)">{{ c.riskLabel }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@if (c.contactHistory && c.contactHistory.length > 0) {
|
||||||
|
<span class="km-del d-ok">Contacted</span>
|
||||||
|
<div class="km-small" style="margin-top: 2px">
|
||||||
|
Last: {{ c.contactHistory[c.contactHistory.length - 1].date }}
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<span class="km-del d-fa">Not Contacted</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@if (c.contactHistory && c.contactHistory.length > 0) {
|
||||||
|
<div class="contact-logs-list" style="max-height: 80px; overflow-y: auto; font-size: 11px">
|
||||||
|
@for (log of c.contactHistory; track $index) {
|
||||||
|
<div style="border-bottom: 1px dashed var(--km-g300); padding: 2px 0">
|
||||||
|
<strong>{{ log.date }} ({{ log.action }}):</strong> {{ log.notes }}
|
||||||
|
<em>— {{ log.officer }}</em>
|
||||||
|
@if (log.platformSent) {
|
||||||
|
@if (log.delivered) {
|
||||||
|
<span class="km-sent-tag km-sent-ok" [title]="log.deliveryMessage"
|
||||||
|
>Sent {{ log.destinationMasked }}</span
|
||||||
|
>
|
||||||
|
} @else {
|
||||||
|
<span class="km-sent-tag km-sent-fail" [title]="log.deliveryMessage">Not sent</span>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<span style="color: var(--km-g500); font-size: 11px">No logs recorded</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div style="display: flex; gap: 8px">
|
||||||
|
<button
|
||||||
|
class="km-btn km-btn-ot"
|
||||||
|
style="border-color: var(--km-blue); color: var(--km-blue)"
|
||||||
|
[routerLink]="['/clients', c.id, 'kyc']"
|
||||||
|
>
|
||||||
|
<fa-icon icon="user-check"></fa-icon> Do KYC
|
||||||
|
</button>
|
||||||
|
<button class="km-btn km-btn-ot" (click)="openContactDialog(c)">
|
||||||
|
<fa-icon icon="phone"></fa-icon> Log Call
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- RESOLVED EXCEPTIONS TAB -->
|
||||||
|
@if (activeExceptionSubTab === 'resolved') {
|
||||||
|
@if (resolvedExceptions.length === 0) {
|
||||||
|
<div class="km-empty" style="padding: 40px; text-align: center">
|
||||||
|
<div class="km-empty-i" style="font-size: 32px; filter: grayscale(1)">📋</div>
|
||||||
|
<div style="font-weight: 500; font-size: 16px; margin-top: 12px; color: var(--km-g500)">
|
||||||
|
No resolved exceptions
|
||||||
|
</div>
|
||||||
|
<div class="km-empty-s" style="color: var(--km-g500); font-size: 13px; margin-top: 4px">
|
||||||
|
Resolved KYC exceptions will appear here once verification is completed.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<table class="km-tbl">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Client / Account</th>
|
||||||
|
<th>Branch</th>
|
||||||
|
<th>Exception Reason & Resolution</th>
|
||||||
|
<th>Risk</th>
|
||||||
|
<th>Contact Logs</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (c of resolvedExceptions; track c.id) {
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div
|
||||||
|
class="km-an"
|
||||||
|
[routerLink]="['/clients', c.id, 'kyc']"
|
||||||
|
style="cursor: pointer; color: var(--km-blue); text-decoration: underline"
|
||||||
|
>
|
||||||
|
{{ c.name }}
|
||||||
|
</div>
|
||||||
|
<div class="km-ai">{{ c.account }}</div>
|
||||||
|
</td>
|
||||||
|
<td>{{ c.contact.branch }}</td>
|
||||||
|
<td>
|
||||||
|
<div style="font-weight: 500; color: var(--km-g500); text-decoration: line-through">
|
||||||
|
{{ c.exceptionReason }}
|
||||||
|
</div>
|
||||||
|
<div class="km-small">Resolved On: {{ c.exceptionResolvedOn }}</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="km-b" [ngClass]="riskClass(c.risk)">{{ c.riskLabel }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@if (c.contactHistory && c.contactHistory.length > 0) {
|
||||||
|
<div class="contact-logs-list" style="max-height: 80px; overflow-y: auto; font-size: 11px">
|
||||||
|
@for (log of c.contactHistory; track $index) {
|
||||||
|
<div style="border-bottom: 1px dashed var(--km-g300); padding: 2px 0">
|
||||||
|
<strong>{{ log.date }} ({{ log.action }}):</strong> {{ log.notes }}
|
||||||
|
<em>— {{ log.officer }}</em>
|
||||||
|
@if (log.platformSent) {
|
||||||
|
@if (log.delivered) {
|
||||||
|
<span class="km-sent-tag km-sent-ok" [title]="log.deliveryMessage"
|
||||||
|
>Sent {{ log.destinationMasked }}</span
|
||||||
|
>
|
||||||
|
} @else {
|
||||||
|
<span class="km-sent-tag km-sent-fail" [title]="log.deliveryMessage">Not sent</span>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<span style="color: var(--km-g500); font-size: 11px">No logs recorded</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
style="
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
background: #e6f4ea;
|
||||||
|
color: #137333;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 12px;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<fa-icon icon="check-circle"></fa-icon> Exception Resolved
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- CLIENT DETAIL MODAL -->
|
<!-- CLIENT DETAIL MODAL -->
|
||||||
@@ -1165,6 +1448,251 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<!-- LOG CONTACT DIALOG -->
|
||||||
|
@if (contactDialogOpen && current) {
|
||||||
|
<div class="km-overlay" (click)="closeContactDialog()">
|
||||||
|
<div class="km-modal hv-modal" (click)="$event.stopPropagation()">
|
||||||
|
<div class="km-mh">
|
||||||
|
<div class="km-mh-av">{{ current.av }}</div>
|
||||||
|
<div>
|
||||||
|
<div class="km-mh-name">Log Customer Contact</div>
|
||||||
|
<div class="km-mh-meta">{{ current.name }} · {{ current.account }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="km-mclose" (click)="closeContactDialog()">✕</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="km-mbody hv-body">
|
||||||
|
<div class="hv-addr">
|
||||||
|
<div class="hv-addr-label">Customer Contact Info</div>
|
||||||
|
<div class="hv-addr-name">{{ current.name }}</div>
|
||||||
|
<div class="hv-addr-text">
|
||||||
|
<fa-icon icon="mobile-alt" class="vkyc-ic"></fa-icon> {{ current.contact.mobile }}
|
||||||
|
</div>
|
||||||
|
<div class="hv-addr-text">
|
||||||
|
<fa-icon icon="envelope" class="vkyc-ic"></fa-icon> {{ current.contact.email }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hv-row">
|
||||||
|
<label class="hv-label">Contact Channel<span class="hv-req">*</span></label>
|
||||||
|
<select class="hv-input" [(ngModel)]="contactChannel">
|
||||||
|
@for (ch of contactChannels; track ch.value) {
|
||||||
|
<option [value]="ch.value">
|
||||||
|
{{ ch.label }}{{ ch.sends ? ' — sends a notice now' : ' — record only' }}
|
||||||
|
</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
@if (selectedChannelSends) {
|
||||||
|
<div class="hv-hint hv-hint-send">
|
||||||
|
<fa-icon icon="paper-plane" class="vkyc-ic"></fa-icon>
|
||||||
|
A KYC notice will be sent to the customer when you save.
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="hv-hint">Records an action you already took. Nothing is sent to the customer.</div>
|
||||||
|
}
|
||||||
|
@if (selectedChannelUnavailable) {
|
||||||
|
<div class="hv-hint hv-hint-error">
|
||||||
|
This customer has no {{ selectedChannel?.requires === 'email' ? 'email address' : 'mobile number' }} on
|
||||||
|
file — choose a different channel.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hv-row">
|
||||||
|
<label class="hv-label">KYC Officer Logging<span class="hv-req">*</span></label>
|
||||||
|
<select class="hv-input" [(ngModel)]="contactOfficer">
|
||||||
|
@for (officer of bankOfficers; track officer) {
|
||||||
|
<option [value]="officer">{{ officer }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hv-row">
|
||||||
|
<label class="hv-label">Summary / Notes<span class="hv-req">*</span></label>
|
||||||
|
<textarea
|
||||||
|
class="hv-input hv-textarea"
|
||||||
|
rows="3"
|
||||||
|
placeholder="Enter details of conversation e.g. Customer promised to visit branch next Tuesday..."
|
||||||
|
[(ngModel)]="contactNotes"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (contactError) {
|
||||||
|
<div class="hv-hint hv-hint-error" role="alert">
|
||||||
|
<fa-icon icon="exclamation-circle" class="vkyc-ic"></fa-icon> {{ contactError }}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hv-footer">
|
||||||
|
<button class="km-btn" [disabled]="contactSaving" (click)="closeContactDialog()">Cancel</button>
|
||||||
|
<button class="km-btn km-btn-t" [disabled]="contactSaveDisabled" (click)="saveContactLog()">
|
||||||
|
{{ contactSaveLabel }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- RESOLVE KYC DIALOG -->
|
||||||
|
@if (resolveDialogOpen && current) {
|
||||||
|
<div class="km-overlay" (click)="closeResolveDialog()">
|
||||||
|
<div class="km-modal hv-modal" (click)="$event.stopPropagation()">
|
||||||
|
<div class="km-mh" style="border-bottom: 2px solid var(--km-green)">
|
||||||
|
<div class="km-mh-av" style="background: var(--km-green)">{{ current.av }}</div>
|
||||||
|
<div>
|
||||||
|
<div class="km-mh-name">Resolve KYC Exception</div>
|
||||||
|
<div class="km-mh-meta">{{ current.name }} · {{ current.account }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="km-mclose" (click)="closeResolveDialog()">✕</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="km-mbody hv-body">
|
||||||
|
<div
|
||||||
|
style="
|
||||||
|
background: #f0fdf4;
|
||||||
|
border: 1px solid #bbf7d0;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #166534;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<strong>Pending Reason:</strong> {{ current.exceptionReason }}
|
||||||
|
@if (current.exceptionRemarks) {
|
||||||
|
<div style="margin-top: 4px; font-style: italic">"{{ current.exceptionRemarks }}"</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (showPanResolution) {
|
||||||
|
<div
|
||||||
|
style="
|
||||||
|
border: 1px solid var(--km-g300);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
background: #fafafa;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px">
|
||||||
|
<input type="checkbox" id="resolvePanChk" [(ngModel)]="resolvePanActive" />
|
||||||
|
<label for="resolvePanChk" style="font-weight: 600; cursor: pointer; color: var(--km-g800)"
|
||||||
|
>Resolve PAN Card Exception</label
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
@if (resolvePanActive) {
|
||||||
|
<div class="hv-row" style="margin-left: 20px">
|
||||||
|
<label class="hv-label">Verified PAN Number<span class="hv-req">*</span></label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="hv-input"
|
||||||
|
[(ngModel)]="resolvePanNumber"
|
||||||
|
placeholder="e.g. ABCDE1234F"
|
||||||
|
maxlength="10"
|
||||||
|
style="text-transform: uppercase"
|
||||||
|
/>
|
||||||
|
@if (resolvePanNumber && resolvePanNumber.length !== 10) {
|
||||||
|
<div style="color: red; font-size: 11px; margin-top: 4px">PAN must be exactly 10 characters.</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (showAadhaarResolution) {
|
||||||
|
<div
|
||||||
|
style="
|
||||||
|
border: 1px solid var(--km-g300);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
background: #fafafa;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px">
|
||||||
|
<input type="checkbox" id="resolveAadhChk" [(ngModel)]="resolveAadhaarActive" />
|
||||||
|
<label for="resolveAadhChk" style="font-weight: 600; cursor: pointer; color: var(--km-g800)"
|
||||||
|
>Resolve Aadhaar Card Exception</label
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
@if (resolveAadhaarActive) {
|
||||||
|
<div class="hv-row" style="margin-left: 20px">
|
||||||
|
<label class="hv-label">Verified Aadhaar Number<span class="hv-req">*</span></label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="hv-input"
|
||||||
|
[(ngModel)]="resolveAadhaarNumber"
|
||||||
|
placeholder="e.g. 597855025299"
|
||||||
|
maxlength="12"
|
||||||
|
/>
|
||||||
|
@if (resolveAadhaarNumber && resolveAadhaarNumber.length !== 12) {
|
||||||
|
<div style="color: red; font-size: 11px; margin-top: 4px">Aadhaar must be exactly 12 digits.</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (showGeneralResolution) {
|
||||||
|
<div
|
||||||
|
style="
|
||||||
|
border: 1px solid var(--km-g300);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
background: #fafafa;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px">
|
||||||
|
<input type="checkbox" id="resolveGenChk" [(ngModel)]="resolveGeneralActive" />
|
||||||
|
<label for="resolveGenChk" style="font-weight: 600; cursor: pointer; color: var(--km-g800)"
|
||||||
|
>Resolve General Exception</label
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
@if (resolveGeneralActive) {
|
||||||
|
<div style="margin-left: 20px">
|
||||||
|
<div class="hv-row">
|
||||||
|
<label class="hv-label">Verification Document Type<span class="hv-req">*</span></label>
|
||||||
|
<select class="hv-input" [(ngModel)]="resolveGeneralDocumentType">
|
||||||
|
<option value="OVD">Officially Valid Document (Passport/Voter ID)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="hv-row">
|
||||||
|
<label class="hv-label">Document Identification Number<span class="hv-req">*</span></label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="hv-input"
|
||||||
|
[(ngModel)]="resolveGeneralDocumentNumber"
|
||||||
|
placeholder="Enter verified document number"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="hv-row">
|
||||||
|
<label class="hv-label">Resolution Comments<span class="hv-req">*</span></label>
|
||||||
|
<textarea
|
||||||
|
class="hv-input hv-textarea"
|
||||||
|
rows="3"
|
||||||
|
placeholder="Describe how verification was resolved..."
|
||||||
|
[(ngModel)]="resolveRemarks"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hv-footer">
|
||||||
|
<button class="km-btn" (click)="closeResolveDialog()">Cancel</button>
|
||||||
|
<button class="km-btn km-btn-g" [disabled]="!isResolveValid" (click)="saveResolveKyc()">
|
||||||
|
Complete KYC & Resolve Exception
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<!-- TOAST -->
|
<!-- TOAST -->
|
||||||
<div class="km-toast" [class.show]="toastVisible">{{ toastMsg }}</div>
|
<div class="km-toast" [class.show]="toastVisible">{{ toastMsg }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1588,6 +1588,58 @@
|
|||||||
min-height: 60px;
|
min-height: 60px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Inline note under a field. The default (neutral) form states that nothing is transmitted;
|
||||||
|
the -send and -error variants call out the two cases the officer must not misread. */
|
||||||
|
.hv-hint {
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: var(--km-g500);
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hv-hint-send {
|
||||||
|
color: #0f766e;
|
||||||
|
background: #f0fdfa;
|
||||||
|
border: 1px solid #99f6e4;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hv-hint-error {
|
||||||
|
color: #b91c1c;
|
||||||
|
background: #fef2f2;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Delivery outcome chip on a contact-log row. A logged attempt and a delivered notice are
|
||||||
|
different facts, so they never share a colour. */
|
||||||
|
.km-sent-tag {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 6px;
|
||||||
|
padding: 0 5px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
vertical-align: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.km-sent-ok {
|
||||||
|
background: #e6f4ea;
|
||||||
|
color: #137333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.km-sent-fail {
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
.hv-footer {
|
.hv-footer {
|
||||||
padding: 14px 22px;
|
padding: 14px 22px;
|
||||||
border-top: 1px solid var(--km-g200);
|
border-top: 1px solid var(--km-g200);
|
||||||
@@ -1648,3 +1700,28 @@
|
|||||||
color: #0f172a;
|
color: #0f172a;
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* KYC Exceptions Styles */
|
||||||
|
.km-ci-e {
|
||||||
|
background: var(--km-amber-l) !important;
|
||||||
|
color: var(--km-amber) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-logs-list {
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-family: inherit;
|
||||||
|
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
|
&::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
background: #cbd5e1;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,10 +6,11 @@
|
|||||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Component, OnInit } from '@angular/core';
|
import { Component, OnInit, inject } from '@angular/core';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
|
import { ClientsService } from '../clients/clients.service';
|
||||||
|
|
||||||
interface KmNotice {
|
interface KmNotice {
|
||||||
no: string;
|
no: string;
|
||||||
@@ -31,6 +32,24 @@ interface KmEvidence {
|
|||||||
docket?: string;
|
docket?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One outreach attempt, as returned by GET /kyc/exceptions/{clientId}/contact-log. */
|
||||||
|
interface KmContactLog {
|
||||||
|
date: string;
|
||||||
|
/** Display label for the channel used, e.g. 'SMS' / 'In-Branch Visit'. */
|
||||||
|
action: string;
|
||||||
|
notes: string;
|
||||||
|
officer: string;
|
||||||
|
/** True when this channel transmits (SMS/Email/WhatsApp) rather than being recorded offline. */
|
||||||
|
platformSent?: boolean;
|
||||||
|
/** True only when the notice actually reached the gateway. */
|
||||||
|
delivered?: boolean;
|
||||||
|
/** SENT | FAILED | SKIPPED_NO_DESTINATION | PROVIDER_NOT_CONFIGURED | NOT_APPLICABLE */
|
||||||
|
deliveryStatus?: string;
|
||||||
|
/** Officer-facing outcome sentence — shown verbatim so a failure is never mistaken for a send. */
|
||||||
|
deliveryMessage?: string;
|
||||||
|
destinationMasked?: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface KmClient {
|
interface KmClient {
|
||||||
id: string;
|
id: string;
|
||||||
av: string;
|
av: string;
|
||||||
@@ -66,6 +85,17 @@ interface KmClient {
|
|||||||
pendingActions: string[];
|
pendingActions: string[];
|
||||||
actionTaken: string[];
|
actionTaken: string[];
|
||||||
evidence: KmEvidence | null;
|
evidence: KmEvidence | null;
|
||||||
|
isException?: boolean;
|
||||||
|
exceptionReason?: string;
|
||||||
|
exceptionRemarks?: string;
|
||||||
|
exceptionDate?: string;
|
||||||
|
exceptionResolved?: boolean;
|
||||||
|
exceptionResolvedOn?: string;
|
||||||
|
contactHistory?: KmContactLog[];
|
||||||
|
kycId?: string;
|
||||||
|
/** Whether the customer has a mobile / email on file — drives channel availability in the dialog. */
|
||||||
|
hasMobile?: boolean;
|
||||||
|
hasEmail?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -79,7 +109,8 @@ interface KmClient {
|
|||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class KycManagementComponent implements OnInit {
|
export class KycManagementComponent implements OnInit {
|
||||||
activeSection: 'alerts' | 'notif' | 'audit' | 'status' = 'alerts';
|
private clientsService = inject(ClientsService);
|
||||||
|
activeSection: 'alerts' | 'notif' | 'audit' | 'status' | 'exceptions' = 'alerts';
|
||||||
|
|
||||||
modalOpen = false;
|
modalOpen = false;
|
||||||
modalTab: 'overview' | 'timeline' | 'notices' | 'evidence' | 'action' = 'overview';
|
modalTab: 'overview' | 'timeline' | 'notices' | 'evidence' | 'action' = 'overview';
|
||||||
@@ -529,15 +560,399 @@ export class KycManagementComponent implements OnInit {
|
|||||||
};
|
};
|
||||||
|
|
||||||
alertRows: KmClient[] = [];
|
alertRows: KmClient[] = [];
|
||||||
|
exceptionRows: KmClient[] = [];
|
||||||
|
pendingExceptions: KmClient[] = [];
|
||||||
|
resolvedExceptions: KmClient[] = [];
|
||||||
|
activeExceptionSubTab: 'pending' | 'resolved' = 'pending';
|
||||||
|
selectedExceptionReason = '';
|
||||||
|
selectedExceptionContactStatus = '';
|
||||||
|
|
||||||
|
// Dialog properties for exceptions
|
||||||
|
contactDialogOpen = false;
|
||||||
|
contactChannel = 'PHONE_CALL';
|
||||||
|
contactNotes = '';
|
||||||
|
contactOfficer = 'Neha Verma — KYC Officer (Bhopal Main)';
|
||||||
|
/** In-flight guard — a double-click must not send the customer two notices. */
|
||||||
|
contactSaving = false;
|
||||||
|
/** Populated when the save itself failed (not when the save worked but delivery didn't). */
|
||||||
|
contactError = '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Channels offered in the Log Customer Contact dialog. `sends` marks the ones where saving
|
||||||
|
* transmits a real notice to the customer, as opposed to recording an action the officer already
|
||||||
|
* took offline — the dialog labels and the confirm button both key off it.
|
||||||
|
*/
|
||||||
|
readonly contactChannels: { value: string; label: string; sends: boolean; requires?: 'mobile' | 'email' }[] = [
|
||||||
|
{ value: 'PHONE_CALL', label: 'Phone Call', sends: false },
|
||||||
|
{ value: 'SMS', label: 'SMS', sends: true, requires: 'mobile' },
|
||||||
|
{ value: 'EMAIL', label: 'Email', sends: true, requires: 'email' },
|
||||||
|
{ value: 'WHATSAPP', label: 'WhatsApp Message', sends: true, requires: 'mobile' },
|
||||||
|
{ value: 'BRANCH_VISIT', label: 'In-Branch Visit', sends: false },
|
||||||
|
{ value: 'FIELD_VISIT', label: 'Agent Field Visit', sends: false }
|
||||||
|
];
|
||||||
|
|
||||||
|
resolveDialogOpen = false;
|
||||||
|
resolveDocumentType = 'PAN';
|
||||||
|
resolveDocumentNumber = '';
|
||||||
|
resolveRemarks = '';
|
||||||
|
|
||||||
|
resolvePanActive = false;
|
||||||
|
resolvePanNumber = '';
|
||||||
|
resolveAadhaarActive = false;
|
||||||
|
resolveAadhaarNumber = '';
|
||||||
|
resolveGeneralActive = false;
|
||||||
|
resolveGeneralDocumentType = 'OVD';
|
||||||
|
resolveGeneralDocumentNumber = '';
|
||||||
|
|
||||||
|
showPanResolution = false;
|
||||||
|
showAadhaarResolution = false;
|
||||||
|
showGeneralResolution = false;
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.alertRows = Object.values(this.clients);
|
this.alertRows = Object.values(this.clients);
|
||||||
|
this.loadExceptions();
|
||||||
}
|
}
|
||||||
|
|
||||||
showSection(section: 'alerts' | 'notif' | 'audit' | 'status'): void {
|
loadExceptions(): void {
|
||||||
|
this.clientsService.getKycExceptions(false).subscribe({
|
||||||
|
next: (data) => {
|
||||||
|
this.pendingExceptions = data.map((item: any) => this.mapException(item, false));
|
||||||
|
this.exceptionRows = this.pendingExceptions;
|
||||||
|
this.loadContactHistories(this.pendingExceptions);
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
console.error('Failed to load active exceptions', err);
|
||||||
|
this.pendingExceptions = [];
|
||||||
|
this.exceptionRows = [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.clientsService.getKycExceptions(true).subscribe({
|
||||||
|
next: (data) => {
|
||||||
|
this.resolvedExceptions = data.map((item: any) => this.mapException(item, true));
|
||||||
|
this.loadContactHistories(this.resolvedExceptions);
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
console.error('Failed to load resolved exceptions', err);
|
||||||
|
this.resolvedExceptions = [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapException(item: any, resolved: boolean): KmClient {
|
||||||
|
return {
|
||||||
|
id: item.clientId?.toString(),
|
||||||
|
av: (item.clientDisplayName || 'Client').split(' ').map((n: string) => n[0]).join('').substring(0, 2).toUpperCase(),
|
||||||
|
name: item.clientDisplayName,
|
||||||
|
meta: `${item.clientAccountNo || `CLI-${item.clientId}`} · Savings A/c · ${item.clientOfficeName || 'Bhopal Main'}`,
|
||||||
|
account: item.clientAccountNo || `CLI-${item.clientId}`,
|
||||||
|
state: 'alert',
|
||||||
|
risk: item.riskCategoryCode?.toLowerCase() || 'low',
|
||||||
|
riskLabel: item.riskCategory || 'Low Risk',
|
||||||
|
expiry: 'Pending',
|
||||||
|
daysLeft: 30,
|
||||||
|
kyc: {
|
||||||
|
type: item.kycType || 'Individual',
|
||||||
|
initiated: item.initiatedOn || '—',
|
||||||
|
lastVerified: item.verifiedOn || '—',
|
||||||
|
validity: '—',
|
||||||
|
pan: item.panNumber || '—',
|
||||||
|
aadhaar: item.aadhaarNumberMasked || '—',
|
||||||
|
panStatus: item.panVerified ? 'Verified' : 'Verification Failed',
|
||||||
|
aadhaarStatus: item.aadhaarVerified ? 'Verified' : 'Verification Failed',
|
||||||
|
panLink: 'Not Linked'
|
||||||
|
},
|
||||||
|
contact: {
|
||||||
|
// Masked server-side; the full values never reach the browser. 'Not on file' is meaningful
|
||||||
|
// here — it's why an SMS or Email channel will be unavailable for this customer.
|
||||||
|
mobile: item.clientMobileMasked || 'Not on file',
|
||||||
|
email: item.clientEmailMasked || 'Not on file',
|
||||||
|
address: 'Client Address, Bhopal MP 462002',
|
||||||
|
branch: item.clientOfficeName || 'Bhopal Main'
|
||||||
|
},
|
||||||
|
notices: [],
|
||||||
|
noticesSent: 0,
|
||||||
|
noticePending: resolved ? 'Resolved Exception' : 'Pending verification',
|
||||||
|
nextStage: resolved ? 'Exception Resolved' : 'KYC Exceptions review',
|
||||||
|
pendingActions: resolved ? [] : ['Resolve Exception reasons'],
|
||||||
|
actionTaken: resolved ? ['KYC Exception resolved'] : [],
|
||||||
|
evidence: null,
|
||||||
|
isException: true,
|
||||||
|
// No invented default — a record whose reason was never stored (or was cleared by the old
|
||||||
|
// resolve path) must not be relabelled as a PAN failure it may never have had.
|
||||||
|
exceptionReason: item.exceptionReason || 'Reason not recorded',
|
||||||
|
exceptionRemarks: item.remarks || '',
|
||||||
|
exceptionDate: item.initiatedOn || '—',
|
||||||
|
exceptionResolved: resolved,
|
||||||
|
exceptionResolvedOn: item.exceptionResolvedOn || '—',
|
||||||
|
contactHistory: [],
|
||||||
|
kycId: item.id?.toString(),
|
||||||
|
hasMobile: item.hasMobile === true,
|
||||||
|
hasEmail: item.hasEmail === true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pull each row's persisted outreach history. Fired after the exception list loads; a failure on
|
||||||
|
* one row leaves that row's history empty rather than blanking the whole grid.
|
||||||
|
*/
|
||||||
|
private loadContactHistories(rows: KmClient[]): void {
|
||||||
|
for (const row of rows) {
|
||||||
|
if (!row.id) continue;
|
||||||
|
this.clientsService.getKycContactLog(row.id).subscribe({
|
||||||
|
next: (logs) => {
|
||||||
|
row.contactHistory = (logs || []).map((log: any) => this.mapContactLog(log));
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
console.error(`Failed to load contact log for client ${row.id}`, err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
get notYetContactedCount(): number {
|
||||||
|
return this.exceptionRows.filter(c => !c.contactHistory || c.contactHistory.length === 0).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
get panMismatchesCount(): number {
|
||||||
|
return this.exceptionRows.filter(c => c.exceptionReason?.includes('PAN')).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
get aadhaarFailuresCount(): number {
|
||||||
|
return this.exceptionRows.filter(c => c.exceptionReason?.includes('Aadhaar')).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
showSection(section: 'alerts' | 'notif' | 'audit' | 'status' | 'exceptions'): void {
|
||||||
this.activeSection = section;
|
this.activeSection = section;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
openContactDialog(client: KmClient): void {
|
||||||
|
this.current = client;
|
||||||
|
this.contactChannel = 'PHONE_CALL';
|
||||||
|
this.contactNotes = '';
|
||||||
|
this.contactError = '';
|
||||||
|
this.contactSaving = false;
|
||||||
|
this.contactOfficer = this.bankOfficers[1]; // Neha Verma
|
||||||
|
this.contactDialogOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
closeContactDialog(): void {
|
||||||
|
this.contactDialogOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The channel currently picked in the dialog. */
|
||||||
|
get selectedChannel(): { value: string; label: string; sends: boolean; requires?: 'mobile' | 'email' } | undefined {
|
||||||
|
return this.contactChannels.find(c => c.value === this.contactChannel);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when saving will transmit a notice to the customer, not merely record one. */
|
||||||
|
get selectedChannelSends(): boolean {
|
||||||
|
return this.selectedChannel?.sends === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when the picked channel needs a destination the customer doesn't have. Blocks the save so
|
||||||
|
* the officer isn't left reading a failure they could have seen up front.
|
||||||
|
*/
|
||||||
|
get selectedChannelUnavailable(): boolean {
|
||||||
|
const requires = this.selectedChannel?.requires;
|
||||||
|
if (!requires || !this.current) return false;
|
||||||
|
return requires === 'mobile' ? this.current.hasMobile === false : this.current.hasEmail === false;
|
||||||
|
}
|
||||||
|
|
||||||
|
get contactSaveDisabled(): boolean {
|
||||||
|
return !this.contactNotes?.trim() || !this.contactChannel || this.contactSaving || this.selectedChannelUnavailable;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 'Send & Save' when a notice goes out, plain 'Save Log' when it's an offline record. */
|
||||||
|
get contactSaveLabel(): string {
|
||||||
|
if (this.contactSaving) return this.selectedChannelSends ? 'Sending…' : 'Saving…';
|
||||||
|
return this.selectedChannelSends ? 'Send & Save' : 'Save Log';
|
||||||
|
}
|
||||||
|
|
||||||
|
saveContactLog(): void {
|
||||||
|
if (!this.current || this.contactSaveDisabled) return;
|
||||||
|
|
||||||
|
const client = this.current;
|
||||||
|
this.contactSaving = true;
|
||||||
|
this.contactError = '';
|
||||||
|
|
||||||
|
this.clientsService
|
||||||
|
.saveKycContactLog(client.id, {
|
||||||
|
channel: this.contactChannel,
|
||||||
|
notes: this.contactNotes.trim(),
|
||||||
|
officerName: this.contactOfficer
|
||||||
|
})
|
||||||
|
.subscribe({
|
||||||
|
next: (log: any) => {
|
||||||
|
this.contactSaving = false;
|
||||||
|
client.contactHistory = [...(client.contactHistory || []), this.mapContactLog(log)];
|
||||||
|
|
||||||
|
// The request succeeding does not mean the customer was reached — a gateway failure comes
|
||||||
|
// back as HTTP 200 with delivered=false, so report what actually happened.
|
||||||
|
if (log?.platformSent && !log?.delivered) {
|
||||||
|
this.toast(log?.deliveryMessage || `Logged, but the ${log?.channelLabel || 'message'} could not be sent.`);
|
||||||
|
} else {
|
||||||
|
this.toast(log?.deliveryMessage || `Contact attempt logged successfully for ${client.name}`);
|
||||||
|
}
|
||||||
|
this.contactDialogOpen = false;
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.contactSaving = false;
|
||||||
|
// Keep the dialog open — the officer's notes are still in the textarea and would
|
||||||
|
// otherwise be lost.
|
||||||
|
this.contactError =
|
||||||
|
err?.error?.errors?.[0]?.defaultUserMessage ||
|
||||||
|
err?.error?.defaultUserMessage ||
|
||||||
|
'Could not save the contact log. Please try again.';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapContactLog(log: any): KmContactLog {
|
||||||
|
return {
|
||||||
|
date: log?.contactedAt ? this.formatLogDate(log.contactedAt) : '—',
|
||||||
|
action: log?.channelLabel || log?.channel || 'Contact',
|
||||||
|
notes: log?.notes || '',
|
||||||
|
officer: (log?.officerName || '').split(' — ')[0],
|
||||||
|
platformSent: log?.platformSent,
|
||||||
|
delivered: log?.delivered,
|
||||||
|
deliveryStatus: log?.deliveryStatus,
|
||||||
|
deliveryMessage: log?.deliveryMessage,
|
||||||
|
destinationMasked: log?.destinationMasked
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `contactedAt` arrives as a LocalDateTime — Jackson serialises it as an ISO string or as a
|
||||||
|
* [y, m, d, h, min, s] array depending on the tenant's date-format setting, so handle both.
|
||||||
|
*/
|
||||||
|
private formatLogDate(value: any): string {
|
||||||
|
const date = Array.isArray(value)
|
||||||
|
? new Date(value[0], (value[1] || 1) - 1, value[2] || 1, value[3] || 0, value[4] || 0)
|
||||||
|
: new Date(value);
|
||||||
|
if (isNaN(date.getTime())) return '—';
|
||||||
|
return date.toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
get isResolveValid(): boolean {
|
||||||
|
if (!this.resolveRemarks?.trim()) return false;
|
||||||
|
let anyActive = false;
|
||||||
|
if (this.showPanResolution && this.resolvePanActive) {
|
||||||
|
anyActive = true;
|
||||||
|
if (!this.resolvePanNumber || this.resolvePanNumber.trim().length !== 10) return false;
|
||||||
|
}
|
||||||
|
if (this.showAadhaarResolution && this.resolveAadhaarActive) {
|
||||||
|
anyActive = true;
|
||||||
|
if (!this.resolveAadhaarNumber || this.resolveAadhaarNumber.trim().length !== 12) return false;
|
||||||
|
}
|
||||||
|
if (this.showGeneralResolution && this.resolveGeneralActive) {
|
||||||
|
anyActive = true;
|
||||||
|
if (!this.resolveGeneralDocumentNumber?.trim()) return false;
|
||||||
|
}
|
||||||
|
return anyActive;
|
||||||
|
}
|
||||||
|
|
||||||
|
openResolveDialog(client: KmClient): void {
|
||||||
|
this.current = client;
|
||||||
|
const reason = client.exceptionReason || '';
|
||||||
|
this.showPanResolution = reason.includes('PAN');
|
||||||
|
this.showAadhaarResolution = reason.includes('Aadhaar');
|
||||||
|
this.showGeneralResolution = !this.showPanResolution && !this.showAadhaarResolution;
|
||||||
|
|
||||||
|
this.resolvePanActive = this.showPanResolution;
|
||||||
|
this.resolveAadhaarActive = this.showAadhaarResolution;
|
||||||
|
this.resolveGeneralActive = this.showGeneralResolution;
|
||||||
|
|
||||||
|
this.resolvePanNumber = '';
|
||||||
|
this.resolveAadhaarNumber = '';
|
||||||
|
this.resolveGeneralDocumentType = 'OVD';
|
||||||
|
this.resolveGeneralDocumentNumber = '';
|
||||||
|
this.resolveRemarks = '';
|
||||||
|
this.resolveDialogOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
closeResolveDialog(): void {
|
||||||
|
this.resolveDialogOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
saveResolveKyc(): void {
|
||||||
|
if (!this.current || !this.current.kycId) return;
|
||||||
|
const clientId = this.current.id;
|
||||||
|
const kycId = this.current.kycId;
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
const day = today.getDate().toString().padStart(2, '0');
|
||||||
|
const months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
|
||||||
|
const monthStr = months[today.getMonth()];
|
||||||
|
const year = today.getFullYear();
|
||||||
|
const dateStr = `${day} ${monthStr} ${year}`;
|
||||||
|
|
||||||
|
const resolvingPan = this.showPanResolution && this.resolvePanActive;
|
||||||
|
const resolvingAadhaar = this.showAadhaarResolution && this.resolveAadhaarActive;
|
||||||
|
const resolvingGeneral = this.showGeneralResolution && this.resolveGeneralActive;
|
||||||
|
|
||||||
|
const remainsPan = this.showPanResolution && !this.resolvePanActive;
|
||||||
|
const remainsAadhaar = this.showAadhaarResolution && !this.resolveAadhaarActive;
|
||||||
|
|
||||||
|
const isFullyResolved = !remainsPan && !remainsAadhaar;
|
||||||
|
|
||||||
|
const resolvedKycData: any = {
|
||||||
|
locale: 'en',
|
||||||
|
dateFormat: 'dd MMMM yyyy',
|
||||||
|
remarks: this.resolveRemarks || 'Resolved via exceptions dashboard'
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isFullyResolved) {
|
||||||
|
resolvedKycData.isException = false;
|
||||||
|
resolvedKycData.exceptionResolved = true;
|
||||||
|
resolvedKycData.exceptionResolvedOn = dateStr;
|
||||||
|
// Keep exceptionReason — the Resolved tab shows what was resolved, and clearing it destroys
|
||||||
|
// the audit record of why the exception was ever raised.
|
||||||
|
} else {
|
||||||
|
resolvedKycData.isException = true;
|
||||||
|
resolvedKycData.exceptionResolved = false;
|
||||||
|
const remainingReasons: string[] = [];
|
||||||
|
if (remainsPan) remainingReasons.push('PAN Card Verification Failed');
|
||||||
|
if (remainsAadhaar) remainingReasons.push('Aadhaar Verification Failed');
|
||||||
|
resolvedKycData.exceptionReason = remainingReasons.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolvingPan) {
|
||||||
|
resolvedKycData.panNumber = this.resolvePanNumber.toUpperCase().trim();
|
||||||
|
resolvedKycData.panVerified = true;
|
||||||
|
}
|
||||||
|
if (resolvingAadhaar) {
|
||||||
|
const aadhaarRaw = this.resolveAadhaarNumber.trim();
|
||||||
|
resolvedKycData.aadhaarNumberMasked = 'XXXXXXXX' + aadhaarRaw.slice(-4);
|
||||||
|
resolvedKycData.aadhaarVerified = true;
|
||||||
|
}
|
||||||
|
if (resolvingGeneral) {
|
||||||
|
resolvedKycData.kycDocumentName = this.resolveGeneralDocumentType;
|
||||||
|
resolvedKycData.kycDocumentNumber = this.resolveGeneralDocumentNumber;
|
||||||
|
resolvedKycData.isException = false;
|
||||||
|
resolvedKycData.exceptionResolved = true;
|
||||||
|
resolvedKycData.exceptionResolvedOn = dateStr;
|
||||||
|
// exceptionReason preserved — see the fully-resolved branch above.
|
||||||
|
}
|
||||||
|
|
||||||
|
this.clientsService.updateClientKyc(clientId, kycId, resolvedKycData).subscribe({
|
||||||
|
next: () => {
|
||||||
|
const msg = isFullyResolved
|
||||||
|
? `KYC Exception fully resolved and completed for ${this.current?.name}`
|
||||||
|
: `KYC Exception partially resolved for ${this.current?.name}`;
|
||||||
|
this.toast(msg);
|
||||||
|
this.loadExceptions();
|
||||||
|
this.resolveDialogOpen = false;
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
console.error('Failed to resolve exception', err);
|
||||||
|
this.toast('Failed to resolve exception. Please try again.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
openModal(id: string): void {
|
openModal(id: string): void {
|
||||||
const c = this.clients[id];
|
const c = this.clients[id];
|
||||||
if (!c) return;
|
if (!c) return;
|
||||||
|
|||||||
@@ -1069,10 +1069,8 @@ export function buildForm15ghPdf(r: Form15ghRecord, kind: Form15Kind): jsPDF {
|
|||||||
y = paintPara(
|
y = paintPara(
|
||||||
c,
|
c,
|
||||||
y,
|
y,
|
||||||
`Declaration under Section ${
|
`Declaration under Section ${kind === '15H' ? '197A(1C)' : '197A(1) / 197A(1A)'
|
||||||
kind === '15H' ? '197A(1C)' : '197A(1) / 197A(1A)'
|
} of the Income-tax Act, 1961, for non-deduction of tax at source on certain incomes — ${kind === '15H'
|
||||||
} of the Income-tax Act, 1961, for non-deduction of tax at source on certain incomes — ${
|
|
||||||
kind === '15H'
|
|
||||||
? 'applicable to resident individuals aged 60 years or above'
|
? 'applicable to resident individuals aged 60 years or above'
|
||||||
: 'applicable to resident individuals (other than companies / firms) below 60 years'
|
: 'applicable to resident individuals (other than companies / firms) below 60 years'
|
||||||
}.`
|
}.`
|
||||||
|
|||||||
+12
-12
@@ -117,8 +117,8 @@
|
|||||||
|
|
||||||
<p class="cpd-para">
|
<p class="cpd-para">
|
||||||
This is to certify that <strong>{{ interest.record.customerName }}</strong> (Customer ID
|
This is to certify that <strong>{{ interest.record.customerName }}</strong> (Customer ID
|
||||||
<strong>{{ interest.record.customerId }}</strong
|
<strong>{{ interest.record.customerId }}</strong>) is availing the following loan facility from our bank, and the
|
||||||
>) is availing the following loan facility from our bank, and the interest paid by the above customer during the
|
interest paid by the above customer during the
|
||||||
financial year <strong>{{ interest.fy.fy }}</strong> is as detailed below.
|
financial year <strong>{{ interest.fy.fy }}</strong> is as detailed below.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -192,8 +192,8 @@
|
|||||||
<p class="cpd-para cpd-para--centered">
|
<p class="cpd-para cpd-para--centered">
|
||||||
This is to certify that <strong>{{ share.memberName }}</strong> is the registered holder of
|
This is to certify that <strong>{{ share.memberName }}</strong> is the registered holder of
|
||||||
<strong>{{ share.shareCount | number }}</strong> ({{ classLabelText() }}) shares of
|
<strong>{{ share.shareCount | number }}</strong> ({{ classLabelText() }}) shares of
|
||||||
<strong>₹{{ share.faceValue | number: '1.0-0' }}</strong> each, fully paid up, in <strong>{{ bankName }}</strong
|
<strong>₹{{ share.faceValue | number: '1.0-0' }}</strong> each, fully paid up, in <strong>{{ bankName }}</strong>,
|
||||||
>, subject to the bye-laws of the bank.
|
subject to the bye-laws of the bank.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<table class="cpd-summary">
|
<table class="cpd-summary">
|
||||||
@@ -259,8 +259,8 @@
|
|||||||
|
|
||||||
<p class="cpd-para">
|
<p class="cpd-para">
|
||||||
This is to certify that <strong>{{ balance.record.customerName }}</strong> (Customer ID
|
This is to certify that <strong>{{ balance.record.customerName }}</strong> (Customer ID
|
||||||
<strong>{{ balance.record.customerId }}</strong
|
<strong>{{ balance.record.customerId }}</strong>) maintains the following account with our bank, and the balance
|
||||||
>) maintains the following account with our bank, and the balance available in the said account as on
|
available in the said account as on
|
||||||
<strong>{{ balance.asOnDate | date: 'd MMMM y' }}</strong> is as detailed below.
|
<strong>{{ balance.asOnDate | date: 'd MMMM y' }}</strong> is as detailed below.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -374,8 +374,8 @@
|
|||||||
</table>
|
</table>
|
||||||
|
|
||||||
<p class="cpd-para">
|
<p class="cpd-para">
|
||||||
I, <strong>{{ form60.customerName }}</strong
|
I, <strong>{{ form60.customerName }}</strong>, do hereby declare that the particulars given above are true and
|
||||||
>, do hereby declare that the particulars given above are true and correct to the best of my knowledge and
|
correct to the best of my knowledge and
|
||||||
belief. I further declare that I have applied / not applied for a Permanent Account Number with the Income-tax
|
belief. I further declare that I have applied / not applied for a Permanent Account Number with the Income-tax
|
||||||
department, and that I have neither been assessed nor am liable to be assessed under the Income-tax Act, 1961.
|
department, and that I have neither been assessed nor am liable to be assessed under the Income-tax Act, 1961.
|
||||||
</p>
|
</p>
|
||||||
@@ -445,8 +445,8 @@
|
|||||||
</table>
|
</table>
|
||||||
|
|
||||||
<p class="cpd-para">
|
<p class="cpd-para">
|
||||||
I, <strong>{{ form15gh.record.customerName }}</strong
|
I, <strong>{{ form15gh.record.customerName }}</strong>, do hereby declare that the particulars given above are
|
||||||
>, do hereby declare that the particulars given above are true and correct, and that the tax on my estimated
|
true and correct, and that the tax on my estimated
|
||||||
total income for the previous year {{ form15gh.record.fy }} will be NIL. I therefore request that no tax be
|
total income for the previous year {{ form15gh.record.fy }} will be NIL. I therefore request that no tax be
|
||||||
deducted at source on the interest paid to me by the bank on the captioned deposits during the said previous
|
deducted at source on the interest paid to me by the bank on the captioned deposits during the said previous
|
||||||
year.
|
year.
|
||||||
@@ -472,8 +472,8 @@
|
|||||||
<p class="cpd-para">
|
<p class="cpd-para">
|
||||||
This is to certify that the asset described below stands hypothecated to
|
This is to certify that the asset described below stands hypothecated to
|
||||||
<strong>{{ bankName }}</strong> as security for the captioned loan facility extended to
|
<strong>{{ bankName }}</strong> as security for the captioned loan facility extended to
|
||||||
<strong>{{ hypothecation.customerName }}</strong
|
<strong>{{ hypothecation.customerName }}</strong>, and the bank holds the first and exclusive charge over the said
|
||||||
>, and the bank holds the first and exclusive charge over the said asset until the loan is fully discharged.
|
asset until the loan is fully discharged.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<table class="cpd-summary">
|
<table class="cpd-summary">
|
||||||
|
|||||||
@@ -505,3 +505,72 @@
|
|||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
color: var(--cert-g900);
|
color: var(--cert-g900);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Form 121 register — eligibility warnings and the quarter-wise drill-down */
|
||||||
|
|
||||||
|
/* A row the branch needs to look at: an ineligible declaration, or the quarter
|
||||||
|
in which the 194A threshold was crossed. */
|
||||||
|
.cert-row--warn > td {
|
||||||
|
background: color-mix(in srgb, var(--cert-amber) 7%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cert-row--detail > td {
|
||||||
|
background: var(--cert-g50);
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cert-note {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: color-mix(in srgb, var(--cert-blue) 6%, transparent);
|
||||||
|
border-left: 3px solid var(--cert-blue);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--cert-g900);
|
||||||
|
line-height: 1.5;
|
||||||
|
|
||||||
|
fa-icon {
|
||||||
|
color: var(--cert-blue);
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
padding-left: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
li + li {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--warn {
|
||||||
|
background: color-mix(in srgb, var(--cert-amber) 8%, transparent);
|
||||||
|
border-left-color: var(--cert-amber);
|
||||||
|
|
||||||
|
fa-icon {
|
||||||
|
color: var(--cert-amber);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The quarter table nested inside an expanded row. */
|
||||||
|
.cert-table--inner {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--cert-g200);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
|
||||||
|
th {
|
||||||
|
background: #fff;
|
||||||
|
border-bottom: 1px solid var(--cert-g200);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cert-pill {
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,26 +11,60 @@
|
|||||||
<div class="cert-head-l">
|
<div class="cert-head-l">
|
||||||
<fa-icon icon="file-signature" class="cert-head-ic"></fa-icon>
|
<fa-icon icon="file-signature" class="cert-head-ic"></fa-icon>
|
||||||
<div>
|
<div>
|
||||||
<h2 class="cert-head-t">Form 121</h2>
|
<h2 class="cert-head-t">{{ statute.form }}</h2>
|
||||||
<div class="cert-head-s">TDS-waiver declarations for FD / RD interest income — Form 121.</div>
|
<div class="cert-head-s">
|
||||||
|
@if (viewMode === 'demo') {
|
||||||
|
Declarations to stop tax being deducted on fixed and recurring deposit interest under
|
||||||
|
{{ statute.section }}. Reported in {{ statute.statement }}; the consolidated statement of declarations is
|
||||||
|
uploaded {{ statute.frequency }}.
|
||||||
|
} @else {
|
||||||
|
Live customer declarations, sourced from client accounts and KYC profiles.
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="cert-head-r">
|
||||||
|
@if (viewMode === 'demo') {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="cert-cta-btn"
|
||||||
|
(click)="toggleViewMode()"
|
||||||
|
matTooltip="Use real client KYC and account data from Fineract"
|
||||||
|
>
|
||||||
|
<fa-icon icon="database"></fa-icon> Real Declarations
|
||||||
|
</button>
|
||||||
|
} @else {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="cert-cta-btn cert-cta-btn--muted"
|
||||||
|
(click)="toggleViewMode()"
|
||||||
|
matTooltip="Back to the sample data page"
|
||||||
|
>
|
||||||
|
<fa-icon icon="table"></fa-icon> Sample Data
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (viewMode === 'real') {
|
||||||
|
<mifosx-real-form15gh-view></mifosx-real-form15gh-view>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (viewMode === 'demo') {
|
||||||
<!-- KPI strip -->
|
<!-- KPI strip -->
|
||||||
<div class="cert-kpis">
|
<div class="cert-kpis">
|
||||||
<div class="cert-kpi cert-kpi--blue">
|
<div class="cert-kpi cert-kpi--blue">
|
||||||
<fa-icon icon="user" class="cert-kpi-ic"></fa-icon>
|
<fa-icon icon="user" class="cert-kpi-ic"></fa-icon>
|
||||||
<div>
|
<div>
|
||||||
<div class="cert-kpi-n">{{ count15G() | number }}</div>
|
<div class="cert-kpi-n">{{ countGeneralBand() | number }}</div>
|
||||||
<div class="cert-kpi-l">Form 121 (under 60)</div>
|
<div class="cert-kpi-l">General band (under 60)</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="cert-kpi cert-kpi--amber">
|
<div class="cert-kpi cert-kpi--amber">
|
||||||
<fa-icon icon="user-tie" class="cert-kpi-ic"></fa-icon>
|
<fa-icon icon="user-tie" class="cert-kpi-ic"></fa-icon>
|
||||||
<div>
|
<div>
|
||||||
<div class="cert-kpi-n">{{ count15H() | number }}</div>
|
<div class="cert-kpi-n">{{ countSeniorBand() | number }}</div>
|
||||||
<div class="cert-kpi-l">Form 121 (60+)</div>
|
<div class="cert-kpi-l">Senior citizen band (60+)</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="cert-kpi cert-kpi--green">
|
<div class="cert-kpi cert-kpi--green">
|
||||||
@@ -47,6 +81,22 @@
|
|||||||
<div class="cert-kpi-l">Projected Interest</div>
|
<div class="cert-kpi-l">Projected Interest</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="cert-kpi cert-kpi--green">
|
||||||
|
<fa-icon icon="shield-alt" class="cert-kpi-ic"></fa-icon>
|
||||||
|
<div>
|
||||||
|
<div class="cert-kpi-n">₹{{ totalTdsSaved() | number: '1.0-0' }}</div>
|
||||||
|
<div class="cert-kpi-l">TDS Saved</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@if (countIneligible() > 0) {
|
||||||
|
<div class="cert-kpi cert-kpi--amber">
|
||||||
|
<fa-icon icon="exclamation-triangle" class="cert-kpi-ic"></fa-icon>
|
||||||
|
<div>
|
||||||
|
<div class="cert-kpi-n">{{ countIneligible() | number }}</div>
|
||||||
|
<div class="cert-kpi-l">Not Eligible</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Filters -->
|
<!-- Filters -->
|
||||||
@@ -60,11 +110,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="cert-filter">
|
<div class="cert-filter">
|
||||||
<label>Form Type</label>
|
<label>Threshold Band</label>
|
||||||
<select [(ngModel)]="kindFilter">
|
<select [(ngModel)]="bandFilter">
|
||||||
<option value="">All Form 121</option>
|
<option value="">All bands</option>
|
||||||
<option value="15G">Form 121 (Below 60 years)</option>
|
<option value="GENERAL">General (below 60)</option>
|
||||||
<option value="15H">Form 121 (Senior Citizen 60+)</option>
|
<option value="SENIOR">Senior citizen (60+)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -77,7 +127,15 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (search || kindFilter || statusFilter) {
|
<div class="cert-filter">
|
||||||
|
<label>Eligibility</label>
|
||||||
|
<select [(ngModel)]="ineligibleOnly">
|
||||||
|
<option [ngValue]="false">All customers</option>
|
||||||
|
<option [ngValue]="true">Not eligible only</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (search || bandFilter || statusFilter || ineligibleOnly) {
|
||||||
<button type="button" class="cert-clear" (click)="clearFilters()"><fa-icon icon="times"></fa-icon> Clear</button>
|
<button type="button" class="cert-clear" (click)="clearFilters()"><fa-icon icon="times"></fa-icon> Clear</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -89,54 +147,92 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Customer · PAN</th>
|
<th>Customer · PAN</th>
|
||||||
<th>Form Type</th>
|
<th>Form · Band</th>
|
||||||
<th>Age</th>
|
<th>Age</th>
|
||||||
<th>Branch</th>
|
<th>Branch</th>
|
||||||
<th class="num">Deposit Principal</th>
|
<th class="num">Est. Interest</th>
|
||||||
<th class="num">Est. Interest ({{ records[0]?.fy }})</th>
|
<th class="num">Threshold</th>
|
||||||
|
<th class="num">TDS Payable</th>
|
||||||
|
<th class="num">TDS Saved</th>
|
||||||
|
<th>Settlement</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th class="col-actions">Actions</th>
|
<th class="col-actions">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@for (r of filteredRecords; track r.customerId) {
|
@for (row of filteredRows; track row.record.customerId) {
|
||||||
<tr>
|
<tr [class.cert-row--warn]="!row.eligibility.eligible">
|
||||||
<td>
|
<td>
|
||||||
<div class="cert-cust-name">{{ r.customerName }}</div>
|
<div class="cert-cust-name">{{ row.record.customerName }}</div>
|
||||||
<div class="mono small muted">{{ r.customerId }} · PAN {{ r.customerPan }}</div>
|
<div class="mono small muted">{{ row.record.customerId }} · PAN {{ row.record.customerPan }}</div>
|
||||||
<div class="small muted">DOB {{ r.dob | date: 'd MMM y' }}</div>
|
<div class="small muted">DOB {{ row.record.dob | date: 'd MMM y' }}</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span
|
<span
|
||||||
class="cert-pill"
|
class="cert-pill"
|
||||||
[class.cert-pill--blue]="formKind(r) === '15G'"
|
[class.cert-pill--blue]="row.band === 'GENERAL'"
|
||||||
[class.cert-pill--amber]="formKind(r) === '15H'"
|
[class.cert-pill--amber]="row.band === 'SENIOR'"
|
||||||
>
|
>
|
||||||
Form 121 ({{ formKind(r) }})
|
{{ row.form === '121' ? 'Form 121' : 'Form ' + row.form }}
|
||||||
</span>
|
</span>
|
||||||
|
<div class="small muted">{{ row.band === 'SENIOR' ? 'Senior citizen' : 'General' }}</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="small">{{ age(r.dob) }} yrs</td>
|
<td class="small">{{ row.age }} yrs</td>
|
||||||
<td class="small">{{ r.branch }}</td>
|
<td class="small">{{ row.record.branch }}</td>
|
||||||
<td class="num">₹{{ r.depositPrincipal | number: '1.0-0' }}</td>
|
|
||||||
<td class="num">
|
<td class="num">
|
||||||
<strong class="cert-interest">₹{{ r.estimatedInterest | number: '1.0-0' }}</strong>
|
<strong class="cert-interest">₹{{ row.record.estimatedInterest | number: '1.0-0' }}</strong>
|
||||||
|
</td>
|
||||||
|
<td class="num small muted">₹{{ row.schedule.threshold | number: '1.0-0' }}</td>
|
||||||
|
<td class="num">
|
||||||
|
@if (row.schedule.totalTds > 0) {
|
||||||
|
<strong>₹{{ row.schedule.totalTds | number: '1.0-0' }}</strong>
|
||||||
|
<div class="small muted">@ {{ row.schedule.rateApplied * 100 | number: '1.0-0' }}%</div>
|
||||||
|
} @else {
|
||||||
|
<span class="small muted">—</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td class="num">
|
||||||
|
@if (row.tdsSaved > 0) {
|
||||||
|
<strong class="cert-interest">₹{{ row.tdsSaved | number: '1.0-0' }}</strong>
|
||||||
|
} @else {
|
||||||
|
<span class="small muted">—</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td class="small">
|
||||||
|
<span class="cert-pill" [class.cert-pill--blue]="row.mode === 'QUARTERLY'">
|
||||||
|
{{ row.mode === 'QUARTERLY' ? 'Quarterly' : 'Year end' }}
|
||||||
|
</span>
|
||||||
|
<div class="small muted">product-driven</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
@if (r.status === 'FILED') {
|
@if (row.record.status === 'FILED') {
|
||||||
<span class="cert-pill cert-pill--green"> <fa-icon icon="check-circle"></fa-icon> Filed </span>
|
<span class="cert-pill cert-pill--green"> <fa-icon icon="check-circle"></fa-icon> Filed </span>
|
||||||
@if (r.filedOn) {
|
@if (row.record.filedOn) {
|
||||||
<div class="mono small muted">{{ r.filedOn | date: 'd MMM y' }}</div>
|
<div class="mono small muted">{{ row.record.filedOn | date: 'd MMM y' }}</div>
|
||||||
}
|
}
|
||||||
} @else {
|
} @else {
|
||||||
<span class="cert-pill cert-pill--amber"> <fa-icon icon="hourglass-half"></fa-icon> Pending </span>
|
<span class="cert-pill cert-pill--amber"> <fa-icon icon="hourglass-half"></fa-icon> Pending </span>
|
||||||
}
|
}
|
||||||
|
@if (!row.eligibility.eligible) {
|
||||||
|
<div class="cert-pill cert-pill--amber">
|
||||||
|
<fa-icon icon="exclamation-triangle"></fa-icon> Not eligible
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</td>
|
</td>
|
||||||
<td class="col-actions">
|
<td class="col-actions">
|
||||||
<div class="cert-row-actions">
|
<div class="cert-row-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="cert-icon-btn"
|
||||||
|
(click)="toggleExpanded(row)"
|
||||||
|
[matTooltip]="isExpanded(row) ? 'Hide quarter-wise' : 'Show quarter-wise'"
|
||||||
|
>
|
||||||
|
<fa-icon [icon]="isExpanded(row) ? 'chevron-up' : 'chevron-down'"></fa-icon>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="cert-icon-btn primary"
|
class="cert-icon-btn primary"
|
||||||
(click)="viewCertificate(r)"
|
(click)="viewCertificate(row)"
|
||||||
matTooltip="View / Print declaration"
|
matTooltip="View / Print declaration"
|
||||||
>
|
>
|
||||||
<fa-icon icon="eye"></fa-icon>
|
<fa-icon icon="eye"></fa-icon>
|
||||||
@@ -144,7 +240,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="cert-icon-btn"
|
class="cert-icon-btn"
|
||||||
(click)="downloadCertificate(r)"
|
(click)="downloadCertificate(row)"
|
||||||
matTooltip="Download PDF"
|
matTooltip="Download PDF"
|
||||||
>
|
>
|
||||||
<fa-icon icon="download"></fa-icon>
|
<fa-icon icon="download"></fa-icon>
|
||||||
@@ -152,10 +248,100 @@
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
|
@if (isExpanded(row)) {
|
||||||
|
<tr class="cert-row--detail">
|
||||||
|
<td colspan="11">
|
||||||
|
@if (!row.eligibility.eligible) {
|
||||||
|
<div class="cert-note cert-note--warn">
|
||||||
|
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||||
|
<div>
|
||||||
|
<strong>Declaration cannot be accepted</strong>
|
||||||
|
<ul>
|
||||||
|
@for (reason of row.eligibility.reasons; track reason) {
|
||||||
|
<li>{{ reason }}</li>
|
||||||
}
|
}
|
||||||
@if (filteredRecords.length === 0) {
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@for (note of row.eligibility.unassessed; track note) {
|
||||||
|
<div class="cert-note">
|
||||||
|
<fa-icon icon="info-circle"></fa-icon>
|
||||||
|
<div>{{ note }}</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@for (note of row.schedule.notes; track note) {
|
||||||
|
<div class="cert-note">
|
||||||
|
<fa-icon icon="info-circle"></fa-icon>
|
||||||
|
<div>{{ note }}</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<table class="cert-table cert-table--inner">
|
||||||
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="8" class="cert-empty">
|
<th>Quarter</th>
|
||||||
|
<th class="num">Interest</th>
|
||||||
|
<th class="num">Cumulative</th>
|
||||||
|
<th class="num">Liability to date</th>
|
||||||
|
<th class="num">TDS this quarter</th>
|
||||||
|
<th>Return due</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (line of row.schedule.lines; track line.quarter) {
|
||||||
|
<tr [class.cert-row--warn]="line.thresholdCrossedHere">
|
||||||
|
<td class="small">
|
||||||
|
{{ line.label }}
|
||||||
|
@if (line.thresholdCrossedHere) {
|
||||||
|
<span class="cert-pill cert-pill--amber">threshold crossed</span>
|
||||||
|
}
|
||||||
|
@if (line.declarationActive) {
|
||||||
|
<span class="cert-pill cert-pill--green">Form 121 active</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td class="num">₹{{ line.interest | number: '1.0-0' }}</td>
|
||||||
|
<td class="num">₹{{ line.cumInterest | number: '1.0-0' }}</td>
|
||||||
|
<td class="num">₹{{ line.cumLiability | number: '1.0-0' }}</td>
|
||||||
|
<td class="num">
|
||||||
|
@if (line.tds !== 0) {
|
||||||
|
<strong>₹{{ line.tds | number: '1.0-0' }}</strong>
|
||||||
|
} @else {
|
||||||
|
<span class="small muted">—</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td class="small muted">{{ line.dueDate }}</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
<tr>
|
||||||
|
<td class="small"><strong>Total</strong></td>
|
||||||
|
<td class="num">
|
||||||
|
<strong>₹{{ row.schedule.totalInterest | number: '1.0-0' }}</strong>
|
||||||
|
</td>
|
||||||
|
<td></td>
|
||||||
|
<td></td>
|
||||||
|
<td class="num">
|
||||||
|
<strong>₹{{ row.schedule.totalTds | number: '1.0-0' }}</strong>
|
||||||
|
</td>
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
@if (row.schedule.shieldedInterest > 0) {
|
||||||
|
<div class="small muted">
|
||||||
|
₹{{ row.schedule.shieldedInterest | number: '1.0-0' }} paid without deduction on the strength of
|
||||||
|
the declaration — reportable in the quarterly {{ statute.statement }} return.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@if (filteredRows.length === 0) {
|
||||||
|
<tr>
|
||||||
|
<td colspan="11" class="cert-empty">
|
||||||
<fa-icon icon="info-circle"></fa-icon>
|
<fa-icon icon="info-circle"></fa-icon>
|
||||||
No declarations match the current filters.
|
No declarations match the current filters.
|
||||||
</td>
|
</td>
|
||||||
@@ -165,4 +351,5 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,9 +21,31 @@ import {
|
|||||||
} from '../certificate-preview-dialog/certificate-preview-dialog.component';
|
} from '../certificate-preview-dialog/certificate-preview-dialog.component';
|
||||||
import { downloadForm15ghPdf } from '../certificate-pdf';
|
import { downloadForm15ghPdf } from '../certificate-pdf';
|
||||||
import { DscSignService } from '../../dsc-sign-dialog/dsc-sign.service';
|
import { DscSignService } from '../../dsc-sign-dialog/dsc-sign.service';
|
||||||
|
import { RealForm15ghViewComponent } from './real-form15gh-view/real-form15gh-view.component';
|
||||||
|
|
||||||
export type Form15Kind = '15G' | '15H';
|
import {
|
||||||
|
Declaration,
|
||||||
|
DeclarationForm,
|
||||||
|
EligibilityResult,
|
||||||
|
FY_TAX_CONFIG,
|
||||||
|
Form15Kind,
|
||||||
|
TdsMode,
|
||||||
|
TdsSchedule,
|
||||||
|
ageInFy,
|
||||||
|
buildSchedule,
|
||||||
|
checkEligibility,
|
||||||
|
declarationFormFor,
|
||||||
|
formKindFor,
|
||||||
|
isSeniorInFy,
|
||||||
|
normaliseFy,
|
||||||
|
projectQuarterlyCredits,
|
||||||
|
tdsModeFor
|
||||||
|
} from './tds-15gh.compute';
|
||||||
|
|
||||||
|
export type { Form15Kind };
|
||||||
export type Form15Status = 'FILED' | 'PENDING';
|
export type Form15Status = 'FILED' | 'PENDING';
|
||||||
|
/** Which deduction trigger the payee falls under. Age-based, still. */
|
||||||
|
export type ThresholdBand = 'GENERAL' | 'SENIOR';
|
||||||
|
|
||||||
export interface Form15ghRecord {
|
export interface Form15ghRecord {
|
||||||
customerId: string;
|
customerId: string;
|
||||||
@@ -32,18 +54,46 @@ export interface Form15ghRecord {
|
|||||||
customerAddress: string;
|
customerAddress: string;
|
||||||
dob: string;
|
dob: string;
|
||||||
branch: string;
|
branch: string;
|
||||||
/** Aggregate interest the bank will pay this FY. */
|
/** Aggregate interest the bank will pay this FY across the customer's time deposits. */
|
||||||
estimatedInterest: number;
|
estimatedInterest: number;
|
||||||
/** Customer's total estimated income for the FY. */
|
/** Customer's total estimated income for the FY, as declared by them. */
|
||||||
estimatedIncome: number;
|
estimatedIncome: number;
|
||||||
/** Sum of FD / RD principals tied to the declaration. */
|
/** Sum of FD / RD principals tied to the declaration. */
|
||||||
depositPrincipal: number;
|
depositPrincipal: number;
|
||||||
fy: string;
|
fy: string;
|
||||||
status: Form15Status;
|
status: Form15Status;
|
||||||
filedOn?: string;
|
filedOn?: string;
|
||||||
/** "Yes / No" for: has any income tax been assessed for the customer
|
/**
|
||||||
under the IT Act in the past six years? */
|
* "Yes / No" for: has any income tax been assessed for the customer under the
|
||||||
|
* IT Act in the past six years?
|
||||||
|
*/
|
||||||
assessed: boolean;
|
assessed: boolean;
|
||||||
|
/**
|
||||||
|
* The deposit product's interest-posting frequency. Tax falls due when interest
|
||||||
|
* is credited, so this — and not any customer preference — decides whether it
|
||||||
|
* settles quarterly or at year end.
|
||||||
|
*/
|
||||||
|
interestPostingPeriodType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A register row with everything the engine derived for it, computed once. */
|
||||||
|
export interface Form15ghRow {
|
||||||
|
record: Form15ghRecord;
|
||||||
|
/** The form actually in force for the record's year — '121' from FY 2026-27. */
|
||||||
|
form: DeclarationForm;
|
||||||
|
/**
|
||||||
|
* The 15G/15H label. Only a form distinction up to FY 2025-26; from FY 2026-27
|
||||||
|
* the forms merged and this reflects the threshold band alone.
|
||||||
|
*/
|
||||||
|
kind: Form15Kind;
|
||||||
|
band: ThresholdBand;
|
||||||
|
age: number;
|
||||||
|
mode: TdsMode;
|
||||||
|
/** Schedule as things stand, declaration included where one is on record. */
|
||||||
|
schedule: TdsSchedule;
|
||||||
|
/** Tax the declaration kept out of the customer's pocket this year. */
|
||||||
|
tdsSaved: number;
|
||||||
|
eligibility: EligibilityResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -55,7 +105,8 @@ export interface Form15ghRecord {
|
|||||||
CommonModule,
|
CommonModule,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
FaIconComponent,
|
FaIconComponent,
|
||||||
MatTooltip
|
MatTooltip,
|
||||||
|
RealForm15ghViewComponent
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class Form15ghComponent implements OnInit {
|
export class Form15ghComponent implements OnInit {
|
||||||
@@ -63,40 +114,89 @@ export class Form15ghComponent implements OnInit {
|
|||||||
private snack = inject(MatSnackBar);
|
private snack = inject(MatSnackBar);
|
||||||
private dscSign = inject(DscSignService);
|
private dscSign = inject(DscSignService);
|
||||||
|
|
||||||
|
/** Mode toggle: 'demo' uses mock seed data; 'real' pulls live Fineract data. */
|
||||||
|
viewMode: 'demo' | 'real' = 'demo';
|
||||||
|
|
||||||
// ── Filters ──
|
// ── Filters ──
|
||||||
search = '';
|
search = '';
|
||||||
/** Which form. Empty = both. */
|
/** Which deduction trigger. Empty = both bands. */
|
||||||
kindFilter: '' | Form15Kind = '';
|
bandFilter: '' | ThresholdBand = '';
|
||||||
statusFilter: '' | Form15Status = '';
|
statusFilter: '' | Form15Status = '';
|
||||||
|
/** Show only declarations the branch cannot lawfully accept. */
|
||||||
|
ineligibleOnly = false;
|
||||||
|
|
||||||
records: Form15ghRecord[] = [];
|
/** Which row's quarter-wise breakdown is open. */
|
||||||
|
expandedId: string | null = null;
|
||||||
|
|
||||||
|
rows: Form15ghRow[] = [];
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.records = seedForm15gh();
|
// Derived once. The template reads these many times per change-detection
|
||||||
|
// pass, and re-running the schedules on every read is what made the other
|
||||||
|
// register screens stall.
|
||||||
|
this.rows = seedForm15gh().map((record) => this.buildRow(record));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Compute age in years from DoB. */
|
private buildRow(record: Form15ghRecord): Form15ghRow {
|
||||||
age(dob: string): number {
|
const ctx = {
|
||||||
if (!dob) return 0;
|
fy: record.fy,
|
||||||
const today = new Date();
|
dateOfBirth: record.dob,
|
||||||
const birth = new Date(dob);
|
pan: record.customerPan,
|
||||||
let age = today.getFullYear() - birth.getFullYear();
|
resident: true
|
||||||
const m = today.getMonth() - birth.getMonth();
|
};
|
||||||
if (m < 0 || (m === 0 && today.getDate() < birth.getDate())) age--;
|
|
||||||
return age;
|
const declaration: Declaration | null =
|
||||||
|
record.status === 'FILED'
|
||||||
|
? {
|
||||||
|
kind: formKindFor(record.dob, record.fy),
|
||||||
|
// The register holds the filing date; a declaration operates from
|
||||||
|
// the day the branch accepted it.
|
||||||
|
acceptedOn: record.filedOn ?? `${record.fy.replace(/\D/g, '').slice(0, 4)}-04-01`,
|
||||||
|
fy: record.fy
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const mode = tdsModeFor({ interestPostingPeriodType: record.interestPostingPeriodType });
|
||||||
|
// The register carries a projected annual figure rather than real postings,
|
||||||
|
// so spread it across the year to drive the schedule.
|
||||||
|
const credits = projectQuarterlyCredits(record.estimatedInterest, record.fy);
|
||||||
|
|
||||||
|
const schedule = buildSchedule(mode, credits, { ...ctx, declaration });
|
||||||
|
const withoutDeclaration = buildSchedule(mode, credits, { ...ctx, declaration: null });
|
||||||
|
|
||||||
|
return {
|
||||||
|
record,
|
||||||
|
form: declarationFormFor(record.fy, record.dob),
|
||||||
|
kind: formKindFor(record.dob, record.fy),
|
||||||
|
band: isSeniorInFy(record.dob, record.fy) ? 'SENIOR' : 'GENERAL',
|
||||||
|
age: ageInFy(record.dob, record.fy),
|
||||||
|
mode,
|
||||||
|
schedule,
|
||||||
|
tdsSaved: declaration ? Math.max(0, withoutDeclaration.totalTds - schedule.totalTds) : 0,
|
||||||
|
eligibility: checkEligibility(ctx, {
|
||||||
|
estimatedInterest: record.estimatedInterest,
|
||||||
|
estimatedIncome: record.estimatedIncome,
|
||||||
|
incomeOnRecord: true
|
||||||
|
})
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 15H once 60+, 15G otherwise. */
|
get filteredRows(): Form15ghRow[] {
|
||||||
formKind(r: Form15ghRecord): Form15Kind {
|
|
||||||
return this.age(r.dob) >= 60 ? '15H' : '15G';
|
|
||||||
}
|
|
||||||
|
|
||||||
get filteredRecords(): Form15ghRecord[] {
|
|
||||||
const q = this.search.trim().toLowerCase();
|
const q = this.search.trim().toLowerCase();
|
||||||
return this.records.filter((r) => {
|
return this.rows.filter((row) => {
|
||||||
if (this.kindFilter && this.formKind(r) !== this.kindFilter) return false;
|
if (this.bandFilter && row.band !== this.bandFilter) {
|
||||||
if (this.statusFilter && r.status !== this.statusFilter) return false;
|
return false;
|
||||||
if (!q) return true;
|
}
|
||||||
|
if (this.statusFilter && row.record.status !== this.statusFilter) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (this.ineligibleOnly && row.eligibility.eligible) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!q) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const r = row.record;
|
||||||
return (
|
return (
|
||||||
r.customerName.toLowerCase().includes(q) ||
|
r.customerName.toLowerCase().includes(q) ||
|
||||||
r.customerId.toLowerCase().includes(q) ||
|
r.customerId.toLowerCase().includes(q) ||
|
||||||
@@ -105,32 +205,67 @@ export class Form15ghComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
count15G(): number {
|
countGeneralBand(): number {
|
||||||
return this.records.filter((r) => this.formKind(r) === '15G').length;
|
return this.rows.filter((r) => r.band === 'GENERAL').length;
|
||||||
}
|
}
|
||||||
|
|
||||||
count15H(): number {
|
countSeniorBand(): number {
|
||||||
return this.records.filter((r) => this.formKind(r) === '15H').length;
|
return this.rows.filter((r) => r.band === 'SENIOR').length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The declaration in force across the register, and the provision charging the
|
||||||
|
* tax. Both changed on 01 April 2026, so they are read from the config rather
|
||||||
|
* than written into the template.
|
||||||
|
*/
|
||||||
|
get statute(): { form: string; section: string; statement: string; frequency: string } {
|
||||||
|
const fy = normaliseFy(this.rows[0]?.record.fy ?? '2026-27');
|
||||||
|
const cfg = FY_TAX_CONFIG[fy];
|
||||||
|
return {
|
||||||
|
form: cfg.declarationForm === '121' ? 'Form 121' : 'Form 15G / 15H',
|
||||||
|
section: cfg.interestTdsSection,
|
||||||
|
statement: cfg.tdsStatementForm,
|
||||||
|
frequency: cfg.declarationStatementFrequency === 'MONTHLY' ? 'monthly' : 'quarterly'
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
countFiled(): number {
|
countFiled(): number {
|
||||||
return this.records.filter((r) => r.status === 'FILED').length;
|
return this.rows.filter((r) => r.record.status === 'FILED').length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Declarations on record that the branch could not lawfully have accepted. */
|
||||||
|
countIneligible(): number {
|
||||||
|
return this.rows.filter((r) => !r.eligibility.eligible).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
totalInterest(): number {
|
totalInterest(): number {
|
||||||
return this.filteredRecords.reduce((s, r) => s + r.estimatedInterest, 0);
|
return this.filteredRows.reduce((s, r) => s + r.record.estimatedInterest, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tax the declarations kept out of customers' pockets, across the filter. */
|
||||||
|
totalTdsSaved(): number {
|
||||||
|
return this.filteredRows.reduce((s, r) => s + r.tdsSaved, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
clearFilters(): void {
|
clearFilters(): void {
|
||||||
this.search = '';
|
this.search = '';
|
||||||
this.kindFilter = '';
|
this.bandFilter = '';
|
||||||
this.statusFilter = '';
|
this.statusFilter = '';
|
||||||
|
this.ineligibleOnly = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
viewCertificate(r: Form15ghRecord): void {
|
toggleExpanded(row: Form15ghRow): void {
|
||||||
|
this.expandedId = this.expandedId === row.record.customerId ? null : row.record.customerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
isExpanded(row: Form15ghRow): boolean {
|
||||||
|
return this.expandedId === row.record.customerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
viewCertificate(row: Form15ghRow): void {
|
||||||
const data: CertificatePreviewDialogData = {
|
const data: CertificatePreviewDialogData = {
|
||||||
kind: 'form15gh',
|
kind: 'form15gh',
|
||||||
form15gh: { record: r, kind: this.formKind(r), age: this.age(r.dob) }
|
form15gh: { record: row.record, kind: row.kind, age: row.age }
|
||||||
};
|
};
|
||||||
this.dialog.open(CertificatePreviewDialogComponent, {
|
this.dialog.open(CertificatePreviewDialogComponent, {
|
||||||
width: '880px',
|
width: '880px',
|
||||||
@@ -142,20 +277,34 @@ export class Form15ghComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadCertificate(r: Form15ghRecord): void {
|
downloadCertificate(row: Form15ghRow): void {
|
||||||
const kind = this.formKind(r);
|
const r = row.record;
|
||||||
|
// A declaration the branch cannot accept must not go out as a signed PDF.
|
||||||
|
if (!row.eligibility.eligible) {
|
||||||
|
this.snack.open(`Cannot issue: ${row.eligibility.reasons[0]}`, 'Dismiss', { duration: 6000 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.dscSign.promptThenDownload(
|
this.dscSign.promptThenDownload(
|
||||||
{
|
{
|
||||||
title: `Form 121 (${kind}) Declaration`,
|
title: `${row.form === '121' ? 'Form 121' : `Form ${row.form}`} Declaration`,
|
||||||
reference: `${r.customerId} · ${r.customerName} · ${r.fy}`,
|
reference: `${r.customerId} · ${r.customerName} · ${r.fy}`,
|
||||||
filename: `Form121-${r.customerId}-${r.fy.replace(/\s+/g, '')}.pdf`
|
filename: `Form121-${r.customerId}-${r.fy.replace(/\s+/g, '')}.pdf`
|
||||||
},
|
},
|
||||||
(dsc) => downloadForm15ghPdf(r, kind, dsc)
|
(dsc) => downloadForm15ghPdf(r, row.kind, dsc)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
toggleViewMode(): void {
|
||||||
|
this.viewMode = this.viewMode === 'demo' ? 'real' : 'demo';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ───────────────────── Mock seed (top-level export) ─────────────────────
|
// ───────────────────────── Mock seed (top-level export) ─────────────────────
|
||||||
|
/**
|
||||||
|
* Two of these customers are deliberately ineligible — their estimated income
|
||||||
|
* leaves a tax liability, so a Form 121 declaration cannot be accepted from them.
|
||||||
|
* That gives the register's eligibility warnings something to show.
|
||||||
|
*/
|
||||||
export function seedForm15gh(): Form15ghRecord[] {
|
export function seedForm15gh(): Form15ghRecord[] {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -165,86 +314,109 @@ export function seedForm15gh(): Form15ghRecord[] {
|
|||||||
customerAddress: 'Flat 902, Skyline Apt, Andheri (E), Mumbai 400069',
|
customerAddress: 'Flat 902, Skyline Apt, Andheri (E), Mumbai 400069',
|
||||||
dob: '1985-02-14',
|
dob: '1985-02-14',
|
||||||
branch: 'Mumbai — Andheri (E)',
|
branch: 'Mumbai — Andheri (E)',
|
||||||
estimatedInterest: 38500,
|
estimatedInterest: 38_500,
|
||||||
estimatedIncome: 850000,
|
// Over the basic exemption limit — not eligible for 15G.
|
||||||
depositPrincipal: 500000,
|
estimatedIncome: 850_000,
|
||||||
fy: 'FY 2025-26',
|
depositPrincipal: 500_000,
|
||||||
status: 'FILED',
|
fy: 'FY 2026-27',
|
||||||
filedOn: '2025-04-10',
|
status: 'PENDING',
|
||||||
assessed: true
|
assessed: true,
|
||||||
|
interestPostingPeriodType: 'savingsPostingInterestPeriodType.quarterly'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
customerId: 'CUST556677',
|
customerId: 'CUST556677',
|
||||||
customerName: 'Karthik Rao',
|
customerName: 'Karthik Rao',
|
||||||
customerPan: 'PQRSR2233Z',
|
customerPan: 'PQRPR2233Z',
|
||||||
customerAddress: '24, MG Road, Bengaluru 560001',
|
customerAddress: '24, MG Road, Bengaluru 560001',
|
||||||
dob: '1972-09-04',
|
dob: '1972-09-04',
|
||||||
branch: 'Bengaluru — MG Road',
|
branch: 'Bengaluru — MG Road',
|
||||||
estimatedInterest: 52800,
|
estimatedInterest: 52_800,
|
||||||
estimatedIncome: 320000,
|
estimatedIncome: 320_000,
|
||||||
depositPrincipal: 700000,
|
depositPrincipal: 700_000,
|
||||||
fy: 'FY 2025-26',
|
fy: 'FY 2026-27',
|
||||||
status: 'PENDING',
|
status: 'PENDING',
|
||||||
assessed: false
|
assessed: false,
|
||||||
|
interestPostingPeriodType: 'savingsPostingInterestPeriodType.quarterly'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
customerId: 'CUST667788',
|
customerId: 'CUST667788',
|
||||||
customerName: 'Lakshmi Naidu',
|
customerName: 'Lakshmi Naidu',
|
||||||
customerPan: 'XYZAN7788Q',
|
customerPan: 'XYZPN7788Q',
|
||||||
customerAddress: 'Plot 14, Road No. 3, Banjara Hills, Hyderabad 500034',
|
customerAddress: 'Plot 14, Road No. 3, Banjara Hills, Hyderabad 500034',
|
||||||
dob: '1958-06-22',
|
dob: '1958-06-22',
|
||||||
branch: 'Hyderabad — Banjara Hills',
|
branch: 'Hyderabad — Banjara Hills',
|
||||||
estimatedInterest: 88200,
|
estimatedInterest: 88_200,
|
||||||
estimatedIncome: 410000,
|
estimatedIncome: 410_000,
|
||||||
depositPrincipal: 1200000,
|
depositPrincipal: 1_200_000,
|
||||||
fy: 'FY 2025-26',
|
fy: 'FY 2026-27',
|
||||||
status: 'FILED',
|
status: 'FILED',
|
||||||
filedOn: '2025-04-15',
|
filedOn: '2026-04-15',
|
||||||
assessed: true
|
assessed: true,
|
||||||
|
interestPostingPeriodType: 'savingsPostingInterestPeriodType.quarterly'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
customerId: 'CUST334455',
|
customerId: 'CUST334455',
|
||||||
customerName: 'Anita Verma',
|
customerName: 'Anita Verma',
|
||||||
customerPan: 'EFGHV5678A',
|
customerPan: 'EFGPV5678A',
|
||||||
customerAddress: 'B-12, Connaught Pl, New Delhi 110001',
|
customerAddress: 'B-12, Connaught Pl, New Delhi 110001',
|
||||||
dob: '1962-11-30',
|
dob: '1962-11-30',
|
||||||
branch: 'New Delhi — Connaught Place',
|
branch: 'New Delhi — Connaught Place',
|
||||||
estimatedInterest: 64500,
|
estimatedInterest: 64_500,
|
||||||
estimatedIncome: 280000,
|
estimatedIncome: 280_000,
|
||||||
depositPrincipal: 900000,
|
depositPrincipal: 900_000,
|
||||||
fy: 'FY 2025-26',
|
fy: 'FY 2026-27',
|
||||||
status: 'FILED',
|
status: 'FILED',
|
||||||
filedOn: '2025-04-05',
|
filedOn: '2026-04-05',
|
||||||
assessed: false
|
assessed: false,
|
||||||
|
interestPostingPeriodType: 'savingsPostingInterestPeriodType.annual'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
customerId: 'CUST990011',
|
customerId: 'CUST990011',
|
||||||
customerName: 'Arjun Banerjee',
|
customerName: 'Arjun Banerjee',
|
||||||
customerPan: 'TUVWB4455F',
|
customerPan: 'TUVPB4455F',
|
||||||
customerAddress: '42, Park Street, Kolkata 700016',
|
customerAddress: '42, Park Street, Kolkata 700016',
|
||||||
dob: '1991-07-19',
|
dob: '1991-07-19',
|
||||||
branch: 'Kolkata — Park Street',
|
branch: 'Kolkata — Park Street',
|
||||||
estimatedInterest: 14200,
|
estimatedInterest: 14_200,
|
||||||
estimatedIncome: 620000,
|
// Over the basic exemption limit — not eligible for 15G.
|
||||||
depositPrincipal: 200000,
|
estimatedIncome: 620_000,
|
||||||
fy: 'FY 2025-26',
|
depositPrincipal: 200_000,
|
||||||
|
fy: 'FY 2026-27',
|
||||||
status: 'PENDING',
|
status: 'PENDING',
|
||||||
assessed: true
|
assessed: true,
|
||||||
|
interestPostingPeriodType: 'savingsPostingInterestPeriodType.monthly'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
customerId: 'CUST330044',
|
customerId: 'CUST330044',
|
||||||
customerName: 'Madhavi Singh',
|
customerName: 'Madhavi Singh',
|
||||||
customerPan: 'TUVWS3344K',
|
customerPan: 'TUVPS3344K',
|
||||||
customerAddress: 'D-5, Greater Kailash, New Delhi 110048',
|
customerAddress: 'D-5, Greater Kailash, New Delhi 110048',
|
||||||
dob: '1955-03-08',
|
dob: '1955-03-08',
|
||||||
branch: 'New Delhi — Connaught Place',
|
branch: 'New Delhi — Connaught Place',
|
||||||
estimatedInterest: 145000,
|
// A 15H declarant faces no ceiling on interest — only a nil liability.
|
||||||
estimatedIncome: 320000,
|
estimatedInterest: 145_000,
|
||||||
depositPrincipal: 2000000,
|
estimatedIncome: 320_000,
|
||||||
fy: 'FY 2025-26',
|
depositPrincipal: 2_000_000,
|
||||||
|
fy: 'FY 2026-27',
|
||||||
status: 'FILED',
|
status: 'FILED',
|
||||||
filedOn: '2025-04-02',
|
filedOn: '2026-04-02',
|
||||||
assessed: false
|
assessed: false,
|
||||||
|
interestPostingPeriodType: 'savingsPostingInterestPeriodType.quarterly'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
customerId: 'CUST778899',
|
||||||
|
customerName: 'Vikram Malhotra',
|
||||||
|
customerPan: '',
|
||||||
|
customerAddress: 'Flat 405, Green Glen Layout, Outer Ring Road, Bengaluru 560103',
|
||||||
|
dob: '1980-05-15',
|
||||||
|
branch: 'Bengaluru — MG Road',
|
||||||
|
estimatedInterest: 60_000,
|
||||||
|
estimatedIncome: 350_000,
|
||||||
|
depositPrincipal: 800_000,
|
||||||
|
fy: 'FY 2026-27',
|
||||||
|
status: 'PENDING',
|
||||||
|
assessed: true,
|
||||||
|
interestPostingPeriodType: 'savingsPostingInterestPeriodType.quarterly'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
+329
@@ -0,0 +1,329 @@
|
|||||||
|
<!--
|
||||||
|
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/.
|
||||||
|
-->
|
||||||
|
|
||||||
|
@if (loading) {
|
||||||
|
<div class="cert-empty">
|
||||||
|
<fa-icon icon="spinner" [spin]="true" size="2x" style="color: #7c3aed; margin-bottom: 12px;"></fa-icon>
|
||||||
|
<div>Loading live declarations and accounts from Fineract…</div>
|
||||||
|
</div>
|
||||||
|
} @else if (loadError) {
|
||||||
|
<div class="cert-empty" style="color: #ef4444;">
|
||||||
|
<fa-icon icon="exclamation-circle" size="2x" style="margin-bottom: 12px;"></fa-icon>
|
||||||
|
<div>Could not load real data from Fineract. Check connection.</div>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<!-- KPI strip -->
|
||||||
|
<div class="cert-kpis">
|
||||||
|
<div class="cert-kpi cert-kpi--blue">
|
||||||
|
<fa-icon icon="user" class="cert-kpi-ic"></fa-icon>
|
||||||
|
<div>
|
||||||
|
<div class="cert-kpi-n">{{ countGeneralBand() | number }}</div>
|
||||||
|
<div class="cert-kpi-l">General band (under 60)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="cert-kpi cert-kpi--amber">
|
||||||
|
<fa-icon icon="user-tie" class="cert-kpi-ic"></fa-icon>
|
||||||
|
<div>
|
||||||
|
<div class="cert-kpi-n">{{ countSeniorBand() | number }}</div>
|
||||||
|
<div class="cert-kpi-l">Senior citizen band (60+)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="cert-kpi cert-kpi--green">
|
||||||
|
<fa-icon icon="check-circle" class="cert-kpi-ic"></fa-icon>
|
||||||
|
<div>
|
||||||
|
<div class="cert-kpi-n">{{ countFiled() | number }}</div>
|
||||||
|
<div class="cert-kpi-l">Filed this FY</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="cert-kpi cert-kpi--teal">
|
||||||
|
<fa-icon icon="coins" class="cert-kpi-ic"></fa-icon>
|
||||||
|
<div>
|
||||||
|
<div class="cert-kpi-n">₹{{ totalInterest() | number: '1.0-0' }}</div>
|
||||||
|
<div class="cert-kpi-l">Projected Interest</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="cert-kpi cert-kpi--green">
|
||||||
|
<fa-icon icon="shield-alt" class="cert-kpi-ic"></fa-icon>
|
||||||
|
<div>
|
||||||
|
<div class="cert-kpi-n">₹{{ totalTdsSaved() | number: '1.0-0' }}</div>
|
||||||
|
<div class="cert-kpi-l">TDS Saved</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@if (countIneligible() > 0) {
|
||||||
|
<div class="cert-kpi cert-kpi--amber">
|
||||||
|
<fa-icon icon="exclamation-triangle" class="cert-kpi-ic"></fa-icon>
|
||||||
|
<div>
|
||||||
|
<div class="cert-kpi-n">{{ countIneligible() | number }}</div>
|
||||||
|
<div class="cert-kpi-l">Not Eligible</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<div class="cert-filters">
|
||||||
|
<div class="cert-filter">
|
||||||
|
<label>Financial Year</label>
|
||||||
|
<select [(ngModel)]="selectedFy" (ngModelChange)="loadRealData()">
|
||||||
|
@for (y of availableFys; track y) {
|
||||||
|
<option [value]="y">{{ y }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cert-filter">
|
||||||
|
<label>Customer / Customer ID / PAN</label>
|
||||||
|
<div class="cert-input-ic">
|
||||||
|
<fa-icon icon="search"></fa-icon>
|
||||||
|
<input type="text" [(ngModel)]="search" placeholder="Search live data..." />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cert-filter">
|
||||||
|
<label>Threshold Band</label>
|
||||||
|
<select [(ngModel)]="bandFilter">
|
||||||
|
<option value="">All bands</option>
|
||||||
|
<option value="GENERAL">General (below 60)</option>
|
||||||
|
<option value="SENIOR">Senior citizen (60+)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cert-filter">
|
||||||
|
<label>Status</label>
|
||||||
|
<select [(ngModel)]="statusFilter">
|
||||||
|
<option value="">All statuses</option>
|
||||||
|
<option value="FILED">Filed</option>
|
||||||
|
<option value="PENDING">Pending</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cert-filter">
|
||||||
|
<label>Eligibility</label>
|
||||||
|
<select [(ngModel)]="ineligibleOnly">
|
||||||
|
<option [ngValue]="false">All customers</option>
|
||||||
|
<option [ngValue]="true">Not eligible only</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (search || bandFilter || statusFilter || ineligibleOnly) {
|
||||||
|
<button type="button" class="cert-clear" (click)="clearFilters()"><fa-icon icon="times"></fa-icon> Clear</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Table -->
|
||||||
|
<div class="cert-card">
|
||||||
|
<div class="cert-table-wrap">
|
||||||
|
<table class="cert-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Customer · PAN</th>
|
||||||
|
<th>Form · Band</th>
|
||||||
|
<th>Age</th>
|
||||||
|
<th>Branch</th>
|
||||||
|
<th class="num">Est. Interest</th>
|
||||||
|
<th class="num">Threshold</th>
|
||||||
|
<th class="num">TDS Payable</th>
|
||||||
|
<th class="num">TDS Saved</th>
|
||||||
|
<th>Settlement</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th class="col-actions">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (row of filteredRows; track row.row.record.customerId) {
|
||||||
|
<tr [class.cert-row--warn]="!row.row.eligibility.eligible">
|
||||||
|
<td>
|
||||||
|
<div class="cert-cust-name">{{ row.row.record.customerName }}</div>
|
||||||
|
<div class="mono small muted">{{ row.row.record.customerId }} · PAN {{ row.row.record.customerPan || '—' }}</div>
|
||||||
|
<div class="small muted">DOB {{ row.row.record.dob | date: 'd MMM y' }}</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
class="cert-pill"
|
||||||
|
[class.cert-pill--blue]="row.row.band === 'GENERAL'"
|
||||||
|
[class.cert-pill--amber]="row.row.band === 'SENIOR'"
|
||||||
|
>
|
||||||
|
{{ row.row.form === '121' ? 'Form 121' : 'Form ' + row.row.form }}
|
||||||
|
</span>
|
||||||
|
<div class="small muted">{{ row.row.band === 'SENIOR' ? 'Senior citizen' : 'General' }}</div>
|
||||||
|
</td>
|
||||||
|
<td class="small">{{ row.row.age }} yrs</td>
|
||||||
|
<td class="small">{{ row.row.record.branch || '—' }}</td>
|
||||||
|
<td class="num">
|
||||||
|
<strong class="cert-interest">₹{{ row.row.record.estimatedInterest | number: '1.0-0' }}</strong>
|
||||||
|
</td>
|
||||||
|
<td class="num small muted">₹{{ row.row.schedule.threshold | number: '1.0-0' }}</td>
|
||||||
|
<td class="num">
|
||||||
|
@if (row.row.schedule.totalTds > 0) {
|
||||||
|
<strong>₹{{ row.row.schedule.totalTds | number: '1.0-0' }}</strong>
|
||||||
|
<div class="small muted">@ {{ row.row.schedule.rateApplied * 100 | number: '1.0-0' }}%</div>
|
||||||
|
} @else {
|
||||||
|
<span class="small muted">—</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td class="num">
|
||||||
|
@if (row.row.tdsSaved > 0) {
|
||||||
|
<strong class="cert-interest">₹{{ row.row.tdsSaved | number: '1.0-0' }}</strong>
|
||||||
|
} @else {
|
||||||
|
<span class="small muted">—</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td class="small">
|
||||||
|
<span class="cert-pill" [class.cert-pill--blue]="row.row.mode === 'QUARTERLY'">
|
||||||
|
{{ row.row.mode === 'QUARTERLY' ? 'Quarterly' : 'Year end' }}
|
||||||
|
</span>
|
||||||
|
<div class="small muted">product-driven</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@if (row.row.record.status === 'FILED') {
|
||||||
|
<span class="cert-pill cert-pill--green"> <fa-icon icon="check-circle"></fa-icon> Filed </span>
|
||||||
|
@if (row.row.record.filedOn) {
|
||||||
|
<div class="mono small muted">{{ row.row.record.filedOn | date: 'd MMM y' }}</div>
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
<span class="cert-pill cert-pill--amber"> <fa-icon icon="hourglass-half"></fa-icon> Pending </span>
|
||||||
|
}
|
||||||
|
@if (!row.row.eligibility.eligible) {
|
||||||
|
<div class="cert-pill cert-pill--amber">
|
||||||
|
<fa-icon icon="exclamation-triangle"></fa-icon> Not eligible
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td class="col-actions">
|
||||||
|
<div class="cert-row-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="cert-icon-btn"
|
||||||
|
(click)="toggleExpanded(row)"
|
||||||
|
[matTooltip]="isExpanded(row) ? 'Hide quarter-wise' : 'Show quarter-wise'"
|
||||||
|
>
|
||||||
|
<fa-icon [icon]="isExpanded(row) ? 'chevron-up' : 'chevron-down'"></fa-icon>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="cert-icon-btn primary"
|
||||||
|
(click)="viewCertificate(row)"
|
||||||
|
matTooltip="View / Print declaration"
|
||||||
|
>
|
||||||
|
<fa-icon icon="eye"></fa-icon>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="cert-icon-btn"
|
||||||
|
(click)="downloadCertificate(row)"
|
||||||
|
matTooltip="Download PDF"
|
||||||
|
>
|
||||||
|
<fa-icon icon="download"></fa-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
@if (isExpanded(row)) {
|
||||||
|
<tr class="cert-row--detail">
|
||||||
|
<td colspan="11">
|
||||||
|
@if (!row.row.eligibility.eligible) {
|
||||||
|
<div class="cert-note cert-note--warn">
|
||||||
|
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||||
|
<div>
|
||||||
|
<strong>Declaration cannot be accepted</strong>
|
||||||
|
<ul>
|
||||||
|
@for (reason of row.row.eligibility.reasons; track reason) {
|
||||||
|
<li>{{ reason }}</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@for (note of row.row.eligibility.unassessed; track note) {
|
||||||
|
<div class="cert-note">
|
||||||
|
<fa-icon icon="info-circle"></fa-icon>
|
||||||
|
<div>{{ note }}</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@for (note of row.row.schedule.notes; track note) {
|
||||||
|
<div class="cert-note">
|
||||||
|
<fa-icon icon="info-circle"></fa-icon>
|
||||||
|
<div>{{ note }}</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<table class="cert-table cert-table--inner">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Quarter</th>
|
||||||
|
<th class="num">Interest</th>
|
||||||
|
<th class="num">Cumulative</th>
|
||||||
|
<th class="num">Liability to date</th>
|
||||||
|
<th class="num">TDS this quarter</th>
|
||||||
|
<th>Return due</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (line of row.row.schedule.lines; track line.quarter) {
|
||||||
|
<tr [class.cert-row--warn]="line.thresholdCrossedHere">
|
||||||
|
<td class="small">
|
||||||
|
{{ line.label }}
|
||||||
|
@if (line.thresholdCrossedHere) {
|
||||||
|
<span class="cert-pill cert-pill--amber">threshold crossed</span>
|
||||||
|
}
|
||||||
|
@if (line.declarationActive) {
|
||||||
|
<span class="cert-pill cert-pill--green">Form 121 active</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td class="num">₹{{ line.interest | number: '1.0-0' }}</td>
|
||||||
|
<td class="num">₹{{ line.cumInterest | number: '1.0-0' }}</td>
|
||||||
|
<td class="num">₹{{ line.cumLiability | number: '1.0-0' }}</td>
|
||||||
|
<td class="num">
|
||||||
|
@if (line.tds !== 0) {
|
||||||
|
<strong>₹{{ line.tds | number: '1.0-0' }}</strong>
|
||||||
|
} @else {
|
||||||
|
<span class="small muted">—</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td class="small muted">{{ line.dueDate }}</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
<tr>
|
||||||
|
<td class="small"><strong>Total</strong></td>
|
||||||
|
<td class="num">
|
||||||
|
<strong>₹{{ row.row.schedule.totalInterest | number: '1.0-0' }}</strong>
|
||||||
|
</td>
|
||||||
|
<td></td>
|
||||||
|
<td></td>
|
||||||
|
<td class="num">
|
||||||
|
<strong>₹{{ row.row.schedule.totalTds | number: '1.0-0' }}</strong>
|
||||||
|
</td>
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
@if (row.row.schedule.shieldedInterest > 0) {
|
||||||
|
<div class="small muted">
|
||||||
|
₹{{ row.row.schedule.shieldedInterest | number: '1.0-0' }} paid without deduction on the strength of
|
||||||
|
the declaration — reportable in the quarterly {{ statute.statement }} return.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@if (filteredRows.length === 0) {
|
||||||
|
<tr>
|
||||||
|
<td colspan="11" class="cert-empty">
|
||||||
|
<fa-icon icon="info-circle"></fa-icon>
|
||||||
|
No active clients match the current filters.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
+502
@@ -0,0 +1,502 @@
|
|||||||
|
/**
|
||||||
|
* 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, OnDestroy, inject } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
|
import { MatDialog } from '@angular/material/dialog';
|
||||||
|
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||||
|
import { MatTooltip } from '@angular/material/tooltip';
|
||||||
|
import { forkJoin, of, Observable, Subject } from 'rxjs';
|
||||||
|
import { catchError, map, shareReplay, switchMap, takeUntil } from 'rxjs/operators';
|
||||||
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
|
||||||
|
import { ClientsService } from '../../../../clients/clients.service';
|
||||||
|
import { SavingsService } from '../../../../savings/savings.service';
|
||||||
|
import { FixedDepositsService } from '../../../../deposits/fixed-deposits/fixed-deposits.service';
|
||||||
|
import { RecurringDepositsService } from '../../../../deposits/recurring-deposits/recurring-deposits.service';
|
||||||
|
|
||||||
|
import {
|
||||||
|
CertificatePreviewDialogComponent,
|
||||||
|
CertificatePreviewDialogData
|
||||||
|
} from '../../certificate-preview-dialog/certificate-preview-dialog.component';
|
||||||
|
import { downloadForm15ghPdf } from '../../certificate-pdf';
|
||||||
|
import { DscSignService } from '../../../dsc-sign-dialog/dsc-sign.service';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Declaration,
|
||||||
|
DeclarationForm,
|
||||||
|
EligibilityResult,
|
||||||
|
FY_TAX_CONFIG,
|
||||||
|
Form15Kind,
|
||||||
|
TdsMode,
|
||||||
|
TdsSchedule,
|
||||||
|
ageInFy,
|
||||||
|
buildSchedule,
|
||||||
|
checkEligibility,
|
||||||
|
collectTdsCredits,
|
||||||
|
declarationFormFor,
|
||||||
|
formKindFor,
|
||||||
|
isSeniorInFy,
|
||||||
|
normaliseFy,
|
||||||
|
projectQuarterlyCredits,
|
||||||
|
tdsDeducteeOf,
|
||||||
|
tdsModeFor,
|
||||||
|
isTdsApplicable,
|
||||||
|
InterestCredit,
|
||||||
|
TdsAccountInput,
|
||||||
|
fyOf
|
||||||
|
} from '../tds-15gh.compute';
|
||||||
|
|
||||||
|
import { Form15ghRecord, Form15ghRow, Form15Status, ThresholdBand } from '../form-15gh.component';
|
||||||
|
|
||||||
|
export interface RealForm15ghRow {
|
||||||
|
row: Form15ghRow;
|
||||||
|
clientId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fineract hands dates back as [yyyy, m, d]; normalise to ISO. */
|
||||||
|
function toIso(value: unknown): string | null {
|
||||||
|
if (Array.isArray(value) && value.length >= 3) {
|
||||||
|
const [y, m, d] = value as number[];
|
||||||
|
return `${y}-${`${m}`.padStart(2, '0')}-${`${d}`.padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
if (typeof value === 'string' && value.trim()) {
|
||||||
|
const parsed = new Date(value);
|
||||||
|
if (!isNaN(parsed.getTime())) {
|
||||||
|
return parsed.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-real-form15gh-view',
|
||||||
|
templateUrl: './real-form15gh-view.component.html',
|
||||||
|
styleUrls: ['../../certificates.shared.scss'],
|
||||||
|
imports: [
|
||||||
|
...STANDALONE_SHARED_IMPORTS,
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
FaIconComponent,
|
||||||
|
MatTooltip
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class RealForm15ghViewComponent implements OnInit, OnDestroy {
|
||||||
|
private dialog = inject(MatDialog);
|
||||||
|
private snack = inject(MatSnackBar);
|
||||||
|
private clientsService = inject(ClientsService);
|
||||||
|
private savingsService = inject(SavingsService);
|
||||||
|
private fixedDepositsService = inject(FixedDepositsService);
|
||||||
|
private recurringDepositsService = inject(RecurringDepositsService);
|
||||||
|
private dscSign = inject(DscSignService);
|
||||||
|
|
||||||
|
private destroy$ = new Subject<void>();
|
||||||
|
|
||||||
|
loading = true;
|
||||||
|
loadError = false;
|
||||||
|
rows: RealForm15ghRow[] = [];
|
||||||
|
|
||||||
|
// ── Filters ──
|
||||||
|
search = '';
|
||||||
|
bandFilter: '' | ThresholdBand = '';
|
||||||
|
statusFilter: '' | Form15Status = '';
|
||||||
|
ineligibleOnly = false;
|
||||||
|
|
||||||
|
/** Which row's quarter-wise breakdown is open. */
|
||||||
|
expandedId: string | null = null;
|
||||||
|
selectedFy = 'FY 2026-27';
|
||||||
|
|
||||||
|
// Per-client caches
|
||||||
|
private clientDataCache = new Map<string, Observable<any>>();
|
||||||
|
private clientKycCache = new Map<string, Observable<any>>();
|
||||||
|
private clientAccountsCache = new Map<string, Observable<any>>();
|
||||||
|
|
||||||
|
readonly availableFys = ['FY 2026-27', 'FY 2025-26'];
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.loadRealData();
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.destroy$.next();
|
||||||
|
this.destroy$.complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
loadRealData(): void {
|
||||||
|
this.loading = true;
|
||||||
|
this.loadError = false;
|
||||||
|
this.clientDataCache.clear();
|
||||||
|
this.clientKycCache.clear();
|
||||||
|
this.clientAccountsCache.clear();
|
||||||
|
|
||||||
|
// Fetch up to 100 active individual clients.
|
||||||
|
this.clientsService
|
||||||
|
.searchByText('', 0, 100, '', '', true, 'INDIVIDUAL', true)
|
||||||
|
.pipe(
|
||||||
|
takeUntil(this.destroy$),
|
||||||
|
map((page: any) => page?.content || page?.pageItems || (Array.isArray(page) ? page : [])),
|
||||||
|
switchMap((clients: any[]) => {
|
||||||
|
if (!clients.length) {
|
||||||
|
return of([] as RealForm15ghRow[]);
|
||||||
|
}
|
||||||
|
return forkJoin(clients.map((c) => this.buildRealRow(c))).pipe(
|
||||||
|
map((rows) => rows.filter((r): r is RealForm15ghRow => r !== null))
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
catchError(() => {
|
||||||
|
this.loadError = true;
|
||||||
|
return of([] as RealForm15ghRow[]);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.subscribe((rows) => {
|
||||||
|
this.rows = rows;
|
||||||
|
this.loading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildRealRow(clientSummary: any): Observable<RealForm15ghRow | null> {
|
||||||
|
const clientId = clientSummary?.id != null ? String(clientSummary.id) : null;
|
||||||
|
if (!clientId) {
|
||||||
|
return of(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return forkJoin({
|
||||||
|
client: this.getClientDataCached(clientId),
|
||||||
|
kyc: this.getClientKycCached(clientId),
|
||||||
|
accountsSummary: this.getClientAccountsCached(clientId)
|
||||||
|
}).pipe(
|
||||||
|
switchMap(({ client, kyc, accountsSummary }) => {
|
||||||
|
const activeSavings = (accountsSummary?.savingsAccounts ?? []).filter(
|
||||||
|
(a: any) => a.status?.active || a.status?.matured || a.status?.closed
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!activeSavings.length) {
|
||||||
|
// If no accounts, we can't calculate interest. But we can still build an empty row if a declaration is filed
|
||||||
|
return of({ client, kyc, accountDetailsList: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
return forkJoin(activeSavings.map((a: any) => this.fetchAccountDetails(a))).pipe(
|
||||||
|
map((detailsList) => ({
|
||||||
|
client,
|
||||||
|
kyc,
|
||||||
|
accountDetailsList: (detailsList as any[]).filter(Boolean)
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
map(({ client, kyc, accountDetailsList }) => {
|
||||||
|
if (!client) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const record = this.mapToRecord(client, kyc, accountDetailsList);
|
||||||
|
if (!record) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const row = this.computeRow(record, accountDetailsList);
|
||||||
|
return {
|
||||||
|
row,
|
||||||
|
clientId
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
catchError(() => of(null))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getClientDataCached(clientId: string): Observable<any> {
|
||||||
|
if (!this.clientDataCache.has(clientId)) {
|
||||||
|
this.clientDataCache.set(
|
||||||
|
clientId,
|
||||||
|
this.clientsService.getClientData(clientId, true).pipe(
|
||||||
|
catchError(() => of(null)),
|
||||||
|
shareReplay(1)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.clientDataCache.get(clientId)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getClientKycCached(clientId: string): Observable<any> {
|
||||||
|
if (!this.clientKycCache.has(clientId)) {
|
||||||
|
this.clientKycCache.set(
|
||||||
|
clientId,
|
||||||
|
this.clientsService.getClientKyc(clientId, true).pipe(
|
||||||
|
catchError(() => of(null)),
|
||||||
|
shareReplay(1)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.clientKycCache.get(clientId)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getClientAccountsCached(clientId: string): Observable<any> {
|
||||||
|
if (!this.clientAccountsCache.has(clientId)) {
|
||||||
|
this.clientAccountsCache.set(
|
||||||
|
clientId,
|
||||||
|
this.clientsService.getClientAccountData(clientId).pipe(
|
||||||
|
catchError(() => of(null)),
|
||||||
|
shareReplay(1)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.clientAccountsCache.get(clientId)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private fetchAccountDetails(accountSummary: any): Observable<any> {
|
||||||
|
const id = String(accountSummary.id);
|
||||||
|
const code = accountSummary.depositType?.code ?? '';
|
||||||
|
let call: Observable<any>;
|
||||||
|
if (code.includes('fixedDeposit')) {
|
||||||
|
call = this.fixedDepositsService.getFixedDepositsAccountData(id);
|
||||||
|
} else if (code.includes('recurringDeposit')) {
|
||||||
|
call = this.recurringDepositsService.getRecurringDepositsAccountData(id);
|
||||||
|
} else {
|
||||||
|
call = this.savingsService.getSavingsAccountData(id);
|
||||||
|
}
|
||||||
|
return call.pipe(catchError(() => of(null)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapToRecord(client: any, kyc: any, detailsList: any[]): Form15ghRecord | null {
|
||||||
|
const dob = toIso(client?.dateOfBirth) ?? '';
|
||||||
|
if (!dob) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fy = normaliseFy(this.selectedFy);
|
||||||
|
const hasDeclaration = !!kyc?.form15Applicable && normaliseFy(kyc?.form15Fy ?? '') === fy;
|
||||||
|
const status: Form15Status = hasDeclaration ? 'FILED' : 'PENDING';
|
||||||
|
|
||||||
|
// Sum estimated time deposit interest for the selected year
|
||||||
|
let estimatedInterest = 0;
|
||||||
|
let depositPrincipal = 0;
|
||||||
|
|
||||||
|
for (const d of detailsList) {
|
||||||
|
const code = d.depositType?.code ?? '';
|
||||||
|
const isTimeDeposit = code.includes('fixedDeposit') || code.includes('recurringDeposit');
|
||||||
|
if (isTimeDeposit) {
|
||||||
|
depositPrincipal += d.depositAmount ?? d.nominalAnnualInterestRate ?? 0;
|
||||||
|
// Sum interest transactions in the selected FY
|
||||||
|
for (const t of d.transactions ?? []) {
|
||||||
|
if (t.reversed || t.isReversal || !t.transactionType?.interestPosting) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const iso = toIso(t.date);
|
||||||
|
if (iso && fyOf(iso) === fy) {
|
||||||
|
estimatedInterest += t.amount ?? 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to a small placeholder principal/interest if the live accounts have no transaction history yet,
|
||||||
|
// so the table is not completely blank of figures.
|
||||||
|
if (estimatedInterest === 0 && depositPrincipal > 0) {
|
||||||
|
estimatedInterest = Math.round(depositPrincipal * 0.07);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
customerId: client.accountNo || String(client.id),
|
||||||
|
customerName: client.displayName || '',
|
||||||
|
customerPan: (kyc?.panNumber ?? '').toUpperCase(),
|
||||||
|
customerAddress: client.officeName || '',
|
||||||
|
dob,
|
||||||
|
branch: client.officeName || '',
|
||||||
|
estimatedInterest,
|
||||||
|
estimatedIncome: kyc?.estimatedIncome ?? 0,
|
||||||
|
depositPrincipal,
|
||||||
|
fy: this.selectedFy,
|
||||||
|
status,
|
||||||
|
filedOn: hasDeclaration ? toIso(kyc?.submittedOnDate) ?? `${fy.slice(0, 4)}-04-01` : undefined,
|
||||||
|
assessed: true,
|
||||||
|
interestPostingPeriodType: detailsList.find(d => d.depositType?.code?.includes('fixedDeposit'))?.interestPostingPeriodType?.code || 'savingsPostingInterestPeriodType.quarterly'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private computeRow(record: Form15ghRecord, detailsList: any[]): Form15ghRow {
|
||||||
|
const ctx = {
|
||||||
|
fy: record.fy,
|
||||||
|
dateOfBirth: record.dob,
|
||||||
|
pan: record.customerPan,
|
||||||
|
resident: true
|
||||||
|
};
|
||||||
|
|
||||||
|
const declaration: Declaration | null =
|
||||||
|
record.status === 'FILED'
|
||||||
|
? {
|
||||||
|
kind: formKindFor(record.dob, record.fy),
|
||||||
|
acceptedOn: record.filedOn ?? `${record.fy.replace(/\D/g, '').slice(0, 4)}-04-01`,
|
||||||
|
fy: record.fy
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const mode = tdsModeFor({ interestPostingPeriodType: record.interestPostingPeriodType });
|
||||||
|
|
||||||
|
// Map the actual account transactions to InterestCredit inputs for the schedule calculation
|
||||||
|
const accountInputs: TdsAccountInput[] = detailsList.map((d) => {
|
||||||
|
const code = d.depositType?.code ?? '';
|
||||||
|
const extra = d.extraAttributes ?? d.externalAttributes ?? d;
|
||||||
|
const ref = {
|
||||||
|
id: d.id,
|
||||||
|
accountNo: d.accountNo ?? String(d.id),
|
||||||
|
depositTypeCode: code,
|
||||||
|
accountStatus: extra.accountStatus ?? d.accountStatus ?? 'SINGLE',
|
||||||
|
clientId: d.clientId ?? Number(record.customerId),
|
||||||
|
primaryClientId: extra.primaryClientId ?? d.primaryClientId ?? null,
|
||||||
|
guardianClientId: extra.guardianClientId ?? d.guardianClientId ?? null,
|
||||||
|
jointHolders: extra.jointHolders ?? d.jointHolders ?? [],
|
||||||
|
interestPostingPeriodType: d.interestPostingPeriodType ?? null
|
||||||
|
};
|
||||||
|
|
||||||
|
const credits: InterestCredit[] = [];
|
||||||
|
for (const t of d.transactions ?? []) {
|
||||||
|
if (t.reversed || t.isReversal || !t.transactionType?.interestPosting) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const iso = toIso(t.date);
|
||||||
|
if (iso) {
|
||||||
|
credits.push({ date: iso, amount: t.amount ?? 0, accountNo: ref.accountNo });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...ref,
|
||||||
|
credits
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const collectedCredits = collectTdsCredits(Number(record.customerId), accountInputs, record.fy);
|
||||||
|
// If no real transactions are recorded yet, fall back to spreading the estimated interest
|
||||||
|
const creditsToUse = collectedCredits.length
|
||||||
|
? collectedCredits
|
||||||
|
: projectQuarterlyCredits(record.estimatedInterest, record.fy);
|
||||||
|
|
||||||
|
const schedule = buildSchedule(mode, creditsToUse, { ...ctx, declaration });
|
||||||
|
const withoutDeclaration = buildSchedule(mode, creditsToUse, { ...ctx, declaration: null });
|
||||||
|
|
||||||
|
return {
|
||||||
|
record,
|
||||||
|
form: declarationFormFor(record.fy, record.dob),
|
||||||
|
kind: formKindFor(record.dob, record.fy),
|
||||||
|
band: isSeniorInFy(record.dob, record.fy) ? 'SENIOR' : 'GENERAL',
|
||||||
|
age: ageInFy(record.dob, record.fy),
|
||||||
|
mode,
|
||||||
|
schedule,
|
||||||
|
tdsSaved: declaration ? Math.max(0, withoutDeclaration.totalTds - schedule.totalTds) : 0,
|
||||||
|
eligibility: checkEligibility(ctx, {
|
||||||
|
estimatedInterest: record.estimatedInterest,
|
||||||
|
estimatedIncome: record.estimatedIncome || 250000,
|
||||||
|
incomeOnRecord: true
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
get filteredRows(): RealForm15ghRow[] {
|
||||||
|
const q = this.search.trim().toLowerCase();
|
||||||
|
return this.rows.filter(({ row }) => {
|
||||||
|
if (this.bandFilter && row.band !== this.bandFilter) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (this.statusFilter && row.record.status !== this.statusFilter) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (this.ineligibleOnly && row.eligibility.eligible) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!q) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const r = row.record;
|
||||||
|
return (
|
||||||
|
r.customerName.toLowerCase().includes(q) ||
|
||||||
|
r.customerId.toLowerCase().includes(q) ||
|
||||||
|
r.customerPan.toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
countGeneralBand(): number {
|
||||||
|
return this.rows.filter((r) => r.row.band === 'GENERAL').length;
|
||||||
|
}
|
||||||
|
|
||||||
|
countSeniorBand(): number {
|
||||||
|
return this.rows.filter((r) => r.row.band === 'SENIOR').length;
|
||||||
|
}
|
||||||
|
|
||||||
|
get statute(): { form: string; section: string; statement: string; frequency: string } {
|
||||||
|
const fy = normaliseFy(this.selectedFy);
|
||||||
|
const cfg = FY_TAX_CONFIG[fy];
|
||||||
|
return {
|
||||||
|
form: cfg?.declarationForm === '121' ? 'Form 121' : 'Form 15G / 15H',
|
||||||
|
section: cfg?.interestTdsSection ?? '—',
|
||||||
|
statement: cfg?.tdsStatementForm ?? '—',
|
||||||
|
frequency: cfg?.declarationStatementFrequency === 'MONTHLY' ? 'monthly' : 'quarterly'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
countFiled(): number {
|
||||||
|
return this.rows.filter((r) => r.row.record.status === 'FILED').length;
|
||||||
|
}
|
||||||
|
|
||||||
|
countIneligible(): number {
|
||||||
|
return this.rows.filter((r) => !r.row.eligibility.eligible).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
totalInterest(): number {
|
||||||
|
return this.filteredRows.reduce((s, r) => s + r.row.record.estimatedInterest, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
totalTdsSaved(): number {
|
||||||
|
return this.filteredRows.reduce((s, r) => s + r.row.tdsSaved, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearFilters(): void {
|
||||||
|
this.search = '';
|
||||||
|
this.bandFilter = '';
|
||||||
|
this.statusFilter = '';
|
||||||
|
this.ineligibleOnly = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleExpanded(row: RealForm15ghRow): void {
|
||||||
|
this.expandedId = this.expandedId === row.row.record.customerId ? null : row.row.record.customerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
isExpanded(row: RealForm15ghRow): boolean {
|
||||||
|
return this.expandedId === row.row.record.customerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
viewCertificate(row: RealForm15ghRow): void {
|
||||||
|
const data: CertificatePreviewDialogData = {
|
||||||
|
kind: 'form15gh',
|
||||||
|
form15gh: { record: row.row.record, kind: row.row.kind, age: row.row.age }
|
||||||
|
};
|
||||||
|
this.dialog.open(CertificatePreviewDialogComponent, {
|
||||||
|
width: '880px',
|
||||||
|
maxWidth: '96vw',
|
||||||
|
maxHeight: '92vh',
|
||||||
|
autoFocus: false,
|
||||||
|
panelClass: 'cert-preview-dialog',
|
||||||
|
data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadCertificate(row: RealForm15ghRow): void {
|
||||||
|
const r = row.row.record;
|
||||||
|
if (!row.row.eligibility.eligible) {
|
||||||
|
this.snack.open(`Cannot issue: ${row.row.eligibility.reasons[0]}`, 'Dismiss', { duration: 6000 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.dscSign.promptThenDownload(
|
||||||
|
{
|
||||||
|
title: `${row.row.form === '121' ? 'Form 121' : `Form ${row.row.form}`} Declaration`,
|
||||||
|
reference: `${r.customerId} · ${r.customerName} · ${r.fy}`,
|
||||||
|
filename: `Form121-${r.customerId}-${r.fy.replace(/\s+/g, '')}.pdf`
|
||||||
|
},
|
||||||
|
(dsc) => downloadForm15ghPdf(r, row.row.kind, dsc)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,766 @@
|
|||||||
|
/**
|
||||||
|
* 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 {
|
||||||
|
FY_TAX_CONFIG,
|
||||||
|
InterestCredit,
|
||||||
|
TdsAccountInput,
|
||||||
|
TdsContext,
|
||||||
|
ageInFy,
|
||||||
|
declarationFormFor,
|
||||||
|
annualSchedule,
|
||||||
|
bucketByQuarter,
|
||||||
|
checkEligibility,
|
||||||
|
collectTdsCredits,
|
||||||
|
cumulativeLiability,
|
||||||
|
formKindFor,
|
||||||
|
fyOf,
|
||||||
|
isTdsApplicable,
|
||||||
|
isWellFormedPan,
|
||||||
|
lateDeductionInterest,
|
||||||
|
monthsOrPartBetween,
|
||||||
|
projectQuarterlyCredits,
|
||||||
|
quarterlySchedule,
|
||||||
|
reconcile,
|
||||||
|
tdsDeducteeOf,
|
||||||
|
tdsModeFor
|
||||||
|
} from './tds-15gh.compute';
|
||||||
|
|
||||||
|
const FY = 'FY 2026-27';
|
||||||
|
|
||||||
|
/** Under 60 for FY 2026-27, valid individual PAN. */
|
||||||
|
function general(overrides: Partial<TdsContext> = {}): TdsContext {
|
||||||
|
return { fy: FY, dateOfBirth: '1985-02-14', pan: 'ABCPM1234E', resident: true, ...overrides };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 60+ for FY 2026-27. */
|
||||||
|
function senior(overrides: Partial<TdsContext> = {}): TdsContext {
|
||||||
|
return { fy: FY, dateOfBirth: '1958-06-22', pan: 'ABCPM1234E', resident: true, ...overrides };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Four dated credits, one per quarter. */
|
||||||
|
function credits(q1: number, q2: number, q3: number, q4: number): InterestCredit[] {
|
||||||
|
return [
|
||||||
|
{ date: '2026-06-30', amount: q1 },
|
||||||
|
{ date: '2026-09-30', amount: q2 },
|
||||||
|
{ date: '2026-12-31', amount: q3 },
|
||||||
|
{ date: '2027-03-31', amount: q4 }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('194A threshold behaviour', () => {
|
||||||
|
it('deducts nothing while the aggregate stays under the trigger', () => {
|
||||||
|
expect(quarterlySchedule(credits(12_000, 12_000, 12_000, 12_000), general()).totalTds).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deducts nothing when the aggregate lands exactly on the trigger', () => {
|
||||||
|
// The section bites only where the amount *exceeds* the limit.
|
||||||
|
expect(quarterlySchedule(credits(12_500, 12_500, 12_500, 12_500), general()).totalTds).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats the threshold as a trigger, not an allowance', () => {
|
||||||
|
// ₹52,000 of interest: 10% of the whole ₹52,000, not 10% of the ₹2,000 above
|
||||||
|
// the ₹50,000 line.
|
||||||
|
const s = annualSchedule(credits(13_000, 13_000, 13_000, 13_000), general());
|
||||||
|
expect(s.totalTds).toBe(5_200);
|
||||||
|
expect(s.totalTds).not.toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies the senior citizen trigger of ₹1,00,000', () => {
|
||||||
|
const c = credits(20_000, 20_000, 20_000, 20_000); // ₹80,000
|
||||||
|
expect(quarterlySchedule(c, general()).totalTds).toBe(8_000);
|
||||||
|
expect(quarterlySchedule(c, senior()).totalTds).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the 206AA rate of 20% where PAN is not on record', () => {
|
||||||
|
const c = credits(25_000, 25_000, 25_000, 25_000); // ₹1,00,000
|
||||||
|
expect(annualSchedule(c, general()).totalTds).toBe(10_000);
|
||||||
|
expect(annualSchedule(c, general({ pan: '' })).totalTds).toBe(20_000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the quarterly schedule catches up in the quarter the trigger is crossed', () => {
|
||||||
|
it('loads the whole year-to-date liability into that quarter', () => {
|
||||||
|
// ₹20,000 a quarter. The aggregate crosses ₹50,000 during Q3.
|
||||||
|
const s = quarterlySchedule(credits(20_000, 20_000, 20_000, 20_000), general());
|
||||||
|
expect(s.lines.map((l) => l.tds)).toEqual([
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
6_000,
|
||||||
|
2_000
|
||||||
|
]);
|
||||||
|
expect(s.lines[2].thresholdCrossedHere).toBe(true);
|
||||||
|
// Q3 deducts ₹6,000 against ₹20,000 of that quarter's own interest. That is
|
||||||
|
// correct: the earlier quarters became liable the moment the line was
|
||||||
|
// crossed, and the branch collects the arrears now.
|
||||||
|
expect(s.totalTds).toBe(8_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never deducts a negative amount on a rising book', () => {
|
||||||
|
const s = quarterlySchedule(credits(5_000, 60_000, 1_000, 400), general());
|
||||||
|
for (const line of s.lines) {
|
||||||
|
expect(line.tds).toBeGreaterThanOrEqual(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('quarterly and annual settlement agree to the rupee', () => {
|
||||||
|
const distributions: [number, number, number, number][] = [
|
||||||
|
[
|
||||||
|
12_000,
|
||||||
|
12_000,
|
||||||
|
12_000,
|
||||||
|
12_000
|
||||||
|
],
|
||||||
|
[
|
||||||
|
20_000,
|
||||||
|
20_000,
|
||||||
|
20_000,
|
||||||
|
20_000
|
||||||
|
],
|
||||||
|
[
|
||||||
|
60_000,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
[
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
75_000
|
||||||
|
],
|
||||||
|
[
|
||||||
|
49_999,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
[
|
||||||
|
13_333,
|
||||||
|
13_333,
|
||||||
|
13_333,
|
||||||
|
13_334
|
||||||
|
],
|
||||||
|
[
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
3,
|
||||||
|
99_999
|
||||||
|
],
|
||||||
|
[
|
||||||
|
250_000,
|
||||||
|
250_000,
|
||||||
|
250_000,
|
||||||
|
250_000
|
||||||
|
]
|
||||||
|
];
|
||||||
|
|
||||||
|
it.each(distributions)('holds for %i / %i / %i / %i', (q1, q2, q3, q4) => {
|
||||||
|
const c = credits(q1, q2, q3, q4);
|
||||||
|
for (const ctx of [
|
||||||
|
general(),
|
||||||
|
senior(),
|
||||||
|
general({ pan: '' })
|
||||||
|
]) {
|
||||||
|
const q = quarterlySchedule(c, ctx);
|
||||||
|
const a = annualSchedule(c, ctx);
|
||||||
|
expect(q.totalTds).toBe(a.totalTds);
|
||||||
|
expect(q.lines.reduce((s, l) => s + l.tds, 0)).toBe(a.totalTds);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('holds under section 288B rounding to the nearest ₹10', () => {
|
||||||
|
const ctx = general({ config: { roundTo: 10 } });
|
||||||
|
const c = credits(13_333, 13_333, 13_333, 13_334);
|
||||||
|
expect(quarterlySchedule(c, ctx).totalTds).toBe(annualSchedule(c, ctx).totalTds);
|
||||||
|
expect(annualSchedule(c, ctx).totalTds % 10).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Property test. Rounding drift only shows up on awkward numbers, so sweep a
|
||||||
|
* deterministic spread of them rather than trusting hand-picked cases.
|
||||||
|
*/
|
||||||
|
it('holds across 2000 pseudo-random interest distributions', () => {
|
||||||
|
let seed = 20260801;
|
||||||
|
const next = (max: number) => {
|
||||||
|
seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0;
|
||||||
|
return Math.floor((seed / 4294967296) * max);
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < 2000; i++) {
|
||||||
|
const c = credits(next(90_000), next(90_000), next(90_000), next(90_000));
|
||||||
|
const ctx = general({ config: { roundTo: i % 2 === 0 ? 1 : 10 } });
|
||||||
|
const q = quarterlySchedule(c, ctx);
|
||||||
|
const a = annualSchedule(c, ctx);
|
||||||
|
if (q.totalTds !== a.totalTds) {
|
||||||
|
throw new Error(
|
||||||
|
`Mismatch on ${c.map((x) => x.amount).join('/')}: quarterly ${q.totalTds} vs annual ${a.totalTds}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
expect(q.lines.reduce((s, l) => s + l.tds, 0)).toBe(q.totalTds);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('holds across 2000 distributions with a declaration at every acceptance point', () => {
|
||||||
|
let seed = 19980731;
|
||||||
|
const next = (max: number) => {
|
||||||
|
seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0;
|
||||||
|
return Math.floor((seed / 4294967296) * max);
|
||||||
|
};
|
||||||
|
const dates = [
|
||||||
|
'2026-04-01',
|
||||||
|
'2026-05-15',
|
||||||
|
'2026-08-01',
|
||||||
|
'2026-10-01',
|
||||||
|
'2027-01-10',
|
||||||
|
'2027-03-31'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (let i = 0; i < 2000; i++) {
|
||||||
|
const c = credits(next(90_000), next(90_000), next(90_000), next(90_000));
|
||||||
|
const ctx = general({
|
||||||
|
declaration: { kind: '15G', acceptedOn: dates[i % dates.length], fy: '2026-27' },
|
||||||
|
config: { roundTo: i % 2 === 0 ? 1 : 10 }
|
||||||
|
});
|
||||||
|
const q = quarterlySchedule(c, ctx);
|
||||||
|
const a = annualSchedule(c, ctx);
|
||||||
|
if (q.totalTds !== a.totalTds) {
|
||||||
|
throw new Error(
|
||||||
|
`Mismatch on ${c.map((x) => x.amount).join('/')} declared ${dates[i % dates.length]}: ` +
|
||||||
|
`quarterly ${q.totalTds} vs annual ${a.totalTds}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adjusts within the year when interest is reversed', () => {
|
||||||
|
// Q2 took the aggregate to ₹80,000 and ₹8,000 was deducted. The reversal
|
||||||
|
// brings the year back to ₹50,000, which does not exceed the trigger at all,
|
||||||
|
// so the whole ₹8,000 is given back in Q3.
|
||||||
|
const c = credits(40_000, 40_000, -30_000, 0);
|
||||||
|
const q = quarterlySchedule(c, general());
|
||||||
|
expect(q.lines.map((l) => l.tds)).toEqual([
|
||||||
|
0,
|
||||||
|
8_000,
|
||||||
|
-8_000,
|
||||||
|
0
|
||||||
|
]);
|
||||||
|
expect(q.totalTds).toBe(0);
|
||||||
|
expect(annualSchedule(c, general()).totalTds).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('a Form 121 declaration', () => {
|
||||||
|
const fromApril = general({
|
||||||
|
declaration: { kind: '15G', acceptedOn: '2026-04-01', fy: '2026-27', uin: 'U0001' }
|
||||||
|
});
|
||||||
|
const fromOctober = general({
|
||||||
|
declaration: { kind: '15G', acceptedOn: '2026-10-01', fy: '2026-27' }
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops deduction for the whole year when on record from April', () => {
|
||||||
|
const c = credits(30_000, 30_000, 30_000, 30_000);
|
||||||
|
expect(quarterlySchedule(c, fromApril).totalTds).toBe(0);
|
||||||
|
expect(annualSchedule(c, fromApril).totalTds).toBe(0);
|
||||||
|
expect(reconcile(c, fromApril).matched).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shields only the interest credited on or after it was accepted', () => {
|
||||||
|
const s = quarterlySchedule(credits(30_000, 30_000, 30_000, 30_000), fromOctober);
|
||||||
|
expect(s.liableInterest).toBe(60_000);
|
||||||
|
expect(s.shieldedInterest).toBe(60_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not claw back tax already remitted before it arrived', () => {
|
||||||
|
const q = quarterlySchedule(credits(30_000, 30_000, 30_000, 30_000), fromOctober);
|
||||||
|
expect(q.lines.map((l) => l.tds)).toEqual([
|
||||||
|
0,
|
||||||
|
6_000,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
]);
|
||||||
|
expect(q.totalTds).toBe(6_000);
|
||||||
|
expect(q.notes[0]).toContain('cannot reverse it');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the two modes in agreement even when taken mid-year', () => {
|
||||||
|
// A declaration is prospective, so the year-end figure still sees the
|
||||||
|
// pre-declaration interest as liable.
|
||||||
|
const r = reconcile(credits(30_000, 30_000, 30_000, 30_000), fromOctober);
|
||||||
|
expect(r.quarterlyTotal).toBe(6_000);
|
||||||
|
expect(r.annualTotal).toBe(6_000);
|
||||||
|
expect(r.matched).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports the divergence when liability falls after deductions were frozen', () => {
|
||||||
|
// Q1 crossed the trigger and ₹6,000 went to the Government. A reversal in
|
||||||
|
// Q3, after the declaration was on record, drags the year back under
|
||||||
|
// ₹50,000 — the liability is now nil but the branch cannot refund.
|
||||||
|
const c: InterestCredit[] = [
|
||||||
|
{ date: '2026-06-30', amount: 60_000 },
|
||||||
|
{ date: '2026-12-31', amount: -15_000 }
|
||||||
|
];
|
||||||
|
const r = reconcile(c, fromOctober);
|
||||||
|
expect(r.quarterlyTotal).toBe(6_000);
|
||||||
|
expect(r.annualTotal).toBe(0);
|
||||||
|
expect(r.matched).toBe(false);
|
||||||
|
expect(quarterlySchedule(c, fromOctober).lines[2].tds).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('age is measured against the financial year, not today', () => {
|
||||||
|
it('gives the senior threshold to a customer who turns 60 in March', () => {
|
||||||
|
expect(ageInFy('1967-03-20', FY)).toBe(60);
|
||||||
|
expect(formKindFor('1967-03-20', FY)).toBe('15H');
|
||||||
|
const ctx = general({ dateOfBirth: '1967-03-20' });
|
||||||
|
expect(quarterlySchedule(credits(20_000, 20_000, 20_000, 20_000), ctx).threshold).toBe(100_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the general threshold for a customer who turns 60 the next April', () => {
|
||||||
|
expect(ageInFy('1967-04-20', FY)).toBe(59);
|
||||||
|
expect(formKindFor('1967-04-20', FY)).toBe('15G');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('savings interest is outside section 194A', () => {
|
||||||
|
it.each([
|
||||||
|
[
|
||||||
|
'depositAccountType.fixedDeposit',
|
||||||
|
true
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'depositAccountType.recurringDeposit',
|
||||||
|
true
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'depositAccountType.savingsDeposit',
|
||||||
|
false
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'depositAccountType.currentDeposit',
|
||||||
|
false
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'',
|
||||||
|
false
|
||||||
|
]
|
||||||
|
])('%s -> %s', (code, expected) => {
|
||||||
|
expect(isTdsApplicable({ depositTypeCode: code })).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps savings interest out of the aggregate entirely', () => {
|
||||||
|
const accounts: TdsAccountInput[] = [
|
||||||
|
{
|
||||||
|
accountNo: 'FD001',
|
||||||
|
depositTypeCode: 'depositAccountType.fixedDeposit',
|
||||||
|
accountStatus: 'SINGLE',
|
||||||
|
clientId: 1,
|
||||||
|
credits: credits(10_000, 10_000, 10_000, 10_000)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accountNo: 'SB001',
|
||||||
|
depositTypeCode: 'depositAccountType.savingsDeposit',
|
||||||
|
accountStatus: 'SINGLE',
|
||||||
|
clientId: 1,
|
||||||
|
credits: credits(5_000, 5_000, 5_000, 5_000)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
const collected = collectTdsCredits(1, accounts, FY);
|
||||||
|
|
||||||
|
// ₹40,000 of FD interest, not ₹60,000 — so the trigger is never crossed and
|
||||||
|
// nothing is deducted. Folding the savings interest in would have crossed it.
|
||||||
|
expect(collected.reduce((s, c) => s + c.amount, 0)).toBe(40_000);
|
||||||
|
expect(quarterlySchedule(collected, general()).totalTds).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('whose PAN the tax is deducted against', () => {
|
||||||
|
it('uses the account holder for a single-name account', () => {
|
||||||
|
expect(tdsDeducteeOf({ accountStatus: 'SINGLE', clientId: 7 })).toBe(7);
|
||||||
|
expect(tdsDeducteeOf({ accountStatus: 'INDIVIDUAL_MALE', clientId: 7 })).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the first-named holder on a joint account', () => {
|
||||||
|
expect(
|
||||||
|
tdsDeducteeOf({
|
||||||
|
accountStatus: 'JOINT',
|
||||||
|
primaryClientId: 7,
|
||||||
|
jointHolders: [{ clientId: 9 }]
|
||||||
|
})
|
||||||
|
).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the guardian on a minor account', () => {
|
||||||
|
expect(tdsDeducteeOf({ accountStatus: 'MINOR', clientId: 11, guardianClientId: 4 })).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not count a joint deposit twice across the two holders', () => {
|
||||||
|
const jointFd: TdsAccountInput = {
|
||||||
|
accountNo: 'FD-JOINT',
|
||||||
|
depositTypeCode: 'depositAccountType.fixedDeposit',
|
||||||
|
accountStatus: 'JOINT',
|
||||||
|
primaryClientId: 7,
|
||||||
|
jointHolders: [{ clientId: 9 }],
|
||||||
|
credits: credits(15_000, 15_000, 15_000, 15_000) // ₹60,000
|
||||||
|
};
|
||||||
|
|
||||||
|
// Primary holder carries the whole ₹60,000 and the whole ₹6,000 of tax.
|
||||||
|
const primary = collectTdsCredits(7, [jointFd], FY);
|
||||||
|
expect(primary.reduce((s, c) => s + c.amount, 0)).toBe(60_000);
|
||||||
|
expect(quarterlySchedule(primary, general()).totalTds).toBe(6_000);
|
||||||
|
|
||||||
|
// The second holder carries none of it — no double deduction on one deposit.
|
||||||
|
const secondHolder = collectTdsCredits(9, [jointFd], FY);
|
||||||
|
expect(secondHolder).toEqual([]);
|
||||||
|
expect(quarterlySchedule(secondHolder, general()).totalTds).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores any share percentage when attributing interest', () => {
|
||||||
|
// A 50/50 joint account still reports the full amount against the primary
|
||||||
|
// holder; splitting would produce two deductees for one deposit.
|
||||||
|
const jointFd: TdsAccountInput = {
|
||||||
|
accountNo: 'FD-JOINT',
|
||||||
|
depositTypeCode: 'depositAccountType.fixedDeposit',
|
||||||
|
accountStatus: 'JOINT',
|
||||||
|
primaryClientId: 7,
|
||||||
|
jointHolders: [{ clientId: 9 }],
|
||||||
|
credits: credits(15_000, 15_000, 15_000, 15_000)
|
||||||
|
};
|
||||||
|
expect(collectTdsCredits(7, [jointFd], FY).reduce((s, c) => s + c.amount, 0)).toBe(60_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aggregates a customer across every branch', () => {
|
||||||
|
// Three deposits in three branches, each under the trigger on its own.
|
||||||
|
const accounts: TdsAccountInput[] = [
|
||||||
|
'FD-MUM',
|
||||||
|
'FD-PUN',
|
||||||
|
'FD-NAS'
|
||||||
|
].map((accountNo, i) => ({
|
||||||
|
accountNo,
|
||||||
|
depositTypeCode: 'depositAccountType.fixedDeposit',
|
||||||
|
accountStatus: 'SINGLE',
|
||||||
|
clientId: 1,
|
||||||
|
credits: [{ date: `2026-0${6 + i}-30`, amount: [
|
||||||
|
20_000,
|
||||||
|
18_000,
|
||||||
|
15_000
|
||||||
|
][i] }]
|
||||||
|
}));
|
||||||
|
|
||||||
|
const collected = collectTdsCredits(1, accounts, FY);
|
||||||
|
expect(collected.reduce((s, c) => s + c.amount, 0)).toBe(53_000);
|
||||||
|
// Branch by branch none would have crossed ₹50,000. Bank-wide, it does.
|
||||||
|
expect(quarterlySchedule(collected, general()).totalTds).toBe(5_300);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps other financial years out of the selected year', () => {
|
||||||
|
const account: TdsAccountInput = {
|
||||||
|
accountNo: 'FD001',
|
||||||
|
depositTypeCode: 'depositAccountType.fixedDeposit',
|
||||||
|
accountStatus: 'SINGLE',
|
||||||
|
clientId: 1,
|
||||||
|
credits: [
|
||||||
|
{ date: '2026-03-31', amount: 99_000 }, // FY 2025-26
|
||||||
|
{ date: '2026-06-30', amount: 10_000 }, // FY 2026-27
|
||||||
|
{ date: '2027-04-01', amount: 99_000 } // FY 2027-28
|
||||||
|
]
|
||||||
|
};
|
||||||
|
expect(collectTdsCredits(1, [account], FY).map((c) => c.amount)).toEqual([10_000]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('settlement mode follows the product, not the customer', () => {
|
||||||
|
it.each([
|
||||||
|
[
|
||||||
|
'savingsPostingInterestPeriodType.monthly',
|
||||||
|
'QUARTERLY'
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'savingsPostingInterestPeriodType.quarterly',
|
||||||
|
'QUARTERLY'
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'savingsPostingInterestPeriodType.annual',
|
||||||
|
'ANNUAL'
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'savingsPostingInterestPeriodType.annually',
|
||||||
|
'ANNUAL'
|
||||||
|
]
|
||||||
|
])('%s -> %s', (code, expected) => {
|
||||||
|
expect(tdsModeFor({ interestPostingPeriodType: { code } })).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not mistake a half-yearly product for an annual one', () => {
|
||||||
|
// 'biannual' contains 'annual' — a naive substring test settles half-yearly
|
||||||
|
// products once a year, which is a deduction default.
|
||||||
|
expect(tdsModeFor({ interestPostingPeriodType: { code: 'savingsPostingInterestPeriodType.biannual' } })).toBe(
|
||||||
|
'QUARTERLY'
|
||||||
|
);
|
||||||
|
expect(tdsModeFor({ interestPostingPeriodType: { value: 'Bi-Annual' } })).toBe('QUARTERLY');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads the value when no code is present, and defaults safely', () => {
|
||||||
|
expect(tdsModeFor({ interestPostingPeriodType: { value: 'Annually' } })).toBe('ANNUAL');
|
||||||
|
// Unknown or missing configuration settles on the quarterly cycle, which is
|
||||||
|
// the conservative choice — deducting sooner is never a default.
|
||||||
|
expect(tdsModeFor({})).toBe('QUARTERLY');
|
||||||
|
expect(tdsModeFor({ interestPostingPeriodType: null })).toBe('QUARTERLY');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PAN validation', () => {
|
||||||
|
it.each([
|
||||||
|
[
|
||||||
|
'ABCPM1234E',
|
||||||
|
true
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'EFGHV5678A',
|
||||||
|
true
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'PQRSR2233Z',
|
||||||
|
false
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'TUVWS3344K',
|
||||||
|
false
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'ABCP1234E',
|
||||||
|
false
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'',
|
||||||
|
false
|
||||||
|
]
|
||||||
|
])('%s -> %s', (pan, expected) => {
|
||||||
|
expect(isWellFormedPan(pan)).toBe(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const income = (estimatedInterest: number, estimatedIncome: number) => ({
|
||||||
|
estimatedInterest,
|
||||||
|
estimatedIncome,
|
||||||
|
incomeOnRecord: true
|
||||||
|
});
|
||||||
|
|
||||||
|
/** FY 2025-26 — the last year the 1961 Act and the 15G/15H split applied. */
|
||||||
|
const OLD_FY = 'FY 2025-26';
|
||||||
|
|
||||||
|
describe('which declaration form is in force', () => {
|
||||||
|
it('splits by age up to FY 2025-26', () => {
|
||||||
|
expect(declarationFormFor(OLD_FY, '1985-02-14')).toBe('15G');
|
||||||
|
expect(declarationFormFor(OLD_FY, '1958-06-22')).toBe('15H');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a single Form 121 from FY 2026-27, whatever the age', () => {
|
||||||
|
expect(declarationFormFor(FY, '1985-02-14')).toBe('121');
|
||||||
|
expect(declarationFormFor(FY, '1958-06-22')).toBe('121');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the senior threshold even though the forms merged', () => {
|
||||||
|
// The two forms became one, but the deduction trigger is still age-based.
|
||||||
|
expect(quarterlySchedule(credits(20_000, 20_000, 20_000, 20_000), general()).threshold).toBe(50_000);
|
||||||
|
expect(quarterlySchedule(credits(20_000, 20_000, 20_000, 20_000), senior()).threshold).toBe(100_000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('eligibility up to FY 2025-26 — the 15G / 15H tests', () => {
|
||||||
|
const old = (o: Partial<TdsContext> = {}) => general({ fy: OLD_FY, ...o });
|
||||||
|
const oldSenior = (o: Partial<TdsContext> = {}) => senior({ fy: OLD_FY, ...o });
|
||||||
|
|
||||||
|
it('accepts a 15G within both the income and the interest ceiling', () => {
|
||||||
|
const r = checkEligibility(old(), income(90_000, 320_000));
|
||||||
|
expect(r.form).toBe('15G');
|
||||||
|
expect(r.eligible).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a 15G whose estimated income exceeds the basic exemption', () => {
|
||||||
|
const r = checkEligibility(old(), income(38_500, 850_000));
|
||||||
|
expect(r.eligible).toBe(false);
|
||||||
|
expect(r.reasons.join(' ')).toContain('exceeds the basic exemption');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a 15G whose interest alone exceeds the basic exemption', () => {
|
||||||
|
// The second limb: interest had to clear the limit as well as income.
|
||||||
|
expect(checkEligibility(old(), income(450_000, 380_000)).eligible).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('places no interest ceiling on a 15H', () => {
|
||||||
|
// A senior citizen with ₹8,00,000 of interest could still declare, provided
|
||||||
|
// deductions and rebate left the liability at nil.
|
||||||
|
const r = checkEligibility(oldSenior(), income(800_000, 900_000));
|
||||||
|
expect(r.form).toBe('15H');
|
||||||
|
expect(r.eligible).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a 15H that leaves a liability', () => {
|
||||||
|
const r = checkEligibility(oldSenior(), income(800_000, 1_500_000));
|
||||||
|
expect(r.eligible).toBe(false);
|
||||||
|
expect(r.reasons.join(' ')).toContain('nil');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cites the rule that applied to that year', () => {
|
||||||
|
const r = checkEligibility(old({ pan: '' }), income(10_000, 100_000));
|
||||||
|
expect(r.eligible).toBe(false);
|
||||||
|
expect(r.reasons.join(' ')).toContain('Rule 29C');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still checks the interest limb when income is not on record', () => {
|
||||||
|
// Income has to be declared by the payee, but the bank knows the interest
|
||||||
|
// figure, so that limb remains assessable.
|
||||||
|
expect(
|
||||||
|
checkEligibility(old(), { estimatedInterest: 450_000, estimatedIncome: 0, incomeOnRecord: false }).eligible
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('eligibility from FY 2026-27 — the single Form 121 test', () => {
|
||||||
|
it('drops the interest limb entirely', () => {
|
||||||
|
// Under a 15G this failed: interest of ₹4,50,000 was over the basic
|
||||||
|
// exemption limit. Form 121 asks only whether the tax liability is nil, so
|
||||||
|
// the same payee is now eligible.
|
||||||
|
expect(checkEligibility(general({ fy: OLD_FY }), income(450_000, 380_000)).eligible).toBe(false);
|
||||||
|
const r = checkEligibility(general(), income(450_000, 380_000));
|
||||||
|
expect(r.form).toBe('121');
|
||||||
|
expect(r.eligible).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies one nil-liability ceiling to every age', () => {
|
||||||
|
for (const ctx of [
|
||||||
|
general(),
|
||||||
|
senior()
|
||||||
|
]) {
|
||||||
|
expect(checkEligibility(ctx, income(800_000, 900_000)).eligible).toBe(true);
|
||||||
|
const over = checkEligibility(ctx, income(800_000, 1_500_000));
|
||||||
|
expect(over.eligible).toBe(false);
|
||||||
|
expect(over.reasons.join(' ')).toContain('nil');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('takes PAN only — Aadhaar is not a substitute', () => {
|
||||||
|
const r = checkEligibility(general({ pan: '' }), income(10_000, 100_000));
|
||||||
|
expect(r.eligible).toBe(false);
|
||||||
|
expect(r.reasons.join(' ')).toContain('Rule 211');
|
||||||
|
expect(r.reasons.join(' ')).toContain('Aadhaar is not a substitute');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses an entity PAN', () => {
|
||||||
|
const r = checkEligibility(general({ pan: 'ABCCM1234E' }), income(10_000, 100_000));
|
||||||
|
expect(r.eligible).toBe(false);
|
||||||
|
expect(r.reasons.join(' ')).toContain('entity');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a non-resident', () => {
|
||||||
|
expect(checkEligibility(general({ resident: false }), income(10_000, 100_000)).eligible).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports the nil-liability test as unassessed rather than passing it silently', () => {
|
||||||
|
// The branch cannot compute income earned outside the bank, so a missing
|
||||||
|
// figure must not read as a pass.
|
||||||
|
const r = checkEligibility(general(), {
|
||||||
|
estimatedInterest: 90_000,
|
||||||
|
estimatedIncome: 0,
|
||||||
|
incomeOnRecord: false
|
||||||
|
});
|
||||||
|
expect(r.unassessed.length).toBe(1);
|
||||||
|
expect(r.unassessed[0]).toContain('not on record');
|
||||||
|
// With no interest limb left, nothing else can fail it — so the branch must
|
||||||
|
// read the unassessed note rather than treat the row as cleared.
|
||||||
|
expect(r.eligible).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('statutory references travel with the year', () => {
|
||||||
|
it('quotes the 1961 Act for FY 2025-26 and the 2025 Act for FY 2026-27', () => {
|
||||||
|
expect(FY_TAX_CONFIG['2025-26'].regime).toBe('ITA_1961');
|
||||||
|
expect(FY_TAX_CONFIG['2025-26'].interestTdsSection).toContain('194A');
|
||||||
|
expect(FY_TAX_CONFIG['2025-26'].tdsStatementForm).toBe('Form 26Q');
|
||||||
|
expect(FY_TAX_CONFIG['2025-26'].declarationStatementFrequency).toBe('QUARTERLY');
|
||||||
|
|
||||||
|
expect(FY_TAX_CONFIG['2026-27'].regime).toBe('ITA_2025');
|
||||||
|
expect(FY_TAX_CONFIG['2026-27'].interestTdsSection).toContain('393');
|
||||||
|
expect(FY_TAX_CONFIG['2026-27'].tdsStatementForm).toBe('Form 140');
|
||||||
|
expect(FY_TAX_CONFIG['2026-27'].declarationStatementFrequency).toBe('MONTHLY');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('carries the same thresholds and rates across the changeover', () => {
|
||||||
|
const a = FY_TAX_CONFIG['2025-26'];
|
||||||
|
const b = FY_TAX_CONFIG['2026-27'];
|
||||||
|
expect(b.thresholdGeneral).toBe(a.thresholdGeneral);
|
||||||
|
expect(b.thresholdSenior).toBe(a.thresholdSenior);
|
||||||
|
expect(b.rate).toBe(a.rate);
|
||||||
|
expect(b.rateNoPan).toBe(a.rateNoPan);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('default interest under section 201(1A)', () => {
|
||||||
|
it('counts any part of a month as a full month', () => {
|
||||||
|
expect(monthsOrPartBetween('2026-07-07', '2026-07-07')).toBe(0);
|
||||||
|
expect(monthsOrPartBetween('2026-07-07', '2026-07-08')).toBe(1);
|
||||||
|
expect(monthsOrPartBetween('2026-07-07', '2026-08-07')).toBe(1);
|
||||||
|
expect(monthsOrPartBetween('2026-07-07', '2026-08-08')).toBe(2);
|
||||||
|
expect(monthsOrPartBetween('2026-07-07', '2027-01-07')).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('charges 1% a month for deducting late and 1.5% for depositing late', () => {
|
||||||
|
expect(lateDeductionInterest(6_000, '2026-07-07', '2027-01-07', 'DEDUCTION', FY)).toEqual({
|
||||||
|
months: 6,
|
||||||
|
rate: 0.01,
|
||||||
|
interest: 360
|
||||||
|
});
|
||||||
|
expect(lateDeductionInterest(6_000, '2026-07-07', '2027-01-07', 'DEPOSIT', FY).interest).toBe(540);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('charges nothing when settled on time', () => {
|
||||||
|
expect(lateDeductionInterest(6_000, '2026-07-07', '2026-07-07', 'DEPOSIT', FY).interest).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('helpers', () => {
|
||||||
|
it('resolves the financial year of a date', () => {
|
||||||
|
expect(fyOf('2026-04-01')).toBe('2026-27');
|
||||||
|
expect(fyOf('2027-03-31')).toBe('2026-27');
|
||||||
|
expect(fyOf('2026-03-31')).toBe('2025-26');
|
||||||
|
expect(fyOf('2027-04-01')).toBe('2027-28');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('buckets dated credits into the right quarters and drops out-of-year dates', () => {
|
||||||
|
const b = bucketByQuarter(
|
||||||
|
[
|
||||||
|
{ date: '2026-04-05', amount: 100 },
|
||||||
|
{ date: '2026-08-01', amount: 200 },
|
||||||
|
{ date: '2026-11-30', amount: 300 },
|
||||||
|
{ date: '2027-01-15', amount: 400 },
|
||||||
|
{ date: '2026-03-31', amount: 999 },
|
||||||
|
{ date: '2027-04-01', amount: 999 }
|
||||||
|
],
|
||||||
|
FY
|
||||||
|
);
|
||||||
|
expect(b).toEqual({ 1: 100, 2: 200, 3: 300, 4: 400 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('spreads an annual estimate without losing rupees to division', () => {
|
||||||
|
for (const annual of [
|
||||||
|
38_500,
|
||||||
|
52_801,
|
||||||
|
1,
|
||||||
|
999_999
|
||||||
|
]) {
|
||||||
|
expect(projectQuarterlyCredits(annual, FY).reduce((s, x) => s + x.amount, 0)).toBe(annual);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes the cumulative liability directly for ad-hoc checks', () => {
|
||||||
|
expect(cumulativeLiability(50_000, 50_000, general())).toBe(0);
|
||||||
|
expect(cumulativeLiability(50_001, 50_001, general())).toBe(5_000);
|
||||||
|
});
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
+45
@@ -115,6 +115,51 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<div class="spa-group">
|
||||||
|
<div class="spa-group__header">
|
||||||
|
<h4>Map Payment Types to Specific Funds</h4>
|
||||||
|
<button type="button" class="spa-add-btn" (click)="addPaymentFundSourceMapping()">+ Add</button>
|
||||||
|
</div>
|
||||||
|
<div class="spa-list" [formArrayName]="'paymentChannelToFundSourceMappings'">
|
||||||
|
@for (row of paymentChannelToFundSourceMappings.controls; track $index; let i = $index) {
|
||||||
|
<div class="spa-other-row-wrap">
|
||||||
|
<div class="spa-row spa-row--other" [formGroupName]="i">
|
||||||
|
<div class="spa-row__meta spa-row__meta--other">
|
||||||
|
<mat-form-field class="spa-row__other-field" appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Payment Type</mat-label>
|
||||||
|
<mat-select formControlName="paymentTypeId">
|
||||||
|
@for (paymentType of paymentTypeOptions; track paymentType.id) {
|
||||||
|
<mat-option [value]="paymentType.id">{{ paymentType.name }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="spa-row__actions spa-row__actions--other">
|
||||||
|
<mat-form-field class="spa-row__other-field" appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Fund Source</mat-label>
|
||||||
|
<mat-select formControlName="fundSourceAccountId">
|
||||||
|
@for (glAccount of fundSourceAccountOptions; track glAccount.id) {
|
||||||
|
<mat-option [value]="glAccount.id">
|
||||||
|
{{ '(' + glAccount.glCode + ') ' + glAccount.name }}
|
||||||
|
</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="spa-remove-btn spa-remove-btn--side"
|
||||||
|
(click)="removePaymentFundSourceMapping(i)"
|
||||||
|
>
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+52
-1
@@ -50,6 +50,11 @@ type OtherGlMappingValue = {
|
|||||||
glAccountId: number | null;
|
glAccountId: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type PaymentFundSourceValue = {
|
||||||
|
paymentTypeId: number | null;
|
||||||
|
fundSourceAccountId: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'mifosx-saving-product-accounting-step',
|
selector: 'mifosx-saving-product-accounting-step',
|
||||||
templateUrl: './saving-product-accounting-step.component.html',
|
templateUrl: './saving-product-accounting-step.component.html',
|
||||||
@@ -78,6 +83,11 @@ export class SavingProductAccountingStepComponent implements OnInit {
|
|||||||
expenseAccountOptions: GLAccount[] = [];
|
expenseAccountOptions: GLAccount[] = [];
|
||||||
incomeAccountOptions: GLAccount[] = [];
|
incomeAccountOptions: GLAccount[] = [];
|
||||||
assetAccountOptions: GLAccount[] = [];
|
assetAccountOptions: GLAccount[] = [];
|
||||||
|
paymentTypeOptions: any[] = [];
|
||||||
|
/** A payment-type Fund Source override can legitimately be a suspense/clearing Liability account
|
||||||
|
* (e.g. an existing "Sundry" account bridging to another module) rather than only a cash-like
|
||||||
|
* Asset account, so this field accepts either. */
|
||||||
|
fundSourceAccountOptions: GLAccount[] = [];
|
||||||
accountingSectionConfigs: AccountingSectionConfig[] = [];
|
accountingSectionConfigs: AccountingSectionConfig[] = [];
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -98,6 +108,8 @@ export class SavingProductAccountingStepComponent implements OnInit {
|
|||||||
this.liabilityAccountOptions = accountingMappingOptions.liabilityAccountOptions || [];
|
this.liabilityAccountOptions = accountingMappingOptions.liabilityAccountOptions || [];
|
||||||
this.expenseAccountOptions = accountingMappingOptions.expenseAccountOptions || [];
|
this.expenseAccountOptions = accountingMappingOptions.expenseAccountOptions || [];
|
||||||
this.incomeAccountOptions = accountingMappingOptions.incomeAccountOptions || [];
|
this.incomeAccountOptions = accountingMappingOptions.incomeAccountOptions || [];
|
||||||
|
this.paymentTypeOptions = this.savingProductsTemplate?.paymentTypeOptions || [];
|
||||||
|
this.fundSourceAccountOptions = this.assetAccountOptions.concat(this.liabilityAccountOptions);
|
||||||
this.buildAccountingSectionConfigs();
|
this.buildAccountingSectionConfigs();
|
||||||
this.savingProductAccountingForm.patchValue({
|
this.savingProductAccountingForm.patchValue({
|
||||||
accountingRule: accountingRuleId
|
accountingRule: accountingRuleId
|
||||||
@@ -105,6 +117,7 @@ export class SavingProductAccountingStepComponent implements OnInit {
|
|||||||
|
|
||||||
this.syncAccountingControls(accountingRuleId);
|
this.syncAccountingControls(accountingRuleId);
|
||||||
this.patchAccountingValues(mappings);
|
this.patchAccountingValues(mappings);
|
||||||
|
this.patchPaymentFundSourceMappings(this.savingProductsTemplate?.paymentChannelToFundSourceMappings || []);
|
||||||
this.setOverdraftControl();
|
this.setOverdraftControl();
|
||||||
this.setDormancyControl();
|
this.setDormancyControl();
|
||||||
}
|
}
|
||||||
@@ -115,10 +128,48 @@ export class SavingProductAccountingStepComponent implements OnInit {
|
|||||||
assetsOtherGlMappings: this.formBuilder.array([]),
|
assetsOtherGlMappings: this.formBuilder.array([]),
|
||||||
liabilitiesOtherGlMappings: this.formBuilder.array([]),
|
liabilitiesOtherGlMappings: this.formBuilder.array([]),
|
||||||
expensesOtherGlMappings: this.formBuilder.array([]),
|
expensesOtherGlMappings: this.formBuilder.array([]),
|
||||||
incomeOtherGlMappings: this.formBuilder.array([])
|
incomeOtherGlMappings: this.formBuilder.array([]),
|
||||||
|
paymentChannelToFundSourceMappings: this.formBuilder.array([])
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get paymentChannelToFundSourceMappings(): UntypedFormArray {
|
||||||
|
return this.savingProductAccountingForm.get('paymentChannelToFundSourceMappings') as UntypedFormArray;
|
||||||
|
}
|
||||||
|
|
||||||
|
private createPaymentFundSourceGroup(value: Partial<PaymentFundSourceValue> = {}) {
|
||||||
|
return this.formBuilder.group({
|
||||||
|
paymentTypeId: [
|
||||||
|
value.paymentTypeId ?? this.paymentTypeOptions[0]?.id ?? null,
|
||||||
|
Validators.required
|
||||||
|
],
|
||||||
|
fundSourceAccountId: [
|
||||||
|
value.fundSourceAccountId ?? this.fundSourceAccountOptions[0]?.id ?? null,
|
||||||
|
Validators.required
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private patchPaymentFundSourceMappings(mappings: any[]): void {
|
||||||
|
this.paymentChannelToFundSourceMappings.clear();
|
||||||
|
(mappings || []).forEach((mapping) => {
|
||||||
|
this.paymentChannelToFundSourceMappings.push(
|
||||||
|
this.createPaymentFundSourceGroup({
|
||||||
|
paymentTypeId: mapping.paymentType?.id,
|
||||||
|
fundSourceAccountId: mapping.fundSourceAccount?.id
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
addPaymentFundSourceMapping(): void {
|
||||||
|
this.paymentChannelToFundSourceMappings.push(this.createPaymentFundSourceGroup());
|
||||||
|
}
|
||||||
|
|
||||||
|
removePaymentFundSourceMapping(index: number): void {
|
||||||
|
this.paymentChannelToFundSourceMappings.removeAt(index);
|
||||||
|
}
|
||||||
|
|
||||||
private setConditionalControls() {
|
private setConditionalControls() {
|
||||||
this.savingProductAccountingForm.get('accountingRule')?.valueChanges.subscribe((accountingRule: number) => {
|
this.savingProductAccountingForm.get('accountingRule')?.valueChanges.subscribe((accountingRule: number) => {
|
||||||
this.syncAccountingControls(accountingRule);
|
this.syncAccountingControls(accountingRule);
|
||||||
|
|||||||
+9
-2
@@ -74,6 +74,12 @@ export class ShareProductAccountingStepComponent implements OnInit {
|
|||||||
liabilityAccountData: GLAccount[] = [];
|
liabilityAccountData: GLAccount[] = [];
|
||||||
incomeAccountData: GLAccount[] = [];
|
incomeAccountData: GLAccount[] = [];
|
||||||
equityAccountData: GLAccount[] = [];
|
equityAccountData: GLAccount[] = [];
|
||||||
|
/** "Share Reference" is where a redemption's credit side lands pending settlement — some banks
|
||||||
|
* model that as a receivable-like Asset (e.g. "Cash Vault", this field's original default),
|
||||||
|
* others as a payable-like Liability suspense account (e.g. an existing "Sundry" account
|
||||||
|
* already used elsewhere as Shares Suspense). Both are valid conventions, so this field accepts
|
||||||
|
* either instead of Asset-only. */
|
||||||
|
referenceAccountData: GLAccount[] = [];
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.createShareProductAccountingForm();
|
this.createShareProductAccountingForm();
|
||||||
@@ -85,6 +91,7 @@ export class ShareProductAccountingStepComponent implements OnInit {
|
|||||||
this.liabilityAccountData = opts.liabilityAccountOptions || [];
|
this.liabilityAccountData = opts.liabilityAccountOptions || [];
|
||||||
this.incomeAccountData = opts.incomeAccountOptions || [];
|
this.incomeAccountData = opts.incomeAccountOptions || [];
|
||||||
this.equityAccountData = opts.equityAccountOptions || [];
|
this.equityAccountData = opts.equityAccountOptions || [];
|
||||||
|
this.referenceAccountData = this.assetAccountData.concat(this.liabilityAccountData);
|
||||||
|
|
||||||
this.buildAccountingSectionConfigs();
|
this.buildAccountingSectionConfigs();
|
||||||
|
|
||||||
@@ -135,10 +142,10 @@ export class ShareProductAccountingStepComponent implements OnInit {
|
|||||||
rows: [
|
rows: [
|
||||||
{
|
{
|
||||||
key: 'shareReferenceId',
|
key: 'shareReferenceId',
|
||||||
label: 'Share Reference (Cash Vault)',
|
label: 'Share Reference (Cash Vault or Suspense)',
|
||||||
inputLabel: 'Share reference',
|
inputLabel: 'Share reference',
|
||||||
controlName: 'shareReferenceId',
|
controlName: 'shareReferenceId',
|
||||||
options: this.assetAccountData,
|
options: this.referenceAccountData,
|
||||||
required: true
|
required: true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -59,6 +59,18 @@ const routes: Routes = [
|
|||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('../risk-review/risk-review-report.component').then((m) => m.RiskReviewReportComponent)
|
import('../risk-review/risk-review-report.component').then((m) => m.RiskReviewReportComponent)
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'tds-interest',
|
||||||
|
data: { title: 'Deposit Interest & TDS', breadcrumb: 'Deposit Interest & TDS' },
|
||||||
|
loadComponent: () =>
|
||||||
|
import('../tds-reports/tds-interest-report.component').then((m) => m.TdsInterestReportComponent)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'tds-remittance',
|
||||||
|
data: { title: 'TDS Remitted to Government', breadcrumb: 'TDS Remitted to Government' },
|
||||||
|
loadComponent: () =>
|
||||||
|
import('../tds-reports/tds-remittance-report.component').then((m) => m.TdsRemittanceReportComponent)
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: ':filter',
|
path: ':filter',
|
||||||
data: { routeParamBreadcrumb: 'filter' },
|
data: { routeParamBreadcrumb: 'filter' },
|
||||||
|
|||||||
@@ -0,0 +1,236 @@
|
|||||||
|
/**
|
||||||
|
* 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, ElementRef, forwardRef, HostListener, Input, ViewChild } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { ControlValueAccessor, FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms';
|
||||||
|
|
||||||
|
export interface SearchSelectOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
/** What typed text is matched against — defaults to `label` when omitted, but a caller can
|
||||||
|
* pass a richer string (e.g. account no + customer name + branch) so a search hits fields
|
||||||
|
* that aren't all visible in the collapsed label. */
|
||||||
|
searchText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop-in replacement for a plain `<select>` when the option list is long enough that scanning
|
||||||
|
* it is impractical (e.g. hundreds of accounts) — a text box that filters the option list as you
|
||||||
|
* type, instead of a native dropdown you can only scroll through. Selection only commits on
|
||||||
|
* click; typing without picking an option reverts the box to the last committed selection on
|
||||||
|
* blur, so the bound ngModel value never drifts from an actual, valid option.
|
||||||
|
*
|
||||||
|
* Usage: <mifosx-search-select [options]="opts" [(ngModel)]="selectedValue"></mifosx-search-select>
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-search-select',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: NG_VALUE_ACCESSOR,
|
||||||
|
useExisting: forwardRef(() => SearchSelectComponent),
|
||||||
|
multi: true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
template: `
|
||||||
|
<div class="inv-search-select">
|
||||||
|
<input
|
||||||
|
#inputEl
|
||||||
|
type="text"
|
||||||
|
class="inv-search-select__input"
|
||||||
|
[value]="query"
|
||||||
|
[placeholder]="placeholder"
|
||||||
|
[disabled]="disabled"
|
||||||
|
(input)="onInput($event)"
|
||||||
|
(focus)="onFocus()"
|
||||||
|
(blur)="onBlur()"
|
||||||
|
/>
|
||||||
|
@if (open) {
|
||||||
|
<ul
|
||||||
|
class="inv-search-select__list"
|
||||||
|
[style.top.px]="listTop"
|
||||||
|
[style.left.px]="listLeft"
|
||||||
|
[style.width.px]="listWidth"
|
||||||
|
(mousedown)="$event.preventDefault()"
|
||||||
|
>
|
||||||
|
@for (o of filteredOptions; track o.value) {
|
||||||
|
<li
|
||||||
|
class="inv-search-select__option"
|
||||||
|
[class.inv-search-select__option--active]="o.value === value"
|
||||||
|
(click)="selectOption(o)"
|
||||||
|
>
|
||||||
|
{{ o.label }}
|
||||||
|
</li>
|
||||||
|
} @empty {
|
||||||
|
<li class="inv-search-select__empty">No matches</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
styles: [
|
||||||
|
`
|
||||||
|
/* Angular view encapsulation stops the host page's shared styles from reaching inside this
|
||||||
|
* component's own template, so its look is re-declared here rather than inherited. */
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.inv-search-select {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.inv-search-select__input {
|
||||||
|
width: 100%;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #0d1b2a;
|
||||||
|
font-family: inherit;
|
||||||
|
background: #fff;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
/* position: fixed, anchored via JS to the input's own screen coords (see
|
||||||
|
* updateListPosition()) rather than absolute inside this component — most of this app's
|
||||||
|
* cards use overflow: hidden for rounded corners, which would otherwise clip a dropdown
|
||||||
|
* that extends past the card's edge. */
|
||||||
|
.inv-search-select__list {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 2000;
|
||||||
|
max-height: 260px;
|
||||||
|
overflow-y: auto;
|
||||||
|
margin: 0;
|
||||||
|
padding: 4px;
|
||||||
|
list-style: none;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 8px 24px rgba(13, 27, 42, 0.18);
|
||||||
|
}
|
||||||
|
.inv-search-select__option {
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #0d1b2a;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.inv-search-select__option:hover,
|
||||||
|
.inv-search-select__option--active {
|
||||||
|
background: #eef2ff;
|
||||||
|
}
|
||||||
|
.inv-search-select__empty {
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class SearchSelectComponent implements ControlValueAccessor {
|
||||||
|
@Input() options: SearchSelectOption[] = [];
|
||||||
|
@Input() placeholder = 'Search…';
|
||||||
|
|
||||||
|
@ViewChild('inputEl') private inputEl?: ElementRef<HTMLInputElement>;
|
||||||
|
|
||||||
|
value = '';
|
||||||
|
query = '';
|
||||||
|
open = false;
|
||||||
|
disabled = false;
|
||||||
|
|
||||||
|
listTop = 0;
|
||||||
|
listLeft = 0;
|
||||||
|
listWidth = 0;
|
||||||
|
|
||||||
|
private onChangeFn: (v: string) => void = () => {};
|
||||||
|
onTouched: () => void = () => {};
|
||||||
|
|
||||||
|
get filteredOptions(): SearchSelectOption[] {
|
||||||
|
const q = this.query.trim().toLowerCase();
|
||||||
|
if (!q) {
|
||||||
|
return this.options;
|
||||||
|
}
|
||||||
|
return this.options.filter((o) => (o.searchText || o.label).toLowerCase().includes(q));
|
||||||
|
}
|
||||||
|
|
||||||
|
private labelFor(value: string): string {
|
||||||
|
return this.options.find((o) => o.value === value)?.label ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
writeValue(v: string): void {
|
||||||
|
this.value = v ?? '';
|
||||||
|
this.query = this.labelFor(this.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
registerOnChange(fn: (v: string) => void): void {
|
||||||
|
this.onChangeFn = fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
registerOnTouched(fn: () => void): void {
|
||||||
|
this.onTouched = fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDisabledState(isDisabled: boolean): void {
|
||||||
|
this.disabled = isDisabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Anchors the fixed-position list to the input's current on-screen box. */
|
||||||
|
private updateListPosition(): void {
|
||||||
|
const el = this.inputEl?.nativeElement;
|
||||||
|
if (!el) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
this.listTop = rect.bottom + 4;
|
||||||
|
this.listLeft = rect.left;
|
||||||
|
this.listWidth = rect.width;
|
||||||
|
}
|
||||||
|
|
||||||
|
@HostListener('window:scroll')
|
||||||
|
@HostListener('window:resize')
|
||||||
|
onWindowChange(): void {
|
||||||
|
if (this.open) {
|
||||||
|
this.updateListPosition();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onInput(event: Event): void {
|
||||||
|
this.query = (event.target as HTMLInputElement).value;
|
||||||
|
this.open = true;
|
||||||
|
this.updateListPosition();
|
||||||
|
}
|
||||||
|
|
||||||
|
onFocus(): void {
|
||||||
|
this.open = true;
|
||||||
|
this.updateListPosition();
|
||||||
|
}
|
||||||
|
|
||||||
|
onBlur(): void {
|
||||||
|
this.open = false;
|
||||||
|
this.onTouched();
|
||||||
|
// Discard any uncommitted typed text — only a clicked option ever changes `value`.
|
||||||
|
this.query = this.labelFor(this.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
selectOption(o: SearchSelectOption): void {
|
||||||
|
this.value = o.value;
|
||||||
|
this.query = o.label;
|
||||||
|
this.open = false;
|
||||||
|
this.onChangeFn(this.value);
|
||||||
|
this.onTouched();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,11 +20,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="table-filters">
|
<div class="table-filters">
|
||||||
<mat-form-field class="table-search" appearance="outline">
|
<mat-form-field class="table-search" appearance="outline" subscriptSizing="dynamic">
|
||||||
<input matInput placeholder="Search by client name or account number" [formControl]="searchControl" />
|
<input matInput placeholder="Search by client name or account number" [formControl]="searchControl" />
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
|
|
||||||
<mat-form-field class="table-filter" appearance="outline">
|
<mat-form-field class="table-filter" appearance="outline" subscriptSizing="dynamic">
|
||||||
<mat-select [(value)]="branchFilter" (selectionChange)="applyFilters()" placeholder="Branch">
|
<mat-select [(value)]="branchFilter" (selectionChange)="applyFilters()" placeholder="Branch">
|
||||||
<mat-option value="">All Branches</mat-option>
|
<mat-option value="">All Branches</mat-option>
|
||||||
<mat-option *ngFor="let office of offices" [value]="office.id">
|
<mat-option *ngFor="let office of offices" [value]="office.id">
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
</mat-select>
|
</mat-select>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
|
|
||||||
<mat-form-field class="table-filter" appearance="outline">
|
<mat-form-field class="table-filter" appearance="outline" subscriptSizing="dynamic">
|
||||||
<mat-select [(value)]="productFilter" (selectionChange)="applyFilters()" placeholder="Share Product">
|
<mat-select [(value)]="productFilter" (selectionChange)="applyFilters()" placeholder="Share Product">
|
||||||
<mat-option value="">All Products</mat-option>
|
<mat-option value="">All Products</mat-option>
|
||||||
<mat-option *ngFor="let product of products" [value]="product.id">
|
<mat-option *ngFor="let product of products" [value]="product.id">
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
</mat-select>
|
</mat-select>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
|
|
||||||
<mat-form-field class="table-filter" appearance="outline">
|
<mat-form-field class="table-filter" appearance="outline" subscriptSizing="dynamic">
|
||||||
<mat-select [(value)]="statusFilter" (selectionChange)="applyFilters()" placeholder="Status">
|
<mat-select [(value)]="statusFilter" (selectionChange)="applyFilters()" placeholder="Status">
|
||||||
<mat-option value="">All Status</mat-option>
|
<mat-option value="">All Status</mat-option>
|
||||||
<mat-option value="active">Active</mat-option>
|
<mat-option value="active">Active</mat-option>
|
||||||
@@ -53,7 +53,7 @@
|
|||||||
</mat-select>
|
</mat-select>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
|
|
||||||
<mat-form-field class="table-filter" appearance="outline" (click)="picker.open()">
|
<mat-form-field class="table-filter" appearance="outline" subscriptSizing="dynamic" (click)="picker.open()">
|
||||||
<mat-date-range-input [formGroup]="dateRangeForm" [rangePicker]="picker">
|
<mat-date-range-input [formGroup]="dateRangeForm" [rangePicker]="picker">
|
||||||
<input matStartDate formControlName="start" placeholder="Start date" (dateChange)="applyFilters()" />
|
<input matStartDate formControlName="start" placeholder="Start date" (dateChange)="applyFilters()" />
|
||||||
<input matEndDate formControlName="end" placeholder="End date" (dateChange)="applyFilters()" />
|
<input matEndDate formControlName="end" placeholder="End date" (dateChange)="applyFilters()" />
|
||||||
|
|||||||
@@ -5,37 +5,43 @@
|
|||||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
* 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/.
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
margin: 1.5rem;
|
margin: 1.5rem auto;
|
||||||
padding: 0;
|
max-width: 1400px;
|
||||||
border-radius: var(--radius-xl);
|
padding: 20px 24px;
|
||||||
background: var(--bg-card);
|
border-radius: 16px;
|
||||||
box-shadow: var(--shadow-sm);
|
background: #ffffff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-header {
|
.table-header {
|
||||||
padding: 1.5rem;
|
margin-bottom: 20px;
|
||||||
border-bottom: 1px solid var(--border-light);
|
|
||||||
|
|
||||||
.table-header-row {
|
.table-header-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 16px;
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-title-group {
|
.table-title-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1rem;
|
gap: 12px;
|
||||||
|
|
||||||
.table-title {
|
.table-title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-main);
|
color: #0f172a;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
@@ -45,83 +51,175 @@
|
|||||||
width: 24px;
|
width: 24px;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-wrap: wrap;
|
grid-template-columns: 1fr 1fr;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
padding: 2px;
|
padding: 2px;
|
||||||
background: var(--bg-hover);
|
background: #f1f5f9;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
|
|
||||||
span {
|
span {
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
background: var(--primary-main);
|
background: #1353a0;
|
||||||
opacity: 0.8;
|
|
||||||
|
|
||||||
&:nth-child(2) {
|
&:nth-child(2) { opacity: 0.7; }
|
||||||
opacity: 0.6;
|
&:nth-child(3) { opacity: 0.4; }
|
||||||
}
|
&:nth-child(4) { opacity: 0.2; }
|
||||||
|
|
||||||
&:nth-child(3) {
|
|
||||||
opacity: 0.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:nth-child(4) {
|
|
||||||
opacity: 0.2;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-count {
|
.table-count {
|
||||||
padding: 0.25rem 0.75rem;
|
padding: 0.2rem 0.65rem;
|
||||||
background: var(--bg-hover);
|
background: #f1f5f9;
|
||||||
color: var(--text-muted);
|
color: #64748b;
|
||||||
border-radius: var(--radius-full);
|
border-radius: 12px;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
letter-spacing: 0.02em;
|
font-family: 'Roboto Mono', monospace;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Pill-styled filter bar */
|
||||||
.table-filters {
|
.table-filters {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
|
||||||
mat-form-field {
|
.table-search {
|
||||||
width: 240px;
|
flex: 1 1 200px;
|
||||||
margin-bottom: -1.25em;
|
min-width: 180px;
|
||||||
|
max-width: 280px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-filter {
|
||||||
|
flex: 0 1 150px;
|
||||||
|
min-width: 130px;
|
||||||
|
max-width: 170px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Override Material Form Field Heights & Subscript Spacing */
|
||||||
|
::ng-deep {
|
||||||
|
.mat-mdc-form-field {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mat-mdc-form-field-subscript-wrapper {
|
||||||
|
display: none !important;
|
||||||
|
height: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mat-mdc-form-field-flex {
|
||||||
|
height: 38px !important;
|
||||||
|
background: #ffffff !important;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mat-mdc-form-field-infix {
|
||||||
|
padding: 6px 0 !important;
|
||||||
|
min-height: 38px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mdc-notched-outline__leading {
|
||||||
|
border-radius: 8px 0 0 8px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mdc-notched-outline__trailing {
|
||||||
|
border-radius: 0 8px 8px 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mat-mdc-select-value,
|
||||||
|
.mat-mdc-select-placeholder,
|
||||||
|
input {
|
||||||
|
font-size: 13px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
button[mat-icon-button] {
|
||||||
|
color: #64748b;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #0f172a;
|
||||||
|
background: #e2e8f0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-container {
|
.table-container {
|
||||||
position: relative;
|
border: 1px solid #e2e8f0;
|
||||||
overflow: auto;
|
border-radius: 12px;
|
||||||
border-radius: 0 0 var(--radius-xl) var(--radius-xl);
|
overflow: hidden;
|
||||||
|
background: #ffffff;
|
||||||
|
|
||||||
.table-loader {
|
.table-loader {
|
||||||
position: absolute;
|
height: 2px;
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
z-index: 10;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.modern-table {
|
.modern-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background: transparent;
|
border-collapse: separate;
|
||||||
|
border-spacing: 0;
|
||||||
|
|
||||||
tr.mat-row {
|
::ng-deep {
|
||||||
|
.mat-mdc-header-row {
|
||||||
|
background: #f8fafc;
|
||||||
|
min-height: 38px;
|
||||||
|
height: 38px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mat-mdc-header-cell {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: #64748b;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
padding: 0 16px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mat-mdc-row {
|
||||||
|
min-height: 42px;
|
||||||
|
height: 42px;
|
||||||
|
border-bottom: 1px solid #f1f5f9;
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background-color 0.2s ease;
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:nth-child(even) {
|
||||||
|
background: #fafbfc;
|
||||||
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: var(--bg-hover);
|
background-color: #f1f5f9 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mat-mdc-cell {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: #0f172a;
|
||||||
|
padding: 0 16px;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mat-paginator {
|
||||||
|
border-top: 1px solid #e2e8f0;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,210 @@
|
|||||||
|
<!--
|
||||||
|
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="passbook-container">
|
||||||
|
<!-- Filter Bar -->
|
||||||
|
<div class="passbook-filter-bar no-print">
|
||||||
|
<form [formGroup]="dateRangeForm" class="passbook-filter-form">
|
||||||
|
<mat-form-field appearance="outline" class="passbook-date-field" (click)="fromDatePicker.open()">
|
||||||
|
<mat-label>{{ 'labels.inputs.From Date' | translate }}</mat-label>
|
||||||
|
<input matInput [min]="minDate" [max]="maxDate" [matDatepicker]="fromDatePicker" formControlName="fromDate" />
|
||||||
|
<mat-datepicker-toggle matSuffix [for]="fromDatePicker"></mat-datepicker-toggle>
|
||||||
|
<mat-datepicker #fromDatePicker></mat-datepicker>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="passbook-date-field" (click)="toDatePicker.open()">
|
||||||
|
<mat-label>{{ 'labels.inputs.To Date' | translate }}</mat-label>
|
||||||
|
<input matInput [min]="minDate" [max]="maxDate" [matDatepicker]="toDatePicker" formControlName="toDate" />
|
||||||
|
<mat-datepicker-toggle matSuffix [for]="toDatePicker"></mat-datepicker-toggle>
|
||||||
|
<mat-datepicker #toDatePicker></mat-datepicker>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<button
|
||||||
|
mat-raised-button
|
||||||
|
color="primary"
|
||||||
|
type="button"
|
||||||
|
[disabled]="!dateRangeForm.valid"
|
||||||
|
(click)="filterTransactions()"
|
||||||
|
>
|
||||||
|
<mat-icon>filter_list</mat-icon>
|
||||||
|
{{ 'labels.buttons.Filter' | translate }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
@if (isFiltered) {
|
||||||
|
<button mat-stroked-button type="button" (click)="clearFilter()">
|
||||||
|
<mat-icon>clear</mat-icon>
|
||||||
|
{{ 'labels.buttons.Clear' | translate }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
|
||||||
|
<span class="passbook-filter-spacer"></span>
|
||||||
|
|
||||||
|
<button mat-raised-button color="accent" type="button" (click)="printPassbook()">
|
||||||
|
<mat-icon>print</mat-icon>
|
||||||
|
{{ 'labels.buttons.Print' | translate }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Share Passbook -->
|
||||||
|
<div class="statement-wrapper" #passbookContent>
|
||||||
|
<!-- Bank Header -->
|
||||||
|
<div class="bank-header">
|
||||||
|
<div class="bank-logo-area">
|
||||||
|
<div class="bank-logo-circle">
|
||||||
|
<mat-icon>account_balance</mat-icon>
|
||||||
|
</div>
|
||||||
|
<div class="bank-name-block">
|
||||||
|
<div class="bank-title">LincPay Co-operative Bank Ltd.</div>
|
||||||
|
<div class="bank-subtitle">Share Account Passbook</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="statement-label">
|
||||||
|
<div class="stmt-label-text">SHARE HOLDING STATEMENT</div>
|
||||||
|
<div class="stmt-period">As on: {{ maxDate | dateFormat }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Account Details -->
|
||||||
|
<div class="account-details-band">
|
||||||
|
<div class="account-details-grid">
|
||||||
|
<div class="detail-block">
|
||||||
|
<div class="detail-label">Member Name</div>
|
||||||
|
<div class="detail-value bold">{{ clientName | uppercase }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-block">
|
||||||
|
<div class="detail-label">Share Account No.</div>
|
||||||
|
<div class="detail-value mono bold">{{ accountNo }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-block">
|
||||||
|
<div class="detail-label">Product</div>
|
||||||
|
<div class="detail-value">{{ productName }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-block">
|
||||||
|
<div class="detail-label">Account Opening Date</div>
|
||||||
|
<div class="detail-value">{{ activatedOnDate | dateFormat }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-block">
|
||||||
|
<div class="detail-label">Status</div>
|
||||||
|
<div class="detail-value status-badge" [class.active]="status === 'Active'">{{ status }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-block">
|
||||||
|
<div class="detail-label">Linked Savings A/C No.</div>
|
||||||
|
<div class="detail-value mono">{{ savingsAccountNumber || '—' }}</div>
|
||||||
|
</div>
|
||||||
|
@if (resolutionNumber) {
|
||||||
|
<div class="detail-block">
|
||||||
|
<div class="detail-label">Resolution No.</div>
|
||||||
|
<div class="detail-value">{{ resolutionNumber }}</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Balance Summary Strip -->
|
||||||
|
<div class="balance-summary-strip">
|
||||||
|
<div class="balance-box">
|
||||||
|
<div class="balance-box-label">Approved Shares</div>
|
||||||
|
<div class="balance-box-value">{{ totalApprovedShares }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="balance-divider"></div>
|
||||||
|
<div class="balance-box">
|
||||||
|
<div class="balance-box-label">Share Value</div>
|
||||||
|
<div class="balance-box-value">
|
||||||
|
{{ currentMarketPrice | currency: currency?.code : 'symbol-narrow' : '1.2-2' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="balance-divider"></div>
|
||||||
|
<div class="balance-box">
|
||||||
|
<div class="balance-box-label">Current Value</div>
|
||||||
|
<div class="balance-box-value">{{ currentValue | currency: currency?.code : 'symbol-narrow' : '1.2-2' }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="balance-divider"></div>
|
||||||
|
<div class="balance-box">
|
||||||
|
<div class="balance-box-label">Total Transactions</div>
|
||||||
|
<div class="balance-box-value">{{ filteredTransactions.length }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Transactions Table -->
|
||||||
|
@if (filteredTransactions.length > 0) {
|
||||||
|
<table class="statement-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="col-sr">#</th>
|
||||||
|
<th class="col-date">Date</th>
|
||||||
|
<th class="col-desc">Description</th>
|
||||||
|
<th class="col-ref">Ref No.</th>
|
||||||
|
<th class="col-amount text-right">Shares Purchased</th>
|
||||||
|
<th class="col-amount text-right">Shares Redeemed</th>
|
||||||
|
<th class="col-balance text-right">Share Balance</th>
|
||||||
|
<th class="col-amount text-right">Amount (₹)</th>
|
||||||
|
<th class="col-desc">Resolution No. / Meeting</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (txn of filteredTransactions; track txn.id; let i = $index) {
|
||||||
|
<tr [class.debit-row]="isRedeem(txn)" [class.credit-row]="!isRedeem(txn)">
|
||||||
|
<td class="col-sr text-center">{{ i + 1 }}</td>
|
||||||
|
<td class="col-date">{{ txn.purchasedDate | dateFormat }}</td>
|
||||||
|
<td class="col-desc">
|
||||||
|
<span class="txn-desc">{{ txn.type.value }} ({{ txn.status.value }})</span>
|
||||||
|
</td>
|
||||||
|
<td class="col-ref text-muted mono">{{ txn.id }}</td>
|
||||||
|
<td class="col-amount text-right credit-amount">
|
||||||
|
@if (!isRedeem(txn)) {
|
||||||
|
{{ txn.numberOfShares }}
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td class="col-amount text-right debit-amount">
|
||||||
|
@if (isRedeem(txn)) {
|
||||||
|
{{ txn.numberOfShares }}
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td class="col-balance text-right balance-amount">{{ txn.runningBalance }}</td>
|
||||||
|
<td class="col-amount text-right">
|
||||||
|
{{ txn.amount | currency: currency?.code : 'symbol-narrow' : '1.2-2' }}
|
||||||
|
</td>
|
||||||
|
<td class="col-desc">
|
||||||
|
@if (isRedeem(txn)) {
|
||||||
|
{{ txn.resolutionNumber || '—' }}
|
||||||
|
@if (txn.meetingDate) {
|
||||||
|
({{ txn.meetingDate | dateFormat }})
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
—
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr class="total-row">
|
||||||
|
<td colspan="6" class="text-right total-label">Closing Share Balance</td>
|
||||||
|
<td class="text-right closing-balance">{{ totalApprovedShares }}</td>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
} @else {
|
||||||
|
<div class="no-data-block">
|
||||||
|
<mat-icon>inbox</mat-icon>
|
||||||
|
<p>No transactions found for the selected date range.</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Statement Footer -->
|
||||||
|
<div class="statement-footer">
|
||||||
|
<div class="footer-note">
|
||||||
|
This is a computer-generated statement and does not require a signature. For queries, contact your branch or
|
||||||
|
call customer care.
|
||||||
|
</div>
|
||||||
|
<div class="footer-meta">Generated on: {{ maxDate | dateFormat }} | Account No: {{ accountNo }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,462 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ─── Variables ──────────────────────────────────────────────────────────────
|
||||||
|
$bank-primary: #1a3a6b; // deep navy
|
||||||
|
$bank-accent: #c8a951; // gold trim
|
||||||
|
$debit-color: #c0392b;
|
||||||
|
$credit-color: #1a7a3f;
|
||||||
|
$balance-color: #1a3a6b;
|
||||||
|
$border-color: #d0d7e3;
|
||||||
|
$row-alt: #f4f7fb;
|
||||||
|
|
||||||
|
// ─── Container ──────────────────────────────────────────────────────────────
|
||||||
|
.passbook-container {
|
||||||
|
padding: 16px;
|
||||||
|
background: #eef1f7;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Filter Bar ─────────────────────────────────────────────────────────────
|
||||||
|
.passbook-filter-bar {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border: 1px solid $border-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.passbook-filter-form {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.passbook-date-field {
|
||||||
|
width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.passbook-filter-spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Statement Wrapper ───────────────────────────────────────────────────────
|
||||||
|
.statement-wrapper {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid $border-color;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 2px 12px rgb(0 0 0 / 8%);
|
||||||
|
font-family: 'Segoe UI', Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Bank Header ─────────────────────────────────────────────────────────────
|
||||||
|
.bank-header {
|
||||||
|
background: $bank-primary;
|
||||||
|
padding: 18px 24px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
border-bottom: 4px solid $bank-accent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bank-logo-area {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bank-logo-circle {
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: $bank-accent;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
color: $bank-primary;
|
||||||
|
font-size: 28px;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bank-name-block {
|
||||||
|
.bank-title {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bank-subtitle {
|
||||||
|
color: $bank-accent;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 1.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.statement-label {
|
||||||
|
text-align: right;
|
||||||
|
|
||||||
|
.stmt-label-text {
|
||||||
|
color: $bank-accent;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stmt-period {
|
||||||
|
color: rgb(255 255 255 / 75%);
|
||||||
|
font-size: 11px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Account Details Band ────────────────────────────────────────────────────
|
||||||
|
.account-details-band {
|
||||||
|
background: #f0f3f9;
|
||||||
|
border-bottom: 1px solid $border-color;
|
||||||
|
padding: 14px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-details-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 12px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-block {
|
||||||
|
.detail-label {
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #7a8aaa;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.8px;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-value {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #222;
|
||||||
|
|
||||||
|
&.bold {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.mono {
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.status-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 1px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
background: #eee;
|
||||||
|
color: #555;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: #d4edda;
|
||||||
|
color: #155724;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Balance Summary Strip ───────────────────────────────────────────────────
|
||||||
|
.balance-summary-strip {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
background: $bank-primary;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-box {
|
||||||
|
flex: 1;
|
||||||
|
padding: 12px 20px;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
.balance-box-label {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgb(255 255 255 / 65%);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-box-value {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $bank-accent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-divider {
|
||||||
|
width: 1px;
|
||||||
|
background: rgb(255 255 255 / 15%);
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Transactions Table ───────────────────────────────────────────────────────
|
||||||
|
.statement-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 12.5px;
|
||||||
|
|
||||||
|
thead tr {
|
||||||
|
background: #e8edf5;
|
||||||
|
border-top: 1px solid $border-color;
|
||||||
|
border-bottom: 2px solid $bank-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
thead th {
|
||||||
|
padding: 9px 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $bank-primary;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr {
|
||||||
|
border-bottom: 1px solid #e8ecf3;
|
||||||
|
transition: background 0.1s;
|
||||||
|
|
||||||
|
&:nth-child(even) {
|
||||||
|
background: $row-alt;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #dce6f5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody td {
|
||||||
|
padding: 7px 10px;
|
||||||
|
vertical-align: middle;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
tfoot .total-row {
|
||||||
|
background: #e8edf5;
|
||||||
|
border-top: 2px solid $bank-primary;
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 9px 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Column widths ────────────────────────────────────────────────────────────
|
||||||
|
.col-sr {
|
||||||
|
width: 38px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-date {
|
||||||
|
width: 100px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-desc {
|
||||||
|
min-width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-ref {
|
||||||
|
width: 70px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-amount {
|
||||||
|
width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-balance {
|
||||||
|
width: 90px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Amount colors ────────────────────────────────────────────────────────────
|
||||||
|
.debit-amount {
|
||||||
|
color: $debit-color;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credit-amount {
|
||||||
|
color: $credit-color;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-amount {
|
||||||
|
color: $balance-color;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.total-label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $bank-primary;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.closing-balance {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $bank-primary;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Badges & misc ────────────────────────────────────────────────────────────
|
||||||
|
.txn-desc {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-right {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-center {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-muted {
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mono {
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── No Data ──────────────────────────────────────────────────────────────────
|
||||||
|
.no-data-block {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 48px;
|
||||||
|
color: #aaa;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 48px;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Statement Footer ─────────────────────────────────────────────────────────
|
||||||
|
.statement-footer {
|
||||||
|
background: #f0f3f9;
|
||||||
|
border-top: 1px solid $border-color;
|
||||||
|
padding: 12px 24px;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
.footer-note {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #888;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-meta {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #aaa;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Responsive ───────────────────────────────────────────────────────────────
|
||||||
|
@media (width <= 768px) {
|
||||||
|
.account-details-grid {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bank-header {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statement-label {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-summary-strip {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-divider {
|
||||||
|
width: auto;
|
||||||
|
height: 1px;
|
||||||
|
margin: 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.passbook-filter-form {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.passbook-date-field {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.passbook-filter-spacer {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 480px) {
|
||||||
|
.account-details-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Print ────────────────────────────────────────────────────────────────────
|
||||||
|
@media print {
|
||||||
|
.no-print {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.passbook-container {
|
||||||
|
background: #fff;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statement-wrapper {
|
||||||
|
border: none;
|
||||||
|
box-shadow: none;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bank-header {
|
||||||
|
-webkit-print-color-adjust: exact;
|
||||||
|
print-color-adjust: exact;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-summary-strip {
|
||||||
|
-webkit-print-color-adjust: exact;
|
||||||
|
print-color-adjust: exact;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
/**
|
||||||
|
* 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, ElementRef, ViewChild, inject } from '@angular/core';
|
||||||
|
import { ActivatedRoute } from '@angular/router';
|
||||||
|
import { UntypedFormBuilder, Validators } from '@angular/forms';
|
||||||
|
import { CurrencyPipe } from '@angular/common';
|
||||||
|
import { MatIcon } from '@angular/material/icon';
|
||||||
|
import { DateFormatPipe } from '../../../pipes/date-format.pipe';
|
||||||
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Share Account Passbook — a printable, chronological (oldest-first) ledger of every
|
||||||
|
* Purchase/Redeem entry, mirroring PassbookTabComponent (Savings) but for share holdings:
|
||||||
|
* shares purchased/redeemed instead of amount deposited/withdrawn, plus the Board Resolution
|
||||||
|
* Number / Date of Meeting on redeem rows.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-shares-passbook-tab',
|
||||||
|
templateUrl: './passbook-tab.component.html',
|
||||||
|
styleUrls: ['./passbook-tab.component.scss'],
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
...STANDALONE_SHARED_IMPORTS,
|
||||||
|
MatIcon,
|
||||||
|
DateFormatPipe,
|
||||||
|
CurrencyPipe
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class SharesPassbookTabComponent implements OnInit {
|
||||||
|
private route = inject(ActivatedRoute);
|
||||||
|
private formBuilder = inject(UntypedFormBuilder);
|
||||||
|
|
||||||
|
@ViewChild('passbookContent') passbookContent: ElementRef;
|
||||||
|
|
||||||
|
/** Account data */
|
||||||
|
accountNo = '';
|
||||||
|
clientName = '';
|
||||||
|
productName = '';
|
||||||
|
currency: any = null;
|
||||||
|
status = '';
|
||||||
|
activatedOnDate: any = null;
|
||||||
|
resolutionNumber = '';
|
||||||
|
savingsAccountNumber = '';
|
||||||
|
totalApprovedShares = 0;
|
||||||
|
currentMarketPrice = 0;
|
||||||
|
|
||||||
|
/** Transactions */
|
||||||
|
allTransactions: any[] = [];
|
||||||
|
filteredTransactions: any[] = [];
|
||||||
|
|
||||||
|
/** Date range form */
|
||||||
|
minDate = new Date(2000, 0, 1);
|
||||||
|
maxDate = new Date();
|
||||||
|
dateRangeForm: any;
|
||||||
|
isFiltered = false;
|
||||||
|
|
||||||
|
get currentValue(): number {
|
||||||
|
return this.totalApprovedShares * this.currentMarketPrice;
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.route.parent.data.subscribe((data: { sharesAccountData: any }) => {
|
||||||
|
const account = data.sharesAccountData;
|
||||||
|
this.accountNo = account.accountNo || '';
|
||||||
|
this.clientName = account.clientName || '';
|
||||||
|
this.productName = account.productName || '';
|
||||||
|
this.currency = account.currency || null;
|
||||||
|
this.status = account.status?.value || '';
|
||||||
|
this.activatedOnDate = account.timeline?.activatedDate || null;
|
||||||
|
this.resolutionNumber = account.resolutionNumber || '';
|
||||||
|
this.savingsAccountNumber = account.savingsAccountNumber || '';
|
||||||
|
this.totalApprovedShares = account.summary?.totalApprovedShares || 0;
|
||||||
|
this.currentMarketPrice = account.currentMarketPrice || 0;
|
||||||
|
|
||||||
|
const transactions = (account.purchasedShares || []).filter(
|
||||||
|
(transaction: any) => transaction.type.value !== 'Charge Payment'
|
||||||
|
);
|
||||||
|
this.allTransactions = this.recalculateRunningBalances(this.sortAscending(transactions));
|
||||||
|
this.filteredTransactions = [...this.allTransactions];
|
||||||
|
});
|
||||||
|
|
||||||
|
this.dateRangeForm = this.formBuilder.group({
|
||||||
|
fromDate: [
|
||||||
|
'',
|
||||||
|
Validators.required
|
||||||
|
],
|
||||||
|
toDate: [
|
||||||
|
'',
|
||||||
|
Validators.required
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
isRedeem(transaction: any): boolean {
|
||||||
|
return transaction.type?.value === 'Redeem';
|
||||||
|
}
|
||||||
|
|
||||||
|
filterTransactions(): void {
|
||||||
|
const fromDate = this.dateRangeForm.value.fromDate;
|
||||||
|
const toDate = this.dateRangeForm.value.toDate;
|
||||||
|
|
||||||
|
if (!fromDate || !toDate) return;
|
||||||
|
|
||||||
|
const from = new Date(fromDate).getTime();
|
||||||
|
const to = new Date(toDate).getTime();
|
||||||
|
|
||||||
|
this.filteredTransactions = this.allTransactions.filter((txn: any) => {
|
||||||
|
const txnDate = this.getTransactionDateAsTime(txn.purchasedDate);
|
||||||
|
return txnDate >= from && txnDate <= to;
|
||||||
|
});
|
||||||
|
this.isFiltered = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearFilter(): void {
|
||||||
|
this.dateRangeForm.reset();
|
||||||
|
this.filteredTransactions = [...this.allTransactions];
|
||||||
|
this.isFiltered = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getTransactionDateAsTime(date: any): number {
|
||||||
|
if (Array.isArray(date) && date.length >= 3) {
|
||||||
|
return new Date(date[0], date[1] - 1, date[2]).getTime();
|
||||||
|
}
|
||||||
|
return new Date(date).getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
printPassbook(): void {
|
||||||
|
const content = this.passbookContent?.nativeElement?.innerHTML;
|
||||||
|
if (!content) return;
|
||||||
|
|
||||||
|
const printWindow = window.open('', 'SharePassbook', 'height=700,width=900');
|
||||||
|
printWindow.document.write(`
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Share Account Passbook - ${this.accountNo}</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { font-family: 'Segoe UI', Arial, sans-serif; padding: 20px; color: #333; }
|
||||||
|
.passbook-header { text-align: center; margin-bottom: 20px; border-bottom: 2px solid #1a3a6b; padding-bottom: 12px; }
|
||||||
|
table { width: 100%; border-collapse: collapse; font-size: 11px; }
|
||||||
|
th { background: #1a3a6b; color: #fff; padding: 6px 8px; text-align: left; font-weight: 600; }
|
||||||
|
td { padding: 5px 8px; border-bottom: 1px solid #e0e0e0; }
|
||||||
|
tr:nth-child(even) { background: #fafafa; }
|
||||||
|
.text-right { text-align: right; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
${content}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`);
|
||||||
|
printWindow.document.close();
|
||||||
|
printWindow.focus();
|
||||||
|
printWindow.print();
|
||||||
|
printWindow.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
private recalculateRunningBalances(transactions: any[]): any[] {
|
||||||
|
let balance = 0;
|
||||||
|
return transactions.map((txn) => {
|
||||||
|
const shares = Number(txn.numberOfShares) || 0;
|
||||||
|
balance += this.isRedeem(txn) ? -shares : shares;
|
||||||
|
return { ...txn, runningBalance: balance };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private sortAscending(transactions: any[]): any[] {
|
||||||
|
return [...transactions].sort((a, b) => {
|
||||||
|
const dateA = this.getTransactionDateAsTime(a.purchasedDate);
|
||||||
|
const dateB = this.getTransactionDateAsTime(b.purchasedDate);
|
||||||
|
if (dateA !== dateB) return dateA - dateB;
|
||||||
|
return (a.id as number) - (b.id as number);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -145,6 +145,16 @@
|
|||||||
>
|
>
|
||||||
{{ 'labels.inputs.Transactions' | translate }}
|
{{ 'labels.inputs.Transactions' | translate }}
|
||||||
</a>
|
</a>
|
||||||
|
<a
|
||||||
|
mat-tab-link
|
||||||
|
[routerLink]="['./passbook']"
|
||||||
|
routerLinkActive
|
||||||
|
#passbook="routerLinkActive"
|
||||||
|
[active]="passbook.isActive"
|
||||||
|
*mifosxHasPermission="'READ_SHAREACCOUNTPURCHASE'"
|
||||||
|
>
|
||||||
|
Passbook
|
||||||
|
</a>
|
||||||
<a
|
<a
|
||||||
mat-tab-link
|
mat-tab-link
|
||||||
[routerLink]="['./charges']"
|
[routerLink]="['./charges']"
|
||||||
|
|||||||
@@ -50,12 +50,10 @@ export class SharesButtonsConfiguration {
|
|||||||
name: 'Reject Additional Shares',
|
name: 'Reject Additional Shares',
|
||||||
icon: 'arrow-left',
|
icon: 'arrow-left',
|
||||||
taskPermissionName: 'REJECTADDITIONAL_SHAREACCOUNT'
|
taskPermissionName: 'REJECTADDITIONAL_SHAREACCOUNT'
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Redeem Shares',
|
|
||||||
icon: 'arrow-left',
|
|
||||||
taskPermissionName: 'WITHDRAW_SAVINGSACCOUNT'
|
|
||||||
}
|
}
|
||||||
|
// 'Redeem Shares' is intentionally not offered here — a share withdrawal must go
|
||||||
|
// through the Clerk Dashboard maker-checker flow (Clerk Dashboard > My Share Requests),
|
||||||
|
// never a direct one-step action from this view.
|
||||||
];
|
];
|
||||||
break;
|
break;
|
||||||
case 'Submitted and pending approval':
|
case 'Submitted and pending approval':
|
||||||
|
|||||||
@@ -54,6 +54,25 @@
|
|||||||
</td>
|
</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="runningBalance">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>Share Balance</th>
|
||||||
|
<td mat-cell *matCellDef="let transaction">{{ transaction.runningBalance }}</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="resolutionMeeting">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>Resolution No. / Meeting Date</th>
|
||||||
|
<td mat-cell *matCellDef="let transaction">
|
||||||
|
@if (transaction.type.value === 'Redeem') {
|
||||||
|
{{ transaction.resolutionNumber || '—' }}
|
||||||
|
@if (transaction.meetingDate) {
|
||||||
|
({{ transaction.meetingDate | dateFormat }})
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
—
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||||
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
|
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -56,14 +56,18 @@ export class TransactionsTabComponent implements OnInit {
|
|||||||
transactionsData: any;
|
transactionsData: any;
|
||||||
/** Data source for transactions table. */
|
/** Data source for transactions table. */
|
||||||
dataSource: MatTableDataSource<any>;
|
dataSource: MatTableDataSource<any>;
|
||||||
/** Columns to be displayed in transactions table. */
|
/** Columns to be displayed in transactions table — a share account's passbook: every
|
||||||
|
* purchase/redeem entry plus the running share balance and, for a withdrawal, the Board
|
||||||
|
* Resolution Number / Date of Meeting that authorised it. */
|
||||||
displayedColumns: string[] = [
|
displayedColumns: string[] = [
|
||||||
'transactionDate',
|
'transactionDate',
|
||||||
'transactionType',
|
'transactionType',
|
||||||
'totalShares',
|
'totalShares',
|
||||||
'purchasedOrRedeemedPrice',
|
'purchasedOrRedeemedPrice',
|
||||||
'chargeAmount',
|
'chargeAmount',
|
||||||
'amountRecievedOrReturned'
|
'amountRecievedOrReturned',
|
||||||
|
'runningBalance',
|
||||||
|
'resolutionMeeting'
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -73,9 +77,19 @@ export class TransactionsTabComponent implements OnInit {
|
|||||||
constructor() {
|
constructor() {
|
||||||
this.route.parent.data.subscribe((data: { sharesAccountData: any }) => {
|
this.route.parent.data.subscribe((data: { sharesAccountData: any }) => {
|
||||||
this.shareAccountData = data.sharesAccountData;
|
this.shareAccountData = data.sharesAccountData;
|
||||||
this.transactionsData = this.shareAccountData.purchasedShares.filter(
|
this.transactionsData = this.shareAccountData.purchasedShares
|
||||||
(transaction: any) => transaction.type.value !== 'Charge Payment'
|
.filter((transaction: any) => transaction.type.value !== 'Charge Payment')
|
||||||
);
|
.slice()
|
||||||
|
.sort((a: any, b: any) => new Date(a.purchasedDate).getTime() - new Date(b.purchasedDate).getTime());
|
||||||
|
|
||||||
|
let runningBalance = 0;
|
||||||
|
this.transactionsData.forEach((transaction: any) => {
|
||||||
|
const shares = Number(transaction.numberOfShares) || 0;
|
||||||
|
runningBalance += transaction.type.value === 'Redeem' ? -shares : shares;
|
||||||
|
transaction.runningBalance = runningBalance;
|
||||||
|
});
|
||||||
|
// Passbook reads newest-first.
|
||||||
|
this.transactionsData.reverse();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { ShareAccountActionsResolver } from './common-resolvers/share-account-ac
|
|||||||
import { SharesAccountTemplateResolver } from './common-resolvers/shares-account-template.resolver';
|
import { SharesAccountTemplateResolver } from './common-resolvers/shares-account-template.resolver';
|
||||||
import { SharesAccountAndTemplateResolver } from './common-resolvers/share-account-and-template.resolver';
|
import { SharesAccountAndTemplateResolver } from './common-resolvers/share-account-and-template.resolver';
|
||||||
import { GeneralTabComponent } from './shares-account-view/general-tab/general-tab.component';
|
import { GeneralTabComponent } from './shares-account-view/general-tab/general-tab.component';
|
||||||
|
import { SharesPassbookTabComponent } from './shares-account-view/passbook-tab/passbook-tab.component';
|
||||||
|
|
||||||
/** Shares Routes */
|
/** Shares Routes */
|
||||||
const routes: Routes = [
|
const routes: Routes = [
|
||||||
@@ -75,6 +76,11 @@ const routes: Routes = [
|
|||||||
component: TransactionsTabComponent,
|
component: TransactionsTabComponent,
|
||||||
data: { title: 'Shares Account Transactions', breadcrumb: 'Transactions', routeParamBreadcrumb: false }
|
data: { title: 'Shares Account Transactions', breadcrumb: 'Transactions', routeParamBreadcrumb: false }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'passbook',
|
||||||
|
component: SharesPassbookTabComponent,
|
||||||
|
data: { title: 'Shares Account Passbook', breadcrumb: 'Passbook', routeParamBreadcrumb: false }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'charges',
|
path: 'charges',
|
||||||
component: ChargesTabComponent,
|
component: ChargesTabComponent,
|
||||||
|
|||||||
+77
@@ -0,0 +1,77 @@
|
|||||||
|
<!--
|
||||||
|
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/.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<h1 mat-dialog-title class="ced-title">
|
||||||
|
<span class="ced-title__icon">✉</span>
|
||||||
|
Approval Email
|
||||||
|
|
||||||
|
<div class="ced-status-row">
|
||||||
|
@if (data.emailSent) {
|
||||||
|
<span class="ced-chip ced-chip--sent"> <span class="ced-chip__dot"></span>Sent </span>
|
||||||
|
} @else {
|
||||||
|
<span class="ced-chip ced-chip--notsent"> <span class="ced-chip__dot"></span>Not sent </span>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (data.emailOpenedAt) {
|
||||||
|
<span class="ced-chip ced-chip--seen"> <fa-icon icon="eye" class="ced-chip__icon"></fa-icon>Seen </span>
|
||||||
|
} @else {
|
||||||
|
<span class="ced-chip ced-chip--notseen">
|
||||||
|
<fa-icon icon="eye-slash" class="ced-chip__icon"></fa-icon>Not yet seen
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div mat-dialog-content class="ced-content">
|
||||||
|
<!-- Classic Email Header (Gmail Style) -->
|
||||||
|
<div class="ced-gmail-header">
|
||||||
|
<h2 class="ced-gmail-subject">{{ data.emailSubject ?? '(No Subject)' }}</h2>
|
||||||
|
|
||||||
|
<div class="ced-gmail-meta">
|
||||||
|
<div class="ced-gmail-avatar">
|
||||||
|
<div class="ced-gmail-avatar-letter">C</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ced-gmail-meta-content">
|
||||||
|
<div class="ced-gmail-meta-top">
|
||||||
|
<span class="ced-gmail-sender-name">CBSNexora</span>
|
||||||
|
<span class="ced-gmail-sender-email"><{{ data.emailFrom ?? 'noreply@cbsnexora.in' }}></span>
|
||||||
|
<span class="ced-gmail-spacer"></span>
|
||||||
|
<span class="ced-gmail-date">{{ data.emailSentAt ? formatDate(data.emailSentAt) : '—' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="ced-gmail-meta-bottom">
|
||||||
|
<span class="ced-gmail-to">
|
||||||
|
to
|
||||||
|
@if (data.emailTo) {
|
||||||
|
{{ data.emailTo }}
|
||||||
|
} @else {
|
||||||
|
<span style="font-style: italic; color: #d32f2f">No email on file</span>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
<fa-icon icon="caret-down" class="ced-gmail-caret"></fa-icon>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- HTML email body -->
|
||||||
|
@if (data.emailBody) {
|
||||||
|
<div class="ced-email-body" [innerHTML]="safeBody"></div>
|
||||||
|
} @else {
|
||||||
|
<div class="ced-empty">
|
||||||
|
<p>No email content available.</p>
|
||||||
|
@if (!data.emailSent) {
|
||||||
|
<p class="ced-empty__hint">The email has not been sent yet for this edit.</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<mat-dialog-actions align="end">
|
||||||
|
<button mat-raised-button color="primary" mat-dialog-close>Close</button>
|
||||||
|
</mat-dialog-actions>
|
||||||
+234
@@ -0,0 +1,234 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ced-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a237e;
|
||||||
|
position: relative; /* For aligning badges to the right */
|
||||||
|
|
||||||
|
&__icon {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ced-content {
|
||||||
|
min-width: 560px;
|
||||||
|
max-width: 760px;
|
||||||
|
max-height: 72vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Gmail-Style Email Header ─────────────────────────────────────────────────── */
|
||||||
|
.ced-gmail-header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
padding: 0 4px;
|
||||||
|
font-family: Roboto, Inter, sans-serif; /* Gmail-like typography */
|
||||||
|
}
|
||||||
|
|
||||||
|
.ced-gmail-subject {
|
||||||
|
font-size: 1.35rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: #202124;
|
||||||
|
margin: 0 0 24px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ced-gmail-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center; /* Avatar vertically centered with text */
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ced-gmail-avatar {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: #f06292;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ced-gmail-avatar-letter {
|
||||||
|
color: white;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ced-gmail-meta-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-grow: 1;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ced-gmail-meta-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ced-gmail-spacer {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ced-gmail-sender-name {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #202124;
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ced-gmail-sender-email {
|
||||||
|
color: #5f6368;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ced-gmail-date {
|
||||||
|
color: #5f6368;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ced-gmail-meta-bottom {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ced-gmail-to {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #5f6368;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ced-gmail-caret {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #5f6368;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ced-status-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-left: auto; /* Pushes badges to the right in the flex container */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Status chips ───────────────────────────────────────────────────────── */
|
||||||
|
.ced-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 12px 4px 10px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
&__dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__icon {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--sent {
|
||||||
|
background: #e8f5e9;
|
||||||
|
color: #1b5e20;
|
||||||
|
|
||||||
|
.ced-chip__dot {
|
||||||
|
background: #43a047;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--notsent {
|
||||||
|
background: #f5f5f5;
|
||||||
|
color: #757575;
|
||||||
|
|
||||||
|
.ced-chip__dot {
|
||||||
|
background: #bdbdbd;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--seen {
|
||||||
|
background: #e3f2fd;
|
||||||
|
color: #0d47a1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--notseen {
|
||||||
|
background: #f5f5f5;
|
||||||
|
color: #9e9e9e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Divider ────────────────────────────────────────────────────────────── */
|
||||||
|
.ced-divider {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid #eee;
|
||||||
|
margin: 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Email body iframe-like container ───────────────────────────────────── */
|
||||||
|
.ced-email-body {
|
||||||
|
background: #fafafa;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px 24px;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
font-size: 0.93rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #333;
|
||||||
|
|
||||||
|
// Reset any Angular Material overrides inside the rendered HTML
|
||||||
|
table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
td,
|
||||||
|
th {
|
||||||
|
padding: 6px 12px;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Empty state ────────────────────────────────────────────────────────── */
|
||||||
|
.ced-empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 32px 0;
|
||||||
|
color: #9e9e9e;
|
||||||
|
|
||||||
|
&__hint {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
+69
@@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, inject } from '@angular/core';
|
||||||
|
import {
|
||||||
|
MAT_DIALOG_DATA,
|
||||||
|
MatDialogTitle,
|
||||||
|
MatDialogContent,
|
||||||
|
MatDialogActions,
|
||||||
|
MatDialogClose
|
||||||
|
} from '@angular/material/dialog';
|
||||||
|
import { CdkScrollable } from '@angular/cdk/scrolling';
|
||||||
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
|
||||||
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
|
|
||||||
|
export interface ClientEditEmailDialogData {
|
||||||
|
emailSubject: string | null;
|
||||||
|
emailBody: string | null;
|
||||||
|
emailSent: boolean;
|
||||||
|
emailSentAt: string | null;
|
||||||
|
emailOpenedAt: string | null;
|
||||||
|
emailFrom: string | null;
|
||||||
|
emailTo: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Displays the exact confirmation email sent to the client when their pending edit was approved.
|
||||||
|
* Shows subject, HTML body (rendered), sent timestamp, and read/seen status. */
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-client-edit-email-dialog',
|
||||||
|
templateUrl: './client-edit-email-dialog.component.html',
|
||||||
|
styleUrls: ['./client-edit-email-dialog.component.scss'],
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
...STANDALONE_SHARED_IMPORTS,
|
||||||
|
MatDialogTitle,
|
||||||
|
CdkScrollable,
|
||||||
|
MatDialogContent,
|
||||||
|
MatDialogActions,
|
||||||
|
MatDialogClose,
|
||||||
|
FaIconComponent
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class ClientEditEmailDialogComponent {
|
||||||
|
data = inject<ClientEditEmailDialogData>(MAT_DIALOG_DATA);
|
||||||
|
private sanitizer = inject(DomSanitizer);
|
||||||
|
|
||||||
|
get safeBody(): SafeHtml {
|
||||||
|
return this.sanitizer.bypassSecurityTrustHtml(this.data.emailBody ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
formatDate(isoDate: string | null | undefined): string {
|
||||||
|
if (!isoDate) return '—';
|
||||||
|
const d = new Date(isoDate);
|
||||||
|
return d.toLocaleString('en-IN', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+33
@@ -126,6 +126,29 @@
|
|||||||
<ng-container matColumnDef="actions">
|
<ng-container matColumnDef="actions">
|
||||||
<th mat-header-cell *matHeaderCellDef>Actions</th>
|
<th mat-header-cell *matHeaderCellDef>Actions</th>
|
||||||
<td mat-cell *matCellDef="let edit">
|
<td mat-cell *matCellDef="let edit">
|
||||||
|
<!-- ── Email status badges (compact, always visible) ── -->
|
||||||
|
<span
|
||||||
|
class="pce-email-badge"
|
||||||
|
[class.pce-email-badge--sent]="edit.emailSent"
|
||||||
|
[class.pce-email-badge--nosent]="!edit.emailSent"
|
||||||
|
[title]="edit.emailSent ? 'Email sent on ' + formatDate(edit.emailSentAt) : 'No confirmation email sent'"
|
||||||
|
>
|
||||||
|
<fa-icon icon="envelope"></fa-icon>
|
||||||
|
{{ edit.emailSent ? 'Sent' : 'Not Sent' }}
|
||||||
|
</span>
|
||||||
|
@if (edit.emailSent) {
|
||||||
|
<span
|
||||||
|
class="pce-email-badge"
|
||||||
|
[class.pce-email-badge--seen]="edit.emailOpenedAt"
|
||||||
|
[class.pce-email-badge--notseen]="!edit.emailOpenedAt"
|
||||||
|
[title]="edit.emailOpenedAt ? 'Opened on ' + formatDate(edit.emailOpenedAt) : 'Not yet opened'"
|
||||||
|
>
|
||||||
|
<fa-icon [icon]="edit.emailOpenedAt ? 'eye' : 'eye-slash'"></fa-icon>
|
||||||
|
{{ edit.emailOpenedAt ? 'Seen' : 'Not Seen' }}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- ── Action buttons ── -->
|
||||||
<button mat-stroked-button class="pce-btn pce-btn-view" (click)="viewCompare(edit)">
|
<button mat-stroked-button class="pce-btn pce-btn-view" (click)="viewCompare(edit)">
|
||||||
<fa-icon icon="eye" class="m-r-10"></fa-icon>Compare
|
<fa-icon icon="eye" class="m-r-10"></fa-icon>Compare
|
||||||
</button>
|
</button>
|
||||||
@@ -137,6 +160,16 @@
|
|||||||
<fa-icon icon="ban" class="m-r-10"></fa-icon>Reject
|
<fa-icon icon="ban" class="m-r-10"></fa-icon>Reject
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
|
@if (edit.emailSent) {
|
||||||
|
<button
|
||||||
|
mat-stroked-button
|
||||||
|
class="pce-btn pce-btn-email"
|
||||||
|
(click)="viewEmail(edit)"
|
||||||
|
title="View the exact confirmation email sent to the client"
|
||||||
|
>
|
||||||
|
<fa-icon icon="envelope" class="m-r-10"></fa-icon>View Email
|
||||||
|
</button>
|
||||||
|
}
|
||||||
</td>
|
</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
|
|||||||
+42
@@ -346,3 +346,45 @@ $text-muted: #64748b;
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Email badges ───────────────────────────────────────────────────────── */
|
||||||
|
.pce-email-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-right: 6px;
|
||||||
|
white-space: nowrap;
|
||||||
|
vertical-align: middle;
|
||||||
|
|
||||||
|
fa-icon {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--sent {
|
||||||
|
background: #e8f5e9;
|
||||||
|
color: #1b5e20;
|
||||||
|
border: 1px solid #c8e6c9;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--nosent {
|
||||||
|
background: #f5f5f5;
|
||||||
|
color: #9e9e9e;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--seen {
|
||||||
|
background: #e3f2fd;
|
||||||
|
color: #0d47a1;
|
||||||
|
border: 1px solid #bbdefb;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--notseen {
|
||||||
|
background: #fff3e0;
|
||||||
|
color: #e65100;
|
||||||
|
border: 1px solid #ffe0b2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+49
-1
@@ -34,6 +34,7 @@ import { InputBase } from 'app/shared/form-dialog/formfield/model/input-base';
|
|||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
import { NON_COMPARABLE_KEYS, PENDING_EDIT_FIELD_LABELS } from 'app/clients/shared/client-edit-diff.util';
|
import { NON_COMPARABLE_KEYS, PENDING_EDIT_FIELD_LABELS } from 'app/clients/shared/client-edit-diff.util';
|
||||||
import { ClientEditCompareDialogComponent } from './client-edit-compare-dialog/client-edit-compare-dialog.component';
|
import { ClientEditCompareDialogComponent } from './client-edit-compare-dialog/client-edit-compare-dialog.component';
|
||||||
|
import { ClientEditEmailDialogComponent } from './client-edit-email-dialog/client-edit-email-dialog.component';
|
||||||
|
|
||||||
/** Maps a payload key that holds a CodeValue/template id to the options array it should be
|
/** Maps a payload key that holds a CodeValue/template id to the options array it should be
|
||||||
* resolved against — populated from /clients/template plus the same per-code-value lookups the
|
* resolved against — populated from /clients/template plus the same per-code-value lookups the
|
||||||
@@ -97,7 +98,8 @@ const NATIONALITY_LABELS: Record<string, string> = {
|
|||||||
MatRowDef,
|
MatRowDef,
|
||||||
MatRow,
|
MatRow,
|
||||||
MatIcon,
|
MatIcon,
|
||||||
MatPaginatorModule
|
MatPaginatorModule,
|
||||||
|
ClientEditEmailDialogComponent
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class PendingClientEditsComponent implements OnInit {
|
export class PendingClientEditsComponent implements OnInit {
|
||||||
@@ -423,6 +425,52 @@ export class PendingClientEditsComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Opens the email preview dialog for an edit row. Fetches the email content first (subject,
|
||||||
|
* body, sent/opened timestamps) via the email-preview endpoint, then opens the dialog. */
|
||||||
|
viewEmail(edit: any): void {
|
||||||
|
this.clientsService.getClientEditEmailPreview(edit.clientId, edit.id).subscribe({
|
||||||
|
next: (preview) => {
|
||||||
|
try {
|
||||||
|
this.dialog.open(ClientEditEmailDialogComponent, {
|
||||||
|
width: '800px',
|
||||||
|
maxWidth: '96vw',
|
||||||
|
data: {
|
||||||
|
emailSubject: preview?.emailSubject || null,
|
||||||
|
emailBody: preview?.emailBody || null,
|
||||||
|
emailSent: preview?.emailSent || false,
|
||||||
|
emailSentAt: preview?.emailSentAt || null,
|
||||||
|
emailOpenedAt: preview?.emailOpenedAt || null,
|
||||||
|
emailFrom: preview?.emailFrom || null,
|
||||||
|
emailTo: preview?.emailTo || null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('Dialog open failed (next): ', e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
try {
|
||||||
|
// Fallback: open dialog with no-content state
|
||||||
|
this.dialog.open(ClientEditEmailDialogComponent, {
|
||||||
|
width: '800px',
|
||||||
|
maxWidth: '96vw',
|
||||||
|
data: {
|
||||||
|
emailSubject: null,
|
||||||
|
emailBody: null,
|
||||||
|
emailSent: false,
|
||||||
|
emailSentAt: null,
|
||||||
|
emailOpenedAt: null,
|
||||||
|
emailFrom: null,
|
||||||
|
emailTo: null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('Dialog open failed (error): ', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
reload(): void {
|
reload(): void {
|
||||||
this.load();
|
this.load();
|
||||||
}
|
}
|
||||||
|
|||||||
+86
@@ -0,0 +1,86 @@
|
|||||||
|
<!--
|
||||||
|
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="pg-card">
|
||||||
|
<div class="pos-section-label">Share Withdrawal Requests — Review, Approve or Reject</div>
|
||||||
|
<div class="inv-form-grid" style="padding: 12px 16px">
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Status</span>
|
||||||
|
<select [(ngModel)]="filterStatus" (change)="onFilterChange()">
|
||||||
|
@for (opt of statusOptions; track opt.value) {
|
||||||
|
<option [value]="opt.value">{{ opt.label }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="inv-form-field" style="align-self: end">
|
||||||
|
<button type="button" class="pg-btn pg-btn--ghost" [disabled]="loading" (click)="load()">Refresh</button>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<table class="pg-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Account No</th>
|
||||||
|
<th>Client</th>
|
||||||
|
<th class="num">Shares</th>
|
||||||
|
<th>Resolution No.</th>
|
||||||
|
<th>Meeting Date</th>
|
||||||
|
<th>Clerk</th>
|
||||||
|
<th>Manager</th>
|
||||||
|
<th>Submitted At</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@if (loading) {
|
||||||
|
<tr>
|
||||||
|
<td colspan="10" class="pg-empty">Loading…</td>
|
||||||
|
</tr>
|
||||||
|
} @else if (loaded && requests.length === 0) {
|
||||||
|
<tr>
|
||||||
|
<td colspan="10" class="pg-empty">No {{ filterStatus | lowercase }} share withdrawal requests.</td>
|
||||||
|
</tr>
|
||||||
|
} @else {
|
||||||
|
@for (r of requests; track r.id) {
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<span class="pg-chip">{{ r.account_no }}</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ r.client_name }}</td>
|
||||||
|
<td class="num">{{ r.requested_shares }}</td>
|
||||||
|
<td>{{ r.resolution_number }}</td>
|
||||||
|
<td>{{ formatDateTime(r.meeting_date) }}</td>
|
||||||
|
<td>{{ r.clerk_name }}</td>
|
||||||
|
<td>{{ r.manager_name || r.manager_username || '—' }}</td>
|
||||||
|
<td>{{ formatDateTime(r.submitted_at) }}</td>
|
||||||
|
<td>
|
||||||
|
<span [class]="statusClass(r.status)">{{ statusLabel(r.status) }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button type="button" class="pg-btn pg-btn--ghost" (click)="review(r)">Review</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (showReviewModal && reviewRequestId) {
|
||||||
|
<div class="swr-list-popup-backdrop" (click)="closeReviewModal()">
|
||||||
|
<div class="swr-list-popup-panel" (click)="$event.stopPropagation()">
|
||||||
|
<mifosx-share-withdrawal-request
|
||||||
|
[embedded]="true"
|
||||||
|
[overrideMode]="'review'"
|
||||||
|
[overrideRequestId]="reviewRequestId"
|
||||||
|
(closeRequested)="closeReviewModal()"
|
||||||
|
(actionCompleted)="onReviewActionCompleted()"
|
||||||
|
></mifosx-share-withdrawal-request>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
+64
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@keyframes swr-list-popup-fade {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes swr-list-popup-zoom {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.97) translateY(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1) translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.swr-list-popup-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
// No backdrop-filter here: filter/backdrop-filter on an ancestor makes browsers treat it as a new
|
||||||
|
// containing block for `position: fixed` descendants — the embedded review screen inside this popup
|
||||||
|
// renders its OWN fixed-position dialogs (Validate, Reject, Confirm-Submit), which would then be
|
||||||
|
// confined to this backdrop's box instead of the real viewport. Extra opacity on the background
|
||||||
|
// color compensates for the loss of the blur look.
|
||||||
|
background: rgb(15 25 45 / 65%);
|
||||||
|
z-index: 1100;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 32px 20px;
|
||||||
|
overflow-y: auto;
|
||||||
|
animation: swr-list-popup-fade 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swr-list-popup-panel {
|
||||||
|
background: #f7f9fc;
|
||||||
|
border-radius: 16px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1100px;
|
||||||
|
box-shadow: 0 24px 60px rgb(0 0 0 / 25%);
|
||||||
|
animation: swr-list-popup-zoom 0.25s ease-out;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.swr-list-popup-backdrop,
|
||||||
|
.swr-list-popup-panel {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
+128
@@ -0,0 +1,128 @@
|
|||||||
|
/**
|
||||||
|
* 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 { catchError } from 'rxjs/operators';
|
||||||
|
import { of } from 'rxjs';
|
||||||
|
|
||||||
|
import { ClerkShareWithdrawalService } from '../../../cashier/clerk-share-withdrawal.service';
|
||||||
|
import { ShareWithdrawalRequestComponent } from '../../../clerk/share-withdrawal-request/share-withdrawal-request.component';
|
||||||
|
|
||||||
|
/** Approval Center — Share Withdrawal Requests tab. A Bank Manager (checker) reviews, approves
|
||||||
|
* or rejects share withdrawal requests submitted from the Clerk Dashboard. Mirrors
|
||||||
|
* WithdrawalRequestsComponent (cash withdrawal), adapted for shares. Clicking "Review" opens the
|
||||||
|
* review screen in a popup on top of this list, rather than navigating away, so approving/rejecting
|
||||||
|
* drops the manager straight back into the (now refreshed) list. */
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-share-withdrawal-requests',
|
||||||
|
templateUrl: './share-withdrawal-requests.component.html',
|
||||||
|
styleUrls: [
|
||||||
|
'../../../cashier/cashier-shared.scss',
|
||||||
|
'../../../clerk/clerk-shared.scss',
|
||||||
|
'./share-withdrawal-requests.component.scss'
|
||||||
|
],
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
ShareWithdrawalRequestComponent
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class ShareWithdrawalRequestsComponent implements OnInit {
|
||||||
|
private service = inject(ClerkShareWithdrawalService);
|
||||||
|
|
||||||
|
requests: any[] = [];
|
||||||
|
totalRecords = 0;
|
||||||
|
loading = false;
|
||||||
|
loaded = false;
|
||||||
|
|
||||||
|
filterStatus = 'PENDING';
|
||||||
|
|
||||||
|
showReviewModal = false;
|
||||||
|
reviewRequestId: number | null = null;
|
||||||
|
|
||||||
|
get statusOptions(): { value: string; label: string }[] {
|
||||||
|
return [
|
||||||
|
{ value: 'PENDING', label: 'Pending' },
|
||||||
|
{ value: 'APPROVED', label: 'Approved' },
|
||||||
|
{ value: 'REJECTED', label: 'Rejected' }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
load(): void {
|
||||||
|
this.loading = true;
|
||||||
|
this.service
|
||||||
|
.getPendingRequests(this.filterStatus, 0, 50)
|
||||||
|
.pipe(catchError(() => of({ pageItems: [], totalFilteredRecords: 0 })))
|
||||||
|
.subscribe((res: any) => {
|
||||||
|
this.requests = res?.pageItems ?? [];
|
||||||
|
this.totalRecords = Number(res?.totalFilteredRecords ?? 0);
|
||||||
|
this.loading = false;
|
||||||
|
this.loaded = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onFilterChange(): void {
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
review(req: any): void {
|
||||||
|
this.reviewRequestId = req.id;
|
||||||
|
this.showReviewModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
closeReviewModal(): void {
|
||||||
|
this.showReviewModal = false;
|
||||||
|
this.reviewRequestId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
onReviewActionCompleted(): void {
|
||||||
|
this.closeReviewModal();
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
statusClass(status: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
PENDING: 'inv-badge--active',
|
||||||
|
APPROVED: 'inv-badge--matured',
|
||||||
|
REJECTED: 'inv-badge--written_off'
|
||||||
|
};
|
||||||
|
return `inv-badge ${map[status] ?? ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
statusLabel(status: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
PENDING: 'Pending',
|
||||||
|
APPROVED: 'Approved',
|
||||||
|
REJECTED: 'Rejected'
|
||||||
|
};
|
||||||
|
return map[status] ?? status;
|
||||||
|
}
|
||||||
|
|
||||||
|
formatDateTime(val: any): string {
|
||||||
|
if (!val) return '—';
|
||||||
|
try {
|
||||||
|
const d = new Date(val);
|
||||||
|
return d.toLocaleString('en-IN', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return String(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,143 +20,207 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Pill-style tab navigation — wraps onto multiple rows so every section stays visible and
|
<!-- Pill-style tab navigation -->
|
||||||
clickable at once, instead of being hidden behind prev/next scroll arrows. -->
|
<nav mat-tab-nav-bar class="ac-tabs" [tabPanel]="tabPanel">
|
||||||
<nav class="ac-tabs">
|
|
||||||
<a
|
<a
|
||||||
|
mat-tab-link
|
||||||
class="ac-tab"
|
class="ac-tab"
|
||||||
routerLink="./checker-inbox"
|
[routerLink]="['./checker-inbox']"
|
||||||
routerLinkActive="ac-tab--active"
|
routerLinkActive
|
||||||
|
#checkerInbox="routerLinkActive"
|
||||||
|
[active]="checkerInbox.isActive"
|
||||||
*mifosxHasPermission="'ALL_FUNCTIONS_READ'"
|
*mifosxHasPermission="'ALL_FUNCTIONS_READ'"
|
||||||
>
|
>
|
||||||
<fa-icon icon="inbox" class="ac-tab__ic"></fa-icon>
|
<fa-icon icon="inbox" class="ac-tab__ic"></fa-icon>
|
||||||
<span>Checker Inbox</span>
|
<span>Checker Inbox</span>
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
|
mat-tab-link
|
||||||
class="ac-tab"
|
class="ac-tab"
|
||||||
routerLink="./client-approval"
|
[routerLink]="['./client-approval']"
|
||||||
routerLinkActive="ac-tab--active"
|
routerLinkActive
|
||||||
|
#clientApproval="routerLinkActive"
|
||||||
|
[active]="clientApproval.isActive"
|
||||||
*mifosxHasPermission="'READ_CLIENT'"
|
*mifosxHasPermission="'READ_CLIENT'"
|
||||||
>
|
>
|
||||||
<fa-icon icon="user-check" class="ac-tab__ic"></fa-icon>
|
<fa-icon icon="user-check" class="ac-tab__ic"></fa-icon>
|
||||||
<span>Client Approval</span>
|
<span>Client Approval</span>
|
||||||
</a>
|
</a>
|
||||||
<a class="ac-tab" routerLink="./loan-approval" routerLinkActive="ac-tab--active" *mifosxHasPermission="'READ_LOAN'">
|
<a
|
||||||
|
mat-tab-link
|
||||||
|
class="ac-tab"
|
||||||
|
[routerLink]="['./loan-approval']"
|
||||||
|
routerLinkActive
|
||||||
|
#loanApproval="routerLinkActive"
|
||||||
|
[active]="loanApproval.isActive"
|
||||||
|
*mifosxHasPermission="'READ_LOAN'"
|
||||||
|
>
|
||||||
<fa-icon icon="hand-holding-usd" class="ac-tab__ic"></fa-icon>
|
<fa-icon icon="hand-holding-usd" class="ac-tab__ic"></fa-icon>
|
||||||
<span>Loan Approval</span>
|
<span>Loan Approval</span>
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
|
mat-tab-link
|
||||||
class="ac-tab"
|
class="ac-tab"
|
||||||
routerLink="./loan-disbursal"
|
[routerLink]="['./loan-disbursal']"
|
||||||
routerLinkActive="ac-tab--active"
|
routerLinkActive
|
||||||
|
#loanDisbursal="routerLinkActive"
|
||||||
|
[active]="loanDisbursal.isActive"
|
||||||
*mifosxHasPermission="'READ_LOAN'"
|
*mifosxHasPermission="'READ_LOAN'"
|
||||||
>
|
>
|
||||||
<fa-icon icon="money-bill-wave" class="ac-tab__ic"></fa-icon>
|
<fa-icon icon="money-bill-wave" class="ac-tab__ic"></fa-icon>
|
||||||
<span>Loan Disbursal</span>
|
<span>Loan Disbursal</span>
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
|
mat-tab-link
|
||||||
class="ac-tab"
|
class="ac-tab"
|
||||||
routerLink="./reschedule-loan"
|
[routerLink]="['./reschedule-loan']"
|
||||||
routerLinkActive="ac-tab--active"
|
routerLinkActive
|
||||||
|
#rescheduleLoan="routerLinkActive"
|
||||||
|
[active]="rescheduleLoan.isActive"
|
||||||
*mifosxHasPermission="'RESCHEDULE_LOAN'"
|
*mifosxHasPermission="'RESCHEDULE_LOAN'"
|
||||||
>
|
>
|
||||||
<fa-icon icon="calendar-alt" class="ac-tab__ic"></fa-icon>
|
<fa-icon icon="calendar-alt" class="ac-tab__ic"></fa-icon>
|
||||||
<span>Reschedule Loan</span>
|
<span>Reschedule Loan</span>
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
|
mat-tab-link
|
||||||
class="ac-tab"
|
class="ac-tab"
|
||||||
routerLink="./pending-account"
|
[routerLink]="['./pending-account']"
|
||||||
routerLinkActive="ac-tab--active"
|
routerLinkActive
|
||||||
|
#pendingAccount="routerLinkActive"
|
||||||
|
[active]="pendingAccount.isActive"
|
||||||
*mifosxHasPermission="'ALL_FUNCTIONS_READ'"
|
*mifosxHasPermission="'ALL_FUNCTIONS_READ'"
|
||||||
>
|
>
|
||||||
<fa-icon icon="hourglass-half" class="ac-tab__ic"></fa-icon>
|
<fa-icon icon="hourglass-half" class="ac-tab__ic"></fa-icon>
|
||||||
<span>Pending Account</span>
|
<span>Pending Account</span>
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
|
mat-tab-link
|
||||||
class="ac-tab"
|
class="ac-tab"
|
||||||
routerLink="./pending-savings-products"
|
[routerLink]="['./pending-savings-products']"
|
||||||
routerLinkActive="ac-tab--active"
|
routerLinkActive
|
||||||
|
#pendingSavingsProducts="routerLinkActive"
|
||||||
|
[active]="pendingSavingsProducts.isActive"
|
||||||
*mifosxHasPermission="'ALL_FUNCTIONS_READ'"
|
*mifosxHasPermission="'ALL_FUNCTIONS_READ'"
|
||||||
>
|
>
|
||||||
<fa-icon icon="piggy-bank" class="ac-tab__ic"></fa-icon>
|
<fa-icon icon="piggy-bank" class="ac-tab__ic"></fa-icon>
|
||||||
<span>Pending Product Edits</span>
|
<span>Pending Product Edits</span>
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
|
mat-tab-link
|
||||||
class="ac-tab"
|
class="ac-tab"
|
||||||
routerLink="./pending-savings-account-edits"
|
[routerLink]="['./pending-savings-account-edits']"
|
||||||
routerLinkActive="ac-tab--active"
|
routerLinkActive
|
||||||
|
#pendingSavingsAccountEdits="routerLinkActive"
|
||||||
|
[active]="pendingSavingsAccountEdits.isActive"
|
||||||
*mifosxHasPermission="'ALL_FUNCTIONS_READ'"
|
*mifosxHasPermission="'ALL_FUNCTIONS_READ'"
|
||||||
>
|
>
|
||||||
<fa-icon icon="edit" class="ac-tab__ic"></fa-icon>
|
<fa-icon icon="edit" class="ac-tab__ic"></fa-icon>
|
||||||
<span>Pending Account Edits</span>
|
<span>Pending Account Edits</span>
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
|
mat-tab-link
|
||||||
class="ac-tab"
|
class="ac-tab"
|
||||||
routerLink="./pending-share-account-edits"
|
[routerLink]="['./pending-share-account-edits']"
|
||||||
routerLinkActive="ac-tab--active"
|
routerLinkActive
|
||||||
|
#pendingShareAccountEdits="routerLinkActive"
|
||||||
|
[active]="pendingShareAccountEdits.isActive"
|
||||||
*mifosxHasPermission="'ALL_FUNCTIONS_READ'"
|
*mifosxHasPermission="'ALL_FUNCTIONS_READ'"
|
||||||
>
|
>
|
||||||
<fa-icon icon="edit" class="ac-tab__ic"></fa-icon>
|
<fa-icon icon="edit" class="ac-tab__ic"></fa-icon>
|
||||||
<span>Pending Share Account Edits</span>
|
<span>Pending Share Account Edits</span>
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
|
mat-tab-link
|
||||||
class="ac-tab"
|
class="ac-tab"
|
||||||
routerLink="./pending-client-edits"
|
[routerLink]="['./pending-client-edits']"
|
||||||
routerLinkActive="ac-tab--active"
|
routerLinkActive
|
||||||
|
#pendingClientEdits="routerLinkActive"
|
||||||
|
[active]="pendingClientEdits.isActive"
|
||||||
*mifosxHasPermission="'ALL_FUNCTIONS_READ'"
|
*mifosxHasPermission="'ALL_FUNCTIONS_READ'"
|
||||||
>
|
>
|
||||||
<fa-icon icon="edit" class="ac-tab__ic"></fa-icon>
|
<fa-icon icon="edit" class="ac-tab__ic"></fa-icon>
|
||||||
<span>Pending Client Edits</span>
|
<span>Pending Client Edits</span>
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
|
mat-tab-link
|
||||||
class="ac-tab"
|
class="ac-tab"
|
||||||
routerLink="./rejected-account"
|
[routerLink]="['./rejected-account']"
|
||||||
routerLinkActive="ac-tab--active"
|
routerLinkActive
|
||||||
|
#rejectedAccount="routerLinkActive"
|
||||||
|
[active]="rejectedAccount.isActive"
|
||||||
*mifosxHasPermission="'ALL_FUNCTIONS_READ'"
|
*mifosxHasPermission="'ALL_FUNCTIONS_READ'"
|
||||||
>
|
>
|
||||||
<fa-icon icon="ban" class="ac-tab__ic"></fa-icon>
|
<fa-icon icon="ban" class="ac-tab__ic"></fa-icon>
|
||||||
<span>Reject Account</span>
|
<span>Reject Account</span>
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
|
mat-tab-link
|
||||||
class="ac-tab"
|
class="ac-tab"
|
||||||
routerLink="./terminated-account"
|
[routerLink]="['./terminated-account']"
|
||||||
routerLinkActive="ac-tab--active"
|
routerLinkActive
|
||||||
|
#terminatedAccount="routerLinkActive"
|
||||||
|
[active]="terminatedAccount.isActive"
|
||||||
*mifosxHasPermission="'ALL_FUNCTIONS_READ'"
|
*mifosxHasPermission="'ALL_FUNCTIONS_READ'"
|
||||||
>
|
>
|
||||||
<fa-icon icon="times-circle" class="ac-tab__ic"></fa-icon>
|
<fa-icon icon="times-circle" class="ac-tab__ic"></fa-icon>
|
||||||
<span>Terminated Account</span>
|
<span>Terminated Account</span>
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
|
mat-tab-link
|
||||||
class="ac-tab"
|
class="ac-tab"
|
||||||
routerLink="./withdrawal-requests"
|
[routerLink]="['./withdrawal-requests']"
|
||||||
routerLinkActive="ac-tab--active"
|
routerLinkActive
|
||||||
|
#withdrawalRequests="routerLinkActive"
|
||||||
|
[active]="withdrawalRequests.isActive"
|
||||||
*mifosxHasPermission="'ALL_FUNCTIONS_READ'"
|
*mifosxHasPermission="'ALL_FUNCTIONS_READ'"
|
||||||
>
|
>
|
||||||
<fa-icon icon="money-bill-wave" class="ac-tab__ic"></fa-icon>
|
<fa-icon icon="money-bill-wave" class="ac-tab__ic"></fa-icon>
|
||||||
<span>Withdrawal Requests</span>
|
<span>Withdrawal Requests</span>
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
|
mat-tab-link
|
||||||
class="ac-tab"
|
class="ac-tab"
|
||||||
routerLink="./deposit-requests"
|
[routerLink]="['./share-withdrawal-requests']"
|
||||||
routerLinkActive="ac-tab--active"
|
routerLinkActive
|
||||||
|
#shareWithdrawalRequests="routerLinkActive"
|
||||||
|
[active]="shareWithdrawalRequests.isActive"
|
||||||
|
*mifosxHasPermission="'ALL_FUNCTIONS_READ'"
|
||||||
|
>
|
||||||
|
<fa-icon icon="hand-holding-usd" class="ac-tab__ic"></fa-icon>
|
||||||
|
<span>Share Withdrawal Requests</span>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
mat-tab-link
|
||||||
|
class="ac-tab"
|
||||||
|
[routerLink]="['./deposit-requests']"
|
||||||
|
routerLinkActive
|
||||||
|
#depositRequests="routerLinkActive"
|
||||||
|
[active]="depositRequests.isActive"
|
||||||
*mifosxHasPermission="'ALL_FUNCTIONS_READ'"
|
*mifosxHasPermission="'ALL_FUNCTIONS_READ'"
|
||||||
>
|
>
|
||||||
<fa-icon icon="hand-holding-usd" class="ac-tab__ic"></fa-icon>
|
<fa-icon icon="hand-holding-usd" class="ac-tab__ic"></fa-icon>
|
||||||
<span>Deposit Requests</span>
|
<span>Deposit Requests</span>
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
|
mat-tab-link
|
||||||
class="ac-tab"
|
class="ac-tab"
|
||||||
routerLink="./disbursement-requests"
|
[routerLink]="['./disbursement-requests']"
|
||||||
routerLinkActive="ac-tab--active"
|
routerLinkActive
|
||||||
|
#disbursementRequests="routerLinkActive"
|
||||||
|
[active]="disbursementRequests.isActive"
|
||||||
*mifosxHasPermission="'ALL_FUNCTIONS_READ'"
|
*mifosxHasPermission="'ALL_FUNCTIONS_READ'"
|
||||||
>
|
>
|
||||||
<fa-icon icon="money-bill-wave" class="ac-tab__ic"></fa-icon>
|
<fa-icon icon="money-bill-wave" class="ac-tab__ic"></fa-icon>
|
||||||
<span>Disbursement & Transfers</span>
|
<span>Disbursement & Transfers</span>
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
|
mat-tab-link
|
||||||
class="ac-tab"
|
class="ac-tab"
|
||||||
routerLink="./chequebook-requests"
|
[routerLink]="['./chequebook-requests']"
|
||||||
routerLinkActive="ac-tab--active"
|
routerLinkActive
|
||||||
|
#chequebookRequests="routerLinkActive"
|
||||||
|
[active]="chequebookRequests.isActive"
|
||||||
*mifosxHasPermission="'READ_CHEQUEBOOKPENDINGREQUEST'"
|
*mifosxHasPermission="'READ_CHEQUEBOOKPENDINGREQUEST'"
|
||||||
>
|
>
|
||||||
<fa-icon icon="money-check" class="ac-tab__ic"></fa-icon>
|
<fa-icon icon="money-check" class="ac-tab__ic"></fa-icon>
|
||||||
@@ -164,7 +228,7 @@
|
|||||||
</a>
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="ac-tab-panel">
|
<mat-tab-nav-panel #tabPanel>
|
||||||
<router-outlet></router-outlet>
|
<router-outlet></router-outlet>
|
||||||
</div>
|
</mat-tab-nav-panel>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { Route } from '../core/route/route.service';
|
|||||||
import { CheckerInboxAndTasksComponent } from './checker-inbox-and-tasks/checker-inbox-and-tasks.component';
|
import { CheckerInboxAndTasksComponent } from './checker-inbox-and-tasks/checker-inbox-and-tasks.component';
|
||||||
import { CheckerInboxComponent } from './checker-inbox-and-tasks-tabs/checker-inbox/checker-inbox.component';
|
import { CheckerInboxComponent } from './checker-inbox-and-tasks-tabs/checker-inbox/checker-inbox.component';
|
||||||
import { WithdrawalRequestsComponent } from './checker-inbox-and-tasks-tabs/withdrawal-requests/withdrawal-requests.component';
|
import { WithdrawalRequestsComponent } from './checker-inbox-and-tasks-tabs/withdrawal-requests/withdrawal-requests.component';
|
||||||
|
import { ShareWithdrawalRequestsComponent } from './checker-inbox-and-tasks-tabs/share-withdrawal-requests/share-withdrawal-requests.component';
|
||||||
import { DepositRequestsComponent } from './checker-inbox-and-tasks-tabs/deposit-requests/deposit-requests.component';
|
import { DepositRequestsComponent } from './checker-inbox-and-tasks-tabs/deposit-requests/deposit-requests.component';
|
||||||
import { DisbursementRequestsComponent } from './checker-inbox-and-tasks-tabs/disbursement-requests/disbursement-requests.component';
|
import { DisbursementRequestsComponent } from './checker-inbox-and-tasks-tabs/disbursement-requests/disbursement-requests.component';
|
||||||
import { ClientApprovalComponent } from './checker-inbox-and-tasks-tabs/client-approval/client-approval.component';
|
import { ClientApprovalComponent } from './checker-inbox-and-tasks-tabs/client-approval/client-approval.component';
|
||||||
@@ -159,6 +160,11 @@ const routes: Routes = [
|
|||||||
component: WithdrawalRequestsComponent,
|
component: WithdrawalRequestsComponent,
|
||||||
data: { title: 'Withdrawal Requests' }
|
data: { title: 'Withdrawal Requests' }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'share-withdrawal-requests',
|
||||||
|
component: ShareWithdrawalRequestsComponent,
|
||||||
|
data: { title: 'Share Withdrawal Requests' }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'deposit-requests',
|
path: 'deposit-requests',
|
||||||
component: DepositRequestsComponent,
|
component: DepositRequestsComponent,
|
||||||
@@ -200,6 +206,19 @@ const routes: Routes = [
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'checker-inbox-and-tasks/share-withdrawal-requests',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: ':id/review',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('../clerk/share-withdrawal-request/share-withdrawal-request.component').then(
|
||||||
|
(m) => m.ShareWithdrawalRequestComponent
|
||||||
|
),
|
||||||
|
data: { title: 'Review Share Withdrawal Request', breadcrumb: 'Review Share Withdrawal', mode: 'review' }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'checker-inbox-and-tasks/disbursement-requests',
|
path: 'checker-inbox-and-tasks/disbursement-requests',
|
||||||
children: [
|
children: [
|
||||||
|
|||||||
@@ -0,0 +1,325 @@
|
|||||||
|
<!--
|
||||||
|
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="tdsr">
|
||||||
|
<div class="tdsr__head">
|
||||||
|
<div class="tdsr__head-l">
|
||||||
|
<fa-icon icon="percentage" class="tdsr__head-ic"></fa-icon>
|
||||||
|
<div>
|
||||||
|
<h2 class="tdsr__title">Deposit Interest & TDS</h2>
|
||||||
|
<div class="tdsr__sub">
|
||||||
|
Quarter-wise and yearly interest on time deposits, with tax deductible under {{ statute.section }} ·
|
||||||
|
{{ scopeLabel }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="tdsr__btn" (click)="exportWorkbook()" [disabled]="exporting">
|
||||||
|
<fa-icon icon="download"></fa-icon>
|
||||||
|
{{ exporting ? 'Preparing…' : 'Export' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Summary -->
|
||||||
|
<div class="tdsr__kpis">
|
||||||
|
<div class="tdsr__kpi">
|
||||||
|
<div class="tdsr__kpi-n">{{ summary.deductees | number }}</div>
|
||||||
|
<div class="tdsr__kpi-l">Deductees</div>
|
||||||
|
</div>
|
||||||
|
<div class="tdsr__kpi">
|
||||||
|
<div class="tdsr__kpi-n">₹{{ summary.applicableInterest | number: '1.0-0' }}</div>
|
||||||
|
<div class="tdsr__kpi-l">Interest on FD / RD</div>
|
||||||
|
</div>
|
||||||
|
<div class="tdsr__kpi tdsr__kpi--muted">
|
||||||
|
<div class="tdsr__kpi-n">₹{{ summary.exemptInterest | number: '1.0-0' }}</div>
|
||||||
|
<div class="tdsr__kpi-l">Savings interest — outside the charge</div>
|
||||||
|
</div>
|
||||||
|
<div class="tdsr__kpi tdsr__kpi--warn">
|
||||||
|
<div class="tdsr__kpi-n">₹{{ summary.totalTds | number: '1.0-0' }}</div>
|
||||||
|
<div class="tdsr__kpi-l">TDS payable</div>
|
||||||
|
</div>
|
||||||
|
<div class="tdsr__kpi tdsr__kpi--warn">
|
||||||
|
<div class="tdsr__kpi-n">{{ summary.crossed | number }}</div>
|
||||||
|
<div class="tdsr__kpi-l">Crossed the threshold</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="tdsr__kpi tdsr__kpi--action" (click)="showNearThreshold()">
|
||||||
|
<div class="tdsr__kpi-n">{{ summary.nearThreshold | number }}</div>
|
||||||
|
<div class="tdsr__kpi-l">Within ₹{{ nearBand | number: '1.0-0' }} of it — chase a declaration</div>
|
||||||
|
</button>
|
||||||
|
<div class="tdsr__kpi tdsr__kpi--ok">
|
||||||
|
<div class="tdsr__kpi-n">₹{{ summary.tdsSaved | number: '1.0-0' }}</div>
|
||||||
|
<div class="tdsr__kpi-l">Saved by {{ summary.declarations | number }} declarations</div>
|
||||||
|
</div>
|
||||||
|
@if (summary.noPan > 0) {
|
||||||
|
<div class="tdsr__kpi tdsr__kpi--warn">
|
||||||
|
<div class="tdsr__kpi-n">{{ summary.noPan | number }}</div>
|
||||||
|
<div class="tdsr__kpi-l">No PAN — deducted at 20%</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quarter strip -->
|
||||||
|
<div class="tdsr__card">
|
||||||
|
<div class="tdsr__card-head"><fa-icon icon="calendar"></fa-icon> Quarter by quarter</div>
|
||||||
|
<div class="tdsr__qstrip">
|
||||||
|
@for (q of summary.byQuarter; track q.quarter) {
|
||||||
|
<div class="tdsr__qcell">
|
||||||
|
<div class="tdsr__qcell-l">{{ quarterLabel[q.quarter] }}</div>
|
||||||
|
<div class="tdsr__qcell-n">₹{{ q.interest | number: '1.0-0' }}</div>
|
||||||
|
<div class="tdsr__qcell-s">TDS ₹{{ q.tds | number: '1.0-0' }}</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<div class="tdsr__filters">
|
||||||
|
<label class="tdsr__filter">
|
||||||
|
<span>Financial year</span>
|
||||||
|
<select [(ngModel)]="filter.fy">
|
||||||
|
@for (year of financialYears; track year) {
|
||||||
|
<option [value]="year">FY {{ year }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
@if (canSelectBranch) {
|
||||||
|
<label class="tdsr__filter">
|
||||||
|
<span>Branch</span>
|
||||||
|
<select [(ngModel)]="filter.branchCode">
|
||||||
|
<option value="">All branches</option>
|
||||||
|
@for (b of branches; track b.code) {
|
||||||
|
<option [value]="b.code">{{ b.name }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
|
||||||
|
<label class="tdsr__filter">
|
||||||
|
<span>Quarter</span>
|
||||||
|
<select [(ngModel)]="filter.quarter">
|
||||||
|
<option [ngValue]="0">Whole year</option>
|
||||||
|
@for (q of quarters; track q) {
|
||||||
|
<option [ngValue]="q">{{ quarterLabel[q] }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="tdsr__filter">
|
||||||
|
<span>Threshold band</span>
|
||||||
|
<select [(ngModel)]="filter.band">
|
||||||
|
<option value="">Both bands</option>
|
||||||
|
<option value="GENERAL">General (below 60)</option>
|
||||||
|
<option value="SENIOR">Senior citizen (60+)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="tdsr__filter tdsr__filter--wide">
|
||||||
|
<span>Customer / CIF / PAN</span>
|
||||||
|
<input type="text" [(ngModel)]="filter.search" placeholder="name, CIF or PAN" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="tdsr__check">
|
||||||
|
<input type="checkbox" [(ngModel)]="filter.crossedOnly" />
|
||||||
|
<span>Threshold crossed only</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="tdsr__check">
|
||||||
|
<input type="checkbox" [(ngModel)]="filter.nearThresholdOnly" />
|
||||||
|
<span>Near threshold only</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
@if (hasFilters) {
|
||||||
|
<button type="button" class="tdsr__clear" (click)="clearFilters()"><fa-icon icon="times"></fa-icon> Clear</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Table -->
|
||||||
|
<div class="tdsr__card">
|
||||||
|
<div class="tdsr__table-wrap">
|
||||||
|
<table class="tdsr__table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Customer · PAN</th>
|
||||||
|
<th>Branch</th>
|
||||||
|
<th>Form · Band</th>
|
||||||
|
<th class="num">Q1</th>
|
||||||
|
<th class="num">Q2</th>
|
||||||
|
<th class="num">Q3</th>
|
||||||
|
<th class="num">Q4</th>
|
||||||
|
<th class="num">Year total</th>
|
||||||
|
<th class="num">Threshold</th>
|
||||||
|
<th>Crossed</th>
|
||||||
|
<th class="num">TDS</th>
|
||||||
|
<th class="num">Saved</th>
|
||||||
|
<th class="col-actions"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (row of rows; track trackRow($index, row)) {
|
||||||
|
<tr [class.tdsr__row--warn]="!row.panOnRecord" [class.tdsr__row--near]="row.nearThreshold">
|
||||||
|
<td>
|
||||||
|
<div class="tdsr__name">{{ row.deductee.name }}</div>
|
||||||
|
<div class="mono small muted">
|
||||||
|
{{ row.deductee.customerId }} ·
|
||||||
|
@if (row.panOnRecord) {
|
||||||
|
{{ row.deductee.pan }}
|
||||||
|
} @else {
|
||||||
|
<span class="tdsr__pill tdsr__pill--warn">no PAN</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="small">{{ branchName(row.branchCode) }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="tdsr__pill" [class.tdsr__pill--ok]="row.hasDeclaration">
|
||||||
|
{{ row.form === '121' ? '121' : row.form }}
|
||||||
|
</span>
|
||||||
|
<div class="small muted">{{ row.band === 'SENIOR' ? 'Senior' : 'General' }}</div>
|
||||||
|
@if (row.hasDeclaration) {
|
||||||
|
<div class="mono small muted">{{ row.declarationUin }}</div>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
@for (q of quarters; track q) {
|
||||||
|
<td class="num small">
|
||||||
|
@if (row.quarters[q]) {
|
||||||
|
₹{{ row.quarters[q] | number: '1.0-0' }}
|
||||||
|
} @else {
|
||||||
|
<span class="muted">—</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
}
|
||||||
|
<td class="num">
|
||||||
|
<strong>₹{{ row.tdsApplicableInterest | number: '1.0-0' }}</strong>
|
||||||
|
@if (row.exemptInterest) {
|
||||||
|
<div class="small muted">+₹{{ row.exemptInterest | number: '1.0-0' }} SB (exempt)</div>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td class="num small muted">₹{{ row.threshold | number: '1.0-0' }}</td>
|
||||||
|
<td class="small">
|
||||||
|
@if (row.crossedInQuarter) {
|
||||||
|
<span class="tdsr__pill tdsr__pill--warn">Q{{ row.crossedInQuarter }}</span>
|
||||||
|
} @else if (row.nearThreshold) {
|
||||||
|
<span class="tdsr__pill tdsr__pill--near"> ₹{{ row.headroom | number: '1.0-0' }} left </span>
|
||||||
|
} @else {
|
||||||
|
<span class="muted">—</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td class="num">
|
||||||
|
@if (row.schedule.totalTds) {
|
||||||
|
<strong>₹{{ row.schedule.totalTds | number: '1.0-0' }}</strong>
|
||||||
|
<div class="small muted">@ {{ row.rateApplied * 100 | number: '1.0-0' }}%</div>
|
||||||
|
} @else {
|
||||||
|
<span class="muted">—</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td class="num">
|
||||||
|
@if (row.tdsSaved) {
|
||||||
|
<strong class="tdsr__ok">₹{{ row.tdsSaved | number: '1.0-0' }}</strong>
|
||||||
|
} @else {
|
||||||
|
<span class="muted">—</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td class="col-actions">
|
||||||
|
<button type="button" class="tdsr__icon-btn" (click)="toggleExpanded(row)">
|
||||||
|
<fa-icon [icon]="isExpanded(row) ? 'chevron-up' : 'chevron-down'"></fa-icon>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
@if (isExpanded(row)) {
|
||||||
|
<tr class="tdsr__row--detail">
|
||||||
|
<td colspan="13">
|
||||||
|
@for (note of row.schedule.notes; track note) {
|
||||||
|
<div class="tdsr__note">
|
||||||
|
<fa-icon icon="info-circle"></fa-icon>
|
||||||
|
<div>{{ note }}</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (row.jointHeldInterest) {
|
||||||
|
<div class="tdsr__note">
|
||||||
|
<fa-icon icon="info-circle"></fa-icon>
|
||||||
|
<div>
|
||||||
|
₹{{ row.jointHeldInterest | number: '1.0-0' }} of interest sits on deposits where this customer
|
||||||
|
is a joint holder but not the first-named one. Tax there is deducted against the first-named
|
||||||
|
holder, so it is excluded from the figures above.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<table class="tdsr__table tdsr__table--inner">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Quarter</th>
|
||||||
|
<th class="num">Interest</th>
|
||||||
|
<th class="num">Cumulative</th>
|
||||||
|
<th class="num">Liability to date</th>
|
||||||
|
<th class="num">TDS this quarter</th>
|
||||||
|
<th>Return due</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (line of row.schedule.lines; track line.quarter) {
|
||||||
|
<tr [class.tdsr__row--warn]="line.thresholdCrossedHere">
|
||||||
|
<td class="small">
|
||||||
|
{{ line.label }}
|
||||||
|
@if (line.thresholdCrossedHere) {
|
||||||
|
<span class="tdsr__pill tdsr__pill--warn">crossed here</span>
|
||||||
|
}
|
||||||
|
@if (line.declarationActive) {
|
||||||
|
<span class="tdsr__pill tdsr__pill--ok">Form 121 active</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td class="num">₹{{ line.interest | number: '1.0-0' }}</td>
|
||||||
|
<td class="num">₹{{ line.cumInterest | number: '1.0-0' }}</td>
|
||||||
|
<td class="num">₹{{ line.cumLiability | number: '1.0-0' }}</td>
|
||||||
|
<td class="num">
|
||||||
|
@if (line.tds !== 0) {
|
||||||
|
<strong>₹{{ line.tds | number: '1.0-0' }}</strong>
|
||||||
|
} @else {
|
||||||
|
<span class="muted">—</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td class="small muted">{{ line.dueDate }}</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="tdsr__meta">
|
||||||
|
<span
|
||||||
|
>Settlement: <strong>{{ row.mode === 'QUARTERLY' ? 'Quarterly' : 'Year end' }}</strong> (set by
|
||||||
|
the deposit product)</span
|
||||||
|
>
|
||||||
|
@if (row.declarationAcceptedOn) {
|
||||||
|
<span
|
||||||
|
>Declaration accepted: <strong>{{ row.declarationAcceptedOn | date: 'd MMM y' }}</strong></span
|
||||||
|
>
|
||||||
|
}
|
||||||
|
@if (row.paidWithoutDeduction) {
|
||||||
|
<span>
|
||||||
|
Paid without deduction:
|
||||||
|
<strong>₹{{ row.paidWithoutDeduction | number: '1.0-0' }}</strong> — reportable in
|
||||||
|
{{ statute.statement }}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@if (rows.length === 0) {
|
||||||
|
<tr>
|
||||||
|
<td colspan="13" class="tdsr__empty">
|
||||||
|
<fa-icon icon="info-circle"></fa-icon> No deductees match the current filters.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
/**
|
||||||
|
* 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 { FormsModule } from '@angular/forms';
|
||||||
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
import * as ExcelJS from 'exceljs';
|
||||||
|
|
||||||
|
import { BranchScopeService } from '../core/branch-scope.service';
|
||||||
|
import { GL_BRANCHES, glBranchName } from '../gl-reports/gl-reports-data';
|
||||||
|
import { FY_TAX_CONFIG, Quarter, QUARTER_LABEL } from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute';
|
||||||
|
import { TDS_FYS } from './tds-reports-data';
|
||||||
|
import {
|
||||||
|
NEAR_THRESHOLD_BAND,
|
||||||
|
TdsInterestRow,
|
||||||
|
TdsInterestSummary,
|
||||||
|
TdsReportFilter,
|
||||||
|
tdsInterestRows,
|
||||||
|
tdsInterestSummary
|
||||||
|
} from './tds-reports.compute';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-tds-interest-report',
|
||||||
|
templateUrl: './tds-interest-report.component.html',
|
||||||
|
styleUrls: ['./tds-reports.shared.scss'],
|
||||||
|
imports: [
|
||||||
|
...STANDALONE_SHARED_IMPORTS,
|
||||||
|
FaIconComponent,
|
||||||
|
FormsModule
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class TdsInterestReportComponent implements OnInit {
|
||||||
|
private scope = inject(BranchScopeService);
|
||||||
|
|
||||||
|
readonly branches = GL_BRANCHES.filter((b) => b.code !== 'HO');
|
||||||
|
readonly financialYears = TDS_FYS;
|
||||||
|
readonly quarterLabel = QUARTER_LABEL;
|
||||||
|
readonly nearBand = NEAR_THRESHOLD_BAND;
|
||||||
|
readonly quarters: Quarter[] = [
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
3,
|
||||||
|
4
|
||||||
|
];
|
||||||
|
|
||||||
|
filter: TdsReportFilter = {
|
||||||
|
fy: TDS_FYS[0],
|
||||||
|
branchCode: '',
|
||||||
|
quarter: 0,
|
||||||
|
band: '',
|
||||||
|
search: '',
|
||||||
|
crossedOnly: false,
|
||||||
|
nearThresholdOnly: false
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Which customer's quarter-wise detail is open. */
|
||||||
|
expandedId: string | null = null;
|
||||||
|
exporting = false;
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
// A branch login sees only its own book and cannot widen the scope.
|
||||||
|
const own = this.scope.ownBranchCode;
|
||||||
|
if (own) {
|
||||||
|
this.filter.branchCode = own;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provision and return references for the selected year. The charge moved from
|
||||||
|
* section 194A to section 393(1) on 01 April 2026 and the quarterly return
|
||||||
|
* became Form 140, so the labels are read from the config rather than written
|
||||||
|
* into the template.
|
||||||
|
*/
|
||||||
|
get statute(): { section: string; statement: string; frequency: string } {
|
||||||
|
const cfg = FY_TAX_CONFIG[this.filter.fy];
|
||||||
|
return {
|
||||||
|
section: cfg?.interestTdsSection ?? '—',
|
||||||
|
statement: cfg?.tdsStatementForm ?? '—',
|
||||||
|
frequency: cfg?.declarationStatementFrequency === 'MONTHLY' ? 'monthly' : 'quarterly'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
get canSelectBranch(): boolean {
|
||||||
|
return this.scope.canSelectBranch;
|
||||||
|
}
|
||||||
|
|
||||||
|
get scopeLabel(): string {
|
||||||
|
return this.filter.branchCode ? glBranchName(this.filter.branchCode) : 'All branches';
|
||||||
|
}
|
||||||
|
|
||||||
|
get rows(): TdsInterestRow[] {
|
||||||
|
return tdsInterestRows(this.filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
get summary(): TdsInterestSummary {
|
||||||
|
return tdsInterestSummary(this.filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasFilters(): boolean {
|
||||||
|
return !!(
|
||||||
|
this.filter.search ||
|
||||||
|
this.filter.quarter ||
|
||||||
|
this.filter.band ||
|
||||||
|
this.filter.crossedOnly ||
|
||||||
|
this.filter.nearThresholdOnly
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearFilters(): void {
|
||||||
|
this.filter = {
|
||||||
|
...this.filter,
|
||||||
|
quarter: 0,
|
||||||
|
band: '',
|
||||||
|
search: '',
|
||||||
|
crossedOnly: false,
|
||||||
|
nearThresholdOnly: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Jump straight to the customers worth chasing a declaration from. */
|
||||||
|
showNearThreshold(): void {
|
||||||
|
this.filter = { ...this.filter, crossedOnly: false, nearThresholdOnly: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleExpanded(row: TdsInterestRow): void {
|
||||||
|
this.expandedId = this.expandedId === row.deductee.customerId ? null : row.deductee.customerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
isExpanded(row: TdsInterestRow): boolean {
|
||||||
|
return this.expandedId === row.deductee.customerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
branchName(code: string): string {
|
||||||
|
return glBranchName(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
trackRow(_: number, row: TdsInterestRow): string {
|
||||||
|
return row.deductee.customerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async exportWorkbook(): Promise<void> {
|
||||||
|
this.exporting = true;
|
||||||
|
try {
|
||||||
|
const wb = new ExcelJS.Workbook();
|
||||||
|
const ws = wb.addWorksheet(`TDS Interest FY ${this.filter.fy}`);
|
||||||
|
|
||||||
|
ws.addRow([`Deposit Interest & TDS — FY ${this.filter.fy}`]);
|
||||||
|
ws.addRow([`Scope: ${this.scopeLabel}`]);
|
||||||
|
ws.addRow([]);
|
||||||
|
ws.addRow([
|
||||||
|
'Customer',
|
||||||
|
'CIF',
|
||||||
|
'PAN',
|
||||||
|
'Branch',
|
||||||
|
'Form',
|
||||||
|
'Band',
|
||||||
|
'Age',
|
||||||
|
'Q1',
|
||||||
|
'Q2',
|
||||||
|
'Q3',
|
||||||
|
'Q4',
|
||||||
|
'Interest (FD/RD)',
|
||||||
|
'Savings interest (exempt)',
|
||||||
|
'Threshold',
|
||||||
|
'Crossed in',
|
||||||
|
'Rate %',
|
||||||
|
'TDS payable',
|
||||||
|
'Declaration',
|
||||||
|
'UIN',
|
||||||
|
'TDS saved',
|
||||||
|
'Paid without deduction'
|
||||||
|
]);
|
||||||
|
|
||||||
|
for (const r of this.rows) {
|
||||||
|
ws.addRow([
|
||||||
|
r.deductee.name,
|
||||||
|
r.deductee.customerId,
|
||||||
|
r.deductee.pan || 'NOT ON RECORD',
|
||||||
|
this.branchName(r.branchCode),
|
||||||
|
r.form === '121' ? 'Form 121' : `Form ${r.form}`,
|
||||||
|
r.band === 'SENIOR' ? 'Senior citizen' : 'General',
|
||||||
|
r.age,
|
||||||
|
r.quarters[1],
|
||||||
|
r.quarters[2],
|
||||||
|
r.quarters[3],
|
||||||
|
r.quarters[4],
|
||||||
|
r.tdsApplicableInterest,
|
||||||
|
r.exemptInterest,
|
||||||
|
r.threshold,
|
||||||
|
r.crossedInQuarter ? `Q${r.crossedInQuarter}` : '—',
|
||||||
|
r.rateApplied * 100,
|
||||||
|
r.schedule.totalTds,
|
||||||
|
r.hasDeclaration ? 'Yes' : 'No',
|
||||||
|
r.declarationUin ?? '',
|
||||||
|
r.tdsSaved,
|
||||||
|
r.paidWithoutDeduction
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const s = this.summary;
|
||||||
|
ws.addRow([]);
|
||||||
|
ws.addRow([
|
||||||
|
'TOTAL',
|
||||||
|
`${s.deductees} deductees`,
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
s.byQuarter[0].interest,
|
||||||
|
s.byQuarter[1].interest,
|
||||||
|
s.byQuarter[2].interest,
|
||||||
|
s.byQuarter[3].interest,
|
||||||
|
s.applicableInterest,
|
||||||
|
s.exemptInterest,
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
s.totalTds,
|
||||||
|
`${s.declarations} filed`,
|
||||||
|
'',
|
||||||
|
s.tdsSaved,
|
||||||
|
s.paidWithoutDeduction
|
||||||
|
]);
|
||||||
|
|
||||||
|
ws.getRow(1).font = { bold: true, size: 13 };
|
||||||
|
ws.getRow(4).font = { bold: true };
|
||||||
|
ws.columns.forEach((c) => (c.width = 18));
|
||||||
|
|
||||||
|
const buffer = await wb.xlsx.writeBuffer();
|
||||||
|
const blob = new Blob([buffer], {
|
||||||
|
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||||
|
});
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `TDS-Interest-FY${this.filter.fy}.xlsx`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} finally {
|
||||||
|
this.exporting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
<!--
|
||||||
|
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="tdsr">
|
||||||
|
<div class="tdsr__head">
|
||||||
|
<div class="tdsr__head-l">
|
||||||
|
<fa-icon icon="building-columns" class="tdsr__head-ic"></fa-icon>
|
||||||
|
<div>
|
||||||
|
<h2 class="tdsr__title">TDS Remitted to Government</h2>
|
||||||
|
<div class="tdsr__sub">
|
||||||
|
Challan-wise deposits and the {{ statute.statement }} year-end position · {{ scopeLabel }} · as at
|
||||||
|
{{ asAt | date: 'd MMM y' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tdsr__head-r">
|
||||||
|
<label class="tdsr__filter">
|
||||||
|
<span>Financial year</span>
|
||||||
|
<select [(ngModel)]="fy">
|
||||||
|
@for (year of financialYears; track year) {
|
||||||
|
<option [value]="year">FY {{ year }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button type="button" class="tdsr__btn" (click)="exportWorkbook()" [disabled]="exporting">
|
||||||
|
<fa-icon icon="download"></fa-icon>
|
||||||
|
{{ exporting ? 'Preparing…' : 'Export' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- The control figure, first thing on the page -->
|
||||||
|
@if (reconciled) {
|
||||||
|
<div class="tdsr__banner tdsr__banner--ok">
|
||||||
|
<fa-icon icon="check-circle"></fa-icon>
|
||||||
|
<div>
|
||||||
|
<strong>Reconciled.</strong>
|
||||||
|
₹{{ summary.totalDeducted | number: '1.0-0' }} deducted, ₹{{
|
||||||
|
summary.totalDeposited | number: '1.0-0'
|
||||||
|
}}
|
||||||
|
deposited, ₹{{ summary.pendingDeposit | number: '1.0-0' }} still within its due date.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="tdsr__banner tdsr__banner--warn">
|
||||||
|
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||||
|
<div>
|
||||||
|
<strong>Does not reconcile — ₹{{ summary.difference | number: '1.0-0' }} unaccounted.</strong>
|
||||||
|
Tax deducted must equal tax deposited plus tax still within its due date. Find the difference before filing the
|
||||||
|
return.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (summary.overdueChallans > 0) {
|
||||||
|
<div class="tdsr__banner tdsr__banner--warn">
|
||||||
|
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||||
|
<div>
|
||||||
|
<strong>{{ summary.overdueChallans }} challan{{ summary.overdueChallans === 1 ? '' : 's' }} overdue.</strong>
|
||||||
|
Interest runs at 1.5% for every month or part of a month until the tax reaches the Government.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- KPIs -->
|
||||||
|
<div class="tdsr__kpis">
|
||||||
|
<div class="tdsr__kpi tdsr__kpi--warn">
|
||||||
|
<div class="tdsr__kpi-n">₹{{ summary.totalDeducted | number: '1.0-0' }}</div>
|
||||||
|
<div class="tdsr__kpi-l">Total TDS deducted</div>
|
||||||
|
</div>
|
||||||
|
<div class="tdsr__kpi tdsr__kpi--ok">
|
||||||
|
<div class="tdsr__kpi-n">₹{{ summary.totalDeposited | number: '1.0-0' }}</div>
|
||||||
|
<div class="tdsr__kpi-l">Deposited with Government</div>
|
||||||
|
</div>
|
||||||
|
<div class="tdsr__kpi">
|
||||||
|
<div class="tdsr__kpi-n">₹{{ summary.pendingDeposit | number: '1.0-0' }}</div>
|
||||||
|
<div class="tdsr__kpi-l">Within due date</div>
|
||||||
|
</div>
|
||||||
|
<div class="tdsr__kpi" [class.tdsr__kpi--warn]="totalDefaultInterest > 0">
|
||||||
|
<div class="tdsr__kpi-n">₹{{ totalDefaultInterest | number: '1.0-0' }}</div>
|
||||||
|
<div class="tdsr__kpi-l">Interest on late deposit</div>
|
||||||
|
</div>
|
||||||
|
<div class="tdsr__kpi">
|
||||||
|
<div class="tdsr__kpi-n">{{ summary.deductees | number }}</div>
|
||||||
|
<div class="tdsr__kpi-l">Deductees in the return</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Part A: challans -->
|
||||||
|
<div class="tdsr__card">
|
||||||
|
<div class="tdsr__card-head"><fa-icon icon="receipt"></fa-icon> Part A — challan-wise remittance</div>
|
||||||
|
<div class="tdsr__table-wrap">
|
||||||
|
<table class="tdsr__table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Quarter</th>
|
||||||
|
<th>Deduction month</th>
|
||||||
|
<th class="num">Amount</th>
|
||||||
|
<th>Due date</th>
|
||||||
|
<th>Deposited on</th>
|
||||||
|
<th>Challan · BSR</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th class="num">Late-deposit interest</th>
|
||||||
|
<th>{{ statute.statement }} due</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (row of rows; track trackRow($index, row)) {
|
||||||
|
<tr [class.tdsr__row--warn]="row.late || row.overdue">
|
||||||
|
<td>
|
||||||
|
<strong>{{ quarterLabel[row.quarter] }}</strong>
|
||||||
|
</td>
|
||||||
|
<td class="small">{{ row.month }}</td>
|
||||||
|
<td class="num">
|
||||||
|
<strong>₹{{ row.amount | number: '1.0-0' }}</strong>
|
||||||
|
</td>
|
||||||
|
<td class="small">{{ row.dueDate | date: 'd MMM y' }}</td>
|
||||||
|
<td class="small">
|
||||||
|
@if (row.depositedOn) {
|
||||||
|
{{ row.depositedOn | date: 'd MMM y' }}
|
||||||
|
} @else {
|
||||||
|
<span class="muted">—</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td class="mono small">
|
||||||
|
@if (row.challanNo) {
|
||||||
|
{{ row.challanNo }}
|
||||||
|
<div class="muted">BSR {{ row.bsrCode }}</div>
|
||||||
|
} @else {
|
||||||
|
<span class="muted">—</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@if (row.overdue) {
|
||||||
|
<span class="tdsr__pill tdsr__pill--warn">Overdue</span>
|
||||||
|
} @else if (row.late) {
|
||||||
|
<span class="tdsr__pill tdsr__pill--warn">Late by {{ row.delayMonths }}m</span>
|
||||||
|
} @else if (row.depositedOn) {
|
||||||
|
<span class="tdsr__pill tdsr__pill--ok">On time</span>
|
||||||
|
} @else {
|
||||||
|
<span class="tdsr__pill">Within due date</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td class="num">
|
||||||
|
@if (row.defaultInterest) {
|
||||||
|
<strong class="tdsr__warn-text">₹{{ row.defaultInterest | number: '1.0-0' }}</strong>
|
||||||
|
<div class="small muted">1.5% × {{ row.delayMonths }}</div>
|
||||||
|
} @else {
|
||||||
|
<span class="muted">—</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td class="small muted">{{ row.returnDueDate }}</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
@if (rows.length === 0) {
|
||||||
|
<tr>
|
||||||
|
<td colspan="9" class="tdsr__empty">
|
||||||
|
<fa-icon icon="info-circle"></fa-icon> No tax was deducted in this financial year.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="tdsr__foot">
|
||||||
|
Tax deducted in a month must reach the Government by the seventh of the following month; March runs to 30 April.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Part B: return summary -->
|
||||||
|
<div class="tdsr__card">
|
||||||
|
<div class="tdsr__card-head">
|
||||||
|
<fa-icon icon="file-alt"></fa-icon> Part B — {{ statute.statement }} year-end position
|
||||||
|
</div>
|
||||||
|
<div class="tdsr__ledger">
|
||||||
|
<div class="tdsr__ledger-row">
|
||||||
|
<span>Total interest paid on time deposits</span>
|
||||||
|
<span class="num">₹{{ summary.totalInterestPaid | number: '1.0-0' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="tdsr__ledger-row tdsr__ledger-row--indent">
|
||||||
|
<span>with tax deducted</span>
|
||||||
|
<span class="num">₹{{ summary.interestWithDeduction | number: '1.0-0' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="tdsr__ledger-row tdsr__ledger-row--indent">
|
||||||
|
<span>
|
||||||
|
without deduction — declaration on record
|
||||||
|
<em>mandatory disclosure in {{ statute.statement }}</em>
|
||||||
|
</span>
|
||||||
|
<span class="num">₹{{ summary.interestWithoutDeduction | number: '1.0-0' }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tdsr__ledger-row tdsr__ledger-row--sep">
|
||||||
|
<span>TDS at standard rate — PAN on record</span>
|
||||||
|
<span class="num">₹{{ summary.tdsAtStandardRate | number: '1.0-0' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="tdsr__ledger-row">
|
||||||
|
<span>TDS at higher rate — no PAN on record</span>
|
||||||
|
<span class="num">₹{{ summary.tdsAtHigherRate | number: '1.0-0' }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tdsr__ledger-row tdsr__ledger-row--total">
|
||||||
|
<span>Total TDS deducted</span>
|
||||||
|
<span class="num">₹{{ summary.totalDeducted | number: '1.0-0' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="tdsr__ledger-row">
|
||||||
|
<span>Deposited with Government</span>
|
||||||
|
<span class="num">₹{{ summary.totalDeposited | number: '1.0-0' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="tdsr__ledger-row">
|
||||||
|
<span>Still within due date</span>
|
||||||
|
<span class="num">₹{{ summary.pendingDeposit | number: '1.0-0' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="tdsr__ledger-row tdsr__ledger-row--control" [class.tdsr__ledger-row--bad]="!reconciled">
|
||||||
|
<span>Difference — must be zero</span>
|
||||||
|
<span class="num">₹{{ summary.difference | number: '1.0-0' }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (summary.defaultInterest > 0) {
|
||||||
|
<div class="tdsr__ledger-row tdsr__ledger-row--sep">
|
||||||
|
<span>Interest payable by the bank for late deposit</span>
|
||||||
|
<span class="num">₹{{ summary.defaultInterest | number: '1.0-0' }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="tdsr__foot">
|
||||||
|
The "without deduction" line is not optional — interest paid gross against a declaration must be reported in the
|
||||||
|
quarterly {{ statute.statement }} return with the appropriate remark code, and the consolidated statement of
|
||||||
|
declarations received is uploaded {{ statute.frequency }}. Confirm the remark code against the current
|
||||||
|
specification before generating the file.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, inject } from '@angular/core';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
import * as ExcelJS from 'exceljs';
|
||||||
|
|
||||||
|
import { BranchScopeService } from '../core/branch-scope.service';
|
||||||
|
import { FY_TAX_CONFIG, QUARTER_LABEL } from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute';
|
||||||
|
import { TDS_AS_AT, TDS_FYS } from './tds-reports-data';
|
||||||
|
import { Tds26qSummary, TdsRemittanceRow, tds26qSummary, tdsRemittanceRows } from './tds-reports.compute';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-tds-remittance-report',
|
||||||
|
templateUrl: './tds-remittance-report.component.html',
|
||||||
|
styleUrls: ['./tds-reports.shared.scss'],
|
||||||
|
imports: [
|
||||||
|
...STANDALONE_SHARED_IMPORTS,
|
||||||
|
FaIconComponent,
|
||||||
|
FormsModule
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class TdsRemittanceReportComponent {
|
||||||
|
private scope = inject(BranchScopeService);
|
||||||
|
|
||||||
|
readonly financialYears = TDS_FYS;
|
||||||
|
readonly quarterLabel = QUARTER_LABEL;
|
||||||
|
readonly asAt = TDS_AS_AT;
|
||||||
|
|
||||||
|
fy = TDS_FYS[0];
|
||||||
|
exporting = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provision and return references for the selected year. The quarterly return
|
||||||
|
* became Form 140 under the 2025 Act, so the labels are read from the config
|
||||||
|
* rather than written into the template.
|
||||||
|
*/
|
||||||
|
get statute(): { section: string; statement: string; frequency: string } {
|
||||||
|
const cfg = FY_TAX_CONFIG[this.fy];
|
||||||
|
return {
|
||||||
|
section: cfg?.interestTdsSection ?? '—',
|
||||||
|
statement: cfg?.tdsStatementForm ?? '—',
|
||||||
|
frequency: cfg?.declarationStatementFrequency === 'MONTHLY' ? 'monthly' : 'quarterly'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
get scopeLabel(): string {
|
||||||
|
return this.scope.isHeadOffice ? 'Bank as a whole' : this.scope.officeName;
|
||||||
|
}
|
||||||
|
|
||||||
|
get rows(): TdsRemittanceRow[] {
|
||||||
|
return tdsRemittanceRows(this.fy, this.asAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
get summary(): Tds26qSummary {
|
||||||
|
return tds26qSummary(this.fy, this.asAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The control that matters. Tax deducted must equal tax deposited plus tax
|
||||||
|
* still within its due date. Anything else means something has gone missing
|
||||||
|
* and must be found before the return is filed.
|
||||||
|
*/
|
||||||
|
get reconciled(): boolean {
|
||||||
|
return this.summary.difference === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
get totalDefaultInterest(): number {
|
||||||
|
return this.summary.defaultInterest;
|
||||||
|
}
|
||||||
|
|
||||||
|
trackRow(_: number, row: TdsRemittanceRow): string {
|
||||||
|
return `${row.fy}-${row.quarter}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async exportWorkbook(): Promise<void> {
|
||||||
|
this.exporting = true;
|
||||||
|
try {
|
||||||
|
const s = this.summary;
|
||||||
|
const wb = new ExcelJS.Workbook();
|
||||||
|
|
||||||
|
const challan = wb.addWorksheet('Challans');
|
||||||
|
challan.addRow([`TDS remitted to Government — FY ${this.fy}`]);
|
||||||
|
challan.addRow([`Scope: ${this.scopeLabel} · as at ${this.asAt}`]);
|
||||||
|
challan.addRow([]);
|
||||||
|
challan.addRow([
|
||||||
|
'Quarter',
|
||||||
|
'Deduction month',
|
||||||
|
'Amount deducted',
|
||||||
|
'Due date',
|
||||||
|
'Deposited on',
|
||||||
|
'Challan no.',
|
||||||
|
'BSR code',
|
||||||
|
'Status',
|
||||||
|
'Delay (months)',
|
||||||
|
'Late-deposit interest',
|
||||||
|
`${this.statute.statement} due`
|
||||||
|
]);
|
||||||
|
for (const r of this.rows) {
|
||||||
|
challan.addRow([
|
||||||
|
`Q${r.quarter}`,
|
||||||
|
r.month,
|
||||||
|
r.amount,
|
||||||
|
r.dueDate,
|
||||||
|
r.depositedOn ?? 'PENDING',
|
||||||
|
r.challanNo ?? '',
|
||||||
|
r.bsrCode ?? '',
|
||||||
|
r.overdue ? 'OVERDUE' : r.late ? 'LATE' : r.depositedOn ? 'On time' : 'Within due date',
|
||||||
|
r.delayMonths || '',
|
||||||
|
r.defaultInterest || '',
|
||||||
|
r.returnDueDate
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
challan.getRow(1).font = { bold: true, size: 13 };
|
||||||
|
challan.getRow(4).font = { bold: true };
|
||||||
|
challan.columns.forEach((c) => (c.width = 18));
|
||||||
|
|
||||||
|
const recon = wb.addWorksheet('Return Summary');
|
||||||
|
recon.addRow([`${this.statute.statement} year-end position — FY ${this.fy}`]);
|
||||||
|
recon.addRow([]);
|
||||||
|
const lines: [string, number | string][] = [
|
||||||
|
[
|
||||||
|
'Deductees',
|
||||||
|
s.deductees
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Total interest paid on time deposits',
|
||||||
|
s.totalInterestPaid
|
||||||
|
],
|
||||||
|
[
|
||||||
|
' — with tax deducted',
|
||||||
|
s.interestWithDeduction
|
||||||
|
],
|
||||||
|
[
|
||||||
|
' — without deduction (declaration on record)',
|
||||||
|
s.interestWithoutDeduction
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'TDS at standard rate (PAN on record)',
|
||||||
|
s.tdsAtStandardRate
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'TDS at higher rate (no PAN)',
|
||||||
|
s.tdsAtHigherRate
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Total TDS deducted',
|
||||||
|
s.totalDeducted
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Total deposited with Government',
|
||||||
|
s.totalDeposited
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Still within due date',
|
||||||
|
s.pendingDeposit
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'DIFFERENCE (must be zero)',
|
||||||
|
s.difference
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Interest payable for late deposit',
|
||||||
|
s.defaultInterest
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Challans deposited late',
|
||||||
|
s.lateChallans
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Challans overdue',
|
||||||
|
s.overdueChallans
|
||||||
|
]
|
||||||
|
];
|
||||||
|
for (const line of lines) {
|
||||||
|
recon.addRow(line);
|
||||||
|
}
|
||||||
|
recon.getRow(1).font = { bold: true, size: 13 };
|
||||||
|
recon.columns.forEach((c) => (c.width = 42));
|
||||||
|
recon.getColumn(2).width = 20;
|
||||||
|
|
||||||
|
const buffer = await wb.xlsx.writeBuffer();
|
||||||
|
const blob = new Blob([buffer], {
|
||||||
|
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||||
|
});
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `TDS-Remittance-FY${this.fy}.xlsx`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} finally {
|
||||||
|
this.exporting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,396 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deductees and their deposit interest, for the two TDS reports.
|
||||||
|
*
|
||||||
|
* The bank's deduction obligation runs on time deposits, so the seed
|
||||||
|
* gives each customer a mix of fixed and recurring deposits — and some savings
|
||||||
|
* accounts, which are outside the section and must stay out of the aggregate.
|
||||||
|
*
|
||||||
|
* Everything is generated from a fixed seed so the figures do not move between
|
||||||
|
* loads, and the challan schedule is deliberately imperfect: one quarter is
|
||||||
|
* deposited late so the default-interest column has something to show.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { GL_BRANCHES } from '../gl-reports/gl-reports-data';
|
||||||
|
import {
|
||||||
|
Declaration,
|
||||||
|
Form15Kind,
|
||||||
|
InterestCredit,
|
||||||
|
TdsAccountInput
|
||||||
|
} from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute';
|
||||||
|
|
||||||
|
/** The years the seed covers. 2025-26 is complete; 2026-27 is in progress. */
|
||||||
|
export const TDS_FYS = [
|
||||||
|
'2026-27',
|
||||||
|
'2025-26'
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Business date the book is drawn to — Q1 of 2026-27 is closed, Q2 is running. */
|
||||||
|
export const TDS_AS_AT = '2026-08-01';
|
||||||
|
|
||||||
|
export interface TdsDeductee {
|
||||||
|
clientId: number;
|
||||||
|
customerId: string;
|
||||||
|
name: string;
|
||||||
|
pan: string;
|
||||||
|
dob: string;
|
||||||
|
branchCode: string;
|
||||||
|
/** Declarations on record, keyed by financial year. */
|
||||||
|
declarations: Record<string, Declaration>;
|
||||||
|
accounts: TdsAccountInput[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One month's remittance of deducted tax to the Government. */
|
||||||
|
export interface TdsChallan {
|
||||||
|
fy: string;
|
||||||
|
/** Quarter the deduction belongs to. */
|
||||||
|
quarter: 1 | 2 | 3 | 4;
|
||||||
|
/** Month the tax was deducted in, e.g. '2026-06'. */
|
||||||
|
month: string;
|
||||||
|
amount: number;
|
||||||
|
/** Statutory date by which it had to be deposited. */
|
||||||
|
dueDate: string;
|
||||||
|
/** When it actually reached the Government — null while still pending. */
|
||||||
|
depositedOn: string | null;
|
||||||
|
challanNo: string | null;
|
||||||
|
bsrCode: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deterministic linear congruential generator — same figures on every load. */
|
||||||
|
function lcg(seed: number): () => number {
|
||||||
|
let s = seed >>> 0;
|
||||||
|
return () => {
|
||||||
|
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
|
||||||
|
return s / 4294967296;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function pick<T>(rand: () => number, list: T[]): T {
|
||||||
|
return list[Math.floor(rand() * list.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
const FIRST_NAMES = [
|
||||||
|
'Ramesh',
|
||||||
|
'Sunita',
|
||||||
|
'Vitthal',
|
||||||
|
'Kamala',
|
||||||
|
'Ganpat',
|
||||||
|
'Shobha',
|
||||||
|
'Dnyaneshwar',
|
||||||
|
'Mangala',
|
||||||
|
'Bhaskar',
|
||||||
|
'Anita',
|
||||||
|
'Tukaram',
|
||||||
|
'Sarika',
|
||||||
|
'Nivrutti',
|
||||||
|
'Rekha',
|
||||||
|
'Popat',
|
||||||
|
'Chhaya',
|
||||||
|
'Ashok',
|
||||||
|
'Vaishali',
|
||||||
|
'Sitaram',
|
||||||
|
'Jayshree',
|
||||||
|
'Madhukar',
|
||||||
|
'Pushpa',
|
||||||
|
'Eknath',
|
||||||
|
'Sulochana',
|
||||||
|
'Hanumant',
|
||||||
|
'Kalpana'
|
||||||
|
];
|
||||||
|
|
||||||
|
const SURNAMES = [
|
||||||
|
'Jadhav',
|
||||||
|
'Pawar',
|
||||||
|
'Shinde',
|
||||||
|
'Gaikwad',
|
||||||
|
'More',
|
||||||
|
'Kadam',
|
||||||
|
'Bhosale',
|
||||||
|
'Patil',
|
||||||
|
'Sawant',
|
||||||
|
'Chavan',
|
||||||
|
'Salunkhe',
|
||||||
|
'Mane',
|
||||||
|
'Deshmukh',
|
||||||
|
'Thorat',
|
||||||
|
'Nikam',
|
||||||
|
'Wagh'
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quarter-end dates for a financial year. Interest is credited on these, which
|
||||||
|
* is what makes the deduction fall due.
|
||||||
|
*/
|
||||||
|
function quarterEnds(fy: string): string[] {
|
||||||
|
const y = Number(fy.slice(0, 4));
|
||||||
|
return [
|
||||||
|
`${y}-06-30`,
|
||||||
|
`${y}-09-30`,
|
||||||
|
`${y}-12-31`,
|
||||||
|
`${y + 1}-03-31`
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A well-formed individual PAN — fourth character 'P'. */
|
||||||
|
function makePan(rand: () => number, surname: string): string {
|
||||||
|
const letter = () => String.fromCharCode(65 + Math.floor(rand() * 26));
|
||||||
|
const digits = `${1000 + Math.floor(rand() * 9000)}`;
|
||||||
|
return `${letter()}${letter()}${letter()}P${surname[0].toUpperCase()}${digits}${letter()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let deducteeCache: TdsDeductee[] | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The bank's deductees.
|
||||||
|
*
|
||||||
|
* The spread is deliberate so both reports have every case in them: customers
|
||||||
|
* comfortably under the trigger, customers sitting just below it, customers well
|
||||||
|
* over, senior citizens on the higher threshold, a few with no PAN on record
|
||||||
|
* (deducted at the 206AA rate), some joint deposits held as second holder, and
|
||||||
|
* some declarations that only came in mid-year.
|
||||||
|
*/
|
||||||
|
export function tdsDeductees(): TdsDeductee[] {
|
||||||
|
if (deducteeCache) {
|
||||||
|
return deducteeCache;
|
||||||
|
}
|
||||||
|
const rand = lcg(20260801);
|
||||||
|
const branches = GL_BRANCHES.filter((b) => b.code !== 'HO');
|
||||||
|
const out: TdsDeductee[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < 64; i++) {
|
||||||
|
const branch = branches[i % branches.length];
|
||||||
|
const first = pick(rand, FIRST_NAMES);
|
||||||
|
const last = pick(rand, SURNAMES);
|
||||||
|
const clientId = 5000 + i;
|
||||||
|
|
||||||
|
// A third of the book is senior citizens, which is about right for a
|
||||||
|
// co-operative bank's deposit base.
|
||||||
|
const senior = rand() < 0.34;
|
||||||
|
const birthYear = senior ? 1948 + Math.floor(rand() * 15) : 1972 + Math.floor(rand() * 22);
|
||||||
|
const dob = `${birthYear}-${`${1 + Math.floor(rand() * 12)}`.padStart(2, '0')}-${`${1 + Math.floor(rand() * 28)}`.padStart(2, '0')}`;
|
||||||
|
|
||||||
|
// Every seventeenth customer has no PAN on record, so tax comes off at the
|
||||||
|
// higher section 206AA rate. Fixed by index rather than drawn at random, so
|
||||||
|
// the case is always present.
|
||||||
|
const hasPan = i % 17 !== 3;
|
||||||
|
|
||||||
|
// Aim each customer's annual time-deposit interest at a band, so the report
|
||||||
|
// has a proper spread either side of the trigger.
|
||||||
|
const draw = rand();
|
||||||
|
let annualInterest: number;
|
||||||
|
const limit = senior ? 100_000 : 50_000;
|
||||||
|
if (draw < 0.3) {
|
||||||
|
annualInterest = 4_000 + Math.floor(rand() * (limit - 10_000)); // comfortably under
|
||||||
|
} else if (draw < 0.45) {
|
||||||
|
annualInterest = limit - 6_000 + Math.floor(rand() * 6_000); // just below the threshold
|
||||||
|
} else if (draw < 0.8) {
|
||||||
|
annualInterest = limit + 2_000 + Math.floor(rand() * 40_000); // over
|
||||||
|
} else {
|
||||||
|
annualInterest = 110_000 + Math.floor(rand() * 320_000); // well over
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push two of the no-PAN customers over the trigger. Left to chance they can
|
||||||
|
// all land under it, and then the 20% line of the return summary comes out
|
||||||
|
// empty — a report line that is never exercised is one nobody has checked.
|
||||||
|
if (!hasPan && (i === 3 || i === 37)) {
|
||||||
|
annualInterest = 70_000 + i * 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
const accounts: TdsAccountInput[] = [];
|
||||||
|
const acctBase = 41_00_000 + i * 137;
|
||||||
|
|
||||||
|
// One or two time deposits, split across the year's quarter ends.
|
||||||
|
const depositCount = rand() < 0.4 ? 2 : 1;
|
||||||
|
for (let d = 0; d < depositCount; d++) {
|
||||||
|
const isRd = rand() < 0.3;
|
||||||
|
const share = depositCount === 1 ? 1 : d === 0 ? 0.6 : 0.4;
|
||||||
|
const credits: InterestCredit[] = [];
|
||||||
|
|
||||||
|
for (const fy of TDS_FYS) {
|
||||||
|
// The current year is only part-run, so later quarters have no postings.
|
||||||
|
const ends = quarterEnds(fy).filter((date) => date <= TDS_AS_AT);
|
||||||
|
if (!ends.length) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// The prior year ran in full; scale the current year to what has posted.
|
||||||
|
const perQuarter = Math.round((annualInterest * share) / 4);
|
||||||
|
for (const date of ends) {
|
||||||
|
credits.push({ date, amount: perQuarter });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
accounts.push({
|
||||||
|
accountNo: `${branch.code}${`${acctBase + d}`.padStart(8, '0')}`,
|
||||||
|
depositTypeCode: isRd ? 'depositAccountType.recurringDeposit' : 'depositAccountType.fixedDeposit',
|
||||||
|
accountStatus: 'SINGLE',
|
||||||
|
clientId,
|
||||||
|
interestPostingPeriodType: {
|
||||||
|
// A fifth of the book is on annual posting, so tax on those settles
|
||||||
|
// once at year end rather than quarterly.
|
||||||
|
code: rand() < 0.2 ? 'savingsPostingInterestPeriodType.annual' : 'savingsPostingInterestPeriodType.quarterly'
|
||||||
|
},
|
||||||
|
credits
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Most customers also hold a savings account. Its interest must never reach
|
||||||
|
// the deduction aggregate — that is the point of including it here.
|
||||||
|
if (rand() < 0.75) {
|
||||||
|
const sbCredits: InterestCredit[] = [];
|
||||||
|
for (const fy of TDS_FYS) {
|
||||||
|
for (const date of quarterEnds(fy).filter((x) => x <= TDS_AS_AT)) {
|
||||||
|
sbCredits.push({ date, amount: 900 + Math.floor(rand() * 2_600) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
accounts.push({
|
||||||
|
accountNo: `${branch.code}${`${acctBase + 50}`.padStart(8, '0')}`,
|
||||||
|
depositTypeCode: 'depositAccountType.savingsDeposit',
|
||||||
|
accountStatus: 'SINGLE',
|
||||||
|
clientId,
|
||||||
|
interestPostingPeriodType: { code: 'savingsPostingInterestPeriodType.quarterly' },
|
||||||
|
credits: sbCredits
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// One in eight is the second holder on somebody else's deposit. Tax there is
|
||||||
|
// deducted against the first-named holder, so it must not land on this
|
||||||
|
// customer's aggregate.
|
||||||
|
if (rand() < 0.12) {
|
||||||
|
const jointCredits: InterestCredit[] = [];
|
||||||
|
for (const fy of TDS_FYS) {
|
||||||
|
for (const date of quarterEnds(fy).filter((x) => x <= TDS_AS_AT)) {
|
||||||
|
jointCredits.push({ date, amount: 16_000 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
accounts.push({
|
||||||
|
accountNo: `${branch.code}${`${acctBase + 70}`.padStart(8, '0')}`,
|
||||||
|
depositTypeCode: 'depositAccountType.fixedDeposit',
|
||||||
|
accountStatus: 'JOINT',
|
||||||
|
// Somebody else is first-named.
|
||||||
|
primaryClientId: clientId + 1,
|
||||||
|
clientId: clientId + 1,
|
||||||
|
jointHolders: [{ clientId }],
|
||||||
|
interestPostingPeriodType: { code: 'savingsPostingInterestPeriodType.quarterly' },
|
||||||
|
credits: jointCredits
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Declarations. Only worth filing where tax would otherwise be deducted, so
|
||||||
|
// they cluster on the customers over the trigger.
|
||||||
|
const declarations: Record<string, Declaration> = {};
|
||||||
|
if (annualInterest > (senior ? 100_000 : 50_000) && hasPan && rand() < 0.45) {
|
||||||
|
const kind: Form15Kind = senior ? '15H' : '15G';
|
||||||
|
for (const fy of TDS_FYS) {
|
||||||
|
const y = Number(fy.slice(0, 4));
|
||||||
|
// A quarter of them only came in mid-year, which leaves tax already
|
||||||
|
// deducted that the branch cannot reverse.
|
||||||
|
const midYear = rand() < 0.25;
|
||||||
|
declarations[fy] = {
|
||||||
|
kind,
|
||||||
|
acceptedOn: midYear ? `${y}-10-01` : `${y}-04-05`,
|
||||||
|
fy,
|
||||||
|
uin: `${branch.code}${kind}${`${i + 1}`.padStart(4, '0')}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out.push({
|
||||||
|
clientId,
|
||||||
|
customerId: `CIF${`${clientId}`.padStart(8, '0')}`,
|
||||||
|
name: `${first} ${last}`,
|
||||||
|
pan: hasPan ? makePan(rand, last) : '',
|
||||||
|
dob,
|
||||||
|
branchCode: branch.code,
|
||||||
|
declarations,
|
||||||
|
accounts
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
deducteeCache = out;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Statutory date by which tax deducted in a given month must be deposited.
|
||||||
|
*
|
||||||
|
* The seventh of the following month, except for March, which runs to 30 April.
|
||||||
|
*/
|
||||||
|
export function depositDueDate(month: string): string {
|
||||||
|
const [
|
||||||
|
y,
|
||||||
|
m
|
||||||
|
] = month.split('-').map(Number);
|
||||||
|
if (m === 3) {
|
||||||
|
return `${y}-04-30`;
|
||||||
|
}
|
||||||
|
const nextY = m === 12 ? y + 1 : y;
|
||||||
|
const nextM = m === 12 ? 1 : m + 1;
|
||||||
|
return `${nextY}-${`${nextM}`.padStart(2, '0')}-07`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let challanCache: Record<string, TdsChallan[]> | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The remittance schedule, derived from what the engine says was deducted.
|
||||||
|
*
|
||||||
|
* Deductions land at each quarter end, so there is one challan per quarter. The
|
||||||
|
* Q2 challan of the prior year is deliberately deposited a month and a bit late
|
||||||
|
* so the late-deposit interest column is exercised, and the current year's latest
|
||||||
|
* quarter is left pending, which is the ordinary state of a live book.
|
||||||
|
*/
|
||||||
|
export function tdsChallans(deductedByQuarter: Record<string, Record<number, number>>): Record<string, TdsChallan[]> {
|
||||||
|
if (challanCache) {
|
||||||
|
return challanCache;
|
||||||
|
}
|
||||||
|
const out: Record<string, TdsChallan[]> = {};
|
||||||
|
|
||||||
|
for (const fy of TDS_FYS) {
|
||||||
|
const y = Number(fy.slice(0, 4));
|
||||||
|
const months: Record<number, string> = {
|
||||||
|
1: `${y}-06`,
|
||||||
|
2: `${y}-09`,
|
||||||
|
3: `${y}-12`,
|
||||||
|
4: `${y + 1}-03`
|
||||||
|
};
|
||||||
|
const rows: TdsChallan[] = [];
|
||||||
|
|
||||||
|
for (const q of [
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
3,
|
||||||
|
4
|
||||||
|
] as (1 | 2 | 3 | 4)[]) {
|
||||||
|
const amount = Math.round(deductedByQuarter[fy]?.[q] ?? 0);
|
||||||
|
if (amount <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const month = months[q];
|
||||||
|
const dueDate = depositDueDate(month);
|
||||||
|
// Anything falling after the business date has not been deposited yet.
|
||||||
|
const pending = dueDate > TDS_AS_AT;
|
||||||
|
const late = fy === '2025-26' && q === 2;
|
||||||
|
|
||||||
|
rows.push({
|
||||||
|
fy,
|
||||||
|
quarter: q,
|
||||||
|
month,
|
||||||
|
amount,
|
||||||
|
dueDate,
|
||||||
|
depositedOn: pending ? null : late ? `${y}-11-14` : dueDate,
|
||||||
|
challanNo: pending ? null : `${y}${q}${`${amount % 10000}`.padStart(4, '0')}`,
|
||||||
|
bsrCode: pending ? null : '0510308'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
out[fy] = rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
challanCache = out;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
/**
|
||||||
|
* 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 { isTdsApplicable, tdsDeducteeOf } from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute';
|
||||||
|
import { TDS_AS_AT, TDS_FYS, depositDueDate, tdsDeductees } from './tds-reports-data';
|
||||||
|
import {
|
||||||
|
NEAR_THRESHOLD_BAND,
|
||||||
|
TdsReportFilter,
|
||||||
|
deductedByQuarter,
|
||||||
|
tds26qSummary,
|
||||||
|
tdsInterestRows,
|
||||||
|
tdsInterestRowsForFy,
|
||||||
|
tdsInterestSummary,
|
||||||
|
tdsRemittanceRows
|
||||||
|
} from './tds-reports.compute';
|
||||||
|
|
||||||
|
/** The complete year in the seed — the one a return would be filed for. */
|
||||||
|
const CLOSED_FY = '2025-26';
|
||||||
|
|
||||||
|
function filter(overrides: Partial<TdsReportFilter> = {}): TdsReportFilter {
|
||||||
|
return {
|
||||||
|
fy: CLOSED_FY,
|
||||||
|
branchCode: '',
|
||||||
|
quarter: 0,
|
||||||
|
band: '',
|
||||||
|
search: '',
|
||||||
|
crossedOnly: false,
|
||||||
|
nearThresholdOnly: false,
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('the seed has every case the reports need to show', () => {
|
||||||
|
it('produces deductees on both sides of the threshold', () => {
|
||||||
|
const rows = tdsInterestRowsForFy(CLOSED_FY);
|
||||||
|
expect(rows.length).toBeGreaterThan(20);
|
||||||
|
expect(rows.some((r) => r.crossed)).toBe(true);
|
||||||
|
expect(rows.some((r) => !r.crossed)).toBe(true);
|
||||||
|
expect(rows.some((r) => r.nearThreshold)).toBe(true);
|
||||||
|
expect(rows.some((r) => r.band === 'GENERAL')).toBe(true);
|
||||||
|
expect(rows.some((r) => r.band === 'SENIOR')).toBe(true);
|
||||||
|
expect(rows.some((r) => !r.panOnRecord)).toBe(true);
|
||||||
|
expect(rows.some((r) => r.hasDeclaration)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives senior citizens the higher threshold', () => {
|
||||||
|
const rows = tdsInterestRowsForFy(CLOSED_FY);
|
||||||
|
for (const row of rows) {
|
||||||
|
expect(row.threshold).toBe(row.band === 'SENIOR' ? 100_000 : 50_000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deducts at the higher rate wherever PAN is missing', () => {
|
||||||
|
for (const row of tdsInterestRowsForFy(CLOSED_FY)) {
|
||||||
|
expect(row.rateApplied).toBe(row.panOnRecord ? 0.1 : 0.2);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the reports follow the Act that governed each year', () => {
|
||||||
|
it('shows 15G / 15H for FY 2025-26 and Form 121 for FY 2026-27', () => {
|
||||||
|
const old = tdsInterestRowsForFy('2025-26');
|
||||||
|
expect(old.some((r) => r.form === '15G')).toBe(true);
|
||||||
|
expect(old.some((r) => r.form === '15H')).toBe(true);
|
||||||
|
expect(old.some((r) => r.form === '121')).toBe(false);
|
||||||
|
|
||||||
|
// The two forms merged on 01 April 2026.
|
||||||
|
const current = tdsInterestRowsForFy('2026-27');
|
||||||
|
expect(current.every((r) => r.form === '121')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the age-based threshold band in both years', () => {
|
||||||
|
for (const fy of TDS_FYS) {
|
||||||
|
const rows = tdsInterestRowsForFy(fy);
|
||||||
|
expect(rows.some((r) => r.band === 'SENIOR')).toBe(true);
|
||||||
|
expect(rows.some((r) => r.band === 'GENERAL')).toBe(true);
|
||||||
|
for (const row of rows) {
|
||||||
|
expect(row.threshold).toBe(row.band === 'SENIOR' ? 100_000 : 50_000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('savings interest stays out of the 194A aggregate', () => {
|
||||||
|
it('reports it separately and never inside the applicable interest', () => {
|
||||||
|
const rows = tdsInterestRowsForFy(CLOSED_FY);
|
||||||
|
const withSavings = rows.filter((r) => r.exemptInterest > 0);
|
||||||
|
expect(withSavings.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
for (const row of withSavings) {
|
||||||
|
// The applicable figure must equal only the time-deposit interest where
|
||||||
|
// this customer is the deductee.
|
||||||
|
const expected = row.deductee.accounts
|
||||||
|
.filter((a) => isTdsApplicable(a) && tdsDeducteeOf(a) === row.deductee.clientId)
|
||||||
|
.flatMap((a) => a.credits)
|
||||||
|
.filter((c) => c.date >= '2025-04-01' && c.date <= '2026-03-31')
|
||||||
|
.reduce((s, c) => s + c.amount, 0);
|
||||||
|
expect(row.tdsApplicableInterest).toBe(expected);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the savings totals out of the summary base', () => {
|
||||||
|
const s = tdsInterestSummary(filter());
|
||||||
|
expect(s.exemptInterest).toBeGreaterThan(0);
|
||||||
|
expect(s.applicableInterest).toBe(tdsInterestRows(filter()).reduce((acc, r) => acc + r.tdsApplicableInterest, 0));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('joint deposits are attributed to the first-named holder only', () => {
|
||||||
|
it('excludes second-holder interest from the aggregate and shows it apart', () => {
|
||||||
|
const rows = tdsInterestRowsForFy(CLOSED_FY);
|
||||||
|
const secondHolders = rows.filter((r) => r.jointHeldInterest > 0);
|
||||||
|
expect(secondHolders.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
for (const row of secondHolders) {
|
||||||
|
const jointAccounts = row.deductee.accounts.filter(
|
||||||
|
(a) => isTdsApplicable(a) && tdsDeducteeOf(a) !== row.deductee.clientId
|
||||||
|
);
|
||||||
|
expect(jointAccounts.length).toBeGreaterThan(0);
|
||||||
|
// None of that interest may appear in the figure the threshold is tested
|
||||||
|
// against, or one deposit would be taxed against two people.
|
||||||
|
const jointTotal = jointAccounts
|
||||||
|
.flatMap((a) => a.credits)
|
||||||
|
.filter((c) => c.date >= '2025-04-01' && c.date <= '2026-03-31')
|
||||||
|
.reduce((s, c) => s + c.amount, 0);
|
||||||
|
expect(row.jointHeldInterest).toBe(jointTotal);
|
||||||
|
expect(row.tdsApplicableInterest).toBeLessThan(row.tdsApplicableInterest + jointTotal);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('quarter figures add up to the year', () => {
|
||||||
|
it('per row', () => {
|
||||||
|
for (const row of tdsInterestRowsForFy(CLOSED_FY)) {
|
||||||
|
const quarterInterest = row.quarters[1] + row.quarters[2] + row.quarters[3] + row.quarters[4];
|
||||||
|
expect(quarterInterest).toBe(row.tdsApplicableInterest);
|
||||||
|
expect(row.schedule.lines.reduce((s, l) => s + l.tds, 0)).toBe(row.schedule.totalTds);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('and across the summary', () => {
|
||||||
|
const s = tdsInterestSummary(filter());
|
||||||
|
expect(s.byQuarter.reduce((acc, q) => acc + q.interest, 0)).toBe(s.applicableInterest);
|
||||||
|
expect(s.byQuarter.reduce((acc, q) => acc + q.tds, 0)).toBe(s.totalTds);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('filters', () => {
|
||||||
|
it("narrows to a branch without changing anyone's figures", () => {
|
||||||
|
const all = tdsInterestRows(filter());
|
||||||
|
const branch = all[0].branchCode;
|
||||||
|
const scoped = tdsInterestRows(filter({ branchCode: branch }));
|
||||||
|
expect(scoped.length).toBeGreaterThan(0);
|
||||||
|
expect(scoped.every((r) => r.branchCode === branch)).toBe(true);
|
||||||
|
// A branch view must not re-aggregate — the threshold is computed bank-wide.
|
||||||
|
const sample = scoped[0];
|
||||||
|
const sameRow = all.find((r) => r.deductee.customerId === sample.deductee.customerId);
|
||||||
|
expect(sample.tdsApplicableInterest).toBe(sameRow?.tdsApplicableInterest);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finds the customers worth chasing a declaration from', () => {
|
||||||
|
const near = tdsInterestRows(filter({ nearThresholdOnly: true }));
|
||||||
|
expect(near.length).toBeGreaterThan(0);
|
||||||
|
for (const row of near) {
|
||||||
|
expect(row.crossed).toBe(false);
|
||||||
|
expect(row.headroom).toBeGreaterThan(0);
|
||||||
|
expect(row.headroom).toBeLessThanOrEqual(NEAR_THRESHOLD_BAND);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('narrows to customers who crossed', () => {
|
||||||
|
const crossed = tdsInterestRows(filter({ crossedOnly: true }));
|
||||||
|
expect(crossed.length).toBeGreaterThan(0);
|
||||||
|
expect(crossed.every((r) => r.crossed && r.tdsApplicableInterest > r.threshold)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('searches on name, CIF and PAN', () => {
|
||||||
|
const target = tdsInterestRowsForFy(CLOSED_FY).find((r) => r.panOnRecord)!;
|
||||||
|
expect(tdsInterestRows(filter({ search: target.deductee.customerId })).length).toBe(1);
|
||||||
|
expect(tdsInterestRows(filter({ search: target.deductee.pan })).length).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('declarations', () => {
|
||||||
|
it('save tax and record the interest paid gross', () => {
|
||||||
|
const declared = tdsInterestRowsForFy(CLOSED_FY).filter((r) => r.hasDeclaration);
|
||||||
|
expect(declared.length).toBeGreaterThan(0);
|
||||||
|
expect(declared.some((r) => r.tdsSaved > 0)).toBe(true);
|
||||||
|
// Interest paid without deduction is a mandatory 26Q disclosure, so it must
|
||||||
|
// be carried even though no tax came off it.
|
||||||
|
expect(declared.some((r) => r.paidWithoutDeduction > 0)).toBe(true);
|
||||||
|
for (const row of declared) {
|
||||||
|
expect(row.declarationUin).toBeTruthy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves tax deducted before a mid-year declaration in place', () => {
|
||||||
|
// Some declarations in the seed were accepted in October. Tax already taken
|
||||||
|
// in Q1/Q2 cannot be reversed, so those rows still carry a deduction.
|
||||||
|
const midYear = tdsInterestRowsForFy(CLOSED_FY).filter(
|
||||||
|
(r) => r.hasDeclaration && r.declarationAcceptedOn && r.declarationAcceptedOn.slice(5, 7) === '10'
|
||||||
|
);
|
||||||
|
expect(midYear.length).toBeGreaterThan(0);
|
||||||
|
expect(midYear.some((r) => r.schedule.totalTds > 0)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('remittance schedule', () => {
|
||||||
|
it('deposits by the seventh of the following month, and 30 April for March', () => {
|
||||||
|
expect(depositDueDate('2025-06')).toBe('2025-07-07');
|
||||||
|
expect(depositDueDate('2025-12')).toBe('2026-01-07');
|
||||||
|
expect(depositDueDate('2026-03')).toBe('2026-04-30');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('remits exactly what the engine says was deducted', () => {
|
||||||
|
const deducted = deductedByQuarter(CLOSED_FY);
|
||||||
|
const rows = tdsRemittanceRows(CLOSED_FY, TDS_AS_AT);
|
||||||
|
for (const row of rows) {
|
||||||
|
expect(row.amount).toBe(Math.round(deducted[row.quarter]));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('charges section 201(1A) interest on a late deposit and nothing on a timely one', () => {
|
||||||
|
const rows = tdsRemittanceRows(CLOSED_FY, TDS_AS_AT);
|
||||||
|
const late = rows.filter((r) => r.late);
|
||||||
|
expect(late.length).toBeGreaterThan(0);
|
||||||
|
for (const row of late) {
|
||||||
|
expect(row.delayMonths).toBeGreaterThan(0);
|
||||||
|
expect(row.defaultInterest).toBe(Math.round(row.amount * 0.015 * row.delayMonths));
|
||||||
|
}
|
||||||
|
for (const row of rows.filter((r) => !r.late && !r.overdue)) {
|
||||||
|
expect(row.defaultInterest).toBe(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the 26Q reconciliation control', () => {
|
||||||
|
it.each(TDS_FYS)('balances for FY %s', (fy) => {
|
||||||
|
const s = tds26qSummary(fy, TDS_AS_AT);
|
||||||
|
// Deducted must equal deposited plus what is still within its due date.
|
||||||
|
// Anything else means tax has gone missing between the ledger and the
|
||||||
|
// Government, and the return must not be filed until it is found.
|
||||||
|
expect(s.difference).toBe(0);
|
||||||
|
expect(s.totalDeducted).toBe(s.totalDeposited + s.pendingDeposit);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('splits the tax between the standard and the no-PAN rate without loss', () => {
|
||||||
|
const s = tds26qSummary(CLOSED_FY, TDS_AS_AT);
|
||||||
|
expect(s.tdsAtStandardRate + s.tdsAtHigherRate).toBe(s.totalDeducted);
|
||||||
|
expect(s.tdsAtHigherRate).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('splits interest into the deducted and the paid-gross halves', () => {
|
||||||
|
const s = tds26qSummary(CLOSED_FY, TDS_AS_AT);
|
||||||
|
expect(s.interestWithDeduction + s.interestWithoutDeduction).toBe(s.totalInterestPaid);
|
||||||
|
// The gross-paid half is the Form 121 disclosure and must not be zero here,
|
||||||
|
// or the report would never exercise that line.
|
||||||
|
expect(s.interestWithoutDeduction).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('agrees with the interest report on the same year', () => {
|
||||||
|
const summary = tdsInterestSummary(filter());
|
||||||
|
expect(tds26qSummary(CLOSED_FY, TDS_AS_AT).totalDeducted).toBe(summary.totalTds);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the part-run current year', () => {
|
||||||
|
it('has postings only up to the business date', () => {
|
||||||
|
for (const deductee of tdsDeductees()) {
|
||||||
|
for (const account of deductee.accounts) {
|
||||||
|
for (const credit of account.credits) {
|
||||||
|
expect(credit.date <= TDS_AS_AT).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the closed quarter as settled and future obligations as not yet due', () => {
|
||||||
|
const rows = tdsRemittanceRows('2026-27', TDS_AS_AT);
|
||||||
|
|
||||||
|
// Q1 (Apr–Jun) has closed, so its tax was deducted and is due by 07 Jul.
|
||||||
|
expect(rows.some((r) => r.quarter === 1)).toBe(true);
|
||||||
|
|
||||||
|
// Q3 cannot appear: no interest has posted there and nothing settles there.
|
||||||
|
expect(rows.some((r) => r.quarter === 3)).toBe(false);
|
||||||
|
|
||||||
|
// Q4 *can* appear even in a part-run year — deposits on annual interest
|
||||||
|
// posting settle once, at year end — but that is a future obligation, so it
|
||||||
|
// must read as pending and must never be flagged overdue.
|
||||||
|
for (const row of rows.filter((r) => r.quarter === 4)) {
|
||||||
|
expect(row.depositedOn).toBeNull();
|
||||||
|
expect(row.overdue).toBe(false);
|
||||||
|
expect(row.defaultInterest).toBe(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggregation for the two TDS reports.
|
||||||
|
*
|
||||||
|
* Every rupee figure here comes out of `tds-15gh.compute` — the reports do no
|
||||||
|
* tax arithmetic of their own. Writing it twice is how the register and the
|
||||||
|
* return end up disagreeing, so the engine is the single authority and this
|
||||||
|
* module only groups and totals what it returns.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
DeclarationForm,
|
||||||
|
FY_TAX_CONFIG,
|
||||||
|
Quarter,
|
||||||
|
TdsMode,
|
||||||
|
TdsSchedule,
|
||||||
|
ageInFy,
|
||||||
|
buildSchedule,
|
||||||
|
collectTdsCredits,
|
||||||
|
declarationFormFor,
|
||||||
|
formKindFor,
|
||||||
|
fyOf,
|
||||||
|
isSeniorInFy,
|
||||||
|
isTdsApplicable,
|
||||||
|
isWellFormedPan,
|
||||||
|
lateDeductionInterest,
|
||||||
|
monthsOrPartBetween,
|
||||||
|
tdsDeducteeOf,
|
||||||
|
tdsModeFor
|
||||||
|
} from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute';
|
||||||
|
import { TdsChallan, TdsDeductee, tdsChallans, tdsDeductees } from './tds-reports-data';
|
||||||
|
|
||||||
|
// ─────────────────────────────── Filtering ───────────────────────────────
|
||||||
|
|
||||||
|
export interface TdsReportFilter {
|
||||||
|
fy: string;
|
||||||
|
/** '' means every branch. */
|
||||||
|
branchCode: string;
|
||||||
|
/** 0 means the whole year. */
|
||||||
|
quarter: 0 | Quarter;
|
||||||
|
/** '' means both declaration types. */
|
||||||
|
band: '' | 'GENERAL' | 'SENIOR';
|
||||||
|
/** Free text over name, CIF and PAN. */
|
||||||
|
search: string;
|
||||||
|
/** Only customers whose aggregate crossed the deduction trigger. */
|
||||||
|
crossedOnly: boolean;
|
||||||
|
/** Only customers sitting just below the trigger — the follow-up list. */
|
||||||
|
nearThresholdOnly: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How close to the trigger counts as "worth chasing a declaration for". */
|
||||||
|
export const NEAR_THRESHOLD_BAND = 6_000;
|
||||||
|
|
||||||
|
// ───────────────────────── Report 1: interest ─────────────────────────
|
||||||
|
|
||||||
|
export interface TdsInterestRow {
|
||||||
|
deductee: TdsDeductee;
|
||||||
|
branchCode: string;
|
||||||
|
/** The form in force for the year — '121' from FY 2026-27. */
|
||||||
|
form: DeclarationForm;
|
||||||
|
/** Which deduction trigger applies. Age-based, even after the forms merged. */
|
||||||
|
band: 'GENERAL' | 'SENIOR';
|
||||||
|
/**
|
||||||
|
* The 15G/15H label. A form distinction only up to FY 2025-26; from FY 2026-27
|
||||||
|
* the two merged, so it duplicates `band` and is kept for pre-2026 years.
|
||||||
|
*/
|
||||||
|
kind: '15G' | '15H';
|
||||||
|
age: number;
|
||||||
|
/** Settlement timing, from the customer's deposit products. */
|
||||||
|
mode: TdsMode;
|
||||||
|
/** Interest on time deposits where this customer is the deductee, by quarter. */
|
||||||
|
quarters: Record<Quarter, number>;
|
||||||
|
/** Total of the above — the figure the deduction trigger is tested against. */
|
||||||
|
tdsApplicableInterest: number;
|
||||||
|
/** Savings interest, shown alongside but outside the charge on time deposits. */
|
||||||
|
exemptInterest: number;
|
||||||
|
/** Interest on deposits where somebody else is the first-named holder. */
|
||||||
|
jointHeldInterest: number;
|
||||||
|
threshold: number;
|
||||||
|
crossed: boolean;
|
||||||
|
/** The quarter the aggregate crossed the trigger, if it did. */
|
||||||
|
crossedInQuarter: Quarter | null;
|
||||||
|
/** How much more interest before the trigger bites. */
|
||||||
|
headroom: number;
|
||||||
|
nearThreshold: boolean;
|
||||||
|
schedule: TdsSchedule;
|
||||||
|
hasDeclaration: boolean;
|
||||||
|
declarationUin: string | null;
|
||||||
|
declarationAcceptedOn: string | null;
|
||||||
|
/** Tax the declaration kept out of the customer's pocket. */
|
||||||
|
tdsSaved: number;
|
||||||
|
/** Interest paid gross on the strength of the declaration — reportable in the return. */
|
||||||
|
paidWithoutDeduction: number;
|
||||||
|
panOnRecord: boolean;
|
||||||
|
rateApplied: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyQuarters(): Record<Quarter, number> {
|
||||||
|
return { 1: 0, 2: 0, 3: 0, 4: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One row per deductee for the selected year.
|
||||||
|
*
|
||||||
|
* Memoised on the year alone: building a row runs two schedules, and the getters
|
||||||
|
* that feed the template are read many times per change-detection pass.
|
||||||
|
*/
|
||||||
|
let rowCacheKey = '';
|
||||||
|
let rowCache: TdsInterestRow[] = [];
|
||||||
|
|
||||||
|
export function tdsInterestRowsForFy(fy: string): TdsInterestRow[] {
|
||||||
|
if (rowCacheKey === fy) {
|
||||||
|
return rowCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows: TdsInterestRow[] = [];
|
||||||
|
|
||||||
|
for (const deductee of tdsDeductees()) {
|
||||||
|
const ctx = {
|
||||||
|
fy,
|
||||||
|
dateOfBirth: deductee.dob,
|
||||||
|
pan: deductee.pan,
|
||||||
|
resident: true
|
||||||
|
};
|
||||||
|
|
||||||
|
// Rules 8 to 12 live in the engine: savings interest dropped, joint deposits
|
||||||
|
// attributed to the first-named holder, every branch aggregated.
|
||||||
|
const credits = collectTdsCredits(deductee.clientId, deductee.accounts, fy);
|
||||||
|
if (!credits.length && !deductee.accounts.length) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const declaration = deductee.declarations[fy] ?? null;
|
||||||
|
const mode = overallModeFor(deductee);
|
||||||
|
const schedule = buildSchedule(mode, credits, { ...ctx, declaration });
|
||||||
|
const withoutDeclaration = declaration ? buildSchedule(mode, credits, { ...ctx, declaration: null }) : schedule;
|
||||||
|
|
||||||
|
const quarters = emptyQuarters();
|
||||||
|
for (const line of schedule.lines) {
|
||||||
|
quarters[line.quarter] = line.interest;
|
||||||
|
}
|
||||||
|
|
||||||
|
let exemptInterest = 0;
|
||||||
|
let jointHeldInterest = 0;
|
||||||
|
for (const account of deductee.accounts) {
|
||||||
|
const inYear = account.credits.filter((c) => fyOf(c.date) === fy).reduce((s, c) => s + c.amount, 0);
|
||||||
|
if (!isTdsApplicable(account)) {
|
||||||
|
exemptInterest += inYear;
|
||||||
|
} else if (tdsDeducteeOf(account) !== deductee.clientId) {
|
||||||
|
jointHeldInterest += inYear;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const crossedLine = schedule.lines.find((l) => l.thresholdCrossedHere);
|
||||||
|
const headroom = Math.max(0, schedule.threshold - schedule.totalInterest);
|
||||||
|
|
||||||
|
rows.push({
|
||||||
|
deductee,
|
||||||
|
branchCode: deductee.branchCode,
|
||||||
|
form: declarationFormFor(fy, deductee.dob),
|
||||||
|
band: isSeniorInFy(deductee.dob, fy) ? 'SENIOR' : 'GENERAL',
|
||||||
|
kind: formKindFor(deductee.dob, fy),
|
||||||
|
age: ageInFy(deductee.dob, fy),
|
||||||
|
mode,
|
||||||
|
quarters,
|
||||||
|
tdsApplicableInterest: schedule.totalInterest,
|
||||||
|
exemptInterest,
|
||||||
|
jointHeldInterest,
|
||||||
|
threshold: schedule.threshold,
|
||||||
|
crossed: schedule.totalInterest > schedule.threshold,
|
||||||
|
crossedInQuarter: crossedLine?.quarter ?? null,
|
||||||
|
headroom,
|
||||||
|
nearThreshold: headroom > 0 && headroom <= NEAR_THRESHOLD_BAND,
|
||||||
|
schedule,
|
||||||
|
hasDeclaration: !!declaration,
|
||||||
|
declarationUin: declaration?.uin ?? null,
|
||||||
|
declarationAcceptedOn: declaration?.acceptedOn ?? null,
|
||||||
|
tdsSaved: declaration ? Math.max(0, withoutDeclaration.totalTds - schedule.totalTds) : 0,
|
||||||
|
paidWithoutDeduction: schedule.shieldedInterest,
|
||||||
|
panOnRecord: isWellFormedPan(deductee.pan),
|
||||||
|
rateApplied: schedule.rateApplied
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.sort((a, b) => b.tdsApplicableInterest - a.tdsApplicableInterest);
|
||||||
|
rowCacheKey = fy;
|
||||||
|
rowCache = rows;
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When tax settles for a customer whose deposits post interest on different
|
||||||
|
* cycles. A quarterly deposit anywhere means a deduction falls due at the first
|
||||||
|
* quarter end, so that governs.
|
||||||
|
*/
|
||||||
|
function overallModeFor(deductee: TdsDeductee): TdsMode {
|
||||||
|
const applicable = deductee.accounts.filter((a) => isTdsApplicable(a) && tdsDeducteeOf(a) === deductee.clientId);
|
||||||
|
return applicable.some((a) => tdsModeFor(a) === 'QUARTERLY') ? 'QUARTERLY' : 'ANNUAL';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tdsInterestRows(filter: TdsReportFilter): TdsInterestRow[] {
|
||||||
|
const needle = filter.search.trim().toLowerCase();
|
||||||
|
return tdsInterestRowsForFy(filter.fy).filter((row) => {
|
||||||
|
if (filter.branchCode && row.branchCode !== filter.branchCode) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (filter.band && row.band !== filter.band) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (filter.crossedOnly && !row.crossed) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (filter.nearThresholdOnly && !row.nearThreshold) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// A quarter filter narrows to customers who actually had interest in it.
|
||||||
|
if (filter.quarter && !row.quarters[filter.quarter]) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!needle) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const d = row.deductee;
|
||||||
|
return (
|
||||||
|
d.name.toLowerCase().includes(needle) ||
|
||||||
|
d.customerId.toLowerCase().includes(needle) ||
|
||||||
|
d.pan.toLowerCase().includes(needle)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TdsInterestSummary {
|
||||||
|
deductees: number;
|
||||||
|
/** Interest on time deposits — the base the charge applies to. */
|
||||||
|
applicableInterest: number;
|
||||||
|
/** Savings interest, outside the section. */
|
||||||
|
exemptInterest: number;
|
||||||
|
totalTds: number;
|
||||||
|
crossed: number;
|
||||||
|
nearThreshold: number;
|
||||||
|
declarations: number;
|
||||||
|
tdsSaved: number;
|
||||||
|
paidWithoutDeduction: number;
|
||||||
|
noPan: number;
|
||||||
|
byQuarter: { quarter: Quarter; interest: number; tds: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tdsInterestSummary(filter: TdsReportFilter): TdsInterestSummary {
|
||||||
|
const rows = tdsInterestRows(filter);
|
||||||
|
const byQuarter = ([
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
3,
|
||||||
|
4
|
||||||
|
] as Quarter[]).map((quarter) => ({
|
||||||
|
quarter,
|
||||||
|
interest: rows.reduce((s, r) => s + r.quarters[quarter], 0),
|
||||||
|
tds: rows.reduce((s, r) => s + (r.schedule.lines.find((l) => l.quarter === quarter)?.tds ?? 0), 0)
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
deductees: rows.length,
|
||||||
|
applicableInterest: rows.reduce((s, r) => s + r.tdsApplicableInterest, 0),
|
||||||
|
exemptInterest: rows.reduce((s, r) => s + r.exemptInterest, 0),
|
||||||
|
totalTds: rows.reduce((s, r) => s + r.schedule.totalTds, 0),
|
||||||
|
crossed: rows.filter((r) => r.crossed).length,
|
||||||
|
nearThreshold: rows.filter((r) => r.nearThreshold).length,
|
||||||
|
declarations: rows.filter((r) => r.hasDeclaration).length,
|
||||||
|
tdsSaved: rows.reduce((s, r) => s + r.tdsSaved, 0),
|
||||||
|
paidWithoutDeduction: rows.reduce((s, r) => s + r.paidWithoutDeduction, 0),
|
||||||
|
noPan: rows.filter((r) => !r.panOnRecord).length,
|
||||||
|
byQuarter
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────── Report 2: remittance ───────────────────────
|
||||||
|
|
||||||
|
export interface TdsRemittanceRow extends TdsChallan {
|
||||||
|
/** Statutory due date has passed with nothing deposited. */
|
||||||
|
overdue: boolean;
|
||||||
|
/** Deposited, but after the due date. */
|
||||||
|
late: boolean;
|
||||||
|
/** Months or part months of delay. */
|
||||||
|
delayMonths: number;
|
||||||
|
/** Interest charged for depositing late. */
|
||||||
|
defaultInterest: number;
|
||||||
|
/** Return the quarter's deduction is reported in. */
|
||||||
|
returnDueDate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RETURN_DUE: Record<Quarter, string> = {
|
||||||
|
1: '31 Jul',
|
||||||
|
2: '31 Oct',
|
||||||
|
3: '31 Jan',
|
||||||
|
4: '31 May'
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Tax deducted per quarter, from the engine, for one year. */
|
||||||
|
export function deductedByQuarter(fy: string): Record<number, number> {
|
||||||
|
const out: Record<number, number> = { 1: 0, 2: 0, 3: 0, 4: 0 };
|
||||||
|
for (const row of tdsInterestRowsForFy(fy)) {
|
||||||
|
for (const line of row.schedule.lines) {
|
||||||
|
out[line.quarter] += line.tds;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tdsRemittanceRows(fy: string, asAt: string): TdsRemittanceRow[] {
|
||||||
|
const deducted: Record<string, Record<number, number>> = {};
|
||||||
|
for (const year of Object.keys(FY_TAX_CONFIG)) {
|
||||||
|
deducted[year] = deductedByQuarter(year);
|
||||||
|
}
|
||||||
|
const challans = tdsChallans(deducted)[fy] ?? [];
|
||||||
|
|
||||||
|
return challans.map((c) => {
|
||||||
|
const overdue = !c.depositedOn && c.dueDate < asAt;
|
||||||
|
const late = !!c.depositedOn && c.depositedOn > c.dueDate;
|
||||||
|
// While a payment is overdue the clock runs to today; once paid it stops on
|
||||||
|
// the payment date.
|
||||||
|
const settledOn = c.depositedOn ?? asAt;
|
||||||
|
const delayMonths = late || overdue ? monthsOrPartBetween(c.dueDate, settledOn) : 0;
|
||||||
|
const defaultInterest =
|
||||||
|
late || overdue ? lateDeductionInterest(c.amount, c.dueDate, settledOn, 'DEPOSIT', fy).interest : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...c,
|
||||||
|
overdue,
|
||||||
|
late,
|
||||||
|
delayMonths,
|
||||||
|
defaultInterest,
|
||||||
|
returnDueDate: RETURN_DUE[c.quarter]
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The year-end position — what the quarterly TDS return has to reconcile to.
|
||||||
|
*
|
||||||
|
* `difference` is the control figure: tax the engine says was deducted, less what
|
||||||
|
* actually reached the Government. Anything other than zero, once the pending
|
||||||
|
* challans are settled, means something is wrong and must be found before filing.
|
||||||
|
*/
|
||||||
|
export interface Tds26qSummary {
|
||||||
|
fy: string;
|
||||||
|
deductees: number;
|
||||||
|
totalInterestPaid: number;
|
||||||
|
interestWithDeduction: number;
|
||||||
|
/** Paid gross against a declaration — a mandatory disclosure in the return. */
|
||||||
|
interestWithoutDeduction: number;
|
||||||
|
tdsAtStandardRate: number;
|
||||||
|
tdsAtHigherRate: number;
|
||||||
|
totalDeducted: number;
|
||||||
|
totalDeposited: number;
|
||||||
|
pendingDeposit: number;
|
||||||
|
/** Deducted less deposited less still-pending. Must be zero. */
|
||||||
|
difference: number;
|
||||||
|
defaultInterest: number;
|
||||||
|
lateChallans: number;
|
||||||
|
overdueChallans: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tds26qSummary(fy: string, asAt: string): Tds26qSummary {
|
||||||
|
const rows = tdsInterestRowsForFy(fy);
|
||||||
|
const challans = tdsRemittanceRows(fy, asAt);
|
||||||
|
const standard = rows.filter((r) => r.panOnRecord);
|
||||||
|
const higher = rows.filter((r) => !r.panOnRecord);
|
||||||
|
const totalDeducted = rows.reduce((s, r) => s + r.schedule.totalTds, 0);
|
||||||
|
const totalDeposited = challans.filter((c) => c.depositedOn).reduce((s, c) => s + c.amount, 0);
|
||||||
|
const pendingDeposit = challans.filter((c) => !c.depositedOn).reduce((s, c) => s + c.amount, 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
fy,
|
||||||
|
deductees: rows.length,
|
||||||
|
totalInterestPaid: rows.reduce((s, r) => s + r.tdsApplicableInterest, 0),
|
||||||
|
interestWithDeduction: rows.reduce((s, r) => s + r.schedule.liableInterest, 0),
|
||||||
|
interestWithoutDeduction: rows.reduce((s, r) => s + r.paidWithoutDeduction, 0),
|
||||||
|
tdsAtStandardRate: standard.reduce((s, r) => s + r.schedule.totalTds, 0),
|
||||||
|
tdsAtHigherRate: higher.reduce((s, r) => s + r.schedule.totalTds, 0),
|
||||||
|
totalDeducted,
|
||||||
|
totalDeposited,
|
||||||
|
pendingDeposit,
|
||||||
|
difference: Math.round(totalDeducted - totalDeposited - pendingDeposit),
|
||||||
|
defaultInterest: challans.reduce((s, c) => s + c.defaultInterest, 0),
|
||||||
|
lateChallans: challans.filter((c) => c.late).length,
|
||||||
|
overdueChallans: challans.filter((c) => c.overdue).length
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,546 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Shared by the interest report and the remittance report. */
|
||||||
|
|
||||||
|
.tdsr {
|
||||||
|
--tdsr-line: #e5e7eb;
|
||||||
|
--tdsr-bg: #f9fafb;
|
||||||
|
--tdsr-ink: #111827;
|
||||||
|
--tdsr-muted: #6b7280;
|
||||||
|
--tdsr-blue: #1d4ed8;
|
||||||
|
--tdsr-green: #15803d;
|
||||||
|
--tdsr-amber: #b45309;
|
||||||
|
|
||||||
|
padding: 20px 24px 40px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--tdsr-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Header ── */
|
||||||
|
.tdsr__head {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tdsr__head-l {
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tdsr__head-r {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tdsr__head-ic {
|
||||||
|
font-size: 22px;
|
||||||
|
color: var(--tdsr-blue);
|
||||||
|
margin-top: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tdsr__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 19px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tdsr__sub {
|
||||||
|
margin-top: 4px;
|
||||||
|
color: var(--tdsr-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tdsr__btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
padding: 9px 15px;
|
||||||
|
border: 1px solid var(--tdsr-line);
|
||||||
|
border-radius: 7px;
|
||||||
|
background: #fff;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--tdsr-ink);
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: var(--tdsr-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Banners ── */
|
||||||
|
.tdsr__banner {
|
||||||
|
display: flex;
|
||||||
|
gap: 11px;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
padding: 13px 15px;
|
||||||
|
border-radius: 9px;
|
||||||
|
border-left: 4px solid var(--tdsr-blue);
|
||||||
|
background: color-mix(in srgb, var(--tdsr-blue) 6%, transparent);
|
||||||
|
line-height: 1.55;
|
||||||
|
|
||||||
|
fa-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 1px;
|
||||||
|
color: var(--tdsr-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--ok {
|
||||||
|
border-left-color: var(--tdsr-green);
|
||||||
|
background: color-mix(in srgb, var(--tdsr-green) 7%, transparent);
|
||||||
|
|
||||||
|
fa-icon {
|
||||||
|
color: var(--tdsr-green);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--warn {
|
||||||
|
border-left-color: var(--tdsr-amber);
|
||||||
|
background: color-mix(in srgb, var(--tdsr-amber) 9%, transparent);
|
||||||
|
|
||||||
|
fa-icon {
|
||||||
|
color: var(--tdsr-amber);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── KPI strip ── */
|
||||||
|
.tdsr__kpis {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(165px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tdsr__kpi {
|
||||||
|
padding: 13px 15px;
|
||||||
|
border: 1px solid var(--tdsr-line);
|
||||||
|
border-left: 3px solid var(--tdsr-blue);
|
||||||
|
border-radius: 9px;
|
||||||
|
background: #fff;
|
||||||
|
text-align: left;
|
||||||
|
|
||||||
|
&--ok {
|
||||||
|
border-left-color: var(--tdsr-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--warn {
|
||||||
|
border-left-color: var(--tdsr-amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--muted {
|
||||||
|
border-left-color: var(--tdsr-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The near-threshold tile doubles as the button that filters to it. */
|
||||||
|
&--action {
|
||||||
|
border-left-color: var(--tdsr-green);
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--tdsr-bg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tdsr__kpi-n {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 800;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tdsr__kpi-l {
|
||||||
|
margin-top: 3px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--tdsr-muted);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Filters ── */
|
||||||
|
.tdsr__filters {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 14px;
|
||||||
|
align-items: flex-end;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border: 1px solid var(--tdsr-line);
|
||||||
|
border-radius: 9px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tdsr__filter {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
|
||||||
|
> span {
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--tdsr-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
select,
|
||||||
|
input {
|
||||||
|
min-width: 150px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
border: 1px solid var(--tdsr-line);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--wide input {
|
||||||
|
min-width: 230px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tdsr__check {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 7px;
|
||||||
|
align-items: center;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tdsr__clear {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
padding: 7px 12px;
|
||||||
|
border: 1px solid var(--tdsr-line);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--tdsr-bg);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--tdsr-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Cards ── */
|
||||||
|
.tdsr__card {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
border: 1px solid var(--tdsr-line);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fff;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tdsr__card-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: var(--tdsr-bg);
|
||||||
|
border-bottom: 1px solid var(--tdsr-line);
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 12.5px;
|
||||||
|
|
||||||
|
fa-icon {
|
||||||
|
color: var(--tdsr-muted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tdsr__foot {
|
||||||
|
padding: 11px 16px;
|
||||||
|
border-top: 1px solid var(--tdsr-line);
|
||||||
|
background: var(--tdsr-bg);
|
||||||
|
color: var(--tdsr-muted);
|
||||||
|
font-size: 11.5px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tdsr__empty {
|
||||||
|
padding: 26px 16px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--tdsr-muted);
|
||||||
|
|
||||||
|
fa-icon {
|
||||||
|
margin-right: 6px;
|
||||||
|
color: var(--tdsr-blue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Quarter strip ── */
|
||||||
|
.tdsr__qstrip {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.tdsr__qcell {
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-right: 1px solid var(--tdsr-line);
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-right: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tdsr__qcell-l {
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--tdsr-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tdsr__qcell-n {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 800;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tdsr__qcell-s {
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--tdsr-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Tables ── */
|
||||||
|
.tdsr__table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tdsr__table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 12.5px;
|
||||||
|
|
||||||
|
th {
|
||||||
|
text-align: left;
|
||||||
|
padding: 11px 14px;
|
||||||
|
background: #fff;
|
||||||
|
border-bottom: 1px solid var(--tdsr-line);
|
||||||
|
color: var(--tdsr-muted);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 10.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 11px 14px;
|
||||||
|
border-bottom: 1px solid #f3f4f6;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:last-child td {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.num {
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mono {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.small {
|
||||||
|
font-size: 11.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: var(--tdsr-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-actions {
|
||||||
|
width: 44px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--inner {
|
||||||
|
border: 1px solid var(--tdsr-line);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tdsr__row--warn > td {
|
||||||
|
background: color-mix(in srgb, var(--tdsr-amber) 7%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tdsr__row--near > td {
|
||||||
|
background: color-mix(in srgb, var(--tdsr-green) 5%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tdsr__row--detail > td {
|
||||||
|
background: var(--tdsr-bg);
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tdsr__name {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tdsr__ok {
|
||||||
|
color: var(--tdsr-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tdsr__warn-text {
|
||||||
|
color: var(--tdsr-amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tdsr__icon-btn {
|
||||||
|
padding: 5px 8px;
|
||||||
|
border: 1px solid var(--tdsr-line);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fff;
|
||||||
|
color: var(--tdsr-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--tdsr-bg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tdsr__note {
|
||||||
|
display: flex;
|
||||||
|
gap: 9px;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 11px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border-left: 3px solid var(--tdsr-blue);
|
||||||
|
background: color-mix(in srgb, var(--tdsr-blue) 6%, transparent);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
|
||||||
|
fa-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 2px;
|
||||||
|
color: var(--tdsr-blue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tdsr__meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 18px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--tdsr-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Ledger (Part B of the remittance report) ── */
|
||||||
|
.tdsr__ledger-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20px;
|
||||||
|
padding: 11px 16px;
|
||||||
|
border-bottom: 1px solid #f3f4f6;
|
||||||
|
line-height: 1.5;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.num {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
em {
|
||||||
|
display: block;
|
||||||
|
font-size: 10.5px;
|
||||||
|
color: var(--tdsr-amber);
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--indent {
|
||||||
|
padding-left: 36px;
|
||||||
|
color: var(--tdsr-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--sep {
|
||||||
|
border-top: 1px solid var(--tdsr-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--total {
|
||||||
|
border-top: 1px solid var(--tdsr-line);
|
||||||
|
background: var(--tdsr-bg);
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The reconciliation control. Green when zero, red when not. */
|
||||||
|
&--control {
|
||||||
|
background: color-mix(in srgb, var(--tdsr-green) 8%, transparent);
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--tdsr-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--bad {
|
||||||
|
background: color-mix(in srgb, var(--tdsr-amber) 10%, transparent);
|
||||||
|
color: var(--tdsr-amber);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Pills ── */
|
||||||
|
.tdsr__pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px 9px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--tdsr-line);
|
||||||
|
background: var(--tdsr-bg);
|
||||||
|
color: var(--tdsr-muted);
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
& + & {
|
||||||
|
margin-left: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--ok {
|
||||||
|
border-color: color-mix(in srgb, var(--tdsr-green) 35%, transparent);
|
||||||
|
background: color-mix(in srgb, var(--tdsr-green) 10%, transparent);
|
||||||
|
color: var(--tdsr-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--warn {
|
||||||
|
border-color: color-mix(in srgb, var(--tdsr-amber) 35%, transparent);
|
||||||
|
background: color-mix(in srgb, var(--tdsr-amber) 12%, transparent);
|
||||||
|
color: var(--tdsr-amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--near {
|
||||||
|
border-color: color-mix(in srgb, var(--tdsr-blue) 30%, transparent);
|
||||||
|
background: color-mix(in srgb, var(--tdsr-blue) 8%, transparent);
|
||||||
|
color: var(--tdsr-blue);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,467 @@
|
|||||||
|
/**
|
||||||
|
* 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 three things the bank actually has to file or hand over.
|
||||||
|
*
|
||||||
|
* 1. The consolidated statement of declarations received — monthly from
|
||||||
|
* FY 2026-27, due on the 7th of the following month, carrying the UIN the
|
||||||
|
* branch allotted to each Form 121.
|
||||||
|
* 2. The deductee annexure of the quarterly return (Form 140), which needs the
|
||||||
|
* UIN against every payee paid gross so the department can tie a
|
||||||
|
* nil-deduction back to a declaration.
|
||||||
|
* 3. The certificate issued to each deductee (Form 131), which is what the
|
||||||
|
* customer uses to claim credit in their own return.
|
||||||
|
*
|
||||||
|
* All figures come from the TDS engine. Nothing here recomputes tax — a return
|
||||||
|
* that disagrees with the register is worse than no return at all.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
FY_TAX_CONFIG,
|
||||||
|
Quarter,
|
||||||
|
buildUin,
|
||||||
|
declarationStatementDueDate,
|
||||||
|
isWellFormedPan,
|
||||||
|
normaliseFy,
|
||||||
|
quarterEnd,
|
||||||
|
quarterOf
|
||||||
|
} from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute';
|
||||||
|
import { TDS_AS_AT } from './tds-reports-data';
|
||||||
|
import { TdsInterestRow, tdsInterestRowsForFy } from './tds-reports.compute';
|
||||||
|
|
||||||
|
/** The bank's own deductor particulars, which head every filing. */
|
||||||
|
export interface DeductorParticulars {
|
||||||
|
tan: string;
|
||||||
|
pan: string;
|
||||||
|
name: string;
|
||||||
|
branchAddress: string;
|
||||||
|
responsiblePerson: string;
|
||||||
|
designation: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Placeholder particulars for the demo book. In a live install these come from
|
||||||
|
* the organisation's tax registration, not from a constant.
|
||||||
|
*/
|
||||||
|
export const DEDUCTOR: DeductorParticulars = {
|
||||||
|
tan: 'MUMC12345E',
|
||||||
|
pan: 'AAACC1234C',
|
||||||
|
name: 'CBS Nexora Co-operative Bank Ltd.',
|
||||||
|
branchAddress: 'Head Office, Mumbai 400001',
|
||||||
|
responsiblePerson: 'Chief Financial Officer',
|
||||||
|
designation: 'Principal Officer'
|
||||||
|
};
|
||||||
|
|
||||||
|
// ───────────────── 1. Consolidated statement of declarations ─────────────────
|
||||||
|
|
||||||
|
export interface DeclarationStatementLine {
|
||||||
|
/** Running number within the statement. */
|
||||||
|
serial: number;
|
||||||
|
uin: string;
|
||||||
|
payeeName: string;
|
||||||
|
pan: string;
|
||||||
|
customerId: string;
|
||||||
|
branchCode: string;
|
||||||
|
/** Date the branch accepted the declaration. */
|
||||||
|
acceptedOn: string;
|
||||||
|
/**
|
||||||
|
* Income the declaration covers, as estimated when it was accepted. The
|
||||||
|
* statement reports the amount the payer expects to pay gross.
|
||||||
|
*/
|
||||||
|
estimatedIncome: number;
|
||||||
|
/** Interest actually paid without deduction so far. */
|
||||||
|
paidWithoutDeduction: number;
|
||||||
|
/** Whether the declaration would survive scrutiny on the branch's own record. */
|
||||||
|
eligible: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeclarationStatementPeriod {
|
||||||
|
/** 'MONTHLY' periods are keyed '2026-04'; quarterly ones 'Q1'. */
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
/** Statutory upload deadline for this period. */
|
||||||
|
dueDate: string;
|
||||||
|
overdue: boolean;
|
||||||
|
lines: DeclarationStatementLine[];
|
||||||
|
totalPaidWithoutDeduction: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeclarationStatement {
|
||||||
|
fy: string;
|
||||||
|
form: string;
|
||||||
|
rule: string;
|
||||||
|
frequency: 'MONTHLY' | 'QUARTERLY';
|
||||||
|
periods: DeclarationStatementPeriod[];
|
||||||
|
totalDeclarations: number;
|
||||||
|
/** Declarations on record that the branch could not lawfully have accepted. */
|
||||||
|
ineligibleCount: number;
|
||||||
|
/** Declarations with no UIN allotted — these cannot be filed at all. */
|
||||||
|
missingUinCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function monthLabel(key: string): string {
|
||||||
|
const months = [
|
||||||
|
'Jan',
|
||||||
|
'Feb',
|
||||||
|
'Mar',
|
||||||
|
'Apr',
|
||||||
|
'May',
|
||||||
|
'Jun',
|
||||||
|
'Jul',
|
||||||
|
'Aug',
|
||||||
|
'Sep',
|
||||||
|
'Oct',
|
||||||
|
'Nov',
|
||||||
|
'Dec'
|
||||||
|
];
|
||||||
|
return `${months[Number(key.slice(5, 7)) - 1]} ${key.slice(0, 4)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Groups every declaration on record for the year into the periods the payer has
|
||||||
|
* to upload, and flags the ones that are already past their due date.
|
||||||
|
*
|
||||||
|
* A declaration with no UIN is counted separately rather than filed with a blank:
|
||||||
|
* the portal rejects the line, and a silently dropped row is how a bank ends up
|
||||||
|
* reporting fewer declarations than it holds.
|
||||||
|
*/
|
||||||
|
export function declarationStatement(fy: string, asAt: string = TDS_AS_AT): DeclarationStatement {
|
||||||
|
const year = normaliseFy(fy);
|
||||||
|
const cfg = FY_TAX_CONFIG[year];
|
||||||
|
if (!cfg) {
|
||||||
|
throw new Error(`No tax configuration for FY ${year}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const declared = tdsInterestRowsForFy(year).filter((r) => r.hasDeclaration && r.declarationAcceptedOn);
|
||||||
|
const buckets = new Map<string, DeclarationStatementLine[]>();
|
||||||
|
let serial = 0;
|
||||||
|
let ineligibleCount = 0;
|
||||||
|
let missingUinCount = 0;
|
||||||
|
|
||||||
|
for (const row of declared) {
|
||||||
|
const acceptedOn = row.declarationAcceptedOn as string;
|
||||||
|
const key = cfg.declarationStatementFrequency === 'MONTHLY' ? acceptedOn.slice(0, 7) : `Q${quarterOf(acceptedOn)}`;
|
||||||
|
|
||||||
|
if (!row.declarationUin) {
|
||||||
|
missingUinCount++;
|
||||||
|
}
|
||||||
|
// The branch cannot compute income earned elsewhere, so eligibility here is
|
||||||
|
// only what the register can see: a valid PAN on an individual.
|
||||||
|
const eligible = isWellFormedPan(row.deductee.pan);
|
||||||
|
if (!eligible) {
|
||||||
|
ineligibleCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const line: DeclarationStatementLine = {
|
||||||
|
serial: ++serial,
|
||||||
|
uin: row.declarationUin ?? '',
|
||||||
|
payeeName: row.deductee.name,
|
||||||
|
pan: row.deductee.pan,
|
||||||
|
customerId: row.deductee.customerId,
|
||||||
|
branchCode: row.branchCode,
|
||||||
|
acceptedOn,
|
||||||
|
estimatedIncome: row.tdsApplicableInterest,
|
||||||
|
paidWithoutDeduction: row.paidWithoutDeduction,
|
||||||
|
eligible
|
||||||
|
};
|
||||||
|
|
||||||
|
const list = buckets.get(key);
|
||||||
|
if (list) {
|
||||||
|
list.push(line);
|
||||||
|
} else {
|
||||||
|
buckets.set(key, [line]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const periods: DeclarationStatementPeriod[] = [...buckets.entries()]
|
||||||
|
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||||
|
.map(
|
||||||
|
([
|
||||||
|
key,
|
||||||
|
lines
|
||||||
|
]) => {
|
||||||
|
const dueDate = declarationStatementDueDate(lines[0].acceptedOn, year);
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
label: cfg.declarationStatementFrequency === 'MONTHLY' ? monthLabel(`${key}-01`) : key,
|
||||||
|
dueDate,
|
||||||
|
overdue: dueDate < asAt,
|
||||||
|
lines,
|
||||||
|
totalPaidWithoutDeduction: lines.reduce((s, l) => s + l.paidWithoutDeduction, 0)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
fy: year,
|
||||||
|
form: cfg.declarationForm === '121' ? 'Form 121' : 'Form 15G / 15H',
|
||||||
|
rule: cfg.declarationRule,
|
||||||
|
frequency: cfg.declarationStatementFrequency,
|
||||||
|
periods,
|
||||||
|
totalDeclarations: declared.length,
|
||||||
|
ineligibleCount,
|
||||||
|
missingUinCount
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allots UINs to declarations that do not have one.
|
||||||
|
*
|
||||||
|
* Sequence numbers continue from the highest already in use for the year, so a
|
||||||
|
* re-run does not renumber declarations that have already been filed.
|
||||||
|
*/
|
||||||
|
export function allotMissingUins(rows: TdsInterestRow[], fy: string, tan: string = DEDUCTOR.tan): Map<string, string> {
|
||||||
|
const out = new Map<string, string>();
|
||||||
|
let next = rows.reduce((max, r) => {
|
||||||
|
const seq = r.declarationUin ? Number(r.declarationUin.slice(1, 10)) : 0;
|
||||||
|
return Number.isFinite(seq) && seq > max ? seq : max;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.hasDeclaration && !row.declarationUin) {
|
||||||
|
out.set(row.deductee.customerId, buildUin(++next, fy, tan));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────── 2. Deductee annexure of the return ────────────────────
|
||||||
|
|
||||||
|
/** One deductee line in the quarterly return's annexure. */
|
||||||
|
export interface ReturnAnnexureLine {
|
||||||
|
serial: number;
|
||||||
|
payeeName: string;
|
||||||
|
pan: string;
|
||||||
|
/** 'Individual' or 'HUF', from the fourth character of the PAN. */
|
||||||
|
payeeType: string;
|
||||||
|
/** Amount paid or credited in the quarter. */
|
||||||
|
amountPaid: number;
|
||||||
|
/** Tax deducted in the quarter. */
|
||||||
|
taxDeducted: number;
|
||||||
|
rate: number;
|
||||||
|
/**
|
||||||
|
* Reason the payer deducted nothing or less than the standard rate.
|
||||||
|
* Blank where tax was deducted normally.
|
||||||
|
*/
|
||||||
|
remark: string;
|
||||||
|
/** UIN of the declaration, required wherever the remark cites one. */
|
||||||
|
uin: string;
|
||||||
|
branchCode: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReturnAnnexure {
|
||||||
|
fy: string;
|
||||||
|
quarter: Quarter;
|
||||||
|
form: string;
|
||||||
|
rule: string;
|
||||||
|
section: string;
|
||||||
|
/** Last day of the quarter the return covers. */
|
||||||
|
periodEnd: string;
|
||||||
|
dueDate: string;
|
||||||
|
lines: ReturnAnnexureLine[];
|
||||||
|
totalPaid: number;
|
||||||
|
totalDeducted: number;
|
||||||
|
/** Lines carrying a nil-deduction remark — these need a UIN. */
|
||||||
|
nilDeductionLines: number;
|
||||||
|
/** Nil-deduction lines with no UIN. The portal will reject these. */
|
||||||
|
missingUinLines: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RETURN_DUE_DATE: Record<Quarter, (startYear: number) => string> = {
|
||||||
|
1: (y) => `${y}-07-31`,
|
||||||
|
2: (y) => `${y}-10-31`,
|
||||||
|
3: (y) => `${y + 1}-01-31`,
|
||||||
|
4: (y) => `${y + 1}-05-31`
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remark codes for a nil or lower deduction.
|
||||||
|
*
|
||||||
|
* The department's file specification assigns a letter per reason. The letters
|
||||||
|
* have been revised more than once, so they live here as named constants and
|
||||||
|
* must be checked against the current specification before a file is generated —
|
||||||
|
* a wrong code fails validation at upload, or worse, passes and misreports.
|
||||||
|
*/
|
||||||
|
export const NIL_DEDUCTION_REMARK = {
|
||||||
|
/** Payee filed a declaration for receipt without deduction. */
|
||||||
|
DECLARATION: 'B',
|
||||||
|
/** Aggregate stayed within the threshold, so nothing was deductible. */
|
||||||
|
BELOW_THRESHOLD: 'Y',
|
||||||
|
/** Higher rate applied because PAN was not furnished. */
|
||||||
|
NO_PAN: 'C'
|
||||||
|
};
|
||||||
|
|
||||||
|
function payeeTypeOf(pan: string): string {
|
||||||
|
const p = (pan || '').toUpperCase();
|
||||||
|
if (!isWellFormedPan(p)) {
|
||||||
|
return 'Unknown';
|
||||||
|
}
|
||||||
|
return p[3] === 'H' ? 'HUF' : p[3] === 'P' ? 'Individual' : 'Entity';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The deductee annexure for one quarter.
|
||||||
|
*
|
||||||
|
* Every payee who was paid interest in the quarter appears, whether or not tax
|
||||||
|
* came off — a payee paid gross against a declaration is precisely what the
|
||||||
|
* department cross-checks, so omitting them is the error to avoid.
|
||||||
|
*/
|
||||||
|
export function returnAnnexure(fy: string, quarter: Quarter): ReturnAnnexure {
|
||||||
|
const year = normaliseFy(fy);
|
||||||
|
const cfg = FY_TAX_CONFIG[year];
|
||||||
|
if (!cfg) {
|
||||||
|
throw new Error(`No tax configuration for FY ${year}`);
|
||||||
|
}
|
||||||
|
const startYear = Number(year.slice(0, 4));
|
||||||
|
const lines: ReturnAnnexureLine[] = [];
|
||||||
|
let serial = 0;
|
||||||
|
|
||||||
|
for (const row of tdsInterestRowsForFy(year)) {
|
||||||
|
const line = row.schedule.lines.find((l) => l.quarter === quarter);
|
||||||
|
const amountPaid = line?.interest ?? 0;
|
||||||
|
const taxDeducted = line?.tds ?? 0;
|
||||||
|
if (amountPaid === 0 && taxDeducted === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The remark explains why the deduction is not simply rate × amount.
|
||||||
|
let remark = '';
|
||||||
|
if (taxDeducted <= 0 && amountPaid > 0) {
|
||||||
|
if (row.hasDeclaration && line?.declarationActive) {
|
||||||
|
remark = NIL_DEDUCTION_REMARK.DECLARATION;
|
||||||
|
} else if (!row.crossed) {
|
||||||
|
remark = NIL_DEDUCTION_REMARK.BELOW_THRESHOLD;
|
||||||
|
}
|
||||||
|
} else if (taxDeducted > 0 && !row.panOnRecord) {
|
||||||
|
remark = NIL_DEDUCTION_REMARK.NO_PAN;
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push({
|
||||||
|
serial: ++serial,
|
||||||
|
payeeName: row.deductee.name,
|
||||||
|
pan: row.deductee.pan,
|
||||||
|
payeeType: payeeTypeOf(row.deductee.pan),
|
||||||
|
amountPaid,
|
||||||
|
taxDeducted,
|
||||||
|
rate: row.rateApplied,
|
||||||
|
remark,
|
||||||
|
uin: row.declarationUin ?? '',
|
||||||
|
branchCode: row.branchCode
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const nilDeduction = lines.filter((l) => l.remark === NIL_DEDUCTION_REMARK.DECLARATION);
|
||||||
|
|
||||||
|
return {
|
||||||
|
fy: year,
|
||||||
|
quarter,
|
||||||
|
form: cfg.tdsStatementForm,
|
||||||
|
rule: cfg.tdsStatementRule,
|
||||||
|
section: cfg.interestTdsSection,
|
||||||
|
periodEnd: quarterEnd(quarter, year),
|
||||||
|
dueDate: RETURN_DUE_DATE[quarter](startYear),
|
||||||
|
lines,
|
||||||
|
totalPaid: lines.reduce((s, l) => s + l.amountPaid, 0),
|
||||||
|
totalDeducted: lines.reduce((s, l) => s + l.taxDeducted, 0),
|
||||||
|
nilDeductionLines: nilDeduction.length,
|
||||||
|
missingUinLines: nilDeduction.filter((l) => !l.uin).length
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────────────── 3. Certificate for the deductee ─────────────────────
|
||||||
|
|
||||||
|
export interface TdsCertificateQuarterLine {
|
||||||
|
quarter: Quarter;
|
||||||
|
label: string;
|
||||||
|
amountPaid: number;
|
||||||
|
taxDeducted: number;
|
||||||
|
/** Tax the bank has actually remitted for this quarter. */
|
||||||
|
taxDeposited: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TdsCertificate {
|
||||||
|
fy: string;
|
||||||
|
form: string;
|
||||||
|
section: string;
|
||||||
|
/** Certificate number, unique per deductee per year. */
|
||||||
|
certificateNo: string;
|
||||||
|
deductor: DeductorParticulars;
|
||||||
|
payeeName: string;
|
||||||
|
pan: string;
|
||||||
|
customerId: string;
|
||||||
|
branchCode: string;
|
||||||
|
lines: TdsCertificateQuarterLine[];
|
||||||
|
totalPaid: number;
|
||||||
|
totalDeducted: number;
|
||||||
|
/** Interest paid gross against a declaration, disclosed on the certificate. */
|
||||||
|
paidWithoutDeduction: number;
|
||||||
|
declarationUin: string | null;
|
||||||
|
rate: number;
|
||||||
|
/** A certificate cannot be issued without a valid PAN to issue it against. */
|
||||||
|
issuable: boolean;
|
||||||
|
blockingReason: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The TDS certificate for one deductee.
|
||||||
|
*
|
||||||
|
* Quarterly deposits are taken as equal to the quarter's deduction: the branch
|
||||||
|
* remits what it deducts, and the remittance report is where any gap between the
|
||||||
|
* two is surfaced. Wiring the certificate to actual challans is the right next
|
||||||
|
* step once challan records come from the backend rather than the seed.
|
||||||
|
*/
|
||||||
|
export function tdsCertificate(row: TdsInterestRow, fy: string): TdsCertificate {
|
||||||
|
const year = normaliseFy(fy);
|
||||||
|
const cfg = FY_TAX_CONFIG[year];
|
||||||
|
if (!cfg) {
|
||||||
|
throw new Error(`No tax configuration for FY ${year}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines: TdsCertificateQuarterLine[] = row.schedule.lines.map((l) => ({
|
||||||
|
quarter: l.quarter,
|
||||||
|
label: l.label,
|
||||||
|
amountPaid: l.interest,
|
||||||
|
taxDeducted: l.tds,
|
||||||
|
taxDeposited: Math.max(0, l.tds)
|
||||||
|
}));
|
||||||
|
|
||||||
|
const issuable = isWellFormedPan(row.deductee.pan);
|
||||||
|
return {
|
||||||
|
fy: year,
|
||||||
|
form: cfg.certificateForm,
|
||||||
|
section: cfg.interestTdsSection,
|
||||||
|
certificateNo: `${DEDUCTOR.tan}/${uinSafe(year)}/${row.deductee.customerId}`,
|
||||||
|
deductor: DEDUCTOR,
|
||||||
|
payeeName: row.deductee.name,
|
||||||
|
pan: row.deductee.pan,
|
||||||
|
customerId: row.deductee.customerId,
|
||||||
|
branchCode: row.branchCode,
|
||||||
|
lines,
|
||||||
|
totalPaid: row.tdsApplicableInterest,
|
||||||
|
totalDeducted: row.schedule.totalTds,
|
||||||
|
paidWithoutDeduction: row.paidWithoutDeduction,
|
||||||
|
declarationUin: row.declarationUin,
|
||||||
|
rate: row.rateApplied,
|
||||||
|
issuable,
|
||||||
|
blockingReason: issuable
|
||||||
|
? null
|
||||||
|
: `No valid PAN on record. A certificate cannot be issued without one — the deductee would be unable to claim credit.`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function uinSafe(fy: string): string {
|
||||||
|
return normaliseFy(fy).replace('-', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Certificates for every deductee who had tax deducted in the year. */
|
||||||
|
export function tdsCertificates(fy: string): TdsCertificate[] {
|
||||||
|
return tdsInterestRowsForFy(normaliseFy(fy))
|
||||||
|
.filter((r) => r.schedule.totalTds > 0 || r.paidWithoutDeduction > 0)
|
||||||
|
.map((r) => tdsCertificate(r, fy));
|
||||||
|
}
|
||||||
@@ -21,7 +21,7 @@ import {
|
|||||||
MatRow
|
MatRow
|
||||||
} from '@angular/material/table';
|
} from '@angular/material/table';
|
||||||
import { MatButton, MatIconButton } from '@angular/material/button';
|
import { MatButton, MatIconButton } from '@angular/material/button';
|
||||||
import { MatIcon } from '@angular/material/icon';
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
import { MatMenu, MatMenuItem, MatMenuTrigger } from '@angular/material/menu';
|
import { MatMenu, MatMenuItem, MatMenuTrigger } from '@angular/material/menu';
|
||||||
import { MatTooltip } from '@angular/material/tooltip';
|
import { MatTooltip } from '@angular/material/tooltip';
|
||||||
import { MatProgressBar } from '@angular/material/progress-bar';
|
import { MatProgressBar } from '@angular/material/progress-bar';
|
||||||
@@ -52,7 +52,7 @@ import { RevokeDscDialogComponent } from '../revoke-dsc-dialog/revoke-dsc-dialog
|
|||||||
MatRow,
|
MatRow,
|
||||||
MatButton,
|
MatButton,
|
||||||
MatIconButton,
|
MatIconButton,
|
||||||
MatIcon,
|
MatIconModule,
|
||||||
MatMenu,
|
MatMenu,
|
||||||
MatMenuItem,
|
MatMenuItem,
|
||||||
MatMenuTrigger,
|
MatMenuTrigger,
|
||||||
|
|||||||
@@ -9,8 +9,8 @@
|
|||||||
/* tslint:disable */
|
/* tslint:disable */
|
||||||
export default {
|
export default {
|
||||||
'mifos_x': {
|
'mifos_x': {
|
||||||
'version': '260806',
|
'version': '260805',
|
||||||
'hash': '858c277a'
|
'hash': '8c7d8287'
|
||||||
},
|
},
|
||||||
'allow_switching_backend_instance': true
|
'allow_switching_backend_instance': true
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user