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
|
||||
}}
|
||||
</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')) {
|
||||
<mat-error>Client name is <strong>required</strong></mat-error>
|
||||
} @else if (createClientForm.controls.fullname.hasError('pattern')) {
|
||||
@@ -151,7 +158,9 @@
|
||||
<div class="full-name-row full-width">
|
||||
<div class="full-name-preview">
|
||||
<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 class="verify-inline">
|
||||
@if (!sanctionsVerified && !sanctionsBlocked) {
|
||||
@@ -214,7 +223,14 @@
|
||||
<mat-form-field appearance="outline" class="name-field first-name">
|
||||
<mat-label>{{ 'labels.inputs.First Name' | translate }}</mat-label>
|
||||
<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')) {
|
||||
<mat-error>First name is <strong>required</strong></mat-error>
|
||||
} @else if (createClientForm.controls.firstname.hasError('pattern')) {
|
||||
@@ -228,7 +244,13 @@
|
||||
<mat-form-field appearance="outline" class="name-field middle-name">
|
||||
<mat-label>{{ 'labels.inputs.Middle Name' | translate }}</mat-label>
|
||||
<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 (
|
||||
createClientForm.controls.middlename?.hasError('pattern') &&
|
||||
createClientForm.controls.middlename?.touched
|
||||
@@ -241,7 +263,14 @@
|
||||
<mat-form-field appearance="outline" class="name-field last-name">
|
||||
<mat-label>{{ 'labels.inputs.Last Name' | translate }}</mat-label>
|
||||
<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')) {
|
||||
<mat-error>Last name is <strong>required</strong></mat-error>
|
||||
} @else if (createClientForm.controls.lastname.hasError('pattern')) {
|
||||
@@ -694,6 +723,9 @@
|
||||
}
|
||||
</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) -->
|
||||
<div class="form-field full-width signatory-section">
|
||||
<h4 class="section-title signatory-title">
|
||||
@@ -1148,6 +1180,79 @@
|
||||
</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) -->
|
||||
@if (createClientForm.get('legalFormId')?.value === LegalFormId.PERSON) {
|
||||
<div class="section-block">
|
||||
|
||||
@@ -56,6 +56,103 @@ form {
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
+179
-24
@@ -32,6 +32,7 @@ import { filter, switchMap, takeUntil } from 'rxjs/operators';
|
||||
import { ClientsService } from 'app/clients/clients.service';
|
||||
import { Dates } from 'app/core/utils/dates';
|
||||
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';
|
||||
|
||||
/** Custom Services */
|
||||
@@ -654,6 +655,11 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
||||
if (this.createClientForm.get('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
|
||||
// screening or verified OTP must not silently apply to a different new client.
|
||||
this.mobileVerified = false;
|
||||
@@ -777,8 +783,13 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
||||
incorpNumber: data.clientNonPersonDetails.incorpNumber || '',
|
||||
remarks: data.clientNonPersonDetails.remarks || '',
|
||||
authorizedSignatory: data.clientNonPersonDetails.authorizedSignatory || '',
|
||||
authorizedSignatoryClientIds: data.clientNonPersonDetails.authorizedSignatoryClientIds || '',
|
||||
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,
|
||||
* 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 {
|
||||
const monthlyIncome = this.createClientForm.get('monthlyIncome');
|
||||
@@ -979,23 +991,33 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
||||
const annualIncome = this.createClientForm.get('annualIncome');
|
||||
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) => {
|
||||
if (this.suppressMonthlyAnnualLink) {
|
||||
return;
|
||||
}
|
||||
const num = parseFloat(value);
|
||||
monthlyTurnover.setValue(!isNaN(num) && num >= 0 ? this.round2(num * 2) : '', { emitEvent: true });
|
||||
const isPristine = monthlyTurnover.pristine && annualTurnover.pristine;
|
||||
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) => {
|
||||
if (this.suppressMonthlyAnnualLink) {
|
||||
return;
|
||||
}
|
||||
const num = parseFloat(value);
|
||||
annualTurnover.setValue(!isNaN(num) && num >= 0 ? this.round2(num * 2) : '', { emitEvent: true });
|
||||
const isPristine = monthlyTurnover.pristine && annualTurnover.pristine;
|
||||
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: [''],
|
||||
incorpNumber: [''],
|
||||
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: [''],
|
||||
remarks: ['']
|
||||
})
|
||||
@@ -1343,6 +1369,17 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
||||
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.
|
||||
* 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.emailAddress = data.emailAddress || 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;
|
||||
},
|
||||
error: () => {
|
||||
@@ -1480,6 +1522,10 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
||||
target.panVerified = kyc?.panVerified || false;
|
||||
target.aadhaarMasked = kyc?.aadhaarNumberMasked || '-';
|
||||
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: () => {
|
||||
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 {
|
||||
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 */
|
||||
@@ -2164,6 +2250,12 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
||||
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) {
|
||||
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]);
|
||||
|
||||
// Format dateOfRegistration if present
|
||||
if (generalDetails.clientNonPersonDetails?.dateOfRegistration instanceof Date) {
|
||||
generalDetails.clientNonPersonDetails.dateOfRegistration = this.dateUtils.formatDate(
|
||||
generalDetails.clientNonPersonDetails.dateOfRegistration,
|
||||
dateFormat
|
||||
);
|
||||
return generalDetails;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(): {
|
||||
@@ -2338,15 +2501,7 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
||||
|
||||
// Default Fallbacks
|
||||
if (legalFormId !== LegalFormId.PERSON) {
|
||||
return {
|
||||
category: 2,
|
||||
reason: 'Non-Individual / Entity profile (Default Medium Risk)',
|
||||
amlCheckStatus: 'CLEAR',
|
||||
bannedEntityMatch: 'NO',
|
||||
willfulDefaulter: 'NO',
|
||||
ecgcSalFlag: 'NO',
|
||||
dormantFlag: 'ACTIVE'
|
||||
};
|
||||
return this.entityRiskFromTurnover(annualTurnover);
|
||||
}
|
||||
return {
|
||||
category: 1,
|
||||
|
||||
@@ -588,95 +588,23 @@
|
||||
|
||||
<!-- ══ NON-INDIVIDUAL KYC ══ -->
|
||||
@if (isNonIndividual) {
|
||||
<!-- Incorporation Date -->
|
||||
<mat-form-field appearance="outline" class="form-field" (click)="incorpDatePicker.open()">
|
||||
<mat-label>Incorporation Date</mat-label>
|
||||
<span matPrefix class="field-prefix-icon"><mat-icon>calendar_today</mat-icon></span>
|
||||
<input matInput [max]="today" [matDatepicker]="incorpDatePicker" formControlName="incorporationDate" />
|
||||
<mat-datepicker-toggle matSuffix [for]="incorpDatePicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #incorpDatePicker></mat-datepicker>
|
||||
<!-- Date of Incorporation — captured before PAN because Enterprise PAN verification
|
||||
matches the entity on name + date of incorporation as held in the PAN records. -->
|
||||
<mat-form-field appearance="outline" class="form-field" (click)="incorporationDatePicker.open()">
|
||||
<mat-label>Date of Incorporation</mat-label>
|
||||
<span matPrefix class="field-prefix-icon"><mat-icon>event_available</mat-icon></span>
|
||||
<input
|
||||
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>
|
||||
|
||||
<!-- 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 -->
|
||||
@if (constitutionMeta?.value === 'PROPRIETORSHIP') {
|
||||
<!-- Proprietorship: Individual PAN (proprietor's personal PAN) -->
|
||||
@@ -826,8 +754,88 @@
|
||||
}
|
||||
}
|
||||
|
||||
<!-- CIN (Companies & LLP only) -->
|
||||
@if (constitutionMeta?.showCin) {
|
||||
<!-- 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>
|
||||
}
|
||||
|
||||
<!-- CIN -->
|
||||
@if (isNonIndividual) {
|
||||
<div class="form-field verify-field">
|
||||
<mat-form-field appearance="outline" class="verify-input">
|
||||
<mat-label>CIN (Corporate Identity Number)</mat-label>
|
||||
@@ -850,7 +858,7 @@
|
||||
}
|
||||
</mat-form-field>
|
||||
<div class="verify-action">
|
||||
@if (cinVerifyStatus === 'NONE' || cinVerifyStatus === 'FAILED') {
|
||||
@if (cinVerifyStatus !== 'VERIFIED') {
|
||||
<button
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
@@ -862,16 +870,31 @@
|
||||
@if (cinVerifying) {
|
||||
<mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner>
|
||||
} @else {
|
||||
<fa-icon icon="shield-alt"></fa-icon> Verify CIN
|
||||
<fa-icon icon="shield-alt"></fa-icon>
|
||||
{{ cinVerifyStatus === 'UNCONFIRMED' ? 'Retry' : 'Verify CIN' }}
|
||||
}
|
||||
</button>
|
||||
}
|
||||
@if (cinVerifyStatus === 'VERIFIED') {
|
||||
<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>
|
||||
|
||||
<!-- 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 -->
|
||||
@if (cinVerifying) {
|
||||
<div class="cin-progress full-width">
|
||||
@@ -1429,15 +1452,19 @@
|
||||
</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) {
|
||||
<mat-form-field appearance="outline" class="form-field full-width">
|
||||
<mat-label>
|
||||
{{
|
||||
isKycVerified
|
||||
? 'Reason for Re-edit'
|
||||
: kycForm.get('allowRiskOverride')?.value
|
||||
? 'Remarks (Required for Risk Override)'
|
||||
: 'Remarks'
|
||||
kycForm.get('allowRiskOverride')?.value
|
||||
? 'Remarks (Required for Risk Override)'
|
||||
: 'Reason for Re-edit (optional)'
|
||||
}}
|
||||
</mat-label>
|
||||
<textarea
|
||||
@@ -1445,18 +1472,13 @@
|
||||
formControlName="remarks"
|
||||
rows="2"
|
||||
[placeholder]="
|
||||
isKycVerified
|
||||
? 'Mandatory — explain why KYC is being re-edited'
|
||||
: kycForm.get('allowRiskOverride')?.value
|
||||
? 'Mandatory — explain why you are overriding the auto-calculated risk category'
|
||||
: ''
|
||||
kycForm.get('allowRiskOverride')?.value
|
||||
? 'Mandatory — explain why you are overriding the auto-calculated risk category'
|
||||
: 'Optional — needed only if you change details already recorded on this verified KYC'
|
||||
"
|
||||
></textarea>
|
||||
@if ((isKycVerified || kycForm.get('allowRiskOverride')?.value) && !kycForm.get('remarks')?.value?.trim()) {
|
||||
<mat-error>
|
||||
Remarks are mandatory when
|
||||
{{ isKycVerified ? 'updating a verified KYC record' : 'overriding the risk category' }}
|
||||
</mat-error>
|
||||
@if (kycForm.get('allowRiskOverride')?.value && !kycForm.get('remarks')?.value?.trim()) {
|
||||
<mat-error> Remarks are mandatory when overriding the risk category </mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
}
|
||||
@@ -1672,77 +1694,92 @@
|
||||
</div>
|
||||
|
||||
<!-- ═══ SECTION: Form 97/98 ═══ -->
|
||||
<div class="section-block">
|
||||
<h4 class="section-title">
|
||||
<fa-icon icon="file-invoice" class="section-icon"></fa-icon>
|
||||
Form 97 / 98
|
||||
@if (!isNonIndividual) {
|
||||
<div class="section-block">
|
||||
<h4 class="section-title">
|
||||
<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) {
|
||||
<span class="kyc-badge required-badge">Required — No PAN</span>
|
||||
}
|
||||
</h4>
|
||||
@if (!kycForm.get('hasPan')?.value) {
|
||||
<div class="kyc-info-hint warning-hint">
|
||||
<mat-icon class="kyc-info-hint-icon">warning</mat-icon>
|
||||
<span>Client does not have PAN. Form 97/98 is mandatory.</span>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
<mat-label>Form Type</mat-label>
|
||||
<mat-select formControlName="form60Type">
|
||||
<mat-option value="FORM_60">Form 97</mat-option>
|
||||
<mat-option value="FORM_61">Form 98</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="form-field" (click)="form60DatePicker.open()">
|
||||
<mat-label>Declaration Date</mat-label>
|
||||
<input matInput [matDatepicker]="form60DatePicker" formControlName="form60Date" />
|
||||
<mat-datepicker-toggle matSuffix [for]="form60DatePicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #form60DatePicker></mat-datepicker>
|
||||
</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 class="kyc-info-hint warning-hint">
|
||||
<mat-icon class="kyc-info-hint-icon">warning</mat-icon>
|
||||
<span>Client does not have PAN. Form 97/98 is mandatory.</span>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
<mat-label>Form Type</mat-label>
|
||||
<mat-select formControlName="form60Type">
|
||||
<mat-option value="FORM_60">Form 97</mat-option>
|
||||
<mat-option value="FORM_61">Form 98</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="form-field" (click)="form60DatePicker.open()">
|
||||
<mat-label>Declaration Date</mat-label>
|
||||
<input matInput [matDatepicker]="form60DatePicker" formControlName="form60Date" />
|
||||
<mat-datepicker-toggle matSuffix [for]="form60DatePicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #form60DatePicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
} @else {
|
||||
<p class="form60-not-needed">Client has PAN — Form 97/98 not required.</p>
|
||||
}
|
||||
</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">
|
||||
<h4 class="section-title">
|
||||
<fa-icon icon="camera" class="section-icon"></fa-icon>
|
||||
Photograph & Signature
|
||||
<span class="section-required-tag">Required</span>
|
||||
{{ isNonIndividual ? 'Entity Photograph' : 'Photograph & Signature' }}
|
||||
@if (isNonIndividual) {
|
||||
<span class="section-optional-tag">Optional</span>
|
||||
} @else {
|
||||
<span class="section-required-tag">Required</span>
|
||||
}
|
||||
</h4>
|
||||
<div class="photo-sig-grid">
|
||||
<div class="photo-sig-grid" [class.photo-sig-grid--single]="isNonIndividual">
|
||||
<div
|
||||
class="upload-box"
|
||||
[class.upload-box--has-preview]="photographPreview"
|
||||
[class.upload-box--missing]="!photographPreview && photoSigTouched"
|
||||
[class.upload-box--missing]="!photographPreview && photoSigTouched && !isNonIndividual"
|
||||
>
|
||||
@if (photographPreview) {
|
||||
<div class="upload-preview">
|
||||
@@ -1758,12 +1795,16 @@
|
||||
</div>
|
||||
</div>
|
||||
} @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">
|
||||
Client Photograph
|
||||
<span class="upload-box__req">*</span>
|
||||
{{ isNonIndividual ? 'Entity Photograph / Logo' : 'Client Photograph' }}
|
||||
@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 class="upload-box__hint">JPG / PNG, max 2MB</span>
|
||||
<div class="upload-box__btn-row">
|
||||
<label class="upload-box__btn">
|
||||
<input type="file" accept="image/*" hidden (change)="onPhotoSelected($event)" />
|
||||
@@ -1773,50 +1814,52 @@
|
||||
<fa-icon icon="video"></fa-icon> Use Webcam
|
||||
</button>
|
||||
</div>
|
||||
@if (photoSigTouched) {
|
||||
@if (photoSigTouched && !isNonIndividual) {
|
||||
<span class="upload-box__error">Photograph is required</span>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<div
|
||||
class="upload-box"
|
||||
[class.upload-box--has-preview]="signaturePreview"
|
||||
[class.upload-box--missing]="!signaturePreview && photoSigTouched"
|
||||
>
|
||||
@if (signaturePreview) {
|
||||
<div class="upload-preview">
|
||||
<img [src]="signaturePreview" alt="Signature" class="upload-preview__img upload-preview__img--sig" />
|
||||
<div class="upload-preview__actions">
|
||||
<label class="upload-preview__btn recrop">
|
||||
@if (!isNonIndividual) {
|
||||
<div
|
||||
class="upload-box"
|
||||
[class.upload-box--has-preview]="signaturePreview"
|
||||
[class.upload-box--missing]="!signaturePreview && photoSigTouched"
|
||||
>
|
||||
@if (signaturePreview) {
|
||||
<div class="upload-preview">
|
||||
<img [src]="signaturePreview" alt="Signature" class="upload-preview__img upload-preview__img--sig" />
|
||||
<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)" />
|
||||
<fa-icon icon="crop"></fa-icon> Re-crop
|
||||
<fa-icon icon="crop"></fa-icon> Choose & Crop
|
||||
</label>
|
||||
<button type="button" class="upload-preview__btn remove" (click)="removeSignature()">
|
||||
<fa-icon icon="trash"></fa-icon> Remove
|
||||
<button type="button" class="upload-box__btn upload-box__btn--secondary" (click)="drawSignature()">
|
||||
<fa-icon icon="pen"></fa-icon> Draw Signature
|
||||
</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)" />
|
||||
<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>
|
||||
@if (photoSigTouched) {
|
||||
<span class="upload-box__error">Signature is required</span>
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -36,6 +36,20 @@
|
||||
letter-spacing: 0.4px;
|
||||
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 {
|
||||
@@ -312,6 +326,34 @@
|
||||
color: #b45309;
|
||||
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 ────────────────────────────────────────────────
|
||||
@@ -1225,6 +1267,12 @@
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
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 {
|
||||
|
||||
@@ -29,6 +29,7 @@ import { ClientsService } from '../../clients.service';
|
||||
import { SettingsService } from 'app/settings/settings.service';
|
||||
import { Dates } from 'app/core/utils/dates';
|
||||
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 { KycUploadHelperService } from '../../shared/kyc-upload-helper.service';
|
||||
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;
|
||||
|
||||
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 = '';
|
||||
/** Whether existing KYC data has been loaded (edit mode) */
|
||||
kycLoaded = false;
|
||||
@@ -121,12 +143,17 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
}
|
||||
|
||||
/** 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) */
|
||||
kycDocProofs: ProofType[] = [];
|
||||
kycDocGroups: { label: string; items: ProofType[] }[] = [];
|
||||
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 */
|
||||
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 */
|
||||
get photoSigComplete(): boolean {
|
||||
if (this.isNonIndividual) {
|
||||
return true;
|
||||
}
|
||||
return this.hasPhoto && this.hasSignature;
|
||||
}
|
||||
|
||||
@@ -225,7 +255,10 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
const needsCin = !!this.kycForm?.get('cinNumber')?.value;
|
||||
const panOk = this.entPanVerifyStatus === '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;
|
||||
} else {
|
||||
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: 'School ID Card', code: 'SCHOOL_ID_CARD' }
|
||||
];
|
||||
/** Generic RBI-prescribed KYC documents for legal entities (Master Direction on KYC,
|
||||
* Annexure I) — shown for non-individual clients before a specific constitution type is
|
||||
* picked. Individual OVDs (Aadhaar/Passport/DL/Voter ID/NREGA/NPR) don't apply to entities. */
|
||||
private nonIndividualDefaultDocTypes = [
|
||||
{ name: 'Certificate of Incorporation / Registration Certificate', code: 'CERT_INCORPORATION' },
|
||||
{ name: 'PAN Card of Entity', code: 'ENTITY_PAN' },
|
||||
{ name: 'Memorandum & Articles of Association / Partnership Deed / Trust Deed', code: 'CONSTITUTIVE_DOC' },
|
||||
{ name: 'Board Resolution / Authorization Letter', code: 'BOARD_RESOLUTION' },
|
||||
{ name: 'KYC of Authorized Signatory / Power of Attorney Holder', code: 'AUTH_SIGNATORY_KYC' },
|
||||
{ name: 'GST Registration Certificate', code: 'GST_CERTIFICATE' }
|
||||
];
|
||||
/** Constitution-based KYC document types for non-individual clients */
|
||||
/** Constitution-based KYC document types for non-individual clients. Shown in addition to
|
||||
* the RBI entity documents (ENTITY_KYC_DOCUMENT_TYPES), never instead of them — see
|
||||
* withEntityKycDocuments. */
|
||||
private constitutionDocTypes: Record<string, { name: string; mandatory: boolean }[]> = {
|
||||
HUF: [
|
||||
{ name: 'PAN Card of HUF', mandatory: true },
|
||||
@@ -513,7 +537,14 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
gstinVerifyState = '';
|
||||
|
||||
/** 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;
|
||||
cinVerifiedOn = '';
|
||||
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
|
||||
* ever applies to individuals. */
|
||||
private getDefaultDocTypes(): { name: string; code: string }[] {
|
||||
return this.isNonIndividual
|
||||
? this.nonIndividualDefaultDocTypes
|
||||
: this.filterDocTypesForMinor(this.defaultDocumentTypes);
|
||||
return this.isNonIndividual ? ENTITY_KYC_DOCUMENT_TYPES : this.filterDocTypesForMinor(this.defaultDocumentTypes);
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
@@ -673,9 +702,9 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
if (this.calculatedRisk) {
|
||||
riskControl?.setValue(this.calculatedRisk.category);
|
||||
}
|
||||
if (!this.isKycVerified) {
|
||||
this.kycForm.get('remarks')?.clearValidators();
|
||||
}
|
||||
// Overriding the risk category is the only thing this form demands a justification for.
|
||||
// A verified record does not, by itself, need one — see loadExistingKycIfNeeded.
|
||||
this.kycForm.get('remarks')?.clearValidators();
|
||||
}
|
||||
this.kycForm.get('remarks')?.updateValueAndValidity();
|
||||
});
|
||||
@@ -797,6 +826,7 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
this.gstinVerifiedOn = '';
|
||||
this.gstinVerifyState = '';
|
||||
this.cinVerifyStatus = 'NONE';
|
||||
this.cinUnconfirmedMessage = '';
|
||||
this.cinVerifying = false;
|
||||
this.cinVerifiedOn = '';
|
||||
this.cinCompanyName = '';
|
||||
@@ -898,14 +928,26 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
private updateDocTypesForConstitution(): void {
|
||||
const constitutionValue = this.constitutionMeta?.value;
|
||||
if (constitutionValue && this.constitutionDocTypes[constitutionValue]) {
|
||||
this.kycDocumentTypes = this.constitutionDocTypes[constitutionValue].map((doc) => ({
|
||||
name: doc.name,
|
||||
code: doc.name,
|
||||
mandatory: doc.mandatory
|
||||
}));
|
||||
// Reset selected document type since options changed
|
||||
this.kycForm.get('kycDocumentName')?.setValue('');
|
||||
this.kycForm.get('kycDocumentName')?.updateValueAndValidity();
|
||||
// The RBI entity documents stay at the head of the list — picking a constitution adds that
|
||||
// constitution's own requirements, it does not swap out the set the officer was just shown.
|
||||
this.kycDocumentTypes = withEntityKycDocuments(
|
||||
this.constitutionDocTypes[constitutionValue],
|
||||
this.constitutionMeta?.label || null
|
||||
);
|
||||
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
@@ -913,18 +955,51 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
/** Rebuild chip-group picker model from kycDocumentTypes */
|
||||
private rebuildKycDocPicker(): void {
|
||||
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);
|
||||
// Sync selected proof with current form value (e.g. on edit-mode prefill)
|
||||
const currentName = this.kycForm?.get('kycDocumentName')?.value;
|
||||
if (currentName) {
|
||||
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 {
|
||||
this.selectedKycDoc = null;
|
||||
this.selectedKycDoc = currentName ? this.findKycDocProofByName(currentName) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a stored document-type name back to a chip in the picker.
|
||||
*
|
||||
* 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 {
|
||||
@@ -1102,19 +1177,17 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
isException: data.isException || false,
|
||||
exceptionReason: data.exceptionReason ? data.exceptionReason.split(', ') : []
|
||||
});
|
||||
// Set remarks as required if KYC is verified
|
||||
if (this.isKycVerified) {
|
||||
this.kycForm.get('remarks')?.setValidators(Validators.required);
|
||||
this.kycForm.get('remarks')?.updateValueAndValidity();
|
||||
this.kycForm.get('remarks')?.markAsTouched(); // Force red underline so user knows it's blocking
|
||||
}
|
||||
// Remarks are NOT forced just because the record is verified. The server asks for them
|
||||
// only when a save actually overwrites data the verified record already held
|
||||
// (ClientKycWritePlatformServiceJpaRepositoryImpl.updateKyc), and it says so in the error
|
||||
// it returns. Demanding them up front made every non-individual client open with a red
|
||||
// "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.persistedExceptionReason = data.exceptionReason || null;
|
||||
// Sync chip-picker selection with the prefilled document name
|
||||
if (data.kycDocumentName) {
|
||||
const cleanStr = (s: string) => (s || '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
const targetName = cleanStr(data.kycDocumentName);
|
||||
this.selectedKycDoc = this.kycDocProofs.find((p) => cleanStr(p.name) === targetName) || null;
|
||||
this.selectedKycDoc = this.findKycDocProofByName(data.kycDocumentName);
|
||||
}
|
||||
// Set verification statuses from existing data
|
||||
if (data.panVerified) {
|
||||
@@ -1175,6 +1248,8 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
}
|
||||
if (data.cinVerified) {
|
||||
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;
|
||||
if (Array.isArray(rawCinDate) && rawCinDate.length >= 3) {
|
||||
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
|
||||
// them (older clients were created via the identifier flow only).
|
||||
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({
|
||||
kycDocumentName: identifier.documentType.name,
|
||||
kycDocumentName: proof?.name || identifier.documentType.name,
|
||||
kycDocumentNumber: identifier.documentKey || ''
|
||||
});
|
||||
this.selectedKycDoc = this.kycDocProofs.find((p) => p.name === identifier.documentType.name) || null;
|
||||
}
|
||||
|
||||
this.clientsService.getClientIdentificationDocuments(this.existingKycIdentifierId).subscribe({
|
||||
@@ -1453,13 +1531,18 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
this.kycTemplate = template;
|
||||
this.kycTemplateLoaded = true;
|
||||
if (template?.requiredDocuments?.length > 0) {
|
||||
this.kycDocumentTypes = this.filterDocTypesForMinor(
|
||||
template.requiredDocuments.map((doc: any) => ({
|
||||
name: doc.name,
|
||||
code: doc.code || doc.name,
|
||||
mandatory: doc.mandatory ?? false
|
||||
}))
|
||||
);
|
||||
const templateDocs = template.requiredDocuments.map((doc: any) => ({
|
||||
name: doc.name,
|
||||
code: doc.code || doc.name,
|
||||
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
|
||||
const hasMandatory = this.kycDocumentTypes.some((d) => d.mandatory);
|
||||
if (hasMandatory) {
|
||||
@@ -1472,11 +1555,18 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
error: () => {
|
||||
// Fallback to defaults if template API unavailable
|
||||
this.kycTemplateLoaded = true;
|
||||
this.kycDocumentTypes = this.getDefaultDocTypes().map((d) => ({
|
||||
name: d.name,
|
||||
code: d.code,
|
||||
mandatory: false
|
||||
}));
|
||||
// An entity keeps whatever constitution-specific documents it already has alongside the
|
||||
// RBI list; a failed template call must not quietly shorten the picker.
|
||||
const constitutionDocs = this.isNonIndividual
|
||||
? 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();
|
||||
}
|
||||
});
|
||||
@@ -1485,17 +1575,25 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
/** Save or update KYC, then execute callback */
|
||||
private saveKycAndDo(payload: any, callback: () => void, onError: (msg: string) => void) {
|
||||
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) {
|
||||
const remarks = this.kycForm.get('remarks')?.value?.trim();
|
||||
if (!remarks) {
|
||||
onError('Remarks are mandatory when updating a verified KYC record.');
|
||||
this.kycForm.get('remarks')?.markAsTouched();
|
||||
return;
|
||||
if (remarks) {
|
||||
updatePayload.remarks = remarks;
|
||||
}
|
||||
updatePayload.remarks = remarks;
|
||||
this.clientsService.reEditClientKyc(this.clientId, this.kycId, updatePayload).subscribe({
|
||||
next: () => {
|
||||
// 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 panPayload: any = {
|
||||
locale: 'en',
|
||||
kycType: 1,
|
||||
riskCategory: this.kycForm.value.riskCategory || 1,
|
||||
kycType: this.kycTypeValue,
|
||||
riskCategory: this.currentRiskCategory,
|
||||
panNumber: pan,
|
||||
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(
|
||||
panPayload,
|
||||
() => {
|
||||
@@ -1834,16 +1950,27 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
const dateFormat = this.settingsService.dateFormat;
|
||||
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 = {
|
||||
locale: 'en',
|
||||
kycType: 1,
|
||||
riskCategory: this.kycForm.value.riskCategory || 1,
|
||||
kycType: this.kycTypeValue,
|
||||
riskCategory: this.currentRiskCategory,
|
||||
panNumber: pan, // Company PAN → panNumber
|
||||
enterprisePan: pan,
|
||||
incorporationDate: incorpDate instanceof Date ? this.dateUtils.formatDate(incorpDate, dateFormat) : incorpDate,
|
||||
dateFormat,
|
||||
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(
|
||||
payload,
|
||||
() => {
|
||||
@@ -1862,6 +1989,11 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
rawDate ||
|
||||
`${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 {
|
||||
this.entPanVerifyStatus = 'FAILED';
|
||||
this.showPanErrorAlert(
|
||||
@@ -1907,8 +2039,8 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
|
||||
const payload: any = {
|
||||
locale: 'en',
|
||||
kycType: 1,
|
||||
riskCategory: this.kycForm.value.riskCategory || 1,
|
||||
kycType: this.kycTypeValue,
|
||||
riskCategory: this.currentRiskCategory,
|
||||
gstinNumber: gstin,
|
||||
dateFormat,
|
||||
panNumber: this.kycForm.value.enterprisePan || this.kycForm.value.panNumber || null,
|
||||
@@ -1976,7 +2108,105 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.clientId) {
|
||||
this.showPanErrorAlert('Client must be created first. Complete General tab and click Next.');
|
||||
return;
|
||||
}
|
||||
|
||||
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.cinCurrentStep = 0;
|
||||
|
||||
@@ -2000,22 +2230,23 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.cinProgressValue = Math.min(Math.round((elapsed / totalDuration) * 100), 100);
|
||||
|
||||
this.cinProgressValue = Math.min(Math.round((elapsed / totalDuration) * 100), 90);
|
||||
if (elapsed >= totalDuration) {
|
||||
clearInterval(this.cinProgressTimer);
|
||||
this.cinCurrentStep = stepDurations.length;
|
||||
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);
|
||||
this.cinProgressTimer = null;
|
||||
}
|
||||
}, 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 */
|
||||
sendAadhaarOtp() {
|
||||
if (!this.clientId) {
|
||||
@@ -2041,8 +2272,8 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
const aadhaarMasked = 'XXXXXXXX' + aadhaar.slice(-4);
|
||||
const aadhaarPayload: any = {
|
||||
locale: 'en',
|
||||
kycType: 1,
|
||||
riskCategory: this.kycForm.value.riskCategory || 1,
|
||||
kycType: this.kycTypeValue,
|
||||
riskCategory: this.currentRiskCategory,
|
||||
aadhaarNumberMasked: aadhaarMasked
|
||||
};
|
||||
const panVal = this.kycForm.value.panNumber;
|
||||
@@ -2203,7 +2434,7 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
}
|
||||
|
||||
getRenewalYears(): number {
|
||||
switch (this.kycForm.value.riskCategory) {
|
||||
switch (this.currentRiskCategory) {
|
||||
case 1:
|
||||
return 10;
|
||||
case 2:
|
||||
@@ -2387,12 +2618,16 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
return {
|
||||
locale: 'en',
|
||||
dateFormat,
|
||||
kycType: 1,
|
||||
kycType: this.kycTypeValue,
|
||||
riskCategory: v.riskCategory || 1,
|
||||
// For non-individual: Company PAN is used as panNumber for verification
|
||||
panNumber: this.isNonIndividual ? enterprisePanNorm : v.hasPan ? panNorm : null,
|
||||
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
|
||||
// does not silently drop the paperwork that is holding the exception off.
|
||||
panDeclarationTaken: this.panDeclarationTaken,
|
||||
@@ -2470,6 +2705,7 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
v.gstinNumber ||
|
||||
v.enterprisePan ||
|
||||
v.cinNumber ||
|
||||
v.incorporationDate ||
|
||||
v.kycDocumentName ||
|
||||
v.form15Applicable ||
|
||||
v.isException
|
||||
|
||||
+6
-6
@@ -442,18 +442,18 @@
|
||||
<span class="preview-value mono">{{ kycData.enterprisePan }}</span>
|
||||
</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) {
|
||||
<div class="preview-field">
|
||||
<span class="preview-label">CIN (Corporate Identity Number)</span>
|
||||
<span class="preview-value mono">{{ kycData.cinNumber }}</span>
|
||||
</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) {
|
||||
<div class="preview-field">
|
||||
<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 { Form121TabComponent } from './clients-view/form121-tab/form121-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';
|
||||
|
||||
const routes: Routes = [
|
||||
@@ -199,6 +200,15 @@ const routes: Routes = [
|
||||
component: Form121TabComponent,
|
||||
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',
|
||||
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
|
||||
</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
|
||||
mat-tab-link
|
||||
[routerLink]="['./documents']"
|
||||
|
||||
+13
-1
@@ -11,9 +11,21 @@
|
||||
<div>
|
||||
<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">
|
||||
<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 }}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
|
||||
+42
@@ -5,3 +5,45 @@
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
.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 */
|
||||
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.
|
||||
* @param {any} $event file change event.
|
||||
@@ -40,6 +50,29 @@ export class UploadImageDialogComponent {
|
||||
onFileSelect($event: any) {
|
||||
if ($event.target.files.length > 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>
|
||||
</div>
|
||||
|
||||
<!-- PAN Row -->
|
||||
<div class="kv-table__row">
|
||||
<span class="kv-table__col kv-table__col--doc">
|
||||
<mat-icon class="kv-doc-icon">credit_card</mat-icon> PAN
|
||||
</span>
|
||||
<span class="kv-table__col kv-table__col--num kv-mono">{{ kycData.panNumber || '—' }}</span>
|
||||
<span class="kv-table__col kv-table__col--status">
|
||||
@if (kycData.panVerified) {
|
||||
<span class="kv-status kv-status--ok"><mat-icon>check_circle</mat-icon> Verified</span>
|
||||
} @else if (panDeclarationTaken) {
|
||||
<!-- Deliberately not "Verified": the check never passed, paperwork covers it. -->
|
||||
<span class="kv-status kv-status--declared"><mat-icon>assignment_turned_in</mat-icon> Declaration</span>
|
||||
} @else {
|
||||
<span class="kv-status kv-status--pending"><mat-icon>radio_button_unchecked</mat-icon> Pending</span>
|
||||
}
|
||||
</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>
|
||||
<!--
|
||||
PAN and Aadhaar below are the individual's own OVDs. An entity holds neither: its PAN is
|
||||
the Enterprise PAN reported further down (the same stored panNumber, under the name the
|
||||
entity actually knows it by), and Aadhaar is issued to people, not companies — the masked
|
||||
number on a non-individual record is a placeholder the KYC step writes to satisfy the
|
||||
column, so showing it as "Pending" invited officers to chase a document that cannot exist.
|
||||
-->
|
||||
@if (!isNonIndividual) {
|
||||
<!-- PAN Row -->
|
||||
<div class="kv-table__row">
|
||||
<span class="kv-table__col kv-table__col--doc">
|
||||
<mat-icon class="kv-doc-icon">credit_card</mat-icon> PAN
|
||||
</span>
|
||||
<span class="kv-table__col kv-table__col--num kv-mono">{{ kycData.panNumber || '—' }}</span>
|
||||
<span class="kv-table__col kv-table__col--status">
|
||||
@if (kycData.panVerified) {
|
||||
<span class="kv-status kv-status--ok"><mat-icon>check_circle</mat-icon> Verified</span>
|
||||
} @else if (panDeclarationTaken) {
|
||||
<!-- Deliberately not "Verified": the check never passed, paperwork covers it. -->
|
||||
<span class="kv-status kv-status--declared"><mat-icon>assignment_turned_in</mat-icon> Declaration</span>
|
||||
} @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
|
||||
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.
|
||||
-->
|
||||
@if (!kycData.panVerified && !panDeclarationTaken && kycData.panNumber) {
|
||||
<button
|
||||
mat-stroked-button
|
||||
class="kv-action-btn"
|
||||
type="button"
|
||||
[disabled]="!kycId || saving"
|
||||
(click)="takePanDeclaration()"
|
||||
>
|
||||
Declaration
|
||||
</button>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
@if (!kycData.panVerified && !panDeclarationTaken && kycData.panNumber) {
|
||||
<button
|
||||
mat-stroked-button
|
||||
class="kv-action-btn"
|
||||
type="button"
|
||||
[disabled]="!kycId || saving"
|
||||
(click)="takePanDeclaration()"
|
||||
>
|
||||
Declaration
|
||||
</button>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
<!--
|
||||
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.
|
||||
-->
|
||||
@if (!kycData.panVerified && panDeclarationTaken) {
|
||||
<div class="kv-decl">
|
||||
<div class="kv-decl__head">
|
||||
<mat-icon>assignment_turned_in</mat-icon>
|
||||
<span class="kv-decl__title">PAN Declaration</span>
|
||||
@if (!saving) {
|
||||
<span class="kv-decl__actions">
|
||||
<button mat-stroked-button type="button" class="kv-action-btn" (click)="takePanDeclaration()">
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
class="kv-action-btn danger"
|
||||
(click)="removePanDeclaration()"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</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>
|
||||
@if (!kycData.panVerified && panDeclarationTaken) {
|
||||
<div class="kv-decl">
|
||||
<div class="kv-decl__head">
|
||||
<mat-icon>assignment_turned_in</mat-icon>
|
||||
<span class="kv-decl__title">PAN Declaration</span>
|
||||
@if (!saving) {
|
||||
<span class="kv-decl__actions">
|
||||
<button mat-stroked-button type="button" class="kv-action-btn" (click)="takePanDeclaration()">
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
class="kv-action-btn danger"
|
||||
(click)="removePanDeclaration()"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
@if (kycData.panDeclarationNameOnPan) {
|
||||
<div class="kv-decl__rows">
|
||||
<div class="kv-decl__row">
|
||||
<span class="k">Name on PAN</span>
|
||||
<span class="v">{{ kycData.panDeclarationNameOnPan }}</span>
|
||||
<span class="k">Does not match</span>
|
||||
<span class="v strong">{{ panDeclarationMismatchLabel }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (kycData.panDeclarationDobOnPan) {
|
||||
<div class="kv-decl__row">
|
||||
<span class="k">DOB on PAN</span>
|
||||
<span class="v">{{ formatDate(kycData.panDeclarationDobOnPan) }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (kycData.panDeclarationRemarks) {
|
||||
<div class="kv-decl__row">
|
||||
<span class="k">Declaration file</span>
|
||||
<span class="v">{{ kycData.panDeclarationRemarks }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (kycData.panDeclarationNameOnPan) {
|
||||
<div class="kv-decl__row">
|
||||
<span class="k">Name on PAN</span>
|
||||
<span class="v">{{ kycData.panDeclarationNameOnPan }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (kycData.panDeclarationDobOnPan) {
|
||||
<div class="kv-decl__row">
|
||||
<span class="k">DOB on PAN</span>
|
||||
<span class="v">{{ formatDate(kycData.panDeclarationDobOnPan) }}</span>
|
||||
</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 class="kv-decl__note">
|
||||
PAN remains unverified. No KYC exception is raised for PAN while this declaration is on file.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<!-- Aadhaar Row -->
|
||||
<div class="kv-table__row">
|
||||
<span class="kv-table__col kv-table__col--doc">
|
||||
<mat-icon class="kv-doc-icon">fingerprint</mat-icon> Aadhaar
|
||||
</span>
|
||||
<span class="kv-table__col kv-table__col--num kv-mono">{{ kycData.aadhaarNumberMasked || '—' }}</span>
|
||||
<span class="kv-table__col kv-table__col--status">
|
||||
@if (kycData.aadhaarVerified) {
|
||||
<span class="kv-status kv-status--ok"><mat-icon>check_circle</mat-icon> Verified</span>
|
||||
} @else if (aadhaarReceiptTaken) {
|
||||
<!-- 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>
|
||||
} @else {
|
||||
<span class="kv-status kv-status--pending"><mat-icon>radio_button_unchecked</mat-icon> Pending</span>
|
||||
}
|
||||
</span>
|
||||
<span class="kv-table__col kv-table__col--date">{{
|
||||
kycData.aadhaarVerified
|
||||
? kycData.aadhaarVerifiedOn
|
||||
? formatDate(kycData.aadhaarVerifiedOn)
|
||||
: '—'
|
||||
: aadhaarReceiptTaken && kycData.aadhaarReceiptTakenOn
|
||||
? formatDate(kycData.aadhaarReceiptTakenOn)
|
||||
: '—'
|
||||
}}</span>
|
||||
<span class="kv-table__col kv-table__col--action">
|
||||
@if (!showAadhaarReVerify && !aadhaarOtpSent) {
|
||||
<button
|
||||
mat-stroked-button
|
||||
class="kv-action-btn"
|
||||
type="button"
|
||||
[disabled]="verifying.aadhaar || !kycData.aadhaarNumberMasked"
|
||||
(click)="startAadhaarReVerify()"
|
||||
>
|
||||
Re-Verify
|
||||
</button>
|
||||
}
|
||||
<!--
|
||||
<!-- Aadhaar Row -->
|
||||
<div class="kv-table__row">
|
||||
<span class="kv-table__col kv-table__col--doc">
|
||||
<mat-icon class="kv-doc-icon">fingerprint</mat-icon> Aadhaar
|
||||
</span>
|
||||
<span class="kv-table__col kv-table__col--num kv-mono">{{ kycData.aadhaarNumberMasked || '—' }}</span>
|
||||
<span class="kv-table__col kv-table__col--status">
|
||||
@if (kycData.aadhaarVerified) {
|
||||
<span class="kv-status kv-status--ok"><mat-icon>check_circle</mat-icon> Verified</span>
|
||||
} @else if (aadhaarReceiptTaken) {
|
||||
<!-- 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>
|
||||
} @else {
|
||||
<span class="kv-status kv-status--pending"><mat-icon>radio_button_unchecked</mat-icon> Pending</span>
|
||||
}
|
||||
</span>
|
||||
<span class="kv-table__col kv-table__col--date">{{
|
||||
kycData.aadhaarVerified
|
||||
? kycData.aadhaarVerifiedOn
|
||||
? formatDate(kycData.aadhaarVerifiedOn)
|
||||
: '—'
|
||||
: aadhaarReceiptTaken && kycData.aadhaarReceiptTakenOn
|
||||
? formatDate(kycData.aadhaarReceiptTakenOn)
|
||||
: '—'
|
||||
}}</span>
|
||||
<span class="kv-table__col kv-table__col--action">
|
||||
@if (!showAadhaarReVerify && !aadhaarOtpSent) {
|
||||
<button
|
||||
mat-stroked-button
|
||||
class="kv-action-btn"
|
||||
type="button"
|
||||
[disabled]="verifying.aadhaar || !kycData.aadhaarNumberMasked"
|
||||
(click)="startAadhaarReVerify()"
|
||||
>
|
||||
Re-Verify
|
||||
</button>
|
||||
}
|
||||
<!--
|
||||
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.
|
||||
-->
|
||||
@if (!kycData.aadhaarVerified && !aadhaarReceiptTaken) {
|
||||
<button
|
||||
mat-stroked-button
|
||||
class="kv-action-btn"
|
||||
type="button"
|
||||
[disabled]="!kycId || saving"
|
||||
(click)="takeAadhaarReceipt()"
|
||||
>
|
||||
Receipt
|
||||
</button>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
@if (!kycData.aadhaarVerified && !aadhaarReceiptTaken) {
|
||||
<button
|
||||
mat-stroked-button
|
||||
class="kv-action-btn"
|
||||
type="button"
|
||||
[disabled]="!kycId || saving"
|
||||
(click)="takeAadhaarReceipt()"
|
||||
>
|
||||
Receipt
|
||||
</button>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
<!--
|
||||
Aadhaar is unverified but an enrolment receipt is on file. Shown in full because it is the
|
||||
only reason Aadhaar carries no KYC exception.
|
||||
-->
|
||||
@if (!kycData.aadhaarVerified && aadhaarReceiptTaken) {
|
||||
<div class="kv-rcpt">
|
||||
<div class="kv-rcpt__head">
|
||||
<mat-icon>receipt_long</mat-icon>
|
||||
<span class="kv-rcpt__title">Aadhaar Enrolment Receipt</span>
|
||||
@if (!saving) {
|
||||
<span class="kv-rcpt__actions">
|
||||
<button mat-stroked-button type="button" class="kv-action-btn" (click)="takeAadhaarReceipt()">
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
class="kv-action-btn danger"
|
||||
(click)="removeAadhaarReceipt()"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</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>
|
||||
@if (!kycData.aadhaarVerified && aadhaarReceiptTaken) {
|
||||
<div class="kv-rcpt">
|
||||
<div class="kv-rcpt__head">
|
||||
<mat-icon>receipt_long</mat-icon>
|
||||
<span class="kv-rcpt__title">Aadhaar Enrolment Receipt</span>
|
||||
@if (!saving) {
|
||||
<span class="kv-rcpt__actions">
|
||||
<button mat-stroked-button type="button" class="kv-action-btn" (click)="takeAadhaarReceipt()">
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
class="kv-action-btn danger"
|
||||
(click)="removeAadhaarReceipt()"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
@if (kycData.aadhaarEnrolmentDate) {
|
||||
<div class="kv-rcpt__rows">
|
||||
<div class="kv-rcpt__row">
|
||||
<span class="k">Enrolment date</span>
|
||||
<span class="v">{{ formatDate(kycData.aadhaarEnrolmentDate) }}</span>
|
||||
<span class="k">Enrolment ID</span>
|
||||
<span class="v mono">{{ kycData.aadhaarEnrolmentId || '—' }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (kycData.aadhaarReceiptRemarks) {
|
||||
<div class="kv-rcpt__row">
|
||||
<span class="k">Remarks</span>
|
||||
<span class="v">{{ kycData.aadhaarReceiptRemarks }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (kycData.aadhaarEnrolmentDate) {
|
||||
<div class="kv-rcpt__row">
|
||||
<span class="k">Enrolment date</span>
|
||||
<span class="v">{{ formatDate(kycData.aadhaarEnrolmentDate) }}</span>
|
||||
</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 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>
|
||||
}
|
||||
|
||||
<!-- 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">
|
||||
<span class="kv-table__col kv-table__col--doc">
|
||||
<mat-icon class="kv-doc-icon">credit_card</mat-icon> Enterprise PAN
|
||||
</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">
|
||||
@if (kycData.enterprisePanVerified) {
|
||||
@if (kycData.panVerified) {
|
||||
<span class="kv-status kv-status--ok"><mat-icon>check_circle</mat-icon> Verified</span>
|
||||
} @else {
|
||||
<span class="kv-status kv-status--pending"><mat-icon>radio_button_unchecked</mat-icon> Pending</span>
|
||||
}
|
||||
</span>
|
||||
<span class="kv-table__col kv-table__col--date">{{
|
||||
kycData.enterprisePanVerifiedOn ? formatDate(kycData.enterprisePanVerifiedOn) : '—'
|
||||
kycData.panVerified && kycData.panVerifiedOn ? formatDate(kycData.panVerifiedOn) : '—'
|
||||
}}</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>
|
||||
</div>
|
||||
}
|
||||
@@ -464,68 +509,61 @@
|
||||
<span class="kv-table__col kv-table__col--action"></span>
|
||||
</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>
|
||||
|
||||
<!-- ─── PAN ↔ Aadhaar Link — premium status card (replaces the legacy
|
||||
table row). Dark gradient base with state-driven accent so the
|
||||
linking state is the first thing a reviewer sees. -->
|
||||
<div
|
||||
class="pal-card"
|
||||
[class.pal-card--ok]="kycData?.panAadhaarSeedingStatus === 'Y'"
|
||||
[class.pal-card--fail]="kycData?.panAadhaarSeedingStatus === 'N'"
|
||||
[class.pal-card--info]="kycData?.panAadhaarSeedingStatus === 'E'"
|
||||
[class.pal-card--pending]="
|
||||
kycData?.panAadhaarSeedingStatus !== 'Y' &&
|
||||
kycData?.panAadhaarSeedingStatus !== 'N' &&
|
||||
kycData?.panAadhaarSeedingStatus !== 'E'
|
||||
"
|
||||
>
|
||||
<div class="pal-card__left">
|
||||
<div class="pal-card__icon">
|
||||
<mat-icon>credit_card</mat-icon>
|
||||
</div>
|
||||
<div class="pal-card__txt">
|
||||
<div class="pal-card__small">Verification Status</div>
|
||||
<div class="pal-card__title">PAN ↔ Aadhaar</div>
|
||||
<div class="pal-card__status">
|
||||
@if (kycData?.panAadhaarSeedingStatus === 'Y') {
|
||||
<mat-icon>check_circle</mat-icon> Linked (Seeded)
|
||||
} @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
|
||||
}
|
||||
linking state is the first thing a reviewer sees.
|
||||
|
||||
Section 139AA seeding applies to a person's PAN and Aadhaar. An entity has no Aadhaar
|
||||
to seed, so for a non-individual the card could only ever read "Pending" against a
|
||||
requirement that does not apply to it. -->
|
||||
@if (!isNonIndividual) {
|
||||
<div
|
||||
class="pal-card"
|
||||
[class.pal-card--ok]="kycData?.panAadhaarSeedingStatus === 'Y'"
|
||||
[class.pal-card--fail]="kycData?.panAadhaarSeedingStatus === 'N'"
|
||||
[class.pal-card--info]="kycData?.panAadhaarSeedingStatus === 'E'"
|
||||
[class.pal-card--pending]="
|
||||
kycData?.panAadhaarSeedingStatus !== 'Y' &&
|
||||
kycData?.panAadhaarSeedingStatus !== 'N' &&
|
||||
kycData?.panAadhaarSeedingStatus !== 'E'
|
||||
"
|
||||
>
|
||||
<div class="pal-card__left">
|
||||
<div class="pal-card__icon">
|
||||
<mat-icon>credit_card</mat-icon>
|
||||
</div>
|
||||
<div class="pal-card__txt">
|
||||
<div class="pal-card__small">Verification Status</div>
|
||||
<div class="pal-card__title">PAN ↔ Aadhaar</div>
|
||||
<div class="pal-card__status">
|
||||
@if (kycData?.panAadhaarSeedingStatus === 'Y') {
|
||||
<mat-icon>check_circle</mat-icon> Linked (Seeded)
|
||||
} @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 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 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) -->
|
||||
@if (showAadhaarReVerify && !aadhaarOtpSent) {
|
||||
@@ -795,15 +833,19 @@
|
||||
<mat-card-content>
|
||||
<form [formGroup]="kycForm" (ngSubmit)="saveKyc()">
|
||||
<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-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>
|
||||
</div>
|
||||
<div class="kyc-input-row">
|
||||
<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
|
||||
matInput
|
||||
formControlName="panNumber"
|
||||
@@ -852,147 +894,154 @@
|
||||
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
|
||||
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">
|
||||
<span>Customer's PAN card genuinely carries different details, or PAN cannot be checked?</span>
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
[disabled]="!kycId || saving"
|
||||
(click)="takePanDeclaration()"
|
||||
>
|
||||
{{ panDeclarationTaken ? 'Edit Declaration' : 'Take Declaration' }}
|
||||
</button>
|
||||
</div>
|
||||
@if (!isNonIndividual) {
|
||||
<div class="kyc-decl-offer">
|
||||
<span>Customer's PAN card genuinely carries different details, or PAN cannot be checked?</span>
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
[disabled]="!kycId || saving"
|
||||
(click)="takePanDeclaration()"
|
||||
>
|
||||
{{ panDeclarationTaken ? 'Edit Declaration' : 'Take Declaration' }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@if (verificationStatus.pan === 'VERIFIED' && verificationDetails.pan?.name) {
|
||||
<span class="verification-detail-text">{{ verificationDetails.pan.name }}</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Aadhaar -->
|
||||
<div class="kyc-edit-field">
|
||||
<div class="kyc-field-label-row">
|
||||
<span class="kyc-field-title">{{ 'labels.inputs.Aadhaar Number' | translate }}</span>
|
||||
<span class="kyc-tag">KYC</span>
|
||||
</div>
|
||||
<div class="kyc-input-row">
|
||||
<mat-form-field appearance="outline" class="kyc-input">
|
||||
<mat-label>{{ 'labels.inputs.Aadhaar Number' | translate }}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="aadhaarNumber"
|
||||
maxlength="12"
|
||||
inputmode="numeric"
|
||||
autocomplete="off"
|
||||
placeholder="Enter 12-digit Aadhaar"
|
||||
[readonly]="aadhaarOtpSent"
|
||||
/>
|
||||
@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
|
||||
<!-- Aadhaar — individuals only; an entity has no Aadhaar to capture or verify. -->
|
||||
@if (!isNonIndividual) {
|
||||
<div class="kyc-edit-field">
|
||||
<div class="kyc-field-label-row">
|
||||
<span class="kyc-field-title">{{ 'labels.inputs.Aadhaar Number' | translate }}</span>
|
||||
<span class="kyc-tag">KYC</span>
|
||||
</div>
|
||||
<div class="kyc-input-row">
|
||||
<mat-form-field appearance="outline" class="kyc-input">
|
||||
<mat-label>{{ 'labels.inputs.Aadhaar Number' | translate }}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="aadhaarNumber"
|
||||
maxlength="12"
|
||||
inputmode="numeric"
|
||||
autocomplete="off"
|
||||
placeholder="Enter 12-digit Aadhaar"
|
||||
[readonly]="aadhaarOtpSent"
|
||||
/>
|
||||
@if (kycData?.aadhaarNumberMasked) {
|
||||
<mat-hint>On file: {{ kycData.aadhaarNumberMasked }}</mat-hint>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- OTP Section in Edit Mode -->
|
||||
@if (aadhaarOtpSent) {
|
||||
<div class="otp-section">
|
||||
<p class="otp-info">
|
||||
<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>
|
||||
@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"
|
||||
class="otp-verify-btn"
|
||||
type="button"
|
||||
[disabled]="!aadhaarOtp || aadhaarOtp.length < 4 || aadhaarOtpVerifying"
|
||||
(click)="verifyAadhaarOtp()"
|
||||
class="verify-btn"
|
||||
[disabled]="
|
||||
!kycId ||
|
||||
!kycForm.get('aadhaarNumber')?.value ||
|
||||
kycForm.get('aadhaarNumber')?.invalid ||
|
||||
verifying.aadhaar
|
||||
"
|
||||
(click)="sendAadhaarOtp()"
|
||||
[matTooltip]="!kycId ? 'Save KYC details first' : ''"
|
||||
>
|
||||
@if (aadhaarOtpVerifying) {
|
||||
<mat-progress-spinner diameter="14" mode="indeterminate"></mat-progress-spinner>
|
||||
@if (verifying.aadhaar) {
|
||||
<mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner>
|
||||
} @else {
|
||||
Verify OTP
|
||||
Send 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>
|
||||
<!--
|
||||
<!-- OTP Section in Edit Mode -->
|
||||
@if (aadhaarOtpSent) {
|
||||
<div class="otp-section">
|
||||
<p class="otp-info">
|
||||
<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
|
||||
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.
|
||||
-->
|
||||
<div class="kyc-decl-offer">
|
||||
<span>Customer has applied for Aadhaar but not received it yet?</span>
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
[disabled]="!kycId || saving"
|
||||
(click)="takeAadhaarReceipt()"
|
||||
>
|
||||
{{ aadhaarReceiptTaken ? 'Edit Receipt' : 'Record Receipt' }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="kyc-decl-offer">
|
||||
<span>Customer has applied for Aadhaar but not received it yet?</span>
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
[disabled]="!kycId || saving"
|
||||
(click)="takeAadhaarReceipt()"
|
||||
>
|
||||
{{ aadhaarReceiptTaken ? 'Edit Receipt' : 'Record Receipt' }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- GSTIN (Non-Individual only) -->
|
||||
@if (isNonIndividual) {
|
||||
|
||||
@@ -221,6 +221,17 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
||||
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. */
|
||||
get panDeclarationMismatchLabel(): string {
|
||||
const fields: string = this.kycData?.panDeclarationMismatchFields;
|
||||
|
||||
@@ -222,6 +222,14 @@
|
||||
<div class="pd-field__value monospace">{{ clientViewData.clientNonPersonDetails.incorpNumber }}</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) {
|
||||
<div class="pd-field full-width">
|
||||
<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`, {});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Sends signatory clientId, DIN number and the non-individual entity name.
|
||||
|
||||
@@ -384,7 +384,8 @@ export class CreateClientComponent {
|
||||
this.dialog
|
||||
.open(VerifyClientAlertDialogComponent, {
|
||||
width: '420px',
|
||||
autoFocus: false
|
||||
autoFocus: false,
|
||||
data: { isEntity: this.legalFormType === 2 }
|
||||
})
|
||||
.afterClosed()
|
||||
.subscribe(() => {
|
||||
@@ -1159,8 +1160,8 @@ export class SaveConfirmDialogComponent {}
|
||||
</div>
|
||||
<div class="vcad-body">
|
||||
<p>
|
||||
Please click <strong>Verify Client</strong> to screen the client name against the UN Security Council
|
||||
Consolidated Sanctions List before proceeding.
|
||||
Please click <strong>{{ data?.isEntity ? 'Verify Entity' : 'Verify Client' }}</strong> to screen the client
|
||||
name against the UN Security Council Consolidated Sanctions List before proceeding.
|
||||
</p>
|
||||
<div class="vcad-info">
|
||||
<mat-icon class="vcad-info__icon">gavel</mat-icon>
|
||||
@@ -1269,4 +1270,6 @@ export class SaveConfirmDialogComponent {}
|
||||
MatDialogClose
|
||||
]
|
||||
})
|
||||
export class VerifyClientAlertDialogComponent {}
|
||||
export class VerifyClientAlertDialogComponent {
|
||||
data = inject(MAT_DIALOG_DATA);
|
||||
}
|
||||
|
||||
@@ -215,11 +215,18 @@
|
||||
</div>
|
||||
}
|
||||
<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
|
||||
#clientKyc
|
||||
[isNonIndividual]="legalFormType === 2"
|
||||
[isMinor]="clientGeneralStep?.isMinor || false"
|
||||
[clientId]="clientId"
|
||||
[constitutionMeta]="clientGeneralStep?.selectedConstitution"
|
||||
[entityName]="clientGeneralStep?.createClientForm?.get('fullname')?.value || ''"
|
||||
[verificationName]="clientGeneralStep?.verificationName || ''"
|
||||
[verificationDateOfBirth]="clientGeneralStep?.verificationDateOfBirth || null"
|
||||
[readOnly]="isTabReadOnly(1)"
|
||||
|
||||
@@ -40,6 +40,7 @@ import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
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.
|
||||
@@ -589,13 +590,11 @@ export class EditClientComponent {
|
||||
const kycId = this.clientKycStep.kycId;
|
||||
const isVerified = this.clientKycStep.isKycVerified;
|
||||
|
||||
// Validate remarks are present for verified KYC
|
||||
if (isVerified && !kycData.remarks?.trim()) {
|
||||
this.savingClient = false;
|
||||
this.clientKycStep.kycForm.get('remarks')?.markAsTouched();
|
||||
return;
|
||||
}
|
||||
|
||||
// Remarks are not demanded here. The server requires them only when a save actually
|
||||
// overwrites data the verified record already held, and it names the reason when it
|
||||
// refuses. Blocking every verified record up front aborted the save silently — the photo
|
||||
// upload above had already gone through, so the image changed, the button came back to
|
||||
// life, and nothing said why nothing had been saved.
|
||||
let saveObs;
|
||||
if (kycId && isVerified) {
|
||||
// VERIFIED → re-edit command
|
||||
@@ -628,8 +627,19 @@ export class EditClientComponent {
|
||||
this.saveKycDocProof(this.clientId);
|
||||
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;
|
||||
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 {
|
||||
@@ -734,17 +744,20 @@ export class EditClientComponent {
|
||||
? this.toLocalDateString(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 }) =>
|
||||
(
|
||||
toProperNameCase(
|
||||
parts.fullname ||
|
||||
[
|
||||
parts.firstname,
|
||||
parts.middlename,
|
||||
parts.lastname
|
||||
]
|
||||
.filter((p) => p && p.trim())
|
||||
.join(' ')
|
||||
).trim();
|
||||
[
|
||||
parts.firstname,
|
||||
parts.middlename,
|
||||
parts.lastname
|
||||
]
|
||||
.filter((p) => p && p.trim())
|
||||
.join(' ')
|
||||
);
|
||||
|
||||
const newName = buildName({
|
||||
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">
|
||||
<!-- HEADER -->
|
||||
<div class="alm-head">
|
||||
<!-- Window chrome. Screen only — the print takes the statement's own heading, not this bar. -->
|
||||
<div class="alm-head no-print">
|
||||
<div class="alm-head-left">
|
||||
<div class="alm-head-ic"><fa-icon icon="balance-scale"></fa-icon></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">
|
||||
{{ bank.name }} · <strong>{{ bank.formCode }}</strong> · {{ bank.quarter }} · As on
|
||||
<strong>{{ bank.asOnDate }}</strong>
|
||||
{{ bank.name }} · {{ branchName }} · FY {{ bank.financialYear }} · Month-wise during the
|
||||
year
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="alm-head-actions">
|
||||
<span class="alm-overall" [ngClass]="gap14dWithinTolerance && gap28dWithinTolerance ? 'on-target' : 'below'">
|
||||
<span class="alm-overall-l">14d Tolerance</span>
|
||||
<span class="alm-overall-v">{{ gap14dPct.toFixed(2) }}%</span>
|
||||
<span class="alm-overall-t">/ -{{ bank.tolerance14d }}%</span>
|
||||
</span>
|
||||
<button class="alm-close" (click)="close()" matTooltip="Close" aria-label="Close">✕</button>
|
||||
<button class="alm-close" (click)="close()" matTooltip="Close" aria-label="Close">✕</button>
|
||||
</div>
|
||||
|
||||
<!-- Report parameters and actions, the way a report is run in the core system -->
|
||||
<div class="alm-params no-print">
|
||||
<label class="alm-param">
|
||||
<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>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
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/.
|
||||
*/
|
||||
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { Component, ElementRef, ViewChild, inject } from '@angular/core';
|
||||
import { CommonModule, DecimalPipe } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
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 { AuthenticationService } from 'app/core/authentication/authentication.service';
|
||||
|
||||
interface MaturityBucket {
|
||||
code: string;
|
||||
label: string;
|
||||
/** Outflows / Liabilities (₹ Lakhs) */
|
||||
outflow: number;
|
||||
/** Inflows / Assets (₹ Lakhs) */
|
||||
inflow: number;
|
||||
}
|
||||
|
||||
interface MonthSnapshot {
|
||||
/**
|
||||
* One month of the average liabilities and assets extract, column for column as the core system
|
||||
* prints it.
|
||||
*/
|
||||
interface MonthlyAlmRow {
|
||||
/** e.g. 'APR-2025' */
|
||||
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. */
|
||||
capital: number;
|
||||
/** Total deposits — the FD component above is part of this figure. */
|
||||
reserveProv: number;
|
||||
/** Total deposits — demand and time. */
|
||||
deposits: number;
|
||||
borrowings: number;
|
||||
otherLiabilities: number;
|
||||
cashOnHand: number;
|
||||
/** Balances with other banks, current accounts. */
|
||||
bankCurrent: number;
|
||||
/** Balances with other banks, held as fixed deposits. */
|
||||
bankFds: number;
|
||||
investments: number;
|
||||
advances: number;
|
||||
otherAssets: number;
|
||||
}
|
||||
|
||||
/** One row of the month-wise average statement — a month, or the Total / Average line. */
|
||||
interface AvgBalanceRow {
|
||||
/** One printed line — a month, or the Total / Average line the statement closes with. */
|
||||
interface StatementRow {
|
||||
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;
|
||||
capital: number;
|
||||
reserveProv: number;
|
||||
@@ -66,6 +58,18 @@ interface AvgBalanceRow {
|
||||
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({
|
||||
selector: 'mifosx-alm-report-dialog',
|
||||
standalone: true,
|
||||
@@ -86,310 +90,226 @@ export class AlmReportDialogComponent {
|
||||
private branchScope = inject(BranchScopeService);
|
||||
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',
|
||||
branch: 'Head Office',
|
||||
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',
|
||||
financialYear: '2025-26',
|
||||
reportRefNo: '1036',
|
||||
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
|
||||
userCode: 'CBSN01'
|
||||
};
|
||||
|
||||
/** Standard RBI ALM time buckets per Master Direction */
|
||||
buckets: MaturityBucket[] = [
|
||||
{ code: 'B01', label: 'Day 1', outflow: 1840, inflow: 2120 },
|
||||
{ code: 'B02', label: '2 to 7 days', outflow: 6420, inflow: 5880 },
|
||||
{ code: 'B03', label: '8 to 14 days', outflow: 4180, inflow: 4360 },
|
||||
{ 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[] = [
|
||||
/**
|
||||
* The financial year, April to March. A part-year print simply stops at the month asked for, which
|
||||
* is why `upToIndex` exists rather than the row list being cut to size here.
|
||||
*/
|
||||
monthly: MonthlyAlmRow[] = [
|
||||
{
|
||||
month: 'APR-2025',
|
||||
reserveProv: 18420,
|
||||
borrowings: 24180,
|
||||
fds: 462800,
|
||||
cashOnHand: 8420,
|
||||
investments: 184600,
|
||||
otherAssets: 12480,
|
||||
capital: 24200,
|
||||
deposits: 602800,
|
||||
otherLiabilities: 28400,
|
||||
bankCurrent: 18900,
|
||||
bankFds: 46200,
|
||||
advances: 372400
|
||||
capital: 264.66,
|
||||
reserveProv: 720.83,
|
||||
deposits: 6108.63,
|
||||
borrowings: 24.47,
|
||||
otherLiabilities: 280.91,
|
||||
cashOnHand: 172.44,
|
||||
bankCurrent: 190.09,
|
||||
bankFds: 0,
|
||||
investments: 0,
|
||||
advances: 0,
|
||||
otherAssets: 0
|
||||
},
|
||||
{
|
||||
month: 'MAY-2025',
|
||||
reserveProv: 18640,
|
||||
borrowings: 23980,
|
||||
fds: 468200,
|
||||
cashOnHand: 8680,
|
||||
investments: 186400,
|
||||
otherAssets: 12620,
|
||||
capital: 24280,
|
||||
deposits: 608600,
|
||||
otherLiabilities: 28560,
|
||||
bankCurrent: 19240,
|
||||
bankFds: 46600,
|
||||
advances: 376800
|
||||
capital: 270.73,
|
||||
reserveProv: 722.11,
|
||||
deposits: 6181.15,
|
||||
borrowings: 26.36,
|
||||
otherLiabilities: 280.91,
|
||||
cashOnHand: 129.0,
|
||||
bankCurrent: 218.27,
|
||||
bankFds: 0,
|
||||
investments: 0,
|
||||
advances: 0,
|
||||
otherAssets: 0
|
||||
},
|
||||
{
|
||||
month: 'JUN-2025',
|
||||
reserveProv: 18820,
|
||||
borrowings: 23640,
|
||||
fds: 472400,
|
||||
cashOnHand: 9120,
|
||||
investments: 188200,
|
||||
otherAssets: 12840,
|
||||
capital: 24360,
|
||||
deposits: 613400,
|
||||
otherLiabilities: 28720,
|
||||
bankCurrent: 19580,
|
||||
bankFds: 47000,
|
||||
advances: 381200
|
||||
capital: 272.61,
|
||||
reserveProv: 722.11,
|
||||
deposits: 6291.01,
|
||||
borrowings: 33.0,
|
||||
otherLiabilities: 280.91,
|
||||
cashOnHand: 115.01,
|
||||
bankCurrent: 322.77,
|
||||
bankFds: 0,
|
||||
investments: 0,
|
||||
advances: 0,
|
||||
otherAssets: 0
|
||||
},
|
||||
{
|
||||
month: 'JUL-2025',
|
||||
reserveProv: 19080,
|
||||
borrowings: 23420,
|
||||
fds: 478600,
|
||||
cashOnHand: 9280,
|
||||
investments: 190400,
|
||||
otherAssets: 13020,
|
||||
capital: 24460,
|
||||
deposits: 619800,
|
||||
otherLiabilities: 28880,
|
||||
bankCurrent: 19860,
|
||||
bankFds: 47400,
|
||||
advances: 385600
|
||||
capital: 274.06,
|
||||
reserveProv: 755.72,
|
||||
deposits: 6299.45,
|
||||
borrowings: 50.07,
|
||||
otherLiabilities: 280.91,
|
||||
cashOnHand: 84.82,
|
||||
bankCurrent: 267.13,
|
||||
bankFds: 0,
|
||||
investments: 0,
|
||||
advances: 0,
|
||||
otherAssets: 0
|
||||
},
|
||||
{
|
||||
month: 'AUG-2025',
|
||||
reserveProv: 19280,
|
||||
borrowings: 23180,
|
||||
fds: 482400,
|
||||
cashOnHand: 9420,
|
||||
investments: 192800,
|
||||
otherAssets: 13280,
|
||||
capital: 24540,
|
||||
deposits: 624200,
|
||||
otherLiabilities: 29040,
|
||||
bankCurrent: 20180,
|
||||
bankFds: 47800,
|
||||
advances: 390200
|
||||
capital: 273.22,
|
||||
reserveProv: 755.72,
|
||||
deposits: 6404.97,
|
||||
borrowings: 26.53,
|
||||
otherLiabilities: 280.91,
|
||||
cashOnHand: 111.77,
|
||||
bankCurrent: 253.24,
|
||||
bankFds: 0,
|
||||
investments: 0,
|
||||
advances: 0,
|
||||
otherAssets: 0
|
||||
},
|
||||
{
|
||||
month: 'SEP-2025',
|
||||
reserveProv: 19460,
|
||||
borrowings: 22940,
|
||||
fds: 488200,
|
||||
cashOnHand: 9640,
|
||||
investments: 195200,
|
||||
otherAssets: 13420,
|
||||
capital: 24620,
|
||||
deposits: 630400,
|
||||
otherLiabilities: 29200,
|
||||
bankCurrent: 20460,
|
||||
bankFds: 48200,
|
||||
advances: 394800
|
||||
capital: 275.34,
|
||||
reserveProv: 755.72,
|
||||
deposits: 6361.83,
|
||||
borrowings: 30.3,
|
||||
otherLiabilities: 280.91,
|
||||
cashOnHand: 136.45,
|
||||
bankCurrent: 190.83,
|
||||
bankFds: 0,
|
||||
investments: 0,
|
||||
advances: 0,
|
||||
otherAssets: 0
|
||||
},
|
||||
{
|
||||
month: 'OCT-2025',
|
||||
reserveProv: 19680,
|
||||
borrowings: 22680,
|
||||
fds: 492600,
|
||||
cashOnHand: 9820,
|
||||
investments: 197600,
|
||||
otherAssets: 13620,
|
||||
capital: 24720,
|
||||
deposits: 635200,
|
||||
otherLiabilities: 29380,
|
||||
bankCurrent: 20740,
|
||||
bankFds: 48600,
|
||||
advances: 399400
|
||||
capital: 274.82,
|
||||
reserveProv: 755.72,
|
||||
deposits: 6304.25,
|
||||
borrowings: 38.0,
|
||||
otherLiabilities: 280.91,
|
||||
cashOnHand: 93.77,
|
||||
bankCurrent: 239.01,
|
||||
bankFds: 0,
|
||||
investments: 0,
|
||||
advances: 0,
|
||||
otherAssets: 0
|
||||
},
|
||||
{
|
||||
month: 'NOV-2025',
|
||||
reserveProv: 19880,
|
||||
borrowings: 22420,
|
||||
fds: 498400,
|
||||
cashOnHand: 9980,
|
||||
investments: 200200,
|
||||
otherAssets: 13820,
|
||||
capital: 24800,
|
||||
deposits: 641000,
|
||||
otherLiabilities: 29540,
|
||||
bankCurrent: 21020,
|
||||
bankFds: 49000,
|
||||
advances: 404200
|
||||
capital: 278.29,
|
||||
reserveProv: 755.72,
|
||||
deposits: 6269.1,
|
||||
borrowings: 52.64,
|
||||
otherLiabilities: 280.91,
|
||||
cashOnHand: 92.03,
|
||||
bankCurrent: 215.31,
|
||||
bankFds: 0,
|
||||
investments: 0,
|
||||
advances: 0,
|
||||
otherAssets: 0
|
||||
},
|
||||
{
|
||||
month: 'DEC-2025',
|
||||
reserveProv: 20120,
|
||||
borrowings: 22180,
|
||||
fds: 504200,
|
||||
cashOnHand: 10240,
|
||||
investments: 202800,
|
||||
otherAssets: 14040,
|
||||
capital: 24900,
|
||||
deposits: 646800,
|
||||
otherLiabilities: 29720,
|
||||
bankCurrent: 21340,
|
||||
bankFds: 49400,
|
||||
advances: 409000
|
||||
capital: 278.97,
|
||||
reserveProv: 755.72,
|
||||
deposits: 6310.57,
|
||||
borrowings: 50.01,
|
||||
otherLiabilities: 280.91,
|
||||
cashOnHand: 98.58,
|
||||
bankCurrent: 221.93,
|
||||
bankFds: 0,
|
||||
investments: 0,
|
||||
advances: 0,
|
||||
otherAssets: 0
|
||||
},
|
||||
{
|
||||
month: 'JAN-2026',
|
||||
reserveProv: 20340,
|
||||
borrowings: 21940,
|
||||
fds: 508800,
|
||||
cashOnHand: 10420,
|
||||
investments: 205200,
|
||||
otherAssets: 14240,
|
||||
capital: 24980,
|
||||
deposits: 651600,
|
||||
otherLiabilities: 29880,
|
||||
bankCurrent: 21620,
|
||||
bankFds: 49800,
|
||||
advances: 413800
|
||||
capital: 280.14,
|
||||
reserveProv: 755.72,
|
||||
deposits: 6358.2,
|
||||
borrowings: 47.85,
|
||||
otherLiabilities: 280.91,
|
||||
cashOnHand: 101.36,
|
||||
bankCurrent: 228.47,
|
||||
bankFds: 0,
|
||||
investments: 0,
|
||||
advances: 0,
|
||||
otherAssets: 0
|
||||
},
|
||||
{
|
||||
month: 'FEB-2026',
|
||||
reserveProv: 20580,
|
||||
borrowings: 21680,
|
||||
fds: 514600,
|
||||
cashOnHand: 10620,
|
||||
investments: 207800,
|
||||
otherAssets: 14460,
|
||||
capital: 25060,
|
||||
deposits: 657400,
|
||||
otherLiabilities: 30040,
|
||||
bankCurrent: 21900,
|
||||
bankFds: 50200,
|
||||
advances: 418600
|
||||
capital: 281.42,
|
||||
reserveProv: 768.4,
|
||||
deposits: 6402.75,
|
||||
borrowings: 44.62,
|
||||
otherLiabilities: 280.91,
|
||||
cashOnHand: 104.19,
|
||||
bankCurrent: 233.08,
|
||||
bankFds: 0,
|
||||
investments: 0,
|
||||
advances: 0,
|
||||
otherAssets: 0
|
||||
},
|
||||
{
|
||||
month: 'MAR-2026',
|
||||
reserveProv: 20840,
|
||||
borrowings: 21420,
|
||||
fds: 520400,
|
||||
cashOnHand: 10820,
|
||||
investments: 210400,
|
||||
otherAssets: 14680,
|
||||
capital: 25160,
|
||||
deposits: 663200,
|
||||
otherLiabilities: 30220,
|
||||
bankCurrent: 22240,
|
||||
bankFds: 50600,
|
||||
advances: 423600
|
||||
capital: 283.05,
|
||||
reserveProv: 768.4,
|
||||
deposits: 6471.38,
|
||||
borrowings: 41.3,
|
||||
otherLiabilities: 280.91,
|
||||
cashOnHand: 110.62,
|
||||
bankCurrent: 241.66,
|
||||
bankFds: 0,
|
||||
investments: 0,
|
||||
advances: 0,
|
||||
otherAssets: 0
|
||||
}
|
||||
];
|
||||
|
||||
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 --------------------
|
||||
bucketGap(b: MaturityBucket): number {
|
||||
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);
|
||||
}
|
||||
/** Columns the core-system extract does not map. Named on screen, not quietly filled in. */
|
||||
readonly unmappedColumns = 'Investments, Advances, Bank-FD balances and Other Assets';
|
||||
|
||||
get totalOutflow(): number {
|
||||
return this.buckets.reduce((s, b) => s + b.outflow, 0);
|
||||
}
|
||||
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.
|
||||
/** Stamped when the statement is run, and again whenever the parameters change it. */
|
||||
generatedAt = this.formatPrintDate(new Date());
|
||||
|
||||
/** "<BANK> , <OFFICE>" — the banner line the printed statement opens with. */
|
||||
get statementHeading(): string {
|
||||
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. */
|
||||
get statementUserCode(): string {
|
||||
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. */
|
||||
readonly statementPrintedAt = this.formatPrintDate(new Date());
|
||||
/** Month choices for the "up to" parameter, e.g. 'DEC-2025'. */
|
||||
get monthOptions(): { index: number; label: string }[] {
|
||||
return this.monthly.map((m, index) => ({ index, label: m.month }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Month rows, then the Total and Average lines the statement closes with.
|
||||
*
|
||||
* 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) => {
|
||||
/** The months the statement prints. */
|
||||
get statementRows(): StatementRow[] {
|
||||
return this.monthly.slice(0, this.upToIndex + 1).map((m) => {
|
||||
const [
|
||||
name,
|
||||
year
|
||||
@@ -412,7 +332,7 @@ export class AlmReportDialogComponent {
|
||||
});
|
||||
}
|
||||
|
||||
private readonly avgBalanceColumns: (keyof AvgBalanceRow)[] = [
|
||||
private readonly amountColumns: (keyof StatementRow)[] = [
|
||||
'capital',
|
||||
'reserveProv',
|
||||
'deposits',
|
||||
@@ -426,190 +346,92 @@ export class AlmReportDialogComponent {
|
||||
'otherAssets'
|
||||
];
|
||||
|
||||
get avgBalanceTotal(): AvgBalanceRow {
|
||||
const rows = this.avgBalanceRows;
|
||||
const out = { label: 'Total', sub: '' } as AvgBalanceRow;
|
||||
for (const key of this.avgBalanceColumns) {
|
||||
out[key] = rows.reduce((s, r) => s + (r[key] as number), 0) as never;
|
||||
get totalRow(): StatementRow {
|
||||
const rows = this.statementRows;
|
||||
const out = { label: 'Total', sub: '' } as StatementRow;
|
||||
for (const key of this.amountColumns) {
|
||||
out[key] = +rows.reduce((s, r) => s + (r[key] as number), 0).toFixed(2) as never;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
get avgBalanceAverage(): AvgBalanceRow {
|
||||
const rows = this.avgBalanceRows;
|
||||
const total = this.avgBalanceTotal;
|
||||
const out = { label: 'Average', sub: '' } as AvgBalanceRow;
|
||||
for (const key of this.avgBalanceColumns) {
|
||||
/**
|
||||
* Mean of the months printed, not Total ÷ 12 — a statement that stops at December must divide by
|
||||
* nine, or every average on it is understated.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** dd/MM/yyyy hh:mm:ssAM — the format the printed statement stamps its footer with. */
|
||||
private formatPrintDate(d: Date): string {
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
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}`
|
||||
);
|
||||
/** Re-running the statement re-stamps the footer, since the figures on it have changed. */
|
||||
runReport(): void {
|
||||
this.generatedAt = this.formatPrintDate(new Date());
|
||||
}
|
||||
|
||||
/** Monthly totals helpers */
|
||||
monthTotalLiab(m: MonthSnapshot): number {
|
||||
return m.reserveProv + m.borrowings + m.fds;
|
||||
}
|
||||
monthTotalAssets(m: MonthSnapshot): number {
|
||||
return m.cashOnHand + m.investments + m.otherAssets;
|
||||
}
|
||||
/** Average across months */
|
||||
avg<K extends keyof MonthSnapshot>(key: K): number {
|
||||
if (typeof this.monthly[0][key] !== 'number') return 0;
|
||||
const total = this.monthly.reduce((s, m) => s + (m[key] as number), 0);
|
||||
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)
|
||||
]);
|
||||
/**
|
||||
* Prints the statement on its own.
|
||||
*
|
||||
* The dialog sits in an overlay on top of the application, so `window.print()` would put the whole
|
||||
* screen behind it on the paper. The rendered statement is handed to a window of its own instead,
|
||||
* which needs no application-wide print stylesheet and cannot pick up anything else on the page.
|
||||
*/
|
||||
print(): void {
|
||||
const node = this.statementRef?.nativeElement;
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
rows.push([
|
||||
'AVERAGE',
|
||||
this.avg('reserveProv').toFixed(2),
|
||||
this.avg('borrowings').toFixed(2),
|
||||
this.avg('fds').toFixed(2),
|
||||
(this.avg('reserveProv') + this.avg('borrowings') + this.avg('fds')).toFixed(2),
|
||||
this.avg('cashOnHand').toFixed(2),
|
||||
this.avg('investments').toFixed(2),
|
||||
this.avg('otherAssets').toFixed(2),
|
||||
(this.avg('cashOnHand') + this.avg('investments') + this.avg('otherAssets')).toFixed(2)
|
||||
]);
|
||||
this.saveTsv(rows, `ALM-Monthly-${this.dateTag()}.xls`);
|
||||
const win = window.open('', '_blank', 'width=1100,height=760');
|
||||
if (!win) {
|
||||
// Blocked by the browser — fall back to the page print, which the print stylesheet handles.
|
||||
window.print();
|
||||
return;
|
||||
}
|
||||
win.document.write(
|
||||
`<!doctype html><html><head><title>Average Liabilities and Assets — ${this.bank.financialYear}</title>` +
|
||||
`<style>${AlmReportDialogComponent.PRINT_CSS}</style></head><body>${node.outerHTML}</body></html>`
|
||||
);
|
||||
win.document.close();
|
||||
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. */
|
||||
exportAvgBalancesXls(): void {
|
||||
/** Stylesheet for the print window — the statement as the core system lays it out on paper. */
|
||||
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[][] = [];
|
||||
rows.push([this.statementHeading]);
|
||||
rows.push(['Average Liabilities and Assets Month-wise during the year']);
|
||||
@@ -628,7 +450,7 @@ export class AlmReportDialogComponent {
|
||||
'Advances',
|
||||
'Other Assets Exclude IRA'
|
||||
]);
|
||||
const line = (r: AvgBalanceRow): string[] => [
|
||||
const line = (r: StatementRow): string[] => [
|
||||
`${r.label} ${r.sub}`.trim(),
|
||||
r.capital.toFixed(2),
|
||||
r.reserveProv.toFixed(2),
|
||||
@@ -642,71 +464,38 @@ export class AlmReportDialogComponent {
|
||||
r.advances.toFixed(2),
|
||||
r.otherAssets.toFixed(2)
|
||||
];
|
||||
this.avgBalanceRows.forEach((r) => rows.push(line(r)));
|
||||
rows.push(line(this.avgBalanceTotal));
|
||||
rows.push(line(this.avgBalanceAverage));
|
||||
this.statementRows.forEach((r) => rows.push(line(r)));
|
||||
rows.push(line(this.totalRow));
|
||||
rows.push(line(this.averageRow));
|
||||
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}`]);
|
||||
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 {
|
||||
const d = new Date();
|
||||
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');
|
||||
this.saveBlob(tsv, filename, 'application/vnd.ms-excel');
|
||||
}
|
||||
private saveCsv(rows: string[][], filename: string): void {
|
||||
const escape = (v: string): string => {
|
||||
const s = (v || '').replace(/"/g, '""');
|
||||
if (s.includes(',') || s.includes('"') || s.includes('\n')) return `"${s}"`;
|
||||
return s;
|
||||
};
|
||||
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);
|
||||
|
||||
/** dd/MM/yyyy hh:mm:ssAM — the format the printed statement stamps its footer with. */
|
||||
private formatPrintDate(d: Date): string {
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
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}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user