Compare commits
34
Commits
733d9d22b2
...
dev-backup
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b609f19e9f | ||
|
|
7f836f6f71 | ||
|
|
1a33f9d6ce | ||
|
|
983c613b8e | ||
|
|
ddb215decf | ||
|
|
2cc8d979ba | ||
|
|
569f17ef46 | ||
|
|
cc5cd4c6ab | ||
|
|
8c7d8287f9 | ||
|
|
a982083b14 | ||
|
|
a560cd6533 | ||
|
|
0bf337b962 | ||
|
|
1daa12a117 | ||
|
|
f687e1808d | ||
|
|
186a9057a1 | ||
|
|
387bd060e1 | ||
|
|
29f1cdc646 | ||
|
|
1efb520009 | ||
|
|
4ea833ff49 | ||
|
|
5dc284bd1c | ||
|
|
56f13ade67 | ||
|
|
dbd160e7e7 | ||
|
|
468eaa96ab | ||
|
|
f682cb17f6 | ||
|
|
79fedb66e5 | ||
|
|
c1fc95d9b7 | ||
|
|
b422ae6600 | ||
|
|
5fd7e1890e | ||
|
|
0389b1f7c3 | ||
|
|
2e4187c3d2 | ||
|
|
cb4260b7a3 | ||
|
|
349dc91951 | ||
|
|
a9c60083bf | ||
|
|
0c99dc3686 |
@@ -1 +0,0 @@
|
|||||||
{"sessionId":"67d120b4-ff78-435d-be80-f79482a386b4","pid":19832,"acquiredAt":1778067581856}
|
|
||||||
+2
-2
@@ -69,8 +69,8 @@
|
|||||||
"budgets": [
|
"budgets": [
|
||||||
{
|
{
|
||||||
"type": "initial",
|
"type": "initial",
|
||||||
"maximumWarning": "13mb",
|
"maximumWarning": "15mb",
|
||||||
"maximumError": "14mb"
|
"maximumError": "16mb"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "anyComponentStyle",
|
"type": "anyComponentStyle",
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 688 KiB |
@@ -0,0 +1,89 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AML transaction-monitoring API: live alerts raised by the rule engine, the
|
||||||
|
* two-tier AML Officer / Principal Officer review workflow, STR cases (with
|
||||||
|
* their 7-working-day FIU filing deadline), monthly CTR reports, and a
|
||||||
|
* client's risk-category change history.
|
||||||
|
*/
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class AmlService {
|
||||||
|
private http = inject(HttpClient);
|
||||||
|
|
||||||
|
// ═══ Alerts (AML Officer) ═══
|
||||||
|
|
||||||
|
getAlerts(status?: string): Observable<any[]> {
|
||||||
|
const params = status ? new HttpParams().set('status', status) : undefined;
|
||||||
|
return this.http.get<any[]>('/aml/alerts', { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** disposition: 'WHITELIST' | 'ESCALATE' */
|
||||||
|
reviewAlert(alertId: number, disposition: 'WHITELIST' | 'ESCALATE', notes: string): Observable<any> {
|
||||||
|
return this.http.post(`/aml/alerts/${alertId}/review`, { disposition, notes });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══ STR cases (Principal Officer) ═══
|
||||||
|
|
||||||
|
getStrCases(): Observable<any[]> {
|
||||||
|
return this.http.get<any[]>('/aml/str-cases');
|
||||||
|
}
|
||||||
|
|
||||||
|
getStrCase(strCaseId: number): Observable<any> {
|
||||||
|
return this.http.get(`/aml/str-cases/${strCaseId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
getWeeklyDigest(): Observable<any[]> {
|
||||||
|
return this.http.get<any[]>('/aml/str-cases/weekly-digest');
|
||||||
|
}
|
||||||
|
|
||||||
|
assignPrincipalOfficer(strCaseId: number, principalOfficerUserId: number): Observable<any> {
|
||||||
|
return this.http.post(`/aml/str-cases/${strCaseId}/assign`, { principalOfficerUserId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** conclusion: 'NOT_SUSPICIOUS' | 'SUSPICIOUS' */
|
||||||
|
concludeStrCase(
|
||||||
|
strCaseId: number,
|
||||||
|
conclusion: 'NOT_SUSPICIOUS' | 'SUSPICIOUS',
|
||||||
|
investigationNotes: string,
|
||||||
|
sourceOfFundsNotes: string
|
||||||
|
): Observable<any> {
|
||||||
|
return this.http.post(`/aml/str-cases/${strCaseId}/conclude`, {
|
||||||
|
conclusion,
|
||||||
|
investigationNotes,
|
||||||
|
sourceOfFundsNotes
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fileStr(strCaseId: number, referenceNo: string): Observable<any> {
|
||||||
|
return this.http.post(`/aml/str-cases/${strCaseId}/file`, { referenceNo });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══ CTR reports (mechanical, threshold-based) ═══
|
||||||
|
|
||||||
|
getCtrReports(): Observable<any[]> {
|
||||||
|
return this.http.get<any[]>('/aml/ctr-reports');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** month format: yyyy-MM */
|
||||||
|
generateCtrReport(month: string): Observable<any> {
|
||||||
|
return this.http.post('/aml/ctr-reports/generate', { month });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══ Client risk-category history ═══
|
||||||
|
|
||||||
|
getRiskCategoryHistory(clientId: number): Observable<any[]> {
|
||||||
|
return this.http.get<any[]>(`/clients/${clientId}/risk-category-history`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -62,6 +62,38 @@ const routes: Routes = [
|
|||||||
),
|
),
|
||||||
data: { title: 'My Disbursement Requests', breadcrumb: 'My Disbursement Requests' }
|
data: { title: 'My Disbursement Requests', breadcrumb: 'My Disbursement Requests' }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'share-withdrawal-request',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('../clerk/share-withdrawal-request/share-withdrawal-request.component').then(
|
||||||
|
(m) => m.ShareWithdrawalRequestComponent
|
||||||
|
),
|
||||||
|
data: { title: 'New Share Withdrawal Request', breadcrumb: 'New Share Withdrawal Request', mode: 'create' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'share-withdrawal-requests',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('../clerk/my-share-withdrawal-requests/my-share-withdrawal-requests.component').then(
|
||||||
|
(m) => m.MyShareWithdrawalRequestsComponent
|
||||||
|
),
|
||||||
|
data: { title: 'My Share Requests', breadcrumb: 'My Share Requests' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'share-closure-request',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('../clerk/share-closure-request/share-closure-request.component').then(
|
||||||
|
(m) => m.ShareClosureRequestComponent
|
||||||
|
),
|
||||||
|
data: { title: 'New Share Closure Request', breadcrumb: 'New Share Closure Request', mode: 'create' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'share-closure-requests',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('../clerk/my-share-closure-requests/my-share-closure-requests.component').then(
|
||||||
|
(m) => m.MyShareClosureRequestsComponent
|
||||||
|
),
|
||||||
|
data: { title: 'My Share Closure Requests', breadcrumb: 'My Share Closure Requests' }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'account-facilities',
|
path: 'account-facilities',
|
||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
|
||||||
|
/** HTTP service for the Share Closure maker-checker workflow — mirrors
|
||||||
|
* ClerkShareWithdrawalService, adapted for closing an already-zero-share account. */
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class ClerkShareClosureService {
|
||||||
|
private http = inject(HttpClient);
|
||||||
|
|
||||||
|
submit(data: any): Observable<any> {
|
||||||
|
return this.http.post('/clerk/share-closure-requests', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
getMyRequests(status?: string, offset = 0, limit = 20, fromDate?: string, toDate?: string): Observable<any> {
|
||||||
|
let params = new HttpParams().set('offset', String(offset)).set('limit', String(limit));
|
||||||
|
if (status) params = params.set('status', status);
|
||||||
|
if (fromDate) params = params.set('fromDate', fromDate);
|
||||||
|
if (toDate) params = params.set('toDate', toDate);
|
||||||
|
return this.http.get('/clerk/share-closure-requests/my', { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
getPendingRequests(status = 'PENDING', offset = 0, limit = 20, fromDate?: string, toDate?: string): Observable<any> {
|
||||||
|
let params = new HttpParams().set('status', status).set('offset', String(offset)).set('limit', String(limit));
|
||||||
|
if (fromDate) params = params.set('fromDate', fromDate);
|
||||||
|
if (toDate) params = params.set('toDate', toDate);
|
||||||
|
return this.http.get('/clerk/share-closure-requests', { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
getById(id: number): Observable<any> {
|
||||||
|
return this.http.get(`/clerk/share-closure-requests/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
validate(id: number): Observable<any> {
|
||||||
|
return this.http.post(`/clerk/share-closure-requests/${id}/validate`, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
approve(id: number): Observable<any> {
|
||||||
|
return this.http.post(`/clerk/share-closure-requests/${id}/approve`, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
reject(id: number, reason: string): Observable<any> {
|
||||||
|
return this.http.post(`/clerk/share-closure-requests/${id}/reject`, { reason });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
|
||||||
|
/** HTTP service for the Share Withdrawal (redeem shares) maker-checker workflow — mirrors
|
||||||
|
* ClerkWithdrawalService (cash withdrawal), adapted for shares. */
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class ClerkShareWithdrawalService {
|
||||||
|
private http = inject(HttpClient);
|
||||||
|
|
||||||
|
submit(data: any): Observable<any> {
|
||||||
|
return this.http.post('/clerk/share-withdrawal-requests', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
getMyRequests(status?: string, offset = 0, limit = 20, fromDate?: string, toDate?: string): Observable<any> {
|
||||||
|
let params = new HttpParams().set('offset', String(offset)).set('limit', String(limit));
|
||||||
|
if (status) params = params.set('status', status);
|
||||||
|
if (fromDate) params = params.set('fromDate', fromDate);
|
||||||
|
if (toDate) params = params.set('toDate', toDate);
|
||||||
|
return this.http.get('/clerk/share-withdrawal-requests/my', { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
getPendingRequests(status = 'PENDING', offset = 0, limit = 20, fromDate?: string, toDate?: string): Observable<any> {
|
||||||
|
let params = new HttpParams().set('status', status).set('offset', String(offset)).set('limit', String(limit));
|
||||||
|
if (fromDate) params = params.set('fromDate', fromDate);
|
||||||
|
if (toDate) params = params.set('toDate', toDate);
|
||||||
|
return this.http.get('/clerk/share-withdrawal-requests', { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
getById(id: number): Observable<any> {
|
||||||
|
return this.http.get(`/clerk/share-withdrawal-requests/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
validate(id: number): Observable<any> {
|
||||||
|
return this.http.post(`/clerk/share-withdrawal-requests/${id}/validate`, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
approve(id: number): Observable<any> {
|
||||||
|
return this.http.post(`/clerk/share-withdrawal-requests/${id}/approve`, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
reject(id: number, reason: string): Observable<any> {
|
||||||
|
return this.http.post(`/clerk/share-withdrawal-requests/${id}/reject`, { reason });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -146,7 +146,7 @@
|
|||||||
[class.ws-f__ctl--req]="!row.paymentTypeId"
|
[class.ws-f__ctl--req]="!row.paymentTypeId"
|
||||||
>
|
>
|
||||||
<option [ngValue]="null">— Select —</option>
|
<option [ngValue]="null">— Select —</option>
|
||||||
@for (pt of paymentTypes; track pt.id) {
|
@for (pt of paymentTypesForRow(row); track pt.id) {
|
||||||
<option [ngValue]="pt.id">{{ pt.name }}</option>
|
<option [ngValue]="pt.id">{{ pt.name }}</option>
|
||||||
}
|
}
|
||||||
</select>
|
</select>
|
||||||
@@ -231,8 +231,11 @@
|
|||||||
<span class="ws-caption">Payment instrument</span>
|
<span class="ws-caption">Payment instrument</span>
|
||||||
<div class="ws-group__fields">
|
<div class="ws-group__fields">
|
||||||
<div class="ws-f ws-f--amt">
|
<div class="ws-f ws-f--amt">
|
||||||
<label class="ws-f__lbl" title="Transaction Amount">
|
<label
|
||||||
AMOUNT
|
class="ws-f__lbl"
|
||||||
|
[title]="row.acTyp === 'SHR' ? 'Number of Shares' : 'Transaction Amount'"
|
||||||
|
>
|
||||||
|
{{ row.acTyp === 'SHR' ? 'SHARE NO.' : 'AMOUNT' }}
|
||||||
@if (!isClerk && activeWithdrawalRequest) {
|
@if (!isClerk && activeWithdrawalRequest) {
|
||||||
<span class="ws-amt-locked-badge" title="Amount locked — approved withdrawal">🔒</span>
|
<span class="ws-amt-locked-badge" title="Amount locked — approved withdrawal">🔒</span>
|
||||||
}
|
}
|
||||||
@@ -242,20 +245,48 @@
|
|||||||
[class.ws-amt-wrap--cash]="isCashPayment && selectedRow === row"
|
[class.ws-amt-wrap--cash]="isCashPayment && selectedRow === row"
|
||||||
[class.ws-amt-wrap--locked]="!isClerk && !!activeWithdrawalRequest"
|
[class.ws-amt-wrap--locked]="!isClerk && !!activeWithdrawalRequest"
|
||||||
>
|
>
|
||||||
<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 {
|
||||||
@@ -394,7 +399,12 @@ export class JointHolderDialogComponent implements OnInit {
|
|||||||
this.kyc = kycArr[0] ?? null;
|
this.kyc = kycArr[0] ?? null;
|
||||||
this.photo = photo ?? null;
|
this.photo = photo ?? null;
|
||||||
const docs: any[] = Array.isArray(documents) ? documents : [];
|
const docs: any[] = Array.isArray(documents) ? documents : [];
|
||||||
const sigDoc = docs.find((d: any) => d.name === 'clientSignature' || d.name?.toLowerCase().includes('signature'));
|
// Multiple 'clientSignature' documents can exist for clients who re-cropped/replaced their
|
||||||
|
// signature before uploads became single-slot, so pick the highest id (latest) rather than
|
||||||
|
// the first unordered match.
|
||||||
|
const sigDoc = docs
|
||||||
|
.filter((d: any) => d.name === 'clientSignature' || d.name?.toLowerCase().includes('signature'))
|
||||||
|
.sort((a: any, b: any) => b.id - a.id)[0];
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
if (sigDoc) {
|
if (sigDoc) {
|
||||||
this.http
|
this.http
|
||||||
@@ -748,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);
|
||||||
@@ -764,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;
|
||||||
@@ -1200,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 = {
|
||||||
@@ -1494,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;
|
||||||
@@ -1530,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;
|
||||||
@@ -1628,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;
|
||||||
@@ -1654,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;
|
||||||
@@ -1688,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',
|
||||||
@@ -1800,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,
|
||||||
@@ -1832,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;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1868,7 +2139,12 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
this.clientAddresses = Array.isArray(r.addresses) ? r.addresses : [];
|
this.clientAddresses = Array.isArray(r.addresses) ? r.addresses : [];
|
||||||
this.clientPhotoUrl = r.photo ?? null;
|
this.clientPhotoUrl = r.photo ?? null;
|
||||||
const docs: any[] = Array.isArray(r.documents) ? r.documents : [];
|
const docs: any[] = Array.isArray(r.documents) ? r.documents : [];
|
||||||
const sigDoc = docs.find((d: any) => d.name === 'clientSignature' || d.name?.toLowerCase().includes('signature'));
|
// Multiple 'clientSignature' documents can exist for clients who re-cropped/replaced their
|
||||||
|
// signature before uploads became single-slot, so pick the highest id (latest) rather than
|
||||||
|
// the first unordered match.
|
||||||
|
const sigDoc = docs
|
||||||
|
.filter((d: any) => d.name === 'clientSignature' || d.name?.toLowerCase().includes('signature'))
|
||||||
|
.sort((a: any, b: any) => b.id - a.id)[0];
|
||||||
if (sigDoc) {
|
if (sigDoc) {
|
||||||
this.http
|
this.http
|
||||||
.get(`/clients/${clientId}/documents/${sigDoc.id}/attachment`, { responseType: 'blob' })
|
.get(`/clients/${clientId}/documents/${sigDoc.id}/attachment`, { responseType: 'blob' })
|
||||||
@@ -1903,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) {
|
||||||
@@ -2079,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2492,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',
|
||||||
@@ -2994,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) {
|
||||||
|
|||||||
@@ -446,8 +446,12 @@ export class RecordMasterComponent implements OnInit {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Signature — client document named 'clientSignature'
|
// 2. Signature — client document named 'clientSignature'. Multiple rows can exist for
|
||||||
const signatureDoc = (documents as any[])?.find((d: any) => d?.name === 'clientSignature');
|
// clients who re-cropped/replaced their signature before uploads became single-slot, so
|
||||||
|
// pick the highest id (latest) rather than the first unordered match.
|
||||||
|
const signatureDoc = (documents as any[])
|
||||||
|
?.filter((d: any) => d?.name === 'clientSignature')
|
||||||
|
?.sort((a: any, b: any) => b.id - a.id)?.[0];
|
||||||
if (signatureDoc) {
|
if (signatureDoc) {
|
||||||
this.viewDetails.docs.signature = {
|
this.viewDetails.docs.signature = {
|
||||||
present: true,
|
present: true,
|
||||||
|
|||||||
@@ -66,9 +66,12 @@ export class SignatoryService {
|
|||||||
fetchClientSignature(clientId: number | string): Observable<string | null> {
|
fetchClientSignature(clientId: number | string): Observable<string | null> {
|
||||||
return this.http.get<any[]>(`/clients/${clientId}/documents`).pipe(
|
return this.http.get<any[]>(`/clients/${clientId}/documents`).pipe(
|
||||||
switchMap((docs: any[]) => {
|
switchMap((docs: any[]) => {
|
||||||
const sigDoc = (docs || []).find(
|
// Multiple 'clientSignature' documents can exist for clients who re-cropped/replaced
|
||||||
(d: any) => d.name === 'clientSignature' || d.name?.toLowerCase().includes('signature')
|
// their signature before uploads became single-slot, so pick the highest id (latest)
|
||||||
);
|
// rather than the first unordered match.
|
||||||
|
const sigDoc = (docs || [])
|
||||||
|
.filter((d: any) => d.name === 'clientSignature' || d.name?.toLowerCase().includes('signature'))
|
||||||
|
.sort((a: any, b: any) => b.id - a.id)[0];
|
||||||
if (!sigDoc) {
|
if (!sigDoc) {
|
||||||
return of(null);
|
return of(null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Small shared layout/status-pill classes for the Share Withdrawal maker-checker screens —
|
||||||
|
* self-contained (this branch has no separate "investment" module to borrow them from). */
|
||||||
|
|
||||||
|
.inv-form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inv-form-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #64748b;
|
||||||
|
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #0d1b2a;
|
||||||
|
font-family: inherit;
|
||||||
|
background: #fff;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
height: auto;
|
||||||
|
padding: 8px 10px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.inv-form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 16px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inv-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #475569;
|
||||||
|
|
||||||
|
&--active {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--matured {
|
||||||
|
background: #dcfce7;
|
||||||
|
color: #166534;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--written_off {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
<!--
|
||||||
|
Copyright since 2025 Mifos Initiative
|
||||||
|
|
||||||
|
This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<div class="pg">
|
||||||
|
<header class="pg-head">
|
||||||
|
<div>
|
||||||
|
<div class="pg-eyebrow">CLERK · SHARE CLOSURE</div>
|
||||||
|
<h1 class="pg-title">My Share Closure Requests</h1>
|
||||||
|
<p class="pg-sub">Share account closure requests you have submitted, and their approval status.</p>
|
||||||
|
</div>
|
||||||
|
<div class="pg-toolbar">
|
||||||
|
<button type="button" class="pg-btn pg-btn--primary" (click)="newRequest()">New Share Closure Request</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="inv-form-grid" style="padding: 12px 16px">
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Status</span>
|
||||||
|
<select [(ngModel)]="filterStatus" (change)="onFilterChange()">
|
||||||
|
@for (opt of statusOptions; track opt.value) {
|
||||||
|
<option [value]="opt.value">{{ opt.label }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<table class="pg-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Account No</th>
|
||||||
|
<th>Client</th>
|
||||||
|
<th>Closed Date</th>
|
||||||
|
<th>Submitted At</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@if (loading) {
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="pg-empty">Loading…</td>
|
||||||
|
</tr>
|
||||||
|
} @else if (loaded && requests.length === 0) {
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="pg-empty">No share closure requests found.</td>
|
||||||
|
</tr>
|
||||||
|
} @else {
|
||||||
|
@for (r of requests; track r.id) {
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<span class="pg-chip">{{ r.account_no }}</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ r.client_name }}</td>
|
||||||
|
<td>{{ formatDateTime(r.closed_date) }}</td>
|
||||||
|
<td>{{ formatDateTime(r.submitted_at) }}</td>
|
||||||
|
<td>
|
||||||
|
<span [class]="statusClass(r.status)">{{ statusLabel(r.status) }}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, OnInit, inject } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { Router, RouterModule } from '@angular/router';
|
||||||
|
import { catchError } from 'rxjs/operators';
|
||||||
|
import { of } from 'rxjs';
|
||||||
|
|
||||||
|
import { ClerkShareClosureService } from '../../cashier/clerk-share-closure.service';
|
||||||
|
|
||||||
|
/** Clerk Dashboard "My Share Closure Requests" — lists the share account closure requests this
|
||||||
|
* clerk has submitted and their approval status. Mirrors MyShareWithdrawalRequestsComponent. */
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-my-share-closure-requests',
|
||||||
|
templateUrl: './my-share-closure-requests.component.html',
|
||||||
|
styleUrls: [
|
||||||
|
'../../cashier/cashier-shared.scss',
|
||||||
|
'../clerk-shared.scss'
|
||||||
|
],
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
RouterModule
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class MyShareClosureRequestsComponent implements OnInit {
|
||||||
|
private service = inject(ClerkShareClosureService);
|
||||||
|
private router = inject(Router);
|
||||||
|
|
||||||
|
requests: any[] = [];
|
||||||
|
totalRecords = 0;
|
||||||
|
loading = false;
|
||||||
|
loaded = false;
|
||||||
|
|
||||||
|
filterStatus = '';
|
||||||
|
|
||||||
|
get statusOptions(): { value: string; label: string }[] {
|
||||||
|
return [
|
||||||
|
{ value: '', label: 'All' },
|
||||||
|
{ value: 'PENDING', label: 'Pending' },
|
||||||
|
{ value: 'APPROVED', label: 'Approved' },
|
||||||
|
{ value: 'REJECTED', label: 'Rejected' }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
load(): void {
|
||||||
|
this.loading = true;
|
||||||
|
this.service
|
||||||
|
.getMyRequests(this.filterStatus || undefined, 0, 50)
|
||||||
|
.pipe(catchError(() => of({ pageItems: [], totalFilteredRecords: 0 })))
|
||||||
|
.subscribe((res: any) => {
|
||||||
|
this.requests = res?.pageItems ?? [];
|
||||||
|
this.totalRecords = Number(res?.totalFilteredRecords ?? 0);
|
||||||
|
this.loading = false;
|
||||||
|
this.loaded = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onFilterChange(): void {
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
newRequest(): void {
|
||||||
|
this.router.navigate(['/clerk/share-closure-request']);
|
||||||
|
}
|
||||||
|
|
||||||
|
statusClass(status: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
PENDING: 'inv-badge--active',
|
||||||
|
APPROVED: 'inv-badge--matured',
|
||||||
|
REJECTED: 'inv-badge--written_off'
|
||||||
|
};
|
||||||
|
return `inv-badge ${map[status] ?? ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
statusLabel(status: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
PENDING: 'Pending Approval',
|
||||||
|
APPROVED: 'Approved',
|
||||||
|
REJECTED: 'Rejected'
|
||||||
|
};
|
||||||
|
return map[status] ?? status;
|
||||||
|
}
|
||||||
|
|
||||||
|
formatDateTime(val: any): string {
|
||||||
|
if (!val) return '—';
|
||||||
|
try {
|
||||||
|
const d = new Date(val);
|
||||||
|
return d.toLocaleString('en-IN', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return String(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+73
@@ -0,0 +1,73 @@
|
|||||||
|
<!--
|
||||||
|
Copyright since 2025 Mifos Initiative
|
||||||
|
|
||||||
|
This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<div class="pg">
|
||||||
|
<header class="pg-head">
|
||||||
|
<div>
|
||||||
|
<div class="pg-eyebrow">CLERK · SHARE WITHDRAWAL</div>
|
||||||
|
<h1 class="pg-title">My Share Requests</h1>
|
||||||
|
<p class="pg-sub">Share withdrawal requests you have submitted, and their approval status.</p>
|
||||||
|
</div>
|
||||||
|
<div class="pg-toolbar">
|
||||||
|
<button type="button" class="pg-btn pg-btn--primary" (click)="newRequest()">New Share Withdrawal</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="inv-form-grid" style="padding: 12px 16px">
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Status</span>
|
||||||
|
<select [(ngModel)]="filterStatus" (change)="onFilterChange()">
|
||||||
|
@for (opt of statusOptions; track opt.value) {
|
||||||
|
<option [value]="opt.value">{{ opt.label }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<table class="pg-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Account No</th>
|
||||||
|
<th>Client</th>
|
||||||
|
<th class="num">Shares</th>
|
||||||
|
<th>Resolution No.</th>
|
||||||
|
<th>Meeting Date</th>
|
||||||
|
<th>Submitted At</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@if (loading) {
|
||||||
|
<tr>
|
||||||
|
<td colspan="7" class="pg-empty">Loading…</td>
|
||||||
|
</tr>
|
||||||
|
} @else if (loaded && requests.length === 0) {
|
||||||
|
<tr>
|
||||||
|
<td colspan="7" class="pg-empty">No share withdrawal requests found.</td>
|
||||||
|
</tr>
|
||||||
|
} @else {
|
||||||
|
@for (r of requests; track r.id) {
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<span class="pg-chip">{{ r.account_no }}</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ r.client_name }}</td>
|
||||||
|
<td class="num">{{ r.requested_shares }}</td>
|
||||||
|
<td>{{ r.resolution_number }}</td>
|
||||||
|
<td>{{ formatDateTime(r.meeting_date) }}</td>
|
||||||
|
<td>{{ formatDateTime(r.submitted_at) }}</td>
|
||||||
|
<td>
|
||||||
|
<span [class]="statusClass(r.status)">{{ statusLabel(r.status) }}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, OnInit, inject } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { Router, RouterModule } from '@angular/router';
|
||||||
|
import { catchError } from 'rxjs/operators';
|
||||||
|
import { of } from 'rxjs';
|
||||||
|
|
||||||
|
import { ClerkShareWithdrawalService } from '../../cashier/clerk-share-withdrawal.service';
|
||||||
|
|
||||||
|
/** Clerk Dashboard "My Share Requests" — lists the share withdrawal requests this clerk has
|
||||||
|
* submitted and their approval status. Mirrors MyRequestsComponent/MyDisbursementRequestsComponent. */
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-my-share-withdrawal-requests',
|
||||||
|
templateUrl: './my-share-withdrawal-requests.component.html',
|
||||||
|
styleUrls: [
|
||||||
|
'../../cashier/cashier-shared.scss',
|
||||||
|
'../clerk-shared.scss'
|
||||||
|
],
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
RouterModule
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class MyShareWithdrawalRequestsComponent implements OnInit {
|
||||||
|
private service = inject(ClerkShareWithdrawalService);
|
||||||
|
private router = inject(Router);
|
||||||
|
|
||||||
|
requests: any[] = [];
|
||||||
|
totalRecords = 0;
|
||||||
|
loading = false;
|
||||||
|
loaded = false;
|
||||||
|
|
||||||
|
filterStatus = '';
|
||||||
|
|
||||||
|
get statusOptions(): { value: string; label: string }[] {
|
||||||
|
return [
|
||||||
|
{ value: '', label: 'All' },
|
||||||
|
{ value: 'PENDING', label: 'Pending' },
|
||||||
|
{ value: 'APPROVED', label: 'Approved' },
|
||||||
|
{ value: 'REJECTED', label: 'Rejected' }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
load(): void {
|
||||||
|
this.loading = true;
|
||||||
|
this.service
|
||||||
|
.getMyRequests(this.filterStatus || undefined, 0, 50)
|
||||||
|
.pipe(catchError(() => of({ pageItems: [], totalFilteredRecords: 0 })))
|
||||||
|
.subscribe((res: any) => {
|
||||||
|
this.requests = res?.pageItems ?? [];
|
||||||
|
this.totalRecords = Number(res?.totalFilteredRecords ?? 0);
|
||||||
|
this.loading = false;
|
||||||
|
this.loaded = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onFilterChange(): void {
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
newRequest(): void {
|
||||||
|
this.router.navigate(['/clerk/share-withdrawal-request']);
|
||||||
|
}
|
||||||
|
|
||||||
|
statusClass(status: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
PENDING: 'inv-badge--active',
|
||||||
|
APPROVED: 'inv-badge--matured',
|
||||||
|
REJECTED: 'inv-badge--written_off'
|
||||||
|
};
|
||||||
|
return `inv-badge ${map[status] ?? ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
statusLabel(status: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
PENDING: 'Pending Approval',
|
||||||
|
APPROVED: 'Approved',
|
||||||
|
REJECTED: 'Rejected'
|
||||||
|
};
|
||||||
|
return map[status] ?? status;
|
||||||
|
}
|
||||||
|
|
||||||
|
formatDateTime(val: any): string {
|
||||||
|
if (!val) return '—';
|
||||||
|
try {
|
||||||
|
const d = new Date(val);
|
||||||
|
return d.toLocaleString('en-IN', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return String(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
<!--
|
||||||
|
Copyright since 2025 Mifos Initiative
|
||||||
|
|
||||||
|
This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<div class="pg">
|
||||||
|
<header class="pg-head">
|
||||||
|
<div>
|
||||||
|
<div class="pg-eyebrow">{{ isReview ? 'CHECKER · APPROVAL' : 'CLERK · SHARE CLOSURE' }}</div>
|
||||||
|
<h1 class="pg-title">{{ pageTitle }}</h1>
|
||||||
|
<p class="pg-sub">{{ pageSubtitle }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="pg-toolbar">
|
||||||
|
<button type="button" class="pg-btn" (click)="goBack()">
|
||||||
|
<mat-icon>arrow_back</mat-icon>
|
||||||
|
{{ isReview ? 'Back to Requests' : 'My Share Closure Requests' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="pos-section-label">Share Account</div>
|
||||||
|
<div class="inv-form-grid" style="padding: 16px">
|
||||||
|
@if (!isReview) {
|
||||||
|
<label class="inv-form-field" style="grid-column: span 2">
|
||||||
|
<span>* Search Account No. / Client Name (only zero-share accounts are eligible)</span>
|
||||||
|
<mifosx-search-select
|
||||||
|
[options]="accountOptions"
|
||||||
|
[(ngModel)]="selectedAccountId"
|
||||||
|
(ngModelChange)="onAccountSelected($event)"
|
||||||
|
placeholder="Search share account no. or client name…"
|
||||||
|
></mifosx-search-select>
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
@if (account) {
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Account No</span>
|
||||||
|
<input type="text" [value]="account.accountNo" readonly />
|
||||||
|
</label>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Client</span>
|
||||||
|
<input type="text" [value]="account.clientName" readonly />
|
||||||
|
</label>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Shares Held</span>
|
||||||
|
<input type="text" [value]="heldShares" readonly />
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
@if (loadingAccount) {
|
||||||
|
<div class="pg-empty">Loading account…</div>
|
||||||
|
}
|
||||||
|
@if (account && heldShares !== 0 && !isReview) {
|
||||||
|
<div class="pg-empty" style="grid-column: span 2; color: #b42318">
|
||||||
|
This account still holds {{ heldShares }} share(s) — all shares must be withdrawn first via the Share
|
||||||
|
Withdrawal workflow before it becomes eligible for closure.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (isReview && reviewRequest) {
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="pos-section-label">Client Compliance</div>
|
||||||
|
<div class="inv-form-grid" style="padding: 16px">
|
||||||
|
<div class="inv-form-field">
|
||||||
|
<span>KYC Status</span>
|
||||||
|
<span
|
||||||
|
class="swr-chip"
|
||||||
|
[class.swr-chip--ok]="reviewRequest.client_kyc_verified"
|
||||||
|
[class.swr-chip--warn]="!reviewRequest.client_kyc_verified"
|
||||||
|
>
|
||||||
|
{{ reviewRequest.client_kyc_status_label }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="inv-form-field">
|
||||||
|
<span>Risk Category</span>
|
||||||
|
<span
|
||||||
|
class="swr-chip"
|
||||||
|
[class.swr-chip--ok]="!reviewRequest.client_risk_high"
|
||||||
|
[class.swr-chip--warn]="reviewRequest.client_risk_high"
|
||||||
|
>
|
||||||
|
{{ reviewRequest.client_risk_category_label }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="inv-form-field">
|
||||||
|
<span>Open AML Alerts</span>
|
||||||
|
<span
|
||||||
|
class="swr-chip"
|
||||||
|
[class.swr-chip--ok]="!reviewRequest.client_open_aml_alerts"
|
||||||
|
[class.swr-chip--warn]="reviewRequest.client_open_aml_alerts > 0"
|
||||||
|
>
|
||||||
|
{{ reviewRequest.client_open_aml_alerts || 0 }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (account) {
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="pos-section-label">Closure Details</div>
|
||||||
|
<div class="inv-form-grid" style="padding: 16px">
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>* Closed Date</span>
|
||||||
|
<input type="date" [(ngModel)]="closedDate" [disabled]="isReview" />
|
||||||
|
</label>
|
||||||
|
<label class="inv-form-field" style="grid-column: span 2">
|
||||||
|
<span>Note (optional)</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
[(ngModel)]="note"
|
||||||
|
[disabled]="isReview"
|
||||||
|
placeholder="e.g. Member exit — all shares redeemed"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!isReview) {
|
||||||
|
<div class="inv-form-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pg-btn pg-btn--primary"
|
||||||
|
[disabled]="!isFormValid || submitting"
|
||||||
|
(click)="openConfirmDialog()"
|
||||||
|
>
|
||||||
|
{{ submitting ? 'Submitting…' : 'Review & Submit' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (isReview && reviewRequest?.status === 'PENDING') {
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="pos-section-label">Verification</div>
|
||||||
|
<div class="swrv-trigger">
|
||||||
|
<div class="swrv-trigger__icon">
|
||||||
|
<mat-icon>fact_check</mat-icon>
|
||||||
|
</div>
|
||||||
|
<div class="swrv-trigger__text">
|
||||||
|
<div class="swrv-trigger__title">Run a live backend validation</div>
|
||||||
|
<div class="swrv-trigger__sub">
|
||||||
|
Re-checks shares held, account status, active loan/guarantee and client compliance before you approve.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="pg-btn pg-btn--primary" (click)="openValidateModal()">
|
||||||
|
<mat-icon>play_circle</mat-icon>
|
||||||
|
Validate Request
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="inv-form-actions">
|
||||||
|
<button type="button" class="pg-btn pg-btn--primary" (click)="approveReview()">
|
||||||
|
<mat-icon>check_circle</mat-icon>
|
||||||
|
Approve & Close Account
|
||||||
|
</button>
|
||||||
|
<button type="button" class="pg-btn pg-btn--danger" (click)="openRejectDialog()">
|
||||||
|
<mat-icon>cancel</mat-icon>
|
||||||
|
Reject
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (showValidateModal) {
|
||||||
|
<div class="swrv-modal">
|
||||||
|
<div class="swrv-modal__card">
|
||||||
|
<div class="swrv-modal__header">
|
||||||
|
<div class="swrv-modal__title">
|
||||||
|
<span class="swrv-modal__title-icon"><mat-icon>fact_check</mat-icon></span>
|
||||||
|
Validate Share Closure Request
|
||||||
|
</div>
|
||||||
|
<button type="button" class="swrv-modal__close" [disabled]="validating" (click)="closeValidateModal()">
|
||||||
|
<mat-icon>close</mat-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="swrv-modal__body">
|
||||||
|
<p class="swrv-modal__intro">
|
||||||
|
Re-checking closure of <strong>{{ account?.accountNo }}</strong> ({{ account?.clientName }}) against the
|
||||||
|
live account, loan/guarantee and compliance state — not just what was captured at submission time.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
@if (backendChecks && !validating && !validateAllPassed) {
|
||||||
|
<div class="swrv-modal__warn">
|
||||||
|
<mat-icon>warning</mat-icon>
|
||||||
|
<div>
|
||||||
|
<strong>Some checks need review before approving.</strong>
|
||||||
|
Scroll down to see which ones failed and why.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<ol class="swrv-steps">
|
||||||
|
@for (step of validateSteps; track step.label; let i = $index) {
|
||||||
|
<li
|
||||||
|
class="swrv-step"
|
||||||
|
[class.swrv-step--active]="step.status === 'active'"
|
||||||
|
[class.swrv-step--done]="step.status === 'done'"
|
||||||
|
[class.swrv-step--failed]="step.status === 'failed'"
|
||||||
|
>
|
||||||
|
<div class="swrv-step__indicator">
|
||||||
|
@if (step.status === 'done') {
|
||||||
|
<mat-icon>check</mat-icon>
|
||||||
|
} @else if (step.status === 'active') {
|
||||||
|
<mat-icon class="swrv-spin">autorenew</mat-icon>
|
||||||
|
} @else if (step.status === 'failed') {
|
||||||
|
<mat-icon>close</mat-icon>
|
||||||
|
} @else {
|
||||||
|
<span>{{ i + 1 }}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="swrv-step__content">
|
||||||
|
<div class="swrv-step__label">{{ step.label }}</div>
|
||||||
|
<div class="swrv-step__msg">{{ step.detail }}</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
@if (validating && validateSteps.length === 0) {
|
||||||
|
<div class="pg-empty">Connecting to server…</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (backendChecks && !validating && validateAllPassed) {
|
||||||
|
<div class="swrv-modal__result">
|
||||||
|
<mat-icon>verified</mat-icon>
|
||||||
|
<span>All checks passed — safe to approve.</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="swrv-modal__footer">
|
||||||
|
@if (!validating && backendChecks) {
|
||||||
|
<button type="button" class="pg-btn" (click)="runBackendValidation()">
|
||||||
|
<mat-icon>refresh</mat-icon>
|
||||||
|
Re-run
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
@if (!validating && backendChecks && validateAllPassed) {
|
||||||
|
<button type="button" class="pg-btn pg-btn--primary" (click)="closeValidateModal(); approveReview()">
|
||||||
|
<mat-icon>check_circle</mat-icon>
|
||||||
|
Approve & Close Account
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
<button type="button" class="pg-btn" [disabled]="validating" (click)="closeValidateModal()">
|
||||||
|
{{ validating ? 'Validating…' : 'Close' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (isReview && activityLog.length > 0) {
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="pos-section-label">Activity Log</div>
|
||||||
|
<table class="pg-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Timestamp</th>
|
||||||
|
<th>Action</th>
|
||||||
|
<th>User</th>
|
||||||
|
<th>Note</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (entry of activityLog; track entry.timestamp) {
|
||||||
|
<tr>
|
||||||
|
<td>{{ entry.timestamp | date: 'medium' }}</td>
|
||||||
|
<td>{{ entry.action }}</td>
|
||||||
|
<td>{{ entry.user }}</td>
|
||||||
|
<td>{{ entry.note || '—' }}</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (showConfirmDialog) {
|
||||||
|
<div class="swr-modal-backdrop">
|
||||||
|
<div class="swr-modal">
|
||||||
|
<h3>Confirm Share Closure Request</h3>
|
||||||
|
<p class="pg-sub">Review the details below before submitting for Bank Manager approval.</p>
|
||||||
|
<table class="pg-table">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>Account No / Client</td>
|
||||||
|
<td>{{ account?.accountNo }} — {{ account?.clientName }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Shares Held</td>
|
||||||
|
<td>{{ heldShares }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Closed Date</td>
|
||||||
|
<td>{{ closedDate }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Note</td>
|
||||||
|
<td>{{ note || '—' }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="pos-section-label" style="margin-top: 12px">Validation Checks</div>
|
||||||
|
<table class="pg-table">
|
||||||
|
<tbody>
|
||||||
|
@for (check of verificationChecks; track check.label) {
|
||||||
|
<tr>
|
||||||
|
<td>{{ check.label }}</td>
|
||||||
|
<td [class.swr-check-pass]="check.pass" [class.swr-check-fail]="!check.pass">
|
||||||
|
{{ check.pass ? 'Pass — ' : 'Review — ' }}{{ check.detail }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="inv-form-actions">
|
||||||
|
<button type="button" class="pg-btn pg-btn--primary" [disabled]="submitting" (click)="confirmSubmit()">
|
||||||
|
{{ submitting ? 'Submitting…' : 'Confirm & Submit' }}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="pg-btn" (click)="cancelConfirmDialog()">Back to Edit</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (showRejectDialog) {
|
||||||
|
<div class="swr-modal-backdrop">
|
||||||
|
<div class="swr-modal">
|
||||||
|
<h3>Reject Share Closure Request</h3>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>* Rejection Reason</span>
|
||||||
|
<textarea rows="3" [(ngModel)]="rejectionReason"></textarea>
|
||||||
|
</label>
|
||||||
|
<div class="inv-form-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pg-btn pg-btn--danger"
|
||||||
|
[disabled]="!rejectionReason.trim()"
|
||||||
|
(click)="confirmRejectReview()"
|
||||||
|
>
|
||||||
|
Confirm Reject
|
||||||
|
</button>
|
||||||
|
<button type="button" class="pg-btn" (click)="showRejectDialog = false">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,336 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, EventEmitter, Input, OnInit, Output, inject } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||||
|
import { catchError } from 'rxjs/operators';
|
||||||
|
import { of } from 'rxjs';
|
||||||
|
|
||||||
|
import { SharesService } from '../../shares/shares.service';
|
||||||
|
import { ClerkShareClosureService } from '../../cashier/clerk-share-closure.service';
|
||||||
|
import { SearchSelectComponent, SearchSelectOption } from '../../shared/search-select/search-select.component';
|
||||||
|
|
||||||
|
export type ShareClosureMode = 'create' | 'review';
|
||||||
|
|
||||||
|
interface ActivityEntry {
|
||||||
|
timestamp: string;
|
||||||
|
action: string;
|
||||||
|
user: string;
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Share Account Closure request — maker screen in the Clerk Dashboard. A clerk finds a member's
|
||||||
|
* Share Account that already holds zero shares (all shares already withdrawn), enters the closed
|
||||||
|
* date plus an optional note, and submits for Bank Manager approval. This is the ONLY entry point
|
||||||
|
* a share account closure should go through — approval server-side executes the real Fineract
|
||||||
|
* "close" command (see ClerkShareClosureRequestApiResource on the backend).
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-share-closure-request',
|
||||||
|
templateUrl: './share-closure-request.component.html',
|
||||||
|
styleUrls: [
|
||||||
|
'../../cashier/cashier-shared.scss',
|
||||||
|
'../clerk-shared.scss',
|
||||||
|
'../share-withdrawal-request/share-withdrawal-request.component.scss'
|
||||||
|
],
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
RouterModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatSnackBarModule,
|
||||||
|
SearchSelectComponent
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class ShareClosureRequestComponent implements OnInit {
|
||||||
|
private route = inject(ActivatedRoute);
|
||||||
|
private router = inject(Router);
|
||||||
|
private sharesService = inject(SharesService);
|
||||||
|
private closureService = inject(ClerkShareClosureService);
|
||||||
|
private snackBar = inject(MatSnackBar);
|
||||||
|
|
||||||
|
/** When embedded (e.g. opened as a popup from a list screen instead of routed to directly), mode
|
||||||
|
* and requestId come from these inputs rather than the route, and success/close actions emit
|
||||||
|
* events instead of navigating away. */
|
||||||
|
@Input() embedded = false;
|
||||||
|
@Input() overrideMode: ShareClosureMode | null = null;
|
||||||
|
@Input() overrideRequestId: number | null = null;
|
||||||
|
@Output() closeRequested = new EventEmitter<void>();
|
||||||
|
@Output() actionCompleted = new EventEmitter<void>();
|
||||||
|
|
||||||
|
mode: ShareClosureMode = 'create';
|
||||||
|
requestId: number | null = null;
|
||||||
|
|
||||||
|
submitting = false;
|
||||||
|
loadingAccount = false;
|
||||||
|
|
||||||
|
selectedAccountId = '';
|
||||||
|
accountOptions: SearchSelectOption[] = [];
|
||||||
|
|
||||||
|
account: any = null;
|
||||||
|
|
||||||
|
closedDate = '';
|
||||||
|
note = '';
|
||||||
|
|
||||||
|
activityLog: ActivityEntry[] = [];
|
||||||
|
reviewRequest: any = null;
|
||||||
|
showRejectDialog = false;
|
||||||
|
rejectionReason = '';
|
||||||
|
showConfirmDialog = false;
|
||||||
|
|
||||||
|
showValidateModal = false;
|
||||||
|
validating = false;
|
||||||
|
validateAllPassed = false;
|
||||||
|
backendChecks: { label: string; pass: boolean; detail: string }[] | null = null;
|
||||||
|
validateSteps: { label: string; detail: string; pass: boolean; status: 'pending' | 'active' | 'done' | 'failed' }[] =
|
||||||
|
[];
|
||||||
|
|
||||||
|
get heldShares(): number {
|
||||||
|
return Number(this.account?.summary?.totalApprovedShares ?? this.account?.totalApprovedShares ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
get isFormValid(): boolean {
|
||||||
|
if (!this.account) return false;
|
||||||
|
if (this.heldShares !== 0) return false;
|
||||||
|
if (!this.closedDate) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shown to the clerk before submit and to the manager before approval, so both can verify the same checks. */
|
||||||
|
get verificationChecks(): { label: string; pass: boolean; detail: string }[] {
|
||||||
|
return [
|
||||||
|
{ label: 'No shares currently held', pass: this.heldShares === 0, detail: `${this.heldShares} share(s) held` },
|
||||||
|
{ label: 'Closed Date provided', pass: !!this.closedDate, detail: this.closedDate || 'Missing' }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
get isReview(): boolean {
|
||||||
|
return this.mode === 'review';
|
||||||
|
}
|
||||||
|
|
||||||
|
get pageTitle(): string {
|
||||||
|
return this.isReview ? 'Review Share Closure Request' : 'New Share Closure Request';
|
||||||
|
}
|
||||||
|
|
||||||
|
get pageSubtitle(): string {
|
||||||
|
return this.isReview
|
||||||
|
? "Approve or reject the clerk's share account closure request."
|
||||||
|
: 'Close a Share Account whose shares have already been fully withdrawn, for Bank Manager approval.';
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.mode = this.overrideMode ?? (this.route.snapshot.data?.['mode'] as ShareClosureMode) ?? 'create';
|
||||||
|
const idParam = this.overrideRequestId ?? this.route.snapshot.params?.['id'];
|
||||||
|
this.requestId = idParam ? Number(idParam) : null;
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
this.closedDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
||||||
|
|
||||||
|
this.sharesService
|
||||||
|
.getShareAccountSummaries()
|
||||||
|
.pipe(catchError(() => of([])))
|
||||||
|
.subscribe((rows: any[]) => {
|
||||||
|
this.accountOptions = (rows || [])
|
||||||
|
.filter((r) => (r.status?.value || '').toLowerCase() === 'active' && Number(r.totalApprovedShares ?? 0) === 0)
|
||||||
|
.map((r) => ({
|
||||||
|
value: String(r.id),
|
||||||
|
label: `${r.accountNo} — ${r.clientName} (0 shares held)`,
|
||||||
|
searchText: `${r.accountNo} ${r.clientName}`
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.requestId && this.mode === 'review') {
|
||||||
|
this.loadReviewRequest(this.requestId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onAccountSelected(accountId: string): void {
|
||||||
|
this.selectedAccountId = accountId;
|
||||||
|
if (!accountId) {
|
||||||
|
this.account = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.loadingAccount = true;
|
||||||
|
this.sharesService
|
||||||
|
.getSharesAccountData(accountId, false)
|
||||||
|
.pipe(catchError(() => of(null)))
|
||||||
|
.subscribe((acc: any) => {
|
||||||
|
this.account = acc;
|
||||||
|
this.loadingAccount = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadReviewRequest(id: number): void {
|
||||||
|
this.closureService.getById(id).subscribe({
|
||||||
|
next: (req: any) => {
|
||||||
|
this.reviewRequest = req;
|
||||||
|
this.closedDate = req.closed_date ? String(req.closed_date).split('T')[0].split(' ')[0] : this.closedDate;
|
||||||
|
this.note = req.note ?? '';
|
||||||
|
|
||||||
|
if (req.activity_log) {
|
||||||
|
try {
|
||||||
|
this.activityLog = JSON.parse(req.activity_log);
|
||||||
|
} catch {
|
||||||
|
this.activityLog = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.sharesService
|
||||||
|
.getSharesAccountData(String(req.share_account_id), false)
|
||||||
|
.pipe(catchError(() => of(null)))
|
||||||
|
.subscribe((acc: any) => (this.account = acc));
|
||||||
|
},
|
||||||
|
error: () => this.snackBar.open('Failed to load share closure request.', 'Close', { duration: 5000 })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Review before Save" per requirement — opens the confirm dialog instead of submitting directly. */
|
||||||
|
openConfirmDialog(): void {
|
||||||
|
if (!this.isFormValid || this.submitting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.showConfirmDialog = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelConfirmDialog(): void {
|
||||||
|
this.showConfirmDialog = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmSubmit(): void {
|
||||||
|
this.showConfirmDialog = false;
|
||||||
|
this.submitting = true;
|
||||||
|
const payload = {
|
||||||
|
shareAccountId: Number(this.selectedAccountId),
|
||||||
|
accountNo: this.account?.accountNo,
|
||||||
|
clientId: this.account?.clientId,
|
||||||
|
clientName: this.account?.clientName,
|
||||||
|
closedDate: this.closedDate,
|
||||||
|
note: this.note
|
||||||
|
};
|
||||||
|
this.closureService.submit(payload).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.snackBar.open('Share closure request submitted for approval.', 'Close', { duration: 4000 });
|
||||||
|
if (this.embedded) {
|
||||||
|
this.actionCompleted.emit();
|
||||||
|
} else {
|
||||||
|
this.router.navigate(['/clerk/dashboard']);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.submitting = false;
|
||||||
|
this.snackBar.open(err?.error?.defaultUserMessage || 'Could not submit the request.', 'Close', {
|
||||||
|
duration: 6000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
openValidateModal(): void {
|
||||||
|
if (!this.reviewRequest) return;
|
||||||
|
this.showValidateModal = true;
|
||||||
|
this.runBackendValidation();
|
||||||
|
}
|
||||||
|
|
||||||
|
closeValidateModal(): void {
|
||||||
|
if (this.validating) return;
|
||||||
|
this.showValidateModal = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-checks live backend state (shares still zero, account still active, no active loan/guarantee,
|
||||||
|
* compliance standing) rather than relying on data already loaded in the browser — catches drift
|
||||||
|
* since submission. Results are revealed one step at a time for the pipeline-style animation. */
|
||||||
|
runBackendValidation(): void {
|
||||||
|
if (!this.reviewRequest || this.validating) return;
|
||||||
|
this.validating = true;
|
||||||
|
this.backendChecks = null;
|
||||||
|
this.validateSteps = [];
|
||||||
|
this.closureService.validate(this.reviewRequest.id).subscribe({
|
||||||
|
next: (res: any) => {
|
||||||
|
const checks = (res?.checks ?? []) as { label: string; pass: boolean; detail: string }[];
|
||||||
|
this.backendChecks = checks;
|
||||||
|
this.validateAllPassed = !!res?.allPassed;
|
||||||
|
this.validateSteps = checks.map((c) => ({ ...c, status: 'pending' as const }));
|
||||||
|
this.revealValidateSteps(0);
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.validating = false;
|
||||||
|
this.snackBar.open(err?.error?.defaultUserMessage || 'Could not run backend validation.', 'Close', {
|
||||||
|
duration: 6000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private revealValidateSteps(index: number): void {
|
||||||
|
if (index >= this.validateSteps.length) {
|
||||||
|
this.validating = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.validateSteps[index].status = 'active';
|
||||||
|
setTimeout(() => {
|
||||||
|
this.validateSteps[index].status = this.validateSteps[index].pass ? 'done' : 'failed';
|
||||||
|
this.revealValidateSteps(index + 1);
|
||||||
|
}, 320);
|
||||||
|
}
|
||||||
|
|
||||||
|
approveReview(): void {
|
||||||
|
if (!this.reviewRequest) return;
|
||||||
|
this.closureService.approve(this.reviewRequest.id).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.snackBar.open('Share closure approved — account closed.', 'Close', { duration: 6000 });
|
||||||
|
if (this.embedded) {
|
||||||
|
this.actionCompleted.emit();
|
||||||
|
} else {
|
||||||
|
this.router.navigate(['/checker-inbox-and-tasks/share-closure-requests']);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: (err) =>
|
||||||
|
this.snackBar.open(err?.error?.defaultUserMessage || 'Could not approve the request.', 'Close', {
|
||||||
|
duration: 6000
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
openRejectDialog(): void {
|
||||||
|
this.showRejectDialog = true;
|
||||||
|
this.rejectionReason = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmRejectReview(): void {
|
||||||
|
if (!this.reviewRequest || !this.rejectionReason.trim()) return;
|
||||||
|
this.closureService.reject(this.reviewRequest.id, this.rejectionReason.trim()).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.showRejectDialog = false;
|
||||||
|
this.snackBar.open('Share closure request rejected.', 'Close', { duration: 4000 });
|
||||||
|
if (this.embedded) {
|
||||||
|
this.actionCompleted.emit();
|
||||||
|
} else {
|
||||||
|
this.router.navigate(['/checker-inbox-and-tasks/share-closure-requests']);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: (err) =>
|
||||||
|
this.snackBar.open(err?.error?.defaultUserMessage || 'Could not reject the request.', 'Close', {
|
||||||
|
duration: 6000
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
goBack(): void {
|
||||||
|
if (this.embedded) {
|
||||||
|
this.closeRequested.emit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.router.navigate(this.isReview ? ['/checker-inbox-and-tasks/share-closure-requests'] : ['/clerk/dashboard']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,431 @@
|
|||||||
|
<!--
|
||||||
|
Copyright since 2025 Mifos Initiative
|
||||||
|
|
||||||
|
This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<div class="pg">
|
||||||
|
<header class="pg-head">
|
||||||
|
<div>
|
||||||
|
<div class="pg-eyebrow">{{ isReview ? 'CHECKER · APPROVAL' : 'CLERK · SHARE WITHDRAWAL' }}</div>
|
||||||
|
<h1 class="pg-title">{{ pageTitle }}</h1>
|
||||||
|
<p class="pg-sub">{{ pageSubtitle }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="pg-toolbar">
|
||||||
|
<button type="button" class="pg-btn" (click)="goBack()">
|
||||||
|
<mat-icon>arrow_back</mat-icon>
|
||||||
|
{{ isReview ? 'Back to Requests' : 'My Share Requests' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="pos-section-label">Share Account</div>
|
||||||
|
<div class="inv-form-grid" style="padding: 16px">
|
||||||
|
@if (!isReview) {
|
||||||
|
<label class="inv-form-field" style="grid-column: span 2">
|
||||||
|
<span>* Search Account No. / Client Name</span>
|
||||||
|
<mifosx-search-select
|
||||||
|
[options]="accountOptions"
|
||||||
|
[(ngModel)]="selectedAccountId"
|
||||||
|
(ngModelChange)="onAccountSelected($event)"
|
||||||
|
placeholder="Search share account no. or client name…"
|
||||||
|
></mifosx-search-select>
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
@if (account) {
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Account No</span>
|
||||||
|
<input type="text" [value]="account.accountNo" readonly />
|
||||||
|
</label>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Client</span>
|
||||||
|
<input type="text" [value]="account.clientName" readonly />
|
||||||
|
</label>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Shares Held</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
[value]="account.summary?.totalApprovedShares ?? account.totalApprovedShares ?? 0"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Unit Price</span>
|
||||||
|
<input type="text" [value]="'₹' + (unitPrice | number: '1.2-2')" readonly />
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
@if (loadingAccount) {
|
||||||
|
<div class="pg-empty">Loading account…</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (isReview && reviewRequest) {
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="pos-section-label">Client Compliance</div>
|
||||||
|
<div class="inv-form-grid" style="padding: 16px">
|
||||||
|
<div class="inv-form-field">
|
||||||
|
<span>KYC Status</span>
|
||||||
|
<span
|
||||||
|
class="swr-chip"
|
||||||
|
[class.swr-chip--ok]="reviewRequest.client_kyc_verified"
|
||||||
|
[class.swr-chip--warn]="!reviewRequest.client_kyc_verified"
|
||||||
|
>
|
||||||
|
{{ reviewRequest.client_kyc_status_label }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="inv-form-field">
|
||||||
|
<span>Risk Category</span>
|
||||||
|
<span
|
||||||
|
class="swr-chip"
|
||||||
|
[class.swr-chip--ok]="!reviewRequest.client_risk_high"
|
||||||
|
[class.swr-chip--warn]="reviewRequest.client_risk_high"
|
||||||
|
>
|
||||||
|
{{ reviewRequest.client_risk_category_label }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="inv-form-field">
|
||||||
|
<span>Open AML Alerts</span>
|
||||||
|
<span
|
||||||
|
class="swr-chip"
|
||||||
|
[class.swr-chip--ok]="!reviewRequest.client_open_aml_alerts"
|
||||||
|
[class.swr-chip--warn]="reviewRequest.client_open_aml_alerts > 0"
|
||||||
|
>
|
||||||
|
{{ reviewRequest.client_open_aml_alerts || 0 }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (account) {
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="pos-section-label">Withdrawal Details</div>
|
||||||
|
<div class="inv-form-grid" style="padding: 16px">
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>* Shares to Withdraw</span>
|
||||||
|
<input type="number" [(ngModel)]="requestedShares" min="1" [disabled]="isReview" />
|
||||||
|
</label>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>* Requested Date</span>
|
||||||
|
<input type="date" [(ngModel)]="requestedDate" [disabled]="isReview" />
|
||||||
|
</label>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Withdrawal Value</span>
|
||||||
|
<input type="text" [value]="'₹' + (withdrawalValue | number: '1.2-2')" readonly />
|
||||||
|
</label>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Remaining Shares After Withdrawal</span>
|
||||||
|
<input type="text" [value]="remainingShares" readonly />
|
||||||
|
</label>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>* Board Resolution Number</span>
|
||||||
|
<input type="text" [(ngModel)]="resolutionNumber" [disabled]="isReview" placeholder="e.g. BR-2026-014" />
|
||||||
|
</label>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>* Date of Meeting</span>
|
||||||
|
<input type="date" [(ngModel)]="meetingDate" [disabled]="isReview" />
|
||||||
|
</label>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>* Settlement Payment Type</span>
|
||||||
|
@if (!isReview) {
|
||||||
|
<select [(ngModel)]="settlementPaymentTypeId">
|
||||||
|
<option [ngValue]="null">Select…</option>
|
||||||
|
@for (pt of paymentTypeOptions; track pt.id) {
|
||||||
|
<option [ngValue]="pt.id">{{ pt.name }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
} @else {
|
||||||
|
<input type="text" [value]="settlementPaymentTypeName" readonly />
|
||||||
|
}
|
||||||
|
</label>
|
||||||
|
<label class="inv-form-field" style="grid-column: span 2">
|
||||||
|
<span>Description / Remarks</span>
|
||||||
|
<input type="text" [(ngModel)]="description" [disabled]="isReview" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!isReview) {
|
||||||
|
<div class="inv-form-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pg-btn pg-btn--primary"
|
||||||
|
[disabled]="!isFormValid || submitting"
|
||||||
|
(click)="openConfirmDialog()"
|
||||||
|
>
|
||||||
|
{{ submitting ? 'Submitting…' : 'Review & Submit' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (isReview && reviewRequest?.status === 'PENDING') {
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="pos-section-label">Verification</div>
|
||||||
|
<div class="swrv-trigger">
|
||||||
|
<div class="swrv-trigger__icon">
|
||||||
|
<mat-icon>fact_check</mat-icon>
|
||||||
|
</div>
|
||||||
|
<div class="swrv-trigger__text">
|
||||||
|
<div class="swrv-trigger__title">Run a live backend validation</div>
|
||||||
|
<div class="swrv-trigger__sub">
|
||||||
|
Re-checks account status, share balance, linked savings account and GL mapping before you approve.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="pg-btn pg-btn--primary" (click)="openValidateModal()">
|
||||||
|
<mat-icon>play_circle</mat-icon>
|
||||||
|
Validate Request
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="inv-form-actions">
|
||||||
|
<button type="button" class="pg-btn pg-btn--primary" (click)="approveReview()">
|
||||||
|
<mat-icon>check_circle</mat-icon>
|
||||||
|
Approve & Process Withdrawal
|
||||||
|
</button>
|
||||||
|
<button type="button" class="pg-btn pg-btn--danger" (click)="openRejectDialog()">
|
||||||
|
<mat-icon>cancel</mat-icon>
|
||||||
|
Reject
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (showValidateModal) {
|
||||||
|
<div class="swrv-modal">
|
||||||
|
<div class="swrv-modal__card">
|
||||||
|
<div class="swrv-modal__header">
|
||||||
|
<div class="swrv-modal__title">
|
||||||
|
<span class="swrv-modal__title-icon"><mat-icon>fact_check</mat-icon></span>
|
||||||
|
Validate Share Withdrawal Request
|
||||||
|
</div>
|
||||||
|
<button type="button" class="swrv-modal__close" [disabled]="validating" (click)="closeValidateModal()">
|
||||||
|
<mat-icon>close</mat-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="swrv-modal__body">
|
||||||
|
<p class="swrv-modal__intro">
|
||||||
|
Re-checking <strong>{{ requestedShares }} share(s)</strong> withdrawal from
|
||||||
|
<strong>{{ account?.accountNo }}</strong> ({{ account?.clientName }}) against the live account, savings link
|
||||||
|
and GL configuration — not just what was captured at submission time.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
@if (backendChecks && !validating && !validateAllPassed) {
|
||||||
|
<div class="swrv-modal__warn">
|
||||||
|
<mat-icon>warning</mat-icon>
|
||||||
|
<div>
|
||||||
|
<strong>Some checks need review before approving.</strong>
|
||||||
|
Scroll down to see which ones failed and why.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<ol class="swrv-steps">
|
||||||
|
@for (step of validateSteps; track step.label; let i = $index) {
|
||||||
|
<li
|
||||||
|
class="swrv-step"
|
||||||
|
[class.swrv-step--active]="step.status === 'active'"
|
||||||
|
[class.swrv-step--done]="step.status === 'done'"
|
||||||
|
[class.swrv-step--failed]="step.status === 'failed'"
|
||||||
|
>
|
||||||
|
<div class="swrv-step__indicator">
|
||||||
|
@if (step.status === 'done') {
|
||||||
|
<mat-icon>check</mat-icon>
|
||||||
|
} @else if (step.status === 'active') {
|
||||||
|
<mat-icon class="swrv-spin">autorenew</mat-icon>
|
||||||
|
} @else if (step.status === 'failed') {
|
||||||
|
<mat-icon>close</mat-icon>
|
||||||
|
} @else {
|
||||||
|
<span>{{ i + 1 }}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="swrv-step__content">
|
||||||
|
<div class="swrv-step__label">{{ step.label }}</div>
|
||||||
|
<div class="swrv-step__msg">{{ step.detail }}</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
@if (validating && validateSteps.length === 0) {
|
||||||
|
<div class="pg-empty">Connecting to server…</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (backendChecks && !validating && validateAllPassed) {
|
||||||
|
<div class="swrv-modal__result">
|
||||||
|
<mat-icon>verified</mat-icon>
|
||||||
|
<span>All checks passed — safe to approve.</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="swrv-modal__footer">
|
||||||
|
@if (!validating && backendChecks) {
|
||||||
|
<button type="button" class="pg-btn" (click)="runBackendValidation()">
|
||||||
|
<mat-icon>refresh</mat-icon>
|
||||||
|
Re-run
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
@if (!validating && backendChecks && validateAllPassed) {
|
||||||
|
<button type="button" class="pg-btn pg-btn--primary" (click)="closeValidateModal(); approveReview()">
|
||||||
|
<mat-icon>check_circle</mat-icon>
|
||||||
|
Approve & Process Withdrawal
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
<button type="button" class="pg-btn" [disabled]="validating" (click)="closeValidateModal()">
|
||||||
|
{{ validating ? 'Validating…' : 'Close' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (isReview && reviewRequest?.status === 'APPROVED') {
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="pos-section-label">Settlement</div>
|
||||||
|
<div class="inv-form-grid" style="padding: 16px">
|
||||||
|
@if (reviewRequest?.settlement_amount) {
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Amount Credited</span>
|
||||||
|
<input type="text" [value]="'₹' + (reviewRequest.settlement_amount | number: '1.2-2')" readonly />
|
||||||
|
</label>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Credited To (Linked Savings A/C)</span>
|
||||||
|
<input type="text" [value]="account?.savingsAccountNumber || '—'" readonly />
|
||||||
|
</label>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Savings Transaction Ref.</span>
|
||||||
|
<input type="text" [value]="reviewRequest.settlement_savings_transaction_id" readonly />
|
||||||
|
</label>
|
||||||
|
} @else {
|
||||||
|
<div class="pg-empty" style="grid-column: span 2">
|
||||||
|
Settlement is processed automatically on approval. Details aren't available when reopening an
|
||||||
|
already-approved request — check the linked savings account's transaction history to confirm.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (isReview && activityLog.length > 0) {
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="pos-section-label">Activity Log</div>
|
||||||
|
<table class="pg-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Timestamp</th>
|
||||||
|
<th>Action</th>
|
||||||
|
<th>User</th>
|
||||||
|
<th>Note</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (entry of activityLog; track entry.timestamp) {
|
||||||
|
<tr>
|
||||||
|
<td>{{ entry.timestamp | date: 'medium' }}</td>
|
||||||
|
<td>{{ entry.action }}</td>
|
||||||
|
<td>{{ entry.user }}</td>
|
||||||
|
<td>{{ entry.note || '—' }}</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (showConfirmDialog) {
|
||||||
|
<div class="swr-modal-backdrop">
|
||||||
|
<div class="swr-modal">
|
||||||
|
<h3>Confirm Share Withdrawal Request</h3>
|
||||||
|
<p class="pg-sub">Review the details below before submitting for Bank Manager approval.</p>
|
||||||
|
<table class="pg-table">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>Account No / Client</td>
|
||||||
|
<td>{{ account?.accountNo }} — {{ account?.clientName }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Shares to Withdraw</td>
|
||||||
|
<td>
|
||||||
|
{{ requestedShares }} @ ₹{{ unitPrice | number: '1.2-2' }} = ₹{{ withdrawalValue | number: '1.2-2' }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Remaining Shares After Withdrawal</td>
|
||||||
|
<td>{{ remainingShares }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Requested Date</td>
|
||||||
|
<td>{{ requestedDate }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Board Resolution Number</td>
|
||||||
|
<td>{{ resolutionNumber }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Date of Meeting</td>
|
||||||
|
<td>{{ meetingDate }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Settlement Payment Type</td>
|
||||||
|
<td>{{ settlementPaymentTypeName }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Linked Savings Account</td>
|
||||||
|
<td>{{ account?.savingsAccountNumber || 'None — settlement will not run automatically' }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="pos-section-label" style="margin-top: 12px">Validation Checks</div>
|
||||||
|
<table class="pg-table">
|
||||||
|
<tbody>
|
||||||
|
@for (check of verificationChecks; track check.label) {
|
||||||
|
<tr>
|
||||||
|
<td>{{ check.label }}</td>
|
||||||
|
<td [class.swr-check-pass]="check.pass" [class.swr-check-fail]="!check.pass">
|
||||||
|
{{ check.pass ? 'Pass — ' : 'Review — ' }}{{ check.detail }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="inv-form-actions">
|
||||||
|
<button type="button" class="pg-btn pg-btn--primary" [disabled]="submitting" (click)="confirmSubmit()">
|
||||||
|
{{ submitting ? 'Submitting…' : 'Confirm & Submit' }}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="pg-btn" (click)="cancelConfirmDialog()">Back to Edit</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (showRejectDialog) {
|
||||||
|
<div class="swr-modal-backdrop">
|
||||||
|
<div class="swr-modal">
|
||||||
|
<h3>Reject Share Withdrawal Request</h3>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>* Rejection Reason</span>
|
||||||
|
<textarea rows="3" [(ngModel)]="rejectionReason"></textarea>
|
||||||
|
</label>
|
||||||
|
<div class="inv-form-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pg-btn pg-btn--danger"
|
||||||
|
[disabled]="!rejectionReason.trim()"
|
||||||
|
(click)="confirmRejectReview()"
|
||||||
|
>
|
||||||
|
Confirm Reject
|
||||||
|
</button>
|
||||||
|
<button type="button" class="pg-btn" (click)="showRejectDialog = false">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,433 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.swr-modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgb(13 27 42 / 55%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swr-modal {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
width: 420px;
|
||||||
|
max-width: 90vw;
|
||||||
|
max-height: 85vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
box-shadow: 0 12px 32px rgb(15 23 42 / 20%);
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #0d1b2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
resize: vertical;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:has(table) {
|
||||||
|
width: 560px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.swr-check-pass {
|
||||||
|
color: #1a7f37;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swr-check-fail {
|
||||||
|
color: #b42318;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swr-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
width: fit-content;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
&--ok {
|
||||||
|
background: #eafaf0;
|
||||||
|
color: #1a7f37;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--warn {
|
||||||
|
background: #fdecea;
|
||||||
|
color: #b42318;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Verification trigger card ---------- */
|
||||||
|
.swrv-trigger {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
&__icon {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: linear-gradient(135deg, #3457d5, #6c8cff);
|
||||||
|
color: #fff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-shadow: 0 4px 12px rgb(52 87 213 / 30%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__text {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0d1b2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__sub {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #64748b;
|
||||||
|
margin-top: 2px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Validate pipeline modal ---------- */
|
||||||
|
@keyframes swrv-fade {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes swrv-zoom {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.95) translateY(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1) translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes swrv-spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.swrv-spin {
|
||||||
|
animation: swrv-spin 0.9s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swrv-modal {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
// No backdrop-filter: it would make this element a containing block for any position:fixed
|
||||||
|
// descendant (e.g. if this modal is ever nested inside another fixed popup, or gains one of its
|
||||||
|
// own later) — see the identical fix in share-withdrawal-requests.component.scss for the bug this
|
||||||
|
// caused. Extra opacity compensates for the loss of the blur look.
|
||||||
|
background: rgb(15 25 45 / 65%);
|
||||||
|
z-index: 1200;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
animation: swrv-fade 0.2s ease-out;
|
||||||
|
|
||||||
|
&__card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 520px;
|
||||||
|
max-height: 90vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 24px 60px rgb(0 0 0 / 25%);
|
||||||
|
animation: swrv-zoom 0.25s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 18px 22px;
|
||||||
|
border-bottom: 1px solid #eef1f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0d1b2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title-icon {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: linear-gradient(135deg, #3457d5, #6c8cff);
|
||||||
|
color: #fff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__close {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
background: #fff;
|
||||||
|
color: #64748b;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.12s;
|
||||||
|
|
||||||
|
&:not(:disabled):hover {
|
||||||
|
color: #b42318;
|
||||||
|
border-color: #b42318;
|
||||||
|
background: #fdecea;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__body {
|
||||||
|
padding: 20px 22px;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__intro {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #475569;
|
||||||
|
line-height: 1.55;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
color: #0d1b2a;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__warn {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: #fef3e2;
|
||||||
|
border: 1px solid rgb(217 119 6 / 30%);
|
||||||
|
border-left: 3px solid #d97706;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #633806;
|
||||||
|
line-height: 1.5;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
color: #d97706;
|
||||||
|
font-size: 18px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
strong {
|
||||||
|
display: block;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #4d2a05;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__result {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 6px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: #eafaf0;
|
||||||
|
border: 1px solid rgb(26 127 55 / 25%);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a7f37;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__footer {
|
||||||
|
padding: 16px 22px;
|
||||||
|
border-top: 1px solid #eef1f5;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.swrv-steps {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swrv-step {
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 10px 0;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&:not(:last-child)::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 15px;
|
||||||
|
top: 38px;
|
||||||
|
bottom: -4px;
|
||||||
|
width: 2px;
|
||||||
|
background: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__indicator {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #fff;
|
||||||
|
border: 2px solid #e2e8f0;
|
||||||
|
color: #64748b;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
transition: all 0.25s;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__content {
|
||||||
|
padding-top: 5px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__msg {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #94a3b8;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--active {
|
||||||
|
.swrv-step__indicator {
|
||||||
|
background: #3457d5;
|
||||||
|
border-color: #3457d5;
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 0 0 4px rgb(52 87 213 / 15%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.swrv-step__label {
|
||||||
|
color: #3457d5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--done {
|
||||||
|
.swrv-step__indicator {
|
||||||
|
background: #1a7f37;
|
||||||
|
border-color: #1a7f37;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
background: #1a7f37;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swrv-step__label {
|
||||||
|
color: #0d1b2a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--failed {
|
||||||
|
.swrv-step__indicator {
|
||||||
|
background: #b42318;
|
||||||
|
border-color: #b42318;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swrv-step__label {
|
||||||
|
color: #b42318;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.swrv-spin {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swrv-modal,
|
||||||
|
.swrv-modal__card {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,416 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, EventEmitter, Input, OnInit, Output, inject } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||||
|
import { catchError } from 'rxjs/operators';
|
||||||
|
import { of } from 'rxjs';
|
||||||
|
|
||||||
|
import { SharesService } from '../../shares/shares.service';
|
||||||
|
import { ClerkShareWithdrawalService } from '../../cashier/clerk-share-withdrawal.service';
|
||||||
|
import { CashierService } from '../../cashier/cashier.service';
|
||||||
|
import { SearchSelectComponent, SearchSelectOption } from '../../shared/search-select/search-select.component';
|
||||||
|
|
||||||
|
export type ShareWdMode = 'create' | 'review';
|
||||||
|
|
||||||
|
interface ActivityEntry {
|
||||||
|
timestamp: string;
|
||||||
|
action: string;
|
||||||
|
user: string;
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Share Withdrawal (redeem shares) request — maker screen in the Clerk Dashboard. A clerk finds
|
||||||
|
* the member's Share Account, enters how many shares to withdraw plus the governing Board
|
||||||
|
* Resolution Number / Date of Meeting, and submits for Bank Manager approval. This is the ONLY
|
||||||
|
* entry point a share withdrawal should go through — approval server-side executes the real
|
||||||
|
* "redeemshares" command (see ClerkShareWithdrawalRequestApiResource on the backend).
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-share-withdrawal-request',
|
||||||
|
templateUrl: './share-withdrawal-request.component.html',
|
||||||
|
styleUrls: [
|
||||||
|
'../../cashier/cashier-shared.scss',
|
||||||
|
'../clerk-shared.scss',
|
||||||
|
'./share-withdrawal-request.component.scss'
|
||||||
|
],
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
RouterModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatSnackBarModule,
|
||||||
|
SearchSelectComponent
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class ShareWithdrawalRequestComponent implements OnInit {
|
||||||
|
private route = inject(ActivatedRoute);
|
||||||
|
private router = inject(Router);
|
||||||
|
private sharesService = inject(SharesService);
|
||||||
|
private wdService = inject(ClerkShareWithdrawalService);
|
||||||
|
private cashierService = inject(CashierService);
|
||||||
|
private snackBar = inject(MatSnackBar);
|
||||||
|
|
||||||
|
/** When embedded (e.g. opened as a popup from a list screen instead of routed to directly), mode
|
||||||
|
* and requestId come from these inputs rather than the route, and success/close actions emit
|
||||||
|
* events instead of navigating away. */
|
||||||
|
@Input() embedded = false;
|
||||||
|
@Input() overrideMode: ShareWdMode | null = null;
|
||||||
|
@Input() overrideRequestId: number | null = null;
|
||||||
|
@Output() closeRequested = new EventEmitter<void>();
|
||||||
|
@Output() actionCompleted = new EventEmitter<void>();
|
||||||
|
|
||||||
|
mode: ShareWdMode = 'create';
|
||||||
|
requestId: number | null = null;
|
||||||
|
|
||||||
|
submitting = false;
|
||||||
|
loadingAccount = false;
|
||||||
|
|
||||||
|
selectedAccountId = '';
|
||||||
|
accountOptions: SearchSelectOption[] = [];
|
||||||
|
|
||||||
|
account: any = null;
|
||||||
|
|
||||||
|
requestedShares: number | null = null;
|
||||||
|
requestedDate = '';
|
||||||
|
resolutionNumber = '';
|
||||||
|
meetingDate = '';
|
||||||
|
description = '';
|
||||||
|
|
||||||
|
paymentTypes: any[] = [];
|
||||||
|
settlementPaymentTypeId: number | null = null;
|
||||||
|
|
||||||
|
activityLog: ActivityEntry[] = [];
|
||||||
|
reviewRequest: any = null;
|
||||||
|
showRejectDialog = false;
|
||||||
|
rejectionReason = '';
|
||||||
|
showConfirmDialog = false;
|
||||||
|
|
||||||
|
showValidateModal = false;
|
||||||
|
validating = false;
|
||||||
|
validateAllPassed = false;
|
||||||
|
backendChecks: { label: string; pass: boolean; detail: string }[] | null = null;
|
||||||
|
validateSteps: { label: string; detail: string; pass: boolean; status: 'pending' | 'active' | 'done' | 'failed' }[] =
|
||||||
|
[];
|
||||||
|
|
||||||
|
get remainingShares(): number {
|
||||||
|
const held = Number(this.account?.summary?.totalApprovedShares ?? this.account?.totalApprovedShares ?? 0);
|
||||||
|
return held - (this.requestedShares || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
get unitPrice(): number {
|
||||||
|
return Number(this.account?.currentMarketPrice ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
get withdrawalValue(): number {
|
||||||
|
return (this.requestedShares || 0) * this.unitPrice;
|
||||||
|
}
|
||||||
|
|
||||||
|
get paymentTypeOptions(): any[] {
|
||||||
|
return this.paymentTypes.filter((pt: any) => !pt.isCashPayment);
|
||||||
|
}
|
||||||
|
|
||||||
|
get selectedSettlementPaymentType(): any {
|
||||||
|
return this.paymentTypes.find((pt: any) => pt.id === this.settlementPaymentTypeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
get settlementPaymentTypeName(): string {
|
||||||
|
if (this.isReview) {
|
||||||
|
return this.reviewRequest?.settlement_payment_type_name || '—';
|
||||||
|
}
|
||||||
|
return this.selectedSettlementPaymentType?.name || '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
get isFormValid(): boolean {
|
||||||
|
if (!this.account) return false;
|
||||||
|
if (!this.requestedShares || this.requestedShares <= 0) return false;
|
||||||
|
const held = Number(this.account?.summary?.totalApprovedShares ?? this.account?.totalApprovedShares ?? 0);
|
||||||
|
if (this.requestedShares > held) return false;
|
||||||
|
if (!this.requestedDate) return false;
|
||||||
|
if (!this.resolutionNumber.trim()) return false;
|
||||||
|
if (!this.meetingDate) return false;
|
||||||
|
if (!this.settlementPaymentTypeId) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shown to the clerk before submit and to the manager before approval, so both can verify the same checks. */
|
||||||
|
get verificationChecks(): { label: string; pass: boolean; detail: string }[] {
|
||||||
|
const held = Number(this.account?.summary?.totalApprovedShares ?? this.account?.totalApprovedShares ?? 0);
|
||||||
|
const shares = this.requestedShares || 0;
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: 'Board Resolution Number provided',
|
||||||
|
pass: !!this.resolutionNumber?.trim(),
|
||||||
|
detail: this.resolutionNumber?.trim() || 'Missing'
|
||||||
|
},
|
||||||
|
{ label: 'Date of Meeting provided', pass: !!this.meetingDate, detail: this.meetingDate || 'Missing' },
|
||||||
|
{
|
||||||
|
label: 'Shares requested within shares held',
|
||||||
|
pass: shares > 0 && shares <= held,
|
||||||
|
detail: `${shares} of ${held} shares held`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Linked savings account confirmed',
|
||||||
|
pass: !!this.account?.savingsAccountNumber,
|
||||||
|
detail: this.account?.savingsAccountNumber || 'No linked savings account — settlement cannot run automatically'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Settlement Payment Type selected (non-cash)',
|
||||||
|
pass: this.settlementPaymentTypeName !== '—',
|
||||||
|
detail: this.settlementPaymentTypeName
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
get isReview(): boolean {
|
||||||
|
return this.mode === 'review';
|
||||||
|
}
|
||||||
|
|
||||||
|
get pageTitle(): string {
|
||||||
|
return this.isReview ? 'Review Share Withdrawal Request' : 'New Share Withdrawal Request';
|
||||||
|
}
|
||||||
|
|
||||||
|
get pageSubtitle(): string {
|
||||||
|
return this.isReview
|
||||||
|
? "Approve or reject the clerk's share withdrawal request."
|
||||||
|
: 'Submit a share withdrawal (redeem shares) request for Bank Manager approval.';
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.mode = this.overrideMode ?? (this.route.snapshot.data?.['mode'] as ShareWdMode) ?? 'create';
|
||||||
|
const idParam = this.overrideRequestId ?? this.route.snapshot.params?.['id'];
|
||||||
|
this.requestId = idParam ? Number(idParam) : null;
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
this.requestedDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
||||||
|
this.meetingDate = this.requestedDate;
|
||||||
|
|
||||||
|
this.sharesService
|
||||||
|
.getShareAccountSummaries()
|
||||||
|
.pipe(catchError(() => of([])))
|
||||||
|
.subscribe((rows: any[]) => {
|
||||||
|
this.accountOptions = (rows || [])
|
||||||
|
.filter((r) => (r.status?.value || '').toLowerCase() === 'active')
|
||||||
|
.map((r) => ({
|
||||||
|
value: String(r.id),
|
||||||
|
label: `${r.accountNo} — ${r.clientName} (${r.totalApprovedShares} shares)`,
|
||||||
|
searchText: `${r.accountNo} ${r.clientName}`
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
this.cashierService
|
||||||
|
.getPaymentTypes()
|
||||||
|
.pipe(catchError(() => of([])))
|
||||||
|
.subscribe((types: any[]) => (this.paymentTypes = types ?? []));
|
||||||
|
|
||||||
|
if (this.requestId && this.mode === 'review') {
|
||||||
|
this.loadReviewRequest(this.requestId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onAccountSelected(accountId: string): void {
|
||||||
|
this.selectedAccountId = accountId;
|
||||||
|
if (!accountId) {
|
||||||
|
this.account = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.loadingAccount = true;
|
||||||
|
this.sharesService
|
||||||
|
.getSharesAccountData(accountId, false)
|
||||||
|
.pipe(catchError(() => of(null)))
|
||||||
|
.subscribe((acc: any) => {
|
||||||
|
this.account = acc;
|
||||||
|
this.loadingAccount = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadReviewRequest(id: number): void {
|
||||||
|
this.wdService.getById(id).subscribe({
|
||||||
|
next: (req: any) => {
|
||||||
|
this.reviewRequest = req;
|
||||||
|
this.requestedShares = Number(req.requested_shares);
|
||||||
|
this.requestedDate = req.requested_date
|
||||||
|
? String(req.requested_date).split('T')[0].split(' ')[0]
|
||||||
|
: this.requestedDate;
|
||||||
|
this.resolutionNumber = req.resolution_number ?? '';
|
||||||
|
this.meetingDate = req.meeting_date ? String(req.meeting_date).split('T')[0].split(' ')[0] : this.meetingDate;
|
||||||
|
this.description = req.description ?? '';
|
||||||
|
|
||||||
|
if (req.activity_log) {
|
||||||
|
try {
|
||||||
|
this.activityLog = JSON.parse(req.activity_log);
|
||||||
|
} catch {
|
||||||
|
this.activityLog = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.sharesService
|
||||||
|
.getSharesAccountData(String(req.share_account_id), false)
|
||||||
|
.pipe(catchError(() => of(null)))
|
||||||
|
.subscribe((acc: any) => (this.account = acc));
|
||||||
|
},
|
||||||
|
error: () => this.snackBar.open('Failed to load share withdrawal request.', 'Close', { duration: 5000 })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Review before Save" per requirement — opens the confirm dialog instead of submitting directly. */
|
||||||
|
openConfirmDialog(): void {
|
||||||
|
if (!this.isFormValid || this.submitting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.showConfirmDialog = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelConfirmDialog(): void {
|
||||||
|
this.showConfirmDialog = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmSubmit(): void {
|
||||||
|
this.showConfirmDialog = false;
|
||||||
|
this.submitting = true;
|
||||||
|
const payload = {
|
||||||
|
shareAccountId: Number(this.selectedAccountId),
|
||||||
|
accountNo: this.account?.accountNo,
|
||||||
|
clientId: this.account?.clientId,
|
||||||
|
clientName: this.account?.clientName,
|
||||||
|
requestedShares: this.requestedShares,
|
||||||
|
requestedDate: this.requestedDate,
|
||||||
|
resolutionNumber: this.resolutionNumber.trim(),
|
||||||
|
meetingDate: this.meetingDate,
|
||||||
|
description: this.description,
|
||||||
|
settlementPaymentTypeId: this.settlementPaymentTypeId
|
||||||
|
};
|
||||||
|
this.wdService.submit(payload).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.snackBar.open('Share withdrawal request submitted for approval.', 'Close', { duration: 4000 });
|
||||||
|
if (this.embedded) {
|
||||||
|
this.actionCompleted.emit();
|
||||||
|
} else {
|
||||||
|
this.router.navigate(['/clerk/dashboard']);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.submitting = false;
|
||||||
|
this.snackBar.open(err?.error?.defaultUserMessage || 'Could not submit the request.', 'Close', {
|
||||||
|
duration: 6000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
openValidateModal(): void {
|
||||||
|
if (!this.reviewRequest) return;
|
||||||
|
this.showValidateModal = true;
|
||||||
|
this.runBackendValidation();
|
||||||
|
}
|
||||||
|
|
||||||
|
closeValidateModal(): void {
|
||||||
|
if (this.validating) return;
|
||||||
|
this.showValidateModal = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-checks live backend state (account still active, GL mapping still configured, etc.) rather
|
||||||
|
* than relying on data already loaded in the browser — catches drift since submission. Results
|
||||||
|
* are revealed one step at a time (see revealValidateSteps()) for the pipeline-style animation. */
|
||||||
|
runBackendValidation(): void {
|
||||||
|
if (!this.reviewRequest || this.validating) return;
|
||||||
|
this.validating = true;
|
||||||
|
this.backendChecks = null;
|
||||||
|
this.validateSteps = [];
|
||||||
|
this.wdService.validate(this.reviewRequest.id).subscribe({
|
||||||
|
next: (res: any) => {
|
||||||
|
const checks = (res?.checks ?? []) as { label: string; pass: boolean; detail: string }[];
|
||||||
|
this.backendChecks = checks;
|
||||||
|
this.validateAllPassed = !!res?.allPassed;
|
||||||
|
this.validateSteps = checks.map((c) => ({ ...c, status: 'pending' as const }));
|
||||||
|
this.revealValidateSteps(0);
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.validating = false;
|
||||||
|
this.snackBar.open(err?.error?.defaultUserMessage || 'Could not run backend validation.', 'Close', {
|
||||||
|
duration: 6000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private revealValidateSteps(index: number): void {
|
||||||
|
if (index >= this.validateSteps.length) {
|
||||||
|
this.validating = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.validateSteps[index].status = 'active';
|
||||||
|
setTimeout(() => {
|
||||||
|
this.validateSteps[index].status = this.validateSteps[index].pass ? 'done' : 'failed';
|
||||||
|
this.revealValidateSteps(index + 1);
|
||||||
|
}, 320);
|
||||||
|
}
|
||||||
|
|
||||||
|
approveReview(): void {
|
||||||
|
if (!this.reviewRequest) return;
|
||||||
|
this.wdService.approve(this.reviewRequest.id).subscribe({
|
||||||
|
next: (res: any) => {
|
||||||
|
let message =
|
||||||
|
res?.settlementAmount != null
|
||||||
|
? `Share withdrawal approved. ₹${Number(res.settlementAmount).toLocaleString('en-IN', { minimumFractionDigits: 2 })} credited to the linked savings account.`
|
||||||
|
: (res?.settlementWarning ?? 'Share withdrawal approved and processed.');
|
||||||
|
if (res?.autoClosureRequestId) {
|
||||||
|
message += ' All shares now withdrawn — a Share Closure Request was auto-submitted for manager review.';
|
||||||
|
}
|
||||||
|
this.snackBar.open(message, 'Close', { duration: 8000 });
|
||||||
|
if (this.embedded) {
|
||||||
|
this.actionCompleted.emit();
|
||||||
|
} else {
|
||||||
|
this.router.navigate(['/checker-inbox-and-tasks/share-withdrawal-requests']);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: (err) =>
|
||||||
|
this.snackBar.open(err?.error?.defaultUserMessage || 'Could not approve the request.', 'Close', {
|
||||||
|
duration: 6000
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
openRejectDialog(): void {
|
||||||
|
this.showRejectDialog = true;
|
||||||
|
this.rejectionReason = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmRejectReview(): void {
|
||||||
|
if (!this.reviewRequest || !this.rejectionReason.trim()) return;
|
||||||
|
this.wdService.reject(this.reviewRequest.id, this.rejectionReason.trim()).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.showRejectDialog = false;
|
||||||
|
this.snackBar.open('Share withdrawal request rejected.', 'Close', { duration: 4000 });
|
||||||
|
if (this.embedded) {
|
||||||
|
this.actionCompleted.emit();
|
||||||
|
} else {
|
||||||
|
this.router.navigate(['/checker-inbox-and-tasks/share-withdrawal-requests']);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: (err) =>
|
||||||
|
this.snackBar.open(err?.error?.defaultUserMessage || 'Could not reject the request.', 'Close', {
|
||||||
|
duration: 6000
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
goBack(): void {
|
||||||
|
if (this.embedded) {
|
||||||
|
this.closeRequested.emit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.router.navigate(this.isReview ? ['/checker-inbox-and-tasks/share-withdrawal-requests'] : ['/clerk/dashboard']);
|
||||||
|
}
|
||||||
|
}
|
||||||
+90
@@ -0,0 +1,90 @@
|
|||||||
|
<div class="fm-dialog">
|
||||||
|
<div class="fm-dialog__header" style="display: flex; align-items: center; gap: 8px;">
|
||||||
|
<mat-icon class="fm-dialog__icon">people</mat-icon>
|
||||||
|
<span style="font-weight: 500; font-size: 18px;">CHILDREN</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<mat-dialog-content>
|
||||||
|
<form [formGroup]="childrenForm">
|
||||||
|
<div formArrayName="children" style="margin-top: 12px; background: #fff; padding: 12px;">
|
||||||
|
@for (child of children.controls; track $index) {
|
||||||
|
<div [formGroupName]="$index"
|
||||||
|
style="position: relative; padding: 24px 16px 16px; margin-top: 16px; margin-bottom: 24px; border: 1px solid #e0e0e0; border-radius: 8px; background: #fff;">
|
||||||
|
<div class="fm-grid">
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Child Full Name*</mat-label>
|
||||||
|
<input matInput formControlName="fullName" required placeholder="Child Full Name*" />
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Gender*</mat-label>
|
||||||
|
<mat-select formControlName="genderId" required>
|
||||||
|
@for (g of genderIdOptions; track g.id) {
|
||||||
|
<mat-option [value]="g.id">{{ g.name }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" (click)="childDobPicker.open()">
|
||||||
|
<mat-label>Date of Birth</mat-label>
|
||||||
|
<input matInput [max]="maxDate" [matDatepicker]="childDobPicker" formControlName="dateOfBirth" />
|
||||||
|
<mat-datepicker-toggle matSuffix [for]="childDobPicker"></mat-datepicker-toggle>
|
||||||
|
<mat-datepicker #childDobPicker></mat-datepicker>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Nationality</mat-label>
|
||||||
|
<mat-select formControlName="nationality">
|
||||||
|
<mat-option value="INDIAN">Indian</mat-option>
|
||||||
|
<mat-option value="NRI">NRI</mat-option>
|
||||||
|
<mat-option value="FOREIGN_NATIONAL">Foreign National</mat-option>
|
||||||
|
<mat-option value="OTHER">Other</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Aadhaar Number (Optional)</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
formControlName="aadhaarNumber"
|
||||||
|
maxlength="12"
|
||||||
|
placeholder="12-digit Aadhaar"
|
||||||
|
(keypress)="$event.charCode >= 48 && $event.charCode <= 57 ? true : $event.preventDefault()"
|
||||||
|
/>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>PAN Number (Optional)</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
formControlName="panNumber"
|
||||||
|
maxlength="10"
|
||||||
|
placeholder="ABCDE1234F"
|
||||||
|
style="text-transform: uppercase"
|
||||||
|
(input)="
|
||||||
|
$any(children.at($index)).get('panNumber')
|
||||||
|
?.setValue($any($event.target).value.toUpperCase(), { emitEvent: false })
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
@if (
|
||||||
|
$any(children.at($index)).get('panNumber')?.hasError('pattern') && $any(children.at($index)).get('panNumber')?.touched
|
||||||
|
) {
|
||||||
|
<mat-error>Invalid PAN format</mat-error>
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@if ($index < children.controls.length - 1) { <div style="border-bottom: 1px dashed #ccc; margin-bottom: 16px;">
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
<div style="border-bottom: 1px dashed #ccc; margin-bottom: 16px;"></div>
|
||||||
|
<button mat-stroked-button type="button" color="primary" (click)="addChild()">
|
||||||
|
<mat-icon>add</mat-icon> Add Another Child
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</mat-dialog-content>
|
||||||
|
|
||||||
|
<mat-dialog-actions align="end">
|
||||||
|
<button mat-stroked-button mat-dialog-close type="button">Cancel</button>
|
||||||
|
<button mat-flat-button color="primary" type="button" [disabled]="childrenForm.invalid" (click)="submit()">
|
||||||
|
Add Member
|
||||||
|
</button>
|
||||||
|
</mat-dialog-actions>
|
||||||
|
</div>
|
||||||
+98
@@ -0,0 +1,98 @@
|
|||||||
|
import { Component, Inject, OnInit } from '@angular/core';
|
||||||
|
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||||
|
import { UntypedFormBuilder, UntypedFormGroup, UntypedFormArray, Validators } from '@angular/forms';
|
||||||
|
|
||||||
|
import { MatIcon } from '@angular/material/icon';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatInputModule } from '@angular/material/input';
|
||||||
|
import { MatSelectModule } from '@angular/material/select';
|
||||||
|
import { MatDatepickerModule } from '@angular/material/datepicker';
|
||||||
|
import { MatDialogModule } from '@angular/material/dialog';
|
||||||
|
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||||
|
import { ReactiveFormsModule } from '@angular/forms';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'cbs-client-family-member-child-dialog',
|
||||||
|
templateUrl: './client-family-member-child-dialog.component.html',
|
||||||
|
styleUrls: ['../client-family-member-dialog/client-family-member-dialog.component.scss'],
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
MatDialogModule,
|
||||||
|
MatIcon,
|
||||||
|
MatButtonModule,
|
||||||
|
MatInputModule,
|
||||||
|
MatSelectModule,
|
||||||
|
MatDatepickerModule,
|
||||||
|
MatTooltipModule
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class ClientFamilyMemberChildDialogComponent implements OnInit {
|
||||||
|
|
||||||
|
childrenForm: UntypedFormGroup;
|
||||||
|
genderIdOptions: any[];
|
||||||
|
maxDate: Date = new Date();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public dialogRef: MatDialogRef<ClientFamilyMemberChildDialogComponent>,
|
||||||
|
@Inject(MAT_DIALOG_DATA) public data: any,
|
||||||
|
private formBuilder: UntypedFormBuilder
|
||||||
|
) {
|
||||||
|
this.genderIdOptions = data.genderIdOptions;
|
||||||
|
|
||||||
|
this.childrenForm = this.formBuilder.group({
|
||||||
|
children: this.formBuilder.array([])
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
if (this.data.children && this.data.children.length > 0) {
|
||||||
|
this.data.children.forEach((child: any) => {
|
||||||
|
this.children.push(this.formBuilder.group({
|
||||||
|
id: [child.id],
|
||||||
|
fullName: [child.fullName, Validators.required],
|
||||||
|
genderId: [child.genderId, Validators.required],
|
||||||
|
dateOfBirth: [child.dateOfBirth ? new Date(child.dateOfBirth) : ''],
|
||||||
|
nationality: [child.nationality || 'INDIAN'],
|
||||||
|
aadhaarNumber: [child.aadhaarNumber || ''],
|
||||||
|
panNumber: [child.panNumber || '', Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)]
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.addChild();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get children(): UntypedFormArray {
|
||||||
|
return this.childrenForm.get('children') as UntypedFormArray;
|
||||||
|
}
|
||||||
|
|
||||||
|
createChildFormGroup(): UntypedFormGroup {
|
||||||
|
return this.formBuilder.group({
|
||||||
|
id: [''],
|
||||||
|
fullName: ['', Validators.required],
|
||||||
|
genderId: ['', Validators.required],
|
||||||
|
dateOfBirth: [''],
|
||||||
|
nationality: ['INDIAN'],
|
||||||
|
aadhaarNumber: [''],
|
||||||
|
panNumber: ['', Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
addChild(): void {
|
||||||
|
this.children.push(this.createChildFormGroup());
|
||||||
|
}
|
||||||
|
|
||||||
|
removeChild(index: number): void {
|
||||||
|
this.children.removeAt(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
submit(): void {
|
||||||
|
if (this.childrenForm.valid) {
|
||||||
|
this.dialogRef.close(this.children.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
+28
@@ -139,6 +139,21 @@
|
|||||||
<span class="fm-readonly-value">{{ familyMemberForm.get('remarks')?.value }}</span>
|
<span class="fm-readonly-value">{{ familyMemberForm.get('remarks')?.value }}</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
@if (isSpouse && familyMemberForm.get('children')?.value?.length > 0) {
|
||||||
|
<div class="fm-readonly-remarks">
|
||||||
|
<span class="fm-readonly-label">Children</span>
|
||||||
|
<div class="fm-children-list">
|
||||||
|
@for (child of familyMemberForm.get('children')?.value; track $index) {
|
||||||
|
<div class="fm-child-item" style="font-size: 13px; color: #555; margin-top: 4px;">
|
||||||
|
<span>{{ child.fullName }}</span>
|
||||||
|
@if (child.dateOfBirth) {
|
||||||
|
<span> · DOB: {{ child.dateOfBirth | date: 'dd MMM yyyy' }}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- ═══ ADD / EDIT MODE ═══ -->
|
<!-- ═══ ADD / EDIT MODE ═══ -->
|
||||||
@@ -447,6 +462,19 @@
|
|||||||
<mat-label>Remarks</mat-label>
|
<mat-label>Remarks</mat-label>
|
||||||
<textarea matInput formControlName="remarks" rows="2"></textarea>
|
<textarea matInput formControlName="remarks" rows="2"></textarea>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
|
|
||||||
|
<!-- Children Section -->
|
||||||
|
@if (isSpouse) {
|
||||||
|
<div class="fm-section-label" style="margin-top: 16px;">Children</div>
|
||||||
|
<div class="fm-checkbox-row" style="display: flex; align-items: center; justify-content: space-between;">
|
||||||
|
<mat-checkbox formControlName="hasChildren">Does this family member have a child/children?</mat-checkbox>
|
||||||
|
@if (familyMemberForm.get('hasChildren')?.value) {
|
||||||
|
<button mat-stroked-button type="button" color="primary" (click)="openChildrenDialog()" style="margin-left: 16px;">
|
||||||
|
<mat-icon>edit</mat-icon> Edit Children Details ({{ children.length }})
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</form>
|
</form>
|
||||||
</mat-dialog-content>
|
</mat-dialog-content>
|
||||||
|
|||||||
+115
-2
@@ -17,7 +17,7 @@ import {
|
|||||||
MatDialogClose,
|
MatDialogClose,
|
||||||
MatDialogContent
|
MatDialogContent
|
||||||
} from '@angular/material/dialog';
|
} from '@angular/material/dialog';
|
||||||
import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms';
|
import { UntypedFormGroup, UntypedFormBuilder, UntypedFormArray, Validators } from '@angular/forms';
|
||||||
import { SettingsService } from 'app/settings/settings.service';
|
import { SettingsService } from 'app/settings/settings.service';
|
||||||
import { Dates } from 'app/core/utils/dates';
|
import { Dates } from 'app/core/utils/dates';
|
||||||
import { ClientsService } from 'app/clients/clients.service';
|
import { ClientsService } from 'app/clients/clients.service';
|
||||||
@@ -58,6 +58,8 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
|
|||||||
private dialog = inject(MatDialog);
|
private dialog = inject(MatDialog);
|
||||||
|
|
||||||
maxDate = new Date();
|
maxDate = new Date();
|
||||||
|
|
||||||
|
hasChildrenChecked = false;
|
||||||
familyMemberForm: UntypedFormGroup;
|
familyMemberForm: UntypedFormGroup;
|
||||||
|
|
||||||
/** Client search */
|
/** Client search */
|
||||||
@@ -197,6 +199,23 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
|
|||||||
// Re-fetch linked client details (profile, signature, KYC status)
|
// Re-fetch linked client details (profile, signature, KYC status)
|
||||||
this.fetchLinkedClientData(m.linkedClientId.toString());
|
this.fetchLinkedClientData(m.linkedClientId.toString());
|
||||||
}
|
}
|
||||||
|
if (m.children && m.children.length > 0) {
|
||||||
|
this.hasChildrenChecked = true;
|
||||||
|
this.familyMemberForm.patchValue({ hasChildren: true });
|
||||||
|
m.children.forEach((child: any) => {
|
||||||
|
this.children.push(
|
||||||
|
this.formBuilder.group({
|
||||||
|
id: [child.id],
|
||||||
|
fullName: [child.fullName, Validators.required],
|
||||||
|
genderId: [child.genderId, Validators.required],
|
||||||
|
dateOfBirth: [child.dateOfBirth ? new Date(child.dateOfBirth) : ''],
|
||||||
|
nationality: [child.nationality || 'INDIAN'],
|
||||||
|
aadhaarNumber: [child.aadhaarNumber || ''],
|
||||||
|
panNumber: [child.panNumber || '', Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)]
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
if (this.isViewMode) {
|
if (this.isViewMode) {
|
||||||
this.familyMemberForm.disable();
|
this.familyMemberForm.disable();
|
||||||
}
|
}
|
||||||
@@ -225,8 +244,83 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
|
|||||||
isAlive: [true],
|
isAlive: [true],
|
||||||
marriageDate: [''],
|
marriageDate: [''],
|
||||||
marriageAuthority: [''],
|
marriageAuthority: [''],
|
||||||
linkedClientId: ['']
|
linkedClientId: [''],
|
||||||
|
hasChildren: [false],
|
||||||
|
children: this.formBuilder.array([])
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.familyMemberForm.get('hasChildren')?.valueChanges.subscribe(value => {
|
||||||
|
if (value && !this.hasChildrenChecked) {
|
||||||
|
this.openChildrenDialog();
|
||||||
|
} else if (!value) {
|
||||||
|
this.hasChildrenChecked = false;
|
||||||
|
this.children.clear();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get children(): UntypedFormArray {
|
||||||
|
return this.familyMemberForm.get('children') as UntypedFormArray;
|
||||||
|
}
|
||||||
|
|
||||||
|
async openChildrenDialog() {
|
||||||
|
// dynamically import the dialog to avoid circular dependency issues if any
|
||||||
|
const { ClientFamilyMemberChildDialogComponent } = await import('../client-family-member-child-dialog/client-family-member-child-dialog.component');
|
||||||
|
|
||||||
|
const dialogRef = this.dialog.open(ClientFamilyMemberChildDialogComponent, {
|
||||||
|
data: {
|
||||||
|
children: this.children.value,
|
||||||
|
genderIdOptions: this.data.options.genderIdOptions
|
||||||
|
},
|
||||||
|
width: '500px'
|
||||||
|
});
|
||||||
|
|
||||||
|
dialogRef.afterClosed().subscribe((result: any[]) => {
|
||||||
|
if (result) {
|
||||||
|
this.children.clear();
|
||||||
|
result.forEach(child => {
|
||||||
|
this.children.push(this.formBuilder.group({
|
||||||
|
id: [child.id],
|
||||||
|
fullName: [child.fullName, Validators.required],
|
||||||
|
genderId: [child.genderId, Validators.required],
|
||||||
|
dateOfBirth: [child.dateOfBirth],
|
||||||
|
nationality: [child.nationality || 'INDIAN'],
|
||||||
|
aadhaarNumber: [child.aadhaarNumber || ''],
|
||||||
|
panNumber: [child.panNumber || '', Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)]
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
this.hasChildrenChecked = true;
|
||||||
|
this.familyMemberForm.patchValue({ hasChildren: true }, { emitEvent: false });
|
||||||
|
} else {
|
||||||
|
if (this.children.length === 0) {
|
||||||
|
this.hasChildrenChecked = false;
|
||||||
|
this.familyMemberForm.patchValue({ hasChildren: false }, { emitEvent: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
createChildFormGroup(): UntypedFormGroup {
|
||||||
|
return this.formBuilder.group({
|
||||||
|
id: [''],
|
||||||
|
fullName: ['', Validators.required],
|
||||||
|
genderId: ['', Validators.required],
|
||||||
|
dateOfBirth: [''],
|
||||||
|
nationality: ['INDIAN'],
|
||||||
|
aadhaarNumber: [''],
|
||||||
|
panNumber: ['', Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
addChild(): void {
|
||||||
|
this.children.push(this.createChildFormGroup());
|
||||||
|
}
|
||||||
|
|
||||||
|
removeChild(index: number): void {
|
||||||
|
this.children.removeAt(index);
|
||||||
|
if (this.children.length === 0) {
|
||||||
|
this.familyMemberForm.patchValue({ hasChildren: false });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Handle marriage certificate file selection */
|
/** Handle marriage certificate file selection */
|
||||||
@@ -481,6 +575,25 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
|
|||||||
member._linkedClient = this.selectedClient;
|
member._linkedClient = this.selectedClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Format children dates
|
||||||
|
if (this.isSpouse && member.children && member.children.length > 0) {
|
||||||
|
member.children = member.children.map((child: any) => {
|
||||||
|
const c = { ...child };
|
||||||
|
if (c.dateOfBirth instanceof Date) {
|
||||||
|
c.dateOfBirth = this.dateUtils.formatDate(c.dateOfBirth, dateFormat);
|
||||||
|
} else if (!c.dateOfBirth) {
|
||||||
|
delete c.dateOfBirth;
|
||||||
|
}
|
||||||
|
if (c.panNumber) {
|
||||||
|
c.panNumber = c.panNumber.toUpperCase();
|
||||||
|
}
|
||||||
|
return c;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
delete member.children;
|
||||||
|
}
|
||||||
|
delete member.hasChildren;
|
||||||
|
|
||||||
// Attach marriage certificate file if present
|
// Attach marriage certificate file if present
|
||||||
if (this.marriageCertFile) {
|
if (this.marriageCertFile) {
|
||||||
member._marriageCertFile = this.marriageCertFile;
|
member._marriageCertFile = this.marriageCertFile;
|
||||||
|
|||||||
+16
@@ -62,6 +62,22 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<!-- Mother requirement — shown independently of the Father/Caretaker check below -->
|
||||||
|
@if (!nomineeOnly && clientFamilyMembers.length > 0 && !hasMother) {
|
||||||
|
<div class="fm-step__empty fm-step__empty--required">
|
||||||
|
<mat-icon class="fm-step__empty-icon">error_outline</mat-icon>
|
||||||
|
<span class="fm-step__empty-required">Mother is required.</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Father-or-Caretaker requirement — shown independently of the Mother check above -->
|
||||||
|
@if (!nomineeOnly && clientFamilyMembers.length > 0 && !hasFather && !hasCaretaker) {
|
||||||
|
<div class="fm-step__empty fm-step__empty--required">
|
||||||
|
<mat-icon class="fm-step__empty-icon">error_outline</mat-icon>
|
||||||
|
<span class="fm-step__empty-required">Either Father or a Caretaker is required.</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<!-- Spouse hint for married clients -->
|
<!-- Spouse hint for married clients -->
|
||||||
@if (!nomineeOnly && isMarried && !hasSpouse) {
|
@if (!nomineeOnly && isMarried && !hasSpouse) {
|
||||||
<div class="fm-spouse-hint">
|
<div class="fm-spouse-hint">
|
||||||
|
|||||||
+44
@@ -12,6 +12,7 @@ import { TranslateService } from '@ngx-translate/core';
|
|||||||
import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component';
|
import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component';
|
||||||
import { ClientFamilyMemberDialogComponent } from './client-family-member-dialog/client-family-member-dialog.component';
|
import { ClientFamilyMemberDialogComponent } from './client-family-member-dialog/client-family-member-dialog.component';
|
||||||
import { ImageZoomDialogComponent } from './client-family-member-dialog/client-family-member-dialog.component';
|
import { ImageZoomDialogComponent } from './client-family-member-dialog/client-family-member-dialog.component';
|
||||||
|
import { FamilyMemberAddedDialogComponent } from './family-member-added-dialog.component';
|
||||||
import { ClientsService } from '../../clients.service';
|
import { ClientsService } from '../../clients.service';
|
||||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
import { MatIcon } from '@angular/material/icon';
|
import { MatIcon } from '@angular/material/icon';
|
||||||
@@ -152,6 +153,22 @@ export class ClientFamilyMembersStepComponent implements OnInit {
|
|||||||
ref.afterClosed().subscribe((response: any) => {
|
ref.afterClosed().subscribe((response: any) => {
|
||||||
if (response?.member) {
|
if (response?.member) {
|
||||||
this.clientFamilyMembers.push(response.member);
|
this.clientFamilyMembers.push(response.member);
|
||||||
|
this.showFamilyMemberAddedConfirmation(response.member);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Confirms what was just recorded — in particular the linked client's own CBS Client ID,
|
||||||
|
* since the "Add Member" dialog closes silently otherwise and gives no acknowledgement. */
|
||||||
|
private showFamilyMemberAddedConfirmation(member: any): void {
|
||||||
|
this.dialog.open(FamilyMemberAddedDialogComponent, {
|
||||||
|
width: '360px',
|
||||||
|
data: {
|
||||||
|
clientId: member._linkedClient?.id ?? member.linkedClientId ?? null,
|
||||||
|
name: member._linkedClient?.displayName || member.firstName,
|
||||||
|
relationship: this.getRelName(member.relationshipId),
|
||||||
|
dateOfBirth: member.dateOfBirth,
|
||||||
|
isDependent: member.isDependent
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -223,6 +240,33 @@ export class ClientFamilyMembersStepComponent implements OnInit {
|
|||||||
return this.clientFamilyMembers.length > 0;
|
return this.clientFamilyMembers.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** True if a family member with the given relationship (case-insensitive) has been added */
|
||||||
|
private hasRelationship(name: string): boolean {
|
||||||
|
const target = name.toLowerCase();
|
||||||
|
return this.clientFamilyMembers.some((m: any) => {
|
||||||
|
const resolved = this.getRelName(m.relationshipId ?? m.relationship).toLowerCase();
|
||||||
|
if (resolved === target) return true;
|
||||||
|
return typeof m.relationship === 'string' && m.relationship.toLowerCase() === target;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasMother(): boolean {
|
||||||
|
return this.hasRelationship('mother');
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasFather(): boolean {
|
||||||
|
return this.hasRelationship('father');
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasCaretaker(): boolean {
|
||||||
|
return this.hasRelationship('caretaker');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mother is always required; Father is required unless a Caretaker has been added instead. */
|
||||||
|
get familyRelationshipRulesMet(): boolean {
|
||||||
|
return this.hasMother && (this.hasFather || this.hasCaretaker);
|
||||||
|
}
|
||||||
|
|
||||||
/** Check if client's marital status is "Married" */
|
/** Check if client's marital status is "Married" */
|
||||||
get isMarried(): boolean {
|
get isMarried(): boolean {
|
||||||
if (!this.maritalStatusId || !this.maritalStatusOptions?.length) return false;
|
if (!this.maritalStatusId || !this.maritalStatusOptions?.length) return false;
|
||||||
|
|||||||
+145
@@ -0,0 +1,145 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, inject } from '@angular/core';
|
||||||
|
import { MatDialogRef, MAT_DIALOG_DATA, MatDialogTitle, MatDialogActions } from '@angular/material/dialog';
|
||||||
|
import { MatIcon } from '@angular/material/icon';
|
||||||
|
import { MatButton } from '@angular/material/button';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirmation popup shown right after a family member is added — surfaces the linked
|
||||||
|
* client's own CBS Client ID (when the member was linked to an existing client rather than
|
||||||
|
* entered manually) alongside name/relationship, since the "Add Member" dialog itself just
|
||||||
|
* closed silently before this with no acknowledgement of what got recorded.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-family-member-added-dialog',
|
||||||
|
imports: [
|
||||||
|
MatDialogTitle,
|
||||||
|
MatDialogActions,
|
||||||
|
MatIcon,
|
||||||
|
MatButton
|
||||||
|
],
|
||||||
|
template: `
|
||||||
|
<h2 mat-dialog-title class="fma-title">
|
||||||
|
<mat-icon class="fma-title__icon">check_circle</mat-icon>
|
||||||
|
Family Member Added
|
||||||
|
</h2>
|
||||||
|
<div class="fma-body">
|
||||||
|
@if (data.clientId) {
|
||||||
|
<div class="fma-row fma-row--id">
|
||||||
|
<span class="fma-row__label">Client ID</span>
|
||||||
|
<span class="fma-row__value fma-row__value--id">{{ data.clientId }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="fma-row">
|
||||||
|
<span class="fma-row__label">Name</span>
|
||||||
|
<span class="fma-row__value">{{ data.name }}</span>
|
||||||
|
</div>
|
||||||
|
@if (data.relationship) {
|
||||||
|
<div class="fma-row">
|
||||||
|
<span class="fma-row__label">Relationship</span>
|
||||||
|
<span class="fma-row__value">{{ data.relationship }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (data.dateOfBirth) {
|
||||||
|
<div class="fma-row">
|
||||||
|
<span class="fma-row__label">Date of Birth</span>
|
||||||
|
<span class="fma-row__value">{{ data.dateOfBirth }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (data.isDependent) {
|
||||||
|
<div class="fma-row">
|
||||||
|
<span class="fma-row__label">Dependent</span>
|
||||||
|
<span class="fma-row__value">Yes</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<mat-dialog-actions align="end">
|
||||||
|
<button mat-flat-button color="primary" (click)="dialogRef.close()">Done</button>
|
||||||
|
</mat-dialog-actions>
|
||||||
|
`,
|
||||||
|
styles: [
|
||||||
|
`
|
||||||
|
.fma-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f172a;
|
||||||
|
margin: 0;
|
||||||
|
padding: 20px 24px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fma-title__icon {
|
||||||
|
color: #059669;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fma-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 12px 24px 8px;
|
||||||
|
min-width: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fma-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px solid #f1f5f9;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fma-row__label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #64748b;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fma-row__value {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #0f172a;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fma-row--id {
|
||||||
|
background: #eff6ff;
|
||||||
|
margin: 0 -24px;
|
||||||
|
padding: 10px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fma-row__value--id {
|
||||||
|
font-family: 'Roboto Mono', monospace;
|
||||||
|
color: #1353a0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
mat-dialog-actions {
|
||||||
|
padding: 8px 24px 16px;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class FamilyMemberAddedDialogComponent {
|
||||||
|
dialogRef = inject<MatDialogRef<FamilyMemberAddedDialogComponent>>(MatDialogRef);
|
||||||
|
data: {
|
||||||
|
clientId?: string | number | null;
|
||||||
|
name: string;
|
||||||
|
relationship?: string;
|
||||||
|
dateOfBirth?: string;
|
||||||
|
isDependent?: boolean;
|
||||||
|
} = inject(MAT_DIALOG_DATA);
|
||||||
|
}
|
||||||
+197
-70
@@ -136,7 +136,7 @@
|
|||||||
| translate
|
| translate
|
||||||
}}
|
}}
|
||||||
</mat-label>
|
</mat-label>
|
||||||
<input matInput required formControlName="fullname" />
|
<input matInput required formControlName="fullname" autocomplete="off" />
|
||||||
@if (createClientForm.controls.fullname.hasError('required')) {
|
@if (createClientForm.controls.fullname.hasError('required')) {
|
||||||
<mat-error>Client name is <strong>required</strong></mat-error>
|
<mat-error>Client name is <strong>required</strong></mat-error>
|
||||||
} @else if (createClientForm.controls.fullname.hasError('pattern')) {
|
} @else if (createClientForm.controls.fullname.hasError('pattern')) {
|
||||||
@@ -214,7 +214,7 @@
|
|||||||
<mat-form-field appearance="outline" class="name-field first-name">
|
<mat-form-field appearance="outline" class="name-field first-name">
|
||||||
<mat-label>{{ 'labels.inputs.First Name' | translate }}</mat-label>
|
<mat-label>{{ 'labels.inputs.First Name' | translate }}</mat-label>
|
||||||
<span matPrefix class="field-prefix-icon"><mat-icon>badge</mat-icon></span>
|
<span matPrefix class="field-prefix-icon"><mat-icon>badge</mat-icon></span>
|
||||||
<input matInput required formControlName="firstname" />
|
<input matInput required formControlName="firstname" autocomplete="off" />
|
||||||
@if (createClientForm.controls.firstname.hasError('required')) {
|
@if (createClientForm.controls.firstname.hasError('required')) {
|
||||||
<mat-error>First name is <strong>required</strong></mat-error>
|
<mat-error>First name is <strong>required</strong></mat-error>
|
||||||
} @else if (createClientForm.controls.firstname.hasError('pattern')) {
|
} @else if (createClientForm.controls.firstname.hasError('pattern')) {
|
||||||
@@ -228,7 +228,7 @@
|
|||||||
<mat-form-field appearance="outline" class="name-field middle-name">
|
<mat-form-field appearance="outline" class="name-field middle-name">
|
||||||
<mat-label>{{ 'labels.inputs.Middle Name' | translate }}</mat-label>
|
<mat-label>{{ 'labels.inputs.Middle Name' | translate }}</mat-label>
|
||||||
<span matPrefix class="field-prefix-icon"><mat-icon>person_outline</mat-icon></span>
|
<span matPrefix class="field-prefix-icon"><mat-icon>person_outline</mat-icon></span>
|
||||||
<input matInput formControlName="middlename" />
|
<input matInput formControlName="middlename" autocomplete="off" />
|
||||||
@if (
|
@if (
|
||||||
createClientForm.controls.middlename?.hasError('pattern') &&
|
createClientForm.controls.middlename?.hasError('pattern') &&
|
||||||
createClientForm.controls.middlename?.touched
|
createClientForm.controls.middlename?.touched
|
||||||
@@ -241,7 +241,7 @@
|
|||||||
<mat-form-field appearance="outline" class="name-field last-name">
|
<mat-form-field appearance="outline" class="name-field last-name">
|
||||||
<mat-label>{{ 'labels.inputs.Last Name' | translate }}</mat-label>
|
<mat-label>{{ 'labels.inputs.Last Name' | translate }}</mat-label>
|
||||||
<span matPrefix class="field-prefix-icon"><mat-icon>person</mat-icon></span>
|
<span matPrefix class="field-prefix-icon"><mat-icon>person</mat-icon></span>
|
||||||
<input matInput required formControlName="lastname" />
|
<input matInput required formControlName="lastname" autocomplete="off" />
|
||||||
@if (createClientForm.controls.lastname.hasError('required')) {
|
@if (createClientForm.controls.lastname.hasError('required')) {
|
||||||
<mat-error>Last name is <strong>required</strong></mat-error>
|
<mat-error>Last name is <strong>required</strong></mat-error>
|
||||||
} @else if (createClientForm.controls.lastname.hasError('pattern')) {
|
} @else if (createClientForm.controls.lastname.hasError('pattern')) {
|
||||||
@@ -358,11 +358,14 @@
|
|||||||
@if (isMinor) {
|
@if (isMinor) {
|
||||||
<span class="minor-badge">Minor</span>
|
<span class="minor-badge">Minor</span>
|
||||||
}
|
}
|
||||||
|
@if (isSeniorCitizen) {
|
||||||
|
<span class="senior-citizen-badge">Senior Citizen</span>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Minor / Guardian Mapping (mandatory if age < 10) -->
|
<!-- Minor / Guardian Mapping (mandatory if age < 18) -->
|
||||||
@if (isMinor && createClientForm.get('legalFormId')?.value === LegalFormId.PERSON) {
|
@if (isMinor && createClientForm.get('legalFormId')?.value === LegalFormId.PERSON) {
|
||||||
<div class="guardian-section">
|
<div class="guardian-section">
|
||||||
<!-- Header bar -->
|
<!-- Header bar -->
|
||||||
@@ -1032,69 +1035,67 @@
|
|||||||
Education & Social Details
|
Education & Social Details
|
||||||
</h4>
|
</h4>
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<!-- Education Level (non-minor) -->
|
<!-- Education Level -->
|
||||||
@if (!isMinor) {
|
<mat-form-field appearance="outline" class="form-field">
|
||||||
<mat-form-field appearance="outline" class="form-field">
|
<mat-label>Education Level</mat-label>
|
||||||
<mat-label>Education Level</mat-label>
|
<mat-select formControlName="educationLevelId">
|
||||||
<mat-select formControlName="educationLevelId">
|
<mat-select-trigger>
|
||||||
<mat-select-trigger>
|
@if (getSelectedEducationLevel()) {
|
||||||
@if (getSelectedEducationLevel()) {
|
<span class="icon-option">
|
||||||
<span class="icon-option">
|
<mat-icon class="icon-option-icon">{{
|
||||||
<mat-icon class="icon-option-icon">{{
|
getEducationLevelIcon(getSelectedEducationLevel().name)
|
||||||
getEducationLevelIcon(getSelectedEducationLevel().name)
|
}}</mat-icon>
|
||||||
}}</mat-icon>
|
{{ getSelectedEducationLevel().name }}
|
||||||
{{ getSelectedEducationLevel().name }}
|
</span>
|
||||||
</span>
|
|
||||||
}
|
|
||||||
</mat-select-trigger>
|
|
||||||
@for (el of educationLevelOptions; track el) {
|
|
||||||
<mat-option [value]="el.id">
|
|
||||||
<span class="icon-option">
|
|
||||||
<mat-icon class="icon-option-icon">{{ getEducationLevelIcon(el.name) }}</mat-icon>
|
|
||||||
{{ el.name }}
|
|
||||||
</span>
|
|
||||||
</mat-option>
|
|
||||||
}
|
}
|
||||||
</mat-select>
|
</mat-select-trigger>
|
||||||
</mat-form-field>
|
@for (el of educationLevelOptions; track el) {
|
||||||
|
<mat-option [value]="el.id">
|
||||||
|
<span class="icon-option">
|
||||||
|
<mat-icon class="icon-option-icon">{{ getEducationLevelIcon(el.name) }}</mat-icon>
|
||||||
|
{{ el.name }}
|
||||||
|
</span>
|
||||||
|
</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
<!-- Degree / Qualification (depends on Education Level) -->
|
<!-- Degree / Qualification (depends on Education Level) -->
|
||||||
<mat-form-field appearance="outline" class="form-field">
|
<mat-form-field appearance="outline" class="form-field">
|
||||||
<mat-label>Degree / Qualification</mat-label>
|
<mat-label>Degree / Qualification</mat-label>
|
||||||
<mat-select
|
<mat-select
|
||||||
formControlName="degreeQualificationId"
|
formControlName="degreeQualificationId"
|
||||||
[placeholder]="getSelectedEducationLevel() ? 'Select Degree' : 'Select Education Level first'"
|
[placeholder]="getSelectedEducationLevel() ? 'Select Degree' : 'Select Education Level first'"
|
||||||
>
|
>
|
||||||
<mat-select-trigger>
|
<mat-select-trigger>
|
||||||
@if (getSelectedDegreeQualification()) {
|
@if (getSelectedDegreeQualification()) {
|
||||||
<span class="icon-option">
|
<span class="icon-option">
|
||||||
<mat-icon class="icon-option-icon">{{
|
<mat-icon class="icon-option-icon">{{
|
||||||
getDegreeQualificationIcon(getSelectedDegreeQualification().name)
|
getDegreeQualificationIcon(getSelectedDegreeQualification().name)
|
||||||
}}</mat-icon>
|
}}</mat-icon>
|
||||||
{{ getSelectedDegreeQualification().name }}
|
{{ getSelectedDegreeQualification().name }}
|
||||||
</span>
|
</span>
|
||||||
}
|
|
||||||
</mat-select-trigger>
|
|
||||||
@for (dq of filteredDegreeQualificationOptions; track dq) {
|
|
||||||
<mat-option [value]="dq.id">
|
|
||||||
<span class="icon-option">
|
|
||||||
<mat-icon class="icon-option-icon">{{ getDegreeQualificationIcon(dq.name) }}</mat-icon>
|
|
||||||
{{ dq.name }}
|
|
||||||
</span>
|
|
||||||
</mat-option>
|
|
||||||
}
|
}
|
||||||
@if (!getSelectedEducationLevel()) {
|
</mat-select-trigger>
|
||||||
<mat-option disabled>
|
@for (dq of filteredDegreeQualificationOptions; track dq) {
|
||||||
<em>Please select Education Level first</em>
|
<mat-option [value]="dq.id">
|
||||||
</mat-option>
|
<span class="icon-option">
|
||||||
} @else if (filteredDegreeQualificationOptions.length === 0) {
|
<mat-icon class="icon-option-icon">{{ getDegreeQualificationIcon(dq.name) }}</mat-icon>
|
||||||
<mat-option disabled>
|
{{ dq.name }}
|
||||||
<em>No options available</em>
|
</span>
|
||||||
</mat-option>
|
</mat-option>
|
||||||
}
|
}
|
||||||
</mat-select>
|
@if (!getSelectedEducationLevel()) {
|
||||||
</mat-form-field>
|
<mat-option disabled>
|
||||||
}
|
<em>Please select Education Level first</em>
|
||||||
|
</mat-option>
|
||||||
|
} @else if (filteredDegreeQualificationOptions.length === 0) {
|
||||||
|
<mat-option disabled>
|
||||||
|
<em>No options available</em>
|
||||||
|
</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
<!-- Religion -->
|
<!-- Religion -->
|
||||||
<mat-form-field appearance="outline" class="form-field">
|
<mat-form-field appearance="outline" class="form-field">
|
||||||
@@ -1217,21 +1218,25 @@
|
|||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
|
|
||||||
<mat-form-field appearance="outline" class="form-field">
|
<mat-form-field appearance="outline" class="form-field">
|
||||||
<mat-label>Annual Turnover (Last FY)</mat-label>
|
<mat-label>Annual Income</mat-label>
|
||||||
<span matPrefix class="rupee-prefix">₹</span>
|
<span matPrefix class="rupee-prefix">₹</span>
|
||||||
<input matInput type="number" formControlName="annualTurnover" min="0" />
|
<input matInput type="number" formControlName="annualIncome" min="0" />
|
||||||
@if (
|
@if (
|
||||||
createClientForm.get('annualTurnover')?.hasError('pattern') &&
|
createClientForm.get('annualIncome')?.hasError('pattern') && createClientForm.get('annualIncome')?.touched
|
||||||
createClientForm.get('annualTurnover')?.touched
|
|
||||||
) {
|
) {
|
||||||
<mat-error>Enter a valid amount</mat-error>
|
<mat-error>Enter a valid amount</mat-error>
|
||||||
} @else if (
|
} @else if (
|
||||||
createClientForm.get('annualTurnover')?.hasError('min') && createClientForm.get('annualTurnover')?.touched
|
createClientForm.get('annualIncome')?.hasError('min') && createClientForm.get('annualIncome')?.touched
|
||||||
) {
|
) {
|
||||||
<mat-error>Amount cannot be negative</mat-error>
|
<mat-error>Amount cannot be negative</mat-error>
|
||||||
}
|
}
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
|
|
||||||
|
<div class="full-width subsection-title">
|
||||||
|
<fa-icon icon="coins" class="subsection-icon"></fa-icon>
|
||||||
|
Other Income
|
||||||
|
</div>
|
||||||
|
|
||||||
<mat-form-field appearance="outline" class="form-field">
|
<mat-form-field appearance="outline" class="form-field">
|
||||||
<mat-label>Agriculture Income</mat-label>
|
<mat-label>Agriculture Income</mat-label>
|
||||||
<span matPrefix class="rupee-prefix">₹</span>
|
<span matPrefix class="rupee-prefix">₹</span>
|
||||||
@@ -1265,6 +1270,128 @@
|
|||||||
<mat-error>Amount cannot be negative</mat-error>
|
<mat-error>Amount cannot be negative</mat-error>
|
||||||
}
|
}
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="form-field">
|
||||||
|
<mat-label>Monthly Turnover</mat-label>
|
||||||
|
<span matPrefix class="rupee-prefix">₹</span>
|
||||||
|
<input matInput type="number" formControlName="monthlyTurnover" min="0" />
|
||||||
|
@if (
|
||||||
|
createClientForm.get('monthlyTurnover')?.hasError('pattern') &&
|
||||||
|
createClientForm.get('monthlyTurnover')?.touched
|
||||||
|
) {
|
||||||
|
<mat-error>Enter a valid amount</mat-error>
|
||||||
|
} @else if (
|
||||||
|
createClientForm.get('monthlyTurnover')?.hasError('min') && createClientForm.get('monthlyTurnover')?.touched
|
||||||
|
) {
|
||||||
|
<mat-error>Amount cannot be negative</mat-error>
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="form-field">
|
||||||
|
<mat-label>Yearly Turnover</mat-label>
|
||||||
|
<span matPrefix class="rupee-prefix">₹</span>
|
||||||
|
<input matInput type="number" formControlName="annualTurnover" min="0" />
|
||||||
|
@if (
|
||||||
|
createClientForm.get('annualTurnover')?.hasError('pattern') &&
|
||||||
|
createClientForm.get('annualTurnover')?.touched
|
||||||
|
) {
|
||||||
|
<mat-error>Enter a valid amount</mat-error>
|
||||||
|
} @else if (
|
||||||
|
createClientForm.get('annualTurnover')?.hasError('min') && createClientForm.get('annualTurnover')?.touched
|
||||||
|
) {
|
||||||
|
<mat-error>Amount cannot be negative</mat-error>
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<div class="full-width subsection-title">
|
||||||
|
<fa-icon icon="user-shield" class="subsection-icon"></fa-icon>
|
||||||
|
Senior Citizen / Person with Disability
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (isSeniorCitizen) {
|
||||||
|
<div class="full-width senior-citizen-auto-note">
|
||||||
|
<fa-icon icon="check-circle"></fa-icon>
|
||||||
|
Senior Citizen (60+ years) — detected automatically from Date of Birth
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="full-width doc-upload-field">
|
||||||
|
<label class="doc-upload-label">
|
||||||
|
<input type="file" accept=".jpg,.jpeg,.png,.pdf" hidden (change)="onSeniorCitizenProofSelected($event)" />
|
||||||
|
<fa-icon icon="file-upload"></fa-icon>
|
||||||
|
@if (seniorCitizenProofFile) {
|
||||||
|
<span>{{ seniorCitizenProofFile.name }}</span>
|
||||||
|
} @else if (existingSeniorCitizenProofDoc) {
|
||||||
|
<span>{{ existingSeniorCitizenProofDoc.name }} (uploaded)</span>
|
||||||
|
} @else {
|
||||||
|
<span>Upload Senior Citizen Proof (Age / ID Proof) — optional</span>
|
||||||
|
}
|
||||||
|
</label>
|
||||||
|
@if (seniorCitizenProofFile) {
|
||||||
|
<button type="button" mat-icon-button (click)="removeSeniorCitizenProof()" matTooltip="Remove">
|
||||||
|
<mat-icon>close</mat-icon>
|
||||||
|
</button>
|
||||||
|
} @else if (existingSeniorCitizenProofDoc) {
|
||||||
|
<button type="button" mat-icon-button (click)="viewExistingSeniorCitizenProof()" matTooltip="View">
|
||||||
|
<mat-icon>visibility</mat-icon>
|
||||||
|
</button>
|
||||||
|
<button type="button" mat-icon-button (click)="removeExistingSeniorCitizenProof()" matTooltip="Remove">
|
||||||
|
<mat-icon>close</mat-icon>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="checkbox-field full-width">
|
||||||
|
<mat-checkbox formControlName="isPwd">Person with Disability (PWD)</mat-checkbox>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (createClientForm.get('isPwd')?.value) {
|
||||||
|
<mat-form-field appearance="outline" class="form-field">
|
||||||
|
<mat-label>Disability Type</mat-label>
|
||||||
|
<mat-select formControlName="pwdCategoryId">
|
||||||
|
@for (category of pwdCategoryOptions; track category.id) {
|
||||||
|
<mat-option [value]="category.id">{{ category.name }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
@if (seniorPwdDocsTouched && !createClientForm.get('pwdCategoryId')?.value) {
|
||||||
|
<mat-error>Disability type is required</mat-error>
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<div class="full-width doc-upload-field">
|
||||||
|
<label
|
||||||
|
class="doc-upload-label"
|
||||||
|
[class.doc-upload-label--missing]="
|
||||||
|
seniorPwdDocsTouched && !pwdCertificateFile && !existingPwdCertificateDoc
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<input type="file" accept=".jpg,.jpeg,.png,.pdf" hidden (change)="onPwdCertificateSelected($event)" />
|
||||||
|
<fa-icon icon="file-upload"></fa-icon>
|
||||||
|
@if (pwdCertificateFile) {
|
||||||
|
<span>{{ pwdCertificateFile.name }}</span>
|
||||||
|
} @else if (existingPwdCertificateDoc) {
|
||||||
|
<span>{{ existingPwdCertificateDoc.name }} (uploaded)</span>
|
||||||
|
} @else {
|
||||||
|
<span>Upload Disability Certificate <span class="doc-upload-req">*</span></span>
|
||||||
|
}
|
||||||
|
</label>
|
||||||
|
@if (pwdCertificateFile) {
|
||||||
|
<button type="button" mat-icon-button (click)="removePwdCertificate()" matTooltip="Remove">
|
||||||
|
<mat-icon>close</mat-icon>
|
||||||
|
</button>
|
||||||
|
} @else if (existingPwdCertificateDoc) {
|
||||||
|
<button type="button" mat-icon-button (click)="viewExistingPwdCertificate()" matTooltip="View">
|
||||||
|
<mat-icon>visibility</mat-icon>
|
||||||
|
</button>
|
||||||
|
<button type="button" mat-icon-button (click)="removeExistingPwdCertificate()" matTooltip="Remove">
|
||||||
|
<mat-icon>close</mat-icon>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
@if (seniorPwdDocsTouched && !pwdCertificateFile && !existingPwdCertificateDoc) {
|
||||||
|
<span class="doc-upload-error">Required when PWD is checked</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,78 @@ form {
|
|||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.senior-citizen-auto-note {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #1074b9;
|
||||||
|
background: #eff6ff;
|
||||||
|
border: 1px solid #bfdbfe;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-upload-field {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-upload-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex: 1;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: 1px dashed #cbd5e1;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #475569;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: #1074b9;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.doc-upload-label--missing {
|
||||||
|
border-color: #dc2626;
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-upload-req {
|
||||||
|
color: #dc2626;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-upload-error {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subsection-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: #6b7280;
|
||||||
|
margin: 20px 0 4px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
|
||||||
|
.subsection-icon {
|
||||||
|
color: var(--md-sys-color-primary, #1074b9);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.checkbox-field {
|
.checkbox-field {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -234,6 +306,18 @@ form {
|
|||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.senior-citizen-badge {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #1074b9;
|
||||||
|
margin-left: 6px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ═══ Guardian Section (Redesigned) ═══ */
|
/* ═══ Guardian Section (Redesigned) ═══ */
|
||||||
.guardian-section {
|
.guardian-section {
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
|
|||||||
+472
-6
@@ -46,6 +46,7 @@ import { MatSelectTrigger } from '@angular/material/select';
|
|||||||
import { MatTooltip } from '@angular/material/tooltip';
|
import { MatTooltip } from '@angular/material/tooltip';
|
||||||
import { MatDialog, MAT_DIALOG_DATA, MatDialogContent, MatDialogClose } from '@angular/material/dialog';
|
import { MatDialog, MAT_DIALOG_DATA, MatDialogContent, MatDialogClose } from '@angular/material/dialog';
|
||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
import { DocumentPreviewDialogComponent } from '../../shared/document-preview-dialog/document-preview-dialog.component';
|
||||||
import { DateFormatPipe } from '../../../pipes/date-format.pipe';
|
import { DateFormatPipe } from '../../../pipes/date-format.pipe';
|
||||||
import { SanctionsVerifyDialogComponent } from './sanctions-verify-dialog.component';
|
import { SanctionsVerifyDialogComponent } from './sanctions-verify-dialog.component';
|
||||||
import { DipVerifyDialogComponent } from './dip-verify-dialog.component';
|
import { DipVerifyDialogComponent } from './dip-verify-dialog.component';
|
||||||
@@ -93,6 +94,148 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
/** Expose enum to template */
|
/** Expose enum to template */
|
||||||
readonly LegalFormId = LegalFormId;
|
readonly LegalFormId = LegalFormId;
|
||||||
|
|
||||||
|
/** Senior Citizen proof is optional; the PWD certificate is mandatory (see seniorPwdDocsComplete). Both, when
|
||||||
|
* attached, are uploaded after client creation. */
|
||||||
|
seniorCitizenProofFile: File | null = null;
|
||||||
|
pwdCertificateFile: File | null = null;
|
||||||
|
|
||||||
|
/** Previously-uploaded documents, loaded in edit mode so the certificate doesn't appear to
|
||||||
|
* "disappear" on reopening — the raw File properties above only ever hold a fresh selection. */
|
||||||
|
existingSeniorCitizenProofDoc: { id: string; name: string } | null = null;
|
||||||
|
existingPwdCertificateDoc: { id: string; name: string } | null = null;
|
||||||
|
|
||||||
|
/** Disability categories (per the Rights of Persons with Disabilities Act, 2016) — fetched from
|
||||||
|
* m_code_value (code "PwdCategory") via the client template, same as occupationCategoryOptions.
|
||||||
|
* Admins can add/reorder/deactivate entries from System -> Manage Codes without a code change. */
|
||||||
|
pwdCategoryOptions: any[] = [];
|
||||||
|
riskRules: any[] = [];
|
||||||
|
|
||||||
|
/** Set true after a save attempt so the UI can highlight a missing required document */
|
||||||
|
seniorPwdDocsTouched = false;
|
||||||
|
|
||||||
|
/** Senior Citizen proof is optional. The PWD certificate + disability type are mandatory
|
||||||
|
* whenever the PWD checkbox is set. */
|
||||||
|
get seniorPwdDocsComplete(): boolean {
|
||||||
|
const pwdOk =
|
||||||
|
!this.createClientForm.get('isPwd')?.value ||
|
||||||
|
((!!this.pwdCertificateFile || !!this.existingPwdCertificateDoc) &&
|
||||||
|
!!this.createClientForm.get('pwdCategoryId')?.value);
|
||||||
|
return pwdOk;
|
||||||
|
}
|
||||||
|
|
||||||
|
markSeniorPwdDocsTouched(): void {
|
||||||
|
this.seniorPwdDocsTouched = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
onSeniorCitizenProofSelected(event: any): void {
|
||||||
|
const file = event.target?.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
this.seniorCitizenProofFile = file;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
removeSeniorCitizenProof(): void {
|
||||||
|
this.seniorCitizenProofFile = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
onPwdCertificateSelected(event: any): void {
|
||||||
|
const file = event.target?.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
this.pwdCertificateFile = file;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
removePwdCertificate(): void {
|
||||||
|
this.pwdCertificateFile = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Loads previously-uploaded senior-citizen-proof / PWD-certificate documents in edit mode —
|
||||||
|
* mirrors the KYC step's loadExistingSignature() pattern so re-opening edit shows what's
|
||||||
|
* already on file instead of an empty upload prompt. */
|
||||||
|
private loadExistingSeniorPwdDocuments(): void {
|
||||||
|
const clientId = this.clientData?.id;
|
||||||
|
if (!clientId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.clientService.getClientDocuments(clientId.toString()).subscribe({
|
||||||
|
next: (documents: any[]) => {
|
||||||
|
const seniorDoc = documents
|
||||||
|
?.filter((d: any) => d.name === 'seniorCitizenProof')
|
||||||
|
?.sort((a: any, b: any) => b.id - a.id)?.[0];
|
||||||
|
if (seniorDoc?.id) {
|
||||||
|
this.existingSeniorCitizenProofDoc = {
|
||||||
|
id: seniorDoc.id.toString(),
|
||||||
|
name: seniorDoc.fileName || 'Senior Citizen Proof'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const pwdDoc = documents
|
||||||
|
?.filter((d: any) => d.name === 'pwdCertificate')
|
||||||
|
?.sort((a: any, b: any) => b.id - a.id)?.[0];
|
||||||
|
if (pwdDoc?.id) {
|
||||||
|
this.existingPwdCertificateDoc = {
|
||||||
|
id: pwdDoc.id.toString(),
|
||||||
|
name: pwdDoc.fileName || 'Disability Certificate'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: () => {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
viewExistingSeniorCitizenProof(): void {
|
||||||
|
const clientId = this.clientData?.id;
|
||||||
|
if (!clientId || !this.existingSeniorCitizenProofDoc) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.clientService.downloadClientDocument(clientId.toString(), this.existingSeniorCitizenProofDoc.id).subscribe({
|
||||||
|
next: (blob: any) =>
|
||||||
|
this.dialog.open(DocumentPreviewDialogComponent, {
|
||||||
|
data: {
|
||||||
|
blob,
|
||||||
|
title: this.existingSeniorCitizenProofDoc?.name,
|
||||||
|
fileName: this.existingSeniorCitizenProofDoc?.name
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
error: () => {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
removeExistingSeniorCitizenProof(): void {
|
||||||
|
const clientId = this.clientData?.id;
|
||||||
|
if (!clientId || !this.existingSeniorCitizenProofDoc) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.clientService.deleteClientDocument(clientId.toString(), this.existingSeniorCitizenProofDoc.id).subscribe({
|
||||||
|
next: () => (this.existingSeniorCitizenProofDoc = null),
|
||||||
|
error: () => {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
viewExistingPwdCertificate(): void {
|
||||||
|
const clientId = this.clientData?.id;
|
||||||
|
if (!clientId || !this.existingPwdCertificateDoc) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.clientService.downloadClientDocument(clientId.toString(), this.existingPwdCertificateDoc.id).subscribe({
|
||||||
|
next: (blob: any) =>
|
||||||
|
this.dialog.open(DocumentPreviewDialogComponent, {
|
||||||
|
data: { blob, title: this.existingPwdCertificateDoc?.name, fileName: this.existingPwdCertificateDoc?.name }
|
||||||
|
}),
|
||||||
|
error: () => {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
removeExistingPwdCertificate(): void {
|
||||||
|
const clientId = this.clientData?.id;
|
||||||
|
if (!clientId || !this.existingPwdCertificateDoc) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.clientService.deleteClientDocument(clientId.toString(), this.existingPwdCertificateDoc.id).subscribe({
|
||||||
|
next: () => (this.existingPwdCertificateDoc = null),
|
||||||
|
error: () => {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Minimum date allowed. */
|
/** Minimum date allowed. */
|
||||||
minDate = new Date(2000, 0, 1);
|
minDate = new Date(2000, 0, 1);
|
||||||
/** Maximum date allowed. */
|
/** Maximum date allowed. */
|
||||||
@@ -155,8 +298,10 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
];
|
];
|
||||||
/** Calculated age detail string (Y/M/D) */
|
/** Calculated age detail string (Y/M/D) */
|
||||||
calculatedAgeDetail: string | null = null;
|
calculatedAgeDetail: string | null = null;
|
||||||
/** Whether client is a minor (age < 10) */
|
/** Whether client is a minor (age < 18) */
|
||||||
isMinor = false;
|
isMinor = false;
|
||||||
|
/** Whether client is a senior citizen (age >= 60) — auto-detected from Date of Birth */
|
||||||
|
isSeniorCitizen = false;
|
||||||
/** Age breakdown for DB storage */
|
/** Age breakdown for DB storage */
|
||||||
ageBreakdown = { years: 0, months: 0, days: 0 };
|
ageBreakdown = { years: 0, months: 0, days: 0 };
|
||||||
|
|
||||||
@@ -402,6 +547,18 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
this.maxDate = this.settingsService.businessDate;
|
this.maxDate = this.settingsService.businessDate;
|
||||||
this.setOptions();
|
this.setOptions();
|
||||||
|
|
||||||
|
// Risk rules are an enhancement on top of the default low/medium-risk fallback in
|
||||||
|
// calculatedRisk — if the endpoint isn't available (e.g. not yet deployed on this
|
||||||
|
// backend), fail silently rather than surfacing a raw HTTP-error toast to the user.
|
||||||
|
this.clientService.getClientRiskRules().subscribe({
|
||||||
|
next: (rules) => {
|
||||||
|
this.riskRules = rules || [];
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.riskRules = [];
|
||||||
|
}
|
||||||
|
});
|
||||||
// Fetch marital status options from code values
|
// Fetch marital status options from code values
|
||||||
this.clientService.getCodeValuesByName('MaritalStatus').subscribe((options) => {
|
this.clientService.getCodeValuesByName('MaritalStatus').subscribe((options) => {
|
||||||
this.maritalStatusOptions = options || [];
|
this.maritalStatusOptions = options || [];
|
||||||
@@ -466,6 +623,51 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resets transient session state back to a blank-client baseline.
|
||||||
|
*
|
||||||
|
* This app's RouteReusableStrategy (core/route/route-reusable-strategy.ts) keeps the SAME
|
||||||
|
* component instance alive across repeat visits to /clients/create — `ngOnInit`/the
|
||||||
|
* constructor never re-run for a second "create new client" session. Without this, computed
|
||||||
|
* state like `isMinor`, the linked guardian, or a prior sanctions/OTP verification would leak
|
||||||
|
* from whatever client was created in the previous session into the new one. Called by
|
||||||
|
* CreateClientComponent whenever `route.data` re-emits (which happens on every navigation,
|
||||||
|
* even a reused one).
|
||||||
|
*/
|
||||||
|
resetForNewClient(): void {
|
||||||
|
this.createClientForm.reset();
|
||||||
|
// legalFormId's raw FormControl default is '' — the buildDependencies() subscription
|
||||||
|
// treats any non-PERSON value (including '') as "non-individual" and adds fullname /
|
||||||
|
// clientNonPersonDetails. Patch it back to the same PERSON default buildDependencies()
|
||||||
|
// itself applies on first construction, so a reset lands on a blank Individual client
|
||||||
|
// instead of stuck with leftover Entity-only controls.
|
||||||
|
this.createClientForm.get('legalFormId')?.setValue(LegalFormId.PERSON);
|
||||||
|
this.isMinor = false;
|
||||||
|
this.calculatedAgeDetail = null;
|
||||||
|
this.ageBreakdown = { years: 0, months: 0, days: 0 };
|
||||||
|
this.selectedGuardian = null;
|
||||||
|
this.guardianSearchText = '';
|
||||||
|
this.guardianSearchResults = [];
|
||||||
|
this.guardianExpanded = false;
|
||||||
|
if (this.createClientForm.get('guardianDetails')) {
|
||||||
|
this.createClientForm.removeControl('guardianDetails');
|
||||||
|
}
|
||||||
|
// Verification state must not carry over — a previous client's cleared sanctions
|
||||||
|
// screening or verified OTP must not silently apply to a different new client.
|
||||||
|
this.mobileVerified = false;
|
||||||
|
this.dipLookupLogId = null;
|
||||||
|
this.dipTimestamp = null;
|
||||||
|
this.alternateMobileVerified = false;
|
||||||
|
this.alternateDipLookupLogId = null;
|
||||||
|
this.alternateDipTimestamp = null;
|
||||||
|
this.mobileNewOtpId = null;
|
||||||
|
this.emailNewOtpId = null;
|
||||||
|
this.sanctionsVerified = false;
|
||||||
|
this.sanctionsBlocked = false;
|
||||||
|
this.screeningLogId = null;
|
||||||
|
this.verifiedName = '';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pre-fills the General step form from a manually-entered family member
|
* Pre-fills the General step form from a manually-entered family member
|
||||||
* when the user clicks "Create as Client" on the family members tab.
|
* when the user clicks "Create as Client" on the family members tab.
|
||||||
@@ -505,6 +707,7 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
*/
|
*/
|
||||||
private prefillForEdit() {
|
private prefillForEdit() {
|
||||||
const data = this.clientData;
|
const data = this.clientData;
|
||||||
|
this.loadExistingSeniorPwdDocuments();
|
||||||
|
|
||||||
// Patch all base controls that exist on the form
|
// Patch all base controls that exist on the form
|
||||||
this.createClientForm.patchValue({
|
this.createClientForm.patchValue({
|
||||||
@@ -521,11 +724,19 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
mobileNo: data.mobileNo,
|
mobileNo: data.mobileNo,
|
||||||
alternateMobileNo: data.alternateMobileNo || '',
|
alternateMobileNo: data.alternateMobileNo || '',
|
||||||
emailAddress: data.emailAddress,
|
emailAddress: data.emailAddress,
|
||||||
dateOfBirth: data.dateOfBirth ? new Date(data.dateOfBirth) : '',
|
dateOfBirth: data.dateOfBirth
|
||||||
|
? Array.isArray(data.dateOfBirth)
|
||||||
|
? new Date(data.dateOfBirth[0], data.dateOfBirth[1] - 1, data.dateOfBirth[2])
|
||||||
|
: new Date(data.dateOfBirth)
|
||||||
|
: '',
|
||||||
occupationCategoryId: data.occupationCategory?.id || data.occupationCategoryId || '',
|
occupationCategoryId: data.occupationCategory?.id || data.occupationCategoryId || '',
|
||||||
occupationId: data.occupation?.id || data.occupationId || '',
|
occupationId: data.occupation?.id || data.occupationId || '',
|
||||||
monthlyIncome: data.monthlyIncome || '',
|
monthlyIncome: data.monthlyIncome || '',
|
||||||
|
annualIncome: data.annualIncome || '',
|
||||||
annualTurnover: data.annualTurnover || '',
|
annualTurnover: data.annualTurnover || '',
|
||||||
|
monthlyTurnover: data.monthlyTurnover || '',
|
||||||
|
isPwd: data.isPwd || false,
|
||||||
|
pwdCategoryId: data.pwdCategory?.id || '',
|
||||||
agricultureIncome: data.agricultureIncome || '',
|
agricultureIncome: data.agricultureIncome || '',
|
||||||
nonAgricultureIncome: data.nonAgricultureIncome || '',
|
nonAgricultureIncome: data.nonAgricultureIncome || '',
|
||||||
submittedOnDate: data.timeline?.submittedOnDate
|
submittedOnDate: data.timeline?.submittedOnDate
|
||||||
@@ -599,6 +810,42 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Restore linked guardian for minor clients. The `guardianDetails` control group above was
|
||||||
|
// just created synchronously by the dateOfBirth subscription (patched a few lines up), which
|
||||||
|
// also resets `selectedGuardian` to null — so this has to run after that patch, not before it.
|
||||||
|
// If a guardian is linked in the database (or the client is marked minor), we force minor state
|
||||||
|
// and explicitly ensure the form control exists so the guardian details load correctly.
|
||||||
|
if ((data.isMinor || this.isMinor || data.guardianClientId) && data.guardianClientId) {
|
||||||
|
this.isMinor = true;
|
||||||
|
if (!this.createClientForm.get('guardianDetails')) {
|
||||||
|
this.createClientForm.addControl(
|
||||||
|
'guardianDetails',
|
||||||
|
this.formBuilder.group({
|
||||||
|
guardianClientId: [
|
||||||
|
'',
|
||||||
|
Validators.required
|
||||||
|
],
|
||||||
|
guardianRelation: [
|
||||||
|
'',
|
||||||
|
Validators.required
|
||||||
|
]
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setTimeout(() => {
|
||||||
|
this.createClientForm.get('guardianDetails')?.patchValue({
|
||||||
|
guardianClientId: data.guardianClientId,
|
||||||
|
guardianRelation: data.guardianRelation || ''
|
||||||
|
});
|
||||||
|
this.selectedGuardian = {
|
||||||
|
id: data.guardianClientId,
|
||||||
|
displayName: data.guardianClientDisplayName || '',
|
||||||
|
_loading: true
|
||||||
|
};
|
||||||
|
this.fetchClientFullDetails(data.guardianClientId, this.selectedGuardian);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -665,6 +912,13 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
Validators.min(0)
|
Validators.min(0)
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
|
annualIncome: [
|
||||||
|
'',
|
||||||
|
[
|
||||||
|
Validators.pattern(/^\d+(\.\d{1,2})?$/),
|
||||||
|
Validators.min(0)
|
||||||
|
]
|
||||||
|
],
|
||||||
annualTurnover: [
|
annualTurnover: [
|
||||||
'',
|
'',
|
||||||
[
|
[
|
||||||
@@ -672,6 +926,15 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
Validators.min(0)
|
Validators.min(0)
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
|
monthlyTurnover: [
|
||||||
|
'',
|
||||||
|
[
|
||||||
|
Validators.pattern(/^\d+(\.\d{1,2})?$/),
|
||||||
|
Validators.min(0)
|
||||||
|
]
|
||||||
|
],
|
||||||
|
isPwd: [false],
|
||||||
|
pwdCategoryId: [''],
|
||||||
agricultureIncome: [
|
agricultureIncome: [
|
||||||
'',
|
'',
|
||||||
[
|
[
|
||||||
@@ -691,6 +954,79 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
Validators.required
|
Validators.required
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.linkMonthlyAnnual('monthlyIncome', 'annualIncome');
|
||||||
|
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. */
|
||||||
|
private suppressMonthlyAnnualLink = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps a Monthly/Annual pair of amount fields in sync at a 12x ratio — typing into either
|
||||||
|
* one auto-fills the other. Uses { emitEvent: false } on the write side so the two
|
||||||
|
* subscriptions don't trigger each other back and forth.
|
||||||
|
*/
|
||||||
|
/** Rounds to at most 2 decimals without padding whole numbers with a trailing ".00". */
|
||||||
|
private round2(n: number): string {
|
||||||
|
return (Math.round(n * 100) / 100).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private linkMonthlyAnnual(monthlyControlName: string, annualControlName: string): void {
|
||||||
|
const monthly = this.createClientForm.get(monthlyControlName);
|
||||||
|
const annual = this.createClientForm.get(annualControlName);
|
||||||
|
if (!monthly || !annual) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
monthly.valueChanges.subscribe((value) => {
|
||||||
|
if (this.suppressMonthlyAnnualLink) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const num = parseFloat(value);
|
||||||
|
annual.setValue(!isNaN(num) && num >= 0 ? this.round2(num * 12) : '', { emitEvent: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
annual.valueChanges.subscribe((value) => {
|
||||||
|
if (this.suppressMonthlyAnnualLink) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const num = parseFloat(value);
|
||||||
|
monthly.setValue(!isNaN(num) && num >= 0 ? this.round2(num / 12) : '', { emitEvent: false });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-calculates turnover based on income (doubling monthly income for monthly turnover,
|
||||||
|
* and annual income for annual/yearly turnover).
|
||||||
|
*/
|
||||||
|
private linkIncomeToTurnover(): void {
|
||||||
|
const monthlyIncome = this.createClientForm.get('monthlyIncome');
|
||||||
|
const monthlyTurnover = this.createClientForm.get('monthlyTurnover');
|
||||||
|
const annualIncome = this.createClientForm.get('annualIncome');
|
||||||
|
const annualTurnover = this.createClientForm.get('annualTurnover');
|
||||||
|
|
||||||
|
if (monthlyIncome && monthlyTurnover) {
|
||||||
|
monthlyIncome.valueChanges.subscribe((value) => {
|
||||||
|
if (this.suppressMonthlyAnnualLink) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const num = parseFloat(value);
|
||||||
|
monthlyTurnover.setValue(!isNaN(num) && num >= 0 ? this.round2(num * 2) : '', { emitEvent: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (annualIncome && annualTurnover) {
|
||||||
|
annualIncome.valueChanges.subscribe((value) => {
|
||||||
|
if (this.suppressMonthlyAnnualLink) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const num = parseFloat(value);
|
||||||
|
annualTurnover.setValue(!isNaN(num) && num >= 0 ? this.round2(num * 2) : '', { emitEvent: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -709,6 +1045,7 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
this.occupationCategoryOptions = this.clientTemplate.occupationCategoryOptions || [];
|
this.occupationCategoryOptions = this.clientTemplate.occupationCategoryOptions || [];
|
||||||
this.occupationOptions = this.clientTemplate.occupationOptions || [];
|
this.occupationOptions = this.clientTemplate.occupationOptions || [];
|
||||||
this.salutationOptions = this.clientTemplate.salutationOptions || [];
|
this.salutationOptions = this.clientTemplate.salutationOptions || [];
|
||||||
|
this.pwdCategoryOptions = this.clientTemplate.pwdCategoryOptions || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -824,7 +1161,8 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
this.calculatedAgeDetail = `${years} yr${years !== 1 ? 's' : ''} ${months} mo${months !== 1 ? 's' : ''} ${days} day${days !== 1 ? 's' : ''}`;
|
this.calculatedAgeDetail = `${years} yr${years !== 1 ? 's' : ''} ${months} mo${months !== 1 ? 's' : ''} ${days} day${days !== 1 ? 's' : ''}`;
|
||||||
this.ageBreakdown = { years, months, days };
|
this.ageBreakdown = { years, months, days };
|
||||||
const wasMinor = this.isMinor;
|
const wasMinor = this.isMinor;
|
||||||
this.isMinor = years < 10;
|
this.isMinor = years < 18;
|
||||||
|
this.isSeniorCitizen = years >= 60;
|
||||||
// Add/remove guardian form group based on minor status
|
// Add/remove guardian form group based on minor status
|
||||||
if (this.isMinor && !wasMinor) {
|
if (this.isMinor && !wasMinor) {
|
||||||
this.createClientForm.addControl(
|
this.createClientForm.addControl(
|
||||||
@@ -850,12 +1188,14 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
} else {
|
} else {
|
||||||
this.calculatedAgeDetail = null;
|
this.calculatedAgeDetail = null;
|
||||||
this.isMinor = false;
|
this.isMinor = false;
|
||||||
|
this.isSeniorCitizen = false;
|
||||||
this.ageBreakdown = { years: 0, months: 0, days: 0 };
|
this.ageBreakdown = { years: 0, months: 0, days: 0 };
|
||||||
this.createClientForm.removeControl('guardianDetails');
|
this.createClientForm.removeControl('guardianDetails');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.calculatedAgeDetail = null;
|
this.calculatedAgeDetail = null;
|
||||||
this.isMinor = false;
|
this.isMinor = false;
|
||||||
|
this.isSeniorCitizen = false;
|
||||||
this.ageBreakdown = { years: 0, months: 0, days: 0 };
|
this.ageBreakdown = { years: 0, months: 0, days: 0 };
|
||||||
this.createClientForm.removeControl('guardianDetails');
|
this.createClientForm.removeControl('guardianDetails');
|
||||||
}
|
}
|
||||||
@@ -1548,6 +1888,9 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
if (n.includes('postgrad') || n === 'pg' || n.includes('master')) return 'postgraduate';
|
if (n.includes('postgrad') || n === 'pg' || n.includes('master')) return 'postgraduate';
|
||||||
if (n.includes('undergrad') || n === 'ug' || n.includes('bachelor') || n.includes('graduate'))
|
if (n.includes('undergrad') || n === 'ug' || n.includes('bachelor') || n.includes('graduate'))
|
||||||
return 'undergraduate';
|
return 'undergraduate';
|
||||||
|
// Diploma is its own Education Level, distinct from School — checked before the school
|
||||||
|
// keywords below so it doesn't get swept into that bucket.
|
||||||
|
if (n.includes('diploma') || n.includes('polytechnic')) return 'diploma';
|
||||||
if (
|
if (
|
||||||
n.includes('school') ||
|
n.includes('school') ||
|
||||||
n.includes('10th') ||
|
n.includes('10th') ||
|
||||||
@@ -1556,7 +1899,6 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
n.includes('hsc') ||
|
n.includes('hsc') ||
|
||||||
n.includes('matric') ||
|
n.includes('matric') ||
|
||||||
n.includes('intermediate') ||
|
n.includes('intermediate') ||
|
||||||
n.includes('diploma') ||
|
|
||||||
n.includes('iti') ||
|
n.includes('iti') ||
|
||||||
n.includes('below')
|
n.includes('below')
|
||||||
)
|
)
|
||||||
@@ -1634,8 +1976,12 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Degrees filtered by the currently selected Education Level.
|
* Degrees filtered by the currently selected Education Level. Each DegreeQualification code
|
||||||
* Returns all degrees if no level is selected, or category can't be determined.
|
* value is tagged with its owning level in `description` (e.g. "Undergraduate") when seeded —
|
||||||
|
* match on that authoritative field first. Only fall back to the name-keyword heuristic
|
||||||
|
* (getDegreeQualificationCategory) for older/untagged rows that predate this tagging, so
|
||||||
|
* degrees never silently disappear because an admin added one without a description.
|
||||||
|
* Returns all degrees if no level is selected.
|
||||||
*/
|
*/
|
||||||
get filteredDegreeQualificationOptions(): any[] {
|
get filteredDegreeQualificationOptions(): any[] {
|
||||||
const level = this.getSelectedEducationLevel();
|
const level = this.getSelectedEducationLevel();
|
||||||
@@ -1643,6 +1989,9 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
const category = this.getEducationLevelCategory(level.name);
|
const category = this.getEducationLevelCategory(level.name);
|
||||||
if (!category) return this.degreeQualificationOptions;
|
if (!category) return this.degreeQualificationOptions;
|
||||||
const filtered = this.degreeQualificationOptions.filter((d: any) => {
|
const filtered = this.degreeQualificationOptions.filter((d: any) => {
|
||||||
|
if (d.description) {
|
||||||
|
return this.getEducationLevelCategory(d.description) === category;
|
||||||
|
}
|
||||||
const c = this.getDegreeQualificationCategory(d.name);
|
const c = this.getDegreeQualificationCategory(d.name);
|
||||||
return c === category || c === '';
|
return c === category || c === '';
|
||||||
});
|
});
|
||||||
@@ -1805,6 +2154,8 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
// Store extended fields separately (not yet supported by Fineract API)
|
// Store extended fields separately (not yet supported by Fineract API)
|
||||||
// Add isMinor flag
|
// Add isMinor flag
|
||||||
generalDetails.isMinor = this.isMinor;
|
generalDetails.isMinor = this.isMinor;
|
||||||
|
// isSeniorCitizen is auto-detected from DOB (not a form control) — add it explicitly
|
||||||
|
generalDetails.isSeniorCitizen = this.isSeniorCitizen;
|
||||||
|
|
||||||
// Add guardian clientId for minors
|
// Add guardian clientId for minors
|
||||||
if (this.isMinor && generalDetails.guardianDetails) {
|
if (this.isMinor && generalDetails.guardianDetails) {
|
||||||
@@ -1855,6 +2206,121 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
|
|
||||||
return generalDetails;
|
return generalDetails;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get calculatedRisk(): {
|
||||||
|
category: number;
|
||||||
|
reason: string;
|
||||||
|
amlCheckStatus?: string;
|
||||||
|
bannedEntityMatch?: string;
|
||||||
|
willfulDefaulter?: string;
|
||||||
|
ecgcSalFlag?: string;
|
||||||
|
dormantFlag?: string;
|
||||||
|
} {
|
||||||
|
const legalFormId = this.createClientForm.get('legalFormId')?.value || 1; // 1 = Person, 2 = Entity
|
||||||
|
const occCatId = this.createClientForm.get('occupationCategoryCvId')?.value;
|
||||||
|
const occId = this.createClientForm.get('occupationCvId')?.value;
|
||||||
|
|
||||||
|
const monthlyIncome = parseFloat(this.createClientForm.get('monthlyIncome')?.value) || 0;
|
||||||
|
const annualIncome = parseFloat(this.createClientForm.get('annualIncome')?.value) || 0;
|
||||||
|
const monthlyTurnover = parseFloat(this.createClientForm.get('monthlyTurnover')?.value) || 0;
|
||||||
|
const annualTurnover = parseFloat(this.createClientForm.get('annualTurnover')?.value) || 0;
|
||||||
|
const agricultureIncome = parseFloat(this.createClientForm.get('agricultureIncome')?.value) || 0;
|
||||||
|
const nonAgricultureIncome = parseFloat(this.createClientForm.get('nonAgricultureIncome')?.value) || 0;
|
||||||
|
|
||||||
|
const totalAnnualIncome = annualIncome + agricultureIncome + nonAgricultureIncome;
|
||||||
|
|
||||||
|
// Evaluate rules from DB (ordered by orderPosition ASC)
|
||||||
|
if (this.riskRules && this.riskRules.length > 0) {
|
||||||
|
for (const rule of this.riskRules) {
|
||||||
|
// Skip transaction/AML rules that have no KYC criteria
|
||||||
|
if (
|
||||||
|
rule.legalFormId == null &&
|
||||||
|
rule.occupationCategoryCvId == null &&
|
||||||
|
rule.occupationCvId == null &&
|
||||||
|
rule.minMonthlyIncome == null &&
|
||||||
|
rule.maxMonthlyIncome == null &&
|
||||||
|
rule.minAnnualIncome == null &&
|
||||||
|
rule.maxAnnualIncome == null &&
|
||||||
|
rule.minAnnualTurnover == null &&
|
||||||
|
rule.maxAnnualTurnover == null
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match legal form
|
||||||
|
if (rule.legalFormId != null && rule.legalFormId !== legalFormId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match occupation category
|
||||||
|
if (rule.occupationCategoryCvId != null && rule.occupationCategoryCvId !== occCatId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match occupation
|
||||||
|
if (rule.occupationCvId != null && rule.occupationCvId !== occId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match monthly income ranges
|
||||||
|
if (rule.minMonthlyIncome != null && monthlyIncome < rule.minMonthlyIncome) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (rule.maxMonthlyIncome != null && monthlyIncome > rule.maxMonthlyIncome) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match annual income ranges (evaluated against totalAnnualIncome)
|
||||||
|
if (rule.minAnnualIncome != null && totalAnnualIncome < rule.minAnnualIncome) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (rule.maxAnnualIncome != null && totalAnnualIncome > rule.maxAnnualIncome) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match turnover ranges
|
||||||
|
if (rule.minAnnualTurnover != null && annualTurnover < rule.minAnnualTurnover) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (rule.maxAnnualTurnover != null && annualTurnover > rule.maxAnnualTurnover) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// All matched!
|
||||||
|
return {
|
||||||
|
category: rule.riskCategory,
|
||||||
|
reason: rule.reason,
|
||||||
|
amlCheckStatus: rule.amlCheckStatus || (rule.riskCategory === 3 ? 'FLAGGED' : 'CLEAR'),
|
||||||
|
bannedEntityMatch: rule.bannedEntityMatch || 'NO',
|
||||||
|
willfulDefaulter: rule.willfulDefaulter || 'NO',
|
||||||
|
ecgcSalFlag: rule.ecgcSalFlag || 'NO',
|
||||||
|
dormantFlag: rule.dormantFlag || 'ACTIVE'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default Fallbacks
|
||||||
|
if (legalFormId !== LegalFormId.PERSON) {
|
||||||
|
return {
|
||||||
|
category: 2,
|
||||||
|
reason: 'Non-Individual / Entity profile (Default Medium Risk)',
|
||||||
|
amlCheckStatus: 'CLEAR',
|
||||||
|
bannedEntityMatch: 'NO',
|
||||||
|
willfulDefaulter: 'NO',
|
||||||
|
ecgcSalFlag: 'NO',
|
||||||
|
dormantFlag: 'ACTIVE'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
category: 1,
|
||||||
|
reason: 'Default Low Risk profile (Salary / Service / Agriculture / Others)',
|
||||||
|
amlCheckStatus: 'CLEAR',
|
||||||
|
bannedEntityMatch: 'NO',
|
||||||
|
willfulDefaulter: 'NO',
|
||||||
|
ecgcSalFlag: 'NO',
|
||||||
|
dormantFlag: 'ACTIVE'
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+35
@@ -42,6 +42,7 @@ import { ClientsService } from 'app/clients/clients.service';
|
|||||||
<strong>UN Security Council</strong> &
|
<strong>UN Security Council</strong> &
|
||||||
<strong>Financial Intelligence Unit - India (FIU-IND under UNSC Resolution 2231)</strong>
|
<strong>Financial Intelligence Unit - India (FIU-IND under UNSC Resolution 2231)</strong>
|
||||||
</p>
|
</p>
|
||||||
|
<p class="regulatory-note">{{ regulatoryBasis }}</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if (!verifying && !matchFound && !errorOccurred) {
|
@if (!verifying && !matchFound && !errorOccurred) {
|
||||||
@@ -69,7 +70,14 @@ import { ClientsService } from 'app/clients/clients.service';
|
|||||||
<span class="meta-label">Database</span>
|
<span class="meta-label">Database</span>
|
||||||
<span class="meta-value">UN SC Consolidated List</span>
|
<span class="meta-value">UN SC Consolidated List</span>
|
||||||
</div>
|
</div>
|
||||||
|
@if (listDataAsOf) {
|
||||||
|
<div class="meta-row">
|
||||||
|
<span class="meta-label">List Data As Of</span>
|
||||||
|
<span class="meta-value">{{ listDataAsOf }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
<p class="regulatory-note">{{ regulatoryBasis }}</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if (!verifying && matchFound) {
|
@if (!verifying && matchFound) {
|
||||||
@@ -96,11 +104,18 @@ import { ClientsService } from 'app/clients/clients.service';
|
|||||||
<span class="meta-label">Database</span>
|
<span class="meta-label">Database</span>
|
||||||
<span class="meta-value">UN SC Consolidated List</span>
|
<span class="meta-value">UN SC Consolidated List</span>
|
||||||
</div>
|
</div>
|
||||||
|
@if (listDataAsOf) {
|
||||||
|
<div class="meta-row">
|
||||||
|
<span class="meta-label">List Data As Of</span>
|
||||||
|
<span class="meta-value">{{ listDataAsOf }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="escalate-banner">
|
<div class="escalate-banner">
|
||||||
<fa-icon icon="exclamation-circle" class="escalate-icon"></fa-icon>
|
<fa-icon icon="exclamation-circle" class="escalate-icon"></fa-icon>
|
||||||
<span><strong>Escalate to Principal Officer immediately.</strong> Account opening blocked.</span>
|
<span><strong>Escalate to Principal Officer immediately.</strong> Account opening blocked.</span>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="regulatory-note">{{ regulatoryBasis }}</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if (!verifying && errorOccurred) {
|
@if (!verifying && errorOccurred) {
|
||||||
@@ -207,6 +222,15 @@ import { ClientsService } from 'app/clients/clients.service';
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.regulatory-note {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #9ca3af;
|
||||||
|
line-height: 1.5;
|
||||||
|
text-align: center;
|
||||||
|
margin: 8px 0 0;
|
||||||
|
max-width: 420px;
|
||||||
|
}
|
||||||
|
|
||||||
.approve-card {
|
.approve-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -524,6 +548,10 @@ export class SanctionsVerifyDialogComponent implements OnInit, OnDestroy {
|
|||||||
errorOccurred = false;
|
errorOccurred = false;
|
||||||
errorMessage = '';
|
errorMessage = '';
|
||||||
screeningLogId: number | null = null;
|
screeningLogId: number | null = null;
|
||||||
|
listDataAsOf: string | null = null;
|
||||||
|
|
||||||
|
regulatoryBasis =
|
||||||
|
'Screened as per Section 51-A of the Unlawful Activities (Prevention) Act, 1967 and the RBI Master Direction on KYC.';
|
||||||
|
|
||||||
screenedDate = new Date().toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' });
|
screenedDate = new Date().toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' });
|
||||||
progressValue = 0;
|
progressValue = 0;
|
||||||
@@ -624,6 +652,13 @@ export class SanctionsVerifyDialogComponent implements OnInit, OnDestroy {
|
|||||||
'Unable to connect to sanctions screening service.';
|
'Unable to connect to sanctions screening service.';
|
||||||
} else {
|
} else {
|
||||||
this.screeningLogId = this.apiResult?.screeningLogId || null;
|
this.screeningLogId = this.apiResult?.screeningLogId || null;
|
||||||
|
this.listDataAsOf = this.apiResult?.listDataAsOf
|
||||||
|
? new Date(this.apiResult.listDataAsOf).toLocaleDateString('en-IN', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric'
|
||||||
|
})
|
||||||
|
: null;
|
||||||
if (this.apiResult?.matchFound) {
|
if (this.apiResult?.matchFound) {
|
||||||
this.matchFound = true;
|
this.matchFound = true;
|
||||||
this.matchCount = this.apiResult.matchCount || 0;
|
this.matchCount = this.apiResult.matchCount || 0;
|
||||||
|
|||||||
+3
-2
@@ -173,8 +173,9 @@
|
|||||||
@if (selectedAddrProof.requiresDate) {
|
@if (selectedAddrProof.requiresDate) {
|
||||||
<mat-form-field appearance="outline" class="addr-ff">
|
<mat-form-field appearance="outline" class="addr-ff">
|
||||||
<mat-label>Issue / bill date</mat-label>
|
<mat-label>Issue / bill date</mat-label>
|
||||||
<span matPrefix class="addr-prefix"><mat-icon>event</mat-icon></span>
|
<input matInput [matDatepicker]="addrIssueDatePicker" formControlName="issueDate" />
|
||||||
<input matInput type="date" formControlName="issueDate" />
|
<mat-datepicker-toggle matSuffix [for]="addrIssueDatePicker"></mat-datepicker-toggle>
|
||||||
|
<mat-datepicker #addrIssueDatePicker></mat-datepicker>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+114
-25
@@ -15,9 +15,12 @@ import { MatCheckbox } from '@angular/material/checkbox';
|
|||||||
import { MatTabsModule } from '@angular/material/tabs';
|
import { MatTabsModule } from '@angular/material/tabs';
|
||||||
import { MatIcon } from '@angular/material/icon';
|
import { MatIcon } from '@angular/material/icon';
|
||||||
import { MatIconButton, MatButton } from '@angular/material/button';
|
import { MatIconButton, MatButton } from '@angular/material/button';
|
||||||
|
import { MatDialog } from '@angular/material/dialog';
|
||||||
import { ClientsService } from '../../clients.service';
|
import { ClientsService } from '../../clients.service';
|
||||||
import { ProofType, normalizeProofType, groupProofsByCategory } from '../../shared/kyc-proof-types';
|
import { ProofType, normalizeProofType, groupProofsByCategory } from '../../shared/kyc-proof-types';
|
||||||
import { KycUploadHelperService } from '../../shared/kyc-upload-helper.service';
|
import { KycUploadHelperService } from '../../shared/kyc-upload-helper.service';
|
||||||
|
import { DocumentPreviewDialogComponent } from '../../shared/document-preview-dialog/document-preview-dialog.component';
|
||||||
|
import { Dates } from 'app/core/utils/dates';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'mifosx-client-india-address-step',
|
selector: 'mifosx-client-india-address-step',
|
||||||
@@ -38,11 +41,20 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
|
|||||||
private http = inject(HttpClient);
|
private http = inject(HttpClient);
|
||||||
private clientsService = inject(ClientsService);
|
private clientsService = inject(ClientsService);
|
||||||
private kycUpload = inject(KycUploadHelperService);
|
private kycUpload = inject(KycUploadHelperService);
|
||||||
|
private dialog = inject(MatDialog);
|
||||||
|
private dates = inject(Dates);
|
||||||
|
|
||||||
/** Client ID — when set, loads existing addresses for edit mode */
|
/** Client ID — when set, loads existing addresses for edit mode */
|
||||||
@Input() clientId: string = '';
|
@Input() clientId: string = '';
|
||||||
/** Read-only mode — disables form editing until user clicks edit icon */
|
/** Read-only mode — disables form editing until user clicks edit icon */
|
||||||
@Input() readOnly = false;
|
@Input() readOnly = false;
|
||||||
|
/** Minor clients can't hold utility bills, bank statements or an adult-only OVD in their own
|
||||||
|
* name — restricts the address-proof picker to Aadhaar/Passport when true. */
|
||||||
|
@Input() isMinor = false;
|
||||||
|
/** Non-individual (entity) clients — hides personal OVDs (Aadhaar/Passport/DL/Voter ID/NREGA/
|
||||||
|
* NPR/Birth Certificate/School ID) from the proof-of-address picker; a company's registered
|
||||||
|
* office address is evidenced by utility bills, GST/rent documents etc., not a person's ID. */
|
||||||
|
@Input() isNonIndividual = false;
|
||||||
|
|
||||||
activeTab = 0;
|
activeTab = 0;
|
||||||
sameAsPermanent = false;
|
sameAsPermanent = false;
|
||||||
@@ -235,12 +247,50 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resets transient address-tab state for a brand-new client.
|
||||||
|
*
|
||||||
|
* This app's RouteReusableStrategy (core/route/route-reusable-strategy.ts) keeps this SAME
|
||||||
|
* component instance alive across repeat visits to /clients/create, so `ngOnInit` never
|
||||||
|
* re-runs for a second "create new client" session — the previous client's address forms,
|
||||||
|
* selected proof-of-address chip and uploaded files would otherwise leak into the new one.
|
||||||
|
* Called by CreateClientComponent.resetForNewClient() whenever a fresh create session begins.
|
||||||
|
*/
|
||||||
|
resetForNewClient(): void {
|
||||||
|
this.permanentForm.reset({ country: 'India' });
|
||||||
|
this.currentForm.reset({ country: 'India' });
|
||||||
|
this.officeForm.reset({ country: 'India' });
|
||||||
|
this.correspondenceForm.reset({ country: 'India' });
|
||||||
|
this.activeTab = 0;
|
||||||
|
this.sameAsPermanent = false;
|
||||||
|
this.pinStatus = {
|
||||||
|
permanent: { loading: false, found: false, error: false, message: '' },
|
||||||
|
current: { loading: false, found: false, error: false, message: '' },
|
||||||
|
office: { loading: false, found: false, error: false, message: '' },
|
||||||
|
correspondence: { loading: false, found: false, error: false, message: '' }
|
||||||
|
};
|
||||||
|
this.localityOptions = { permanent: [], current: [], office: [], correspondence: [] };
|
||||||
|
this.selectedAddrProof = null;
|
||||||
|
this.addrFiles = { front: null, back: null };
|
||||||
|
this.addrMergedFile = null;
|
||||||
|
this.addrMergedPreview = null;
|
||||||
|
this.existingAddressProofIdentifierId = null;
|
||||||
|
this.existingAddressProofDocuments = [];
|
||||||
|
this.addressLoaded = false;
|
||||||
|
// Re-fetch rather than re-filter `addressProofs` in place — if the previous session's
|
||||||
|
// isMinor filtering already reduced it (e.g. down to Aadhaar/Passport only), filtering
|
||||||
|
// that reduced list again can never recover the items it already removed.
|
||||||
|
this.loadProofTypes();
|
||||||
|
}
|
||||||
|
|
||||||
private loadProofTypes(): void {
|
private loadProofTypes(): void {
|
||||||
this.loadingProofs = true;
|
this.loadingProofs = true;
|
||||||
this.clientsService.getKycProofTypes().subscribe({
|
this.clientsService.getKycProofTypes().subscribe({
|
||||||
next: (res: any) => {
|
next: (res: any) => {
|
||||||
const activeFilter = (p: any) => p?.active !== false;
|
const activeFilter = (p: any) => p?.active !== false;
|
||||||
this.addressProofs = (res?.addressProofs || []).filter(activeFilter).map(normalizeProofType);
|
this.addressProofs = this.filterAddressProofsForNonIndividual(
|
||||||
|
this.filterAddressProofsForMinor((res?.addressProofs || []).filter(activeFilter).map(normalizeProofType))
|
||||||
|
);
|
||||||
this.addressGroups = groupProofsByCategory(this.addressProofs);
|
this.addressGroups = groupProofsByCategory(this.addressProofs);
|
||||||
this.loadingProofs = false;
|
this.loadingProofs = false;
|
||||||
},
|
},
|
||||||
@@ -250,6 +300,41 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Minors don't hold utility bills, bank statements, rent agreements, property tax receipts,
|
||||||
|
* employer letters or pension orders in their own name, and Voter ID/Driving Licence both
|
||||||
|
* require adult age — Aadhaar and Passport are the only OVDs left for proof of address. */
|
||||||
|
private static readonly MINOR_ELIGIBLE_ADDRESS_PROOF_CODES = new Set([
|
||||||
|
'AADHAAR',
|
||||||
|
'PASSPORT'
|
||||||
|
]);
|
||||||
|
|
||||||
|
private filterAddressProofsForMinor(proofs: ProofType[]): ProofType[] {
|
||||||
|
if (!this.isMinor) return proofs;
|
||||||
|
return proofs.filter((p) =>
|
||||||
|
ClientIndiaAddressStepComponent.MINOR_ELIGIBLE_ADDRESS_PROOF_CODES.has((p.code || '').toUpperCase())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Personal OVDs — not valid proof of a registered office/entity address. */
|
||||||
|
private static readonly NON_INDIVIDUAL_EXCLUDED_ADDRESS_PROOF_CODES = new Set([
|
||||||
|
'AADHAAR',
|
||||||
|
'PASSPORT',
|
||||||
|
'VOTER_ID',
|
||||||
|
'DRIVING_LICENSE',
|
||||||
|
'NREGA_JOB_CARD',
|
||||||
|
'NPR_LETTER',
|
||||||
|
'BIRTH_CERTIFICATE',
|
||||||
|
'SCHOOL_ID_CARD'
|
||||||
|
]);
|
||||||
|
|
||||||
|
private filterAddressProofsForNonIndividual(proofs: ProofType[]): ProofType[] {
|
||||||
|
if (!this.isNonIndividual) return proofs;
|
||||||
|
return proofs.filter(
|
||||||
|
(p) =>
|
||||||
|
!ClientIndiaAddressStepComponent.NON_INDIVIDUAL_EXCLUDED_ADDRESS_PROOF_CODES.has((p.code || '').toUpperCase())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
selectAddrProof(proof: ProofType): void {
|
selectAddrProof(proof: ProofType): void {
|
||||||
this.selectedAddrProof = proof;
|
this.selectedAddrProof = proof;
|
||||||
this.permanentForm.get('proofId')?.setValue(proof.id);
|
this.permanentForm.get('proofId')?.setValue(proof.id);
|
||||||
@@ -330,11 +415,12 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
|
|||||||
files.push({ slot: 'Back', file: this.addrFiles.back });
|
files.push({ slot: 'Back', file: this.addrFiles.back });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const rawIssueDate = this.permanentForm.get('issueDate')?.value;
|
||||||
return {
|
return {
|
||||||
proof: this.selectedAddrProof,
|
proof: this.selectedAddrProof,
|
||||||
proofId: this.permanentForm.get('proofId')?.value || '',
|
proofId: this.permanentForm.get('proofId')?.value || '',
|
||||||
documentKey: this.permanentForm.get('documentKey')?.value || '',
|
documentKey: this.permanentForm.get('documentKey')?.value || '',
|
||||||
issueDate: this.permanentForm.get('issueDate')?.value || '',
|
issueDate: rawIssueDate ? this.dates.getDate(rawIssueDate) : '',
|
||||||
files
|
files
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -355,6 +441,25 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (changes['isMinor'] && !changes['isMinor'].firstChange && this.addressProofs.length) {
|
||||||
|
const filtered = this.filterAddressProofsForMinor(this.addressProofs);
|
||||||
|
if (filtered.length !== this.addressProofs.length) {
|
||||||
|
this.addressProofs = filtered;
|
||||||
|
this.addressGroups = groupProofsByCategory(this.addressProofs);
|
||||||
|
if (this.selectedAddrProof && !filtered.some((p) => p.id === this.selectedAddrProof?.id)) {
|
||||||
|
this.selectedAddrProof = null;
|
||||||
|
this.permanentForm.get('proofId')?.setValue('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Legal form toggled on an already-initialized instance — re-fetch rather than filter
|
||||||
|
// `addressProofs` in place, since the non-individual filter only ever removes entries and
|
||||||
|
// toggling back to individual could never recover what a prior pass had stripped out.
|
||||||
|
if (changes['isNonIndividual'] && !changes['isNonIndividual'].firstChange) {
|
||||||
|
this.selectedAddrProof = null;
|
||||||
|
this.permanentForm.get('proofId')?.setValue('');
|
||||||
|
this.loadProofTypes();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -458,13 +563,13 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
|
|||||||
proofId: matchedProof.id,
|
proofId: matchedProof.id,
|
||||||
documentKey: identifier.documentKey || ''
|
documentKey: identifier.documentKey || ''
|
||||||
});
|
});
|
||||||
// The description carries `Issue date: dd/mm/yyyy` when set on create (see
|
// The description carries `Issue date: yyyy-MM-dd` when set on create (see
|
||||||
// create-client.saveAddressProof) — pull it out so the form's issueDate field
|
// create-client.saveAddressProof) — pull it out so the form's issueDate field
|
||||||
// shows what was previously entered.
|
// shows what was previously entered.
|
||||||
const desc: string = identifier.description || '';
|
const desc: string = identifier.description || '';
|
||||||
const issueMatch = desc.match(/Issue date:\s*(.+)$/i);
|
const issueMatch = desc.match(/Issue date:\s*(.+)$/i);
|
||||||
if (issueMatch) {
|
if (issueMatch) {
|
||||||
this.permanentForm.patchValue({ issueDate: issueMatch[1].trim() });
|
this.permanentForm.patchValue({ issueDate: this.dates.parseDate(issueMatch[1].trim()) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -566,33 +671,17 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Open the saved address proof in an in-app preview dialog. */
|
||||||
* Open the saved address proof in a new browser tab.
|
|
||||||
*
|
|
||||||
* For images we already hold a base64 data URL in `previewUrl` from
|
|
||||||
* `loadExistingAddressProof`, so we can hand it straight to `window.open`.
|
|
||||||
* For PDFs / other types we need to fetch the binary blob and create a
|
|
||||||
* temporary object URL so the browser's PDF viewer can render it.
|
|
||||||
*/
|
|
||||||
viewExistingAddressProof(): void {
|
viewExistingAddressProof(): void {
|
||||||
if (!this.existingAddressProofIdentifierId || this.existingAddressProofDocuments.length === 0) {
|
if (!this.existingAddressProofIdentifierId || this.existingAddressProofDocuments.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const doc = this.existingAddressProofDocuments[0];
|
const doc = this.existingAddressProofDocuments[0];
|
||||||
if (doc.previewUrl) {
|
|
||||||
window.open(doc.previewUrl, '_blank', 'noopener');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.clientsService.downloadClientIdentificationDocument(this.existingAddressProofIdentifierId, doc.id).subscribe({
|
this.clientsService.downloadClientIdentificationDocument(this.existingAddressProofIdentifierId, doc.id).subscribe({
|
||||||
next: (blob: Blob) => {
|
next: (blob: Blob) =>
|
||||||
const url = URL.createObjectURL(blob);
|
this.dialog.open(DocumentPreviewDialogComponent, {
|
||||||
const w = window.open(url, '_blank', 'noopener');
|
data: { blob, title: doc.fileName || doc.name, fileName: doc.fileName || doc.name }
|
||||||
// Revoke after the tab has had time to load — browsers vary, so
|
}),
|
||||||
// err on the side of a generous wait.
|
|
||||||
if (w) {
|
|
||||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
error: () => {
|
error: () => {
|
||||||
// Silent fail — UI shows the saved name so user knows it exists.
|
// Silent fail — UI shows the saved name so user knows it exists.
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,33 +28,36 @@
|
|||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<!-- ══ INDIVIDUAL KYC ══ -->
|
<!-- ══ INDIVIDUAL KYC ══ -->
|
||||||
@if (!isNonIndividual) {
|
@if (!isNonIndividual) {
|
||||||
@if (readOnly) {
|
<!-- Minors don't hold a PAN card — skip the PAN checkbox/section entirely -->
|
||||||
<!-- In view mode the mat-checkbox renders as a faded grey square
|
@if (!isMinor) {
|
||||||
that reads as "blurry". Show a clean Yes/No pill instead. -->
|
@if (readOnly) {
|
||||||
<div class="form-field checkbox-field full-width">
|
<!-- In view mode the mat-checkbox renders as a faded grey square
|
||||||
<div class="ro-flag">
|
that reads as "blurry". Show a clean Yes/No pill instead. -->
|
||||||
<span class="ro-flag__label">Client has PAN Card</span>
|
<div class="form-field checkbox-field full-width">
|
||||||
@if (kycForm.get('hasPan')?.value) {
|
<div class="ro-flag">
|
||||||
<span class="ro-flag__chip ro-flag__chip--yes">
|
<span class="ro-flag__label">Client has PAN Card</span>
|
||||||
<mat-icon>check_circle</mat-icon>
|
@if (kycForm.get('hasPan')?.value) {
|
||||||
Yes
|
<span class="ro-flag__chip ro-flag__chip--yes">
|
||||||
</span>
|
<mat-icon>check_circle</mat-icon>
|
||||||
} @else {
|
Yes
|
||||||
<span class="ro-flag__chip ro-flag__chip--no">
|
</span>
|
||||||
<mat-icon>cancel</mat-icon>
|
} @else {
|
||||||
No
|
<span class="ro-flag__chip ro-flag__chip--no">
|
||||||
</span>
|
<mat-icon>cancel</mat-icon>
|
||||||
}
|
No
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
} @else {
|
||||||
} @else {
|
<div class="form-field checkbox-field full-width">
|
||||||
<div class="form-field checkbox-field full-width">
|
<mat-checkbox formControlName="hasPan">Client has PAN Card</mat-checkbox>
|
||||||
<mat-checkbox formControlName="hasPan">Client has PAN Card</mat-checkbox>
|
</div>
|
||||||
</div>
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- Individual PAN with inline verify -->
|
<!-- Individual PAN with inline verify -->
|
||||||
@if (kycForm.get('hasPan')?.value) {
|
@if (!isMinor && kycForm.get('hasPan')?.value) {
|
||||||
<div class="form-field verify-field">
|
<div class="form-field verify-field">
|
||||||
<mat-form-field appearance="outline" class="verify-input">
|
<mat-form-field appearance="outline" class="verify-input">
|
||||||
<mat-label>PAN Number</mat-label>
|
<mat-label>PAN Number</mat-label>
|
||||||
@@ -158,6 +161,17 @@
|
|||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
|
@if (aadhaarOtpSent && aadhaarVerifyStatus !== 'VERIFIED') {
|
||||||
|
<button
|
||||||
|
mat-stroked-button
|
||||||
|
type="button"
|
||||||
|
class="change-number-btn"
|
||||||
|
[disabled]="readOnly"
|
||||||
|
(click)="changeAadhaarNumber()"
|
||||||
|
>
|
||||||
|
<mat-icon>edit</mat-icon> Change Number
|
||||||
|
</button>
|
||||||
|
}
|
||||||
@if (aadhaarVerifyStatus === 'VERIFIED') {
|
@if (aadhaarVerifyStatus === 'VERIFIED') {
|
||||||
<span class="verify-chip verified"><fa-icon icon="check-circle"></fa-icon> Verified</span>
|
<span class="verify-chip verified"><fa-icon icon="check-circle"></fa-icon> Verified</span>
|
||||||
}
|
}
|
||||||
@@ -709,6 +723,33 @@
|
|||||||
<span>PAN verification requires client to be created first. Complete General tab and click Next to save.</span>
|
<span>PAN verification requires client to be created first. Complete General tab and click Next to save.</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<!-- KYC Exception Flag -->
|
||||||
|
<div class="exception-flag-box" style="margin-top: 20px; padding: 16px; border: 1px solid #f59e0b; background: #fffbeb; border-radius: 8px; clear: both;">
|
||||||
|
<h5 style="margin: 0 0 12px 0; font-size: 14px; font-weight: 600; color: #b45309; display: flex; align-items: center; gap: 8px;">
|
||||||
|
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||||
|
KYC Exception Flag
|
||||||
|
</h5>
|
||||||
|
<div style="display: flex; align-items: center; gap: 20px; flex-wrap: wrap;">
|
||||||
|
<mat-checkbox formControlName="isException">Mark as KYC Exception</mat-checkbox>
|
||||||
|
|
||||||
|
@if (kycForm.get('isException')?.value) {
|
||||||
|
<mat-form-field appearance="outline" style="flex: 1; min-width: 280px; margin-bottom: -1.25em;">
|
||||||
|
<mat-label>Exception Reasons</mat-label>
|
||||||
|
<mat-select formControlName="exceptionReason" multiple>
|
||||||
|
@if (!isNonIndividual) {
|
||||||
|
<mat-option value="PAN Card Verification Failed">PAN Card Verification Failed</mat-option>
|
||||||
|
<mat-option value="Aadhaar Verification Failed">Aadhaar Verification Failed</mat-option>
|
||||||
|
} @else {
|
||||||
|
<mat-option value="Enterprise PAN Verification Failed">Enterprise PAN Verification Failed</mat-option>
|
||||||
|
<mat-option value="GSTIN Verification Failed">GSTIN Verification Failed</mat-option>
|
||||||
|
<mat-option value="CIN Verification Failed">CIN Verification Failed</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ═══ SECTION: KYC Document Management ═══ -->
|
<!-- ═══ SECTION: KYC Document Management ═══ -->
|
||||||
@@ -1035,6 +1076,12 @@
|
|||||||
</h4>
|
</h4>
|
||||||
|
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
|
<div class="checkbox-field override-checkbox-wrapper full-width">
|
||||||
|
<mat-checkbox formControlName="allowRiskOverride" [disabled]="readOnly">
|
||||||
|
Override Auto-calculated Risk Category
|
||||||
|
</mat-checkbox>
|
||||||
|
</div>
|
||||||
|
|
||||||
<mat-form-field appearance="outline" class="form-field">
|
<mat-form-field appearance="outline" class="form-field">
|
||||||
<mat-label>Risk Category</mat-label>
|
<mat-label>Risk Category</mat-label>
|
||||||
<mat-select formControlName="riskCategory">
|
<mat-select formControlName="riskCategory">
|
||||||
@@ -1072,6 +1119,64 @@
|
|||||||
<mat-hint>Auto: Low 10yr, Medium 8yr, High 2yr</mat-hint>
|
<mat-hint>Auto: Low 10yr, Medium 8yr, High 2yr</mat-hint>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
|
|
||||||
|
@if (riskCalculationReason && !kycForm.get('allowRiskOverride')?.value && !readOnly) {
|
||||||
|
<div
|
||||||
|
class="risk-auto-calc-info full-width"
|
||||||
|
[class.risk-auto-calc-info--low]="kycForm.get('riskCategory')?.value === 1"
|
||||||
|
[class.risk-auto-calc-info--medium]="kycForm.get('riskCategory')?.value === 2"
|
||||||
|
[class.risk-auto-calc-info--high]="kycForm.get('riskCategory')?.value === 3"
|
||||||
|
>
|
||||||
|
<mat-icon class="info-icon">info</mat-icon>
|
||||||
|
<span class="info-text"
|
||||||
|
>Auto-determined: <strong>{{ riskCalculationReason }}</strong></span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="risk-rules-toggle-wrapper full-width">
|
||||||
|
<button mat-button type="button" class="rules-toggle-btn" (click)="showRiskRulesTable = !showRiskRulesTable">
|
||||||
|
<mat-icon class="rules-toggle-icon">{{ showRiskRulesTable ? 'expand_less' : 'expand_more' }}</mat-icon>
|
||||||
|
{{
|
||||||
|
showRiskRulesTable ? 'Hide Risk Classification Criteria Table' : 'View Risk Classification Criteria Table'
|
||||||
|
}}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (showRiskRulesTable) {
|
||||||
|
<div class="risk-rules-card mat-elevation-z1 full-width">
|
||||||
|
<table class="risk-rules-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Risk Category</th>
|
||||||
|
<th>Primary Criteria / Basis</th>
|
||||||
|
<th>Examples / Thresholds</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr class="rule-row rule-row--high">
|
||||||
|
<td><span class="risk-badge risk-badge--high">High Risk</span></td>
|
||||||
|
<td>PEPs, High-Risk Industries, High Income / Turnovers</td>
|
||||||
|
<td>
|
||||||
|
Politicians; Real Estate, Import/Export; Commission Agents; Income > ₹20L/yr; Turnover > ₹1 Crore
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="rule-row rule-row--medium">
|
||||||
|
<td><span class="risk-badge risk-badge--medium">Medium Risk</span></td>
|
||||||
|
<td>Self-Employed, Professionals, Cross-Border, Moderate Income</td>
|
||||||
|
<td>
|
||||||
|
Business Owners, Traders; Doctors, Lawyers, CAs; NRI, PIO, OCI; Income > ₹6L/yr; Turnover > ₹20L
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="rule-row rule-row--low">
|
||||||
|
<td><span class="risk-badge risk-badge--low">Low Risk</span></td>
|
||||||
|
<td>Non-Earning, Government Employment, Agriculture / Manual</td>
|
||||||
|
<td>Students, Minors, Homemakers; Govt/PSU Employees; Farmers, Daily Wage; Income <= ₹6L/yr</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<mat-form-field appearance="outline" class="form-field full-width">
|
<mat-form-field appearance="outline" class="form-field full-width">
|
||||||
<mat-label>{{ isKycVerified ? 'Reason for Re-edit *' : 'Remarks' }}</mat-label>
|
<mat-label>{{ isKycVerified ? 'Reason for Re-edit *' : 'Remarks' }}</mat-label>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -1173,6 +1278,12 @@
|
|||||||
</h4>
|
</h4>
|
||||||
|
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
|
<div class="checkbox-field override-checkbox-wrapper full-width">
|
||||||
|
<mat-checkbox formControlName="allowAmlOverride" [disabled]="readOnly">
|
||||||
|
Override Auto-calculated AML & Compliance Checks
|
||||||
|
</mat-checkbox>
|
||||||
|
</div>
|
||||||
|
|
||||||
<mat-form-field appearance="outline" class="form-field">
|
<mat-form-field appearance="outline" class="form-field">
|
||||||
<mat-label>AML Check Status</mat-label>
|
<mat-label>AML Check Status</mat-label>
|
||||||
<mat-select required formControlName="amlCheckStatus">
|
<mat-select required formControlName="amlCheckStatus">
|
||||||
@@ -1266,11 +1377,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ═══ SECTION: Form 60/61 ═══ -->
|
<!-- ═══ SECTION: Form 97/98 ═══ -->
|
||||||
<div class="section-block">
|
<div class="section-block">
|
||||||
<h4 class="section-title">
|
<h4 class="section-title">
|
||||||
<fa-icon icon="file-invoice" class="section-icon"></fa-icon>
|
<fa-icon icon="file-invoice" class="section-icon"></fa-icon>
|
||||||
Form 60 / 61
|
Form 97 / 98
|
||||||
@if (!kycForm.get('hasPan')?.value) {
|
@if (!kycForm.get('hasPan')?.value) {
|
||||||
<span class="kyc-badge required-badge">Required — No PAN</span>
|
<span class="kyc-badge required-badge">Required — No PAN</span>
|
||||||
}
|
}
|
||||||
@@ -1278,14 +1389,14 @@
|
|||||||
@if (!kycForm.get('hasPan')?.value) {
|
@if (!kycForm.get('hasPan')?.value) {
|
||||||
<div class="kyc-info-hint warning-hint">
|
<div class="kyc-info-hint warning-hint">
|
||||||
<mat-icon class="kyc-info-hint-icon">warning</mat-icon>
|
<mat-icon class="kyc-info-hint-icon">warning</mat-icon>
|
||||||
<span>Client does not have PAN. Form 60/61 is mandatory.</span>
|
<span>Client does not have PAN. Form 97/98 is mandatory.</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<mat-form-field appearance="outline" class="form-field">
|
<mat-form-field appearance="outline" class="form-field">
|
||||||
<mat-label>Form Type</mat-label>
|
<mat-label>Form Type</mat-label>
|
||||||
<mat-select formControlName="form60Type">
|
<mat-select formControlName="form60Type">
|
||||||
<mat-option value="FORM_60">Form 60</mat-option>
|
<mat-option value="FORM_60">Form 97</mat-option>
|
||||||
<mat-option value="FORM_61">Form 61</mat-option>
|
<mat-option value="FORM_61">Form 98</mat-option>
|
||||||
</mat-select>
|
</mat-select>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
<mat-form-field appearance="outline" class="form-field" (click)="form60DatePicker.open()">
|
<mat-form-field appearance="outline" class="form-field" (click)="form60DatePicker.open()">
|
||||||
@@ -1296,26 +1407,26 @@
|
|||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<p class="form60-not-needed">Client has PAN — Form 60/61 not required.</p>
|
<p class="form60-not-needed">Client has PAN — Form 97/98 not required.</p>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ═══ SECTION: Form 15G / 15H ═══ -->
|
<!-- ═══ SECTION: Form 121 ═══ -->
|
||||||
<div class="section-block">
|
<div class="section-block">
|
||||||
<h4 class="section-title">
|
<h4 class="section-title">
|
||||||
<fa-icon icon="receipt" class="section-icon"></fa-icon>
|
<fa-icon icon="receipt" class="section-icon"></fa-icon>
|
||||||
Form 15G / 15H
|
Form 121
|
||||||
</h4>
|
</h4>
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<div class="form-field checkbox-field">
|
<div class="form-field checkbox-field">
|
||||||
<mat-checkbox formControlName="form15Applicable">Form 15G/15H Applicable</mat-checkbox>
|
<mat-checkbox formControlName="form15Applicable">Form 121 Applicable</mat-checkbox>
|
||||||
</div>
|
</div>
|
||||||
@if (kycForm.get('form15Applicable')?.value) {
|
@if (kycForm.get('form15Applicable')?.value) {
|
||||||
<mat-form-field appearance="outline" class="form-field">
|
<mat-form-field appearance="outline" class="form-field">
|
||||||
<mat-label>Form Type</mat-label>
|
<mat-label>Form Type</mat-label>
|
||||||
<mat-select formControlName="form15Type">
|
<mat-select formControlName="form15Type">
|
||||||
<mat-option value="15G">15G (Below 60 years)</mat-option>
|
<mat-option value="15G">Form 121 (Below 60 years)</mat-option>
|
||||||
<mat-option value="15H">15H (Senior Citizen 60+)</mat-option>
|
<mat-option value="15H">Form 121 (Senior Citizen 60+)</mat-option>
|
||||||
</mat-select>
|
</mat-select>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
<mat-form-field appearance="outline" class="form-field">
|
<mat-form-field appearance="outline" class="form-field">
|
||||||
@@ -1326,6 +1437,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<!-- ═══ SECTION: Photograph & Signature ═══ -->
|
<!-- ═══ SECTION: Photograph & Signature ═══ -->
|
||||||
<div class="section-block">
|
<div class="section-block">
|
||||||
<h4 class="section-title">
|
<h4 class="section-title">
|
||||||
@@ -1403,8 +1515,8 @@
|
|||||||
<input type="file" accept="image/*" hidden (change)="onSignatureSelected($event)" />
|
<input type="file" accept="image/*" hidden (change)="onSignatureSelected($event)" />
|
||||||
<fa-icon icon="crop"></fa-icon> Choose & Crop
|
<fa-icon icon="crop"></fa-icon> Choose & Crop
|
||||||
</label>
|
</label>
|
||||||
<button type="button" class="upload-box__btn upload-box__btn--secondary" (click)="captureWebcamSignature()">
|
<button type="button" class="upload-box__btn upload-box__btn--secondary" (click)="drawSignature()">
|
||||||
<fa-icon icon="video"></fa-icon> Use Webcam
|
<fa-icon icon="pen"></fa-icon> Draw Signature
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@if (photoSigTouched) {
|
@if (photoSigTouched) {
|
||||||
|
|||||||
@@ -266,6 +266,23 @@
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.change-number-btn {
|
||||||
|
font-size: 12px !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
padding: 0 14px !important;
|
||||||
|
height: 36px !important;
|
||||||
|
border-radius: 6px !important;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.verify-chip {
|
.verify-chip {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -297,8 +314,24 @@
|
|||||||
.toggle-vis-btn {
|
.toggle-vis-btn {
|
||||||
width: 28px !important;
|
width: 28px !important;
|
||||||
height: 28px !important;
|
height: 28px !important;
|
||||||
|
padding: 0 !important;
|
||||||
font-size: 13px !important;
|
font-size: 13px !important;
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
|
|
||||||
|
/* Material reserves a 48x48 invisible touch-target + ripple around every
|
||||||
|
mat-icon-button by default. Shrinking the button to 28px above doesn't
|
||||||
|
shrink that touch-target/ripple with it, so — since the suffix
|
||||||
|
container doesn't clip overflow — it visually spills out past the
|
||||||
|
field's own border, making the icon look misplaced/half-outside the box. */
|
||||||
|
::ng-deep .mat-mdc-button-touch-target {
|
||||||
|
width: 28px !important;
|
||||||
|
height: 28px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
::ng-deep .mat-mdc-button-persistent-ripple,
|
||||||
|
::ng-deep .mat-ripple {
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Verification Info Banner */
|
/* Verification Info Banner */
|
||||||
@@ -2240,3 +2273,161 @@
|
|||||||
width: 95px;
|
width: 95px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.override-checkbox-wrapper {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-auto-calc-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-top: -8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
|
||||||
|
.info-icon {
|
||||||
|
font-size: 18px !important;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-text strong {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--low {
|
||||||
|
background: #ecfdf5;
|
||||||
|
color: #065f46;
|
||||||
|
border-color: #a7f3d0;
|
||||||
|
|
||||||
|
.info-icon {
|
||||||
|
color: #10b981;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--medium {
|
||||||
|
background: #fffbeb;
|
||||||
|
color: #92400e;
|
||||||
|
border-color: #fde68a;
|
||||||
|
|
||||||
|
.info-icon {
|
||||||
|
color: #f59e0b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--high {
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #991b1b;
|
||||||
|
border-color: #fecaca;
|
||||||
|
|
||||||
|
.info-icon {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-rules-toggle-wrapper {
|
||||||
|
margin-top: 4px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
|
.rules-toggle-btn {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1e6bb8;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 20px;
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
background: #f1f5f9;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #e2e8f0;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-rules-card {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
box-shadow: 0 1px 3px rgb(0 0 0 / 5%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-rules-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: left;
|
||||||
|
|
||||||
|
th {
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #475569;
|
||||||
|
border-bottom: 2px solid #cbd5e1;
|
||||||
|
background: #f8fafc;
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 12px;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
color: #334155;
|
||||||
|
vertical-align: top;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-row {
|
||||||
|
transition: background 0.15s ease-in-out;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
text-align: center;
|
||||||
|
width: 80px;
|
||||||
|
|
||||||
|
&--high {
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #dc2626;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--medium {
|
||||||
|
background: #fffbeb;
|
||||||
|
color: #d97706;
|
||||||
|
border: 1px solid #fde68a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--low {
|
||||||
|
background: #ecfdf5;
|
||||||
|
color: #16a34a;
|
||||||
|
border: 1px solid #a7f3d0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,9 +29,12 @@ 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 { dataUrlToFile } from 'app/shared/utils/data-url.util';
|
import { dataUrlToFile } from 'app/shared/utils/data-url.util';
|
||||||
|
import { DocumentPreviewDialogComponent } from '../../shared/document-preview-dialog/document-preview-dialog.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'mifosx-client-kyc-step',
|
selector: 'mifosx-client-kyc-step',
|
||||||
@@ -61,12 +64,23 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
/** Set by parent after client is created */
|
/** Set by parent after client is created */
|
||||||
@Input() clientId: string = '';
|
@Input() clientId: string = '';
|
||||||
@Input() isNonIndividual = false;
|
@Input() isNonIndividual = false;
|
||||||
|
/** Minor clients don't hold a PAN card — hides the PAN section on the individual KYC form */
|
||||||
|
@Input() isMinor = false;
|
||||||
/** Constitution metadata (panLabel, showCin, etc.) passed from general step */
|
/** Constitution metadata (panLabel, showCin, etc.) passed from general step */
|
||||||
@Input() constitutionMeta: any = null;
|
@Input() constitutionMeta: any = null;
|
||||||
/** Entity full name (non-individual) — used for enterprise PAN verification */
|
/** Entity full name (non-individual) — used for enterprise PAN verification */
|
||||||
@Input() entityName: string = '';
|
@Input() entityName: string = '';
|
||||||
/** Read-only mode — disables form editing until user clicks edit icon */
|
/** Read-only mode — disables form editing until user clicks edit icon */
|
||||||
@Input() readOnly = false;
|
@Input() readOnly = false;
|
||||||
|
@Input() calculatedRisk: {
|
||||||
|
category: number;
|
||||||
|
reason: string;
|
||||||
|
amlCheckStatus?: string;
|
||||||
|
bannedEntityMatch?: string;
|
||||||
|
willfulDefaulter?: string;
|
||||||
|
ecgcSalFlag?: string;
|
||||||
|
dormantFlag?: string;
|
||||||
|
} | null = null;
|
||||||
|
|
||||||
today = new Date();
|
today = new Date();
|
||||||
kycId: string = '';
|
kycId: string = '';
|
||||||
@@ -139,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
|
||||||
@@ -163,7 +178,20 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
{ name: 'Driving Licence', code: 'DRIVING_LICENSE' },
|
{ name: 'Driving Licence', code: 'DRIVING_LICENSE' },
|
||||||
{ name: 'Voter ID', code: 'VOTER_ID' },
|
{ name: 'Voter ID', code: 'VOTER_ID' },
|
||||||
{ name: 'NREGA Job Card', code: 'NREGA_JOB_CARD' },
|
{ name: 'NREGA Job Card', code: 'NREGA_JOB_CARD' },
|
||||||
{ name: 'National Population Register Letter', code: 'NPR_LETTER' }
|
{ name: 'National Population Register Letter', code: 'NPR_LETTER' },
|
||||||
|
{ name: 'Birth Certificate', code: 'BIRTH_CERTIFICATE' },
|
||||||
|
{ name: 'School ID Card', code: 'SCHOOL_ID_CARD' }
|
||||||
|
];
|
||||||
|
/** Generic RBI-prescribed KYC documents for legal entities (Master Direction on KYC,
|
||||||
|
* Annexure I) — shown for non-individual clients before a specific constitution type is
|
||||||
|
* picked. Individual OVDs (Aadhaar/Passport/DL/Voter ID/NREGA/NPR) don't apply to entities. */
|
||||||
|
private nonIndividualDefaultDocTypes = [
|
||||||
|
{ name: 'Certificate of Incorporation / Registration Certificate', code: 'CERT_INCORPORATION' },
|
||||||
|
{ name: 'PAN Card of Entity', code: 'ENTITY_PAN' },
|
||||||
|
{ name: 'Memorandum & Articles of Association / Partnership Deed / Trust Deed', code: 'CONSTITUTIVE_DOC' },
|
||||||
|
{ name: 'Board Resolution / Authorization Letter', code: 'BOARD_RESOLUTION' },
|
||||||
|
{ name: 'KYC of Authorized Signatory / Power of Attorney Holder', code: 'AUTH_SIGNATORY_KYC' },
|
||||||
|
{ name: 'GST Registration Certificate', code: 'GST_CERTIFICATE' }
|
||||||
];
|
];
|
||||||
/** Constitution-based KYC document types for non-individual clients */
|
/** Constitution-based KYC document types for non-individual clients */
|
||||||
private constitutionDocTypes: Record<string, { name: string; mandatory: boolean }[]> = {
|
private constitutionDocTypes: Record<string, { name: string; mandatory: boolean }[]> = {
|
||||||
@@ -245,6 +273,17 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
{ value: 3, label: 'High Risk', years: 2, color: 'red' }
|
{ value: 3, label: 'High Risk', years: 2, color: 'red' }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
riskCalculationReason = '';
|
||||||
|
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;
|
||||||
@@ -394,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: [''],
|
||||||
@@ -436,7 +475,11 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
kycDocIssueDate: [''],
|
kycDocIssueDate: [''],
|
||||||
kycDocExpiryDate: [''],
|
kycDocExpiryDate: [''],
|
||||||
riskCategory: [1],
|
riskCategory: [1],
|
||||||
|
allowRiskOverride: [false],
|
||||||
|
allowAmlOverride: [false],
|
||||||
remarks: [''],
|
remarks: [''],
|
||||||
|
isException: [false],
|
||||||
|
exceptionReason: [[]],
|
||||||
amlCheckStatus: [
|
amlCheckStatus: [
|
||||||
'CLEAR',
|
'CLEAR',
|
||||||
Validators.required
|
Validators.required
|
||||||
@@ -458,22 +501,181 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
form15FY: ['']
|
form15FY: ['']
|
||||||
});
|
});
|
||||||
|
|
||||||
ngOnInit(): void {
|
/** RBI-accepted OVDs for a minor client — mirrors the backend's minor branch in
|
||||||
// Seed defaults immediately so the picker has options before clientId is set
|
* ClientKycApiResource.getRequiredDocuments(). Voter ID, Driving Licence, PAN and NREGA
|
||||||
// (template API requires clientId; user can still pick a type pre-save).
|
* all require adult eligibility; National Population Register Letter is deliberately
|
||||||
if (!this.kycDocumentTypes.length) {
|
* excluded too (not offered as a minor OVD in this system). */
|
||||||
this.kycDocumentTypes = this.defaultDocumentTypes.map((d) => ({
|
private static readonly MINOR_ELIGIBLE_DOC_CODES = new Set([
|
||||||
name: d.name,
|
'AADHAAR',
|
||||||
code: d.code,
|
'PASSPORT',
|
||||||
mandatory: false
|
'BIRTH_CERTIFICATE',
|
||||||
}));
|
'SCHOOL_ID_CARD'
|
||||||
this.rebuildKycDocPicker();
|
]);
|
||||||
|
|
||||||
|
/** Birth Certificate / School ID Card are minor-specific OVDs — not relevant once the
|
||||||
|
* client is an adult, so they're hidden the other direction. */
|
||||||
|
private static readonly MINOR_ONLY_DOC_CODES = new Set([
|
||||||
|
'BIRTH_CERTIFICATE',
|
||||||
|
'SCHOOL_ID_CARD'
|
||||||
|
]);
|
||||||
|
|
||||||
|
private filterDocTypesForMinor<T extends { name: string; code?: string }>(docs: T[]): T[] {
|
||||||
|
if (this.isMinor) {
|
||||||
|
return docs.filter((d) => ClientKycStepComponent.MINOR_ELIGIBLE_DOC_CODES.has((d.code || '').toUpperCase()));
|
||||||
}
|
}
|
||||||
|
return docs.filter((d) => !ClientKycStepComponent.MINOR_ONLY_DOC_CODES.has((d.code || '').toUpperCase()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Individual-only OVDs vs. entity-only RBI docs — never both, and minor filtering only
|
||||||
|
* ever applies to individuals. */
|
||||||
|
private getDefaultDocTypes(): { name: string; code: string }[] {
|
||||||
|
return this.isNonIndividual
|
||||||
|
? this.nonIndividualDefaultDocTypes
|
||||||
|
: this.filterDocTypesForMinor(this.defaultDocumentTypes);
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.seedDefaultDocTypes();
|
||||||
this.loadExistingKycIfNeeded();
|
this.loadExistingKycIfNeeded();
|
||||||
this.loadKycTemplate();
|
this.loadKycTemplate();
|
||||||
if (this.isNonIndividual && this.constitutionMeta) {
|
if (this.isNonIndividual && this.constitutionMeta) {
|
||||||
this.updateDocTypesForConstitution();
|
this.updateDocTypesForConstitution();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.kycForm.get('allowRiskOverride')?.valueChanges.subscribe((allowed: boolean) => {
|
||||||
|
const riskControl = this.kycForm.get('riskCategory');
|
||||||
|
if (allowed) {
|
||||||
|
if (!this.readOnly) {
|
||||||
|
riskControl?.enable();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
riskControl?.disable();
|
||||||
|
if (this.calculatedRisk) {
|
||||||
|
riskControl?.setValue(this.calculatedRisk.category);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.kycForm.get('allowAmlOverride')?.valueChanges.subscribe((allowed: boolean) => {
|
||||||
|
const amlControls = [
|
||||||
|
this.kycForm.get('amlCheckStatus'),
|
||||||
|
this.kycForm.get('bannedEntityMatch'),
|
||||||
|
this.kycForm.get('willfulDefaulter'),
|
||||||
|
this.kycForm.get('ecgcSalFlag'),
|
||||||
|
this.kycForm.get('dormantFlag')
|
||||||
|
];
|
||||||
|
if (allowed) {
|
||||||
|
if (!this.readOnly) {
|
||||||
|
amlControls.forEach((ctrl) => ctrl?.enable());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
amlControls.forEach((ctrl) => ctrl?.disable());
|
||||||
|
this.applyAutoCalculatedAmlFlags();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!this.kycForm.get('allowRiskOverride')?.value) {
|
||||||
|
this.kycForm.get('riskCategory')?.disable();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.kycForm.get('allowAmlOverride')?.value) {
|
||||||
|
this.kycForm.get('amlCheckStatus')?.disable();
|
||||||
|
this.kycForm.get('bannedEntityMatch')?.disable();
|
||||||
|
this.kycForm.get('willfulDefaulter')?.disable();
|
||||||
|
this.kycForm.get('ecgcSalFlag')?.disable();
|
||||||
|
this.kycForm.get('dormantFlag')?.disable();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seed the document-type picker from defaults (entity RBI docs vs. individual OVDs,
|
||||||
|
* filtered by the current isMinor input for individuals). Only seeds when the list is
|
||||||
|
* empty — the template API (loadKycTemplate) takes over once a clientId is available. */
|
||||||
|
private seedDefaultDocTypes(): void {
|
||||||
|
if (this.kycDocumentTypes.length) return;
|
||||||
|
this.kycDocumentTypes = this.getDefaultDocTypes().map((d) => ({
|
||||||
|
name: d.name,
|
||||||
|
code: d.code,
|
||||||
|
mandatory: false
|
||||||
|
}));
|
||||||
|
this.rebuildKycDocPicker();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resets transient KYC-tab state for a brand-new client.
|
||||||
|
*
|
||||||
|
* This app's RouteReusableStrategy (core/route/route-reusable-strategy.ts) keeps this SAME
|
||||||
|
* component instance alive across repeat visits to /clients/create, so `ngOnInit` never
|
||||||
|
* re-runs for a second "create new client" session. Without this, the document-type picker
|
||||||
|
* (and any PAN/Aadhaar verification state, uploaded photo/signature) would otherwise leak
|
||||||
|
* from whatever client was created in the previous session into the new one. Called by
|
||||||
|
* CreateClientComponent.resetForNewClient() whenever a fresh create session begins.
|
||||||
|
*/
|
||||||
|
resetForNewClient(): void {
|
||||||
|
this.kycForm.reset({
|
||||||
|
hasPan: true,
|
||||||
|
riskCategory: 1,
|
||||||
|
allowRiskOverride: false,
|
||||||
|
amlCheckStatus: 'CLEAR',
|
||||||
|
bannedEntityMatch: 'NO',
|
||||||
|
willfulDefaulter: 'NO',
|
||||||
|
ecgcSalFlag: 'NO',
|
||||||
|
dormantFlag: 'ACTIVE',
|
||||||
|
accountStatus: 'ACTIVE',
|
||||||
|
form60Type: 'FORM_60',
|
||||||
|
form15Type: '15G',
|
||||||
|
isException: false,
|
||||||
|
exceptionReason: []
|
||||||
|
});
|
||||||
|
this.persistedIsException = false;
|
||||||
|
this.persistedExceptionReason = null;
|
||||||
|
this.kycId = '';
|
||||||
|
this.kycLoaded = false;
|
||||||
|
this.kycStatus = 0;
|
||||||
|
this.kycTemplate = null;
|
||||||
|
this.kycTemplateLoaded = false;
|
||||||
|
this.kycDocumentTypes = [];
|
||||||
|
this.selectedKycDoc = null;
|
||||||
|
this.kycDocFiles = { front: null, back: null };
|
||||||
|
this.kycDocMergedFile = null;
|
||||||
|
this.kycDocMergedPreview = null;
|
||||||
|
this.existingKycIdentifierId = null;
|
||||||
|
this.riskCalculationReason = '';
|
||||||
|
this.showRiskRulesTable = false;
|
||||||
|
this.existingKycDocuments = [];
|
||||||
|
this.photoSigTouched = false;
|
||||||
|
this.panVerifyStatus = 'NONE';
|
||||||
|
this.panVerifying = false;
|
||||||
|
this.panVerifyError = '';
|
||||||
|
this.panVerifyName = '';
|
||||||
|
this.panVerifiedOn = '';
|
||||||
|
this.aadhaarVerifyStatus = 'NONE';
|
||||||
|
this.aadhaarVerifying = false;
|
||||||
|
this.aadhaarVerifyError = '';
|
||||||
|
this.aadhaarVerifyName = '';
|
||||||
|
this.aadhaarVisible = false;
|
||||||
|
this.aadhaarOtpSent = false;
|
||||||
|
this.resetOtpDigits();
|
||||||
|
this.aadhaarOtpReferenceId = '';
|
||||||
|
this.panAadhaarSeedingStatus = '';
|
||||||
|
this.entPanVerifyStatus = 'NONE';
|
||||||
|
this.entPanVerifying = false;
|
||||||
|
this.entPanVerifyName = '';
|
||||||
|
this.entPanVerifiedOn = '';
|
||||||
|
this.gstinVerifyStatus = 'NONE';
|
||||||
|
this.gstinVerifying = false;
|
||||||
|
this.gstinVerifyName = '';
|
||||||
|
this.gstinVerifiedOn = '';
|
||||||
|
this.gstinVerifyState = '';
|
||||||
|
this.cinVerifyStatus = 'NONE';
|
||||||
|
this.cinVerifying = false;
|
||||||
|
this.cinVerifiedOn = '';
|
||||||
|
this.cinCompanyName = '';
|
||||||
|
this.photographFile = null;
|
||||||
|
this.photographPreview = null;
|
||||||
|
this.signatureFile = null;
|
||||||
|
this.signaturePreview = null;
|
||||||
|
this.existingSignatureDocId = '';
|
||||||
|
this.seedDefaultDocTypes();
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
@@ -491,11 +693,75 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
this.kycForm.disable({ emitEvent: false });
|
this.kycForm.disable({ emitEvent: false });
|
||||||
} else {
|
} else {
|
||||||
this.kycForm.enable({ emitEvent: false });
|
this.kycForm.enable({ emitEvent: false });
|
||||||
|
if (!this.kycForm.get('allowRiskOverride')?.value) {
|
||||||
|
this.kycForm.get('riskCategory')?.disable({ emitEvent: false });
|
||||||
|
}
|
||||||
|
if (!this.kycForm.get('allowAmlOverride')?.value) {
|
||||||
|
this.kycForm.get('amlCheckStatus')?.disable({ emitEvent: false });
|
||||||
|
this.kycForm.get('bannedEntityMatch')?.disable({ emitEvent: false });
|
||||||
|
this.kycForm.get('willfulDefaulter')?.disable({ emitEvent: false });
|
||||||
|
this.kycForm.get('ecgcSalFlag')?.disable({ emitEvent: false });
|
||||||
|
this.kycForm.get('dormantFlag')?.disable({ emitEvent: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changes['calculatedRisk']) {
|
||||||
|
const newRisk = changes['calculatedRisk'].currentValue;
|
||||||
|
if (newRisk) {
|
||||||
|
this.riskCalculationReason = newRisk.reason;
|
||||||
|
const allowOverride = this.kycForm.get('allowRiskOverride')?.value;
|
||||||
|
const riskControl = this.kycForm.get('riskCategory');
|
||||||
|
if (!allowOverride && riskControl && riskControl.value !== newRisk.category) {
|
||||||
|
riskControl.setValue(newRisk.category);
|
||||||
|
riskControl.markAsDirty();
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowAmlOverride = this.kycForm.get('allowAmlOverride')?.value;
|
||||||
|
if (!allowAmlOverride) {
|
||||||
|
this.applyAutoCalculatedAmlFlags(newRisk);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.riskCalculationReason = '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (changes['constitutionMeta'] && this.isNonIndividual) {
|
if (changes['constitutionMeta'] && this.isNonIndividual) {
|
||||||
this.updateDocTypesForConstitution();
|
this.updateDocTypesForConstitution();
|
||||||
}
|
}
|
||||||
|
// Legal form toggled on an already-initialized instance (route-reuse keeps this
|
||||||
|
// component alive across create sessions) — the individual OVD list and the entity RBI
|
||||||
|
// doc list are mutually exclusive, so re-seed from scratch rather than filtering in place.
|
||||||
|
if (changes['isNonIndividual'] && !changes['isNonIndividual'].firstChange && !this.kycTemplateLoaded) {
|
||||||
|
this.kycDocumentTypes = [];
|
||||||
|
this.selectedKycDoc = null;
|
||||||
|
this.kycForm.get('kycDocumentName')?.setValue('');
|
||||||
|
this.seedDefaultDocTypes();
|
||||||
|
if (this.isNonIndividual && this.constitutionMeta) {
|
||||||
|
this.updateDocTypesForConstitution();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changes['isMinor'] && !this.isNonIndividual) {
|
||||||
|
if (this.isMinor) {
|
||||||
|
this.kycForm.get('hasPan')?.setValue(false);
|
||||||
|
this.kycForm.get('panNumber')?.setValue('');
|
||||||
|
}
|
||||||
|
// Re-derive from the full default list, not by re-filtering the already-narrowed
|
||||||
|
// `kycDocumentTypes` — filterDocTypesForMinor() only ever REMOVES entries, so once the
|
||||||
|
// initial (pre-DOB, isMinor=false) seed has already stripped out Birth Certificate /
|
||||||
|
// School ID Card, re-filtering that same shortened list can never bring them back once
|
||||||
|
// the client turns out to be a minor. Once the backend KYC template has loaded
|
||||||
|
// (kycTemplateLoaded), that list is already minor-aware and authoritative — leave it alone.
|
||||||
|
if (!this.kycTemplateLoaded) {
|
||||||
|
const rebuilt = this.filterDocTypesForMinor(
|
||||||
|
this.defaultDocumentTypes.map((d) => ({ name: d.name, code: d.code, mandatory: false }))
|
||||||
|
);
|
||||||
|
this.kycDocumentTypes = rebuilt;
|
||||||
|
const currentName = this.kycForm.get('kycDocumentName')?.value;
|
||||||
|
if (currentName && !rebuilt.some((d) => d.name === currentName)) {
|
||||||
|
this.kycForm.get('kycDocumentName')?.setValue('');
|
||||||
|
}
|
||||||
|
this.rebuildKycDocPicker();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Update KYC document types based on selected constitution type */
|
/** Update KYC document types based on selected constitution type */
|
||||||
@@ -566,30 +832,17 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
this.kycDocMergedPreview = null;
|
this.kycDocMergedPreview = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Open the saved KYC ID-proof document in an in-app preview dialog. */
|
||||||
* Open the saved KYC ID-proof document in a new browser tab.
|
|
||||||
*
|
|
||||||
* Same flow as the address-step variant: images use the already-loaded
|
|
||||||
* base64 data URL; PDFs (or anything without a cached preview) are fetched
|
|
||||||
* fresh and rendered via a temporary blob URL.
|
|
||||||
*/
|
|
||||||
viewExistingKycDoc(): void {
|
viewExistingKycDoc(): void {
|
||||||
if (!this.existingKycIdentifierId || this.existingKycDocuments.length === 0) {
|
if (!this.existingKycIdentifierId || this.existingKycDocuments.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const doc = this.existingKycDocuments[0];
|
const doc = this.existingKycDocuments[0];
|
||||||
if (doc.previewUrl) {
|
|
||||||
window.open(doc.previewUrl, '_blank', 'noopener');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.clientsService.downloadClientIdentificationDocument(this.existingKycIdentifierId, doc.id).subscribe({
|
this.clientsService.downloadClientIdentificationDocument(this.existingKycIdentifierId, doc.id).subscribe({
|
||||||
next: (blob: Blob) => {
|
next: (blob: Blob) =>
|
||||||
const url = URL.createObjectURL(blob);
|
this.dialog.open(DocumentPreviewDialogComponent, {
|
||||||
const w = window.open(url, '_blank', 'noopener');
|
data: { blob, title: doc.fileName || doc.name, fileName: doc.fileName || doc.name }
|
||||||
if (w) {
|
}),
|
||||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
error: () => {
|
error: () => {
|
||||||
/* silent — UI still shows the saved filename */
|
/* silent — UI still shows the saved filename */
|
||||||
}
|
}
|
||||||
@@ -680,6 +933,14 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
kycDocExpiryDate: parseDate(data.kycDocExpiryDate),
|
kycDocExpiryDate: parseDate(data.kycDocExpiryDate),
|
||||||
// Risk & Remarks
|
// Risk & Remarks
|
||||||
riskCategory: data.riskCategory || 1,
|
riskCategory: data.riskCategory || 1,
|
||||||
|
allowRiskOverride: this.calculatedRisk ? this.calculatedRisk.category !== (data.riskCategory || 1) : false,
|
||||||
|
allowAmlOverride: this.calculatedRisk
|
||||||
|
? this.calculatedRisk.amlCheckStatus !== (data.amlCheckStatus || 'CLEAR') ||
|
||||||
|
this.calculatedRisk.bannedEntityMatch !== (data.bannedEntityMatch || 'NO') ||
|
||||||
|
this.calculatedRisk.willfulDefaulter !== (data.willfulDefaulter || 'NO') ||
|
||||||
|
this.calculatedRisk.ecgcSalFlag !== (data.ecgcSalFlag || 'NO') ||
|
||||||
|
this.calculatedRisk.dormantFlag !== (data.dormantFlag || 'ACTIVE')
|
||||||
|
: false,
|
||||||
remarks: data.remarks || '',
|
remarks: data.remarks || '',
|
||||||
hasPan: !!data.panNumber,
|
hasPan: !!data.panNumber,
|
||||||
// AML & Compliance
|
// AML & Compliance
|
||||||
@@ -697,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;
|
||||||
@@ -765,12 +1036,17 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
const n = i?.documentType?.name?.toLowerCase();
|
const n = i?.documentType?.name?.toLowerCase();
|
||||||
return !!n && this.kycDocProofs.some((p) => p.name?.toLowerCase() === n);
|
return !!n && this.kycDocProofs.some((p) => p.name?.toLowerCase() === n);
|
||||||
};
|
};
|
||||||
|
// Re-uploading a KYC document creates a new identifier row rather than updating the
|
||||||
|
// old one in place (same pattern as OVD identifiers), so sort newest-first before
|
||||||
|
// picking — otherwise a repeat upload of the same document type would keep showing
|
||||||
|
// the ORIGINAL file forever, since .find() would always hit the oldest matching row.
|
||||||
|
const sorted = [...identifiers].sort((a: any, b: any) => (b?.id ?? 0) - (a?.id ?? 0));
|
||||||
// Pick by document-type name match first; otherwise the first identifier whose
|
// Pick by document-type name match first; otherwise the first identifier whose
|
||||||
// type lives in the KYC proof list (avoid bleeding the address proof in here).
|
// type lives in the KYC proof list (avoid bleeding the address proof in here).
|
||||||
const match = kycDocumentName
|
const match = kycDocumentName
|
||||||
? identifiers.find((i: any) => i?.documentType?.name?.toLowerCase() === kycDocumentName.toLowerCase())
|
? sorted.find((i: any) => i?.documentType?.name?.toLowerCase() === kycDocumentName.toLowerCase())
|
||||||
: null;
|
: null;
|
||||||
const identifier = match || identifiers.find(isKycType);
|
const identifier = match || sorted.find(isKycType);
|
||||||
if (!identifier?.id) return;
|
if (!identifier?.id) return;
|
||||||
|
|
||||||
this.existingKycIdentifierId = identifier.id.toString();
|
this.existingKycIdentifierId = identifier.id.toString();
|
||||||
@@ -845,7 +1121,12 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
if (!this.clientId) return;
|
if (!this.clientId) return;
|
||||||
this.clientsService.getClientDocuments(this.clientId).subscribe({
|
this.clientsService.getClientDocuments(this.clientId).subscribe({
|
||||||
next: (documents: any[]) => {
|
next: (documents: any[]) => {
|
||||||
const sigDoc = documents?.find((d: any) => d.name === 'clientSignature');
|
// Multiple 'clientSignature' documents can exist for clients who re-cropped/replaced
|
||||||
|
// their signature before uploads became single-slot, so pick the highest id (latest)
|
||||||
|
// rather than the first unordered match.
|
||||||
|
const sigDoc = documents
|
||||||
|
?.filter((d: any) => d.name === 'clientSignature')
|
||||||
|
?.sort((a: any, b: any) => b.id - a.id)?.[0];
|
||||||
if (sigDoc?.id) {
|
if (sigDoc?.id) {
|
||||||
this.existingSignatureDocId = sigDoc.id.toString();
|
this.existingSignatureDocId = sigDoc.id.toString();
|
||||||
this.clientsService.getClientSignatureImage(this.clientId, this.existingSignatureDocId).subscribe({
|
this.clientsService.getClientSignatureImage(this.clientId, this.existingSignatureDocId).subscribe({
|
||||||
@@ -874,11 +1155,13 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
this.kycTemplate = template;
|
this.kycTemplate = template;
|
||||||
this.kycTemplateLoaded = true;
|
this.kycTemplateLoaded = true;
|
||||||
if (template?.requiredDocuments?.length > 0) {
|
if (template?.requiredDocuments?.length > 0) {
|
||||||
this.kycDocumentTypes = template.requiredDocuments.map((doc: any) => ({
|
this.kycDocumentTypes = this.filterDocTypesForMinor(
|
||||||
name: doc.name,
|
template.requiredDocuments.map((doc: any) => ({
|
||||||
code: doc.code || doc.name,
|
name: doc.name,
|
||||||
mandatory: doc.mandatory ?? false
|
code: doc.code || doc.name,
|
||||||
}));
|
mandatory: doc.mandatory ?? false
|
||||||
|
}))
|
||||||
|
);
|
||||||
// Update kycDocumentName validator based on mandatory docs
|
// Update kycDocumentName validator based on mandatory docs
|
||||||
const hasMandatory = this.kycDocumentTypes.some((d) => d.mandatory);
|
const hasMandatory = this.kycDocumentTypes.some((d) => d.mandatory);
|
||||||
if (hasMandatory) {
|
if (hasMandatory) {
|
||||||
@@ -891,7 +1174,7 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
error: () => {
|
error: () => {
|
||||||
// Fallback to defaults if template API unavailable
|
// Fallback to defaults if template API unavailable
|
||||||
this.kycTemplateLoaded = true;
|
this.kycTemplateLoaded = true;
|
||||||
this.kycDocumentTypes = this.defaultDocumentTypes.map((d) => ({
|
this.kycDocumentTypes = this.getDefaultDocTypes().map((d) => ({
|
||||||
name: d.name,
|
name: d.name,
|
||||||
code: d.code,
|
code: d.code,
|
||||||
mandatory: false
|
mandatory: false
|
||||||
@@ -1318,6 +1601,21 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Lets the user go back and fix a wrong Aadhaar number after an OTP was already sent —
|
||||||
|
* unlocks the (readonly-while-OTP-pending) input and discards the in-flight OTP session,
|
||||||
|
* since it was requested for the now-being-corrected number. */
|
||||||
|
changeAadhaarNumber(): void {
|
||||||
|
this.aadhaarOtpSent = false;
|
||||||
|
this.aadhaarVerifyError = '';
|
||||||
|
this.aadhaarOtpReferenceId = '';
|
||||||
|
this.resetOtpDigits();
|
||||||
|
this.resendCountdown = 0;
|
||||||
|
if (this.resendTimer) {
|
||||||
|
clearInterval(this.resendTimer);
|
||||||
|
this.resendTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Verify Aadhaar OTP */
|
/** Verify Aadhaar OTP */
|
||||||
verifyAadhaarOtp() {
|
verifyAadhaarOtp() {
|
||||||
if (!this.aadhaarOtp || !this.clientId || !this.kycId) return;
|
if (!this.aadhaarOtp || !this.clientId || !this.kycId) return;
|
||||||
@@ -1440,20 +1738,6 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
await this.openSignatureCropDialog(file);
|
await this.openSignatureCropDialog(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Webcam capture entry point for the specimen signature — photographs a paper
|
|
||||||
* signature, then reuses the same crop dialog + staging/upload path as the file-picker
|
|
||||||
* flow above (additive to the existing draw-pad / upload options, not a replacement). */
|
|
||||||
async captureWebcamSignature(): Promise<void> {
|
|
||||||
const dialogRef = this.dialog.open(CaptureImageDialogComponent, {
|
|
||||||
data: { captureTarget: 'signature' }
|
|
||||||
});
|
|
||||||
dialogRef.afterClosed().subscribe(async (imageDataUrl: string) => {
|
|
||||||
if (!imageDataUrl) return;
|
|
||||||
const file = dataUrlToFile(imageDataUrl, 'webcam-signature.jpg');
|
|
||||||
await this.openSignatureCropDialog(file);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async openSignatureCropDialog(file: File): Promise<void> {
|
private async openSignatureCropDialog(file: File): Promise<void> {
|
||||||
const { ImageCropDialogComponent } = await import('./image-crop-dialog/image-crop-dialog.component');
|
const { ImageCropDialogComponent } = await import('./image-crop-dialog/image-crop-dialog.component');
|
||||||
const dialogRef = this.dialog.open(ImageCropDialogComponent, {
|
const dialogRef = this.dialog.open(ImageCropDialogComponent, {
|
||||||
@@ -1463,42 +1747,51 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
});
|
});
|
||||||
dialogRef.afterClosed().subscribe((result: any) => {
|
dialogRef.afterClosed().subscribe((result: any) => {
|
||||||
if (result) {
|
if (result) {
|
||||||
this.signatureFile = result.croppedFile;
|
this.applySignatureFile(result.croppedFile, result.previewUrl);
|
||||||
this.signaturePreview = result.previewUrl;
|
|
||||||
// In edit mode, delete old signature doc then upload new one
|
|
||||||
if (this.clientId) {
|
|
||||||
if (this.existingSignatureDocId) {
|
|
||||||
this.clientsService.deleteClientDocument(this.clientId, this.existingSignatureDocId).subscribe({
|
|
||||||
next: () => {
|
|
||||||
this.clientsService.uploadClientSignatureImage(this.clientId, result.croppedFile).subscribe({
|
|
||||||
next: (res: any) => {
|
|
||||||
if (res?.resourceId) this.existingSignatureDocId = res.resourceId.toString();
|
|
||||||
},
|
|
||||||
error: () => {}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
error: () => {
|
|
||||||
this.clientsService.uploadClientSignatureImage(this.clientId, result.croppedFile).subscribe({
|
|
||||||
next: (res: any) => {
|
|
||||||
if (res?.resourceId) this.existingSignatureDocId = res.resourceId.toString();
|
|
||||||
},
|
|
||||||
error: () => {}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
this.clientsService.uploadClientSignatureImage(this.clientId, result.croppedFile).subscribe({
|
|
||||||
next: (res: any) => {
|
|
||||||
if (res?.resourceId) this.existingSignatureDocId = res.resourceId.toString();
|
|
||||||
},
|
|
||||||
error: () => {}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Draw-with-mouse/touchpad entry point for the specimen signature — same staging/upload
|
||||||
|
* path as the file-picker and webcam flows, minus the crop step (the pad's canvas is
|
||||||
|
* already the right shape/size). */
|
||||||
|
drawSignature(): void {
|
||||||
|
const dialogRef = this.dialog.open(DrawSignatureDialogComponent);
|
||||||
|
dialogRef.afterClosed().subscribe((file: File) => {
|
||||||
|
if (file) {
|
||||||
|
this.applySignatureFile(file, URL.createObjectURL(file));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stages the signature locally and, in edit mode, persists it — replacing any existing
|
||||||
|
* signature doc first so a client never ends up with two. Shared by the file-picker, webcam
|
||||||
|
* and draw-pad entry points. */
|
||||||
|
private applySignatureFile(file: File, previewUrl: string): void {
|
||||||
|
this.signatureFile = file;
|
||||||
|
this.signaturePreview = previewUrl;
|
||||||
|
if (!this.clientId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.existingSignatureDocId) {
|
||||||
|
this.clientsService.deleteClientDocument(this.clientId, this.existingSignatureDocId).subscribe({
|
||||||
|
next: () => this.uploadSignatureFile(file),
|
||||||
|
error: () => this.uploadSignatureFile(file)
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.uploadSignatureFile(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private uploadSignatureFile(file: File): void {
|
||||||
|
this.clientsService.uploadClientSignatureImage(this.clientId, file).subscribe({
|
||||||
|
next: (res: any) => {
|
||||||
|
if (res?.resourceId) this.existingSignatureDocId = res.resourceId.toString();
|
||||||
|
},
|
||||||
|
error: () => {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
removePhoto() {
|
removePhoto() {
|
||||||
this.photographFile = null;
|
this.photographFile = null;
|
||||||
this.photographPreview = null;
|
this.photographPreview = null;
|
||||||
@@ -1523,7 +1816,7 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get kycData(): any {
|
get kycData(): any {
|
||||||
const formVal = this.readOnly ? this.kycForm.getRawValue() : this.kycForm.value;
|
const formVal = this.kycForm.getRawValue();
|
||||||
const aadhaarRaw = formVal.aadhaarNumber || '';
|
const aadhaarRaw = formVal.aadhaarNumber || '';
|
||||||
const aadhaarMasked = aadhaarRaw.length === 12 ? 'XXXXXXXX' + aadhaarRaw.slice(-4) : null;
|
const aadhaarMasked = aadhaarRaw.length === 12 ? 'XXXXXXXX' + aadhaarRaw.slice(-4) : null;
|
||||||
const dateFormat = this.settingsService.dateFormat;
|
const dateFormat = this.settingsService.dateFormat;
|
||||||
@@ -1576,7 +1869,21 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
form15Applicable: v.form15Applicable || false,
|
form15Applicable: v.form15Applicable || false,
|
||||||
form15Type: v.form15Type || null,
|
form15Type: v.form15Type || null,
|
||||||
form15FY: v.form15FY || null,
|
form15FY: v.form15FY || null,
|
||||||
remarks: v.remarks || null
|
remarks: v.remarks || null,
|
||||||
|
// Exception state is DERIVED from verification status, not from a checkbox — an unverified
|
||||||
|
// or blank PAN/Aadhaar is an exception by definition, so the officer cannot forget to flag
|
||||||
|
// it. See deriveKycExceptionFields() for the raise / narrow / close rules.
|
||||||
|
...deriveKycExceptionFields({
|
||||||
|
panVerified: this.isNonIndividual ? this.entPanVerifyStatus === 'VERIFIED' : this.panVerifyStatus === 'VERIFIED',
|
||||||
|
aadhaarVerified: this.aadhaarVerifyStatus === 'VERIFIED',
|
||||||
|
// Non-individual clients submit a dummy masked Aadhaar, so checking it would saddle every
|
||||||
|
// company with a permanent exception.
|
||||||
|
aadhaarApplicable: !this.isNonIndividual,
|
||||||
|
hadException: this.persistedIsException,
|
||||||
|
previousReason: this.persistedExceptionReason,
|
||||||
|
manualReasons: Array.isArray(v.exceptionReason) ? v.exceptionReason : [],
|
||||||
|
todayFormatted: this.dateUtils.formatDate(new Date(), dateFormat)
|
||||||
|
})
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1589,9 +1896,25 @@ 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
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private applyAutoCalculatedAmlFlags(risk = this.calculatedRisk): void {
|
||||||
|
if (!risk) return;
|
||||||
|
this.kycForm.get('amlCheckStatus')?.setValue(risk.amlCheckStatus || 'CLEAR');
|
||||||
|
this.kycForm.get('bannedEntityMatch')?.setValue(risk.bannedEntityMatch || 'NO');
|
||||||
|
this.kycForm.get('willfulDefaulter')?.setValue(risk.willfulDefaulter || 'NO');
|
||||||
|
this.kycForm.get('ecgcSalFlag')?.setValue(risk.ecgcSalFlag || 'NO');
|
||||||
|
this.kycForm.get('dormantFlag')?.setValue(risk.dormantFlag || 'ACTIVE');
|
||||||
|
|
||||||
|
this.kycForm.get('amlCheckStatus')?.markAsDirty();
|
||||||
|
this.kycForm.get('bannedEntityMatch')?.markAsDirty();
|
||||||
|
this.kycForm.get('willfulDefaulter')?.markAsDirty();
|
||||||
|
this.kycForm.get('ecgcSalFlag')?.markAsDirty();
|
||||||
|
this.kycForm.get('dormantFlag')?.markAsDirty();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+1
-1
@@ -195,7 +195,7 @@
|
|||||||
client.isMinor ? 'preview-badge preview-badge--danger' : 'preview-badge preview-badge--verified'
|
client.isMinor ? 'preview-badge preview-badge--danger' : 'preview-badge preview-badge--verified'
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
{{ client.isMinor ? 'Yes (Age < 10)' : 'No' }}
|
{{ client.isMinor ? 'Yes (Age < 18)' : 'No' }}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
@@ -191,7 +192,12 @@ const routes: Routes = [
|
|||||||
{
|
{
|
||||||
path: 'form60',
|
path: 'form60',
|
||||||
component: Form60TabComponent,
|
component: Form60TabComponent,
|
||||||
data: { title: 'Form 60', breadcrumb: 'Form 60', 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',
|
||||||
|
|||||||
@@ -234,8 +234,12 @@ export class CkycTabComponent implements OnInit, OnDestroy {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Signature — client document with name='clientSignature'
|
// 2. Signature — client document with name='clientSignature'. Multiple rows can exist
|
||||||
const signatureDoc = (documents as any[])?.find((d: any) => d?.name === 'clientSignature');
|
// for clients who re-cropped/replaced their signature before uploads became single-slot,
|
||||||
|
// so pick the highest id (latest) rather than the first unordered match.
|
||||||
|
const signatureDoc = (documents as any[])
|
||||||
|
?.filter((d: any) => d?.name === 'clientSignature')
|
||||||
|
?.sort((a: any, b: any) => b.id - a.id)?.[0];
|
||||||
if (signatureDoc) {
|
if (signatureDoc) {
|
||||||
this.existing.CLIENT_SIGNATURE = {
|
this.existing.CLIENT_SIGNATURE = {
|
||||||
present: true,
|
present: true,
|
||||||
|
|||||||
@@ -410,6 +410,9 @@
|
|||||||
<button mat-menu-item (click)="doAction('Add Charge')" *mifosxHasPermission="'CREATE_CLIENTCHARGE'">
|
<button mat-menu-item (click)="doAction('Add Charge')" *mifosxHasPermission="'CREATE_CLIENTCHARGE'">
|
||||||
{{ 'labels.buttons.Add Charge' | translate }}
|
{{ 'labels.buttons.Add Charge' | translate }}
|
||||||
</button>
|
</button>
|
||||||
|
<button mat-menu-item (click)="doAction('Welcome Kit')" *mifosxHasPermission="'READ_WELCOMEKIT'">
|
||||||
|
Welcome Kit
|
||||||
|
</button>
|
||||||
<button mat-menu-item (click)="doAction('Create Collateral')">
|
<button mat-menu-item (click)="doAction('Create Collateral')">
|
||||||
{{ 'labels.buttons.Create Collateral' | translate }}
|
{{ 'labels.buttons.Create Collateral' | translate }}
|
||||||
</button>
|
</button>
|
||||||
@@ -542,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']"
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { DrawSignatureDialogComponent } from './custom-dialogs/draw-signature-di
|
|||||||
import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component';
|
import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component';
|
||||||
import { UploadImageDialogComponent } from './custom-dialogs/upload-image-dialog/upload-image-dialog.component';
|
import { UploadImageDialogComponent } from './custom-dialogs/upload-image-dialog/upload-image-dialog.component';
|
||||||
import { CaptureImageDialogComponent } from './custom-dialogs/capture-image-dialog/capture-image-dialog.component';
|
import { CaptureImageDialogComponent } from './custom-dialogs/capture-image-dialog/capture-image-dialog.component';
|
||||||
|
import { WelcomeKitDialogComponent } from './custom-dialogs/welcome-kit-dialog/welcome-kit-dialog.component';
|
||||||
|
|
||||||
/** Custom Services */
|
/** Custom Services */
|
||||||
import { ClientsService } from '../clients.service';
|
import { ClientsService } from '../clients.service';
|
||||||
@@ -255,6 +256,9 @@ export class ClientsViewComponent implements OnInit {
|
|||||||
case 'Delete Image':
|
case 'Delete Image':
|
||||||
this.deleteProfileImage();
|
this.deleteProfileImage();
|
||||||
break;
|
break;
|
||||||
|
case 'Welcome Kit':
|
||||||
|
this.dispatchWelcomeKit();
|
||||||
|
break;
|
||||||
case 'Create Standing Instructions':
|
case 'Create Standing Instructions':
|
||||||
const createStandingInstructionsQueryParams: any = {
|
const createStandingInstructionsQueryParams: any = {
|
||||||
officeId: this.clientViewData.officeId,
|
officeId: this.clientViewData.officeId,
|
||||||
@@ -455,4 +459,20 @@ export class ClientsViewComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the Welcome Kit Dispatch dialog.
|
||||||
|
*/
|
||||||
|
private dispatchWelcomeKit() {
|
||||||
|
const welcomeKitDialogRef = this.dialog.open(WelcomeKitDialogComponent, {
|
||||||
|
data: { clientId: this.clientViewData.id },
|
||||||
|
width: '600px'
|
||||||
|
});
|
||||||
|
welcomeKitDialogRef.afterClosed().subscribe((response: any) => {
|
||||||
|
if (response) {
|
||||||
|
// the form was successfully submitted
|
||||||
|
this.reload();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-1
@@ -47,7 +47,13 @@ export class DeleteSignatureDialogComponent {
|
|||||||
* @param {any} data Documents data
|
* @param {any} data Documents data
|
||||||
*/
|
*/
|
||||||
constructor() {
|
constructor() {
|
||||||
const signature = this.data.find((document: any) => document.name === 'clientSignature') || {};
|
// Multiple 'clientSignature' documents can exist for clients who re-cropped/replaced their
|
||||||
|
// signature before uploads became single-slot, so pick the highest id (latest) rather than
|
||||||
|
// the first unordered match.
|
||||||
|
const signature =
|
||||||
|
this.data
|
||||||
|
.filter((document: any) => document.name === 'clientSignature')
|
||||||
|
.sort((a: any, b: any) => b.id - a.id)[0] || {};
|
||||||
this.signatureId = signature.id;
|
this.signatureId = signature.id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-3
@@ -347,9 +347,18 @@
|
|||||||
<i class="fa fa-calendar"></i> Issue / statement date
|
<i class="fa fa-calendar"></i> Issue / statement date
|
||||||
<span class="kyc-opt">(not older than 3 months)</span>
|
<span class="kyc-opt">(not older than 3 months)</span>
|
||||||
</label>
|
</label>
|
||||||
<div class="kyc-input-wrap kyc-input-wrap--date">
|
<div class="kyc-input-wrap kyc-input-wrap--date" (click)="uploadIssueDatePicker.open()">
|
||||||
<i class="fa fa-calendar-o kyc-input-wrap__icon"></i>
|
<input
|
||||||
<input type="date" class="kyc-input kyc-input--date" formControlName="issueDate" />
|
class="kyc-input kyc-input--date"
|
||||||
|
[matDatepicker]="uploadIssueDatePicker"
|
||||||
|
formControlName="issueDate"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
<mat-datepicker-toggle
|
||||||
|
class="kyc-input-wrap__calendar-toggle"
|
||||||
|
[for]="uploadIssueDatePicker"
|
||||||
|
></mat-datepicker-toggle>
|
||||||
|
<mat-datepicker #uploadIssueDatePicker></mat-datepicker>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-28
@@ -388,42 +388,32 @@ $red-900: #993c1d;
|
|||||||
padding-left: 38px;
|
padding-left: 38px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Date input — match styling of other kyc inputs */
|
/* Date input — opens the Material calendar picker; styled to match other kyc inputs */
|
||||||
.kyc-input--date {
|
.kyc-input--date {
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
color: $gray-900;
|
color: $gray-900;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
min-height: 40px;
|
min-height: 40px;
|
||||||
padding-right: 14px !important;
|
padding-left: 12px !important;
|
||||||
|
padding-right: 40px !important;
|
||||||
|
}
|
||||||
|
|
||||||
&::-webkit-calendar-picker-indicator {
|
.kyc-input-wrap--date .kyc-input-wrap__calendar-toggle {
|
||||||
opacity: 0.55;
|
position: absolute;
|
||||||
cursor: pointer;
|
right: 2px;
|
||||||
filter: grayscale(1);
|
top: 50%;
|
||||||
transition: opacity 0.15s;
|
transform: translateY(-50%);
|
||||||
|
color: $gray-500;
|
||||||
|
|
||||||
|
::ng-deep .mat-mdc-icon-button {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover::-webkit-calendar-picker-indicator {
|
::ng-deep .mat-mdc-icon-button svg {
|
||||||
opacity: 0.9;
|
width: 18px;
|
||||||
}
|
height: 18px;
|
||||||
|
|
||||||
&::-webkit-datetime-edit {
|
|
||||||
color: $gray-900;
|
|
||||||
padding-top: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::-webkit-datetime-edit-fields-wrapper {
|
|
||||||
color: $gray-900;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::-webkit-datetime-edit-text {
|
|
||||||
color: $gray-500;
|
|
||||||
padding: 0 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:invalid::-webkit-datetime-edit,
|
|
||||||
&:not(:focus):placeholder-shown::-webkit-datetime-edit {
|
|
||||||
color: $gray-500;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+7
-1
@@ -65,7 +65,13 @@ export class ViewSignatureDialogComponent implements OnInit {
|
|||||||
* @param {any} data Documents data
|
* @param {any} data Documents data
|
||||||
*/
|
*/
|
||||||
constructor() {
|
constructor() {
|
||||||
const signature = this.data.documents.find((document: any) => document.name === 'clientSignature') || {};
|
// Multiple 'clientSignature' documents can exist for clients who re-cropped/replaced their
|
||||||
|
// signature before uploads became single-slot, so pick the highest id (latest) rather than
|
||||||
|
// the first unordered match.
|
||||||
|
const signature =
|
||||||
|
this.data.documents
|
||||||
|
.filter((document: any) => document.name === 'clientSignature')
|
||||||
|
.sort((a: any, b: any) => b.id - a.id)[0] || {};
|
||||||
this.signatureId = signature.id;
|
this.signatureId = signature.id;
|
||||||
this.clientId = this.data.id;
|
this.clientId = this.data.id;
|
||||||
}
|
}
|
||||||
|
|||||||
+121
@@ -0,0 +1,121 @@
|
|||||||
|
<!--
|
||||||
|
Copyright since 2025 Mifos Initiative
|
||||||
|
|
||||||
|
This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
-->
|
||||||
|
<h2 mat-dialog-title>Welcome Kit Dispatch</h2>
|
||||||
|
|
||||||
|
<mat-dialog-content class="mat-typography">
|
||||||
|
<div fxLayout="row" fxLayoutAlign="center center" style="padding: 2rem" *ngIf="isLoading">
|
||||||
|
<mat-spinner diameter="40"></mat-spinner>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ng-container *ngIf="!isLoading">
|
||||||
|
<!-- Tracking view if dispatches already exist -->
|
||||||
|
<div
|
||||||
|
class="dispatch-tracking-panel"
|
||||||
|
*ngFor="let dispatch of activeDispatches; let i = index"
|
||||||
|
style="margin-bottom: 16px; padding: 16px; border: 1px solid #e0e0e0; border-radius: 4px; background: #fafafa"
|
||||||
|
>
|
||||||
|
<div fxLayout="row" fxLayoutAlign="space-between start" style="margin-bottom: 8px">
|
||||||
|
<h3 style="margin: 0">{{ i === 0 ? 'Current' : 'Previous' }} Welcome Kit Status</h3>
|
||||||
|
<button mat-stroked-button color="primary" type="button" (click)="downloadPdf(dispatch)">
|
||||||
|
Download Details
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p style="margin: 4px 0"><strong>Delivery Address:</strong> {{ getDisplayAddress(dispatch) }}</p>
|
||||||
|
<p style="margin: 4px 0"><strong>Tracking ID:</strong> {{ dispatch.trackingId }}</p>
|
||||||
|
<p style="margin: 4px 0">
|
||||||
|
<strong>Delivery Mode:</strong> {{ dispatch.deliveryMode }}
|
||||||
|
<ng-container *ngIf="dispatch.deliveryMode === 'OTHER'">({{ dispatch.deliveryModeOtherText }})</ng-container>
|
||||||
|
</p>
|
||||||
|
<p style="margin: 4px 0"><strong>Dispatched On:</strong> {{ dispatch.dispatchedOnDate | date: 'mediumDate' }}</p>
|
||||||
|
<p style="margin: 4px 0">
|
||||||
|
<strong>Status:</strong>
|
||||||
|
<span class="status-badge status-{{ dispatch.status | lowercase }}">{{ dispatch.status }}</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Action buttons: only shown for DISPATCHED / IN_TRANSIT, only for users with UPDATE_WELCOMEKIT permission -->
|
||||||
|
<ng-container *mifosxHasPermission="'UPDATE_WELCOMEKIT'">
|
||||||
|
<div
|
||||||
|
*ngIf="canUpdateStatus(dispatch.status)"
|
||||||
|
style="margin-top: 12px; display: flex; gap: 8px; flex-wrap: wrap;"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
mat-stroked-button
|
||||||
|
color="primary"
|
||||||
|
type="button"
|
||||||
|
[disabled]="isLoading"
|
||||||
|
(click)="updateStatus(dispatch, 'DELIVERED')"
|
||||||
|
>
|
||||||
|
✅ Mark as Delivered
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
mat-stroked-button
|
||||||
|
color="warn"
|
||||||
|
type="button"
|
||||||
|
[disabled]="isLoading"
|
||||||
|
(click)="updateStatus(dispatch, 'RETURNED')"
|
||||||
|
>
|
||||||
|
↩ Mark as Returned
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
</div><!-- end dispatch-tracking-panel -->
|
||||||
|
|
||||||
|
<hr *ngIf="activeDispatches.length > 0 && welcomeKitsData?.canDispatchNew" />
|
||||||
|
|
||||||
|
<!-- Create form if they can dispatch a new one -->
|
||||||
|
<div class="dispatch-create-form" [formGroup]="dispatchForm" *ngIf="welcomeKitsData?.canDispatchNew">
|
||||||
|
<h3>Create New Dispatch</h3>
|
||||||
|
<div fxLayout="column" fxLayoutGap="16px">
|
||||||
|
<mat-form-field appearance="outline" class="full-width">
|
||||||
|
<mat-label>Delivery Address</mat-label>
|
||||||
|
<textarea matInput formControlName="deliveryAddress" rows="3" required></textarea>
|
||||||
|
<mat-error *ngIf="dispatchForm.get('deliveryAddress')?.hasError('required')">
|
||||||
|
Delivery Address is required
|
||||||
|
</mat-error>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="full-width">
|
||||||
|
<mat-label>Delivery Mode</mat-label>
|
||||||
|
<mat-select formControlName="deliveryMode" required>
|
||||||
|
<mat-option *ngFor="let mode of deliveryModes" [value]="mode">
|
||||||
|
{{ mode }}
|
||||||
|
</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
<mat-error *ngIf="dispatchForm.get('deliveryMode')?.hasError('required')">
|
||||||
|
Delivery Mode is required
|
||||||
|
</mat-error>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field
|
||||||
|
appearance="outline"
|
||||||
|
class="full-width"
|
||||||
|
*ngIf="dispatchForm.get('deliveryMode')?.value === 'OTHER'"
|
||||||
|
>
|
||||||
|
<mat-label>Specify Other Mode</mat-label>
|
||||||
|
<input matInput formControlName="deliveryModeOtherText" required />
|
||||||
|
<mat-error *ngIf="dispatchForm.get('deliveryModeOtherText')?.hasError('required')">
|
||||||
|
Please specify the delivery mode
|
||||||
|
</mat-error>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
</mat-dialog-content>
|
||||||
|
|
||||||
|
<mat-dialog-actions align="end">
|
||||||
|
<button mat-button mat-dialog-close>Close</button>
|
||||||
|
<button
|
||||||
|
mat-raised-button
|
||||||
|
color="primary"
|
||||||
|
*ngIf="welcomeKitsData?.canDispatchNew && !isLoading"
|
||||||
|
(click)="submit()"
|
||||||
|
[disabled]="dispatchForm.invalid || isLoading"
|
||||||
|
>
|
||||||
|
Dispatch
|
||||||
|
</button>
|
||||||
|
</mat-dialog-actions>
|
||||||
+59
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
.dispatch-tracking-panel {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: white;
|
||||||
|
|
||||||
|
&.status-dispatched {
|
||||||
|
background-color: #1976d2;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.status-in_transit {
|
||||||
|
background-color: #f57c00;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.status-delivered {
|
||||||
|
background-color: #388e3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.status-returned {
|
||||||
|
background-color: #d32f2f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dispatch-create-form {
|
||||||
|
h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.full-width {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
+371
@@ -0,0 +1,371 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
import { Component, OnInit, inject } from '@angular/core';
|
||||||
|
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||||
|
import {
|
||||||
|
MAT_DIALOG_DATA,
|
||||||
|
MatDialogRef,
|
||||||
|
MatDialogTitle,
|
||||||
|
MatDialogContent,
|
||||||
|
MatDialogActions,
|
||||||
|
MatDialogClose
|
||||||
|
} from '@angular/material/dialog';
|
||||||
|
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||||
|
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||||
|
import { ClientsService } from '../../../clients.service';
|
||||||
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
import { DatePipe } from '@angular/common';
|
||||||
|
import { jsPDF } from 'jspdf';
|
||||||
|
import autoTable from 'jspdf-autotable';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-welcome-kit-dialog',
|
||||||
|
templateUrl: './welcome-kit-dialog.component.html',
|
||||||
|
styleUrls: ['./welcome-kit-dialog.component.scss'],
|
||||||
|
imports: [
|
||||||
|
...STANDALONE_SHARED_IMPORTS,
|
||||||
|
MatDialogTitle,
|
||||||
|
MatDialogContent,
|
||||||
|
MatDialogActions,
|
||||||
|
MatDialogClose,
|
||||||
|
MatProgressSpinnerModule
|
||||||
|
],
|
||||||
|
providers: [DatePipe]
|
||||||
|
})
|
||||||
|
export class WelcomeKitDialogComponent implements OnInit {
|
||||||
|
private dialogRef = inject(MatDialogRef<WelcomeKitDialogComponent>);
|
||||||
|
data: { clientId: string } = inject(MAT_DIALOG_DATA);
|
||||||
|
private clientsService = inject(ClientsService);
|
||||||
|
private formBuilder = inject(FormBuilder);
|
||||||
|
private snackBar = inject(MatSnackBar);
|
||||||
|
private datePipe = inject(DatePipe);
|
||||||
|
|
||||||
|
isLoading = true;
|
||||||
|
welcomeKitsData: any = null;
|
||||||
|
dispatchForm: FormGroup = this.formBuilder.group({
|
||||||
|
deliveryAddress: [
|
||||||
|
'',
|
||||||
|
Validators.required
|
||||||
|
],
|
||||||
|
deliveryMode: [
|
||||||
|
'POSTMAN',
|
||||||
|
Validators.required
|
||||||
|
],
|
||||||
|
deliveryModeOtherText: ['']
|
||||||
|
});
|
||||||
|
deliveryModes = [
|
||||||
|
'POSTMAN',
|
||||||
|
'COURIER',
|
||||||
|
'OTHER'
|
||||||
|
];
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.dispatchForm.get('deliveryMode')?.valueChanges.subscribe((mode) => {
|
||||||
|
const otherControl = this.dispatchForm.get('deliveryModeOtherText');
|
||||||
|
if (mode === 'OTHER') {
|
||||||
|
otherControl?.setValidators([Validators.required]);
|
||||||
|
} else {
|
||||||
|
otherControl?.clearValidators();
|
||||||
|
}
|
||||||
|
otherControl?.updateValueAndValidity();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.fetchData();
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchData() {
|
||||||
|
this.isLoading = true;
|
||||||
|
this.clientsService.getWelcomeKits(this.data.clientId).subscribe({
|
||||||
|
next: (response: any) => {
|
||||||
|
this.welcomeKitsData = response;
|
||||||
|
if (this.welcomeKitsData && this.welcomeKitsData.canDispatchNew) {
|
||||||
|
this.fetchPrimaryAddress();
|
||||||
|
} else {
|
||||||
|
this.isLoading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.isLoading = false;
|
||||||
|
this.snackBar.open('Failed to load welcome kits data', 'Close', { duration: 3000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchPrimaryAddress() {
|
||||||
|
this.clientsService.getClientAddressData(this.data.clientId).subscribe({
|
||||||
|
next: (addresses: any) => {
|
||||||
|
this.isLoading = false;
|
||||||
|
if (addresses && addresses.length > 0) {
|
||||||
|
// Attempt to build a readable address from the first address returned
|
||||||
|
const primary = addresses[0];
|
||||||
|
const parts = [];
|
||||||
|
if (primary.addressLine1) parts.push(primary.addressLine1);
|
||||||
|
if (primary.addressLine2) parts.push(primary.addressLine2);
|
||||||
|
if (primary.city) parts.push(primary.city);
|
||||||
|
if (primary.stateName) parts.push(primary.stateName);
|
||||||
|
if (primary.postalCode) parts.push(primary.postalCode);
|
||||||
|
if (primary.countryName) parts.push(primary.countryName);
|
||||||
|
|
||||||
|
const clientNameStr = this.welcomeKitsData?.clientName ? this.welcomeKitsData.clientName + ' — ' : '';
|
||||||
|
this.dispatchForm.patchValue({
|
||||||
|
deliveryAddress: clientNameStr + parts.join(', ')
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.isLoading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get activeDispatches() {
|
||||||
|
if (this.welcomeKitsData && this.welcomeKitsData.dispatches && this.welcomeKitsData.dispatches.length > 0) {
|
||||||
|
return this.welcomeKitsData.dispatches;
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
submit() {
|
||||||
|
if (this.dispatchForm.invalid) {
|
||||||
|
this.dispatchForm.markAllAsTouched();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isLoading = true;
|
||||||
|
const formValue = this.dispatchForm.value;
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
const formattedDate = this.datePipe.transform(today, 'dd MMMM yyyy');
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
deliveryAddress: formValue.deliveryAddress,
|
||||||
|
deliveryMode: formValue.deliveryMode,
|
||||||
|
deliveryModeOtherText: formValue.deliveryMode === 'OTHER' ? formValue.deliveryModeOtherText : null,
|
||||||
|
dispatchedOnDate: formattedDate,
|
||||||
|
locale: 'en',
|
||||||
|
dateFormat: 'dd MMMM yyyy'
|
||||||
|
};
|
||||||
|
|
||||||
|
this.clientsService.dispatchWelcomeKit(this.data.clientId, payload).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.snackBar.open('Welcome Kit Dispatched successfully', 'Close', { duration: 3000 });
|
||||||
|
this.fetchData();
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.isLoading = false;
|
||||||
|
this.snackBar.open('Failed to dispatch welcome kit', 'Close', { duration: 3000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
canUpdateStatus(status: string): boolean {
|
||||||
|
return status === 'DISPATCHED' || status === 'IN_TRANSIT';
|
||||||
|
}
|
||||||
|
|
||||||
|
updateStatus(dispatch: any, newStatus: string) {
|
||||||
|
const label = newStatus === 'DELIVERED' ? 'Delivered' : 'Returned';
|
||||||
|
const confirmed = confirm(`Are you sure you want to mark this as ${label}? This action cannot be undone from the UI.`);
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isLoading = true;
|
||||||
|
this.clientsService.updateWelcomeKitStatus(dispatch.id, newStatus).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.snackBar.open(`Welcome Kit marked as ${label} successfully`, 'Close', { duration: 3000 });
|
||||||
|
this.fetchData();
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.isLoading = false;
|
||||||
|
this.snackBar.open(`Failed to mark Welcome Kit as ${label}`, 'Close', { duration: 3000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getDisplayAddress(dispatch: any): string {
|
||||||
|
const address = dispatch.deliveryAddress || '';
|
||||||
|
const name = this.welcomeKitsData?.clientName || '';
|
||||||
|
if (name && !address.startsWith(name)) {
|
||||||
|
return `${name} — ${address}`;
|
||||||
|
}
|
||||||
|
return address;
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadPdf(dispatch: any) {
|
||||||
|
const doc = new jsPDF();
|
||||||
|
const clientName = this.welcomeKitsData?.clientName || dispatch.clientName || 'N/A';
|
||||||
|
const address = this.getDisplayAddress(dispatch);
|
||||||
|
const mode =
|
||||||
|
dispatch.deliveryMode === 'OTHER'
|
||||||
|
? `${dispatch.deliveryMode} (${dispatch.deliveryModeOtherText || ''})`
|
||||||
|
: dispatch.deliveryMode;
|
||||||
|
const trackingId = dispatch.trackingId || 'N/A';
|
||||||
|
const status = dispatch.status || 'N/A';
|
||||||
|
const dispatchedOn = this.datePipe.transform(dispatch.dispatchedOnDate, 'mediumDate') || 'N/A';
|
||||||
|
|
||||||
|
const primaryColor: [number, number, number] = [
|
||||||
|
31,
|
||||||
|
73,
|
||||||
|
125
|
||||||
|
]; // Navy Blue
|
||||||
|
const grayColor: [number, number, number] = [
|
||||||
|
100,
|
||||||
|
100,
|
||||||
|
100
|
||||||
|
];
|
||||||
|
const lightGray: [number, number, number] = [
|
||||||
|
230,
|
||||||
|
230,
|
||||||
|
230
|
||||||
|
];
|
||||||
|
const pageWidth = doc.internal.pageSize.getWidth();
|
||||||
|
const pageHeight = doc.internal.pageSize.getHeight();
|
||||||
|
|
||||||
|
// --- 1. Header Banner ---
|
||||||
|
doc.setFillColor(...primaryColor);
|
||||||
|
doc.rect(0, 0, pageWidth, 40, 'F');
|
||||||
|
|
||||||
|
// Decorative circles
|
||||||
|
doc.setFillColor(255, 255, 255);
|
||||||
|
doc.circle(pageWidth - 20, 15, 3, 'F');
|
||||||
|
doc.circle(pageWidth - 30, 25, 2, 'F');
|
||||||
|
doc.circle(pageWidth - 10, 30, 1.5, 'F');
|
||||||
|
|
||||||
|
doc.setFontSize(28);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.setTextColor(255, 255, 255);
|
||||||
|
doc.text('CBSNexora', 20, 22);
|
||||||
|
|
||||||
|
doc.setFontSize(11);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.text('Core Banking Solution', 22, 30);
|
||||||
|
|
||||||
|
// --- 2. Title ---
|
||||||
|
let currentY = 55;
|
||||||
|
doc.setFontSize(16);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.setTextColor(...primaryColor);
|
||||||
|
doc.text('Welcome Kit Dispatch Confirmation', pageWidth / 2, currentY, { align: 'center' });
|
||||||
|
currentY += 12;
|
||||||
|
|
||||||
|
// --- 3. Date Line ---
|
||||||
|
doc.setFontSize(11);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.setTextColor(0, 0, 0);
|
||||||
|
const today = this.datePipe.transform(new Date(), 'longDate');
|
||||||
|
doc.text(`Date: ${today}`, pageWidth - 20, currentY, { align: 'right' });
|
||||||
|
currentY += 15;
|
||||||
|
|
||||||
|
// --- 4. Greeting ---
|
||||||
|
doc.text(`Dear ${clientName},`, 20, currentY);
|
||||||
|
currentY += 10;
|
||||||
|
|
||||||
|
// --- 5. Opening welcome paragraph ---
|
||||||
|
const p1 =
|
||||||
|
'Welcome to the CBSNexora family! We are delighted to have you on board as a valued client. As part of our onboarding process, we have dispatched your Welcome Kit to your registered address, which includes your account documentation and essential banking materials to help you get started with us.';
|
||||||
|
const splitP1 = doc.splitTextToSize(p1, pageWidth - 40);
|
||||||
|
doc.text(splitP1, 20, currentY);
|
||||||
|
currentY += splitP1.length * 6 + 8;
|
||||||
|
|
||||||
|
// --- 6. Short paragraph about the company ---
|
||||||
|
const p2 =
|
||||||
|
'CBSNexora is committed to providing secure, reliable, and customer-first banking services. Our team is always available to assist you with any banking needs, from account management to digital services, ensuring a seamless banking experience.';
|
||||||
|
const splitP2 = doc.splitTextToSize(p2, pageWidth - 40);
|
||||||
|
doc.text(splitP2, 20, currentY);
|
||||||
|
currentY += splitP2.length * 6 + 8;
|
||||||
|
|
||||||
|
// --- 7. Transition line before details ---
|
||||||
|
doc.text('Please find your Welcome Kit dispatch details below for your reference:', 20, currentY);
|
||||||
|
currentY += 8;
|
||||||
|
|
||||||
|
// --- 8. Details Section ---
|
||||||
|
autoTable(doc, {
|
||||||
|
startY: currentY,
|
||||||
|
theme: 'plain',
|
||||||
|
margin: { left: 20, right: 20 },
|
||||||
|
styles: { fontSize: 11, cellPadding: 6, textColor: [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
] },
|
||||||
|
columnStyles: {
|
||||||
|
0: { fontStyle: 'bold', cellWidth: 50 },
|
||||||
|
1: { cellWidth: 'auto' }
|
||||||
|
},
|
||||||
|
didDrawCell: (data) => {
|
||||||
|
if (data.row.index >= 0) {
|
||||||
|
doc.setDrawColor(...lightGray);
|
||||||
|
doc.setLineWidth(0.2);
|
||||||
|
doc.line(
|
||||||
|
data.cell.x,
|
||||||
|
data.cell.y + data.cell.height,
|
||||||
|
data.cell.x + data.cell.width,
|
||||||
|
data.cell.y + data.cell.height
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
body: [
|
||||||
|
[
|
||||||
|
'Delivery Address',
|
||||||
|
address
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Delivery Mode',
|
||||||
|
mode
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Tracking ID',
|
||||||
|
trackingId
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Status',
|
||||||
|
status
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Dispatched On',
|
||||||
|
dispatchedOn
|
||||||
|
]
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- 9. Closing paragraph ---
|
||||||
|
currentY = (doc as any).lastAutoTable.finalY + 12;
|
||||||
|
const p3 =
|
||||||
|
'Should you have any questions about your Welcome Kit or need any assistance, please do not hesitate to reach out to our support team. We look forward to serving you and building a long-term relationship with you.';
|
||||||
|
const splitP3 = doc.splitTextToSize(p3, pageWidth - 40);
|
||||||
|
doc.text(splitP3, 20, currentY);
|
||||||
|
currentY += splitP3.length * 6 + 12;
|
||||||
|
|
||||||
|
// --- 10. Sign-off ---
|
||||||
|
doc.text('Warm regards,', 20, currentY);
|
||||||
|
currentY += 7;
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.text('CBSNexora Operations Team', 20, currentY);
|
||||||
|
|
||||||
|
// --- 11. Footer ---
|
||||||
|
// Ensure footer doesn't float too high on short pages, but pushes down if content goes long
|
||||||
|
let footerY = currentY + 30;
|
||||||
|
if (footerY < pageHeight - 30) {
|
||||||
|
footerY = pageHeight - 30;
|
||||||
|
}
|
||||||
|
|
||||||
|
doc.setDrawColor(...primaryColor);
|
||||||
|
doc.setLineWidth(0.3);
|
||||||
|
doc.line(20, footerY - 5, pageWidth - 20, footerY - 5);
|
||||||
|
|
||||||
|
doc.setFontSize(9);
|
||||||
|
doc.setFont('helvetica', 'italic');
|
||||||
|
doc.setTextColor(...grayColor);
|
||||||
|
const timestamp = this.datePipe.transform(new Date(), 'medium');
|
||||||
|
doc.text(`This is a system-generated document. Generated on: ${timestamp}`, 20, footerY + 2);
|
||||||
|
|
||||||
|
doc.save(`WelcomeKit_Dispatch_${trackingId !== 'N/A' ? trackingId : new Date().getTime()}.pdf`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,428 @@
|
|||||||
|
<!--
|
||||||
|
Copyright since 2025 Mifos Initiative
|
||||||
|
|
||||||
|
This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<div class="f121">
|
||||||
|
@if (loading) {
|
||||||
|
<div class="f121__loading">
|
||||||
|
<mat-spinner [diameter]="36"></mat-spinner>
|
||||||
|
<span>Reading the customer's deposit ledger…</span>
|
||||||
|
</div>
|
||||||
|
} @else if (loadError) {
|
||||||
|
<div class="f121__note f121__note--warn">
|
||||||
|
<mat-icon>error_outline</mat-icon>
|
||||||
|
<div>Could not load the customer's accounts. Try again, or check the deposit service.</div>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<!-- ── Header ── -->
|
||||||
|
<div class="f121__head">
|
||||||
|
<div>
|
||||||
|
<h3 class="f121__title">Form 121 & TDS on deposit interest</h3>
|
||||||
|
<div class="f121__sub">
|
||||||
|
Tax deducted at source under {{ statute.section }} on fixed and recurring deposit interest.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="f121__controls">
|
||||||
|
@if (availableFys.length) {
|
||||||
|
<label class="f121__field">
|
||||||
|
<span>Financial year</span>
|
||||||
|
<select [(ngModel)]="fy" (ngModelChange)="recompute()">
|
||||||
|
@for (year of availableFys; track year) {
|
||||||
|
<option [value]="year">FY {{ year }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
<div class="f121__field">
|
||||||
|
<span>Settlement</span>
|
||||||
|
<div
|
||||||
|
class="f121__mode"
|
||||||
|
[matTooltip]="'Set by the deposit product\'s interest-posting frequency: tax falls due when interest is credited, so the branch cannot defer it. Not a customer preference.'"
|
||||||
|
>
|
||||||
|
<mat-icon>schedule</mat-icon>
|
||||||
|
{{ mode === 'QUARTERLY' ? 'Quarterly' : 'At year end' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!schedule) {
|
||||||
|
<div class="f121__note">
|
||||||
|
<mat-icon>info_outline</mat-icon>
|
||||||
|
<div>
|
||||||
|
@if (!dateOfBirth) {
|
||||||
|
Date of birth is not on record, so the senior-citizen threshold cannot be determined. Add it on the Personal
|
||||||
|
Data tab.
|
||||||
|
} @else {
|
||||||
|
No interest has been credited to this customer's deposits yet.
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<!-- ── KPI strip ── -->
|
||||||
|
<div class="f121__kpis">
|
||||||
|
<div class="f121__kpi">
|
||||||
|
<div class="f121__kpi-n">₹{{ schedule.totalInterest | number: '1.0-0' }}</div>
|
||||||
|
<div class="f121__kpi-l">Interest earned (FY {{ schedule.fy }})</div>
|
||||||
|
</div>
|
||||||
|
<div class="f121__kpi" [class.f121__kpi--warn]="actualTds > 0">
|
||||||
|
<div class="f121__kpi-n">₹{{ actualTds | number: '1.0-0' }}</div>
|
||||||
|
<div class="f121__kpi-l">TDS deducted</div>
|
||||||
|
</div>
|
||||||
|
<div class="f121__kpi">
|
||||||
|
<div class="f121__kpi-n">₹{{ schedule.threshold | number: '1.0-0' }}</div>
|
||||||
|
<div class="f121__kpi-l">Threshold ({{ schedule.senior ? 'senior citizen' : 'general' }})</div>
|
||||||
|
</div>
|
||||||
|
@if (thresholdCrossed) {
|
||||||
|
<div class="f121__kpi f121__kpi--warn">
|
||||||
|
<div class="f121__kpi-n">Crossed</div>
|
||||||
|
<div class="f121__kpi-l">TDS applies this year</div>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="f121__kpi f121__kpi--ok">
|
||||||
|
<div class="f121__kpi-n">₹{{ headroom | number: '1.0-0' }}</div>
|
||||||
|
<div class="f121__kpi-l">More interest before TDS starts</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (tdsSaved > 0) {
|
||||||
|
<div class="f121__kpi f121__kpi--ok">
|
||||||
|
<div class="f121__kpi-n">₹{{ tdsSaved | number: '1.0-0' }}</div>
|
||||||
|
<div class="f121__kpi-l">Saved by Form 121</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Alerts ── -->
|
||||||
|
@if (variance !== 0) {
|
||||||
|
<div class="f121__note f121__note--warn">
|
||||||
|
<mat-icon>warning_amber</mat-icon>
|
||||||
|
<div>
|
||||||
|
<strong>Ledger and computed tax differ by ₹{{ variance | number: '1.0-0' }}.</strong>
|
||||||
|
The ledger shows ₹{{ actualTds | number: '1.0-0' }} withheld; {{ statute.section }} on this year's interest
|
||||||
|
comes to ₹{{ schedule.totalTds | number: '1.0-0' }}. Investigate before the quarterly return is filed.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (declarationNotAppliedToAccounts) {
|
||||||
|
<div class="f121__note f121__note--warn">
|
||||||
|
<mat-icon>warning_amber</mat-icon>
|
||||||
|
<div>
|
||||||
|
A Form 121 declaration is on record for this year, but withholding is still switched on for one or more
|
||||||
|
deposits. Turn it off on those accounts, or tax will keep being deducted.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (declarationUselessOnJointAccounts) {
|
||||||
|
<div class="f121__note f121__note--warn">
|
||||||
|
<mat-icon>warning_amber</mat-icon>
|
||||||
|
<div>
|
||||||
|
This customer has filed Form 121, but tax on
|
||||||
|
{{ jointHolderAccounts.length }} joint deposit{{ jointHolderAccounts.length === 1 ? '' : 's' }} is deducted
|
||||||
|
against the first-named holder. Their declaration does not cover those — the primary holder must file their
|
||||||
|
own.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (shouldSuggestDeclaration) {
|
||||||
|
<div class="f121__note f121__note--tip">
|
||||||
|
<mat-icon>lightbulb_outline</mat-icon>
|
||||||
|
<div>
|
||||||
|
₹{{ schedule.totalTds | number: '1.0-0' }} of tax falls due this year. If the customer's total tax liability
|
||||||
|
for the year is nil, a {{ formLabel }} declaration would stop it being deducted at all — better than
|
||||||
|
reclaiming it later as a refund.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!panWellFormed) {
|
||||||
|
<div class="f121__note f121__note--warn">
|
||||||
|
<mat-icon>error_outline</mat-icon>
|
||||||
|
<div>
|
||||||
|
No valid PAN on record, so tax is deducted at the higher section 206AA rate of
|
||||||
|
{{ schedule.rateApplied * 100 | number: '1.0-0' }}%. A Form 121 declaration is void without a valid PAN.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@for (note of schedule.notes; track note) {
|
||||||
|
<div class="f121__note">
|
||||||
|
<mat-icon>info_outline</mat-icon>
|
||||||
|
<div>{{ note }}</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- ── Declaration panel ── -->
|
||||||
|
<div class="f121__card">
|
||||||
|
<div class="f121__card-head">
|
||||||
|
<mat-icon>assignment_turned_in</mat-icon>
|
||||||
|
<span>Declaration</span>
|
||||||
|
</div>
|
||||||
|
<div class="f121__kv">
|
||||||
|
<div class="f121__kv-row">
|
||||||
|
<span>Status</span>
|
||||||
|
<span>
|
||||||
|
@if (declarationOnRecord) {
|
||||||
|
<span class="f121__pill f121__pill--ok">On record — {{ formLabel }}</span>
|
||||||
|
} @else {
|
||||||
|
<span class="f121__pill">Not filed</span>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="f121__kv-row">
|
||||||
|
<span>Form & threshold band</span>
|
||||||
|
<span>{{ formLabel }} · {{ bandLabel }} band · {{ age }} yrs in this FY</span>
|
||||||
|
</div>
|
||||||
|
<div class="f121__kv-row">
|
||||||
|
<span>PAN</span>
|
||||||
|
<span class="f121__mono">
|
||||||
|
{{ pan || '—' }}
|
||||||
|
@if (pan && !panWellFormed) {
|
||||||
|
<span class="f121__pill f121__pill--warn">invalid</span>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
@if (declarationOnRecord && declarationFy) {
|
||||||
|
<div class="f121__kv-row">
|
||||||
|
<span>Declared for</span>
|
||||||
|
<span>FY {{ declarationFy }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (declarationOnRecord && !acceptanceDateKnown) {
|
||||||
|
<div class="f121__kv-row f121__kv-row--muted">
|
||||||
|
<span>Acceptance date</span>
|
||||||
|
<span>
|
||||||
|
Not captured — the declaration is treated as covering the whole year. A mid-year acceptance would change
|
||||||
|
the figures below.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (eligibility) {
|
||||||
|
@for (reason of eligibility.reasons; track reason) {
|
||||||
|
<div class="f121__kv-row f121__kv-row--warn">
|
||||||
|
<span>Eligibility</span>
|
||||||
|
<span>{{ reason }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@for (note of eligibility.unassessed; track note) {
|
||||||
|
<div class="f121__kv-row f121__kv-row--muted">
|
||||||
|
<span>Not assessed</span>
|
||||||
|
<span>{{ note }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Quarter-wise ── -->
|
||||||
|
<div class="f121__card">
|
||||||
|
<div class="f121__card-head">
|
||||||
|
<mat-icon>table_chart</mat-icon>
|
||||||
|
<span>Quarter by quarter</span>
|
||||||
|
</div>
|
||||||
|
<div class="f121__table-wrap">
|
||||||
|
<table class="f121__table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Quarter</th>
|
||||||
|
<th class="num">Interest</th>
|
||||||
|
<th class="num">Cumulative</th>
|
||||||
|
<th class="num">Liability to date</th>
|
||||||
|
<th class="num">TDS this quarter</th>
|
||||||
|
<th>Return due</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (line of schedule.lines; track trackLine($index, line)) {
|
||||||
|
<tr [class.f121__row--warn]="line.thresholdCrossedHere">
|
||||||
|
<td>
|
||||||
|
{{ line.label }}
|
||||||
|
@if (line.thresholdCrossedHere) {
|
||||||
|
<span class="f121__pill f121__pill--warn">threshold crossed</span>
|
||||||
|
}
|
||||||
|
@if (line.declarationActive) {
|
||||||
|
<span class="f121__pill f121__pill--ok">Form 121 active</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td class="num">₹{{ line.interest | number: '1.0-0' }}</td>
|
||||||
|
<td class="num">₹{{ line.cumInterest | number: '1.0-0' }}</td>
|
||||||
|
<td class="num">₹{{ line.cumLiability | number: '1.0-0' }}</td>
|
||||||
|
<td class="num">
|
||||||
|
@if (line.tds !== 0) {
|
||||||
|
<strong>₹{{ line.tds | number: '1.0-0' }}</strong>
|
||||||
|
} @else {
|
||||||
|
<span class="muted">—</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td class="muted">{{ line.dueDate }}</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
<tr class="f121__row--total">
|
||||||
|
<td><strong>Total</strong></td>
|
||||||
|
<td class="num">
|
||||||
|
<strong>₹{{ schedule.totalInterest | number: '1.0-0' }}</strong>
|
||||||
|
</td>
|
||||||
|
<td></td>
|
||||||
|
<td></td>
|
||||||
|
<td class="num">
|
||||||
|
<strong>₹{{ schedule.totalTds | number: '1.0-0' }}</strong>
|
||||||
|
</td>
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
@if (schedule.shieldedInterest > 0) {
|
||||||
|
<div class="f121__foot">
|
||||||
|
₹{{ schedule.shieldedInterest | number: '1.0-0' }} was paid without deduction on the strength of the
|
||||||
|
declaration. This amount is still reportable in the quarterly {{ statute.statement }} return.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Section 1: accounts that count ── -->
|
||||||
|
<div class="f121__card">
|
||||||
|
<div class="f121__card-head">
|
||||||
|
<mat-icon>account_balance</mat-icon>
|
||||||
|
<span>Deposits counted towards this customer's TDS</span>
|
||||||
|
</div>
|
||||||
|
@if (liableAccounts.length) {
|
||||||
|
<div class="f121__table-wrap">
|
||||||
|
<table class="f121__table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Account</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th class="num">Interest (FY)</th>
|
||||||
|
<th class="num">TDS deducted</th>
|
||||||
|
<th>Withholding</th>
|
||||||
|
<th>Settles</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (a of liableAccounts; track trackAccount($index, a)) {
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div class="f121__mono">{{ a.accountNo }}</div>
|
||||||
|
<div class="muted">{{ a.productName }}</div>
|
||||||
|
</td>
|
||||||
|
<td>{{ a.kind }}</td>
|
||||||
|
<td class="num">₹{{ a.interest | number: '1.0-0' }}</td>
|
||||||
|
<td class="num">₹{{ a.tdsDeducted | number: '1.0-0' }}</td>
|
||||||
|
<td>
|
||||||
|
@if (a.withHoldTax) {
|
||||||
|
<span class="f121__pill f121__pill--warn">On</span>
|
||||||
|
@if (a.taxGroup) {
|
||||||
|
<div class="muted">{{ a.taxGroup }}</div>
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
<span class="f121__pill">Off</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>{{ a.mode === 'QUARTERLY' ? 'Quarterly' : 'Year end' }}</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="f121__foot">
|
||||||
|
Aggregated across every branch — a bank on core banking computes the deduction threshold for the bank as a
|
||||||
|
whole, not branch by branch.
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="f121__empty">No fixed or recurring deposits where this customer is the deductee.</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Section 2: joint holdings ── -->
|
||||||
|
@if (jointHolderAccounts.length) {
|
||||||
|
<div class="f121__card">
|
||||||
|
<div class="f121__card-head">
|
||||||
|
<mat-icon>group</mat-icon>
|
||||||
|
<span>Deposits where this customer is a joint holder</span>
|
||||||
|
</div>
|
||||||
|
<div class="f121__table-wrap">
|
||||||
|
<table class="f121__table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Account</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th class="num">Interest (FY)</th>
|
||||||
|
<th>Deducted against</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (a of jointHolderAccounts; track trackAccount($index, a)) {
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div class="f121__mono">{{ a.accountNo }}</div>
|
||||||
|
<div class="muted">{{ a.productName }}</div>
|
||||||
|
</td>
|
||||||
|
<td>{{ a.kind }}</td>
|
||||||
|
<td class="num">₹{{ a.interest | number: '1.0-0' }}</td>
|
||||||
|
<td class="muted">
|
||||||
|
First-named holder
|
||||||
|
@if (a.deducteeClientId) {
|
||||||
|
(client {{ a.deducteeClientId }})
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="f121__foot">
|
||||||
|
Tax on a joint deposit is deducted against the first-named holder's PAN, so this interest is
|
||||||
|
<strong>not</strong> counted in the figures above and does not use up this customer's threshold.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- ── Section 3: exempt accounts ── -->
|
||||||
|
@if (exemptAccounts.length) {
|
||||||
|
<div class="f121__card">
|
||||||
|
<div class="f121__card-head">
|
||||||
|
<mat-icon>savings</mat-icon>
|
||||||
|
<span>Savings and current accounts</span>
|
||||||
|
</div>
|
||||||
|
<div class="f121__table-wrap">
|
||||||
|
<table class="f121__table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Account</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th class="num">Interest (FY)</th>
|
||||||
|
<th>TDS</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (a of exemptAccounts; track trackAccount($index, a)) {
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div class="f121__mono">{{ a.accountNo }}</div>
|
||||||
|
<div class="muted">{{ a.productName }}</div>
|
||||||
|
</td>
|
||||||
|
<td>{{ a.kind }}</td>
|
||||||
|
<td class="num">₹{{ a.interest | number: '1.0-0' }}</td>
|
||||||
|
<td><span class="f121__pill">Not applicable</span></td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="f121__foot">
|
||||||
|
The charge applies to time deposits. Savings bank interest is outside it and is excluded from the threshold
|
||||||
|
— the customer claims the 80TTA or 80TTB deduction on it in their own return instead.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.f121 {
|
||||||
|
--f121-line: #e5e7eb;
|
||||||
|
--f121-bg: #f9fafb;
|
||||||
|
--f121-ink: #111827;
|
||||||
|
--f121-muted: #6b7280;
|
||||||
|
--f121-blue: #1d4ed8;
|
||||||
|
--f121-green: #15803d;
|
||||||
|
--f121-amber: #b45309;
|
||||||
|
|
||||||
|
padding: 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--f121-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.f121__loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 40px 16px;
|
||||||
|
color: var(--f121-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Header ── */
|
||||||
|
.f121__head {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f121__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f121__sub {
|
||||||
|
margin-top: 4px;
|
||||||
|
color: var(--f121-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f121__controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f121__field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
> span {
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--f121-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
padding: 7px 10px;
|
||||||
|
border: 1px solid var(--f121-line);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.f121__mode {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
border: 1px solid var(--f121-line);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--f121-bg);
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: help;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
color: var(--f121-muted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── KPI strip ── */
|
||||||
|
.f121__kpis {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f121__kpi {
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid var(--f121-line);
|
||||||
|
border-left: 3px solid var(--f121-blue);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
|
||||||
|
&--ok {
|
||||||
|
border-left-color: var(--f121-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--warn {
|
||||||
|
border-left-color: var(--f121-amber);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.f121__kpi-n {
|
||||||
|
font-size: 19px;
|
||||||
|
font-weight: 800;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f121__kpi-l {
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--f121-muted);
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Notes ── */
|
||||||
|
.f121__note {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding: 11px 13px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border-left: 3px solid var(--f121-blue);
|
||||||
|
background: color-mix(in srgb, var(--f121-blue) 6%, transparent);
|
||||||
|
line-height: 1.5;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
color: var(--f121-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--warn {
|
||||||
|
border-left-color: var(--f121-amber);
|
||||||
|
background: color-mix(in srgb, var(--f121-amber) 8%, transparent);
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
color: var(--f121-amber);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--tip {
|
||||||
|
border-left-color: var(--f121-green);
|
||||||
|
background: color-mix(in srgb, var(--f121-green) 7%, transparent);
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
color: var(--f121-green);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Cards ── */
|
||||||
|
.f121__card {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
border: 1px solid var(--f121-line);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fff;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f121__card-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 11px 14px;
|
||||||
|
background: var(--f121-bg);
|
||||||
|
border-bottom: 1px solid var(--f121-line);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 12.5px;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
color: var(--f121-muted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.f121__foot {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-top: 1px solid var(--f121-line);
|
||||||
|
background: var(--f121-bg);
|
||||||
|
color: var(--f121-muted);
|
||||||
|
font-size: 11.5px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f121__empty {
|
||||||
|
padding: 20px 14px;
|
||||||
|
color: var(--f121-muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Key/value list ── */
|
||||||
|
.f121__kv-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 190px 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-bottom: 1px solid #f3f4f6;
|
||||||
|
line-height: 1.5;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
> span:first-child {
|
||||||
|
color: var(--f121-muted);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--muted > span:last-child {
|
||||||
|
color: var(--f121-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--warn > span:last-child {
|
||||||
|
color: var(--f121-amber);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Tables ── */
|
||||||
|
.f121__table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f121__table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 12.5px;
|
||||||
|
|
||||||
|
th {
|
||||||
|
text-align: left;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: #fff;
|
||||||
|
border-bottom: 1px solid var(--f121-line);
|
||||||
|
color: var(--f121-muted);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 10.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-bottom: 1px solid #f3f4f6;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:last-child td {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.num {
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: var(--f121-muted);
|
||||||
|
font-size: 11.5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.f121__row--warn > td {
|
||||||
|
background: color-mix(in srgb, var(--f121-amber) 7%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.f121__row--total > td {
|
||||||
|
background: var(--f121-bg);
|
||||||
|
border-top: 1px solid var(--f121-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Bits ── */
|
||||||
|
.f121__pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px 9px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--f121-line);
|
||||||
|
background: var(--f121-bg);
|
||||||
|
color: var(--f121-muted);
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
& + & {
|
||||||
|
margin-left: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--ok {
|
||||||
|
border-color: color-mix(in srgb, var(--f121-green) 35%, transparent);
|
||||||
|
background: color-mix(in srgb, var(--f121-green) 10%, transparent);
|
||||||
|
color: var(--f121-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--warn {
|
||||||
|
border-color: color-mix(in srgb, var(--f121-amber) 35%, transparent);
|
||||||
|
background: color-mix(in srgb, var(--f121-amber) 12%, transparent);
|
||||||
|
color: var(--f121-amber);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.f121__mono {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
@@ -0,0 +1,500 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Angular Imports */
|
||||||
|
import { Component, OnDestroy, OnInit, inject } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { ActivatedRoute } from '@angular/router';
|
||||||
|
import { MatIcon } from '@angular/material/icon';
|
||||||
|
import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
||||||
|
import { MatTooltip } from '@angular/material/tooltip';
|
||||||
|
import { Observable, Subject, forkJoin, of } from 'rxjs';
|
||||||
|
import { catchError, switchMap, takeUntil } from 'rxjs/operators';
|
||||||
|
|
||||||
|
/** Custom Services */
|
||||||
|
import { ClientsService } from '../../clients.service';
|
||||||
|
import { SavingsService } from 'app/savings/savings.service';
|
||||||
|
import { FixedDepositsService } from 'app/deposits/fixed-deposits/fixed-deposits.service';
|
||||||
|
import { RecurringDepositsService } from 'app/deposits/recurring-deposits/recurring-deposits.service';
|
||||||
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
|
||||||
|
/** Domain logic */
|
||||||
|
import {
|
||||||
|
Declaration,
|
||||||
|
DeclarationForm,
|
||||||
|
EligibilityResult,
|
||||||
|
FY_TAX_CONFIG,
|
||||||
|
InterestCredit,
|
||||||
|
TdsAccountInput,
|
||||||
|
TdsMode,
|
||||||
|
TdsQuarterLine,
|
||||||
|
TdsSchedule,
|
||||||
|
ageInFy,
|
||||||
|
buildSchedule,
|
||||||
|
checkEligibility,
|
||||||
|
collectTdsCredits,
|
||||||
|
declarationFormFor,
|
||||||
|
formKindFor,
|
||||||
|
fyOf,
|
||||||
|
isTdsApplicable,
|
||||||
|
isWellFormedPan,
|
||||||
|
normaliseFy,
|
||||||
|
tdsDeducteeOf,
|
||||||
|
tdsModeFor
|
||||||
|
} from 'app/letter-mgmt/certificates/form-15gh/tds-15gh.compute';
|
||||||
|
|
||||||
|
/** One of the customer's interest-bearing accounts, with its FY figures. */
|
||||||
|
export interface Form121AccountRow {
|
||||||
|
accountNo: string;
|
||||||
|
productName: string;
|
||||||
|
/** 'Fixed Deposit', 'Recurring Deposit' or 'Savings'. */
|
||||||
|
kind: string;
|
||||||
|
/** Interest credited to this account inside the selected FY. */
|
||||||
|
interest: number;
|
||||||
|
/** Tax Fineract has actually withheld on this account inside the FY. */
|
||||||
|
tdsDeducted: number;
|
||||||
|
/** Whether Fineract is currently withholding tax on this account. */
|
||||||
|
withHoldTax: boolean;
|
||||||
|
taxGroup: string | null;
|
||||||
|
/** When tax on this account settles — from the product, not a preference. */
|
||||||
|
mode: TdsMode;
|
||||||
|
/** Interest here attracts TDS at all (a time deposit, not a savings account). */
|
||||||
|
tdsApplicable: boolean;
|
||||||
|
/** This customer is the deductee — false when they are a joint holder only. */
|
||||||
|
isDeductee: boolean;
|
||||||
|
/** Who the tax is deducted against, when it is not this customer. */
|
||||||
|
deducteeClientId: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fineract hands dates back as [yyyy, m, d]; normalise to ISO. */
|
||||||
|
function toIso(value: unknown): string | null {
|
||||||
|
if (Array.isArray(value) && value.length >= 3) {
|
||||||
|
const [
|
||||||
|
y,
|
||||||
|
m,
|
||||||
|
d
|
||||||
|
] = value as number[];
|
||||||
|
return `${y}-${`${m}`.padStart(2, '0')}-${`${d}`.padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
if (typeof value === 'string' && value.trim()) {
|
||||||
|
const parsed = new Date(value);
|
||||||
|
if (!isNaN(parsed.getTime())) {
|
||||||
|
return parsed.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-form121-tab',
|
||||||
|
templateUrl: './form121-tab.component.html',
|
||||||
|
styleUrls: ['./form121-tab.component.scss'],
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
...STANDALONE_SHARED_IMPORTS,
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
MatIcon,
|
||||||
|
MatProgressSpinner,
|
||||||
|
MatTooltip
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class Form121TabComponent implements OnInit, OnDestroy {
|
||||||
|
private route = inject(ActivatedRoute);
|
||||||
|
private clientsService = inject(ClientsService);
|
||||||
|
private savingsService = inject(SavingsService);
|
||||||
|
private fixedDepositsService = inject(FixedDepositsService);
|
||||||
|
private recurringDepositsService = inject(RecurringDepositsService);
|
||||||
|
|
||||||
|
private destroy$ = new Subject<void>();
|
||||||
|
|
||||||
|
clientId = '';
|
||||||
|
loading = true;
|
||||||
|
loadError = false;
|
||||||
|
|
||||||
|
// ── Customer ──
|
||||||
|
clientName = '';
|
||||||
|
dateOfBirth = '';
|
||||||
|
pan = '';
|
||||||
|
panWellFormed = false;
|
||||||
|
|
||||||
|
// ── Declaration on record ──
|
||||||
|
declarationOnRecord = false;
|
||||||
|
/**
|
||||||
|
* The form actually in force for the selected year. From FY 2026-27 a single
|
||||||
|
* Form 121 replaced the 15G/15H split, so this is derived from the year rather
|
||||||
|
* than from the payee's age.
|
||||||
|
*/
|
||||||
|
declarationForm: DeclarationForm = '121';
|
||||||
|
/** Kind recorded at KYC, meaningful only for a pre-2026 year. */
|
||||||
|
declarationKind: '15G' | '15H' = '15G';
|
||||||
|
declarationFy = '';
|
||||||
|
/**
|
||||||
|
* The KYC step records only the year, not the date the branch accepted the
|
||||||
|
* declaration, so it has to be treated as covering the whole year. Surfaced on
|
||||||
|
* screen rather than hidden — a mid-year acceptance would change the figures.
|
||||||
|
*/
|
||||||
|
acceptanceDateKnown = false;
|
||||||
|
|
||||||
|
eligibility: EligibilityResult | null = null;
|
||||||
|
|
||||||
|
// ── Selection ──
|
||||||
|
availableFys: string[] = [];
|
||||||
|
fy = '';
|
||||||
|
|
||||||
|
// ── Computed ──
|
||||||
|
accounts: Form121AccountRow[] = [];
|
||||||
|
schedule: TdsSchedule | null = null;
|
||||||
|
actualTds = 0;
|
||||||
|
tdsSaved = 0;
|
||||||
|
|
||||||
|
/** Raw account inputs, kept so the year selector can recompute cheaply. */
|
||||||
|
private accountInputs: (TdsAccountInput & { meta: Form121AccountRow })[] = [];
|
||||||
|
/** Withheld-tax postings by FY, from the ledger. */
|
||||||
|
private actualTdsByFy = new Map<string, number>();
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.route.parent.params.pipe(takeUntil(this.destroy$)).subscribe((params: any) => {
|
||||||
|
this.clientId = params['clientId'];
|
||||||
|
this.load();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.destroy$.next();
|
||||||
|
this.destroy$.complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────── Loading ──────────────────────────────
|
||||||
|
|
||||||
|
private load(): void {
|
||||||
|
this.loading = true;
|
||||||
|
this.loadError = false;
|
||||||
|
|
||||||
|
forkJoin({
|
||||||
|
client: this.clientsService.getClientData(this.clientId, true).pipe(catchError(() => of(null))),
|
||||||
|
kyc: this.clientsService.getClientKyc(this.clientId, true).pipe(catchError(() => of(null))),
|
||||||
|
accounts: this.clientsService.getClientAccountData(this.clientId).pipe(catchError(() => of(null)))
|
||||||
|
})
|
||||||
|
.pipe(
|
||||||
|
takeUntil(this.destroy$),
|
||||||
|
switchMap(({ client, kyc, accounts }) => {
|
||||||
|
this.applyClient(client);
|
||||||
|
this.applyKyc(kyc);
|
||||||
|
const deposits = this.depositAccountsOf(accounts);
|
||||||
|
return deposits.length ? forkJoin(deposits.map((a) => this.fetchAccount(a))) : of([] as any[]);
|
||||||
|
}),
|
||||||
|
catchError(() => {
|
||||||
|
this.loadError = true;
|
||||||
|
return of([] as any[]);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.subscribe((details: any[]) => {
|
||||||
|
this.loading = false;
|
||||||
|
this.ingest(details.filter(Boolean));
|
||||||
|
this.recompute();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyClient(client: any): void {
|
||||||
|
this.clientName = client?.displayName ?? '';
|
||||||
|
this.dateOfBirth = toIso(client?.dateOfBirth) ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyKyc(kyc: any): void {
|
||||||
|
this.pan = (kyc?.panNumber ?? '').toUpperCase();
|
||||||
|
this.panWellFormed = isWellFormedPan(this.pan);
|
||||||
|
this.declarationOnRecord = !!kyc?.form15Applicable;
|
||||||
|
this.declarationKind = kyc?.form15Type === '15H' ? '15H' : '15G';
|
||||||
|
this.declarationForm = this.fy ? declarationFormFor(this.fy, this.dateOfBirth) : '121';
|
||||||
|
this.declarationFy = kyc?.form15FY ?? '';
|
||||||
|
this.acceptanceDateKnown = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Savings, FD and RD accounts — everything that can credit interest. */
|
||||||
|
private depositAccountsOf(accounts: any): any[] {
|
||||||
|
return (accounts?.savingsAccounts ?? []).filter(
|
||||||
|
(a: any) => a.status?.active || a.status?.matured || a.status?.closed
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private fetchAccount(a: any): Observable<any> {
|
||||||
|
const id = `${a.id}`;
|
||||||
|
const code = a.depositType?.code ?? '';
|
||||||
|
let call: Observable<any>;
|
||||||
|
if (code.includes('fixedDeposit')) {
|
||||||
|
call = this.fixedDepositsService.getFixedDepositsAccountData(id);
|
||||||
|
} else if (code.includes('recurringDeposit')) {
|
||||||
|
call = this.recurringDepositsService.getRecurringDepositsAccountData(id);
|
||||||
|
} else {
|
||||||
|
call = this.savingsService.getSavingsAccountData(id);
|
||||||
|
}
|
||||||
|
// One unreadable account must not blank the whole tab.
|
||||||
|
return call.pipe(catchError(() => of(null)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns each account payload into an engine input.
|
||||||
|
*
|
||||||
|
* Joint and minor attributes are read defensively: they are captured on the
|
||||||
|
* account stepper but may not surface on every read endpoint, and an absent
|
||||||
|
* field must default to a single-name account rather than throw.
|
||||||
|
*/
|
||||||
|
private ingest(details: any[]): void {
|
||||||
|
this.accountInputs = [];
|
||||||
|
this.actualTdsByFy = new Map<string, number>();
|
||||||
|
const fys = new Set<string>();
|
||||||
|
const clientIdNum = Number(this.clientId);
|
||||||
|
|
||||||
|
for (const d of details) {
|
||||||
|
const code: string = d.depositType?.code ?? '';
|
||||||
|
const extra = d.extraAttributes ?? d.externalAttributes ?? d;
|
||||||
|
|
||||||
|
const ref = {
|
||||||
|
id: d.id,
|
||||||
|
accountNo: d.accountNo ?? `${d.id}`,
|
||||||
|
depositTypeCode: code,
|
||||||
|
accountStatus: extra.accountStatus ?? d.accountStatus ?? 'SINGLE',
|
||||||
|
clientId: d.clientId ?? clientIdNum,
|
||||||
|
primaryClientId: extra.primaryClientId ?? d.primaryClientId ?? null,
|
||||||
|
guardianClientId: extra.guardianClientId ?? d.guardianClientId ?? null,
|
||||||
|
jointHolders: extra.jointHolders ?? d.jointHolders ?? [],
|
||||||
|
interestPostingPeriodType: d.interestPostingPeriodType ?? null
|
||||||
|
};
|
||||||
|
|
||||||
|
const credits: InterestCredit[] = [];
|
||||||
|
let tdsDeducted = 0;
|
||||||
|
|
||||||
|
for (const t of d.transactions ?? []) {
|
||||||
|
if (t.reversed || t.isReversal) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const iso = toIso(t.date);
|
||||||
|
if (!iso) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
fys.add(fyOf(iso));
|
||||||
|
|
||||||
|
if (t.transactionType?.interestPosting) {
|
||||||
|
credits.push({ date: iso, amount: t.amount ?? 0, accountNo: ref.accountNo });
|
||||||
|
} else if (t.transactionType?.withholdTax) {
|
||||||
|
const amount = t.amount ?? 0;
|
||||||
|
tdsDeducted += amount;
|
||||||
|
const txnFy = fyOf(iso);
|
||||||
|
this.actualTdsByFy.set(txnFy, (this.actualTdsByFy.get(txnFy) ?? 0) + amount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deductee = tdsDeducteeOf(ref);
|
||||||
|
const meta: Form121AccountRow = {
|
||||||
|
accountNo: ref.accountNo,
|
||||||
|
productName: d.savingsProductName ?? d.depositProductName ?? d.productName ?? '—',
|
||||||
|
kind: code.includes('fixedDeposit')
|
||||||
|
? 'Fixed Deposit'
|
||||||
|
: code.includes('recurringDeposit')
|
||||||
|
? 'Recurring Deposit'
|
||||||
|
: 'Savings',
|
||||||
|
interest: 0,
|
||||||
|
tdsDeducted,
|
||||||
|
withHoldTax: !!d.withHoldTax,
|
||||||
|
taxGroup: d.taxGroup?.name ?? null,
|
||||||
|
mode: tdsModeFor(ref),
|
||||||
|
tdsApplicable: isTdsApplicable(ref),
|
||||||
|
isDeductee: deductee === clientIdNum,
|
||||||
|
deducteeClientId: deductee
|
||||||
|
};
|
||||||
|
|
||||||
|
this.accountInputs.push({ ...ref, credits, meta });
|
||||||
|
}
|
||||||
|
|
||||||
|
this.availableFys = [...fys].sort().reverse();
|
||||||
|
if (!this.availableFys.includes(this.fy)) {
|
||||||
|
this.fy = this.availableFys[0] ?? (this.declarationFy ? normaliseFy(this.declarationFy) : '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────────────────────── Computation ─────────────────────────────
|
||||||
|
|
||||||
|
/** Re-runs the engine for the selected year. */
|
||||||
|
recompute(): void {
|
||||||
|
this.schedule = null;
|
||||||
|
this.eligibility = null;
|
||||||
|
this.tdsSaved = 0;
|
||||||
|
this.accounts = this.accountInputs.map((a) => a.meta);
|
||||||
|
|
||||||
|
if (!this.fy) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const year = normaliseFy(this.fy);
|
||||||
|
|
||||||
|
// Per-account interest for the selected year, for the three lists.
|
||||||
|
for (const a of this.accountInputs) {
|
||||||
|
a.meta.interest = a.credits.filter((c) => fyOf(c.date) === year).reduce((s, c) => s + c.amount, 0);
|
||||||
|
}
|
||||||
|
this.actualTds = this.actualTdsByFy.get(year) ?? 0;
|
||||||
|
|
||||||
|
if (!this.dateOfBirth) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The one place rules 8 to 12 are applied: savings interest dropped, joint
|
||||||
|
// deposits attributed to the primary holder, every branch aggregated.
|
||||||
|
const credits = collectTdsCredits(Number(this.clientId), this.accountInputs, year);
|
||||||
|
const ctx = { fy: year, dateOfBirth: this.dateOfBirth, pan: this.pan, resident: true };
|
||||||
|
const declaration = this.declarationForSelectedFy();
|
||||||
|
|
||||||
|
this.schedule = buildSchedule(this.overallMode, credits, { ...ctx, declaration });
|
||||||
|
|
||||||
|
if (declaration) {
|
||||||
|
const without = buildSchedule(this.overallMode, credits, { ...ctx, declaration: null });
|
||||||
|
this.tdsSaved = Math.max(0, without.totalTds - this.schedule.totalTds);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The branch cannot compute income earned outside the bank, so the income
|
||||||
|
// limb is reported as unassessed rather than silently passed.
|
||||||
|
this.eligibility = checkEligibility(ctx, {
|
||||||
|
estimatedInterest: this.schedule.totalInterest,
|
||||||
|
estimatedIncome: 0,
|
||||||
|
incomeOnRecord: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When tax settles for this customer overall.
|
||||||
|
*
|
||||||
|
* Timing is per account, but the customer needs one view. A quarterly account
|
||||||
|
* anywhere in the book means a deduction falls due at the first quarter end, so
|
||||||
|
* that is the honest headline; the per-account list carries the detail.
|
||||||
|
*/
|
||||||
|
private get overallMode(): TdsMode {
|
||||||
|
const applicable = this.accountInputs.filter((a) => a.meta.tdsApplicable && a.meta.isDeductee);
|
||||||
|
return applicable.some((a) => a.meta.mode === 'QUARTERLY') ? 'QUARTERLY' : 'ANNUAL';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The declaration, but only when it was filed for the year being shown. */
|
||||||
|
private declarationForSelectedFy(): Declaration | null {
|
||||||
|
if (!this.declarationOnRecord || !this.fy) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const year = normaliseFy(this.fy);
|
||||||
|
if (this.declarationFy && normaliseFy(this.declarationFy) !== year) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
kind: this.declarationKind,
|
||||||
|
// No acceptance date on record — treated as covering the whole year.
|
||||||
|
acceptedOn: `${year.slice(0, 4)}-04-01`,
|
||||||
|
fy: year
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────────────────── Template helpers ─────────────────────────
|
||||||
|
|
||||||
|
get mode(): TdsMode {
|
||||||
|
return this.schedule?.mode ?? 'QUARTERLY';
|
||||||
|
}
|
||||||
|
|
||||||
|
get age(): number {
|
||||||
|
return this.dateOfBirth && this.fy ? ageInFy(this.dateOfBirth, this.fy) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
get formKind(): string {
|
||||||
|
return this.dateOfBirth && this.fy ? formKindFor(this.dateOfBirth, this.fy) : '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The form the payee would file for the selected year. */
|
||||||
|
get formLabel(): string {
|
||||||
|
if (!this.fy) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
const form = declarationFormFor(this.fy, this.dateOfBirth);
|
||||||
|
return form === '121' ? 'Form 121' : `Form ${form}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Which deduction trigger applies, now that the forms no longer split by age. */
|
||||||
|
get bandLabel(): string {
|
||||||
|
return this.schedule?.senior ? 'Senior citizen' : 'General';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provision and return references for the selected year. Both changed on
|
||||||
|
* 01 April 2026, so the template reads them rather than hard-coding them.
|
||||||
|
*/
|
||||||
|
get statute(): { section: string; statement: string; rule: string; frequency: string } {
|
||||||
|
const cfg = this.fy ? FY_TAX_CONFIG[normaliseFy(this.fy)] : undefined;
|
||||||
|
return {
|
||||||
|
section: cfg?.interestTdsSection ?? '—',
|
||||||
|
statement: cfg?.tdsStatementForm ?? '—',
|
||||||
|
rule: cfg?.declarationRule ?? '—',
|
||||||
|
frequency: cfg?.declarationStatementFrequency === 'MONTHLY' ? 'monthly' : 'quarterly'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Accounts whose interest counts towards this customer's deduction aggregate. */
|
||||||
|
get liableAccounts(): Form121AccountRow[] {
|
||||||
|
return this.accounts.filter((a) => a.tdsApplicable && a.isDeductee);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deposits where this customer is a joint holder but not the deductee. */
|
||||||
|
get jointHolderAccounts(): Form121AccountRow[] {
|
||||||
|
return this.accounts.filter((a) => a.tdsApplicable && !a.isDeductee);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Savings and current accounts — interest outside the charge on time deposits. */
|
||||||
|
get exemptAccounts(): Form121AccountRow[] {
|
||||||
|
return this.accounts.filter((a) => !a.tdsApplicable);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Engine figure less what the ledger shows was actually withheld. */
|
||||||
|
get variance(): number {
|
||||||
|
return (this.schedule?.totalTds ?? 0) - this.actualTds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How much more interest before TDS starts. */
|
||||||
|
get headroom(): number {
|
||||||
|
if (!this.schedule) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return Math.max(0, this.schedule.threshold - this.schedule.totalInterest);
|
||||||
|
}
|
||||||
|
|
||||||
|
get thresholdCrossed(): boolean {
|
||||||
|
return !!this.schedule && this.schedule.totalInterest > this.schedule.threshold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A declaration is on record but deposit accounts are still set to withhold —
|
||||||
|
* the branch has not switched them off.
|
||||||
|
*/
|
||||||
|
get declarationNotAppliedToAccounts(): boolean {
|
||||||
|
return !!this.declarationForSelectedFy() && this.liableAccounts.some((a) => a.withHoldTax);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The customer filed a declaration but holds joint deposits where someone else
|
||||||
|
* is the deductee — their form does nothing for those.
|
||||||
|
*/
|
||||||
|
get declarationUselessOnJointAccounts(): boolean {
|
||||||
|
return !!this.declarationForSelectedFy() && this.jointHolderAccounts.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Worth prompting the branch to collect a declaration. */
|
||||||
|
get shouldSuggestDeclaration(): boolean {
|
||||||
|
return !this.declarationForSelectedFy() && (this.schedule?.totalTds ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
trackLine(_: number, line: TdsQuarterLine): number {
|
||||||
|
return line.quarter;
|
||||||
|
}
|
||||||
|
|
||||||
|
trackAccount(_: number, account: Form121AccountRow): string {
|
||||||
|
return account.accountNo;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,14 +19,14 @@
|
|||||||
<div class="f60-pan-ok__icon-wrap">
|
<div class="f60-pan-ok__icon-wrap">
|
||||||
<mat-icon class="f60-pan-ok__icon">verified</mat-icon>
|
<mat-icon class="f60-pan-ok__icon">verified</mat-icon>
|
||||||
</div>
|
</div>
|
||||||
<h3 class="f60-pan-ok__title">Form 60 Not Required</h3>
|
<h3 class="f60-pan-ok__title">Form 97 Not Required</h3>
|
||||||
<p class="f60-pan-ok__desc">
|
<p class="f60-pan-ok__desc">
|
||||||
PAN <strong>{{ panNumber }}</strong> is attached to this client.
|
PAN <strong>{{ panNumber }}</strong> is attached to this client.
|
||||||
@if (panVerified) {
|
@if (panVerified) {
|
||||||
<mat-icon class="f60-pan-ok__tick">check_circle</mat-icon> <span class="f60-pan-ok__verified">Verified</span>
|
<mat-icon class="f60-pan-ok__tick">check_circle</mat-icon> <span class="f60-pan-ok__verified">Verified</span>
|
||||||
}
|
}
|
||||||
</p>
|
</p>
|
||||||
<p class="f60-pan-ok__rule">As per Income Tax Rule 114B, Form 60 is only required when PAN is not available.</p>
|
<p class="f60-pan-ok__rule">As per Income Tax Rule 114B, Form 97 is only required when PAN is not available.</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
<div class="f60-warning">
|
<div class="f60-warning">
|
||||||
<mat-icon class="f60-warning__icon">warning_amber</mat-icon>
|
<mat-icon class="f60-warning__icon">warning_amber</mat-icon>
|
||||||
<div class="f60-warning__text">
|
<div class="f60-warning__text">
|
||||||
<strong>PAN not provided</strong> — Form 60 declaration is mandatory as per Income Tax Rule 114B.
|
<strong>PAN not provided</strong> — Form 97 declaration is mandatory as per Income Tax Rule 114B.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
<div class="f60-card">
|
<div class="f60-card">
|
||||||
<div class="f60-card__header">
|
<div class="f60-card__header">
|
||||||
<mat-icon class="f60-card__header-icon">description</mat-icon>
|
<mat-icon class="f60-card__header-icon">description</mat-icon>
|
||||||
<h3>Form 60 Declaration</h3>
|
<h3>Form 97 Declaration</h3>
|
||||||
<span class="f60-card__status">Submitted</span>
|
<span class="f60-card__status">Submitted</span>
|
||||||
<button mat-stroked-button color="primary" (click)="toggleEditMode()" class="f60-card__edit">
|
<button mat-stroked-button color="primary" (click)="toggleEditMode()" class="f60-card__edit">
|
||||||
<mat-icon>edit</mat-icon> Edit
|
<mat-icon>edit</mat-icon> Edit
|
||||||
@@ -123,7 +123,7 @@
|
|||||||
<div class="f60-card">
|
<div class="f60-card">
|
||||||
<div class="f60-card__header">
|
<div class="f60-card__header">
|
||||||
<mat-icon class="f60-card__header-icon">edit_note</mat-icon>
|
<mat-icon class="f60-card__header-icon">edit_note</mat-icon>
|
||||||
<h3>Form 60 Declaration</h3>
|
<h3>Form 97 Declaration</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="f60-card__body">
|
<div class="f60-card__body">
|
||||||
<form [formGroup]="form60Form" (ngSubmit)="submit()">
|
<form [formGroup]="form60Form" (ngSubmit)="submit()">
|
||||||
@@ -204,7 +204,7 @@
|
|||||||
<button type="button" mat-stroked-button (click)="toggleEditMode()">Cancel</button>
|
<button type="button" mat-stroked-button (click)="toggleEditMode()">Cancel</button>
|
||||||
}
|
}
|
||||||
<button type="submit" mat-flat-button color="primary" [disabled]="!form60Form.valid">
|
<button type="submit" mat-flat-button color="primary" [disabled]="!form60Form.valid">
|
||||||
<mat-icon>check</mat-icon> Submit Form 60
|
<mat-icon>check</mat-icon> Submit Form 97
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -224,14 +224,14 @@ export class Form60TabComponent implements OnInit, OnDestroy {
|
|||||||
this.isEditMode = false;
|
this.isEditMode = false;
|
||||||
this.alertService.alert({
|
this.alertService.alert({
|
||||||
type: 'success',
|
type: 'success',
|
||||||
message: 'Form 60 submitted successfully.'
|
message: 'Form 97 submitted successfully.'
|
||||||
});
|
});
|
||||||
this.loadForm60Data();
|
this.loadForm60Data();
|
||||||
},
|
},
|
||||||
error: (err: any) => {
|
error: (err: any) => {
|
||||||
this.alertService.alert({
|
this.alertService.alert({
|
||||||
type: 'error',
|
type: 'error',
|
||||||
message: err?.error?.developerMessage || err?.error?.defaultUserMessage || 'Failed to submit Form 60.'
|
message: err?.error?.developerMessage || err?.error?.defaultUserMessage || 'Failed to submit Form 97.'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -42,6 +42,19 @@
|
|||||||
|
|
||||||
<!-- KYC Summary (read-only when not in edit mode) -->
|
<!-- KYC Summary (read-only when not in edit mode) -->
|
||||||
@if (!isEditMode && kycData) {
|
@if (!isEditMode && kycData) {
|
||||||
|
@if (kycData.isException && !kycData.exceptionResolved) {
|
||||||
|
<div class="kyc-exception-banner" style="background: #fef3c7; border: 1px solid #f59e0b; border-radius: 8px; padding: 16px; margin-bottom: 20px; display: flex; align-items: flex-start; gap: 12px;">
|
||||||
|
<mat-icon style="color: #d97706; margin-top: 2px;">warning</mat-icon>
|
||||||
|
<div>
|
||||||
|
<strong style="color: #92400e; font-size: 14px;">KYC Exception Active</strong>
|
||||||
|
<p style="color: #b45309; font-size: 13px; margin: 4px 0 0 0;">
|
||||||
|
Account is open under a temporary KYC Exception. Reason: <strong>{{ kycData.exceptionReason || 'Other' }}</strong>.
|
||||||
|
Please contact the customer and complete verification as soon as possible.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<!-- Verification Table -->
|
<!-- Verification Table -->
|
||||||
<div class="kv-table">
|
<div class="kv-table">
|
||||||
<div class="kv-table__header">
|
<div class="kv-table__header">
|
||||||
@@ -740,11 +753,33 @@
|
|||||||
</mat-select>
|
</mat-select>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
|
|
||||||
|
<!-- KYC Exception Flag -->
|
||||||
|
<div class="full-span exception-controls-row" style="display: flex; align-items: center; gap: 20px; margin-bottom: 12px; flex-wrap: wrap;">
|
||||||
|
<mat-checkbox formControlName="isException" style="margin-top: 8px;">Mark as KYC Exception</mat-checkbox>
|
||||||
|
|
||||||
|
@if (kycForm.get('isException')?.value) {
|
||||||
|
<mat-form-field appearance="outline" style="flex: 1; min-width: 250px;">
|
||||||
|
<mat-label>Exception Reasons</mat-label>
|
||||||
|
<mat-select formControlName="exceptionReason" multiple>
|
||||||
|
@if (!isNonIndividual) {
|
||||||
|
<mat-option value="PAN Card Verification Failed">PAN Card Verification Failed</mat-option>
|
||||||
|
<mat-option value="Aadhaar Verification Failed">Aadhaar Verification Failed</mat-option>
|
||||||
|
} @else {
|
||||||
|
<mat-option value="Enterprise PAN Verification Failed">Enterprise PAN Verification Failed</mat-option>
|
||||||
|
<mat-option value="GSTIN Verification Failed">GSTIN Verification Failed</mat-option>
|
||||||
|
<mat-option value="CIN Verification Failed">CIN Verification Failed</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Remarks -->
|
<!-- Remarks -->
|
||||||
<mat-form-field appearance="outline" class="full-span">
|
<mat-form-field appearance="outline" class="full-span">
|
||||||
<mat-label>{{ 'labels.inputs.Remarks' | translate }}</mat-label>
|
<mat-label>{{ 'labels.inputs.Remarks' | translate }}</mat-label>
|
||||||
<textarea matInput formControlName="remarks" rows="3"></textarea>
|
<textarea matInput formControlName="remarks" rows="3"></textarea>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (errorMessage) {
|
@if (errorMessage) {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { catchError, of, Subject, takeUntil } from 'rxjs';
|
|||||||
|
|
||||||
/** Custom Services */
|
/** Custom Services */
|
||||||
import { ClientsService } from '../../clients.service';
|
import { ClientsService } from '../../clients.service';
|
||||||
|
import { deriveKycExceptionFields } from '../../kyc-exception.util';
|
||||||
import { Dates } from 'app/core/utils/dates';
|
import { Dates } from 'app/core/utils/dates';
|
||||||
import { jsPDF } from 'jspdf';
|
import { jsPDF } from 'jspdf';
|
||||||
import { MatIcon } from '@angular/material/icon';
|
import { MatIcon } from '@angular/material/icon';
|
||||||
@@ -121,7 +122,9 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
|||||||
'',
|
'',
|
||||||
[Validators.pattern(/^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z]{1}[1-9A-Z]{1}Z[0-9A-Z]{1}$/)]
|
[Validators.pattern(/^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z]{1}[1-9A-Z]{1}Z[0-9A-Z]{1}$/)]
|
||||||
],
|
],
|
||||||
remarks: ['']
|
remarks: [''],
|
||||||
|
isException: [false],
|
||||||
|
exceptionReason: [[]]
|
||||||
});
|
});
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
@@ -157,7 +160,9 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
|||||||
// the full 12-digit number to re-verify, so don't pre-fill.
|
// the full 12-digit number to re-verify, so don't pre-fill.
|
||||||
aadhaarNumber: '',
|
aadhaarNumber: '',
|
||||||
gstinNumber: data.gstinNumber || '',
|
gstinNumber: data.gstinNumber || '',
|
||||||
remarks: data.remarks || ''
|
remarks: data.remarks || '',
|
||||||
|
isException: data.isException || false,
|
||||||
|
exceptionReason: data.exceptionReason ? data.exceptionReason.split(', ') : []
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -195,6 +200,28 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exception columns derived from verification status — see `deriveKycExceptionFields()`.
|
||||||
|
*
|
||||||
|
* A field counts as verified if it was verified in this session OR is already verified on the
|
||||||
|
* stored record; either way the customer has cleared that check. Verifying one of two
|
||||||
|
* outstanding items narrows the exception to the remaining one rather than closing it.
|
||||||
|
*/
|
||||||
|
private buildExceptionFields() {
|
||||||
|
const panVerified = this.verificationStatus.pan === 'VERIFIED' || this.kycData?.panVerified === true;
|
||||||
|
const aadhaarVerified = this.verificationStatus.aadhaar === 'VERIFIED' || this.kycData?.aadhaarVerified === true;
|
||||||
|
|
||||||
|
return deriveKycExceptionFields({
|
||||||
|
panVerified,
|
||||||
|
aadhaarVerified,
|
||||||
|
aadhaarApplicable: !this.isNonIndividual,
|
||||||
|
hadException: this.kycData?.isException === true,
|
||||||
|
previousReason: this.kycData?.exceptionReason || null,
|
||||||
|
manualReasons: Array.isArray(this.kycForm.value.exceptionReason) ? this.kycForm.value.exceptionReason : [],
|
||||||
|
todayFormatted: this.dateUtils.formatDate(new Date(), 'dd MMMM yyyy')
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
saveKyc(): void {
|
saveKyc(): void {
|
||||||
if (this.kycForm.invalid) {
|
if (this.kycForm.invalid) {
|
||||||
return;
|
return;
|
||||||
@@ -210,7 +237,10 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
|||||||
panNumber: this.kycForm.value.panNumber || null,
|
panNumber: this.kycForm.value.panNumber || null,
|
||||||
aadhaarNumberMasked: aadhaarMasked,
|
aadhaarNumberMasked: aadhaarMasked,
|
||||||
gstinNumber: this.kycForm.value.gstinNumber || null,
|
gstinNumber: this.kycForm.value.gstinNumber || null,
|
||||||
remarks: this.kycForm.value.remarks || null
|
remarks: this.kycForm.value.remarks || null,
|
||||||
|
// dateFormat is required so the backend can parse exceptionResolvedOn when an exception closes.
|
||||||
|
dateFormat: 'dd MMMM yyyy',
|
||||||
|
...this.buildExceptionFields()
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!this.kycId) {
|
if (!this.kycId) {
|
||||||
@@ -244,6 +274,37 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private saveKycBeforeVerify(callback: () => void, field: 'pan' | 'aadhaar' | 'gstin'): void {
|
||||||
|
if (this.isEditMode) {
|
||||||
|
const aadhaarRaw = this.kycForm.value.aadhaarNumber || '';
|
||||||
|
const aadhaarMasked = aadhaarRaw.length === 12 ? 'XXXXXXXX' + aadhaarRaw.slice(-4) : aadhaarRaw || null;
|
||||||
|
const payload: any = {
|
||||||
|
locale: 'en',
|
||||||
|
panNumber: this.kycForm.value.panNumber || null,
|
||||||
|
aadhaarNumberMasked: aadhaarMasked,
|
||||||
|
gstinNumber: this.kycForm.value.gstinNumber || null,
|
||||||
|
remarks: this.kycForm.value.remarks || null,
|
||||||
|
riskCategory: this.kycForm.value.riskCategory || 1,
|
||||||
|
dateFormat: 'dd MMMM yyyy',
|
||||||
|
...this.buildExceptionFields()
|
||||||
|
};
|
||||||
|
|
||||||
|
this.clientsService.updateClientKyc(this.clientId, this.kycId, payload).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.loadKycData();
|
||||||
|
callback();
|
||||||
|
},
|
||||||
|
error: (err: any) => {
|
||||||
|
this.verifying[field] = false;
|
||||||
|
this.verificationStatus[field] = 'FAILED';
|
||||||
|
this.verificationError[field] = err?.error?.defaultUserMessage || `Failed to update ${field.toUpperCase()} number before verification.`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
verifyPAN(): void {
|
verifyPAN(): void {
|
||||||
if (!this.kycId) {
|
if (!this.kycId) {
|
||||||
this.verificationError.pan = 'KYC record not found. Save KYC details first.';
|
this.verificationError.pan = 'KYC record not found. Save KYC details first.';
|
||||||
@@ -254,31 +315,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 +368,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 +445,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 {
|
||||||
|
|||||||
@@ -105,7 +105,7 @@
|
|||||||
[class.pd-verify-badge--verified]="!clientViewData.isMinor"
|
[class.pd-verify-badge--verified]="!clientViewData.isMinor"
|
||||||
[class.pd-verify-badge--pending]="clientViewData.isMinor"
|
[class.pd-verify-badge--pending]="clientViewData.isMinor"
|
||||||
>
|
>
|
||||||
{{ clientViewData.isMinor ? 'Yes (Age < 10)' : 'No' }}
|
{{ clientViewData.isMinor ? 'Yes (Age < 18)' : 'No' }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -98,12 +98,10 @@
|
|||||||
</td>
|
</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<!-- Account no Column -->
|
<!-- Client ID Column -->
|
||||||
<ng-container matColumnDef="accountNumber">
|
<ng-container matColumnDef="accountNumber">
|
||||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'labels.inputs.Account No' | translate }}</th>
|
<th mat-header-cell *matHeaderCellDef mat-sort-header>Client ID</th>
|
||||||
<td mat-cell *matCellDef="let row" class="cell-mono">
|
<td mat-cell *matCellDef="let row" class="cell-mono">{{ row.id }}</td>
|
||||||
<mifosx-account-number display="left" accountNo="{{ row.accountNumber }}"></mifosx-account-number>
|
|
||||||
</td>
|
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<!-- CIF Number Column -->
|
<!-- CIF Number Column -->
|
||||||
|
|||||||
@@ -137,7 +137,16 @@ export class ClientsComponent implements OnInit {
|
|||||||
private getClients() {
|
private getClients() {
|
||||||
this.isLoading = true;
|
this.isLoading = true;
|
||||||
this.clientService
|
this.clientService
|
||||||
.searchByText(this.filterText, this.currentPage, this.pageSize, this.sortAttribute, this.sortDirection)
|
.searchByText(
|
||||||
|
this.filterText,
|
||||||
|
this.currentPage,
|
||||||
|
this.pageSize,
|
||||||
|
this.sortAttribute,
|
||||||
|
this.sortDirection,
|
||||||
|
false,
|
||||||
|
'',
|
||||||
|
true
|
||||||
|
)
|
||||||
.subscribe(
|
.subscribe(
|
||||||
(data: any) => {
|
(data: any) => {
|
||||||
this.allRows = data.content || [];
|
this.allRows = data.content || [];
|
||||||
@@ -166,7 +175,7 @@ export class ClientsComponent implements OnInit {
|
|||||||
// Use individual subscriptions instead of forkJoin to avoid EMPTY observable issues
|
// Use individual subscriptions instead of forkJoin to avoid EMPTY observable issues
|
||||||
clients.forEach((client, i) => {
|
clients.forEach((client, i) => {
|
||||||
this.clientService
|
this.clientService
|
||||||
.getClientData(client.id?.toString())
|
.getClientData(client.id?.toString(), true)
|
||||||
.pipe(catchError(() => of(null)))
|
.pipe(catchError(() => of(null)))
|
||||||
.subscribe((data: any) => {
|
.subscribe((data: any) => {
|
||||||
if (data?.legalForm) {
|
if (data?.legalForm) {
|
||||||
@@ -176,7 +185,7 @@ export class ClientsComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.clientService
|
this.clientService
|
||||||
.getClientKyc(client.id?.toString())
|
.getClientKyc(client.id?.toString(), true)
|
||||||
.pipe(catchError(() => of(null)))
|
.pipe(catchError(() => of(null)))
|
||||||
.subscribe((kyc: any) => {
|
.subscribe((kyc: any) => {
|
||||||
if (kyc) {
|
if (kyc) {
|
||||||
@@ -188,7 +197,7 @@ export class ClientsComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.clientService
|
this.clientService
|
||||||
.getClientAddressData(client.id?.toString())
|
.getClientAddressData(client.id?.toString(), true)
|
||||||
.pipe(catchError(() => of(null)))
|
.pipe(catchError(() => of(null)))
|
||||||
.subscribe((addr: any) => {
|
.subscribe((addr: any) => {
|
||||||
if (addr && Array.isArray(addr) && addr.length > 0) {
|
if (addr && Array.isArray(addr) && addr.length > 0) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -8,7 +8,8 @@
|
|||||||
|
|
||||||
/** Angular Imports */
|
/** Angular Imports */
|
||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import { HttpClient, HttpParams, HttpBackend, HttpHeaders } from '@angular/common/http';
|
import { HttpClient, HttpParams, HttpBackend, HttpHeaders, HttpContext } from '@angular/common/http';
|
||||||
|
import { SILENT_HTTP_ERROR } from 'app/core/http/silent-error.token';
|
||||||
|
|
||||||
/** rxjs Imports */
|
/** rxjs Imports */
|
||||||
import { Observable, of, throwError } from 'rxjs';
|
import { Observable, of, throwError } from 'rxjs';
|
||||||
@@ -66,8 +67,11 @@ export class ClientsService {
|
|||||||
return this.http.get(`/clients/template?officeId=${officeId}&staffInSelectedOfficeOnly=true`);
|
return this.http.get(`/clients/template?officeId=${officeId}&staffInSelectedOfficeOnly=true`);
|
||||||
}
|
}
|
||||||
|
|
||||||
getClientData(clientId: string) {
|
getClientData(clientId: string, silent = false) {
|
||||||
return this.http.get(`/clients/${clientId}`);
|
return this.http.get(
|
||||||
|
`/clients/${clientId}`,
|
||||||
|
silent ? { context: new HttpContext().set(SILENT_HTTP_ERROR, true) } : {}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
createClient(client: any) {
|
createClient(client: any) {
|
||||||
@@ -82,6 +86,57 @@ export class ClientsService {
|
|||||||
return this.http.delete(`/clients/${clientId}`);
|
return this.http.delete(`/clients/${clientId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maker-checker for Client edits — stages the change in m_client_pending_edit instead of
|
||||||
|
* applying it immediately; a different user must call approveClientEdit() before it takes
|
||||||
|
* effect. See ClientEditApiResource on the backend.
|
||||||
|
*/
|
||||||
|
submitClientEdit(clientId: string, client: any) {
|
||||||
|
return this.http.post(`/clients/${clientId}/edit`, client);
|
||||||
|
}
|
||||||
|
|
||||||
|
getPendingClientEdit(clientId: string) {
|
||||||
|
return this.http.get(`/clients/${clientId}/edit/pending`);
|
||||||
|
}
|
||||||
|
|
||||||
|
getAllPendingClientEdits(
|
||||||
|
status?: string,
|
||||||
|
clientName?: string,
|
||||||
|
fromDate?: string,
|
||||||
|
toDate?: string,
|
||||||
|
offset = 0,
|
||||||
|
limit = 20
|
||||||
|
): Observable<any> {
|
||||||
|
let params = new HttpParams().set('offset', offset).set('limit', limit);
|
||||||
|
if (status) params = params.set('status', status);
|
||||||
|
if (clientName) params = params.set('clientName', clientName);
|
||||||
|
if (fromDate) params = params.set('fromDate', fromDate);
|
||||||
|
if (toDate) params = params.set('toDate', toDate);
|
||||||
|
return this.http.get(`/clients/edit/pending`, { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
approveClientEdit(clientId: string) {
|
||||||
|
return this.http.post(`/clients/${clientId}/edit/approve`, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
rejectClientEdit(clientId: string, rejectReason?: string) {
|
||||||
|
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 });
|
||||||
@@ -116,8 +171,11 @@ export class ClientsService {
|
|||||||
return this.http.get(`/clients/${clientId}/accounts`);
|
return this.http.get(`/clients/${clientId}/accounts`);
|
||||||
}
|
}
|
||||||
|
|
||||||
getClientAddressData(clientId: string) {
|
getClientAddressData(clientId: string, silent = false) {
|
||||||
return this.http.get(`/client/${clientId}/addresses`);
|
return this.http.get(
|
||||||
|
`/client/${clientId}/addresses`,
|
||||||
|
silent ? { context: new HttpContext().set(SILENT_HTTP_ERROR, true) } : {}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
getClientChargesData(clientId: string) {
|
getClientChargesData(clientId: string) {
|
||||||
@@ -661,7 +719,8 @@ export class ClientsService {
|
|||||||
sortAttribute: string = '',
|
sortAttribute: string = '',
|
||||||
sortDirection: string = '',
|
sortDirection: string = '',
|
||||||
activeOnly: boolean = false,
|
activeOnly: boolean = false,
|
||||||
legalFormType: string = ''
|
legalFormType: string = '',
|
||||||
|
silent: boolean = false
|
||||||
) {
|
) {
|
||||||
const innerRequest: any = { text };
|
const innerRequest: any = { text };
|
||||||
if (activeOnly) {
|
if (activeOnly) {
|
||||||
@@ -690,7 +749,11 @@ export class ClientsService {
|
|||||||
]
|
]
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return this.http.post(`/v2/clients/search`, request);
|
return this.http.post(
|
||||||
|
`/v2/clients/search`,
|
||||||
|
request,
|
||||||
|
silent ? { context: new HttpContext().set(SILENT_HTTP_ERROR, true) } : {}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -727,8 +790,11 @@ export class ClientsService {
|
|||||||
* Gets KYC details for a client.
|
* Gets KYC details for a client.
|
||||||
* @param clientId Client ID
|
* @param clientId Client ID
|
||||||
*/
|
*/
|
||||||
getClientKyc(clientId: string): Observable<any> {
|
getClientKyc(clientId: string, silent = false): Observable<any> {
|
||||||
return this.http.get(`/clients/${clientId}/kyc`);
|
return this.http.get(
|
||||||
|
`/clients/${clientId}/kyc`,
|
||||||
|
silent ? { context: new HttpContext().set(SILENT_HTTP_ERROR, true) } : {}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -739,6 +805,10 @@ export class ClientsService {
|
|||||||
return this.http.get(`/clients/${clientId}/kyc/template`);
|
return this.http.get(`/clients/${clientId}/kyc/template`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getClientRiskRules(): Observable<any[]> {
|
||||||
|
return this.http.get<any[]>('/kyc/risk-rules');
|
||||||
|
}
|
||||||
|
|
||||||
/** Fetch code values by code ID */
|
/** Fetch code values by code ID */
|
||||||
getCodeValuesById(codeId: number): Observable<any[]> {
|
getCodeValuesById(codeId: number): Observable<any[]> {
|
||||||
return this.http.get<any[]>(`/codes/${codeId}/codevalues`).pipe(catchError(() => of([])));
|
return this.http.get<any[]>(`/codes/${codeId}/codevalues`).pipe(catchError(() => of([])));
|
||||||
@@ -894,4 +964,38 @@ export class ClientsService {
|
|||||||
submitClientForm60(clientId: string, data: any): Observable<any> {
|
submitClientForm60(clientId: string, data: any): Observable<any> {
|
||||||
return this.http.post(`/clients/${clientId}/form60`, data);
|
return this.http.post(`/clients/${clientId}/form60`, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getWelcomeKits(clientId: string | number): Observable<any> {
|
||||||
|
return this.http.get(`/clients/${clientId}/welcomekit`);
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatchWelcomeKit(clientId: string | number, data: any): Observable<any> {
|
||||||
|
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`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,10 +71,12 @@
|
|||||||
<mifosx-client-kyc-step
|
<mifosx-client-kyc-step
|
||||||
#clientKyc
|
#clientKyc
|
||||||
[isNonIndividual]="legalFormType === 2"
|
[isNonIndividual]="legalFormType === 2"
|
||||||
|
[isMinor]="clientGeneralStep?.isMinor || false"
|
||||||
[clientId]="createdClientId"
|
[clientId]="createdClientId"
|
||||||
[constitutionMeta]="clientGeneralStep?.selectedConstitution"
|
[constitutionMeta]="clientGeneralStep?.selectedConstitution"
|
||||||
[entityName]="clientGeneralStep?.createClientForm?.get('fullname')?.value || ''"
|
[entityName]="clientGeneralStep?.createClientForm?.get('fullname')?.value || ''"
|
||||||
[readOnly]="isTabReadOnly(1)"
|
[readOnly]="isTabReadOnly(1)"
|
||||||
|
[calculatedRisk]="clientGeneralStep?.calculatedRisk"
|
||||||
></mifosx-client-kyc-step>
|
></mifosx-client-kyc-step>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -146,6 +148,8 @@
|
|||||||
}
|
}
|
||||||
<mifosx-client-india-address-step
|
<mifosx-client-india-address-step
|
||||||
#clientIndiaAddress
|
#clientIndiaAddress
|
||||||
|
[isMinor]="clientGeneralStep?.isMinor || false"
|
||||||
|
[isNonIndividual]="legalFormType === 2"
|
||||||
[readOnly]="isTabReadOnly(addressTabIdx)"
|
[readOnly]="isTabReadOnly(addressTabIdx)"
|
||||||
></mifosx-client-india-address-step>
|
></mifosx-client-india-address-step>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -111,6 +111,12 @@ export class CreateClientComponent {
|
|||||||
this.route.data.subscribe((data: { clientTemplate: any; clientAddressFieldConfig: any }) => {
|
this.route.data.subscribe((data: { clientTemplate: any; clientAddressFieldConfig: any }) => {
|
||||||
this.clientTemplate = data.clientTemplate;
|
this.clientTemplate = data.clientTemplate;
|
||||||
this.clientAddressFieldConfig = data.clientAddressFieldConfig;
|
this.clientAddressFieldConfig = data.clientAddressFieldConfig;
|
||||||
|
// This app's RouteReusableStrategy keeps this SAME component instance alive across
|
||||||
|
// repeat visits to /clients/create, so ngOnInit/constructor won't re-run for a second
|
||||||
|
// "create new client" session — reset transient state here instead, since `route.data`
|
||||||
|
// re-emits on every navigation regardless of whether the component was reused. Must run
|
||||||
|
// BEFORE setDatatables(), which reads legalFormType to filter clientTemplate.datatables.
|
||||||
|
this.resetForNewClient();
|
||||||
this.setDatatables();
|
this.setDatatables();
|
||||||
});
|
});
|
||||||
// Read pre-fill data set by "Create as Client" from a manual family member
|
// Read pre-fill data set by "Create as Client" from a manual family member
|
||||||
@@ -121,6 +127,22 @@ export class CreateClientComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Resets session state that could otherwise leak between clients when the route is reused. */
|
||||||
|
private resetForNewClient(): void {
|
||||||
|
this.legalFormType = 1;
|
||||||
|
this.selectedTabIndex = 0;
|
||||||
|
this.createdClientId = '';
|
||||||
|
this.savingClient = false;
|
||||||
|
this.saveProgress = 0;
|
||||||
|
this.tabEditMode = {};
|
||||||
|
this.highestTabReached = 0;
|
||||||
|
this.validationError = '';
|
||||||
|
this.prefillData = null;
|
||||||
|
this.clientGeneralStep?.resetForNewClient();
|
||||||
|
this.clientKycStep?.resetForNewClient();
|
||||||
|
this.clientIndiaAddressStep?.resetForNewClient();
|
||||||
|
}
|
||||||
|
|
||||||
/** Check if a tab is in read-only mode (completed tab navigated back to) */
|
/** Check if a tab is in read-only mode (completed tab navigated back to) */
|
||||||
isTabReadOnly(tabIndex: number): boolean {
|
isTabReadOnly(tabIndex: number): boolean {
|
||||||
// Only tabs that have been passed (below highest reached) AND not explicitly set to edit mode
|
// Only tabs that have been passed (below highest reached) AND not explicitly set to edit mode
|
||||||
@@ -218,8 +240,8 @@ export class CreateClientComponent {
|
|||||||
return this.isAddressStepValid();
|
return this.isAddressStepValid();
|
||||||
}
|
}
|
||||||
if (idx === 3) {
|
if (idx === 3) {
|
||||||
// Family tab — relations are optional, always allow.
|
// Family tab — Mother is required, and either Father or a Caretaker.
|
||||||
return true;
|
return !this.clientFamilyMembersStep || this.clientFamilyMembersStep.familyRelationshipRulesMet;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -253,6 +275,11 @@ export class CreateClientComponent {
|
|||||||
return `Please fill/correct: ${labels.join(', ')}.`;
|
return `Please fill/correct: ${labels.join(', ')}.`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (idx === 3 && this.legalFormType === 1 && this.clientFamilyMembersStep) {
|
||||||
|
const step = this.clientFamilyMembersStep;
|
||||||
|
if (!step.hasMother) return 'Mother is required as a family member.';
|
||||||
|
if (!step.hasFather && !step.hasCaretaker) return 'Either Father or a Caretaker is required as a family member.';
|
||||||
|
}
|
||||||
return 'Please fill all mandatory fields on this tab to continue.';
|
return 'Please fill all mandatory fields on this tab to continue.';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,6 +358,18 @@ export class CreateClientComponent {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PWD gate — disability type + certificate are mandatory when PWD is checked
|
||||||
|
// (Senior Citizen proof upload is optional, not gated here)
|
||||||
|
if (!this.clientGeneralStep?.seniorPwdDocsComplete) {
|
||||||
|
this.clientGeneralStep?.markSeniorPwdDocsTouched();
|
||||||
|
this.snackBar.open(
|
||||||
|
'Select the disability type and upload the Disability Certificate before continuing.',
|
||||||
|
'Close',
|
||||||
|
{ duration: 5000 }
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Sanctions verification gate — Verify Client must be clicked before save
|
// Sanctions verification gate — Verify Client must be clicked before save
|
||||||
if (!this.clientGeneralStep?.sanctionsVerified) {
|
if (!this.clientGeneralStep?.sanctionsVerified) {
|
||||||
this.dialog
|
this.dialog
|
||||||
@@ -391,8 +430,20 @@ 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) {
|
||||||
@@ -469,6 +520,29 @@ export class CreateClientComponent {
|
|||||||
clearInterval(progressInterval);
|
clearInterval(progressInterval);
|
||||||
this.saveProgress = 100;
|
this.saveProgress = 100;
|
||||||
this.createdClientId = response.resourceId;
|
this.createdClientId = response.resourceId;
|
||||||
|
|
||||||
|
// Senior Citizen / PWD supporting documents — uploaded after creation, same pattern as
|
||||||
|
// the KYC step's photo/signature uploads (the generic /clients/{id}/documents endpoint
|
||||||
|
// needs a real clientId, which only exists once createClient() has returned).
|
||||||
|
if (this.clientGeneralStep?.seniorCitizenProofFile) {
|
||||||
|
const seniorCitizenProofForm = new FormData();
|
||||||
|
seniorCitizenProofForm.append('file', this.clientGeneralStep.seniorCitizenProofFile);
|
||||||
|
seniorCitizenProofForm.append('name', 'seniorCitizenProof');
|
||||||
|
seniorCitizenProofForm.append('description', 'Senior Citizen Proof (Age/ID Proof)');
|
||||||
|
this.clientsService
|
||||||
|
.uploadClientDocument(this.createdClientId, seniorCitizenProofForm)
|
||||||
|
.subscribe({ error: () => {} });
|
||||||
|
}
|
||||||
|
if (this.clientGeneralStep?.pwdCertificateFile) {
|
||||||
|
const pwdCertificateForm = new FormData();
|
||||||
|
pwdCertificateForm.append('file', this.clientGeneralStep.pwdCertificateFile);
|
||||||
|
pwdCertificateForm.append('name', 'pwdCertificate');
|
||||||
|
pwdCertificateForm.append('description', 'Disability Certificate');
|
||||||
|
this.clientsService
|
||||||
|
.uploadClientDocument(this.createdClientId, pwdCertificateForm)
|
||||||
|
.subscribe({ error: () => {} });
|
||||||
|
}
|
||||||
|
|
||||||
// Brief delay to show 100% before transitioning
|
// Brief delay to show 100% before transitioning
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.savingClient = false;
|
this.savingClient = false;
|
||||||
@@ -514,6 +588,21 @@ export class CreateClientComponent {
|
|||||||
this.selectedTabIndex = 3;
|
this.selectedTabIndex = 3;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Validate: Mother required, and either Father or a Caretaker (Individual clients)
|
||||||
|
if (
|
||||||
|
this.legalFormType === 1 &&
|
||||||
|
this.clientFamilyMembersStep &&
|
||||||
|
!this.clientFamilyMembersStep.familyRelationshipRulesMet
|
||||||
|
) {
|
||||||
|
const step = this.clientFamilyMembersStep;
|
||||||
|
if (!step.hasMother) {
|
||||||
|
this.validationError = 'Mother is required as a family member.';
|
||||||
|
} else {
|
||||||
|
this.validationError = 'Either Father or a Caretaker is required as a family member.';
|
||||||
|
}
|
||||||
|
this.selectedTabIndex = 3;
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.validationError = '';
|
this.validationError = '';
|
||||||
|
|
||||||
if (this.createdClientId) {
|
if (this.createdClientId) {
|
||||||
@@ -619,12 +708,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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,9 +25,22 @@
|
|||||||
{{ 'labels.inputs.GENERAL' | translate }}
|
{{ 'labels.inputs.GENERAL' | translate }}
|
||||||
</ng-template>
|
</ng-template>
|
||||||
<div class="tab-content" [class.tab-content--readonly]="isTabReadOnly(0)">
|
<div class="tab-content" [class.tab-content--readonly]="isTabReadOnly(0)">
|
||||||
|
@if (hasPendingGeneralEdit) {
|
||||||
|
<div class="pending-edit-banner">
|
||||||
|
<mat-icon>hourglass_top</mat-icon>
|
||||||
|
<span
|
||||||
|
>An edit is awaiting approval from another user. You can't submit another edit until it's resolved.</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
<div class="tab-mode-bar">
|
<div class="tab-mode-bar">
|
||||||
@if (isTabReadOnly(0)) {
|
@if (isTabReadOnly(0)) {
|
||||||
<button mat-flat-button class="tab-mode-btn tab-mode-btn--edit" (click)="enableTabEdit(0)">
|
<button
|
||||||
|
mat-flat-button
|
||||||
|
class="tab-mode-btn tab-mode-btn--edit"
|
||||||
|
[disabled]="hasPendingGeneralEdit"
|
||||||
|
(click)="enableTabEdit(0)"
|
||||||
|
>
|
||||||
<mat-icon>edit</mat-icon> Edit
|
<mat-icon>edit</mat-icon> Edit
|
||||||
</button>
|
</button>
|
||||||
} @else {
|
} @else {
|
||||||
@@ -71,14 +84,46 @@
|
|||||||
<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"
|
||||||
@@ -149,8 +194,10 @@
|
|||||||
<mifosx-client-kyc-step
|
<mifosx-client-kyc-step
|
||||||
#clientKyc
|
#clientKyc
|
||||||
[isNonIndividual]="legalFormType === 2"
|
[isNonIndividual]="legalFormType === 2"
|
||||||
|
[isMinor]="clientGeneralStep?.isMinor || false"
|
||||||
[clientId]="clientId"
|
[clientId]="clientId"
|
||||||
[readOnly]="isTabReadOnly(1)"
|
[readOnly]="isTabReadOnly(1)"
|
||||||
|
[calculatedRisk]="clientGeneralStep?.calculatedRisk"
|
||||||
></mifosx-client-kyc-step>
|
></mifosx-client-kyc-step>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -206,6 +253,8 @@
|
|||||||
<mifosx-client-india-address-step
|
<mifosx-client-india-address-step
|
||||||
#clientIndiaAddress
|
#clientIndiaAddress
|
||||||
[clientId]="clientId"
|
[clientId]="clientId"
|
||||||
|
[isMinor]="clientGeneralStep?.isMinor || false"
|
||||||
|
[isNonIndividual]="legalFormType === 2"
|
||||||
[readOnly]="isTabReadOnly(addressTabIdx)"
|
[readOnly]="isTabReadOnly(addressTabIdx)"
|
||||||
></mifosx-client-india-address-step>
|
></mifosx-client-india-address-step>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -217,6 +217,26 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Pending Edit Approval Banner ─── */
|
||||||
|
.pending-edit-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
background: #eff6ff;
|
||||||
|
border: 1px solid #93c5fd;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #1e40af;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
color: #2563eb;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ─── Re-KYC Alert Banner ─── */
|
/* ─── Re-KYC Alert Banner ─── */
|
||||||
.rekyc-alert {
|
.rekyc-alert {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -33,11 +33,13 @@ 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';
|
||||||
import { MatTooltip } from '@angular/material/tooltip';
|
import { MatTooltip } from '@angular/material/tooltip';
|
||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
import { ORIGINAL_VALUES_PAYLOAD_KEY, buildOriginalValuesSnapshot } from '../shared/client-edit-diff.util';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Edit Client Component — uses the same wizard/stepper layout as Create Client.
|
* Edit Client Component — uses the same wizard/stepper layout as Create Client.
|
||||||
@@ -50,6 +52,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
|||||||
...STANDALONE_SHARED_IMPORTS,
|
...STANDALONE_SHARED_IMPORTS,
|
||||||
MatTabsModule,
|
MatTabsModule,
|
||||||
MatIconModule,
|
MatIconModule,
|
||||||
|
MatCheckboxModule,
|
||||||
MatDivider,
|
MatDivider,
|
||||||
FaIconComponent,
|
FaIconComponent,
|
||||||
MatProgressSpinner,
|
MatProgressSpinner,
|
||||||
@@ -107,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;
|
||||||
@@ -116,6 +126,9 @@ export class EditClientComponent {
|
|||||||
clientDataAndTemplate: any;
|
clientDataAndTemplate: any;
|
||||||
/** Client ID */
|
/** Client ID */
|
||||||
clientId: string;
|
clientId: string;
|
||||||
|
/** True while a General-tab edit is staged awaiting checker approval — blocks submitting
|
||||||
|
* another one (the backend enforces this too; this just avoids a round-trip error toast). */
|
||||||
|
hasPendingGeneralEdit = false;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.route.data.subscribe((data: { clientDataAndTemplate: any }) => {
|
this.route.data.subscribe((data: { clientDataAndTemplate: any }) => {
|
||||||
@@ -124,6 +137,15 @@ export class EditClientComponent {
|
|||||||
if (this.clientDataAndTemplate.legalForm) {
|
if (this.clientDataAndTemplate.legalForm) {
|
||||||
this.legalFormType = this.clientDataAndTemplate.legalForm.id;
|
this.legalFormType = this.clientDataAndTemplate.legalForm.id;
|
||||||
}
|
}
|
||||||
|
this.loadPendingGeneralEdit();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadPendingGeneralEdit(): void {
|
||||||
|
if (!this.clientId) return;
|
||||||
|
this.clientsService.getPendingClientEdit(this.clientId).subscribe({
|
||||||
|
next: (edit: any) => (this.hasPendingGeneralEdit = !!edit && edit.status === 'PENDING'),
|
||||||
|
error: () => (this.hasPendingGeneralEdit = false)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,11 +224,16 @@ export class EditClientComponent {
|
|||||||
return this.detectSensitiveFieldChanges(this.clientDataAndTemplate);
|
return this.detectSensitiveFieldChanges(this.clientDataAndTemplate);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether every OTP block required by the fields that actually changed has been verified. */
|
/** Whether every OTP block required by the fields that actually changed has been verified (or FIR uploaded for lost mobile). */
|
||||||
get contactVerificationComplete(): boolean {
|
get contactVerificationComplete(): boolean {
|
||||||
const changes = this.contactChanges;
|
const changes = this.contactChanges;
|
||||||
if (changes.mobileChanged && !(this.oldMobileVerified && this.newMobileVerified)) {
|
if (changes.mobileChanged) {
|
||||||
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;
|
||||||
@@ -214,6 +241,41 @@ export class EditClientComponent {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onOldMobileNotAccessibleToggle(checked?: boolean): void {
|
||||||
|
if (checked !== undefined) {
|
||||||
|
this.oldMobileNotAccessible = checked;
|
||||||
|
}
|
||||||
|
if (this.oldMobileNotAccessible) {
|
||||||
|
this.oldMobileOtpId = null;
|
||||||
|
this.oldMobileVerified = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onFirFileSelected(event: any): void {
|
||||||
|
const file: File = event.target?.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
this.firFileName = file.name;
|
||||||
|
this.uploadingFir = true;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
formData.append('name', 'FIR - Lost Mobile Number');
|
||||||
|
formData.append('description', 'FIR police complaint copy for lost mobile number verification exception');
|
||||||
|
|
||||||
|
this.clientsService.uploadClientDocument(this.clientId, formData).subscribe({
|
||||||
|
next: (res: any) => {
|
||||||
|
this.uploadingFir = false;
|
||||||
|
this.firDocumentId = res?.resourceId || res?.id || null;
|
||||||
|
this.snackBar.open('FIR document uploaded successfully', 'Close', { duration: 3000 });
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.uploadingFir = false;
|
||||||
|
this.firDocumentId = null;
|
||||||
|
this.snackBar.open('Failed to upload FIR document', 'Close', { duration: 4000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
onOldMobileOtpState(state: ContactOtpState): void {
|
onOldMobileOtpState(state: ContactOtpState): void {
|
||||||
this.oldMobileOtpId = state.otpId;
|
this.oldMobileOtpId = state.otpId;
|
||||||
this.oldMobileVerified = state.verified;
|
this.oldMobileVerified = state.verified;
|
||||||
@@ -241,6 +303,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;
|
||||||
@@ -287,14 +353,20 @@ export class EditClientComponent {
|
|||||||
if (this.legalFormType === 2) {
|
if (this.legalFormType === 2) {
|
||||||
if (idx === 2) return true; // Scheme & Sector — opt-in
|
if (idx === 2) return true; // Scheme & Sector — opt-in
|
||||||
if (idx === 3) return this.isAddressStepValid();
|
if (idx === 3) return this.isAddressStepValid();
|
||||||
if (idx === 4) return true; // Family — optional
|
if (idx === 4) return true; // Family — not applicable to non-individual entities
|
||||||
} else {
|
} else {
|
||||||
if (idx === 2) return this.isAddressStepValid();
|
if (idx === 2) return this.isAddressStepValid();
|
||||||
if (idx === 3) return true; // Family — optional
|
if (idx === 3) return this.isFamilyStepValid(); // Mother required, plus Father or a Caretaker
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Mother is required, and either Father or a Caretaker, for individual clients. */
|
||||||
|
private isFamilyStepValid(): boolean {
|
||||||
|
const step = this.clientFamilyMembersStep;
|
||||||
|
return !step || step.familyRelationshipRulesMet;
|
||||||
|
}
|
||||||
|
|
||||||
private isAddressStepValid(): boolean {
|
private isAddressStepValid(): boolean {
|
||||||
const step = this.clientIndiaAddressStep;
|
const step = this.clientIndiaAddressStep;
|
||||||
if (!step) return true;
|
if (!step) return true;
|
||||||
@@ -318,6 +390,12 @@ export class EditClientComponent {
|
|||||||
return `Please fill/correct: ${labels.join(', ')}.`;
|
return `Please fill/correct: ${labels.join(', ')}.`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const familyTabIndex = this.legalFormType === 2 ? 4 : 3;
|
||||||
|
if (this.selectedTabIndex === familyTabIndex && this.legalFormType !== 2 && this.clientFamilyMembersStep) {
|
||||||
|
const step = this.clientFamilyMembersStep;
|
||||||
|
if (!step.hasMother) return 'Mother is required as a family member.';
|
||||||
|
if (!step.hasFather && !step.hasCaretaker) return 'Either Father or a Caretaker is required as a family member.';
|
||||||
|
}
|
||||||
return 'Please fill all mandatory fields on this tab to continue.';
|
return 'Please fill all mandatory fields on this tab to continue.';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -414,7 +492,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) {
|
||||||
@@ -422,46 +504,51 @@ export class EditClientComponent {
|
|||||||
clientData.emailNewOtpId = this.newEmailOtpId;
|
clientData.emailNewOtpId = this.newEmailOtpId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Point-in-time "before" snapshot for the checker's diff view — captured now, from the
|
||||||
|
// client record loaded when this edit page opened, rather than relying on comparing against
|
||||||
|
// the LIVE client later (which would show nothing changed once a checker approves the edit,
|
||||||
|
// since by then current == proposed).
|
||||||
|
clientData[ORIGINAL_VALUES_PAYLOAD_KEY] = buildOriginalValuesSnapshot(originalData);
|
||||||
|
|
||||||
this.savingClient = true;
|
this.savingClient = true;
|
||||||
this.clientsService.updateClient(this.clientId, clientData).subscribe({
|
// Maker-checker: stage the edit instead of applying it immediately. The customer-facing
|
||||||
|
// side effects that a direct update would have had immediately — SMS/Email contact-change
|
||||||
|
// notification, re-KYC flagging, PAN re-verification reset — are all deferred until a
|
||||||
|
// different user actually approves the edit (approval replays this exact payload through
|
||||||
|
// the same updateClient() the direct PUT used to call, so the backend-driven notification
|
||||||
|
// still fires correctly, just at approval time instead of now).
|
||||||
|
this.clientsService.submitClientEdit(this.clientId, clientData).subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
this.savingClient = false;
|
this.savingClient = false;
|
||||||
this.tabEditMode[tabIndex] = false;
|
this.tabEditMode[tabIndex] = false;
|
||||||
|
this.hasPendingGeneralEdit = true;
|
||||||
|
|
||||||
// Log mobile/email changes as client notes for risk audit
|
// Senior Citizen / PWD supporting documents — separate upload endpoints, not part of
|
||||||
this.logFieldChangeNotes(changedFields);
|
// the staged JSON edit, so these still apply immediately as before.
|
||||||
|
if (this.clientGeneralStep?.seniorCitizenProofFile) {
|
||||||
// If name or DOB changed, flag re-KYC requirement and reset PAN verification
|
const seniorCitizenProofForm = new FormData();
|
||||||
if (changedFields.nameChanged || changedFields.dobChanged) {
|
seniorCitizenProofForm.append('file', this.clientGeneralStep.seniorCitizenProofFile);
|
||||||
this.dobChanged = true;
|
seniorCitizenProofForm.append('name', 'seniorCitizenProof');
|
||||||
this.tabEditMode[1] = true; // auto-enable KYC tab for re-verification
|
seniorCitizenProofForm.append('description', 'Senior Citizen Proof (Age/ID Proof)');
|
||||||
this.flagReKycRequired(changedFields);
|
this.clientsService
|
||||||
|
.uploadClientDocument(this.clientId, seniorCitizenProofForm)
|
||||||
|
.subscribe({ error: () => {} });
|
||||||
|
}
|
||||||
|
if (this.clientGeneralStep?.pwdCertificateFile) {
|
||||||
|
const pwdCertificateForm = new FormData();
|
||||||
|
pwdCertificateForm.append('file', this.clientGeneralStep.pwdCertificateFile);
|
||||||
|
pwdCertificateForm.append('name', 'pwdCertificate');
|
||||||
|
pwdCertificateForm.append('description', 'Disability Certificate');
|
||||||
|
this.clientsService.uploadClientDocument(this.clientId, pwdCertificateForm).subscribe({ error: () => {} });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update original data snapshot so subsequent edits compare against latest
|
this.snackBar.open(
|
||||||
if (changedFields.mobileChanged) {
|
'Edit submitted for approval — a different user must review it before it takes effect.',
|
||||||
originalData.mobileNo = changedFields.newMobile;
|
'Close',
|
||||||
}
|
{
|
||||||
if (changedFields.emailChanged) {
|
duration: 5000
|
||||||
originalData.emailAddress = changedFields.newEmail;
|
}
|
||||||
}
|
);
|
||||||
if (changedFields.dobChanged) {
|
|
||||||
originalData.dateOfBirth = changedFields.newDob;
|
|
||||||
}
|
|
||||||
if (changedFields.nameChanged) {
|
|
||||||
const form = this.clientGeneralForm;
|
|
||||||
originalData.firstname = form?.get('firstname')?.value ?? originalData.firstname;
|
|
||||||
originalData.middlename = form?.get('middlename')?.value ?? originalData.middlename;
|
|
||||||
originalData.lastname = form?.get('lastname')?.value ?? originalData.lastname;
|
|
||||||
originalData.fullname = form?.get('fullname')?.value ?? originalData.fullname;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Notification is backend-driven (SMS + Email) — just reassure staff it happened.
|
|
||||||
if (changedFields.mobileChanged || changedFields.emailChanged) {
|
|
||||||
this.snackBar.open('Customer notified via SMS/Email of the contact change.', 'Close', {
|
|
||||||
duration: 4000
|
|
||||||
});
|
|
||||||
}
|
|
||||||
this.resetContactVerificationState();
|
this.resetContactVerificationState();
|
||||||
|
|
||||||
this.selectedTabIndex = this.selectedTabIndex + 1;
|
this.selectedTabIndex = this.selectedTabIndex + 1;
|
||||||
@@ -600,25 +687,34 @@ export class EditClientComponent {
|
|||||||
|
|
||||||
private detectSensitiveFieldChanges(originalData: any) {
|
private detectSensitiveFieldChanges(originalData: any) {
|
||||||
const form = this.clientGeneralForm;
|
const form = this.clientGeneralForm;
|
||||||
// Trimmed so browser autofill adding invisible leading/trailing whitespace never reads as
|
if (!form || !originalData) {
|
||||||
// a "change" — the masked preview shown in the Verify Contact Change panel only reveals the
|
return {
|
||||||
// first couple of characters, so a whitespace-only difference is otherwise invisible to the
|
nameChanged: false,
|
||||||
// user and just looks like the panel appeared for no reason. These trimmed-but-original-case
|
mobileChanged: false,
|
||||||
// values are what get returned/sent as the actual OTP destination and saved value — only the
|
emailChanged: false,
|
||||||
// *comparison* (below) is further case-normalized for email, so we never silently rewrite the
|
dobChanged: false,
|
||||||
// customer's email casing into the saved record or the OTP destination.
|
oldName: '',
|
||||||
const newMobile = (form?.get('mobileNo')?.value || '').trim();
|
newName: '',
|
||||||
const newEmail = (form?.get('emailAddress')?.value || '').trim();
|
oldMobile: '',
|
||||||
const newDobRaw = form?.get('dateOfBirth')?.value;
|
newMobile: '',
|
||||||
// Local calendar date, NOT `.toISOString()` — that converts to UTC first, which silently
|
oldEmail: '',
|
||||||
// shifts the date by a day in any positive-UTC-offset timezone (e.g. India, UTC+5:30:
|
newEmail: '',
|
||||||
// midnight local on the 15th becomes 18:30 UTC on the 14th). That made this comparison
|
oldDob: '',
|
||||||
// report a DOB "change" — falsely triggering the Re-KYC dialog — even when the user never
|
newDob: ''
|
||||||
// touched the date field, purely because the Date object round-tripped through the picker.
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const mobileControl = form.get('mobileNo');
|
||||||
|
const emailControl = form.get('emailAddress');
|
||||||
|
|
||||||
|
// Trimmed so browser autofill adding invisible leading/trailing whitespace never reads as a change
|
||||||
|
const newMobile = (mobileControl?.value || '').toString().trim();
|
||||||
|
const newEmail = (emailControl?.value || '').toString().trim();
|
||||||
|
const newDobRaw = form.get('dateOfBirth')?.value;
|
||||||
const newDob = newDobRaw instanceof Date ? this.toLocalDateString(newDobRaw) : newDobRaw || '';
|
const newDob = newDobRaw instanceof Date ? this.toLocalDateString(newDobRaw) : newDobRaw || '';
|
||||||
|
|
||||||
const oldMobile = (originalData.mobileNo || '').trim();
|
const oldMobile = (originalData.mobileNo || '').toString().trim();
|
||||||
const oldEmail = (originalData.emailAddress || '').trim();
|
const oldEmail = (originalData.emailAddress || '').toString().trim();
|
||||||
const oldDobRaw = originalData.dateOfBirth;
|
const oldDobRaw = originalData.dateOfBirth;
|
||||||
const oldDob = Array.isArray(oldDobRaw)
|
const oldDob = Array.isArray(oldDobRaw)
|
||||||
? `${oldDobRaw[0]}-${String(oldDobRaw[1]).padStart(2, '0')}-${String(oldDobRaw[2]).padStart(2, '0')}`
|
? `${oldDobRaw[0]}-${String(oldDobRaw[1]).padStart(2, '0')}-${String(oldDobRaw[2]).padStart(2, '0')}`
|
||||||
@@ -639,10 +735,10 @@ export class EditClientComponent {
|
|||||||
).trim();
|
).trim();
|
||||||
|
|
||||||
const newName = buildName({
|
const newName = buildName({
|
||||||
firstname: form?.get('firstname')?.value || '',
|
firstname: form.get('firstname')?.value || '',
|
||||||
middlename: form?.get('middlename')?.value || '',
|
middlename: form.get('middlename')?.value || '',
|
||||||
lastname: form?.get('lastname')?.value || '',
|
lastname: form.get('lastname')?.value || '',
|
||||||
fullname: form?.get('fullname')?.value || ''
|
fullname: form.get('fullname')?.value || ''
|
||||||
});
|
});
|
||||||
const oldName = buildName({
|
const oldName = buildName({
|
||||||
firstname: originalData.firstname || '',
|
firstname: originalData.firstname || '',
|
||||||
@@ -651,12 +747,19 @@ export class EditClientComponent {
|
|||||||
fullname: originalData.fullname || ''
|
fullname: originalData.fullname || ''
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Normalize phone numbers by stripping non-digit characters (+91 9009009009 -> 9009009009)
|
||||||
|
const normPhone = (val: string) => val.replace(/\D/g, '');
|
||||||
|
const newMobileNorm = normPhone(newMobile);
|
||||||
|
const oldMobileNorm = normPhone(oldMobile);
|
||||||
|
|
||||||
|
// Mobile/Email are considered changed ONLY when the control has been edited (dirty) AND normalized values differ
|
||||||
|
const mobileChanged = !!oldMobileNorm && !!newMobileNorm && !!mobileControl?.dirty && (newMobileNorm !== oldMobileNorm);
|
||||||
|
const emailChanged = !!oldEmail && !!newEmail && !!emailControl?.dirty && (newEmail.toLowerCase() !== oldEmail.toLowerCase());
|
||||||
|
|
||||||
return {
|
return {
|
||||||
nameChanged: newName !== oldName,
|
nameChanged: newName !== oldName,
|
||||||
mobileChanged: newMobile !== oldMobile,
|
mobileChanged,
|
||||||
// Case-insensitive: emails are not case-sensitive in practice, and comparing raw case
|
emailChanged,
|
||||||
// would flag a change (surfacing the dual-OTP panel) even when nothing meaningful changed.
|
|
||||||
emailChanged: newEmail.toLowerCase() !== oldEmail.toLowerCase(),
|
|
||||||
dobChanged: newDob !== oldDob,
|
dobChanged: newDob !== oldDob,
|
||||||
oldName,
|
oldName,
|
||||||
newName,
|
newName,
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
deriveKycExceptionFields,
|
||||||
|
KycExceptionInput,
|
||||||
|
KYC_EXCEPTION_AADHAAR,
|
||||||
|
KYC_EXCEPTION_PAN
|
||||||
|
} from './kyc-exception.util';
|
||||||
|
|
||||||
|
const TODAY = '05 August 2026';
|
||||||
|
|
||||||
|
/** Individual client, nothing previously recorded — the client-creation case. */
|
||||||
|
const newIndividual = (panVerified: boolean, aadhaarVerified: boolean): KycExceptionInput => ({
|
||||||
|
panVerified,
|
||||||
|
aadhaarVerified,
|
||||||
|
aadhaarApplicable: true,
|
||||||
|
hadException: false,
|
||||||
|
previousReason: null,
|
||||||
|
todayFormatted: TODAY
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Individual client that already carries an open exception. */
|
||||||
|
const existingException = (
|
||||||
|
panVerified: boolean,
|
||||||
|
aadhaarVerified: boolean,
|
||||||
|
previousReason: string
|
||||||
|
): KycExceptionInput => ({
|
||||||
|
panVerified,
|
||||||
|
aadhaarVerified,
|
||||||
|
aadhaarApplicable: true,
|
||||||
|
hadException: true,
|
||||||
|
previousReason,
|
||||||
|
todayFormatted: TODAY
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deriveKycExceptionFields', () => {
|
||||||
|
describe('at client creation', () => {
|
||||||
|
it('marks both PAN and Aadhaar when neither is entered or verified', () => {
|
||||||
|
expect(deriveKycExceptionFields(newIndividual(false, false))).toEqual({
|
||||||
|
isException: true,
|
||||||
|
exceptionReason: `${KYC_EXCEPTION_PAN}, ${KYC_EXCEPTION_AADHAAR}`,
|
||||||
|
exceptionResolved: false,
|
||||||
|
exceptionResolvedOn: null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks only PAN when Aadhaar verified but PAN did not', () => {
|
||||||
|
expect(deriveKycExceptionFields(newIndividual(false, true))).toEqual({
|
||||||
|
isException: true,
|
||||||
|
exceptionReason: KYC_EXCEPTION_PAN,
|
||||||
|
exceptionResolved: false,
|
||||||
|
exceptionResolvedOn: null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks only Aadhaar when PAN verified but Aadhaar did not', () => {
|
||||||
|
expect(deriveKycExceptionFields(newIndividual(true, false))).toEqual({
|
||||||
|
isException: true,
|
||||||
|
exceptionReason: KYC_EXCEPTION_AADHAAR,
|
||||||
|
exceptionResolved: false,
|
||||||
|
exceptionResolvedOn: null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('raises nothing when both verify, and does not mark the client resolved', () => {
|
||||||
|
// A clean client never had an exception, so it must not appear in the Resolved tab either.
|
||||||
|
expect(deriveKycExceptionFields(newIndividual(true, true))).toEqual({
|
||||||
|
isException: false,
|
||||||
|
exceptionReason: null,
|
||||||
|
exceptionResolved: false,
|
||||||
|
exceptionResolvedOn: null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not raise an Aadhaar exception for a non-individual client', () => {
|
||||||
|
// Companies submit a dummy masked Aadhaar; checking it would mean a permanent exception.
|
||||||
|
expect(
|
||||||
|
deriveKycExceptionFields({
|
||||||
|
panVerified: true,
|
||||||
|
aadhaarVerified: false,
|
||||||
|
aadhaarApplicable: false,
|
||||||
|
hadException: false,
|
||||||
|
previousReason: null,
|
||||||
|
todayFormatted: TODAY
|
||||||
|
}).isException
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolving', () => {
|
||||||
|
it('closes the exception once both checks pass, keeping the reason for the audit trail', () => {
|
||||||
|
const both = `${KYC_EXCEPTION_PAN}, ${KYC_EXCEPTION_AADHAAR}`;
|
||||||
|
expect(deriveKycExceptionFields(existingException(true, true, both))).toEqual({
|
||||||
|
isException: false,
|
||||||
|
exceptionReason: both,
|
||||||
|
exceptionResolved: true,
|
||||||
|
exceptionResolvedOn: TODAY
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('narrows to the outstanding item when only one of two verifies', () => {
|
||||||
|
const both = `${KYC_EXCEPTION_PAN}, ${KYC_EXCEPTION_AADHAAR}`;
|
||||||
|
// PAN verified, Aadhaar still outstanding — stays open, listing Aadhaar alone.
|
||||||
|
expect(deriveKycExceptionFields(existingException(true, false, both))).toEqual({
|
||||||
|
isException: true,
|
||||||
|
exceptionReason: KYC_EXCEPTION_AADHAAR,
|
||||||
|
exceptionResolved: false,
|
||||||
|
exceptionResolvedOn: null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('narrows the other way round too', () => {
|
||||||
|
const both = `${KYC_EXCEPTION_PAN}, ${KYC_EXCEPTION_AADHAAR}`;
|
||||||
|
expect(deriveKycExceptionFields(existingException(false, true, both)).exceptionReason).toBe(KYC_EXCEPTION_PAN);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('re-opening', () => {
|
||||||
|
it('clears a prior resolution so a new exception returns to Pending', () => {
|
||||||
|
// Regression: without exceptionResolved=false the record still matched the Resolved query
|
||||||
|
// and a brand-new exception appeared as already closed.
|
||||||
|
const result = deriveKycExceptionFields({
|
||||||
|
panVerified: false,
|
||||||
|
aadhaarVerified: true,
|
||||||
|
aadhaarApplicable: true,
|
||||||
|
hadException: false,
|
||||||
|
previousReason: 'PAN Card Verification Failed',
|
||||||
|
todayFormatted: TODAY
|
||||||
|
});
|
||||||
|
expect(result.exceptionResolved).toBe(false);
|
||||||
|
expect(result.exceptionResolvedOn).toBeNull();
|
||||||
|
expect(result.isException).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('officer-entered reasons', () => {
|
||||||
|
it('keeps a manual reason alongside the automatic ones', () => {
|
||||||
|
const result = deriveKycExceptionFields({
|
||||||
|
...newIndividual(true, true),
|
||||||
|
manualReasons: ['Address proof pending']
|
||||||
|
});
|
||||||
|
expect(result).toEqual({
|
||||||
|
isException: true,
|
||||||
|
exceptionReason: 'Address proof pending',
|
||||||
|
exceptionResolved: false,
|
||||||
|
exceptionResolvedOn: null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops a stale automatic reason left in the form once the check passes', () => {
|
||||||
|
// The form still holds 'PAN Card Verification Failed' from a previous load; PAN has since
|
||||||
|
// verified, so it must not survive into the payload.
|
||||||
|
const result = deriveKycExceptionFields({
|
||||||
|
...newIndividual(true, true),
|
||||||
|
manualReasons: [KYC_EXCEPTION_PAN]
|
||||||
|
});
|
||||||
|
expect(result.isException).toBe(false);
|
||||||
|
expect(result.exceptionReason).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not duplicate a manual reason that matches an outstanding automatic one', () => {
|
||||||
|
const result = deriveKycExceptionFields({
|
||||||
|
...newIndividual(false, true),
|
||||||
|
manualReasons: [KYC_EXCEPTION_PAN]
|
||||||
|
});
|
||||||
|
expect(result.exceptionReason).toBe(KYC_EXCEPTION_PAN);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derives a client's KYC exception state from their verification status.
|
||||||
|
*
|
||||||
|
* A KYC exception is not something an officer should have to remember to tick — it is simply the
|
||||||
|
* fact that a mandatory identity check has not passed. Deriving it means the exception list can
|
||||||
|
* never drift out of step with reality: raising, narrowing and closing all fall out of whether PAN
|
||||||
|
* and Aadhaar are verified at the moment of the save.
|
||||||
|
*
|
||||||
|
* Shared by the client-creation stepper and the client-view KYC tab so both write identical state.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const KYC_EXCEPTION_PAN = 'PAN Card Verification Failed';
|
||||||
|
export const KYC_EXCEPTION_AADHAAR = 'Aadhaar Verification Failed';
|
||||||
|
|
||||||
|
/** The two reasons this module owns. Anything else on a record was added by an officer by hand. */
|
||||||
|
export const AUTO_MANAGED_KYC_EXCEPTION_REASONS: string[] = [
|
||||||
|
KYC_EXCEPTION_PAN,
|
||||||
|
KYC_EXCEPTION_AADHAAR
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface KycExceptionInput {
|
||||||
|
/** PAN verified against the Income Tax database. Unverified OR simply not entered → exception. */
|
||||||
|
panVerified: boolean;
|
||||||
|
/** Aadhaar verified against UIDAI. Unverified OR simply not entered → exception. */
|
||||||
|
aadhaarVerified: boolean;
|
||||||
|
/**
|
||||||
|
* False for non-individual clients, where no Aadhaar is collected (the payload sends a dummy
|
||||||
|
* masked value). Without this guard every company would carry a permanent Aadhaar exception.
|
||||||
|
*/
|
||||||
|
aadhaarApplicable: boolean;
|
||||||
|
/** Whether an exception is already recorded — decides raise vs. resolve on a clean save. */
|
||||||
|
hadException: boolean;
|
||||||
|
/** Reason currently persisted, kept verbatim when closing so the audit trail survives. */
|
||||||
|
previousReason: string | null;
|
||||||
|
/** Officer-entered reasons beyond the two automatic ones; carried through untouched. */
|
||||||
|
manualReasons?: string[];
|
||||||
|
/** Today in the tenant's date format — stamped as exceptionResolvedOn when closing. */
|
||||||
|
todayFormatted: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KycExceptionFields {
|
||||||
|
isException: boolean;
|
||||||
|
exceptionReason: string | null;
|
||||||
|
exceptionResolved: boolean;
|
||||||
|
exceptionResolvedOn: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps verification status to the four exception columns the API expects.
|
||||||
|
*
|
||||||
|
* Three outcomes:
|
||||||
|
* - **Something outstanding** → raise/keep the exception, listing only what is still failing. A
|
||||||
|
* record that had both PAN and Aadhaar outstanding and has since verified PAN comes back with
|
||||||
|
* Aadhaar alone, which is what narrows the entry rather than closing it.
|
||||||
|
* - **Nothing outstanding, exception existed** → close it. `exceptionReason` is retained on
|
||||||
|
* purpose: the Resolved tab shows what was resolved, and clearing it would erase the record of
|
||||||
|
* why the exception was ever raised.
|
||||||
|
* - **Nothing outstanding, no exception** → leave every flag off, so a clean client never appears
|
||||||
|
* in either tab.
|
||||||
|
*/
|
||||||
|
export function deriveKycExceptionFields(input: KycExceptionInput): KycExceptionFields {
|
||||||
|
const outstanding: string[] = [];
|
||||||
|
|
||||||
|
if (!input.panVerified) {
|
||||||
|
outstanding.push(KYC_EXCEPTION_PAN);
|
||||||
|
}
|
||||||
|
if (input.aadhaarApplicable && !input.aadhaarVerified) {
|
||||||
|
outstanding.push(KYC_EXCEPTION_AADHAAR);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Officer-added reasons ride along, but the two automatic ones are filtered out first — a stale
|
||||||
|
// 'PAN Card Verification Failed' left in the form must not survive PAN actually being verified.
|
||||||
|
for (const reason of input.manualReasons || []) {
|
||||||
|
const trimmed = (reason || '').trim();
|
||||||
|
if (trimmed && !AUTO_MANAGED_KYC_EXCEPTION_REASONS.includes(trimmed) && !outstanding.includes(trimmed)) {
|
||||||
|
outstanding.push(trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (outstanding.length > 0) {
|
||||||
|
return {
|
||||||
|
isException: true,
|
||||||
|
exceptionReason: outstanding.join(', '),
|
||||||
|
exceptionResolved: false,
|
||||||
|
exceptionResolvedOn: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.hadException) {
|
||||||
|
return {
|
||||||
|
isException: false,
|
||||||
|
exceptionReason: input.previousReason || null,
|
||||||
|
exceptionResolved: true,
|
||||||
|
exceptionResolvedOn: input.todayFormatted
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isException: false,
|
||||||
|
exceptionReason: null,
|
||||||
|
exceptionResolved: false,
|
||||||
|
exceptionResolvedOn: null
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared between the maker side (edit-client, which captures a point-in-time snapshot of the
|
||||||
|
* client's current values right before staging an edit) and the checker side (pending-client-edits,
|
||||||
|
* which diffs the staged payload against that snapshot). Keeping both in one place means the two
|
||||||
|
* sides can never drift out of sync on which fields are captured/compared/labelled.
|
||||||
|
*
|
||||||
|
* Extracts a payload key's CURRENT value from a client record (as returned by GET /clients/{id}) —
|
||||||
|
* CodeValue fields come back as nested {id, name} objects, so most extractors pull the nested .id
|
||||||
|
* to compare against the payload's raw id.
|
||||||
|
*/
|
||||||
|
export const PENDING_EDIT_FIELD_EXTRACTORS: Record<string, (c: any) => any> = {
|
||||||
|
officeId: (c) => c.officeId,
|
||||||
|
legalFormId: (c) => c.legalForm?.id,
|
||||||
|
nationalityId: (c) => c.nationality,
|
||||||
|
isStaff: (c) => c.isStaff,
|
||||||
|
active: (c) => c.active,
|
||||||
|
accountNo: (c) => c.accountNo,
|
||||||
|
salutationId: (c) => c.salutation?.id,
|
||||||
|
firstname: (c) => c.firstname,
|
||||||
|
middlename: (c) => c.middlename,
|
||||||
|
lastname: (c) => c.lastname,
|
||||||
|
fullname: (c) => c.fullname,
|
||||||
|
genderId: (c) => c.gender?.id,
|
||||||
|
maritalStatusId: (c) => c.maritalStatus?.id,
|
||||||
|
educationLevelId: (c) => c.educationLevel?.id,
|
||||||
|
degreeQualificationId: (c) => c.degreeQualification?.id,
|
||||||
|
religionId: (c) => c.religion?.id,
|
||||||
|
socialCategoryId: (c) => c.socialCategory?.id,
|
||||||
|
mobileNo: (c) => c.mobileNo,
|
||||||
|
alternateMobileNo: (c) => c.alternateMobileNo,
|
||||||
|
emailAddress: (c) => c.emailAddress,
|
||||||
|
dateOfBirth: (c) => c.dateOfBirth,
|
||||||
|
occupationCategoryId: (c) => c.occupationCategory?.id,
|
||||||
|
occupationId: (c) => c.occupation?.id,
|
||||||
|
monthlyIncome: (c) => c.monthlyIncome,
|
||||||
|
annualIncome: (c) => c.annualIncome,
|
||||||
|
annualTurnover: (c) => c.annualTurnover,
|
||||||
|
monthlyTurnover: (c) => c.monthlyTurnover,
|
||||||
|
isPwd: (c) => c.isPwd,
|
||||||
|
pwdCategoryId: (c) => c.pwdCategory?.id,
|
||||||
|
isSeniorCitizen: (c) => c.isSeniorCitizen,
|
||||||
|
agricultureIncome: (c) => c.agricultureIncome,
|
||||||
|
nonAgricultureIncome: (c) => c.nonAgricultureIncome,
|
||||||
|
savingsProductId: (c) => c.savingsProductId,
|
||||||
|
staffId: (c) => c.staffId,
|
||||||
|
guardianClientId: (c) => c.guardianClientId,
|
||||||
|
guardianRelation: (c) => c.guardianRelation
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Payload keys that are computed, internal, or otherwise never meaningfully "changed" from a
|
||||||
|
* checker's point of view — always sent by the form (age breakdown, OTP tracking ids, the
|
||||||
|
* business-date default on submittedOnDate) but with no comparable "current" value. */
|
||||||
|
export const NON_COMPARABLE_KEYS = new Set([
|
||||||
|
'dateFormat',
|
||||||
|
'locale',
|
||||||
|
'ageYears',
|
||||||
|
'ageMonths',
|
||||||
|
'ageDays',
|
||||||
|
'isMinor',
|
||||||
|
'addSavings',
|
||||||
|
'submittedOnDate',
|
||||||
|
'activationDate',
|
||||||
|
// Flattened onto top-level guardianClientId/guardianRelation right after this in the payload —
|
||||||
|
// that flattened pair already has real comparators above, so the nested group is redundant.
|
||||||
|
'guardianDetails',
|
||||||
|
// Mobile/email DIP verification + name-match + sanctions-screening results — carried along on
|
||||||
|
// every save to re-affirm the client's EXISTING verification state, not something the maker
|
||||||
|
// edited on this pass. dipTimestamp especially would almost never string-match exactly even
|
||||||
|
// when functionally unchanged, so these were never real "proposed changes" to review.
|
||||||
|
'dipVerified',
|
||||||
|
'dipLookupLogId',
|
||||||
|
'dipTimestamp',
|
||||||
|
'alternateDipVerified',
|
||||||
|
'alternateDipLookupLogId',
|
||||||
|
'alternateDipTimestamp',
|
||||||
|
'nameVerified',
|
||||||
|
'screeningLogId',
|
||||||
|
'mobileNewOtpId',
|
||||||
|
'mobileOldOtpId',
|
||||||
|
'emailNewOtpId',
|
||||||
|
'emailOldOtpId'
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const PENDING_EDIT_FIELD_LABELS: Record<string, string> = {
|
||||||
|
officeId: 'Office',
|
||||||
|
legalFormId: 'Legal Form',
|
||||||
|
nationalityId: 'Nationality',
|
||||||
|
isStaff: 'Is Staff',
|
||||||
|
active: 'Active',
|
||||||
|
accountNo: 'Account Number',
|
||||||
|
salutationId: 'Salutation',
|
||||||
|
firstname: 'First Name',
|
||||||
|
middlename: 'Middle Name',
|
||||||
|
lastname: 'Last Name',
|
||||||
|
fullname: 'Full Name',
|
||||||
|
genderId: 'Gender',
|
||||||
|
maritalStatusId: 'Marital Status',
|
||||||
|
educationLevelId: 'Education Level',
|
||||||
|
degreeQualificationId: 'Degree / Qualification',
|
||||||
|
religionId: 'Religion',
|
||||||
|
socialCategoryId: 'Social Category',
|
||||||
|
mobileNo: 'Mobile Number',
|
||||||
|
alternateMobileNo: 'Alternate Mobile Number',
|
||||||
|
emailAddress: 'Email Address',
|
||||||
|
dateOfBirth: 'Date of Birth',
|
||||||
|
occupationCategoryId: 'Occupation Category',
|
||||||
|
occupationId: 'Occupation',
|
||||||
|
monthlyIncome: 'Monthly Income',
|
||||||
|
annualIncome: 'Annual Income',
|
||||||
|
annualTurnover: 'Yearly Turnover',
|
||||||
|
monthlyTurnover: 'Monthly Turnover',
|
||||||
|
isPwd: 'Person with Disability',
|
||||||
|
pwdCategoryId: 'Disability Type',
|
||||||
|
isSeniorCitizen: 'Senior Citizen',
|
||||||
|
agricultureIncome: 'Agriculture Income',
|
||||||
|
nonAgricultureIncome: 'Non-Agriculture Income',
|
||||||
|
submittedOnDate: 'Submitted On',
|
||||||
|
savingsProductId: 'Linked Savings Product',
|
||||||
|
staffId: 'Staff'
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Key under which the maker's point-in-time "before" snapshot rides along in the submit payload —
|
||||||
|
* stripped out by the backend before replaying the rest through updateClient(), and stored
|
||||||
|
* separately as original_json for the checker's diff. Never a real Client field name, so it can't
|
||||||
|
* collide with anything ClientDataValidator actually expects. */
|
||||||
|
export const ORIGINAL_VALUES_PAYLOAD_KEY = '_originalValues';
|
||||||
|
|
||||||
|
/** Builds the maker-side "before" snapshot from the client record loaded when the edit page
|
||||||
|
* opened — flat key names matching the payload's own, so the checker side can diff two plain
|
||||||
|
* objects directly without needing to know Client's nested response shape at all. */
|
||||||
|
export function buildOriginalValuesSnapshot(clientData: any): Record<string, any> {
|
||||||
|
const snapshot: Record<string, any> = {};
|
||||||
|
if (!clientData) return snapshot;
|
||||||
|
for (const key of Object.keys(PENDING_EDIT_FIELD_EXTRACTORS)) {
|
||||||
|
const value = PENDING_EDIT_FIELD_EXTRACTORS[key](clientData);
|
||||||
|
if (value !== undefined) {
|
||||||
|
snapshot[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<!--
|
||||||
|
Copyright since 2025 Mifos Initiative
|
||||||
|
|
||||||
|
This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<h1 mat-dialog-title>{{ data.title || data.fileName || 'Document Preview' }}</h1>
|
||||||
|
|
||||||
|
<div mat-dialog-content class="doc-preview-content">
|
||||||
|
@if (isImage) {
|
||||||
|
<img [src]="previewUrl" [alt]="data.fileName || 'Document'" class="doc-preview-image" />
|
||||||
|
} @else if (isPdf) {
|
||||||
|
<iframe [src]="safeUrl" class="doc-preview-pdf" title="Document preview"></iframe>
|
||||||
|
} @else {
|
||||||
|
<p>Preview isn't available for this file type — use Download to view it.</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<mat-dialog-actions align="end">
|
||||||
|
<button mat-raised-button (click)="download()">Download</button>
|
||||||
|
<button mat-raised-button mat-dialog-close color="primary">Close</button>
|
||||||
|
</mat-dialog-actions>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.doc-preview-content {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 320px;
|
||||||
|
max-width: 80vw;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-preview-image {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 70vh;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-preview-pdf {
|
||||||
|
width: 70vw;
|
||||||
|
height: 70vh;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Angular Imports */
|
||||||
|
import { Component, OnDestroy, inject } from '@angular/core';
|
||||||
|
import {
|
||||||
|
MatDialogRef,
|
||||||
|
MAT_DIALOG_DATA,
|
||||||
|
MatDialogTitle,
|
||||||
|
MatDialogContent,
|
||||||
|
MatDialogActions,
|
||||||
|
MatDialogClose
|
||||||
|
} from '@angular/material/dialog';
|
||||||
|
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
|
||||||
|
import { CdkScrollable } from '@angular/cdk/scrolling';
|
||||||
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
|
||||||
|
export interface DocumentPreviewDialogData {
|
||||||
|
blob: Blob;
|
||||||
|
title?: string;
|
||||||
|
fileName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic in-app document preview dialog — shows an image inline or a PDF in an embedded
|
||||||
|
* viewer, with a Download fallback for anything else. Used in place of window.open() for
|
||||||
|
* saved KYC/address/senior-citizen/PWD documents: opening a new browser tab is subject to
|
||||||
|
* popup blockers (fires only after the async download resolves) and, for images, Chrome/Edge
|
||||||
|
* frequently fail to paint a huge base64 data: URL until the tab is manually refreshed.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-document-preview-dialog',
|
||||||
|
templateUrl: './document-preview-dialog.component.html',
|
||||||
|
styleUrls: ['./document-preview-dialog.component.scss'],
|
||||||
|
imports: [
|
||||||
|
...STANDALONE_SHARED_IMPORTS,
|
||||||
|
MatDialogTitle,
|
||||||
|
CdkScrollable,
|
||||||
|
MatDialogContent,
|
||||||
|
MatDialogActions,
|
||||||
|
MatDialogClose
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class DocumentPreviewDialogComponent implements OnDestroy {
|
||||||
|
dialogRef = inject<MatDialogRef<DocumentPreviewDialogComponent>>(MatDialogRef);
|
||||||
|
private sanitizer = inject(DomSanitizer);
|
||||||
|
data = inject<DocumentPreviewDialogData>(MAT_DIALOG_DATA);
|
||||||
|
|
||||||
|
private readonly objectUrl: string;
|
||||||
|
readonly safeUrl: SafeResourceUrl;
|
||||||
|
readonly isImage: boolean;
|
||||||
|
readonly isPdf: boolean;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.objectUrl = URL.createObjectURL(this.data.blob);
|
||||||
|
this.safeUrl = this.sanitizer.bypassSecurityTrustResourceUrl(this.objectUrl);
|
||||||
|
const type = this.data.blob.type || '';
|
||||||
|
this.isImage = type.startsWith('image/');
|
||||||
|
this.isPdf = type === 'application/pdf';
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
URL.revokeObjectURL(this.objectUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
get previewUrl(): string {
|
||||||
|
return this.objectUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
download(): void {
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = this.objectUrl;
|
||||||
|
a.download = this.data.fileName || 'document';
|
||||||
|
a.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -70,6 +70,20 @@ export const PROOF_META: Record<string, Partial<ProofType>> = {
|
|||||||
documentKeyPlaceholder: 'NPR reference number',
|
documentKeyPlaceholder: 'NPR reference number',
|
||||||
info: 'NPR letter — upload full document.'
|
info: 'NPR letter — upload full document.'
|
||||||
},
|
},
|
||||||
|
BIRTH_CERTIFICATE: {
|
||||||
|
sides: 1,
|
||||||
|
documentKeyLabel: 'Birth certificate registration number',
|
||||||
|
documentKeyPlaceholder: 'Registration / certificate number',
|
||||||
|
category: 'Government-issued ID',
|
||||||
|
info: 'Birth certificate issued by the Municipal Corporation / Registrar of Births & Deaths — accepted proof of identity for minor clients as per RBI KYC norms.'
|
||||||
|
},
|
||||||
|
SCHOOL_ID_CARD: {
|
||||||
|
sides: 1,
|
||||||
|
documentKeyLabel: 'School ID / admission number',
|
||||||
|
documentKeyPlaceholder: 'Student ID number',
|
||||||
|
category: 'Government-issued ID',
|
||||||
|
info: 'School identity card bearing photograph — accepted proof of identity for minor clients as per RBI KYC norms.'
|
||||||
|
},
|
||||||
UTILITY_BILL: {
|
UTILITY_BILL: {
|
||||||
sides: 1,
|
sides: 1,
|
||||||
category: 'Utility bills',
|
category: 'Utility bills',
|
||||||
@@ -131,7 +145,46 @@ export const PROOF_META: Record<string, Partial<ProofType>> = {
|
|||||||
PENSION_PAYMENT_ORDER: {
|
PENSION_PAYMENT_ORDER: {
|
||||||
sides: 1,
|
sides: 1,
|
||||||
category: 'Financial & other',
|
category: 'Financial & other',
|
||||||
|
requiresDate: true,
|
||||||
info: 'Pension payment order — upload full document issued by pension authority.'
|
info: 'Pension payment order — upload full document issued by pension authority.'
|
||||||
|
},
|
||||||
|
CERT_INCORPORATION: {
|
||||||
|
sides: 1,
|
||||||
|
category: 'Entity KYC documents',
|
||||||
|
documentKeyLabel: 'Registration / incorporation number',
|
||||||
|
documentKeyPlaceholder: 'CIN / registration number',
|
||||||
|
info: 'Certificate of Incorporation (companies/LLPs) or Registration Certificate (firms/trusts/societies) — as prescribed under the RBI Master Direction on KYC, Annexure I.'
|
||||||
|
},
|
||||||
|
ENTITY_PAN: {
|
||||||
|
sides: 1,
|
||||||
|
category: 'Entity KYC documents',
|
||||||
|
documentKeyLabel: 'PAN of entity',
|
||||||
|
documentKeyPlaceholder: 'e.g. ABCDE1234F',
|
||||||
|
info: 'PAN card issued in the name of the entity.'
|
||||||
|
},
|
||||||
|
CONSTITUTIVE_DOC: {
|
||||||
|
sides: 1,
|
||||||
|
category: 'Entity KYC documents',
|
||||||
|
documentKeyLabel: 'Document reference number',
|
||||||
|
documentKeyPlaceholder: 'Reference / registration number',
|
||||||
|
info: 'Constitutive document — Memorandum & Articles of Association (companies), Partnership Deed (firms), Trust Deed (trusts), or By-laws (societies) — as prescribed under the RBI Master Direction on KYC.'
|
||||||
|
},
|
||||||
|
BOARD_RESOLUTION: {
|
||||||
|
sides: 1,
|
||||||
|
category: 'Entity KYC documents',
|
||||||
|
info: 'Board Resolution / Authorization Letter naming the officers, managers or employees authorised to transact on the entity’s behalf.'
|
||||||
|
},
|
||||||
|
AUTH_SIGNATORY_KYC: {
|
||||||
|
sides: 1,
|
||||||
|
category: 'Entity KYC documents',
|
||||||
|
info: 'OVD-based KYC of the person(s) holding Power of Attorney / authorised to transact on the entity’s behalf.'
|
||||||
|
},
|
||||||
|
GST_CERTIFICATE: {
|
||||||
|
sides: 1,
|
||||||
|
category: 'Entity KYC documents',
|
||||||
|
documentKeyLabel: 'GSTIN',
|
||||||
|
documentKeyPlaceholder: 'e.g. 22AAAAA0000A1Z5',
|
||||||
|
info: 'GST Registration Certificate, where the entity is GST-registered.'
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -384,6 +384,14 @@ 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();
|
||||||
@@ -392,6 +400,22 @@ export class AuthenticationService {
|
|||||||
return roleNames.some((n) => n === 'clerk');
|
return roleNames.some((n) => n === 'clerk');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Returns true when the logged-in user has the "Principal Officer" role (STR conclusion/filing authority). */
|
||||||
|
isPrincipalOfficerUser(): boolean {
|
||||||
|
const creds = this.getCredentials();
|
||||||
|
if (!creds || !creds.roles) return false;
|
||||||
|
const roleNames: string[] = (creds.roles as any[]).map((r) => (r.name || r.role || '').toLowerCase().trim());
|
||||||
|
return roleNames.some((n) => n === 'principal officer');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns true when the logged-in user has the "AML Officer" role (first-level alert review). */
|
||||||
|
isAmlOfficerUser(): boolean {
|
||||||
|
const creds = this.getCredentials();
|
||||||
|
if (!creds || !creds.roles) return false;
|
||||||
|
const roleNames: string[] = (creds.roles as any[]).map((r) => (r.name || r.role || '').toLowerCase().trim());
|
||||||
|
return roleNames.some((n) => n === 'aml officer');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns true ONLY for regular cashier role (not Head Cashier).
|
* Returns true ONLY for regular cashier role (not Head Cashier).
|
||||||
* Regular cashier → full-screen transaction entry, no nav.
|
* Regular cashier → full-screen transaction entry, no nav.
|
||||||
@@ -416,6 +440,14 @@ export class AuthenticationService {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Returns true when the logged-in user has the "Cashier" role. */
|
||||||
|
hasCashierRole(): boolean {
|
||||||
|
const creds = this.getCredentials();
|
||||||
|
if (!creds || !creds.roles) return false;
|
||||||
|
const roleNames: string[] = (creds.roles as any[]).map((r) => (r.name || r.role || '').toLowerCase().trim());
|
||||||
|
return roleNames.some((n) => n === 'cashier');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the user credentials.
|
* Sets the user credentials.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { AuthenticationService } from './authentication/authentication.service';
|
||||||
|
import { GL_BRANCHES, GlBranch } from 'app/gl-reports/gl-reports-data';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves what branch data the logged-in user is allowed to see.
|
||||||
|
*
|
||||||
|
* Head Office sees every branch and can drill into any one of them; a branch login is
|
||||||
|
* locked to its own book. Previously this logic was copy-pasted into the Cash/Clearing
|
||||||
|
* Summary and the NPA report with slightly different branch code lists; this service is
|
||||||
|
* the single place it lives so new reports cannot drift.
|
||||||
|
*
|
||||||
|
* The logged-in office is matched to a branch by name, since session credentials carry
|
||||||
|
* only officeId and officeName. Office names are seeded to match the GL branch names
|
||||||
|
* exactly (see Liquibase 0442).
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class BranchScopeService {
|
||||||
|
private auth = inject(AuthenticationService);
|
||||||
|
|
||||||
|
/** Sentinel used when a branch login cannot be matched to a known branch. */
|
||||||
|
static readonly UNRESOLVED = '__unresolved__';
|
||||||
|
|
||||||
|
get isHeadOffice(): boolean {
|
||||||
|
return this.auth.isHeadOfficeUser();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The office name on the session, for labelling the scope in report headers. */
|
||||||
|
get officeName(): string {
|
||||||
|
return this.auth.getCredentials()?.officeName || 'Branch Office';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Branch code the user is confined to, or null for Head Office.
|
||||||
|
*
|
||||||
|
* Deliberately returns the UNRESOLVED sentinel rather than falling back to the first
|
||||||
|
* branch when the office cannot be matched: showing an unrecognised branch someone
|
||||||
|
* else's figures would be worse than showing them nothing.
|
||||||
|
*/
|
||||||
|
get ownBranchCode(): string | null {
|
||||||
|
if (this.isHeadOffice) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const name = this.officeName.toLowerCase().trim();
|
||||||
|
const match = GL_BRANCHES.find((b) => b.code !== 'HO' && name === b.name.toLowerCase());
|
||||||
|
// Fall back to a substring match for offices named e.g. "Andheri (E) Branch".
|
||||||
|
const loose = match ?? GL_BRANCHES.find((b) => b.code !== 'HO' && name.includes(b.name.toLowerCase()));
|
||||||
|
return loose?.code ?? BranchScopeService.UNRESOLVED;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the user may choose which branch to view. */
|
||||||
|
get canSelectBranch(): boolean {
|
||||||
|
return this.isHeadOffice;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Branches offered in a selector — everything for Head Office, nothing for a branch login. */
|
||||||
|
get selectableBranches(): GlBranch[] {
|
||||||
|
return this.isHeadOffice ? GL_BRANCHES : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Branch code a report should start on. '' means all branches consolidated. */
|
||||||
|
get defaultBranchCode(): string {
|
||||||
|
return this.isHeadOffice ? '' : (this.ownBranchCode ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forces a requested branch code into what the user is actually allowed to see.
|
||||||
|
* Call this wherever a branch code reaches a data query, so a stale or hand-edited
|
||||||
|
* selection cannot widen the user's scope.
|
||||||
|
*/
|
||||||
|
enforce(requested: string): string {
|
||||||
|
return this.isHeadOffice ? requested : (this.ownBranchCode ?? BranchScopeService.UNRESOLVED);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when a branch login's office matched no known branch, so it sees no data. */
|
||||||
|
get isUnresolved(): boolean {
|
||||||
|
return this.ownBranchCode === BranchScopeService.UNRESOLVED;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Human label for the current scope, for report headers. */
|
||||||
|
scopeLabel(selectedCode: string): string {
|
||||||
|
if (!this.isHeadOffice) {
|
||||||
|
return this.officeName;
|
||||||
|
}
|
||||||
|
if (!selectedCode) {
|
||||||
|
return 'All Branches (Consolidated)';
|
||||||
|
}
|
||||||
|
return GL_BRANCHES.find((b) => b.code === selectedCode)?.name ?? selectedCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ import { environment } from '../../../environments/environment';
|
|||||||
import { Logger } from '../logger/logger.service';
|
import { Logger } from '../logger/logger.service';
|
||||||
import { AlertService } from '../alert/alert.service';
|
import { AlertService } from '../alert/alert.service';
|
||||||
import { TranslateService } from '@ngx-translate/core'; // Added import for TranslateService
|
import { TranslateService } from '@ngx-translate/core'; // Added import for TranslateService
|
||||||
|
import { SILENT_HTTP_ERROR } from './silent-error.token';
|
||||||
|
|
||||||
/** Initialize Logger */
|
/** Initialize Logger */
|
||||||
const log = new Logger('ErrorHandlerInterceptor');
|
const log = new Logger('ErrorHandlerInterceptor');
|
||||||
@@ -54,12 +55,33 @@ export class ErrorHandlerInterceptor implements HttpInterceptor {
|
|||||||
const isClientImageRequest = request.url.includes('/clients/') && request.url.includes('/images');
|
const isClientImageRequest = request.url.includes('/clients/') && request.url.includes('/images');
|
||||||
const isClientKycRequest = request.url.match(/\/clients\/\d+\/kyc$/);
|
const isClientKycRequest = request.url.match(/\/clients\/\d+\/kyc$/);
|
||||||
const isDirectorRequest = request.url.includes('/election/directors');
|
const isDirectorRequest = request.url.includes('/election/directors');
|
||||||
const isSilentRequest = isClientImageRequest || isClientKycRequest || isDirectorRequest;
|
// "No pending edit for this client" is the normal/expected state — checked on every edit-page
|
||||||
|
// load, not a real error the user needs to see.
|
||||||
|
const isClientPendingEditRequest = request.url.match(/\/clients\/\d+\/edit\/pending$/);
|
||||||
|
// Risk rules are an enhancement on top of a default risk-category fallback — if this
|
||||||
|
// endpoint isn't available on a given backend deployment, the client-general-step
|
||||||
|
// component already falls back to riskRules = [] silently; no toast needed here either.
|
||||||
|
const isKycRiskRulesRequest = request.url.includes('/kyc/risk-rules');
|
||||||
|
const isSilentRequest =
|
||||||
|
isClientImageRequest ||
|
||||||
|
isClientKycRequest ||
|
||||||
|
isDirectorRequest ||
|
||||||
|
isClientPendingEditRequest ||
|
||||||
|
isKycRiskRulesRequest;
|
||||||
|
|
||||||
if (!environment.production && !isSilentRequest) {
|
// Explicit per-request opt-out (e.g. per-row enrichment fetches on a list page whose
|
||||||
|
// component already recovers via its own catchError) — silenced for every status, since
|
||||||
|
// the caller took responsibility for handling failure when it set this.
|
||||||
|
const isSilentOverride = request.context.get(SILENT_HTTP_ERROR);
|
||||||
|
|
||||||
|
if (!environment.production && !isSilentRequest && !isSilentOverride) {
|
||||||
log.error(`Request Error: ${errorMessage}`);
|
log.error(`Request Error: ${errorMessage}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isSilentOverride) {
|
||||||
|
throw response;
|
||||||
|
}
|
||||||
|
|
||||||
// Silently handle expected 403/404 errors (no image, no KYC record)
|
// Silently handle expected 403/404 errors (no image, no KYC record)
|
||||||
// Throw instead of EMPTY so component-level catchError can handle it
|
// Throw instead of EMPTY so component-level catchError can handle it
|
||||||
if (isSilentRequest && (status === 403 || status === 404)) {
|
if (isSilentRequest && (status === 403 || status === 404)) {
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { HttpContextToken } from '@angular/common/http';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-request opt-out of ErrorHandlerInterceptor's toast. Set via
|
||||||
|
* `{ context: new HttpContext().set(SILENT_HTTP_ERROR, true) }` on calls whose caller already
|
||||||
|
* handles failures itself (e.g. per-row enrichment fetches on a list page) — the interceptor
|
||||||
|
* still throws so the caller's own catchError/error callback runs, it just skips the alert.
|
||||||
|
*/
|
||||||
|
export const SILENT_HTTP_ERROR = new HttpContextToken<boolean>(() => false);
|
||||||
@@ -99,6 +99,30 @@
|
|||||||
</svg>
|
</svg>
|
||||||
My Disbursements
|
My Disbursements
|
||||||
</a>
|
</a>
|
||||||
|
<a
|
||||||
|
class="cashier-nav__item cashier-nav__item--sec"
|
||||||
|
[class.cashier-nav__item--active]="currentUrl.includes('/clerk/share-withdrawal-request')"
|
||||||
|
[routerLink]="['/clerk/share-withdrawal-requests']"
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="12" cy="12" r="9" />
|
||||||
|
<path
|
||||||
|
d="M9.5 9a2.5 2.5 0 0 1 2.5-1.5c1.4 0 2.5.8 2.5 2s-1 1.5-2.5 1.9c-1.5.4-2.5.9-2.5 2.1s1.1 2 2.5 2 2.5-.7 2.5-1.6"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
My Share Requests
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
class="cashier-nav__item cashier-nav__item--sec"
|
||||||
|
[class.cashier-nav__item--active]="currentUrl.includes('/clerk/share-closure-request')"
|
||||||
|
[routerLink]="['/clerk/share-closure-requests']"
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<rect x="4" y="11" width="16" height="9" rx="2" />
|
||||||
|
<path d="M8 11V7a4 4 0 0 1 8 0v4" />
|
||||||
|
</svg>
|
||||||
|
My Share Closures
|
||||||
|
</a>
|
||||||
@if (clerkAccountLoaded) {
|
@if (clerkAccountLoaded) {
|
||||||
<div class="cashier-nav__item-wrap">
|
<div class="cashier-nav__item-wrap">
|
||||||
<a
|
<a
|
||||||
@@ -566,6 +590,19 @@
|
|||||||
<button mat-menu-item [routerLink]="['/reports', 'Accounting']" tabindex="0">
|
<button mat-menu-item [routerLink]="['/reports', 'Accounting']" tabindex="0">
|
||||||
<i class="fa fa-calculator menu-item-icon"></i> Accounting
|
<i class="fa fa-calculator menu-item-icon"></i> Accounting
|
||||||
</button>
|
</button>
|
||||||
|
<mat-divider></mat-divider>
|
||||||
|
<button mat-menu-item routerLink="/reports/form-97-98" tabindex="0">
|
||||||
|
<i class="fa fa-file-invoice menu-item-icon"></i> Form 97/98 Transaction Report
|
||||||
|
</button>
|
||||||
|
<button mat-menu-item routerLink="/reports/risk-review" tabindex="0">
|
||||||
|
<i class="fa fa-user-shield menu-item-icon"></i> Risk Review Report
|
||||||
|
</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,
|
||||||
@@ -1121,7 +1158,7 @@
|
|||||||
<button mat-menu-item tabindex="0">
|
<button mat-menu-item tabindex="0">
|
||||||
<fa-icon icon="check-circle" class="menu-item-icon"></fa-icon> Share Issued
|
<fa-icon icon="check-circle" class="menu-item-icon"></fa-icon> Share Issued
|
||||||
</button>
|
</button>
|
||||||
<button mat-menu-item tabindex="0">
|
<button mat-menu-item tabindex="0" [routerLink]="['/shares/share-holders']">
|
||||||
<fa-icon icon="users" class="menu-item-icon"></fa-icon> Share Holder
|
<fa-icon icon="users" class="menu-item-icon"></fa-icon> Share Holder
|
||||||
</button>
|
</button>
|
||||||
<button mat-menu-item tabindex="0">
|
<button mat-menu-item tabindex="0">
|
||||||
@@ -1167,10 +1204,6 @@
|
|||||||
<button mat-menu-item tabindex="0">
|
<button mat-menu-item tabindex="0">
|
||||||
<fa-icon icon="chart-bar" class="menu-item-icon"></fa-icon> Depreciation Register
|
<fa-icon icon="chart-bar" class="menu-item-icon"></fa-icon> Depreciation Register
|
||||||
</button>
|
</button>
|
||||||
<button mat-menu-item routerLink="/fiu" tabindex="0">
|
|
||||||
<fa-icon icon="shield-alt" class="menu-item-icon" style="color: #dc2626"></fa-icon> Financial Intelligence
|
|
||||||
Unit (FIU-IND)
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<!-- Investment -->
|
<!-- Investment -->
|
||||||
<div class="deposit-menu-group">
|
<div class="deposit-menu-group">
|
||||||
@@ -1468,13 +1501,21 @@
|
|||||||
Balance Certificate
|
Balance Certificate
|
||||||
</button>
|
</button>
|
||||||
<button mat-menu-item routerLink="/letter-mgmt/form-60" tabindex="0">
|
<button mat-menu-item routerLink="/letter-mgmt/form-60" tabindex="0">
|
||||||
<fa-icon icon="file-signature" class="menu-item-icon" style="color: #15803d"></fa-icon> Form 60
|
<fa-icon icon="file-signature" class="menu-item-icon" style="color: #15803d"></fa-icon> Form 97
|
||||||
</button>
|
</button>
|
||||||
<button mat-menu-item tabindex="0">
|
<button mat-menu-item tabindex="0">
|
||||||
<fa-icon icon="file-signature" class="menu-item-icon" style="color: #15803d"></fa-icon> Form 61
|
<fa-icon icon="file-signature" class="menu-item-icon" style="color: #15803d"></fa-icon> Form 98
|
||||||
</button>
|
</button>
|
||||||
<button mat-menu-item routerLink="/letter-mgmt/form-15gh" tabindex="0">
|
<button mat-menu-item routerLink="/letter-mgmt/form-15gh" tabindex="0">
|
||||||
<fa-icon icon="file-signature" class="menu-item-icon" style="color: #15803d"></fa-icon> Form 15G and H
|
<fa-icon icon="file-signature" class="menu-item-icon" style="color: #15803d"></fa-icon> Form 121
|
||||||
|
</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>
|
||||||
<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>
|
||||||
@@ -1568,8 +1609,13 @@
|
|||||||
<a class="gl-link" [class.hidden]="!matchesRbiSearch('Balance Sheet')" (click)="rbiMenuOpen = false">
|
<a class="gl-link" [class.hidden]="!matchesRbiSearch('Balance Sheet')" (click)="rbiMenuOpen = false">
|
||||||
<fa-icon icon="file-alt" class="gl-icon"></fa-icon> Balance Sheet (BR Act & Others)
|
<fa-icon icon="file-alt" class="gl-icon"></fa-icon> Balance Sheet (BR Act & Others)
|
||||||
</a>
|
</a>
|
||||||
<a class="gl-link" [class.hidden]="!matchesRbiSearch('CRR / SLR')" (click)="rbiMenuOpen = false">
|
<a
|
||||||
<fa-icon icon="calculator" class="gl-icon"></fa-icon> CRR / SLR
|
class="gl-link"
|
||||||
|
[class.hidden]="!matchesRbiSearch('CTR / STR')"
|
||||||
|
routerLink="/fiu"
|
||||||
|
(click)="rbiMenuOpen = false"
|
||||||
|
>
|
||||||
|
<fa-icon icon="calculator" class="gl-icon"></fa-icon> CTR / STR
|
||||||
</a>
|
</a>
|
||||||
<a class="gl-link" [class.hidden]="!matchesRbiSearch('Annexure')" (click)="rbiMenuOpen = false">
|
<a class="gl-link" [class.hidden]="!matchesRbiSearch('Annexure')" (click)="rbiMenuOpen = false">
|
||||||
<fa-icon icon="paperclip" class="gl-icon"></fa-icon> Annexure
|
<fa-icon icon="paperclip" class="gl-icon"></fa-icon> Annexure
|
||||||
@@ -1664,8 +1710,13 @@
|
|||||||
>
|
>
|
||||||
<fa-icon icon="bell" class="gl-icon"></fa-icon> Daily Position Notice
|
<fa-icon icon="bell" class="gl-icon"></fa-icon> Daily Position Notice
|
||||||
</a>
|
</a>
|
||||||
<a class="gl-link" [class.hidden]="!matchesRbiSearch('R/SLR')" (click)="rbiMenuOpen = false">
|
<a
|
||||||
<fa-icon icon="lock" class="gl-icon"></fa-icon> R/SLR
|
class="gl-link"
|
||||||
|
[class.hidden]="!matchesRbiSearch('AML')"
|
||||||
|
routerLink="/rbi/aml"
|
||||||
|
(click)="rbiMenuOpen = false"
|
||||||
|
>
|
||||||
|
<fa-icon icon="shield-alt" class="gl-icon"></fa-icon> AML
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<!-- OSS Reports -->
|
<!-- OSS Reports -->
|
||||||
|
|||||||
@@ -179,9 +179,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. */
|
||||||
|
|||||||
@@ -7,6 +7,9 @@
|
|||||||
-->
|
-->
|
||||||
|
|
||||||
<div class="cur-view">
|
<div class="cur-view">
|
||||||
|
<!-- ─── Dormancy risk alert (only when subStatus has left Active) ──── -->
|
||||||
|
<mifosx-dormancy-alert-panel [accountData]="currentAccountData"></mifosx-dormancy-alert-panel>
|
||||||
|
|
||||||
<!-- ─── Gradient hero header ─────────────────────────────────────── -->
|
<!-- ─── Gradient hero header ─────────────────────────────────────── -->
|
||||||
<header class="cur-hero">
|
<header class="cur-hero">
|
||||||
<div class="cur-hero__left">
|
<div class="cur-hero__left">
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { TranslateService } from '@ngx-translate/core';
|
|||||||
import { AccountsService } from 'app/accounts/accounts.service';
|
import { AccountsService } from 'app/accounts/accounts.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 { AuthenticationService } from '../../core/authentication/authentication.service';
|
||||||
|
|
||||||
/** Environment Configuration */
|
/** Environment Configuration */
|
||||||
import { environment } from '../../../environments/environment';
|
import { environment } from '../../../environments/environment';
|
||||||
@@ -48,6 +49,7 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
|||||||
import { MatTabNav, MatTabLink, MatTabNavPanel } from '@angular/material/tabs';
|
import { MatTabNav, MatTabLink, MatTabNavPanel } from '@angular/material/tabs';
|
||||||
import { StatusLookupPipe } from '../../pipes/status-lookup.pipe';
|
import { StatusLookupPipe } from '../../pipes/status-lookup.pipe';
|
||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
import { DormancyAlertPanelComponent } from 'app/shared/dormancy-alert-panel/dormancy-alert-panel.component';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Current Account View Component
|
* Current Account View Component
|
||||||
@@ -77,7 +79,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
|||||||
MatTabNavPanel,
|
MatTabNavPanel,
|
||||||
RouterOutlet,
|
RouterOutlet,
|
||||||
CurrencyPipe,
|
CurrencyPipe,
|
||||||
StatusLookupPipe
|
StatusLookupPipe,
|
||||||
|
DormancyAlertPanelComponent
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class CurrentAccountViewComponent implements OnInit {
|
export class CurrentAccountViewComponent implements OnInit {
|
||||||
@@ -89,6 +92,7 @@ export class CurrentAccountViewComponent implements OnInit {
|
|||||||
private accountsService = inject(AccountsService);
|
private accountsService = inject(AccountsService);
|
||||||
private settingsService = inject(SettingsService);
|
private settingsService = inject(SettingsService);
|
||||||
private dateUtils = inject(Dates);
|
private dateUtils = inject(Dates);
|
||||||
|
private authenticationService = inject(AuthenticationService);
|
||||||
dialog = inject(MatDialog);
|
dialog = inject(MatDialog);
|
||||||
|
|
||||||
/** Current Account Data */
|
/** Current Account Data */
|
||||||
@@ -271,6 +275,14 @@ export class CurrentAccountViewComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (!this.authenticationService.hasCashierRole()) {
|
||||||
|
this.buttonConfig.buttonsArray = this.buttonConfig.buttonsArray.filter(
|
||||||
|
(btn) => btn.name !== 'Deposit' && btn.name !== 'Withdrawal'
|
||||||
|
);
|
||||||
|
this.buttonConfig.optionArray = this.buttonConfig.optionArray.filter(
|
||||||
|
(opt) => opt.name !== 'Deposit' && opt.name !== 'Withdrawal'
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+3
@@ -7,6 +7,9 @@
|
|||||||
-->
|
-->
|
||||||
|
|
||||||
<div class="fd-view">
|
<div class="fd-view">
|
||||||
|
<!-- ─── Dormancy risk alert (only when subStatus has left Active) ──── -->
|
||||||
|
<mifosx-dormancy-alert-panel [accountData]="fixedDepositsAccountData"></mifosx-dormancy-alert-panel>
|
||||||
|
|
||||||
<!-- ─── Gradient hero header ─────────────────────────────────────── -->
|
<!-- ─── Gradient hero header ─────────────────────────────────────── -->
|
||||||
<header class="fd-hero">
|
<header class="fd-hero">
|
||||||
<div class="fd-hero__left">
|
<div class="fd-hero__left">
|
||||||
|
|||||||
+13
-1
@@ -27,6 +27,7 @@ import { AccountsService } from 'app/accounts/accounts.service';
|
|||||||
import { Currency } from 'app/shared/models/general.model';
|
import { Currency } from 'app/shared/models/general.model';
|
||||||
import { SettingsService } from 'app/settings/settings.service';
|
import { SettingsService } from 'app/settings/settings.service';
|
||||||
import { Dates } from 'app/core/utils/dates';
|
import { Dates } from 'app/core/utils/dates';
|
||||||
|
import { AuthenticationService } from '../../../core/authentication/authentication.service';
|
||||||
import { MatTooltip } from '@angular/material/tooltip';
|
import { MatTooltip } from '@angular/material/tooltip';
|
||||||
import { NgClass, CurrencyPipe } from '@angular/common';
|
import { NgClass, CurrencyPipe } from '@angular/common';
|
||||||
import { AccountNumberComponent } from '../../../shared/account-number/account-number.component';
|
import { AccountNumberComponent } from '../../../shared/account-number/account-number.component';
|
||||||
@@ -37,6 +38,7 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
|||||||
import { MatTabNav, MatTabLink, MatTabNavPanel } from '@angular/material/tabs';
|
import { MatTabNav, MatTabLink, MatTabNavPanel } from '@angular/material/tabs';
|
||||||
import { StatusLookupPipe } from '../../../pipes/status-lookup.pipe';
|
import { StatusLookupPipe } from '../../../pipes/status-lookup.pipe';
|
||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
import { DormancyAlertPanelComponent } from 'app/shared/dormancy-alert-panel/dormancy-alert-panel.component';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fixed Deposits Account View Component
|
* Fixed Deposits Account View Component
|
||||||
@@ -62,7 +64,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
|||||||
MatTabNavPanel,
|
MatTabNavPanel,
|
||||||
RouterOutlet,
|
RouterOutlet,
|
||||||
CurrencyPipe,
|
CurrencyPipe,
|
||||||
StatusLookupPipe
|
StatusLookupPipe,
|
||||||
|
DormancyAlertPanelComponent
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class FixedDepositAccountViewComponent implements OnInit {
|
export class FixedDepositAccountViewComponent implements OnInit {
|
||||||
@@ -73,6 +76,7 @@ export class FixedDepositAccountViewComponent implements OnInit {
|
|||||||
private accountsService = inject(AccountsService);
|
private accountsService = inject(AccountsService);
|
||||||
private settingsService = inject(SettingsService);
|
private settingsService = inject(SettingsService);
|
||||||
private dateUtils = inject(Dates);
|
private dateUtils = inject(Dates);
|
||||||
|
private authenticationService = inject(AuthenticationService);
|
||||||
dialog = inject(MatDialog);
|
dialog = inject(MatDialog);
|
||||||
|
|
||||||
/** Fixed Deposits Account Data */
|
/** Fixed Deposits Account Data */
|
||||||
@@ -194,6 +198,14 @@ export class FixedDepositAccountViewComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (!this.authenticationService.hasCashierRole()) {
|
||||||
|
this.buttonConfig.buttonsArray = this.buttonConfig.buttonsArray.filter(
|
||||||
|
(btn) => btn.name !== 'Deposit' && btn.name !== 'Withdrawal'
|
||||||
|
);
|
||||||
|
this.buttonConfig.optionArray = this.buttonConfig.optionArray.filter(
|
||||||
|
(opt) => opt.name !== 'Deposit' && opt.name !== 'Withdrawal'
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+3
@@ -7,6 +7,9 @@
|
|||||||
-->
|
-->
|
||||||
|
|
||||||
<div class="rd-view">
|
<div class="rd-view">
|
||||||
|
<!-- ─── Dormancy risk alert (only when subStatus has left Active) ──── -->
|
||||||
|
<mifosx-dormancy-alert-panel [accountData]="recurringDepositsAccountData"></mifosx-dormancy-alert-panel>
|
||||||
|
|
||||||
<!-- ─── Gradient hero header ─────────────────────────────────────── -->
|
<!-- ─── Gradient hero header ─────────────────────────────────────── -->
|
||||||
<header class="rd-hero">
|
<header class="rd-hero">
|
||||||
<div class="rd-hero__left">
|
<div class="rd-hero__left">
|
||||||
|
|||||||
+13
-1
@@ -27,6 +27,7 @@ import { TranslateService } from '@ngx-translate/core';
|
|||||||
import { AccountsService } from 'app/accounts/accounts.service';
|
import { AccountsService } from 'app/accounts/accounts.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 { AuthenticationService } from '../../../core/authentication/authentication.service';
|
||||||
import { MatTooltip } from '@angular/material/tooltip';
|
import { MatTooltip } from '@angular/material/tooltip';
|
||||||
import { NgClass, DecimalPipe, CurrencyPipe } from '@angular/common';
|
import { NgClass, DecimalPipe, CurrencyPipe } from '@angular/common';
|
||||||
import { AccountNumberComponent } from '../../../shared/account-number/account-number.component';
|
import { AccountNumberComponent } from '../../../shared/account-number/account-number.component';
|
||||||
@@ -37,6 +38,7 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
|||||||
import { MatTabNav, MatTabLink, MatTabNavPanel } from '@angular/material/tabs';
|
import { MatTabNav, MatTabLink, MatTabNavPanel } from '@angular/material/tabs';
|
||||||
import { StatusLookupPipe } from '../../../pipes/status-lookup.pipe';
|
import { StatusLookupPipe } from '../../../pipes/status-lookup.pipe';
|
||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
import { DormancyAlertPanelComponent } from 'app/shared/dormancy-alert-panel/dormancy-alert-panel.component';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RecurringDeposits Account View Component
|
* RecurringDeposits Account View Component
|
||||||
@@ -63,7 +65,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
|||||||
RouterOutlet,
|
RouterOutlet,
|
||||||
DecimalPipe,
|
DecimalPipe,
|
||||||
CurrencyPipe,
|
CurrencyPipe,
|
||||||
StatusLookupPipe
|
StatusLookupPipe,
|
||||||
|
DormancyAlertPanelComponent
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class RecurringDepositsAccountViewComponent implements OnInit {
|
export class RecurringDepositsAccountViewComponent implements OnInit {
|
||||||
@@ -74,6 +77,7 @@ export class RecurringDepositsAccountViewComponent implements OnInit {
|
|||||||
private accountsService = inject(AccountsService);
|
private accountsService = inject(AccountsService);
|
||||||
private settingsService = inject(SettingsService);
|
private settingsService = inject(SettingsService);
|
||||||
private dateUtils = inject(Dates);
|
private dateUtils = inject(Dates);
|
||||||
|
private authenticationService = inject(AuthenticationService);
|
||||||
dialog = inject(MatDialog);
|
dialog = inject(MatDialog);
|
||||||
private translateService = inject(TranslateService);
|
private translateService = inject(TranslateService);
|
||||||
|
|
||||||
@@ -245,6 +249,14 @@ export class RecurringDepositsAccountViewComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (!this.authenticationService.hasCashierRole()) {
|
||||||
|
this.buttonConfig.buttonsArray = this.buttonConfig.buttonsArray.filter(
|
||||||
|
(btn) => btn.name !== 'Deposit' && btn.name !== 'Withdrawal'
|
||||||
|
);
|
||||||
|
this.buttonConfig.optionArray = this.buttonConfig.optionArray.filter(
|
||||||
|
(opt) => opt.name !== 'Deposit' && opt.name !== 'Withdrawal'
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -129,9 +129,130 @@
|
|||||||
<!-- CTR -->
|
<!-- CTR -->
|
||||||
@if (activeTab === 'ctr') {
|
@if (activeTab === 'ctr') {
|
||||||
<div class="fiu-section">
|
<div class="fiu-section">
|
||||||
|
<!-- LIVE: cash-related AML alerts awaiting AML Officer review -->
|
||||||
<div class="fiu-form-meta">
|
<div class="fiu-form-meta">
|
||||||
<div class="fiu-form-title">
|
<div class="fiu-form-title">
|
||||||
<h3>CTR — Cash Transaction Report</h3>
|
<h3>Live Cash Alerts</h3>
|
||||||
|
<p>
|
||||||
|
Raised by the transaction-monitoring rule engine — review and whitelist or escalate to Principal Officer
|
||||||
|
</p>
|
||||||
|
</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 class="fiu-tbl-wrap">
|
||||||
|
<table class="fiu-tbl">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Client</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Rule</th>
|
||||||
|
<th class="ta-r">Amount</th>
|
||||||
|
<th>Raised On</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Notes</th>
|
||||||
|
<th>Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (alert of amlAlerts; track alert.id) {
|
||||||
|
<tr>
|
||||||
|
<td>{{ alert.id }}</td>
|
||||||
|
<td>{{ alert.clientDisplayName }}</td>
|
||||||
|
<td>{{ alert.transactionType }}</td>
|
||||||
|
<td>{{ alert.ruleCode }} — {{ alert.ruleDescription }}</td>
|
||||||
|
<td class="ta-r mono">₹{{ alert.amount | number: '1.0-0' }}</td>
|
||||||
|
<td>{{ alert.raisedOn | date: 'medium' }}</td>
|
||||||
|
<td>
|
||||||
|
<span [class]="amlStatusClass(alert.status)">{{ alert.status }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@if (alert.status === 'RAISED' || alert.status === 'UNDER_REVIEW') {
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="fiu-inp"
|
||||||
|
placeholder="Review notes"
|
||||||
|
[(ngModel)]="amlReviewNotes[alert.id]"
|
||||||
|
/>
|
||||||
|
} @else {
|
||||||
|
<span class="muted">{{ alert.reviewNotes }}</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@if (alert.status === 'RAISED' || alert.status === 'UNDER_REVIEW') {
|
||||||
|
<div class="fiu-action-group" *mifosxHasPermission="'REVIEW_AML_ALERT'">
|
||||||
|
<button class="fiu-btn fiu-btn-g" (click)="whitelistAlert(alert)">Whitelist</button>
|
||||||
|
<button class="fiu-btn fiu-btn-xml" (click)="escalateAlert(alert)">Escalate</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="9" class="fiu-empty">No live alerts.</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<!-- FINnet export template (separate concern — TC1/TC2 file-format generation) -->
|
||||||
|
<div class="fiu-form-meta">
|
||||||
|
<div class="fiu-form-title">
|
||||||
|
<h3>FINnet TC2 Export Template</h3>
|
||||||
<p>
|
<p>
|
||||||
All cash transactions ≥ ₹{{ bank.cashThreshold | number }} (single or aggregate per month per account)
|
All cash transactions ≥ ₹{{ bank.cashThreshold | number }} (single or aggregate per month per account)
|
||||||
</p>
|
</p>
|
||||||
@@ -221,9 +342,107 @@
|
|||||||
<!-- STR -->
|
<!-- STR -->
|
||||||
@if (activeTab === 'str') {
|
@if (activeTab === 'str') {
|
||||||
<div class="fiu-section">
|
<div class="fiu-section">
|
||||||
|
<!-- LIVE: STR cases with full Principal Officer actions -->
|
||||||
<div class="fiu-form-meta">
|
<div class="fiu-form-meta">
|
||||||
<div class="fiu-form-title">
|
<div class="fiu-form-title">
|
||||||
<h3>STR — Suspicious Transaction Report</h3>
|
<h3>Live STR Cases</h3>
|
||||||
|
<p>
|
||||||
|
Escalated by an AML Officer; Principal Officer investigates, concludes and files with FIU-IND within 7
|
||||||
|
working days
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="fiu-tbl-wrap">
|
||||||
|
<table class="fiu-tbl">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Client</th>
|
||||||
|
<th class="ta-c">Cumulative Cases</th>
|
||||||
|
<th>Assigned PO</th>
|
||||||
|
<th>Conclusion</th>
|
||||||
|
<th>FIU Due Date</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (sc of amlStrCases; track sc.id) {
|
||||||
|
<tr [class.selected-row]="selectedStrCase?.id === sc.id">
|
||||||
|
<td>{{ sc.id }}</td>
|
||||||
|
<td>{{ sc.clientDisplayName }}</td>
|
||||||
|
<td class="ta-c" [class.escalation-warning]="sc.cumulativeStrCaseCountForClient >= 3">
|
||||||
|
{{ sc.cumulativeStrCaseCountForClient }}
|
||||||
|
</td>
|
||||||
|
<td>{{ sc.assignedPoUsername || '—' }}</td>
|
||||||
|
<td>{{ sc.conclusion || '—' }}</td>
|
||||||
|
<td>{{ sc.fiuDueDate || '—' }}</td>
|
||||||
|
<td>
|
||||||
|
<span [class]="amlStatusClass(sc.status)">{{ sc.status }}</span>
|
||||||
|
</td>
|
||||||
|
<td><button class="fiu-btn fiu-btn-g" (click)="selectStrCase(sc)">Investigate</button></td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="8" class="fiu-empty">No STR cases yet.</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (selectedStrCase) {
|
||||||
|
<div class="str-case-detail">
|
||||||
|
<div class="fiu-form-meta">
|
||||||
|
<div class="fiu-form-title">
|
||||||
|
<h3>STR Case #{{ selectedStrCase.id }} — {{ selectedStrCase.clientDisplayName }}</h3>
|
||||||
|
<p>
|
||||||
|
Alert #{{ selectedStrCase.alertId }} ·
|
||||||
|
{{ selectedStrCase.cumulativeStrCaseCountForClient }} cumulative STR case(s) for this client
|
||||||
|
@if (selectedStrCase.cumulativeStrCaseCountForClient >= 3) {
|
||||||
|
<span class="escalation-badge">Risk category auto-escalated</span>
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button class="fiu-btn fiu-btn-g" (click)="closeStrCaseDetail()">Close</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="case-form" *mifosxHasPermission="'CONCLUDE_AML_STR'">
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="fiuInvestigationNotes">Investigation notes (source-of-transaction history reviewed)</label>
|
||||||
|
<textarea id="fiuInvestigationNotes" rows="3" [(ngModel)]="concludeInvestigationNotes"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="fiuSourceOfFundsNotes">Source-of-funds notes</label>
|
||||||
|
<textarea id="fiuSourceOfFundsNotes" rows="2" [(ngModel)]="concludeSourceOfFundsNotes"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-row conclusion-row">
|
||||||
|
<label>
|
||||||
|
<input type="radio" name="fiuConclusion" value="NOT_SUSPICIOUS" [(ngModel)]="concludeConclusion" />
|
||||||
|
Not suspicious (whitelist)
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input type="radio" name="fiuConclusion" value="SUSPICIOUS" [(ngModel)]="concludeConclusion" />
|
||||||
|
Suspicious (file STR)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button class="fiu-btn fiu-btn-xml" (click)="concludeStrCase()">Conclude</button>
|
||||||
|
|
||||||
|
@if (selectedStrCase.status === 'CONCLUDED_SUSPICIOUS') {
|
||||||
|
<div class="file-str-row">
|
||||||
|
<label for="fiuFileReferenceNo">FIU-IND acknowledgement / reference number</label>
|
||||||
|
<input id="fiuFileReferenceNo" class="fiu-inp" type="text" [(ngModel)]="fileReferenceNo" />
|
||||||
|
<button class="fiu-btn fiu-btn-xml" (click)="fileStrCase()">Mark STR Filed</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- FINnet export template (separate concern — STR XML file-format generation) -->
|
||||||
|
<div class="fiu-form-meta">
|
||||||
|
<div class="fiu-form-title">
|
||||||
|
<h3>FINnet STR XML Export Template</h3>
|
||||||
<p>Detected by AML rules engine + branch compliance officers; filed within 7 days</p>
|
<p>Detected by AML rules engine + branch compliance officers; filed within 7 days</p>
|
||||||
</div>
|
</div>
|
||||||
<button class="fiu-btn fiu-btn-xml" (click)="exportStrXml()">
|
<button class="fiu-btn fiu-btn-xml" (click)="exportStrXml()">
|
||||||
|
|||||||
@@ -579,6 +579,41 @@
|
|||||||
background: var(--f-red-l);
|
background: var(--f-red-l);
|
||||||
color: var(--f-red);
|
color: var(--f-red);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.status-raised {
|
||||||
|
background: var(--f-amber-l);
|
||||||
|
color: var(--f-amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.status-under-review {
|
||||||
|
background: var(--f-blue-l);
|
||||||
|
color: var(--f-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.status-whitelisted,
|
||||||
|
&.status-concluded-clear,
|
||||||
|
&.status-generated,
|
||||||
|
&.status-submitted {
|
||||||
|
background: var(--f-green-l);
|
||||||
|
color: var(--f-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.status-escalated,
|
||||||
|
&.status-open,
|
||||||
|
&.status-concluded-suspicious {
|
||||||
|
background: var(--f-red-l);
|
||||||
|
color: var(--f-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.status-filed {
|
||||||
|
background: var(--f-purple-l);
|
||||||
|
color: var(--f-purple);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.status-draft {
|
||||||
|
background: var(--f-g100);
|
||||||
|
color: var(--f-g500);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.risk-pill {
|
.risk-pill {
|
||||||
@@ -892,3 +927,85 @@
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.selected-row {
|
||||||
|
background: var(--f-blue-l);
|
||||||
|
}
|
||||||
|
|
||||||
|
.escalation-warning {
|
||||||
|
color: var(--f-red);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.escalation-badge {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 8px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--f-amber-l);
|
||||||
|
color: var(--f-amber);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.str-case-detail {
|
||||||
|
border: 1px solid var(--f-g200);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 16px;
|
||||||
|
background: #fff;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--f-g300);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--f-g500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.conclusion-row {
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 24px;
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-str-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px dashed var(--f-g300);
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--f-g500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,10 +6,12 @@
|
|||||||
* 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 } 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 { AmlService } from 'app/aml/aml.service';
|
||||||
|
import { AuthenticationService } from 'app/core/authentication/authentication.service';
|
||||||
import {
|
import {
|
||||||
CtrTransaction,
|
CtrTransaction,
|
||||||
CTR_TXNS,
|
CTR_TXNS,
|
||||||
@@ -33,7 +35,9 @@ import {
|
|||||||
FormsModule
|
FormsModule
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class FiuComponent {
|
export class FiuComponent implements OnInit {
|
||||||
|
private amlService = inject(AmlService);
|
||||||
|
private authenticationService = inject(AuthenticationService);
|
||||||
/** Active tab */
|
/** Active tab */
|
||||||
activeTab: 'dashboard' | 'ctr' | 'str' | 'customers' | 'files' = 'dashboard';
|
activeTab: 'dashboard' | 'ctr' | 'str' | 'customers' | 'files' = 'dashboard';
|
||||||
|
|
||||||
@@ -57,6 +61,126 @@ export class FiuComponent {
|
|||||||
toastVisible = false;
|
toastVisible = false;
|
||||||
private toastTimer: any;
|
private toastTimer: any;
|
||||||
|
|
||||||
|
// -------------------- Live AML monitoring (real backend, replaces mock CTR/STR tables) --------------------
|
||||||
|
amlAlerts: any[] = [];
|
||||||
|
amlAlertStatusFilter = '';
|
||||||
|
amlReviewNotes: Record<number, string> = {};
|
||||||
|
|
||||||
|
amlCtrReports: any[] = [];
|
||||||
|
ctrGenerateMonth = '';
|
||||||
|
|
||||||
|
amlStrCases: any[] = [];
|
||||||
|
selectedStrCase: any = null;
|
||||||
|
concludeConclusion: 'NOT_SUSPICIOUS' | 'SUSPICIOUS' = 'NOT_SUSPICIOUS';
|
||||||
|
concludeInvestigationNotes = '';
|
||||||
|
concludeSourceOfFundsNotes = '';
|
||||||
|
fileReferenceNo = '';
|
||||||
|
|
||||||
|
get isPrincipalOfficer(): boolean {
|
||||||
|
return this.authenticationService.isPrincipalOfficerUser();
|
||||||
|
}
|
||||||
|
|
||||||
|
get isAmlOfficer(): boolean {
|
||||||
|
return this.authenticationService.isAmlOfficerUser();
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.loadAmlAlerts();
|
||||||
|
this.loadAmlCtrReports();
|
||||||
|
this.loadAmlStrCases();
|
||||||
|
}
|
||||||
|
|
||||||
|
loadAmlAlerts(): void {
|
||||||
|
this.amlService
|
||||||
|
.getAlerts(this.amlAlertStatusFilter || undefined)
|
||||||
|
.subscribe((data) => (this.amlAlerts = data || []));
|
||||||
|
}
|
||||||
|
|
||||||
|
loadAmlCtrReports(): void {
|
||||||
|
this.amlService.getCtrReports().subscribe((data) => (this.amlCtrReports = data || []));
|
||||||
|
}
|
||||||
|
|
||||||
|
loadAmlStrCases(): void {
|
||||||
|
this.amlService.getStrCases().subscribe((data) => (this.amlStrCases = data || []));
|
||||||
|
}
|
||||||
|
|
||||||
|
whitelistAlert(alert: any): void {
|
||||||
|
this.amlService.reviewAlert(alert.id, 'WHITELIST', this.amlReviewNotes[alert.id] || '').subscribe(() => {
|
||||||
|
this.toast(`Alert #${alert.id} whitelisted`);
|
||||||
|
this.loadAmlAlerts();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
escalateAlert(alert: any): void {
|
||||||
|
this.amlService.reviewAlert(alert.id, 'ESCALATE', this.amlReviewNotes[alert.id] || '').subscribe(() => {
|
||||||
|
this.toast(`Alert #${alert.id} escalated to Principal Officer`);
|
||||||
|
this.loadAmlAlerts();
|
||||||
|
this.loadAmlStrCases();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
generateCtrReport(): void {
|
||||||
|
if (!this.ctrGenerateMonth) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.amlService.generateCtrReport(this.ctrGenerateMonth).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.toast(`CTR report generated for ${this.ctrGenerateMonth}`);
|
||||||
|
this.loadAmlCtrReports();
|
||||||
|
},
|
||||||
|
error: (err) => this.toast(err?.error?.defaultUserMessage || 'CTR report generation failed')
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
selectStrCase(strCase: any): void {
|
||||||
|
this.selectedStrCase = strCase;
|
||||||
|
this.concludeConclusion = 'NOT_SUSPICIOUS';
|
||||||
|
this.concludeInvestigationNotes = strCase.investigationNotes || '';
|
||||||
|
this.concludeSourceOfFundsNotes = strCase.sourceOfFundsNotes || '';
|
||||||
|
this.fileReferenceNo = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
closeStrCaseDetail(): void {
|
||||||
|
this.selectedStrCase = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
concludeStrCase(): void {
|
||||||
|
if (!this.selectedStrCase) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.amlService
|
||||||
|
.concludeStrCase(
|
||||||
|
this.selectedStrCase.id,
|
||||||
|
this.concludeConclusion,
|
||||||
|
this.concludeInvestigationNotes,
|
||||||
|
this.concludeSourceOfFundsNotes
|
||||||
|
)
|
||||||
|
.subscribe((updated) => {
|
||||||
|
this.toast(
|
||||||
|
this.concludeConclusion === 'SUSPICIOUS'
|
||||||
|
? `STR case #${updated.id} concluded suspicious — FIU due date ${updated.fiuDueDate}`
|
||||||
|
: `STR case #${updated.id} concluded not suspicious`
|
||||||
|
);
|
||||||
|
this.selectedStrCase = updated;
|
||||||
|
this.loadAmlStrCases();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fileStrCase(): void {
|
||||||
|
if (!this.selectedStrCase || !this.fileReferenceNo) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.amlService.fileStr(this.selectedStrCase.id, this.fileReferenceNo).subscribe((updated) => {
|
||||||
|
this.toast(`STR case #${updated.id} recorded as filed (ref ${updated.strReferenceNo})`);
|
||||||
|
this.selectedStrCase = updated;
|
||||||
|
this.loadAmlStrCases();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
amlStatusClass(status: string): string {
|
||||||
|
return 'status-pill status-' + (status || '').toLowerCase().replace(/_/g, '-');
|
||||||
|
}
|
||||||
|
|
||||||
showTab(tab: 'dashboard' | 'ctr' | 'str' | 'customers' | 'files'): void {
|
showTab(tab: 'dashboard' | 'ctr' | 'str' | 'customers' | 'files'): void {
|
||||||
this.activeTab = tab;
|
this.activeTab = tab;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,723 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data layer for the General Ledger report suite.
|
||||||
|
*
|
||||||
|
* Rather than hard-coding seven finished reports, this file holds a chart of
|
||||||
|
* accounts plus a generated ledger of double-entry postings. Every report is
|
||||||
|
* then computed from those postings, which is what makes the day / month /
|
||||||
|
* branch filters genuinely recompute instead of just re-labelling a static table.
|
||||||
|
*
|
||||||
|
* The generator is deterministic — a fixed-seed LCG, never Math.random — so the
|
||||||
|
* figures are identical on every reload and across users. Amounts are in rupees.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type GlAccountType = 'ASSET' | 'LIABILITY' | 'INCOME' | 'EXPENSE' | 'CONTINGENT';
|
||||||
|
|
||||||
|
export interface GlAccount {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
type: GlAccountType;
|
||||||
|
/** Balance-sheet / P&L grouping used as a sub-heading in the reports. */
|
||||||
|
group: string;
|
||||||
|
/** Included in the Misc Expense Ledger report. */
|
||||||
|
miscExpense?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GlBranch {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One side of a double-entry voucher. */
|
||||||
|
export interface GlPosting {
|
||||||
|
/** ISO yyyy-MM-dd — the posting (value) date. */
|
||||||
|
date: string;
|
||||||
|
branchCode: string;
|
||||||
|
accountCode: string;
|
||||||
|
debit: number;
|
||||||
|
credit: number;
|
||||||
|
voucherNo: string;
|
||||||
|
narration: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GL_BRANCHES: GlBranch[] = [
|
||||||
|
{ code: 'HO', name: 'Head Office' },
|
||||||
|
{ code: '0002', name: 'Andheri (E)' },
|
||||||
|
{ code: '0003', name: 'Bandra (W)' },
|
||||||
|
{ code: '0004', name: 'Borivali' },
|
||||||
|
{ code: '0005', name: 'Dadar' },
|
||||||
|
{ code: '0006', name: 'Thane' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const GL_ACCOUNTS: GlAccount[] = [
|
||||||
|
// ── Assets ────────────────────────────────────────────────────────────────
|
||||||
|
{ code: '110101', name: 'Cash in Hand', type: 'ASSET', group: 'Cash & Bank Balances' },
|
||||||
|
{ code: '110102', name: 'Balance with RBI', type: 'ASSET', group: 'Cash & Bank Balances' },
|
||||||
|
{ code: '110103', name: 'Balance with Other Banks — Current', type: 'ASSET', group: 'Cash & Bank Balances' },
|
||||||
|
{ code: '110104', name: 'Balance with Other Banks — Fixed', type: 'ASSET', group: 'Cash & Bank Balances' },
|
||||||
|
{ code: '120101', name: 'SLR Investments — Govt Securities', type: 'ASSET', group: 'Investments' },
|
||||||
|
{ code: '120102', name: 'SLR Investments — State Govt Securities', type: 'ASSET', group: 'Investments' },
|
||||||
|
{ code: '120201', name: 'Non-SLR Investments — Bonds', type: 'ASSET', group: 'Investments' },
|
||||||
|
{ code: '120202', name: 'Non-SLR Investments — Mutual Funds', type: 'ASSET', group: 'Investments' },
|
||||||
|
{ code: '130101', name: 'Cash Credit & Overdrafts', type: 'ASSET', group: 'Loans & Advances' },
|
||||||
|
{ code: '130102', name: 'Term Loans — Short Term', type: 'ASSET', group: 'Loans & Advances' },
|
||||||
|
{ code: '130103', name: 'Term Loans — Medium Term', type: 'ASSET', group: 'Loans & Advances' },
|
||||||
|
{ code: '130104', name: 'Term Loans — Long Term', type: 'ASSET', group: 'Loans & Advances' },
|
||||||
|
{ code: '130105', name: 'Housing Loans', type: 'ASSET', group: 'Loans & Advances' },
|
||||||
|
{ code: '130106', name: 'Gold Loans', type: 'ASSET', group: 'Loans & Advances' },
|
||||||
|
{ code: '140101', name: 'Interest Receivable', type: 'ASSET', group: 'Other Assets' },
|
||||||
|
{ code: '140102', name: 'Premises (net of depreciation)', type: 'ASSET', group: 'Fixed Assets' },
|
||||||
|
{ code: '140103', name: 'Furniture & Fixtures (net)', type: 'ASSET', group: 'Fixed Assets' },
|
||||||
|
{ code: '140104', name: 'Sundry Debtors', type: 'ASSET', group: 'Other Assets' },
|
||||||
|
{ code: '140105', name: 'Advance Tax & TDS', type: 'ASSET', group: 'Other Assets' },
|
||||||
|
{ code: '140199', name: 'Opening Balance Suspense (unreconciled)', type: 'ASSET', group: 'Other Assets' },
|
||||||
|
|
||||||
|
// ── Liabilities & Capital ─────────────────────────────────────────────────
|
||||||
|
{ code: '210101', name: 'Share Capital — Individuals', type: 'LIABILITY', group: 'Capital & Reserves' },
|
||||||
|
{ code: '210201', name: 'Statutory Reserve Fund', type: 'LIABILITY', group: 'Capital & Reserves' },
|
||||||
|
{ code: '210202', name: 'Building Fund', type: 'LIABILITY', group: 'Capital & Reserves' },
|
||||||
|
{ code: '210203', name: 'Investment Depreciation Reserve', type: 'LIABILITY', group: 'Capital & Reserves' },
|
||||||
|
{ code: '210204', name: 'BDDR / Special BDDR', type: 'LIABILITY', group: 'Capital & Reserves' },
|
||||||
|
{ code: '220101', name: 'Savings Bank Deposits', type: 'LIABILITY', group: 'Deposits' },
|
||||||
|
{ code: '220102', name: 'Current Deposits', type: 'LIABILITY', group: 'Deposits' },
|
||||||
|
{ code: '220103', name: 'Term Deposits', type: 'LIABILITY', group: 'Deposits' },
|
||||||
|
{ code: '220104', name: 'Recurring Deposits', type: 'LIABILITY', group: 'Deposits' },
|
||||||
|
{ code: '230101', name: 'Interest Payable on Deposits', type: 'LIABILITY', group: 'Other Liabilities' },
|
||||||
|
{ code: '230102', name: 'Bills Payable', type: 'LIABILITY', group: 'Other Liabilities' },
|
||||||
|
{ code: '230103', name: 'Sundry Creditors', type: 'LIABILITY', group: 'Other Liabilities' },
|
||||||
|
{ code: '230104', name: 'TDS / GST Payable', type: 'LIABILITY', group: 'Other Liabilities' },
|
||||||
|
{ code: '230105', name: 'Overdue Interest Reserve', type: 'LIABILITY', group: 'Other Liabilities' },
|
||||||
|
|
||||||
|
// ── Income ────────────────────────────────────────────────────────────────
|
||||||
|
{ code: '310101', name: 'Interest on Loans & Advances', type: 'INCOME', group: 'Interest Income' },
|
||||||
|
{ code: '310102', name: 'Interest on Investments', type: 'INCOME', group: 'Interest Income' },
|
||||||
|
{ code: '310103', name: 'Interest on Inter-bank Deposits', type: 'INCOME', group: 'Interest Income' },
|
||||||
|
{ code: '320101', name: 'Commission & Exchange', type: 'INCOME', group: 'Other Income' },
|
||||||
|
{ code: '320102', name: 'Locker Rent', type: 'INCOME', group: 'Other Income' },
|
||||||
|
{ code: '320103', name: 'Processing Fees', type: 'INCOME', group: 'Other Income' },
|
||||||
|
{ code: '320104', name: 'Profit on Sale of Securities', type: 'INCOME', group: 'Other Income' },
|
||||||
|
{ code: '320105', name: 'Miscellaneous Income', type: 'INCOME', group: 'Other Income' },
|
||||||
|
|
||||||
|
// ── Expenditure ───────────────────────────────────────────────────────────
|
||||||
|
{ code: '410101', name: 'Interest on Savings Deposits', type: 'EXPENSE', group: 'Interest Expended' },
|
||||||
|
{ code: '410102', name: 'Interest on Term Deposits', type: 'EXPENSE', group: 'Interest Expended' },
|
||||||
|
{ code: '410103', name: 'Interest on Borrowings', type: 'EXPENSE', group: 'Interest Expended' },
|
||||||
|
{ code: '420101', name: 'Salaries & Allowances', type: 'EXPENSE', group: 'Operating Expenses' },
|
||||||
|
{ code: '420102', name: "Directors' Fees", type: 'EXPENSE', group: 'Operating Expenses' },
|
||||||
|
{ code: '420103', name: "Auditors' Fees", type: 'EXPENSE', group: 'Operating Expenses' },
|
||||||
|
{ code: '420104', name: 'Rent, Taxes & Lighting', type: 'EXPENSE', group: 'Operating Expenses' },
|
||||||
|
{ code: '420105', name: 'Depreciation on Bank Property', type: 'EXPENSE', group: 'Operating Expenses' },
|
||||||
|
{
|
||||||
|
code: '430101',
|
||||||
|
name: 'Printing & Stationery',
|
||||||
|
type: 'EXPENSE',
|
||||||
|
group: 'Miscellaneous Expenses',
|
||||||
|
miscExpense: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: '430102',
|
||||||
|
name: 'Postage, Telegram & Telephone',
|
||||||
|
type: 'EXPENSE',
|
||||||
|
group: 'Miscellaneous Expenses',
|
||||||
|
miscExpense: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: '430103',
|
||||||
|
name: 'Advertisement & Publicity',
|
||||||
|
type: 'EXPENSE',
|
||||||
|
group: 'Miscellaneous Expenses',
|
||||||
|
miscExpense: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: '430104',
|
||||||
|
name: 'Repairs & Maintenance',
|
||||||
|
type: 'EXPENSE',
|
||||||
|
group: 'Miscellaneous Expenses',
|
||||||
|
miscExpense: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: '430105',
|
||||||
|
name: 'Travelling & Conveyance',
|
||||||
|
type: 'EXPENSE',
|
||||||
|
group: 'Miscellaneous Expenses',
|
||||||
|
miscExpense: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: '430106',
|
||||||
|
name: 'Computer & Software Expenses',
|
||||||
|
type: 'EXPENSE',
|
||||||
|
group: 'Miscellaneous Expenses',
|
||||||
|
miscExpense: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: '430107',
|
||||||
|
name: 'Legal & Professional Charges',
|
||||||
|
type: 'EXPENSE',
|
||||||
|
group: 'Miscellaneous Expenses',
|
||||||
|
miscExpense: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: '430108',
|
||||||
|
name: 'Insurance (DICGC & Other)',
|
||||||
|
type: 'EXPENSE',
|
||||||
|
group: 'Miscellaneous Expenses',
|
||||||
|
miscExpense: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: '430109',
|
||||||
|
name: 'Bank Charges & Commission Paid',
|
||||||
|
type: 'EXPENSE',
|
||||||
|
group: 'Miscellaneous Expenses',
|
||||||
|
miscExpense: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: '430110',
|
||||||
|
name: 'AGM & Meeting Expenses',
|
||||||
|
type: 'EXPENSE',
|
||||||
|
group: 'Miscellaneous Expenses',
|
||||||
|
miscExpense: true
|
||||||
|
},
|
||||||
|
{ code: '430111', name: 'Electricity Charges', type: 'EXPENSE', group: 'Miscellaneous Expenses', miscExpense: true },
|
||||||
|
{
|
||||||
|
code: '430112',
|
||||||
|
name: 'Other Miscellaneous Expenses',
|
||||||
|
type: 'EXPENSE',
|
||||||
|
group: 'Miscellaneous Expenses',
|
||||||
|
miscExpense: true
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Contingent (memoranda, not part of the balance sheet totals) ──────────
|
||||||
|
{ code: '910101', name: 'Guarantees Issued — Financial', type: 'CONTINGENT', group: 'Guarantees' },
|
||||||
|
{ code: '910102', name: 'Guarantees Issued — Performance', type: 'CONTINGENT', group: 'Guarantees' },
|
||||||
|
{ code: '910103', name: 'Letters of Credit Outstanding', type: 'CONTINGENT', group: 'Letters of Credit' },
|
||||||
|
{ code: '910104', name: 'Bills for Collection', type: 'CONTINGENT', group: 'Bills & Claims' },
|
||||||
|
{ code: '910105', name: 'Claims Against Bank Not Acknowledged as Debt', type: 'CONTINGENT', group: 'Bills & Claims' },
|
||||||
|
{ code: '910106', name: 'Forward Exchange Contracts', type: 'CONTINGENT', group: 'Other Commitments' },
|
||||||
|
{ code: '910107', name: 'Capital Commitments Not Provided For', type: 'CONTINGENT', group: 'Other Commitments' }
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Financial year covered by the generated ledger. */
|
||||||
|
export const GL_PERIOD = {
|
||||||
|
/** FY 2026-27 opens here. */
|
||||||
|
fyStart: '2026-04-01',
|
||||||
|
/** Postings run to the day before the app's "today" (01 Aug 2026). */
|
||||||
|
fyEnd: '2026-07-31',
|
||||||
|
label: 'FY 2026-27'
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opening balances as at 01-Apr-2026, keyed by account code. Positive means the
|
||||||
|
* account opens on its natural side (debit for assets/expenses, credit for the
|
||||||
|
* rest). Head Office carries the investments and reserves; branches carry the
|
||||||
|
* customer-facing book.
|
||||||
|
*/
|
||||||
|
const OPENING_BALANCES: Record<string, number> = {
|
||||||
|
'110101': 9_996_000,
|
||||||
|
'110102': 12_400_000,
|
||||||
|
'110103': 13_667_000,
|
||||||
|
// Surplus deposits parked with correspondent banks. Sized so the opening
|
||||||
|
// balance sheet tallies exactly — see the assertion in glPostings().
|
||||||
|
'110104': 54_206_000,
|
||||||
|
'120101': 63_839_000,
|
||||||
|
'120102': 147_895_000,
|
||||||
|
'120201': 22_500_000,
|
||||||
|
'120202': 24_234_000,
|
||||||
|
'130101': 166_614_000,
|
||||||
|
'130102': 125_082_000,
|
||||||
|
'130103': 70_236_000,
|
||||||
|
'130104': 29_356_000,
|
||||||
|
'130105': 10_241_000,
|
||||||
|
'130106': 8_450_000,
|
||||||
|
'140101': 33_541_000,
|
||||||
|
'140102': 3_185_000,
|
||||||
|
'140103': 1_763_000,
|
||||||
|
'140104': 1_240_000,
|
||||||
|
'140105': 657_000,
|
||||||
|
'210101': 27_278_000,
|
||||||
|
'210201': 31_149_000,
|
||||||
|
'210202': 13_642_000,
|
||||||
|
'210203': 3_750_000,
|
||||||
|
'210204': 24_341_000,
|
||||||
|
'220101': 208_116_000,
|
||||||
|
'220102': 19_592_000,
|
||||||
|
'220103': 406_601_000,
|
||||||
|
'220104': 24_880_000,
|
||||||
|
'230101': 731_000,
|
||||||
|
'230102': 291_000,
|
||||||
|
'230103': 5_515_000,
|
||||||
|
'230104': 1_120_000,
|
||||||
|
'230105': 32_096_000,
|
||||||
|
'910101': 1_450_000,
|
||||||
|
'910102': 980_000,
|
||||||
|
'910103': 620_000,
|
||||||
|
'910104': 1_180_000,
|
||||||
|
'910105': 2_638_000,
|
||||||
|
'910106': 0,
|
||||||
|
'910107': 340_000
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Deterministic linear congruential generator — same figures on every load. */
|
||||||
|
function lcg(seed: number): () => number {
|
||||||
|
let s = seed >>> 0;
|
||||||
|
return () => {
|
||||||
|
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
|
||||||
|
return s / 4294967296;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isoDate(d: Date): string {
|
||||||
|
const y = d.getUTCFullYear();
|
||||||
|
const m = `${d.getUTCMonth() + 1}`.padStart(2, '0');
|
||||||
|
const day = `${d.getUTCDate()}`.padStart(2, '0');
|
||||||
|
return `${y}-${m}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every posting date in the ledger period, Sundays excluded (bank holiday). */
|
||||||
|
export function ledgerDates(): string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
const cur = new Date(`${GL_PERIOD.fyStart}T00:00:00Z`);
|
||||||
|
const end = new Date(`${GL_PERIOD.fyEnd}T00:00:00Z`);
|
||||||
|
while (cur <= end) {
|
||||||
|
if (cur.getUTCDay() !== 0) {
|
||||||
|
out.push(isoDate(cur));
|
||||||
|
}
|
||||||
|
cur.setUTCDate(cur.getUTCDate() + 1);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Templates for the vouchers the generator posts each day. */
|
||||||
|
const VOUCHER_TEMPLATES: {
|
||||||
|
debit: string;
|
||||||
|
credit: string;
|
||||||
|
narration: string;
|
||||||
|
/** Rough rupee scale of the voucher. */
|
||||||
|
scale: number;
|
||||||
|
/** Chance the voucher is raised on any given branch-day. */
|
||||||
|
frequency: number;
|
||||||
|
}[] = [
|
||||||
|
{ debit: '110101', credit: '220101', narration: 'Cash deposit — savings accounts', scale: 900_000, frequency: 1 },
|
||||||
|
{ debit: '220101', credit: '110101', narration: 'Cash withdrawal — savings accounts', scale: 780_000, frequency: 1 },
|
||||||
|
{ debit: '110101', credit: '220103', narration: 'Term deposit received', scale: 1_400_000, frequency: 0.75 },
|
||||||
|
{ debit: '220103', credit: '110101', narration: 'Term deposit matured and paid', scale: 1_150_000, frequency: 0.6 },
|
||||||
|
{ debit: '110101', credit: '220102', narration: 'Current account deposit', scale: 620_000, frequency: 0.8 },
|
||||||
|
{ debit: '220102', credit: '110101', narration: 'Current account withdrawal', scale: 560_000, frequency: 0.8 },
|
||||||
|
{ debit: '110101', credit: '220104', narration: 'Recurring deposit instalment', scale: 180_000, frequency: 0.7 },
|
||||||
|
{ debit: '130101', credit: '110101', narration: 'Cash credit disbursed', scale: 1_250_000, frequency: 0.55 },
|
||||||
|
{ debit: '110101', credit: '130101', narration: 'Cash credit repayment', scale: 1_100_000, frequency: 0.6 },
|
||||||
|
{ debit: '130102', credit: '110101', narration: 'Short term loan disbursed', scale: 850_000, frequency: 0.4 },
|
||||||
|
{ debit: '110101', credit: '130102', narration: 'Short term loan repayment', scale: 760_000, frequency: 0.5 },
|
||||||
|
{ debit: '130103', credit: '110101', narration: 'Medium term loan disbursed', scale: 640_000, frequency: 0.3 },
|
||||||
|
{ debit: '110101', credit: '130104', narration: 'Long term loan repayment', scale: 410_000, frequency: 0.35 },
|
||||||
|
{ debit: '130105', credit: '110101', narration: 'Housing loan disbursed', scale: 520_000, frequency: 0.2 },
|
||||||
|
{ debit: '130106', credit: '110101', narration: 'Gold loan disbursed', scale: 260_000, frequency: 0.4 },
|
||||||
|
// Interest on advances is the dominant earning line — sized so the bank runs a
|
||||||
|
// ~13% net margin rather than the loss the first-cut figures produced.
|
||||||
|
{ debit: '110101', credit: '310101', narration: 'Interest recovered on advances', scale: 430_000, frequency: 1 },
|
||||||
|
{ debit: '140101', credit: '310102', narration: 'Interest accrued on investments', scale: 148_000, frequency: 0.5 },
|
||||||
|
{ debit: '110103', credit: '310103', narration: 'Interest on inter-bank deposits', scale: 42_000, frequency: 0.25 },
|
||||||
|
{ debit: '110101', credit: '320101', narration: 'Commission and exchange collected', scale: 26_000, frequency: 0.9 },
|
||||||
|
{ debit: '110101', credit: '320102', narration: 'Locker rent collected', scale: 9_000, frequency: 0.3 },
|
||||||
|
{ debit: '110101', credit: '320103', narration: 'Loan processing fees', scale: 18_000, frequency: 0.5 },
|
||||||
|
{ debit: '110101', credit: '320105', narration: 'Miscellaneous income', scale: 7_500, frequency: 0.4 },
|
||||||
|
{
|
||||||
|
debit: '410101',
|
||||||
|
credit: '230101',
|
||||||
|
narration: 'Interest provided on savings deposits',
|
||||||
|
scale: 128_000,
|
||||||
|
frequency: 0.8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
debit: '410102',
|
||||||
|
credit: '230101',
|
||||||
|
narration: 'Interest provided on term deposits',
|
||||||
|
scale: 265_000,
|
||||||
|
frequency: 0.9
|
||||||
|
},
|
||||||
|
{ debit: '420101', credit: '110101', narration: 'Salaries and allowances paid', scale: 210_000, frequency: 0.35 },
|
||||||
|
{ debit: '420104', credit: '110101', narration: 'Rent, taxes and lighting', scale: 46_000, frequency: 0.3 },
|
||||||
|
{ debit: '430101', credit: '110101', narration: 'Printing and stationery purchased', scale: 9_400, frequency: 0.3 },
|
||||||
|
{ debit: '430102', credit: '110101', narration: 'Postage and telephone charges', scale: 6_200, frequency: 0.35 },
|
||||||
|
{ debit: '430103', credit: '110101', narration: 'Advertisement and publicity', scale: 12_500, frequency: 0.15 },
|
||||||
|
{ debit: '430104', credit: '110101', narration: 'Repairs and maintenance', scale: 15_800, frequency: 0.25 },
|
||||||
|
{ debit: '430105', credit: '110101', narration: 'Travelling and conveyance', scale: 7_100, frequency: 0.3 },
|
||||||
|
{ debit: '430106', credit: '110101', narration: 'Computer and software expenses', scale: 22_400, frequency: 0.2 },
|
||||||
|
{ debit: '430107', credit: '110101', narration: 'Legal and professional charges', scale: 11_300, frequency: 0.15 },
|
||||||
|
{ debit: '430109', credit: '110101', narration: 'Bank charges and commission paid', scale: 4_800, frequency: 0.3 },
|
||||||
|
{ debit: '430111', credit: '110101', narration: 'Electricity charges', scale: 13_600, frequency: 0.25 },
|
||||||
|
{ debit: '430112', credit: '110101', narration: 'Other miscellaneous expenses', scale: 8_900, frequency: 0.3 },
|
||||||
|
{
|
||||||
|
debit: '110102',
|
||||||
|
credit: '110101',
|
||||||
|
narration: 'Cash remitted to RBI currency chest',
|
||||||
|
scale: 1_800_000,
|
||||||
|
frequency: 0.2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
debit: '110101',
|
||||||
|
credit: '110103',
|
||||||
|
narration: 'Funds drawn from correspondent bank',
|
||||||
|
scale: 1_400_000,
|
||||||
|
frequency: 0.2
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Head-Office-only vouchers — investments, reserves and centralised charges. */
|
||||||
|
const HO_TEMPLATES: typeof VOUCHER_TEMPLATES = [
|
||||||
|
{ debit: '120101', credit: '110102', narration: 'Purchase of Govt securities', scale: 2_400_000, frequency: 0.12 },
|
||||||
|
{
|
||||||
|
debit: '120102',
|
||||||
|
credit: '110102',
|
||||||
|
narration: 'Purchase of State Govt securities',
|
||||||
|
scale: 1_900_000,
|
||||||
|
frequency: 0.12
|
||||||
|
},
|
||||||
|
{ debit: '110102', credit: '320104', narration: 'Profit on sale of securities', scale: 78_000, frequency: 0.1 },
|
||||||
|
{ debit: '420102', credit: '110101', narration: "Directors' sitting fees", scale: 32_000, frequency: 0.08 },
|
||||||
|
{ debit: '420103', credit: '110101', narration: "Statutory auditors' fees", scale: 45_000, frequency: 0.06 },
|
||||||
|
{ debit: '420105', credit: '140103', narration: 'Depreciation charged on assets', scale: 64_000, frequency: 0.12 },
|
||||||
|
{ debit: '430108', credit: '110101', narration: 'DICGC premium and other insurance', scale: 38_000, frequency: 0.1 },
|
||||||
|
{ debit: '430110', credit: '110101', narration: 'AGM and meeting expenses', scale: 26_000, frequency: 0.06 },
|
||||||
|
{ debit: '410103', credit: '230101', narration: 'Interest on borrowings', scale: 54_000, frequency: 0.1 },
|
||||||
|
{ debit: '230104', credit: '110102', narration: 'TDS and GST remitted', scale: 145_000, frequency: 0.15 }
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contingent-account movements are memoranda, each posted single-sided.
|
||||||
|
* Guarantees, LCs and collection bills are raised at the counter, so these run
|
||||||
|
* at every branch — not just Head Office.
|
||||||
|
*/
|
||||||
|
const CONTINGENT_TEMPLATES: { account: string; narration: string; scale: number; frequency: number }[] = [
|
||||||
|
{ account: '910101', narration: 'Financial guarantee issued', scale: 320_000, frequency: 0.1 },
|
||||||
|
{ account: '910102', narration: 'Performance guarantee issued', scale: 240_000, frequency: 0.08 },
|
||||||
|
{ account: '910103', narration: 'Letter of credit opened', scale: 180_000, frequency: 0.07 },
|
||||||
|
{ account: '910104', narration: 'Bills lodged for collection', scale: 95_000, frequency: 0.12 },
|
||||||
|
{ account: '910105', narration: 'Claim lodged against the bank', scale: 140_000, frequency: 0.03 },
|
||||||
|
{ account: '910107', narration: 'Capital commitment contracted', scale: 85_000, frequency: 0.04 }
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How the opening contingent book is spread across the network. Head Office
|
||||||
|
* carries the capital commitments and large claims; the branches carry the
|
||||||
|
* counter-issued guarantees. Weights are applied per account and the rounding
|
||||||
|
* remainder is absorbed by Head Office so each account's total stays exact.
|
||||||
|
*/
|
||||||
|
const CONTINGENT_BRANCH_WEIGHTS: Record<string, number> = {
|
||||||
|
HO: 0.2,
|
||||||
|
'0002': 0.18,
|
||||||
|
'0003': 0.16,
|
||||||
|
'0004': 0.16,
|
||||||
|
'0005': 0.15,
|
||||||
|
'0006': 0.15
|
||||||
|
};
|
||||||
|
|
||||||
|
let cachedPostings: GlPosting[] | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The full generated ledger. Built once and memoised — every report reads from
|
||||||
|
* this array, so the reports can never disagree with one another.
|
||||||
|
*/
|
||||||
|
export function glPostings(): GlPosting[] {
|
||||||
|
if (cachedPostings) {
|
||||||
|
return cachedPostings;
|
||||||
|
}
|
||||||
|
const rand = lcg(20260401);
|
||||||
|
const out: GlPosting[] = [];
|
||||||
|
const dates = ledgerDates();
|
||||||
|
let voucherSeq = 1;
|
||||||
|
|
||||||
|
// Opening entries, posted at Head Office on day one so the books start in balance.
|
||||||
|
const openingVoucher = `OB/${GL_PERIOD.label.replace(/\s/g, '')}`;
|
||||||
|
let openingDebit = 0;
|
||||||
|
let openingCredit = 0;
|
||||||
|
|
||||||
|
for (const [
|
||||||
|
code,
|
||||||
|
amount
|
||||||
|
] of Object.entries(OPENING_BALANCES)) {
|
||||||
|
if (!amount) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const account = GL_ACCOUNTS.find((a) => a.code === code);
|
||||||
|
if (!account) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const debitSide = account.type === 'ASSET' || account.type === 'EXPENSE' || account.type === 'CONTINGENT';
|
||||||
|
|
||||||
|
// Contingent accounts are single-sided memoranda held at the issuing branch,
|
||||||
|
// so their opening book is spread across the network rather than sat at HO.
|
||||||
|
if (account.type === 'CONTINGENT') {
|
||||||
|
let allocated = 0;
|
||||||
|
for (const branch of GL_BRANCHES) {
|
||||||
|
if (branch.code === 'HO') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const share = Math.round((amount * (CONTINGENT_BRANCH_WEIGHTS[branch.code] ?? 0)) / 100) * 100;
|
||||||
|
if (!share) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
allocated += share;
|
||||||
|
out.push({
|
||||||
|
date: GL_PERIOD.fyStart,
|
||||||
|
branchCode: branch.code,
|
||||||
|
accountCode: code,
|
||||||
|
debit: share,
|
||||||
|
credit: 0,
|
||||||
|
voucherNo: openingVoucher,
|
||||||
|
narration: 'Opening balance brought forward'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Head Office takes its own share plus whatever rounding left behind.
|
||||||
|
out.push({
|
||||||
|
date: GL_PERIOD.fyStart,
|
||||||
|
branchCode: 'HO',
|
||||||
|
accountCode: code,
|
||||||
|
debit: amount - allocated,
|
||||||
|
credit: 0,
|
||||||
|
voucherNo: openingVoucher,
|
||||||
|
narration: 'Opening balance brought forward'
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
out.push({
|
||||||
|
date: GL_PERIOD.fyStart,
|
||||||
|
branchCode: 'HO',
|
||||||
|
accountCode: code,
|
||||||
|
debit: debitSide ? amount : 0,
|
||||||
|
credit: debitSide ? 0 : amount,
|
||||||
|
voucherNo: openingVoucher,
|
||||||
|
narration: 'Opening balance brought forward'
|
||||||
|
});
|
||||||
|
if (debitSide) {
|
||||||
|
openingDebit += amount;
|
||||||
|
} else {
|
||||||
|
openingCredit += amount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If OPENING_BALANCES is ever edited so the two sides diverge, park the residual
|
||||||
|
// in a visibly-named suspense account rather than letting the trial balance drift.
|
||||||
|
const openingResidual = openingCredit - openingDebit;
|
||||||
|
if (openingResidual !== 0) {
|
||||||
|
out.push({
|
||||||
|
date: GL_PERIOD.fyStart,
|
||||||
|
branchCode: 'HO',
|
||||||
|
accountCode: '140199',
|
||||||
|
debit: openingResidual > 0 ? openingResidual : 0,
|
||||||
|
credit: openingResidual < 0 ? -openingResidual : 0,
|
||||||
|
voucherNo: openingVoucher,
|
||||||
|
narration: 'Unreconciled opening balance difference'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const round = (n: number): number => Math.round(n / 100) * 100;
|
||||||
|
|
||||||
|
for (const date of dates) {
|
||||||
|
for (const branch of GL_BRANCHES) {
|
||||||
|
// Head Office posts fewer counter transactions than the branches.
|
||||||
|
const weight = branch.code === 'HO' ? 0.45 : 1;
|
||||||
|
const templates = branch.code === 'HO' ? [
|
||||||
|
...VOUCHER_TEMPLATES,
|
||||||
|
...HO_TEMPLATES
|
||||||
|
] : VOUCHER_TEMPLATES;
|
||||||
|
|
||||||
|
for (const tpl of templates) {
|
||||||
|
if (rand() > tpl.frequency * weight) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// 0.55x .. 1.45x of the template scale keeps daily figures varied but plausible.
|
||||||
|
const amount = round(tpl.scale * (0.55 + rand() * 0.9));
|
||||||
|
if (amount <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const voucherNo = `${branch.code}/${date.slice(5, 7)}/${`${voucherSeq++}`.padStart(5, '0')}`;
|
||||||
|
out.push({
|
||||||
|
date,
|
||||||
|
branchCode: branch.code,
|
||||||
|
accountCode: tpl.debit,
|
||||||
|
debit: amount,
|
||||||
|
credit: 0,
|
||||||
|
voucherNo,
|
||||||
|
narration: tpl.narration
|
||||||
|
});
|
||||||
|
out.push({
|
||||||
|
date,
|
||||||
|
branchCode: branch.code,
|
||||||
|
accountCode: tpl.credit,
|
||||||
|
debit: 0,
|
||||||
|
credit: amount,
|
||||||
|
voucherNo,
|
||||||
|
narration: tpl.narration
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contingent memoranda are raised at every branch, weighted by its size.
|
||||||
|
const contingentWeight = CONTINGENT_BRANCH_WEIGHTS[branch.code] ?? 0.15;
|
||||||
|
for (const tpl of CONTINGENT_TEMPLATES) {
|
||||||
|
// Claims against the bank and capital commitments are Head Office matters.
|
||||||
|
const headOfficeOnly = tpl.account === '910105' || tpl.account === '910107';
|
||||||
|
if (headOfficeOnly && branch.code !== 'HO') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const frequency = headOfficeOnly ? tpl.frequency : tpl.frequency * (contingentWeight / 0.2);
|
||||||
|
if (rand() > frequency) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const amount = round(tpl.scale * (0.55 + rand() * 0.9));
|
||||||
|
out.push({
|
||||||
|
date,
|
||||||
|
branchCode: branch.code,
|
||||||
|
accountCode: tpl.account,
|
||||||
|
debit: amount,
|
||||||
|
credit: 0,
|
||||||
|
voucherNo: `CNT/${branch.code}/${date.slice(5, 7)}/${`${voucherSeq++}`.padStart(5, '0')}`,
|
||||||
|
narration: tpl.narration
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cachedPostings = out;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Customer sub-ledger.
|
||||||
|
*
|
||||||
|
* The GL is account-level, so a client-wise report needs a customer dimension on top.
|
||||||
|
* Each customer holds a share of one GL head at one branch; balances are not stored,
|
||||||
|
* they are derived at query time as `share x that head's GL balance`. That way the
|
||||||
|
* client-wise report always reconciles to the General Ledger exactly, whatever period
|
||||||
|
* or branch is selected, instead of drifting as a second source of truth.
|
||||||
|
*/
|
||||||
|
export interface GlCustomer {
|
||||||
|
cif: string;
|
||||||
|
name: string;
|
||||||
|
branchCode: string;
|
||||||
|
/** GL head this relationship sits under. */
|
||||||
|
accountCode: string;
|
||||||
|
accountNo: string;
|
||||||
|
/** Fraction of the branch's balance in that head. Shares per (branch, head) sum to 1. */
|
||||||
|
share: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FIRST_NAMES = [
|
||||||
|
'Ravi',
|
||||||
|
'Priya',
|
||||||
|
'Vikram',
|
||||||
|
'Nirav',
|
||||||
|
'Kavita',
|
||||||
|
'Arjun',
|
||||||
|
'Lakshmi',
|
||||||
|
'Sameer',
|
||||||
|
'Anjali',
|
||||||
|
'Rohan',
|
||||||
|
'Meera',
|
||||||
|
'Karan',
|
||||||
|
'Divya',
|
||||||
|
'Sanjay',
|
||||||
|
'Pooja',
|
||||||
|
'Amit',
|
||||||
|
'Sneha',
|
||||||
|
'Manish',
|
||||||
|
'Rekha',
|
||||||
|
'Deepak'
|
||||||
|
];
|
||||||
|
const LAST_NAMES = [
|
||||||
|
'Sharma',
|
||||||
|
'Patel',
|
||||||
|
'Reddy',
|
||||||
|
'Shah',
|
||||||
|
'Verma',
|
||||||
|
'Deshmukh',
|
||||||
|
'Iyer',
|
||||||
|
'Kapoor',
|
||||||
|
'Nair',
|
||||||
|
'Joshi',
|
||||||
|
'Mehta',
|
||||||
|
'Gupta',
|
||||||
|
'Rao',
|
||||||
|
'Bhatt',
|
||||||
|
'Chauhan'
|
||||||
|
];
|
||||||
|
const FIRMS = [
|
||||||
|
'Traders',
|
||||||
|
'Enterprises',
|
||||||
|
'Agencies',
|
||||||
|
'& Sons',
|
||||||
|
'Industries',
|
||||||
|
'Stores',
|
||||||
|
'Corporation'
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Customer-facing heads a client relationship can sit under. */
|
||||||
|
const CUSTOMER_HEADS = [
|
||||||
|
'220101',
|
||||||
|
'220102',
|
||||||
|
'220103',
|
||||||
|
'220104',
|
||||||
|
'130101',
|
||||||
|
'130102',
|
||||||
|
'130103',
|
||||||
|
'130104',
|
||||||
|
'130105',
|
||||||
|
'130106'
|
||||||
|
];
|
||||||
|
|
||||||
|
let cachedCustomers: GlCustomer[] | null = null;
|
||||||
|
|
||||||
|
export function glCustomers(): GlCustomer[] {
|
||||||
|
if (cachedCustomers) {
|
||||||
|
return cachedCustomers;
|
||||||
|
}
|
||||||
|
const rand = lcg(76543210);
|
||||||
|
const out: GlCustomer[] = [];
|
||||||
|
let seq = 1;
|
||||||
|
|
||||||
|
for (const branch of GL_BRANCHES) {
|
||||||
|
for (const head of CUSTOMER_HEADS) {
|
||||||
|
// Deposits are held by many customers; advances by fewer, larger ones.
|
||||||
|
const isAdvance = head.startsWith('13');
|
||||||
|
const count = isAdvance ? 2 + Math.floor(rand() * 3) : 3 + Math.floor(rand() * 4);
|
||||||
|
const weights: number[] = [];
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
weights.push(0.35 + rand() * 0.65);
|
||||||
|
}
|
||||||
|
const weightTotal = weights.reduce((a, b) => a + b, 0);
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const corporate = isAdvance && rand() > 0.55;
|
||||||
|
const name = corporate
|
||||||
|
? `${LAST_NAMES[Math.floor(rand() * LAST_NAMES.length)]} ${FIRMS[Math.floor(rand() * FIRMS.length)]}`
|
||||||
|
: `${FIRST_NAMES[Math.floor(rand() * FIRST_NAMES.length)]} ${LAST_NAMES[Math.floor(rand() * LAST_NAMES.length)]}`;
|
||||||
|
out.push({
|
||||||
|
cif: `CIF${`${100000 + seq}`}`,
|
||||||
|
name,
|
||||||
|
branchCode: branch.code,
|
||||||
|
accountCode: head,
|
||||||
|
accountNo: `${branch.code === 'HO' ? '0001' : branch.code}${head}${`${seq}`.padStart(4, '0')}`,
|
||||||
|
share: weights[i] / weightTotal
|
||||||
|
});
|
||||||
|
seq++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cachedCustomers = out;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function glAccount(code: string): GlAccount | undefined {
|
||||||
|
return GL_ACCOUNTS.find((a) => a.code === code);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function glBranchName(code: string): string {
|
||||||
|
return GL_BRANCHES.find((b) => b.code === code)?.name ?? code;
|
||||||
|
}
|
||||||
@@ -40,6 +40,10 @@ const routes: Routes = [
|
|||||||
resolve: {
|
resolve: {
|
||||||
offices: OfficesResolver
|
offices: OfficesResolver
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'shares',
|
||||||
|
loadChildren: () => import('../shares/shares.module').then((m) => m.SharesModule)
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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,228 @@
|
|||||||
</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 +1392,202 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<!-- LOG CONTACT DIALOG -->
|
||||||
|
@if (contactDialogOpen && current) {
|
||||||
|
<div class="km-overlay" (click)="closeContactDialog()">
|
||||||
|
<div class="km-modal hv-modal" (click)="$event.stopPropagation()">
|
||||||
|
<div class="km-mh">
|
||||||
|
<div class="km-mh-av">{{ current.av }}</div>
|
||||||
|
<div>
|
||||||
|
<div class="km-mh-name">Log Customer Contact</div>
|
||||||
|
<div class="km-mh-meta">{{ current.name }} · {{ current.account }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="km-mclose" (click)="closeContactDialog()">✕</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="km-mbody hv-body">
|
||||||
|
<div class="hv-addr">
|
||||||
|
<div class="hv-addr-label">Customer Contact Info</div>
|
||||||
|
<div class="hv-addr-name">{{ current.name }}</div>
|
||||||
|
<div class="hv-addr-text">
|
||||||
|
<fa-icon icon="mobile-alt" class="vkyc-ic"></fa-icon> {{ current.contact.mobile }}
|
||||||
|
</div>
|
||||||
|
<div class="hv-addr-text">
|
||||||
|
<fa-icon icon="envelope" class="vkyc-ic"></fa-icon> {{ current.contact.email }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hv-row">
|
||||||
|
<label class="hv-label">Contact Channel<span class="hv-req">*</span></label>
|
||||||
|
<select class="hv-input" [(ngModel)]="contactChannel">
|
||||||
|
@for (ch of contactChannels; track ch.value) {
|
||||||
|
<option [value]="ch.value">{{ ch.label }}{{ ch.sends ? ' — sends a notice now' : ' — record only' }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
@if (selectedChannelSends) {
|
||||||
|
<div class="hv-hint hv-hint-send">
|
||||||
|
<fa-icon icon="paper-plane" class="vkyc-ic"></fa-icon>
|
||||||
|
A KYC notice will be sent to the customer when you save.
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="hv-hint">
|
||||||
|
Records an action you already took. Nothing is sent to the customer.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (selectedChannelUnavailable) {
|
||||||
|
<div class="hv-hint hv-hint-error">
|
||||||
|
This customer has no {{ selectedChannel?.requires === 'email' ? 'email address' : 'mobile number' }} on file —
|
||||||
|
choose a different channel.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hv-row">
|
||||||
|
<label class="hv-label">KYC Officer Logging<span class="hv-req">*</span></label>
|
||||||
|
<select class="hv-input" [(ngModel)]="contactOfficer">
|
||||||
|
@for (officer of bankOfficers; track officer) {
|
||||||
|
<option [value]="officer">{{ officer }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hv-row">
|
||||||
|
<label class="hv-label">Summary / Notes<span class="hv-req">*</span></label>
|
||||||
|
<textarea
|
||||||
|
class="hv-input hv-textarea"
|
||||||
|
rows="3"
|
||||||
|
placeholder="Enter details of conversation e.g. Customer promised to visit branch next Tuesday..."
|
||||||
|
[(ngModel)]="contactNotes"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (contactError) {
|
||||||
|
<div class="hv-hint hv-hint-error" role="alert">
|
||||||
|
<fa-icon icon="exclamation-circle" class="vkyc-ic"></fa-icon> {{ contactError }}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hv-footer">
|
||||||
|
<button class="km-btn" [disabled]="contactSaving" (click)="closeContactDialog()">Cancel</button>
|
||||||
|
<button
|
||||||
|
class="km-btn km-btn-t"
|
||||||
|
[disabled]="contactSaveDisabled"
|
||||||
|
(click)="saveContactLog()"
|
||||||
|
>
|
||||||
|
{{ contactSaveLabel }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- RESOLVE KYC DIALOG -->
|
||||||
|
@if (resolveDialogOpen && current) {
|
||||||
|
<div class="km-overlay" (click)="closeResolveDialog()">
|
||||||
|
<div class="km-modal hv-modal" (click)="$event.stopPropagation()">
|
||||||
|
<div class="km-mh" style="border-bottom: 2px solid var(--km-green);">
|
||||||
|
<div class="km-mh-av" style="background: var(--km-green);">{{ current.av }}</div>
|
||||||
|
<div>
|
||||||
|
<div class="km-mh-name">Resolve KYC Exception</div>
|
||||||
|
<div class="km-mh-meta">{{ current.name }} · {{ current.account }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="km-mclose" (click)="closeResolveDialog()">✕</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="km-mbody hv-body">
|
||||||
|
<div style="background: #f0fdf4; border: 1px solid #bbf7d0; border-radius: 6px; padding: 12px; margin-bottom: 16px; font-size: 13px; color: #166534;">
|
||||||
|
<strong>Pending Reason:</strong> {{ current.exceptionReason }}
|
||||||
|
@if (current.exceptionRemarks) {
|
||||||
|
<div style="margin-top: 4px; font-style: italic;">"{{ current.exceptionRemarks }}"</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (showPanResolution) {
|
||||||
|
<div style="border: 1px solid var(--km-g300); border-radius: 6px; padding: 12px; margin-bottom: 16px; background: #fafafa;">
|
||||||
|
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
|
||||||
|
<input type="checkbox" id="resolvePanChk" [(ngModel)]="resolvePanActive" />
|
||||||
|
<label for="resolvePanChk" style="font-weight: 600; cursor: pointer; color: var(--km-g800);">Resolve PAN Card Exception</label>
|
||||||
|
</div>
|
||||||
|
@if (resolvePanActive) {
|
||||||
|
<div class="hv-row" style="margin-left: 20px;">
|
||||||
|
<label class="hv-label">Verified PAN Number<span class="hv-req">*</span></label>
|
||||||
|
<input type="text" class="hv-input" [(ngModel)]="resolvePanNumber" placeholder="e.g. ABCDE1234F" maxlength="10" style="text-transform: uppercase;" />
|
||||||
|
@if (resolvePanNumber && resolvePanNumber.length !== 10) {
|
||||||
|
<div style="color: red; font-size: 11px; margin-top: 4px;">PAN must be exactly 10 characters.</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (showAadhaarResolution) {
|
||||||
|
<div style="border: 1px solid var(--km-g300); border-radius: 6px; padding: 12px; margin-bottom: 16px; background: #fafafa;">
|
||||||
|
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
|
||||||
|
<input type="checkbox" id="resolveAadhChk" [(ngModel)]="resolveAadhaarActive" />
|
||||||
|
<label for="resolveAadhChk" style="font-weight: 600; cursor: pointer; color: var(--km-g800);">Resolve Aadhaar Card Exception</label>
|
||||||
|
</div>
|
||||||
|
@if (resolveAadhaarActive) {
|
||||||
|
<div class="hv-row" style="margin-left: 20px;">
|
||||||
|
<label class="hv-label">Verified Aadhaar Number<span class="hv-req">*</span></label>
|
||||||
|
<input type="text" class="hv-input" [(ngModel)]="resolveAadhaarNumber" placeholder="e.g. 597855025299" maxlength="12" />
|
||||||
|
@if (resolveAadhaarNumber && resolveAadhaarNumber.length !== 12) {
|
||||||
|
<div style="color: red; font-size: 11px; margin-top: 4px;">Aadhaar must be exactly 12 digits.</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (showGeneralResolution) {
|
||||||
|
<div style="border: 1px solid var(--km-g300); border-radius: 6px; padding: 12px; margin-bottom: 16px; background: #fafafa;">
|
||||||
|
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
|
||||||
|
<input type="checkbox" id="resolveGenChk" [(ngModel)]="resolveGeneralActive" />
|
||||||
|
<label for="resolveGenChk" style="font-weight: 600; cursor: pointer; color: var(--km-g800);">Resolve General Exception</label>
|
||||||
|
</div>
|
||||||
|
@if (resolveGeneralActive) {
|
||||||
|
<div style="margin-left: 20px;">
|
||||||
|
<div class="hv-row">
|
||||||
|
<label class="hv-label">Verification Document Type<span class="hv-req">*</span></label>
|
||||||
|
<select class="hv-input" [(ngModel)]="resolveGeneralDocumentType">
|
||||||
|
<option value="OVD">Officially Valid Document (Passport/Voter ID)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="hv-row">
|
||||||
|
<label class="hv-label">Document Identification Number<span class="hv-req">*</span></label>
|
||||||
|
<input type="text" class="hv-input" [(ngModel)]="resolveGeneralDocumentNumber" placeholder="Enter verified document number" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="hv-row">
|
||||||
|
<label class="hv-label">Resolution Comments<span class="hv-req">*</span></label>
|
||||||
|
<textarea
|
||||||
|
class="hv-input hv-textarea"
|
||||||
|
rows="3"
|
||||||
|
placeholder="Describe how verification was resolved..."
|
||||||
|
[(ngModel)]="resolveRemarks"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hv-footer">
|
||||||
|
<button class="km-btn" (click)="closeResolveDialog()">Cancel</button>
|
||||||
|
<button
|
||||||
|
class="km-btn km-btn-g"
|
||||||
|
[disabled]="!isResolveValid"
|
||||||
|
(click)="saveResolveKyc()"
|
||||||
|
>
|
||||||
|
Complete KYC & Resolve Exception
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<!-- TOAST -->
|
<!-- TOAST -->
|
||||||
<div class="km-toast" [class.show]="toastVisible">{{ toastMsg }}</div>
|
<div class="km-toast" [class.show]="toastVisible">{{ toastMsg }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1588,6 +1588,58 @@
|
|||||||
min-height: 60px;
|
min-height: 60px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Inline note under a field. The default (neutral) form states that nothing is transmitted;
|
||||||
|
the -send and -error variants call out the two cases the officer must not misread. */
|
||||||
|
.hv-hint {
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: var(--km-g500);
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hv-hint-send {
|
||||||
|
color: #0f766e;
|
||||||
|
background: #f0fdfa;
|
||||||
|
border: 1px solid #99f6e4;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hv-hint-error {
|
||||||
|
color: #b91c1c;
|
||||||
|
background: #fef2f2;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Delivery outcome chip on a contact-log row. A logged attempt and a delivered notice are
|
||||||
|
different facts, so they never share a colour. */
|
||||||
|
.km-sent-tag {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 6px;
|
||||||
|
padding: 0 5px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
vertical-align: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.km-sent-ok {
|
||||||
|
background: #e6f4ea;
|
||||||
|
color: #137333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.km-sent-fail {
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
.hv-footer {
|
.hv-footer {
|
||||||
padding: 14px 22px;
|
padding: 14px 22px;
|
||||||
border-top: 1px solid var(--km-g200);
|
border-top: 1px solid var(--km-g200);
|
||||||
@@ -1648,3 +1700,28 @@
|
|||||||
color: #0f172a;
|
color: #0f172a;
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* KYC Exceptions Styles */
|
||||||
|
.km-ci-e {
|
||||||
|
background: var(--km-amber-l) !important;
|
||||||
|
color: var(--km-amber) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-logs-list {
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-family: inherit;
|
||||||
|
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
|
&::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
background: #cbd5e1;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,10 +6,11 @@
|
|||||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Component, OnInit } from '@angular/core';
|
import { Component, OnInit, inject } from '@angular/core';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
|
import { ClientsService } from '../clients/clients.service';
|
||||||
|
|
||||||
interface KmNotice {
|
interface KmNotice {
|
||||||
no: string;
|
no: string;
|
||||||
@@ -31,6 +32,24 @@ interface KmEvidence {
|
|||||||
docket?: string;
|
docket?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One outreach attempt, as returned by GET /kyc/exceptions/{clientId}/contact-log. */
|
||||||
|
interface KmContactLog {
|
||||||
|
date: string;
|
||||||
|
/** Display label for the channel used, e.g. 'SMS' / 'In-Branch Visit'. */
|
||||||
|
action: string;
|
||||||
|
notes: string;
|
||||||
|
officer: string;
|
||||||
|
/** True when this channel transmits (SMS/Email/WhatsApp) rather than being recorded offline. */
|
||||||
|
platformSent?: boolean;
|
||||||
|
/** True only when the notice actually reached the gateway. */
|
||||||
|
delivered?: boolean;
|
||||||
|
/** SENT | FAILED | SKIPPED_NO_DESTINATION | PROVIDER_NOT_CONFIGURED | NOT_APPLICABLE */
|
||||||
|
deliveryStatus?: string;
|
||||||
|
/** Officer-facing outcome sentence — shown verbatim so a failure is never mistaken for a send. */
|
||||||
|
deliveryMessage?: string;
|
||||||
|
destinationMasked?: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface KmClient {
|
interface KmClient {
|
||||||
id: string;
|
id: string;
|
||||||
av: string;
|
av: string;
|
||||||
@@ -66,6 +85,17 @@ interface KmClient {
|
|||||||
pendingActions: string[];
|
pendingActions: string[];
|
||||||
actionTaken: string[];
|
actionTaken: string[];
|
||||||
evidence: KmEvidence | null;
|
evidence: KmEvidence | null;
|
||||||
|
isException?: boolean;
|
||||||
|
exceptionReason?: string;
|
||||||
|
exceptionRemarks?: string;
|
||||||
|
exceptionDate?: string;
|
||||||
|
exceptionResolved?: boolean;
|
||||||
|
exceptionResolvedOn?: string;
|
||||||
|
contactHistory?: KmContactLog[];
|
||||||
|
kycId?: string;
|
||||||
|
/** Whether the customer has a mobile / email on file — drives channel availability in the dialog. */
|
||||||
|
hasMobile?: boolean;
|
||||||
|
hasEmail?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -79,7 +109,8 @@ interface KmClient {
|
|||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class KycManagementComponent implements OnInit {
|
export class KycManagementComponent implements OnInit {
|
||||||
activeSection: 'alerts' | 'notif' | 'audit' | 'status' = 'alerts';
|
private clientsService = inject(ClientsService);
|
||||||
|
activeSection: 'alerts' | 'notif' | 'audit' | 'status' | 'exceptions' = 'alerts';
|
||||||
|
|
||||||
modalOpen = false;
|
modalOpen = false;
|
||||||
modalTab: 'overview' | 'timeline' | 'notices' | 'evidence' | 'action' = 'overview';
|
modalTab: 'overview' | 'timeline' | 'notices' | 'evidence' | 'action' = 'overview';
|
||||||
@@ -529,15 +560,399 @@ export class KycManagementComponent implements OnInit {
|
|||||||
};
|
};
|
||||||
|
|
||||||
alertRows: KmClient[] = [];
|
alertRows: KmClient[] = [];
|
||||||
|
exceptionRows: KmClient[] = [];
|
||||||
|
pendingExceptions: KmClient[] = [];
|
||||||
|
resolvedExceptions: KmClient[] = [];
|
||||||
|
activeExceptionSubTab: 'pending' | 'resolved' = 'pending';
|
||||||
|
selectedExceptionReason = '';
|
||||||
|
selectedExceptionContactStatus = '';
|
||||||
|
|
||||||
|
// Dialog properties for exceptions
|
||||||
|
contactDialogOpen = false;
|
||||||
|
contactChannel = 'PHONE_CALL';
|
||||||
|
contactNotes = '';
|
||||||
|
contactOfficer = 'Neha Verma — KYC Officer (Bhopal Main)';
|
||||||
|
/** In-flight guard — a double-click must not send the customer two notices. */
|
||||||
|
contactSaving = false;
|
||||||
|
/** Populated when the save itself failed (not when the save worked but delivery didn't). */
|
||||||
|
contactError = '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Channels offered in the Log Customer Contact dialog. `sends` marks the ones where saving
|
||||||
|
* transmits a real notice to the customer, as opposed to recording an action the officer already
|
||||||
|
* took offline — the dialog labels and the confirm button both key off it.
|
||||||
|
*/
|
||||||
|
readonly contactChannels: { value: string; label: string; sends: boolean; requires?: 'mobile' | 'email' }[] = [
|
||||||
|
{ value: 'PHONE_CALL', label: 'Phone Call', sends: false },
|
||||||
|
{ value: 'SMS', label: 'SMS', sends: true, requires: 'mobile' },
|
||||||
|
{ value: 'EMAIL', label: 'Email', sends: true, requires: 'email' },
|
||||||
|
{ value: 'WHATSAPP', label: 'WhatsApp Message', sends: true, requires: 'mobile' },
|
||||||
|
{ value: 'BRANCH_VISIT', label: 'In-Branch Visit', sends: false },
|
||||||
|
{ value: 'FIELD_VISIT', label: 'Agent Field Visit', sends: false }
|
||||||
|
];
|
||||||
|
|
||||||
|
resolveDialogOpen = false;
|
||||||
|
resolveDocumentType = 'PAN';
|
||||||
|
resolveDocumentNumber = '';
|
||||||
|
resolveRemarks = '';
|
||||||
|
|
||||||
|
resolvePanActive = false;
|
||||||
|
resolvePanNumber = '';
|
||||||
|
resolveAadhaarActive = false;
|
||||||
|
resolveAadhaarNumber = '';
|
||||||
|
resolveGeneralActive = false;
|
||||||
|
resolveGeneralDocumentType = 'OVD';
|
||||||
|
resolveGeneralDocumentNumber = '';
|
||||||
|
|
||||||
|
showPanResolution = false;
|
||||||
|
showAadhaarResolution = false;
|
||||||
|
showGeneralResolution = false;
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.alertRows = Object.values(this.clients);
|
this.alertRows = Object.values(this.clients);
|
||||||
|
this.loadExceptions();
|
||||||
}
|
}
|
||||||
|
|
||||||
showSection(section: 'alerts' | 'notif' | 'audit' | 'status'): void {
|
loadExceptions(): void {
|
||||||
|
this.clientsService.getKycExceptions(false).subscribe({
|
||||||
|
next: (data) => {
|
||||||
|
this.pendingExceptions = data.map((item: any) => this.mapException(item, false));
|
||||||
|
this.exceptionRows = this.pendingExceptions;
|
||||||
|
this.loadContactHistories(this.pendingExceptions);
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
console.error('Failed to load active exceptions', err);
|
||||||
|
this.pendingExceptions = [];
|
||||||
|
this.exceptionRows = [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.clientsService.getKycExceptions(true).subscribe({
|
||||||
|
next: (data) => {
|
||||||
|
this.resolvedExceptions = data.map((item: any) => this.mapException(item, true));
|
||||||
|
this.loadContactHistories(this.resolvedExceptions);
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
console.error('Failed to load resolved exceptions', err);
|
||||||
|
this.resolvedExceptions = [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapException(item: any, resolved: boolean): KmClient {
|
||||||
|
return {
|
||||||
|
id: item.clientId?.toString(),
|
||||||
|
av: (item.clientDisplayName || 'Client').split(' ').map((n: string) => n[0]).join('').substring(0, 2).toUpperCase(),
|
||||||
|
name: item.clientDisplayName,
|
||||||
|
meta: `${item.clientAccountNo || `CLI-${item.clientId}`} · Savings A/c · ${item.clientOfficeName || 'Bhopal Main'}`,
|
||||||
|
account: item.clientAccountNo || `CLI-${item.clientId}`,
|
||||||
|
state: 'alert',
|
||||||
|
risk: item.riskCategoryCode?.toLowerCase() || 'low',
|
||||||
|
riskLabel: item.riskCategory || 'Low Risk',
|
||||||
|
expiry: 'Pending',
|
||||||
|
daysLeft: 30,
|
||||||
|
kyc: {
|
||||||
|
type: item.kycType || 'Individual',
|
||||||
|
initiated: item.initiatedOn || '—',
|
||||||
|
lastVerified: item.verifiedOn || '—',
|
||||||
|
validity: '—',
|
||||||
|
pan: item.panNumber || '—',
|
||||||
|
aadhaar: item.aadhaarNumberMasked || '—',
|
||||||
|
panStatus: item.panVerified ? 'Verified' : 'Verification Failed',
|
||||||
|
aadhaarStatus: item.aadhaarVerified ? 'Verified' : 'Verification Failed',
|
||||||
|
panLink: 'Not Linked'
|
||||||
|
},
|
||||||
|
contact: {
|
||||||
|
// Masked server-side; the full values never reach the browser. 'Not on file' is meaningful
|
||||||
|
// here — it's why an SMS or Email channel will be unavailable for this customer.
|
||||||
|
mobile: item.clientMobileMasked || 'Not on file',
|
||||||
|
email: item.clientEmailMasked || 'Not on file',
|
||||||
|
address: 'Client Address, Bhopal MP 462002',
|
||||||
|
branch: item.clientOfficeName || 'Bhopal Main'
|
||||||
|
},
|
||||||
|
notices: [],
|
||||||
|
noticesSent: 0,
|
||||||
|
noticePending: resolved ? 'Resolved Exception' : 'Pending verification',
|
||||||
|
nextStage: resolved ? 'Exception Resolved' : 'KYC Exceptions review',
|
||||||
|
pendingActions: resolved ? [] : ['Resolve Exception reasons'],
|
||||||
|
actionTaken: resolved ? ['KYC Exception resolved'] : [],
|
||||||
|
evidence: null,
|
||||||
|
isException: true,
|
||||||
|
// No invented default — a record whose reason was never stored (or was cleared by the old
|
||||||
|
// resolve path) must not be relabelled as a PAN failure it may never have had.
|
||||||
|
exceptionReason: item.exceptionReason || 'Reason not recorded',
|
||||||
|
exceptionRemarks: item.remarks || '',
|
||||||
|
exceptionDate: item.initiatedOn || '—',
|
||||||
|
exceptionResolved: resolved,
|
||||||
|
exceptionResolvedOn: item.exceptionResolvedOn || '—',
|
||||||
|
contactHistory: [],
|
||||||
|
kycId: item.id?.toString(),
|
||||||
|
hasMobile: item.hasMobile === true,
|
||||||
|
hasEmail: item.hasEmail === true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pull each row's persisted outreach history. Fired after the exception list loads; a failure on
|
||||||
|
* one row leaves that row's history empty rather than blanking the whole grid.
|
||||||
|
*/
|
||||||
|
private loadContactHistories(rows: KmClient[]): void {
|
||||||
|
for (const row of rows) {
|
||||||
|
if (!row.id) continue;
|
||||||
|
this.clientsService.getKycContactLog(row.id).subscribe({
|
||||||
|
next: (logs) => {
|
||||||
|
row.contactHistory = (logs || []).map((log: any) => this.mapContactLog(log));
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
console.error(`Failed to load contact log for client ${row.id}`, err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
get notYetContactedCount(): number {
|
||||||
|
return this.exceptionRows.filter(c => !c.contactHistory || c.contactHistory.length === 0).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
get panMismatchesCount(): number {
|
||||||
|
return this.exceptionRows.filter(c => c.exceptionReason?.includes('PAN')).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
get aadhaarFailuresCount(): number {
|
||||||
|
return this.exceptionRows.filter(c => c.exceptionReason?.includes('Aadhaar')).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
showSection(section: 'alerts' | 'notif' | 'audit' | 'status' | 'exceptions'): void {
|
||||||
this.activeSection = section;
|
this.activeSection = section;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
openContactDialog(client: KmClient): void {
|
||||||
|
this.current = client;
|
||||||
|
this.contactChannel = 'PHONE_CALL';
|
||||||
|
this.contactNotes = '';
|
||||||
|
this.contactError = '';
|
||||||
|
this.contactSaving = false;
|
||||||
|
this.contactOfficer = this.bankOfficers[1]; // Neha Verma
|
||||||
|
this.contactDialogOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
closeContactDialog(): void {
|
||||||
|
this.contactDialogOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The channel currently picked in the dialog. */
|
||||||
|
get selectedChannel(): { value: string; label: string; sends: boolean; requires?: 'mobile' | 'email' } | undefined {
|
||||||
|
return this.contactChannels.find(c => c.value === this.contactChannel);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when saving will transmit a notice to the customer, not merely record one. */
|
||||||
|
get selectedChannelSends(): boolean {
|
||||||
|
return this.selectedChannel?.sends === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when the picked channel needs a destination the customer doesn't have. Blocks the save so
|
||||||
|
* the officer isn't left reading a failure they could have seen up front.
|
||||||
|
*/
|
||||||
|
get selectedChannelUnavailable(): boolean {
|
||||||
|
const requires = this.selectedChannel?.requires;
|
||||||
|
if (!requires || !this.current) return false;
|
||||||
|
return requires === 'mobile' ? this.current.hasMobile === false : this.current.hasEmail === false;
|
||||||
|
}
|
||||||
|
|
||||||
|
get contactSaveDisabled(): boolean {
|
||||||
|
return !this.contactNotes?.trim() || !this.contactChannel || this.contactSaving || this.selectedChannelUnavailable;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 'Send & Save' when a notice goes out, plain 'Save Log' when it's an offline record. */
|
||||||
|
get contactSaveLabel(): string {
|
||||||
|
if (this.contactSaving) return this.selectedChannelSends ? 'Sending…' : 'Saving…';
|
||||||
|
return this.selectedChannelSends ? 'Send & Save' : 'Save Log';
|
||||||
|
}
|
||||||
|
|
||||||
|
saveContactLog(): void {
|
||||||
|
if (!this.current || this.contactSaveDisabled) return;
|
||||||
|
|
||||||
|
const client = this.current;
|
||||||
|
this.contactSaving = true;
|
||||||
|
this.contactError = '';
|
||||||
|
|
||||||
|
this.clientsService
|
||||||
|
.saveKycContactLog(client.id, {
|
||||||
|
channel: this.contactChannel,
|
||||||
|
notes: this.contactNotes.trim(),
|
||||||
|
officerName: this.contactOfficer
|
||||||
|
})
|
||||||
|
.subscribe({
|
||||||
|
next: (log: any) => {
|
||||||
|
this.contactSaving = false;
|
||||||
|
client.contactHistory = [...(client.contactHistory || []), this.mapContactLog(log)];
|
||||||
|
|
||||||
|
// The request succeeding does not mean the customer was reached — a gateway failure comes
|
||||||
|
// back as HTTP 200 with delivered=false, so report what actually happened.
|
||||||
|
if (log?.platformSent && !log?.delivered) {
|
||||||
|
this.toast(log?.deliveryMessage || `Logged, but the ${log?.channelLabel || 'message'} could not be sent.`);
|
||||||
|
} else {
|
||||||
|
this.toast(log?.deliveryMessage || `Contact attempt logged successfully for ${client.name}`);
|
||||||
|
}
|
||||||
|
this.contactDialogOpen = false;
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.contactSaving = false;
|
||||||
|
// Keep the dialog open — the officer's notes are still in the textarea and would
|
||||||
|
// otherwise be lost.
|
||||||
|
this.contactError =
|
||||||
|
err?.error?.errors?.[0]?.defaultUserMessage ||
|
||||||
|
err?.error?.defaultUserMessage ||
|
||||||
|
'Could not save the contact log. Please try again.';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapContactLog(log: any): KmContactLog {
|
||||||
|
return {
|
||||||
|
date: log?.contactedAt ? this.formatLogDate(log.contactedAt) : '—',
|
||||||
|
action: log?.channelLabel || log?.channel || 'Contact',
|
||||||
|
notes: log?.notes || '',
|
||||||
|
officer: (log?.officerName || '').split(' — ')[0],
|
||||||
|
platformSent: log?.platformSent,
|
||||||
|
delivered: log?.delivered,
|
||||||
|
deliveryStatus: log?.deliveryStatus,
|
||||||
|
deliveryMessage: log?.deliveryMessage,
|
||||||
|
destinationMasked: log?.destinationMasked
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `contactedAt` arrives as a LocalDateTime — Jackson serialises it as an ISO string or as a
|
||||||
|
* [y, m, d, h, min, s] array depending on the tenant's date-format setting, so handle both.
|
||||||
|
*/
|
||||||
|
private formatLogDate(value: any): string {
|
||||||
|
const date = Array.isArray(value)
|
||||||
|
? new Date(value[0], (value[1] || 1) - 1, value[2] || 1, value[3] || 0, value[4] || 0)
|
||||||
|
: new Date(value);
|
||||||
|
if (isNaN(date.getTime())) return '—';
|
||||||
|
return date.toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
get isResolveValid(): boolean {
|
||||||
|
if (!this.resolveRemarks?.trim()) return false;
|
||||||
|
let anyActive = false;
|
||||||
|
if (this.showPanResolution && this.resolvePanActive) {
|
||||||
|
anyActive = true;
|
||||||
|
if (!this.resolvePanNumber || this.resolvePanNumber.trim().length !== 10) return false;
|
||||||
|
}
|
||||||
|
if (this.showAadhaarResolution && this.resolveAadhaarActive) {
|
||||||
|
anyActive = true;
|
||||||
|
if (!this.resolveAadhaarNumber || this.resolveAadhaarNumber.trim().length !== 12) return false;
|
||||||
|
}
|
||||||
|
if (this.showGeneralResolution && this.resolveGeneralActive) {
|
||||||
|
anyActive = true;
|
||||||
|
if (!this.resolveGeneralDocumentNumber?.trim()) return false;
|
||||||
|
}
|
||||||
|
return anyActive;
|
||||||
|
}
|
||||||
|
|
||||||
|
openResolveDialog(client: KmClient): void {
|
||||||
|
this.current = client;
|
||||||
|
const reason = client.exceptionReason || '';
|
||||||
|
this.showPanResolution = reason.includes('PAN');
|
||||||
|
this.showAadhaarResolution = reason.includes('Aadhaar');
|
||||||
|
this.showGeneralResolution = !this.showPanResolution && !this.showAadhaarResolution;
|
||||||
|
|
||||||
|
this.resolvePanActive = this.showPanResolution;
|
||||||
|
this.resolveAadhaarActive = this.showAadhaarResolution;
|
||||||
|
this.resolveGeneralActive = this.showGeneralResolution;
|
||||||
|
|
||||||
|
this.resolvePanNumber = '';
|
||||||
|
this.resolveAadhaarNumber = '';
|
||||||
|
this.resolveGeneralDocumentType = 'OVD';
|
||||||
|
this.resolveGeneralDocumentNumber = '';
|
||||||
|
this.resolveRemarks = '';
|
||||||
|
this.resolveDialogOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
closeResolveDialog(): void {
|
||||||
|
this.resolveDialogOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
saveResolveKyc(): void {
|
||||||
|
if (!this.current || !this.current.kycId) return;
|
||||||
|
const clientId = this.current.id;
|
||||||
|
const kycId = this.current.kycId;
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
const day = today.getDate().toString().padStart(2, '0');
|
||||||
|
const months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
|
||||||
|
const monthStr = months[today.getMonth()];
|
||||||
|
const year = today.getFullYear();
|
||||||
|
const dateStr = `${day} ${monthStr} ${year}`;
|
||||||
|
|
||||||
|
const resolvingPan = this.showPanResolution && this.resolvePanActive;
|
||||||
|
const resolvingAadhaar = this.showAadhaarResolution && this.resolveAadhaarActive;
|
||||||
|
const resolvingGeneral = this.showGeneralResolution && this.resolveGeneralActive;
|
||||||
|
|
||||||
|
const remainsPan = this.showPanResolution && !this.resolvePanActive;
|
||||||
|
const remainsAadhaar = this.showAadhaarResolution && !this.resolveAadhaarActive;
|
||||||
|
|
||||||
|
const isFullyResolved = !remainsPan && !remainsAadhaar;
|
||||||
|
|
||||||
|
const resolvedKycData: any = {
|
||||||
|
locale: 'en',
|
||||||
|
dateFormat: 'dd MMMM yyyy',
|
||||||
|
remarks: this.resolveRemarks || 'Resolved via exceptions dashboard'
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isFullyResolved) {
|
||||||
|
resolvedKycData.isException = false;
|
||||||
|
resolvedKycData.exceptionResolved = true;
|
||||||
|
resolvedKycData.exceptionResolvedOn = dateStr;
|
||||||
|
// Keep exceptionReason — the Resolved tab shows what was resolved, and clearing it destroys
|
||||||
|
// the audit record of why the exception was ever raised.
|
||||||
|
} else {
|
||||||
|
resolvedKycData.isException = true;
|
||||||
|
resolvedKycData.exceptionResolved = false;
|
||||||
|
const remainingReasons: string[] = [];
|
||||||
|
if (remainsPan) remainingReasons.push('PAN Card Verification Failed');
|
||||||
|
if (remainsAadhaar) remainingReasons.push('Aadhaar Verification Failed');
|
||||||
|
resolvedKycData.exceptionReason = remainingReasons.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolvingPan) {
|
||||||
|
resolvedKycData.panNumber = this.resolvePanNumber.toUpperCase().trim();
|
||||||
|
resolvedKycData.panVerified = true;
|
||||||
|
}
|
||||||
|
if (resolvingAadhaar) {
|
||||||
|
const aadhaarRaw = this.resolveAadhaarNumber.trim();
|
||||||
|
resolvedKycData.aadhaarNumberMasked = 'XXXXXXXX' + aadhaarRaw.slice(-4);
|
||||||
|
resolvedKycData.aadhaarVerified = true;
|
||||||
|
}
|
||||||
|
if (resolvingGeneral) {
|
||||||
|
resolvedKycData.kycDocumentName = this.resolveGeneralDocumentType;
|
||||||
|
resolvedKycData.kycDocumentNumber = this.resolveGeneralDocumentNumber;
|
||||||
|
resolvedKycData.isException = false;
|
||||||
|
resolvedKycData.exceptionResolved = true;
|
||||||
|
resolvedKycData.exceptionResolvedOn = dateStr;
|
||||||
|
// exceptionReason preserved — see the fully-resolved branch above.
|
||||||
|
}
|
||||||
|
|
||||||
|
this.clientsService.updateClientKyc(clientId, kycId, resolvedKycData).subscribe({
|
||||||
|
next: () => {
|
||||||
|
const msg = isFullyResolved
|
||||||
|
? `KYC Exception fully resolved and completed for ${this.current?.name}`
|
||||||
|
: `KYC Exception partially resolved for ${this.current?.name}`;
|
||||||
|
this.toast(msg);
|
||||||
|
this.loadExceptions();
|
||||||
|
this.resolveDialogOpen = false;
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
console.error('Failed to resolve exception', err);
|
||||||
|
this.toast('Failed to resolve exception. Please try again.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
openModal(id: string): void {
|
openModal(id: string): void {
|
||||||
const c = this.clients[id];
|
const c = this.clients[id];
|
||||||
if (!c) return;
|
if (!c) return;
|
||||||
|
|||||||
@@ -544,82 +544,315 @@ export function downloadLoanInterestPdf(record: LoanInterestRecord, fy: FyIntere
|
|||||||
|
|
||||||
// ───────────────────── Share Certificate ─────────────────────
|
// ───────────────────── Share Certificate ─────────────────────
|
||||||
export function buildShareCertificatePdf(r: ShareCertificateRecord): jsPDF {
|
export function buildShareCertificatePdf(r: ShareCertificateRecord): jsPDF {
|
||||||
const c = newCtx(ACCENT_AMBER);
|
const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' });
|
||||||
|
const pageW = doc.internal.pageSize.getWidth();
|
||||||
|
const pageH = doc.internal.pageSize.getHeight();
|
||||||
|
const margin = 20;
|
||||||
|
const contentW = pageW - 40;
|
||||||
|
|
||||||
|
const gold: [number, number, number] = [
|
||||||
|
197,
|
||||||
|
160,
|
||||||
|
89
|
||||||
|
]; // #c5a059
|
||||||
|
const darkBlue: [number, number, number] = [
|
||||||
|
11,
|
||||||
|
60,
|
||||||
|
93
|
||||||
|
]; // #0b3c5d
|
||||||
|
const textDark: [number, number, number] = [
|
||||||
|
44,
|
||||||
|
37,
|
||||||
|
32
|
||||||
|
]; // #2c2520
|
||||||
|
const textMuted: [number, number, number] = [
|
||||||
|
123,
|
||||||
|
111,
|
||||||
|
99
|
||||||
|
]; // #7b6f63
|
||||||
|
const lightBg: [number, number, number] = [
|
||||||
|
253,
|
||||||
|
252,
|
||||||
|
247
|
||||||
|
]; // #fdfcf7
|
||||||
|
const containerBg: [number, number, number] = [
|
||||||
|
250,
|
||||||
|
248,
|
||||||
|
245
|
||||||
|
]; // #faf8f5
|
||||||
|
|
||||||
|
// 1. Draw Cream Background (page body)
|
||||||
|
doc.setFillColor(containerBg[0], containerBg[1], containerBg[2]);
|
||||||
|
doc.rect(0, 0, pageW, pageH, 'F');
|
||||||
|
|
||||||
|
// 2. Draw Textured cream background inside the outer border
|
||||||
|
doc.setFillColor(lightBg[0], lightBg[1], lightBg[2]);
|
||||||
|
doc.rect(12, 12, pageW - 24, pageH - 24, 'F');
|
||||||
|
|
||||||
|
// 3. Draw Gold Double Borders (Outer and Inner)
|
||||||
|
doc.setDrawColor(gold[0], gold[1], gold[2]);
|
||||||
|
|
||||||
|
// Outer double line
|
||||||
|
doc.setLineWidth(1.2);
|
||||||
|
doc.rect(12, 12, pageW - 24, pageH - 24, 'D');
|
||||||
|
|
||||||
|
doc.setLineWidth(0.3);
|
||||||
|
doc.rect(13.8, 13.8, pageW - 27.6, pageH - 27.6, 'D');
|
||||||
|
|
||||||
|
// Thin interior border line
|
||||||
|
doc.setDrawColor(220, 209, 189); // light brown/grey
|
||||||
|
doc.setLineWidth(0.2);
|
||||||
|
doc.rect(17, 17, pageW - 34, pageH - 34, 'D');
|
||||||
|
|
||||||
|
// 4. Certificate Header
|
||||||
|
let y = 28;
|
||||||
|
doc.setFont('times', 'bold');
|
||||||
|
doc.setFontSize(22);
|
||||||
|
doc.setTextColor(darkBlue[0], darkBlue[1], darkBlue[2]);
|
||||||
|
doc.text('NEXORA CO-OPERATIVE BANK LTD.', pageW / 2, y, { align: 'center' });
|
||||||
|
|
||||||
|
y += 5;
|
||||||
|
doc.setFont('times', 'italic');
|
||||||
|
doc.setFontSize(10.5);
|
||||||
|
doc.setTextColor(textMuted[0], textMuted[1], textMuted[2]);
|
||||||
|
doc.text('Mumbai, Maharashtra, India', pageW / 2, y, { align: 'center' });
|
||||||
|
|
||||||
|
y += 4;
|
||||||
|
doc.setDrawColor(242, 237, 213); // light gold separator
|
||||||
|
doc.setLineWidth(1.5);
|
||||||
|
doc.line(22, y, pageW - 22, y);
|
||||||
|
|
||||||
|
y += 9;
|
||||||
|
doc.setFont('times', 'bold');
|
||||||
|
doc.setFontSize(24);
|
||||||
|
doc.setTextColor(gold[0], gold[1], gold[2]);
|
||||||
|
doc.text('SHARE CERTIFICATE', pageW / 2, y, { align: 'center' });
|
||||||
|
|
||||||
|
y += 5.5;
|
||||||
|
doc.setFont('times', 'bold');
|
||||||
|
doc.setFontSize(7.5);
|
||||||
|
doc.setTextColor(textMuted[0], textMuted[1], textMuted[2]);
|
||||||
|
doc.text('ISSUED UNDER THE COMMON SEAL OF THE BANK', pageW / 2, y, { align: 'center' });
|
||||||
|
|
||||||
|
y += 4;
|
||||||
|
doc.setDrawColor(242, 237, 213); // separator
|
||||||
|
doc.setLineWidth(0.3);
|
||||||
|
doc.line(22, y, pageW - 22, y);
|
||||||
|
|
||||||
|
// 5. Meta Row (Certificate No, Folio No, Date of Issue)
|
||||||
|
y += 7;
|
||||||
|
|
||||||
|
// Column 1: Certificate No
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.setFontSize(7.5);
|
||||||
|
doc.setTextColor(textMuted[0], textMuted[1], textMuted[2]);
|
||||||
|
doc.text('CERTIFICATE NO.', 25, y);
|
||||||
|
doc.setFont('times', 'bold');
|
||||||
|
doc.setFontSize(10);
|
||||||
|
doc.setTextColor(textDark[0], textDark[1], textDark[2]);
|
||||||
|
doc.text(r.certificateNo, 25, y + 4.5);
|
||||||
|
|
||||||
|
// Column 2: Folio No (Centered)
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.setFontSize(7.5);
|
||||||
|
doc.setTextColor(textMuted[0], textMuted[1], textMuted[2]);
|
||||||
|
doc.text('FOLIO NO.', pageW / 2, y, { align: 'center' });
|
||||||
|
doc.setFont('times', 'bold');
|
||||||
|
doc.setFontSize(10);
|
||||||
|
doc.setTextColor(textDark[0], textDark[1], textDark[2]);
|
||||||
|
doc.text(r.folioNo, pageW / 2, y + 4.5, { align: 'center' });
|
||||||
|
|
||||||
|
// Column 3: Date of Issue (Right Aligned)
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.setFontSize(7.5);
|
||||||
|
doc.setTextColor(textMuted[0], textMuted[1], textMuted[2]);
|
||||||
|
doc.text('DATE OF ISSUE', pageW - 25, y, { align: 'right' });
|
||||||
|
doc.setFont('times', 'bold');
|
||||||
|
doc.setFontSize(10);
|
||||||
|
doc.setTextColor(textDark[0], textDark[1], textDark[2]);
|
||||||
|
doc.text(formatLongDate(r.issueDate), pageW - 25, y + 4.5, { align: 'right' });
|
||||||
|
|
||||||
|
y += 8;
|
||||||
|
doc.setDrawColor(242, 237, 213);
|
||||||
|
doc.setLineWidth(0.3);
|
||||||
|
doc.line(22, y, pageW - 22, y);
|
||||||
|
|
||||||
|
// 6. Centered Body Section
|
||||||
|
y += 9;
|
||||||
const classLabel = r.shareClass === 'PREFERENCE' ? 'Preference' : 'Ordinary';
|
const classLabel = r.shareClass === 'PREFERENCE' ? 'Preference' : 'Ordinary';
|
||||||
|
|
||||||
let y = paintLetterhead(c, 'Share Certificate', 'CERTIFICATE REFERENCE', r.certificateNo, 'Folio No.', r.folioNo);
|
doc.setFont('times', 'normal');
|
||||||
|
doc.setFontSize(11);
|
||||||
|
doc.setTextColor(textDark[0], textDark[1], textDark[2]);
|
||||||
|
|
||||||
y = paintAddressee(c, y, 'Issued To,', r.memberName, r.memberId, r.memberPan, r.memberAddress);
|
const bodyText = `This is to certify that ${r.memberName} is the registered holder of ${r.shareCount.toLocaleString('en-IN')} (${classLabel}) shares of Rs. ${r.faceValue.toLocaleString('en-IN')} each, fully paid up, in Nexora Co-operative Bank Ltd., subject to the memorandum, articles of association, and bye-laws of the bank.`;
|
||||||
|
const bodyLines = doc.splitTextToSize(bodyText, pageW - 50);
|
||||||
// Centered, dramatic body paragraph (mirrors the highlighted "centered" style in the preview)
|
for (const line of bodyLines) {
|
||||||
if (y > c.pageH - c.margin - 80) {
|
doc.text(line, pageW / 2, y, { align: 'center' });
|
||||||
c.doc.addPage();
|
y += 5.5;
|
||||||
setFill(c, c.accent);
|
|
||||||
c.doc.rect(0, 0, c.pageW, 4, 'F');
|
|
||||||
y = c.margin;
|
|
||||||
}
|
}
|
||||||
const ctaText = `This is to certify that ${r.memberName} is the registered holder of ${r.shareCount.toLocaleString('en-IN')} (${classLabel}) shares of Rs. ${r.faceValue.toLocaleString('en-IN')} each, fully paid up, in ${BANK_NAME}, subject to the bye-laws of the bank.`;
|
|
||||||
const ctaLines = c.doc.splitTextToSize(ctaText, c.contentW - 10);
|
|
||||||
const ctaH = ctaLines.length * 5 + 6;
|
|
||||||
setFill(c, c.accentTint);
|
|
||||||
c.doc.rect(c.margin, y, c.contentW, ctaH, 'F');
|
|
||||||
setFill(c, c.accent);
|
|
||||||
c.doc.rect(c.margin, y, 1.8, ctaH, 'F');
|
|
||||||
c.doc.setFont('times', 'bold');
|
|
||||||
c.doc.setFontSize(11);
|
|
||||||
setText(c, C_TEXT);
|
|
||||||
c.doc.text(ctaLines, c.margin + 6, y + 5, { align: 'justify', maxWidth: c.contentW - 10 });
|
|
||||||
y += ctaH + 6;
|
|
||||||
|
|
||||||
y = paintSummary(
|
// 7. ISSUED TO Box
|
||||||
c,
|
y += 3;
|
||||||
y,
|
const addrLines = doc.splitTextToSize(r.memberAddress || '', pageW - 60);
|
||||||
[
|
const boxH = 26 + addrLines.length * 4;
|
||||||
[
|
|
||||||
'CERTIFICATE NO.',
|
doc.setFillColor(253, 252, 247);
|
||||||
r.certificateNo,
|
doc.setDrawColor(230, 225, 210);
|
||||||
'FOLIO NO.',
|
doc.rect(25, y, pageW - 50, boxH, 'FD');
|
||||||
r.folioNo
|
|
||||||
],
|
let boxY = y + 6;
|
||||||
[
|
doc.setFont('helvetica', 'bold');
|
||||||
'BRANCH',
|
doc.setFontSize(8);
|
||||||
r.branch,
|
doc.setTextColor(gold[0], gold[1], gold[2]);
|
||||||
'MEMBER SINCE',
|
doc.text('ISSUED TO:', 30, boxY);
|
||||||
formatLongDate(r.joiningDate)
|
|
||||||
],
|
boxY += 5.5;
|
||||||
[
|
doc.setFont('times', 'bold');
|
||||||
'SHARE CLASS',
|
doc.setFontSize(13);
|
||||||
classLabel,
|
doc.setTextColor(darkBlue[0], darkBlue[1], darkBlue[2]);
|
||||||
'DATE OF ISSUE',
|
doc.text(r.memberName, 30, boxY);
|
||||||
formatLongDate(r.issueDate)
|
|
||||||
],
|
boxY += 5.5;
|
||||||
[
|
doc.setFont('helvetica', 'normal');
|
||||||
'SHARE NUMBERS',
|
doc.setFontSize(9);
|
||||||
`${r.shareNoFrom.toLocaleString('en-IN')} – ${r.shareNoTo.toLocaleString('en-IN')}`,
|
doc.setTextColor(textDark[0], textDark[1], textDark[2]);
|
||||||
'DIVIDEND PAID',
|
let idLine = `Member ID: ${r.memberId}`;
|
||||||
`Rs. ${r.dividendYtd.toLocaleString('en-IN')}`
|
if (r.memberPan && r.memberPan !== 'Not Available' && r.memberPan !== '—') {
|
||||||
]
|
idLine += ` · PAN: ${r.memberPan}`;
|
||||||
],
|
}
|
||||||
{
|
doc.text(idLine, 30, boxY);
|
||||||
label: 'TOTAL FACE VALUE',
|
|
||||||
value: `Rs. ${(r.shareCount * r.faceValue).toLocaleString('en-IN')}`
|
boxY += 5;
|
||||||
|
doc.setFontSize(8.5);
|
||||||
|
doc.setTextColor(textMuted[0], textMuted[1], textMuted[2]);
|
||||||
|
doc.text(addrLines, 30, boxY);
|
||||||
|
|
||||||
|
y += boxH + 8;
|
||||||
|
|
||||||
|
// 8. Details Grid
|
||||||
|
const tableW = pageW - 50;
|
||||||
|
const tableColW = tableW / 4;
|
||||||
|
const tableRowH = 8.5;
|
||||||
|
|
||||||
|
const drawTableCell = (
|
||||||
|
x: number,
|
||||||
|
tblY: number,
|
||||||
|
w: number,
|
||||||
|
h: number,
|
||||||
|
text: string,
|
||||||
|
isLabel: boolean,
|
||||||
|
align: 'left' | 'right' = 'left'
|
||||||
|
) => {
|
||||||
|
if (isLabel) {
|
||||||
|
doc.setFillColor(253, 252, 247);
|
||||||
|
doc.rect(x, tblY, w, h, 'F');
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.setFontSize(8);
|
||||||
|
doc.setTextColor(gold[0], gold[1], gold[2]);
|
||||||
|
} else {
|
||||||
|
doc.setFont('times', 'bold');
|
||||||
|
doc.setFontSize(9.5);
|
||||||
|
doc.setTextColor(darkBlue[0], darkBlue[1], darkBlue[2]);
|
||||||
}
|
}
|
||||||
);
|
doc.setDrawColor(220, 209, 189);
|
||||||
|
doc.setLineWidth(0.25);
|
||||||
|
doc.rect(x, tblY, w, h, 'D');
|
||||||
|
doc.text(text, align === 'right' ? x + w - 3 : x + 3, tblY + h / 2 + 1.2, { align });
|
||||||
|
};
|
||||||
|
|
||||||
y = paintPara(
|
// Row 1
|
||||||
c,
|
drawTableCell(25, y, tableColW, tableRowH, 'Branch', true);
|
||||||
|
drawTableCell(25 + tableColW, y, tableColW, tableRowH, r.branch || 'Mumbai Main Branch', false);
|
||||||
|
drawTableCell(25 + tableColW * 2, y, tableColW, tableRowH, 'Member Since', true);
|
||||||
|
drawTableCell(25 + tableColW * 3, y, tableColW, tableRowH, formatLongDate(r.joiningDate), false);
|
||||||
|
y += tableRowH;
|
||||||
|
|
||||||
|
// Row 2
|
||||||
|
drawTableCell(25, y, tableColW, tableRowH, 'Share Class', true);
|
||||||
|
drawTableCell(25 + tableColW, y, tableColW, tableRowH, classLabel, false);
|
||||||
|
drawTableCell(25 + tableColW * 2, y, tableColW, tableRowH, 'Date of Issue', true);
|
||||||
|
drawTableCell(25 + tableColW * 3, y, tableColW, tableRowH, formatLongDate(r.issueDate), false);
|
||||||
|
y += tableRowH;
|
||||||
|
|
||||||
|
// Row 3
|
||||||
|
drawTableCell(25, y, tableColW, tableRowH, 'Share Numbers', true);
|
||||||
|
drawTableCell(
|
||||||
|
25 + tableColW,
|
||||||
y,
|
y,
|
||||||
`Given under the common seal of the bank this ${new Date().toLocaleDateString('en-IN', {
|
tableColW,
|
||||||
day: 'numeric',
|
tableRowH,
|
||||||
month: 'long',
|
`${r.shareNoFrom.toLocaleString('en-IN')} – ${r.shareNoTo.toLocaleString('en-IN')}`,
|
||||||
year: 'numeric'
|
false
|
||||||
})}.`
|
|
||||||
);
|
);
|
||||||
|
drawTableCell(25 + tableColW * 2, y, tableColW, tableRowH, 'Dividend Paid', true);
|
||||||
|
drawTableCell(25 + tableColW * 3, y, tableColW, tableRowH, `Rs. ${r.dividendYtd.toLocaleString('en-IN')}`, false);
|
||||||
|
y += tableRowH;
|
||||||
|
|
||||||
y = paintSignoff(c, y + 6, r.branch);
|
// Row 4 (Highlight)
|
||||||
paintFooter(c, y + 4);
|
doc.setFillColor(darkBlue[0], darkBlue[1], darkBlue[2]);
|
||||||
return c.doc;
|
doc.rect(25, y, tableColW * 2, tableRowH, 'F');
|
||||||
|
doc.rect(25 + tableColW * 2, y, tableColW * 2, tableRowH, 'F');
|
||||||
|
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.setFontSize(8.5);
|
||||||
|
doc.setTextColor(255, 255, 255);
|
||||||
|
doc.text('TOTAL FACE VALUE', 28, y + tableRowH / 2 + 1.2);
|
||||||
|
|
||||||
|
doc.setFont('times', 'bold');
|
||||||
|
doc.setFontSize(11);
|
||||||
|
doc.text(`Rs. ${(r.shareCount * r.faceValue).toLocaleString('en-IN')}`, pageW - 28, y + tableRowH / 2 + 1.2, {
|
||||||
|
align: 'right'
|
||||||
|
});
|
||||||
|
y += tableRowH + 8;
|
||||||
|
|
||||||
|
// 9. Footer Text (Given under common seal...)
|
||||||
|
doc.setFont('times', 'italic');
|
||||||
|
doc.setFontSize(10.5);
|
||||||
|
doc.setTextColor(textDark[0], textDark[1], textDark[2]);
|
||||||
|
|
||||||
|
const sealDay = new Date().getDate();
|
||||||
|
const sealMonthYear = new Date().toLocaleDateString('en-IN', { month: 'long', year: 'numeric' });
|
||||||
|
const sealPhrase = `Given under the common seal of the bank this ${sealDay} day of ${sealMonthYear}.`;
|
||||||
|
doc.text(sealPhrase, pageW / 2, y, { align: 'center' });
|
||||||
|
|
||||||
|
y += 8;
|
||||||
|
|
||||||
|
// 10. Seal Circle (left) and Signatory block (right)
|
||||||
|
const sealX = 50;
|
||||||
|
const sealY = y + 14;
|
||||||
|
doc.setDrawColor(gold[0], gold[1], gold[2]); // Gold
|
||||||
|
doc.setLineWidth(0.6);
|
||||||
|
doc.circle(sealX, sealY, 14, 'D');
|
||||||
|
|
||||||
|
doc.setLineWidth(0.3);
|
||||||
|
doc.setLineDashPattern(
|
||||||
|
[
|
||||||
|
1,
|
||||||
|
1
|
||||||
|
],
|
||||||
|
0
|
||||||
|
);
|
||||||
|
doc.circle(sealX, sealY, 11, 'D');
|
||||||
|
doc.setLineDashPattern([], 0);
|
||||||
|
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.setFontSize(7.5);
|
||||||
|
doc.setTextColor(gold[0], gold[1], gold[2]);
|
||||||
|
doc.text('COMMON', sealX, sealY - 1, { align: 'center' });
|
||||||
|
doc.text('SEAL', sealX, sealY + 3.5, { align: 'center' });
|
||||||
|
|
||||||
|
// Signatory Line
|
||||||
|
doc.setDrawColor(textMuted[0], textMuted[1], textMuted[2]);
|
||||||
|
doc.setLineWidth(0.3);
|
||||||
|
doc.line(pageW - 75, y + 20, pageW - 25, y + 20);
|
||||||
|
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.setFontSize(9.5);
|
||||||
|
doc.setTextColor(textDark[0], textDark[1], textDark[2]);
|
||||||
|
doc.text('Authorised Signatory', pageW - 50, y + 25, { align: 'center' });
|
||||||
|
|
||||||
|
return doc;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function downloadShareCertificatePdf(r: ShareCertificateRecord, dsc?: BankUserDsc): void {
|
export function downloadShareCertificatePdf(r: ShareCertificateRecord, dsc?: BankUserDsc): void {
|
||||||
@@ -736,7 +969,7 @@ export function buildForm60Pdf(r: Form60Record): jsPDF {
|
|||||||
|
|
||||||
let y = paintLetterhead(
|
let y = paintLetterhead(
|
||||||
c,
|
c,
|
||||||
'Form 60 Declaration',
|
'Form 97 Declaration',
|
||||||
'CERTIFICATE REFERENCE',
|
'CERTIFICATE REFERENCE',
|
||||||
`F60-${r.customerId}`,
|
`F60-${r.customerId}`,
|
||||||
'PAN Status',
|
'PAN Status',
|
||||||
@@ -836,12 +1069,10 @@ export function buildForm15ghPdf(r: Form15ghRecord, kind: Form15Kind): jsPDF {
|
|||||||
y = paintPara(
|
y = paintPara(
|
||||||
c,
|
c,
|
||||||
y,
|
y,
|
||||||
`Declaration under Section ${
|
`Declaration under Section ${kind === '15H' ? '197A(1C)' : '197A(1) / 197A(1A)'
|
||||||
kind === '15H' ? '197A(1C)' : '197A(1) / 197A(1A)'
|
} of the Income-tax Act, 1961, for non-deduction of tax at source on certain incomes — ${kind === '15H'
|
||||||
} of the Income-tax Act, 1961, for non-deduction of tax at source on certain incomes — ${
|
? 'applicable to resident individuals aged 60 years or above'
|
||||||
kind === '15H'
|
: 'applicable to resident individuals (other than companies / firms) below 60 years'
|
||||||
? 'applicable to resident individuals aged 60 years or above'
|
|
||||||
: 'applicable to resident individuals (other than companies / firms) below 60 years'
|
|
||||||
}.`
|
}.`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
+525
-525
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -151,9 +151,9 @@ export class CertificatePreviewDialogComponent {
|
|||||||
case 'balance':
|
case 'balance':
|
||||||
return 'Balance Certificate';
|
return 'Balance Certificate';
|
||||||
case 'form60':
|
case 'form60':
|
||||||
return 'Form 60 Declaration';
|
return 'Form 97 Declaration';
|
||||||
case 'form15gh':
|
case 'form15gh':
|
||||||
return this.form15gh?.kind === '15H' ? 'Form 15H Declaration' : 'Form 15G Declaration';
|
return 'Form 121 Declaration';
|
||||||
case 'hypothecation':
|
case 'hypothecation':
|
||||||
return 'Hypothecation Certificate';
|
return 'Hypothecation Certificate';
|
||||||
case 'noc':
|
case 'noc':
|
||||||
|
|||||||
@@ -89,6 +89,43 @@
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cert-head-r {
|
||||||
|
margin-left: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cert-cta-btn {
|
||||||
|
height: 38px;
|
||||||
|
padding: 0 16px;
|
||||||
|
border: 1px solid var(--cert-blue-d);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--cert-blue-d);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--cert-blue);
|
||||||
|
border-color: var(--cert-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--muted {
|
||||||
|
background: #fff;
|
||||||
|
color: var(--cert-g700);
|
||||||
|
border-color: var(--cert-g200);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--cert-g50);
|
||||||
|
border-color: var(--cert-g300);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ───────── KPI strip ───────── */
|
/* ───────── KPI strip ───────── */
|
||||||
.cert-kpis {
|
.cert-kpis {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -468,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,29 +11,60 @@
|
|||||||
<div class="cert-head-l">
|
<div class="cert-head-l">
|
||||||
<fa-icon icon="file-signature" class="cert-head-ic"></fa-icon>
|
<fa-icon icon="file-signature" class="cert-head-ic"></fa-icon>
|
||||||
<div>
|
<div>
|
||||||
<h2 class="cert-head-t">Form 15G & 15H</h2>
|
<h2 class="cert-head-t">{{ statute.form }}</h2>
|
||||||
<div class="cert-head-s">
|
<div class="cert-head-s">
|
||||||
TDS-waiver declarations for FD / RD interest income —
|
@if (viewMode === 'demo') {
|
||||||
<strong>15G</strong> for individuals below 60 years, <strong>15H</strong> for senior citizens (60+).
|
Declarations to stop tax being deducted on fixed and recurring deposit interest under
|
||||||
|
{{ statute.section }}. Reported in {{ statute.statement }}; the consolidated statement of declarations is
|
||||||
|
uploaded {{ statute.frequency }}.
|
||||||
|
} @else {
|
||||||
|
Live customer declarations, sourced from client accounts and KYC profiles.
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="cert-head-r">
|
||||||
|
@if (viewMode === 'demo') {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="cert-cta-btn"
|
||||||
|
(click)="toggleViewMode()"
|
||||||
|
matTooltip="Use real client KYC and account data from Fineract"
|
||||||
|
>
|
||||||
|
<fa-icon icon="database"></fa-icon> Real Declarations
|
||||||
|
</button>
|
||||||
|
} @else {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="cert-cta-btn cert-cta-btn--muted"
|
||||||
|
(click)="toggleViewMode()"
|
||||||
|
matTooltip="Back to the sample data page"
|
||||||
|
>
|
||||||
|
<fa-icon icon="table"></fa-icon> Sample Data
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- KPI strip -->
|
@if (viewMode === 'real') {
|
||||||
|
<mifosx-real-form15gh-view></mifosx-real-form15gh-view>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (viewMode === 'demo') {
|
||||||
|
<!-- KPI strip -->
|
||||||
<div class="cert-kpis">
|
<div class="cert-kpis">
|
||||||
<div class="cert-kpi cert-kpi--blue">
|
<div class="cert-kpi cert-kpi--blue">
|
||||||
<fa-icon icon="user" class="cert-kpi-ic"></fa-icon>
|
<fa-icon icon="user" class="cert-kpi-ic"></fa-icon>
|
||||||
<div>
|
<div>
|
||||||
<div class="cert-kpi-n">{{ count15G() | number }}</div>
|
<div class="cert-kpi-n">{{ countGeneralBand() | number }}</div>
|
||||||
<div class="cert-kpi-l">Form 15G (under 60)</div>
|
<div class="cert-kpi-l">General band (under 60)</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="cert-kpi cert-kpi--amber">
|
<div class="cert-kpi cert-kpi--amber">
|
||||||
<fa-icon icon="user-tie" class="cert-kpi-ic"></fa-icon>
|
<fa-icon icon="user-tie" class="cert-kpi-ic"></fa-icon>
|
||||||
<div>
|
<div>
|
||||||
<div class="cert-kpi-n">{{ count15H() | number }}</div>
|
<div class="cert-kpi-n">{{ countSeniorBand() | number }}</div>
|
||||||
<div class="cert-kpi-l">Form 15H (60+)</div>
|
<div class="cert-kpi-l">Senior citizen band (60+)</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="cert-kpi cert-kpi--green">
|
<div class="cert-kpi cert-kpi--green">
|
||||||
@@ -50,6 +81,22 @@
|
|||||||
<div class="cert-kpi-l">Projected Interest</div>
|
<div class="cert-kpi-l">Projected Interest</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="cert-kpi cert-kpi--green">
|
||||||
|
<fa-icon icon="shield-alt" class="cert-kpi-ic"></fa-icon>
|
||||||
|
<div>
|
||||||
|
<div class="cert-kpi-n">₹{{ totalTdsSaved() | number: '1.0-0' }}</div>
|
||||||
|
<div class="cert-kpi-l">TDS Saved</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@if (countIneligible() > 0) {
|
||||||
|
<div class="cert-kpi cert-kpi--amber">
|
||||||
|
<fa-icon icon="exclamation-triangle" class="cert-kpi-ic"></fa-icon>
|
||||||
|
<div>
|
||||||
|
<div class="cert-kpi-n">{{ countIneligible() | number }}</div>
|
||||||
|
<div class="cert-kpi-l">Not Eligible</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Filters -->
|
<!-- Filters -->
|
||||||
@@ -63,11 +110,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="cert-filter">
|
<div class="cert-filter">
|
||||||
<label>Form</label>
|
<label>Threshold Band</label>
|
||||||
<select [(ngModel)]="kindFilter">
|
<select [(ngModel)]="bandFilter">
|
||||||
<option value="">Both forms</option>
|
<option value="">All bands</option>
|
||||||
<option value="15G">15G — Under 60</option>
|
<option value="GENERAL">General (below 60)</option>
|
||||||
<option value="15H">15H — Senior citizens (60+)</option>
|
<option value="SENIOR">Senior citizen (60+)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -80,7 +127,15 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (search || kindFilter || statusFilter) {
|
<div class="cert-filter">
|
||||||
|
<label>Eligibility</label>
|
||||||
|
<select [(ngModel)]="ineligibleOnly">
|
||||||
|
<option [ngValue]="false">All customers</option>
|
||||||
|
<option [ngValue]="true">Not eligible only</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (search || bandFilter || statusFilter || ineligibleOnly) {
|
||||||
<button type="button" class="cert-clear" (click)="clearFilters()"><fa-icon icon="times"></fa-icon> Clear</button>
|
<button type="button" class="cert-clear" (click)="clearFilters()"><fa-icon icon="times"></fa-icon> Clear</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -92,54 +147,92 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Customer · PAN</th>
|
<th>Customer · PAN</th>
|
||||||
<th>Form</th>
|
<th>Form · Band</th>
|
||||||
<th>Age</th>
|
<th>Age</th>
|
||||||
<th>Branch</th>
|
<th>Branch</th>
|
||||||
<th class="num">Deposit Principal</th>
|
<th class="num">Est. Interest</th>
|
||||||
<th class="num">Est. Interest ({{ records[0]?.fy }})</th>
|
<th class="num">Threshold</th>
|
||||||
|
<th class="num">TDS Payable</th>
|
||||||
|
<th class="num">TDS Saved</th>
|
||||||
|
<th>Settlement</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th class="col-actions">Actions</th>
|
<th class="col-actions">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@for (r of filteredRecords; track r.customerId) {
|
@for (row of filteredRows; track row.record.customerId) {
|
||||||
<tr>
|
<tr [class.cert-row--warn]="!row.eligibility.eligible">
|
||||||
<td>
|
<td>
|
||||||
<div class="cert-cust-name">{{ r.customerName }}</div>
|
<div class="cert-cust-name">{{ row.record.customerName }}</div>
|
||||||
<div class="mono small muted">{{ r.customerId }} · PAN {{ r.customerPan }}</div>
|
<div class="mono small muted">{{ row.record.customerId }} · PAN {{ row.record.customerPan }}</div>
|
||||||
<div class="small muted">DOB {{ r.dob | date: 'd MMM y' }}</div>
|
<div class="small muted">DOB {{ row.record.dob | date: 'd MMM y' }}</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span
|
<span
|
||||||
class="cert-pill"
|
class="cert-pill"
|
||||||
[class.cert-pill--blue]="formKind(r) === '15G'"
|
[class.cert-pill--blue]="row.band === 'GENERAL'"
|
||||||
[class.cert-pill--amber]="formKind(r) === '15H'"
|
[class.cert-pill--amber]="row.band === 'SENIOR'"
|
||||||
>
|
>
|
||||||
Form {{ formKind(r) }}
|
{{ row.form === '121' ? 'Form 121' : 'Form ' + row.form }}
|
||||||
</span>
|
</span>
|
||||||
|
<div class="small muted">{{ row.band === 'SENIOR' ? 'Senior citizen' : 'General' }}</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="small">{{ age(r.dob) }} yrs</td>
|
<td class="small">{{ row.age }} yrs</td>
|
||||||
<td class="small">{{ r.branch }}</td>
|
<td class="small">{{ row.record.branch }}</td>
|
||||||
<td class="num">₹{{ r.depositPrincipal | number: '1.0-0' }}</td>
|
|
||||||
<td class="num">
|
<td class="num">
|
||||||
<strong class="cert-interest">₹{{ r.estimatedInterest | number: '1.0-0' }}</strong>
|
<strong class="cert-interest">₹{{ row.record.estimatedInterest | number: '1.0-0' }}</strong>
|
||||||
|
</td>
|
||||||
|
<td class="num small muted">₹{{ row.schedule.threshold | number: '1.0-0' }}</td>
|
||||||
|
<td class="num">
|
||||||
|
@if (row.schedule.totalTds > 0) {
|
||||||
|
<strong>₹{{ row.schedule.totalTds | number: '1.0-0' }}</strong>
|
||||||
|
<div class="small muted">@ {{ row.schedule.rateApplied * 100 | number: '1.0-0' }}%</div>
|
||||||
|
} @else {
|
||||||
|
<span class="small muted">—</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td class="num">
|
||||||
|
@if (row.tdsSaved > 0) {
|
||||||
|
<strong class="cert-interest">₹{{ row.tdsSaved | number: '1.0-0' }}</strong>
|
||||||
|
} @else {
|
||||||
|
<span class="small muted">—</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td class="small">
|
||||||
|
<span class="cert-pill" [class.cert-pill--blue]="row.mode === 'QUARTERLY'">
|
||||||
|
{{ row.mode === 'QUARTERLY' ? 'Quarterly' : 'Year end' }}
|
||||||
|
</span>
|
||||||
|
<div class="small muted">product-driven</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
@if (r.status === 'FILED') {
|
@if (row.record.status === 'FILED') {
|
||||||
<span class="cert-pill cert-pill--green"> <fa-icon icon="check-circle"></fa-icon> Filed </span>
|
<span class="cert-pill cert-pill--green"> <fa-icon icon="check-circle"></fa-icon> Filed </span>
|
||||||
@if (r.filedOn) {
|
@if (row.record.filedOn) {
|
||||||
<div class="mono small muted">{{ r.filedOn | date: 'd MMM y' }}</div>
|
<div class="mono small muted">{{ row.record.filedOn | date: 'd MMM y' }}</div>
|
||||||
}
|
}
|
||||||
} @else {
|
} @else {
|
||||||
<span class="cert-pill cert-pill--amber"> <fa-icon icon="hourglass-half"></fa-icon> Pending </span>
|
<span class="cert-pill cert-pill--amber"> <fa-icon icon="hourglass-half"></fa-icon> Pending </span>
|
||||||
}
|
}
|
||||||
|
@if (!row.eligibility.eligible) {
|
||||||
|
<div class="cert-pill cert-pill--amber">
|
||||||
|
<fa-icon icon="exclamation-triangle"></fa-icon> Not eligible
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</td>
|
</td>
|
||||||
<td class="col-actions">
|
<td class="col-actions">
|
||||||
<div class="cert-row-actions">
|
<div class="cert-row-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="cert-icon-btn"
|
||||||
|
(click)="toggleExpanded(row)"
|
||||||
|
[matTooltip]="isExpanded(row) ? 'Hide quarter-wise' : 'Show quarter-wise'"
|
||||||
|
>
|
||||||
|
<fa-icon [icon]="isExpanded(row) ? 'chevron-up' : 'chevron-down'"></fa-icon>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="cert-icon-btn primary"
|
class="cert-icon-btn primary"
|
||||||
(click)="viewCertificate(r)"
|
(click)="viewCertificate(row)"
|
||||||
matTooltip="View / Print declaration"
|
matTooltip="View / Print declaration"
|
||||||
>
|
>
|
||||||
<fa-icon icon="eye"></fa-icon>
|
<fa-icon icon="eye"></fa-icon>
|
||||||
@@ -147,7 +240,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="cert-icon-btn"
|
class="cert-icon-btn"
|
||||||
(click)="downloadCertificate(r)"
|
(click)="downloadCertificate(row)"
|
||||||
matTooltip="Download PDF"
|
matTooltip="Download PDF"
|
||||||
>
|
>
|
||||||
<fa-icon icon="download"></fa-icon>
|
<fa-icon icon="download"></fa-icon>
|
||||||
@@ -155,10 +248,100 @@
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
|
@if (isExpanded(row)) {
|
||||||
|
<tr class="cert-row--detail">
|
||||||
|
<td colspan="11">
|
||||||
|
@if (!row.eligibility.eligible) {
|
||||||
|
<div class="cert-note cert-note--warn">
|
||||||
|
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||||
|
<div>
|
||||||
|
<strong>Declaration cannot be accepted</strong>
|
||||||
|
<ul>
|
||||||
|
@for (reason of row.eligibility.reasons; track reason) {
|
||||||
|
<li>{{ reason }}</li>
|
||||||
|
}
|
||||||
|
</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 (filteredRecords.length === 0) {
|
@if (filteredRows.length === 0) {
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="8" class="cert-empty">
|
<td colspan="11" class="cert-empty">
|
||||||
<fa-icon icon="info-circle"></fa-icon>
|
<fa-icon icon="info-circle"></fa-icon>
|
||||||
No declarations match the current filters.
|
No declarations match the current filters.
|
||||||
</td>
|
</td>
|
||||||
@@ -168,4 +351,5 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,9 +21,31 @@ import {
|
|||||||
} from '../certificate-preview-dialog/certificate-preview-dialog.component';
|
} from '../certificate-preview-dialog/certificate-preview-dialog.component';
|
||||||
import { downloadForm15ghPdf } from '../certificate-pdf';
|
import { downloadForm15ghPdf } from '../certificate-pdf';
|
||||||
import { DscSignService } from '../../dsc-sign-dialog/dsc-sign.service';
|
import { DscSignService } from '../../dsc-sign-dialog/dsc-sign.service';
|
||||||
|
import { RealForm15ghViewComponent } from './real-form15gh-view/real-form15gh-view.component';
|
||||||
|
|
||||||
export type Form15Kind = '15G' | '15H';
|
import {
|
||||||
|
Declaration,
|
||||||
|
DeclarationForm,
|
||||||
|
EligibilityResult,
|
||||||
|
FY_TAX_CONFIG,
|
||||||
|
Form15Kind,
|
||||||
|
TdsMode,
|
||||||
|
TdsSchedule,
|
||||||
|
ageInFy,
|
||||||
|
buildSchedule,
|
||||||
|
checkEligibility,
|
||||||
|
declarationFormFor,
|
||||||
|
formKindFor,
|
||||||
|
isSeniorInFy,
|
||||||
|
normaliseFy,
|
||||||
|
projectQuarterlyCredits,
|
||||||
|
tdsModeFor
|
||||||
|
} from './tds-15gh.compute';
|
||||||
|
|
||||||
|
export type { Form15Kind };
|
||||||
export type Form15Status = 'FILED' | 'PENDING';
|
export type Form15Status = 'FILED' | 'PENDING';
|
||||||
|
/** Which deduction trigger the payee falls under. Age-based, still. */
|
||||||
|
export type ThresholdBand = 'GENERAL' | 'SENIOR';
|
||||||
|
|
||||||
export interface Form15ghRecord {
|
export interface Form15ghRecord {
|
||||||
customerId: string;
|
customerId: string;
|
||||||
@@ -32,18 +54,46 @@ export interface Form15ghRecord {
|
|||||||
customerAddress: string;
|
customerAddress: string;
|
||||||
dob: string;
|
dob: string;
|
||||||
branch: string;
|
branch: string;
|
||||||
/** Aggregate interest the bank will pay this FY. */
|
/** Aggregate interest the bank will pay this FY across the customer's time deposits. */
|
||||||
estimatedInterest: number;
|
estimatedInterest: number;
|
||||||
/** Customer's total estimated income for the FY. */
|
/** Customer's total estimated income for the FY, as declared by them. */
|
||||||
estimatedIncome: number;
|
estimatedIncome: number;
|
||||||
/** Sum of FD / RD principals tied to the declaration. */
|
/** Sum of FD / RD principals tied to the declaration. */
|
||||||
depositPrincipal: number;
|
depositPrincipal: number;
|
||||||
fy: string;
|
fy: string;
|
||||||
status: Form15Status;
|
status: Form15Status;
|
||||||
filedOn?: string;
|
filedOn?: string;
|
||||||
/** "Yes / No" for: has any income tax been assessed for the customer
|
/**
|
||||||
under the IT Act in the past six years? */
|
* "Yes / No" for: has any income tax been assessed for the customer under the
|
||||||
|
* IT Act in the past six years?
|
||||||
|
*/
|
||||||
assessed: boolean;
|
assessed: boolean;
|
||||||
|
/**
|
||||||
|
* The deposit product's interest-posting frequency. Tax falls due when interest
|
||||||
|
* is credited, so this — and not any customer preference — decides whether it
|
||||||
|
* settles quarterly or at year end.
|
||||||
|
*/
|
||||||
|
interestPostingPeriodType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A register row with everything the engine derived for it, computed once. */
|
||||||
|
export interface Form15ghRow {
|
||||||
|
record: Form15ghRecord;
|
||||||
|
/** The form actually in force for the record's year — '121' from FY 2026-27. */
|
||||||
|
form: DeclarationForm;
|
||||||
|
/**
|
||||||
|
* The 15G/15H label. Only a form distinction up to FY 2025-26; from FY 2026-27
|
||||||
|
* the forms merged and this reflects the threshold band alone.
|
||||||
|
*/
|
||||||
|
kind: Form15Kind;
|
||||||
|
band: ThresholdBand;
|
||||||
|
age: number;
|
||||||
|
mode: TdsMode;
|
||||||
|
/** Schedule as things stand, declaration included where one is on record. */
|
||||||
|
schedule: TdsSchedule;
|
||||||
|
/** Tax the declaration kept out of the customer's pocket this year. */
|
||||||
|
tdsSaved: number;
|
||||||
|
eligibility: EligibilityResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -55,7 +105,8 @@ export interface Form15ghRecord {
|
|||||||
CommonModule,
|
CommonModule,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
FaIconComponent,
|
FaIconComponent,
|
||||||
MatTooltip
|
MatTooltip,
|
||||||
|
RealForm15ghViewComponent
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class Form15ghComponent implements OnInit {
|
export class Form15ghComponent implements OnInit {
|
||||||
@@ -63,40 +114,89 @@ export class Form15ghComponent implements OnInit {
|
|||||||
private snack = inject(MatSnackBar);
|
private snack = inject(MatSnackBar);
|
||||||
private dscSign = inject(DscSignService);
|
private dscSign = inject(DscSignService);
|
||||||
|
|
||||||
|
/** Mode toggle: 'demo' uses mock seed data; 'real' pulls live Fineract data. */
|
||||||
|
viewMode: 'demo' | 'real' = 'demo';
|
||||||
|
|
||||||
// ── Filters ──
|
// ── Filters ──
|
||||||
search = '';
|
search = '';
|
||||||
/** Which form. Empty = both. */
|
/** Which deduction trigger. Empty = both bands. */
|
||||||
kindFilter: '' | Form15Kind = '';
|
bandFilter: '' | ThresholdBand = '';
|
||||||
statusFilter: '' | Form15Status = '';
|
statusFilter: '' | Form15Status = '';
|
||||||
|
/** Show only declarations the branch cannot lawfully accept. */
|
||||||
|
ineligibleOnly = false;
|
||||||
|
|
||||||
records: Form15ghRecord[] = [];
|
/** Which row's quarter-wise breakdown is open. */
|
||||||
|
expandedId: string | null = null;
|
||||||
|
|
||||||
|
rows: Form15ghRow[] = [];
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.records = seedForm15gh();
|
// Derived once. The template reads these many times per change-detection
|
||||||
|
// pass, and re-running the schedules on every read is what made the other
|
||||||
|
// register screens stall.
|
||||||
|
this.rows = seedForm15gh().map((record) => this.buildRow(record));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Compute age in years from DoB. */
|
private buildRow(record: Form15ghRecord): Form15ghRow {
|
||||||
age(dob: string): number {
|
const ctx = {
|
||||||
if (!dob) return 0;
|
fy: record.fy,
|
||||||
const today = new Date();
|
dateOfBirth: record.dob,
|
||||||
const birth = new Date(dob);
|
pan: record.customerPan,
|
||||||
let age = today.getFullYear() - birth.getFullYear();
|
resident: true
|
||||||
const m = today.getMonth() - birth.getMonth();
|
};
|
||||||
if (m < 0 || (m === 0 && today.getDate() < birth.getDate())) age--;
|
|
||||||
return age;
|
const declaration: Declaration | null =
|
||||||
|
record.status === 'FILED'
|
||||||
|
? {
|
||||||
|
kind: formKindFor(record.dob, record.fy),
|
||||||
|
// The register holds the filing date; a declaration operates from
|
||||||
|
// the day the branch accepted it.
|
||||||
|
acceptedOn: record.filedOn ?? `${record.fy.replace(/\D/g, '').slice(0, 4)}-04-01`,
|
||||||
|
fy: record.fy
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const mode = tdsModeFor({ interestPostingPeriodType: record.interestPostingPeriodType });
|
||||||
|
// The register carries a projected annual figure rather than real postings,
|
||||||
|
// so spread it across the year to drive the schedule.
|
||||||
|
const credits = projectQuarterlyCredits(record.estimatedInterest, record.fy);
|
||||||
|
|
||||||
|
const schedule = buildSchedule(mode, credits, { ...ctx, declaration });
|
||||||
|
const withoutDeclaration = buildSchedule(mode, credits, { ...ctx, declaration: null });
|
||||||
|
|
||||||
|
return {
|
||||||
|
record,
|
||||||
|
form: declarationFormFor(record.fy, record.dob),
|
||||||
|
kind: formKindFor(record.dob, record.fy),
|
||||||
|
band: isSeniorInFy(record.dob, record.fy) ? 'SENIOR' : 'GENERAL',
|
||||||
|
age: ageInFy(record.dob, record.fy),
|
||||||
|
mode,
|
||||||
|
schedule,
|
||||||
|
tdsSaved: declaration ? Math.max(0, withoutDeclaration.totalTds - schedule.totalTds) : 0,
|
||||||
|
eligibility: checkEligibility(ctx, {
|
||||||
|
estimatedInterest: record.estimatedInterest,
|
||||||
|
estimatedIncome: record.estimatedIncome,
|
||||||
|
incomeOnRecord: true
|
||||||
|
})
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 15H once 60+, 15G otherwise. */
|
get filteredRows(): Form15ghRow[] {
|
||||||
formKind(r: Form15ghRecord): Form15Kind {
|
|
||||||
return this.age(r.dob) >= 60 ? '15H' : '15G';
|
|
||||||
}
|
|
||||||
|
|
||||||
get filteredRecords(): Form15ghRecord[] {
|
|
||||||
const q = this.search.trim().toLowerCase();
|
const q = this.search.trim().toLowerCase();
|
||||||
return this.records.filter((r) => {
|
return this.rows.filter((row) => {
|
||||||
if (this.kindFilter && this.formKind(r) !== this.kindFilter) return false;
|
if (this.bandFilter && row.band !== this.bandFilter) {
|
||||||
if (this.statusFilter && r.status !== this.statusFilter) return false;
|
return false;
|
||||||
if (!q) return true;
|
}
|
||||||
|
if (this.statusFilter && row.record.status !== this.statusFilter) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (this.ineligibleOnly && row.eligibility.eligible) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!q) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const r = row.record;
|
||||||
return (
|
return (
|
||||||
r.customerName.toLowerCase().includes(q) ||
|
r.customerName.toLowerCase().includes(q) ||
|
||||||
r.customerId.toLowerCase().includes(q) ||
|
r.customerId.toLowerCase().includes(q) ||
|
||||||
@@ -105,32 +205,67 @@ export class Form15ghComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
count15G(): number {
|
countGeneralBand(): number {
|
||||||
return this.records.filter((r) => this.formKind(r) === '15G').length;
|
return this.rows.filter((r) => r.band === 'GENERAL').length;
|
||||||
}
|
}
|
||||||
|
|
||||||
count15H(): number {
|
countSeniorBand(): number {
|
||||||
return this.records.filter((r) => this.formKind(r) === '15H').length;
|
return this.rows.filter((r) => r.band === 'SENIOR').length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The declaration in force across the register, and the provision charging the
|
||||||
|
* tax. Both changed on 01 April 2026, so they are read from the config rather
|
||||||
|
* than written into the template.
|
||||||
|
*/
|
||||||
|
get statute(): { form: string; section: string; statement: string; frequency: string } {
|
||||||
|
const fy = normaliseFy(this.rows[0]?.record.fy ?? '2026-27');
|
||||||
|
const cfg = FY_TAX_CONFIG[fy];
|
||||||
|
return {
|
||||||
|
form: cfg.declarationForm === '121' ? 'Form 121' : 'Form 15G / 15H',
|
||||||
|
section: cfg.interestTdsSection,
|
||||||
|
statement: cfg.tdsStatementForm,
|
||||||
|
frequency: cfg.declarationStatementFrequency === 'MONTHLY' ? 'monthly' : 'quarterly'
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
countFiled(): number {
|
countFiled(): number {
|
||||||
return this.records.filter((r) => r.status === 'FILED').length;
|
return this.rows.filter((r) => r.record.status === 'FILED').length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Declarations on record that the branch could not lawfully have accepted. */
|
||||||
|
countIneligible(): number {
|
||||||
|
return this.rows.filter((r) => !r.eligibility.eligible).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
totalInterest(): number {
|
totalInterest(): number {
|
||||||
return this.filteredRecords.reduce((s, r) => s + r.estimatedInterest, 0);
|
return this.filteredRows.reduce((s, r) => s + r.record.estimatedInterest, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tax the declarations kept out of customers' pockets, across the filter. */
|
||||||
|
totalTdsSaved(): number {
|
||||||
|
return this.filteredRows.reduce((s, r) => s + r.tdsSaved, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
clearFilters(): void {
|
clearFilters(): void {
|
||||||
this.search = '';
|
this.search = '';
|
||||||
this.kindFilter = '';
|
this.bandFilter = '';
|
||||||
this.statusFilter = '';
|
this.statusFilter = '';
|
||||||
|
this.ineligibleOnly = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
viewCertificate(r: Form15ghRecord): void {
|
toggleExpanded(row: Form15ghRow): void {
|
||||||
|
this.expandedId = this.expandedId === row.record.customerId ? null : row.record.customerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
isExpanded(row: Form15ghRow): boolean {
|
||||||
|
return this.expandedId === row.record.customerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
viewCertificate(row: Form15ghRow): void {
|
||||||
const data: CertificatePreviewDialogData = {
|
const data: CertificatePreviewDialogData = {
|
||||||
kind: 'form15gh',
|
kind: 'form15gh',
|
||||||
form15gh: { record: r, kind: this.formKind(r), age: this.age(r.dob) }
|
form15gh: { record: row.record, kind: row.kind, age: row.age }
|
||||||
};
|
};
|
||||||
this.dialog.open(CertificatePreviewDialogComponent, {
|
this.dialog.open(CertificatePreviewDialogComponent, {
|
||||||
width: '880px',
|
width: '880px',
|
||||||
@@ -142,20 +277,34 @@ export class Form15ghComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadCertificate(r: Form15ghRecord): void {
|
downloadCertificate(row: Form15ghRow): void {
|
||||||
const kind = this.formKind(r);
|
const r = row.record;
|
||||||
|
// A declaration the branch cannot accept must not go out as a signed PDF.
|
||||||
|
if (!row.eligibility.eligible) {
|
||||||
|
this.snack.open(`Cannot issue: ${row.eligibility.reasons[0]}`, 'Dismiss', { duration: 6000 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.dscSign.promptThenDownload(
|
this.dscSign.promptThenDownload(
|
||||||
{
|
{
|
||||||
title: `Form ${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: `Form-${kind}-${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'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user