feat(clients, alm): add CIN/MCA verification & authorized signatories tab; refactor ALM report print and export
This commit is contained in:
+110
-5
@@ -136,7 +136,14 @@
|
|||||||
| translate
|
| translate
|
||||||
}}
|
}}
|
||||||
</mat-label>
|
</mat-label>
|
||||||
<input matInput required formControlName="fullname" autocomplete="off" />
|
<input
|
||||||
|
matInput
|
||||||
|
required
|
||||||
|
formControlName="fullname"
|
||||||
|
autocomplete="off"
|
||||||
|
(blur)="capitalizeControl('fullname')"
|
||||||
|
style="text-transform: capitalize"
|
||||||
|
/>
|
||||||
@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')) {
|
||||||
@@ -151,7 +158,9 @@
|
|||||||
<div class="full-name-row full-width">
|
<div class="full-name-row full-width">
|
||||||
<div class="full-name-preview">
|
<div class="full-name-preview">
|
||||||
<span class="full-name-label">Entity Name:</span>
|
<span class="full-name-label">Entity Name:</span>
|
||||||
<span class="full-name-value">{{ createClientForm.get('fullname')?.value }}</span>
|
<span class="full-name-value" style="text-transform: capitalize">{{
|
||||||
|
createClientForm.get('fullname')?.value
|
||||||
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="verify-inline">
|
<div class="verify-inline">
|
||||||
@if (!sanctionsVerified && !sanctionsBlocked) {
|
@if (!sanctionsVerified && !sanctionsBlocked) {
|
||||||
@@ -214,7 +223,14 @@
|
|||||||
<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" autocomplete="off" />
|
<input
|
||||||
|
matInput
|
||||||
|
required
|
||||||
|
formControlName="firstname"
|
||||||
|
autocomplete="off"
|
||||||
|
(blur)="capitalizeControl('firstname')"
|
||||||
|
style="text-transform: capitalize"
|
||||||
|
/>
|
||||||
@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 +244,13 @@
|
|||||||
<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" autocomplete="off" />
|
<input
|
||||||
|
matInput
|
||||||
|
formControlName="middlename"
|
||||||
|
autocomplete="off"
|
||||||
|
(blur)="capitalizeControl('middlename')"
|
||||||
|
style="text-transform: capitalize"
|
||||||
|
/>
|
||||||
@if (
|
@if (
|
||||||
createClientForm.controls.middlename?.hasError('pattern') &&
|
createClientForm.controls.middlename?.hasError('pattern') &&
|
||||||
createClientForm.controls.middlename?.touched
|
createClientForm.controls.middlename?.touched
|
||||||
@@ -241,7 +263,14 @@
|
|||||||
<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" autocomplete="off" />
|
<input
|
||||||
|
matInput
|
||||||
|
required
|
||||||
|
formControlName="lastname"
|
||||||
|
autocomplete="off"
|
||||||
|
(blur)="capitalizeControl('lastname')"
|
||||||
|
style="text-transform: capitalize"
|
||||||
|
/>
|
||||||
@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')) {
|
||||||
@@ -694,6 +723,9 @@
|
|||||||
}
|
}
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
|
|
||||||
|
<!-- Date of Incorporation is captured on the KYC step, next to the entity PAN it is
|
||||||
|
verified against — see client-kyc-step's `incorporationDate` control. -->
|
||||||
|
|
||||||
<!-- Authorized Signatories (multiple, registered clients) -->
|
<!-- Authorized Signatories (multiple, registered clients) -->
|
||||||
<div class="form-field full-width signatory-section">
|
<div class="form-field full-width signatory-section">
|
||||||
<h4 class="section-title signatory-title">
|
<h4 class="section-title signatory-title">
|
||||||
@@ -1148,6 +1180,79 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Section: Turnover & Risk (Non-Individual only)
|
||||||
|
|
||||||
|
An entity has no salary to declare, so the individual Financial & Classification section below
|
||||||
|
never applied to it — which left a company with no turnover on file at all, and its risk stuck
|
||||||
|
on the flat entity default. Turnover is the size signal the branch actually has at onboarding,
|
||||||
|
so it is captured here and the risk band it produces is shown right beside it: the officer sees
|
||||||
|
the grade move as they type, instead of first meeting it on the KYC tab.
|
||||||
|
-->
|
||||||
|
@if (createClientForm.get('legalFormId')?.value === LegalFormId.ENTITY) {
|
||||||
|
<div class="section-block">
|
||||||
|
<h4 class="section-title">
|
||||||
|
<fa-icon icon="chart-line" class="section-icon"></fa-icon>
|
||||||
|
Turnover & Risk Profile
|
||||||
|
</h4>
|
||||||
|
<div class="form-grid">
|
||||||
|
<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" />
|
||||||
|
<mat-hint>Yearly turnover fills in automatically (× 12)</mat-hint>
|
||||||
|
@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>Annual Turnover</mat-label>
|
||||||
|
<span matPrefix class="rupee-prefix">₹</span>
|
||||||
|
<input matInput type="number" formControlName="annualTurnover" min="0" />
|
||||||
|
<mat-hint>Drives the risk grade below</mat-hint>
|
||||||
|
@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 entity-risk-card"
|
||||||
|
[class.entity-risk-card--low]="entityTurnoverRisk.category === 1"
|
||||||
|
[class.entity-risk-card--medium]="entityTurnoverRisk.category === 2"
|
||||||
|
[class.entity-risk-card--high]="entityTurnoverRisk.category === 3"
|
||||||
|
>
|
||||||
|
<div class="entity-risk-card__head">
|
||||||
|
<fa-icon icon="shield-alt" class="entity-risk-card__icon"></fa-icon>
|
||||||
|
<span class="entity-risk-card__title">Auto-calculated Risk Category</span>
|
||||||
|
<span class="entity-risk-card__badge">{{ riskCategoryLabel(entityTurnoverRisk.category) }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="entity-risk-card__reason">{{ entityTurnoverRisk.reason }}</p>
|
||||||
|
<p class="entity-risk-card__scale">
|
||||||
|
Up to ₹1 crore — Low · ₹1 crore to ₹10 crore — Medium · Above ₹10 crore — High
|
||||||
|
</p>
|
||||||
|
<p class="entity-risk-card__note">
|
||||||
|
Carried into the KYC tab, where it can still be overridden with a recorded reason.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<!-- Section: Financial & Classification (Individual only) -->
|
<!-- Section: Financial & Classification (Individual only) -->
|
||||||
@if (createClientForm.get('legalFormId')?.value === LegalFormId.PERSON) {
|
@if (createClientForm.get('legalFormId')?.value === LegalFormId.PERSON) {
|
||||||
<div class="section-block">
|
<div class="section-block">
|
||||||
|
|||||||
@@ -56,6 +56,103 @@ form {
|
|||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Live risk grade beside the entity's turnover fields. Neutral by default; the accent tracks the
|
||||||
|
band so a jump from Medium to High is visible without reading the sentence. */
|
||||||
|
.entity-risk-card {
|
||||||
|
margin: 4px 0 12px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-left: 4px solid #94a3b8;
|
||||||
|
|
||||||
|
&__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__icon {
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__badge {
|
||||||
|
margin-left: auto;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
background: #e2e8f0;
|
||||||
|
color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__reason {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__scale,
|
||||||
|
&__note {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--low {
|
||||||
|
background: #f0fdf4;
|
||||||
|
border-color: #bbf7d0;
|
||||||
|
border-left-color: #16a34a;
|
||||||
|
|
||||||
|
.entity-risk-card__icon {
|
||||||
|
color: #16a34a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-risk-card__badge {
|
||||||
|
background: #dcfce7;
|
||||||
|
color: #15803d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--medium {
|
||||||
|
background: #fffbeb;
|
||||||
|
border-color: #fde68a;
|
||||||
|
border-left-color: #d97706;
|
||||||
|
|
||||||
|
.entity-risk-card__icon {
|
||||||
|
color: #d97706;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-risk-card__badge {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #b45309;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--high {
|
||||||
|
background: #fef2f2;
|
||||||
|
border-color: #fecaca;
|
||||||
|
border-left-color: #dc2626;
|
||||||
|
|
||||||
|
.entity-risk-card__icon {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-risk-card__badge {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.senior-citizen-auto-note {
|
.senior-citizen-auto-note {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
+179
-24
@@ -32,6 +32,7 @@ import { filter, switchMap, takeUntil } from 'rxjs/operators';
|
|||||||
import { ClientsService } from 'app/clients/clients.service';
|
import { ClientsService } from 'app/clients/clients.service';
|
||||||
import { Dates } from 'app/core/utils/dates';
|
import { Dates } from 'app/core/utils/dates';
|
||||||
import { LegalFormId } from 'app/clients/models/legal-form.enum';
|
import { LegalFormId } from 'app/clients/models/legal-form.enum';
|
||||||
|
import { toProperNameCase } from 'app/clients/utils/name-case.util';
|
||||||
import { ExternalNationalIdService } from 'app/clients/services/external-national-id.service';
|
import { ExternalNationalIdService } from 'app/clients/services/external-national-id.service';
|
||||||
|
|
||||||
/** Custom Services */
|
/** Custom Services */
|
||||||
@@ -654,6 +655,11 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
if (this.createClientForm.get('guardianDetails')) {
|
if (this.createClientForm.get('guardianDetails')) {
|
||||||
this.createClientForm.removeControl('guardianDetails');
|
this.createClientForm.removeControl('guardianDetails');
|
||||||
}
|
}
|
||||||
|
// Same reasoning as the guardian above: the reused component instance would otherwise carry the
|
||||||
|
// previous entity's authorized signatories into a brand new client.
|
||||||
|
this.signatories = [];
|
||||||
|
this.signatorySearchText = '';
|
||||||
|
this.signatorySearchResults = [];
|
||||||
// Verification state must not carry over — a previous client's cleared sanctions
|
// 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.
|
// screening or verified OTP must not silently apply to a different new client.
|
||||||
this.mobileVerified = false;
|
this.mobileVerified = false;
|
||||||
@@ -777,8 +783,13 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
incorpNumber: data.clientNonPersonDetails.incorpNumber || '',
|
incorpNumber: data.clientNonPersonDetails.incorpNumber || '',
|
||||||
remarks: data.clientNonPersonDetails.remarks || '',
|
remarks: data.clientNonPersonDetails.remarks || '',
|
||||||
authorizedSignatory: data.clientNonPersonDetails.authorizedSignatory || '',
|
authorizedSignatory: data.clientNonPersonDetails.authorizedSignatory || '',
|
||||||
|
authorizedSignatoryClientIds: data.clientNonPersonDetails.authorizedSignatoryClientIds || '',
|
||||||
additionalInfo: data.clientNonPersonDetails.additionalInfo || ''
|
additionalInfo: data.clientNonPersonDetails.additionalInfo || ''
|
||||||
});
|
});
|
||||||
|
this.restoreSignatories(
|
||||||
|
data.clientNonPersonDetails.authorizedSignatoryClientIds,
|
||||||
|
data.clientNonPersonDetails.authorizedSignatory
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -971,7 +982,8 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Auto-calculates turnover based on income (doubling monthly income for monthly turnover,
|
* Auto-calculates turnover based on income (doubling monthly income for monthly turnover,
|
||||||
* and annual income for annual/yearly turnover).
|
* and annual income for annual/yearly turnover), but only if the turnover fields
|
||||||
|
* have not been manually modified or cleared.
|
||||||
*/
|
*/
|
||||||
private linkIncomeToTurnover(): void {
|
private linkIncomeToTurnover(): void {
|
||||||
const monthlyIncome = this.createClientForm.get('monthlyIncome');
|
const monthlyIncome = this.createClientForm.get('monthlyIncome');
|
||||||
@@ -979,23 +991,33 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
const annualIncome = this.createClientForm.get('annualIncome');
|
const annualIncome = this.createClientForm.get('annualIncome');
|
||||||
const annualTurnover = this.createClientForm.get('annualTurnover');
|
const annualTurnover = this.createClientForm.get('annualTurnover');
|
||||||
|
|
||||||
if (monthlyIncome && monthlyTurnover) {
|
const isEmpty = (val: any) => val === '' || val === null || val === undefined;
|
||||||
|
|
||||||
|
if (monthlyIncome && monthlyTurnover && annualTurnover) {
|
||||||
monthlyIncome.valueChanges.subscribe((value) => {
|
monthlyIncome.valueChanges.subscribe((value) => {
|
||||||
if (this.suppressMonthlyAnnualLink) {
|
if (this.suppressMonthlyAnnualLink) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const num = parseFloat(value);
|
const isPristine = monthlyTurnover.pristine && annualTurnover.pristine;
|
||||||
monthlyTurnover.setValue(!isNaN(num) && num >= 0 ? this.round2(num * 2) : '', { emitEvent: true });
|
const isTurnoverEmpty = isEmpty(monthlyTurnover.value) && isEmpty(annualTurnover.value);
|
||||||
|
if (isPristine || isTurnoverEmpty) {
|
||||||
|
const num = parseFloat(value);
|
||||||
|
monthlyTurnover.setValue(!isNaN(num) && num >= 0 ? this.round2(num * 2) : '', { emitEvent: true });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (annualIncome && annualTurnover) {
|
if (annualIncome && annualTurnover && monthlyTurnover) {
|
||||||
annualIncome.valueChanges.subscribe((value) => {
|
annualIncome.valueChanges.subscribe((value) => {
|
||||||
if (this.suppressMonthlyAnnualLink) {
|
if (this.suppressMonthlyAnnualLink) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const num = parseFloat(value);
|
const isPristine = monthlyTurnover.pristine && annualTurnover.pristine;
|
||||||
annualTurnover.setValue(!isNaN(num) && num >= 0 ? this.round2(num * 2) : '', { emitEvent: true });
|
const isTurnoverEmpty = isEmpty(monthlyTurnover.value) && isEmpty(annualTurnover.value);
|
||||||
|
if (isPristine || isTurnoverEmpty) {
|
||||||
|
const num = parseFloat(value);
|
||||||
|
annualTurnover.setValue(!isNaN(num) && num >= 0 ? this.round2(num * 2) : '', { emitEvent: true });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1122,6 +1144,10 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
mainBusinessLineId: [''],
|
mainBusinessLineId: [''],
|
||||||
incorpNumber: [''],
|
incorpNumber: [''],
|
||||||
authorizedSignatory: [''],
|
authorizedSignatory: [''],
|
||||||
|
// Client ids behind the names above, in the same order — what lets an edit rebuild the
|
||||||
|
// signatory rows with their client links and PAN/DIN state. No input is bound to it;
|
||||||
|
// syncSignatoryNames() keeps it in step with the chips.
|
||||||
|
authorizedSignatoryClientIds: [''],
|
||||||
additionalInfo: [''],
|
additionalInfo: [''],
|
||||||
remarks: ['']
|
remarks: ['']
|
||||||
})
|
})
|
||||||
@@ -1343,6 +1369,17 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
this.removeGuardian();
|
this.removeGuardian();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
capitalizeControl(controlName: string) {
|
||||||
|
const control = this.createClientForm.get(controlName);
|
||||||
|
if (control && control.value) {
|
||||||
|
const originalValue = control.value;
|
||||||
|
const capitalized = toProperNameCase(originalValue);
|
||||||
|
if (originalValue !== capitalized) {
|
||||||
|
control.setValue(capitalized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verify client name against sanctions lists via dialog.
|
* Verify client name against sanctions lists via dialog.
|
||||||
* Passes the client name to the dialog for real API screening.
|
* Passes the client name to the dialog for real API screening.
|
||||||
@@ -1467,6 +1504,11 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
target.mobileNo = data.mobileNo || null;
|
target.mobileNo = data.mobileNo || null;
|
||||||
target.emailAddress = data.emailAddress || null;
|
target.emailAddress = data.emailAddress || null;
|
||||||
target.officeName = data.officeName || null;
|
target.officeName = data.officeName || null;
|
||||||
|
// Filled in only when the caller has not supplied them. A signatory restored from stored
|
||||||
|
// ids starts as a bare { id } and needs both to render its row; one picked from the search
|
||||||
|
// dropdown already carries them, and a guardian must keep whatever it was given.
|
||||||
|
target.displayName = target.displayName || data.displayName;
|
||||||
|
target.status = target.status || data.status;
|
||||||
target._loading = false;
|
target._loading = false;
|
||||||
},
|
},
|
||||||
error: () => {
|
error: () => {
|
||||||
@@ -1480,6 +1522,10 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
target.panVerified = kyc?.panVerified || false;
|
target.panVerified = kyc?.panVerified || false;
|
||||||
target.aadhaarMasked = kyc?.aadhaarNumberMasked || '-';
|
target.aadhaarMasked = kyc?.aadhaarNumberMasked || '-';
|
||||||
target.aadhaarVerified = kyc?.aadhaarVerified || false;
|
target.aadhaarVerified = kyc?.aadhaarVerified || false;
|
||||||
|
// Same reasoning as above: a DIN verified in an earlier session lives on the signatory's own
|
||||||
|
// KYC record, so re-reading it here is what stops the edit screen asking for it again.
|
||||||
|
target.dinNumber = target.dinNumber || kyc?.dinNumber || '';
|
||||||
|
target.dinVerified = target.dinVerified || kyc?.dinVerified || false;
|
||||||
},
|
},
|
||||||
error: () => {
|
error: () => {
|
||||||
target.panNumber = '-';
|
target.panNumber = '-';
|
||||||
@@ -1678,9 +1724,49 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rebuilds the authorized-signatory rows when an existing entity client is opened for edit.
|
||||||
|
*
|
||||||
|
* Driven by the stored client ids rather than the stored names: the ids are what make each row a
|
||||||
|
* real client again, so its CIF, PAN and DIN verification can be re-read instead of the officer
|
||||||
|
* having to re-add and re-verify signatories that were already recorded. The saved names are used
|
||||||
|
* only as the label until the lookup returns, and as the fallback for a client that can no longer
|
||||||
|
* be fetched, so a row is never silently dropped.
|
||||||
|
*
|
||||||
|
* Clients created before the ids were stored have names but no ids; those still list, as
|
||||||
|
* name-only rows, rather than showing nothing at all.
|
||||||
|
*/
|
||||||
|
private restoreSignatories(storedIds: string | null | undefined, storedNames: string | null | undefined): void {
|
||||||
|
const ids = (storedIds || '')
|
||||||
|
.split(',')
|
||||||
|
.map((id: string) => id.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const names = (storedNames || '')
|
||||||
|
.split(',')
|
||||||
|
.map((name: string) => name.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
if (!ids.length) {
|
||||||
|
this.signatories = names.map((displayName: string) => ({ displayName }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.signatories = ids.map((id: string, index: number) => ({
|
||||||
|
id: Number(id),
|
||||||
|
displayName: names[index] || '',
|
||||||
|
_loading: true
|
||||||
|
}));
|
||||||
|
this.signatories.forEach((sig: any) => this.fetchClientFullDetails(sig.id, sig));
|
||||||
|
}
|
||||||
|
|
||||||
private syncSignatoryNames(): void {
|
private syncSignatoryNames(): void {
|
||||||
const names = this.signatories.map((s) => s.displayName).join(', ');
|
const names = this.signatories.map((s) => s.displayName).join(', ');
|
||||||
this.createClientForm.get('clientNonPersonDetails')?.patchValue({ authorizedSignatory: names });
|
// Ids are written in the same order as the names, so the two columns line up on reload.
|
||||||
|
const clientIds = this.signatories.map((s) => s.id).join(',');
|
||||||
|
this.createClientForm.get('clientNonPersonDetails')?.patchValue({
|
||||||
|
authorizedSignatory: names,
|
||||||
|
authorizedSignatoryClientIds: clientIds
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Watch MSME turnover & investment for auto-classification */
|
/** Watch MSME turnover & investment for auto-classification */
|
||||||
@@ -2164,6 +2250,12 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
delete generalDetails[key];
|
delete generalDetails[key];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Entity name is normalised here as well as on blur: a pasted/auto-filled value, or one the
|
||||||
|
// user never blurred out of before submitting, would otherwise reach Fineract exactly as typed.
|
||||||
|
if (generalDetails.fullname) {
|
||||||
|
generalDetails.fullname = toProperNameCase(generalDetails.fullname);
|
||||||
|
}
|
||||||
|
|
||||||
if (generalDetails.submittedOnDate instanceof Date) {
|
if (generalDetails.submittedOnDate instanceof Date) {
|
||||||
generalDetails.submittedOnDate = this.dateUtils.formatDate(generalDetails.submittedOnDate, dateFormat);
|
generalDetails.submittedOnDate = this.dateUtils.formatDate(generalDetails.submittedOnDate, dateFormat);
|
||||||
}
|
}
|
||||||
@@ -2233,15 +2325,86 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
];
|
];
|
||||||
unsupportedFields.forEach((f) => delete generalDetails[f]);
|
unsupportedFields.forEach((f) => delete generalDetails[f]);
|
||||||
|
|
||||||
// Format dateOfRegistration if present
|
return generalDetails;
|
||||||
if (generalDetails.clientNonPersonDetails?.dateOfRegistration instanceof Date) {
|
}
|
||||||
generalDetails.clientNonPersonDetails.dateOfRegistration = this.dateUtils.formatDate(
|
|
||||||
generalDetails.clientNonPersonDetails.dateOfRegistration,
|
/**
|
||||||
dateFormat
|
* Turnover ladder that decides an entity's risk when no configured risk rule matches it.
|
||||||
);
|
*
|
||||||
|
* A company's size is the one thing about it the branch always knows at onboarding, and the
|
||||||
|
* bigger the book the more exposure it carries — so the band moves up with annual turnover
|
||||||
|
* instead of every entity landing on a flat Medium regardless of whether it turns over ten lakh
|
||||||
|
* or a hundred crore. Thresholds follow the MSME-style split the business already thinks in
|
||||||
|
* (crores), and are only a fallback: a rule configured in m_client_risk_rule with
|
||||||
|
* minAnnualTurnover / maxAnnualTurnover still wins, which is where a bank sets its own numbers.
|
||||||
|
*
|
||||||
|
* Amounts are in rupees. `upTo: null` is the open-ended top band.
|
||||||
|
*/
|
||||||
|
private static readonly ENTITY_TURNOVER_RISK_BANDS: { upTo: number | null; category: number; label: string }[] = [
|
||||||
|
{ upTo: 10000000, category: 1, label: 'up to ₹1 crore' },
|
||||||
|
{ upTo: 100000000, category: 2, label: '₹1 crore to ₹10 crore' },
|
||||||
|
{ upTo: null, category: 3, label: 'above ₹10 crore' }
|
||||||
|
];
|
||||||
|
|
||||||
|
/** "Low Risk" / "Medium Risk" / "High Risk" for a category id. */
|
||||||
|
riskCategoryLabel(category: number): string {
|
||||||
|
if (category === 3) {
|
||||||
|
return 'High Risk';
|
||||||
|
}
|
||||||
|
return category === 2 ? 'Medium Risk' : 'Low Risk';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entity risk from annual turnover, for the fallback path only.
|
||||||
|
*
|
||||||
|
* AML is deliberately left CLEAR even in the top band. A configured High-risk rule defaults to
|
||||||
|
* FLAGGED because someone decided that case warrants it; turnover on its own is a size signal,
|
||||||
|
* not a suspicion, and auto-flagging every large company would put them all in the AML queue.
|
||||||
|
*/
|
||||||
|
private entityRiskFromTurnover(annualTurnover: number): {
|
||||||
|
category: number;
|
||||||
|
reason: string;
|
||||||
|
amlCheckStatus: string;
|
||||||
|
bannedEntityMatch: string;
|
||||||
|
willfulDefaulter: string;
|
||||||
|
ecgcSalFlag: string;
|
||||||
|
dormantFlag: string;
|
||||||
|
} {
|
||||||
|
const base = {
|
||||||
|
amlCheckStatus: 'CLEAR',
|
||||||
|
bannedEntityMatch: 'NO',
|
||||||
|
willfulDefaulter: 'NO',
|
||||||
|
ecgcSalFlag: 'NO',
|
||||||
|
dormantFlag: 'ACTIVE'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Nothing captured yet — the entity default stands until a turnover is entered.
|
||||||
|
if (!annualTurnover || annualTurnover <= 0) {
|
||||||
|
return {
|
||||||
|
category: 2,
|
||||||
|
reason: 'Non-Individual / Entity profile (Default Medium Risk)',
|
||||||
|
...base
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return generalDetails;
|
const band =
|
||||||
|
ClientGeneralStepComponent.ENTITY_TURNOVER_RISK_BANDS.find((b) => b.upTo == null || annualTurnover <= b.upTo) ??
|
||||||
|
ClientGeneralStepComponent.ENTITY_TURNOVER_RISK_BANDS[
|
||||||
|
ClientGeneralStepComponent.ENTITY_TURNOVER_RISK_BANDS.length - 1
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
category: band.category,
|
||||||
|
reason: `Annual turnover ₹${annualTurnover.toLocaleString('en-IN')} — ${band.label} (${this.riskCategoryLabel(
|
||||||
|
band.category
|
||||||
|
)})`,
|
||||||
|
...base
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Live risk band for the entity turnover card in the General step. */
|
||||||
|
get entityTurnoverRisk(): { category: number; reason: string } {
|
||||||
|
return this.calculatedRisk;
|
||||||
}
|
}
|
||||||
|
|
||||||
get calculatedRisk(): {
|
get calculatedRisk(): {
|
||||||
@@ -2338,15 +2501,7 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
|
|
||||||
// Default Fallbacks
|
// Default Fallbacks
|
||||||
if (legalFormId !== LegalFormId.PERSON) {
|
if (legalFormId !== LegalFormId.PERSON) {
|
||||||
return {
|
return this.entityRiskFromTurnover(annualTurnover);
|
||||||
category: 2,
|
|
||||||
reason: 'Non-Individual / Entity profile (Default Medium Risk)',
|
|
||||||
amlCheckStatus: 'CLEAR',
|
|
||||||
bannedEntityMatch: 'NO',
|
|
||||||
willfulDefaulter: 'NO',
|
|
||||||
ecgcSalFlag: 'NO',
|
|
||||||
dormantFlag: 'ACTIVE'
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
category: 1,
|
category: 1,
|
||||||
|
|||||||
@@ -588,95 +588,23 @@
|
|||||||
|
|
||||||
<!-- ══ NON-INDIVIDUAL KYC ══ -->
|
<!-- ══ NON-INDIVIDUAL KYC ══ -->
|
||||||
@if (isNonIndividual) {
|
@if (isNonIndividual) {
|
||||||
<!-- Incorporation Date -->
|
<!-- Date of Incorporation — captured before PAN because Enterprise PAN verification
|
||||||
<mat-form-field appearance="outline" class="form-field" (click)="incorpDatePicker.open()">
|
matches the entity on name + date of incorporation as held in the PAN records. -->
|
||||||
<mat-label>Incorporation Date</mat-label>
|
<mat-form-field appearance="outline" class="form-field" (click)="incorporationDatePicker.open()">
|
||||||
<span matPrefix class="field-prefix-icon"><mat-icon>calendar_today</mat-icon></span>
|
<mat-label>Date of Incorporation</mat-label>
|
||||||
<input matInput [max]="today" [matDatepicker]="incorpDatePicker" formControlName="incorporationDate" />
|
<span matPrefix class="field-prefix-icon"><mat-icon>event_available</mat-icon></span>
|
||||||
<mat-datepicker-toggle matSuffix [for]="incorpDatePicker"></mat-datepicker-toggle>
|
<input
|
||||||
<mat-datepicker #incorpDatePicker></mat-datepicker>
|
matInput
|
||||||
|
[max]="today"
|
||||||
|
[matDatepicker]="incorporationDatePicker"
|
||||||
|
formControlName="incorporationDate"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
<mat-datepicker-toggle matSuffix [for]="incorporationDatePicker"></mat-datepicker-toggle>
|
||||||
|
<mat-datepicker #incorporationDatePicker></mat-datepicker>
|
||||||
|
<mat-hint>As per Certificate of Incorporation / Registration</mat-hint>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
|
|
||||||
<!-- GSTIN (all non-individual) with Verify -->
|
|
||||||
<div class="form-field verify-field">
|
|
||||||
<mat-form-field appearance="outline" class="verify-input">
|
|
||||||
<mat-label>GSTIN Number</mat-label>
|
|
||||||
<span matPrefix class="field-prefix-icon"><mat-icon>receipt_long</mat-icon></span>
|
|
||||||
<input
|
|
||||||
matInput
|
|
||||||
formControlName="gstinNumber"
|
|
||||||
placeholder="15-character GSTIN"
|
|
||||||
maxlength="15"
|
|
||||||
style="text-transform: uppercase"
|
|
||||||
(input)="
|
|
||||||
kycForm.get('gstinNumber')?.setValue($any($event.target).value.toUpperCase(), { emitEvent: false })
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
@if (kycForm.get('gstinNumber')?.hasError('pattern') && kycForm.get('gstinNumber')?.touched) {
|
|
||||||
<mat-error>Invalid GSTIN format (e.g. 22AAAAA0000A1Z5)</mat-error>
|
|
||||||
}
|
|
||||||
@if (gstinVerifyStatus === 'VERIFIED') {
|
|
||||||
<mat-icon matSuffix class="verify-icon-ok">check_circle</mat-icon>
|
|
||||||
}
|
|
||||||
@if (gstinVerifyStatus === 'FAILED') {
|
|
||||||
<mat-icon matSuffix class="verify-icon-fail">cancel</mat-icon>
|
|
||||||
}
|
|
||||||
</mat-form-field>
|
|
||||||
<div class="verify-action">
|
|
||||||
@if (gstinVerifyStatus === 'NONE' || gstinVerifyStatus === 'FAILED') {
|
|
||||||
<button
|
|
||||||
mat-raised-button
|
|
||||||
color="primary"
|
|
||||||
type="button"
|
|
||||||
class="verify-btn-inline"
|
|
||||||
[disabled]="
|
|
||||||
readOnly ||
|
|
||||||
!kycForm.get('gstinNumber')?.value ||
|
|
||||||
kycForm.get('gstinNumber')?.value.length < 15 ||
|
|
||||||
gstinVerifying ||
|
|
||||||
!clientId
|
|
||||||
"
|
|
||||||
(click)="verifyGstin()"
|
|
||||||
>
|
|
||||||
@if (gstinVerifying) {
|
|
||||||
<mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner>
|
|
||||||
} @else {
|
|
||||||
<fa-icon icon="shield-alt"></fa-icon> Verify GSTIN
|
|
||||||
}
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
@if (gstinVerifyStatus === 'VERIFIED') {
|
|
||||||
<span class="verify-chip verified"><fa-icon icon="check-circle"></fa-icon> Verified</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@if (gstinVerifyStatus === 'VERIFIED') {
|
|
||||||
<div class="pan-result-card full-width">
|
|
||||||
<div class="pan-result-header">
|
|
||||||
<fa-icon icon="check-circle" class="pan-result-ok-icon"></fa-icon>
|
|
||||||
<span>GSTIN Verified Successfully</span>
|
|
||||||
</div>
|
|
||||||
<div class="pan-result-body">
|
|
||||||
@if (gstinVerifyName) {
|
|
||||||
<div class="pan-result-row">
|
|
||||||
<span class="pan-result-label">Trade Name / Legal Name</span>
|
|
||||||
<span class="pan-result-value name">{{ gstinVerifyName }}</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@if (gstinVerifyState) {
|
|
||||||
<div class="pan-result-row">
|
|
||||||
<span class="pan-result-label">State</span>
|
|
||||||
<span class="pan-result-value">{{ gstinVerifyState }}</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
<div class="pan-result-row">
|
|
||||||
<span class="pan-result-label">Verified On</span>
|
|
||||||
<span class="pan-result-value date">{{ gstinVerifiedOn }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- PAN — Proprietorship uses Individual PAN, others use Enterprise PAN -->
|
<!-- PAN — Proprietorship uses Individual PAN, others use Enterprise PAN -->
|
||||||
@if (constitutionMeta?.value === 'PROPRIETORSHIP') {
|
@if (constitutionMeta?.value === 'PROPRIETORSHIP') {
|
||||||
<!-- Proprietorship: Individual PAN (proprietor's personal PAN) -->
|
<!-- Proprietorship: Individual PAN (proprietor's personal PAN) -->
|
||||||
@@ -826,8 +754,88 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- CIN (Companies & LLP only) -->
|
<!-- GSTIN (all non-individual) with Verify -->
|
||||||
@if (constitutionMeta?.showCin) {
|
<div class="form-field verify-field">
|
||||||
|
<mat-form-field appearance="outline" class="verify-input">
|
||||||
|
<mat-label>GSTIN Number</mat-label>
|
||||||
|
<span matPrefix class="field-prefix-icon"><mat-icon>receipt_long</mat-icon></span>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
formControlName="gstinNumber"
|
||||||
|
placeholder="15-character GSTIN"
|
||||||
|
maxlength="15"
|
||||||
|
style="text-transform: uppercase"
|
||||||
|
(input)="
|
||||||
|
kycForm.get('gstinNumber')?.setValue($any($event.target).value.toUpperCase(), { emitEvent: false })
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
@if (kycForm.get('gstinNumber')?.hasError('pattern') && kycForm.get('gstinNumber')?.touched) {
|
||||||
|
<mat-error>Invalid GSTIN format (e.g. 22AAAAA0000A1Z5)</mat-error>
|
||||||
|
}
|
||||||
|
@if (gstinVerifyStatus === 'VERIFIED') {
|
||||||
|
<mat-icon matSuffix class="verify-icon-ok">check_circle</mat-icon>
|
||||||
|
}
|
||||||
|
@if (gstinVerifyStatus === 'FAILED') {
|
||||||
|
<mat-icon matSuffix class="verify-icon-fail">cancel</mat-icon>
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
<div class="verify-action">
|
||||||
|
@if (gstinVerifyStatus === 'NONE' || gstinVerifyStatus === 'FAILED') {
|
||||||
|
<button
|
||||||
|
mat-raised-button
|
||||||
|
color="primary"
|
||||||
|
type="button"
|
||||||
|
class="verify-btn-inline"
|
||||||
|
[disabled]="
|
||||||
|
readOnly ||
|
||||||
|
!kycForm.get('gstinNumber')?.value ||
|
||||||
|
kycForm.get('gstinNumber')?.value.length < 15 ||
|
||||||
|
gstinVerifying ||
|
||||||
|
!clientId
|
||||||
|
"
|
||||||
|
(click)="verifyGstin()"
|
||||||
|
>
|
||||||
|
@if (gstinVerifying) {
|
||||||
|
<mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner>
|
||||||
|
} @else {
|
||||||
|
<fa-icon icon="shield-alt"></fa-icon> Verify GSTIN
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
@if (gstinVerifyStatus === 'VERIFIED') {
|
||||||
|
<span class="verify-chip verified"><fa-icon icon="check-circle"></fa-icon> Verified</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@if (gstinVerifyStatus === 'VERIFIED') {
|
||||||
|
<div class="pan-result-card full-width">
|
||||||
|
<div class="pan-result-header">
|
||||||
|
<fa-icon icon="check-circle" class="pan-result-ok-icon"></fa-icon>
|
||||||
|
<span>GSTIN Verified Successfully</span>
|
||||||
|
</div>
|
||||||
|
<div class="pan-result-body">
|
||||||
|
@if (gstinVerifyName) {
|
||||||
|
<div class="pan-result-row">
|
||||||
|
<span class="pan-result-label">Trade Name / Legal Name</span>
|
||||||
|
<span class="pan-result-value name">{{ gstinVerifyName }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (gstinVerifyState) {
|
||||||
|
<div class="pan-result-row">
|
||||||
|
<span class="pan-result-label">State</span>
|
||||||
|
<span class="pan-result-value">{{ gstinVerifyState }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="pan-result-row">
|
||||||
|
<span class="pan-result-label">Verified On</span>
|
||||||
|
<span class="pan-result-value date">{{ gstinVerifiedOn }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- CIN -->
|
||||||
|
@if (isNonIndividual) {
|
||||||
<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>CIN (Corporate Identity Number)</mat-label>
|
<mat-label>CIN (Corporate Identity Number)</mat-label>
|
||||||
@@ -850,7 +858,7 @@
|
|||||||
}
|
}
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
<div class="verify-action">
|
<div class="verify-action">
|
||||||
@if (cinVerifyStatus === 'NONE' || cinVerifyStatus === 'FAILED') {
|
@if (cinVerifyStatus !== 'VERIFIED') {
|
||||||
<button
|
<button
|
||||||
mat-raised-button
|
mat-raised-button
|
||||||
color="primary"
|
color="primary"
|
||||||
@@ -862,16 +870,31 @@
|
|||||||
@if (cinVerifying) {
|
@if (cinVerifying) {
|
||||||
<mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner>
|
<mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner>
|
||||||
} @else {
|
} @else {
|
||||||
<fa-icon icon="shield-alt"></fa-icon> Verify CIN
|
<fa-icon icon="shield-alt"></fa-icon>
|
||||||
|
{{ cinVerifyStatus === 'UNCONFIRMED' ? 'Retry' : 'Verify CIN' }}
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
@if (cinVerifyStatus === 'VERIFIED') {
|
@if (cinVerifyStatus === '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>
|
||||||
}
|
}
|
||||||
|
@if (cinVerifyStatus === 'UNCONFIRMED') {
|
||||||
|
<span class="verify-chip unconfirmed"
|
||||||
|
><fa-icon icon="info-circle"></fa-icon> Saved — not confirmed</span
|
||||||
|
>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Accepted, but MCA returned no match. Stated plainly rather than as a failure: the
|
||||||
|
number is stored and the officer has nothing to correct. -->
|
||||||
|
@if (cinUnconfirmedMessage) {
|
||||||
|
<div class="cin-unconfirmed-note full-width">
|
||||||
|
<fa-icon icon="info-circle" class="cin-unconfirmed-note__icon"></fa-icon>
|
||||||
|
<span>{{ cinUnconfirmedMessage }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<!-- CIN verification progress -->
|
<!-- CIN verification progress -->
|
||||||
@if (cinVerifying) {
|
@if (cinVerifying) {
|
||||||
<div class="cin-progress full-width">
|
<div class="cin-progress full-width">
|
||||||
@@ -1429,15 +1452,19 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Overriding the risk category is the one thing that makes remarks mandatory here. A verified
|
||||||
|
record still gets the box — the officer needs somewhere to record a re-edit reason, and the
|
||||||
|
server asks for one if the save actually replaces data already on the record — but it is
|
||||||
|
offered, not demanded, so a record that auto-verified mid-wizard does not open in error.
|
||||||
|
-->
|
||||||
@if (kycForm.get('allowRiskOverride')?.value || isKycVerified) {
|
@if (kycForm.get('allowRiskOverride')?.value || isKycVerified) {
|
||||||
<mat-form-field appearance="outline" class="form-field full-width">
|
<mat-form-field appearance="outline" class="form-field full-width">
|
||||||
<mat-label>
|
<mat-label>
|
||||||
{{
|
{{
|
||||||
isKycVerified
|
kycForm.get('allowRiskOverride')?.value
|
||||||
? 'Reason for Re-edit'
|
? 'Remarks (Required for Risk Override)'
|
||||||
: kycForm.get('allowRiskOverride')?.value
|
: 'Reason for Re-edit (optional)'
|
||||||
? 'Remarks (Required for Risk Override)'
|
|
||||||
: 'Remarks'
|
|
||||||
}}
|
}}
|
||||||
</mat-label>
|
</mat-label>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -1445,18 +1472,13 @@
|
|||||||
formControlName="remarks"
|
formControlName="remarks"
|
||||||
rows="2"
|
rows="2"
|
||||||
[placeholder]="
|
[placeholder]="
|
||||||
isKycVerified
|
kycForm.get('allowRiskOverride')?.value
|
||||||
? 'Mandatory — explain why KYC is being re-edited'
|
? 'Mandatory — explain why you are overriding the auto-calculated risk category'
|
||||||
: kycForm.get('allowRiskOverride')?.value
|
: 'Optional — needed only if you change details already recorded on this verified KYC'
|
||||||
? 'Mandatory — explain why you are overriding the auto-calculated risk category'
|
|
||||||
: ''
|
|
||||||
"
|
"
|
||||||
></textarea>
|
></textarea>
|
||||||
@if ((isKycVerified || kycForm.get('allowRiskOverride')?.value) && !kycForm.get('remarks')?.value?.trim()) {
|
@if (kycForm.get('allowRiskOverride')?.value && !kycForm.get('remarks')?.value?.trim()) {
|
||||||
<mat-error>
|
<mat-error> Remarks are mandatory when overriding the risk category </mat-error>
|
||||||
Remarks are mandatory when
|
|
||||||
{{ isKycVerified ? 'updating a verified KYC record' : 'overriding the risk category' }}
|
|
||||||
</mat-error>
|
|
||||||
}
|
}
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
}
|
}
|
||||||
@@ -1672,77 +1694,92 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ═══ SECTION: Form 97/98 ═══ -->
|
<!-- ═══ SECTION: Form 97/98 ═══ -->
|
||||||
<div class="section-block">
|
@if (!isNonIndividual) {
|
||||||
<h4 class="section-title">
|
<div class="section-block">
|
||||||
<fa-icon icon="file-invoice" class="section-icon"></fa-icon>
|
<h4 class="section-title">
|
||||||
Form 97 / 98
|
<fa-icon icon="file-invoice" class="section-icon"></fa-icon>
|
||||||
|
Form 97 / 98
|
||||||
|
@if (!kycForm.get('hasPan')?.value) {
|
||||||
|
<span class="kyc-badge required-badge">Required — No PAN</span>
|
||||||
|
}
|
||||||
|
</h4>
|
||||||
@if (!kycForm.get('hasPan')?.value) {
|
@if (!kycForm.get('hasPan')?.value) {
|
||||||
<span class="kyc-badge required-badge">Required — No PAN</span>
|
<div class="kyc-info-hint warning-hint">
|
||||||
}
|
<mat-icon class="kyc-info-hint-icon">warning</mat-icon>
|
||||||
</h4>
|
<span>Client does not have PAN. Form 97/98 is mandatory.</span>
|
||||||
@if (!kycForm.get('hasPan')?.value) {
|
</div>
|
||||||
<div class="kyc-info-hint warning-hint">
|
<div class="form-grid">
|
||||||
<mat-icon class="kyc-info-hint-icon">warning</mat-icon>
|
<mat-form-field appearance="outline" class="form-field">
|
||||||
<span>Client does not have PAN. Form 97/98 is mandatory.</span>
|
<mat-label>Form Type</mat-label>
|
||||||
</div>
|
<mat-select formControlName="form60Type">
|
||||||
<div class="form-grid">
|
<mat-option value="FORM_60">Form 97</mat-option>
|
||||||
<mat-form-field appearance="outline" class="form-field">
|
<mat-option value="FORM_61">Form 98</mat-option>
|
||||||
<mat-label>Form Type</mat-label>
|
</mat-select>
|
||||||
<mat-select formControlName="form60Type">
|
</mat-form-field>
|
||||||
<mat-option value="FORM_60">Form 97</mat-option>
|
<mat-form-field appearance="outline" class="form-field" (click)="form60DatePicker.open()">
|
||||||
<mat-option value="FORM_61">Form 98</mat-option>
|
<mat-label>Declaration Date</mat-label>
|
||||||
</mat-select>
|
<input matInput [matDatepicker]="form60DatePicker" formControlName="form60Date" />
|
||||||
</mat-form-field>
|
<mat-datepicker-toggle matSuffix [for]="form60DatePicker"></mat-datepicker-toggle>
|
||||||
<mat-form-field appearance="outline" class="form-field" (click)="form60DatePicker.open()">
|
<mat-datepicker #form60DatePicker></mat-datepicker>
|
||||||
<mat-label>Declaration Date</mat-label>
|
</mat-form-field>
|
||||||
<input matInput [matDatepicker]="form60DatePicker" formControlName="form60Date" />
|
</div>
|
||||||
<mat-datepicker-toggle matSuffix [for]="form60DatePicker"></mat-datepicker-toggle>
|
} @else {
|
||||||
<mat-datepicker #form60DatePicker></mat-datepicker>
|
<p class="form60-not-needed">Client has PAN — Form 97/98 not required.</p>
|
||||||
</mat-form-field>
|
|
||||||
</div>
|
|
||||||
} @else {
|
|
||||||
<p class="form60-not-needed">Client has PAN — Form 97/98 not required.</p>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ═══ SECTION: Form 121 ═══ -->
|
|
||||||
<div class="section-block">
|
|
||||||
<h4 class="section-title">
|
|
||||||
<fa-icon icon="receipt" class="section-icon"></fa-icon>
|
|
||||||
Form 121
|
|
||||||
</h4>
|
|
||||||
<div class="form-grid">
|
|
||||||
<div class="form-field checkbox-field">
|
|
||||||
<mat-checkbox formControlName="form15Applicable">Form 121 Applicable</mat-checkbox>
|
|
||||||
</div>
|
|
||||||
@if (kycForm.get('form15Applicable')?.value) {
|
|
||||||
<mat-form-field appearance="outline" class="form-field">
|
|
||||||
<mat-label>Form Type</mat-label>
|
|
||||||
<mat-select formControlName="form15Type">
|
|
||||||
<mat-option value="15G">Form 121 (Below 60 years)</mat-option>
|
|
||||||
<mat-option value="15H">Form 121 (Senior Citizen 60+)</mat-option>
|
|
||||||
</mat-select>
|
|
||||||
</mat-form-field>
|
|
||||||
<mat-form-field appearance="outline" class="form-field">
|
|
||||||
<mat-label>Financial Year</mat-label>
|
|
||||||
<input matInput formControlName="form15FY" placeholder="e.g. 2025-26" />
|
|
||||||
</mat-form-field>
|
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
}
|
||||||
|
|
||||||
<!-- ═══ SECTION: Photograph & Signature ═══ -->
|
<!-- ═══ SECTION: Form 121 ═══ -->
|
||||||
|
@if (!isNonIndividual) {
|
||||||
|
<div class="section-block">
|
||||||
|
<h4 class="section-title">
|
||||||
|
<fa-icon icon="receipt" class="section-icon"></fa-icon>
|
||||||
|
Form 121
|
||||||
|
</h4>
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-field checkbox-field">
|
||||||
|
<mat-checkbox formControlName="form15Applicable">Form 121 Applicable</mat-checkbox>
|
||||||
|
</div>
|
||||||
|
@if (kycForm.get('form15Applicable')?.value) {
|
||||||
|
<mat-form-field appearance="outline" class="form-field">
|
||||||
|
<mat-label>Form Type</mat-label>
|
||||||
|
<mat-select formControlName="form15Type">
|
||||||
|
<mat-option value="15G">Form 121 (Below 60 years)</mat-option>
|
||||||
|
<mat-option value="15H">Form 121 (Senior Citizen 60+)</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" class="form-field">
|
||||||
|
<mat-label>Financial Year</mat-label>
|
||||||
|
<input matInput formControlName="form15FY" placeholder="e.g. 2025-26" />
|
||||||
|
</mat-form-field>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- ═══ SECTION: Photograph & Signature ═══
|
||||||
|
|
||||||
|
An entity gets the photograph box too — a shopfront, signboard or logo is what staff
|
||||||
|
recognise the client by in the ledger, and the client view has always had a photo slot in
|
||||||
|
its header with no way to fill it from the stepper. The specimen signature stays individual-
|
||||||
|
only: an entity signs through its authorised signatories, whose signatures are captured with
|
||||||
|
their own KYC rather than as one image for the company. Optional here, so a company can
|
||||||
|
still be onboarded before anyone has a usable photograph of the premises. -->
|
||||||
<div class="section-block">
|
<div class="section-block">
|
||||||
<h4 class="section-title">
|
<h4 class="section-title">
|
||||||
<fa-icon icon="camera" class="section-icon"></fa-icon>
|
<fa-icon icon="camera" class="section-icon"></fa-icon>
|
||||||
Photograph & Signature
|
{{ isNonIndividual ? 'Entity Photograph' : 'Photograph & Signature' }}
|
||||||
<span class="section-required-tag">Required</span>
|
@if (isNonIndividual) {
|
||||||
|
<span class="section-optional-tag">Optional</span>
|
||||||
|
} @else {
|
||||||
|
<span class="section-required-tag">Required</span>
|
||||||
|
}
|
||||||
</h4>
|
</h4>
|
||||||
<div class="photo-sig-grid">
|
<div class="photo-sig-grid" [class.photo-sig-grid--single]="isNonIndividual">
|
||||||
<div
|
<div
|
||||||
class="upload-box"
|
class="upload-box"
|
||||||
[class.upload-box--has-preview]="photographPreview"
|
[class.upload-box--has-preview]="photographPreview"
|
||||||
[class.upload-box--missing]="!photographPreview && photoSigTouched"
|
[class.upload-box--missing]="!photographPreview && photoSigTouched && !isNonIndividual"
|
||||||
>
|
>
|
||||||
@if (photographPreview) {
|
@if (photographPreview) {
|
||||||
<div class="upload-preview">
|
<div class="upload-preview">
|
||||||
@@ -1758,12 +1795,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<div class="upload-box__icon"><fa-icon icon="user"></fa-icon></div>
|
<div class="upload-box__icon"><fa-icon [icon]="isNonIndividual ? 'building' : 'user'"></fa-icon></div>
|
||||||
<span class="upload-box__label">
|
<span class="upload-box__label">
|
||||||
Client Photograph
|
{{ isNonIndividual ? 'Entity Photograph / Logo' : 'Client Photograph' }}
|
||||||
<span class="upload-box__req">*</span>
|
@if (!isNonIndividual) {
|
||||||
|
<span class="upload-box__req">*</span>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
<span class="upload-box__hint">
|
||||||
|
{{ isNonIndividual ? 'Office premises, signboard or logo — JPG / PNG, max 2MB' : 'JPG / PNG, max 2MB' }}
|
||||||
</span>
|
</span>
|
||||||
<span class="upload-box__hint">JPG / PNG, max 2MB</span>
|
|
||||||
<div class="upload-box__btn-row">
|
<div class="upload-box__btn-row">
|
||||||
<label class="upload-box__btn">
|
<label class="upload-box__btn">
|
||||||
<input type="file" accept="image/*" hidden (change)="onPhotoSelected($event)" />
|
<input type="file" accept="image/*" hidden (change)="onPhotoSelected($event)" />
|
||||||
@@ -1773,50 +1814,52 @@
|
|||||||
<fa-icon icon="video"></fa-icon> Use Webcam
|
<fa-icon icon="video"></fa-icon> Use Webcam
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@if (photoSigTouched) {
|
@if (photoSigTouched && !isNonIndividual) {
|
||||||
<span class="upload-box__error">Photograph is required</span>
|
<span class="upload-box__error">Photograph is required</span>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div
|
@if (!isNonIndividual) {
|
||||||
class="upload-box"
|
<div
|
||||||
[class.upload-box--has-preview]="signaturePreview"
|
class="upload-box"
|
||||||
[class.upload-box--missing]="!signaturePreview && photoSigTouched"
|
[class.upload-box--has-preview]="signaturePreview"
|
||||||
>
|
[class.upload-box--missing]="!signaturePreview && photoSigTouched"
|
||||||
@if (signaturePreview) {
|
>
|
||||||
<div class="upload-preview">
|
@if (signaturePreview) {
|
||||||
<img [src]="signaturePreview" alt="Signature" class="upload-preview__img upload-preview__img--sig" />
|
<div class="upload-preview">
|
||||||
<div class="upload-preview__actions">
|
<img [src]="signaturePreview" alt="Signature" class="upload-preview__img upload-preview__img--sig" />
|
||||||
<label class="upload-preview__btn recrop">
|
<div class="upload-preview__actions">
|
||||||
|
<label class="upload-preview__btn recrop">
|
||||||
|
<input type="file" accept="image/*" hidden (change)="onSignatureSelected($event)" />
|
||||||
|
<fa-icon icon="crop"></fa-icon> Re-crop
|
||||||
|
</label>
|
||||||
|
<button type="button" class="upload-preview__btn remove" (click)="removeSignature()">
|
||||||
|
<fa-icon icon="trash"></fa-icon> Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="upload-box__icon"><fa-icon icon="pen"></fa-icon></div>
|
||||||
|
<span class="upload-box__label">
|
||||||
|
Specimen Signature
|
||||||
|
<span class="upload-box__req">*</span>
|
||||||
|
</span>
|
||||||
|
<span class="upload-box__hint">Scanned image, max 1MB</span>
|
||||||
|
<div class="upload-box__btn-row">
|
||||||
|
<label class="upload-box__btn">
|
||||||
<input type="file" accept="image/*" hidden (change)="onSignatureSelected($event)" />
|
<input type="file" accept="image/*" hidden (change)="onSignatureSelected($event)" />
|
||||||
<fa-icon icon="crop"></fa-icon> Re-crop
|
<fa-icon icon="crop"></fa-icon> Choose & Crop
|
||||||
</label>
|
</label>
|
||||||
<button type="button" class="upload-preview__btn remove" (click)="removeSignature()">
|
<button type="button" class="upload-box__btn upload-box__btn--secondary" (click)="drawSignature()">
|
||||||
<fa-icon icon="trash"></fa-icon> Remove
|
<fa-icon icon="pen"></fa-icon> Draw Signature
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
@if (photoSigTouched) {
|
||||||
} @else {
|
<span class="upload-box__error">Signature is required</span>
|
||||||
<div class="upload-box__icon"><fa-icon icon="pen"></fa-icon></div>
|
}
|
||||||
<span class="upload-box__label">
|
|
||||||
Specimen Signature
|
|
||||||
<span class="upload-box__req">*</span>
|
|
||||||
</span>
|
|
||||||
<span class="upload-box__hint">Scanned image, max 1MB</span>
|
|
||||||
<div class="upload-box__btn-row">
|
|
||||||
<label class="upload-box__btn">
|
|
||||||
<input type="file" accept="image/*" hidden (change)="onSignatureSelected($event)" />
|
|
||||||
<fa-icon icon="crop"></fa-icon> Choose & Crop
|
|
||||||
</label>
|
|
||||||
<button type="button" class="upload-box__btn upload-box__btn--secondary" (click)="drawSignature()">
|
|
||||||
<fa-icon icon="pen"></fa-icon> Draw Signature
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
@if (photoSigTouched) {
|
|
||||||
<span class="upload-box__error">Signature is required</span>
|
|
||||||
}
|
}
|
||||||
}
|
</div>
|
||||||
</div>
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,20 @@
|
|||||||
letter-spacing: 0.4px;
|
letter-spacing: 0.4px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Same chip, muted — says "you may" where the red one says "you must". */
|
||||||
|
.section-optional-tag {
|
||||||
|
margin-left: 6px;
|
||||||
|
padding: 2px 9px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #475569;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.4px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.kyc-badge {
|
.kyc-badge {
|
||||||
@@ -312,6 +326,34 @@
|
|||||||
color: #b45309;
|
color: #b45309;
|
||||||
border: 1px solid #fcd34d;
|
border: 1px solid #fcd34d;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Recorded but unconfirmed by the registry. Neutral blue rather than green or red: nothing failed
|
||||||
|
// and nothing passed, so it must not read as either.
|
||||||
|
&.unconfirmed {
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #1d4ed8;
|
||||||
|
border: 1px solid #bfdbfe;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CIN saved but not confirmed by MCA ─────────────────────────────────────
|
||||||
|
.cin-unconfirmed-note {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
margin: -4px 0 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #eff6ff;
|
||||||
|
border: 1px solid #bfdbfe;
|
||||||
|
color: #1e40af;
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cin-unconfirmed-note__icon {
|
||||||
|
margin-top: 1px;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── PAN declaration summary ────────────────────────────────────────────────
|
// ── PAN declaration summary ────────────────────────────────────────────────
|
||||||
@@ -1225,6 +1267,12 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
|
|
||||||
|
/* Entity clients get the photograph box only, so it keeps a card's width instead of
|
||||||
|
stretching across the whole step where the signature box used to sit beside it. */
|
||||||
|
&--single {
|
||||||
|
grid-template-columns: minmax(0, 420px);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.upload-box {
|
.upload-box {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import { ClientsService } from '../../clients.service';
|
|||||||
import { SettingsService } from 'app/settings/settings.service';
|
import { SettingsService } from 'app/settings/settings.service';
|
||||||
import { Dates } from 'app/core/utils/dates';
|
import { Dates } from 'app/core/utils/dates';
|
||||||
import { ProofType, normalizeProofType, groupProofsByCategory } from '../../shared/kyc-proof-types';
|
import { ProofType, normalizeProofType, groupProofsByCategory } from '../../shared/kyc-proof-types';
|
||||||
|
import { ENTITY_KYC_DOCUMENT_TYPES, KycDocumentType, withEntityKycDocuments } from '../../shared/entity-kyc-documents';
|
||||||
import { deriveKycExceptionFields } from '../../kyc-exception.util';
|
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';
|
||||||
@@ -110,6 +111,27 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
} | null = null;
|
} | null = null;
|
||||||
|
|
||||||
today = new Date();
|
today = new Date();
|
||||||
|
/**
|
||||||
|
* ClientKycType on the backend: 1 = INDIVIDUAL, 2 = NON_INDIVIDUAL. Every payload must carry the
|
||||||
|
* value matching the client's legal form — the backend rejects the entity-only fields
|
||||||
|
* (enterprisePan / gstinNumber / cinNumber / incorporationDate) on a KYC record typed INDIVIDUAL.
|
||||||
|
*/
|
||||||
|
get kycTypeValue(): number {
|
||||||
|
return this.isNonIndividual ? 2 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Risk category as it will actually be saved.
|
||||||
|
*
|
||||||
|
* Read via getRawValue() because the riskCategory control is disabled whenever the override
|
||||||
|
* checkbox is off — which is the normal case — and `kycForm.value` omits disabled controls.
|
||||||
|
* Reading `.value` there yields undefined and quietly falls back to 1 (Low), so a verification
|
||||||
|
* would stamp Low onto the record and the next full save would change it to the auto-calculated
|
||||||
|
* category, showing up as an edit of a verified record that the officer never made.
|
||||||
|
*/
|
||||||
|
private get currentRiskCategory(): number {
|
||||||
|
return this.kycForm.getRawValue().riskCategory || 1;
|
||||||
|
}
|
||||||
kycId: string = '';
|
kycId: string = '';
|
||||||
/** Whether existing KYC data has been loaded (edit mode) */
|
/** Whether existing KYC data has been loaded (edit mode) */
|
||||||
kycLoaded = false;
|
kycLoaded = false;
|
||||||
@@ -121,12 +143,17 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** KYC Document Types — populated from API template, falls back to defaults */
|
/** KYC Document Types — populated from API template, falls back to defaults */
|
||||||
kycDocumentTypes: { name: string; code: string; mandatory: boolean }[] = [];
|
kycDocumentTypes: KycDocumentType[] = [];
|
||||||
|
|
||||||
/** Chip-group state for KYC document picker (mirrors identity proof in identifier dialog) */
|
/** Chip-group state for KYC document picker (mirrors identity proof in identifier dialog) */
|
||||||
kycDocProofs: ProofType[] = [];
|
kycDocProofs: ProofType[] = [];
|
||||||
kycDocGroups: { label: string; items: ProofType[] }[] = [];
|
kycDocGroups: { label: string; items: ProofType[] }[] = [];
|
||||||
selectedKycDoc: ProofType | null = null;
|
selectedKycDoc: ProofType | null = null;
|
||||||
|
/**
|
||||||
|
* Constitution whose document list is currently loaded. Distinguishes the first arrival of a
|
||||||
|
* constitution from the officer switching to a different one — see updateDocTypesForConstitution.
|
||||||
|
*/
|
||||||
|
private appliedConstitutionValue: string | null = null;
|
||||||
/** Front/back files for the chosen KYC document */
|
/** Front/back files for the chosen KYC document */
|
||||||
kycDocFiles: { front: File | null; back: File | null } = { front: null, back: null };
|
kycDocFiles: { front: File | null; back: File | null } = { front: null, back: null };
|
||||||
/**
|
/**
|
||||||
@@ -185,6 +212,9 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
|
|
||||||
/** Photo + signature are mandatory before leaving the KYC tab */
|
/** Photo + signature are mandatory before leaving the KYC tab */
|
||||||
get photoSigComplete(): boolean {
|
get photoSigComplete(): boolean {
|
||||||
|
if (this.isNonIndividual) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
return this.hasPhoto && this.hasSignature;
|
return this.hasPhoto && this.hasSignature;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,7 +255,10 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
const needsCin = !!this.kycForm?.get('cinNumber')?.value;
|
const needsCin = !!this.kycForm?.get('cinNumber')?.value;
|
||||||
const panOk = this.entPanVerifyStatus === 'VERIFIED';
|
const panOk = this.entPanVerifyStatus === 'VERIFIED';
|
||||||
const gstinOk = !needsGstin || this.gstinVerifyStatus === 'VERIFIED';
|
const gstinOk = !needsGstin || this.gstinVerifyStatus === 'VERIFIED';
|
||||||
const cinOk = !needsCin || this.cinVerifyStatus === 'VERIFIED';
|
// A CIN the provider cannot confirm still counts as done. MCA lookups return no match for
|
||||||
|
// CINs that are perfectly valid, and PAN and GSTIN already establish the entity's identity —
|
||||||
|
// holding the whole KYC open on a check that cannot pass would strand the client instead.
|
||||||
|
const cinOk = !needsCin || this.cinVerifyStatus === 'VERIFIED' || this.cinVerifyStatus === 'UNCONFIRMED';
|
||||||
return panOk && gstinOk && cinOk;
|
return panOk && gstinOk && cinOk;
|
||||||
} else {
|
} else {
|
||||||
const needsPan = !this.isMinor && this.kycForm?.get('hasPan')?.value;
|
const needsPan = !this.isMinor && this.kycForm?.get('hasPan')?.value;
|
||||||
@@ -250,18 +283,9 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
{ name: 'Birth Certificate', code: 'BIRTH_CERTIFICATE' },
|
{ name: 'Birth Certificate', code: 'BIRTH_CERTIFICATE' },
|
||||||
{ name: 'School ID Card', code: 'SCHOOL_ID_CARD' }
|
{ name: 'School ID Card', code: 'SCHOOL_ID_CARD' }
|
||||||
];
|
];
|
||||||
/** Generic RBI-prescribed KYC documents for legal entities (Master Direction on KYC,
|
/** Constitution-based KYC document types for non-individual clients. Shown in addition to
|
||||||
* Annexure I) — shown for non-individual clients before a specific constitution type is
|
* the RBI entity documents (ENTITY_KYC_DOCUMENT_TYPES), never instead of them — see
|
||||||
* picked. Individual OVDs (Aadhaar/Passport/DL/Voter ID/NREGA/NPR) don't apply to entities. */
|
* withEntityKycDocuments. */
|
||||||
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 */
|
|
||||||
private constitutionDocTypes: Record<string, { name: string; mandatory: boolean }[]> = {
|
private constitutionDocTypes: Record<string, { name: string; mandatory: boolean }[]> = {
|
||||||
HUF: [
|
HUF: [
|
||||||
{ name: 'PAN Card of HUF', mandatory: true },
|
{ name: 'PAN Card of HUF', mandatory: true },
|
||||||
@@ -513,7 +537,14 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
gstinVerifyState = '';
|
gstinVerifyState = '';
|
||||||
|
|
||||||
/** CIN verification (dummy) */
|
/** CIN verification (dummy) */
|
||||||
cinVerifyStatus: 'NONE' | 'VERIFIED' | 'FAILED' = 'NONE';
|
/**
|
||||||
|
* UNCONFIRMED means the CIN is saved and correctly formatted, but MCA (via LincPay) would not
|
||||||
|
* confirm it. Kept distinct from VERIFIED, which is only ever set by a positive answer from the
|
||||||
|
* provider, and from FAILED, which is reserved for the provider being unreachable.
|
||||||
|
*/
|
||||||
|
cinVerifyStatus: 'NONE' | 'VERIFIED' | 'UNCONFIRMED' | 'FAILED' = 'NONE';
|
||||||
|
/** Explanation shown beside the CIN when the provider could not confirm it. */
|
||||||
|
cinUnconfirmedMessage = '';
|
||||||
cinVerifying = false;
|
cinVerifying = false;
|
||||||
cinVerifiedOn = '';
|
cinVerifiedOn = '';
|
||||||
cinCompanyName = '';
|
cinCompanyName = '';
|
||||||
@@ -648,9 +679,7 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
/** Individual-only OVDs vs. entity-only RBI docs — never both, and minor filtering only
|
/** Individual-only OVDs vs. entity-only RBI docs — never both, and minor filtering only
|
||||||
* ever applies to individuals. */
|
* ever applies to individuals. */
|
||||||
private getDefaultDocTypes(): { name: string; code: string }[] {
|
private getDefaultDocTypes(): { name: string; code: string }[] {
|
||||||
return this.isNonIndividual
|
return this.isNonIndividual ? ENTITY_KYC_DOCUMENT_TYPES : this.filterDocTypesForMinor(this.defaultDocumentTypes);
|
||||||
? this.nonIndividualDefaultDocTypes
|
|
||||||
: this.filterDocTypesForMinor(this.defaultDocumentTypes);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
@@ -673,9 +702,9 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
if (this.calculatedRisk) {
|
if (this.calculatedRisk) {
|
||||||
riskControl?.setValue(this.calculatedRisk.category);
|
riskControl?.setValue(this.calculatedRisk.category);
|
||||||
}
|
}
|
||||||
if (!this.isKycVerified) {
|
// Overriding the risk category is the only thing this form demands a justification for.
|
||||||
this.kycForm.get('remarks')?.clearValidators();
|
// A verified record does not, by itself, need one — see loadExistingKycIfNeeded.
|
||||||
}
|
this.kycForm.get('remarks')?.clearValidators();
|
||||||
}
|
}
|
||||||
this.kycForm.get('remarks')?.updateValueAndValidity();
|
this.kycForm.get('remarks')?.updateValueAndValidity();
|
||||||
});
|
});
|
||||||
@@ -797,6 +826,7 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
this.gstinVerifiedOn = '';
|
this.gstinVerifiedOn = '';
|
||||||
this.gstinVerifyState = '';
|
this.gstinVerifyState = '';
|
||||||
this.cinVerifyStatus = 'NONE';
|
this.cinVerifyStatus = 'NONE';
|
||||||
|
this.cinUnconfirmedMessage = '';
|
||||||
this.cinVerifying = false;
|
this.cinVerifying = false;
|
||||||
this.cinVerifiedOn = '';
|
this.cinVerifiedOn = '';
|
||||||
this.cinCompanyName = '';
|
this.cinCompanyName = '';
|
||||||
@@ -898,14 +928,26 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
private updateDocTypesForConstitution(): void {
|
private updateDocTypesForConstitution(): void {
|
||||||
const constitutionValue = this.constitutionMeta?.value;
|
const constitutionValue = this.constitutionMeta?.value;
|
||||||
if (constitutionValue && this.constitutionDocTypes[constitutionValue]) {
|
if (constitutionValue && this.constitutionDocTypes[constitutionValue]) {
|
||||||
this.kycDocumentTypes = this.constitutionDocTypes[constitutionValue].map((doc) => ({
|
// The RBI entity documents stay at the head of the list — picking a constitution adds that
|
||||||
name: doc.name,
|
// constitution's own requirements, it does not swap out the set the officer was just shown.
|
||||||
code: doc.name,
|
this.kycDocumentTypes = withEntityKycDocuments(
|
||||||
mandatory: doc.mandatory
|
this.constitutionDocTypes[constitutionValue],
|
||||||
}));
|
this.constitutionMeta?.label || null
|
||||||
// Reset selected document type since options changed
|
);
|
||||||
this.kycForm.get('kycDocumentName')?.setValue('');
|
|
||||||
this.kycForm.get('kycDocumentName')?.updateValueAndValidity();
|
// Clearing the chosen document type only makes sense when the officer switches constitution,
|
||||||
|
// which genuinely invalidates it. It must NOT happen the first time a constitution arrives:
|
||||||
|
// on edit that happens after the saved KYC has been restored, so wiping the value here would
|
||||||
|
// discard the restored document type — and with it the upload slots that display the file
|
||||||
|
// already held against the client, since they render only once a type is selected.
|
||||||
|
const constitutionChanged =
|
||||||
|
this.appliedConstitutionValue !== null && this.appliedConstitutionValue !== constitutionValue;
|
||||||
|
this.appliedConstitutionValue = constitutionValue;
|
||||||
|
if (constitutionChanged) {
|
||||||
|
this.kycForm.get('kycDocumentName')?.setValue('');
|
||||||
|
this.kycForm.get('kycDocumentName')?.updateValueAndValidity();
|
||||||
|
}
|
||||||
|
// Re-resolves the selected chip against the new list from whatever name is on the form.
|
||||||
this.rebuildKycDocPicker();
|
this.rebuildKycDocPicker();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -913,18 +955,51 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
/** Rebuild chip-group picker model from kycDocumentTypes */
|
/** Rebuild chip-group picker model from kycDocumentTypes */
|
||||||
private rebuildKycDocPicker(): void {
|
private rebuildKycDocPicker(): void {
|
||||||
this.kycDocProofs = (this.kycDocumentTypes || []).map((d, i) =>
|
this.kycDocProofs = (this.kycDocumentTypes || []).map((d, i) =>
|
||||||
normalizeProofType({ id: d.code || d.name || i, name: d.name, code: d.code, description: d.code || d.name })
|
normalizeProofType({
|
||||||
|
id: d.code || d.name || i,
|
||||||
|
name: d.name,
|
||||||
|
code: d.code,
|
||||||
|
description: d.code || d.name,
|
||||||
|
// Constitution-specific documents carry no code PROOF_META knows, so their group label
|
||||||
|
// has to be handed over explicitly or they all collect under "Other".
|
||||||
|
category: d.category
|
||||||
|
})
|
||||||
);
|
);
|
||||||
this.kycDocGroups = groupProofsByCategory(this.kycDocProofs);
|
this.kycDocGroups = groupProofsByCategory(this.kycDocProofs);
|
||||||
// Sync selected proof with current form value (e.g. on edit-mode prefill)
|
// Sync selected proof with current form value (e.g. on edit-mode prefill)
|
||||||
const currentName = this.kycForm?.get('kycDocumentName')?.value;
|
const currentName = this.kycForm?.get('kycDocumentName')?.value;
|
||||||
if (currentName) {
|
this.selectedKycDoc = currentName ? this.findKycDocProofByName(currentName) : null;
|
||||||
const cleanStr = (s: string) => (s || '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
}
|
||||||
const targetName = cleanStr(currentName);
|
|
||||||
this.selectedKycDoc = this.kycDocProofs.find((p) => cleanStr(p.name) === targetName) || null;
|
/**
|
||||||
} else {
|
* Resolves a stored document-type name back to a chip in the picker.
|
||||||
this.selectedKycDoc = null;
|
*
|
||||||
|
* A tolerant match is required because the two names are not written by the same thing: an
|
||||||
|
* identifier row carries the CodeValue label the backend holds ("Certificate of Incorporation")
|
||||||
|
* while the picker shows the fuller, constitution-specific label ("Certificate of Incorporation /
|
||||||
|
* Registration Certificate"), so one is usually contained in the other rather than equal to it.
|
||||||
|
* An exact comparison leaves the chip unselected on edit, which in turn hides the file-upload
|
||||||
|
* slots and therefore the document the officer already uploaded.
|
||||||
|
*/
|
||||||
|
private findKycDocProofByName(name: string | null | undefined): ProofType | null {
|
||||||
|
const clean = (s: string) => (s || '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||||
|
const target = clean(name || '');
|
||||||
|
if (!target) {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
const proofs = this.kycDocProofs || [];
|
||||||
|
const exact = proofs.find((p) => clean(p.name) === target);
|
||||||
|
if (exact) {
|
||||||
|
return exact;
|
||||||
|
}
|
||||||
|
// Closest containment wins, so a short label cannot claim a chip that a nearer one also matches.
|
||||||
|
const contained = proofs
|
||||||
|
.filter((p) => {
|
||||||
|
const candidate = clean(p.name);
|
||||||
|
return candidate.includes(target) || target.includes(candidate);
|
||||||
|
})
|
||||||
|
.sort((a, b) => Math.abs(clean(a.name).length - target.length) - Math.abs(clean(b.name).length - target.length));
|
||||||
|
return contained[0] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
selectKycDoc(proof: ProofType): void {
|
selectKycDoc(proof: ProofType): void {
|
||||||
@@ -1102,19 +1177,17 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
isException: data.isException || false,
|
isException: data.isException || false,
|
||||||
exceptionReason: data.exceptionReason ? data.exceptionReason.split(', ') : []
|
exceptionReason: data.exceptionReason ? data.exceptionReason.split(', ') : []
|
||||||
});
|
});
|
||||||
// Set remarks as required if KYC is verified
|
// Remarks are NOT forced just because the record is verified. The server asks for them
|
||||||
if (this.isKycVerified) {
|
// only when a save actually overwrites data the verified record already held
|
||||||
this.kycForm.get('remarks')?.setValidators(Validators.required);
|
// (ClientKycWritePlatformServiceJpaRepositoryImpl.updateKyc), and it says so in the error
|
||||||
this.kycForm.get('remarks')?.updateValueAndValidity();
|
// it returns. Demanding them up front made every non-individual client open with a red
|
||||||
this.kycForm.get('remarks')?.markAsTouched(); // Force red underline so user knows it's blocking
|
// "Reason for Re-edit" the moment PAN and GSTIN auto-verified — before the officer had
|
||||||
}
|
// changed anything, and while they still had the KYC document fields left to fill in.
|
||||||
this.persistedIsException = data.isException === true;
|
this.persistedIsException = data.isException === true;
|
||||||
this.persistedExceptionReason = data.exceptionReason || null;
|
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) {
|
||||||
const cleanStr = (s: string) => (s || '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
this.selectedKycDoc = this.findKycDocProofByName(data.kycDocumentName);
|
||||||
const targetName = cleanStr(data.kycDocumentName);
|
|
||||||
this.selectedKycDoc = this.kycDocProofs.find((p) => cleanStr(p.name) === targetName) || null;
|
|
||||||
}
|
}
|
||||||
// Set verification statuses from existing data
|
// Set verification statuses from existing data
|
||||||
if (data.panVerified) {
|
if (data.panVerified) {
|
||||||
@@ -1175,6 +1248,8 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
}
|
}
|
||||||
if (data.cinVerified) {
|
if (data.cinVerified) {
|
||||||
this.cinVerifyStatus = 'VERIFIED';
|
this.cinVerifyStatus = 'VERIFIED';
|
||||||
|
// The verified card shows this; without it a reopened client renders the panel blank.
|
||||||
|
this.cinCompanyName = data.cinCompanyName || 'Verified via MCA';
|
||||||
const rawCinDate = data.cinVerifiedOn;
|
const rawCinDate = data.cinVerifiedOn;
|
||||||
if (Array.isArray(rawCinDate) && rawCinDate.length >= 3) {
|
if (Array.isArray(rawCinDate) && rawCinDate.length >= 3) {
|
||||||
this.cinVerifiedOn = `${rawCinDate[2]?.toString().padStart(2, '0')}/${rawCinDate[1]?.toString().padStart(2, '0')}/${rawCinDate[0]}`;
|
this.cinVerifiedOn = `${rawCinDate[2]?.toString().padStart(2, '0')}/${rawCinDate[1]?.toString().padStart(2, '0')}/${rawCinDate[0]}`;
|
||||||
@@ -1243,11 +1318,14 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
// Backfill document type + number on the form in case the KYC record didn't have
|
// Backfill document type + number on the form in case the KYC record didn't have
|
||||||
// them (older clients were created via the identifier flow only).
|
// them (older clients were created via the identifier flow only).
|
||||||
if (!this.kycForm.get('kycDocumentName')?.value && identifier?.documentType?.name) {
|
if (!this.kycForm.get('kycDocumentName')?.value && identifier?.documentType?.name) {
|
||||||
|
const proof = this.findKycDocProofByName(identifier.documentType.name);
|
||||||
|
this.selectedKycDoc = proof;
|
||||||
|
// Store the picker's own label rather than the identifier's when one matched, so the
|
||||||
|
// selected chip and the form value agree and a later save round-trips the same name.
|
||||||
this.kycForm.patchValue({
|
this.kycForm.patchValue({
|
||||||
kycDocumentName: identifier.documentType.name,
|
kycDocumentName: proof?.name || identifier.documentType.name,
|
||||||
kycDocumentNumber: identifier.documentKey || ''
|
kycDocumentNumber: identifier.documentKey || ''
|
||||||
});
|
});
|
||||||
this.selectedKycDoc = this.kycDocProofs.find((p) => p.name === identifier.documentType.name) || null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.clientsService.getClientIdentificationDocuments(this.existingKycIdentifierId).subscribe({
|
this.clientsService.getClientIdentificationDocuments(this.existingKycIdentifierId).subscribe({
|
||||||
@@ -1453,13 +1531,18 @@ 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 = this.filterDocTypesForMinor(
|
const templateDocs = template.requiredDocuments.map((doc: any) => ({
|
||||||
template.requiredDocuments.map((doc: any) => ({
|
name: doc.name,
|
||||||
name: doc.name,
|
code: doc.code || doc.name,
|
||||||
code: doc.code || doc.name,
|
mandatory: doc.mandatory ?? false
|
||||||
mandatory: doc.mandatory ?? false
|
}));
|
||||||
}))
|
// The backend template names the same entity documents differently ("Certificate of
|
||||||
);
|
// Incorporation" / "COI", "PAN Card of Company" / "PAN"), so for an entity it is merged
|
||||||
|
// onto the RBI list rather than replacing it — otherwise reopening a non-individual
|
||||||
|
// client for edit shows a different set of chips than the one used to create it.
|
||||||
|
this.kycDocumentTypes = this.isNonIndividual
|
||||||
|
? withEntityKycDocuments(templateDocs, template?.clientTypeName || this.constitutionMeta?.label || null)
|
||||||
|
: this.filterDocTypesForMinor(templateDocs);
|
||||||
// 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) {
|
||||||
@@ -1472,11 +1555,18 @@ 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.getDefaultDocTypes().map((d) => ({
|
// An entity keeps whatever constitution-specific documents it already has alongside the
|
||||||
name: d.name,
|
// RBI list; a failed template call must not quietly shorten the picker.
|
||||||
code: d.code,
|
const constitutionDocs = this.isNonIndividual
|
||||||
mandatory: false
|
? this.constitutionDocTypes[this.constitutionMeta?.value] || []
|
||||||
}));
|
: [];
|
||||||
|
this.kycDocumentTypes = this.isNonIndividual
|
||||||
|
? withEntityKycDocuments(constitutionDocs, this.constitutionMeta?.label || null)
|
||||||
|
: this.getDefaultDocTypes().map((d) => ({
|
||||||
|
name: d.name,
|
||||||
|
code: d.code,
|
||||||
|
mandatory: false
|
||||||
|
}));
|
||||||
this.rebuildKycDocPicker();
|
this.rebuildKycDocPicker();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -1485,17 +1575,25 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
/** Save or update KYC, then execute callback */
|
/** Save or update KYC, then execute callback */
|
||||||
private saveKycAndDo(payload: any, callback: () => void, onError: (msg: string) => void) {
|
private saveKycAndDo(payload: any, callback: () => void, onError: (msg: string) => void) {
|
||||||
if (this.kycId) {
|
if (this.kycId) {
|
||||||
const { kycType, ...updatePayload } = payload;
|
// riskCategory is dropped alongside kycType on an update. The verify payloads only carry it to
|
||||||
|
// satisfy the initiate validator, which requires it when the record is first created; once the
|
||||||
|
// row exists, re-asserting it on every PAN/GSTIN/CIN check means a verification silently
|
||||||
|
// rewrites the risk category. On a record that has auto-verified that reads as an edit of
|
||||||
|
// verified data and is refused, blocking the verification itself. The officer's risk choice is
|
||||||
|
// persisted by the KYC step's own save, which does not come through here.
|
||||||
|
const { kycType, riskCategory, ...updatePayload } = payload;
|
||||||
|
|
||||||
// If KYC is VERIFIED, remarks are mandatory — use re-edit command
|
// If KYC is VERIFIED, changing data already on the record needs remarks — use re-edit command.
|
||||||
|
// Whether this particular save actually replaces stored data is something only the server can
|
||||||
|
// tell (it holds the current values), so it decides: a non-individual record auto-verifies as
|
||||||
|
// soon as PAN and GSTIN pass, and the officer must still be able to fill in the KYC document
|
||||||
|
// fields that are empty at that point. Remarks are forwarded when present and the server
|
||||||
|
// rejects the save if they were required.
|
||||||
if (this.isKycVerified) {
|
if (this.isKycVerified) {
|
||||||
const remarks = this.kycForm.get('remarks')?.value?.trim();
|
const remarks = this.kycForm.get('remarks')?.value?.trim();
|
||||||
if (!remarks) {
|
if (remarks) {
|
||||||
onError('Remarks are mandatory when updating a verified KYC record.');
|
updatePayload.remarks = remarks;
|
||||||
this.kycForm.get('remarks')?.markAsTouched();
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
updatePayload.remarks = remarks;
|
|
||||||
this.clientsService.reEditClientKyc(this.clientId, this.kycId, updatePayload).subscribe({
|
this.clientsService.reEditClientKyc(this.clientId, this.kycId, updatePayload).subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
// Status transitions to IN_PROGRESS, PAN verification is invalidated
|
// Status transitions to IN_PROGRESS, PAN verification is invalidated
|
||||||
@@ -1571,12 +1669,30 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
const aadhaarRaw = this.kycForm.value.aadhaarNumber || '';
|
const aadhaarRaw = this.kycForm.value.aadhaarNumber || '';
|
||||||
const panPayload: any = {
|
const panPayload: any = {
|
||||||
locale: 'en',
|
locale: 'en',
|
||||||
kycType: 1,
|
kycType: this.kycTypeValue,
|
||||||
riskCategory: this.kycForm.value.riskCategory || 1,
|
riskCategory: this.currentRiskCategory,
|
||||||
panNumber: pan,
|
panNumber: pan,
|
||||||
aadhaarNumberMasked: aadhaarRaw.length === 12 ? 'XXXXXXXX' + aadhaarRaw.slice(-4) : 'XXXXXXXX0000'
|
aadhaarNumberMasked: aadhaarRaw.length === 12 ? 'XXXXXXXX' + aadhaarRaw.slice(-4) : 'XXXXXXXX0000'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// A proprietorship reaches this method (it verifies the proprietor's individual PAN) but is
|
||||||
|
// still legalForm=ENTITY to the backend, which matches an entity on name + date of
|
||||||
|
// incorporation. Persist that date with this save so verifyPAN() can read it off the record.
|
||||||
|
// Never sent for individuals — the backend rejects entity-only fields on an individual KYC.
|
||||||
|
if (this.isNonIndividual) {
|
||||||
|
const incorpDate = this.kycForm.value.incorporationDate;
|
||||||
|
const dateFormat = this.settingsService.dateFormat;
|
||||||
|
panPayload.dateFormat = dateFormat;
|
||||||
|
panPayload.incorporationDate =
|
||||||
|
incorpDate instanceof Date ? this.dateUtils.formatDate(incorpDate, dateFormat) : incorpDate || null;
|
||||||
|
if (!panPayload.incorporationDate) {
|
||||||
|
this.panVerifying = false;
|
||||||
|
this.panVerifyError = 'Enter the Date of Incorporation before verifying the PAN.';
|
||||||
|
this.kycForm.get('incorporationDate')?.markAsTouched();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.saveKycAndDo(
|
this.saveKycAndDo(
|
||||||
panPayload,
|
panPayload,
|
||||||
() => {
|
() => {
|
||||||
@@ -1834,16 +1950,27 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
const dateFormat = this.settingsService.dateFormat;
|
const dateFormat = this.settingsService.dateFormat;
|
||||||
const incorpDate = this.kycForm.value.incorporationDate;
|
const incorpDate = this.kycForm.value.incorporationDate;
|
||||||
|
|
||||||
// Same payload structure as individual — backend uses panNumber + client name/DOB for verification
|
// Same payload structure as individual — backend uses panNumber + client name/DOB for verification.
|
||||||
|
// For an entity the provider's "DOB" is the date of incorporation, so it must be persisted on the
|
||||||
|
// KYC record by this same save — verifyPAN() below reads it back off the record, not off the form.
|
||||||
const payload: any = {
|
const payload: any = {
|
||||||
locale: 'en',
|
locale: 'en',
|
||||||
kycType: 1,
|
kycType: this.kycTypeValue,
|
||||||
riskCategory: this.kycForm.value.riskCategory || 1,
|
riskCategory: this.currentRiskCategory,
|
||||||
panNumber: pan, // Company PAN → panNumber
|
panNumber: pan, // Company PAN → panNumber
|
||||||
|
enterprisePan: pan,
|
||||||
|
incorporationDate: incorpDate instanceof Date ? this.dateUtils.formatDate(incorpDate, dateFormat) : incorpDate,
|
||||||
dateFormat,
|
dateFormat,
|
||||||
aadhaarNumberMasked: 'XXXXXXXX0000' // Placeholder — not applicable for entities
|
aadhaarNumberMasked: 'XXXXXXXX0000' // Placeholder — not applicable for entities
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!payload.incorporationDate) {
|
||||||
|
this.entPanVerifying = false;
|
||||||
|
this.showPanErrorAlert('Enter the Date of Incorporation before verifying the Enterprise PAN.');
|
||||||
|
this.kycForm.get('incorporationDate')?.markAsTouched();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.saveKycAndDo(
|
this.saveKycAndDo(
|
||||||
payload,
|
payload,
|
||||||
() => {
|
() => {
|
||||||
@@ -1862,6 +1989,11 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
rawDate ||
|
rawDate ||
|
||||||
`${now.getDate().toString().padStart(2, '0')}/${(now.getMonth() + 1).toString().padStart(2, '0')}/${now.getFullYear()}`;
|
`${now.getDate().toString().padStart(2, '0')}/${(now.getMonth() + 1).toString().padStart(2, '0')}/${now.getFullYear()}`;
|
||||||
}
|
}
|
||||||
|
// Seeding status — mirrors verifyPan(), so the value the provider returned is held
|
||||||
|
// here too and the next save round-trips it instead of leaving this screen blank.
|
||||||
|
if (res.changes?.panAadhaarSeedingStatus) {
|
||||||
|
this.panAadhaarSeedingStatus = res.changes.panAadhaarSeedingStatus;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
this.entPanVerifyStatus = 'FAILED';
|
this.entPanVerifyStatus = 'FAILED';
|
||||||
this.showPanErrorAlert(
|
this.showPanErrorAlert(
|
||||||
@@ -1907,8 +2039,8 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
|
|
||||||
const payload: any = {
|
const payload: any = {
|
||||||
locale: 'en',
|
locale: 'en',
|
||||||
kycType: 1,
|
kycType: this.kycTypeValue,
|
||||||
riskCategory: this.kycForm.value.riskCategory || 1,
|
riskCategory: this.currentRiskCategory,
|
||||||
gstinNumber: gstin,
|
gstinNumber: gstin,
|
||||||
dateFormat,
|
dateFormat,
|
||||||
panNumber: this.kycForm.value.enterprisePan || this.kycForm.value.panNumber || null,
|
panNumber: this.kycForm.value.enterprisePan || this.kycForm.value.panNumber || null,
|
||||||
@@ -1976,7 +2108,105 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!this.clientId) {
|
||||||
|
this.showPanErrorAlert('Client must be created first. Complete General tab and click Next.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.cinVerifying = true;
|
this.cinVerifying = true;
|
||||||
|
this.startCinProgress();
|
||||||
|
|
||||||
|
// The CIN has to be on the record before the command runs: the backend reads it off the KYC row
|
||||||
|
// (and rejects with "CIN number is not set on this KYC record" otherwise) rather than taking it
|
||||||
|
// from the request. Same save-then-verify order as PAN and GSTIN.
|
||||||
|
const payload: any = {
|
||||||
|
locale: 'en',
|
||||||
|
kycType: this.kycTypeValue,
|
||||||
|
riskCategory: this.currentRiskCategory,
|
||||||
|
cinNumber: cin,
|
||||||
|
dateFormat: this.settingsService.dateFormat,
|
||||||
|
panNumber: this.kycForm.value.enterprisePan || this.kycForm.value.panNumber || null,
|
||||||
|
aadhaarNumberMasked: 'XXXXXXXX0000'
|
||||||
|
};
|
||||||
|
|
||||||
|
this.saveKycAndDo(
|
||||||
|
payload,
|
||||||
|
() => {
|
||||||
|
this.clientsService.verifyCIN(this.clientId, this.kycId).subscribe({
|
||||||
|
next: (res: any) => {
|
||||||
|
this.stopCinProgress();
|
||||||
|
const changes = res?.changes || res;
|
||||||
|
if (changes?.cinVerified === true) {
|
||||||
|
this.cinVerifyStatus = 'VERIFIED';
|
||||||
|
// The label distinguishes a CIN the registry actually matched from one accepted on
|
||||||
|
// its structure alone, so the card never claims an MCA confirmation that never came.
|
||||||
|
this.cinCompanyName =
|
||||||
|
changes?.cinCompanyName ||
|
||||||
|
(changes?.cinConfirmedByRegistry === false ? 'Format validated' : 'Verified via MCA');
|
||||||
|
this.cinUnconfirmedMessage =
|
||||||
|
changes?.cinConfirmedByRegistry === false ? changes?.cinVerificationMessage || '' : '';
|
||||||
|
const rawDate = changes?.cinVerifiedOn;
|
||||||
|
if (Array.isArray(rawDate)) {
|
||||||
|
this.cinVerifiedOn = `${rawDate[2]?.toString().padStart(2, '0')}/${rawDate[1]?.toString().padStart(2, '0')}/${rawDate[0]}`;
|
||||||
|
} else {
|
||||||
|
const now = new Date();
|
||||||
|
this.cinVerifiedOn =
|
||||||
|
rawDate ||
|
||||||
|
`${now.getDate().toString().padStart(2, '0')}/${(now.getMonth() + 1).toString().padStart(2, '0')}/${now.getFullYear()}`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.markCinUnconfirmed(changes?.cinVerificationMessage);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: (err: any) => {
|
||||||
|
this.stopCinProgress();
|
||||||
|
const message =
|
||||||
|
err?.error?.errors?.[0]?.defaultUserMessage ||
|
||||||
|
err?.error?.defaultUserMessage ||
|
||||||
|
'CIN verification failed.';
|
||||||
|
// A 503 means the provider could not be reached, which is worth interrupting the officer
|
||||||
|
// for — it is likely to succeed on a retry. Anything else is the provider having
|
||||||
|
// answered without confirming the company, which is not something a retry fixes and not
|
||||||
|
// something the officer can correct, so it is reported in place instead of as an alert.
|
||||||
|
if (err?.status === 503) {
|
||||||
|
this.cinVerifyStatus = 'FAILED';
|
||||||
|
this.showPanErrorAlert(message);
|
||||||
|
} else {
|
||||||
|
this.markCinUnconfirmed(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
(msg) => {
|
||||||
|
this.stopCinProgress();
|
||||||
|
this.showPanErrorAlert(msg);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The CIN is stored and well-formed, but MCA did not confirm it.
|
||||||
|
*
|
||||||
|
* Reported in place rather than as an error dialog because there is nothing for the officer to
|
||||||
|
* correct: the number is already saved and the provider simply has no record to match it against.
|
||||||
|
* Deliberately NOT recorded as verified — `cinVerified` stays false on the KYC record, so the
|
||||||
|
* distinction between a confirmed and an unconfirmed CIN survives into the client's history.
|
||||||
|
*/
|
||||||
|
private markCinUnconfirmed(message?: string): void {
|
||||||
|
this.cinVerifyStatus = 'UNCONFIRMED';
|
||||||
|
this.cinUnconfirmedMessage =
|
||||||
|
message || 'MCA could not confirm this CIN. The number has been saved against the client.';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drives the stepped MCA progress display while the verification is in flight. Deliberately stops
|
||||||
|
* short of the last step and of 100%: the real outcome comes from the server, and letting the bar
|
||||||
|
* finish on its own is what used to make an unverified CIN look verified.
|
||||||
|
*/
|
||||||
|
private startCinProgress(): void {
|
||||||
|
if (this.cinProgressTimer) {
|
||||||
|
clearInterval(this.cinProgressTimer);
|
||||||
|
}
|
||||||
this.cinProgressValue = 0;
|
this.cinProgressValue = 0;
|
||||||
this.cinCurrentStep = 0;
|
this.cinCurrentStep = 0;
|
||||||
|
|
||||||
@@ -2000,22 +2230,23 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.cinProgressValue = Math.min(Math.round((elapsed / totalDuration) * 100), 100);
|
this.cinProgressValue = Math.min(Math.round((elapsed / totalDuration) * 100), 90);
|
||||||
|
|
||||||
if (elapsed >= totalDuration) {
|
if (elapsed >= totalDuration) {
|
||||||
clearInterval(this.cinProgressTimer);
|
clearInterval(this.cinProgressTimer);
|
||||||
this.cinCurrentStep = stepDurations.length;
|
this.cinProgressTimer = null;
|
||||||
setTimeout(() => {
|
|
||||||
this.cinVerifying = false;
|
|
||||||
this.cinVerifyStatus = 'VERIFIED';
|
|
||||||
this.cinCompanyName = 'Verified via MCA';
|
|
||||||
const now = new Date();
|
|
||||||
this.cinVerifiedOn = `${now.getDate().toString().padStart(2, '0')}/${(now.getMonth() + 1).toString().padStart(2, '0')}/${now.getFullYear()}`;
|
|
||||||
}, 300);
|
|
||||||
}
|
}
|
||||||
}, interval);
|
}, interval);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private stopCinProgress(): void {
|
||||||
|
if (this.cinProgressTimer) {
|
||||||
|
clearInterval(this.cinProgressTimer);
|
||||||
|
this.cinProgressTimer = null;
|
||||||
|
}
|
||||||
|
this.cinProgressValue = 100;
|
||||||
|
this.cinVerifying = false;
|
||||||
|
}
|
||||||
|
|
||||||
/** Send Aadhaar OTP — saves Aadhaar-only KYC data, then sends OTP */
|
/** Send Aadhaar OTP — saves Aadhaar-only KYC data, then sends OTP */
|
||||||
sendAadhaarOtp() {
|
sendAadhaarOtp() {
|
||||||
if (!this.clientId) {
|
if (!this.clientId) {
|
||||||
@@ -2041,8 +2272,8 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
const aadhaarMasked = 'XXXXXXXX' + aadhaar.slice(-4);
|
const aadhaarMasked = 'XXXXXXXX' + aadhaar.slice(-4);
|
||||||
const aadhaarPayload: any = {
|
const aadhaarPayload: any = {
|
||||||
locale: 'en',
|
locale: 'en',
|
||||||
kycType: 1,
|
kycType: this.kycTypeValue,
|
||||||
riskCategory: this.kycForm.value.riskCategory || 1,
|
riskCategory: this.currentRiskCategory,
|
||||||
aadhaarNumberMasked: aadhaarMasked
|
aadhaarNumberMasked: aadhaarMasked
|
||||||
};
|
};
|
||||||
const panVal = this.kycForm.value.panNumber;
|
const panVal = this.kycForm.value.panNumber;
|
||||||
@@ -2203,7 +2434,7 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getRenewalYears(): number {
|
getRenewalYears(): number {
|
||||||
switch (this.kycForm.value.riskCategory) {
|
switch (this.currentRiskCategory) {
|
||||||
case 1:
|
case 1:
|
||||||
return 10;
|
return 10;
|
||||||
case 2:
|
case 2:
|
||||||
@@ -2387,12 +2618,16 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
return {
|
return {
|
||||||
locale: 'en',
|
locale: 'en',
|
||||||
dateFormat,
|
dateFormat,
|
||||||
kycType: 1,
|
kycType: this.kycTypeValue,
|
||||||
riskCategory: v.riskCategory || 1,
|
riskCategory: v.riskCategory || 1,
|
||||||
// For non-individual: Company PAN is used as panNumber for verification
|
// For non-individual: Company PAN is used as panNumber for verification
|
||||||
panNumber: this.isNonIndividual ? enterprisePanNorm : v.hasPan ? panNorm : null,
|
panNumber: this.isNonIndividual ? enterprisePanNorm : v.hasPan ? panNorm : null,
|
||||||
aadhaarNumberMasked: this.isNonIndividual ? 'XXXXXXXX0000' : aadhaarMasked,
|
aadhaarNumberMasked: this.isNonIndividual ? 'XXXXXXXX0000' : aadhaarMasked,
|
||||||
panAadhaarSeedingStatus: this.panAadhaarSeedingStatus || null,
|
// Only ever sent when this screen actually holds a value. The seeding status is derived by
|
||||||
|
// the provider during PAN verification and written straight to the record, so sending an
|
||||||
|
// empty one back would blank a field the server filled in — which reads as an officer
|
||||||
|
// editing a verified record rather than the no-op it is.
|
||||||
|
...(this.panAadhaarSeedingStatus ? { panAadhaarSeedingStatus: this.panAadhaarSeedingStatus } : {}),
|
||||||
// PAN declaration — stored so the waived exception can be justified later, and so a reload
|
// PAN declaration — stored so the waived exception can be justified later, and so a reload
|
||||||
// does not silently drop the paperwork that is holding the exception off.
|
// does not silently drop the paperwork that is holding the exception off.
|
||||||
panDeclarationTaken: this.panDeclarationTaken,
|
panDeclarationTaken: this.panDeclarationTaken,
|
||||||
@@ -2470,6 +2705,7 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
v.gstinNumber ||
|
v.gstinNumber ||
|
||||||
v.enterprisePan ||
|
v.enterprisePan ||
|
||||||
v.cinNumber ||
|
v.cinNumber ||
|
||||||
|
v.incorporationDate ||
|
||||||
v.kycDocumentName ||
|
v.kycDocumentName ||
|
||||||
v.form15Applicable ||
|
v.form15Applicable ||
|
||||||
v.isException
|
v.isException
|
||||||
|
|||||||
+6
-6
@@ -442,18 +442,18 @@
|
|||||||
<span class="preview-value mono">{{ kycData.enterprisePan }}</span>
|
<span class="preview-value mono">{{ kycData.enterprisePan }}</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
@if (kycData.incorporationDate) {
|
||||||
|
<div class="preview-field">
|
||||||
|
<span class="preview-label">Date of Incorporation</span>
|
||||||
|
<span class="preview-value">{{ kycData.incorporationDate }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@if (kycData.cinNumber) {
|
@if (kycData.cinNumber) {
|
||||||
<div class="preview-field">
|
<div class="preview-field">
|
||||||
<span class="preview-label">CIN (Corporate Identity Number)</span>
|
<span class="preview-label">CIN (Corporate Identity Number)</span>
|
||||||
<span class="preview-value mono">{{ kycData.cinNumber }}</span>
|
<span class="preview-value mono">{{ kycData.cinNumber }}</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if (kycData.incorporationDate) {
|
|
||||||
<div class="preview-field">
|
|
||||||
<span class="preview-label">Incorporation Date</span>
|
|
||||||
<span class="preview-value">{{ kycData.incorporationDate | dateFormat }}</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@if (kycData.gstinNumber) {
|
@if (kycData.gstinNumber) {
|
||||||
<div class="preview-field">
|
<div class="preview-field">
|
||||||
<span class="preview-label">GSTIN Number</span>
|
<span class="preview-label">GSTIN Number</span>
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ import { CkycTabComponent } from './clients-view/ckyc-tab/ckyc-tab.component';
|
|||||||
import { Form60TabComponent } from './clients-view/form60-tab/form60-tab.component';
|
import { Form60TabComponent } from './clients-view/form60-tab/form60-tab.component';
|
||||||
import { Form121TabComponent } from './clients-view/form121-tab/form121-tab.component';
|
import { Form121TabComponent } from './clients-view/form121-tab/form121-tab.component';
|
||||||
import { EntityClassificationTabComponent } from './clients-view/entity-classification-tab/entity-classification-tab.component';
|
import { EntityClassificationTabComponent } from './clients-view/entity-classification-tab/entity-classification-tab.component';
|
||||||
|
import { AuthorizedSignatoriesTabComponent } from './clients-view/authorized-signatories-tab/authorized-signatories-tab.component';
|
||||||
import { familyAllowedGuard, nomineeAllowedGuard } from './utils/family-nominee.guard';
|
import { familyAllowedGuard, nomineeAllowedGuard } from './utils/family-nominee.guard';
|
||||||
|
|
||||||
const routes: Routes = [
|
const routes: Routes = [
|
||||||
@@ -199,6 +200,15 @@ const routes: Routes = [
|
|||||||
component: Form121TabComponent,
|
component: Form121TabComponent,
|
||||||
data: { title: 'Form 121 / TDS', breadcrumb: 'Form 121 / TDS', routeParamBreadcrumb: false }
|
data: { title: 'Form 121 / TDS', breadcrumb: 'Form 121 / TDS', routeParamBreadcrumb: false }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'authorized-signatories',
|
||||||
|
component: AuthorizedSignatoriesTabComponent,
|
||||||
|
data: {
|
||||||
|
title: 'Authorized Signatories',
|
||||||
|
breadcrumb: 'Authorized Signatories',
|
||||||
|
routeParamBreadcrumb: false
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'documents',
|
path: 'documents',
|
||||||
component: DocumentsTabComponent,
|
component: DocumentsTabComponent,
|
||||||
|
|||||||
+95
@@ -0,0 +1,95 @@
|
|||||||
|
<!--
|
||||||
|
Copyright since 2025 Mifos Initiative
|
||||||
|
|
||||||
|
This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
-->
|
||||||
|
<div class="as-view">
|
||||||
|
@if (loading) {
|
||||||
|
<div class="as-loading">
|
||||||
|
<mat-progress-spinner mode="indeterminate" diameter="40"></mat-progress-spinner>
|
||||||
|
<p>Loading authorized signatories...</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!loading) {
|
||||||
|
<div class="as-header">
|
||||||
|
<h3><fa-icon icon="user-shield"></fa-icon> Authorized Signatories</h3>
|
||||||
|
@if (signatories.length) {
|
||||||
|
<span class="as-count">{{ signatories.length }} on record</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- The tab link is entity-only, but the route can be reached directly by URL. -->
|
||||||
|
@if (!isNonIndividual) {
|
||||||
|
<div class="as-empty">
|
||||||
|
<mat-icon>person</mat-icon>
|
||||||
|
<span>Authorized signatories apply to non-individual clients only.</span>
|
||||||
|
</div>
|
||||||
|
} @else if (!signatories.length) {
|
||||||
|
<div class="as-empty">
|
||||||
|
<mat-icon>group_off</mat-icon>
|
||||||
|
<span>No authorized signatories recorded for this entity.</span>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<p class="as-note">
|
||||||
|
The persons authorised to transact on this entity's behalf, as named in its Board Resolution / Authorization
|
||||||
|
Letter.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
@for (signatory of signatories; track $index) {
|
||||||
|
<div class="as-card">
|
||||||
|
<div class="as-card__main">
|
||||||
|
<div class="as-card__avatar"><mat-icon>person</mat-icon></div>
|
||||||
|
<div class="as-card__identity">
|
||||||
|
<span class="as-card__name">{{ signatory.name || 'Unnamed signatory' }}</span>
|
||||||
|
<span class="as-card__meta">
|
||||||
|
@if (signatory.loading) {
|
||||||
|
Loading details…
|
||||||
|
} @else {
|
||||||
|
@if (signatory.cifNumber) {
|
||||||
|
<span class="as-mono">CIF {{ signatory.cifNumber }}</span>
|
||||||
|
}
|
||||||
|
@if (signatory.officeName) {
|
||||||
|
<span class="as-card__dot">•</span>{{ signatory.officeName }}
|
||||||
|
}
|
||||||
|
@if (signatory.status) {
|
||||||
|
<span class="as-card__dot">•</span>{{ signatory.status }}
|
||||||
|
}
|
||||||
|
<!-- A signatory recorded as a name only — nothing to link to, and saying so is
|
||||||
|
better than an empty row the officer cannot explain. -->
|
||||||
|
@if (!signatory.clientId) {
|
||||||
|
<span class="as-card__unlinked">Name on record only — no linked client</span>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="as-card__side">
|
||||||
|
<div class="as-din">
|
||||||
|
<span class="as-din__label">DIN</span>
|
||||||
|
@if (signatory.dinNumber) {
|
||||||
|
<span class="as-din__value as-mono">{{ signatory.dinNumber }}</span>
|
||||||
|
@if (signatory.dinVerified) {
|
||||||
|
<span class="as-chip as-chip--ok"><mat-icon>check_circle</mat-icon> Verified</span>
|
||||||
|
} @else {
|
||||||
|
<span class="as-chip as-chip--pending"><mat-icon>radio_button_unchecked</mat-icon> Unverified</span>
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
<span class="as-din__value as-din__value--none">—</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (signatory.clientId) {
|
||||||
|
<a mat-stroked-button class="as-link-btn" [routerLink]="['/clients', signatory.clientId, 'general']">
|
||||||
|
<mat-icon>open_in_new</mat-icon> View client
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
+206
@@ -0,0 +1,206 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.as-view {
|
||||||
|
padding: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.as-loading {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 48px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.as-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1e293b;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
fa-icon {
|
||||||
|
color: #1565c0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.as-count {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #059669;
|
||||||
|
background: #ecfdf5;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.as-note {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.as-empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 40px;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 14px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 2px dashed #e2e8f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.as-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 14px 18px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
|
||||||
|
&__main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__avatar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #eaf2fb;
|
||||||
|
color: #1565c0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__identity {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__dot {
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__unlinked {
|
||||||
|
font-style: italic;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__side {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.as-din {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__value {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #1e293b;
|
||||||
|
|
||||||
|
&--none {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.as-mono {
|
||||||
|
font-family: 'Roboto Mono', 'Courier New', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.as-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 2px 9px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 13px;
|
||||||
|
width: 13px;
|
||||||
|
height: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--ok {
|
||||||
|
background: #ecfdf5;
|
||||||
|
color: #047857;
|
||||||
|
border: 1px solid #a7f3d0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--pending {
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #475569;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.as-link-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 15px;
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
}
|
||||||
|
}
|
||||||
+137
@@ -0,0 +1,137 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, OnInit, inject } from '@angular/core';
|
||||||
|
import { ActivatedRoute } from '@angular/router';
|
||||||
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
|
import { MatIcon } from '@angular/material/icon';
|
||||||
|
import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
||||||
|
// MatAnchor, not MatButton: the link to the signatory's client record is an <a>, and the shared
|
||||||
|
// imports only carry the <button> directive.
|
||||||
|
import { MatAnchor } from '@angular/material/button';
|
||||||
|
import { ClientsService } from '../../clients.service';
|
||||||
|
import { LegalFormId } from '../../models/legal-form.enum';
|
||||||
|
|
||||||
|
/** One row of the tab — a name from the entity's record, enriched from the linked client. */
|
||||||
|
interface AuthorizedSignatory {
|
||||||
|
/** Client id of the individual, when the signatory was linked to a real client record. */
|
||||||
|
clientId: number | null;
|
||||||
|
name: string;
|
||||||
|
cifNumber: string;
|
||||||
|
officeName: string;
|
||||||
|
status: string;
|
||||||
|
dinNumber: string;
|
||||||
|
dinVerified: boolean;
|
||||||
|
/** Details are still being fetched for this row. */
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-authorized-signatories-tab',
|
||||||
|
templateUrl: './authorized-signatories-tab.component.html',
|
||||||
|
styleUrls: ['./authorized-signatories-tab.component.scss'],
|
||||||
|
imports: [
|
||||||
|
...STANDALONE_SHARED_IMPORTS,
|
||||||
|
FaIconComponent,
|
||||||
|
MatIcon,
|
||||||
|
MatProgressSpinner,
|
||||||
|
MatAnchor
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class AuthorizedSignatoriesTabComponent implements OnInit {
|
||||||
|
private route = inject(ActivatedRoute);
|
||||||
|
private clientsService = inject(ClientsService);
|
||||||
|
|
||||||
|
clientId = '';
|
||||||
|
loading = true;
|
||||||
|
isNonIndividual = false;
|
||||||
|
signatories: AuthorizedSignatory[] = [];
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.clientId = this.route.parent?.snapshot.paramMap.get('clientId') || '';
|
||||||
|
// Read from the parent route's resolved data — the entity's own record already carries the
|
||||||
|
// signatory names and ids, so the tab needs no request of its own to render its rows. Same
|
||||||
|
// subscription style as the other client tabs, which keeps this correct when the view is
|
||||||
|
// re-entered for a different client on a reused component instance.
|
||||||
|
this.route.parent?.data.subscribe((data: any) => {
|
||||||
|
const clientViewData = data?.clientViewData;
|
||||||
|
this.isNonIndividual = clientViewData?.legalForm?.id === LegalFormId.ENTITY;
|
||||||
|
|
||||||
|
const nonPerson = clientViewData?.clientNonPersonDetails;
|
||||||
|
this.signatories = this.buildRows(nonPerson?.authorizedSignatory, nonPerson?.authorizedSignatoryClientIds);
|
||||||
|
this.loading = false;
|
||||||
|
|
||||||
|
// DIN lives on the signatory's own KYC record, not on the entity's — the same place the
|
||||||
|
// General step reads it back from — so each linked row is filled in from there.
|
||||||
|
this.signatories.filter((s) => s.clientId != null).forEach((s) => this.loadSignatoryDetails(s));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Names and client ids are stored as two comma-separated columns written in the same order
|
||||||
|
* (see ClientGeneralStepComponent.syncSignatoryNames), so they are zipped back by position.
|
||||||
|
* A name with no id behind it is still shown: signatories recorded before linking existed, or
|
||||||
|
* typed in without a client record, are part of the entity's mandate and must not disappear.
|
||||||
|
*/
|
||||||
|
private buildRows(names: string | null | undefined, clientIds: string | null | undefined): AuthorizedSignatory[] {
|
||||||
|
const splitCsv = (value: string | null | undefined): string[] =>
|
||||||
|
(value || '')
|
||||||
|
.split(',')
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const nameList = splitCsv(names);
|
||||||
|
const idList = splitCsv(clientIds);
|
||||||
|
// An id column longer than the name column would otherwise drop the extra links silently.
|
||||||
|
const rowCount = Math.max(nameList.length, idList.length);
|
||||||
|
|
||||||
|
return Array.from({ length: rowCount }, (_unused, index) => {
|
||||||
|
const rawId = idList[index];
|
||||||
|
const parsedId = rawId ? Number(rawId) : NaN;
|
||||||
|
const clientId = Number.isFinite(parsedId) ? parsedId : null;
|
||||||
|
return {
|
||||||
|
clientId,
|
||||||
|
name: nameList[index] || '',
|
||||||
|
cifNumber: '',
|
||||||
|
officeName: '',
|
||||||
|
status: '',
|
||||||
|
dinNumber: '',
|
||||||
|
dinVerified: false,
|
||||||
|
loading: clientId != null
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadSignatoryDetails(signatory: AuthorizedSignatory): void {
|
||||||
|
const id = signatory.clientId!.toString();
|
||||||
|
|
||||||
|
this.clientsService.getClientData(id).subscribe({
|
||||||
|
next: (data: any) => {
|
||||||
|
signatory.name = signatory.name || data?.displayName || '';
|
||||||
|
signatory.cifNumber = data?.cifNumber || data?.accountNo || '';
|
||||||
|
signatory.officeName = data?.officeName || '';
|
||||||
|
signatory.status = data?.status?.value || '';
|
||||||
|
signatory.loading = false;
|
||||||
|
},
|
||||||
|
// A signatory whose client record cannot be read still shows as a row with its stored name,
|
||||||
|
// rather than spinning forever.
|
||||||
|
error: () => {
|
||||||
|
signatory.loading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.clientsService.getClientKyc(id).subscribe({
|
||||||
|
next: (kyc: any) => {
|
||||||
|
signatory.dinNumber = kyc?.dinNumber || '';
|
||||||
|
signatory.dinVerified = kyc?.dinVerified === true;
|
||||||
|
},
|
||||||
|
error: () => {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -554,6 +554,22 @@
|
|||||||
>
|
>
|
||||||
<i class="fa fa-percent cv-tab-icon"></i> Form 121 / TDS
|
<i class="fa fa-percent cv-tab-icon"></i> Form 121 / TDS
|
||||||
</a>
|
</a>
|
||||||
|
<!--
|
||||||
|
Authorized signatories are an entity concept — the persons named in a Board Resolution as
|
||||||
|
authorised to transact for the company. They are captured in the General step at onboarding
|
||||||
|
but have never been readable anywhere in the client view until now.
|
||||||
|
-->
|
||||||
|
@if (clientViewData.legalForm?.id === 2) {
|
||||||
|
<a
|
||||||
|
mat-tab-link
|
||||||
|
[routerLink]="['./authorized-signatories']"
|
||||||
|
routerLinkActive
|
||||||
|
#signatoriesTab="routerLinkActive"
|
||||||
|
[active]="signatoriesTab.isActive"
|
||||||
|
>
|
||||||
|
<i class="fa fa-user-shield cv-tab-icon"></i> Authorized Signatories
|
||||||
|
</a>
|
||||||
|
}
|
||||||
<a
|
<a
|
||||||
mat-tab-link
|
mat-tab-link
|
||||||
[routerLink]="['./documents']"
|
[routerLink]="['./documents']"
|
||||||
|
|||||||
+13
-1
@@ -11,9 +11,21 @@
|
|||||||
<div>
|
<div>
|
||||||
<mifosx-file-upload flex="60%" (change)="onFileSelect($event)" acceptFilter=".png,.jpeg,.jpg"></mifosx-file-upload>
|
<mifosx-file-upload flex="60%" (change)="onFileSelect($event)" acceptFilter=".png,.jpeg,.jpg"></mifosx-file-upload>
|
||||||
|
|
||||||
|
@if (imagePreview) {
|
||||||
|
<div class="image-preview">
|
||||||
|
<span class="image-preview__label">Preview</span>
|
||||||
|
<img class="image-preview__img" [src]="imagePreview" alt="Selected profile image preview" />
|
||||||
|
<span class="image-preview__name">{{ image.name }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (previewError) {
|
||||||
|
<div class="image-preview__error">{{ previewError }}</div>
|
||||||
|
}
|
||||||
|
|
||||||
<mat-dialog-actions align="end">
|
<mat-dialog-actions align="end">
|
||||||
<button mat-raised-button mat-dialog-close>{{ 'labels.buttons.Cancel' | translate }}</button>
|
<button mat-raised-button mat-dialog-close>{{ 'labels.buttons.Cancel' | translate }}</button>
|
||||||
<button mat-raised-button color="primary" [disabled]="!image" [mat-dialog-close]="image">
|
<button mat-raised-button color="primary" [disabled]="!image || !imagePreview" [mat-dialog-close]="image">
|
||||||
{{ 'labels.buttons.Confirm' | translate }}
|
{{ 'labels.buttons.Confirm' | translate }}
|
||||||
</button>
|
</button>
|
||||||
</mat-dialog-actions>
|
</mat-dialog-actions>
|
||||||
|
|||||||
+42
@@ -5,3 +5,45 @@
|
|||||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
.image-preview {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 12px 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-preview__label {
|
||||||
|
align-self: flex-start;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.4px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: rgb(0 0 0 / 60%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-preview__img {
|
||||||
|
/* Capped in vh as well as px so a tall portrait photo cannot push the Cancel/Confirm footer
|
||||||
|
below the fold on a short viewport — `contain` keeps the whole frame visible rather than
|
||||||
|
cropping it, since the point of the preview is to check the picture before it goes live. */
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: min(260px, 40vh);
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgb(0 0 0 / 4%);
|
||||||
|
border: 1px solid rgb(0 0 0 / 12%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-preview__name {
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgb(0 0 0 / 60%);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-preview__error {
|
||||||
|
margin: 12px 0 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #b3261e;
|
||||||
|
}
|
||||||
|
|||||||
+33
@@ -33,6 +33,16 @@ export class UploadImageDialogComponent {
|
|||||||
/** Client Image */
|
/** Client Image */
|
||||||
image: File;
|
image: File;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data URL of the selected image, rendered as a preview so the officer sees what they picked
|
||||||
|
* before confirming — the upload replaces the client's profile picture immediately on confirm,
|
||||||
|
* and picking the wrong file is otherwise only discovered once it is already live.
|
||||||
|
*/
|
||||||
|
imagePreview: string | null = null;
|
||||||
|
|
||||||
|
/** Set when the picked file is not a readable image, in place of a preview. */
|
||||||
|
previewError = '';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets file form control value.
|
* Sets file form control value.
|
||||||
* @param {any} $event file change event.
|
* @param {any} $event file change event.
|
||||||
@@ -40,6 +50,29 @@ export class UploadImageDialogComponent {
|
|||||||
onFileSelect($event: any) {
|
onFileSelect($event: any) {
|
||||||
if ($event.target.files.length > 0) {
|
if ($event.target.files.length > 0) {
|
||||||
this.image = $event.target.files[0];
|
this.image = $event.target.files[0];
|
||||||
|
this.buildPreview(this.image);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Reads the picked file into a data URL for the preview. */
|
||||||
|
private buildPreview(file: File) {
|
||||||
|
this.imagePreview = null;
|
||||||
|
this.previewError = '';
|
||||||
|
|
||||||
|
// The picker's accept filter is a hint the OS dialog can be talked out of, so re-check here
|
||||||
|
// rather than handing a non-image to FileReader and rendering a broken <img>.
|
||||||
|
if (!file.type?.startsWith('image/')) {
|
||||||
|
this.previewError = 'Selected file is not an image. Choose a PNG or JPEG file.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
this.imagePreview = reader.result as string;
|
||||||
|
};
|
||||||
|
reader.onerror = () => {
|
||||||
|
this.previewError = 'Could not read the selected file.';
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,221 +104,230 @@
|
|||||||
<span class="kv-table__col kv-table__col--action">Action</span>
|
<span class="kv-table__col kv-table__col--action">Action</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- PAN Row -->
|
<!--
|
||||||
<div class="kv-table__row">
|
PAN and Aadhaar below are the individual's own OVDs. An entity holds neither: its PAN is
|
||||||
<span class="kv-table__col kv-table__col--doc">
|
the Enterprise PAN reported further down (the same stored panNumber, under the name the
|
||||||
<mat-icon class="kv-doc-icon">credit_card</mat-icon> PAN
|
entity actually knows it by), and Aadhaar is issued to people, not companies — the masked
|
||||||
</span>
|
number on a non-individual record is a placeholder the KYC step writes to satisfy the
|
||||||
<span class="kv-table__col kv-table__col--num kv-mono">{{ kycData.panNumber || '—' }}</span>
|
column, so showing it as "Pending" invited officers to chase a document that cannot exist.
|
||||||
<span class="kv-table__col kv-table__col--status">
|
-->
|
||||||
@if (kycData.panVerified) {
|
@if (!isNonIndividual) {
|
||||||
<span class="kv-status kv-status--ok"><mat-icon>check_circle</mat-icon> Verified</span>
|
<!-- PAN Row -->
|
||||||
} @else if (panDeclarationTaken) {
|
<div class="kv-table__row">
|
||||||
<!-- Deliberately not "Verified": the check never passed, paperwork covers it. -->
|
<span class="kv-table__col kv-table__col--doc">
|
||||||
<span class="kv-status kv-status--declared"><mat-icon>assignment_turned_in</mat-icon> Declaration</span>
|
<mat-icon class="kv-doc-icon">credit_card</mat-icon> PAN
|
||||||
} @else {
|
</span>
|
||||||
<span class="kv-status kv-status--pending"><mat-icon>radio_button_unchecked</mat-icon> Pending</span>
|
<span class="kv-table__col kv-table__col--num kv-mono">{{ kycData.panNumber || '—' }}</span>
|
||||||
}
|
<span class="kv-table__col kv-table__col--status">
|
||||||
</span>
|
@if (kycData.panVerified) {
|
||||||
<span class="kv-table__col kv-table__col--date">{{
|
<span class="kv-status kv-status--ok"><mat-icon>check_circle</mat-icon> Verified</span>
|
||||||
kycData.panVerified
|
} @else if (panDeclarationTaken) {
|
||||||
? kycData.panVerifiedOn
|
<!-- Deliberately not "Verified": the check never passed, paperwork covers it. -->
|
||||||
? formatDate(kycData.panVerifiedOn)
|
<span class="kv-status kv-status--declared"><mat-icon>assignment_turned_in</mat-icon> Declaration</span>
|
||||||
: '—'
|
|
||||||
: panDeclarationTaken && kycData.panDeclarationTakenOn
|
|
||||||
? formatDate(kycData.panDeclarationTakenOn)
|
|
||||||
: '—'
|
|
||||||
}}</span>
|
|
||||||
<span class="kv-table__col kv-table__col--action">
|
|
||||||
<button
|
|
||||||
mat-stroked-button
|
|
||||||
class="kv-action-btn"
|
|
||||||
type="button"
|
|
||||||
[disabled]="verifying.pan || !kycData.panNumber"
|
|
||||||
(click)="verifyPAN()"
|
|
||||||
>
|
|
||||||
@if (verifying.pan) {
|
|
||||||
<mat-progress-spinner diameter="14" mode="indeterminate"></mat-progress-spinner>
|
|
||||||
} @else {
|
} @else {
|
||||||
Re-Verify
|
<span class="kv-status kv-status--pending"><mat-icon>radio_button_unchecked</mat-icon> Pending</span>
|
||||||
}
|
}
|
||||||
</button>
|
</span>
|
||||||
<!--
|
<span class="kv-table__col kv-table__col--date">{{
|
||||||
|
kycData.panVerified
|
||||||
|
? kycData.panVerifiedOn
|
||||||
|
? formatDate(kycData.panVerifiedOn)
|
||||||
|
: '—'
|
||||||
|
: panDeclarationTaken && kycData.panDeclarationTakenOn
|
||||||
|
? formatDate(kycData.panDeclarationTakenOn)
|
||||||
|
: '—'
|
||||||
|
}}</span>
|
||||||
|
<span class="kv-table__col kv-table__col--action">
|
||||||
|
<button
|
||||||
|
mat-stroked-button
|
||||||
|
class="kv-action-btn"
|
||||||
|
type="button"
|
||||||
|
[disabled]="verifying.pan || !kycData.panNumber"
|
||||||
|
(click)="verifyPAN()"
|
||||||
|
>
|
||||||
|
@if (verifying.pan) {
|
||||||
|
<mat-progress-spinner diameter="14" mode="indeterminate"></mat-progress-spinner>
|
||||||
|
} @else {
|
||||||
|
Re-Verify
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
<!--
|
||||||
A PAN that has never passed is not always fixable by another attempt. Offer the
|
A PAN that has never passed is not always fixable by another attempt. Offer the
|
||||||
declaration here too, so an existing client sitting in the exception list can be
|
declaration here too, so an existing client sitting in the exception list can be
|
||||||
cleared without the officer having to run a verification they know will fail.
|
cleared without the officer having to run a verification they know will fail.
|
||||||
-->
|
-->
|
||||||
@if (!kycData.panVerified && !panDeclarationTaken && kycData.panNumber) {
|
@if (!kycData.panVerified && !panDeclarationTaken && kycData.panNumber) {
|
||||||
<button
|
<button
|
||||||
mat-stroked-button
|
mat-stroked-button
|
||||||
class="kv-action-btn"
|
class="kv-action-btn"
|
||||||
type="button"
|
type="button"
|
||||||
[disabled]="!kycId || saving"
|
[disabled]="!kycId || saving"
|
||||||
(click)="takePanDeclaration()"
|
(click)="takePanDeclaration()"
|
||||||
>
|
>
|
||||||
Declaration
|
Declaration
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
PAN is unverified but a declaration is on file. Shown in full because it is the only
|
PAN is unverified but a declaration is on file. Shown in full because it is the only
|
||||||
reason PAN carries no KYC exception — a reviewer has to see what was declared.
|
reason PAN carries no KYC exception — a reviewer has to see what was declared.
|
||||||
-->
|
-->
|
||||||
@if (!kycData.panVerified && panDeclarationTaken) {
|
@if (!kycData.panVerified && panDeclarationTaken) {
|
||||||
<div class="kv-decl">
|
<div class="kv-decl">
|
||||||
<div class="kv-decl__head">
|
<div class="kv-decl__head">
|
||||||
<mat-icon>assignment_turned_in</mat-icon>
|
<mat-icon>assignment_turned_in</mat-icon>
|
||||||
<span class="kv-decl__title">PAN Declaration</span>
|
<span class="kv-decl__title">PAN Declaration</span>
|
||||||
@if (!saving) {
|
@if (!saving) {
|
||||||
<span class="kv-decl__actions">
|
<span class="kv-decl__actions">
|
||||||
<button mat-stroked-button type="button" class="kv-action-btn" (click)="takePanDeclaration()">
|
<button mat-stroked-button type="button" class="kv-action-btn" (click)="takePanDeclaration()">
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
mat-stroked-button
|
mat-stroked-button
|
||||||
type="button"
|
type="button"
|
||||||
class="kv-action-btn danger"
|
class="kv-action-btn danger"
|
||||||
(click)="removePanDeclaration()"
|
(click)="removePanDeclaration()"
|
||||||
>
|
>
|
||||||
Remove
|
Remove
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
</div>
|
|
||||||
<div class="kv-decl__rows">
|
|
||||||
<div class="kv-decl__row">
|
|
||||||
<span class="k">Does not match</span>
|
|
||||||
<span class="v strong">{{ panDeclarationMismatchLabel }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
@if (kycData.panDeclarationNameOnPan) {
|
<div class="kv-decl__rows">
|
||||||
<div class="kv-decl__row">
|
<div class="kv-decl__row">
|
||||||
<span class="k">Name on PAN</span>
|
<span class="k">Does not match</span>
|
||||||
<span class="v">{{ kycData.panDeclarationNameOnPan }}</span>
|
<span class="v strong">{{ panDeclarationMismatchLabel }}</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
@if (kycData.panDeclarationNameOnPan) {
|
||||||
@if (kycData.panDeclarationDobOnPan) {
|
<div class="kv-decl__row">
|
||||||
<div class="kv-decl__row">
|
<span class="k">Name on PAN</span>
|
||||||
<span class="k">DOB on PAN</span>
|
<span class="v">{{ kycData.panDeclarationNameOnPan }}</span>
|
||||||
<span class="v">{{ formatDate(kycData.panDeclarationDobOnPan) }}</span>
|
</div>
|
||||||
</div>
|
}
|
||||||
}
|
@if (kycData.panDeclarationDobOnPan) {
|
||||||
@if (kycData.panDeclarationRemarks) {
|
<div class="kv-decl__row">
|
||||||
<div class="kv-decl__row">
|
<span class="k">DOB on PAN</span>
|
||||||
<span class="k">Declaration file</span>
|
<span class="v">{{ formatDate(kycData.panDeclarationDobOnPan) }}</span>
|
||||||
<span class="v">{{ kycData.panDeclarationRemarks }}</span>
|
</div>
|
||||||
</div>
|
}
|
||||||
}
|
@if (kycData.panDeclarationRemarks) {
|
||||||
|
<div class="kv-decl__row">
|
||||||
|
<span class="k">Declaration file</span>
|
||||||
|
<span class="v">{{ kycData.panDeclarationRemarks }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="kv-decl__note">
|
||||||
|
PAN remains unverified. No KYC exception is raised for PAN while this declaration is on file.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="kv-decl__note">
|
}
|
||||||
PAN remains unverified. No KYC exception is raised for PAN while this declaration is on file.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- Aadhaar Row -->
|
<!-- Aadhaar Row -->
|
||||||
<div class="kv-table__row">
|
<div class="kv-table__row">
|
||||||
<span class="kv-table__col kv-table__col--doc">
|
<span class="kv-table__col kv-table__col--doc">
|
||||||
<mat-icon class="kv-doc-icon">fingerprint</mat-icon> Aadhaar
|
<mat-icon class="kv-doc-icon">fingerprint</mat-icon> Aadhaar
|
||||||
</span>
|
</span>
|
||||||
<span class="kv-table__col kv-table__col--num kv-mono">{{ kycData.aadhaarNumberMasked || '—' }}</span>
|
<span class="kv-table__col kv-table__col--num kv-mono">{{ kycData.aadhaarNumberMasked || '—' }}</span>
|
||||||
<span class="kv-table__col kv-table__col--status">
|
<span class="kv-table__col kv-table__col--status">
|
||||||
@if (kycData.aadhaarVerified) {
|
@if (kycData.aadhaarVerified) {
|
||||||
<span class="kv-status kv-status--ok"><mat-icon>check_circle</mat-icon> Verified</span>
|
<span class="kv-status kv-status--ok"><mat-icon>check_circle</mat-icon> Verified</span>
|
||||||
} @else if (aadhaarReceiptTaken) {
|
} @else if (aadhaarReceiptTaken) {
|
||||||
<!-- Deliberately not "Verified": no Aadhaar exists yet, the slip covers it. -->
|
<!-- Deliberately not "Verified": no Aadhaar exists yet, the slip covers it. -->
|
||||||
<span class="kv-status kv-status--receipt"><mat-icon>receipt_long</mat-icon> Receipt</span>
|
<span class="kv-status kv-status--receipt"><mat-icon>receipt_long</mat-icon> Receipt</span>
|
||||||
} @else {
|
} @else {
|
||||||
<span class="kv-status kv-status--pending"><mat-icon>radio_button_unchecked</mat-icon> Pending</span>
|
<span class="kv-status kv-status--pending"><mat-icon>radio_button_unchecked</mat-icon> Pending</span>
|
||||||
}
|
}
|
||||||
</span>
|
</span>
|
||||||
<span class="kv-table__col kv-table__col--date">{{
|
<span class="kv-table__col kv-table__col--date">{{
|
||||||
kycData.aadhaarVerified
|
kycData.aadhaarVerified
|
||||||
? kycData.aadhaarVerifiedOn
|
? kycData.aadhaarVerifiedOn
|
||||||
? formatDate(kycData.aadhaarVerifiedOn)
|
? formatDate(kycData.aadhaarVerifiedOn)
|
||||||
: '—'
|
: '—'
|
||||||
: aadhaarReceiptTaken && kycData.aadhaarReceiptTakenOn
|
: aadhaarReceiptTaken && kycData.aadhaarReceiptTakenOn
|
||||||
? formatDate(kycData.aadhaarReceiptTakenOn)
|
? formatDate(kycData.aadhaarReceiptTakenOn)
|
||||||
: '—'
|
: '—'
|
||||||
}}</span>
|
}}</span>
|
||||||
<span class="kv-table__col kv-table__col--action">
|
<span class="kv-table__col kv-table__col--action">
|
||||||
@if (!showAadhaarReVerify && !aadhaarOtpSent) {
|
@if (!showAadhaarReVerify && !aadhaarOtpSent) {
|
||||||
<button
|
<button
|
||||||
mat-stroked-button
|
mat-stroked-button
|
||||||
class="kv-action-btn"
|
class="kv-action-btn"
|
||||||
type="button"
|
type="button"
|
||||||
[disabled]="verifying.aadhaar || !kycData.aadhaarNumberMasked"
|
[disabled]="verifying.aadhaar || !kycData.aadhaarNumberMasked"
|
||||||
(click)="startAadhaarReVerify()"
|
(click)="startAadhaarReVerify()"
|
||||||
>
|
>
|
||||||
Re-Verify
|
Re-Verify
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
<!--
|
<!--
|
||||||
No Aadhaar number means the OTP check has nothing to run against. Offer the enrolment
|
No Aadhaar number means the OTP check has nothing to run against. Offer the enrolment
|
||||||
slip here so an existing client sitting in the exception list can be cleared.
|
slip here so an existing client sitting in the exception list can be cleared.
|
||||||
-->
|
-->
|
||||||
@if (!kycData.aadhaarVerified && !aadhaarReceiptTaken) {
|
@if (!kycData.aadhaarVerified && !aadhaarReceiptTaken) {
|
||||||
<button
|
<button
|
||||||
mat-stroked-button
|
mat-stroked-button
|
||||||
class="kv-action-btn"
|
class="kv-action-btn"
|
||||||
type="button"
|
type="button"
|
||||||
[disabled]="!kycId || saving"
|
[disabled]="!kycId || saving"
|
||||||
(click)="takeAadhaarReceipt()"
|
(click)="takeAadhaarReceipt()"
|
||||||
>
|
>
|
||||||
Receipt
|
Receipt
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Aadhaar is unverified but an enrolment receipt is on file. Shown in full because it is the
|
Aadhaar is unverified but an enrolment receipt is on file. Shown in full because it is the
|
||||||
only reason Aadhaar carries no KYC exception.
|
only reason Aadhaar carries no KYC exception.
|
||||||
-->
|
-->
|
||||||
@if (!kycData.aadhaarVerified && aadhaarReceiptTaken) {
|
@if (!kycData.aadhaarVerified && aadhaarReceiptTaken) {
|
||||||
<div class="kv-rcpt">
|
<div class="kv-rcpt">
|
||||||
<div class="kv-rcpt__head">
|
<div class="kv-rcpt__head">
|
||||||
<mat-icon>receipt_long</mat-icon>
|
<mat-icon>receipt_long</mat-icon>
|
||||||
<span class="kv-rcpt__title">Aadhaar Enrolment Receipt</span>
|
<span class="kv-rcpt__title">Aadhaar Enrolment Receipt</span>
|
||||||
@if (!saving) {
|
@if (!saving) {
|
||||||
<span class="kv-rcpt__actions">
|
<span class="kv-rcpt__actions">
|
||||||
<button mat-stroked-button type="button" class="kv-action-btn" (click)="takeAadhaarReceipt()">
|
<button mat-stroked-button type="button" class="kv-action-btn" (click)="takeAadhaarReceipt()">
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
mat-stroked-button
|
mat-stroked-button
|
||||||
type="button"
|
type="button"
|
||||||
class="kv-action-btn danger"
|
class="kv-action-btn danger"
|
||||||
(click)="removeAadhaarReceipt()"
|
(click)="removeAadhaarReceipt()"
|
||||||
>
|
>
|
||||||
Remove
|
Remove
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
</div>
|
|
||||||
<div class="kv-rcpt__rows">
|
|
||||||
<div class="kv-rcpt__row">
|
|
||||||
<span class="k">Enrolment ID</span>
|
|
||||||
<span class="v mono">{{ kycData.aadhaarEnrolmentId || '—' }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
@if (kycData.aadhaarEnrolmentDate) {
|
<div class="kv-rcpt__rows">
|
||||||
<div class="kv-rcpt__row">
|
<div class="kv-rcpt__row">
|
||||||
<span class="k">Enrolment date</span>
|
<span class="k">Enrolment ID</span>
|
||||||
<span class="v">{{ formatDate(kycData.aadhaarEnrolmentDate) }}</span>
|
<span class="v mono">{{ kycData.aadhaarEnrolmentId || '—' }}</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
@if (kycData.aadhaarEnrolmentDate) {
|
||||||
@if (kycData.aadhaarReceiptRemarks) {
|
<div class="kv-rcpt__row">
|
||||||
<div class="kv-rcpt__row">
|
<span class="k">Enrolment date</span>
|
||||||
<span class="k">Remarks</span>
|
<span class="v">{{ formatDate(kycData.aadhaarEnrolmentDate) }}</span>
|
||||||
<span class="v">{{ kycData.aadhaarReceiptRemarks }}</span>
|
</div>
|
||||||
</div>
|
}
|
||||||
}
|
@if (kycData.aadhaarReceiptRemarks) {
|
||||||
|
<div class="kv-rcpt__row">
|
||||||
|
<span class="k">Remarks</span>
|
||||||
|
<span class="v">{{ kycData.aadhaarReceiptRemarks }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="kv-rcpt__note">
|
||||||
|
Aadhaar remains unverified. No KYC exception is raised for Aadhaar while this receipt is on file.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="kv-rcpt__note">
|
}
|
||||||
Aadhaar remains unverified. No KYC exception is raised for Aadhaar while this receipt is on file.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
@@ -425,23 +434,59 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- Enterprise PAN Row (Non-Individual) -->
|
<!--
|
||||||
@if (isNonIndividual && kycData?.enterprisePan) {
|
Enterprise PAN Row (Non-Individual) — the entity's only PAN, so it stands in for the PAN
|
||||||
|
row hidden above and carries that row's verification state and Re-Verify action.
|
||||||
|
|
||||||
|
Status comes from panVerified, not from an enterprisePanVerified flag: the record has no
|
||||||
|
such field (ClientKycData holds enterprisePan as a bare string), and the KYC step stores
|
||||||
|
the entity PAN in panNumber and verifies it through the same verify-pan command. Reading
|
||||||
|
the flag that does not exist left a verified entity PAN permanently showing "Pending".
|
||||||
|
-->
|
||||||
|
@if (isNonIndividual && enterprisePanNumber) {
|
||||||
<div class="kv-table__row">
|
<div class="kv-table__row">
|
||||||
<span class="kv-table__col kv-table__col--doc">
|
<span class="kv-table__col kv-table__col--doc">
|
||||||
<mat-icon class="kv-doc-icon">credit_card</mat-icon> Enterprise PAN
|
<mat-icon class="kv-doc-icon">credit_card</mat-icon> Enterprise PAN
|
||||||
</span>
|
</span>
|
||||||
<span class="kv-table__col kv-table__col--num kv-mono">{{ kycData.enterprisePan }}</span>
|
<span class="kv-table__col kv-table__col--num kv-mono">{{ enterprisePanNumber }}</span>
|
||||||
<span class="kv-table__col kv-table__col--status">
|
<span class="kv-table__col kv-table__col--status">
|
||||||
@if (kycData.enterprisePanVerified) {
|
@if (kycData.panVerified) {
|
||||||
<span class="kv-status kv-status--ok"><mat-icon>check_circle</mat-icon> Verified</span>
|
<span class="kv-status kv-status--ok"><mat-icon>check_circle</mat-icon> Verified</span>
|
||||||
} @else {
|
} @else {
|
||||||
<span class="kv-status kv-status--pending"><mat-icon>radio_button_unchecked</mat-icon> Pending</span>
|
<span class="kv-status kv-status--pending"><mat-icon>radio_button_unchecked</mat-icon> Pending</span>
|
||||||
}
|
}
|
||||||
</span>
|
</span>
|
||||||
<span class="kv-table__col kv-table__col--date">{{
|
<span class="kv-table__col kv-table__col--date">{{
|
||||||
kycData.enterprisePanVerifiedOn ? formatDate(kycData.enterprisePanVerifiedOn) : '—'
|
kycData.panVerified && kycData.panVerifiedOn ? formatDate(kycData.panVerifiedOn) : '—'
|
||||||
}}</span>
|
}}</span>
|
||||||
|
<span class="kv-table__col kv-table__col--action">
|
||||||
|
<button
|
||||||
|
mat-stroked-button
|
||||||
|
class="kv-action-btn"
|
||||||
|
type="button"
|
||||||
|
[disabled]="verifying.pan"
|
||||||
|
(click)="verifyPAN()"
|
||||||
|
>
|
||||||
|
@if (verifying.pan) {
|
||||||
|
<mat-progress-spinner diameter="14" mode="indeterminate"></mat-progress-spinner>
|
||||||
|
} @else {
|
||||||
|
Re-Verify
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Date of Incorporation Row (Non-Individual) — captured alongside the entity PAN
|
||||||
|
it is verified against, so it is reported here rather than under personal data. -->
|
||||||
|
@if (isNonIndividual && kycData?.incorporationDate) {
|
||||||
|
<div class="kv-table__row">
|
||||||
|
<span class="kv-table__col kv-table__col--doc">
|
||||||
|
<mat-icon class="kv-doc-icon">event_available</mat-icon> Date of Incorporation
|
||||||
|
</span>
|
||||||
|
<span class="kv-table__col kv-table__col--num">{{ formatDate(kycData.incorporationDate) }}</span>
|
||||||
|
<span class="kv-table__col kv-table__col--status">—</span>
|
||||||
|
<span class="kv-table__col kv-table__col--date">—</span>
|
||||||
<span class="kv-table__col kv-table__col--action"></span>
|
<span class="kv-table__col kv-table__col--action"></span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -464,68 +509,61 @@
|
|||||||
<span class="kv-table__col kv-table__col--action"></span>
|
<span class="kv-table__col kv-table__col--action"></span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- Incorporation Date Row (Non-Individual) -->
|
|
||||||
@if (isNonIndividual && kycData?.incorporationDate) {
|
|
||||||
<div class="kv-table__row kv-table__row--link">
|
|
||||||
<span class="kv-table__col kv-table__col--doc">
|
|
||||||
<mat-icon class="kv-doc-icon">event</mat-icon> Incorporation Date
|
|
||||||
</span>
|
|
||||||
<span class="kv-table__col kv-table__col--num">{{ formatDate(kycData.incorporationDate) }}</span>
|
|
||||||
<span class="kv-table__col kv-table__col--status">—</span>
|
|
||||||
<span class="kv-table__col kv-table__col--date">—</span>
|
|
||||||
<span class="kv-table__col kv-table__col--action"></span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ─── PAN ↔ Aadhaar Link — premium status card (replaces the legacy
|
<!-- ─── PAN ↔ Aadhaar Link — premium status card (replaces the legacy
|
||||||
table row). Dark gradient base with state-driven accent so the
|
table row). Dark gradient base with state-driven accent so the
|
||||||
linking state is the first thing a reviewer sees. -->
|
linking state is the first thing a reviewer sees.
|
||||||
<div
|
|
||||||
class="pal-card"
|
Section 139AA seeding applies to a person's PAN and Aadhaar. An entity has no Aadhaar
|
||||||
[class.pal-card--ok]="kycData?.panAadhaarSeedingStatus === 'Y'"
|
to seed, so for a non-individual the card could only ever read "Pending" against a
|
||||||
[class.pal-card--fail]="kycData?.panAadhaarSeedingStatus === 'N'"
|
requirement that does not apply to it. -->
|
||||||
[class.pal-card--info]="kycData?.panAadhaarSeedingStatus === 'E'"
|
@if (!isNonIndividual) {
|
||||||
[class.pal-card--pending]="
|
<div
|
||||||
kycData?.panAadhaarSeedingStatus !== 'Y' &&
|
class="pal-card"
|
||||||
kycData?.panAadhaarSeedingStatus !== 'N' &&
|
[class.pal-card--ok]="kycData?.panAadhaarSeedingStatus === 'Y'"
|
||||||
kycData?.panAadhaarSeedingStatus !== 'E'
|
[class.pal-card--fail]="kycData?.panAadhaarSeedingStatus === 'N'"
|
||||||
"
|
[class.pal-card--info]="kycData?.panAadhaarSeedingStatus === 'E'"
|
||||||
>
|
[class.pal-card--pending]="
|
||||||
<div class="pal-card__left">
|
kycData?.panAadhaarSeedingStatus !== 'Y' &&
|
||||||
<div class="pal-card__icon">
|
kycData?.panAadhaarSeedingStatus !== 'N' &&
|
||||||
<mat-icon>credit_card</mat-icon>
|
kycData?.panAadhaarSeedingStatus !== 'E'
|
||||||
</div>
|
"
|
||||||
<div class="pal-card__txt">
|
>
|
||||||
<div class="pal-card__small">Verification Status</div>
|
<div class="pal-card__left">
|
||||||
<div class="pal-card__title">PAN ↔ Aadhaar</div>
|
<div class="pal-card__icon">
|
||||||
<div class="pal-card__status">
|
<mat-icon>credit_card</mat-icon>
|
||||||
@if (kycData?.panAadhaarSeedingStatus === 'Y') {
|
</div>
|
||||||
<mat-icon>check_circle</mat-icon> Linked (Seeded)
|
<div class="pal-card__txt">
|
||||||
} @else if (kycData?.panAadhaarSeedingStatus === 'N') {
|
<div class="pal-card__small">Verification Status</div>
|
||||||
<mat-icon>cancel</mat-icon> Not Linked
|
<div class="pal-card__title">PAN ↔ Aadhaar</div>
|
||||||
} @else if (kycData?.panAadhaarSeedingStatus === 'E') {
|
<div class="pal-card__status">
|
||||||
<mat-icon>info</mat-icon> Exempt
|
@if (kycData?.panAadhaarSeedingStatus === 'Y') {
|
||||||
} @else {
|
<mat-icon>check_circle</mat-icon> Linked (Seeded)
|
||||||
<mat-icon>radio_button_unchecked</mat-icon> Pending
|
} @else if (kycData?.panAadhaarSeedingStatus === 'N') {
|
||||||
}
|
<mat-icon>cancel</mat-icon> Not Linked
|
||||||
|
} @else if (kycData?.panAadhaarSeedingStatus === 'E') {
|
||||||
|
<mat-icon>info</mat-icon> Exempt
|
||||||
|
} @else {
|
||||||
|
<mat-icon>radio_button_unchecked</mat-icon> Pending
|
||||||
|
}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="pal-card__badge">
|
||||||
|
@if (kycData?.panAadhaarSeedingStatus === 'Y') {
|
||||||
|
<mat-icon>check</mat-icon>
|
||||||
|
} @else if (kycData?.panAadhaarSeedingStatus === 'N') {
|
||||||
|
<mat-icon>close</mat-icon>
|
||||||
|
} @else if (kycData?.panAadhaarSeedingStatus === 'E') {
|
||||||
|
<mat-icon>info</mat-icon>
|
||||||
|
} @else {
|
||||||
|
<mat-icon>schedule</mat-icon>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="pal-card__hint">As per Income Tax Portal — Section 139AA</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="pal-card__badge">
|
}
|
||||||
@if (kycData?.panAadhaarSeedingStatus === 'Y') {
|
|
||||||
<mat-icon>check</mat-icon>
|
|
||||||
} @else if (kycData?.panAadhaarSeedingStatus === 'N') {
|
|
||||||
<mat-icon>close</mat-icon>
|
|
||||||
} @else if (kycData?.panAadhaarSeedingStatus === 'E') {
|
|
||||||
<mat-icon>info</mat-icon>
|
|
||||||
} @else {
|
|
||||||
<mat-icon>schedule</mat-icon>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<div class="pal-card__hint">As per Income Tax Portal — Section 139AA</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Aadhaar OTP inline (shown below table when re-verifying) -->
|
<!-- Aadhaar OTP inline (shown below table when re-verifying) -->
|
||||||
@if (showAadhaarReVerify && !aadhaarOtpSent) {
|
@if (showAadhaarReVerify && !aadhaarOtpSent) {
|
||||||
@@ -795,15 +833,19 @@
|
|||||||
<mat-card-content>
|
<mat-card-content>
|
||||||
<form [formGroup]="kycForm" (ngSubmit)="saveKyc()">
|
<form [formGroup]="kycForm" (ngSubmit)="saveKyc()">
|
||||||
<div class="kyc-edit-grid">
|
<div class="kyc-edit-grid">
|
||||||
<!-- PAN -->
|
<!-- PAN — the same stored panNumber either way, named for whose PAN it is. -->
|
||||||
<div class="kyc-edit-field">
|
<div class="kyc-edit-field">
|
||||||
<div class="kyc-field-label-row">
|
<div class="kyc-field-label-row">
|
||||||
<span class="kyc-field-title">{{ 'labels.inputs.PAN Number' | translate }}</span>
|
<span class="kyc-field-title">{{
|
||||||
|
isNonIndividual ? 'Enterprise PAN' : ('labels.inputs.PAN Number' | translate)
|
||||||
|
}}</span>
|
||||||
<span class="kyc-tag">KYC</span>
|
<span class="kyc-tag">KYC</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="kyc-input-row">
|
<div class="kyc-input-row">
|
||||||
<mat-form-field appearance="outline" class="kyc-input">
|
<mat-form-field appearance="outline" class="kyc-input">
|
||||||
<mat-label>{{ 'labels.inputs.PAN Number' | translate }}</mat-label>
|
<mat-label>{{
|
||||||
|
isNonIndividual ? 'Enterprise PAN' : ('labels.inputs.PAN Number' | translate)
|
||||||
|
}}</mat-label>
|
||||||
<input
|
<input
|
||||||
matInput
|
matInput
|
||||||
formControlName="panNumber"
|
formControlName="panNumber"
|
||||||
@@ -852,147 +894,154 @@
|
|||||||
The check can fail for reasons no retry will fix — a PAN card that spells the
|
The check can fail for reasons no retry will fix — a PAN card that spells the
|
||||||
name differently, or a vendor that is simply down. The declaration is the way
|
name differently, or a vendor that is simply down. The declaration is the way
|
||||||
out, so it is offered here rather than leaving the officer at a dead end.
|
out, so it is offered here rather than leaving the officer at a dead end.
|
||||||
|
|
||||||
|
It records a mismatch against the customer's own name and date of birth, so it
|
||||||
|
is offered to individuals only — an entity PAN is checked against neither.
|
||||||
-->
|
-->
|
||||||
<div class="kyc-decl-offer">
|
@if (!isNonIndividual) {
|
||||||
<span>Customer's PAN card genuinely carries different details, or PAN cannot be checked?</span>
|
<div class="kyc-decl-offer">
|
||||||
<button
|
<span>Customer's PAN card genuinely carries different details, or PAN cannot be checked?</span>
|
||||||
mat-stroked-button
|
<button
|
||||||
type="button"
|
mat-stroked-button
|
||||||
[disabled]="!kycId || saving"
|
type="button"
|
||||||
(click)="takePanDeclaration()"
|
[disabled]="!kycId || saving"
|
||||||
>
|
(click)="takePanDeclaration()"
|
||||||
{{ panDeclarationTaken ? 'Edit Declaration' : 'Take Declaration' }}
|
>
|
||||||
</button>
|
{{ panDeclarationTaken ? 'Edit Declaration' : 'Take Declaration' }}
|
||||||
</div>
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@if (verificationStatus.pan === 'VERIFIED' && verificationDetails.pan?.name) {
|
@if (verificationStatus.pan === 'VERIFIED' && verificationDetails.pan?.name) {
|
||||||
<span class="verification-detail-text">{{ verificationDetails.pan.name }}</span>
|
<span class="verification-detail-text">{{ verificationDetails.pan.name }}</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Aadhaar -->
|
<!-- Aadhaar — individuals only; an entity has no Aadhaar to capture or verify. -->
|
||||||
<div class="kyc-edit-field">
|
@if (!isNonIndividual) {
|
||||||
<div class="kyc-field-label-row">
|
<div class="kyc-edit-field">
|
||||||
<span class="kyc-field-title">{{ 'labels.inputs.Aadhaar Number' | translate }}</span>
|
<div class="kyc-field-label-row">
|
||||||
<span class="kyc-tag">KYC</span>
|
<span class="kyc-field-title">{{ 'labels.inputs.Aadhaar Number' | translate }}</span>
|
||||||
</div>
|
<span class="kyc-tag">KYC</span>
|
||||||
<div class="kyc-input-row">
|
</div>
|
||||||
<mat-form-field appearance="outline" class="kyc-input">
|
<div class="kyc-input-row">
|
||||||
<mat-label>{{ 'labels.inputs.Aadhaar Number' | translate }}</mat-label>
|
<mat-form-field appearance="outline" class="kyc-input">
|
||||||
<input
|
<mat-label>{{ 'labels.inputs.Aadhaar Number' | translate }}</mat-label>
|
||||||
matInput
|
<input
|
||||||
formControlName="aadhaarNumber"
|
matInput
|
||||||
maxlength="12"
|
formControlName="aadhaarNumber"
|
||||||
inputmode="numeric"
|
maxlength="12"
|
||||||
autocomplete="off"
|
inputmode="numeric"
|
||||||
placeholder="Enter 12-digit Aadhaar"
|
autocomplete="off"
|
||||||
[readonly]="aadhaarOtpSent"
|
placeholder="Enter 12-digit Aadhaar"
|
||||||
/>
|
[readonly]="aadhaarOtpSent"
|
||||||
@if (kycData?.aadhaarNumberMasked) {
|
/>
|
||||||
<mat-hint>On file: {{ kycData.aadhaarNumberMasked }}</mat-hint>
|
@if (kycData?.aadhaarNumberMasked) {
|
||||||
}
|
<mat-hint>On file: {{ kycData.aadhaarNumberMasked }}</mat-hint>
|
||||||
@if (kycForm.get('aadhaarNumber')?.hasError('pattern') && kycForm.get('aadhaarNumber')?.touched) {
|
|
||||||
<mat-error>{{ 'labels.inputs.Enter 12-digit Aadhaar' | translate }}</mat-error>
|
|
||||||
}
|
|
||||||
@if (verificationStatus.aadhaar === 'VERIFIED') {
|
|
||||||
<mat-icon matSuffix class="status-icon verified">check_circle</mat-icon>
|
|
||||||
}
|
|
||||||
@if (verificationStatus.aadhaar === 'FAILED') {
|
|
||||||
<mat-icon matSuffix class="status-icon failed">cancel</mat-icon>
|
|
||||||
}
|
|
||||||
</mat-form-field>
|
|
||||||
@if (!aadhaarOtpSent) {
|
|
||||||
<button
|
|
||||||
mat-raised-button
|
|
||||||
color="accent"
|
|
||||||
type="button"
|
|
||||||
class="verify-btn"
|
|
||||||
[disabled]="
|
|
||||||
!kycId ||
|
|
||||||
!kycForm.get('aadhaarNumber')?.value ||
|
|
||||||
kycForm.get('aadhaarNumber')?.invalid ||
|
|
||||||
verifying.aadhaar
|
|
||||||
"
|
|
||||||
(click)="sendAadhaarOtp()"
|
|
||||||
[matTooltip]="!kycId ? 'Save KYC details first' : ''"
|
|
||||||
>
|
|
||||||
@if (verifying.aadhaar) {
|
|
||||||
<mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner>
|
|
||||||
} @else {
|
|
||||||
Send OTP
|
|
||||||
}
|
}
|
||||||
</button>
|
@if (kycForm.get('aadhaarNumber')?.hasError('pattern') && kycForm.get('aadhaarNumber')?.touched) {
|
||||||
}
|
<mat-error>{{ 'labels.inputs.Enter 12-digit Aadhaar' | translate }}</mat-error>
|
||||||
</div>
|
}
|
||||||
|
@if (verificationStatus.aadhaar === 'VERIFIED') {
|
||||||
<!-- OTP Section in Edit Mode -->
|
<mat-icon matSuffix class="status-icon verified">check_circle</mat-icon>
|
||||||
@if (aadhaarOtpSent) {
|
}
|
||||||
<div class="otp-section">
|
@if (verificationStatus.aadhaar === 'FAILED') {
|
||||||
<p class="otp-info">
|
<mat-icon matSuffix class="status-icon failed">cancel</mat-icon>
|
||||||
<mat-icon class="otp-info-icon">sms</mat-icon>
|
}
|
||||||
OTP sent to Aadhaar-linked mobile
|
</mat-form-field>
|
||||||
</p>
|
@if (!aadhaarOtpSent) {
|
||||||
<div class="otp-inline">
|
|
||||||
<mat-form-field appearance="outline" class="otp-field">
|
|
||||||
<mat-label>Enter OTP</mat-label>
|
|
||||||
<input
|
|
||||||
matInput
|
|
||||||
[(ngModel)]="aadhaarOtp"
|
|
||||||
[ngModelOptions]="{ standalone: true }"
|
|
||||||
maxlength="6"
|
|
||||||
autocomplete="one-time-code"
|
|
||||||
/>
|
|
||||||
</mat-form-field>
|
|
||||||
<button
|
<button
|
||||||
mat-raised-button
|
mat-raised-button
|
||||||
color="accent"
|
color="accent"
|
||||||
class="otp-verify-btn"
|
|
||||||
type="button"
|
type="button"
|
||||||
[disabled]="!aadhaarOtp || aadhaarOtp.length < 4 || aadhaarOtpVerifying"
|
class="verify-btn"
|
||||||
(click)="verifyAadhaarOtp()"
|
[disabled]="
|
||||||
|
!kycId ||
|
||||||
|
!kycForm.get('aadhaarNumber')?.value ||
|
||||||
|
kycForm.get('aadhaarNumber')?.invalid ||
|
||||||
|
verifying.aadhaar
|
||||||
|
"
|
||||||
|
(click)="sendAadhaarOtp()"
|
||||||
|
[matTooltip]="!kycId ? 'Save KYC details first' : ''"
|
||||||
>
|
>
|
||||||
@if (aadhaarOtpVerifying) {
|
@if (verifying.aadhaar) {
|
||||||
<mat-progress-spinner diameter="14" mode="indeterminate"></mat-progress-spinner>
|
<mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner>
|
||||||
} @else {
|
} @else {
|
||||||
Verify OTP
|
Send OTP
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
<button
|
}
|
||||||
mat-stroked-button
|
|
||||||
type="button"
|
|
||||||
class="otp-resend-btn"
|
|
||||||
[disabled]="verifying.aadhaar"
|
|
||||||
(click)="sendAadhaarOtp()"
|
|
||||||
>
|
|
||||||
Resend
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
|
||||||
|
|
||||||
@if (verificationError.aadhaar) {
|
<!-- OTP Section in Edit Mode -->
|
||||||
<div class="kyc-error-msg">
|
@if (aadhaarOtpSent) {
|
||||||
<mat-icon class="kyc-error-icon">error_outline</mat-icon>
|
<div class="otp-section">
|
||||||
<span>{{ verificationError.aadhaar }}</span>
|
<p class="otp-info">
|
||||||
</div>
|
<mat-icon class="otp-info-icon">sms</mat-icon>
|
||||||
<!--
|
OTP sent to Aadhaar-linked mobile
|
||||||
|
</p>
|
||||||
|
<div class="otp-inline">
|
||||||
|
<mat-form-field appearance="outline" class="otp-field">
|
||||||
|
<mat-label>Enter OTP</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
[(ngModel)]="aadhaarOtp"
|
||||||
|
[ngModelOptions]="{ standalone: true }"
|
||||||
|
maxlength="6"
|
||||||
|
autocomplete="one-time-code"
|
||||||
|
/>
|
||||||
|
</mat-form-field>
|
||||||
|
<button
|
||||||
|
mat-raised-button
|
||||||
|
color="accent"
|
||||||
|
class="otp-verify-btn"
|
||||||
|
type="button"
|
||||||
|
[disabled]="!aadhaarOtp || aadhaarOtp.length < 4 || aadhaarOtpVerifying"
|
||||||
|
(click)="verifyAadhaarOtp()"
|
||||||
|
>
|
||||||
|
@if (aadhaarOtpVerifying) {
|
||||||
|
<mat-progress-spinner diameter="14" mode="indeterminate"></mat-progress-spinner>
|
||||||
|
} @else {
|
||||||
|
Verify OTP
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
mat-stroked-button
|
||||||
|
type="button"
|
||||||
|
class="otp-resend-btn"
|
||||||
|
[disabled]="verifying.aadhaar"
|
||||||
|
(click)="sendAadhaarOtp()"
|
||||||
|
>
|
||||||
|
Resend
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (verificationError.aadhaar) {
|
||||||
|
<div class="kyc-error-msg">
|
||||||
|
<mat-icon class="kyc-error-icon">error_outline</mat-icon>
|
||||||
|
<span>{{ verificationError.aadhaar }}</span>
|
||||||
|
</div>
|
||||||
|
<!--
|
||||||
A failed OTP is not always something a retry can fix — a customer who has only
|
A failed OTP is not always something a retry can fix — a customer who has only
|
||||||
applied for Aadhaar has no number to verify at all. The enrolment slip is the way
|
applied for Aadhaar has no number to verify at all. The enrolment slip is the way
|
||||||
out, so it is offered here rather than leaving the officer at a dead end.
|
out, so it is offered here rather than leaving the officer at a dead end.
|
||||||
-->
|
-->
|
||||||
<div class="kyc-decl-offer">
|
<div class="kyc-decl-offer">
|
||||||
<span>Customer has applied for Aadhaar but not received it yet?</span>
|
<span>Customer has applied for Aadhaar but not received it yet?</span>
|
||||||
<button
|
<button
|
||||||
mat-stroked-button
|
mat-stroked-button
|
||||||
type="button"
|
type="button"
|
||||||
[disabled]="!kycId || saving"
|
[disabled]="!kycId || saving"
|
||||||
(click)="takeAadhaarReceipt()"
|
(click)="takeAadhaarReceipt()"
|
||||||
>
|
>
|
||||||
{{ aadhaarReceiptTaken ? 'Edit Receipt' : 'Record Receipt' }}
|
{{ aadhaarReceiptTaken ? 'Edit Receipt' : 'Record Receipt' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<!-- GSTIN (Non-Individual only) -->
|
<!-- GSTIN (Non-Individual only) -->
|
||||||
@if (isNonIndividual) {
|
@if (isNonIndividual) {
|
||||||
|
|||||||
@@ -221,6 +221,17 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
|||||||
return this.kycData?.panDeclarationTaken === true;
|
return this.kycData?.panDeclarationTaken === true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The entity's PAN, for the Enterprise PAN row a non-individual gets in place of the PAN row.
|
||||||
|
*
|
||||||
|
* Falls back to panNumber because that is where the KYC step actually stores it — the entity PAN
|
||||||
|
* is written to both fields on create, but a record saved before `enterprisePan` existed, or one
|
||||||
|
* saved from a screen that only writes panNumber, would otherwise show no PAN at all.
|
||||||
|
*/
|
||||||
|
get enterprisePanNumber(): string {
|
||||||
|
return this.kycData?.enterprisePan || this.kycData?.panNumber || '';
|
||||||
|
}
|
||||||
|
|
||||||
/** "Name", "Date of Birth", "Name and Date of Birth", or the not-checked-at-all case. */
|
/** "Name", "Date of Birth", "Name and Date of Birth", or the not-checked-at-all case. */
|
||||||
get panDeclarationMismatchLabel(): string {
|
get panDeclarationMismatchLabel(): string {
|
||||||
const fields: string = this.kycData?.panDeclarationMismatchFields;
|
const fields: string = this.kycData?.panDeclarationMismatchFields;
|
||||||
|
|||||||
@@ -222,6 +222,14 @@
|
|||||||
<div class="pd-field__value monospace">{{ clientViewData.clientNonPersonDetails.incorpNumber }}</div>
|
<div class="pd-field__value monospace">{{ clientViewData.clientNonPersonDetails.incorpNumber }}</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
@if (clientViewData.clientNonPersonDetails.dateOfRegistration) {
|
||||||
|
<div class="pd-field">
|
||||||
|
<div class="pd-field__label">{{ 'labels.inputs.Incorporation Date' | translate }}</div>
|
||||||
|
<div class="pd-field__value">
|
||||||
|
{{ clientViewData.clientNonPersonDetails.dateOfRegistration | dateFormat: 'DD-MM-YYYY' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@if (clientViewData.clientNonPersonDetails.remarks) {
|
@if (clientViewData.clientNonPersonDetails.remarks) {
|
||||||
<div class="pd-field full-width">
|
<div class="pd-field full-width">
|
||||||
<div class="pd-field__label">{{ 'labels.inputs.Remarks' | translate }}</div>
|
<div class="pd-field__label">{{ 'labels.inputs.Remarks' | translate }}</div>
|
||||||
|
|||||||
@@ -915,6 +915,15 @@ export class ClientsService {
|
|||||||
return this.http.post(`/clients/${clientId}/kyc/${kycId}?command=verify-gstin`, {});
|
return this.http.post(`/clients/${clientId}/kyc/${kycId}?command=verify-gstin`, {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies CIN against MCA (reads the CIN from the stored KYC record, so it must be saved first).
|
||||||
|
* @param clientId Client ID
|
||||||
|
* @param kycId KYC record ID
|
||||||
|
*/
|
||||||
|
verifyCIN(clientId: string, kycId: string): Observable<any> {
|
||||||
|
return this.http.post(`/clients/${clientId}/kyc/${kycId}?command=verify-cin`, {});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verifies DIN (Director Identification Number) for an authorized signatory.
|
* Verifies DIN (Director Identification Number) for an authorized signatory.
|
||||||
* Sends signatory clientId, DIN number and the non-individual entity name.
|
* Sends signatory clientId, DIN number and the non-individual entity name.
|
||||||
|
|||||||
@@ -384,7 +384,8 @@ export class CreateClientComponent {
|
|||||||
this.dialog
|
this.dialog
|
||||||
.open(VerifyClientAlertDialogComponent, {
|
.open(VerifyClientAlertDialogComponent, {
|
||||||
width: '420px',
|
width: '420px',
|
||||||
autoFocus: false
|
autoFocus: false,
|
||||||
|
data: { isEntity: this.legalFormType === 2 }
|
||||||
})
|
})
|
||||||
.afterClosed()
|
.afterClosed()
|
||||||
.subscribe(() => {
|
.subscribe(() => {
|
||||||
@@ -1159,8 +1160,8 @@ export class SaveConfirmDialogComponent {}
|
|||||||
</div>
|
</div>
|
||||||
<div class="vcad-body">
|
<div class="vcad-body">
|
||||||
<p>
|
<p>
|
||||||
Please click <strong>Verify Client</strong> to screen the client name against the UN Security Council
|
Please click <strong>{{ data?.isEntity ? 'Verify Entity' : 'Verify Client' }}</strong> to screen the client
|
||||||
Consolidated Sanctions List before proceeding.
|
name against the UN Security Council Consolidated Sanctions List before proceeding.
|
||||||
</p>
|
</p>
|
||||||
<div class="vcad-info">
|
<div class="vcad-info">
|
||||||
<mat-icon class="vcad-info__icon">gavel</mat-icon>
|
<mat-icon class="vcad-info__icon">gavel</mat-icon>
|
||||||
@@ -1269,4 +1270,6 @@ export class SaveConfirmDialogComponent {}
|
|||||||
MatDialogClose
|
MatDialogClose
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class VerifyClientAlertDialogComponent {}
|
export class VerifyClientAlertDialogComponent {
|
||||||
|
data = inject(MAT_DIALOG_DATA);
|
||||||
|
}
|
||||||
|
|||||||
@@ -215,11 +215,18 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<div class="tab-section">
|
<div class="tab-section">
|
||||||
|
<!-- constitutionMeta and entityName are bound here exactly as they are in create-client.
|
||||||
|
Without them an entity opened for edit falls back to the generic document list rather
|
||||||
|
than the one its constitution actually uses, so the document type saved at creation
|
||||||
|
can be missing from the picker; a proprietorship is also mis-rendered, since the
|
||||||
|
proprietor-PAN branch keys off the constitution. -->
|
||||||
<mifosx-client-kyc-step
|
<mifosx-client-kyc-step
|
||||||
#clientKyc
|
#clientKyc
|
||||||
[isNonIndividual]="legalFormType === 2"
|
[isNonIndividual]="legalFormType === 2"
|
||||||
[isMinor]="clientGeneralStep?.isMinor || false"
|
[isMinor]="clientGeneralStep?.isMinor || false"
|
||||||
[clientId]="clientId"
|
[clientId]="clientId"
|
||||||
|
[constitutionMeta]="clientGeneralStep?.selectedConstitution"
|
||||||
|
[entityName]="clientGeneralStep?.createClientForm?.get('fullname')?.value || ''"
|
||||||
[verificationName]="clientGeneralStep?.verificationName || ''"
|
[verificationName]="clientGeneralStep?.verificationName || ''"
|
||||||
[verificationDateOfBirth]="clientGeneralStep?.verificationDateOfBirth || null"
|
[verificationDateOfBirth]="clientGeneralStep?.verificationDateOfBirth || null"
|
||||||
[readOnly]="isTabReadOnly(1)"
|
[readOnly]="isTabReadOnly(1)"
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ 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';
|
import { ORIGINAL_VALUES_PAYLOAD_KEY, buildOriginalValuesSnapshot } from '../shared/client-edit-diff.util';
|
||||||
|
import { toProperNameCase } from '../utils/name-case.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.
|
||||||
@@ -589,13 +590,11 @@ export class EditClientComponent {
|
|||||||
const kycId = this.clientKycStep.kycId;
|
const kycId = this.clientKycStep.kycId;
|
||||||
const isVerified = this.clientKycStep.isKycVerified;
|
const isVerified = this.clientKycStep.isKycVerified;
|
||||||
|
|
||||||
// Validate remarks are present for verified KYC
|
// Remarks are not demanded here. The server requires them only when a save actually
|
||||||
if (isVerified && !kycData.remarks?.trim()) {
|
// overwrites data the verified record already held, and it names the reason when it
|
||||||
this.savingClient = false;
|
// refuses. Blocking every verified record up front aborted the save silently — the photo
|
||||||
this.clientKycStep.kycForm.get('remarks')?.markAsTouched();
|
// upload above had already gone through, so the image changed, the button came back to
|
||||||
return;
|
// life, and nothing said why nothing had been saved.
|
||||||
}
|
|
||||||
|
|
||||||
let saveObs;
|
let saveObs;
|
||||||
if (kycId && isVerified) {
|
if (kycId && isVerified) {
|
||||||
// VERIFIED → re-edit command
|
// VERIFIED → re-edit command
|
||||||
@@ -628,8 +627,19 @@ export class EditClientComponent {
|
|||||||
this.saveKycDocProof(this.clientId);
|
this.saveKycDocProof(this.clientId);
|
||||||
this.selectedTabIndex = this.selectedTabIndex + 1;
|
this.selectedTabIndex = this.selectedTabIndex + 1;
|
||||||
},
|
},
|
||||||
error: () => {
|
// A rejected save used to just re-enable the button, which reads as "nothing happened".
|
||||||
|
// The server's own message says what is missing — remarks on a re-edit, most often — so
|
||||||
|
// it is shown rather than swallowed, and the KYC tab stays put for the officer to fix.
|
||||||
|
error: (err: any) => {
|
||||||
this.savingClient = false;
|
this.savingClient = false;
|
||||||
|
const message =
|
||||||
|
err?.error?.errors?.[0]?.defaultUserMessage ||
|
||||||
|
err?.error?.defaultUserMessage ||
|
||||||
|
'KYC could not be saved. Please review the KYC details and try again.';
|
||||||
|
this.snackBar.open(message, 'Close', { duration: 6000 });
|
||||||
|
if (isVerified) {
|
||||||
|
this.clientKycStep?.kycForm.get('remarks')?.markAsTouched();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -734,17 +744,20 @@ export class EditClientComponent {
|
|||||||
? this.toLocalDateString(oldDobRaw)
|
? this.toLocalDateString(oldDobRaw)
|
||||||
: oldDobRaw || '';
|
: oldDobRaw || '';
|
||||||
|
|
||||||
|
// Both sides run through toProperNameCase so a casing-only difference — the stored
|
||||||
|
// 'LINCPAY LTD' against the normalized 'Lincpay LTD' the form now submits — is not
|
||||||
|
// mistaken for a real rename and does not force a PAN re-verification.
|
||||||
const buildName = (parts: { firstname?: string; middlename?: string; lastname?: string; fullname?: string }) =>
|
const buildName = (parts: { firstname?: string; middlename?: string; lastname?: string; fullname?: string }) =>
|
||||||
(
|
toProperNameCase(
|
||||||
parts.fullname ||
|
parts.fullname ||
|
||||||
[
|
[
|
||||||
parts.firstname,
|
parts.firstname,
|
||||||
parts.middlename,
|
parts.middlename,
|
||||||
parts.lastname
|
parts.lastname
|
||||||
]
|
]
|
||||||
.filter((p) => p && p.trim())
|
.filter((p) => p && p.trim())
|
||||||
.join(' ')
|
.join(' ')
|
||||||
).trim();
|
);
|
||||||
|
|
||||||
const newName = buildName({
|
const newName = buildName({
|
||||||
firstname: form?.get('firstname')?.value || '',
|
firstname: form?.get('firstname')?.value || '',
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
/**
|
||||||
|
* 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 { ENTITY_KYC_CATEGORY, ENTITY_KYC_DOCUMENT_TYPES, withEntityKycDocuments } from './entity-kyc-documents';
|
||||||
|
|
||||||
|
describe('withEntityKycDocuments', () => {
|
||||||
|
const names = (docs: { name: string }[]) => docs.map((d) => d.name);
|
||||||
|
|
||||||
|
it('keeps the six RBI entity documents when nothing else is supplied', () => {
|
||||||
|
expect(names(withEntityKycDocuments())).toEqual(names(ENTITY_KYC_DOCUMENT_TYPES));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('puts the entity documents ahead of anything the caller adds', () => {
|
||||||
|
const merged = withEntityKycDocuments([{ name: 'Declaration by Karta', mandatory: true }], 'HUF');
|
||||||
|
expect(names(merged.slice(0, 6))).toEqual(names(ENTITY_KYC_DOCUMENT_TYPES));
|
||||||
|
expect(merged[6].name).toBe('Declaration by Karta');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('groups the entity documents together and the rest under the constitution', () => {
|
||||||
|
const merged = withEntityKycDocuments([{ name: 'Declaration by Karta' }], 'HUF');
|
||||||
|
expect(merged[0].category).toBe(ENTITY_KYC_CATEGORY);
|
||||||
|
expect(merged[6].category).toBe('HUF documents');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('folds the backend template names onto the entity chips instead of duplicating them', () => {
|
||||||
|
const merged = withEntityKycDocuments(
|
||||||
|
[
|
||||||
|
{ name: 'Certificate of Incorporation', code: 'COI', mandatory: true },
|
||||||
|
{ name: 'Memorandum of Association', code: 'MOA', mandatory: true },
|
||||||
|
{ name: 'Articles of Association', code: 'AOA', mandatory: true },
|
||||||
|
{ name: 'PAN Card of Company', code: 'PAN', mandatory: true },
|
||||||
|
{ name: 'Board Resolution', code: 'BOARD_RESOLUTION', mandatory: true },
|
||||||
|
{ name: 'KYC of Authorized Signatories', code: 'SIGNATORY_KYC', mandatory: true }
|
||||||
|
],
|
||||||
|
'Private Limited Company'
|
||||||
|
);
|
||||||
|
expect(merged).toHaveLength(6);
|
||||||
|
expect(names(merged)).toEqual(names(ENTITY_KYC_DOCUMENT_TYPES));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('folds constitution labels on too, since those lists carry no codes', () => {
|
||||||
|
const merged = withEntityKycDocuments(
|
||||||
|
[
|
||||||
|
{ name: 'Certificate of Incorporation', code: 'Certificate of Incorporation', mandatory: true },
|
||||||
|
{ name: 'LLP Agreement', code: 'LLP Agreement', mandatory: true },
|
||||||
|
{ name: 'PAN of LLP', code: 'PAN of LLP', mandatory: true },
|
||||||
|
{ name: 'Designated Partners Resolution', code: 'Designated Partners Resolution', mandatory: true },
|
||||||
|
{ name: 'KYC of Designated Partners / Authorized Signatories', code: 'x', mandatory: true }
|
||||||
|
],
|
||||||
|
'Limited Liability Partnership (LLP)'
|
||||||
|
);
|
||||||
|
expect(merged).toHaveLength(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('carries a mandatory flag over to the entity document it folds into', () => {
|
||||||
|
const merged = withEntityKycDocuments([{ name: 'PAN Card of Company', code: 'PAN', mandatory: true }]);
|
||||||
|
expect(merged.find((d) => d.code === 'ENTITY_PAN')?.mandatory).toBe(true);
|
||||||
|
expect(merged.find((d) => d.code === 'GST_CERTIFICATE')?.mandatory).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a document that only mentions another one as its own requirement', () => {
|
||||||
|
const merged = withEntityKycDocuments(
|
||||||
|
[{ name: 'Proof of Business Existence (GST Certificate / Shop License)', mandatory: true }],
|
||||||
|
'Sole Proprietorship'
|
||||||
|
);
|
||||||
|
expect(names(merged)).toContain('Proof of Business Existence (GST Certificate / Shop License)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps constitution-specific requirements the entity list does not cover', () => {
|
||||||
|
const merged = withEntityKycDocuments(
|
||||||
|
[
|
||||||
|
{ name: 'Government Notification / Order', mandatory: true },
|
||||||
|
{ name: 'Udyam Registration Certificate', code: 'UDYAM', mandatory: true },
|
||||||
|
{ name: 'Proof of Address of HUF' }],
|
||||||
|
'Government Body / PSU'
|
||||||
|
);
|
||||||
|
expect(names(merged).slice(6)).toEqual([
|
||||||
|
'Government Notification / Order',
|
||||||
|
'Udyam Registration Certificate',
|
||||||
|
'Proof of Address of HUF'
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Shape the KYC document-type picker works with. */
|
||||||
|
export interface KycDocumentType {
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
mandatory: boolean;
|
||||||
|
category?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ENTITY_KYC_CATEGORY = 'Entity KYC documents';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The KYC documents every legal entity must produce, whatever its constitution — RBI Master
|
||||||
|
* Direction on KYC, Annexure I. Codes match PROOF_META in kyc-proof-types.ts, which is what
|
||||||
|
* groups them under "Entity KYC documents" and supplies each chip's banner text.
|
||||||
|
*
|
||||||
|
* This is the single source for the entity list so a non-individual client sees the same
|
||||||
|
* documents wherever the picker is rendered — the create stepper, the edit stepper, before a
|
||||||
|
* constitution has been chosen and after, and once the backend KYC template has loaded.
|
||||||
|
*/
|
||||||
|
export const ENTITY_KYC_DOCUMENT_TYPES: { name: string; code: string }[] = [
|
||||||
|
{ 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' }
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Codes the backend KYC template uses for documents the entity list already covers
|
||||||
|
* (ClientKycApiResource.getRequiredDocuments). Folding them onto the entity codes keeps one
|
||||||
|
* chip per document instead of "PAN Card of Company" sitting next to "PAN Card of Entity".
|
||||||
|
*/
|
||||||
|
const ENTITY_DOC_CODE_ALIASES: Record<string, string> = {
|
||||||
|
CERT_INCORPORATION: 'CERT_INCORPORATION',
|
||||||
|
COI: 'CERT_INCORPORATION',
|
||||||
|
REG_CERT: 'CERT_INCORPORATION',
|
||||||
|
ENTITY_PAN: 'ENTITY_PAN',
|
||||||
|
PAN: 'ENTITY_PAN',
|
||||||
|
CONSTITUTIVE_DOC: 'CONSTITUTIVE_DOC',
|
||||||
|
MOA: 'CONSTITUTIVE_DOC',
|
||||||
|
AOA: 'CONSTITUTIVE_DOC',
|
||||||
|
PARTNERSHIP_DEED: 'CONSTITUTIVE_DOC',
|
||||||
|
TRUST_DEED: 'CONSTITUTIVE_DOC',
|
||||||
|
BOARD_RESOLUTION: 'BOARD_RESOLUTION',
|
||||||
|
AUTH_SIGNATORY_KYC: 'AUTH_SIGNATORY_KYC',
|
||||||
|
SIGNATORY_KYC: 'AUTH_SIGNATORY_KYC',
|
||||||
|
GST_CERTIFICATE: 'GST_CERTIFICATE',
|
||||||
|
GST_CERT: 'GST_CERTIFICATE'
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The per-constitution lists carry no codes of their own (code === name), so those entries are
|
||||||
|
* matched on the label instead. Patterns are anchored deliberately: a document that merely
|
||||||
|
* mentions another one ("Proof of Business Existence (GST Certificate / Shop License)") is a
|
||||||
|
* distinct requirement and has to survive as its own chip rather than vanish into GST.
|
||||||
|
*/
|
||||||
|
const ENTITY_DOC_NAME_PATTERNS: { pattern: RegExp; code: string }[] = [
|
||||||
|
{ pattern: /^certificate of incorporation\b/, code: 'CERT_INCORPORATION' },
|
||||||
|
{ pattern: /^registration certificate\b/, code: 'CERT_INCORPORATION' },
|
||||||
|
{
|
||||||
|
pattern: /^(memorandum|articles of association|partnership deed|trust deed|llp agreement|by-?laws)\b/,
|
||||||
|
code: 'CONSTITUTIVE_DOC'
|
||||||
|
},
|
||||||
|
{ pattern: /^pan\b/, code: 'ENTITY_PAN' },
|
||||||
|
{ pattern: /^(board resolution|designated partners resolution|resolution authorizing)\b/, code: 'BOARD_RESOLUTION' },
|
||||||
|
{ pattern: /^(kyc of|authorized signatory kyc|proprietor'?s kyc)\b/, code: 'AUTH_SIGNATORY_KYC' },
|
||||||
|
{ pattern: /^gst (registration )?certificate\b/, code: 'GST_CERTIFICATE' }
|
||||||
|
];
|
||||||
|
|
||||||
|
/** The entity document this one duplicates, or null when it is a requirement of its own. */
|
||||||
|
function resolveEntityDocCode(doc: { name?: string; code?: string }): string | null {
|
||||||
|
const code = (doc.code || '').toUpperCase().trim();
|
||||||
|
// A constitution list stores the label in `code`, so only treat it as a code when it is one.
|
||||||
|
if (code && !code.includes(' ') && ENTITY_DOC_CODE_ALIASES[code]) {
|
||||||
|
return ENTITY_DOC_CODE_ALIASES[code];
|
||||||
|
}
|
||||||
|
const name = (doc.name || '').toLowerCase().trim();
|
||||||
|
return ENTITY_DOC_NAME_PATTERNS.find((p) => p.pattern.test(name))?.code || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Puts the RBI entity documents in front of whatever list a caller has (per-constitution docs,
|
||||||
|
* or the backend KYC template) instead of letting that list replace them.
|
||||||
|
*
|
||||||
|
* Without this the officer sees a different set of documents on every screen for the same
|
||||||
|
* client: the entity list until a constitution is picked, the constitution's own list after,
|
||||||
|
* and the backend template's list once the client exists and is reopened for edit. Anything in
|
||||||
|
* `extra` that isn't already one of the six stays, grouped under `extraCategory` — those are
|
||||||
|
* genuine constitution-specific requirements (Karta declaration, Udyam certificate, government
|
||||||
|
* notification) and dropping them would lose real requirements.
|
||||||
|
*
|
||||||
|
* A mandatory flag on a duplicate carries over to the entity chip it folds into, so the
|
||||||
|
* "at least one mandatory document" validator still sees it.
|
||||||
|
*/
|
||||||
|
export function withEntityKycDocuments(
|
||||||
|
extra: { name: string; code?: string; mandatory?: boolean }[] = [],
|
||||||
|
extraCategory?: string | null
|
||||||
|
): KycDocumentType[] {
|
||||||
|
const mandatoryCodes = new Set<string>();
|
||||||
|
const leftovers: KycDocumentType[] = [];
|
||||||
|
|
||||||
|
extra.forEach((doc) => {
|
||||||
|
const entityCode = resolveEntityDocCode(doc);
|
||||||
|
if (entityCode) {
|
||||||
|
if (doc.mandatory) {
|
||||||
|
mandatoryCodes.add(entityCode);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
leftovers.push({
|
||||||
|
name: doc.name,
|
||||||
|
code: doc.code || doc.name,
|
||||||
|
mandatory: !!doc.mandatory,
|
||||||
|
category: extraCategory ? `${extraCategory} documents` : 'Additional documents'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const base: KycDocumentType[] = ENTITY_KYC_DOCUMENT_TYPES.map((d) => ({
|
||||||
|
name: d.name,
|
||||||
|
code: d.code,
|
||||||
|
mandatory: mandatoryCodes.has(d.code),
|
||||||
|
category: ENTITY_KYC_CATEGORY
|
||||||
|
}));
|
||||||
|
|
||||||
|
return [
|
||||||
|
...base,
|
||||||
|
...leftovers
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* 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 { toProperNameCase } from './name-case.util';
|
||||||
|
|
||||||
|
describe('toProperNameCase', () => {
|
||||||
|
it('raises a lower-case first letter on every word', () => {
|
||||||
|
expect(toProperNameCase('keshav Singh sisodiya')).toBe('Keshav Singh Sisodiya');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the rest of each word exactly as typed', () => {
|
||||||
|
expect(toProperNameCase('lincpay lTD')).toBe('Lincpay LTD');
|
||||||
|
expect(toProperNameCase('lincpay pvt ltd')).toBe('Lincpay Pvt Ltd');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not rewrite a name that is already correct', () => {
|
||||||
|
expect(toProperNameCase('Technoludium Solutions Private Limited')).toBe('Technoludium Solutions Private Limited');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves an upper-case name shouted rather than title-casing it', () => {
|
||||||
|
expect(toProperNameCase('TECHNOLUDIUM SOLUTIONS')).toBe('TECHNOLUDIUM SOLUTIONS');
|
||||||
|
expect(toProperNameCase('KESHAV singh')).toBe('KESHAV Singh');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('capitalises after a hyphen', () => {
|
||||||
|
expect(toProperNameCase('jean-marie traders')).toBe('Jean-Marie Traders');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('capitalises name prefixes after an apostrophe but not possessives', () => {
|
||||||
|
expect(toProperNameCase("o'brien d'souza")).toBe("O'Brien D'Souza");
|
||||||
|
expect(toProperNameCase("lincpay's agency")).toBe("Lincpay's Agency");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves punctuation and non-letters untouched', () => {
|
||||||
|
expect(toProperNameCase('abc pvt. ltd.')).toBe('Abc Pvt. Ltd.');
|
||||||
|
expect(toProperNameCase('tata & sons')).toBe('Tata & Sons');
|
||||||
|
expect(toProperNameCase('3m india')).toBe('3m India');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('trims and collapses whitespace', () => {
|
||||||
|
expect(toProperNameCase(' lincpay holdings ')).toBe('Lincpay Holdings');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an empty string for blank or non-string input', () => {
|
||||||
|
expect(toProperNameCase('')).toBe('');
|
||||||
|
expect(toProperNameCase(' ')).toBe('');
|
||||||
|
expect(toProperNameCase(null)).toBe('');
|
||||||
|
expect(toProperNameCase(undefined)).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Casing for client names: the name is stored exactly as the officer typed it, except that a word
|
||||||
|
* starting with a lower-case letter has that first letter raised. Nothing else is touched.
|
||||||
|
*
|
||||||
|
* 'keshav Singh sisodiya' → 'Keshav Singh Sisodiya'
|
||||||
|
* 'lincpay lTD' → 'Lincpay LTD'
|
||||||
|
* 'Technoludium Solutions Private Limited' → unchanged
|
||||||
|
* 'TECHNOLUDIUM SOLUTIONS' → unchanged
|
||||||
|
*
|
||||||
|
* Deliberately NOT full title-casing. Lower-casing the rest of each word would rewrite an entity
|
||||||
|
* name the officer entered as it appears on the certificate — 'TECHNOLUDIUM' becoming
|
||||||
|
* 'Technoludium', 'Ltd' becoming a forced 'LTD'. That name is matched character-for-character
|
||||||
|
* against the PAN holder's registered name during KYC verification, so the typed spelling is the
|
||||||
|
* thing to preserve; only an unintended lower-case first letter is corrected.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Characters after which a new word starts: 'jean-marie' → 'Jean-Marie'. */
|
||||||
|
const SEGMENT_STARTERS = new Set([
|
||||||
|
'-',
|
||||||
|
"'",
|
||||||
|
'’'
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raises the first letter of each word in a single space-delimited token, leaving every other
|
||||||
|
* character as typed.
|
||||||
|
*/
|
||||||
|
function capitalizeWord(word: string): string {
|
||||||
|
let result = '';
|
||||||
|
for (let i = 0; i < word.length; i++) {
|
||||||
|
const previous = i === 0 ? '' : word[i - 1];
|
||||||
|
let startsWord = i === 0 || SEGMENT_STARTERS.has(previous);
|
||||||
|
if (previous === "'" || previous === '’') {
|
||||||
|
// 'o'brien' / 'd'souza' capitalise after the apostrophe, but a possessive
|
||||||
|
// ('lincpay's') must not — only a single leading letter marks the name prefix.
|
||||||
|
startsWord = i === 2;
|
||||||
|
}
|
||||||
|
result += startsWord ? word[i].toUpperCase() : word[i];
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies the casing rule above to a person or entity name.
|
||||||
|
* Trims and collapses runs of whitespace; non-string input yields ''.
|
||||||
|
*/
|
||||||
|
export function toProperNameCase(value: string | null | undefined): string {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const collapsed = value.trim().replace(/\s+/g, ' ');
|
||||||
|
if (!collapsed) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return collapsed.split(' ').map(capitalizeWord).join(' ');
|
||||||
|
}
|
||||||
@@ -7,556 +7,121 @@
|
|||||||
-->
|
-->
|
||||||
|
|
||||||
<div class="alm-dlg">
|
<div class="alm-dlg">
|
||||||
<!-- HEADER -->
|
<!-- Window chrome. Screen only — the print takes the statement's own heading, not this bar. -->
|
||||||
<div class="alm-head">
|
<div class="alm-head no-print">
|
||||||
<div class="alm-head-left">
|
<div class="alm-head-left">
|
||||||
<div class="alm-head-ic"><fa-icon icon="balance-scale"></fa-icon></div>
|
<div class="alm-head-ic"><fa-icon icon="balance-scale"></fa-icon></div>
|
||||||
<div>
|
<div>
|
||||||
<h2 class="alm-title">Asset Liability Management — Structural Liquidity</h2>
|
<h2 class="alm-title">Average Liabilities and Assets</h2>
|
||||||
<p class="alm-sub">
|
<p class="alm-sub">
|
||||||
{{ bank.name }} · <strong>{{ bank.formCode }}</strong> · {{ bank.quarter }} · As on
|
{{ bank.name }} · {{ branchName }} · FY {{ bank.financialYear }} · Month-wise during the
|
||||||
<strong>{{ bank.asOnDate }}</strong>
|
year
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="alm-head-actions">
|
<button class="alm-close" (click)="close()" matTooltip="Close" aria-label="Close">✕</button>
|
||||||
<span class="alm-overall" [ngClass]="gap14dWithinTolerance && gap28dWithinTolerance ? 'on-target' : 'below'">
|
</div>
|
||||||
<span class="alm-overall-l">14d Tolerance</span>
|
|
||||||
<span class="alm-overall-v">{{ gap14dPct.toFixed(2) }}%</span>
|
<!-- Report parameters and actions, the way a report is run in the core system -->
|
||||||
<span class="alm-overall-t">/ -{{ bank.tolerance14d }}%</span>
|
<div class="alm-params no-print">
|
||||||
</span>
|
<label class="alm-param">
|
||||||
<button class="alm-close" (click)="close()" matTooltip="Close" aria-label="Close">✕</button>
|
<span>Financial year</span>
|
||||||
|
<output class="alm-param-static">{{ bank.financialYear }}</output>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="alm-param">
|
||||||
|
<span>Up to month</span>
|
||||||
|
<select [(ngModel)]="upToIndex" (ngModelChange)="runReport()">
|
||||||
|
<option *ngFor="let m of monthOptions" [ngValue]="m.index">{{ m.label }}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="alm-param">
|
||||||
|
<span>Months included</span>
|
||||||
|
<output class="alm-param-static">{{ statementRows.length }}</output>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<span class="alm-params-sp"></span>
|
||||||
|
|
||||||
|
<button type="button" class="alm-btn" (click)="print()"><fa-icon icon="print"></fa-icon> Print</button>
|
||||||
|
<button type="button" class="alm-btn" (click)="exportXls()">
|
||||||
|
<fa-icon icon="file-excel"></fa-icon> Export
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="alm-note no-print">
|
||||||
|
<fa-icon icon="info-circle"></fa-icon> <strong>{{ unmappedColumns }}</strong> are not mapped in the
|
||||||
|
core-system extract and print as 0.00. Total and Average cover the {{ statementRows.length }} month(s) shown.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- ═══ THE STATEMENT — what goes on paper ═══ -->
|
||||||
|
<div class="alm-body">
|
||||||
|
<div class="stmt" #statement>
|
||||||
|
<div class="stmt-bank">{{ statementHeading }}</div>
|
||||||
|
<div class="stmt-title">Average Liabilities and Assets Month-wise during the year</div>
|
||||||
|
|
||||||
|
<div class="stmt-tbl-wrap">
|
||||||
|
<table class="stmt-tbl">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th rowspan="2" class="col-month">Month</th>
|
||||||
|
<th rowspan="2">Capital</th>
|
||||||
|
<th rowspan="2">Reserve/Prov<br />Exclude OIR</th>
|
||||||
|
<th rowspan="2">Deposits</th>
|
||||||
|
<th rowspan="2">Borrowings</th>
|
||||||
|
<th rowspan="2">Other Liabilities</th>
|
||||||
|
<th rowspan="2">Cash on Hand</th>
|
||||||
|
<th colspan="2" class="col-grp">Bank Balances</th>
|
||||||
|
<th rowspan="2">Investments<br />including</th>
|
||||||
|
<th rowspan="2">Advances</th>
|
||||||
|
<th rowspan="2">Other Assets<br />Exclude IRA</th>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Current</th>
|
||||||
|
<th>FDs</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr *ngFor="let r of statementRows">
|
||||||
|
<td class="col-month">
|
||||||
|
<strong>{{ r.label }}</strong
|
||||||
|
><br /><span class="stmt-yr">{{ r.sub }}</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ r.capital | number: '1.2-2' }}</td>
|
||||||
|
<td>{{ r.reserveProv | number: '1.2-2' }}</td>
|
||||||
|
<td>{{ r.deposits | number: '1.2-2' }}</td>
|
||||||
|
<td>{{ r.borrowings | number: '1.2-2' }}</td>
|
||||||
|
<td>{{ r.otherLiabilities | number: '1.2-2' }}</td>
|
||||||
|
<td>{{ r.cashOnHand | number: '1.2-2' }}</td>
|
||||||
|
<td>{{ r.bankCurrent | number: '1.2-2' }}</td>
|
||||||
|
<td>{{ r.bankFds | number: '1.2-2' }}</td>
|
||||||
|
<td>{{ r.investments | number: '1.2-2' }}</td>
|
||||||
|
<td>{{ r.advances | number: '1.2-2' }}</td>
|
||||||
|
<td>{{ r.otherAssets | number: '1.2-2' }}</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr class="stmt-sum" *ngFor="let r of [totalRow, averageRow]">
|
||||||
|
<td class="col-month">{{ r.label }}</td>
|
||||||
|
<td>{{ r.capital | number: '1.2-2' }}</td>
|
||||||
|
<td>{{ r.reserveProv | number: '1.2-2' }}</td>
|
||||||
|
<td>{{ r.deposits | number: '1.2-2' }}</td>
|
||||||
|
<td>{{ r.borrowings | number: '1.2-2' }}</td>
|
||||||
|
<td>{{ r.otherLiabilities | number: '1.2-2' }}</td>
|
||||||
|
<td>{{ r.cashOnHand | number: '1.2-2' }}</td>
|
||||||
|
<td>{{ r.bankCurrent | number: '1.2-2' }}</td>
|
||||||
|
<td>{{ r.bankFds | number: '1.2-2' }}</td>
|
||||||
|
<td>{{ r.investments | number: '1.2-2' }}</td>
|
||||||
|
<td>{{ r.advances | number: '1.2-2' }}</td>
|
||||||
|
<td>{{ r.otherAssets | number: '1.2-2' }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stmt-foot">
|
||||||
|
<div>Print Date : {{ generatedAt }}</div>
|
||||||
|
<div>Report Ref No : {{ bank.reportRefNo }} User Code:{{ statementUserCode }}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- TABS -->
|
|
||||||
<div class="alm-tabs">
|
|
||||||
<button class="alm-tab" [class.active]="activeTab === 'dashboard'" (click)="setTab('dashboard')">
|
|
||||||
<fa-icon icon="chart-pie"></fa-icon> Dashboard
|
|
||||||
</button>
|
|
||||||
<button class="alm-tab" [class.active]="activeTab === 'ladder'" (click)="setTab('ladder')">
|
|
||||||
<fa-icon icon="layer-group"></fa-icon> Maturity Ladder
|
|
||||||
</button>
|
|
||||||
<button class="alm-tab" [class.active]="activeTab === 'monthly'" (click)="setTab('monthly')">
|
|
||||||
<fa-icon icon="calendar-alt"></fa-icon> Monthly Snapshot
|
|
||||||
</button>
|
|
||||||
<button class="alm-tab" [class.active]="activeTab === 'avgbal'" (click)="setTab('avgbal')">
|
|
||||||
<fa-icon icon="table"></fa-icon> Avg. Liabilities & Assets
|
|
||||||
</button>
|
|
||||||
<button class="alm-tab" [class.active]="activeTab === 'returns'" (click)="setTab('returns')">
|
|
||||||
<fa-icon icon="file-invoice"></fa-icon> Form ALM-1
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="alm-body">
|
|
||||||
<!-- ════════════ DASHBOARD ════════════ -->
|
|
||||||
<ng-container *ngIf="activeTab === 'dashboard'">
|
|
||||||
<div class="alm-stats">
|
|
||||||
<div class="alm-stat-card alm-stat-gauge">
|
|
||||||
<div
|
|
||||||
class="gauge"
|
|
||||||
[style.--pct.%]="totalGapPct > 0 ? (totalGapPct > 100 ? 100 : totalGapPct) : 0"
|
|
||||||
[ngClass]="totalGap >= 0 ? 'on-target' : 'below'"
|
|
||||||
>
|
|
||||||
<div class="gauge-inner">
|
|
||||||
<div class="gauge-pct">{{ totalGapPct.toFixed(1) }}%</div>
|
|
||||||
<div class="gauge-label">Net Gap / Outflow</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="alm-stat-card">
|
|
||||||
<div class="alm-stat-l">NDTL</div>
|
|
||||||
<div class="alm-stat-v">₹ {{ bank.ndtl / 100 | number: '1.2-2' }} Cr</div>
|
|
||||||
<div class="alm-stat-meta">Net Demand & Time Liabilities</div>
|
|
||||||
</div>
|
|
||||||
<div class="alm-stat-card">
|
|
||||||
<div class="alm-stat-l">Total Outflows</div>
|
|
||||||
<div class="alm-stat-v">₹ {{ totalOutflow / 100 | number: '1.2-2' }} Cr</div>
|
|
||||||
<div class="alm-stat-meta">All maturity buckets</div>
|
|
||||||
</div>
|
|
||||||
<div class="alm-stat-card">
|
|
||||||
<div class="alm-stat-l">Total Inflows</div>
|
|
||||||
<div class="alm-stat-v">₹ {{ totalInflow / 100 | number: '1.2-2' }} Cr</div>
|
|
||||||
<div class="alm-stat-meta" [ngClass]="totalGap >= 0 ? 'pos' : 'neg'">
|
|
||||||
Net: {{ totalGap >= 0 ? '+' : '' }}{{ totalGap / 100 | number: '1.2-2' }} Cr
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- TOLERANCE BARS -->
|
|
||||||
<div class="alm-section">
|
|
||||||
<div class="alm-sec-h">
|
|
||||||
<h3 class="alm-sec-t">RBI Tolerance Limits — Cumulative Gap</h3>
|
|
||||||
<span class="alm-circular">RBI MD: {{ bank.rbiCircular }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="alm-tolerance">
|
|
||||||
<div class="alm-tol-row" [ngClass]="gap14dWithinTolerance ? 'good' : 'bad'">
|
|
||||||
<div class="alm-tol-h">
|
|
||||||
<span class="alm-tol-l"
|
|
||||||
><fa-icon icon="hourglass-half"></fa-icon> Cumulative Gap up to 14 days</span
|
|
||||||
>
|
|
||||||
<span class="alm-tol-p">
|
|
||||||
<strong>{{ gap14dPct.toFixed(2) }}%</strong>
|
|
||||||
<span class="alm-tol-lim">limit -{{ bank.tolerance14d }}%</span>
|
|
||||||
<span class="alm-tol-st">{{ gap14dWithinTolerance ? 'WITHIN' : 'BREACH' }}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="alm-tol-track">
|
|
||||||
<div
|
|
||||||
class="alm-tol-fill"
|
|
||||||
[style.width.%]="gap14dPct >= 0 ? 100 : 100 + (gap14dPct / bank.tolerance14d) * 100"
|
|
||||||
></div>
|
|
||||||
<div class="alm-tol-marker"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="alm-tol-row" [ngClass]="gap28dWithinTolerance ? 'good' : 'bad'">
|
|
||||||
<div class="alm-tol-h">
|
|
||||||
<span class="alm-tol-l"><fa-icon icon="clock"></fa-icon> Cumulative Gap up to 30 days</span>
|
|
||||||
<span class="alm-tol-p">
|
|
||||||
<strong>{{ gap28dPct.toFixed(2) }}%</strong>
|
|
||||||
<span class="alm-tol-lim">limit -{{ bank.tolerance28d }}%</span>
|
|
||||||
<span class="alm-tol-st">{{ gap28dWithinTolerance ? 'WITHIN' : 'BREACH' }}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="alm-tol-track">
|
|
||||||
<div
|
|
||||||
class="alm-tol-fill"
|
|
||||||
[style.width.%]="gap28dPct >= 0 ? 100 : 100 + (gap28dPct / bank.tolerance28d) * 100"
|
|
||||||
></div>
|
|
||||||
<div class="alm-tol-marker"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- BUCKET MINI HEATMAP -->
|
|
||||||
<div class="alm-section">
|
|
||||||
<div class="alm-sec-h">
|
|
||||||
<h3 class="alm-sec-t">Bucket-wise Mismatch Heatmap</h3>
|
|
||||||
</div>
|
|
||||||
<div class="alm-heatmap">
|
|
||||||
<div
|
|
||||||
*ngFor="let b of buckets"
|
|
||||||
class="heat-cell"
|
|
||||||
[ngClass]="bucketStatus(b)"
|
|
||||||
[matTooltip]="b.label + ' — Gap: ' + bucketGapPct(b).toFixed(2) + '%'"
|
|
||||||
>
|
|
||||||
<div class="heat-code">{{ b.code }}</div>
|
|
||||||
<div class="heat-pct">{{ bucketGapPct(b).toFixed(1) }}%</div>
|
|
||||||
<div class="heat-lbl">{{ b.label }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="alm-section">
|
|
||||||
<div class="alm-sec-h">
|
|
||||||
<h3 class="alm-sec-t">Reporting Snapshot</h3>
|
|
||||||
</div>
|
|
||||||
<div class="alm-info-grid">
|
|
||||||
<div class="alm-info">
|
|
||||||
<div class="alm-info-l">Reporting Bank</div>
|
|
||||||
<div class="alm-info-v">{{ bank.name }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="alm-info">
|
|
||||||
<div class="alm-info-l">Branch</div>
|
|
||||||
<div class="alm-info-v">{{ bank.branch }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="alm-info">
|
|
||||||
<div class="alm-info-l">Principal Officer</div>
|
|
||||||
<div class="alm-info-v">{{ bank.principalOfficer }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="alm-info">
|
|
||||||
<div class="alm-info-l">Form Code</div>
|
|
||||||
<div class="alm-info-v">{{ bank.formCode }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="alm-info">
|
|
||||||
<div class="alm-info-l">Report Ref No</div>
|
|
||||||
<div class="alm-info-v mono">{{ bank.reportRefNo }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="alm-info">
|
|
||||||
<div class="alm-info-l">User Code</div>
|
|
||||||
<div class="alm-info-v mono">{{ bank.userCode }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<!-- ════════════ MATURITY LADDER ════════════ -->
|
|
||||||
<ng-container *ngIf="activeTab === 'ladder'">
|
|
||||||
<div class="alm-section">
|
|
||||||
<div class="alm-sec-h">
|
|
||||||
<h3 class="alm-sec-t">Statement of Structural Liquidity — Maturity Ladder</h3>
|
|
||||||
<div class="alm-sec-actions">
|
|
||||||
<select class="alm-sel" [(ngModel)]="filterBucket">
|
|
||||||
<option value="">All Buckets</option>
|
|
||||||
<option value="short">Short Term (≤ 30 days)</option>
|
|
||||||
<option value="medium">Medium Term (1–12 months)</option>
|
|
||||||
<option value="long">Long Term (> 1 year)</option>
|
|
||||||
</select>
|
|
||||||
<button mat-stroked-button class="alm-btn-xls" (click)="exportLadderCsv()">
|
|
||||||
<fa-icon icon="file-csv"></fa-icon> Export CSV
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="alm-tbl-wrap">
|
|
||||||
<table class="alm-tbl">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Bucket</th>
|
|
||||||
<th>Time Period</th>
|
|
||||||
<th class="ta-r">Outflow (Liab)</th>
|
|
||||||
<th class="ta-r">Inflow (Assets)</th>
|
|
||||||
<th class="ta-r">Gap</th>
|
|
||||||
<th class="ta-r">Gap %</th>
|
|
||||||
<th class="ta-c">Status</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr *ngFor="let b of filteredBuckets">
|
|
||||||
<td class="mono">{{ b.code }}</td>
|
|
||||||
<td>{{ b.label }}</td>
|
|
||||||
<td class="ta-r">{{ b.outflow | number: '1.2-2' }}</td>
|
|
||||||
<td class="ta-r">{{ b.inflow | number: '1.2-2' }}</td>
|
|
||||||
<td class="ta-r" [ngClass]="bucketGap(b) >= 0 ? 'pos' : 'neg'">
|
|
||||||
<strong>{{ bucketGap(b) >= 0 ? '+' : '' }}{{ bucketGap(b) | number: '1.2-2' }}</strong>
|
|
||||||
</td>
|
|
||||||
<td class="ta-r" [ngClass]="bucketGap(b) >= 0 ? 'pos' : 'neg'">
|
|
||||||
{{ bucketGapPct(b) >= 0 ? '+' : '' }}{{ bucketGapPct(b).toFixed(2) }}%
|
|
||||||
</td>
|
|
||||||
<td class="ta-c">
|
|
||||||
<span class="status-pill" [ngClass]="bucketStatus(b)">
|
|
||||||
<ng-container *ngIf="bucketStatus(b) === 'surplus'">SURPLUS</ng-container>
|
|
||||||
<ng-container *ngIf="bucketStatus(b) === 'deficit'">DEFICIT</ng-container>
|
|
||||||
<ng-container *ngIf="bucketStatus(b) === 'balanced'">BALANCED</ng-container>
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr class="row-total">
|
|
||||||
<td colspan="2"><strong>TOTAL</strong></td>
|
|
||||||
<td class="ta-r">
|
|
||||||
<strong>{{ totalOutflow | number: '1.2-2' }}</strong>
|
|
||||||
</td>
|
|
||||||
<td class="ta-r">
|
|
||||||
<strong>{{ totalInflow | number: '1.2-2' }}</strong>
|
|
||||||
</td>
|
|
||||||
<td class="ta-r" [ngClass]="totalGap >= 0 ? 'pos' : 'neg'">
|
|
||||||
<strong>{{ totalGap >= 0 ? '+' : '' }}{{ totalGap | number: '1.2-2' }}</strong>
|
|
||||||
</td>
|
|
||||||
<td class="ta-r" [ngClass]="totalGap >= 0 ? 'pos' : 'neg'">
|
|
||||||
<strong>{{ totalGapPct >= 0 ? '+' : '' }}{{ totalGapPct.toFixed(2) }}%</strong>
|
|
||||||
</td>
|
|
||||||
<td></td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Cumulative table -->
|
|
||||||
<h4 class="form-iii-t">Cumulative Position</h4>
|
|
||||||
<div class="alm-tbl-wrap">
|
|
||||||
<table class="alm-tbl">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Through Bucket</th>
|
|
||||||
<th class="ta-r">Cum. Outflow</th>
|
|
||||||
<th class="ta-r">Cum. Inflow</th>
|
|
||||||
<th class="ta-r">Cum. Gap</th>
|
|
||||||
<th class="ta-r">Cum. Gap %</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr *ngFor="let b of buckets; let i = index">
|
|
||||||
<td>{{ b.code }} — {{ b.label }}</td>
|
|
||||||
<td class="ta-r">{{ cumulativeOutflow(i) | number: '1.2-2' }}</td>
|
|
||||||
<td class="ta-r">{{ cumulativeInflow(i) | number: '1.2-2' }}</td>
|
|
||||||
<td class="ta-r" [ngClass]="cumulativeGap(i) >= 0 ? 'pos' : 'neg'">
|
|
||||||
{{ cumulativeGap(i) >= 0 ? '+' : '' }}{{ cumulativeGap(i) | number: '1.2-2' }}
|
|
||||||
</td>
|
|
||||||
<td class="ta-r" [ngClass]="cumulativeGap(i) >= 0 ? 'pos' : 'neg'">
|
|
||||||
<strong>{{ cumulativeGapPct(i) >= 0 ? '+' : '' }}{{ cumulativeGapPct(i).toFixed(2) }}%</strong>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<!-- ════════════ MONTHLY ════════════ -->
|
|
||||||
<ng-container *ngIf="activeTab === 'monthly'">
|
|
||||||
<div class="alm-section">
|
|
||||||
<div class="alm-sec-h">
|
|
||||||
<h3 class="alm-sec-t">Monthly Asset / Liability Snapshot — FY {{ bank.fiscalYear }}</h3>
|
|
||||||
<button mat-stroked-button class="alm-btn-xls" (click)="exportMonthlyXls()">
|
|
||||||
<fa-icon icon="file-excel"></fa-icon> Export Excel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="alm-tbl-wrap">
|
|
||||||
<table class="alm-tbl">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th rowspan="2">Month</th>
|
|
||||||
<th colspan="4" class="ta-c grp-liab">Liabilities (₹ Lakh)</th>
|
|
||||||
<th colspan="4" class="ta-c grp-asset">Assets (₹ Lakh)</th>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th class="ta-r">Reserve/Prov</th>
|
|
||||||
<th class="ta-r">Borrowings</th>
|
|
||||||
<th class="ta-r">FDs</th>
|
|
||||||
<th class="ta-r">Total Liab</th>
|
|
||||||
<th class="ta-r">Cash</th>
|
|
||||||
<th class="ta-r">Investments</th>
|
|
||||||
<th class="ta-r">Other Assets</th>
|
|
||||||
<th class="ta-r">Total Assets</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr *ngFor="let m of monthly">
|
|
||||||
<td class="mono">{{ m.month }}</td>
|
|
||||||
<td class="ta-r">{{ m.reserveProv | number: '1.2-2' }}</td>
|
|
||||||
<td class="ta-r">{{ m.borrowings | number: '1.2-2' }}</td>
|
|
||||||
<td class="ta-r">{{ m.fds | number: '1.2-2' }}</td>
|
|
||||||
<td class="ta-r">
|
|
||||||
<strong>{{ monthTotalLiab(m) | number: '1.2-2' }}</strong>
|
|
||||||
</td>
|
|
||||||
<td class="ta-r">{{ m.cashOnHand | number: '1.2-2' }}</td>
|
|
||||||
<td class="ta-r">{{ m.investments | number: '1.2-2' }}</td>
|
|
||||||
<td class="ta-r">{{ m.otherAssets | number: '1.2-2' }}</td>
|
|
||||||
<td class="ta-r">
|
|
||||||
<strong>{{ monthTotalAssets(m) | number: '1.2-2' }}</strong>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr class="row-total">
|
|
||||||
<td><strong>AVERAGE</strong></td>
|
|
||||||
<td class="ta-r">{{ avg('reserveProv') | number: '1.2-2' }}</td>
|
|
||||||
<td class="ta-r">{{ avg('borrowings') | number: '1.2-2' }}</td>
|
|
||||||
<td class="ta-r">{{ avg('fds') | number: '1.2-2' }}</td>
|
|
||||||
<td class="ta-r">
|
|
||||||
<strong>{{ avg('reserveProv') + avg('borrowings') + avg('fds') | number: '1.2-2' }}</strong>
|
|
||||||
</td>
|
|
||||||
<td class="ta-r">{{ avg('cashOnHand') | number: '1.2-2' }}</td>
|
|
||||||
<td class="ta-r">{{ avg('investments') | number: '1.2-2' }}</td>
|
|
||||||
<td class="ta-r">{{ avg('otherAssets') | number: '1.2-2' }}</td>
|
|
||||||
<td class="ta-r">
|
|
||||||
<strong>{{ avg('cashOnHand') + avg('investments') + avg('otherAssets') | number: '1.2-2' }}</strong>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<!-- ════════════ AVERAGE LIABILITIES AND ASSETS ════════════ -->
|
|
||||||
<ng-container *ngIf="activeTab === 'avgbal'">
|
|
||||||
<div class="alm-section">
|
|
||||||
<div class="alm-sec-h">
|
|
||||||
<h3 class="alm-sec-t">Average Liabilities and Assets Month-wise during the year</h3>
|
|
||||||
<button mat-stroked-button class="alm-btn-xls" (click)="exportAvgBalancesXls()">
|
|
||||||
<fa-icon icon="file-excel"></fa-icon> Export Excel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Printed-statement banner: institution and office come off the session, not the report. -->
|
|
||||||
<div class="alm-stmt">
|
|
||||||
<div class="alm-stmt-bank">{{ statementHeading }}</div>
|
|
||||||
<div class="alm-stmt-title">Average Liabilities and Assets Month-wise during the year</div>
|
|
||||||
|
|
||||||
<div class="alm-tbl-wrap">
|
|
||||||
<table class="alm-tbl alm-stmt-tbl">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th rowspan="2">Month</th>
|
|
||||||
<th rowspan="2" class="ta-r">Capital</th>
|
|
||||||
<th rowspan="2" class="ta-r">Reserve/Prov<br />Exclude OIR</th>
|
|
||||||
<th rowspan="2" class="ta-r">Deposits</th>
|
|
||||||
<th rowspan="2" class="ta-r">Borrowings</th>
|
|
||||||
<th rowspan="2" class="ta-r">Other Liabilities</th>
|
|
||||||
<th rowspan="2" class="ta-r">Cash on Hand</th>
|
|
||||||
<th colspan="2" class="ta-c">Bank Balances</th>
|
|
||||||
<th rowspan="2" class="ta-r">Investments<br />including</th>
|
|
||||||
<th rowspan="2" class="ta-r">Advances</th>
|
|
||||||
<th rowspan="2" class="ta-r">Other Assets<br />Exclude IRA</th>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th class="ta-r">Current</th>
|
|
||||||
<th class="ta-r">FDs</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr *ngFor="let r of avgBalanceRows">
|
|
||||||
<td class="mono">
|
|
||||||
<strong>{{ r.label }}</strong
|
|
||||||
><br /><span class="alm-stmt-sub">{{ r.sub }}</span>
|
|
||||||
</td>
|
|
||||||
<td class="ta-r">{{ r.capital | number: '1.2-2' }}</td>
|
|
||||||
<td class="ta-r">{{ r.reserveProv | number: '1.2-2' }}</td>
|
|
||||||
<td class="ta-r">{{ r.deposits | number: '1.2-2' }}</td>
|
|
||||||
<td class="ta-r">{{ r.borrowings | number: '1.2-2' }}</td>
|
|
||||||
<td class="ta-r">{{ r.otherLiabilities | number: '1.2-2' }}</td>
|
|
||||||
<td class="ta-r">{{ r.cashOnHand | number: '1.2-2' }}</td>
|
|
||||||
<td class="ta-r">{{ r.bankCurrent | number: '1.2-2' }}</td>
|
|
||||||
<td class="ta-r">{{ r.bankFds | number: '1.2-2' }}</td>
|
|
||||||
<td class="ta-r">{{ r.investments | number: '1.2-2' }}</td>
|
|
||||||
<td class="ta-r">{{ r.advances | number: '1.2-2' }}</td>
|
|
||||||
<td class="ta-r">{{ r.otherAssets | number: '1.2-2' }}</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
<tr class="row-total" *ngFor="let r of [avgBalanceTotal, avgBalanceAverage]">
|
|
||||||
<td>{{ r.label }}</td>
|
|
||||||
<td class="ta-r">{{ r.capital | number: '1.2-2' }}</td>
|
|
||||||
<td class="ta-r">{{ r.reserveProv | number: '1.2-2' }}</td>
|
|
||||||
<td class="ta-r">{{ r.deposits | number: '1.2-2' }}</td>
|
|
||||||
<td class="ta-r">{{ r.borrowings | number: '1.2-2' }}</td>
|
|
||||||
<td class="ta-r">{{ r.otherLiabilities | number: '1.2-2' }}</td>
|
|
||||||
<td class="ta-r">{{ r.cashOnHand | number: '1.2-2' }}</td>
|
|
||||||
<td class="ta-r">{{ r.bankCurrent | number: '1.2-2' }}</td>
|
|
||||||
<td class="ta-r">{{ r.bankFds | number: '1.2-2' }}</td>
|
|
||||||
<td class="ta-r">{{ r.investments | number: '1.2-2' }}</td>
|
|
||||||
<td class="ta-r">{{ r.advances | number: '1.2-2' }}</td>
|
|
||||||
<td class="ta-r">{{ r.otherAssets | number: '1.2-2' }}</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="alm-stmt-foot">
|
|
||||||
<div>Print Date : {{ statementPrintedAt }}</div>
|
|
||||||
<div>Report Ref No : {{ bank.reportRefNo }} User Code:{{ statementUserCode }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<!-- ════════════ RETURNS / FORM ALM-1 ════════════ -->
|
|
||||||
<ng-container *ngIf="activeTab === 'returns'">
|
|
||||||
<div class="alm-section">
|
|
||||||
<div class="alm-sec-h">
|
|
||||||
<h3 class="alm-sec-t">{{ bank.formCode }} — Statement of Structural Liquidity</h3>
|
|
||||||
<button mat-stroked-button class="alm-btn-xls" (click)="exportFormAlm()">
|
|
||||||
<fa-icon icon="file-excel"></fa-icon> Generate {{ bank.formCode }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-iii">
|
|
||||||
<div class="form-iii-h">
|
|
||||||
<div class="form-iii-bank">
|
|
||||||
<strong>{{ bank.name }}</strong> — {{ bank.branch }}
|
|
||||||
</div>
|
|
||||||
<div class="form-iii-meta">
|
|
||||||
<span><fa-icon icon="calendar"></fa-icon> As On {{ bank.asOnDate }}</span>
|
|
||||||
<span><fa-icon icon="university"></fa-icon> {{ bank.quarter }}</span>
|
|
||||||
<span class="mono"><fa-icon icon="hashtag"></fa-icon> Ref: {{ bank.reportRefNo }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h4 class="form-iii-t">A. NDTL & Reference</h4>
|
|
||||||
<table class="alm-tbl form-tbl">
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td>Net Demand & Time Liabilities (NDTL) (₹ Lakhs)</td>
|
|
||||||
<td class="ta-r">
|
|
||||||
<strong>{{ bank.ndtl | number: '1.2-2' }}</strong>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>RBI Master Direction</td>
|
|
||||||
<td class="ta-r mono">{{ bank.rbiCircular }}</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<h4 class="form-iii-t">B. Maturity Ladder (₹ in Lakhs)</h4>
|
|
||||||
<table class="alm-tbl form-tbl">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Bucket</th>
|
|
||||||
<th>Time Period</th>
|
|
||||||
<th class="ta-r">Outflows</th>
|
|
||||||
<th class="ta-r">Inflows</th>
|
|
||||||
<th class="ta-r">Mismatch</th>
|
|
||||||
<th class="ta-r">Cum. Mismatch</th>
|
|
||||||
<th class="ta-r">Cum. % of Outflow</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr *ngFor="let b of buckets; let i = index">
|
|
||||||
<td class="mono">{{ b.code }}</td>
|
|
||||||
<td>{{ b.label }}</td>
|
|
||||||
<td class="ta-r">{{ b.outflow | number: '1.2-2' }}</td>
|
|
||||||
<td class="ta-r">{{ b.inflow | number: '1.2-2' }}</td>
|
|
||||||
<td class="ta-r" [ngClass]="bucketGap(b) >= 0 ? 'pos' : 'neg'">
|
|
||||||
{{ bucketGap(b) >= 0 ? '+' : '' }}{{ bucketGap(b) | number: '1.2-2' }}
|
|
||||||
</td>
|
|
||||||
<td class="ta-r" [ngClass]="cumulativeGap(i) >= 0 ? 'pos' : 'neg'">
|
|
||||||
{{ cumulativeGap(i) >= 0 ? '+' : '' }}{{ cumulativeGap(i) | number: '1.2-2' }}
|
|
||||||
</td>
|
|
||||||
<td class="ta-r">
|
|
||||||
<strong [ngClass]="cumulativeGap(i) >= 0 ? 'pos' : 'neg'"
|
|
||||||
>{{ cumulativeGapPct(i) >= 0 ? '+' : '' }}{{ cumulativeGapPct(i).toFixed(2) }}%</strong
|
|
||||||
>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr class="form-total">
|
|
||||||
<td colspan="2"><strong>TOTAL</strong></td>
|
|
||||||
<td class="ta-r">
|
|
||||||
<strong>{{ totalOutflow | number: '1.2-2' }}</strong>
|
|
||||||
</td>
|
|
||||||
<td class="ta-r">
|
|
||||||
<strong>{{ totalInflow | number: '1.2-2' }}</strong>
|
|
||||||
</td>
|
|
||||||
<td class="ta-r" [ngClass]="totalGap >= 0 ? 'pos' : 'neg'">
|
|
||||||
<strong>{{ totalGap >= 0 ? '+' : '' }}{{ totalGap | number: '1.2-2' }}</strong>
|
|
||||||
</td>
|
|
||||||
<td colspan="2"></td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<h4 class="form-iii-t">C. RBI Tolerance Compliance</h4>
|
|
||||||
<table class="alm-tbl form-tbl">
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td>Cumulative Negative Gap up to 14 days</td>
|
|
||||||
<td class="ta-r">
|
|
||||||
<strong>{{ gap14dPct.toFixed(2) }}%</strong>
|
|
||||||
</td>
|
|
||||||
<td class="ta-r">Limit: -{{ bank.tolerance14d }}%</td>
|
|
||||||
<td class="ta-c">
|
|
||||||
<span class="status-pill" [ngClass]="gap14dWithinTolerance ? 'on-target' : 'below'">
|
|
||||||
{{ gap14dWithinTolerance ? 'WITHIN' : 'BREACH' }}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>Cumulative Negative Gap up to 30 days</td>
|
|
||||||
<td class="ta-r">
|
|
||||||
<strong>{{ gap28dPct.toFixed(2) }}%</strong>
|
|
||||||
</td>
|
|
||||||
<td class="ta-r">Limit: -{{ bank.tolerance28d }}%</td>
|
|
||||||
<td class="ta-c">
|
|
||||||
<span class="status-pill" [ngClass]="gap28dWithinTolerance ? 'on-target' : 'below'">
|
|
||||||
{{ gap28dWithinTolerance ? 'WITHIN' : 'BREACH' }}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<div class="form-iii-foot">
|
|
||||||
<div class="cert">
|
|
||||||
<div class="cert-l">Certified by</div>
|
|
||||||
<div class="cert-v">{{ bank.principalOfficer }}</div>
|
|
||||||
<div class="cert-r">Principal Officer — {{ bank.name }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="cert">
|
|
||||||
<div class="cert-l">Submitted under</div>
|
|
||||||
<div class="cert-v mono">{{ bank.rbiCircular }}</div>
|
|
||||||
<div class="cert-r">RBI Master Direction on Asset Liability Management</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ng-container>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,7 @@
|
|||||||
* 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, inject } from '@angular/core';
|
import { Component, ElementRef, ViewChild, inject } from '@angular/core';
|
||||||
import { CommonModule, DecimalPipe } from '@angular/common';
|
import { CommonModule, DecimalPipe } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { MatDialogRef, MatDialogModule } from '@angular/material/dialog';
|
import { MatDialogRef, MatDialogModule } from '@angular/material/dialog';
|
||||||
@@ -16,42 +16,34 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
|||||||
import { BranchScopeService } from 'app/core/branch-scope.service';
|
import { BranchScopeService } from 'app/core/branch-scope.service';
|
||||||
import { AuthenticationService } from 'app/core/authentication/authentication.service';
|
import { AuthenticationService } from 'app/core/authentication/authentication.service';
|
||||||
|
|
||||||
interface MaturityBucket {
|
/**
|
||||||
code: string;
|
* One month of the average liabilities and assets extract, column for column as the core system
|
||||||
label: string;
|
* prints it.
|
||||||
/** Outflows / Liabilities (₹ Lakhs) */
|
*/
|
||||||
outflow: number;
|
interface MonthlyAlmRow {
|
||||||
/** Inflows / Assets (₹ Lakhs) */
|
/** e.g. 'APR-2025' */
|
||||||
inflow: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MonthSnapshot {
|
|
||||||
month: string;
|
month: string;
|
||||||
reserveProv: number;
|
|
||||||
borrowings: number;
|
|
||||||
fds: number;
|
|
||||||
cashOnHand: number;
|
|
||||||
investments: number;
|
|
||||||
otherAssets: number;
|
|
||||||
// ── Columns the month-wise average statement adds to the snapshot above ──
|
|
||||||
// The statement is a full balance sheet averaged over each month, so it needs the own-funds
|
|
||||||
// and deposit sides the liquidity tabs do not use, and it splits bank balances from cash.
|
|
||||||
/** Paid-up share capital. */
|
/** Paid-up share capital. */
|
||||||
capital: number;
|
capital: number;
|
||||||
/** Total deposits — the FD component above is part of this figure. */
|
reserveProv: number;
|
||||||
|
/** Total deposits — demand and time. */
|
||||||
deposits: number;
|
deposits: number;
|
||||||
|
borrowings: number;
|
||||||
otherLiabilities: number;
|
otherLiabilities: number;
|
||||||
|
cashOnHand: number;
|
||||||
/** Balances with other banks, current accounts. */
|
/** Balances with other banks, current accounts. */
|
||||||
bankCurrent: number;
|
bankCurrent: number;
|
||||||
/** Balances with other banks, held as fixed deposits. */
|
/** Balances with other banks, held as fixed deposits. */
|
||||||
bankFds: number;
|
bankFds: number;
|
||||||
|
investments: number;
|
||||||
advances: number;
|
advances: number;
|
||||||
|
otherAssets: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One row of the month-wise average statement — a month, or the Total / Average line. */
|
/** One printed line — a month, or the Total / Average line the statement closes with. */
|
||||||
interface AvgBalanceRow {
|
interface StatementRow {
|
||||||
label: string;
|
label: string;
|
||||||
/** Sub-label under the month, e.g. the year, as the printed statement prints it. */
|
/** Sub-label under the month, the year, as the printed statement sets it. */
|
||||||
sub: string;
|
sub: string;
|
||||||
capital: number;
|
capital: number;
|
||||||
reserveProv: number;
|
reserveProv: number;
|
||||||
@@ -66,6 +58,18 @@ interface AvgBalanceRow {
|
|||||||
otherAssets: number;
|
otherAssets: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Average Liabilities and Assets Month-wise during the year.
|
||||||
|
*
|
||||||
|
* One report, one statement, the way the core system prints it — a parameter line, the statement,
|
||||||
|
* and the print footer. It is deliberately not a dashboard: an ALM screen in a bank is a report
|
||||||
|
* you run and print, not a set of tabs, and figures that go out over the bank's name have to look
|
||||||
|
* the same on screen as they do on paper.
|
||||||
|
*
|
||||||
|
* Month rows are the figures as extracted. Investments, Advances, Bank-FD balances and Other
|
||||||
|
* Assets are not mapped in the extract and print as 0.00 — stated on screen rather than filled in,
|
||||||
|
* because a nil that is really "not captured" must not read as a real balance.
|
||||||
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'mifosx-alm-report-dialog',
|
selector: 'mifosx-alm-report-dialog',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
@@ -86,310 +90,226 @@ export class AlmReportDialogComponent {
|
|||||||
private branchScope = inject(BranchScopeService);
|
private branchScope = inject(BranchScopeService);
|
||||||
private auth = inject(AuthenticationService);
|
private auth = inject(AuthenticationService);
|
||||||
|
|
||||||
activeTab: 'dashboard' | 'ladder' | 'monthly' | 'avgbal' | 'returns' = 'dashboard';
|
/** The rendered statement, handed to the print window as-is. */
|
||||||
|
@ViewChild('statement') private statementRef?: ElementRef<HTMLElement>;
|
||||||
|
|
||||||
bank = {
|
readonly bank = {
|
||||||
name: 'CBSNexora Bank Ltd',
|
name: 'CBSNexora Bank Ltd',
|
||||||
branch: 'Head Office',
|
financialYear: '2025-26',
|
||||||
asOnDate: '31-03-2026',
|
|
||||||
quarter: 'Q4 FY 2025-26',
|
|
||||||
fiscalYear: '2025-26',
|
|
||||||
rbiCircular: 'DCBR.BPD.(PCB).Cir.No.4/12.05.001/2018-19',
|
|
||||||
principalOfficer: 'Karthik Kamath',
|
|
||||||
formCode: 'Form ALM-1',
|
|
||||||
reportRefNo: '1036',
|
reportRefNo: '1036',
|
||||||
userCode: 'CBSN01',
|
userCode: 'CBSN01'
|
||||||
/** Net Demand & Time Liabilities — denominator for ratios */
|
|
||||||
ndtl: 740000, // ₹7,400 Cr in Lakhs
|
|
||||||
/** RBI tolerance: cumulative negative gap up to 14 days ≤ 5%, 28 days ≤ 10% */
|
|
||||||
tolerance14d: 5,
|
|
||||||
tolerance28d: 10
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Standard RBI ALM time buckets per Master Direction */
|
/**
|
||||||
buckets: MaturityBucket[] = [
|
* The financial year, April to March. A part-year print simply stops at the month asked for, which
|
||||||
{ code: 'B01', label: 'Day 1', outflow: 1840, inflow: 2120 },
|
* is why `upToIndex` exists rather than the row list being cut to size here.
|
||||||
{ code: 'B02', label: '2 to 7 days', outflow: 6420, inflow: 5880 },
|
*/
|
||||||
{ code: 'B03', label: '8 to 14 days', outflow: 4180, inflow: 4360 },
|
monthly: MonthlyAlmRow[] = [
|
||||||
{ code: 'B04', label: '15 to 30 days', outflow: 8240, inflow: 7920 },
|
|
||||||
{ code: 'B05', label: '31 days to 2 months', outflow: 12480, inflow: 13160 },
|
|
||||||
{ code: 'B06', label: '> 2 months to 3 months', outflow: 9620, inflow: 10240 },
|
|
||||||
{ code: 'B07', label: '> 3 months to 6 months', outflow: 28640, inflow: 32180 },
|
|
||||||
{ code: 'B08', label: '> 6 months to 1 year', outflow: 64280, inflow: 68420 },
|
|
||||||
{ code: 'B09', label: '> 1 year to 3 years', outflow: 142800, inflow: 156400 },
|
|
||||||
{ code: 'B10', label: '> 3 years to 5 years', outflow: 96400, inflow: 108200 },
|
|
||||||
{ code: 'B11', label: 'Over 5 years', outflow: 124800, inflow: 138400 }
|
|
||||||
];
|
|
||||||
|
|
||||||
/** Monthly snapshot — mirrors the format of the legacy ALM Excel */
|
|
||||||
monthly: MonthSnapshot[] = [
|
|
||||||
{
|
{
|
||||||
month: 'APR-2025',
|
month: 'APR-2025',
|
||||||
reserveProv: 18420,
|
capital: 264.66,
|
||||||
borrowings: 24180,
|
reserveProv: 720.83,
|
||||||
fds: 462800,
|
deposits: 6108.63,
|
||||||
cashOnHand: 8420,
|
borrowings: 24.47,
|
||||||
investments: 184600,
|
otherLiabilities: 280.91,
|
||||||
otherAssets: 12480,
|
cashOnHand: 172.44,
|
||||||
capital: 24200,
|
bankCurrent: 190.09,
|
||||||
deposits: 602800,
|
bankFds: 0,
|
||||||
otherLiabilities: 28400,
|
investments: 0,
|
||||||
bankCurrent: 18900,
|
advances: 0,
|
||||||
bankFds: 46200,
|
otherAssets: 0
|
||||||
advances: 372400
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
month: 'MAY-2025',
|
month: 'MAY-2025',
|
||||||
reserveProv: 18640,
|
capital: 270.73,
|
||||||
borrowings: 23980,
|
reserveProv: 722.11,
|
||||||
fds: 468200,
|
deposits: 6181.15,
|
||||||
cashOnHand: 8680,
|
borrowings: 26.36,
|
||||||
investments: 186400,
|
otherLiabilities: 280.91,
|
||||||
otherAssets: 12620,
|
cashOnHand: 129.0,
|
||||||
capital: 24280,
|
bankCurrent: 218.27,
|
||||||
deposits: 608600,
|
bankFds: 0,
|
||||||
otherLiabilities: 28560,
|
investments: 0,
|
||||||
bankCurrent: 19240,
|
advances: 0,
|
||||||
bankFds: 46600,
|
otherAssets: 0
|
||||||
advances: 376800
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
month: 'JUN-2025',
|
month: 'JUN-2025',
|
||||||
reserveProv: 18820,
|
capital: 272.61,
|
||||||
borrowings: 23640,
|
reserveProv: 722.11,
|
||||||
fds: 472400,
|
deposits: 6291.01,
|
||||||
cashOnHand: 9120,
|
borrowings: 33.0,
|
||||||
investments: 188200,
|
otherLiabilities: 280.91,
|
||||||
otherAssets: 12840,
|
cashOnHand: 115.01,
|
||||||
capital: 24360,
|
bankCurrent: 322.77,
|
||||||
deposits: 613400,
|
bankFds: 0,
|
||||||
otherLiabilities: 28720,
|
investments: 0,
|
||||||
bankCurrent: 19580,
|
advances: 0,
|
||||||
bankFds: 47000,
|
otherAssets: 0
|
||||||
advances: 381200
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
month: 'JUL-2025',
|
month: 'JUL-2025',
|
||||||
reserveProv: 19080,
|
capital: 274.06,
|
||||||
borrowings: 23420,
|
reserveProv: 755.72,
|
||||||
fds: 478600,
|
deposits: 6299.45,
|
||||||
cashOnHand: 9280,
|
borrowings: 50.07,
|
||||||
investments: 190400,
|
otherLiabilities: 280.91,
|
||||||
otherAssets: 13020,
|
cashOnHand: 84.82,
|
||||||
capital: 24460,
|
bankCurrent: 267.13,
|
||||||
deposits: 619800,
|
bankFds: 0,
|
||||||
otherLiabilities: 28880,
|
investments: 0,
|
||||||
bankCurrent: 19860,
|
advances: 0,
|
||||||
bankFds: 47400,
|
otherAssets: 0
|
||||||
advances: 385600
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
month: 'AUG-2025',
|
month: 'AUG-2025',
|
||||||
reserveProv: 19280,
|
capital: 273.22,
|
||||||
borrowings: 23180,
|
reserveProv: 755.72,
|
||||||
fds: 482400,
|
deposits: 6404.97,
|
||||||
cashOnHand: 9420,
|
borrowings: 26.53,
|
||||||
investments: 192800,
|
otherLiabilities: 280.91,
|
||||||
otherAssets: 13280,
|
cashOnHand: 111.77,
|
||||||
capital: 24540,
|
bankCurrent: 253.24,
|
||||||
deposits: 624200,
|
bankFds: 0,
|
||||||
otherLiabilities: 29040,
|
investments: 0,
|
||||||
bankCurrent: 20180,
|
advances: 0,
|
||||||
bankFds: 47800,
|
otherAssets: 0
|
||||||
advances: 390200
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
month: 'SEP-2025',
|
month: 'SEP-2025',
|
||||||
reserveProv: 19460,
|
capital: 275.34,
|
||||||
borrowings: 22940,
|
reserveProv: 755.72,
|
||||||
fds: 488200,
|
deposits: 6361.83,
|
||||||
cashOnHand: 9640,
|
borrowings: 30.3,
|
||||||
investments: 195200,
|
otherLiabilities: 280.91,
|
||||||
otherAssets: 13420,
|
cashOnHand: 136.45,
|
||||||
capital: 24620,
|
bankCurrent: 190.83,
|
||||||
deposits: 630400,
|
bankFds: 0,
|
||||||
otherLiabilities: 29200,
|
investments: 0,
|
||||||
bankCurrent: 20460,
|
advances: 0,
|
||||||
bankFds: 48200,
|
otherAssets: 0
|
||||||
advances: 394800
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
month: 'OCT-2025',
|
month: 'OCT-2025',
|
||||||
reserveProv: 19680,
|
capital: 274.82,
|
||||||
borrowings: 22680,
|
reserveProv: 755.72,
|
||||||
fds: 492600,
|
deposits: 6304.25,
|
||||||
cashOnHand: 9820,
|
borrowings: 38.0,
|
||||||
investments: 197600,
|
otherLiabilities: 280.91,
|
||||||
otherAssets: 13620,
|
cashOnHand: 93.77,
|
||||||
capital: 24720,
|
bankCurrent: 239.01,
|
||||||
deposits: 635200,
|
bankFds: 0,
|
||||||
otherLiabilities: 29380,
|
investments: 0,
|
||||||
bankCurrent: 20740,
|
advances: 0,
|
||||||
bankFds: 48600,
|
otherAssets: 0
|
||||||
advances: 399400
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
month: 'NOV-2025',
|
month: 'NOV-2025',
|
||||||
reserveProv: 19880,
|
capital: 278.29,
|
||||||
borrowings: 22420,
|
reserveProv: 755.72,
|
||||||
fds: 498400,
|
deposits: 6269.1,
|
||||||
cashOnHand: 9980,
|
borrowings: 52.64,
|
||||||
investments: 200200,
|
otherLiabilities: 280.91,
|
||||||
otherAssets: 13820,
|
cashOnHand: 92.03,
|
||||||
capital: 24800,
|
bankCurrent: 215.31,
|
||||||
deposits: 641000,
|
bankFds: 0,
|
||||||
otherLiabilities: 29540,
|
investments: 0,
|
||||||
bankCurrent: 21020,
|
advances: 0,
|
||||||
bankFds: 49000,
|
otherAssets: 0
|
||||||
advances: 404200
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
month: 'DEC-2025',
|
month: 'DEC-2025',
|
||||||
reserveProv: 20120,
|
capital: 278.97,
|
||||||
borrowings: 22180,
|
reserveProv: 755.72,
|
||||||
fds: 504200,
|
deposits: 6310.57,
|
||||||
cashOnHand: 10240,
|
borrowings: 50.01,
|
||||||
investments: 202800,
|
otherLiabilities: 280.91,
|
||||||
otherAssets: 14040,
|
cashOnHand: 98.58,
|
||||||
capital: 24900,
|
bankCurrent: 221.93,
|
||||||
deposits: 646800,
|
bankFds: 0,
|
||||||
otherLiabilities: 29720,
|
investments: 0,
|
||||||
bankCurrent: 21340,
|
advances: 0,
|
||||||
bankFds: 49400,
|
otherAssets: 0
|
||||||
advances: 409000
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
month: 'JAN-2026',
|
month: 'JAN-2026',
|
||||||
reserveProv: 20340,
|
capital: 280.14,
|
||||||
borrowings: 21940,
|
reserveProv: 755.72,
|
||||||
fds: 508800,
|
deposits: 6358.2,
|
||||||
cashOnHand: 10420,
|
borrowings: 47.85,
|
||||||
investments: 205200,
|
otherLiabilities: 280.91,
|
||||||
otherAssets: 14240,
|
cashOnHand: 101.36,
|
||||||
capital: 24980,
|
bankCurrent: 228.47,
|
||||||
deposits: 651600,
|
bankFds: 0,
|
||||||
otherLiabilities: 29880,
|
investments: 0,
|
||||||
bankCurrent: 21620,
|
advances: 0,
|
||||||
bankFds: 49800,
|
otherAssets: 0
|
||||||
advances: 413800
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
month: 'FEB-2026',
|
month: 'FEB-2026',
|
||||||
reserveProv: 20580,
|
capital: 281.42,
|
||||||
borrowings: 21680,
|
reserveProv: 768.4,
|
||||||
fds: 514600,
|
deposits: 6402.75,
|
||||||
cashOnHand: 10620,
|
borrowings: 44.62,
|
||||||
investments: 207800,
|
otherLiabilities: 280.91,
|
||||||
otherAssets: 14460,
|
cashOnHand: 104.19,
|
||||||
capital: 25060,
|
bankCurrent: 233.08,
|
||||||
deposits: 657400,
|
bankFds: 0,
|
||||||
otherLiabilities: 30040,
|
investments: 0,
|
||||||
bankCurrent: 21900,
|
advances: 0,
|
||||||
bankFds: 50200,
|
otherAssets: 0
|
||||||
advances: 418600
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
month: 'MAR-2026',
|
month: 'MAR-2026',
|
||||||
reserveProv: 20840,
|
capital: 283.05,
|
||||||
borrowings: 21420,
|
reserveProv: 768.4,
|
||||||
fds: 520400,
|
deposits: 6471.38,
|
||||||
cashOnHand: 10820,
|
borrowings: 41.3,
|
||||||
investments: 210400,
|
otherLiabilities: 280.91,
|
||||||
otherAssets: 14680,
|
cashOnHand: 110.62,
|
||||||
capital: 25160,
|
bankCurrent: 241.66,
|
||||||
deposits: 663200,
|
bankFds: 0,
|
||||||
otherLiabilities: 30220,
|
investments: 0,
|
||||||
bankCurrent: 22240,
|
advances: 0,
|
||||||
bankFds: 50600,
|
otherAssets: 0
|
||||||
advances: 423600
|
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
filterBucket = '';
|
/**
|
||||||
|
* Last month included in the print. Defaults to the ninth month, DEC-2025, because that is where a
|
||||||
|
* statement taken part-way through the year stops — the months after it have not closed.
|
||||||
|
*/
|
||||||
|
upToIndex = 8;
|
||||||
|
|
||||||
// -------------------- Computed --------------------
|
/** Columns the core-system extract does not map. Named on screen, not quietly filled in. */
|
||||||
bucketGap(b: MaturityBucket): number {
|
readonly unmappedColumns = 'Investments, Advances, Bank-FD balances and Other Assets';
|
||||||
return b.inflow - b.outflow;
|
|
||||||
}
|
|
||||||
bucketGapPct(b: MaturityBucket): number {
|
|
||||||
return b.outflow === 0 ? 0 : +(((b.inflow - b.outflow) / b.outflow) * 100).toFixed(2);
|
|
||||||
}
|
|
||||||
cumulativeOutflow(idx: number): number {
|
|
||||||
return this.buckets.slice(0, idx + 1).reduce((s, b) => s + b.outflow, 0);
|
|
||||||
}
|
|
||||||
cumulativeInflow(idx: number): number {
|
|
||||||
return this.buckets.slice(0, idx + 1).reduce((s, b) => s + b.inflow, 0);
|
|
||||||
}
|
|
||||||
cumulativeGap(idx: number): number {
|
|
||||||
return this.cumulativeInflow(idx) - this.cumulativeOutflow(idx);
|
|
||||||
}
|
|
||||||
cumulativeGapPct(idx: number): number {
|
|
||||||
const out = this.cumulativeOutflow(idx);
|
|
||||||
return out === 0 ? 0 : +((this.cumulativeGap(idx) / out) * 100).toFixed(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
get totalOutflow(): number {
|
/** Stamped when the statement is run, and again whenever the parameters change it. */
|
||||||
return this.buckets.reduce((s, b) => s + b.outflow, 0);
|
generatedAt = this.formatPrintDate(new Date());
|
||||||
}
|
|
||||||
get totalInflow(): number {
|
|
||||||
return this.buckets.reduce((s, b) => s + b.inflow, 0);
|
|
||||||
}
|
|
||||||
get totalGap(): number {
|
|
||||||
return this.totalInflow - this.totalOutflow;
|
|
||||||
}
|
|
||||||
get totalGapPct(): number {
|
|
||||||
return this.totalOutflow === 0 ? 0 : +((this.totalGap / this.totalOutflow) * 100).toFixed(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** RBI tolerance test: cumulative negative gap up to bucket 3 (14 days) ≤ 5% of cumulative outflow */
|
|
||||||
get gap14dPct(): number {
|
|
||||||
return this.cumulativeGapPct(2);
|
|
||||||
}
|
|
||||||
/** Up to bucket 4 (30 days) ≤ 10% */
|
|
||||||
get gap28dPct(): number {
|
|
||||||
return this.cumulativeGapPct(3);
|
|
||||||
}
|
|
||||||
get gap14dWithinTolerance(): boolean {
|
|
||||||
return this.gap14dPct >= -this.bank.tolerance14d;
|
|
||||||
}
|
|
||||||
get gap28dWithinTolerance(): boolean {
|
|
||||||
return this.gap28dPct >= -this.bank.tolerance28d;
|
|
||||||
}
|
|
||||||
|
|
||||||
bucketStatus(b: MaturityBucket): 'surplus' | 'deficit' | 'balanced' {
|
|
||||||
const gap = this.bucketGap(b);
|
|
||||||
if (gap > 0) return 'surplus';
|
|
||||||
if (gap < 0) return 'deficit';
|
|
||||||
return 'balanced';
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Average Liabilities and Assets, month-wise ────────────────────────────
|
|
||||||
//
|
|
||||||
// The printed statement carries the institution and office it was taken for, and who took it.
|
|
||||||
// Those come off the session rather than being typed into the report, so a branch login prints
|
|
||||||
// its own office and its own user code instead of whatever the last person hard-coded.
|
|
||||||
|
|
||||||
/** "<BANK> , <OFFICE>" — the banner line the printed statement opens with. */
|
/** "<BANK> , <OFFICE>" — the banner line the printed statement opens with. */
|
||||||
get statementHeading(): string {
|
get statementHeading(): string {
|
||||||
return `${this.bank.name.toUpperCase()} , ${this.branchScope.officeName.toUpperCase()}`;
|
return `${this.bank.name.toUpperCase()} , ${this.branchScope.officeName.toUpperCase()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Office the statement was taken for, off the session rather than typed into the report. */
|
||||||
|
get branchName(): string {
|
||||||
|
return this.branchScope.officeName;
|
||||||
|
}
|
||||||
|
|
||||||
/** Who ran the report — the login's username, which is what the printed footer calls a code. */
|
/** Who ran the report — the login's username, which is what the printed footer calls a code. */
|
||||||
get statementUserCode(): string {
|
get statementUserCode(): string {
|
||||||
return (this.auth.getCredentials()?.username || this.bank.userCode).toUpperCase();
|
return (this.auth.getCredentials()?.username || this.bank.userCode).toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Stamped when the tab is opened, so the footer records when the figures were taken off. */
|
/** Month choices for the "up to" parameter, e.g. 'DEC-2025'. */
|
||||||
readonly statementPrintedAt = this.formatPrintDate(new Date());
|
get monthOptions(): { index: number; label: string }[] {
|
||||||
|
return this.monthly.map((m, index) => ({ index, label: m.month }));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/** The months the statement prints. */
|
||||||
* Month rows, then the Total and Average lines the statement closes with.
|
get statementRows(): StatementRow[] {
|
||||||
*
|
return this.monthly.slice(0, this.upToIndex + 1).map((m) => {
|
||||||
* Average is the mean of the months shown, not Total ÷ 12 — a statement printed part-way
|
|
||||||
* through the year would otherwise divide by months that have not happened yet.
|
|
||||||
*/
|
|
||||||
get avgBalanceRows(): AvgBalanceRow[] {
|
|
||||||
return this.monthly.map((m) => {
|
|
||||||
const [
|
const [
|
||||||
name,
|
name,
|
||||||
year
|
year
|
||||||
@@ -412,7 +332,7 @@ export class AlmReportDialogComponent {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private readonly avgBalanceColumns: (keyof AvgBalanceRow)[] = [
|
private readonly amountColumns: (keyof StatementRow)[] = [
|
||||||
'capital',
|
'capital',
|
||||||
'reserveProv',
|
'reserveProv',
|
||||||
'deposits',
|
'deposits',
|
||||||
@@ -426,190 +346,92 @@ export class AlmReportDialogComponent {
|
|||||||
'otherAssets'
|
'otherAssets'
|
||||||
];
|
];
|
||||||
|
|
||||||
get avgBalanceTotal(): AvgBalanceRow {
|
get totalRow(): StatementRow {
|
||||||
const rows = this.avgBalanceRows;
|
const rows = this.statementRows;
|
||||||
const out = { label: 'Total', sub: '' } as AvgBalanceRow;
|
const out = { label: 'Total', sub: '' } as StatementRow;
|
||||||
for (const key of this.avgBalanceColumns) {
|
for (const key of this.amountColumns) {
|
||||||
out[key] = rows.reduce((s, r) => s + (r[key] as number), 0) as never;
|
out[key] = +rows.reduce((s, r) => s + (r[key] as number), 0).toFixed(2) as never;
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
get avgBalanceAverage(): AvgBalanceRow {
|
/**
|
||||||
const rows = this.avgBalanceRows;
|
* Mean of the months printed, not Total ÷ 12 — a statement that stops at December must divide by
|
||||||
const total = this.avgBalanceTotal;
|
* nine, or every average on it is understated.
|
||||||
const out = { label: 'Average', sub: '' } as AvgBalanceRow;
|
*/
|
||||||
for (const key of this.avgBalanceColumns) {
|
get averageRow(): StatementRow {
|
||||||
|
const rows = this.statementRows;
|
||||||
|
const total = this.totalRow;
|
||||||
|
const out = { label: 'Average', sub: '' } as StatementRow;
|
||||||
|
for (const key of this.amountColumns) {
|
||||||
out[key] = (rows.length ? +((total[key] as number) / rows.length).toFixed(2) : 0) as never;
|
out[key] = (rows.length ? +((total[key] as number) / rows.length).toFixed(2) : 0) as never;
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** dd/MM/yyyy hh:mm:ssAM — the format the printed statement stamps its footer with. */
|
/** Re-running the statement re-stamps the footer, since the figures on it have changed. */
|
||||||
private formatPrintDate(d: Date): string {
|
runReport(): void {
|
||||||
const pad = (n: number) => String(n).padStart(2, '0');
|
this.generatedAt = this.formatPrintDate(new Date());
|
||||||
const hours24 = d.getHours();
|
|
||||||
const meridiem = hours24 < 12 ? 'AM' : 'PM';
|
|
||||||
const hours12 = hours24 % 12 === 0 ? 12 : hours24 % 12;
|
|
||||||
return (
|
|
||||||
`${pad(d.getDate())}/${pad(d.getMonth() + 1)}/${d.getFullYear()} ` +
|
|
||||||
`${pad(hours12)}:${pad(d.getMinutes())}:${pad(d.getSeconds())}${meridiem}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Monthly totals helpers */
|
/**
|
||||||
monthTotalLiab(m: MonthSnapshot): number {
|
* Prints the statement on its own.
|
||||||
return m.reserveProv + m.borrowings + m.fds;
|
*
|
||||||
}
|
* The dialog sits in an overlay on top of the application, so `window.print()` would put the whole
|
||||||
monthTotalAssets(m: MonthSnapshot): number {
|
* screen behind it on the paper. The rendered statement is handed to a window of its own instead,
|
||||||
return m.cashOnHand + m.investments + m.otherAssets;
|
* which needs no application-wide print stylesheet and cannot pick up anything else on the page.
|
||||||
}
|
*/
|
||||||
/** Average across months */
|
print(): void {
|
||||||
avg<K extends keyof MonthSnapshot>(key: K): number {
|
const node = this.statementRef?.nativeElement;
|
||||||
if (typeof this.monthly[0][key] !== 'number') return 0;
|
if (!node) {
|
||||||
const total = this.monthly.reduce((s, m) => s + (m[key] as number), 0);
|
return;
|
||||||
return +(total / this.monthly.length).toFixed(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------- Filter --------------------
|
|
||||||
get filteredBuckets(): MaturityBucket[] {
|
|
||||||
if (!this.filterBucket) return this.buckets;
|
|
||||||
if (this.filterBucket === 'short') return this.buckets.slice(0, 4);
|
|
||||||
if (this.filterBucket === 'medium') return this.buckets.slice(4, 8);
|
|
||||||
if (this.filterBucket === 'long') return this.buckets.slice(8);
|
|
||||||
return this.buckets;
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------- Excel Exports --------------------
|
|
||||||
|
|
||||||
/** Form ALM-1 — Statement of Structural Liquidity */
|
|
||||||
exportFormAlm(): void {
|
|
||||||
const rows: string[][] = [];
|
|
||||||
rows.push([
|
|
||||||
this.bank.name,
|
|
||||||
this.bank.branch
|
|
||||||
]);
|
|
||||||
rows.push([
|
|
||||||
`STATEMENT OF STRUCTURAL LIQUIDITY (${this.bank.formCode})`,
|
|
||||||
`As On ${this.bank.asOnDate}`
|
|
||||||
]);
|
|
||||||
rows.push([
|
|
||||||
`RBI Circular: ${this.bank.rbiCircular}`,
|
|
||||||
`Quarter: ${this.bank.quarter}`
|
|
||||||
]);
|
|
||||||
rows.push([
|
|
||||||
`Report Ref: ${this.bank.reportRefNo}`,
|
|
||||||
`User Code: ${this.bank.userCode}`
|
|
||||||
]);
|
|
||||||
rows.push([]);
|
|
||||||
rows.push([
|
|
||||||
'NDTL (₹ Lakhs)',
|
|
||||||
this.bank.ndtl.toString()
|
|
||||||
]);
|
|
||||||
rows.push([]);
|
|
||||||
rows.push(['Maturity Ladder (₹ in Lakhs)']);
|
|
||||||
rows.push([
|
|
||||||
'Bucket',
|
|
||||||
'Time Period',
|
|
||||||
'Outflows (Liabilities)',
|
|
||||||
'Inflows (Assets)',
|
|
||||||
'Mismatch (Gap)',
|
|
||||||
'Cum. Outflows',
|
|
||||||
'Cum. Inflows',
|
|
||||||
'Cum. Gap',
|
|
||||||
'Cum. Gap % of Outflow'
|
|
||||||
]);
|
|
||||||
this.buckets.forEach((b, i) => {
|
|
||||||
rows.push([
|
|
||||||
b.code,
|
|
||||||
b.label,
|
|
||||||
b.outflow.toFixed(2),
|
|
||||||
b.inflow.toFixed(2),
|
|
||||||
this.bucketGap(b).toFixed(2),
|
|
||||||
this.cumulativeOutflow(i).toFixed(2),
|
|
||||||
this.cumulativeInflow(i).toFixed(2),
|
|
||||||
this.cumulativeGap(i).toFixed(2),
|
|
||||||
this.cumulativeGapPct(i).toFixed(2) + '%'
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
rows.push([]);
|
|
||||||
rows.push([
|
|
||||||
'TOTAL',
|
|
||||||
'',
|
|
||||||
this.totalOutflow.toFixed(2),
|
|
||||||
this.totalInflow.toFixed(2),
|
|
||||||
this.totalGap.toFixed(2)
|
|
||||||
]);
|
|
||||||
rows.push([]);
|
|
||||||
rows.push(['RBI Tolerance Compliance']);
|
|
||||||
rows.push([
|
|
||||||
'Cumulative Gap up to 14 days',
|
|
||||||
this.gap14dPct.toFixed(2) + '%',
|
|
||||||
`Limit: -${this.bank.tolerance14d}%`,
|
|
||||||
this.gap14dWithinTolerance ? 'WITHIN' : 'BREACH'
|
|
||||||
]);
|
|
||||||
rows.push([
|
|
||||||
'Cumulative Gap up to 30 days',
|
|
||||||
this.gap28dPct.toFixed(2) + '%',
|
|
||||||
`Limit: -${this.bank.tolerance28d}%`,
|
|
||||||
this.gap28dWithinTolerance ? 'WITHIN' : 'BREACH'
|
|
||||||
]);
|
|
||||||
rows.push([]);
|
|
||||||
rows.push([
|
|
||||||
'Certified by',
|
|
||||||
this.bank.principalOfficer + ' — Principal Officer'
|
|
||||||
]);
|
|
||||||
this.saveTsv(rows, `ALM-StructuralLiquidity-${this.dateTag()}.xls`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Monthly snapshot export */
|
|
||||||
exportMonthlyXls(): void {
|
|
||||||
const rows: string[][] = [];
|
|
||||||
rows.push([
|
|
||||||
`${this.bank.name} — ${this.bank.branch}`,
|
|
||||||
`Monthly ALM Snapshot — FY ${this.bank.fiscalYear}`
|
|
||||||
]);
|
|
||||||
rows.push([]);
|
|
||||||
rows.push([
|
|
||||||
'Month',
|
|
||||||
'Reserve/Prov (Excl. OIR)',
|
|
||||||
'Borrowings',
|
|
||||||
'FDs',
|
|
||||||
'Total Liabilities',
|
|
||||||
'Cash on Hand',
|
|
||||||
'Investments incl. Bal/Repo',
|
|
||||||
'Other Assets (Excl. IRA)',
|
|
||||||
'Total Assets'
|
|
||||||
]);
|
|
||||||
for (const m of this.monthly) {
|
|
||||||
rows.push([
|
|
||||||
m.month,
|
|
||||||
m.reserveProv.toFixed(2),
|
|
||||||
m.borrowings.toFixed(2),
|
|
||||||
m.fds.toFixed(2),
|
|
||||||
this.monthTotalLiab(m).toFixed(2),
|
|
||||||
m.cashOnHand.toFixed(2),
|
|
||||||
m.investments.toFixed(2),
|
|
||||||
m.otherAssets.toFixed(2),
|
|
||||||
this.monthTotalAssets(m).toFixed(2)
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
rows.push([
|
const win = window.open('', '_blank', 'width=1100,height=760');
|
||||||
'AVERAGE',
|
if (!win) {
|
||||||
this.avg('reserveProv').toFixed(2),
|
// Blocked by the browser — fall back to the page print, which the print stylesheet handles.
|
||||||
this.avg('borrowings').toFixed(2),
|
window.print();
|
||||||
this.avg('fds').toFixed(2),
|
return;
|
||||||
(this.avg('reserveProv') + this.avg('borrowings') + this.avg('fds')).toFixed(2),
|
}
|
||||||
this.avg('cashOnHand').toFixed(2),
|
win.document.write(
|
||||||
this.avg('investments').toFixed(2),
|
`<!doctype html><html><head><title>Average Liabilities and Assets — ${this.bank.financialYear}</title>` +
|
||||||
this.avg('otherAssets').toFixed(2),
|
`<style>${AlmReportDialogComponent.PRINT_CSS}</style></head><body>${node.outerHTML}</body></html>`
|
||||||
(this.avg('cashOnHand') + this.avg('investments') + this.avg('otherAssets')).toFixed(2)
|
);
|
||||||
]);
|
win.document.close();
|
||||||
this.saveTsv(rows, `ALM-Monthly-${this.dateTag()}.xls`);
|
win.focus();
|
||||||
|
// Let the document lay out before the print dialog measures it.
|
||||||
|
win.setTimeout(() => {
|
||||||
|
win.print();
|
||||||
|
win.close();
|
||||||
|
}, 150);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Average Liabilities and Assets — the printed statement, column for column. */
|
/** Stylesheet for the print window — the statement as the core system lays it out on paper. */
|
||||||
exportAvgBalancesXls(): void {
|
private static readonly PRINT_CSS = `
|
||||||
|
@page { size: A4 landscape; margin: 12mm; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { margin: 0; font-family: Arial, Helvetica, sans-serif; color: #000; }
|
||||||
|
.stmt { padding: 0; }
|
||||||
|
.stmt-bank { font-size: 8.5pt; font-weight: 700; }
|
||||||
|
.stmt-title { margin: 2pt 0 14pt; font-size: 7.5pt; }
|
||||||
|
.stmt-tbl { width: 100%; border-collapse: collapse; font-size: 8pt; }
|
||||||
|
.stmt-tbl th { padding: 0 6pt 7pt; text-align: right; vertical-align: bottom;
|
||||||
|
font-weight: 400; font-size: 7pt; line-height: 1.3; white-space: nowrap; }
|
||||||
|
.stmt-tbl td { padding: 5pt 6pt; text-align: right; font-weight: 700; white-space: nowrap; }
|
||||||
|
.stmt-tbl .col-month { text-align: left; }
|
||||||
|
.stmt-tbl .col-grp { text-align: center; padding-bottom: 2pt; }
|
||||||
|
.stmt-tbl tr { page-break-inside: avoid; }
|
||||||
|
.stmt-sum td { padding-top: 10pt; }
|
||||||
|
.stmt-sum:first-of-type td { border-top: 0.5pt solid #000; }
|
||||||
|
.stmt-sum .col-month { font-weight: 400; }
|
||||||
|
.stmt-foot { margin-top: 24pt; font-size: 7pt; line-height: 1.8; }
|
||||||
|
`;
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
this.dialogRef.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Excel copy of the statement, column for column and in the same order. */
|
||||||
|
exportXls(): void {
|
||||||
const rows: string[][] = [];
|
const rows: string[][] = [];
|
||||||
rows.push([this.statementHeading]);
|
rows.push([this.statementHeading]);
|
||||||
rows.push(['Average Liabilities and Assets Month-wise during the year']);
|
rows.push(['Average Liabilities and Assets Month-wise during the year']);
|
||||||
@@ -628,7 +450,7 @@ export class AlmReportDialogComponent {
|
|||||||
'Advances',
|
'Advances',
|
||||||
'Other Assets Exclude IRA'
|
'Other Assets Exclude IRA'
|
||||||
]);
|
]);
|
||||||
const line = (r: AvgBalanceRow): string[] => [
|
const line = (r: StatementRow): string[] => [
|
||||||
`${r.label} ${r.sub}`.trim(),
|
`${r.label} ${r.sub}`.trim(),
|
||||||
r.capital.toFixed(2),
|
r.capital.toFixed(2),
|
||||||
r.reserveProv.toFixed(2),
|
r.reserveProv.toFixed(2),
|
||||||
@@ -642,71 +464,38 @@ export class AlmReportDialogComponent {
|
|||||||
r.advances.toFixed(2),
|
r.advances.toFixed(2),
|
||||||
r.otherAssets.toFixed(2)
|
r.otherAssets.toFixed(2)
|
||||||
];
|
];
|
||||||
this.avgBalanceRows.forEach((r) => rows.push(line(r)));
|
this.statementRows.forEach((r) => rows.push(line(r)));
|
||||||
rows.push(line(this.avgBalanceTotal));
|
rows.push(line(this.totalRow));
|
||||||
rows.push(line(this.avgBalanceAverage));
|
rows.push(line(this.averageRow));
|
||||||
rows.push([]);
|
rows.push([]);
|
||||||
rows.push([`Print Date : ${this.statementPrintedAt}`]);
|
rows.push([`Not mapped in this extract: ${this.unmappedColumns}`]);
|
||||||
|
rows.push([`Print Date : ${this.generatedAt}`]);
|
||||||
rows.push([`Report Ref No : ${this.bank.reportRefNo} User Code:${this.statementUserCode}`]);
|
rows.push([`Report Ref No : ${this.bank.reportRefNo} User Code:${this.statementUserCode}`]);
|
||||||
this.saveTsv(rows, `ALM-AverageLiabilitiesAssets-${this.dateTag()}.xls`);
|
|
||||||
|
const tsv = rows.map((r) => r.map((v) => (v || '').replace(/\t/g, ' ')).join('\t')).join('\n');
|
||||||
|
const blob = new Blob([tsv], { type: 'application/vnd.ms-excel;charset=utf-8' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `ALM-AverageLiabilitiesAssets-${this.dateTag()}.xls`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Maturity ladder CSV */
|
|
||||||
exportLadderCsv(): void {
|
|
||||||
const headers = [
|
|
||||||
'Bucket',
|
|
||||||
'Time Period',
|
|
||||||
'Outflow (₹ Lakh)',
|
|
||||||
'Inflow (₹ Lakh)',
|
|
||||||
'Gap',
|
|
||||||
'Gap %'
|
|
||||||
];
|
|
||||||
const rows = [headers];
|
|
||||||
for (const b of this.buckets) {
|
|
||||||
rows.push([
|
|
||||||
b.code,
|
|
||||||
b.label,
|
|
||||||
b.outflow.toString(),
|
|
||||||
b.inflow.toString(),
|
|
||||||
this.bucketGap(b).toString(),
|
|
||||||
this.bucketGapPct(b).toFixed(2) + '%'
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
this.saveCsv(rows, `ALM-Ladder-${this.dateTag()}.csv`);
|
|
||||||
}
|
|
||||||
|
|
||||||
setTab(tab: 'dashboard' | 'ladder' | 'monthly' | 'avgbal' | 'returns'): void {
|
|
||||||
this.activeTab = tab;
|
|
||||||
}
|
|
||||||
close(): void {
|
|
||||||
this.dialogRef.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- helpers ----
|
|
||||||
private dateTag(): string {
|
private dateTag(): string {
|
||||||
const d = new Date();
|
const d = new Date();
|
||||||
return `${String(d.getDate()).padStart(2, '0')}${String(d.getMonth() + 1).padStart(2, '0')}${d.getFullYear()}`;
|
return `${String(d.getDate()).padStart(2, '0')}${String(d.getMonth() + 1).padStart(2, '0')}${d.getFullYear()}`;
|
||||||
}
|
}
|
||||||
private saveTsv(rows: string[][], filename: string): void {
|
|
||||||
const tsv = rows.map((r) => r.map((v) => (v || '').replace(/\t/g, ' ')).join('\t')).join('\n');
|
/** dd/MM/yyyy hh:mm:ssAM — the format the printed statement stamps its footer with. */
|
||||||
this.saveBlob(tsv, filename, 'application/vnd.ms-excel');
|
private formatPrintDate(d: Date): string {
|
||||||
}
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
private saveCsv(rows: string[][], filename: string): void {
|
const hours24 = d.getHours();
|
||||||
const escape = (v: string): string => {
|
const meridiem = hours24 < 12 ? 'AM' : 'PM';
|
||||||
const s = (v || '').replace(/"/g, '""');
|
const hours12 = hours24 % 12 === 0 ? 12 : hours24 % 12;
|
||||||
if (s.includes(',') || s.includes('"') || s.includes('\n')) return `"${s}"`;
|
return (
|
||||||
return s;
|
`${pad(d.getDate())}/${pad(d.getMonth() + 1)}/${d.getFullYear()} ` +
|
||||||
};
|
`${pad(hours12)}:${pad(d.getMinutes())}:${pad(d.getSeconds())}${meridiem}`
|
||||||
const csv = rows.map((r) => r.map(escape).join(',')).join('\n') + '\n';
|
);
|
||||||
this.saveBlob(csv, filename, 'text/csv');
|
|
||||||
}
|
|
||||||
private saveBlob(content: string, filename: string, mime: string): void {
|
|
||||||
const blob = new Blob([content], { type: `${mime};charset=utf-8` });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = filename;
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user