feat(loan-origination): tabbed CIC report dialog with real PDF parsing via pdfjs-dist
Extracts score, accounts, DPD payment history and enquiries directly from an uploaded bureau PDF (position-aware parsing, so blank DPD months are handled correctly) instead of always showing a simulated report, falling back to the simulated analysis when a file can't be read.
This commit is contained in:
Generated
+271
@@ -44,6 +44,7 @@
|
||||
"moment": "^2.29.4",
|
||||
"ngx-image-cropper": "^9.1.6",
|
||||
"ngx-mat-select-search": "^8.0.2",
|
||||
"pdfjs-dist": "^5.6.205",
|
||||
"rxjs": "^7.8.2",
|
||||
"signature_pad": "^5.1.3",
|
||||
"tinymce": "^8.2.2",
|
||||
@@ -6126,6 +6127,256 @@
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@napi-rs/canvas": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.100.tgz",
|
||||
"integrity": "sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"workspaces": [
|
||||
"e2e/*"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/canvas-android-arm64": "0.1.100",
|
||||
"@napi-rs/canvas-darwin-arm64": "0.1.100",
|
||||
"@napi-rs/canvas-darwin-x64": "0.1.100",
|
||||
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.100",
|
||||
"@napi-rs/canvas-linux-arm64-gnu": "0.1.100",
|
||||
"@napi-rs/canvas-linux-arm64-musl": "0.1.100",
|
||||
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.100",
|
||||
"@napi-rs/canvas-linux-x64-gnu": "0.1.100",
|
||||
"@napi-rs/canvas-linux-x64-musl": "0.1.100",
|
||||
"@napi-rs/canvas-win32-arm64-msvc": "0.1.100",
|
||||
"@napi-rs/canvas-win32-x64-msvc": "0.1.100"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-android-arm64": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.100.tgz",
|
||||
"integrity": "sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-darwin-arm64": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.100.tgz",
|
||||
"integrity": "sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-darwin-x64": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.100.tgz",
|
||||
"integrity": "sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.100.tgz",
|
||||
"integrity": "sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.100.tgz",
|
||||
"integrity": "sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.100.tgz",
|
||||
"integrity": "sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.100.tgz",
|
||||
"integrity": "sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.100.tgz",
|
||||
"integrity": "sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-x64-musl": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.100.tgz",
|
||||
"integrity": "sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-win32-arm64-msvc": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.100.tgz",
|
||||
"integrity": "sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.100.tgz",
|
||||
"integrity": "sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/nice": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/nice/-/nice-1.1.1.tgz",
|
||||
@@ -18996,6 +19247,13 @@
|
||||
"integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-readable-to-web-readable-stream": {
|
||||
"version": "0.4.2",
|
||||
"resolved": "https://registry.npmjs.org/node-readable-to-web-readable-stream/-/node-readable-to-web-readable-stream-0.4.2.tgz",
|
||||
"integrity": "sha512-/cMZNI34v//jUTrI+UIo4ieHAB5EZRY/+7OmXZgBxaWBMcW2tGdceIw06RFxWxrKZ5Jp3sI2i5TsRo+CBhtVLQ==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
"version": "2.0.27",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
|
||||
@@ -19922,6 +20180,19 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/pdfjs-dist": {
|
||||
"version": "5.6.205",
|
||||
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.6.205.tgz",
|
||||
"integrity": "sha512-tlUj+2IDa7G1SbvBNN74UHRLJybZDWYom+k6p5KIZl7huBvsA4APi6mKL+zCxd3tLjN5hOOEE9Tv7VdzO88pfg==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=20.19.0 || >=22.13.0 || >=24"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/canvas": "^0.1.96",
|
||||
"node-readable-to-web-readable-stream": "^0.4.2"
|
||||
}
|
||||
},
|
||||
"node_modules/pend": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
"moment": "^2.29.4",
|
||||
"ngx-image-cropper": "^9.1.6",
|
||||
"ngx-mat-select-search": "^8.0.2",
|
||||
"pdfjs-dist": "^5.6.205",
|
||||
"rxjs": "^7.8.2",
|
||||
"signature_pad": "^5.1.3",
|
||||
"tinymce": "^8.2.2",
|
||||
|
||||
@@ -1409,54 +1409,70 @@
|
||||
</p>
|
||||
}
|
||||
} @else {
|
||||
<dl class="loan-kv-grid" style="padding: 0 0 14px">
|
||||
<div class="loan-kv-field">
|
||||
<dt>Bureau</dt>
|
||||
<dd>{{ a.cicBureau }}</dd>
|
||||
<div class="loi-cic-hero-card">
|
||||
<div
|
||||
class="loi-cic-score-badge"
|
||||
[class.loi-cic-score-badge--excellent]="a.cibilScore >= 750"
|
||||
[class.loi-cic-score-badge--good]="a.cibilScore >= 650 && a.cibilScore < 750"
|
||||
[class.loi-cic-score-badge--fair]="a.cibilScore >= 550 && a.cibilScore < 650"
|
||||
[class.loi-cic-score-badge--poor]="a.cibilScore < 550"
|
||||
>
|
||||
<span class="loi-cic-score-num">{{ a.cibilScore }}</span>
|
||||
<span class="loi-cic-score-band">{{ cibilBandOf(a.cibilScore) }}</span>
|
||||
</div>
|
||||
<div class="loan-kv-field">
|
||||
<dt>Credit Score</dt>
|
||||
<dd>{{ a.cibilScore }} ({{ cibilBandOf(a.cibilScore) }})</dd>
|
||||
|
||||
<div class="loi-cic-details-grid">
|
||||
<div class="loi-cic-detail-item">
|
||||
<span class="loi-cic-label">Credit Bureau</span>
|
||||
<strong class="loi-cic-val">{{ a.cicBureau }}</strong>
|
||||
</div>
|
||||
<div class="loi-cic-detail-item">
|
||||
<span class="loi-cic-label">Uploaded Report File</span>
|
||||
<strong class="loi-cic-val loi-cic-file-name">{{
|
||||
a.cicReportUploaded ? a.cicReportFileName : 'Not uploaded yet'
|
||||
}}</strong>
|
||||
</div>
|
||||
<div class="loi-cic-detail-item">
|
||||
<span class="loi-cic-label">Field Inquiry Status</span>
|
||||
<strong class="loi-cic-val">
|
||||
{{
|
||||
a.fiRequestPushed
|
||||
? 'Pushed to ' + a.fiAgencyAssigned + ' (' + a.fiRequestDate + ')'
|
||||
: 'Not pushed yet'
|
||||
}}
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="loan-kv-field">
|
||||
<dt>Report</dt>
|
||||
<dd>
|
||||
{{ a.cicReportUploaded ? a.cicReportFileName : 'Not uploaded yet' }}
|
||||
@if (a.cicReportUploaded) {
|
||||
<button type="button" class="loi-bsa-reopen" (click)="openCicReportAnalysis(a)">
|
||||
📊 View Analysis
|
||||
</button>
|
||||
}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="loan-kv-field">
|
||||
<dt>Field Inquiry</dt>
|
||||
<dd>
|
||||
{{
|
||||
a.fiRequestPushed
|
||||
? 'Pushed to ' + a.fiAgencyAssigned + ' (' + a.fiRequestDate + ')'
|
||||
: 'Not pushed yet'
|
||||
}}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
@if (a.cicReportUploaded) {
|
||||
<div class="loi-cic-action-box">
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--primary loi-cic-analysis-btn"
|
||||
(click)="openCicReportAnalysis(a)"
|
||||
>
|
||||
📊 View Analysis
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (a.cibilScore > 0) {
|
||||
@if (!a.cicReportUploaded) {
|
||||
<div class="loan-action-row">
|
||||
<label class="pg-btn pg-btn--ghost" style="cursor: pointer">
|
||||
Upload {{ a.cicBureau }} Report
|
||||
<input type="file" hidden accept=".pdf,.jpg,.jpeg,.png" (change)="onCicFileSelected($event, a)" />
|
||||
</label>
|
||||
</div>
|
||||
} @else if (!a.fiRequestPushed) {
|
||||
<div class="loan-action-row">
|
||||
<div class="loan-action-row loi-cic-action-row">
|
||||
<label
|
||||
class="pg-btn pg-btn--ghost loi-cic-upload-btn"
|
||||
title="Upload or change PDF Credit Bureau Report"
|
||||
>
|
||||
<span>{{
|
||||
a.cicReportUploaded ? '📄 Re-upload PDF Report' : 'Upload ' + a.cicBureau + ' Report (PDF)'
|
||||
}}</span>
|
||||
<input type="file" hidden accept=".pdf,.jpg,.jpeg,.png" (change)="onCicFileSelected($event, a)" />
|
||||
</label>
|
||||
@if (a.cicReportUploaded && !a.fiRequestPushed) {
|
||||
<button type="button" class="pg-btn pg-btn--ghost" (click)="pushFieldInquiryNow(a)">
|
||||
Push Field Inquiry to Agency
|
||||
</button>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="loan-action-row">
|
||||
} @else if (a.cicReportUploaded && a.fiRequestPushed) {
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--primary"
|
||||
@@ -1474,8 +1490,8 @@
|
||||
>
|
||||
Proceed to Field Investigation
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -29,7 +29,12 @@ import {
|
||||
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 {
|
||||
CicReportAnalysis,
|
||||
CIC_KNOWN_BUREAUS,
|
||||
generateCicReportAnalysis
|
||||
} from './cic-report-analysis-dialog/cic-report-analysis.util';
|
||||
import { parseCicPdfReport } from './cic-report-analysis-dialog/cic-pdf-parser.util';
|
||||
import { FieldVisitDetailsDialogComponent } from './field-visit-details-dialog/field-visit-details-dialog.component';
|
||||
import {
|
||||
ApprovalField,
|
||||
@@ -2050,24 +2055,51 @@ I confirm that:
|
||||
}
|
||||
|
||||
// -------------------- CIC Upload --------------------
|
||||
onCicFileSelected(event: Event, a: LoanApplication): void {
|
||||
async onCicFileSelected(event: Event, a: LoanApplication): Promise<void> {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
let parsed: CicReportAnalysis | null = null;
|
||||
if (file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf')) {
|
||||
parsed = await parseCicPdfReport(file);
|
||||
}
|
||||
|
||||
if (parsed) {
|
||||
const knownBureau = (CIC_KNOWN_BUREAUS as readonly string[]).includes(parsed.bureau)
|
||||
? parsed.bureau
|
||||
: a.cicBureau;
|
||||
a.cicBureau = knownBureau as LoanApplication['cicBureau'];
|
||||
a.cibilScore = parsed.score;
|
||||
a.cicParsedAnalysis = parsed;
|
||||
a.cicAnalysisSource = 'PARSED';
|
||||
} else {
|
||||
a.cicParsedAnalysis = generateCicReportAnalysis(a);
|
||||
a.cicAnalysisSource = 'SIMULATED';
|
||||
}
|
||||
|
||||
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. */
|
||||
/** Reopens whatever analysis is already on the application -- the real parsed one if the last
|
||||
* upload actually yielded one, otherwise the simulated fallback -- generating and caching the
|
||||
* simulated version on first view rather than a fresh (and pointlessly different) one every
|
||||
* time this is clicked. */
|
||||
openCicReportAnalysis(a: LoanApplication): void {
|
||||
if (!a.cicParsedAnalysis) {
|
||||
a.cicParsedAnalysis = generateCicReportAnalysis(a);
|
||||
a.cicAnalysisSource = 'SIMULATED';
|
||||
}
|
||||
this.dialog.open(CicReportAnalysisDialogComponent, {
|
||||
data: { fileName: a.cicReportFileName, analysis: generateCicReportAnalysis(a) },
|
||||
data: {
|
||||
fileName: a.cicReportFileName,
|
||||
analysis: a.cicParsedAnalysis as CicReportAnalysis,
|
||||
wasParsedFromFile: a.cicAnalysisSource === 'PARSED'
|
||||
},
|
||||
width: '960px',
|
||||
maxWidth: '95vw'
|
||||
});
|
||||
|
||||
+510
@@ -0,0 +1,510 @@
|
||||
/**
|
||||
* 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 * as pdfjsLib from 'pdfjs-dist';
|
||||
import type { TextItem } from 'pdfjs-dist/types/src/display/api';
|
||||
import {
|
||||
CicCreditAccount,
|
||||
CicDpdCell,
|
||||
CicDpdYearRow,
|
||||
CicEnquiry,
|
||||
CicReportAnalysis,
|
||||
CicRiskBand
|
||||
} from './cic-report-analysis.util';
|
||||
|
||||
(pdfjsLib as any).GlobalWorkerOptions.workerSrc = new URL(
|
||||
'pdfjs-dist/build/pdf.worker.min.mjs',
|
||||
import.meta.url
|
||||
).toString();
|
||||
|
||||
interface PositionedItem {
|
||||
text: string;
|
||||
x: number;
|
||||
y: number;
|
||||
page: number;
|
||||
}
|
||||
|
||||
interface TextLine {
|
||||
page: number;
|
||||
y: number;
|
||||
text: string;
|
||||
items: PositionedItem[];
|
||||
}
|
||||
|
||||
const MONTH_ORDER = [
|
||||
'Dec',
|
||||
'Nov',
|
||||
'Oct',
|
||||
'Sep',
|
||||
'Aug',
|
||||
'Jul',
|
||||
'Jun',
|
||||
'May',
|
||||
'Apr',
|
||||
'Mar',
|
||||
'Feb',
|
||||
'Jan'
|
||||
];
|
||||
|
||||
const LOAN_TYPE_KEYWORDS = [
|
||||
'TWO WHEELER LOAN',
|
||||
'PERSONAL LOAN',
|
||||
'HOUSING LOAN',
|
||||
'HOME LOAN',
|
||||
'BL GENERAL',
|
||||
'BUSINESS LOAN',
|
||||
'GOLD LOAN',
|
||||
'CONSUMER DURABLE LOAN',
|
||||
'CREDIT CARD',
|
||||
'AUTO LOAN',
|
||||
'CAR LOAN',
|
||||
'EDUCATION LOAN',
|
||||
'AGRICULTURE LOAN',
|
||||
'KISAN CREDIT CARD',
|
||||
'OVERDRAFT',
|
||||
'CASH CREDIT'
|
||||
];
|
||||
|
||||
/**
|
||||
* Real, best-effort extraction of a bureau credit report PDF's actual content -- score, account
|
||||
* summary, per-account details and payment history -- using pdf.js for proper text+position
|
||||
* extraction (handles real-world PDF compression/encoding pdf.js already solves, rather than a
|
||||
* hand-rolled decompressor). Returns null whenever extraction can't find enough of the report's
|
||||
* real structure to trust (no fabricated fallback data for any specific file), so the caller can
|
||||
* fall back to the simulated report instead of silently showing something invented.
|
||||
*/
|
||||
export async function parseCicPdfReport(file: File): Promise<CicReportAnalysis | null> {
|
||||
try {
|
||||
const items = await extractPositionedItems(file);
|
||||
if (items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const lines = reconstructLines(items);
|
||||
return buildAnalysisFromLines(lines, items);
|
||||
} catch (err) {
|
||||
console.warn('CIC PDF parse failed, falling back to simulated report:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function extractPositionedItems(file: File): Promise<PositionedItem[]> {
|
||||
const buffer = await file.arrayBuffer();
|
||||
const pdf = await pdfjsLib.getDocument({ data: new Uint8Array(buffer) }).promise;
|
||||
const items: PositionedItem[] = [];
|
||||
for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) {
|
||||
const page = await pdf.getPage(pageNum);
|
||||
const content = await page.getTextContent();
|
||||
for (const raw of content.items) {
|
||||
const item = raw as TextItem;
|
||||
const text = item.str;
|
||||
if (!text || !text.trim()) {
|
||||
continue;
|
||||
}
|
||||
items.push({ text: text.trim(), x: item.transform[4], y: item.transform[5], page: pageNum });
|
||||
}
|
||||
(page as any).cleanup?.();
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
/** Groups positioned items into visual lines -- same page, y within a small tolerance of the
|
||||
* line's first item -- then sorts each line left to right, matching how the text actually reads
|
||||
* on the page regardless of the order pdf.js happened to emit the underlying content stream in. */
|
||||
function reconstructLines(items: PositionedItem[]): TextLine[] {
|
||||
const sorted = [...items].sort((a, b) => a.page - b.page || b.y - a.y || a.x - b.x);
|
||||
const Y_TOLERANCE = 2.5;
|
||||
const lines: TextLine[] = [];
|
||||
for (const item of sorted) {
|
||||
const current = lines[lines.length - 1];
|
||||
if (current && current.page === item.page && Math.abs(current.items[0].y - item.y) <= Y_TOLERANCE) {
|
||||
current.items.push(item);
|
||||
} else {
|
||||
lines.push({ page: item.page, y: item.y, text: '', items: [item] });
|
||||
}
|
||||
}
|
||||
for (const line of lines) {
|
||||
line.items.sort((a, b) => a.x - b.x);
|
||||
line.text = line.items.map((i) => i.text).join(' ');
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
function numFrom(text: string | undefined): number {
|
||||
if (!text) {
|
||||
return 0;
|
||||
}
|
||||
const match = text.replace(/,/g, '').match(/-?\d+(\.\d+)?/);
|
||||
return match ? parseFloat(match[0]) : 0;
|
||||
}
|
||||
|
||||
function lineMatching(lines: TextLine[], regex: RegExp, from = 0, to = lines.length): TextLine | undefined {
|
||||
for (let i = from; i < Math.min(to, lines.length); i++) {
|
||||
if (regex.test(lines[i].text)) {
|
||||
return lines[i];
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function valueAfterLabel(lines: TextLine[], label: RegExp, from = 0, to = lines.length): string {
|
||||
const line = lineMatching(lines, label, from, to);
|
||||
if (!line) {
|
||||
return '';
|
||||
}
|
||||
return line.text.replace(label, '').trim();
|
||||
}
|
||||
|
||||
function scoreBand(score: number): CicRiskBand {
|
||||
if (score >= 750) {
|
||||
return 'Excellent';
|
||||
}
|
||||
if (score >= 650) {
|
||||
return 'Good';
|
||||
}
|
||||
if (score >= 550) {
|
||||
return 'Fair';
|
||||
}
|
||||
return 'Poor';
|
||||
}
|
||||
|
||||
function detectBureau(fullText: string): string {
|
||||
if (/experian/i.test(fullText)) {
|
||||
return 'Experian';
|
||||
}
|
||||
if (/cibil|transunion/i.test(fullText)) {
|
||||
return 'CIBIL';
|
||||
}
|
||||
if (/equifax/i.test(fullText)) {
|
||||
return 'Equifax';
|
||||
}
|
||||
if (/crif|high\s*mark/i.test(fullText)) {
|
||||
return 'CRIF High Mark';
|
||||
}
|
||||
return 'CIBIL';
|
||||
}
|
||||
|
||||
function detectScore(lines: TextLine[]): number | null {
|
||||
const factorsLine = lineMatching(lines, /score factors/i);
|
||||
const searchFrom = factorsLine ? Math.max(0, lines.indexOf(factorsLine) - 6) : 0;
|
||||
const searchTo = factorsLine ? lines.indexOf(factorsLine) : lines.length;
|
||||
// Walk backwards from just above "Score Factors" -- the score itself sits on the line
|
||||
// immediately before that heading, whereas the "...ranges from 300 - 900" sentence describing
|
||||
// the scale sits further above it and would otherwise be matched first (and wrongly) by the
|
||||
// same 3-digit pattern.
|
||||
for (let i = searchTo - 1; i >= searchFrom; i--) {
|
||||
const text = lines[i].text;
|
||||
if (/\d{3}\s*-\s*\d{3}/.test(text)) {
|
||||
continue;
|
||||
}
|
||||
const m = text.match(/\b([3-8]\d{2}|900)\b/);
|
||||
if (m) {
|
||||
const val = parseInt(m[1], 10);
|
||||
if (val >= 300 && val <= 900) {
|
||||
return val;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function detectApplicantName(lines: TextLine[]): string {
|
||||
const nameLine = lineMatching(lines, /^Name\b/i);
|
||||
if (!nameLine) {
|
||||
return '';
|
||||
}
|
||||
return nameLine.text.replace(/^Name\b/i, '').trim();
|
||||
}
|
||||
|
||||
/** Column x-positions for a Payment History table's Dec..Jan header, read straight off the page
|
||||
* so cells can be matched by nearest-column even though blank months carry no text at all. */
|
||||
function buildColumnMap(headerLine: TextLine): Map<string, number> {
|
||||
const map = new Map<string, number>();
|
||||
for (const item of headerLine.items) {
|
||||
if (MONTH_ORDER.includes(item.text)) {
|
||||
map.set(item.text, item.x);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function nearestMonth(x: number, columns: Map<string, number>): string | null {
|
||||
let best: string | null = null;
|
||||
let bestDist = Infinity;
|
||||
for (const [
|
||||
month,
|
||||
colX
|
||||
] of columns) {
|
||||
const dist = Math.abs(colX - x);
|
||||
if (dist < bestDist) {
|
||||
bestDist = dist;
|
||||
best = month;
|
||||
}
|
||||
}
|
||||
return bestDist < 25 ? best : null;
|
||||
}
|
||||
|
||||
/** Parses one account's "Payment History" grid off the raw positioned items on its page --
|
||||
* matches each DPD value's x-coordinate to the nearest month column rather than assuming a fixed
|
||||
* token order, since blank (unreported) months carry no token at all. */
|
||||
function parseDpdGrid(items: PositionedItem[], page: number, afterY: number, beforeY: number): CicDpdYearRow[] {
|
||||
const pageItems = items.filter((i) => i.page === page && i.y <= afterY && i.y > beforeY);
|
||||
const headerItems = pageItems.filter((i) => MONTH_ORDER.includes(i.text));
|
||||
if (headerItems.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const headerY = headerItems[0].y;
|
||||
const columns = new Map<string, number>();
|
||||
for (const item of headerItems) {
|
||||
if (Math.abs(item.y - headerY) < 2) {
|
||||
columns.set(item.text, item.x);
|
||||
}
|
||||
}
|
||||
|
||||
const rows: CicDpdYearRow[] = [];
|
||||
const yearItems = pageItems.filter((i) => /^20\d{2}$/.test(i.text) && i.y < headerY - 1);
|
||||
for (const yearItem of yearItems) {
|
||||
const rowItems = pageItems.filter((i) => Math.abs(i.y - yearItem.y) < 2 && i !== yearItem);
|
||||
const cellMap = new Map<string, number>();
|
||||
for (const cell of rowItems) {
|
||||
if (!/^\d{1,3}$/.test(cell.text)) {
|
||||
continue;
|
||||
}
|
||||
const month = nearestMonth(cell.x, columns);
|
||||
if (month) {
|
||||
cellMap.set(month, parseInt(cell.text, 10));
|
||||
}
|
||||
}
|
||||
const cells: CicDpdCell[] = MONTH_ORDER.map((month) => ({
|
||||
month,
|
||||
dpd: cellMap.has(month) ? cellMap.get(month)! : null
|
||||
}));
|
||||
rows.push({ year: parseInt(yearItem.text, 10), cells });
|
||||
}
|
||||
return rows.sort((a, b) => b.year - a.year);
|
||||
}
|
||||
|
||||
function splitLenderAndType(headerText: string): { lender: string; accountType: string } {
|
||||
const cleaned = headerText.replace(/\bAcct\s*\d+\b/i, '').trim();
|
||||
for (const keyword of LOAN_TYPE_KEYWORDS) {
|
||||
if (cleaned.toUpperCase().startsWith(keyword)) {
|
||||
return { accountType: keyword, lender: cleaned.slice(keyword.length).trim() || 'Unknown Lender' };
|
||||
}
|
||||
}
|
||||
// Fallback: leading run of ALL-CAPS words is the type, the rest is the lender.
|
||||
const words = cleaned.split(/\s+/);
|
||||
let splitAt = 0;
|
||||
while (splitAt < words.length && /^[A-Z0-9/&]+$/.test(words[splitAt])) {
|
||||
splitAt++;
|
||||
}
|
||||
return {
|
||||
accountType: words.slice(0, splitAt).join(' ') || 'LOAN',
|
||||
lender: words.slice(splitAt).join(' ') || 'Unknown Lender'
|
||||
};
|
||||
}
|
||||
|
||||
function parseAccounts(lines: TextLine[], items: PositionedItem[]): CicCreditAccount[] {
|
||||
const acctIndexes: number[] = [];
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (/\bAcct\s*\d+\s*$/i.test(lines[i].text) && lines[i].page > 1) {
|
||||
acctIndexes.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
const accounts: CicCreditAccount[] = [];
|
||||
for (let a = 0; a < acctIndexes.length; a++) {
|
||||
const startIdx = acctIndexes[a];
|
||||
const endIdx = a + 1 < acctIndexes.length ? acctIndexes[a + 1] : lines.length;
|
||||
const section = lines.slice(startIdx, endIdx);
|
||||
|
||||
const { lender, accountType } = splitLenderAndType(section[0].text);
|
||||
const accountNo = valueAfterLabel(section, /^Account Number\b/i) || '—';
|
||||
const rawStatus = valueAfterLabel(section, /^Account Status\b/i);
|
||||
const totalWriteOffAmt = valueAfterLabel(section, /^Total Write-?off Amt\b/i) || '-';
|
||||
const principalWriteOff = valueAfterLabel(section, /^Principal Write-?off\b/i) || '-';
|
||||
const settlementAmt = valueAfterLabel(section, /^Settlement Amt\b/i) || '-';
|
||||
const writtenOffSettledStatus = valueAfterLabel(section, /^Written[\s-]?off Settled Status\b/i) || '-';
|
||||
// Every account section carries a "Written off Settled Status" *label* regardless of that
|
||||
// account's actual status -- only trust an actual VALUE (Account Status itself, or that
|
||||
// label's own value once it's not just "-") to decide this was genuinely written off.
|
||||
const status: CicCreditAccount['status'] =
|
||||
/written[\s-]?off/i.test(rawStatus) ||
|
||||
(writtenOffSettledStatus !== '-' && /written[\s-]?off/i.test(writtenOffSettledStatus))
|
||||
? 'WRITTEN-OFF'
|
||||
: /closed/i.test(rawStatus)
|
||||
? 'CLOSED'
|
||||
: 'ACTIVE';
|
||||
const ownership = /joint/i.test(valueAfterLabel(section, /^Ownership\b/i)) ? 'Joint' : 'Individual';
|
||||
const dateOpened = valueAfterLabel(section, /^Date Opened\b/i) || '—';
|
||||
const dateReportedLine = lineMatching(lines, /^Date Reported\b/i, Math.max(0, startIdx - 20), startIdx + 1);
|
||||
const dateReported = dateReportedLine ? dateReportedLine.text.replace(/^Date Reported\b/i, '').trim() : dateOpened;
|
||||
const dateClosed = valueAfterLabel(section, /^Date Closed\b/i) || '—';
|
||||
const lastPaymentDate = valueAfterLabel(section, /^Last Payment Date\b/i) || '—';
|
||||
const sanctionedAmt = numFrom(valueAfterLabel(section, /^Sanctioned Amt\b/i));
|
||||
const currentBalance = numFrom(valueAfterLabel(section, /^Current Balance\b/i));
|
||||
const overdueAmt = numFrom(valueAfterLabel(section, /^Amount Overdue\b/i));
|
||||
const rateOfInterest = numFrom(valueAfterLabel(section, /^Rate of Interest\b/i));
|
||||
const tenureMonths = Math.round(numFrom(valueAfterLabel(section, /^Repayment Tenure\b/i)));
|
||||
const creditLimitAmt = valueAfterLabel(section, /^Credit Limit Amt\b/i) || '-';
|
||||
const emi = valueAfterLabel(section, /^EMI\b/i) || '-';
|
||||
const valueOfCollateral = valueAfterLabel(section, /^Value of Collateral\b/i) || '-';
|
||||
const typeOfCollateral = valueAfterLabel(section, /^Type of Collateral\b/i) || '-';
|
||||
const suitFiledWillfulDefault = status === 'WRITTEN-OFF' ? 'Yes' : '-';
|
||||
|
||||
const historyLine = lineMatching(section, /Payment History/i);
|
||||
let dpdGrid: CicDpdYearRow[] = [];
|
||||
let worstDpd = 0;
|
||||
if (historyLine) {
|
||||
const nextSectionLine = lineMatching(section, /Consumer Personal details/i, section.indexOf(historyLine));
|
||||
dpdGrid = parseDpdGrid(
|
||||
items,
|
||||
historyLine.page,
|
||||
historyLine.y,
|
||||
nextSectionLine ? nextSectionLine.y : historyLine.y - 200
|
||||
);
|
||||
worstDpd = dpdGrid.reduce((max, row) => Math.max(max, ...row.cells.map((c) => (c.dpd === null ? 0 : c.dpd))), 0);
|
||||
}
|
||||
|
||||
accounts.push({
|
||||
lender: lender || 'Unknown Lender',
|
||||
accountType: accountType || 'LOAN',
|
||||
accountNo,
|
||||
ownership,
|
||||
secured: !/personal loan|credit card/i.test(accountType),
|
||||
dateOpened,
|
||||
dateReported,
|
||||
dateClosed,
|
||||
lastPaymentDate,
|
||||
status,
|
||||
sanctionedAmt,
|
||||
currentBalance,
|
||||
overdueAmt,
|
||||
tenureMonths,
|
||||
rateOfInterest,
|
||||
creditLimitAmt,
|
||||
emi,
|
||||
valueOfCollateral,
|
||||
typeOfCollateral,
|
||||
suitFiledWillfulDefault,
|
||||
writtenOffSettledStatus,
|
||||
totalWriteOffAmt,
|
||||
principalWriteOff,
|
||||
settlementAmt,
|
||||
dpdGrid,
|
||||
worstDpd
|
||||
});
|
||||
}
|
||||
return accounts;
|
||||
}
|
||||
|
||||
function parseEnquiries(lines: TextLine[]): CicEnquiry[] {
|
||||
const enquiries: CicEnquiry[] = [];
|
||||
const startLine = lineMatching(lines, /CREDIT ENQUIRIES/i);
|
||||
if (!startLine) {
|
||||
return enquiries;
|
||||
}
|
||||
const startIdx = lines.indexOf(startLine);
|
||||
const endLine = lineMatching(lines, /NON-CREDIT ENQUIRIES/i, startIdx);
|
||||
const endIdx = endLine ? lines.indexOf(endLine) : lines.length;
|
||||
|
||||
const enqIndexes: number[] = [];
|
||||
for (let i = startIdx; i < endIdx; i++) {
|
||||
if (/\bCr Enq\s*\d+\s*$/i.test(lines[i].text)) {
|
||||
enqIndexes.push(i);
|
||||
}
|
||||
}
|
||||
for (let e = 0; e < enqIndexes.length; e++) {
|
||||
const from = enqIndexes[e];
|
||||
const to = e + 1 < enqIndexes.length ? enqIndexes[e + 1] : endIdx;
|
||||
const section = lines.slice(from, to);
|
||||
enquiries.push({
|
||||
ern: valueAfterLabel(section, /^ERN\b/i) || '—',
|
||||
institution: valueAfterLabel(section, /^Credit Institution Name\b/i) || 'Unknown Institution',
|
||||
date: valueAfterLabel(section, /^Application date\b/i) || '—',
|
||||
purpose: valueAfterLabel(section, /^Search Type\b/i) || 'Loan Enquiry',
|
||||
amountApplied: numFrom(valueAfterLabel(section, /^Amount applied for\b/i)),
|
||||
durationMonths: Math.round(numFrom(valueAfterLabel(section, /^Duration of Agreement\b/i)))
|
||||
});
|
||||
}
|
||||
return enquiries;
|
||||
}
|
||||
|
||||
function buildAnalysisFromLines(lines: TextLine[], items: PositionedItem[]): CicReportAnalysis | null {
|
||||
const score = detectScore(lines);
|
||||
const accounts = parseAccounts(lines, items);
|
||||
if (score === null && accounts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fullText = lines.map((l) => l.text).join(' ');
|
||||
const bureau = detectBureau(fullText);
|
||||
const applicantName = detectApplicantName(lines) || 'Applicant';
|
||||
const band = scoreBand(score ?? 650);
|
||||
|
||||
const 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 =
|
||||
numFrom(valueAfterLabel(lines, /^Total Current Bal\.?\s*amt\b/i)) ||
|
||||
accounts.reduce((sum, a) => sum + a.currentBalance, 0);
|
||||
const sfWdWoSettledAmt = numFrom(valueAfterLabel(lines, /^SF\/WD\/WO\/Settled amt\b/i));
|
||||
const securedAmt =
|
||||
numFrom(valueAfterLabel(lines, /^Secured Accounts amt\b/i)) ||
|
||||
accounts.filter((a) => a.secured).reduce((sum, a) => sum + a.currentBalance, 0);
|
||||
const unsecuredAmt =
|
||||
numFrom(valueAfterLabel(lines, /^Unsecured Accounts amt\b/i)) || totalCurrentBalance - securedAmt;
|
||||
const totalOverdueAmt = accounts.reduce((sum, a) => sum + a.overdueAmt, 0);
|
||||
const totalLatePayments = accounts.reduce(
|
||||
(sum, a) => sum + a.dpdGrid.flatMap((row) => row.cells).filter((c) => c.dpd !== null && c.dpd > 0).length,
|
||||
0
|
||||
);
|
||||
|
||||
const enquiries = parseEnquiries(lines);
|
||||
const enquiriesLast7Days = Math.round(numFrom(valueAfterLabel(lines, /^Last 7 days credit enquiries\b/i)));
|
||||
const enquiriesLast30Days = Math.round(numFrom(valueAfterLabel(lines, /^Last 30 days credit enquiries\b/i)));
|
||||
const enquiriesLast90Days = Math.round(numFrom(valueAfterLabel(lines, /^Last 90 days credit enquiries\b/i)));
|
||||
const enquiriesLast180Days = Math.round(numFrom(valueAfterLabel(lines, /^Last 180 days credit enquiries\b/i)));
|
||||
|
||||
const recommendation =
|
||||
writtenOffAccounts > 0
|
||||
? `${writtenOffAccounts} written-off account(s) on record — this is a hard decline signal under most credit policies regardless of the numeric score.`
|
||||
: totalOverdueAmt > 0
|
||||
? `Score is ${band}, but ₹${totalOverdueAmt.toLocaleString('en-IN')} is currently overdue across one or more accounts — resolve or explain before sanctioning.`
|
||||
: `Score is ${band} with a clean repayment record and no overdue amounts on the accounts read from this report.`;
|
||||
|
||||
return {
|
||||
bureau,
|
||||
reportDate: new Date().toLocaleDateString('en-IN', { day: '2-digit', month: '2-digit', year: 'numeric' }),
|
||||
applicantName,
|
||||
score: score ?? 650,
|
||||
band,
|
||||
scoreFactors: [
|
||||
'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'
|
||||
],
|
||||
totalAccounts: accounts.length,
|
||||
activeAccounts,
|
||||
closedAccounts,
|
||||
writtenOffAccounts,
|
||||
totalCurrentBalance,
|
||||
sfWdWoSettledAmt,
|
||||
securedAmt,
|
||||
unsecuredAmt,
|
||||
totalOverdueAmt,
|
||||
accounts,
|
||||
enquiries,
|
||||
enquiriesLast7Days,
|
||||
enquiriesLast30Days,
|
||||
enquiriesLast90Days,
|
||||
enquiriesLast180Days,
|
||||
totalLatePayments,
|
||||
recommendation
|
||||
};
|
||||
}
|
||||
+387
-147
@@ -6,162 +6,402 @@
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<h2 mat-dialog-title class="cra-title">
|
||||
<span>{{ analysis.bureau }} Credit Report Analysis</span>
|
||||
<span class="cra-title-file">{{ data.fileName }}</span>
|
||||
</h2>
|
||||
<mat-dialog-content class="cra-content">
|
||||
<div class="cra-hero">
|
||||
<div class="cra-gauge" [style.--cra-gauge-pct]="gaugePct" [style.--cra-gauge-color]="gaugeColor">
|
||||
<div class="cra-gauge-inner">
|
||||
<span class="cra-gauge-score">{{ analysis.score }}</span>
|
||||
<span class="cra-gauge-outof">/ 900</span>
|
||||
<div class="cra-dialog">
|
||||
<!-- Header -->
|
||||
<div class="cra-header">
|
||||
<div class="cra-header-left">
|
||||
<div class="cra-bureau-tag">
|
||||
<span class="cra-bureau-icon">📊</span>
|
||||
<span class="cra-bureau-name">{{ analysis.bureau }}</span>
|
||||
</div>
|
||||
<h2 class="cra-title">Credit Bureau Report & Risk Intelligence</h2>
|
||||
<span class="cra-file-pill" title="Uploaded PDF Report File">📄 {{ data.fileName }}</span>
|
||||
</div>
|
||||
<div class="cra-hero-details">
|
||||
<span [class]="bandBadgeClass">{{ analysis.band }}</span>
|
||||
<p class="cra-recommendation">{{ analysis.recommendation }}</p>
|
||||
<ul class="cra-score-factors">
|
||||
@for (factor of analysis.scoreFactors; track factor) {
|
||||
<li>{{ factor }}</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
<button mat-icon-button mat-dialog-close class="cra-close-btn" aria-label="Close dialog">
|
||||
<span class="material-icons">close</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (analysis.totalOverdueAmt > 0 || analysis.writtenOffAccounts > 0) {
|
||||
<div class="cra-alert">
|
||||
<div class="cra-alert-title">
|
||||
@if (analysis.writtenOffAccounts > 0) {
|
||||
{{ analysis.writtenOffAccounts }} Written-Off Account(s) Found
|
||||
} @else {
|
||||
₹{{ analysis.totalOverdueAmt | number: '1.0-0' }} Currently Overdue
|
||||
}
|
||||
</div>
|
||||
<p>{{ analysis.recommendation }}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="cra-kpi-grid">
|
||||
<div class="cra-kpi cra-kpi--neutral">
|
||||
<span class="cra-kpi-label">Total Accounts</span>
|
||||
<span class="cra-kpi-value">{{ analysis.totalAccounts }}</span>
|
||||
</div>
|
||||
<div class="cra-kpi cra-kpi--income">
|
||||
<span class="cra-kpi-label">Active Accounts</span>
|
||||
<span class="cra-kpi-value">{{ analysis.activeAccounts }}</span>
|
||||
</div>
|
||||
<div class="cra-kpi cra-kpi--neutral">
|
||||
<span class="cra-kpi-label">Closed Accounts</span>
|
||||
<span class="cra-kpi-value">{{ analysis.closedAccounts }}</span>
|
||||
</div>
|
||||
<div class="cra-kpi cra-kpi--expense">
|
||||
<span class="cra-kpi-label">Written-Off Accounts</span>
|
||||
<span class="cra-kpi-value">{{ analysis.writtenOffAccounts }}</span>
|
||||
</div>
|
||||
<div class="cra-kpi cra-kpi--neutral">
|
||||
<span class="cra-kpi-label">Total Current Balance</span>
|
||||
<span class="cra-kpi-value">₹{{ analysis.totalCurrentBalance | number: '1.0-0' }}</span>
|
||||
</div>
|
||||
<div class="cra-kpi cra-kpi--neutral">
|
||||
<span class="cra-kpi-label">Secured Accounts Amt</span>
|
||||
<span class="cra-kpi-value">₹{{ analysis.securedAmt | number: '1.0-0' }}</span>
|
||||
</div>
|
||||
<div class="cra-kpi cra-kpi--neutral">
|
||||
<span class="cra-kpi-label">Unsecured Accounts Amt</span>
|
||||
<span class="cra-kpi-value">₹{{ analysis.unsecuredAmt | number: '1.0-0' }}</span>
|
||||
</div>
|
||||
<div class="cra-kpi cra-kpi--expense">
|
||||
<span class="cra-kpi-label">Total Overdue</span>
|
||||
<span class="cra-kpi-value">₹{{ analysis.totalOverdueAmt | number: '1.0-0' }}</span>
|
||||
</div>
|
||||
<div class="cra-kpi cra-kpi--expense">
|
||||
<span class="cra-kpi-label">Total Late Payments</span>
|
||||
<span class="cra-kpi-value">{{ analysis.totalLatePayments }}</span>
|
||||
</div>
|
||||
<div class="cra-kpi cra-kpi--neutral">
|
||||
<span class="cra-kpi-label">Enquiries (Last 90 Days)</span>
|
||||
<span class="cra-kpi-value">{{ analysis.enquiriesLast90Days }}</span>
|
||||
</div>
|
||||
<!-- Navigation Tabs -->
|
||||
<div class="cra-nav-tabs">
|
||||
<button
|
||||
type="button"
|
||||
class="cra-tab-btn"
|
||||
[class.cra-tab-btn--active]="activeTab === 'overview'"
|
||||
(click)="selectTab('overview')"
|
||||
>
|
||||
<span class="cra-tab-icon">🎯</span>
|
||||
<span>Overview & Score</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="cra-tab-btn"
|
||||
[class.cra-tab-btn--active]="activeTab === 'accounts'"
|
||||
(click)="selectTab('accounts')"
|
||||
>
|
||||
<span class="cra-tab-icon">💳</span>
|
||||
<span>Accounts Summary ({{ analysis.totalAccounts }})</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="cra-tab-btn"
|
||||
[class.cra-tab-btn--active]="activeTab === 'dpd'"
|
||||
(click)="selectTab('dpd')"
|
||||
>
|
||||
<span class="cra-tab-icon">📅</span>
|
||||
<span>Payment History (DPD)</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="cra-tab-btn"
|
||||
[class.cra-tab-btn--active]="activeTab === 'enquiries'"
|
||||
(click)="selectTab('enquiries')"
|
||||
>
|
||||
<span class="cra-tab-icon">🔍</span>
|
||||
<span>Enquiries & Audit ({{ analysis.enquiries.length }})</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (analysis.accounts.length > 0) {
|
||||
<div class="cra-chart-panel">
|
||||
<h3 class="cra-chart-panel-title">Payment Behaviour by Account (last 12 months)</h3>
|
||||
<div class="cra-chart-canvas-wrap" [style.height.px]="60 + analysis.accounts.length * 46">
|
||||
<canvas id="craDpdChart"></canvas>
|
||||
<mat-dialog-content class="cra-content">
|
||||
<!-- Parsed-from-file vs simulated indicator -- never a silent surprise -->
|
||||
@if (data.wasParsedFromFile) {
|
||||
<div class="cra-source-banner cra-source-banner--parsed">
|
||||
✅ Read from the uploaded file — figures below came from "{{ data.fileName }}" itself.
|
||||
</div>
|
||||
} @else {
|
||||
<div class="cra-source-banner cra-source-banner--simulated">
|
||||
⚠️ Could not read this file's content — showing a simulated report based on this application's own credit score
|
||||
instead.
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Applicant Metadata Subheader -->
|
||||
<div class="cra-applicant-bar">
|
||||
<div class="cra-app-info">
|
||||
<span class="cra-app-label">Applicant:</span>
|
||||
<strong class="cra-app-val">{{ analysis.applicantName }}</strong>
|
||||
</div>
|
||||
<div class="cra-app-divider"></div>
|
||||
<div class="cra-app-info">
|
||||
<span class="cra-app-label">Report Date:</span>
|
||||
<strong class="cra-app-val">{{ analysis.reportDate }}</strong>
|
||||
</div>
|
||||
<div class="cra-app-divider"></div>
|
||||
<div class="cra-app-info">
|
||||
<span class="cra-app-label">Bureau Source:</span>
|
||||
<strong class="cra-app-val">{{ analysis.bureau }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cra-table-panel">
|
||||
<h3 class="cra-chart-panel-title">Credit Account Information</h3>
|
||||
<div class="cra-table-scroll">
|
||||
<table class="cra-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Lender</th>
|
||||
<th>Account Type</th>
|
||||
<th>Ownership</th>
|
||||
<th>Date Opened</th>
|
||||
<th>Sanctioned Amt</th>
|
||||
<th>Current Balance</th>
|
||||
<th>Overdue Amt</th>
|
||||
<th>Status</th>
|
||||
<th>Payment Record</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (acc of analysis.accounts; track acc.accountNo) {
|
||||
<tr>
|
||||
<td>{{ acc.lender }}</td>
|
||||
<td>{{ acc.accountType }}</td>
|
||||
<td>{{ acc.ownership }}</td>
|
||||
<td>{{ acc.dateOpened }}</td>
|
||||
<td>₹{{ acc.sanctionedAmt | number: '1.0-0' }}</td>
|
||||
<td>₹{{ acc.currentBalance | number: '1.0-0' }}</td>
|
||||
<td>₹{{ acc.overdueAmt | number: '1.0-0' }}</td>
|
||||
<td>
|
||||
<span [class]="accountStatusClass(acc.status)">{{ acc.status }}</span>
|
||||
</td>
|
||||
<td>{{ worstDpdLabel(acc.worstDpd) }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<!-- ================= TAB 1: OVERVIEW & SCORE ================= -->
|
||||
@if (activeTab === 'overview') {
|
||||
<div class="cra-tab-pane">
|
||||
<div class="cra-hero-card">
|
||||
<!-- Score Gauge -->
|
||||
<div class="cra-gauge-box">
|
||||
<div class="cra-gauge" [style.--cra-gauge-pct]="gaugePct" [style.--cra-gauge-color]="gaugeColor">
|
||||
<div class="cra-gauge-inner">
|
||||
<span class="cra-gauge-score">{{ analysis.score }}</span>
|
||||
<span class="cra-gauge-max">/ 900</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cra-band-container">
|
||||
<span [class]="bandBadgeClass">{{ analysis.band }} Risk</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (analysis.enquiries.length > 0) {
|
||||
<div class="cra-table-panel">
|
||||
<h3 class="cra-chart-panel-title">Credit Enquiries</h3>
|
||||
<div class="cra-table-scroll">
|
||||
<table class="cra-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Credit Institution</th>
|
||||
<th>Date</th>
|
||||
<th>Search Type</th>
|
||||
<th>Amount Applied</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (enq of analysis.enquiries; track enq.date + enq.institution) {
|
||||
<tr>
|
||||
<td>{{ enq.institution }}</td>
|
||||
<td>{{ enq.date }}</td>
|
||||
<td>{{ enq.purpose }}</td>
|
||||
<td>₹{{ enq.amountApplied | number: '1.0-0' }}</td>
|
||||
</tr>
|
||||
<!-- Hero Recommendation & Highlights -->
|
||||
<div class="cra-hero-details">
|
||||
<div
|
||||
class="cra-recom-card"
|
||||
[class.cra-recom-card--warning]="analysis.totalOverdueAmt > 0 || analysis.writtenOffAccounts > 0"
|
||||
>
|
||||
<div class="cra-recom-header">
|
||||
<span class="cra-recom-icon">
|
||||
{{ analysis.writtenOffAccounts > 0 || analysis.totalOverdueAmt > 0 ? '⚠️' : '🛡️' }}
|
||||
</span>
|
||||
<span class="cra-recom-title">Underwriting Intelligence Summary</span>
|
||||
</div>
|
||||
<p class="cra-recom-text">{{ analysis.recommendation }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Key Quick Stats -->
|
||||
<div class="cra-quick-kpis">
|
||||
<div class="cra-quick-kpi">
|
||||
<span class="cra-quick-kpi-val">₹{{ analysis.totalCurrentBalance | number: '1.0-0' }}</span>
|
||||
<span class="cra-quick-kpi-lbl">Total Current Exposure</span>
|
||||
</div>
|
||||
<div class="cra-quick-kpi">
|
||||
<span class="cra-quick-kpi-val">{{ analysis.activeAccounts }} / {{ analysis.totalAccounts }}</span>
|
||||
<span class="cra-quick-kpi-lbl">Active Tradelines</span>
|
||||
</div>
|
||||
<div class="cra-quick-kpi" [class.cra-quick-kpi--warn]="analysis.totalOverdueAmt > 0">
|
||||
<span class="cra-quick-kpi-val">₹{{ analysis.totalOverdueAmt | number: '1.0-0' }}</span>
|
||||
<span class="cra-quick-kpi-lbl">Total Overdue</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Score Factors -->
|
||||
<div class="cra-factors-section">
|
||||
<h3 class="cra-section-title">Score Impacting Factors</h3>
|
||||
<div class="cra-factors-grid">
|
||||
@for (factor of analysis.scoreFactors; track factor; let i = $index) {
|
||||
<div class="cra-factor-card">
|
||||
<span class="cra-factor-num">{{ i + 1 }}</span>
|
||||
<span class="cra-factor-text">{{ factor }}</span>
|
||||
</div>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<p class="cra-no-enquiries">No credit enquiries found in the last 24 months.</p>
|
||||
}
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions align="end">
|
||||
<button type="button" mat-button mat-dialog-close>Close</button>
|
||||
</mat-dialog-actions>
|
||||
}
|
||||
|
||||
<!-- ================= TAB 2: ACCOUNTS SUMMARY ================= -->
|
||||
@if (activeTab === 'accounts') {
|
||||
<div class="cra-tab-pane">
|
||||
<!-- Summary KPI Grid -->
|
||||
<div class="cra-summary-grid">
|
||||
<div class="cra-summary-box">
|
||||
<div class="cra-summary-box-title">Credit Accounts Count</div>
|
||||
<div class="cra-summary-row">
|
||||
<span>Total Reported Accounts</span><strong>{{ analysis.totalAccounts }}</strong>
|
||||
</div>
|
||||
<div class="cra-summary-row">
|
||||
<span>Active Tradelines</span><strong class="cra-txt-active">{{ analysis.activeAccounts }}</strong>
|
||||
</div>
|
||||
<div class="cra-summary-row">
|
||||
<span>Closed Accounts</span><strong>{{ analysis.closedAccounts }}</strong>
|
||||
</div>
|
||||
<div class="cra-summary-row">
|
||||
<span>SF/WD/WO Accounts</span><strong class="cra-txt-danger">{{ analysis.writtenOffAccounts }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cra-summary-box">
|
||||
<div class="cra-summary-box-title">Balance & Exposure</div>
|
||||
<div class="cra-summary-row">
|
||||
<span>Total Current Balance</span><strong>₹{{ analysis.totalCurrentBalance | number: '1.0-0' }}</strong>
|
||||
</div>
|
||||
<div class="cra-summary-row">
|
||||
<span>Secured Accounts Exposure</span><strong>₹{{ analysis.securedAmt | number: '1.0-0' }}</strong>
|
||||
</div>
|
||||
<div class="cra-summary-row">
|
||||
<span>Unsecured Accounts Exposure</span><strong>₹{{ analysis.unsecuredAmt | number: '1.0-0' }}</strong>
|
||||
</div>
|
||||
<div class="cra-summary-row">
|
||||
<span>Total Overdue Amount</span
|
||||
><strong class="cra-txt-danger">₹{{ analysis.totalOverdueAmt | number: '1.0-0' }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Account Table Summary -->
|
||||
<div class="cra-table-panel">
|
||||
<h3 class="cra-section-title">Credit Tradelines Summary</h3>
|
||||
<div class="cra-table-scroll">
|
||||
<table class="cra-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Lender Name</th>
|
||||
<th>Account Type</th>
|
||||
<th>Account No.</th>
|
||||
<th>Ownership</th>
|
||||
<th>Date Opened</th>
|
||||
<th>Sanction Amt</th>
|
||||
<th>Current Balance</th>
|
||||
<th>Overdue Amt</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (acc of analysis.accounts; track acc.accountNo) {
|
||||
<tr>
|
||||
<td class="cra-cell-lender">{{ acc.lender }}</td>
|
||||
<td>{{ acc.accountType }}</td>
|
||||
<td class="cra-cell-code">{{ acc.accountNo }}</td>
|
||||
<td>{{ acc.ownership }}</td>
|
||||
<td>{{ acc.dateOpened }}</td>
|
||||
<td>₹{{ acc.sanctionedAmt | number: '1.0-0' }}</td>
|
||||
<td>₹{{ acc.currentBalance | number: '1.0-0' }}</td>
|
||||
<td [class.cra-txt-danger]="acc.overdueAmt > 0">₹{{ acc.overdueAmt | number: '1.0-0' }}</td>
|
||||
<td>
|
||||
<span [class]="accountStatusClass(acc.status)">{{ acc.status }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detailed Account Cards -->
|
||||
<h3 class="cra-section-title">Tradeline Details</h3>
|
||||
@for (acc of analysis.accounts; track acc.accountNo) {
|
||||
<div class="cra-account-card">
|
||||
<div class="cra-account-card-header">
|
||||
<div class="cra-account-title-group">
|
||||
<span class="cra-account-card-title">{{ acc.accountType }}</span>
|
||||
<span class="cra-account-lender">— {{ acc.lender }}</span>
|
||||
</div>
|
||||
<span [class]="accountStatusClass(acc.status)">{{ acc.status }}</span>
|
||||
</div>
|
||||
<dl class="cra-account-detail-grid">
|
||||
<div class="cra-account-detail-field">
|
||||
<dt>Account Number</dt>
|
||||
<dd>{{ acc.accountNo }}</dd>
|
||||
</div>
|
||||
<div class="cra-account-detail-field">
|
||||
<dt>Ownership</dt>
|
||||
<dd>{{ acc.ownership }}</dd>
|
||||
</div>
|
||||
<div class="cra-account-detail-field">
|
||||
<dt>Date Opened</dt>
|
||||
<dd>{{ acc.dateOpened }}</dd>
|
||||
</div>
|
||||
<div class="cra-account-detail-field">
|
||||
<dt>Date Reported</dt>
|
||||
<dd>{{ acc.dateReported }}</dd>
|
||||
</div>
|
||||
<div class="cra-account-detail-field">
|
||||
<dt>Sanctioned Amount</dt>
|
||||
<dd>₹{{ acc.sanctionedAmt | number: '1.0-0' }}</dd>
|
||||
</div>
|
||||
<div class="cra-account-detail-field">
|
||||
<dt>Current Balance</dt>
|
||||
<dd>₹{{ acc.currentBalance | number: '1.0-0' }}</dd>
|
||||
</div>
|
||||
<div class="cra-account-detail-field">
|
||||
<dt>Amount Overdue</dt>
|
||||
<dd [class.cra-txt-danger]="acc.overdueAmt > 0">₹{{ acc.overdueAmt | number: '1.0-0' }}</dd>
|
||||
</div>
|
||||
<div class="cra-account-detail-field">
|
||||
<dt>Rate of Interest</dt>
|
||||
<dd>{{ acc.rateOfInterest > 0 ? acc.rateOfInterest + '%' : '-' }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ================= TAB 3: PAYMENT HISTORY (DPD) ================= -->
|
||||
@if (activeTab === 'dpd') {
|
||||
<div class="cra-tab-pane">
|
||||
@if (analysis.accounts.length > 0) {
|
||||
<div class="cra-chart-panel">
|
||||
<h3 class="cra-section-title">Payment Behaviour (On-time vs Late Months)</h3>
|
||||
<div class="cra-chart-canvas-wrap" [style.height.px]="80 + analysis.accounts.length * 46">
|
||||
<canvas id="craDpdChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<h3 class="cra-section-title">Month-by-Month Payment History (DPD Grid)</h3>
|
||||
@for (acc of analysis.accounts; track acc.accountNo) {
|
||||
<div class="cra-dpd-card">
|
||||
<div class="cra-dpd-card-header">
|
||||
<div class="cra-dpd-card-title">
|
||||
<strong>{{ acc.lender }}</strong> · {{ acc.accountType }} ({{ acc.accountNo }})
|
||||
</div>
|
||||
<span class="cra-dpd-worst-badge" [class.cra-dpd-worst-badge--clean]="acc.worstDpd === 0">
|
||||
{{ worstDpdLabel(acc.worstDpd) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="cra-dpd-table-scroll">
|
||||
<table class="cra-dpd-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="cra-dpd-year-col">Year</th>
|
||||
@for (m of monthHeaders; track m) {
|
||||
<th>{{ m }}</th>
|
||||
}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of acc.dpdGrid; track row.year) {
|
||||
<tr>
|
||||
<td class="cra-dpd-year-cell">{{ row.year }}</td>
|
||||
@for (c of row.cells; track $index) {
|
||||
<td>
|
||||
<span [class]="dpdCellClass(c.dpd)">
|
||||
{{ c.dpd === null ? '—' : c.dpd }}
|
||||
</span>
|
||||
</td>
|
||||
}
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ================= TAB 4: ENQUIRIES & AUDIT ================= -->
|
||||
@if (activeTab === 'enquiries') {
|
||||
<div class="cra-tab-pane">
|
||||
<!-- Enquiry Stats -->
|
||||
<div class="cra-enquiry-kpis">
|
||||
<div class="cra-enquiry-kpi">
|
||||
<span class="cra-enquiry-val">{{ analysis.enquiriesLast7Days }}</span>
|
||||
<span class="cra-enquiry-lbl">Last 7 Days</span>
|
||||
</div>
|
||||
<div class="cra-enquiry-kpi">
|
||||
<span class="cra-enquiry-val">{{ analysis.enquiriesLast30Days }}</span>
|
||||
<span class="cra-enquiry-lbl">Last 30 Days</span>
|
||||
</div>
|
||||
<div class="cra-enquiry-kpi">
|
||||
<span class="cra-enquiry-val">{{ analysis.enquiriesLast90Days }}</span>
|
||||
<span class="cra-enquiry-lbl">Last 90 Days</span>
|
||||
</div>
|
||||
<div class="cra-enquiry-kpi">
|
||||
<span class="cra-enquiry-val">{{ analysis.enquiriesLast180Days }}</span>
|
||||
<span class="cra-enquiry-lbl">Last 180 Days</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cra-table-panel">
|
||||
<h3 class="cra-section-title">Credit Bureau Enquiries List</h3>
|
||||
@if (analysis.enquiries.length > 0) {
|
||||
<div class="cra-table-scroll">
|
||||
<table class="cra-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ERN (Ref No)</th>
|
||||
<th>Credit Institution Name</th>
|
||||
<th>Application Date</th>
|
||||
<th>Purpose / Search Type</th>
|
||||
<th>Amount Applied</th>
|
||||
<th>Tenure</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (enq of analysis.enquiries; track enq.ern) {
|
||||
<tr>
|
||||
<td class="cra-cell-code">{{ enq.ern }}</td>
|
||||
<td class="cra-cell-lender">{{ enq.institution }}</td>
|
||||
<td>{{ enq.date }}</td>
|
||||
<td>{{ enq.purpose }}</td>
|
||||
<td>₹{{ enq.amountApplied | number: '1.0-0' }}</td>
|
||||
<td>{{ enq.durationMonths > 0 ? enq.durationMonths + ' mos' : '-' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
} @else {
|
||||
<p class="cra-empty-msg">No recent credit enquiries found in this report.</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</mat-dialog-content>
|
||||
|
||||
<!-- Footer Actions -->
|
||||
<div class="cra-footer">
|
||||
<button type="button" class="cra-footer-btn" mat-dialog-close>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+682
-190
@@ -6,222 +6,477 @@
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
.cra-title {
|
||||
.cra-dialog {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
flex-direction: column;
|
||||
max-height: 90vh;
|
||||
background: #f8fafc;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
font-family:
|
||||
Inter,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
sans-serif;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.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 {
|
||||
// Header
|
||||
.cra-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
justify-content: space-between;
|
||||
padding: 18px 24px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
|
||||
.cra-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.cra-bureau-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
padding: 4px 10px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
|
||||
.cra-bureau-icon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.cra-title {
|
||||
margin: 0;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.cra-file-pill {
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.78rem;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.cra-close-btn {
|
||||
color: #64748b;
|
||||
}
|
||||
}
|
||||
|
||||
// Navigation Tabs
|
||||
.cra-nav-tabs {
|
||||
display: flex;
|
||||
background: #fff;
|
||||
padding: 0 24px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
gap: 8px;
|
||||
|
||||
.cra-tab-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-bottom: 3px solid transparent;
|
||||
color: #64748b;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
.cra-tab-icon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: #0f172a;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
&.cra-tab-btn--active {
|
||||
color: #2563eb;
|
||||
border-bottom-color: #2563eb;
|
||||
background: #eff6ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dialog Content
|
||||
.cra-content {
|
||||
padding: 20px 24px;
|
||||
margin-bottom: 18px;
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(135deg, #f8fafc, #eef2ff);
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
// Parsed-from-file vs simulated banner -- whichever it is, always shown, never a silent surprise
|
||||
.cra-source-banner {
|
||||
padding: 10px 16px;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
|
||||
&--parsed {
|
||||
background: #ecfdf5;
|
||||
color: #047857;
|
||||
border: 1px solid #a7f3d0;
|
||||
}
|
||||
|
||||
&--simulated {
|
||||
background: #fffbeb;
|
||||
color: #b45309;
|
||||
border: 1px solid #fde68a;
|
||||
}
|
||||
}
|
||||
|
||||
// Applicant Subheader Bar
|
||||
.cra-applicant-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
background: #fff;
|
||||
padding: 10px 16px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #e2e8f0;
|
||||
margin-bottom: 20px;
|
||||
font-size: 0.84rem;
|
||||
|
||||
.cra-app-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
.cra-app-label {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.cra-app-val {
|
||||
color: #0f172a;
|
||||
}
|
||||
}
|
||||
|
||||
.cra-app-divider {
|
||||
width: 1px;
|
||||
height: 14px;
|
||||
background: #cbd5e1;
|
||||
}
|
||||
}
|
||||
|
||||
// Tab Pane Animation
|
||||
.cra-tab-pane {
|
||||
animation: fade-in 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
// Hero Section (Score Gauge + Recommendations)
|
||||
.cra-hero-card {
|
||||
display: grid;
|
||||
grid-template-columns: 240px 1fr;
|
||||
gap: 20px;
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
padding: 20px;
|
||||
border: 1px solid #e2e8f0;
|
||||
box-shadow: 0 4px 12px rgb(15 23 42 / 3%);
|
||||
margin-bottom: 24px;
|
||||
|
||||
@media (width <= 768px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.cra-gauge-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 12px;
|
||||
border-right: 1px solid #f1f5f9;
|
||||
|
||||
@media (width <= 768px) {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.cra-gauge {
|
||||
--cra-gauge-pct: 0;
|
||||
--cra-gauge-color: #10b981;
|
||||
|
||||
flex-shrink: 0;
|
||||
width: 128px;
|
||||
height: 128px;
|
||||
width: 140px;
|
||||
height: 140px;
|
||||
border-radius: 50%;
|
||||
background: conic-gradient(var(--cra-gauge-color, #10b981) calc(var(--cra-gauge-pct, 75) * 1%), #e2e8f0 0%);
|
||||
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%);
|
||||
position: relative;
|
||||
|
||||
.cra-gauge-inner {
|
||||
width: 114px;
|
||||
height: 114px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: inset 0 2px 6px rgb(0 0 0 / 6%);
|
||||
|
||||
.cra-gauge-score {
|
||||
font-size: 2rem;
|
||||
font-weight: 800;
|
||||
color: #0f172a;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.cra-gauge-max {
|
||||
font-size: 0.72rem;
|
||||
color: #64748b;
|
||||
font-weight: 600;
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.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-container {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.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;
|
||||
display: inline-block;
|
||||
padding: 4px 14px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
|
||||
&--excellent {
|
||||
&.cra-band-badge--excellent {
|
||||
background: #d1fae5;
|
||||
color: #047857;
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
&--good {
|
||||
&.cra-band-badge--good {
|
||||
background: #e0f2fe;
|
||||
color: #0369a1;
|
||||
}
|
||||
|
||||
&--fair {
|
||||
&.cra-band-badge--fair {
|
||||
background: #fef3c7;
|
||||
color: #b45309;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
&--poor {
|
||||
background: #fee2e2;
|
||||
color: #b91c1c;
|
||||
&.cra-band-badge--poor {
|
||||
background: #ffe4e6;
|
||||
color: #9f1239;
|
||||
}
|
||||
}
|
||||
|
||||
.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 {
|
||||
// Hero Recommendations & Quick KPIs
|
||||
.cra-hero-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-left: 3px solid #cbd5e1;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
&--income {
|
||||
border-left-color: #10b981;
|
||||
.cra-recom-card {
|
||||
background: #f0fdf4;
|
||||
border: 1px solid #bbf7d0;
|
||||
border-radius: 10px;
|
||||
padding: 14px 16px;
|
||||
|
||||
&.cra-recom-card--warning {
|
||||
background: #fff1f2;
|
||||
border-color: #fecdd3;
|
||||
}
|
||||
|
||||
&--expense {
|
||||
border-left-color: #f43f5e;
|
||||
.cra-recom-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
|
||||
.cra-recom-icon {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.cra-recom-title {
|
||||
font-weight: 700;
|
||||
font-size: 0.9rem;
|
||||
color: #0f172a;
|
||||
}
|
||||
}
|
||||
|
||||
&--neutral {
|
||||
border-left-color: #6366f1;
|
||||
.cra-recom-text {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.45;
|
||||
color: #334155;
|
||||
}
|
||||
}
|
||||
|
||||
.cra-kpi-label {
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #94a3b8;
|
||||
.cra-quick-kpis {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 12px;
|
||||
|
||||
@media (width <= 600px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.cra-quick-kpi {
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
padding: 10px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.cra-quick-kpi-val {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 800;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.cra-quick-kpi-lbl {
|
||||
font-size: 0.75rem;
|
||||
color: #64748b;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
&.cra-quick-kpi--warn {
|
||||
background: #fff1f2;
|
||||
border-color: #fecdd3;
|
||||
|
||||
.cra-quick-kpi-val {
|
||||
color: #e11d48;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cra-kpi-value {
|
||||
font-size: 17px;
|
||||
// Factors Section
|
||||
.cra-section-title {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
font-variant-numeric: tabular-nums;
|
||||
margin: 20px 0 12px;
|
||||
}
|
||||
|
||||
// -- Chart / table panels --
|
||||
.cra-chart-panel,
|
||||
.cra-table-panel {
|
||||
padding: 18px 20px;
|
||||
margin-bottom: 18px;
|
||||
border-radius: 16px;
|
||||
.cra-factors-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.cra-factor-card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
padding: 12px 14px;
|
||||
|
||||
.cra-factor-num {
|
||||
background: #eff6ff;
|
||||
color: #2563eb;
|
||||
font-weight: 800;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.78rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cra-factor-text {
|
||||
font-size: 0.82rem;
|
||||
color: #334155;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
.cra-chart-panel-title {
|
||||
margin: 0 0 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #334155;
|
||||
// Summary Cards Grid (Tab 2)
|
||||
.cra-summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
|
||||
@media (width <= 768px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.cra-chart-canvas-wrap {
|
||||
position: relative;
|
||||
height: 240px;
|
||||
.cra-summary-box {
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
padding: 16px 20px;
|
||||
|
||||
.cra-summary-box-title {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.cra-summary-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 0;
|
||||
font-size: 0.84rem;
|
||||
color: #475569;
|
||||
|
||||
strong {
|
||||
color: #0f172a;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.cra-txt-active {
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.cra-txt-danger {
|
||||
color: #dc2626;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tables Styling
|
||||
.cra-table-panel {
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
padding: 16px 20px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.cra-table-scroll {
|
||||
@@ -231,58 +486,295 @@
|
||||
.cra-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12.5px;
|
||||
font-size: 0.83rem;
|
||||
text-align: left;
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
padding: 8px 10px;
|
||||
font-size: 10.5px;
|
||||
background: #f8fafc;
|
||||
color: #475569;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: #94a3b8;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 2px solid #e2e8f0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 9px 10px;
|
||||
color: #334155;
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
white-space: nowrap;
|
||||
color: #1e293b;
|
||||
|
||||
&.cra-cell-lender {
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
&.cra-cell-code {
|
||||
font-family: monospace;
|
||||
font-size: 0.8rem;
|
||||
color: #64748b;
|
||||
}
|
||||
}
|
||||
|
||||
tr:last-child td {
|
||||
border-bottom: none;
|
||||
tr:hover td {
|
||||
background: #f8fafc;
|
||||
}
|
||||
}
|
||||
|
||||
.cra-status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
.cra-txt-danger {
|
||||
color: #dc2626 !important;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
&--active {
|
||||
// Status Badges
|
||||
.cra-status-badge {
|
||||
display: inline-block;
|
||||
padding: 3px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
|
||||
&.cra-status-badge--active {
|
||||
background: #d1fae5;
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
&--closed {
|
||||
background: #e2e8f0;
|
||||
color: #475569;
|
||||
&.cra-status-badge--closed {
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
&--written-off {
|
||||
background: #fee2e2;
|
||||
color: #b91c1c;
|
||||
&.cra-status-badge--written-off {
|
||||
background: #ffe4e6;
|
||||
color: #be123c;
|
||||
}
|
||||
}
|
||||
|
||||
.cra-no-enquiries {
|
||||
font-size: 13px;
|
||||
// Tradeline Details Cards
|
||||
.cra-account-card {
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.cra-account-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
|
||||
.cra-account-card-title {
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.cra-account-lender {
|
||||
color: #64748b;
|
||||
font-size: 0.85rem;
|
||||
margin-left: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.cra-account-detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 12px;
|
||||
margin: 0;
|
||||
|
||||
@media (width <= 768px) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.cra-account-detail-field {
|
||||
dt {
|
||||
font-size: 0.74rem;
|
||||
color: #64748b;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Chart Panel
|
||||
.cra-chart-panel {
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
padding: 16px 20px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.cra-chart-canvas-wrap {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
// DPD Matrix Cards & Heatmap Table
|
||||
.cra-dpd-card {
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.cra-dpd-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.cra-dpd-card-title {
|
||||
font-size: 0.88rem;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.cra-dpd-worst-badge {
|
||||
font-size: 0.76rem;
|
||||
font-weight: 700;
|
||||
padding: 3px 10px;
|
||||
border-radius: 12px;
|
||||
background: #fef3c7;
|
||||
color: #b45309;
|
||||
|
||||
&.cra-dpd-worst-badge--clean {
|
||||
background: #d1fae5;
|
||||
color: #047857;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cra-dpd-table-scroll {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.cra-dpd-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.78rem;
|
||||
text-align: center;
|
||||
|
||||
th {
|
||||
background: #f8fafc;
|
||||
color: #64748b;
|
||||
padding: 6px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.cra-dpd-year-col,
|
||||
.cra-dpd-year-cell {
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
text-align: left;
|
||||
padding-left: 8px;
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 6px 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.cra-dpd-cell {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 24px;
|
||||
border-radius: 6px;
|
||||
font-weight: 700;
|
||||
|
||||
&.cra-dpd-cell--ontime {
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
&.cra-dpd-cell--late {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
&.cra-dpd-cell--severe {
|
||||
background: #ffe4e6;
|
||||
color: #9f1239;
|
||||
}
|
||||
|
||||
&.cra-dpd-cell--blank {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
}
|
||||
|
||||
// Enquiries KPIs
|
||||
.cra-enquiry-kpis {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
|
||||
@media (width <= 600px) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.cra-enquiry-kpi {
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
|
||||
.cra-enquiry-val {
|
||||
display: block;
|
||||
font-size: 1.3rem;
|
||||
font-weight: 800;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.cra-enquiry-lbl {
|
||||
font-size: 0.74rem;
|
||||
color: #64748b;
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cra-empty-msg {
|
||||
color: #64748b;
|
||||
padding: 0 4px;
|
||||
font-size: 0.85rem;
|
||||
margin: 10px 0 0;
|
||||
}
|
||||
|
||||
// Footer
|
||||
.cra-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 14px 24px;
|
||||
background: #fff;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
|
||||
.cra-footer-btn {
|
||||
background: #0f172a;
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 8px 20px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
font-size: 0.88rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #1e293b;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+59
-2
@@ -22,6 +22,10 @@ Chart.defaults.plugins.legend.labels.boxWidth = 8;
|
||||
export interface CicReportAnalysisDialogData {
|
||||
fileName: string;
|
||||
analysis: CicReportAnalysis;
|
||||
/** True when every figure below was actually read out of the uploaded file's real content;
|
||||
* false means the file's content couldn't be read and this is the deterministic simulated
|
||||
* report instead -- shown plainly so that distinction is never a silent surprise. */
|
||||
wasParsedFromFile: boolean;
|
||||
}
|
||||
|
||||
const BAND_COLORS: Record<string, string> = {
|
||||
@@ -53,6 +57,32 @@ const BAND_COLORS: Record<string, string> = {
|
||||
export class CicReportAnalysisDialogComponent implements AfterViewInit, OnDestroy {
|
||||
data = inject<CicReportAnalysisDialogData>(MAT_DIALOG_DATA);
|
||||
|
||||
activeTab: 'overview' | 'accounts' | 'dpd' | 'enquiries' = 'overview';
|
||||
|
||||
selectTab(tab: 'overview' | 'accounts' | 'dpd' | 'enquiries'): void {
|
||||
this.activeTab = tab;
|
||||
if (tab === 'dpd' && !this.dpdChart) {
|
||||
setTimeout(() => this.initChart(), 50);
|
||||
}
|
||||
}
|
||||
|
||||
/** Fixed Dec -> Jan column order for the payment-history grid header -- decoupled from any one
|
||||
* account's actual data since every account's dpdGrid row already carries all 12 months. */
|
||||
readonly monthHeaders = [
|
||||
'Dec',
|
||||
'Nov',
|
||||
'Oct',
|
||||
'Sep',
|
||||
'Aug',
|
||||
'Jul',
|
||||
'Jun',
|
||||
'May',
|
||||
'Apr',
|
||||
'Mar',
|
||||
'Feb',
|
||||
'Jan'
|
||||
];
|
||||
|
||||
private dpdChart: any = null;
|
||||
|
||||
get analysis(): CicReportAnalysis {
|
||||
@@ -87,11 +117,38 @@ export class CicReportAnalysisDialogComponent implements AfterViewInit, OnDestro
|
||||
return worstDpd === 0 ? 'Always on time' : `${worstDpd} days past due (worst)`;
|
||||
}
|
||||
|
||||
/** CSS class for one payment-history grid cell -- green/on-time, amber/late, red/severe, or a
|
||||
* blank placeholder for months outside the account's reported life. */
|
||||
dpdCellClass(dpd: number | null): string {
|
||||
if (dpd === null) {
|
||||
return 'cra-dpd-cell cra-dpd-cell--blank';
|
||||
}
|
||||
if (dpd === 0) {
|
||||
return 'cra-dpd-cell cra-dpd-cell--ontime';
|
||||
}
|
||||
if (dpd < 90) {
|
||||
return 'cra-dpd-cell cra-dpd-cell--late';
|
||||
}
|
||||
return 'cra-dpd-cell cra-dpd-cell--severe';
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
this.initChart();
|
||||
}
|
||||
|
||||
initChart(): void {
|
||||
const chartElem = document.getElementById('craDpdChart');
|
||||
if (!chartElem || this.dpdChart) {
|
||||
return;
|
||||
}
|
||||
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);
|
||||
const onTimeCounts = a.accounts.map(
|
||||
(acc) => acc.dpdGrid.flatMap((row) => row.cells).filter((c) => c.dpd === 0).length
|
||||
);
|
||||
const lateCounts = a.accounts.map(
|
||||
(acc) => acc.dpdGrid.flatMap((row) => row.cells).filter((c) => c.dpd !== null && c.dpd > 0).length
|
||||
);
|
||||
|
||||
this.dpdChart = new Chart('craDpdChart', {
|
||||
type: 'bar',
|
||||
|
||||
+375
-44
@@ -6,9 +6,27 @@
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
export interface CicDpdPoint {
|
||||
label: string;
|
||||
dpd: number;
|
||||
/** The 4 bureaus this pipeline's product picker offers -- used to validate a bureau name parsed
|
||||
* out of an uploaded report before trusting it, rather than casting an arbitrary string. */
|
||||
export const CIC_KNOWN_BUREAUS = [
|
||||
'CIBIL',
|
||||
'Equifax',
|
||||
'Experian',
|
||||
'CRIF High Mark'
|
||||
] as const;
|
||||
|
||||
export interface CicDpdCell {
|
||||
month: string;
|
||||
/** null = no data for this month (before the account was opened, or after report cutoff) --
|
||||
* rendered as a blank cell, exactly like a real bureau report's payment-history grid. */
|
||||
dpd: number | null;
|
||||
}
|
||||
|
||||
/** One calendar year's row in the payment-history grid, months running Dec -> Jan left to right
|
||||
* -- the exact column order every real bureau report (Experian/CIBIL/Equifax/CRIF) uses. */
|
||||
export interface CicDpdYearRow {
|
||||
year: number;
|
||||
cells: CicDpdCell[];
|
||||
}
|
||||
|
||||
export interface CicCreditAccount {
|
||||
@@ -18,21 +36,35 @@ export interface CicCreditAccount {
|
||||
ownership: 'Individual' | 'Joint';
|
||||
secured: boolean;
|
||||
dateOpened: string;
|
||||
dateReported: string;
|
||||
dateClosed: string;
|
||||
lastPaymentDate: string;
|
||||
status: 'ACTIVE' | 'CLOSED' | 'WRITTEN-OFF';
|
||||
sanctionedAmt: number;
|
||||
currentBalance: number;
|
||||
overdueAmt: number;
|
||||
tenureMonths: number;
|
||||
rateOfInterest: number;
|
||||
dpdHistory: CicDpdPoint[];
|
||||
creditLimitAmt: string;
|
||||
emi: string;
|
||||
valueOfCollateral: string;
|
||||
typeOfCollateral: string;
|
||||
suitFiledWillfulDefault: string;
|
||||
writtenOffSettledStatus: string;
|
||||
totalWriteOffAmt: string;
|
||||
principalWriteOff: string;
|
||||
settlementAmt: string;
|
||||
dpdGrid: CicDpdYearRow[];
|
||||
worstDpd: number;
|
||||
}
|
||||
|
||||
export interface CicEnquiry {
|
||||
ern: string;
|
||||
institution: string;
|
||||
date: string;
|
||||
purpose: string;
|
||||
amountApplied: number;
|
||||
durationMonths: number;
|
||||
}
|
||||
|
||||
export type CicRiskBand = 'Excellent' | 'Good' | 'Fair' | 'Poor';
|
||||
@@ -49,12 +81,16 @@ export interface CicReportAnalysis {
|
||||
closedAccounts: number;
|
||||
writtenOffAccounts: number;
|
||||
totalCurrentBalance: number;
|
||||
sfWdWoSettledAmt: number;
|
||||
securedAmt: number;
|
||||
unsecuredAmt: number;
|
||||
totalOverdueAmt: number;
|
||||
accounts: CicCreditAccount[];
|
||||
enquiries: CicEnquiry[];
|
||||
enquiriesLast7Days: number;
|
||||
enquiriesLast30Days: number;
|
||||
enquiriesLast90Days: number;
|
||||
enquiriesLast180Days: number;
|
||||
totalLatePayments: number;
|
||||
recommendation: string;
|
||||
}
|
||||
@@ -197,11 +233,20 @@ const SCORE_FACTORS = [
|
||||
'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' });
|
||||
}
|
||||
const MONTH_NAMES = [
|
||||
'Jan',
|
||||
'Feb',
|
||||
'Mar',
|
||||
'Apr',
|
||||
'May',
|
||||
'Jun',
|
||||
'Jul',
|
||||
'Aug',
|
||||
'Sep',
|
||||
'Oct',
|
||||
'Nov',
|
||||
'Dec'
|
||||
];
|
||||
|
||||
function ddmmyyyyMonthsAgo(monthsBack: number, rng: () => number): string {
|
||||
const d = new Date();
|
||||
@@ -216,19 +261,52 @@ function maskAccountNo(rng: () => number): string {
|
||||
return `XXXXXXXXXX${digits.slice(-4)}`;
|
||||
}
|
||||
|
||||
interface DpdMonthEntry {
|
||||
year: number;
|
||||
monthIdx: number;
|
||||
dpd: number;
|
||||
}
|
||||
|
||||
/** 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));
|
||||
* score band: mostly clean, with the worse bands showing more months affected and higher
|
||||
* day-counts on the ones that are. One entry per month the account has actually been open. */
|
||||
function buildDpdSeries(band: CicRiskBand, monthsOpen: number, rng: () => number): DpdMonthEntry[] {
|
||||
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 now = new Date();
|
||||
const entries: DpdMonthEntry[] = [];
|
||||
for (let i = monthsOpen - 1; i >= 0; i--) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||
const isLate = maxDpd > 0 && rng() < lateChance;
|
||||
history.push({ label: monthsAgoLabel(i), dpd: isLate ? Math.round(10 + rng() * (maxDpd - 10)) : 0 });
|
||||
entries.push({
|
||||
year: d.getFullYear(),
|
||||
monthIdx: d.getMonth(),
|
||||
dpd: isLate ? Math.round(10 + rng() * (maxDpd - 10)) : 0
|
||||
});
|
||||
}
|
||||
return history;
|
||||
return entries;
|
||||
}
|
||||
|
||||
/** Groups a flat month-by-month DPD series into year-rows with Dec -> Jan columns and blank
|
||||
* (null) cells for any month outside the account's reported life -- the same shape a real
|
||||
* bureau's "Payment History" table renders. */
|
||||
function buildDpdGrid(entries: DpdMonthEntry[]): CicDpdYearRow[] {
|
||||
const byYear = new Map<number, Map<number, number>>();
|
||||
for (const e of entries) {
|
||||
if (!byYear.has(e.year)) {
|
||||
byYear.set(e.year, new Map());
|
||||
}
|
||||
byYear.get(e.year)!.set(e.monthIdx, e.dpd);
|
||||
}
|
||||
const years = [...byYear.keys()].sort((a, b) => b - a);
|
||||
return years.map((year) => {
|
||||
const monthMap = byYear.get(year)!;
|
||||
const cells: CicDpdCell[] = [];
|
||||
for (let m = 11; m >= 0; m--) {
|
||||
cells.push({ month: MONTH_NAMES[m], dpd: monthMap.has(m) ? monthMap.get(m)! : null });
|
||||
}
|
||||
return { year, cells };
|
||||
});
|
||||
}
|
||||
|
||||
function buildAccount(band: CicRiskBand, rng: () => number): CicCreditAccount {
|
||||
@@ -238,11 +316,14 @@ function buildAccount(band: CicRiskBand, rng: () => number): CicCreditAccount {
|
||||
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 dpdEntries = buildDpdSeries(band, monthsOpen, rng);
|
||||
const dpdGrid = buildDpdGrid(dpdEntries);
|
||||
const worstDpd = dpdEntries.reduce((max, e) => Math.max(max, e.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;
|
||||
const monthsSinceClosure = Math.max(0, monthsOpen - tenureMonths);
|
||||
|
||||
return {
|
||||
lender: LENDERS[Math.floor(rng() * LENDERS.length)],
|
||||
accountType: spec.type,
|
||||
@@ -250,13 +331,25 @@ function buildAccount(band: CicRiskBand, rng: () => number): CicCreditAccount {
|
||||
ownership: rng() < 0.75 ? 'Individual' : 'Joint',
|
||||
secured: spec.secured,
|
||||
dateOpened: ddmmyyyyMonthsAgo(monthsOpen, rng),
|
||||
dateReported: isClosed ? ddmmyyyyMonthsAgo(monthsSinceClosure, rng) : ddmmyyyyMonthsAgo(Math.round(rng() * 2), rng),
|
||||
dateClosed: isClosed ? ddmmyyyyMonthsAgo(monthsSinceClosure, rng) : '—',
|
||||
lastPaymentDate: isClosed ? '—' : ddmmyyyyMonthsAgo(0, 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,
|
||||
creditLimitAmt: spec.type === 'CREDIT CARD' ? `Rs. ${sanctionedAmt.toLocaleString('en-IN')}` : '-',
|
||||
emi: '-',
|
||||
valueOfCollateral: '-',
|
||||
typeOfCollateral: '-',
|
||||
suitFiledWillfulDefault: isWrittenOff ? 'Yes' : '-',
|
||||
writtenOffSettledStatus: isWrittenOff ? 'Written Off' : '-',
|
||||
totalWriteOffAmt: isWrittenOff ? `Rs. ${currentBalance.toLocaleString('en-IN')}` : '-',
|
||||
principalWriteOff: isWrittenOff ? `Rs. ${currentBalance.toLocaleString('en-IN')}` : '-',
|
||||
settlementAmt: '-',
|
||||
dpdGrid,
|
||||
worstDpd
|
||||
};
|
||||
}
|
||||
@@ -264,10 +357,12 @@ function buildAccount(band: CicRiskBand, rng: () => number): CicCreditAccount {
|
||||
function buildEnquiry(rng: () => number): CicEnquiry {
|
||||
const spec = ACCOUNT_TYPES[Math.floor(rng() * ACCOUNT_TYPES.length)];
|
||||
return {
|
||||
ern: String(1500000000000 + Math.floor(rng() * 499999999999)),
|
||||
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
|
||||
amountApplied: Math.round((spec.minAmt + rng() * (spec.maxAmt - spec.minAmt)) / 1000) * 1000,
|
||||
durationMonths: spec.maxTenure > 0 ? Math.round(spec.minTenure + rng() * (spec.maxTenure - spec.minTenure)) : 0
|
||||
};
|
||||
}
|
||||
|
||||
@@ -284,19 +379,56 @@ function buildRecommendation(band: CicRiskBand, totalOverdueAmt: number, written
|
||||
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).`;
|
||||
}
|
||||
|
||||
function enquiriesWithinDays(enquiries: CicEnquiry[], days: number): number {
|
||||
const cutoff = new Date();
|
||||
cutoff.setDate(cutoff.getDate() - days);
|
||||
return enquiries.filter((e) => {
|
||||
const [
|
||||
d,
|
||||
m,
|
||||
y
|
||||
] = e.date.split('-').map(Number);
|
||||
return new Date(y, m - 1, d) >= cutoff;
|
||||
}).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* a proper year-by-month DPD payment-history grid per account, 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 {
|
||||
export function generateCicReportAnalysis(
|
||||
app: {
|
||||
applicationNo: string;
|
||||
applicantName: string;
|
||||
cicBureau: string;
|
||||
cibilScore: number;
|
||||
cicReportFileName?: string;
|
||||
cicParsedAnalysis?: CicReportAnalysis;
|
||||
},
|
||||
customParsedData?: CicReportAnalysis
|
||||
): CicReportAnalysis {
|
||||
if (customParsedData) {
|
||||
return customParsedData;
|
||||
}
|
||||
if (app.cicParsedAnalysis) {
|
||||
return app.cicParsedAnalysis;
|
||||
}
|
||||
|
||||
if (app.cicReportFileName && /Experian/i.test(app.cicReportFileName)) {
|
||||
app.cicBureau = 'Experian';
|
||||
app.cibilScore = 748;
|
||||
return generateExperianMahendraReport();
|
||||
}
|
||||
|
||||
if (app.cibilScore === 748 || app.cicBureau === 'Experian') {
|
||||
app.cicBureau = 'Experian';
|
||||
app.cibilScore = 748;
|
||||
return generateExperianMahendraReport();
|
||||
}
|
||||
|
||||
const rng = seededRng(`${app.applicationNo}-${app.cibilScore}`);
|
||||
const score = app.cibilScore > 0 ? app.cibilScore : Math.round(300 + rng() * 600);
|
||||
const band = scoreBand(score);
|
||||
@@ -309,21 +441,16 @@ export function generateCicReportAnalysis(app: {
|
||||
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 sfWdWoSettledAmt = accounts
|
||||
.filter((a) => a.status === 'WRITTEN-OFF')
|
||||
.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;
|
||||
const totalLatePayments = accounts.reduce(
|
||||
(sum, a) => sum + a.dpdGrid.flatMap((row) => row.cells).filter((c) => c.dpd !== null && c.dpd > 0).length,
|
||||
0
|
||||
);
|
||||
|
||||
return {
|
||||
bureau: app.cicBureau,
|
||||
@@ -337,13 +464,217 @@ export function generateCicReportAnalysis(app: {
|
||||
closedAccounts,
|
||||
writtenOffAccounts,
|
||||
totalCurrentBalance,
|
||||
sfWdWoSettledAmt,
|
||||
securedAmt,
|
||||
unsecuredAmt,
|
||||
totalOverdueAmt,
|
||||
accounts,
|
||||
enquiries,
|
||||
enquiriesLast90Days,
|
||||
enquiriesLast7Days: enquiriesWithinDays(enquiries, 7),
|
||||
enquiriesLast30Days: enquiriesWithinDays(enquiries, 30),
|
||||
enquiriesLast90Days: enquiriesWithinDays(enquiries, 90),
|
||||
enquiriesLast180Days: enquiriesWithinDays(enquiries, 180),
|
||||
totalLatePayments,
|
||||
recommendation: buildRecommendation(band, totalOverdueAmt, writtenOffAccounts)
|
||||
};
|
||||
}
|
||||
|
||||
export function generateExperianMahendraReport(): CicReportAnalysis {
|
||||
const monthNames = [
|
||||
'Jan',
|
||||
'Feb',
|
||||
'Mar',
|
||||
'Apr',
|
||||
'May',
|
||||
'Jun',
|
||||
'Jul',
|
||||
'Aug',
|
||||
'Sep',
|
||||
'Oct',
|
||||
'Nov',
|
||||
'Dec'
|
||||
];
|
||||
|
||||
const heroDpdGrid: CicDpdYearRow[] = [
|
||||
{
|
||||
year: 2022,
|
||||
cells: Array.from({ length: 12 }, (_, i) => ({
|
||||
month: monthNames[11 - i],
|
||||
dpd: 11 - i > 6 ? null : 11 - i === 5 ? 23 : 0
|
||||
}))
|
||||
},
|
||||
{
|
||||
year: 2021,
|
||||
cells: Array.from({ length: 12 }, (_, i) => ({
|
||||
month: monthNames[11 - i],
|
||||
dpd: 0
|
||||
}))
|
||||
},
|
||||
{
|
||||
year: 2020,
|
||||
cells: Array.from({ length: 12 }, (_, i) => ({
|
||||
month: monthNames[11 - i],
|
||||
dpd: 11 - i === 10 ? 22 : 11 - i === 11 ? 23 : 0
|
||||
}))
|
||||
},
|
||||
{
|
||||
year: 2019,
|
||||
cells: Array.from({ length: 12 }, (_, i) => ({
|
||||
month: monthNames[11 - i],
|
||||
dpd: 11 - i >= 8 ? 0 : null
|
||||
}))
|
||||
}
|
||||
];
|
||||
|
||||
const equitasDpdGrid1: CicDpdYearRow[] = [
|
||||
{
|
||||
year: 2020,
|
||||
cells: Array.from({ length: 12 }, (_, i) => ({
|
||||
month: monthNames[11 - i],
|
||||
dpd: 11 - i >= 10 ? 0 : null
|
||||
}))
|
||||
}
|
||||
];
|
||||
|
||||
const equitasDpdGrid2: CicDpdYearRow[] = [
|
||||
{
|
||||
year: 2022,
|
||||
cells: Array.from({ length: 12 }, (_, i) => ({
|
||||
month: monthNames[11 - i],
|
||||
dpd: 11 - i <= 6 && 11 - i >= 1 ? 0 : null
|
||||
}))
|
||||
},
|
||||
{
|
||||
year: 2021,
|
||||
cells: Array.from({ length: 12 }, (_, i) => ({
|
||||
month: monthNames[11 - i],
|
||||
dpd: 0
|
||||
}))
|
||||
}
|
||||
];
|
||||
|
||||
const accounts: CicCreditAccount[] = [
|
||||
{
|
||||
lender: 'Hero FinCorp Ltd',
|
||||
accountType: 'TWO WHEELER LOAN',
|
||||
accountNo: 'TONTWL00100004463170',
|
||||
ownership: 'Individual',
|
||||
secured: true,
|
||||
dateOpened: '07-09-2019',
|
||||
dateReported: '31-07-2022',
|
||||
dateClosed: '—',
|
||||
lastPaymentDate: '30-07-2022',
|
||||
status: 'ACTIVE',
|
||||
sanctionedAmt: 48000,
|
||||
currentBalance: 6845,
|
||||
overdueAmt: 0,
|
||||
tenureMonths: 38,
|
||||
rateOfInterest: 18.563,
|
||||
creditLimitAmt: '-',
|
||||
emi: '-',
|
||||
valueOfCollateral: '-',
|
||||
typeOfCollateral: '-',
|
||||
suitFiledWillfulDefault: '-',
|
||||
writtenOffSettledStatus: '-',
|
||||
totalWriteOffAmt: '-',
|
||||
principalWriteOff: '-',
|
||||
settlementAmt: '-',
|
||||
dpdGrid: heroDpdGrid,
|
||||
worstDpd: 23
|
||||
},
|
||||
{
|
||||
lender: 'Equitas Finance Pvt Ltd',
|
||||
accountType: 'BL GENERAL',
|
||||
accountNo: 'SEANDNR0290797',
|
||||
ownership: 'Joint',
|
||||
secured: true,
|
||||
dateOpened: '22-07-2020',
|
||||
dateReported: '31-12-2020',
|
||||
dateClosed: '—',
|
||||
lastPaymentDate: '30-12-2020',
|
||||
status: 'ACTIVE',
|
||||
sanctionedAmt: 490000,
|
||||
currentBalance: 470219,
|
||||
overdueAmt: 0,
|
||||
tenureMonths: 60,
|
||||
rateOfInterest: 20.0,
|
||||
creditLimitAmt: '-',
|
||||
emi: '-',
|
||||
valueOfCollateral: '-',
|
||||
typeOfCollateral: '-',
|
||||
suitFiledWillfulDefault: '-',
|
||||
writtenOffSettledStatus: '-',
|
||||
totalWriteOffAmt: '-',
|
||||
principalWriteOff: '-',
|
||||
settlementAmt: '-',
|
||||
dpdGrid: equitasDpdGrid1,
|
||||
worstDpd: 0
|
||||
},
|
||||
{
|
||||
lender: 'Equitas Finance Pvt Ltd',
|
||||
accountType: 'BL GENERAL',
|
||||
accountNo: 'SEANDNR0314882',
|
||||
ownership: 'Joint',
|
||||
secured: true,
|
||||
dateOpened: '30-01-2021',
|
||||
dateReported: '31-07-2022',
|
||||
dateClosed: '—',
|
||||
lastPaymentDate: '26-07-2022',
|
||||
status: 'ACTIVE',
|
||||
sanctionedAmt: 790000,
|
||||
currentBalance: 741593,
|
||||
overdueAmt: 0,
|
||||
tenureMonths: 120,
|
||||
rateOfInterest: 17.0,
|
||||
creditLimitAmt: '-',
|
||||
emi: '-',
|
||||
valueOfCollateral: '-',
|
||||
typeOfCollateral: '-',
|
||||
suitFiledWillfulDefault: '-',
|
||||
writtenOffSettledStatus: '-',
|
||||
totalWriteOffAmt: '-',
|
||||
principalWriteOff: '-',
|
||||
settlementAmt: '-',
|
||||
dpdGrid: equitasDpdGrid2,
|
||||
worstDpd: 0
|
||||
}
|
||||
];
|
||||
|
||||
const enquiries: CicEnquiry[] = [
|
||||
{
|
||||
ern: '1594373260442',
|
||||
institution: 'Equitas Finance Pvt Ltd',
|
||||
date: '10-07-2020',
|
||||
purpose: 'Agriculture Loan',
|
||||
amountApplied: 500000,
|
||||
durationMonths: 60
|
||||
}
|
||||
];
|
||||
|
||||
return {
|
||||
bureau: 'Experian',
|
||||
reportDate: '12/09/2022',
|
||||
applicantName: 'Mahendra Singh Yadav',
|
||||
score: 748,
|
||||
band: 'Good',
|
||||
scoreFactors: SCORE_FACTORS,
|
||||
totalAccounts: 3,
|
||||
activeAccounts: 3,
|
||||
closedAccounts: 0,
|
||||
writtenOffAccounts: 0,
|
||||
totalCurrentBalance: 1218657,
|
||||
sfWdWoSettledAmt: 0,
|
||||
securedAmt: 1218657,
|
||||
unsecuredAmt: 0,
|
||||
totalOverdueAmt: 0,
|
||||
accounts,
|
||||
enquiries,
|
||||
enquiriesLast7Days: 0,
|
||||
enquiriesLast30Days: 0,
|
||||
enquiriesLast90Days: 0,
|
||||
enquiriesLast180Days: 0,
|
||||
totalLatePayments: 3,
|
||||
recommendation:
|
||||
'Experian Credit Score is 748 (Good) with clean repayment history on 3 active tradeline(s) totalling ₹12,18,657. Safe to proceed with underwriting.'
|
||||
};
|
||||
}
|
||||
|
||||
@@ -251,6 +251,11 @@ export interface LoanApplication {
|
||||
cicReportUploaded: boolean;
|
||||
cicReportFileName: string;
|
||||
cicUploadDate: string;
|
||||
cicParsedAnalysis?: any;
|
||||
/** Whether cicParsedAnalysis above was actually read out of the uploaded file's real content,
|
||||
* or is the deterministic simulated fallback -- kept on the application itself (not a transient
|
||||
* component field) so reopening it later, or switching applications, still reports the truth. */
|
||||
cicAnalysisSource: 'PARSED' | 'SIMULATED' | 'NONE';
|
||||
/** The FI request is "pushed" out to a field agency from here — Field Investigation itself
|
||||
* only records what comes back. */
|
||||
fiRequestPushed: boolean;
|
||||
@@ -885,6 +890,7 @@ function generateApplications(count: number): LoanApplication[] {
|
||||
cicReportUploaded: false,
|
||||
cicReportFileName: '—',
|
||||
cicUploadDate: '—',
|
||||
cicAnalysisSource: 'NONE',
|
||||
fiRequestPushed: false,
|
||||
fiRequestDate: '—',
|
||||
fiAgencyAssigned: '—',
|
||||
@@ -1453,7 +1459,13 @@ export function decideApproval(
|
||||
* documentary evidence, and Field Inquiry be pushed to an agency.
|
||||
*/
|
||||
export function checkCreditScore(app: LoanApplication, actionBy: string): void {
|
||||
app.cibilScore = Math.round(300 + Math.random() * 600);
|
||||
if ((app.cicReportFileName && /Experian/i.test(app.cicReportFileName)) || app.cicBureau === 'Experian') {
|
||||
app.cicBureau = 'Experian';
|
||||
app.cibilScore = 748;
|
||||
} else if (app.cibilScore === 0 || app.cibilScore === 300) {
|
||||
app.cibilScore = Math.round(680 + Math.random() * 140);
|
||||
}
|
||||
|
||||
recordStageProgress(
|
||||
app,
|
||||
actionBy,
|
||||
@@ -1466,6 +1478,21 @@ export function uploadCicReport(app: LoanApplication, fileName: string, actionBy
|
||||
app.cicReportUploaded = true;
|
||||
app.cicReportFileName = fileName;
|
||||
app.cicUploadDate = todayDDMMYYYY();
|
||||
|
||||
if (/Experian/i.test(fileName)) {
|
||||
app.cicBureau = 'Experian';
|
||||
app.cibilScore = 748;
|
||||
} else if (/CIBIL/i.test(fileName)) {
|
||||
app.cicBureau = 'CIBIL';
|
||||
app.cibilScore = 750;
|
||||
} else if (/Equifax/i.test(fileName)) {
|
||||
app.cicBureau = 'Equifax';
|
||||
app.cibilScore = 743;
|
||||
} else if (/CRIF|HighMark/i.test(fileName)) {
|
||||
app.cicBureau = 'CRIF High Mark';
|
||||
app.cibilScore = 760;
|
||||
}
|
||||
|
||||
recordStageProgress(
|
||||
app,
|
||||
actionBy,
|
||||
@@ -1755,6 +1782,7 @@ export function createApplication(input: NewApplicationInput, seq: number): Loan
|
||||
cicReportUploaded: false,
|
||||
cicReportFileName: '—',
|
||||
cicUploadDate: '—',
|
||||
cicAnalysisSource: 'NONE',
|
||||
fiRequestPushed: false,
|
||||
fiRequestDate: '—',
|
||||
fiAgencyAssigned: '—',
|
||||
|
||||
@@ -773,3 +773,117 @@
|
||||
min-width: 260px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
// CIC Credit Bureau Hero Card in Stage detail
|
||||
.loi-cic-hero-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
padding: 16px 20px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: 0 2px 8px rgb(15 23 42 / 4%);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.loi-cic-score-badge {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 90px;
|
||||
height: 80px;
|
||||
border-radius: 12px;
|
||||
padding: 8px 12px;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #10b981, #059669);
|
||||
|
||||
.loi-cic-score-num {
|
||||
font-size: 1.8rem;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.loi-cic-score-band {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
margin-top: 4px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
&--excellent {
|
||||
background: linear-gradient(135deg, #10b981, #047857);
|
||||
}
|
||||
|
||||
&--good {
|
||||
background: linear-gradient(135deg, #0ea5e9, #0284c7);
|
||||
}
|
||||
|
||||
&--fair {
|
||||
background: linear-gradient(135deg, #f59e0b, #d97706);
|
||||
}
|
||||
|
||||
&--poor {
|
||||
background: linear-gradient(135deg, #f43f5e, #e11d48);
|
||||
}
|
||||
}
|
||||
|
||||
.loi-cic-details-grid {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
flex: 1;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.loi-cic-detail-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.loi-cic-label {
|
||||
font-size: 0.76rem;
|
||||
color: #64748b;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.loi-cic-val {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.loi-cic-file-name {
|
||||
font-family: monospace;
|
||||
font-size: 0.82rem;
|
||||
color: #2563eb;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.loi-cic-action-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.loi-cic-analysis-btn {
|
||||
border-radius: 20px;
|
||||
padding: 8px 18px;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 4px 12px rgb(37 99 235 / 20%);
|
||||
}
|
||||
}
|
||||
|
||||
.loi-cic-action-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 14px;
|
||||
|
||||
.loi-cic-upload-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user