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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user