From 997e8920e44d757e485ded982aec44ec7587475a Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Thu, 13 Aug 2026 15:14:39 +0530 Subject: [PATCH] feat(loan-origination): product type, income/co-borrower affordability, bank statement & CIC analysis, richer Field Investigation, and meeting-scheduled Personal Discussion - New Application: add Product Type (Term Loan/Overdraft) driven by product eligibility. - KYC: 3-year ITR capture with exceptions, product-specific document checklists with officer-added custom docs, Housing Loan co-owner Aadhaar/PAN capture+verify in a popup dialog. - Income & Affordability: editable income/amount/tenure with product bounds validation, 40% EMI rule, and a full co-borrower flow (relationship, salary slips, combined-income eligibility). - Bank Statement upload auto-opens a full CSV-driven cash-flow/FOIR/risk analysis dialog. - CIC report upload auto-opens a bureau-style credit report analysis (score gauge, accounts, DPD history, enquiries) generated from the application's own credit score. - Field Investigation reworked into per-address cards (Home/Office/Property/Shop) with officer assignment, 48-hour SLA tracking, GPS + multi-photo capture, verify/reject with remarks, a full-detail popup, real Fineract addresses for existing clients, and a calculated property valuation (market/guideline/realizable value, LTV). - Personal Discussion: meeting scheduler (date/time/mode/officer) gating the discussion report, plus a full Application Summary Report PDF (KYC, CIC, FI, valuation, stage history) for the meeting. Co-Authored-By: Claude Sonnet 5 --- .../application-detail.component.html | 986 +++++++++++++--- .../application-detail.component.ts | 1045 ++++++++++++++++- ...k-statement-analysis-dialog.component.html | 126 ++ ...k-statement-analysis-dialog.component.scss | 218 ++++ ...ank-statement-analysis-dialog.component.ts | 185 +++ .../bank-statement-analysis.util.ts | 427 +++++++ .../cic-report-analysis-dialog.component.html | 167 +++ .../cic-report-analysis-dialog.component.scss | 288 +++++ .../cic-report-analysis-dialog.component.ts | 137 +++ .../cic-report-analysis.util.ts | 349 ++++++ .../co-owner-kyc-verify-dialog.component.html | 69 ++ .../co-owner-kyc-verify-dialog.component.scss | 127 ++ .../co-owner-kyc-verify-dialog.component.ts | 146 +++ .../field-visit-details-dialog.component.html | 62 + .../field-visit-details-dialog.component.scss | 114 ++ .../field-visit-details-dialog.component.ts | 57 + .../loan-origination/loan-origination-data.ts | 348 +++++- .../loan-origination-shared.scss | 105 ++ .../loan-pipeline.component.html | 10 +- .../loan-pipeline/loan-pipeline.component.ts | 22 + 20 files changed, 4747 insertions(+), 241 deletions(-) create mode 100644 src/app/loan-origination/application-detail/bank-statement-analysis-dialog/bank-statement-analysis-dialog.component.html create mode 100644 src/app/loan-origination/application-detail/bank-statement-analysis-dialog/bank-statement-analysis-dialog.component.scss create mode 100644 src/app/loan-origination/application-detail/bank-statement-analysis-dialog/bank-statement-analysis-dialog.component.ts create mode 100644 src/app/loan-origination/application-detail/bank-statement-analysis-dialog/bank-statement-analysis.util.ts create mode 100644 src/app/loan-origination/application-detail/cic-report-analysis-dialog/cic-report-analysis-dialog.component.html create mode 100644 src/app/loan-origination/application-detail/cic-report-analysis-dialog/cic-report-analysis-dialog.component.scss create mode 100644 src/app/loan-origination/application-detail/cic-report-analysis-dialog/cic-report-analysis-dialog.component.ts create mode 100644 src/app/loan-origination/application-detail/cic-report-analysis-dialog/cic-report-analysis.util.ts create mode 100644 src/app/loan-origination/application-detail/co-owner-kyc-verify-dialog/co-owner-kyc-verify-dialog.component.html create mode 100644 src/app/loan-origination/application-detail/co-owner-kyc-verify-dialog/co-owner-kyc-verify-dialog.component.scss create mode 100644 src/app/loan-origination/application-detail/co-owner-kyc-verify-dialog/co-owner-kyc-verify-dialog.component.ts create mode 100644 src/app/loan-origination/application-detail/field-visit-details-dialog/field-visit-details-dialog.component.html create mode 100644 src/app/loan-origination/application-detail/field-visit-details-dialog/field-visit-details-dialog.component.scss create mode 100644 src/app/loan-origination/application-detail/field-visit-details-dialog/field-visit-details-dialog.component.ts diff --git a/src/app/loan-origination/application-detail/application-detail.component.html b/src/app/loan-origination/application-detail/application-detail.component.html index d78d970ab..2f62c51eb 100644 --- a/src/app/loan-origination/application-detail/application-detail.component.html +++ b/src/app/loan-origination/application-detail/application-detail.component.html @@ -84,7 +84,12 @@ @if (a.coBorrowerRequired) {
Co-Borrower
-
{{ a.coBorrowerName }}
+
+ {{ a.coBorrowerName }} + @if (coBorrowerCapture.wanted && coBorrowerCapture.relationship) { + ({{ coBorrowerCapture.relationship }}) + } +
} @if (a.guarantorRequired) { @@ -316,37 +321,97 @@ -
- - @if (newKycCapture.incomeProof.file) { - + } +
+ } @else { +

Last 3 years' Income Tax Returns:

+ @for (doc of newKycCapture.incomeProof.itr; track $index) { +
+ @if (!doc.exception) { + + @if (doc.file) { + + } } - + @if (!doc.file) { + + } +
+ @if (doc.exception) { + + } } - + } @if (a.incomeProofSubmitted) { - Submitted + @if (itrHasException) { + Submitted — with exception(s) + } @else { + Submitted + } } @else { Missing } @@ -366,7 +431,7 @@ {{ newKycCapture.bankStatement.file ? 'Statement ✓' : 'Upload Bank Statement' }} @@ -384,6 +449,16 @@ View file } + @if (isCsvFile(newKycCapture.bankStatement.file)) { + + } } @if (a.bankStatementSubmitted) { @@ -445,24 +520,6 @@ } } - @if (a.secured) { -
- -
-
- -
- } -
- -
} } @else { @@ -640,35 +697,95 @@ -
- - @if (newKycCapture.incomeProof.file) { - + } +
+ } @else { +

Last 3 years' Income Tax Returns:

+ @for (doc of newKycCapture.incomeProof.itr; track $index) { +
+ @if (!doc.exception) { + + @if (doc.file) { + + } } - + @if (!doc.file) { + + } +
+ @if (doc.exception) { + + } } - + } @if (a.incomeProofSubmitted) { - Submitted + @if (itrHasException) { + Submitted — with exception(s) + } @else { + Submitted + } } @else { Missing } @@ -680,7 +797,7 @@ {{ newKycCapture.bankStatement.file ? 'Statement ✓' : 'Upload Bank Statement' }} @@ -698,6 +815,16 @@ View file } + @if (isCsvFile(newKycCapture.bankStatement.file)) { + + } } @if (a.bankStatementSubmitted) { @@ -750,48 +877,180 @@ Missing } - @if (a.secured) { -
- -
-
- -
- } -
- +
+ } + @if (a.productDocuments.length > 0) { +
+
Loan-Specific Documents — {{ a.productName }}
+
+ @for (doc of a.productDocuments; track doc.key) { + @if (doc.key === 'coOwnerKyc') { +
+ {{ doc.label }} + @if (doc.hint) { + {{ doc.hint }} + } + + @if (coOwnerCapture.hasCoOwner) { +
+ + +
+ } + @if (doc.submitted) { + + {{ coOwnerCapture.hasCoOwner ? 'Verified' : 'Not Applicable' }} + + } @else { + Missing + } +
+ } @else { +
+ + {{ doc.label }} + @if (doc.custom) { + + } + + @if (doc.hint) { + {{ doc.hint }} + } +
+ + @if (productDocCapture[doc.key]; as capture) { + @if (capture.file) { + + } + } +
+ @if (doc.submitted) { + Submitted + } @else { + Missing + } +
+ } + } +
+
+ +
} - @if (a.monthlyIncome === 0) { + + @if (!isRevolving(a)) { -

- Eligible Loan Amount preview: ₹{{ eligiblePreview(a) | number: '1.0-0' }} -

- @if (isRevolving(a)) { -

- {{ a.productName }} is a revolving/renewable limit — repaid on demand, no fixed EMI. + @if (amountOutOfRange(a)) { +

+ {{ a.productName }} allows ₹{{ productLimitsFor(a)?.minAmt | number: '1.0-0' }} – ₹{{ + productLimitsFor(a)?.maxAmt | number: '1.0-0' + }} + — this amount won't be saved until it's in range.

} -
- -
+ + @if (tenureOutOfRange(a)) { +

+ {{ a.productName }} allows {{ productLimitsFor(a)?.minTenure }}–{{ productLimitsFor(a)?.maxTenure }} + months — this tenure won't be saved until it's in range. +

+ } + } +

+ Eligible Loan Amount preview: ₹{{ eligiblePreview(a) | number: '1.0-0' }} +

+ @if (isRevolving(a)) { +

+ {{ a.productName }} is a revolving/renewable limit — repaid on demand, no fixed EMI. +

} @else { +

+ Estimated EMI: ₹{{ emiPreview(a) | number: '1.0-0' }} / month ({{ + emiToIncomePctPreview(a) | number: '1.0-1' + }}% of monthly income) +

+ @if (emiToIncomePctPreview(a) > 40) { + @if (a.coBorrowerRequired && coBorrowerDetailsComplete(a) && combinedEligibilityOk(a)) { +

+ EMI exceeds 40% of the applicant's own income, but the Co-Borrower added below already covers it — + see the Co-Borrower section. +

+ } @else { +

+ EMI exceeds 40% of monthly income — add a Co-Borrower, reduce the loan amount, or increase the + tenure before proceeding. +

+ } + } + } +
+ +
+ @if (a.monthlyIncome > 0) {
-
Monthly Income
+
Monthly Income (Last Assessed)
₹{{ a.monthlyIncome | number: '1.0-0' }}
@@ -847,7 +1106,132 @@
} @if (a.coBorrowerRequired) { -

Co-Borrower Required — {{ a.coBorrowerName }}

+
+
Co-Borrower (raises household eligibility)
+ + @if (coBorrowerCapture.wanted) { + + + +

Upload the co-borrower's last 3 months' salary slips:

+
+ @for (doc of coBorrowerCapture.salarySlips; track $index) { + + @if (doc.file) { + + } + } +
+ @if (coBorrowerDetailsComplete(a)) { +
+
Combined Household Eligibility Calculation
+
+
+
Applicant's Monthly Income
+
₹{{ a.monthlyIncome | number: '1.0-0' }}
+
+
+
Applicant's EMI Capacity (40%)
+
₹{{ applicantEmiCapacity(a) | number: '1.0-0' }}
+
+
+
Co-Borrower's Monthly Income
+
₹{{ coBorrowerCapture.monthlyIncome | number: '1.0-0' }}
+
+
+
Co-Borrower's EMI Capacity (40%)
+
₹{{ coBorrowerEmiCapacity() | number: '1.0-0' }}
+
+
+
Combined Household EMI Capacity
+
₹{{ combinedEmiCapacity(a) | number: '1.0-0' }}
+
+
+
Estimated EMI (this loan)
+
₹{{ emiAmountFor(a) | number: '1.0-0' }}
+
+
+

+ Combined Capacity = (40% × ₹{{ a.monthlyIncome | number: '1.0-0' }} applicant) + (40% × ₹{{ + coBorrowerCapture.monthlyIncome | number: '1.0-0' + }} + co-borrower) = ₹{{ applicantEmiCapacity(a) | number: '1.0-0' }} + ₹{{ + coBorrowerEmiCapacity() | number: '1.0-0' + }} + = ₹{{ combinedEmiCapacity(a) | number: '1.0-0' }}. Estimated EMI ₹{{ + emiAmountFor(a) | number: '1.0-0' + }} + is {{ combinedEligibilityOk(a) ? 'within' : 'above' }} this combined capacity. +

+ @if (combinedEligibilityOk(a)) { + Eligible with Co-Borrower + } @else { + Still not eligible — increase co-borrower income, reduce the amount, or extend tenure + } +
+ } + } + @if (!affordabilityOk(a)) { +

+ @if (!coBorrowerDetailsComplete(a)) { + @if (!coBorrowerCapture.wanted) { + Check "Add a Co-Borrower" and complete their details to proceed. + } @else { + Add the co-borrower's name, relationship, monthly income and all 3 salary slips to proceed. + } + } @else { + Combined household income still doesn't cover the EMI at 40% — adjust the amount, tenure, or + co-borrower before proceeding. + } +

+ } +
}
+ } +
Field Inquiry
@@ -1095,45 +1486,248 @@
-
- - - @if (a.secured) { - - + @if (a.clientId) { +
+
Registered Addresses on File (Fineract)
+ @if (loadingAddresses) { +

Loading addresses…

+ } @else if (realAddresses.length === 0) { +

No addresses on file for this client.

+ } @else { +
+ @for (addr of realAddresses; track $index) { +
+
{{ addr.addressType || 'Address' }}
+

{{ formatRealAddress(addr) }}

+
+ } +
+ } +
+ } + + + +
+ @for (key of fiRows(a); track key) { +
+
{{ fiCheckLabels[key] }}
+ + @if (a[key].officerAssigned === '—') { +
+ +
+
+ +
+ } @else { +

+ Assigned to {{ a[key].officerAssigned }} on {{ a[key].assignedDate }} +

+ + @if (a[key].status === 'PENDING') { + @if (fiIsOverdue(a, key)) { +

⚠ {{ fiSlaLabel(a, key) }} — this visit is overdue.

+ } @else { +

{{ fiSlaLabel(a, key) }}

+ } + +
+ + +
+ +

Site Photos ({{ fiPhotoCapture[key].length }}):

+
+ + @for (photo of fiPhotoCapture[key]; track $index) { + + } +
+ +
+ +
+ @if (!fiCanVerify(a, key)) { +

+ Capture latitude, longitude and at least one site photo before marking Verified. +

+ } + + +
+ +
+ } @else if (a[key].status === 'VERIFIED') { + Verified + + } @else { + Failed + +
+ +
+
+ +
+ } + } +
}
+ + @if (a.secured) { +
+
Property Valuation
+
+ + + + + + +
+
+
+
Market Value (Area × Market Rate)
+
₹{{ a.propertyValuation | number: '1.0-0' }}
+
+
+
Guideline Value (Area × Circle Rate)
+
₹{{ guidelineValue(a) | number: '1.0-0' }}
+
+
+
Realizable Value (90% of Market)
+
₹{{ realizableValue(a) | number: '1.0-0' }}
+
+
+
Distress Sale Value (75% of Market)
+
₹{{ distressValue(a) | number: '1.0-0' }}
+
+
+
Loan-to-Value (vs Realizable)
+
{{ ltvPercent(a) | number: '1.0-1' }}%
+
+
+ @if (ltvHigh(a)) { +

+ LTV exceeds 80% of the realizable value — this loan amount is aggressive against the property's + worth; consider a lower amount or additional security. +

+ } +
+ }
Agent Report
{{ a.agentReportUploaded ? a.agentReportFileName : 'Not uploaded yet' }}
+ @if (a.agentReportUploaded) { +
+
Report Remark
+
{{ a.agentReportRemark }}
+
+ }
@if (!a.agentReportUploaded) { +
} diff --git a/src/app/loan-origination/application-detail/application-detail.component.ts b/src/app/loan-origination/application-detail/application-detail.component.ts index ab7e7e3d6..82f9cace2 100644 --- a/src/app/loan-origination/application-detail/application-detail.component.ts +++ b/src/app/loan-origination/application-detail/application-detail.component.ts @@ -15,12 +15,22 @@ import { Subscription, forkJoin } from 'rxjs'; import { catchError } from 'rxjs/operators'; import { of } from 'rxjs'; import { jsPDF } from 'jspdf'; +import autoTable from 'jspdf-autotable'; import { AuthenticationService } from '../../core/authentication/authentication.service'; import { ClientsService } from '../../clients/clients.service'; import { SharesService } from '../../shares/shares.service'; import { KycUploadHelperService } from '../../clients/shared/kyc-upload-helper.service'; import { DocumentPreviewDialogComponent } from '../../clients/shared/document-preview-dialog/document-preview-dialog.component'; import { ShareAccountDetailsDialogComponent } from './share-account-details-dialog/share-account-details-dialog.component'; +import { BankStatementAnalysisDialogComponent } from './bank-statement-analysis-dialog/bank-statement-analysis-dialog.component'; +import { CoOwnerKycVerifyDialogComponent } from './co-owner-kyc-verify-dialog/co-owner-kyc-verify-dialog.component'; +import { + parseBankStatementCsv, + summarizeBankStatement +} from './bank-statement-analysis-dialog/bank-statement-analysis.util'; +import { CicReportAnalysisDialogComponent } from './cic-report-analysis-dialog/cic-report-analysis-dialog.component'; +import { generateCicReportAnalysis } from './cic-report-analysis-dialog/cic-report-analysis.util'; +import { FieldVisitDetailsDialogComponent } from './field-visit-details-dialog/field-visit-details-dialog.component'; import { ApprovalField, BRANCHES, @@ -30,11 +40,13 @@ import { EmiScheduleRow, LoanApplication, LOAN_APPLICATIONS, + ORIGINATION_PRODUCTS, OriginationStage, STAGE_LABELS, STAGE_ORDER, STATUS_LABELS, allotShareMembership, + assignFieldOfficer, buildEmiSchedule, calcEligibleAmount, calculateEmi, @@ -46,14 +58,19 @@ import { confirmChequeAch, decideApproval, disburseApplication, + FiCheckKey, + FI_CHECK_LABELS, flagDiscrepancy, - isRevolvingProduct, + MANAGERS, markCustomerSigned, moveToStage, + ProductDocumentEntry, progressPct, + PROPERTY_TYPES, pushFieldInquiry, resolveDiscrepancyAndProceed, sanctioningAuthority, + scheduleMeeting, setMonthlyIncome, stageBadgeClass, stageIndex, @@ -80,10 +97,28 @@ interface NewKycSingleDoc { type EmploymentType = 'SALARIED' | 'BUSINESS'; +/** An ITR year the customer genuinely doesn't have (e.g. business started after that year) can be + * waived with a remark instead of blocking the checklist forever on a document that will never + * turn up -- `exception` only ever applies when there's no file for that year (see the + * `!doc.file` guard in the template), so a file and a waiver can never both be "true" at once. */ +interface ItrYearCapture extends NewKycSingleDoc { + exception: boolean; + exceptionRemark: string; +} + +/** A salaried applicant proves income with one combined Form 16 / 3-month salary slip upload; + * a business/self-employed applicant needs the last 3 years' Income Tax Returns instead, each + * as its own separate upload. */ +interface IncomeProofCapture { + employmentType: EmploymentType; + salaried: NewKycSingleDoc; + itr: ItrYearCapture[]; +} + interface NewKycCaptureState { aadhaar: NewKycDocSide & { otpSent: boolean; otpDigits: string[] }; pan: NewKycDocSide; - incomeProof: NewKycSingleDoc & { employmentType: EmploymentType }; + incomeProof: IncomeProofCapture; bankStatement: NewKycSingleDoc; electricityBill: NewKycSingleDoc & { billDate: string | null }; } @@ -123,8 +158,12 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy { actionRemarks = ''; discrepancySelection: DiscrepancyType = 'NONE'; disbursementMode = 'Account Credit'; - /** KYC stage's Monthly Income entry field — blank until the officer keys it in. */ + /** KYC stage's Income & Affordability inputs -- always editable (not just before the first + * "Assess"), pre-filled from the application and re-committed on every click, so the officer + * can revisit and adjust income, requested amount or tenure as many times as needed. */ monthlyIncomeInput: number | null = null; + requestedAmountInput: number | null = null; + tenureMonthsInput: number | null = null; toastMsg = ''; toastVisible = false; @@ -163,6 +202,13 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy { realDocuments: any[] = []; loadingKyc = false; + /** Every address Fineract has on file for this application's linked client (Residential, + * Permanent, Office/Business, etc.) -- shown in full at Field Investigation instead of the + * single free-text address every mock/New-Customer application uses, so the officer sending + * someone out to verify knows exactly which addresses actually exist for this person. */ + realAddresses: any[] = []; + loadingAddresses = false; + /** New-Customer applications (no real client yet) get a client-creation-style capture UI * instead -- front/back document images + an OTP box -- but since no real client/kycId * exists yet, Aadhaar/PAN can't actually be verified against Fineract here (every one of @@ -175,6 +221,115 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy { * Score" can't be reached on a self-ticked checkbox with nothing behind it. */ cicConsentCapture: NewKycSingleDoc = { file: null, previewUrl: null }; + /** Local file capture for each of `a.productDocuments`, keyed by the entry's `key` -- the + * submitted/missing flag lives on the application itself (like every other KYC checklist + * item), but the actual File/preview is local-only, same as the rest of this mock pipeline. */ + productDocCapture: Record = {}; + newCustomDocLabel = ''; + + /** 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 + * document card grid doesn't balloon in height) even shows up. */ + coOwnerCapture: { hasCoOwner: boolean; aadhaar: NewKycDocSide; pan: NewKycDocSide } = { + hasCoOwner: false, + aadhaar: this.emptyDocSide(), + pan: this.emptyDocSide() + }; + + /** Once `coBorrowerRequired` fires (either the eligible-amount shortfall in setMonthlyIncome() + * or the 40%-EMI rule in setIncomeNow()), the officer opts into actually adding one via this + * checkbox -- relationship, their own monthly income and last-3-months' salary slips, all + * local-only like the rest of this mock pipeline. Their income only counts toward affordability + * once all of that is in, see combinedEmiCapacity()/affordabilityOk() below. */ + coBorrowerCapture: { + wanted: boolean; + relationship: string; + monthlyIncome: number | null; + salarySlips: NewKycSingleDoc[]; + } = this.emptyCoBorrowerCapture(); + + private emptyCoBorrowerCapture(): { + wanted: boolean; + relationship: string; + monthlyIncome: number | null; + salarySlips: NewKycSingleDoc[]; + } { + return { + wanted: false, + relationship: '', + monthlyIncome: null, + salarySlips: [ + { file: null, previewUrl: null }, + { file: null, previewUrl: null }, + { file: null, previewUrl: null } + ] + }; + } + + private revokeCoBorrowerPreviews(): void { + this.coBorrowerCapture.salarySlips.forEach((doc) => { + if (doc.previewUrl) { + URL.revokeObjectURL(doc.previewUrl); + } + }); + } + + /** The officer name typed before "Assign Officer" is clicked -- transient, cleared once the + * assignment actually lands on the application (see assignFieldOfficerNow()). */ + fiOfficerNameInput: Record = { + addressVerification: '', + officeVerification: '', + propertyVerification: '', + shopVerification: '' + }; + + /** Ditto for the rejection remark textarea. */ + fiRejectRemarkInput: Record = { + addressVerification: '', + officeVerification: '', + propertyVerification: '', + shopVerification: '' + }; + + /** The site photos captured for each FI address -- local-only, same as every other document + * capture in this mock pipeline; only the count is persisted on the application itself + * (VerificationCheck.photoCount), which is what actually gates "Mark Verified". */ + fiPhotoCapture: Record = { + addressVerification: [], + officeVerification: [], + propertyVerification: [], + shopVerification: [] + }; + + /** Ticks once a minute so the 48-hour SLA countdown/overdue alert stays live on screen without + * needing a manual refresh. */ + nowTick = Date.now(); + private fiTickTimer: ReturnType | undefined; + + private revokeFiPhotoPreviews(): void { + (Object.keys(this.fiPhotoCapture) as FiCheckKey[]).forEach((key) => { + this.fiPhotoCapture[key].forEach((doc) => { + if (doc.previewUrl) { + URL.revokeObjectURL(doc.previewUrl); + } + }); + }); + } + + private emptyDocSide(): NewKycDocSide { + return { front: null, back: null, frontPreviewUrl: null, backPreviewUrl: null, number: '', verified: false }; + } + + private revokeDocSidePreviews(side: NewKycDocSide): void { + if (side.frontPreviewUrl) { + URL.revokeObjectURL(side.frontPreviewUrl); + } + if (side.backPreviewUrl) { + URL.revokeObjectURL(side.backPreviewUrl); + } + } + private emptyNewKycCapture(): NewKycCaptureState { return { aadhaar: { @@ -195,7 +350,15 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy { verified: false }, pan: { front: null, back: null, frontPreviewUrl: null, backPreviewUrl: null, number: '', verified: false }, - incomeProof: { file: null, previewUrl: null, employmentType: 'SALARIED' }, + incomeProof: { + employmentType: 'SALARIED', + salaried: { file: null, previewUrl: null }, + itr: [ + { file: null, previewUrl: null, exception: false, exceptionRemark: '' }, + { file: null, previewUrl: null, exception: false, exceptionRemark: '' }, + { file: null, previewUrl: null, exception: false, exceptionRemark: '' } + ] + }, bankStatement: { file: null, previewUrl: null }, electricityBill: { file: null, previewUrl: null, billDate: null } }; @@ -210,7 +373,8 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy { state.aadhaar.backPreviewUrl, state.pan.frontPreviewUrl, state.pan.backPreviewUrl, - state.incomeProof.previewUrl, + state.incomeProof.salaried.previewUrl, + ...state.incomeProof.itr.map((doc) => doc.previewUrl), state.bankStatement.previewUrl, state.electricityBill.previewUrl, this.cicConsentCapture.previewUrl @@ -228,6 +392,9 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy { this.ownBranchCode = BRANCHES.find((b: BranchMaster) => officeName.toLowerCase().includes(b.name.toLowerCase()))?.code || null; } + this.fiTickTimer = setInterval(() => { + this.nowTick = Date.now(); + }, 60000); this.sub = this.route.paramMap.subscribe((params) => { const applicationNo = params.get('applicationNo'); const found = LOAN_APPLICATIONS.find((a) => a.applicationNo === applicationNo); @@ -238,12 +405,49 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy { this.actionRemarks = ''; this.discrepancySelection = 'NONE'; this.monthlyIncomeInput = this.application?.monthlyIncome || null; + this.requestedAmountInput = this.application?.requestedAmount || null; + this.tenureMonthsInput = this.application?.tenureMonths || null; this.revokeNewKycPreviews(this.newKycCapture); this.newKycCapture = this.emptyNewKycCapture(); this.cicConsentCapture = { file: null, previewUrl: null }; this.cicPaymentRefInput = ''; + this.agentReportRemarkInput = ''; + this.revokeProductDocPreviews(); + this.productDocCapture = {}; + (this.application?.productDocuments ?? []).forEach((doc) => { + this.productDocCapture[doc.key] = { file: null, previewUrl: null }; + }); + this.newCustomDocLabel = ''; + this.revokeDocSidePreviews(this.coOwnerCapture.aadhaar); + this.revokeDocSidePreviews(this.coOwnerCapture.pan); + this.coOwnerCapture = { hasCoOwner: false, aadhaar: this.emptyDocSide(), pan: this.emptyDocSide() }; + this.revokeCoBorrowerPreviews(); + this.coBorrowerCapture = this.emptyCoBorrowerCapture(); + if (this.application) { + this.syncCoOwnerDocSubmitted(this.application); + } + this.revokeFiPhotoPreviews(); + this.fiPhotoCapture = { + addressVerification: [], + officeVerification: [], + propertyVerification: [], + shopVerification: [] + }; + this.fiOfficerNameInput = { + addressVerification: '', + officeVerification: '', + propertyVerification: '', + shopVerification: '' + }; + this.fiRejectRemarkInput = { + addressVerification: '', + officeVerification: '', + propertyVerification: '', + shopVerification: '' + }; this.loadRealClientAccounts(); this.loadRealKyc(); + this.loadRealAddresses(); }); } @@ -342,6 +546,39 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy { ); } + // -------------------- Field Investigation — real client addresses -------------------- + /** Every address type Fineract has on file for the linked client -- only meaningful once + * a.clientId is set, same real-vs-mock split as loadRealKyc() above. */ + private loadRealAddresses(): void { + this.realAddresses = []; + const clientId = this.application?.clientId; + if (!clientId) { + return; + } + this.loadingAddresses = true; + this.clientsService + .getClientAddressData(String(clientId)) + .pipe(catchError(() => of([]))) + .subscribe((addresses: any) => { + this.realAddresses = Array.isArray(addresses) ? addresses : []; + this.loadingAddresses = false; + }); + } + + formatRealAddress(addr: any): string { + return [ + addr?.addressLine1, + addr?.addressLine2, + addr?.townVillage, + addr?.city, + addr?.stateName, + addr?.countryName, + addr?.postalCode + ] + .filter((p) => !!p && String(p).trim()) + .join(', '); + } + /** Masks a PAN (e.g. ABCDE1234F) down to just the last 4 characters, matching the masking * convention Fineract's KYC API already applies to Aadhaar (aadhaarNumberMasked). */ maskPan(pan: string | undefined): string { @@ -492,33 +729,52 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy { } } - /** Salaried customers prove income with Form 16 / salary slips; business/self-employed - * customers prove it with their Income Tax Return -- so the required document changes with - * the employment type the officer selects, rather than asking for a generic "income proof". */ - get incomeProofDocLabel(): string { - return this.newKycCapture.incomeProof.employmentType === 'BUSINESS' - ? 'ITR (Income Tax Return)' - : 'Form 16 / Salary Slip'; - } - + /** Salaried customers prove income with one Form 16 / 3-month salary slip upload; business/ + * self-employed customers need their last 3 years' Income Tax Returns instead, each as its + * own separate upload since a single year only proves one point in time. */ onIncomeProofEmploymentTypeChange(a: LoanApplication): void { - // Switching employment type invalidates whatever was already picked under the other type's - // label -- clear it so the officer re-uploads the document that actually matches. - if (this.newKycCapture.incomeProof.previewUrl) { - URL.revokeObjectURL(this.newKycCapture.incomeProof.previewUrl); - } - this.newKycCapture.incomeProof.file = null; - this.newKycCapture.incomeProof.previewUrl = null; - a.incomeProofSubmitted = false; + this.syncIncomeProofSubmitted(a); } - async onNewIncomeProofFile(a: LoanApplication, event: Event): Promise { - const file = await this.pickSingleNewKycFile(event, this.incomeProofDocLabel); + async onNewSalariedProofFile(a: LoanApplication, event: Event): Promise { + const file = await this.pickSingleNewKycFile(event, 'Form 16 / 3 Month Salary Slip'); if (!file) { return; } - this.setSingleDoc(this.newKycCapture.incomeProof, file); - a.incomeProofSubmitted = true; + this.setSingleDoc(this.newKycCapture.incomeProof.salaried, file); + this.syncIncomeProofSubmitted(a); + } + + async onNewItrFile(a: LoanApplication, yearIndex: number, event: Event): Promise { + const file = await this.pickSingleNewKycFile(event, `ITR — Year ${yearIndex + 1}`); + if (!file) { + return; + } + this.setSingleDoc(this.newKycCapture.incomeProof.itr[yearIndex], file); + this.syncIncomeProofSubmitted(a); + } + + /** An officer waives a missing ITR year here instead of the checklist being permanently + * blocked on a return that will never turn up -- the remark records exactly which year and + * why, so anyone reviewing the file later knows it wasn't just forgotten. */ + onItrExceptionChange(a: LoanApplication): void { + this.syncIncomeProofSubmitted(a); + } + + private syncIncomeProofSubmitted(a: LoanApplication): void { + const ip = this.newKycCapture.incomeProof; + a.incomeProofSubmitted = + ip.employmentType === 'BUSINESS' + ? ip.itr.every((doc) => !!doc.file || (doc.exception && doc.exceptionRemark.trim() !== '')) + : !!ip.salaried.file; + } + + /** True once every ITR year is covered but at least one of them is a waiver rather than an + * actual return -- flagged separately in the UI so "Submitted" doesn't quietly hide the fact + * that a year's return was never actually produced. */ + get itrHasException(): boolean { + const ip = this.newKycCapture.incomeProof; + return ip.employmentType === 'BUSINESS' && ip.itr.some((doc) => doc.exception); } async onNewBankStatementFile(a: LoanApplication, event: Event): Promise { @@ -528,6 +784,38 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy { } this.setSingleDoc(this.newKycCapture.bankStatement, file); a.bankStatementSubmitted = true; + if (this.isCsvFile(file)) { + await this.analyzeBankStatement(file); + } + } + + isCsvFile(file: File | null): boolean { + return !!file && (file.type === 'text/csv' || file.name.toLowerCase().endsWith('.csv')); + } + + /** Re-opens the analysis for a bank statement already on file -- the dialog only shows once, + * right after upload, so this is the only way back into it once an officer has closed it to + * double-check something. */ + reopenBankStatementAnalysis(file: File | null): void { + if (!file) { + return; + } + void this.analyzeBankStatement(file); + } + + /** A CSV bank statement can actually be read, unlike a PDF/photo -- so dropping one in here + * opens straight into the transaction analysis instead of just filing it away unread. */ + private async analyzeBankStatement(file: File): Promise { + const text = await file.text(); + const transactions = parseBankStatementCsv(text); + if (!transactions.length) { + return; + } + this.dialog.open(BankStatementAnalysisDialogComponent, { + data: { fileName: file.name, summary: summarizeBankStatement(transactions) }, + width: '900px', + maxWidth: '95vw' + }); } /** Address-proof bills go stale -- RBI KYC practice treats a utility bill older than 3 @@ -560,6 +848,130 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy { a.electricityBillSubmitted = !!this.newKycCapture.electricityBill.file && !this.electricityBillTooOld; } + // -------------------- Product-specific documents (Housing/KCC/Vehicle/... papers) -------------------- + async onProductDocFile(doc: ProductDocumentEntry, event: Event): Promise { + const file = await this.pickSingleNewKycFile(event, doc.label); + if (!file) { + return; + } + if (!this.productDocCapture[doc.key]) { + this.productDocCapture[doc.key] = { file: null, previewUrl: null }; + } + this.setSingleDoc(this.productDocCapture[doc.key], file); + doc.submitted = true; + } + + /** Not every product's required-document list anticipates everything a specific file might + * need (e.g. a second or third co-owner's KYC on a Housing Loan) -- this lets the officer add + * exactly the document they're holding, under whatever label describes it. */ + addCustomProductDoc(a: LoanApplication): void { + const label = this.newCustomDocLabel.trim(); + if (!label) { + return; + } + const key = `custom-${a.productDocuments.length}-${label.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`; + a.productDocuments.push({ key, label, custom: true, submitted: false }); + this.productDocCapture[key] = { file: null, previewUrl: null }; + this.newCustomDocLabel = ''; + } + + /** Only officer-added documents can be removed -- a product's own required list stays fixed, + * since that's what the loan is actually collateralised/eligible against. */ + removeCustomProductDoc(a: LoanApplication, doc: ProductDocumentEntry): void { + if (!doc.custom) { + return; + } + const idx = a.productDocuments.indexOf(doc); + if (idx >= 0) { + a.productDocuments.splice(idx, 1); + } + const capture = this.productDocCapture[doc.key]; + if (capture?.previewUrl) { + URL.revokeObjectURL(capture.previewUrl); + } + delete this.productDocCapture[doc.key]; + } + + onCoOwnerToggle(a: LoanApplication): void { + this.syncCoOwnerDocSubmitted(a); + } + + // -------------------- Co-Borrower income capture -------------------- + async onCoBorrowerSalarySlipFile(index: number, event: Event): Promise { + const file = await this.pickSingleNewKycFile(event, `Co-Borrower Salary Slip — Month ${index + 1}`); + if (!file) { + return; + } + this.setSingleDoc(this.coBorrowerCapture.salarySlips[index], file); + } + + /** Single source of truth for "is there actually a usable co-borrower on file" -- name, + * relationship, income and all 3 salary slips. Both the eligibility badge and the blocking + * error message key off this SAME method (not separate ad-hoc checks), so the two can never + * disagree with each other on screen. */ + coBorrowerDetailsComplete(a: LoanApplication): boolean { + const hasName = !!a.coBorrowerName && a.coBorrowerName !== '—' && a.coBorrowerName !== 'To be added'; + return ( + this.coBorrowerCapture.wanted && + hasName && + !!this.coBorrowerCapture.relationship && + !!this.coBorrowerCapture.monthlyIncome && + this.coBorrowerCapture.monthlyIncome > 0 && + this.coBorrowerCapture.salarySlips.every((doc) => !!doc.file) + ); + } + + applicantEmiCapacity(a: LoanApplication): number { + return a.monthlyIncome * 0.4; + } + + coBorrowerEmiCapacity(): number { + return (this.coBorrowerCapture.monthlyIncome || 0) * 0.4; + } + + /** The 40% affordability rule applied to the household rather than the applicant alone: 40% of + * the applicant's own income, plus 40% of the co-borrower's (once their details are actually + * complete -- income and salary slips, not just a number typed in), added together. */ + combinedEmiCapacity(a: LoanApplication): number { + const coBorrowerCapacity = this.coBorrowerDetailsComplete(a) ? this.coBorrowerEmiCapacity() : 0; + return this.applicantEmiCapacity(a) + coBorrowerCapacity; + } + + combinedEligibilityOk(a: LoanApplication): boolean { + if (this.isRevolving(a)) { + return true; + } + return this.emiAmountFor(a) <= this.combinedEmiCapacity(a); + } + + /** Front/back capture and OTP/format verification happen in their own popup rather than inline + * in the document card -- the exact same reason the applicant's own Aadhaar/PAN capture was + * moved out of a flat card grid: that much UI crammed into one card breaks the grid's layout. + * `state` is passed by reference, so anything the dialog does to it (new files, verified flip) + * is already reflected here the moment the dialog closes, regardless of how it was closed. */ + openCoOwnerVerify(a: LoanApplication, docType: 'aadhaar' | 'pan'): void { + this.dialog + .open(CoOwnerKycVerifyDialogComponent, { + data: { docType, state: this.coOwnerCapture[docType] }, + width: '480px', + maxWidth: '95vw' + }) + .afterClosed() + .subscribe(() => this.syncCoOwnerDocSubmitted(a)); + } + + /** No co-owner ticked means there's nothing to collect, so the requirement is satisfied by + * default -- it only actually blocks the checklist once the officer says a co-owner exists + * and their Aadhaar/PAN haven't both been VERIFIED yet (not just uploaded). */ + private syncCoOwnerDocSubmitted(a: LoanApplication): void { + const doc = a.productDocuments.find((d) => d.key === 'coOwnerKyc'); + if (!doc) { + return; + } + doc.submitted = + !this.coOwnerCapture.hasCoOwner || (this.coOwnerCapture.aadhaar.verified && this.coOwnerCapture.pan.verified); + } + private setSingleDoc(doc: NewKycSingleDoc, file: File): void { if (doc.previewUrl) { URL.revokeObjectURL(doc.previewUrl); @@ -592,11 +1004,27 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy { ngOnDestroy(): void { this.sub?.unsubscribe(); this.revokeNewKycPreviews(this.newKycCapture); + this.revokeProductDocPreviews(); + this.revokeDocSidePreviews(this.coOwnerCapture.aadhaar); + this.revokeDocSidePreviews(this.coOwnerCapture.pan); + this.revokeCoBorrowerPreviews(); + this.revokeFiPhotoPreviews(); + if (this.fiTickTimer) { + clearInterval(this.fiTickTimer); + } if (this.toastTimer) { clearTimeout(this.toastTimer); } } + private revokeProductDocPreviews(): void { + Object.values(this.productDocCapture).forEach((doc) => { + if (doc.previewUrl) { + URL.revokeObjectURL(doc.previewUrl); + } + }); + } + private toast(msg: string): void { this.toastMsg = msg; this.toastVisible = true; @@ -970,55 +1398,554 @@ I confirm that: return calcEligibleAmount(this.monthlyIncomeInput || 0, a.productCode); } + /** Live EMI preview off whatever's currently typed into Requested Amount / Tenure -- falls + * back to the application's own committed values for whichever input is blank, so editing + * just one field still previews correctly against the other two. */ + emiPreview(a: LoanApplication): number { + const amount = this.requestedAmountInput || a.requestedAmount; + const tenure = this.tenureMonthsInput || a.tenureMonths; + if (amount <= 0 || tenure <= 0) { + return 0; + } + return calculateEmi(amount, a.interestRate, tenure); + } + + /** What share of monthly income the previewed EMI would eat -- the number the 40% + * affordability rule below is actually checked against. */ + emiToIncomePctPreview(a: LoanApplication): number { + const income = this.monthlyIncomeInput || 0; + if (income <= 0) { + return 0; + } + return (this.emiPreview(a) / income) * 100; + } + + /** Same ratio, but off the application's actually-committed figures -- this is what + * `allKycDone` gates on, since a figure the officer merely typed but never saved shouldn't be + * able to unlock (or block) progress. */ + emiToIncomePct(a: LoanApplication): number { + if (a.monthlyIncome <= 0) { + return 0; + } + return (this.emiAmountFor(a) / a.monthlyIncome) * 100; + } + + /** RBI-style affordability guardrail: an EMI eating more than 40% of monthly income needs a + * co-borrower's income behind it (or the amount/tenure adjusted until it doesn't) before the + * application can move past KYC. The eligible-amount-shortfall rule in setMonthlyIncome() can + * also set `coBorrowerRequired` -- either way, this only clears once a real co-borrower (name + + * relationship + salary slips + monthly income) is on file AND the combined 40% household + * capacity actually covers the EMI -- adding a co-borrower on paper without enough income + * behind them doesn't just wave the requirement through. */ + affordabilityOk(a: LoanApplication): boolean { + if (!a.coBorrowerRequired) { + return true; + } + if (!this.coBorrowerDetailsComplete(a)) { + return false; + } + return this.combinedEligibilityOk(a); + } + + /** The product's own min/max amount and tenure -- a 40,000-month tenure or a ₹1 crore Housing + * Loan against a 10-40 lakh product both "compute" a technically-correct EMI, they just aren't + * a real sanction anyone would write up, so out-of-range values are rejected rather than + * silently accepted. */ + productLimitsFor(a: LoanApplication) { + return ORIGINATION_PRODUCTS.find((p) => p.code === a.productCode); + } + + amountOutOfRange(a: LoanApplication): boolean { + const product = this.productLimitsFor(a); + if (!product || !this.requestedAmountInput) { + return false; + } + return this.requestedAmountInput < product.minAmt || this.requestedAmountInput > product.maxAmt; + } + + tenureOutOfRange(a: LoanApplication): boolean { + const product = this.productLimitsFor(a); + if (!product || !this.tenureMonthsInput) { + return false; + } + return this.tenureMonthsInput < product.minTenure || this.tenureMonthsInput > product.maxTenure; + } + + /** Re-assessable at any time, not just once before the first click -- lets the officer revisit + * income, requested amount or tenure as many times as the conversation with the customer + * actually needs. Amount/tenure are only actually applied when they fall inside the product's + * own range -- an out-of-range value is left showing (with its error) but doesn't touch the + * application, same as leaving the field blank does. */ setIncomeNow(a: LoanApplication): void { if (!this.monthlyIncomeInput || this.monthlyIncomeInput <= 0) { return; } + if (this.requestedAmountInput && this.requestedAmountInput > 0 && !this.amountOutOfRange(a)) { + a.requestedAmount = this.requestedAmountInput; + } + if (this.tenureMonthsInput && this.tenureMonthsInput > 0 && !this.tenureOutOfRange(a)) { + a.tenureMonths = this.tenureMonthsInput; + } setMonthlyIncome(a, this.monthlyIncomeInput, this.currentUserName); + if (!this.isRevolving(a) && this.emiToIncomePct(a) > 40) { + a.coBorrowerRequired = true; + if (a.coBorrowerName === '—') { + a.coBorrowerName = 'To be added'; + } + } } allKycDone(a: LoanApplication): boolean { - const base = + return ( a.aadhaarVerified && a.panVerified && - a.aadhaarFingerprintCaptured && a.incomeProofSubmitted && a.bankStatementSubmitted && a.electricityBillSubmitted && - a.monthlyIncome > 0; - return a.secured ? base && a.propertyDetailsSubmitted && a.taxReceiptSubmitted : base; + a.monthlyIncome > 0 && + a.productDocuments.every((doc) => doc.submitted) && + this.affordabilityOk(a) + ); } // -------------------- Field Investigation -------------------- + readonly fiCheckLabels = FI_CHECK_LABELS; + fiComplete(a: LoanApplication): boolean { const addressOk = a.addressVerification.status === 'VERIFIED'; const officeOk = a.officeVerification.status === 'VERIFIED'; const propertyOk = !a.secured || a.propertyVerification.status === 'VERIFIED'; - return addressOk && officeOk && propertyOk; + const shopOk = !a.hasShop || a.shopVerification.status === 'VERIFIED'; + return addressOk && officeOk && propertyOk && shopOk; + } + + /** The FI checks to render -- Property only for a secured product, Shop only when the officer + * has flagged that this applicant runs one, same gates fiComplete() already uses. */ + fiRows(a: LoanApplication): FiCheckKey[] { + const keys: FiCheckKey[] = [ + 'addressVerification', + 'officeVerification' + ]; + if (a.secured) { + keys.push('propertyVerification'); + } + if (a.hasShop) { + keys.push('shopVerification'); + } + return keys; + } + + assignFieldOfficerNow(a: LoanApplication, key: FiCheckKey): void { + const name = this.fiOfficerNameInput[key].trim(); + if (!name) { + return; + } + assignFieldOfficer(a, key, name); + this.fiOfficerNameInput[key] = ''; + const removed = this.fiPhotoCapture[key].splice(0, this.fiPhotoCapture[key].length); + removed.forEach((doc) => { + if (doc.previewUrl) { + URL.revokeObjectURL(doc.previewUrl); + } + }); + } + + async onFiPhotoFile(a: LoanApplication, key: FiCheckKey, event: Event): Promise { + const input = event.target as HTMLInputElement; + const files = Array.from(input.files ?? []); + input.value = ''; + for (const file of files) { + this.fiPhotoCapture[key].push({ + file, + previewUrl: file.type.startsWith('image/') ? URL.createObjectURL(file) : null + }); + } + a[key].photoCount = this.fiPhotoCapture[key].length; + } + + removeFiPhoto(a: LoanApplication, key: FiCheckKey, index: number): void { + const [removed] = this.fiPhotoCapture[key].splice(index, 1); + if (removed?.previewUrl) { + URL.revokeObjectURL(removed.previewUrl); + } + a[key].photoCount = this.fiPhotoCapture[key].length; + } + + /** A visit can only be marked Verified once the officer has actually captured something -- + * GPS coordinates and at least one site photo -- a bare status flip proves nothing, same + * reasoning as every other "self-ticked checkbox" this pipeline replaced this session. */ + fiCanVerify(a: LoanApplication, key: FiCheckKey): boolean { + const v = a[key]; + return !!v.latitude.trim() && !!v.longitude.trim() && this.fiPhotoCapture[key].length > 0; + } + + verifyFieldVisit(a: LoanApplication, key: FiCheckKey): void { + if (!this.fiCanVerify(a, key)) { + return; + } + recordVerification(a, key, 'VERIFIED', a[key].officerAssigned, 'Address visited and confirmed correct.'); + } + + rejectFieldVisit(a: LoanApplication, key: FiCheckKey): void { + const remark = this.fiRejectRemarkInput[key].trim(); + if (!remark) { + return; + } + recordVerification(a, key, 'FAILED', a[key].officerAssigned, remark); + this.fiRejectRemarkInput[key] = ''; + } + + /** Hours elapsed since the officer was assigned -- the raw number the 48-hour SLA is checked + * against, driven off `nowTick` so it stays live without a page refresh. */ + fiElapsedHours(a: LoanApplication, key: FiCheckKey): number { + const assignedAt = a[key].assignedAt; + if (!assignedAt) { + return 0; + } + return (this.nowTick - assignedAt) / 3600000; + } + + fiIsOverdue(a: LoanApplication, key: FiCheckKey): boolean { + return a[key].status === 'PENDING' && !!a[key].assignedAt && this.fiElapsedHours(a, key) > 48; + } + + fiSlaLabel(a: LoanApplication, key: FiCheckKey): string { + if (a[key].status !== 'PENDING' || !a[key].assignedAt) { + return ''; + } + const remaining = 48 - this.fiElapsedHours(a, key); + return remaining > 0 + ? `${remaining.toFixed(1)}h left of the 48h SLA` + : `${Math.abs(remaining).toFixed(1)}h past the 48h SLA`; } completeFi(a: LoanApplication): void { - const agent = 'Field Verification Team'; - const keys: Array<'addressVerification' | 'officeVerification' | 'propertyVerification'> = [ - 'addressVerification', - 'officeVerification', - 'propertyVerification' - ]; - for (const key of keys) { - if (a[key].status !== 'PENDING') { - recordVerification( - a, - key, - a[key].status, - agent, - a[key].remarks === '—' ? `${key} recorded on FI visit.` : a[key].remarks - ); - } - } this.proceed(a, 'Field Investigation completed — Agent Report filed.'); } + /** Once a visit is Verified or Failed, the inline card only shows a short summary -- this opens + * the full record (officer, GPS, every site photo, remarks) in a popup instead of cramming it + * all into the card and breaking the grid layout. */ + openFiVisitDetails(a: LoanApplication, key: FiCheckKey): void { + this.dialog.open(FieldVisitDetailsDialogComponent, { + data: { label: this.fiCheckLabels[key], check: a[key], photos: this.fiPhotoCapture[key] }, + width: '520px', + maxWidth: '95vw' + }); + } + + // -------------------- Property Valuation (Field Investigation, secured products) -------------------- + readonly propertyTypes = PROPERTY_TYPES; + + /** propertyValuation is a derived field, not directly editable -- re-synced every time the area + * or either rate changes, so every existing read of a.propertyValuation elsewhere in the app + * (stage-history remarks, Risk Approval) stays correct without needing to change those sites. */ + updatePropertyValuation(a: LoanApplication): void { + a.propertyValuation = (a.builtUpAreaSqFt || 0) * (a.marketRatePerSqFt || 0); + } + + guidelineValue(a: LoanApplication): number { + return (a.builtUpAreaSqFt || 0) * (a.guidelineRatePerSqFt || 0); + } + + /** Banks don't lend against the full market value -- a distress/forced sale realises less than + * what the property would fetch with a patient seller and enough time on the market. 90%/75% + * are the general rule-of-thumb haircuts Indian lenders apply, not a product-specific RBI slab + * (those vary too much by product to hard-code one number here). */ + realizableValue(a: LoanApplication): number { + return a.propertyValuation * 0.9; + } + + distressValue(a: LoanApplication): number { + return a.propertyValuation * 0.75; + } + + /** Loan-to-Value against the realizable (not market) value -- the conservative number a real + * credit committee actually underwrites against. */ + ltvPercent(a: LoanApplication): number { + const realizable = this.realizableValue(a); + if (realizable <= 0) { + return 0; + } + return (a.requestedAmount / realizable) * 100; + } + + ltvHigh(a: LoanApplication): boolean { + return this.ltvPercent(a) > 80; + } + // -------------------- Personal Discussion -------------------- + readonly meetingManagers = MANAGERS; + + /** Bound directly to a.meetingDate/meetingTime/meetingMode/meetingWith (plain text, same + * DD-MM-YYYY convention every other date on this screen uses) -- this just makes the + * appointment official (flips meetingScheduled, logs it to stage history) once all 4 are + * filled in; editable again afterwards the same way for a reschedule. */ + scheduleMeetingNow(a: LoanApplication): void { + if (!a.meetingDate.trim() || !a.meetingTime.trim() || !a.meetingMode || !a.meetingWith.trim()) { + return; + } + scheduleMeeting( + a, + a.meetingDate.trim(), + a.meetingTime.trim(), + a.meetingMode, + a.meetingWith.trim(), + this.currentUserName + ); + } + + // -------------------- Application Summary Report (briefing doc for the meeting) -------------------- + /** Everything captured on this application so far -- KYC, CIC, Field Investigation (every + * address, GPS, photos, remarks), property valuation, agent report and the full stage-history + * audit trail -- rolled into one PDF so whoever takes the Personal Discussion meeting has a + * complete briefing in hand instead of clicking through every stage of the screen themselves. */ + private buildApplicationSummaryPdf(a: LoanApplication): jsPDF { + const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' }); + const pageWidth = doc.internal.pageSize.getWidth(); + const margin = 14; + let y = 20; + + const addLine = (label: string, value: string, yPos: number): number => { + doc.setFont('helvetica', 'bold'); + doc.setFontSize(9.5); + doc.text(label, margin, yPos); + doc.setFont('helvetica', 'normal'); + doc.text(value || '—', margin + 60, yPos); + return yPos + 6; + }; + + const sectionTitle = (title: string, yPos: number): number => { + doc.setFont('helvetica', 'bold'); + doc.setFontSize(11.5); + doc.setTextColor(16, 116, 185); + doc.text(title, margin, yPos); + doc.setTextColor(0, 0, 0); + return yPos + 7; + }; + + const ensureSpace = (needed: number): void => { + if (y + needed > doc.internal.pageSize.getHeight() - 15) { + doc.addPage(); + y = 20; + } + }; + + const finalYOf = (): number => (doc as unknown as { lastAutoTable: { finalY: number } }).lastAutoTable.finalY; + + doc.setFillColor(15, 23, 42); + doc.rect(0, 0, pageWidth, 26, 'F'); + doc.setTextColor(255, 255, 255); + doc.setFont('helvetica', 'bold'); + doc.setFontSize(15); + doc.text('Loan Application Summary Report', pageWidth / 2, 12, { align: 'center' }); + doc.setFontSize(8.5); + doc.setFont('helvetica', 'normal'); + doc.text('Prepared for the Personal Discussion meeting', pageWidth / 2, 19, { align: 'center' }); + doc.setTextColor(0, 0, 0); + y = 34; + + doc.setFontSize(9); + doc.text(`Generated: ${todayDDMMYYYY()}`, margin, y); + doc.text(`Application No: ${a.applicationNo}`, pageWidth - margin, y, { align: 'right' }); + y += 8; + + y = sectionTitle('Application Details', y); + y = addLine('Applicant Name:', a.applicantName, y); + y = addLine('Branch:', `${a.branchName} (${a.branchCode})`, y); + y = addLine('Channel:', a.channel, y); + y = addLine('Customer Type:', a.customerType, y); + y = addLine('Product:', `${a.productName} (${a.productType === 'OVERDRAFT' ? 'Overdraft' : 'Term Loan'})`, y); + y = addLine('Requested Amount:', `Rs. ${a.requestedAmount.toLocaleString('en-IN')}`, y); + y = addLine('Tenure:', `${a.tenureMonths} months`, y); + y = addLine('Interest Rate:', `${a.interestRate}% p.a.`, y); + y += 2; + + y = sectionTitle('Income & Eligibility', y); + y = addLine('Monthly Income:', `Rs. ${a.monthlyIncome.toLocaleString('en-IN')}`, y); + y = addLine('Eligible Loan Amount:', `Rs. ${a.eligibleLoanAmount.toLocaleString('en-IN')}`, y); + y = addLine( + 'Estimated EMI:', + this.isRevolving(a) ? 'No fixed EMI (revolving)' : `Rs. ${this.emiAmountFor(a).toLocaleString('en-IN')}/month`, + y + ); + if (a.coBorrowerRequired) { + y = addLine('Co-Borrower:', a.coBorrowerName, y); + } + y += 2; + + ensureSpace(45); + y = sectionTitle('KYC & Documents', y); + const kycRows: string[][] = [ + [ + 'Aadhaar Verified', + a.aadhaarVerified ? 'Yes' : 'No' + ], + [ + 'PAN Verified', + a.panVerified ? 'Yes' : 'No' + ], + [ + 'Income Proof', + a.incomeProofSubmitted ? 'Submitted' : 'Missing' + ], + [ + 'Bank Statement', + a.bankStatementSubmitted ? 'Submitted' : 'Missing' + ], + [ + 'Electricity Bill / Address Proof', + a.electricityBillSubmitted ? 'Submitted' : 'Missing' + ], + ...a.productDocuments.map((d) => [ + d.label, + d.submitted ? 'Submitted' : 'Missing' + ]) + ]; + autoTable(doc, { + startY: y, + head: [[ + 'Document', + 'Status' + ]], + body: kycRows, + margin: { left: margin, right: margin }, + styles: { fontSize: 8.5 }, + headStyles: { fillColor: [ + 15, + 23, + 42 + ] } + }); + y = finalYOf() + 8; + + ensureSpace(30); + y = sectionTitle('CIC / Credit Bureau Check', y); + y = addLine('Bureau:', a.cicBureau, y); + y = addLine('Credit Score:', a.cibilScore > 0 ? `${a.cibilScore} (${cibilBand(a.cibilScore)})` : 'Not checked', y); + y = addLine('Report:', a.cicReportUploaded ? a.cicReportFileName : 'Not uploaded', y); + y += 2; + + ensureSpace(40); + y = sectionTitle('Field Investigation', y); + const fiRowsData = this.fiRows(a).map((key) => { + const v = a[key]; + return [ + this.fiCheckLabels[key], + v.status, + v.officerAssigned, + v.latitude && v.longitude ? `${v.latitude}, ${v.longitude}` : '—', + String(v.photoCount), + v.remarks + ]; + }); + autoTable(doc, { + startY: y, + head: [ + [ + 'Address', + 'Status', + 'Officer', + 'GPS', + 'Photos', + 'Remarks' + ] + ], + body: fiRowsData, + margin: { left: margin, right: margin }, + styles: { fontSize: 7.5 }, + headStyles: { fillColor: [ + 15, + 23, + 42 + ] } + }); + y = finalYOf() + 8; + + if (a.secured) { + ensureSpace(50); + y = sectionTitle('Property Valuation', y); + y = addLine('Property Type:', a.propertyType, y); + y = addLine('Built-Up Area:', `${a.builtUpAreaSqFt} sq ft`, y); + y = addLine('Market Rate:', `Rs. ${a.marketRatePerSqFt.toLocaleString('en-IN')}/sq ft`, y); + y = addLine('Market Value:', `Rs. ${a.propertyValuation.toLocaleString('en-IN')}`, y); + y = addLine('Guideline Value:', `Rs. ${this.guidelineValue(a).toLocaleString('en-IN')}`, y); + y = addLine('Realizable Value (90%):', `Rs. ${this.realizableValue(a).toLocaleString('en-IN')}`, y); + y = addLine('Loan-to-Value:', `${this.ltvPercent(a).toFixed(1)}%`, y); + y = addLine('Valuer:', a.valuerName, y); + y += 2; + } + + ensureSpace(25); + y = sectionTitle('Agent Report', y); + y = addLine('Report File:', a.agentReportUploaded ? a.agentReportFileName : 'Not uploaded', y); + y = addLine('Remark:', a.agentReportRemark, y); + y += 2; + + ensureSpace(30); + y = sectionTitle('Personal Discussion Meeting', y); + y = addLine('Scheduled With:', a.meetingWith, y); + y = addLine('Date & Time:', a.meetingScheduled ? `${a.meetingDate} ${a.meetingTime}` : 'Not scheduled', y); + y = addLine('Mode:', a.meetingMode || '—', y); + y += 2; + + ensureSpace(30); + y = sectionTitle('Application Stage History', y); + const historyRows = a.stageHistory.map((h) => [ + this.stageLabels[h.stage], + h.date, + h.actionBy, + h.outcome, + h.remarks + ]); + autoTable(doc, { + startY: y, + head: [ + [ + 'Stage', + 'Date', + 'By', + 'Outcome', + 'Remarks' + ] + ], + body: historyRows, + margin: { left: margin, right: margin }, + styles: { fontSize: 7 }, + headStyles: { fillColor: [ + 15, + 23, + 42 + ] } + }); + + const footerY = doc.internal.pageSize.getHeight() - 8; + doc.setFontSize(7); + doc.setTextColor(150, 150, 150); + doc.text( + 'This is a system-generated document. Generated from CBS Nexora — Loan Origination Module.', + pageWidth / 2, + footerY, + { align: 'center' } + ); + + return doc; + } + + private applicationSummaryFileName(a: LoanApplication): string { + return `Application_Summary_${a.applicationNo}.pdf`; + } + + printApplicationSummary(a: LoanApplication): void { + const doc = this.buildApplicationSummaryPdf(a); + doc.autoPrint(); + window.open(doc.output('bloburl'), '_blank'); + } + + downloadApplicationSummary(a: LoanApplication): void { + this.buildApplicationSummaryPdf(a).save(this.applicationSummaryFileName(a)); + } + clearPersonalDiscussion(a: LoanApplication): void { clearPersonalDiscussionAndProceed( a, @@ -1131,19 +2058,37 @@ I confirm that: } uploadCicReport(a, file.name, this.currentUserName); input.value = ''; + this.openCicReportAnalysis(a); + } + + /** No real bureau API or PDF parser sits behind this mock pipeline, so the analysis shown here + * is generated (deterministically, seeded off the application) from the cibilScore the "Check + * Credit Score" step already pulled -- not read from the actual uploaded file. Auto-opens right + * after upload, and reopenable afterwards via the "View Analysis" pill next to the Report row. */ + openCicReportAnalysis(a: LoanApplication): void { + this.dialog.open(CicReportAnalysisDialogComponent, { + data: { fileName: a.cicReportFileName, analysis: generateCicReportAnalysis(a) }, + width: '960px', + maxWidth: '95vw' + }); } pushFieldInquiryNow(a: LoanApplication): void { pushFieldInquiry(a, 'Field Verification Team', this.currentUserName); } + /** Free-text reference/summary the officer types before attaching the Agent Report file -- + * transient, cleared once the upload lands on the application (see onAgentReportFileSelected). */ + agentReportRemarkInput = ''; + onAgentReportFileSelected(event: Event, a: LoanApplication): void { const input = event.target as HTMLInputElement; const file = input.files?.[0]; if (!file) { return; } - uploadAgentReport(a, file.name, this.currentUserName); + uploadAgentReport(a, file.name, this.agentReportRemarkInput.trim(), this.currentUserName); + this.agentReportRemarkInput = ''; input.value = ''; } @@ -1162,7 +2107,7 @@ I confirm that: // -------------------- EMI Repayment Schedule (once disbursed) -------------------- isRevolving(a: LoanApplication): boolean { - return isRevolvingProduct(a.productCode); + return a.productType === 'OVERDRAFT'; } emiAmountFor(a: LoanApplication): number { diff --git a/src/app/loan-origination/application-detail/bank-statement-analysis-dialog/bank-statement-analysis-dialog.component.html b/src/app/loan-origination/application-detail/bank-statement-analysis-dialog/bank-statement-analysis-dialog.component.html new file mode 100644 index 000000000..b62fabf77 --- /dev/null +++ b/src/app/loan-origination/application-detail/bank-statement-analysis-dialog/bank-statement-analysis-dialog.component.html @@ -0,0 +1,126 @@ + + +

+ Bank Statement Analysis + {{ data.fileName }} +

+ +
+
+
+ {{ summary.riskScore }} + / 100 +
+
+
+ {{ summary.riskRating }} RISK +
    + @for (reason of summary.riskReasons; track reason) { +
  • {{ reason }}
  • + } +
+
+
+ + @if (summary.bouncedTransactions.length > 0) { +
+
+ {{ summary.bouncedTransactions.length }} Bounced / Returned Transaction(s) Found +
+
    + @for (b of summary.bouncedTransactions; track b.date) { +
  • {{ b.date | date: 'dd-MM-yyyy' }} — {{ b.narration }} (₹{{ b.amount | number: '1.0-0' }})
  • + } +
+
+ } + +
+
+ Avg Monthly Income + ₹{{ summary.avgMonthlyCredit | number: '1.0-0' }} +
+
+ Avg Monthly Expense + ₹{{ summary.avgMonthlyDebit | number: '1.0-0' }} +
+
+ Average Balance + ₹{{ summary.avgBalance | number: '1.0-0' }} +
+
+ Closing Balance + ₹{{ summary.closingBalance | number: '1.0-0' }} +
+
+ Minimum Balance + ₹{{ summary.minBalance | number: '1.0-0' }} +
+
+ Maximum Balance + ₹{{ summary.maxBalance | number: '1.0-0' }} +
+
+ Existing EMI / Loan Obligations + ₹{{ summary.existingMonthlyObligations | number: '1.0-0' }}/mo +
+
+ FOIR (Obligations ÷ Income) + {{ summary.foirPercent | number: '1.0-1' }}% +
+
+ Income Stability + {{ summary.incomeStability === 'STABLE' ? 'Stable' : 'Irregular' }} +
+
+ Positive Cash-Flow Months + {{ summary.positiveCashFlowMonths }} / {{ summary.monthsCovered }} +
+
+ Negative Cash-Flow Months + {{ summary.negativeCashFlowMonths }} / {{ summary.monthsCovered }} +
+
+ Salary Credits Detected + {{ summary.salaryCreditsDetected }} +
+
+ Total Transactions + {{ summary.transactionCount }} +
+
+ Period Covered + {{ summary.monthsCovered }} months +
+
+ +
+

Monthly Income vs Expense

+
+ +
+
+
+

Account Balance Trend

+
+ +
+
+ @if (summary.categoryBreakdown.length > 0) { +
+

Where the Money Goes

+
+ +
+
+ } +
+ + + diff --git a/src/app/loan-origination/application-detail/bank-statement-analysis-dialog/bank-statement-analysis-dialog.component.scss b/src/app/loan-origination/application-detail/bank-statement-analysis-dialog/bank-statement-analysis-dialog.component.scss new file mode 100644 index 000000000..e35ffebfc --- /dev/null +++ b/src/app/loan-origination/application-detail/bank-statement-analysis-dialog/bank-statement-analysis-dialog.component.scss @@ -0,0 +1,218 @@ +/** + * 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/. + */ + +.bsa-title { + display: flex; + align-items: baseline; + gap: 10px; + flex-wrap: wrap; +} + +.bsa-title-file { + font-size: 13px; + font-weight: 500; + color: #94a3b8; +} + +.bsa-content { + min-width: 520px; + max-width: 900px; +} + +// -- Risk hero: a CSS conic-gradient gauge ring (no canvas dependency, always pixel-correct) -- +.bsa-hero { + display: flex; + align-items: center; + gap: 24px; + padding: 20px 24px; + margin-bottom: 18px; + border-radius: 16px; + background: linear-gradient(135deg, #f8fafc, #eef2ff); + border: 1px solid #e2e8f0; +} + +.bsa-gauge { + --bsa-gauge-pct: 0; + --bsa-gauge-color: #10b981; + + flex-shrink: 0; + width: 128px; + height: 128px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + background: conic-gradient(var(--bsa-gauge-color) calc(var(--bsa-gauge-pct) * 1%), #e2e8f0 0); + box-shadow: 0 4px 14px rgb(15 23 42 / 8%); +} + +.bsa-gauge-inner { + width: 98px; + height: 98px; + border-radius: 50%; + background: #fff; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + box-shadow: inset 0 0 0 1px #f1f5f9; +} + +.bsa-gauge-score { + font-size: 30px; + font-weight: 800; + color: #0f172a; + line-height: 1; +} + +.bsa-gauge-outof { + font-size: 11px; + font-weight: 600; + color: #94a3b8; + margin-top: 2px; +} + +.bsa-hero-details { + display: flex; + flex-direction: column; + gap: 10px; + min-width: 0; +} + +.bsa-risk-badge { + display: inline-flex; + align-items: center; + align-self: flex-start; + padding: 5px 14px; + border-radius: 999px; + font-size: 12.5px; + font-weight: 800; + letter-spacing: 0.05em; + + &--low { + background: #d1fae5; + color: #047857; + } + + &--medium { + background: #fef3c7; + color: #b45309; + } + + &--high { + background: #fee2e2; + color: #b91c1c; + } +} + +.bsa-risk-reasons { + margin: 0; + padding-left: 18px; + font-size: 13px; + color: #334155; + line-height: 1.6; +} + +// -- Bounced/returned transactions -- the strongest single red flag, called out on its own -- +.bsa-bounce-alert { + padding: 14px 18px; + margin-bottom: 18px; + border-radius: 14px; + border: 1px solid #fecaca; + background: #fef2f2; +} + +.bsa-bounce-alert-title { + font-size: 13px; + font-weight: 800; + color: #b91c1c; + text-transform: uppercase; + letter-spacing: 0.03em; + margin-bottom: 6px; +} + +.bsa-bounce-alert ul { + margin: 0; + padding-left: 18px; + font-size: 13px; + color: #7f1d1d; + line-height: 1.6; +} + +// -- KPI cards -- +.bsa-kpi-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); + gap: 12px; + margin-bottom: 24px; +} + +.bsa-kpi { + display: flex; + flex-direction: column; + gap: 5px; + padding: 12px 14px; + border-radius: 12px; + background: #fff; + border: 1px solid #e2e8f0; + border-left: 3px solid #cbd5e1; + + &--income { + border-left-color: #10b981; + } + + &--expense { + border-left-color: #f43f5e; + } + + &--neutral { + border-left-color: #6366f1; + } +} + +.bsa-kpi-label { + font-size: 10.5px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #94a3b8; +} + +.bsa-kpi-value { + font-size: 17px; + font-weight: 700; + color: #0f172a; + font-variant-numeric: tabular-nums; +} + +// -- Chart panels -- +.bsa-chart-panel { + padding: 18px 20px 8px; + margin-bottom: 18px; + border-radius: 16px; + background: #fff; + border: 1px solid #e2e8f0; +} + +.bsa-chart-panel-title { + margin: 0 0 14px; + font-size: 13px; + font-weight: 700; + color: #334155; +} + +.bsa-chart-canvas-wrap { + position: relative; + height: 240px; + + &--category { + height: 280px; + max-width: 460px; + margin: 0 auto; + } +} diff --git a/src/app/loan-origination/application-detail/bank-statement-analysis-dialog/bank-statement-analysis-dialog.component.ts b/src/app/loan-origination/application-detail/bank-statement-analysis-dialog/bank-statement-analysis-dialog.component.ts new file mode 100644 index 000000000..9785d3781 --- /dev/null +++ b/src/app/loan-origination/application-detail/bank-statement-analysis-dialog/bank-statement-analysis-dialog.component.ts @@ -0,0 +1,185 @@ +/** + * 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 { AfterViewInit, Component, OnDestroy, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog'; +import { MatButtonModule } from '@angular/material/button'; +import { Chart, registerables } from 'chart.js'; +import { BankStatementSummary } from './bank-statement-analysis.util'; + +Chart.register(...registerables); +Chart.defaults.font.family = "'Inter', 'Segoe UI', -apple-system, sans-serif"; +Chart.defaults.color = '#64748b'; +Chart.defaults.plugins.legend.labels.usePointStyle = true; +Chart.defaults.plugins.legend.labels.boxWidth = 8; + +export interface BankStatementAnalysisDialogData { + fileName: string; + summary: BankStatementSummary; +} + +const CATEGORY_COLORS = [ + '#6366f1', + '#10b981', + '#f43f5e', + '#f59e0b', + '#0ea5e9', + '#a855f7', + '#ec4899', + '#14b8a6', + '#94a3b8', + '#84cc16', + '#fb923c' +]; + +/** + * Shows a bank statement's transaction history rolled up into what a credit officer actually + * needs from it -- a plain-language risk read, existing-obligation load (FOIR), bounced- + * transaction red flags, monthly income vs expense, the balance trend, and where the money + * actually goes -- opened straight after a CSV bank statement is picked in the KYC step, no + * separate "analyze" action needed. + */ +@Component({ + selector: 'mifosx-bank-statement-analysis-dialog', + standalone: true, + imports: [ + CommonModule, + MatDialogModule, + MatButtonModule + ], + templateUrl: './bank-statement-analysis-dialog.component.html', + styleUrls: ['./bank-statement-analysis-dialog.component.scss'] +}) +export class BankStatementAnalysisDialogComponent implements AfterViewInit, OnDestroy { + data = inject(MAT_DIALOG_DATA); + + private barChart: any = null; + private balanceChart: any = null; + private categoryChart: any = null; + + get summary(): BankStatementSummary { + return this.data.summary; + } + + get riskBadgeClass(): string { + return `bsa-risk-badge bsa-risk-badge--${this.summary.riskRating.toLowerCase()}`; + } + + /** Drives the CSS conic-gradient gauge ring -- a plain custom-property, not a canvas chart, so + * it always renders pixel-correct without depending on Chart.js's own gauge quirks. */ + get gaugeColor(): string { + return this.summary.riskRating === 'LOW' ? '#10b981' : this.summary.riskRating === 'MEDIUM' ? '#f59e0b' : '#f43f5e'; + } + + ngAfterViewInit(): void { + const s = this.summary; + const labels = s.monthly.map((m) => m.label); + + const barCtx = (document.getElementById('bsaIncomeExpenseChart') as HTMLCanvasElement)?.getContext('2d'); + const balanceCtx = (document.getElementById('bsaBalanceTrendChart') as HTMLCanvasElement)?.getContext('2d'); + + const balanceGradient = balanceCtx?.createLinearGradient(0, 0, 0, 260); + balanceGradient?.addColorStop(0, 'rgba(99, 102, 241, 0.35)'); + balanceGradient?.addColorStop(1, 'rgba(99, 102, 241, 0)'); + + this.barChart = new Chart(barCtx ?? 'bsaIncomeExpenseChart', { + type: 'bar', + data: { + labels, + datasets: [ + { + label: 'Credits (Income)', + data: s.monthly.map((m) => m.credit), + backgroundColor: '#10b981', + borderRadius: 6, + barPercentage: 0.7, + categoryPercentage: 0.6 + }, + { + label: 'Debits (Expense)', + data: s.monthly.map((m) => m.debit), + backgroundColor: '#f43f5e', + borderRadius: 6, + barPercentage: 0.7, + categoryPercentage: 0.6 + } + ] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { legend: { position: 'top', align: 'end' } }, + scales: { + x: { grid: { display: false } }, + y: { beginAtZero: true, grid: { color: '#f1f5f9' } } + } + } + }); + + this.balanceChart = new Chart(balanceCtx ?? 'bsaBalanceTrendChart', { + type: 'line', + data: { + labels, + datasets: [ + { + label: 'End-of-Month Balance', + data: s.monthly.map((m) => m.closingBalance), + borderColor: '#6366f1', + backgroundColor: balanceGradient ?? 'rgba(99, 102, 241, 0.15)', + pointBackgroundColor: '#6366f1', + pointRadius: 3, + pointHoverRadius: 6, + borderWidth: 2.5, + fill: true, + tension: 0.35 + } + ] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { legend: { display: false } }, + scales: { + x: { grid: { display: false } }, + y: { beginAtZero: false, grid: { color: '#f1f5f9' } } + } + } + }); + + if (s.categoryBreakdown.length) { + this.categoryChart = new Chart('bsaCategoryChart', { + type: 'doughnut', + data: { + labels: s.categoryBreakdown.map((c) => c.category), + datasets: [ + { + data: s.categoryBreakdown.map((c) => c.amount), + backgroundColor: s.categoryBreakdown.map((_, i) => CATEGORY_COLORS[i % CATEGORY_COLORS.length]), + borderWidth: 2, + borderColor: '#fff', + hoverOffset: 8 + } + ] + }, + options: { + responsive: true, + maintainAspectRatio: false, + cutout: '68%', + plugins: { legend: { position: 'right', labels: { padding: 14 } } } + } + }); + } + } + + ngOnDestroy(): void { + this.barChart?.destroy(); + this.balanceChart?.destroy(); + this.categoryChart?.destroy(); + } +} diff --git a/src/app/loan-origination/application-detail/bank-statement-analysis-dialog/bank-statement-analysis.util.ts b/src/app/loan-origination/application-detail/bank-statement-analysis-dialog/bank-statement-analysis.util.ts new file mode 100644 index 000000000..7e8f715a1 --- /dev/null +++ b/src/app/loan-origination/application-detail/bank-statement-analysis-dialog/bank-statement-analysis.util.ts @@ -0,0 +1,427 @@ +/** + * 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/. + */ + +export interface BankTransaction { + date: Date; + narration: string; + debit: number; + credit: number; + balance: number; +} + +export interface MonthlyBankSummary { + label: string; + credit: number; + debit: number; + closingBalance: number; + netCashFlow: number; +} + +export interface BouncedTransaction { + date: Date; + narration: string; + amount: number; +} + +export interface CategorySpend { + category: string; + amount: number; +} + +export type RiskRating = 'LOW' | 'MEDIUM' | 'HIGH'; + +export interface BankStatementSummary { + openingBalance: number; + closingBalance: number; + minBalance: number; + maxBalance: number; + avgBalance: number; + totalCredit: number; + totalDebit: number; + avgMonthlyCredit: number; + avgMonthlyDebit: number; + transactionCount: number; + monthsCovered: number; + salaryCreditsDetected: number; + monthly: MonthlyBankSummary[]; + + // -- Underwriting-focused signals a loan officer actually needs, not just cash-flow totals -- + /** Bounced/returned debits (ECS return, cheque return, insufficient funds) -- the single + * strongest red flag a bank statement carries; a clean cash-flow summary can still hide these + * if nobody goes looking for the narration text specifically. */ + bouncedTransactions: BouncedTransaction[]; + /** Recurring EMI/loan-type debits already going out every month, annualised to a monthly + * average -- the "existing obligations" side of a FOIR (Fixed Obligation to Income Ratio) + * calculation. */ + existingMonthlyObligations: number; + /** existingMonthlyObligations as a % of avgMonthlyCredit -- how much of this customer's income + * is already committed before a new EMI is even added. */ + foirPercent: number; + /** Coefficient-of-variation based read on whether monthly income credits land at a + * similar amount each month (salaried-style) or swing widely (irregular/gig income). */ + incomeStability: 'STABLE' | 'IRREGULAR'; + /** How many of the covered months closed with more money in than out. */ + positiveCashFlowMonths: number; + negativeCashFlowMonths: number; + /** Where the money actually goes -- narration-keyword-classified debit totals, for the + * expense-breakdown chart. */ + categoryBreakdown: CategorySpend[]; + /** A transparent, rule-based read (not a black-box score) an officer can act on immediately, + * plus the specific reasons behind it so it can be checked against the actual statement. */ + riskRating: RiskRating; + riskScore: number; + riskReasons: string[]; +} + +/** Splits one CSV line into fields, honouring double-quoted values that may contain commas -- + * a plain `.split(',')` would break on a quoted narration like "UPI-RENT, LANDLORD A/C". */ +function splitCsvLine(line: string): string[] { + const fields: string[] = []; + let current = ''; + let inQuotes = false; + for (const ch of line) { + if (ch === '"') { + inQuotes = !inQuotes; + } else if (ch === ',' && !inQuotes) { + fields.push(current); + current = ''; + } else { + current += ch; + } + } + fields.push(current); + return fields; +} + +function parseDDMMYYYY(value: string | undefined): Date | null { + const match = (value || '').trim().match(/^(\d{1,2})-(\d{1,2})-(\d{4})$/); + if (!match) { + return null; + } + const [ + , + day, + month, + year + ] = match; + return new Date(+year, +month - 1, +day); +} + +/** Parses a bank-statement CSV export (Date, Narration, Debit, Credit, Balance columns, in any + * order, matched case-insensitively) into transaction rows. Recognises a couple of common + * header aliases (e.g. "Withdrawal"/"Deposit") since not every bank's export uses "Debit"/ + * "Credit" literally, but otherwise expects the same shape every Indian bank statement uses. */ +export function parseBankStatementCsv(text: string): BankTransaction[] { + const lines = text.split(/\r?\n/).filter((line) => line.trim().length > 0); + if (lines.length < 2) { + return []; + } + const header = splitCsvLine(lines[0]).map((h) => h.trim().toLowerCase()); + const columnIndex = (aliases: string[]): number => { + for (const alias of aliases) { + const idx = header.indexOf(alias); + if (idx >= 0) { + return idx; + } + } + return -1; + }; + const dateIdx = columnIndex([ + 'date', + 'txn date', + 'transaction date' + ]); + const narrationIdx = columnIndex([ + 'narration', + 'description', + 'particulars' + ]); + const debitIdx = columnIndex([ + 'debit', + 'withdrawal amt.', + 'withdrawal' + ]); + const creditIdx = columnIndex([ + 'credit', + 'deposit amt.', + 'deposit' + ]); + const balanceIdx = columnIndex([ + 'balance', + 'closing balance' + ]); + if (dateIdx < 0 || balanceIdx < 0) { + return []; + } + + const transactions: BankTransaction[] = []; + for (let i = 1; i < lines.length; i++) { + const cols = splitCsvLine(lines[i]); + const date = parseDDMMYYYY(cols[dateIdx]); + if (!date) { + continue; + } + transactions.push({ + date, + narration: narrationIdx >= 0 ? (cols[narrationIdx] || '').trim() : '', + debit: parseFloat(cols[debitIdx]) || 0, + credit: parseFloat(cols[creditIdx]) || 0, + balance: parseFloat(cols[balanceIdx]) || 0 + }); + } + return transactions; +} + +const BOUNCE_KEYWORDS = [ + 'RETURN', + 'BOUNCE', + 'DISHONOUR', + 'DISHONOR', + 'INSUFFICIENT FUND', + 'INSUFFICIENT BALANCE' +]; + +function isBouncedTransaction(narration: string): boolean { + const n = narration.toUpperCase(); + return BOUNCE_KEYWORDS.some((k) => n.includes(k)); +} + +function isExistingObligationDebit(narration: string): boolean { + const n = narration.toUpperCase(); + return (n.includes('EMI') || n.includes('LOAN')) && !isBouncedTransaction(n); +} + +/** Keyword categories checked in this order -- first match wins, so a more specific label (e.g. + * Insurance) is claimed before a generic one (e.g. Bank Charges) can grab it. */ +const CATEGORY_RULES: Array<{ category: string; keywords: string[] }> = [ + { category: 'Bounced / Returned', keywords: BOUNCE_KEYWORDS }, + { category: 'Insurance', keywords: [ + 'LIC', + 'INSURANCE', + 'PREMIUM' + ] }, + { category: 'EMI / Loan Repayment', keywords: [ + 'EMI', + 'LOAN' + ] }, + { category: 'Rent', keywords: ['RENT'] }, + { category: 'Utilities & Bills', keywords: [ + 'ELECTRICITY', + 'RECHARGE', + 'BBPS', + 'BILL', + 'JIO' + ] }, + { category: 'Groceries', keywords: [ + 'BIGBASKET', + 'DMART', + 'RELIANCE FRESH', + 'KIRANA', + 'SUPERMARKET', + 'GROCERY' + ] }, + { category: 'Dining & Food Delivery', keywords: [ + 'SWIGGY', + 'ZOMATO', + 'PIZZA', + 'CAFE', + 'RESTAURANT' + ] }, + { category: 'Shopping', keywords: [ + 'AMAZON', + 'FLIPKART', + 'MYNTRA', + 'AJIO', + 'GARMENTS', + 'SHOP' + ] }, + { category: 'Fuel & Transport', keywords: [ + 'PETROL', + 'FUEL', + 'AUTO FARE' + ] }, + { category: 'Cash Withdrawal', keywords: ['ATM'] }, + { category: 'Bank Charges', keywords: [ + 'CHARGE', + 'FEE', + 'PENALTY' + ] } +]; + +function categorize(narration: string): string { + const n = narration.toUpperCase(); + for (const rule of CATEGORY_RULES) { + if (rule.keywords.some((k) => n.includes(k))) { + return rule.category; + } + } + return 'Other'; +} + +/** Coefficient of variation on the months that actually saw a credit -- a salaried applicant's + * income lands at a similar figure every month (low variation); gig/business income swings + * much more (high variation), which is exactly the distinction a lender needs to know before + * trusting a monthly average. */ +function assessIncomeStability(monthly: MonthlyBankSummary[]): 'STABLE' | 'IRREGULAR' { + const credits = monthly.map((m) => m.credit).filter((c) => c > 0); + if (credits.length < 2) { + return 'STABLE'; + } + const mean = credits.reduce((sum, c) => sum + c, 0) / credits.length; + const variance = credits.reduce((sum, c) => sum + (c - mean) ** 2, 0) / credits.length; + const coefficientOfVariation = mean > 0 ? Math.sqrt(variance) / mean : 0; + return coefficientOfVariation < 0.25 ? 'STABLE' : 'IRREGULAR'; +} + +/** A transparent, rule-based risk read instead of an opaque score -- every point lost is tied to + * a specific, checkable reason, which is what a real credit decision needs to be defensible. */ +function assessRisk(input: { + bounceCount: number; + foirPercent: number; + minBalance: number; + avgMonthlyCredit: number; + incomeStability: 'STABLE' | 'IRREGULAR'; +}): { rating: RiskRating; score: number; reasons: string[] } { + let score = 100; + const reasons: string[] = []; + + if (input.bounceCount > 0) { + score -= Math.min(40, input.bounceCount * 20); + reasons.push(`${input.bounceCount} bounced/returned transaction(s) found`); + } + if (input.foirPercent > 50) { + score -= 25; + reasons.push(`Existing obligations are ${input.foirPercent.toFixed(0)}% of average income`); + } else if (input.foirPercent > 35) { + score -= 10; + reasons.push(`Existing obligations run moderate at ${input.foirPercent.toFixed(0)}% of average income`); + } + if (input.minBalance < 0) { + score -= 20; + reasons.push('Account balance went negative at least once'); + } else if (input.avgMonthlyCredit > 0 && input.minBalance < input.avgMonthlyCredit * 0.1) { + score -= 10; + reasons.push('Minimum balance runs very thin relative to income'); + } + if (input.incomeStability === 'IRREGULAR') { + score -= 15; + reasons.push('Monthly income credits are irregular, not a steady salaried pattern'); + } + score = Math.max(0, Math.min(100, score)); + if (reasons.length === 0) { + reasons.push('No red flags found in this statement'); + } + const rating: RiskRating = score >= 75 ? 'LOW' : score >= 50 ? 'MEDIUM' : 'HIGH'; + return { rating, score, reasons }; +} + +/** Rolls a year of transactions up into the numbers a credit officer actually reads a bank + * statement for: average monthly inflow/outflow, how tight the balance runs, how many salary- + * like credits show up, whether anything ever bounced, how much of the income is already + * committed to existing EMIs, and a plain-language risk read tying all of that together. */ +export function summarizeBankStatement(transactions: BankTransaction[]): BankStatementSummary { + const sorted = [...transactions].sort((a, b) => a.date.getTime() - b.date.getTime()); + const monthlyMap = new Map(); + const bouncedTransactions: BouncedTransaction[] = []; + const categoryTotals = new Map(); + let totalCredit = 0; + let totalDebit = 0; + let salaryCreditsDetected = 0; + let existingMonthlyObligations = 0; + let minBalance = Infinity; + let maxBalance = -Infinity; + let balanceSum = 0; + + for (const t of sorted) { + const key = `${t.date.getFullYear()}-${t.date.getMonth()}`; + if (!monthlyMap.has(key)) { + monthlyMap.set(key, { + label: t.date.toLocaleDateString('en-IN', { month: 'short', year: 'numeric' }), + credit: 0, + debit: 0, + closingBalance: t.balance, + netCashFlow: 0 + }); + } + const month = monthlyMap.get(key)!; + month.credit += t.credit; + month.debit += t.debit; + month.closingBalance = t.balance; + month.netCashFlow = month.credit - month.debit; + + totalCredit += t.credit; + totalDebit += t.debit; + minBalance = Math.min(minBalance, t.balance); + maxBalance = Math.max(maxBalance, t.balance); + balanceSum += t.balance; + + if (t.credit >= 20000 && /salary/i.test(t.narration)) { + salaryCreditsDetected++; + } + if (t.debit > 0) { + if (isBouncedTransaction(t.narration)) { + bouncedTransactions.push({ date: t.date, narration: t.narration, amount: t.debit }); + } else if (isExistingObligationDebit(t.narration)) { + existingMonthlyObligations += t.debit; + } + const category = categorize(t.narration); + categoryTotals.set(category, (categoryTotals.get(category) ?? 0) + t.debit); + } + } + + const monthly = [...monthlyMap.values()]; + const monthsCovered = monthly.length || 1; + const first = sorted[0]; + const avgMonthlyCredit = totalCredit / monthsCovered; + const avgMonthlyObligations = existingMonthlyObligations / monthsCovered; + const foirPercent = avgMonthlyCredit > 0 ? (avgMonthlyObligations / avgMonthlyCredit) * 100 : 0; + const incomeStability = assessIncomeStability(monthly); + const positiveCashFlowMonths = monthly.filter((m) => m.netCashFlow >= 0).length; + const negativeCashFlowMonths = monthly.length - positiveCashFlowMonths; + const categoryBreakdown: CategorySpend[] = [...categoryTotals.entries()] + .map( + ([ + category, + amount + ]) => ({ category, amount }) + ) + .sort((a, b) => b.amount - a.amount); + const risk = assessRisk({ + bounceCount: bouncedTransactions.length, + foirPercent, + minBalance: sorted.length ? minBalance : 0, + avgMonthlyCredit, + incomeStability + }); + + return { + openingBalance: first ? first.balance - first.credit + first.debit : 0, + closingBalance: sorted.length ? sorted[sorted.length - 1].balance : 0, + minBalance: sorted.length ? minBalance : 0, + maxBalance: sorted.length ? maxBalance : 0, + avgBalance: sorted.length ? balanceSum / sorted.length : 0, + totalCredit, + totalDebit, + avgMonthlyCredit, + avgMonthlyDebit: totalDebit / monthsCovered, + transactionCount: sorted.length, + monthsCovered, + salaryCreditsDetected, + monthly, + bouncedTransactions, + existingMonthlyObligations: avgMonthlyObligations, + foirPercent, + incomeStability, + positiveCashFlowMonths, + negativeCashFlowMonths, + categoryBreakdown, + riskRating: risk.rating, + riskScore: risk.score, + riskReasons: risk.reasons + }; +} diff --git a/src/app/loan-origination/application-detail/cic-report-analysis-dialog/cic-report-analysis-dialog.component.html b/src/app/loan-origination/application-detail/cic-report-analysis-dialog/cic-report-analysis-dialog.component.html new file mode 100644 index 000000000..1749ef389 --- /dev/null +++ b/src/app/loan-origination/application-detail/cic-report-analysis-dialog/cic-report-analysis-dialog.component.html @@ -0,0 +1,167 @@ + + +

+ {{ analysis.bureau }} Credit Report Analysis + {{ data.fileName }} +

+ +
+
+
+ {{ analysis.score }} + / 900 +
+
+
+ {{ analysis.band }} +

{{ analysis.recommendation }}

+
    + @for (factor of analysis.scoreFactors; track factor) { +
  • {{ factor }}
  • + } +
+
+
+ + @if (analysis.totalOverdueAmt > 0 || analysis.writtenOffAccounts > 0) { +
+
+ @if (analysis.writtenOffAccounts > 0) { + {{ analysis.writtenOffAccounts }} Written-Off Account(s) Found + } @else { + ₹{{ analysis.totalOverdueAmt | number: '1.0-0' }} Currently Overdue + } +
+

{{ analysis.recommendation }}

+
+ } + +
+
+ Total Accounts + {{ analysis.totalAccounts }} +
+
+ Active Accounts + {{ analysis.activeAccounts }} +
+
+ Closed Accounts + {{ analysis.closedAccounts }} +
+
+ Written-Off Accounts + {{ analysis.writtenOffAccounts }} +
+
+ Total Current Balance + ₹{{ analysis.totalCurrentBalance | number: '1.0-0' }} +
+
+ Secured Accounts Amt + ₹{{ analysis.securedAmt | number: '1.0-0' }} +
+
+ Unsecured Accounts Amt + ₹{{ analysis.unsecuredAmt | number: '1.0-0' }} +
+
+ Total Overdue + ₹{{ analysis.totalOverdueAmt | number: '1.0-0' }} +
+
+ Total Late Payments + {{ analysis.totalLatePayments }} +
+
+ Enquiries (Last 90 Days) + {{ analysis.enquiriesLast90Days }} +
+
+ + @if (analysis.accounts.length > 0) { +
+

Payment Behaviour by Account (last 12 months)

+
+ +
+
+ +
+

Credit Account Information

+
+ + + + + + + + + + + + + + + + @for (acc of analysis.accounts; track acc.accountNo) { + + + + + + + + + + + + } + +
LenderAccount TypeOwnershipDate OpenedSanctioned AmtCurrent BalanceOverdue AmtStatusPayment Record
{{ acc.lender }}{{ acc.accountType }}{{ acc.ownership }}{{ acc.dateOpened }}₹{{ acc.sanctionedAmt | number: '1.0-0' }}₹{{ acc.currentBalance | number: '1.0-0' }}₹{{ acc.overdueAmt | number: '1.0-0' }} + {{ acc.status }} + {{ worstDpdLabel(acc.worstDpd) }}
+
+
+ } + + @if (analysis.enquiries.length > 0) { +
+

Credit Enquiries

+
+ + + + + + + + + + + @for (enq of analysis.enquiries; track enq.date + enq.institution) { + + + + + + + } + +
Credit InstitutionDateSearch TypeAmount Applied
{{ enq.institution }}{{ enq.date }}{{ enq.purpose }}₹{{ enq.amountApplied | number: '1.0-0' }}
+
+
+ } @else { +

No credit enquiries found in the last 24 months.

+ } +
+ + + diff --git a/src/app/loan-origination/application-detail/cic-report-analysis-dialog/cic-report-analysis-dialog.component.scss b/src/app/loan-origination/application-detail/cic-report-analysis-dialog/cic-report-analysis-dialog.component.scss new file mode 100644 index 000000000..9b1cf6093 --- /dev/null +++ b/src/app/loan-origination/application-detail/cic-report-analysis-dialog/cic-report-analysis-dialog.component.scss @@ -0,0 +1,288 @@ +/** + * 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/. + */ + +.cra-title { + display: flex; + align-items: baseline; + gap: 10px; + flex-wrap: wrap; +} + +.cra-title-file { + font-size: 13px; + font-weight: 500; + color: #94a3b8; +} + +.cra-content { + min-width: 560px; + max-width: 960px; +} + +// -- Score hero: a CSS conic-gradient gauge ring on the bureau's real 300-900 scale -- +.cra-hero { + display: flex; + align-items: center; + gap: 24px; + padding: 20px 24px; + margin-bottom: 18px; + border-radius: 16px; + background: linear-gradient(135deg, #f8fafc, #eef2ff); + border: 1px solid #e2e8f0; +} + +.cra-gauge { + --cra-gauge-pct: 0; + --cra-gauge-color: #10b981; + + flex-shrink: 0; + width: 128px; + height: 128px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + background: conic-gradient(var(--cra-gauge-color) calc(var(--cra-gauge-pct) * 1%), #e2e8f0 0); + box-shadow: 0 4px 14px rgb(15 23 42 / 8%); +} + +.cra-gauge-inner { + width: 98px; + height: 98px; + border-radius: 50%; + background: #fff; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + box-shadow: inset 0 0 0 1px #f1f5f9; +} + +.cra-gauge-score { + font-size: 28px; + font-weight: 800; + color: #0f172a; + line-height: 1; +} + +.cra-gauge-outof { + font-size: 11px; + font-weight: 600; + color: #94a3b8; + margin-top: 2px; +} + +.cra-hero-details { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 0; +} + +.cra-band-badge { + display: inline-flex; + align-items: center; + align-self: flex-start; + padding: 5px 14px; + border-radius: 999px; + font-size: 12.5px; + font-weight: 800; + letter-spacing: 0.05em; + text-transform: uppercase; + + &--excellent { + background: #d1fae5; + color: #047857; + } + + &--good { + background: #e0f2fe; + color: #0369a1; + } + + &--fair { + background: #fef3c7; + color: #b45309; + } + + &--poor { + background: #fee2e2; + color: #b91c1c; + } +} + +.cra-recommendation { + margin: 0; + font-size: 13px; + color: #334155; + line-height: 1.5; +} + +.cra-score-factors { + margin: 0; + padding-left: 18px; + font-size: 12px; + color: #64748b; + line-height: 1.6; +} + +// -- Overdue / written-off alert -- the strongest single red flag, called out on its own -- +.cra-alert { + padding: 14px 18px; + margin-bottom: 18px; + border-radius: 14px; + border: 1px solid #fecaca; + background: #fef2f2; +} + +.cra-alert-title { + font-size: 13px; + font-weight: 800; + color: #b91c1c; + text-transform: uppercase; + letter-spacing: 0.03em; + margin-bottom: 6px; +} + +.cra-alert p { + margin: 0; + font-size: 13px; + color: #7f1d1d; + line-height: 1.5; +} + +// -- KPI cards -- +.cra-kpi-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); + gap: 12px; + margin-bottom: 24px; +} + +.cra-kpi { + display: flex; + flex-direction: column; + gap: 5px; + padding: 12px 14px; + border-radius: 12px; + background: #fff; + border: 1px solid #e2e8f0; + border-left: 3px solid #cbd5e1; + + &--income { + border-left-color: #10b981; + } + + &--expense { + border-left-color: #f43f5e; + } + + &--neutral { + border-left-color: #6366f1; + } +} + +.cra-kpi-label { + font-size: 10.5px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #94a3b8; +} + +.cra-kpi-value { + font-size: 17px; + font-weight: 700; + color: #0f172a; + font-variant-numeric: tabular-nums; +} + +// -- Chart / table panels -- +.cra-chart-panel, +.cra-table-panel { + padding: 18px 20px; + margin-bottom: 18px; + border-radius: 16px; + background: #fff; + border: 1px solid #e2e8f0; +} + +.cra-chart-panel-title { + margin: 0 0 14px; + font-size: 13px; + font-weight: 700; + color: #334155; +} + +.cra-chart-canvas-wrap { + position: relative; + height: 240px; +} + +.cra-table-scroll { + overflow-x: auto; +} + +.cra-table { + width: 100%; + border-collapse: collapse; + font-size: 12.5px; + + th { + text-align: left; + padding: 8px 10px; + font-size: 10.5px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #94a3b8; + border-bottom: 1px solid #e2e8f0; + white-space: nowrap; + } + + td { + padding: 9px 10px; + color: #334155; + border-bottom: 1px solid #f1f5f9; + white-space: nowrap; + } + + tr:last-child td { + border-bottom: none; + } +} + +.cra-status-badge { + display: inline-flex; + align-items: center; + padding: 3px 10px; + border-radius: 999px; + font-size: 11px; + font-weight: 700; + + &--active { + background: #d1fae5; + color: #047857; + } + + &--closed { + background: #e2e8f0; + color: #475569; + } + + &--written-off { + background: #fee2e2; + color: #b91c1c; + } +} + +.cra-no-enquiries { + font-size: 13px; + color: #64748b; + padding: 0 4px; +} diff --git a/src/app/loan-origination/application-detail/cic-report-analysis-dialog/cic-report-analysis-dialog.component.ts b/src/app/loan-origination/application-detail/cic-report-analysis-dialog/cic-report-analysis-dialog.component.ts new file mode 100644 index 000000000..ace652324 --- /dev/null +++ b/src/app/loan-origination/application-detail/cic-report-analysis-dialog/cic-report-analysis-dialog.component.ts @@ -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 { AfterViewInit, Component, OnDestroy, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog'; +import { MatButtonModule } from '@angular/material/button'; +import { Chart, registerables } from 'chart.js'; +import { CicReportAnalysis } from './cic-report-analysis.util'; + +Chart.register(...registerables); +Chart.defaults.font.family = "'Inter', 'Segoe UI', -apple-system, sans-serif"; +Chart.defaults.color = '#64748b'; +Chart.defaults.plugins.legend.labels.usePointStyle = true; +Chart.defaults.plugins.legend.labels.boxWidth = 8; + +export interface CicReportAnalysisDialogData { + fileName: string; + analysis: CicReportAnalysis; +} + +const BAND_COLORS: Record = { + Excellent: '#10b981', + Good: '#0ea5e9', + Fair: '#f59e0b', + Poor: '#f43f5e' +}; + +/** + * Shows a full bureau-style credit report analysis -- score gauge, score factors, account + * summary, per-account payment behaviour and enquiry history -- straight after a CIC/Credit + * Bureau report file is uploaded, the same "auto-opens on upload" pattern as the Bank Statement + * Analysis dialog. Everything shown is generated from this application's own cibilScore, not + * read from the actual uploaded file, since there's no real bureau API or PDF parser behind this + * mock pipeline -- see cic-report-analysis.util.ts for why it's still fully self-consistent. + */ +@Component({ + selector: 'mifosx-cic-report-analysis-dialog', + standalone: true, + imports: [ + CommonModule, + MatDialogModule, + MatButtonModule + ], + templateUrl: './cic-report-analysis-dialog.component.html', + styleUrls: ['./cic-report-analysis-dialog.component.scss'] +}) +export class CicReportAnalysisDialogComponent implements AfterViewInit, OnDestroy { + data = inject(MAT_DIALOG_DATA); + + private dpdChart: any = null; + + get analysis(): CicReportAnalysis { + return this.data.analysis; + } + + get bandBadgeClass(): string { + return `cra-band-badge cra-band-badge--${this.analysis.band.toLowerCase()}`; + } + + /** Drives the CSS conic-gradient gauge ring -- same pixel-correct approach used for the Bank + * Statement risk gauge, scaled to the bureau's real 300-900 score range. */ + get gaugeColor(): string { + return BAND_COLORS[this.analysis.band]; + } + + get gaugePct(): number { + return Math.max(0, Math.min(100, ((this.analysis.score - 300) / 600) * 100)); + } + + accountStatusClass(status: string): string { + if (status === 'WRITTEN-OFF') { + return 'cra-status-badge cra-status-badge--written-off'; + } + if (status === 'CLOSED') { + return 'cra-status-badge cra-status-badge--closed'; + } + return 'cra-status-badge cra-status-badge--active'; + } + + worstDpdLabel(worstDpd: number): string { + return worstDpd === 0 ? 'Always on time' : `${worstDpd} days past due (worst)`; + } + + ngAfterViewInit(): void { + const a = this.analysis; + const labels = a.accounts.map((acc) => `${acc.lender} — ${acc.accountType}`); + const onTimeCounts = a.accounts.map((acc) => acc.dpdHistory.filter((p) => p.dpd === 0).length); + const lateCounts = a.accounts.map((acc) => acc.dpdHistory.filter((p) => p.dpd > 0).length); + + this.dpdChart = new Chart('craDpdChart', { + type: 'bar', + data: { + labels, + datasets: [ + { + label: 'On-Time Months', + data: onTimeCounts, + backgroundColor: '#10b981', + borderRadius: 6, + barPercentage: 0.6, + categoryPercentage: 0.6, + stack: 'dpd' + }, + { + label: 'Late Months', + data: lateCounts, + backgroundColor: '#f43f5e', + borderRadius: 6, + barPercentage: 0.6, + categoryPercentage: 0.6, + stack: 'dpd' + } + ] + }, + options: { + indexAxis: 'y', + responsive: true, + maintainAspectRatio: false, + plugins: { legend: { position: 'top', align: 'end' } }, + scales: { + x: { beginAtZero: true, grid: { color: '#f1f5f9' }, ticks: { stepSize: 1 } }, + y: { grid: { display: false } } + } + } + }); + } + + ngOnDestroy(): void { + this.dpdChart?.destroy(); + } +} diff --git a/src/app/loan-origination/application-detail/cic-report-analysis-dialog/cic-report-analysis.util.ts b/src/app/loan-origination/application-detail/cic-report-analysis-dialog/cic-report-analysis.util.ts new file mode 100644 index 000000000..959b461c5 --- /dev/null +++ b/src/app/loan-origination/application-detail/cic-report-analysis-dialog/cic-report-analysis.util.ts @@ -0,0 +1,349 @@ +/** + * 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/. + */ + +export interface CicDpdPoint { + label: string; + dpd: number; +} + +export interface CicCreditAccount { + lender: string; + accountType: string; + accountNo: string; + ownership: 'Individual' | 'Joint'; + secured: boolean; + dateOpened: string; + status: 'ACTIVE' | 'CLOSED' | 'WRITTEN-OFF'; + sanctionedAmt: number; + currentBalance: number; + overdueAmt: number; + tenureMonths: number; + rateOfInterest: number; + dpdHistory: CicDpdPoint[]; + worstDpd: number; +} + +export interface CicEnquiry { + institution: string; + date: string; + purpose: string; + amountApplied: number; +} + +export type CicRiskBand = 'Excellent' | 'Good' | 'Fair' | 'Poor'; + +export interface CicReportAnalysis { + bureau: string; + reportDate: string; + applicantName: string; + score: number; + band: CicRiskBand; + scoreFactors: string[]; + totalAccounts: number; + activeAccounts: number; + closedAccounts: number; + writtenOffAccounts: number; + totalCurrentBalance: number; + securedAmt: number; + unsecuredAmt: number; + totalOverdueAmt: number; + accounts: CicCreditAccount[]; + enquiries: CicEnquiry[]; + enquiriesLast90Days: number; + totalLatePayments: number; + recommendation: string; +} + +/** A tiny string-seeded PRNG (not cryptographic, just deterministic) -- the same application + * should show the exact same "pulled" credit report every time an officer opens or re-checks it + * in this session, the same way a real bureau pull doesn't change on a second look. */ +function seededRng(seed: string): () => number { + let h = 0; + for (let i = 0; i < seed.length; i++) { + h = (Math.imul(31, h) + seed.charCodeAt(i)) | 0; + } + let state = h || 1; + return () => { + state = (Math.imul(1103515245, state) + 12345) & 0x7fffffff; + return state / 0x7fffffff; + }; +} + +const LENDERS = [ + 'HDFC Bank', + 'ICICI Bank', + 'Axis Bank', + 'State Bank of India', + 'Bajaj Finserv', + 'Hero FinCorp Ltd', + 'Equitas Finance Pvt Ltd', + 'IDFC First Bank', + 'Muthoot Finance', + 'Tata Capital', + 'Cholamandalam Finance', + 'L&T Finance' +]; + +interface AccountTypeSpec { + type: string; + secured: boolean; + minAmt: number; + maxAmt: number; + minTenure: number; + maxTenure: number; + minRate: number; + maxRate: number; +} + +const ACCOUNT_TYPES: AccountTypeSpec[] = [ + { + type: 'TWO WHEELER LOAN', + secured: true, + minAmt: 30000, + maxAmt: 150000, + minTenure: 12, + maxTenure: 48, + minRate: 11, + maxRate: 19 + }, + { + type: 'PERSONAL LOAN', + secured: false, + minAmt: 50000, + maxAmt: 500000, + minTenure: 12, + maxTenure: 60, + minRate: 13, + maxRate: 22 + }, + { + type: 'BL GENERAL', + secured: false, + minAmt: 100000, + maxAmt: 900000, + minTenure: 24, + maxTenure: 120, + minRate: 15, + maxRate: 21 + }, + { + type: 'GOLD LOAN', + secured: true, + minAmt: 20000, + maxAmt: 300000, + minTenure: 6, + maxTenure: 24, + minRate: 9, + maxRate: 15 + }, + { + type: 'CONSUMER DURABLE LOAN', + secured: false, + minAmt: 5000, + maxAmt: 100000, + minTenure: 6, + maxTenure: 24, + minRate: 14, + maxRate: 24 + }, + { + type: 'CREDIT CARD', + secured: false, + minAmt: 20000, + maxAmt: 300000, + minTenure: 0, + maxTenure: 0, + minRate: 36, + maxRate: 42 + }, + { + type: 'HOUSING LOAN', + secured: true, + minAmt: 1000000, + maxAmt: 4000000, + minTenure: 120, + maxTenure: 240, + minRate: 8.5, + maxRate: 10.5 + } +]; + +/** RBI/credit-bureau-standard CIBIL score bands -- kept in sync with loan-origination-data.ts's + * own cibilBand() rather than importing it, so this analysis util has no dependency on the + * pipeline's mock-data module and can be unit-tested/reused on its own. */ +function scoreBand(score: number): CicRiskBand { + if (score >= 750) { + return 'Excellent'; + } + if (score >= 650) { + return 'Good'; + } + if (score >= 550) { + return 'Fair'; + } + return 'Poor'; +} + +const SCORE_FACTORS = [ + 'Recency: Recent Credit Account Defaults', + 'Leverage: Credit Accounts with on-time re-payment history', + 'Coverage: Non-delinquent and delinquent Credit Accounts', + 'Delinquency Status: Defaults on Credit Accounts (current & recent periodic intervals)', + 'Credit Applications: Credit Account Applications over last 30 days' +]; + +function monthsAgoLabel(monthsBack: number): string { + const d = new Date(); + d.setMonth(d.getMonth() - monthsBack); + return d.toLocaleDateString('en-IN', { month: 'short', year: 'numeric' }); +} + +function ddmmyyyyMonthsAgo(monthsBack: number, rng: () => number): string { + const d = new Date(); + d.setMonth(d.getMonth() - monthsBack); + d.setDate(1 + Math.floor(rng() * 27)); + const pad = (n: number): string => String(n).padStart(2, '0'); + return `${pad(d.getDate())}-${pad(d.getMonth() + 1)}-${d.getFullYear()}`; +} + +function maskAccountNo(rng: () => number): string { + const digits = Array.from({ length: 10 }, () => Math.floor(rng() * 10)).join(''); + return `XXXXXXXXXX${digits.slice(-4)}`; +} + +/** How badly (and how often) an account's monthly instalments have slipped -- driven by the + * score band, same shape as a real bureau's per-account DPD table: mostly clean, with the worse + * bands showing more months affected and higher day-counts on the ones that are. */ +function buildDpdHistory(band: CicRiskBand, monthsOpen: number, rng: () => number): CicDpdPoint[] { + const span = Math.min(12, Math.max(1, monthsOpen)); + const lateChance = band === 'Excellent' ? 0 : band === 'Good' ? 0.08 : band === 'Fair' ? 0.22 : 0.4; + const maxDpd = band === 'Excellent' ? 0 : band === 'Good' ? 29 : band === 'Fair' ? 89 : 180; + const history: CicDpdPoint[] = []; + for (let i = span - 1; i >= 0; i--) { + const isLate = maxDpd > 0 && rng() < lateChance; + history.push({ label: monthsAgoLabel(i), dpd: isLate ? Math.round(10 + rng() * (maxDpd - 10)) : 0 }); + } + return history; +} + +function buildAccount(band: CicRiskBand, rng: () => number): CicCreditAccount { + const spec = ACCOUNT_TYPES[Math.floor(rng() * ACCOUNT_TYPES.length)]; + const sanctionedAmt = Math.round((spec.minAmt + rng() * (spec.maxAmt - spec.minAmt)) / 1000) * 1000; + const monthsOpen = Math.round(3 + rng() * 48); + const tenureMonths = spec.maxTenure > 0 ? Math.round(spec.minTenure + rng() * (spec.maxTenure - spec.minTenure)) : 0; + const isClosed = tenureMonths > 0 && monthsOpen >= tenureMonths && rng() < 0.6; + const paidFraction = tenureMonths > 0 ? Math.min(1, monthsOpen / tenureMonths) : rng() * 0.6; + const dpdHistory = buildDpdHistory(band, monthsOpen, rng); + const worstDpd = dpdHistory.reduce((max, p) => Math.max(max, p.dpd), 0); + const isWrittenOff = band === 'Poor' && worstDpd >= 90 && rng() < 0.35; + const currentBalance = isClosed ? 0 : Math.round(sanctionedAmt * (1 - paidFraction) * (0.85 + rng() * 0.3)); + const overdueAmt = worstDpd > 30 && !isClosed ? Math.round(currentBalance * (0.05 + rng() * 0.15)) : 0; + return { + lender: LENDERS[Math.floor(rng() * LENDERS.length)], + accountType: spec.type, + accountNo: maskAccountNo(rng), + ownership: rng() < 0.75 ? 'Individual' : 'Joint', + secured: spec.secured, + dateOpened: ddmmyyyyMonthsAgo(monthsOpen, rng), + status: isWrittenOff ? 'WRITTEN-OFF' : isClosed ? 'CLOSED' : 'ACTIVE', + sanctionedAmt, + currentBalance: Math.max(0, currentBalance), + overdueAmt, + tenureMonths, + rateOfInterest: Math.round((spec.minRate + rng() * (spec.maxRate - spec.minRate)) * 100) / 100, + dpdHistory, + worstDpd + }; +} + +function buildEnquiry(rng: () => number): CicEnquiry { + const spec = ACCOUNT_TYPES[Math.floor(rng() * ACCOUNT_TYPES.length)]; + return { + institution: LENDERS[Math.floor(rng() * LENDERS.length)], + date: ddmmyyyyMonthsAgo(Math.round(rng() * 24), rng), + purpose: spec.type.replace('BL GENERAL', 'BUSINESS LOAN'), + amountApplied: Math.round((spec.minAmt + rng() * (spec.maxAmt - spec.minAmt)) / 1000) * 1000 + }; +} + +function buildRecommendation(band: CicRiskBand, totalOverdueAmt: number, writtenOffAccounts: number): string { + if (writtenOffAccounts > 0) { + return `${writtenOffAccounts} written-off account(s) on record — this is a hard decline signal under most credit policies regardless of the numeric score.`; + } + if (totalOverdueAmt > 0) { + return `Score is ${band}, but ₹${totalOverdueAmt.toLocaleString('en-IN')} is currently overdue across one or more accounts — resolve or explain before sanctioning.`; + } + if (band === 'Excellent' || band === 'Good') { + return `Score is ${band} with a clean repayment record and no overdue amounts — proceed with standard underwriting.`; + } + return `Score is ${band} — no overdue amount currently, but the repayment history is inconsistent enough to warrant additional scrutiny (co-borrower, lower amount, or tighter tenure).`; +} + +/** + * Builds a full, self-consistent mock bureau report for this applicant -- realistic account mix, + * per-account DPD history, enquiries and a plain-language recommendation -- all rolled up around + * the `cibilScore` this application already has from the "Check Credit Score" step. Deterministic + * per application (seeded off applicationNo + score), so re-opening it later shows the exact same + * "pulled" report rather than a new random one each time. + */ +export function generateCicReportAnalysis(app: { + applicationNo: string; + applicantName: string; + cicBureau: string; + cibilScore: number; +}): CicReportAnalysis { + const rng = seededRng(`${app.applicationNo}-${app.cibilScore}`); + const score = app.cibilScore > 0 ? app.cibilScore : Math.round(300 + rng() * 600); + const band = scoreBand(score); + const accountCount = 1 + Math.floor(rng() * 4); + const accounts = Array.from({ length: accountCount }, () => buildAccount(band, rng)); + const enquiryCount = band === 'Excellent' ? Math.floor(rng() * 2) : Math.floor(rng() * 4); + const enquiries = Array.from({ length: enquiryCount }, () => buildEnquiry(rng)); + + const activeAccounts = accounts.filter((a) => a.status === 'ACTIVE').length; + const closedAccounts = accounts.filter((a) => a.status === 'CLOSED').length; + const writtenOffAccounts = accounts.filter((a) => a.status === 'WRITTEN-OFF').length; + const totalCurrentBalance = accounts.reduce((sum, a) => sum + a.currentBalance, 0); + const securedAmt = accounts.filter((a) => a.secured).reduce((sum, a) => sum + a.currentBalance, 0); + const unsecuredAmt = totalCurrentBalance - securedAmt; + const totalOverdueAmt = accounts.reduce((sum, a) => sum + a.overdueAmt, 0); + const totalLatePayments = accounts.reduce((sum, a) => sum + a.dpdHistory.filter((p) => p.dpd > 0).length, 0); + const enquiriesLast90Days = enquiries.filter((e) => { + const [ + d, + m, + y + ] = e.date.split('-').map(Number); + const enquiryDate = new Date(y, m - 1, d); + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() - 90); + return enquiryDate >= cutoff; + }).length; + + return { + bureau: app.cicBureau, + reportDate: new Date().toLocaleDateString('en-IN', { day: '2-digit', month: '2-digit', year: 'numeric' }), + applicantName: app.applicantName, + score, + band, + scoreFactors: SCORE_FACTORS, + totalAccounts: accounts.length, + activeAccounts, + closedAccounts, + writtenOffAccounts, + totalCurrentBalance, + securedAmt, + unsecuredAmt, + totalOverdueAmt, + accounts, + enquiries, + enquiriesLast90Days, + totalLatePayments, + recommendation: buildRecommendation(band, totalOverdueAmt, writtenOffAccounts) + }; +} diff --git a/src/app/loan-origination/application-detail/co-owner-kyc-verify-dialog/co-owner-kyc-verify-dialog.component.html b/src/app/loan-origination/application-detail/co-owner-kyc-verify-dialog/co-owner-kyc-verify-dialog.component.html new file mode 100644 index 000000000..fa994c5cd --- /dev/null +++ b/src/app/loan-origination/application-detail/co-owner-kyc-verify-dialog/co-owner-kyc-verify-dialog.component.html @@ -0,0 +1,69 @@ + + +

Co-Owner {{ docLabel }} Verification

+ +
+ + +
+ + @if (data.state.frontPreviewUrl || data.state.backPreviewUrl) { +
+ @if (data.state.frontPreviewUrl) { + Front + } + @if (data.state.backPreviewUrl) { + Back + } +
+ } + + @if (data.state.verified) { + ✓ Verified + } @else { +
+ + @if (data.docType === 'aadhaar') { + + } @else { + + } +
+ @if (data.docType === 'aadhaar' && otpSent) { +
+
+ @for (d of otpDigits; track $index) { + + } +
+ +
+ } + } +
+ + + diff --git a/src/app/loan-origination/application-detail/co-owner-kyc-verify-dialog/co-owner-kyc-verify-dialog.component.scss b/src/app/loan-origination/application-detail/co-owner-kyc-verify-dialog/co-owner-kyc-verify-dialog.component.scss new file mode 100644 index 000000000..d930e5933 --- /dev/null +++ b/src/app/loan-origination/application-detail/co-owner-kyc-verify-dialog/co-owner-kyc-verify-dialog.component.scss @@ -0,0 +1,127 @@ +/** + * 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/. + */ + +.cokv-content { + min-width: 380px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.cokv-files { + display: flex; + gap: 8px; +} + +.cokv-upload-btn { + display: inline-flex; + align-items: center; + height: 36px; + padding: 0 16px; + border: 1px solid #cbd5e1; + border-radius: 999px; + background: #fff; + color: #334155; + font-size: 13px; + font-weight: 600; + cursor: pointer; + + &:hover { + background: #f8fafc; + } +} + +.cokv-thumbs { + display: flex; + gap: 8px; +} + +.cokv-thumb { + width: 100px; + height: 70px; + object-fit: cover; + border: 1px solid #e2e8f0; + border-radius: 8px; +} + +.cokv-verified-badge { + display: inline-flex; + align-self: flex-start; + align-items: center; + padding: 5px 14px; + border-radius: 999px; + background: #d1fae5; + color: #047857; + font-size: 12.5px; + font-weight: 800; +} + +.cokv-verify-row { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + + // stylelint-disable-next-line selector-no-qualifying-type + input[type='text'] { + height: 36px; + padding: 0 12px; + border: 1px solid #cbd5e1; + border-radius: 6px; + font-size: 13px; + width: 160px; + } +} + +.cokv-otp-row { + display: flex; + align-items: center; + gap: 10px; +} + +.cokv-otp-digits { + display: flex; + gap: 5px; + + // stylelint-disable-next-line selector-no-qualifying-type + input[type='text'] { + width: 30px; + height: 34px; + text-align: center; + padding: 0; + border: 1px solid #cbd5e1; + border-radius: 6px; + font-size: 14px; + } +} + +.cokv-btn { + height: 36px; + padding: 0 16px; + border: 1px solid #cbd5e1; + border-radius: 999px; + background: #fff; + color: #334155; + font-size: 13px; + font-weight: 700; + cursor: pointer; + + &:hover { + background: #f8fafc; + } + + &--primary { + border-color: #4338ca; + background: #4338ca; + color: #fff; + + &:hover { + background: #3730a3; + } + } +} diff --git a/src/app/loan-origination/application-detail/co-owner-kyc-verify-dialog/co-owner-kyc-verify-dialog.component.ts b/src/app/loan-origination/application-detail/co-owner-kyc-verify-dialog/co-owner-kyc-verify-dialog.component.ts new file mode 100644 index 000000000..827b7149e --- /dev/null +++ b/src/app/loan-origination/application-detail/co-owner-kyc-verify-dialog/co-owner-kyc-verify-dialog.component.ts @@ -0,0 +1,146 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog'; +import { MatButtonModule } from '@angular/material/button'; +import { KycUploadHelperService } from '../../../clients/shared/kyc-upload-helper.service'; + +export interface CoOwnerDocSide { + front: File | null; + back: File | null; + frontPreviewUrl: string | null; + backPreviewUrl: string | null; + number: string; + verified: boolean; +} + +export interface CoOwnerKycVerifyDialogData { + docType: 'aadhaar' | 'pan'; + /** Passed by reference on purpose -- every capture/verify action here mutates the caller's own + * object directly, so the result is already in place the instant this dialog closes, no + * matter whether it was closed via the Done button, the backdrop, or Esc. */ + state: CoOwnerDocSide; +} + +/** + * Front/back capture + OTP (Aadhaar) or format (PAN) verification for a Housing Loan co-owner, + * lifted into its own popup for exactly the same reason the applicant's own Aadhaar/PAN capture + * needed one: that much UI (two upload buttons, thumbnails, a number field, six OTP boxes) makes + * a flat document-card grid balloon in height and break its layout. + */ +@Component({ + selector: 'mifosx-co-owner-kyc-verify-dialog', + standalone: true, + imports: [ + CommonModule, + FormsModule, + MatDialogModule, + MatButtonModule + ], + templateUrl: './co-owner-kyc-verify-dialog.component.html', + styleUrls: ['./co-owner-kyc-verify-dialog.component.scss'] +}) +export class CoOwnerKycVerifyDialogComponent { + data = inject(MAT_DIALOG_DATA); + private kycUploadHelper = inject(KycUploadHelperService); + + otpSent = false; + otpDigits: string[] = [ + '', + '', + '', + '', + '', + '' + ]; + + get docLabel(): string { + return this.data.docType === 'aadhaar' ? 'Aadhaar' : 'PAN'; + } + + async onFile(side: 'front' | 'back', event: Event): Promise { + const input = event.target as HTMLInputElement; + const file = input.files?.[0]; + input.value = ''; + if (!file) { + return; + } + const cropped = await this.kycUploadHelper.pickWithCrop(file, `Co-Owner ${this.docLabel} — ${side}`); + if (!cropped) { + return; + } + const state = this.data.state; + if (side === 'front') { + if (state.frontPreviewUrl) { + URL.revokeObjectURL(state.frontPreviewUrl); + } + state.front = cropped; + state.frontPreviewUrl = cropped.type.startsWith('image/') ? URL.createObjectURL(cropped) : null; + } else { + if (state.backPreviewUrl) { + URL.revokeObjectURL(state.backPreviewUrl); + } + state.back = cropped; + state.backPreviewUrl = cropped.type.startsWith('image/') ? URL.createObjectURL(cropped) : null; + } + } + + sendOtp(): void { + if (this.data.state.number.trim().length !== 12) { + return; + } + this.otpSent = true; + this.otpDigits = [ + '', + '', + '', + '', + '', + '' + ]; + } + + onOtpDigit(index: number, event: Event): void { + const input = event.target as HTMLInputElement; + const val = input.value.replace(/\D/g, ''); + this.otpDigits[index] = val.charAt(0) || ''; + input.value = this.otpDigits[index]; + if (this.otpDigits[index] && index < 5) { + const next = input.parentElement?.querySelectorAll('input')[index + 1]; + (next as HTMLInputElement)?.focus(); + } + } + + onOtpKeydown(index: number, event: KeyboardEvent): void { + if (event.key === 'Backspace' && !this.otpDigits[index] && index > 0) { + const inputs = (event.target as HTMLElement).parentElement?.querySelectorAll('input'); + if (inputs) { + this.otpDigits[index - 1] = ''; + (inputs[index - 1] as HTMLInputElement).value = ''; + (inputs[index - 1] as HTMLInputElement).focus(); + } + } + } + + /** Local-only "verification" -- no real Aadhaar API is reachable here (no clientId exists for + * a co-owner who isn't even a client), same as the applicant's own capture flow. */ + verifyAadhaar(): void { + if (this.otpDigits.every((d) => d !== '')) { + this.data.state.verified = true; + } + } + + verifyPan(): void { + if (/^[A-Z]{5}[0-9]{4}[A-Z]$/.test(this.data.state.number.trim().toUpperCase())) { + this.data.state.verified = true; + } + } +} diff --git a/src/app/loan-origination/application-detail/field-visit-details-dialog/field-visit-details-dialog.component.html b/src/app/loan-origination/application-detail/field-visit-details-dialog/field-visit-details-dialog.component.html new file mode 100644 index 000000000..92d8e32d6 --- /dev/null +++ b/src/app/loan-origination/application-detail/field-visit-details-dialog/field-visit-details-dialog.component.html @@ -0,0 +1,62 @@ + + +

{{ data.label }} — Visit Details

+ + {{ data.check.status }} + +
+
+
Officer Assigned
+
{{ data.check.officerAssigned }}
+
+
+
Assigned On
+
{{ data.check.assignedDate }}
+
+
+
GPS Coordinates
+
{{ data.check.latitude || '—' }}, {{ data.check.longitude || '—' }}
+
+
+
{{ data.check.status === 'FAILED' ? 'Rejected By' : 'Verified By' }}
+
{{ data.check.verifiedBy }}
+
+
+
{{ data.check.status === 'FAILED' ? 'Rejected On' : 'Verified On' }}
+
{{ data.check.verifiedDate }}
+
+
+
Remarks
+
{{ data.check.remarks }}
+
+
+ +

Site Photos ({{ data.check.photoCount }})

+ @if (data.photos.length > 0) { +
+ @for (photo of data.photos; track $index) { +
+ @if (photo.previewUrl) { + Site photo {{ $index + 1 }} + } @else { + {{ photo.file.name }} + } +
+ } +
+ } @else { +

+ No photo files available in this browser session -- they were captured earlier and aren't retained after a page + refresh, though the count above is. +

+ } +
+ + + diff --git a/src/app/loan-origination/application-detail/field-visit-details-dialog/field-visit-details-dialog.component.scss b/src/app/loan-origination/application-detail/field-visit-details-dialog/field-visit-details-dialog.component.scss new file mode 100644 index 000000000..01dade83b --- /dev/null +++ b/src/app/loan-origination/application-detail/field-visit-details-dialog/field-visit-details-dialog.component.scss @@ -0,0 +1,114 @@ +/** + * 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/. + */ + +.fvd-content { + min-width: 420px; + max-width: 640px; + display: flex; + flex-direction: column; + gap: 14px; +} + +.fvd-badge { + display: inline-flex; + align-items: center; + align-self: flex-start; + padding: 5px 14px; + border-radius: 999px; + font-size: 12px; + font-weight: 800; + letter-spacing: 0.05em; + + &--verified { + background: #d1fae5; + color: #047857; + } + + &--failed { + background: #fee2e2; + color: #b91c1c; + } + + &--pending { + background: #fef3c7; + color: #b45309; + } +} + +.fvd-kv-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 12px 18px; + margin: 0; +} + +.fvd-kv-field { + display: flex; + flex-direction: column; + gap: 3px; + + dt { + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #94a3b8; + } + + dd { + margin: 0; + font-size: 13.5px; + font-weight: 600; + color: #1e293b; + } +} + +.fvd-photos-title { + margin: 0; + font-size: 12.5px; + font-weight: 700; + color: #334155; +} + +.fvd-photo-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); + gap: 10px; +} + +.fvd-photo { + height: 90px; + border-radius: 10px; + border: 1px solid #e2e8f0; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + background: #f8fafc; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } +} + +.fvd-photo-label { + font-size: 11px; + color: #64748b; + padding: 0 6px; + text-align: center; + word-break: break-word; +} + +.fvd-no-photos { + margin: 0; + font-size: 12.5px; + color: #64748b; + line-height: 1.5; +} diff --git a/src/app/loan-origination/application-detail/field-visit-details-dialog/field-visit-details-dialog.component.ts b/src/app/loan-origination/application-detail/field-visit-details-dialog/field-visit-details-dialog.component.ts new file mode 100644 index 000000000..bf194628a --- /dev/null +++ b/src/app/loan-origination/application-detail/field-visit-details-dialog/field-visit-details-dialog.component.ts @@ -0,0 +1,57 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog'; +import { MatButtonModule } from '@angular/material/button'; +import { VerificationCheck } from '../../loan-origination-data'; + +export interface FieldVisitPhoto { + file: File; + previewUrl: string | null; +} + +export interface FieldVisitDetailsDialogData { + label: string; + check: VerificationCheck; + photos: FieldVisitPhoto[]; +} + +/** + * Everything saved for one Field Investigation address visit -- officer, assignment date, GPS, + * status/remarks and every site photo -- in one popup, since the inline card only ever shows a + * short summary once the visit is Verified/Failed. Photos are local-only File objects (same as + * every other document capture in this mock pipeline), so a photo count with no files behind it + * just means the page was refreshed since the visit was captured, not that anything was lost from + * the application record itself. + */ +@Component({ + selector: 'mifosx-field-visit-details-dialog', + standalone: true, + imports: [ + CommonModule, + MatDialogModule, + MatButtonModule + ], + templateUrl: './field-visit-details-dialog.component.html', + styleUrls: ['./field-visit-details-dialog.component.scss'] +}) +export class FieldVisitDetailsDialogComponent { + data = inject(MAT_DIALOG_DATA); + + get statusBadgeClass(): string { + if (this.data.check.status === 'VERIFIED') { + return 'fvd-badge fvd-badge--verified'; + } + if (this.data.check.status === 'FAILED') { + return 'fvd-badge fvd-badge--failed'; + } + return 'fvd-badge fvd-badge--pending'; + } +} diff --git a/src/app/loan-origination/loan-origination-data.ts b/src/app/loan-origination/loan-origination-data.ts index 8050becbe..03afc781f 100644 --- a/src/app/loan-origination/loan-origination-data.ts +++ b/src/app/loan-origination/loan-origination-data.ts @@ -152,6 +152,16 @@ export interface VerificationCheck { verifiedBy: string; verifiedDate: string; remarks: string; + /** Field Investigation only -- the officer assigned to physically visit this address, when they + * were assigned (both a display date and a raw epoch-ms timestamp for the 48-hour SLA clock), + * and what they captured on the visit (GPS + a photo count -- the actual File objects are kept + * local-only in the component, same as every other document capture in this mock pipeline). */ + officerAssigned: string; + assignedDate: string; + assignedAt: number; + latitude: string; + longitude: string; + photoCount: number; } export interface ApprovalRecord { @@ -188,6 +198,9 @@ export interface LoanApplication { productCode: string; productName: string; + /** Term Loan (fixed tenure, EMI-based) vs Overdraft (revolving limit, renewed annually) -- + * see allowedProductTypes() for which products can be which. */ + productType: ProductType; secured: boolean; requestedAmount: number; tenureMonths: number; @@ -203,11 +216,11 @@ export interface LoanApplication { panVerified: boolean; incomeProofSubmitted: boolean; bankStatementSubmitted: boolean; - propertyDetailsSubmitted: boolean; - taxReceiptSubmitted: boolean; electricityBillSubmitted: boolean; - /** Aadhaar e-KYC biometric authentication (UIDAI) — captured on top of the OVD paper check. */ - aadhaarFingerprintCaptured: boolean; + /** Documents specific to this application's loan product (e.g. property papers for a Housing + * Loan, land records for a Kisan Credit Card) -- seeded from PRODUCT_DOCUMENT_REQUIREMENTS at + * creation time, plus whatever the officer adds on top with "Add Custom Document". */ + productDocuments: ProductDocumentEntry[]; monthlyIncome: number; eligibleLoanAmount: number; coBorrowerRequired: boolean; @@ -248,12 +261,34 @@ export interface LoanApplication { addressVerification: VerificationCheck; officeVerification: VerificationCheck; propertyVerification: VerificationCheck; + /** Whether this applicant runs a shop/business premises worth sending a field officer to -- + * officer-ticked at Field Investigation itself (not derivable from anything else on the + * application), same conditional-row pattern propertyVerification already uses off `secured`. */ + hasShop: boolean; + shopVerification: VerificationCheck; + /** propertyValuation itself is always derived (builtUpAreaSqFt × marketRatePerSqFt) -- kept as + * its own field since every other part of the app already reads it directly (stage-history + * remarks, Risk Approval), rather than making every one of those call sites recompute it. */ + propertyType: string; + builtUpAreaSqFt: number; + marketRatePerSqFt: number; + guidelineRatePerSqFt: number; + valuerName: string; + valuationDate: string; propertyValuation: number; agentReportUploaded: boolean; agentReportFileName: string; + agentReportRemark: string; // Personal Discussion / discrepancy handling discrepancyType: DiscrepancyType; + /** Scheduled before the meeting happens -- date/time/mode/officer -- so there's a real + * appointment on record instead of the discussion notes just appearing from nowhere. */ + meetingScheduled: boolean; + meetingDate: string; + meetingTime: string; + meetingMode: 'ONLINE' | 'OFFLINE' | 'CALL' | ''; + meetingWith: string; personalDiscussionNotes: string; personalDiscussionDate: string; /** Customer's signed declaration taken after the personal discussion clears. */ @@ -468,6 +503,80 @@ export const ORIGINATION_PRODUCTS: OriginationProduct[] = [ } ]; +/** 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. */ +export interface ProductDocumentEntry { + key: string; + label: string; + hint?: string; + /** True for a document the officer added on the spot with "Add Custom Document" -- these can + * be removed again; the product's own required list cannot. */ + custom: boolean; + submitted: boolean; +} + +interface ProductDocumentTemplate { + key: string; + label: string; + hint?: string; +} + +/** What each product needs beyond the generic KYC checklist -- a Housing or Kisan Credit loan is + * secured against a specific asset (property / agricultural land), so the paperwork proving and + * encumbering that asset has to be collected before the loan can be sanctioned, on top of the + * borrower's own identity/income proof. */ +export const PRODUCT_DOCUMENT_REQUIREMENTS: Record = { + HL: [ + { key: 'propertyTaxReceipt', label: 'Property Tax Receipt' }, + { key: 'propertyRegistration', label: 'Property Registration Document (Sale Deed)' }, + { key: 'propertyElectricityBill', label: 'Electricity Bill (of the Property)' }, + { + key: 'coOwnerKyc', + label: 'KYC Documents of Co-Owners', + hint: 'If the registry lists a second co-owner, use "Add More Doc" below for their KYC too' + }, + { key: 'mutationExtract', label: 'Property Mutation Extract (Namantaran)' }, + { key: 'noDuesCertificate', label: 'No-Dues Certificate' }, + { key: 'encumbranceCertificate', label: 'Encumbrance Certificate (EC)' }, + { key: 'approvedBuildingPlan', label: 'Approved Building Plan / Sanctioned Layout' } + ], + KCC: [ + { key: 'landRegistry', label: 'Land Registry / Title Deed' }, + { key: 'khasraKhatauni', label: 'Khasra-Khatauni (Land Revenue Record)' }, + { key: 'girdawari', label: 'Girdawari (Crop Inspection Report)' }, + { key: 'noDuesOtherBank', label: 'No-Dues Certificate from Other Banks/Societies' } + ], + VL: [ + { key: 'vehicleQuotation', label: 'Vehicle Quotation / Proforma Invoice' }, + { key: 'drivingLicense', label: 'Driving License' }, + { key: 'vehicleRC', label: 'RC Copy', hint: 'For a used vehicle' }, + { key: 'vehicleInsurance', label: 'Insurance Copy' } + ], + CC: [ + { key: 'udyamRegistration', label: 'Udyam / Business Registration Certificate' }, + { key: 'gstCertificate', label: 'GST Registration Certificate' }, + { key: 'businessFinancials', label: "Last 2 Years' Business Financial Statements" }, + { key: 'businessAddressProof', label: 'Business Address Proof' }, + { key: 'partnershipDeed', label: 'Partnership Deed / MOA-AOA', hint: 'If applicable' } + ], + EL: [ + { key: 'admissionLetter', label: 'Admission Letter from Institution' }, + { key: 'feeStructure', label: 'Fee Structure / Schedule' }, + { key: 'marksheets', label: 'Previous Qualifying Exam Marksheets' }, + { key: 'coBorrowerKyc', label: 'Co-Borrower (Parent/Guardian) KYC & Income Proof' } + ], + GL: [ + { key: 'goldValuation', label: 'Gold Appraisal / Valuation Certificate' }, + { key: 'goldPurchaseInvoice', label: 'Purchase Invoice of Gold Ornaments', hint: 'If available' } + ], + PL: [{ key: 'employmentProof', label: 'Employment Proof / Appointment Letter' }] +}; + +export function buildProductDocuments(productCode: string): ProductDocumentEntry[] { + return (PRODUCT_DOCUMENT_REQUIREMENTS[productCode] ?? []).map((doc) => ({ ...doc, custom: false, submitted: false })); +} + const FIRST_NAMES = [ 'Ramesh', 'Suresh', @@ -534,7 +643,7 @@ const BC_AGENTS = [ 'Digital Seva Kendra — Sangli' ]; -const MANAGERS = [ +export const MANAGERS = [ 'Anil Deshpande (Branch Manager)', 'Sunil Kadam (Branch Manager)', 'Vikas Pathak (Credit Manager)', @@ -558,6 +667,20 @@ const FI_AGENCIES = [ 'VerifyIndia Field Services' ]; +export const PROPERTY_TYPES = [ + 'Residential', + 'Commercial', + 'Plot', + 'Under Construction' +]; + +const VALUERS = [ + 'Apex Valuers & Engineers', + 'Prime Property Valuation Services', + 'National Valuation Associates', + 'Metro Approved Valuers' +]; + // ------------------------------------------------------------------------- // Delegation of Powers — sanctioning authority by loan amount, exactly the kind // of amount-based approval matrix every Indian bank/co-operative society runs. @@ -607,7 +730,18 @@ export function stageBadgeClass(app: LoanApplication): string { } function blankVerification(): VerificationCheck { - return { status: 'PENDING', verifiedBy: '—', verifiedDate: '—', remarks: '—' }; + return { + status: 'PENDING', + verifiedBy: '—', + verifiedDate: '—', + remarks: '—', + officerAssigned: '—', + assignedDate: '—', + assignedAt: 0, + latitude: '', + longitude: '', + photoCount: 0 + }; } function blankApproval(): ApprovalRecord { @@ -703,6 +837,14 @@ function generateApplications(count: number): LoanApplication[] { const legalOfficer = pick(LEGAL_OFFICERS); const fiAgency = pick(FI_AGENCIES); + // Reverse-engineered the same way monthlyIncome is -- area × rate lands close to a plausible + // multiple of the requested amount, rather than picking propertyValuation directly and having + // no consistent area/rate breakdown behind it. + const propertyBuiltUpAreaSqFt = product.secured ? round(between(600, 2400)) : 0; + const propertyMarketRatePerSqFt = product.secured + ? round((requestedAmount * between(1.1, 1.4)) / propertyBuiltUpAreaSqFt) + : 0; + const app: LoanApplication = { applicationNo: `LN${branch.code.slice(2)}${product.code}${seq}`, applicationDate, @@ -714,6 +856,7 @@ function generateApplications(count: number): LoanApplication[] { customerType, productCode: product.code, productName: product.name, + productType: pick(allowedProductTypes(product.code)), secured: product.secured, requestedAmount, tenureMonths, @@ -725,10 +868,8 @@ function generateApplications(count: number): LoanApplication[] { panVerified: false, incomeProofSubmitted: false, bankStatementSubmitted: false, - propertyDetailsSubmitted: false, - taxReceiptSubmitted: false, electricityBillSubmitted: false, - aadhaarFingerprintCaptured: false, + productDocuments: buildProductDocuments(product.code), monthlyIncome, eligibleLoanAmount, coBorrowerRequired: eligibleLoanAmount < requestedAmount, @@ -750,10 +891,24 @@ function generateApplications(count: number): LoanApplication[] { addressVerification: blankVerification(), officeVerification: blankVerification(), propertyVerification: blankVerification(), - propertyValuation: product.secured ? round(requestedAmount * between(1.1, 1.4)) : 0, + hasShop: rng() < 0.3, + shopVerification: blankVerification(), + propertyType: product.secured ? pick(PROPERTY_TYPES) : '—', + builtUpAreaSqFt: propertyBuiltUpAreaSqFt, + marketRatePerSqFt: propertyMarketRatePerSqFt, + guidelineRatePerSqFt: product.secured ? round(propertyMarketRatePerSqFt * between(0.6, 0.85)) : 0, + valuerName: product.secured ? pick(VALUERS) : '—', + valuationDate: product.secured ? fmt(addDays(today, -round(between(5, 60)))) : '—', + propertyValuation: product.secured ? propertyBuiltUpAreaSqFt * propertyMarketRatePerSqFt : 0, agentReportUploaded: false, agentReportFileName: '—', + agentReportRemark: '—', discrepancyType: 'NONE', + meetingScheduled: false, + meetingDate: '', + meetingTime: '', + meetingMode: '', + meetingWith: '', personalDiscussionNotes: '—', personalDiscussionDate: '—', declarationTaken: false, @@ -836,10 +991,8 @@ function generateApplications(count: number): LoanApplication[] { app.panVerified = true; app.incomeProofSubmitted = true; app.bankStatementSubmitted = true; - app.propertyDetailsSubmitted = product.secured; - app.taxReceiptSubmitted = product.secured; app.electricityBillSubmitted = true; - app.aadhaarFingerprintCaptured = true; + app.productDocuments.forEach((doc) => (doc.submitted = true)); app.stageHistory.push({ stage, date: fmt(cursor), @@ -876,24 +1029,57 @@ function generateApplications(count: number): LoanApplication[] { status: 'VERIFIED', verifiedBy: fiAgency, verifiedDate: fmt(cursor), - remarks: 'Registered address confirmed on physical visit.' + remarks: 'Registered address confirmed on physical visit.', + officerAssigned: fiAgency, + assignedDate: fmt(cursor), + assignedAt: cursor.getTime(), + latitude: (23.2 + rng() * 5).toFixed(6), + longitude: (77.4 + rng() * 5).toFixed(6), + photoCount: 2 + Math.floor(rng() * 3) }; app.officeVerification = { status: 'VERIFIED', verifiedBy: fiAgency, verifiedDate: fmt(cursor), - remarks: 'Employment/business confirmed — Agent Report filed.' + remarks: 'Employment/business confirmed — Agent Report filed.', + officerAssigned: fiAgency, + assignedDate: fmt(cursor), + assignedAt: cursor.getTime(), + latitude: (23.2 + rng() * 5).toFixed(6), + longitude: (77.4 + rng() * 5).toFixed(6), + photoCount: 2 + Math.floor(rng() * 3) }; if (product.secured) { app.propertyVerification = { status: 'VERIFIED', verifiedBy: fiAgency, verifiedDate: fmt(cursor), - remarks: `Property inspected and valued at ₹${app.propertyValuation.toLocaleString('en-IN')}.` + remarks: `Property inspected and valued at ₹${app.propertyValuation.toLocaleString('en-IN')}.`, + officerAssigned: fiAgency, + assignedDate: fmt(cursor), + assignedAt: cursor.getTime(), + latitude: (23.2 + rng() * 5).toFixed(6), + longitude: (77.4 + rng() * 5).toFixed(6), + photoCount: 2 + Math.floor(rng() * 3) + }; + } + if (app.hasShop) { + app.shopVerification = { + status: 'VERIFIED', + verifiedBy: fiAgency, + verifiedDate: fmt(cursor), + remarks: 'Shop/business premises visited and confirmed operational.', + officerAssigned: fiAgency, + assignedDate: fmt(cursor), + assignedAt: cursor.getTime(), + latitude: (23.2 + rng() * 5).toFixed(6), + longitude: (77.4 + rng() * 5).toFixed(6), + photoCount: 2 + Math.floor(rng() * 3) }; } app.agentReportUploaded = true; app.agentReportFileName = `AgentReport_${app.applicationNo}.pdf`; + app.agentReportRemark = 'All addresses visited and cross-verified — no discrepancies noted.'; app.stageHistory.push({ stage, date: fmt(cursor), @@ -906,6 +1092,15 @@ function generateApplications(count: number): LoanApplication[] { break; case 'PERSONAL_DISCUSSION': + app.meetingScheduled = true; + app.meetingDate = fmt(cursor); + app.meetingTime = `${String(10 + Math.floor(rng() * 6)).padStart(2, '0')}:${rng() < 0.5 ? '00' : '30'}`; + app.meetingMode = pick<'ONLINE' | 'OFFLINE' | 'CALL'>([ + 'ONLINE', + 'OFFLINE', + 'CALL' + ]); + app.meetingWith = manager; if (isHoldStage) { app.discrepancyType = pick([ 'DOCUMENT_DISCREPANCY', @@ -1174,6 +1369,30 @@ export function moveToStage(app: LoanApplication, stage: OriginationStage, actio app.currentStage = stage; } +/** Books the Personal Discussion appointment -- date, time, mode and which bank officer is + * taking it -- before any discussion notes exist, so there's a real scheduled meeting on record + * rather than a discussion report just appearing with no appointment behind it. */ +export function scheduleMeeting( + app: LoanApplication, + date: string, + time: string, + mode: 'ONLINE' | 'OFFLINE' | 'CALL', + withWhom: string, + actionBy: string +): void { + app.meetingScheduled = true; + app.meetingDate = date; + app.meetingTime = time; + app.meetingMode = mode; + app.meetingWith = withWhom; + recordStageProgress( + app, + actionBy, + 'Meeting Scheduled', + `Personal Discussion scheduled with ${withWhom} on ${date} ${time} (${mode}).` + ); +} + export function flagDiscrepancy(app: LoanApplication, type: DiscrepancyType, actionBy: string, notes: string): void { app.discrepancyType = type; app.personalDiscussionDate = todayDDMMYYYY(); @@ -1264,20 +1483,48 @@ export function pushFieldInquiry(app: LoanApplication, agencyName: string, actio recordStageProgress(app, actionBy, 'FI Pushed', `Field Inquiry request pushed to ${agencyName}.`); } -export function uploadAgentReport(app: LoanApplication, fileName: string, actionBy: string): void { +export function uploadAgentReport(app: LoanApplication, fileName: string, remark: string, actionBy: string): void { app.agentReportUploaded = true; app.agentReportFileName = fileName; - recordStageProgress(app, actionBy, 'Uploaded', `Agent Report "${fileName}" uploaded.`); + app.agentReportRemark = remark || 'Agent Report filed.'; + recordStageProgress(app, actionBy, 'Uploaded', `Agent Report "${fileName}" uploaded — ${app.agentReportRemark}`); +} + +export type FiCheckKey = 'addressVerification' | 'officeVerification' | 'propertyVerification' | 'shopVerification'; + +export const FI_CHECK_LABELS: Record = { + addressVerification: 'Home Address', + officeVerification: 'Office / Employment Address', + propertyVerification: 'Property Address', + shopVerification: 'Shop / Business Premises' +}; + +/** Sends a field officer out to physically visit and verify one of the 3 FI addresses -- resets + * that address's whole visit cycle (GPS, photo count, prior remarks) fresh, since a newly + * assigned visit (including a retry after a FAILED one) starts the 48-hour SLA clock over. */ +export function assignFieldOfficer(app: LoanApplication, which: FiCheckKey, officerName: string): void { + app[which] = { + status: 'PENDING', + verifiedBy: '—', + verifiedDate: '—', + remarks: '—', + officerAssigned: officerName, + assignedDate: todayDDMMYYYY(), + assignedAt: Date.now(), + latitude: '', + longitude: '', + photoCount: 0 + }; } export function recordVerification( app: LoanApplication, - which: 'addressVerification' | 'officeVerification' | 'propertyVerification', + which: FiCheckKey, status: VerificationStatus, verifiedBy: string, remarks: string ): void { - app[which] = { status, verifiedBy, verifiedDate: todayDDMMYYYY(), remarks }; + app[which] = { ...app[which], status, verifiedBy, verifiedDate: todayDDMMYYYY(), remarks }; } /** Cooperative-bank membership can be allotted independently of the loan's own progress — a @@ -1348,14 +1595,45 @@ export function disburseApplication(app: LoanApplication, actionBy: string, mode } // ------------------------------------------------------------------------- -// EMI Repayment Schedule — the standard reducing-balance amortisation table a real CBS shows -// once a term loan is disbursed. Cash Credit / KCC are revolving limits renewed annually, not -// repaid on a fixed instalment schedule, so they're excluded rather than shown a misleading one. +// Product Type — Term Loan (fixed tenure, EMI-based) vs Overdraft (revolving limit, renewed +// annually rather than repaid on a fixed instalment schedule). // ------------------------------------------------------------------------- -export function isRevolvingProduct(productCode: string): boolean { - return productCode === 'CC' || productCode === 'KCC'; +export type ProductType = 'TERM_LOAN' | 'OVERDRAFT'; + +export const PRODUCT_TYPE_LABELS: Record = { + TERM_LOAN: 'Term Loan', + OVERDRAFT: 'Overdraft' +}; + +/** Which Product Type(s) a given product can be sanctioned as. Cash Credit and Kisan Credit Card + * are inherently revolving limits, so Overdraft is the only option. Gold Loan is commonly + * written up either way at a UCB -- a straight EMI term loan, or a 1-year overdraft/cash-credit + * limit renewed annually -- so both are offered, defaulting to Overdraft (the more common + * cooperative-bank practice for gold loans). Every other product here is a straight term loan. */ +export function allowedProductTypes(productCode: string): ProductType[] { + if (productCode === 'CC' || productCode === 'KCC') { + return ['OVERDRAFT']; + } + if (productCode === 'GL') { + return [ + 'OVERDRAFT', + 'TERM_LOAN' + ]; + } + return ['TERM_LOAN']; } +export function defaultProductType(productCode: string): ProductType { + return allowedProductTypes(productCode)[0]; +} + +// ------------------------------------------------------------------------- +// EMI Repayment Schedule — the standard reducing-balance amortisation table a real CBS shows +// once a term loan is disbursed. Overdraft-type products are revolving limits renewed annually, +// not repaid on a fixed instalment schedule, so they're excluded rather than shown a misleading +// one (see LoanApplication.productType). +// ------------------------------------------------------------------------- + export interface EmiScheduleRow { installmentNo: number; dueDate: string; @@ -1426,6 +1704,7 @@ export interface NewApplicationInput { branchName: string; productCode: string; productName: string; + productType: ProductType; secured: boolean; requestedAmount: number; tenureMonths: number; @@ -1447,6 +1726,7 @@ export function createApplication(input: NewApplicationInput, seq: number): Loan clientId: input.clientId ?? null, productCode: input.productCode, productName: input.productName, + productType: input.productType, secured: input.secured, requestedAmount: input.requestedAmount, tenureMonths: input.tenureMonths, @@ -1458,10 +1738,8 @@ export function createApplication(input: NewApplicationInput, seq: number): Loan panVerified: false, incomeProofSubmitted: false, bankStatementSubmitted: false, - propertyDetailsSubmitted: false, - taxReceiptSubmitted: false, electricityBillSubmitted: false, - aadhaarFingerprintCaptured: false, + productDocuments: buildProductDocuments(input.productCode), monthlyIncome: 0, eligibleLoanAmount: 0, coBorrowerRequired: false, @@ -1483,10 +1761,24 @@ export function createApplication(input: NewApplicationInput, seq: number): Loan addressVerification: blankVerification(), officeVerification: blankVerification(), propertyVerification: blankVerification(), + hasShop: false, + shopVerification: blankVerification(), + propertyType: '—', + builtUpAreaSqFt: 0, + marketRatePerSqFt: 0, + guidelineRatePerSqFt: 0, + valuerName: '—', + valuationDate: '—', propertyValuation: 0, agentReportUploaded: false, agentReportFileName: '—', + agentReportRemark: '—', discrepancyType: 'NONE', + meetingScheduled: false, + meetingDate: '', + meetingTime: '', + meetingMode: '', + meetingWith: '', personalDiscussionNotes: '—', personalDiscussionDate: '—', declarationTaken: false, diff --git a/src/app/loan-origination/loan-origination-shared.scss b/src/app/loan-origination/loan-origination-shared.scss index 1de058338..ca07ccc24 100644 --- a/src/app/loan-origination/loan-origination-shared.scss +++ b/src/app/loan-origination/loan-origination-shared.scss @@ -268,6 +268,14 @@ grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px 20px; padding: 18px; + + &--tight-bottom { + padding: 0 0 10px; + } + + &--tight-top { + padding: 10px 0 0; + } } .loan-kv-field { @@ -594,6 +602,53 @@ margin: -2px 0 0; } +// Field Investigation -- one card per address (Home/Office/Property/Shop) instead of stacked +// full-width sections, since each visit's officer/SLA/GPS/photo capture is a self-contained unit. +.loi-fi-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); + gap: 14px; + margin-bottom: 14px; +} + +.loi-fi-card { + display: flex; + flex-direction: column; + gap: 10px; + padding: 14px 16px; + border: 1px solid #e2e8f0; + border-radius: 12px; + background: #fff; +} + +.loi-fi-card-title { + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #1e40af; +} + +.loi-fi-view-details { + display: inline-flex; + align-items: center; + align-self: flex-start; + gap: 6px; + height: 30px; + padding: 0 12px; + border: 1px solid #c7d2fe; + border-radius: 999px; + background: #eef2ff; + color: #4338ca; + font-size: 12px; + font-weight: 700; + cursor: pointer; + + &:hover { + background: #e0e7ff; + } +} + .loi-otp-digits { display: flex; gap: 5px; @@ -668,3 +723,53 @@ } } } + +// Re-opens the bank statement analysis dialog for a CSV already on file -- the analysis only +// pops up once automatically (right after upload), so this is the only way back into it once an +// officer has closed it and wants to double-check something. +.loi-bsa-reopen { + display: inline-flex; + align-items: center; + gap: 6px; + height: 34px; + padding: 0 14px; + border: 1px solid #c7d2fe; + border-radius: 999px; + background: #eef2ff; + color: #4338ca; + font-size: 12.5px; + font-weight: 700; + cursor: pointer; + + &:hover { + background: #e0e7ff; + border-color: #a5b4fc; + } +} + +// Loan-specific documents (Housing/KCC/Vehicle/... papers) -- an officer-added custom document +// gets a small remove control right next to its title; the product's own required list never +// shows one, since removeCustomProductDoc() is a no-op for a non-custom entry anyway. +.loi-doc-remove { + margin-left: 6px; + border: none; + background: none; + color: #94a3b8; + font-size: 12px; + cursor: pointer; + padding: 0 2px; + + &:hover { + color: #dc2626; + } +} + +.loi-custom-doc-input { + height: 36px; + padding: 0 12px; + border: 1px solid #cbd5e1; + border-radius: 6px; + font-size: 13px; + min-width: 260px; + flex: 1; +} diff --git a/src/app/loan-origination/loan-pipeline/loan-pipeline.component.html b/src/app/loan-origination/loan-pipeline/loan-pipeline.component.html index ddd878b60..c365f5953 100644 --- a/src/app/loan-origination/loan-pipeline/loan-pipeline.component.html +++ b/src/app/loan-origination/loan-pipeline/loan-pipeline.component.html @@ -297,12 +297,20 @@ +