feat(loan-origination): guarantor share-account verification, committee resolution gating, legal doc uploads, CIC score-detection fixes, and interest/share rate chart
- Guarantor must be a real existing client with a verified active share account, with clickable share-account chips - Loan Committee Approval now requires Resolution No./Date plus a full application report before approving - Loan Committee gets its own pre-filtered pipeline view via a menu link and stage query param - Legal Approval stage gets a multi-file document upload checklist with add-more support - CIC PDF parser: item-level score detection before line-based fallback, fixing font-size-driven line-merging misdetection - New Interest & Share Rate Chart tab: per-product interest/tenure bands and per-amount-slab Share Capital linkage requirements
This commit is contained in:
@@ -1386,6 +1386,14 @@
|
||||
<button mat-menu-item tabindex="0" routerLink="/loan-origination/pipeline">
|
||||
<fa-icon icon="stream" class="menu-item-icon"></fa-icon> Application Pipeline
|
||||
</button>
|
||||
<button
|
||||
mat-menu-item
|
||||
tabindex="0"
|
||||
routerLink="/loan-origination/pipeline"
|
||||
[queryParams]="{ stage: 'CREDIT_COMMITTEE' }"
|
||||
>
|
||||
<fa-icon icon="users" class="menu-item-icon"></fa-icon> Loan Committee
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0" routerLink="/loan-origination/eligibility-checklist">
|
||||
<fa-icon icon="clipboard-check" class="menu-item-icon"></fa-icon> RBI Eligibility Checklist
|
||||
</button>
|
||||
|
||||
@@ -1879,6 +1879,22 @@
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Loan Committee Approval — {{ sanctioningAuthorityOf(a) }}</div>
|
||||
<div class="loan-action-card">
|
||||
<div class="loi-cic-subsection">
|
||||
<div class="loi-cic-subsection-title">Full Application Report — For the Committee</div>
|
||||
<p class="pg-sub npa-report-note">
|
||||
Everything captured on this application — KYC, CIC score, Field Investigation, property valuation, agent
|
||||
report, meeting and stage history — as one PDF for every committee member to review before deciding.
|
||||
</p>
|
||||
<div class="loan-action-row">
|
||||
<button type="button" class="pg-btn pg-btn--ghost" (click)="printApplicationSummary(a)">
|
||||
🖨️ Print Full Report
|
||||
</button>
|
||||
<button type="button" class="pg-btn pg-btn--ghost" (click)="downloadApplicationSummary(a)">
|
||||
⬇️ Download Full Report
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="pg-sub npa-report-note">Reports Required — present below reports, then complete.</p>
|
||||
<div class="loan-checklist">
|
||||
<label class="loan-checklist-item">
|
||||
@@ -1917,12 +1933,27 @@
|
||||
<span>Committee Remarks</span>
|
||||
<input type="text" [(ngModel)]="actionRemarks" placeholder="Remarks" />
|
||||
</label>
|
||||
<div class="inv-form-grid">
|
||||
<label class="inv-form-field">
|
||||
<span>Resolution No.</span>
|
||||
<input type="text" [(ngModel)]="a.committeeResolutionNo" placeholder="e.g. LC/2026/0042" />
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>Resolution Date</span>
|
||||
<input type="text" [(ngModel)]="a.committeeResolutionDate" placeholder="DD-MM-YYYY" />
|
||||
</label>
|
||||
</div>
|
||||
@if (committeeReportsReady(a) && !committeeCanApprove(a)) {
|
||||
<p class="loi-kyc-error">
|
||||
Enter the Resolution No. and Resolution Date the committee passed this decision under before approving.
|
||||
</p>
|
||||
}
|
||||
<div class="loan-action-row">
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--success"
|
||||
[disabled]="!committeeReportsReady(a)"
|
||||
(click)="approveStage(a, 'creditCommitteeApproval', 'Sanctioning Authority')"
|
||||
[disabled]="!committeeCanApprove(a)"
|
||||
(click)="approveCommitteeStage(a)"
|
||||
>
|
||||
Approve
|
||||
</button>
|
||||
@@ -1973,10 +2004,65 @@
|
||||
</label>
|
||||
</div>
|
||||
@if (a.guarantorRequired) {
|
||||
<label class="inv-form-field">
|
||||
<span>Guarantor Name</span>
|
||||
<input type="text" [(ngModel)]="a.guarantorName" />
|
||||
</label>
|
||||
<div class="loi-cic-subsection">
|
||||
<div class="loi-cic-subsection-title">Guarantor — Must Be an Existing Share Member</div>
|
||||
@if (!a.guarantorClientId) {
|
||||
<label class="inv-form-field">
|
||||
<span>Search Existing Client</span>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="guarantorSearchText"
|
||||
(ngModelChange)="onGuarantorSearch($event)"
|
||||
placeholder="Name, account no., mobile..."
|
||||
/>
|
||||
</label>
|
||||
@if (searchingGuarantor) {
|
||||
<p class="loi-search-hint">Searching…</p>
|
||||
} @else if (guarantorSearchText.length >= 2 && guarantorSearchResults.length === 0) {
|
||||
<p class="loi-search-hint">No clients found.</p>
|
||||
}
|
||||
@if (guarantorSearchResults.length > 0) {
|
||||
<div class="loi-search-results">
|
||||
@for (client of guarantorSearchResults; track client.id) {
|
||||
<button type="button" class="loi-search-row" (click)="selectGuarantor(a, client)">
|
||||
<span class="loi-search-name">{{ client.displayName }}</span>
|
||||
<span class="loi-search-meta">{{ client.accountNo ?? client.officeName ?? '' }}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<p class="loi-kyc-error">
|
||||
The guarantor must already be a bank client with an active share account — search and select them
|
||||
above, a name alone isn't enough.
|
||||
</p>
|
||||
} @else {
|
||||
<label class="inv-form-field">
|
||||
<span>Guarantor</span>
|
||||
<div class="loi-selected-client">
|
||||
<span>{{ a.guarantorName }}</span>
|
||||
<button type="button" class="pg-btn" (click)="clearGuarantor(a)">Change</button>
|
||||
</div>
|
||||
</label>
|
||||
@if (loadingGuarantorShareCheck) {
|
||||
<p class="loi-search-hint">Checking share membership…</p>
|
||||
} @else if (a.guarantorHasShareAccount) {
|
||||
<span class="loan-badge loan-badge--verified">Share Member — Verified</span>
|
||||
<div class="loi-share-account-list">
|
||||
@for (sa of guarantorShareAccounts; track sa.id) {
|
||||
<button type="button" class="loi-share-account-chip" (click)="viewShareAccountDetails(sa.id)">
|
||||
{{ sa.accountNo }} — {{ sa.productName }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<span class="loan-badge loan-badge--missing">No Share Account Found</span>
|
||||
<p class="loi-kyc-error">
|
||||
This client has no active share account — a guarantor must already be a bank member. Pick a
|
||||
different guarantor.
|
||||
</p>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<label class="inv-form-field">
|
||||
<span>Risk Remarks</span>
|
||||
@@ -2028,6 +2114,98 @@
|
||||
<input type="checkbox" [(ngModel)]="a.fileFilingDone" /> File Filing Done
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@if (a.legalDocuments.length > 0) {
|
||||
<div class="loi-cic-subsection">
|
||||
<div class="loi-legal-doc-header">
|
||||
<div class="loi-cic-subsection-title">Legal Documents</div>
|
||||
<span class="loi-legal-doc-progress">
|
||||
{{ legalDocsSubmittedCount(a) }} / {{ a.legalDocuments.length }} submitted
|
||||
</span>
|
||||
</div>
|
||||
<div class="loi-legal-doc-grid">
|
||||
@for (doc of a.legalDocuments; track doc.key) {
|
||||
<div class="loi-legal-doc-card" [class.loi-legal-doc-card--submitted]="doc.submitted">
|
||||
<div class="loi-legal-doc-title-row">
|
||||
<span class="loi-legal-doc-icon">{{ legalDocIcon(doc.key) }}</span>
|
||||
<span class="loi-legal-doc-title">{{ doc.label }}</span>
|
||||
@if (doc.custom) {
|
||||
<button
|
||||
type="button"
|
||||
class="loi-doc-remove"
|
||||
title="Remove this document"
|
||||
(click)="removeCustomLegalDoc(a, doc)"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<div class="loi-kyc-card-files">
|
||||
<label class="pg-btn pg-btn--ghost loi-legal-doc-upload-btn">
|
||||
+ Add File(s)
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*,application/pdf"
|
||||
multiple
|
||||
hidden
|
||||
(change)="onLegalDocFile(doc, $event)"
|
||||
/>
|
||||
</label>
|
||||
@for (capture of legalDocCapture[doc.key]; track $index) {
|
||||
<div class="loi-legal-doc-thumb-wrap">
|
||||
<button
|
||||
type="button"
|
||||
class="loi-kyc-thumb"
|
||||
title="View document"
|
||||
(click)="viewNewSingleDoc(capture.file)"
|
||||
>
|
||||
@if (capture.previewUrl) {
|
||||
<img [src]="capture.previewUrl" alt="{{ doc.label }} {{ $index + 1 }}" />
|
||||
} @else {
|
||||
<span class="loi-kyc-thumb-label">View file</span>
|
||||
}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="loi-legal-doc-thumb-remove"
|
||||
title="Remove this file"
|
||||
(click)="removeLegalDocFile(doc, $index)"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@if (doc.submitted) {
|
||||
<span class="loan-badge loan-badge--verified">
|
||||
✓ Submitted
|
||||
@if (legalDocCapture[doc.key]?.length) {
|
||||
· {{ legalDocCapture[doc.key].length }} file{{
|
||||
legalDocCapture[doc.key].length > 1 ? 's' : ''
|
||||
}}
|
||||
this session
|
||||
}
|
||||
</span>
|
||||
} @else {
|
||||
<span class="loan-badge loan-badge--missing">Missing</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="loan-action-row">
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="newLegalDocLabel"
|
||||
placeholder="Add More Doc"
|
||||
class="loi-custom-doc-input"
|
||||
/>
|
||||
<button type="button" class="pg-btn pg-btn--ghost" (click)="addCustomLegalDoc(a)">
|
||||
+ Add Custom Document
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<label class="inv-form-field">
|
||||
<span>Agreement Signing Slot</span>
|
||||
<input type="text" [(ngModel)]="a.agreementSigningSlot" placeholder="DD-MM-YYYY" />
|
||||
|
||||
@@ -232,6 +232,12 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
|
||||
productDocCapture: Record<string, NewKycSingleDoc> = {};
|
||||
newCustomDocLabel = '';
|
||||
|
||||
/** Same local-only capture, "Add More Doc" included, for `a.legalDocuments` at Legal Approval --
|
||||
* a separate record since it's a different array on the application, not the product's own
|
||||
* document list. */
|
||||
legalDocCapture: Record<string, NewKycSingleDoc[]> = {};
|
||||
newLegalDocLabel = '';
|
||||
|
||||
/** The Housing Loan "KYC Documents of Co-Owners" requirement only actually applies when the
|
||||
* registry lists more than one person -- this checkbox is how the officer says whether that's
|
||||
* the case, before the Aadhaar/PAN capture (front/back + verification, in its own popup so the
|
||||
@@ -417,12 +423,21 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
|
||||
this.cicConsentCapture = { file: null, previewUrl: null };
|
||||
this.cicPaymentRefInput = '';
|
||||
this.agentReportRemarkInput = '';
|
||||
this.guarantorSearchText = '';
|
||||
this.guarantorSearchResults = [];
|
||||
this.guarantorShareAccounts = [];
|
||||
this.revokeProductDocPreviews();
|
||||
this.productDocCapture = {};
|
||||
(this.application?.productDocuments ?? []).forEach((doc) => {
|
||||
this.productDocCapture[doc.key] = { file: null, previewUrl: null };
|
||||
});
|
||||
this.newCustomDocLabel = '';
|
||||
this.revokeLegalDocPreviews();
|
||||
this.legalDocCapture = {};
|
||||
(this.application?.legalDocuments ?? []).forEach((doc) => {
|
||||
this.legalDocCapture[doc.key] = [];
|
||||
});
|
||||
this.newLegalDocLabel = '';
|
||||
this.revokeDocSidePreviews(this.coOwnerCapture.aadhaar);
|
||||
this.revokeDocSidePreviews(this.coOwnerCapture.pan);
|
||||
this.coOwnerCapture = { hasCoOwner: false, aadhaar: this.emptyDocSide(), pan: this.emptyDocSide() };
|
||||
@@ -897,6 +912,88 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
|
||||
delete this.productDocCapture[doc.key];
|
||||
}
|
||||
|
||||
// -------------------- Legal documents (Legal Approval) --------------------
|
||||
/** Any one legal document can genuinely be several files -- a multi-page registered agreement
|
||||
* scanned page by page, more than one mortgagor's stamp paper, etc. -- so each doc key holds an
|
||||
* array here, the same shape the Field Investigation site-photo capture already uses, rather
|
||||
* than a single file that a second upload would silently replace. */
|
||||
async onLegalDocFile(doc: ProductDocumentEntry, event: Event): Promise<void> {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const files = Array.from(input.files ?? []);
|
||||
input.value = '';
|
||||
if (files.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (!this.legalDocCapture[doc.key]) {
|
||||
this.legalDocCapture[doc.key] = [];
|
||||
}
|
||||
for (const file of files) {
|
||||
this.legalDocCapture[doc.key].push({
|
||||
file,
|
||||
previewUrl: file.type.startsWith('image/') ? URL.createObjectURL(file) : null
|
||||
});
|
||||
}
|
||||
doc.submitted = true;
|
||||
}
|
||||
|
||||
removeLegalDocFile(doc: ProductDocumentEntry, index: number): void {
|
||||
const files = this.legalDocCapture[doc.key];
|
||||
if (!files) {
|
||||
return;
|
||||
}
|
||||
const [removed] = files.splice(index, 1);
|
||||
if (removed?.previewUrl) {
|
||||
URL.revokeObjectURL(removed.previewUrl);
|
||||
}
|
||||
}
|
||||
|
||||
private readonly legalDocIcons: Record<string, string> = {
|
||||
loanAgreement: '📜',
|
||||
stampDutyReceipt: '🧾',
|
||||
feeReceipt: '💳',
|
||||
mortgageDeed: '🏠',
|
||||
titleSearchReport: '🔎',
|
||||
fileFilingAck: '🗂️'
|
||||
};
|
||||
|
||||
legalDocIcon(key: string): string {
|
||||
return this.legalDocIcons[key] ?? '📄';
|
||||
}
|
||||
|
||||
legalDocsSubmittedCount(a: LoanApplication): number {
|
||||
return a.legalDocuments.filter((doc) => doc.submitted).length;
|
||||
}
|
||||
|
||||
/** Lets the officer add a document this matter specifically needed beyond the starter list --
|
||||
* a second mortgagor's consent letter, a court NOC, etc. -- same "Add More Doc" pattern the
|
||||
* product-specific documents already use. */
|
||||
addCustomLegalDoc(a: LoanApplication): void {
|
||||
const label = this.newLegalDocLabel.trim();
|
||||
if (!label) {
|
||||
return;
|
||||
}
|
||||
const key = `custom-${a.legalDocuments.length}-${label.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`;
|
||||
a.legalDocuments.push({ key, label, custom: true, submitted: false });
|
||||
this.legalDocCapture[key] = [];
|
||||
this.newLegalDocLabel = '';
|
||||
}
|
||||
|
||||
removeCustomLegalDoc(a: LoanApplication, doc: ProductDocumentEntry): void {
|
||||
if (!doc.custom) {
|
||||
return;
|
||||
}
|
||||
const idx = a.legalDocuments.indexOf(doc);
|
||||
if (idx >= 0) {
|
||||
a.legalDocuments.splice(idx, 1);
|
||||
}
|
||||
(this.legalDocCapture[doc.key] ?? []).forEach((capture) => {
|
||||
if (capture.previewUrl) {
|
||||
URL.revokeObjectURL(capture.previewUrl);
|
||||
}
|
||||
});
|
||||
delete this.legalDocCapture[doc.key];
|
||||
}
|
||||
|
||||
onCoOwnerToggle(a: LoanApplication): void {
|
||||
this.syncCoOwnerDocSubmitted(a);
|
||||
}
|
||||
@@ -1010,6 +1107,7 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
|
||||
this.sub?.unsubscribe();
|
||||
this.revokeNewKycPreviews(this.newKycCapture);
|
||||
this.revokeProductDocPreviews();
|
||||
this.revokeLegalDocPreviews();
|
||||
this.revokeDocSidePreviews(this.coOwnerCapture.aadhaar);
|
||||
this.revokeDocSidePreviews(this.coOwnerCapture.pan);
|
||||
this.revokeCoBorrowerPreviews();
|
||||
@@ -1030,6 +1128,16 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
private revokeLegalDocPreviews(): void {
|
||||
Object.values(this.legalDocCapture).forEach((docs) => {
|
||||
docs.forEach((doc) => {
|
||||
if (doc.previewUrl) {
|
||||
URL.revokeObjectURL(doc.previewUrl);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private toast(msg: string): void {
|
||||
this.toastMsg = msg;
|
||||
this.toastVisible = true;
|
||||
@@ -1984,6 +2092,22 @@ I confirm that:
|
||||
}
|
||||
|
||||
// -------------------- Approvals (Credit Committee / Risk / Legal / Post-Check) --------------------
|
||||
/** The Loan Committee's decision only counts once it's actually minuted -- a resolution number
|
||||
* and date, the same thing that gets quoted on the sanction letter later -- not just a
|
||||
* self-ticked Approve button with nothing behind it. */
|
||||
committeeCanApprove(a: LoanApplication): boolean {
|
||||
return this.committeeReportsReady(a) && !!a.committeeResolutionNo.trim() && !!a.committeeResolutionDate.trim();
|
||||
}
|
||||
|
||||
approveCommitteeStage(a: LoanApplication): void {
|
||||
if (!this.committeeCanApprove(a)) {
|
||||
return;
|
||||
}
|
||||
const remarks = `${this.actionRemarks.trim() || 'Reviewed and approved.'} (Resolution No. ${a.committeeResolutionNo.trim()} dated ${a.committeeResolutionDate.trim()})`;
|
||||
decideApproval(a, 'creditCommitteeApproval', 'APPROVED', this.currentUserName, 'Sanctioning Authority', remarks);
|
||||
this.actionRemarks = '';
|
||||
}
|
||||
|
||||
approveStage(a: LoanApplication, field: ApprovalField, approverRole: string): void {
|
||||
decideApproval(
|
||||
a,
|
||||
@@ -2044,10 +2168,85 @@ I confirm that:
|
||||
a.previousLoanDeclarationDone &&
|
||||
(!a.secured || (a.propertySearchReportDone && a.valuationReportConfirmed)) &&
|
||||
this.fiComplete(a) &&
|
||||
a.backgroundCheckDone
|
||||
a.backgroundCheckDone &&
|
||||
(!a.guarantorRequired || (!!a.guarantorClientId && a.guarantorHasShareAccount))
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------- Guarantor (Risk Approval) --------------------
|
||||
/** A guarantor has to be a real, already-onboarded bank client -- same search-as-you-type
|
||||
* against real Fineract clients the New Application form's "Existing Customer" picker uses --
|
||||
* not a free-text name nobody has actually verified exists. */
|
||||
guarantorSearchText = '';
|
||||
guarantorSearchResults: any[] = [];
|
||||
searchingGuarantor = false;
|
||||
loadingGuarantorShareCheck = false;
|
||||
/** The guarantor's own real share accounts -- shown as clickable chips (same
|
||||
* viewShareAccountDetails popup the applicant's own Share Member section already uses) once
|
||||
* membership is verified, not just a bare "Verified" badge with nothing behind it. */
|
||||
guarantorShareAccounts: any[] = [];
|
||||
private guarantorSearchTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
onGuarantorSearch(text: string): void {
|
||||
this.guarantorSearchText = text;
|
||||
clearTimeout(this.guarantorSearchTimer);
|
||||
if (!text || text.length < 2) {
|
||||
this.guarantorSearchResults = [];
|
||||
return;
|
||||
}
|
||||
this.searchingGuarantor = true;
|
||||
this.guarantorSearchTimer = setTimeout(() => {
|
||||
this.clientsService.searchByText(text, 0, 8, '', '', true, 'INDIVIDUAL').subscribe({
|
||||
next: (data: any) => {
|
||||
this.guarantorSearchResults = data?.content || [];
|
||||
this.searchingGuarantor = false;
|
||||
},
|
||||
error: () => {
|
||||
this.guarantorSearchResults = [];
|
||||
this.searchingGuarantor = false;
|
||||
}
|
||||
});
|
||||
}, 400);
|
||||
}
|
||||
|
||||
selectGuarantor(a: LoanApplication, client: any): void {
|
||||
a.guarantorClientId = client.id;
|
||||
a.guarantorName = client.displayName;
|
||||
a.guarantorHasShareAccount = false;
|
||||
this.guarantorSearchResults = [];
|
||||
this.guarantorSearchText = '';
|
||||
this.verifyGuarantorShareAccount(a);
|
||||
}
|
||||
|
||||
/** Lets the officer pick a different guarantor -- clears the share-account result too, since
|
||||
* it belonged to whichever client was selected before. */
|
||||
clearGuarantor(a: LoanApplication): void {
|
||||
a.guarantorClientId = null;
|
||||
a.guarantorName = '—';
|
||||
a.guarantorHasShareAccount = false;
|
||||
this.guarantorShareAccounts = [];
|
||||
this.guarantorSearchText = '';
|
||||
this.guarantorSearchResults = [];
|
||||
}
|
||||
|
||||
/** Same `/clients/{id}/accounts` call the applicant's own Share Member check uses -- a
|
||||
* guarantor with no active share account isn't actually a member of this cooperative bank,
|
||||
* regardless of what their name is. */
|
||||
private verifyGuarantorShareAccount(a: LoanApplication): void {
|
||||
if (!a.guarantorClientId) {
|
||||
return;
|
||||
}
|
||||
this.loadingGuarantorShareCheck = true;
|
||||
this.clientsService
|
||||
.getClientAccountData(String(a.guarantorClientId))
|
||||
.pipe(catchError(() => of(null)))
|
||||
.subscribe((data: any) => {
|
||||
this.guarantorShareAccounts = data?.shareAccounts ?? [];
|
||||
a.guarantorHasShareAccount = this.guarantorShareAccounts.length > 0;
|
||||
this.loadingGuarantorShareCheck = false;
|
||||
});
|
||||
}
|
||||
|
||||
postCheckSignaturesDone(a: LoanApplication): boolean {
|
||||
return (
|
||||
a.signedByAgent && a.signedByManager && a.signedByCommittee && a.signedByRiskApproval && a.signedByLegalApproval
|
||||
@@ -2064,7 +2263,7 @@ I confirm that:
|
||||
|
||||
let parsed: CicReportAnalysis | null = null;
|
||||
if (file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf')) {
|
||||
parsed = await parseCicPdfReport(file);
|
||||
parsed = await parseCicPdfReport(file, a.cibilScore);
|
||||
}
|
||||
|
||||
if (parsed) {
|
||||
|
||||
+62
-18
@@ -77,15 +77,21 @@ const LOAN_TYPE_KEYWORDS = [
|
||||
* hand-rolled decompressor). Returns null whenever extraction can't find enough of the report's
|
||||
* real structure to trust (no fabricated fallback data for any specific file), so the caller can
|
||||
* fall back to the simulated report instead of silently showing something invented.
|
||||
*
|
||||
* `existingScore` is used only if the score genuinely can't be read as text off the page (some
|
||||
* report generators draw the gauge's number as part of a vector/raster graphic rather than real
|
||||
* selectable text, which no text-extraction approach can recover) -- falling back to whatever
|
||||
* score this application already has on file avoids showing a number that visibly contradicts
|
||||
* the rest of the screen.
|
||||
*/
|
||||
export async function parseCicPdfReport(file: File): Promise<CicReportAnalysis | null> {
|
||||
export async function parseCicPdfReport(file: File, existingScore = 0): Promise<CicReportAnalysis | null> {
|
||||
try {
|
||||
const items = await extractPositionedItems(file);
|
||||
if (items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const lines = reconstructLines(items);
|
||||
return buildAnalysisFromLines(lines, items);
|
||||
return buildAnalysisFromLines(lines, items, existingScore);
|
||||
} catch (err) {
|
||||
console.warn('CIC PDF parse failed, falling back to simulated report:', err);
|
||||
return null;
|
||||
@@ -188,16 +194,41 @@ function detectBureau(fullText: string): string {
|
||||
return 'CIBIL';
|
||||
}
|
||||
|
||||
function detectScore(lines: TextLine[]): number | null {
|
||||
const factorsLine = lineMatching(lines, /score factors/i);
|
||||
const searchFrom = factorsLine ? Math.max(0, lines.indexOf(factorsLine) - 6) : 0;
|
||||
const searchTo = factorsLine ? lines.indexOf(factorsLine) : lines.length;
|
||||
// Walk backwards from just above "Score Factors" -- the score itself sits on the line
|
||||
// immediately before that heading, whereas the "...ranges from 300 - 900" sentence describing
|
||||
// the scale sits further above it and would otherwise be matched first (and wrongly) by the
|
||||
// same 3-digit pattern.
|
||||
for (let i = searchTo - 1; i >= searchFrom; i--) {
|
||||
/** Score digits are normally their own PDF text run (rendered in a much larger font than the
|
||||
* surrounding heading/paragraph text, inside the gauge graphic) -- checking the raw, ungrouped
|
||||
* items directly sidesteps any line-reconstruction ambiguity entirely: it doesn't matter whether
|
||||
* pdf.js's y-tolerance happened to merge that run onto the same visual line as "Score Factors"
|
||||
* or the intro paragraph, because the ITEM itself still carries just "748", not the whole line's
|
||||
* text. This is checked before anything line-based, since it's the least ambiguous signal. */
|
||||
function detectScoreFromItems(items: PositionedItem[]): number | null {
|
||||
const isolated = items
|
||||
.map((item) => item.text.trim())
|
||||
.filter((text) => /^([3-8]\d{2}|900)$/.test(text))
|
||||
.map((text) => parseInt(text, 10));
|
||||
return isolated.length === 1 ? isolated[0] : null;
|
||||
}
|
||||
|
||||
/** Fallback for when the score isn't its own isolated text item (some report generators do embed
|
||||
* it into a longer text run) -- scans lines around the "Score Factors"/"Credit Score" heading,
|
||||
* preferring an isolated number-only line and skipping the "...ranges from 300 - 900" sentence
|
||||
* describing the scale, which matches the exact same 3-digit pattern. */
|
||||
function detectScoreFromLines(lines: TextLine[]): number | null {
|
||||
const anchorLine = lineMatching(lines, /score factors/i) || lineMatching(lines, /credit score/i);
|
||||
const anchorIdx = anchorLine ? lines.indexOf(anchorLine) : Math.floor(lines.length / 2);
|
||||
const from = Math.max(0, anchorIdx - 15);
|
||||
const to = Math.min(lines.length, anchorIdx + 5);
|
||||
|
||||
const candidates: { value: number; isolated: boolean; distance: number }[] = [];
|
||||
for (let i = from; i < to; i++) {
|
||||
const text = lines[i].text;
|
||||
if (/ranges?\s+from/i.test(text)) {
|
||||
continue;
|
||||
}
|
||||
const trimmed = text.trim();
|
||||
if (/^([3-8]\d{2}|900)$/.test(trimmed)) {
|
||||
candidates.push({ value: parseInt(trimmed, 10), isolated: true, distance: Math.abs(i - anchorIdx) });
|
||||
continue;
|
||||
}
|
||||
if (/\d{3}\s*-\s*\d{3}/.test(text)) {
|
||||
continue;
|
||||
}
|
||||
@@ -205,11 +236,19 @@ function detectScore(lines: TextLine[]): number | null {
|
||||
if (m) {
|
||||
const val = parseInt(m[1], 10);
|
||||
if (val >= 300 && val <= 900) {
|
||||
return val;
|
||||
candidates.push({ value: val, isolated: false, distance: Math.abs(i - anchorIdx) });
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
if (candidates.length === 0) {
|
||||
return null;
|
||||
}
|
||||
candidates.sort((a, b) => (a.isolated === b.isolated ? a.distance - b.distance : a.isolated ? -1 : 1));
|
||||
return candidates[0].value;
|
||||
}
|
||||
|
||||
function detectScore(lines: TextLine[], items: PositionedItem[]): number | null {
|
||||
return detectScoreFromItems(items) ?? detectScoreFromLines(lines);
|
||||
}
|
||||
|
||||
function detectApplicantName(lines: TextLine[]): string {
|
||||
@@ -433,17 +472,22 @@ function parseEnquiries(lines: TextLine[]): CicEnquiry[] {
|
||||
return enquiries;
|
||||
}
|
||||
|
||||
function buildAnalysisFromLines(lines: TextLine[], items: PositionedItem[]): CicReportAnalysis | null {
|
||||
const score = detectScore(lines);
|
||||
function buildAnalysisFromLines(
|
||||
lines: TextLine[],
|
||||
items: PositionedItem[],
|
||||
existingScore: number
|
||||
): CicReportAnalysis | null {
|
||||
const detectedScore = detectScore(lines, items);
|
||||
const accounts = parseAccounts(lines, items);
|
||||
if (score === null && accounts.length === 0) {
|
||||
if (detectedScore === null && accounts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fullText = lines.map((l) => l.text).join(' ');
|
||||
const bureau = detectBureau(fullText);
|
||||
const applicantName = detectApplicantName(lines) || 'Applicant';
|
||||
const band = scoreBand(score ?? 650);
|
||||
const score = detectedScore ?? (existingScore > 0 ? existingScore : 650);
|
||||
const band = scoreBand(score);
|
||||
|
||||
const activeAccounts = accounts.filter((a) => a.status === 'ACTIVE').length;
|
||||
const closedAccounts = accounts.filter((a) => a.status === 'CLOSED').length;
|
||||
@@ -480,7 +524,7 @@ function buildAnalysisFromLines(lines: TextLine[], items: PositionedItem[]): Cic
|
||||
bureau,
|
||||
reportDate: new Date().toLocaleDateString('en-IN', { day: '2-digit', month: '2-digit', year: 'numeric' }),
|
||||
applicantName,
|
||||
score: score ?? 650,
|
||||
score,
|
||||
band,
|
||||
scoreFactors: [
|
||||
'Recency: Recent Credit Account Defaults',
|
||||
|
||||
@@ -302,7 +302,12 @@ export interface LoanApplication {
|
||||
|
||||
// Guarantor
|
||||
guarantorRequired: boolean;
|
||||
/** A guarantor has to be a real, already-onboarded bank client with an active share account --
|
||||
* not just a name typed in -- so this is a real Fineract clientId (null until one is actually
|
||||
* picked via search), not a free-text field. */
|
||||
guarantorClientId: number | null;
|
||||
guarantorName: string;
|
||||
guarantorHasShareAccount: boolean;
|
||||
|
||||
// "Reports Required" presented at Loan Committee, and the "Verification Checklist" run at Risk
|
||||
// Approval — kept as their own fields since ID/Address/Income/FI-Report are already covered by
|
||||
@@ -317,6 +322,11 @@ export interface LoanApplication {
|
||||
|
||||
// Multi-level approvals (segregation of duties: Credit / Risk / Legal / Post-Check)
|
||||
creditCommitteeApproval: ApprovalRecord;
|
||||
/** The committee's formal decision reference -- unlike Risk/Legal/Post-Check (single-officer
|
||||
* sign-offs), a Loan Committee decision is minuted with its own resolution number/date, which
|
||||
* is what actually gets quoted in the sanction letter later. */
|
||||
committeeResolutionNo: string;
|
||||
committeeResolutionDate: string;
|
||||
riskApproval: ApprovalRecord;
|
||||
legalApproval: ApprovalRecord;
|
||||
postCheckApproval: ApprovalRecord;
|
||||
@@ -327,6 +337,7 @@ export interface LoanApplication {
|
||||
legalStampDone: boolean;
|
||||
fileFilingDone: boolean;
|
||||
agreementSigningSlot: string;
|
||||
legalDocuments: ProductDocumentEntry[];
|
||||
|
||||
// Signing / disbursement
|
||||
customerSigningDate: string;
|
||||
@@ -420,6 +431,9 @@ interface OriginationProduct {
|
||||
maxTenure: number;
|
||||
/** Rough FOIR-style income multiplier used to derive Eligible Loan Amount from Monthly Income. */
|
||||
incomeMultiplier: number;
|
||||
/** Minimum Share Capital the borrower must hold, as a % of the loan amount, per bank bye-laws
|
||||
* (a UCB norm -- membership/share-linkage against borrowing, distinct from margin/collateral). */
|
||||
shareLinkagePercent: number;
|
||||
}
|
||||
export const ORIGINATION_PRODUCTS: OriginationProduct[] = [
|
||||
{
|
||||
@@ -432,7 +446,8 @@ export const ORIGINATION_PRODUCTS: OriginationProduct[] = [
|
||||
maxRate: 16,
|
||||
minTenure: 12,
|
||||
maxTenure: 48,
|
||||
incomeMultiplier: 20
|
||||
incomeMultiplier: 20,
|
||||
shareLinkagePercent: 5
|
||||
},
|
||||
{
|
||||
code: 'HL',
|
||||
@@ -444,7 +459,8 @@ export const ORIGINATION_PRODUCTS: OriginationProduct[] = [
|
||||
maxRate: 10.5,
|
||||
minTenure: 120,
|
||||
maxTenure: 240,
|
||||
incomeMultiplier: 60
|
||||
incomeMultiplier: 60,
|
||||
shareLinkagePercent: 2.5
|
||||
},
|
||||
{
|
||||
code: 'VL',
|
||||
@@ -456,7 +472,8 @@ export const ORIGINATION_PRODUCTS: OriginationProduct[] = [
|
||||
maxRate: 11.5,
|
||||
minTenure: 36,
|
||||
maxTenure: 84,
|
||||
incomeMultiplier: 36
|
||||
incomeMultiplier: 36,
|
||||
shareLinkagePercent: 2.5
|
||||
},
|
||||
{
|
||||
code: 'GL',
|
||||
@@ -468,7 +485,8 @@ export const ORIGINATION_PRODUCTS: OriginationProduct[] = [
|
||||
maxRate: 12,
|
||||
minTenure: 6,
|
||||
maxTenure: 12,
|
||||
incomeMultiplier: 15
|
||||
incomeMultiplier: 15,
|
||||
shareLinkagePercent: 2
|
||||
},
|
||||
{
|
||||
code: 'CC',
|
||||
@@ -480,7 +498,8 @@ export const ORIGINATION_PRODUCTS: OriginationProduct[] = [
|
||||
maxRate: 13.5,
|
||||
minTenure: 12,
|
||||
maxTenure: 12,
|
||||
incomeMultiplier: 40
|
||||
incomeMultiplier: 40,
|
||||
shareLinkagePercent: 5
|
||||
},
|
||||
{
|
||||
code: 'KCC',
|
||||
@@ -492,7 +511,8 @@ export const ORIGINATION_PRODUCTS: OriginationProduct[] = [
|
||||
maxRate: 9,
|
||||
minTenure: 12,
|
||||
maxTenure: 12,
|
||||
incomeMultiplier: 30
|
||||
incomeMultiplier: 30,
|
||||
shareLinkagePercent: 2.5
|
||||
},
|
||||
{
|
||||
code: 'EL',
|
||||
@@ -504,10 +524,27 @@ export const ORIGINATION_PRODUCTS: OriginationProduct[] = [
|
||||
maxRate: 12,
|
||||
minTenure: 60,
|
||||
maxTenure: 84,
|
||||
incomeMultiplier: 45
|
||||
incomeMultiplier: 45,
|
||||
shareLinkagePercent: 2.5
|
||||
}
|
||||
];
|
||||
|
||||
/** Amount-slab view of the same Share Capital linkage rule -- bye-laws state it both ways: a
|
||||
* per-product minimum (above) and a general slab schedule by loan quantum (below). Whichever is
|
||||
* higher for a given case governs; both are shown so officers can quote either basis. */
|
||||
export interface ShareLinkageSlab {
|
||||
minAmt: number;
|
||||
/** null = "and above" (open-ended top slab). */
|
||||
maxAmt: number | null;
|
||||
sharePercent: number;
|
||||
}
|
||||
export const SHARE_LINKAGE_SLABS: ShareLinkageSlab[] = [
|
||||
{ minAmt: 0, maxAmt: 100000, sharePercent: 2.5 },
|
||||
{ minAmt: 100000, maxAmt: 500000, sharePercent: 5 },
|
||||
{ minAmt: 500000, maxAmt: 2000000, sharePercent: 5 },
|
||||
{ minAmt: 2000000, maxAmt: null, sharePercent: 7.5 }
|
||||
];
|
||||
|
||||
/** One product-specific document a loan officer has to collect for a given product's collateral/
|
||||
* eligibility type -- distinct from the generic KYC docs (Aadhaar, PAN, Income Proof, ...) every
|
||||
* application needs regardless of product. */
|
||||
@@ -582,6 +619,26 @@ export function buildProductDocuments(productCode: string): ProductDocumentEntry
|
||||
return (PRODUCT_DOCUMENT_REQUIREMENTS[productCode] ?? []).map((doc) => ({ ...doc, custom: false, submitted: false }));
|
||||
}
|
||||
|
||||
/** What Legal Approval actually collects proof of -- the executed agreement, the stamp duty paid
|
||||
* on it, and (for a secured product) the mortgage/title paperwork -- same "Add More Doc" pattern
|
||||
* as the product-specific documents above, since a specific matter can always need one more
|
||||
* paper (a second mortgagor's consent letter, a court NOC, etc.) than this starter list expects. */
|
||||
export function buildLegalDocuments(secured: boolean): ProductDocumentEntry[] {
|
||||
const templates: ProductDocumentTemplate[] = [
|
||||
{ key: 'loanAgreement', label: 'Loan Agreement (Registered/Notarized)' },
|
||||
{ key: 'stampDutyReceipt', label: 'Legal Stamp Paper / Stamp Duty Receipt' },
|
||||
{ key: 'feeReceipt', label: 'Fee & Account Opening Receipt' }
|
||||
];
|
||||
if (secured) {
|
||||
templates.push(
|
||||
{ key: 'mortgageDeed', label: 'Mortgage Deed' },
|
||||
{ key: 'titleSearchReport', label: 'Title Search Report / Legal Opinion' }
|
||||
);
|
||||
}
|
||||
templates.push({ key: 'fileFilingAck', label: 'File Filing Acknowledgement' });
|
||||
return templates.map((doc) => ({ ...doc, custom: false, submitted: false }));
|
||||
}
|
||||
|
||||
const FIRST_NAMES = [
|
||||
'Ramesh',
|
||||
'Suresh',
|
||||
@@ -920,7 +977,9 @@ function generateApplications(count: number): LoanApplication[] {
|
||||
declarationTaken: false,
|
||||
declarationDate: '—',
|
||||
guarantorRequired: !product.secured && requestedAmount > 300000,
|
||||
guarantorClientId: null,
|
||||
guarantorName: '—',
|
||||
guarantorHasShareAccount: false,
|
||||
backgroundCheckDone: false,
|
||||
cersaiSearchDone: false,
|
||||
managerMeetingDone: false,
|
||||
@@ -928,6 +987,8 @@ function generateApplications(count: number): LoanApplication[] {
|
||||
propertySearchReportDone: false,
|
||||
valuationReportConfirmed: false,
|
||||
creditCommitteeApproval: blankApproval(),
|
||||
committeeResolutionNo: '',
|
||||
committeeResolutionDate: '',
|
||||
riskApproval: blankApproval(),
|
||||
legalApproval: blankApproval(),
|
||||
postCheckApproval: blankApproval(),
|
||||
@@ -936,6 +997,7 @@ function generateApplications(count: number): LoanApplication[] {
|
||||
legalStampDone: false,
|
||||
fileFilingDone: false,
|
||||
agreementSigningSlot: '—',
|
||||
legalDocuments: buildLegalDocuments(product.secured),
|
||||
customerSigningDate: '—',
|
||||
chequeReceived: false,
|
||||
achSigned: false,
|
||||
@@ -960,6 +1022,7 @@ function generateApplications(count: number): LoanApplication[] {
|
||||
}
|
||||
if (app.guarantorRequired) {
|
||||
app.guarantorName = `${pick(FIRST_NAMES)} ${pick(LAST_NAMES)} (Guarantor)`;
|
||||
app.guarantorHasShareAccount = true;
|
||||
}
|
||||
// Membership pre-dates the application itself (you join the society, then later apply for a
|
||||
// loan) — so it's generated unconditionally here, never gated on how far this particular
|
||||
@@ -1161,14 +1224,18 @@ function generateApplications(count: number): LoanApplication[] {
|
||||
: `Declined — background check findings did not meet the committee's risk appetite, despite an adequate credit score (${app.cibilScore}).`
|
||||
};
|
||||
} else {
|
||||
app.committeeResolutionNo = `LC/${cursor.getFullYear()}/${String(round(between(100, 9999))).padStart(4, '0')}`;
|
||||
app.committeeResolutionDate = fmt(cursor);
|
||||
app.creditCommitteeApproval = {
|
||||
decision: 'APPROVED',
|
||||
approverName: sanctioningAuthority(app.requestedAmount),
|
||||
approverRole: 'Sanctioning Authority',
|
||||
date: fmt(cursor),
|
||||
remarks: product.secured
|
||||
? 'CIBIL report, background check, CSE, OVD, income proof, FI report, manager meeting and share-member status reviewed — approved.'
|
||||
: 'CIBIL report, background check, OVD, income proof, FI report, manager meeting and share-member status reviewed — approved.'
|
||||
remarks: `${
|
||||
product.secured
|
||||
? 'CIBIL report, background check, CSE, OVD, income proof, FI report, manager meeting and share-member status reviewed — approved.'
|
||||
: 'CIBIL report, background check, OVD, income proof, FI report, manager meeting and share-member status reviewed — approved.'
|
||||
} (Resolution No. ${app.committeeResolutionNo} dated ${app.committeeResolutionDate})`
|
||||
};
|
||||
}
|
||||
app.stageHistory.push({
|
||||
@@ -1223,6 +1290,7 @@ function generateApplications(count: number): LoanApplication[] {
|
||||
app.legalStampDone = true;
|
||||
app.fileFilingDone = true;
|
||||
app.agreementSigningSlot = fmt(addDays(cursor, 2));
|
||||
app.legalDocuments.forEach((doc) => (doc.submitted = true));
|
||||
app.legalApproval = {
|
||||
decision: 'APPROVED',
|
||||
approverName: legalOfficer,
|
||||
@@ -1812,7 +1880,9 @@ export function createApplication(input: NewApplicationInput, seq: number): Loan
|
||||
declarationTaken: false,
|
||||
declarationDate: '—',
|
||||
guarantorRequired: false,
|
||||
guarantorClientId: null,
|
||||
guarantorName: '—',
|
||||
guarantorHasShareAccount: false,
|
||||
backgroundCheckDone: false,
|
||||
cersaiSearchDone: false,
|
||||
managerMeetingDone: false,
|
||||
@@ -1820,6 +1890,8 @@ export function createApplication(input: NewApplicationInput, seq: number): Loan
|
||||
propertySearchReportDone: false,
|
||||
valuationReportConfirmed: false,
|
||||
creditCommitteeApproval: blankApproval(),
|
||||
committeeResolutionNo: '',
|
||||
committeeResolutionDate: '',
|
||||
riskApproval: blankApproval(),
|
||||
legalApproval: blankApproval(),
|
||||
postCheckApproval: blankApproval(),
|
||||
@@ -1828,6 +1900,7 @@ export function createApplication(input: NewApplicationInput, seq: number): Loan
|
||||
legalStampDone: false,
|
||||
fileFilingDone: false,
|
||||
agreementSigningSlot: '—',
|
||||
legalDocuments: buildLegalDocuments(input.secured),
|
||||
customerSigningDate: '—',
|
||||
chequeReceived: false,
|
||||
achSigned: false,
|
||||
|
||||
@@ -774,6 +774,97 @@
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
// Legal Approval documents -- its own card style (icon + status accent stripe) rather than the
|
||||
// plain .loi-kyc-card, since Legal Approval is a page most officers look at repeatedly right
|
||||
// before disbursement and benefits from a clearer, less generic-looking checklist.
|
||||
.loi-legal-doc-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.loi-legal-doc-progress {
|
||||
font-size: 11.5px;
|
||||
font-weight: 700;
|
||||
color: #64748b;
|
||||
background: #f1f5f9;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.loi-legal-doc-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.loi-legal-doc-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-left: 4px solid #f59e0b;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
transition:
|
||||
box-shadow 0.15s ease,
|
||||
border-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 3px 12px rgb(15 23 42 / 6%);
|
||||
}
|
||||
|
||||
&--submitted {
|
||||
border-left-color: #10b981;
|
||||
}
|
||||
}
|
||||
|
||||
.loi-legal-doc-title-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.loi-legal-doc-icon {
|
||||
font-size: 18px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.loi-legal-doc-title {
|
||||
flex: 1;
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.loi-legal-doc-upload-btn {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.loi-legal-doc-thumb-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.loi-legal-doc-thumb-remove {
|
||||
border: none;
|
||||
background: none;
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
padding: 0 2px;
|
||||
|
||||
&:hover {
|
||||
color: #dc2626;
|
||||
}
|
||||
}
|
||||
|
||||
// CIC Credit Bureau Hero Card in Stage detail
|
||||
.loi-cic-hero-card {
|
||||
display: flex;
|
||||
|
||||
@@ -116,6 +116,14 @@
|
||||
>
|
||||
Target vs Actual
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="activeView === 'rates'"
|
||||
(click)="setView('rates')"
|
||||
>
|
||||
Interest & Share Rate Chart
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ===================== PIPELINE ===================== -->
|
||||
@@ -614,6 +622,103 @@
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ===================== INTEREST & SHARE RATE CHART ===================== -->
|
||||
@if (activeView === 'rates') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Product-wise — Interest Rate & Share Capital Required</div>
|
||||
<p class="pg-sub npa-report-note">
|
||||
Total Interest is an illustrative simple-interest figure (Amount x Rate x Tenure in years) at the product's own
|
||||
min/max amount and tenure band — actual repayment is on a reducing balance and depends on the amount and tenure
|
||||
chosen at sanction. Share Capital Required is the minimum a borrower must hold per bank bye-laws, proportional
|
||||
to the loan amount.
|
||||
</p>
|
||||
<div class="pg-table-scroll">
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Product</th>
|
||||
<th>Type</th>
|
||||
<th class="num">Amount Range</th>
|
||||
<th class="num">Interest Rate (p.a.)</th>
|
||||
<th class="num">Tenure Range</th>
|
||||
<th class="num">Illustrative Total Interest</th>
|
||||
<th class="num">Share Capital Required</th>
|
||||
<th class="num">Illustrative Share Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (p of products; track p.code) {
|
||||
<tr>
|
||||
<td>{{ p.name }}</td>
|
||||
<td>
|
||||
<span
|
||||
class="loan-badge"
|
||||
[class.loan-badge--progress]="p.secured"
|
||||
[class.loan-badge--hold]="!p.secured"
|
||||
>
|
||||
{{ p.secured ? 'Secured' : 'Unsecured' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="num">₹{{ p.minAmt | number: '1.0-0' }} – ₹{{ p.maxAmt | number: '1.0-0' }}</td>
|
||||
<td class="num">{{ p.minRate | number: '1.1-1' }}% – {{ p.maxRate | number: '1.1-1' }}%</td>
|
||||
<td class="num">{{ p.minTenure }} – {{ p.maxTenure }} months</td>
|
||||
<td class="num">
|
||||
₹{{ illustrativeInterest(p.minAmt, p.minRate, p.minTenure) | number: '1.0-0' }} – ₹{{
|
||||
illustrativeInterest(p.maxAmt, p.maxRate, p.maxTenure) | number: '1.0-0'
|
||||
}}
|
||||
</td>
|
||||
<td class="num">{{ p.shareLinkagePercent | number: '1.1-1' }}%</td>
|
||||
<td class="num">
|
||||
₹{{ p.minAmt * (p.shareLinkagePercent / 100) | number: '1.0-0' }} – ₹{{
|
||||
p.maxAmt * (p.shareLinkagePercent / 100) | number: '1.0-0'
|
||||
}}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Amount Slab-wise — Share Capital Required (All Products)</div>
|
||||
<p class="pg-sub npa-report-note">
|
||||
A general slab schedule by loan quantum, independent of product — where this differs from the product-wise
|
||||
minimum above, whichever is higher governs the actual requirement.
|
||||
</p>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="num">Loan Amount Slab</th>
|
||||
<th class="num">Share Capital Required</th>
|
||||
<th class="num">Illustrative Share Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of shareLinkageSlabRows; track row.minAmt) {
|
||||
<tr>
|
||||
<td class="num">
|
||||
@if (row.maxAmt !== null) {
|
||||
₹{{ row.minAmt | number: '1.0-0' }} – ₹{{ row.maxAmt | number: '1.0-0' }}
|
||||
} @else {
|
||||
Above ₹{{ row.minAmt | number: '1.0-0' }}
|
||||
}
|
||||
</td>
|
||||
<td class="num">{{ row.sharePercent | number: '1.1-1' }}%</td>
|
||||
<td class="num">
|
||||
@if (row.shareAtMax !== null) {
|
||||
₹{{ row.shareAtMin | number: '1.0-0' }} – ₹{{ row.shareAtMax | number: '1.0-0' }}
|
||||
} @else {
|
||||
From ₹{{ row.shareAtMin | number: '1.0-0' }}
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="inv-toast" [class.show]="toastVisible">{{ toastMsg }}</div>
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
PRODUCT_TARGET_PCT,
|
||||
PRODUCT_TYPE_LABELS,
|
||||
ProductType,
|
||||
SHARE_LINKAGE_SLABS,
|
||||
STAGE_LABELS,
|
||||
STAGE_ORDER,
|
||||
STATUS_LABELS,
|
||||
@@ -81,7 +82,17 @@ interface MixTargetRow {
|
||||
isRbiMandated: boolean;
|
||||
}
|
||||
|
||||
type ViewId = 'pipeline' | 'new' | 'channel' | 'bc' | 'share' | 'target';
|
||||
interface AmountSlabRow {
|
||||
minAmt: number;
|
||||
maxAmt: number | null;
|
||||
sharePercent: number;
|
||||
/** Share Capital required at the slab's lower and upper bound, so the rupee amount is visible
|
||||
* without officers having to do the multiplication themselves. */
|
||||
shareAtMin: number;
|
||||
shareAtMax: number | null;
|
||||
}
|
||||
|
||||
type ViewId = 'pipeline' | 'new' | 'channel' | 'bc' | 'share' | 'target' | 'rates';
|
||||
|
||||
/**
|
||||
* Loan Origination pipeline — every application currently moving through the sanctioning
|
||||
@@ -115,6 +126,7 @@ export class LoanPipelineComponent implements OnInit {
|
||||
readonly channelLabels = CHANNEL_LABELS;
|
||||
readonly branches = BRANCHES;
|
||||
readonly products = ORIGINATION_PRODUCTS;
|
||||
readonly shareLinkageSlabs = SHARE_LINKAGE_SLABS;
|
||||
asOnDate = todayDDMMYYYY();
|
||||
|
||||
activeView: ViewId = 'pipeline';
|
||||
@@ -185,11 +197,20 @@ export class LoanPipelineComponent implements OnInit {
|
||||
'channel',
|
||||
'bc',
|
||||
'share',
|
||||
'target'
|
||||
'target',
|
||||
'rates'
|
||||
].includes(requestedView)) {
|
||||
this.activeView = requestedView;
|
||||
}
|
||||
|
||||
// Lets a menu link (e.g. "Loan Committee") land here pre-filtered to just the applications
|
||||
// waiting on that one stage, reusing this same pipeline list/filter instead of a separate
|
||||
// queue view -- same pattern as the `view` query param above.
|
||||
const requestedStage = this.route.snapshot.queryParamMap.get('stage') as OriginationStage | null;
|
||||
if (requestedStage && this.stageOrder.includes(requestedStage)) {
|
||||
this.stageFilter = requestedStage;
|
||||
}
|
||||
|
||||
const creds = this.auth.getCredentials();
|
||||
this.isHeadOffice = this.auth.isHeadOfficeUser();
|
||||
this.currentOfficeName = creds?.officeName || 'Branch Office';
|
||||
@@ -427,6 +448,22 @@ export class LoanPipelineComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------- Interest & Share Linkage Reference --------------------
|
||||
/** Illustrative total interest over the product's own tenure band, simple-interest approximation
|
||||
* (principal x rate x years) -- flagged as illustrative in the template since actual repayment
|
||||
* is on a reducing balance and varies by amount/tenure chosen at sanction. */
|
||||
illustrativeInterest(amount: number, ratePct: number, tenureMonths: number): number {
|
||||
return amount * (ratePct / 100) * (tenureMonths / 12);
|
||||
}
|
||||
|
||||
get shareLinkageSlabRows(): AmountSlabRow[] {
|
||||
return this.shareLinkageSlabs.map((slab) => ({
|
||||
...slab,
|
||||
shareAtMin: slab.minAmt * (slab.sharePercent / 100),
|
||||
shareAtMax: slab.maxAmt !== null ? slab.maxAmt * (slab.sharePercent / 100) : null
|
||||
}));
|
||||
}
|
||||
|
||||
get channelTargetRows(): MixTargetRow[] {
|
||||
const total = this.disbursedInScope.reduce((s, a) => s + a.requestedAmount, 0);
|
||||
const channels: Channel[] = [
|
||||
|
||||
Reference in New Issue
Block a user