Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4281a596af | ||
|
|
9f9c9bfab6 | ||
|
|
513e863bba | ||
|
|
13333088d7 | ||
|
|
89bb76455e | ||
|
|
7e69468995 |
@@ -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"
|
||||||
>
|
>
|
||||||
<span class="ws-amt-prefix">₹</span>
|
@if (row.acTyp !== 'SHR') {
|
||||||
|
<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,27 +1433,51 @@
|
|||||||
[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"
|
||||||
>
|
>
|
||||||
<span class="ws-amt-prefix">₹</span>
|
@if (row.acTyp !== 'SHR') {
|
||||||
|
<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>
|
||||||
<div class="ws-f ws-f--cheqno">
|
@if (row.acTyp === 'SHR' && row.typ === 'Enter Bank Transfer') {
|
||||||
<label class="ws-f__lbl">CHEQUE NO.</label>
|
<div class="ws-f ws-f--cheqno">
|
||||||
<input class="ws-f__ctl ws-f__ctl--mono" [(ngModel)]="row.chequeNo" placeholder="—" />
|
<label class="ws-f__lbl">RESOLUTION NO.</label>
|
||||||
</div>
|
<input
|
||||||
<div class="ws-f ws-f--cheqdt">
|
class="ws-f__ctl"
|
||||||
<label class="ws-f__lbl">CHEQ. DATE</label>
|
[(ngModel)]="row.resolutionNumber"
|
||||||
<input class="ws-f__ctl" [(ngModel)]="row.cheqDate" placeholder="dd-mm-yyyy" />
|
placeholder="e.g. BR-2026-014"
|
||||||
</div>
|
/>
|
||||||
|
</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">
|
||||||
|
<label class="ws-f__lbl">CHEQUE NO.</label>
|
||||||
|
<input class="ws-f__ctl ws-f__ctl--mono" [(ngModel)]="row.chequeNo" placeholder="—" />
|
||||||
|
</div>
|
||||||
|
<div class="ws-f ws-f--cheqdt">
|
||||||
|
<label class="ws-f__lbl">CHEQ. DATE</label>
|
||||||
|
<input class="ws-f__ctl" [(ngModel)]="row.cheqDate" placeholder="dd-mm-yyyy" />
|
||||||
|
</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,12 +1866,14 @@ 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) {
|
||||||
const clientIdFromSearch = acTyp !== 'GL' && match.clientId != null ? String(match.clientId) : null;
|
const clientIdFromSearch = acTyp !== 'GL' && match.clientId != null ? String(match.clientId) : null;
|
||||||
@@ -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,18 +2179,22 @@ 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
|
||||||
.get<any>(`/standinginstructions?fromAccountType=${cfg.siAccountType}&fromAccountId=${accountId}&limit=10`)
|
? of({ pageItems: [] })
|
||||||
.pipe(catchError(() => of({ pageItems: [] }))),
|
|
||||||
todayTxns: cfg.txnSearchSupported
|
|
||||||
? this.http
|
|
||||||
.get<any>(
|
|
||||||
`${cfg.txnBase}/${accountId}/transactions/search?fromDate=${todayStr}&toDate=${todayStr}&limit=20`
|
|
||||||
)
|
|
||||||
.pipe(catchError(() => of({ pageItems: [] })))
|
|
||||||
: this.http
|
: this.http
|
||||||
.get<any>(`${cfg.txnBase}/${accountId}/transactions?limit=50`)
|
.get<any>(`/standinginstructions?fromAccountType=${cfg.siAccountType}&fromAccountId=${accountId}&limit=10`)
|
||||||
.pipe(catchError(() => of({ pageItems: [] })))
|
.pipe(catchError(() => of({ pageItems: [] }))),
|
||||||
|
todayTxns: cfg.txnFetchUnsupported
|
||||||
|
? of({ pageItems: [] })
|
||||||
|
: cfg.txnSearchSupported
|
||||||
|
? this.http
|
||||||
|
.get<any>(
|
||||||
|
`${cfg.txnBase}/${accountId}/transactions/search?fromDate=${todayStr}&toDate=${todayStr}&limit=20`
|
||||||
|
)
|
||||||
|
.pipe(catchError(() => of({ pageItems: [] })))
|
||||||
|
: this.http
|
||||||
|
.get<any>(`${cfg.txnBase}/${accountId}/transactions?limit=50`)
|
||||||
|
.pipe(catchError(() => of({ pageItems: [] })))
|
||||||
});
|
});
|
||||||
|
|
||||||
if (clientIdHint) {
|
if (clientIdHint) {
|
||||||
@@ -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) {
|
||||||
|
|||||||
+34
@@ -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,25 @@
|
|||||||
<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>
|
||||||
|
|||||||
+143
-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,32 @@ 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 +253,102 @@ 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 a circular dependency between the two dialogs.
|
||||||
|
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 +603,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;
|
||||||
|
|||||||
+31
-3
@@ -966,11 +966,39 @@ 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();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Guards the Monthly/Annual and Income/Turnover auto-sync subscriptions below — set this to
|
/**
|
||||||
* true while programmatically writing both fields of a pair to avoid a feedback loop. */
|
* Auto-calculates turnover based on income (doubling monthly income for monthly turnover,
|
||||||
private suppressMonthlyAnnualLink = false;
|
* 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 });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Keeps a Monthly/Annual pair of amount fields in sync at a 12x ratio — typing into either
|
* Keeps a Monthly/Annual pair of amount fields in sync at a 12x ratio — typing into either
|
||||||
|
|||||||
@@ -723,6 +723,53 @@
|
|||||||
<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 ═══ -->
|
||||||
|
|||||||
@@ -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,23 @@ 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 +1898,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']"
|
||||||
|
|||||||
@@ -42,6 +42,32 @@
|
|||||||
|
|
||||||
<!-- 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,6 +766,32 @@
|
|||||||
</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>
|
||||||
|
|||||||
@@ -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,38 @@ 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,31 +316,33 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
|||||||
this.verificationError.pan = null;
|
this.verificationError.pan = null;
|
||||||
this.verificationDetails.pan = null;
|
this.verificationDetails.pan = null;
|
||||||
|
|
||||||
this.clientsService
|
this.saveKycBeforeVerify(() => {
|
||||||
.verifyPAN(this.clientId, this.kycId)
|
this.clientsService
|
||||||
.pipe(takeUntil(this.destroy$))
|
.verifyPAN(this.clientId, this.kycId)
|
||||||
.subscribe({
|
.pipe(takeUntil(this.destroy$))
|
||||||
next: (response: any) => {
|
.subscribe({
|
||||||
this.verifying.pan = false;
|
next: (response: any) => {
|
||||||
if (response?.changes?.panVerified) {
|
this.verifying.pan = false;
|
||||||
this.verificationStatus.pan = 'VERIFIED';
|
if (response?.changes?.panVerified) {
|
||||||
this.verificationDetails.pan = response.changes;
|
this.verificationStatus.pan = 'VERIFIED';
|
||||||
this.loadKycData();
|
this.verificationDetails.pan = response.changes;
|
||||||
} else {
|
this.loadKycData();
|
||||||
|
} else {
|
||||||
|
this.verificationStatus.pan = 'FAILED';
|
||||||
|
this.verificationError.pan = 'PAN verification failed.';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: (err: any) => {
|
||||||
|
this.verifying.pan = false;
|
||||||
this.verificationStatus.pan = 'FAILED';
|
this.verificationStatus.pan = 'FAILED';
|
||||||
this.verificationError.pan = 'PAN verification failed.';
|
this.verificationError.pan =
|
||||||
|
err?.error?.developerMessage ||
|
||||||
|
err?.error?.defaultUserMessage ||
|
||||||
|
err?.message ||
|
||||||
|
'PAN verification failed. Please try again.';
|
||||||
}
|
}
|
||||||
},
|
});
|
||||||
error: (err: any) => {
|
}, 'pan');
|
||||||
this.verifying.pan = false;
|
|
||||||
this.verificationStatus.pan = 'FAILED';
|
|
||||||
this.verificationError.pan =
|
|
||||||
err?.error?.developerMessage ||
|
|
||||||
err?.error?.defaultUserMessage ||
|
|
||||||
err?.message ||
|
|
||||||
'PAN verification failed. Please try again.';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Show Aadhaar input in summary view for re-verification */
|
/** Show Aadhaar input in summary view for re-verification */
|
||||||
@@ -305,31 +369,33 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
|||||||
this.aadhaarOtpSent = false;
|
this.aadhaarOtpSent = false;
|
||||||
this.aadhaarOtp = '';
|
this.aadhaarOtp = '';
|
||||||
|
|
||||||
this.clientsService
|
this.saveKycBeforeVerify(() => {
|
||||||
.sendAadhaarOtp(this.clientId, this.kycId, aadhaar)
|
this.clientsService
|
||||||
.pipe(takeUntil(this.destroy$))
|
.sendAadhaarOtp(this.clientId, this.kycId, aadhaar)
|
||||||
.subscribe({
|
.pipe(takeUntil(this.destroy$))
|
||||||
next: (response: any) => {
|
.subscribe({
|
||||||
this.verifying.aadhaar = false;
|
next: (response: any) => {
|
||||||
const refNumber =
|
this.verifying.aadhaar = false;
|
||||||
response?.changes?.aadhaarRefNumber ||
|
const refNumber =
|
||||||
response?.aadhaarRefNumber ||
|
response?.changes?.aadhaarRefNumber ||
|
||||||
response?.changes?.referenceNumber ||
|
response?.aadhaarRefNumber ||
|
||||||
response?.resourceId;
|
response?.changes?.referenceNumber ||
|
||||||
this.aadhaarOtpSent = true;
|
response?.resourceId;
|
||||||
this.aadhaarOtpReferenceId = refNumber || null;
|
this.aadhaarOtpSent = true;
|
||||||
this.verificationStatus.aadhaar = 'PENDING';
|
this.aadhaarOtpReferenceId = refNumber || null;
|
||||||
},
|
this.verificationStatus.aadhaar = 'PENDING';
|
||||||
error: (err: any) => {
|
},
|
||||||
this.verifying.aadhaar = false;
|
error: (err: any) => {
|
||||||
this.verificationStatus.aadhaar = 'FAILED';
|
this.verifying.aadhaar = false;
|
||||||
this.verificationError.aadhaar =
|
this.verificationStatus.aadhaar = 'FAILED';
|
||||||
err?.error?.developerMessage ||
|
this.verificationError.aadhaar =
|
||||||
err?.error?.defaultUserMessage ||
|
err?.error?.developerMessage ||
|
||||||
err?.message ||
|
err?.error?.defaultUserMessage ||
|
||||||
'Failed to send OTP. Please try again.';
|
err?.message ||
|
||||||
}
|
'Failed to send OTP. Please try again.';
|
||||||
});
|
}
|
||||||
|
});
|
||||||
|
}, 'aadhaar');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Step 2: Verify OTP entered by user */
|
/** Step 2: Verify OTP entered by user */
|
||||||
@@ -380,31 +446,33 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
|||||||
this.verificationError.gstin = null;
|
this.verificationError.gstin = null;
|
||||||
this.verificationDetails.gstin = null;
|
this.verificationDetails.gstin = null;
|
||||||
|
|
||||||
this.clientsService
|
this.saveKycBeforeVerify(() => {
|
||||||
.verifyGSTIN(this.clientId, this.kycId)
|
this.clientsService
|
||||||
.pipe(takeUntil(this.destroy$))
|
.verifyGSTIN(this.clientId, this.kycId)
|
||||||
.subscribe({
|
.pipe(takeUntil(this.destroy$))
|
||||||
next: (response: any) => {
|
.subscribe({
|
||||||
this.verifying.gstin = false;
|
next: (response: any) => {
|
||||||
if (response?.changes?.gstinVerified) {
|
this.verifying.gstin = false;
|
||||||
this.verificationStatus.gstin = 'VERIFIED';
|
if (response?.changes?.gstinVerified) {
|
||||||
this.verificationDetails.gstin = response.changes;
|
this.verificationStatus.gstin = 'VERIFIED';
|
||||||
this.loadKycData();
|
this.verificationDetails.gstin = response.changes;
|
||||||
} else {
|
this.loadKycData();
|
||||||
|
} else {
|
||||||
|
this.verificationStatus.gstin = 'FAILED';
|
||||||
|
this.verificationError.gstin = 'GSTIN verification failed.';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: (err: any) => {
|
||||||
|
this.verifying.gstin = false;
|
||||||
this.verificationStatus.gstin = 'FAILED';
|
this.verificationStatus.gstin = 'FAILED';
|
||||||
this.verificationError.gstin = 'GSTIN verification failed.';
|
this.verificationError.gstin =
|
||||||
|
err?.error?.developerMessage ||
|
||||||
|
err?.error?.defaultUserMessage ||
|
||||||
|
err?.message ||
|
||||||
|
'GSTIN verification failed. Please try again.';
|
||||||
}
|
}
|
||||||
},
|
});
|
||||||
error: (err: any) => {
|
}, 'gstin');
|
||||||
this.verifying.gstin = false;
|
|
||||||
this.verificationStatus.gstin = 'FAILED';
|
|
||||||
this.verificationError.gstin =
|
|
||||||
err?.error?.developerMessage ||
|
|
||||||
err?.error?.defaultUserMessage ||
|
|
||||||
err?.message ||
|
|
||||||
'GSTIN verification failed. Please try again.';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
||||||
this.clientsService.saveClientKyc(clientId, this.clientKycStep.kycData).subscribe({
|
if (!this.clientKycStep.kycId) {
|
||||||
next: () => navigateToClient(),
|
this.clientsService.saveClientKyc(clientId, this.clientKycStep.kycData).subscribe({
|
||||||
error: () => navigateToClient()
|
next: () => 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,14 +84,70 @@
|
|||||||
<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">
|
||||||
<mifosx-client-contact-otp-block
|
@if (!oldMobileNotAccessible) {
|
||||||
[clientId]="clientId"
|
<mifosx-client-contact-otp-block
|
||||||
channel="MOBILE"
|
[clientId]="clientId"
|
||||||
[destination]="contactChanges.oldMobile"
|
channel="MOBILE"
|
||||||
label="Old Mobile Number"
|
[destination]="contactChanges.oldMobile"
|
||||||
(stateChange)="onOldMobileOtpState($event)"
|
label="Old Mobile Number"
|
||||||
></mifosx-client-contact-otp-block>
|
(stateChange)="onOldMobileOtpState($event)"
|
||||||
|
></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,11 +224,15 @@ 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 the FIR exception has been uploaded in place of old-mobile OTP verification). */
|
||||||
get contactVerificationComplete(): boolean {
|
get contactVerificationComplete(): boolean {
|
||||||
const changes = this.contactChanges;
|
const changes = this.contactChanges;
|
||||||
if (changes.mobileChanged && !(this.oldMobileVerified && this.newMobileVerified)) {
|
if (changes.mobileChanged) {
|
||||||
return false;
|
const isOldSatisfied = this.oldMobileNotAccessible ? this.firDocumentId != null : this.oldMobileVerified;
|
||||||
|
if (!isOldSatisfied || !this.newMobileVerified) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (changes.emailChanged && !(this.oldEmailVerified && this.newEmailVerified)) {
|
if (changes.emailChanged && !(this.oldEmailVerified && this.newEmailVerified)) {
|
||||||
return false;
|
return false;
|
||||||
@@ -227,6 +240,43 @@ export class EditClientComponent {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Toggle the "old mobile not accessible" exception — clears any in-progress old-mobile OTP. */
|
||||||
|
onOldMobileNotAccessibleToggle(checked?: boolean): void {
|
||||||
|
if (checked !== undefined) {
|
||||||
|
this.oldMobileNotAccessible = checked;
|
||||||
|
}
|
||||||
|
if (this.oldMobileNotAccessible) {
|
||||||
|
this.oldMobileOtpId = null;
|
||||||
|
this.oldMobileVerified = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Upload the FIR / police-complaint copy that substitutes for old-mobile OTP verification. */
|
||||||
|
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 +304,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 +493,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) {
|
||||||
clientData.mobileOldOtpId = this.oldMobileOtpId;
|
if (this.oldMobileNotAccessible && this.firDocumentId) {
|
||||||
|
(clientData as any).oldMobileVerificationExceptionDocumentId = this.firDocumentId;
|
||||||
|
} else {
|
||||||
|
clientData.mobileOldOtpId = this.oldMobileOtpId;
|
||||||
|
}
|
||||||
clientData.mobileNewOtpId = this.newMobileOtpId;
|
clientData.mobileNewOtpId = this.newMobileOtpId;
|
||||||
}
|
}
|
||||||
if (changedFields.emailChanged) {
|
if (changedFields.emailChanged) {
|
||||||
@@ -630,16 +688,36 @@ export class EditClientComponent {
|
|||||||
|
|
||||||
private detectSensitiveFieldChanges(originalData: any) {
|
private detectSensitiveFieldChanges(originalData: any) {
|
||||||
const form = this.clientGeneralForm;
|
const form = this.clientGeneralForm;
|
||||||
|
if (!form || !originalData) {
|
||||||
|
return {
|
||||||
|
nameChanged: false,
|
||||||
|
mobileChanged: false,
|
||||||
|
emailChanged: false,
|
||||||
|
dobChanged: false,
|
||||||
|
oldName: '',
|
||||||
|
newName: '',
|
||||||
|
oldMobile: '',
|
||||||
|
newMobile: '',
|
||||||
|
oldEmail: '',
|
||||||
|
newEmail: '',
|
||||||
|
oldDob: '',
|
||||||
|
newDob: ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const mobileControl = form.get('mobileNo');
|
||||||
|
const emailControl = form.get('emailAddress');
|
||||||
|
|
||||||
// Trimmed so browser autofill adding invisible leading/trailing whitespace never reads as
|
// Trimmed so browser autofill adding invisible leading/trailing whitespace never reads as
|
||||||
// a "change" — the masked preview shown in the Verify Contact Change panel only reveals the
|
// a "change" — the masked preview shown in the Verify Contact Change panel only reveals the
|
||||||
// first couple of characters, so a whitespace-only difference is otherwise invisible to the
|
// first couple of characters, so a whitespace-only difference is otherwise invisible to the
|
||||||
// user and just looks like the panel appeared for no reason. These trimmed-but-original-case
|
// user and just looks like the panel appeared for no reason. These trimmed-but-original-case
|
||||||
// values are what get returned/sent as the actual OTP destination and saved value — only the
|
// values are what get returned/sent as the actual OTP destination and saved value — only the
|
||||||
// *comparison* (below) is further case-normalized for email, so we never silently rewrite the
|
// *comparison* (below) is further normalized for mobile/email, so we never silently rewrite the
|
||||||
// customer's email casing into the saved record or the OTP destination.
|
// customer's saved value or the OTP destination.
|
||||||
const newMobile = (form?.get('mobileNo')?.value || '').trim();
|
const newMobile = (mobileControl?.value || '').toString().trim();
|
||||||
const newEmail = (form?.get('emailAddress')?.value || '').trim();
|
const newEmail = (emailControl?.value || '').toString().trim();
|
||||||
const newDobRaw = form?.get('dateOfBirth')?.value;
|
const newDobRaw = form.get('dateOfBirth')?.value;
|
||||||
// Local calendar date, NOT `.toISOString()` — that converts to UTC first, which silently
|
// Local calendar date, NOT `.toISOString()` — that converts to UTC first, which silently
|
||||||
// shifts the date by a day in any positive-UTC-offset timezone (e.g. India, UTC+5:30:
|
// shifts the date by a day in any positive-UTC-offset timezone (e.g. India, UTC+5:30:
|
||||||
// midnight local on the 15th becomes 18:30 UTC on the 14th). That made this comparison
|
// midnight local on the 15th becomes 18:30 UTC on the 14th). That made this comparison
|
||||||
@@ -647,8 +725,8 @@ export class EditClientComponent {
|
|||||||
// touched the date field, purely because the Date object round-tripped through the picker.
|
// touched the date field, purely because the Date object round-tripped through the picker.
|
||||||
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')}`
|
||||||
@@ -681,12 +759,25 @@ 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 — otherwise a re-render/reformat of the control by the framework
|
||||||
|
// (never actually touched by the user) would falsely surface the dual-OTP verification panel.
|
||||||
|
const mobileChanged =
|
||||||
|
!!oldMobileNorm && !!newMobileNorm && !!mobileControl?.dirty && newMobileNorm !== oldMobileNorm;
|
||||||
|
// Case-insensitive: emails are not case-sensitive in practice, and comparing raw case
|
||||||
|
// would flag a change (surfacing the dual-OTP panel) even when nothing meaningful changed.
|
||||||
|
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,
|
||||||
|
|||||||
@@ -384,14 +384,6 @@ export class AuthenticationService {
|
|||||||
return JSON.parse(this.storage.getItem(this.credentialsStorageKey));
|
return JSON.parse(this.storage.getItem(this.credentialsStorageKey));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns true when the logged-in user is at Head Office rather than a branch login. */
|
|
||||||
isHeadOfficeUser(): boolean {
|
|
||||||
const creds = this.getCredentials();
|
|
||||||
if (!creds) return false;
|
|
||||||
if (creds.officeId === 1) return true;
|
|
||||||
return (creds.officeName || '').toLowerCase().includes('head office');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Returns true when the logged-in user has the "Clerk" role. */
|
/** Returns true when the logged-in user has the "Clerk" role. */
|
||||||
isClerkUser(): boolean {
|
isClerkUser(): boolean {
|
||||||
const creds = this.getCredentials();
|
const creds = this.getCredentials();
|
||||||
|
|||||||
@@ -618,6 +618,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,
|
||||||
@@ -1629,6 +1635,14 @@
|
|||||||
<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
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -295,6 +295,53 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- LIVE: monthly CTR reports (mechanical, threshold-based) -->
|
||||||
|
<div class="fiu-form-meta">
|
||||||
|
<div class="fiu-form-title">
|
||||||
|
<h3>Monthly CTR Reports</h3>
|
||||||
|
<p>
|
||||||
|
Threshold: <strong>₹{{ bank.cashThreshold | number }}</strong> per single txn / connected aggregate
|
||||||
|
per month
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="fiu-action-group">
|
||||||
|
<input class="fiu-inp" type="month" [(ngModel)]="ctrGenerateMonth" />
|
||||||
|
<button class="fiu-btn fiu-btn-g" *mifosxHasPermission="'REVIEW_AML_ALERT'" (click)="generateCtrReport()">
|
||||||
|
Generate
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="fiu-tbl-wrap">
|
||||||
|
<table class="fiu-tbl">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Month</th>
|
||||||
|
<th>Generated On</th>
|
||||||
|
<th class="ta-c">Records</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Submitted On</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (r of amlCtrReports; track r.id) {
|
||||||
|
<tr>
|
||||||
|
<td>{{ r.reportMonth }}</td>
|
||||||
|
<td>{{ r.generatedOn | date: 'medium' }}</td>
|
||||||
|
<td class="ta-c">{{ r.recordCount }}</td>
|
||||||
|
<td>
|
||||||
|
<span [class]="amlStatusClass(r.status)">{{ r.status }}</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ r.submittedOn ? (r.submittedOn | date: 'medium') : '—' }}</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="fiu-empty">No CTR reports generated yet.</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,6 +354,13 @@
|
|||||||
<h3>Live AML Alerts (Awaiting Triage)</h3>
|
<h3>Live AML Alerts (Awaiting Triage)</h3>
|
||||||
<p>Flagged by the monitoring engine for suspicious behavioral patterns — triage and whitelist or escalate</p>
|
<p>Flagged by the monitoring engine for suspicious behavioral patterns — triage and whitelist or escalate</p>
|
||||||
</div>
|
</div>
|
||||||
|
<select class="fiu-inp" [(ngModel)]="amlAlertStatusFilter" (ngModelChange)="loadAmlAlerts()">
|
||||||
|
<option value="">All statuses</option>
|
||||||
|
<option value="RAISED">Raised</option>
|
||||||
|
<option value="UNDER_REVIEW">Under Review</option>
|
||||||
|
<option value="WHITELISTED">Whitelisted</option>
|
||||||
|
<option value="ESCALATED">Escalated</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="fiu-tbl-wrap">
|
<div class="fiu-tbl-wrap">
|
||||||
<table class="fiu-tbl">
|
<table class="fiu-tbl">
|
||||||
|
|||||||
@@ -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,30 @@
|
|||||||
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,419 @@ 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;
|
||||||
|
|||||||
@@ -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,158 +11,347 @@
|
|||||||
<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 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>
|
</div>
|
||||||
|
|
||||||
<!-- KPI strip -->
|
@if (viewMode === 'real') {
|
||||||
<div class="cert-kpis">
|
<mifosx-real-form15gh-view></mifosx-real-form15gh-view>
|
||||||
<div class="cert-kpi cert-kpi--blue">
|
}
|
||||||
<fa-icon icon="user" class="cert-kpi-ic"></fa-icon>
|
|
||||||
<div>
|
|
||||||
<div class="cert-kpi-n">{{ count15G() | number }}</div>
|
|
||||||
<div class="cert-kpi-l">Form 121 (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">{{ count15H() | number }}</div>
|
|
||||||
<div class="cert-kpi-l">Form 121 (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>
|
|
||||||
|
|
||||||
<!-- Filters -->
|
@if (viewMode === 'demo') {
|
||||||
<div class="cert-filters">
|
<!-- KPI strip -->
|
||||||
<div class="cert-filter">
|
<div class="cert-kpis">
|
||||||
<label>Customer / Customer ID / PAN</label>
|
<div class="cert-kpi cert-kpi--blue">
|
||||||
<div class="cert-input-ic">
|
<fa-icon icon="user" class="cert-kpi-ic"></fa-icon>
|
||||||
<fa-icon icon="search"></fa-icon>
|
<div>
|
||||||
<input type="text" [(ngModel)]="search" placeholder="e.g. CUST556677 or customer name" />
|
<div class="cert-kpi-n">{{ countGeneralBand() | number }}</div>
|
||||||
|
<div class="cert-kpi-l">General band (under 60)</div>
|
||||||
|
</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>
|
</div>
|
||||||
|
|
||||||
<div class="cert-filter">
|
<!-- Filters -->
|
||||||
<label>Form Type</label>
|
<div class="cert-filters">
|
||||||
<select [(ngModel)]="kindFilter">
|
<div class="cert-filter">
|
||||||
<option value="">All Form 121</option>
|
<label>Customer / Customer ID / PAN</label>
|
||||||
<option value="15G">Form 121 (Below 60 years)</option>
|
<div class="cert-input-ic">
|
||||||
<option value="15H">Form 121 (Senior Citizen 60+)</option>
|
<fa-icon icon="search"></fa-icon>
|
||||||
</select>
|
<input type="text" [(ngModel)]="search" placeholder="e.g. CUST556677 or customer name" />
|
||||||
|
</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>
|
</div>
|
||||||
|
|
||||||
<div class="cert-filter">
|
<!-- Table -->
|
||||||
<label>Status</label>
|
<div class="cert-card">
|
||||||
<select [(ngModel)]="statusFilter">
|
<div class="cert-table-wrap">
|
||||||
<option value="">All statuses</option>
|
<table class="cert-table">
|
||||||
<option value="FILED">Filed</option>
|
<thead>
|
||||||
<option value="PENDING">Pending</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (search || kindFilter || statusFilter) {
|
|
||||||
<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 Type</th>
|
|
||||||
<th>Age</th>
|
|
||||||
<th>Branch</th>
|
|
||||||
<th class="num">Deposit Principal</th>
|
|
||||||
<th class="num">Est. Interest ({{ records[0]?.fy }})</th>
|
|
||||||
<th>Status</th>
|
|
||||||
<th class="col-actions">Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@for (r of filteredRecords; track r.customerId) {
|
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<th>Customer · PAN</th>
|
||||||
<div class="cert-cust-name">{{ r.customerName }}</div>
|
<th>Form · Band</th>
|
||||||
<div class="mono small muted">{{ r.customerId }} · PAN {{ r.customerPan }}</div>
|
<th>Age</th>
|
||||||
<div class="small muted">DOB {{ r.dob | date: 'd MMM y' }}</div>
|
<th>Branch</th>
|
||||||
</td>
|
<th class="num">Est. Interest</th>
|
||||||
<td>
|
<th class="num">Threshold</th>
|
||||||
<span
|
<th class="num">TDS Payable</th>
|
||||||
class="cert-pill"
|
<th class="num">TDS Saved</th>
|
||||||
[class.cert-pill--blue]="formKind(r) === '15G'"
|
<th>Settlement</th>
|
||||||
[class.cert-pill--amber]="formKind(r) === '15H'"
|
<th>Status</th>
|
||||||
>
|
<th class="col-actions">Actions</th>
|
||||||
Form 121 ({{ formKind(r) }})
|
</tr>
|
||||||
</span>
|
</thead>
|
||||||
</td>
|
<tbody>
|
||||||
<td class="small">{{ age(r.dob) }} yrs</td>
|
@for (row of filteredRows; track row.record.customerId) {
|
||||||
<td class="small">{{ r.branch }}</td>
|
<tr [class.cert-row--warn]="!row.eligibility.eligible">
|
||||||
<td class="num">₹{{ r.depositPrincipal | number: '1.0-0' }}</td>
|
<td>
|
||||||
<td class="num">
|
<div class="cert-cust-name">{{ row.record.customerName }}</div>
|
||||||
<strong class="cert-interest">₹{{ r.estimatedInterest | number: '1.0-0' }}</strong>
|
<div class="mono small muted">{{ row.record.customerId }} · PAN {{ row.record.customerPan }}</div>
|
||||||
</td>
|
<div class="small muted">DOB {{ row.record.dob | date: 'd MMM y' }}</div>
|
||||||
<td>
|
</td>
|
||||||
@if (r.status === 'FILED') {
|
<td>
|
||||||
<span class="cert-pill cert-pill--green"> <fa-icon icon="check-circle"></fa-icon> Filed </span>
|
<span
|
||||||
@if (r.filedOn) {
|
class="cert-pill"
|
||||||
<div class="mono small muted">{{ r.filedOn | date: 'd MMM y' }}</div>
|
[class.cert-pill--blue]="row.band === 'GENERAL'"
|
||||||
|
[class.cert-pill--amber]="row.band === 'SENIOR'"
|
||||||
|
>
|
||||||
|
{{ row.form === '121' ? 'Form 121' : 'Form ' + row.form }}
|
||||||
|
</span>
|
||||||
|
<div class="small muted">{{ row.band === 'SENIOR' ? 'Senior citizen' : 'General' }}</div>
|
||||||
|
</td>
|
||||||
|
<td class="small">{{ row.age }} yrs</td>
|
||||||
|
<td class="small">{{ row.record.branch }}</td>
|
||||||
|
<td class="num">
|
||||||
|
<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>
|
||||||
}
|
}
|
||||||
} @else {
|
</td>
|
||||||
<span class="cert-pill cert-pill--amber"> <fa-icon icon="hourglass-half"></fa-icon> Pending </span>
|
<td class="num">
|
||||||
}
|
@if (row.tdsSaved > 0) {
|
||||||
</td>
|
<strong class="cert-interest">₹{{ row.tdsSaved | number: '1.0-0' }}</strong>
|
||||||
<td class="col-actions">
|
} @else {
|
||||||
<div class="cert-row-actions">
|
<span class="small muted">—</span>
|
||||||
<button
|
}
|
||||||
type="button"
|
</td>
|
||||||
class="cert-icon-btn primary"
|
<td class="small">
|
||||||
(click)="viewCertificate(r)"
|
<span class="cert-pill" [class.cert-pill--blue]="row.mode === 'QUARTERLY'">
|
||||||
matTooltip="View / Print declaration"
|
{{ row.mode === 'QUARTERLY' ? 'Quarterly' : 'Year end' }}
|
||||||
>
|
</span>
|
||||||
<fa-icon icon="eye"></fa-icon>
|
<div class="small muted">product-driven</div>
|
||||||
</button>
|
</td>
|
||||||
<button
|
<td>
|
||||||
type="button"
|
@if (row.record.status === 'FILED') {
|
||||||
class="cert-icon-btn"
|
<span class="cert-pill cert-pill--green"> <fa-icon icon="check-circle"></fa-icon> Filed </span>
|
||||||
(click)="downloadCertificate(r)"
|
@if (row.record.filedOn) {
|
||||||
matTooltip="Download PDF"
|
<div class="mono small muted">{{ row.record.filedOn | date: 'd MMM y' }}</div>
|
||||||
>
|
}
|
||||||
<fa-icon icon="download"></fa-icon>
|
} @else {
|
||||||
</button>
|
<span class="cert-pill cert-pill--amber"> <fa-icon icon="hourglass-half"></fa-icon> Pending </span>
|
||||||
</div>
|
}
|
||||||
</td>
|
@if (!row.eligibility.eligible) {
|
||||||
</tr>
|
<div class="cert-pill cert-pill--amber">
|
||||||
}
|
<fa-icon icon="exclamation-triangle"></fa-icon> Not eligible
|
||||||
@if (filteredRecords.length === 0) {
|
</div>
|
||||||
<tr>
|
}
|
||||||
<td colspan="8" class="cert-empty">
|
</td>
|
||||||
<fa-icon icon="info-circle"></fa-icon>
|
<td class="col-actions">
|
||||||
No declarations match the current filters.
|
<div class="cert-row-actions">
|
||||||
</td>
|
<button
|
||||||
</tr>
|
type="button"
|
||||||
}
|
class="cert-icon-btn"
|
||||||
</tbody>
|
(click)="toggleExpanded(row)"
|
||||||
</table>
|
[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.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>
|
||||||
|
}
|
||||||
|
</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>
|
||||||
|
<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>
|
||||||
|
No declarations match the current filters.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</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'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
+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' },
|
||||||
|
|||||||
@@ -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: #fff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
box-shadow: 0 1px 3px rgb(0 0 0 / 4%);
|
||||||
|
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,22 +51,19 @@
|
|||||||
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.6;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:nth-child(3) {
|
&:nth-child(3) {
|
||||||
@@ -75,53 +78,156 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.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: #fff !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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[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: #fff;
|
||||||
|
|
||||||
.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 {
|
||||||
cursor: pointer;
|
.mat-mdc-header-row {
|
||||||
transition: background-color 0.2s ease;
|
background: #f8fafc;
|
||||||
|
min-height: 38px;
|
||||||
|
height: 38px;
|
||||||
|
}
|
||||||
|
|
||||||
&:hover {
|
.mat-mdc-header-cell {
|
||||||
background-color: var(--bg-hover);
|
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;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:nth-child(even) {
|
||||||
|
background: #fafbfc;
|
||||||
|
}
|
||||||
|
|
||||||
|
&: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: #fff;
|
||||||
|
}
|
||||||
|
|||||||
@@ -152,6 +152,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,
|
||||||
|
|||||||
+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();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user