Personal Discussion with Manager
-
- Discrepancy (if any)
-
- No Discrepancy
- Document Discrepancy
- Name Mismatch
- Office Verification Failed
- Property Discrepancy
- FI Report Discrepancy
- Fake Declaration Suspected
-
-
-
- Discussion Notes
-
-
-
- @if (discrepancySelection === 'NONE') {
-
- Clear, Take Declaration & Proceed to Loan Committee
+
+
Briefing Report — For the Meeting
+
+ Everything captured so far — KYC, CIC score, Field Investigation, property valuation, agent report and
+ the full stage history — as one PDF for whoever takes this meeting.
+
+
+
+ 🖨️ Print Summary Report
- } @else {
-
- Flag Discrepancy — Hold Application
+
+ ⬇️ Download Summary Report
+
+
+
+
+
Meeting Scheduler
+
+
+ Meeting Date
+
+
+
+ Meeting Time
+
+
+
+ Mode of Meeting
+
+ Select
+ Offline (In-Branch)
+ Online (Video Call)
+ Phone Call
+
+
+
+ Bank Officer Taking the Meeting
+
+ Select
+ @for (m of meetingManagers; track m) {
+ {{ m }}
+ }
+
+
+
+
+
+ {{ a.meetingScheduled ? 'Update Schedule' : 'Schedule Meeting' }}
+
+
+ @if (a.meetingScheduled) {
+
+ Scheduled with {{ a.meetingWith }} on {{ a.meetingDate }} at {{ a.meetingTime }} ({{
+ a.meetingMode === 'ONLINE' ? 'Online' : a.meetingMode === 'CALL' ? 'Phone Call' : 'Offline'
+ }})
+
}
+
+ @if (a.meetingScheduled) {
+
+ Discrepancy (if any)
+
+ No Discrepancy
+ Document Discrepancy
+ Name Mismatch
+ Office Verification Failed
+ Property Discrepancy
+ FI Report Discrepancy
+ Fake Declaration Suspected
+
+
+
+ Discussion Report
+
+
+
+ @if (discrepancySelection === 'NONE') {
+
+ Clear, Take Declaration & Proceed to Loan Committee
+
+ } @else {
+
+ Flag Discrepancy — Hold Application
+
+ }
+
+ } @else {
+ Schedule the meeting above before recording the discussion report.
+ }
}
diff --git a/src/app/loan-origination/application-detail/application-detail.component.ts b/src/app/loan-origination/application-detail/application-detail.component.ts
index ab7e7e3d6..82f9cace2 100644
--- a/src/app/loan-origination/application-detail/application-detail.component.ts
+++ b/src/app/loan-origination/application-detail/application-detail.component.ts
@@ -15,12 +15,22 @@ import { Subscription, forkJoin } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { of } from 'rxjs';
import { jsPDF } from 'jspdf';
+import autoTable from 'jspdf-autotable';
import { AuthenticationService } from '../../core/authentication/authentication.service';
import { ClientsService } from '../../clients/clients.service';
import { SharesService } from '../../shares/shares.service';
import { KycUploadHelperService } from '../../clients/shared/kyc-upload-helper.service';
import { DocumentPreviewDialogComponent } from '../../clients/shared/document-preview-dialog/document-preview-dialog.component';
import { ShareAccountDetailsDialogComponent } from './share-account-details-dialog/share-account-details-dialog.component';
+import { BankStatementAnalysisDialogComponent } from './bank-statement-analysis-dialog/bank-statement-analysis-dialog.component';
+import { CoOwnerKycVerifyDialogComponent } from './co-owner-kyc-verify-dialog/co-owner-kyc-verify-dialog.component';
+import {
+ parseBankStatementCsv,
+ summarizeBankStatement
+} from './bank-statement-analysis-dialog/bank-statement-analysis.util';
+import { CicReportAnalysisDialogComponent } from './cic-report-analysis-dialog/cic-report-analysis-dialog.component';
+import { generateCicReportAnalysis } from './cic-report-analysis-dialog/cic-report-analysis.util';
+import { FieldVisitDetailsDialogComponent } from './field-visit-details-dialog/field-visit-details-dialog.component';
import {
ApprovalField,
BRANCHES,
@@ -30,11 +40,13 @@ import {
EmiScheduleRow,
LoanApplication,
LOAN_APPLICATIONS,
+ ORIGINATION_PRODUCTS,
OriginationStage,
STAGE_LABELS,
STAGE_ORDER,
STATUS_LABELS,
allotShareMembership,
+ assignFieldOfficer,
buildEmiSchedule,
calcEligibleAmount,
calculateEmi,
@@ -46,14 +58,19 @@ import {
confirmChequeAch,
decideApproval,
disburseApplication,
+ FiCheckKey,
+ FI_CHECK_LABELS,
flagDiscrepancy,
- isRevolvingProduct,
+ MANAGERS,
markCustomerSigned,
moveToStage,
+ ProductDocumentEntry,
progressPct,
+ PROPERTY_TYPES,
pushFieldInquiry,
resolveDiscrepancyAndProceed,
sanctioningAuthority,
+ scheduleMeeting,
setMonthlyIncome,
stageBadgeClass,
stageIndex,
@@ -80,10 +97,28 @@ interface NewKycSingleDoc {
type EmploymentType = 'SALARIED' | 'BUSINESS';
+/** An ITR year the customer genuinely doesn't have (e.g. business started after that year) can be
+ * waived with a remark instead of blocking the checklist forever on a document that will never
+ * turn up -- `exception` only ever applies when there's no file for that year (see the
+ * `!doc.file` guard in the template), so a file and a waiver can never both be "true" at once. */
+interface ItrYearCapture extends NewKycSingleDoc {
+ exception: boolean;
+ exceptionRemark: string;
+}
+
+/** A salaried applicant proves income with one combined Form 16 / 3-month salary slip upload;
+ * a business/self-employed applicant needs the last 3 years' Income Tax Returns instead, each
+ * as its own separate upload. */
+interface IncomeProofCapture {
+ employmentType: EmploymentType;
+ salaried: NewKycSingleDoc;
+ itr: ItrYearCapture[];
+}
+
interface NewKycCaptureState {
aadhaar: NewKycDocSide & { otpSent: boolean; otpDigits: string[] };
pan: NewKycDocSide;
- incomeProof: NewKycSingleDoc & { employmentType: EmploymentType };
+ incomeProof: IncomeProofCapture;
bankStatement: NewKycSingleDoc;
electricityBill: NewKycSingleDoc & { billDate: string | null };
}
@@ -123,8 +158,12 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
actionRemarks = '';
discrepancySelection: DiscrepancyType = 'NONE';
disbursementMode = 'Account Credit';
- /** KYC stage's Monthly Income entry field — blank until the officer keys it in. */
+ /** KYC stage's Income & Affordability inputs -- always editable (not just before the first
+ * "Assess"), pre-filled from the application and re-committed on every click, so the officer
+ * can revisit and adjust income, requested amount or tenure as many times as needed. */
monthlyIncomeInput: number | null = null;
+ requestedAmountInput: number | null = null;
+ tenureMonthsInput: number | null = null;
toastMsg = '';
toastVisible = false;
@@ -163,6 +202,13 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
realDocuments: any[] = [];
loadingKyc = false;
+ /** Every address Fineract has on file for this application's linked client (Residential,
+ * Permanent, Office/Business, etc.) -- shown in full at Field Investigation instead of the
+ * single free-text address every mock/New-Customer application uses, so the officer sending
+ * someone out to verify knows exactly which addresses actually exist for this person. */
+ realAddresses: any[] = [];
+ loadingAddresses = false;
+
/** New-Customer applications (no real client yet) get a client-creation-style capture UI
* instead -- front/back document images + an OTP box -- but since no real client/kycId
* exists yet, Aadhaar/PAN can't actually be verified against Fineract here (every one of
@@ -175,6 +221,115 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
* Score" can't be reached on a self-ticked checkbox with nothing behind it. */
cicConsentCapture: NewKycSingleDoc = { file: null, previewUrl: null };
+ /** Local file capture for each of `a.productDocuments`, keyed by the entry's `key` -- the
+ * submitted/missing flag lives on the application itself (like every other KYC checklist
+ * item), but the actual File/preview is local-only, same as the rest of this mock pipeline. */
+ productDocCapture: Record
= {};
+ newCustomDocLabel = '';
+
+ /** The Housing Loan "KYC Documents of Co-Owners" requirement only actually applies when the
+ * registry lists more than one person -- this checkbox is how the officer says whether that's
+ * the case, before the Aadhaar/PAN capture (front/back + verification, in its own popup so the
+ * document card grid doesn't balloon in height) even shows up. */
+ coOwnerCapture: { hasCoOwner: boolean; aadhaar: NewKycDocSide; pan: NewKycDocSide } = {
+ hasCoOwner: false,
+ aadhaar: this.emptyDocSide(),
+ pan: this.emptyDocSide()
+ };
+
+ /** Once `coBorrowerRequired` fires (either the eligible-amount shortfall in setMonthlyIncome()
+ * or the 40%-EMI rule in setIncomeNow()), the officer opts into actually adding one via this
+ * checkbox -- relationship, their own monthly income and last-3-months' salary slips, all
+ * local-only like the rest of this mock pipeline. Their income only counts toward affordability
+ * once all of that is in, see combinedEmiCapacity()/affordabilityOk() below. */
+ coBorrowerCapture: {
+ wanted: boolean;
+ relationship: string;
+ monthlyIncome: number | null;
+ salarySlips: NewKycSingleDoc[];
+ } = this.emptyCoBorrowerCapture();
+
+ private emptyCoBorrowerCapture(): {
+ wanted: boolean;
+ relationship: string;
+ monthlyIncome: number | null;
+ salarySlips: NewKycSingleDoc[];
+ } {
+ return {
+ wanted: false,
+ relationship: '',
+ monthlyIncome: null,
+ salarySlips: [
+ { file: null, previewUrl: null },
+ { file: null, previewUrl: null },
+ { file: null, previewUrl: null }
+ ]
+ };
+ }
+
+ private revokeCoBorrowerPreviews(): void {
+ this.coBorrowerCapture.salarySlips.forEach((doc) => {
+ if (doc.previewUrl) {
+ URL.revokeObjectURL(doc.previewUrl);
+ }
+ });
+ }
+
+ /** The officer name typed before "Assign Officer" is clicked -- transient, cleared once the
+ * assignment actually lands on the application (see assignFieldOfficerNow()). */
+ fiOfficerNameInput: Record = {
+ addressVerification: '',
+ officeVerification: '',
+ propertyVerification: '',
+ shopVerification: ''
+ };
+
+ /** Ditto for the rejection remark textarea. */
+ fiRejectRemarkInput: Record = {
+ addressVerification: '',
+ officeVerification: '',
+ propertyVerification: '',
+ shopVerification: ''
+ };
+
+ /** The site photos captured for each FI address -- local-only, same as every other document
+ * capture in this mock pipeline; only the count is persisted on the application itself
+ * (VerificationCheck.photoCount), which is what actually gates "Mark Verified". */
+ fiPhotoCapture: Record = {
+ addressVerification: [],
+ officeVerification: [],
+ propertyVerification: [],
+ shopVerification: []
+ };
+
+ /** Ticks once a minute so the 48-hour SLA countdown/overdue alert stays live on screen without
+ * needing a manual refresh. */
+ nowTick = Date.now();
+ private fiTickTimer: ReturnType | undefined;
+
+ private revokeFiPhotoPreviews(): void {
+ (Object.keys(this.fiPhotoCapture) as FiCheckKey[]).forEach((key) => {
+ this.fiPhotoCapture[key].forEach((doc) => {
+ if (doc.previewUrl) {
+ URL.revokeObjectURL(doc.previewUrl);
+ }
+ });
+ });
+ }
+
+ private emptyDocSide(): NewKycDocSide {
+ return { front: null, back: null, frontPreviewUrl: null, backPreviewUrl: null, number: '', verified: false };
+ }
+
+ private revokeDocSidePreviews(side: NewKycDocSide): void {
+ if (side.frontPreviewUrl) {
+ URL.revokeObjectURL(side.frontPreviewUrl);
+ }
+ if (side.backPreviewUrl) {
+ URL.revokeObjectURL(side.backPreviewUrl);
+ }
+ }
+
private emptyNewKycCapture(): NewKycCaptureState {
return {
aadhaar: {
@@ -195,7 +350,15 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
verified: false
},
pan: { front: null, back: null, frontPreviewUrl: null, backPreviewUrl: null, number: '', verified: false },
- incomeProof: { file: null, previewUrl: null, employmentType: 'SALARIED' },
+ incomeProof: {
+ employmentType: 'SALARIED',
+ salaried: { file: null, previewUrl: null },
+ itr: [
+ { file: null, previewUrl: null, exception: false, exceptionRemark: '' },
+ { file: null, previewUrl: null, exception: false, exceptionRemark: '' },
+ { file: null, previewUrl: null, exception: false, exceptionRemark: '' }
+ ]
+ },
bankStatement: { file: null, previewUrl: null },
electricityBill: { file: null, previewUrl: null, billDate: null }
};
@@ -210,7 +373,8 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
state.aadhaar.backPreviewUrl,
state.pan.frontPreviewUrl,
state.pan.backPreviewUrl,
- state.incomeProof.previewUrl,
+ state.incomeProof.salaried.previewUrl,
+ ...state.incomeProof.itr.map((doc) => doc.previewUrl),
state.bankStatement.previewUrl,
state.electricityBill.previewUrl,
this.cicConsentCapture.previewUrl
@@ -228,6 +392,9 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
this.ownBranchCode =
BRANCHES.find((b: BranchMaster) => officeName.toLowerCase().includes(b.name.toLowerCase()))?.code || null;
}
+ this.fiTickTimer = setInterval(() => {
+ this.nowTick = Date.now();
+ }, 60000);
this.sub = this.route.paramMap.subscribe((params) => {
const applicationNo = params.get('applicationNo');
const found = LOAN_APPLICATIONS.find((a) => a.applicationNo === applicationNo);
@@ -238,12 +405,49 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
this.actionRemarks = '';
this.discrepancySelection = 'NONE';
this.monthlyIncomeInput = this.application?.monthlyIncome || null;
+ this.requestedAmountInput = this.application?.requestedAmount || null;
+ this.tenureMonthsInput = this.application?.tenureMonths || null;
this.revokeNewKycPreviews(this.newKycCapture);
this.newKycCapture = this.emptyNewKycCapture();
this.cicConsentCapture = { file: null, previewUrl: null };
this.cicPaymentRefInput = '';
+ this.agentReportRemarkInput = '';
+ this.revokeProductDocPreviews();
+ this.productDocCapture = {};
+ (this.application?.productDocuments ?? []).forEach((doc) => {
+ this.productDocCapture[doc.key] = { file: null, previewUrl: null };
+ });
+ this.newCustomDocLabel = '';
+ this.revokeDocSidePreviews(this.coOwnerCapture.aadhaar);
+ this.revokeDocSidePreviews(this.coOwnerCapture.pan);
+ this.coOwnerCapture = { hasCoOwner: false, aadhaar: this.emptyDocSide(), pan: this.emptyDocSide() };
+ this.revokeCoBorrowerPreviews();
+ this.coBorrowerCapture = this.emptyCoBorrowerCapture();
+ if (this.application) {
+ this.syncCoOwnerDocSubmitted(this.application);
+ }
+ this.revokeFiPhotoPreviews();
+ this.fiPhotoCapture = {
+ addressVerification: [],
+ officeVerification: [],
+ propertyVerification: [],
+ shopVerification: []
+ };
+ this.fiOfficerNameInput = {
+ addressVerification: '',
+ officeVerification: '',
+ propertyVerification: '',
+ shopVerification: ''
+ };
+ this.fiRejectRemarkInput = {
+ addressVerification: '',
+ officeVerification: '',
+ propertyVerification: '',
+ shopVerification: ''
+ };
this.loadRealClientAccounts();
this.loadRealKyc();
+ this.loadRealAddresses();
});
}
@@ -342,6 +546,39 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
);
}
+ // -------------------- Field Investigation — real client addresses --------------------
+ /** Every address type Fineract has on file for the linked client -- only meaningful once
+ * a.clientId is set, same real-vs-mock split as loadRealKyc() above. */
+ private loadRealAddresses(): void {
+ this.realAddresses = [];
+ const clientId = this.application?.clientId;
+ if (!clientId) {
+ return;
+ }
+ this.loadingAddresses = true;
+ this.clientsService
+ .getClientAddressData(String(clientId))
+ .pipe(catchError(() => of([])))
+ .subscribe((addresses: any) => {
+ this.realAddresses = Array.isArray(addresses) ? addresses : [];
+ this.loadingAddresses = false;
+ });
+ }
+
+ formatRealAddress(addr: any): string {
+ return [
+ addr?.addressLine1,
+ addr?.addressLine2,
+ addr?.townVillage,
+ addr?.city,
+ addr?.stateName,
+ addr?.countryName,
+ addr?.postalCode
+ ]
+ .filter((p) => !!p && String(p).trim())
+ .join(', ');
+ }
+
/** Masks a PAN (e.g. ABCDE1234F) down to just the last 4 characters, matching the masking
* convention Fineract's KYC API already applies to Aadhaar (aadhaarNumberMasked). */
maskPan(pan: string | undefined): string {
@@ -492,33 +729,52 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
}
}
- /** Salaried customers prove income with Form 16 / salary slips; business/self-employed
- * customers prove it with their Income Tax Return -- so the required document changes with
- * the employment type the officer selects, rather than asking for a generic "income proof". */
- get incomeProofDocLabel(): string {
- return this.newKycCapture.incomeProof.employmentType === 'BUSINESS'
- ? 'ITR (Income Tax Return)'
- : 'Form 16 / Salary Slip';
- }
-
+ /** Salaried customers prove income with one Form 16 / 3-month salary slip upload; business/
+ * self-employed customers need their last 3 years' Income Tax Returns instead, each as its
+ * own separate upload since a single year only proves one point in time. */
onIncomeProofEmploymentTypeChange(a: LoanApplication): void {
- // Switching employment type invalidates whatever was already picked under the other type's
- // label -- clear it so the officer re-uploads the document that actually matches.
- if (this.newKycCapture.incomeProof.previewUrl) {
- URL.revokeObjectURL(this.newKycCapture.incomeProof.previewUrl);
- }
- this.newKycCapture.incomeProof.file = null;
- this.newKycCapture.incomeProof.previewUrl = null;
- a.incomeProofSubmitted = false;
+ this.syncIncomeProofSubmitted(a);
}
- async onNewIncomeProofFile(a: LoanApplication, event: Event): Promise {
- const file = await this.pickSingleNewKycFile(event, this.incomeProofDocLabel);
+ async onNewSalariedProofFile(a: LoanApplication, event: Event): Promise {
+ const file = await this.pickSingleNewKycFile(event, 'Form 16 / 3 Month Salary Slip');
if (!file) {
return;
}
- this.setSingleDoc(this.newKycCapture.incomeProof, file);
- a.incomeProofSubmitted = true;
+ this.setSingleDoc(this.newKycCapture.incomeProof.salaried, file);
+ this.syncIncomeProofSubmitted(a);
+ }
+
+ async onNewItrFile(a: LoanApplication, yearIndex: number, event: Event): Promise {
+ const file = await this.pickSingleNewKycFile(event, `ITR — Year ${yearIndex + 1}`);
+ if (!file) {
+ return;
+ }
+ this.setSingleDoc(this.newKycCapture.incomeProof.itr[yearIndex], file);
+ this.syncIncomeProofSubmitted(a);
+ }
+
+ /** An officer waives a missing ITR year here instead of the checklist being permanently
+ * blocked on a return that will never turn up -- the remark records exactly which year and
+ * why, so anyone reviewing the file later knows it wasn't just forgotten. */
+ onItrExceptionChange(a: LoanApplication): void {
+ this.syncIncomeProofSubmitted(a);
+ }
+
+ private syncIncomeProofSubmitted(a: LoanApplication): void {
+ const ip = this.newKycCapture.incomeProof;
+ a.incomeProofSubmitted =
+ ip.employmentType === 'BUSINESS'
+ ? ip.itr.every((doc) => !!doc.file || (doc.exception && doc.exceptionRemark.trim() !== ''))
+ : !!ip.salaried.file;
+ }
+
+ /** True once every ITR year is covered but at least one of them is a waiver rather than an
+ * actual return -- flagged separately in the UI so "Submitted" doesn't quietly hide the fact
+ * that a year's return was never actually produced. */
+ get itrHasException(): boolean {
+ const ip = this.newKycCapture.incomeProof;
+ return ip.employmentType === 'BUSINESS' && ip.itr.some((doc) => doc.exception);
}
async onNewBankStatementFile(a: LoanApplication, event: Event): Promise {
@@ -528,6 +784,38 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
}
this.setSingleDoc(this.newKycCapture.bankStatement, file);
a.bankStatementSubmitted = true;
+ if (this.isCsvFile(file)) {
+ await this.analyzeBankStatement(file);
+ }
+ }
+
+ isCsvFile(file: File | null): boolean {
+ return !!file && (file.type === 'text/csv' || file.name.toLowerCase().endsWith('.csv'));
+ }
+
+ /** Re-opens the analysis for a bank statement already on file -- the dialog only shows once,
+ * right after upload, so this is the only way back into it once an officer has closed it to
+ * double-check something. */
+ reopenBankStatementAnalysis(file: File | null): void {
+ if (!file) {
+ return;
+ }
+ void this.analyzeBankStatement(file);
+ }
+
+ /** A CSV bank statement can actually be read, unlike a PDF/photo -- so dropping one in here
+ * opens straight into the transaction analysis instead of just filing it away unread. */
+ private async analyzeBankStatement(file: File): Promise {
+ const text = await file.text();
+ const transactions = parseBankStatementCsv(text);
+ if (!transactions.length) {
+ return;
+ }
+ this.dialog.open(BankStatementAnalysisDialogComponent, {
+ data: { fileName: file.name, summary: summarizeBankStatement(transactions) },
+ width: '900px',
+ maxWidth: '95vw'
+ });
}
/** Address-proof bills go stale -- RBI KYC practice treats a utility bill older than 3
@@ -560,6 +848,130 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
a.electricityBillSubmitted = !!this.newKycCapture.electricityBill.file && !this.electricityBillTooOld;
}
+ // -------------------- Product-specific documents (Housing/KCC/Vehicle/... papers) --------------------
+ async onProductDocFile(doc: ProductDocumentEntry, event: Event): Promise {
+ const file = await this.pickSingleNewKycFile(event, doc.label);
+ if (!file) {
+ return;
+ }
+ if (!this.productDocCapture[doc.key]) {
+ this.productDocCapture[doc.key] = { file: null, previewUrl: null };
+ }
+ this.setSingleDoc(this.productDocCapture[doc.key], file);
+ doc.submitted = true;
+ }
+
+ /** Not every product's required-document list anticipates everything a specific file might
+ * need (e.g. a second or third co-owner's KYC on a Housing Loan) -- this lets the officer add
+ * exactly the document they're holding, under whatever label describes it. */
+ addCustomProductDoc(a: LoanApplication): void {
+ const label = this.newCustomDocLabel.trim();
+ if (!label) {
+ return;
+ }
+ const key = `custom-${a.productDocuments.length}-${label.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`;
+ a.productDocuments.push({ key, label, custom: true, submitted: false });
+ this.productDocCapture[key] = { file: null, previewUrl: null };
+ this.newCustomDocLabel = '';
+ }
+
+ /** Only officer-added documents can be removed -- a product's own required list stays fixed,
+ * since that's what the loan is actually collateralised/eligible against. */
+ removeCustomProductDoc(a: LoanApplication, doc: ProductDocumentEntry): void {
+ if (!doc.custom) {
+ return;
+ }
+ const idx = a.productDocuments.indexOf(doc);
+ if (idx >= 0) {
+ a.productDocuments.splice(idx, 1);
+ }
+ const capture = this.productDocCapture[doc.key];
+ if (capture?.previewUrl) {
+ URL.revokeObjectURL(capture.previewUrl);
+ }
+ delete this.productDocCapture[doc.key];
+ }
+
+ onCoOwnerToggle(a: LoanApplication): void {
+ this.syncCoOwnerDocSubmitted(a);
+ }
+
+ // -------------------- Co-Borrower income capture --------------------
+ async onCoBorrowerSalarySlipFile(index: number, event: Event): Promise {
+ const file = await this.pickSingleNewKycFile(event, `Co-Borrower Salary Slip — Month ${index + 1}`);
+ if (!file) {
+ return;
+ }
+ this.setSingleDoc(this.coBorrowerCapture.salarySlips[index], file);
+ }
+
+ /** Single source of truth for "is there actually a usable co-borrower on file" -- name,
+ * relationship, income and all 3 salary slips. Both the eligibility badge and the blocking
+ * error message key off this SAME method (not separate ad-hoc checks), so the two can never
+ * disagree with each other on screen. */
+ coBorrowerDetailsComplete(a: LoanApplication): boolean {
+ const hasName = !!a.coBorrowerName && a.coBorrowerName !== '—' && a.coBorrowerName !== 'To be added';
+ return (
+ this.coBorrowerCapture.wanted &&
+ hasName &&
+ !!this.coBorrowerCapture.relationship &&
+ !!this.coBorrowerCapture.monthlyIncome &&
+ this.coBorrowerCapture.monthlyIncome > 0 &&
+ this.coBorrowerCapture.salarySlips.every((doc) => !!doc.file)
+ );
+ }
+
+ applicantEmiCapacity(a: LoanApplication): number {
+ return a.monthlyIncome * 0.4;
+ }
+
+ coBorrowerEmiCapacity(): number {
+ return (this.coBorrowerCapture.monthlyIncome || 0) * 0.4;
+ }
+
+ /** The 40% affordability rule applied to the household rather than the applicant alone: 40% of
+ * the applicant's own income, plus 40% of the co-borrower's (once their details are actually
+ * complete -- income and salary slips, not just a number typed in), added together. */
+ combinedEmiCapacity(a: LoanApplication): number {
+ const coBorrowerCapacity = this.coBorrowerDetailsComplete(a) ? this.coBorrowerEmiCapacity() : 0;
+ return this.applicantEmiCapacity(a) + coBorrowerCapacity;
+ }
+
+ combinedEligibilityOk(a: LoanApplication): boolean {
+ if (this.isRevolving(a)) {
+ return true;
+ }
+ return this.emiAmountFor(a) <= this.combinedEmiCapacity(a);
+ }
+
+ /** Front/back capture and OTP/format verification happen in their own popup rather than inline
+ * in the document card -- the exact same reason the applicant's own Aadhaar/PAN capture was
+ * moved out of a flat card grid: that much UI crammed into one card breaks the grid's layout.
+ * `state` is passed by reference, so anything the dialog does to it (new files, verified flip)
+ * is already reflected here the moment the dialog closes, regardless of how it was closed. */
+ openCoOwnerVerify(a: LoanApplication, docType: 'aadhaar' | 'pan'): void {
+ this.dialog
+ .open(CoOwnerKycVerifyDialogComponent, {
+ data: { docType, state: this.coOwnerCapture[docType] },
+ width: '480px',
+ maxWidth: '95vw'
+ })
+ .afterClosed()
+ .subscribe(() => this.syncCoOwnerDocSubmitted(a));
+ }
+
+ /** No co-owner ticked means there's nothing to collect, so the requirement is satisfied by
+ * default -- it only actually blocks the checklist once the officer says a co-owner exists
+ * and their Aadhaar/PAN haven't both been VERIFIED yet (not just uploaded). */
+ private syncCoOwnerDocSubmitted(a: LoanApplication): void {
+ const doc = a.productDocuments.find((d) => d.key === 'coOwnerKyc');
+ if (!doc) {
+ return;
+ }
+ doc.submitted =
+ !this.coOwnerCapture.hasCoOwner || (this.coOwnerCapture.aadhaar.verified && this.coOwnerCapture.pan.verified);
+ }
+
private setSingleDoc(doc: NewKycSingleDoc, file: File): void {
if (doc.previewUrl) {
URL.revokeObjectURL(doc.previewUrl);
@@ -592,11 +1004,27 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy {
ngOnDestroy(): void {
this.sub?.unsubscribe();
this.revokeNewKycPreviews(this.newKycCapture);
+ this.revokeProductDocPreviews();
+ this.revokeDocSidePreviews(this.coOwnerCapture.aadhaar);
+ this.revokeDocSidePreviews(this.coOwnerCapture.pan);
+ this.revokeCoBorrowerPreviews();
+ this.revokeFiPhotoPreviews();
+ if (this.fiTickTimer) {
+ clearInterval(this.fiTickTimer);
+ }
if (this.toastTimer) {
clearTimeout(this.toastTimer);
}
}
+ private revokeProductDocPreviews(): void {
+ Object.values(this.productDocCapture).forEach((doc) => {
+ if (doc.previewUrl) {
+ URL.revokeObjectURL(doc.previewUrl);
+ }
+ });
+ }
+
private toast(msg: string): void {
this.toastMsg = msg;
this.toastVisible = true;
@@ -970,55 +1398,554 @@ I confirm that:
return calcEligibleAmount(this.monthlyIncomeInput || 0, a.productCode);
}
+ /** Live EMI preview off whatever's currently typed into Requested Amount / Tenure -- falls
+ * back to the application's own committed values for whichever input is blank, so editing
+ * just one field still previews correctly against the other two. */
+ emiPreview(a: LoanApplication): number {
+ const amount = this.requestedAmountInput || a.requestedAmount;
+ const tenure = this.tenureMonthsInput || a.tenureMonths;
+ if (amount <= 0 || tenure <= 0) {
+ return 0;
+ }
+ return calculateEmi(amount, a.interestRate, tenure);
+ }
+
+ /** What share of monthly income the previewed EMI would eat -- the number the 40%
+ * affordability rule below is actually checked against. */
+ emiToIncomePctPreview(a: LoanApplication): number {
+ const income = this.monthlyIncomeInput || 0;
+ if (income <= 0) {
+ return 0;
+ }
+ return (this.emiPreview(a) / income) * 100;
+ }
+
+ /** Same ratio, but off the application's actually-committed figures -- this is what
+ * `allKycDone` gates on, since a figure the officer merely typed but never saved shouldn't be
+ * able to unlock (or block) progress. */
+ emiToIncomePct(a: LoanApplication): number {
+ if (a.monthlyIncome <= 0) {
+ return 0;
+ }
+ return (this.emiAmountFor(a) / a.monthlyIncome) * 100;
+ }
+
+ /** RBI-style affordability guardrail: an EMI eating more than 40% of monthly income needs a
+ * co-borrower's income behind it (or the amount/tenure adjusted until it doesn't) before the
+ * application can move past KYC. The eligible-amount-shortfall rule in setMonthlyIncome() can
+ * also set `coBorrowerRequired` -- either way, this only clears once a real co-borrower (name +
+ * relationship + salary slips + monthly income) is on file AND the combined 40% household
+ * capacity actually covers the EMI -- adding a co-borrower on paper without enough income
+ * behind them doesn't just wave the requirement through. */
+ affordabilityOk(a: LoanApplication): boolean {
+ if (!a.coBorrowerRequired) {
+ return true;
+ }
+ if (!this.coBorrowerDetailsComplete(a)) {
+ return false;
+ }
+ return this.combinedEligibilityOk(a);
+ }
+
+ /** The product's own min/max amount and tenure -- a 40,000-month tenure or a ₹1 crore Housing
+ * Loan against a 10-40 lakh product both "compute" a technically-correct EMI, they just aren't
+ * a real sanction anyone would write up, so out-of-range values are rejected rather than
+ * silently accepted. */
+ productLimitsFor(a: LoanApplication) {
+ return ORIGINATION_PRODUCTS.find((p) => p.code === a.productCode);
+ }
+
+ amountOutOfRange(a: LoanApplication): boolean {
+ const product = this.productLimitsFor(a);
+ if (!product || !this.requestedAmountInput) {
+ return false;
+ }
+ return this.requestedAmountInput < product.minAmt || this.requestedAmountInput > product.maxAmt;
+ }
+
+ tenureOutOfRange(a: LoanApplication): boolean {
+ const product = this.productLimitsFor(a);
+ if (!product || !this.tenureMonthsInput) {
+ return false;
+ }
+ return this.tenureMonthsInput < product.minTenure || this.tenureMonthsInput > product.maxTenure;
+ }
+
+ /** Re-assessable at any time, not just once before the first click -- lets the officer revisit
+ * income, requested amount or tenure as many times as the conversation with the customer
+ * actually needs. Amount/tenure are only actually applied when they fall inside the product's
+ * own range -- an out-of-range value is left showing (with its error) but doesn't touch the
+ * application, same as leaving the field blank does. */
setIncomeNow(a: LoanApplication): void {
if (!this.monthlyIncomeInput || this.monthlyIncomeInput <= 0) {
return;
}
+ if (this.requestedAmountInput && this.requestedAmountInput > 0 && !this.amountOutOfRange(a)) {
+ a.requestedAmount = this.requestedAmountInput;
+ }
+ if (this.tenureMonthsInput && this.tenureMonthsInput > 0 && !this.tenureOutOfRange(a)) {
+ a.tenureMonths = this.tenureMonthsInput;
+ }
setMonthlyIncome(a, this.monthlyIncomeInput, this.currentUserName);
+ if (!this.isRevolving(a) && this.emiToIncomePct(a) > 40) {
+ a.coBorrowerRequired = true;
+ if (a.coBorrowerName === '—') {
+ a.coBorrowerName = 'To be added';
+ }
+ }
}
allKycDone(a: LoanApplication): boolean {
- const base =
+ return (
a.aadhaarVerified &&
a.panVerified &&
- a.aadhaarFingerprintCaptured &&
a.incomeProofSubmitted &&
a.bankStatementSubmitted &&
a.electricityBillSubmitted &&
- a.monthlyIncome > 0;
- return a.secured ? base && a.propertyDetailsSubmitted && a.taxReceiptSubmitted : base;
+ a.monthlyIncome > 0 &&
+ a.productDocuments.every((doc) => doc.submitted) &&
+ this.affordabilityOk(a)
+ );
}
// -------------------- Field Investigation --------------------
+ readonly fiCheckLabels = FI_CHECK_LABELS;
+
fiComplete(a: LoanApplication): boolean {
const addressOk = a.addressVerification.status === 'VERIFIED';
const officeOk = a.officeVerification.status === 'VERIFIED';
const propertyOk = !a.secured || a.propertyVerification.status === 'VERIFIED';
- return addressOk && officeOk && propertyOk;
+ const shopOk = !a.hasShop || a.shopVerification.status === 'VERIFIED';
+ return addressOk && officeOk && propertyOk && shopOk;
+ }
+
+ /** The FI checks to render -- Property only for a secured product, Shop only when the officer
+ * has flagged that this applicant runs one, same gates fiComplete() already uses. */
+ fiRows(a: LoanApplication): FiCheckKey[] {
+ const keys: FiCheckKey[] = [
+ 'addressVerification',
+ 'officeVerification'
+ ];
+ if (a.secured) {
+ keys.push('propertyVerification');
+ }
+ if (a.hasShop) {
+ keys.push('shopVerification');
+ }
+ return keys;
+ }
+
+ assignFieldOfficerNow(a: LoanApplication, key: FiCheckKey): void {
+ const name = this.fiOfficerNameInput[key].trim();
+ if (!name) {
+ return;
+ }
+ assignFieldOfficer(a, key, name);
+ this.fiOfficerNameInput[key] = '';
+ const removed = this.fiPhotoCapture[key].splice(0, this.fiPhotoCapture[key].length);
+ removed.forEach((doc) => {
+ if (doc.previewUrl) {
+ URL.revokeObjectURL(doc.previewUrl);
+ }
+ });
+ }
+
+ async onFiPhotoFile(a: LoanApplication, key: FiCheckKey, event: Event): Promise {
+ const input = event.target as HTMLInputElement;
+ const files = Array.from(input.files ?? []);
+ input.value = '';
+ for (const file of files) {
+ this.fiPhotoCapture[key].push({
+ file,
+ previewUrl: file.type.startsWith('image/') ? URL.createObjectURL(file) : null
+ });
+ }
+ a[key].photoCount = this.fiPhotoCapture[key].length;
+ }
+
+ removeFiPhoto(a: LoanApplication, key: FiCheckKey, index: number): void {
+ const [removed] = this.fiPhotoCapture[key].splice(index, 1);
+ if (removed?.previewUrl) {
+ URL.revokeObjectURL(removed.previewUrl);
+ }
+ a[key].photoCount = this.fiPhotoCapture[key].length;
+ }
+
+ /** A visit can only be marked Verified once the officer has actually captured something --
+ * GPS coordinates and at least one site photo -- a bare status flip proves nothing, same
+ * reasoning as every other "self-ticked checkbox" this pipeline replaced this session. */
+ fiCanVerify(a: LoanApplication, key: FiCheckKey): boolean {
+ const v = a[key];
+ return !!v.latitude.trim() && !!v.longitude.trim() && this.fiPhotoCapture[key].length > 0;
+ }
+
+ verifyFieldVisit(a: LoanApplication, key: FiCheckKey): void {
+ if (!this.fiCanVerify(a, key)) {
+ return;
+ }
+ recordVerification(a, key, 'VERIFIED', a[key].officerAssigned, 'Address visited and confirmed correct.');
+ }
+
+ rejectFieldVisit(a: LoanApplication, key: FiCheckKey): void {
+ const remark = this.fiRejectRemarkInput[key].trim();
+ if (!remark) {
+ return;
+ }
+ recordVerification(a, key, 'FAILED', a[key].officerAssigned, remark);
+ this.fiRejectRemarkInput[key] = '';
+ }
+
+ /** Hours elapsed since the officer was assigned -- the raw number the 48-hour SLA is checked
+ * against, driven off `nowTick` so it stays live without a page refresh. */
+ fiElapsedHours(a: LoanApplication, key: FiCheckKey): number {
+ const assignedAt = a[key].assignedAt;
+ if (!assignedAt) {
+ return 0;
+ }
+ return (this.nowTick - assignedAt) / 3600000;
+ }
+
+ fiIsOverdue(a: LoanApplication, key: FiCheckKey): boolean {
+ return a[key].status === 'PENDING' && !!a[key].assignedAt && this.fiElapsedHours(a, key) > 48;
+ }
+
+ fiSlaLabel(a: LoanApplication, key: FiCheckKey): string {
+ if (a[key].status !== 'PENDING' || !a[key].assignedAt) {
+ return '';
+ }
+ const remaining = 48 - this.fiElapsedHours(a, key);
+ return remaining > 0
+ ? `${remaining.toFixed(1)}h left of the 48h SLA`
+ : `${Math.abs(remaining).toFixed(1)}h past the 48h SLA`;
}
completeFi(a: LoanApplication): void {
- const agent = 'Field Verification Team';
- const keys: Array<'addressVerification' | 'officeVerification' | 'propertyVerification'> = [
- 'addressVerification',
- 'officeVerification',
- 'propertyVerification'
- ];
- for (const key of keys) {
- if (a[key].status !== 'PENDING') {
- recordVerification(
- a,
- key,
- a[key].status,
- agent,
- a[key].remarks === '—' ? `${key} recorded on FI visit.` : a[key].remarks
- );
- }
- }
this.proceed(a, 'Field Investigation completed — Agent Report filed.');
}
+ /** Once a visit is Verified or Failed, the inline card only shows a short summary -- this opens
+ * the full record (officer, GPS, every site photo, remarks) in a popup instead of cramming it
+ * all into the card and breaking the grid layout. */
+ openFiVisitDetails(a: LoanApplication, key: FiCheckKey): void {
+ this.dialog.open(FieldVisitDetailsDialogComponent, {
+ data: { label: this.fiCheckLabels[key], check: a[key], photos: this.fiPhotoCapture[key] },
+ width: '520px',
+ maxWidth: '95vw'
+ });
+ }
+
+ // -------------------- Property Valuation (Field Investigation, secured products) --------------------
+ readonly propertyTypes = PROPERTY_TYPES;
+
+ /** propertyValuation is a derived field, not directly editable -- re-synced every time the area
+ * or either rate changes, so every existing read of a.propertyValuation elsewhere in the app
+ * (stage-history remarks, Risk Approval) stays correct without needing to change those sites. */
+ updatePropertyValuation(a: LoanApplication): void {
+ a.propertyValuation = (a.builtUpAreaSqFt || 0) * (a.marketRatePerSqFt || 0);
+ }
+
+ guidelineValue(a: LoanApplication): number {
+ return (a.builtUpAreaSqFt || 0) * (a.guidelineRatePerSqFt || 0);
+ }
+
+ /** Banks don't lend against the full market value -- a distress/forced sale realises less than
+ * what the property would fetch with a patient seller and enough time on the market. 90%/75%
+ * are the general rule-of-thumb haircuts Indian lenders apply, not a product-specific RBI slab
+ * (those vary too much by product to hard-code one number here). */
+ realizableValue(a: LoanApplication): number {
+ return a.propertyValuation * 0.9;
+ }
+
+ distressValue(a: LoanApplication): number {
+ return a.propertyValuation * 0.75;
+ }
+
+ /** Loan-to-Value against the realizable (not market) value -- the conservative number a real
+ * credit committee actually underwrites against. */
+ ltvPercent(a: LoanApplication): number {
+ const realizable = this.realizableValue(a);
+ if (realizable <= 0) {
+ return 0;
+ }
+ return (a.requestedAmount / realizable) * 100;
+ }
+
+ ltvHigh(a: LoanApplication): boolean {
+ return this.ltvPercent(a) > 80;
+ }
+
// -------------------- Personal Discussion --------------------
+ readonly meetingManagers = MANAGERS;
+
+ /** Bound directly to a.meetingDate/meetingTime/meetingMode/meetingWith (plain text, same
+ * DD-MM-YYYY convention every other date on this screen uses) -- this just makes the
+ * appointment official (flips meetingScheduled, logs it to stage history) once all 4 are
+ * filled in; editable again afterwards the same way for a reschedule. */
+ scheduleMeetingNow(a: LoanApplication): void {
+ if (!a.meetingDate.trim() || !a.meetingTime.trim() || !a.meetingMode || !a.meetingWith.trim()) {
+ return;
+ }
+ scheduleMeeting(
+ a,
+ a.meetingDate.trim(),
+ a.meetingTime.trim(),
+ a.meetingMode,
+ a.meetingWith.trim(),
+ this.currentUserName
+ );
+ }
+
+ // -------------------- Application Summary Report (briefing doc for the meeting) --------------------
+ /** Everything captured on this application so far -- KYC, CIC, Field Investigation (every
+ * address, GPS, photos, remarks), property valuation, agent report and the full stage-history
+ * audit trail -- rolled into one PDF so whoever takes the Personal Discussion meeting has a
+ * complete briefing in hand instead of clicking through every stage of the screen themselves. */
+ private buildApplicationSummaryPdf(a: LoanApplication): jsPDF {
+ const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' });
+ const pageWidth = doc.internal.pageSize.getWidth();
+ const margin = 14;
+ let y = 20;
+
+ const addLine = (label: string, value: string, yPos: number): number => {
+ doc.setFont('helvetica', 'bold');
+ doc.setFontSize(9.5);
+ doc.text(label, margin, yPos);
+ doc.setFont('helvetica', 'normal');
+ doc.text(value || '—', margin + 60, yPos);
+ return yPos + 6;
+ };
+
+ const sectionTitle = (title: string, yPos: number): number => {
+ doc.setFont('helvetica', 'bold');
+ doc.setFontSize(11.5);
+ doc.setTextColor(16, 116, 185);
+ doc.text(title, margin, yPos);
+ doc.setTextColor(0, 0, 0);
+ return yPos + 7;
+ };
+
+ const ensureSpace = (needed: number): void => {
+ if (y + needed > doc.internal.pageSize.getHeight() - 15) {
+ doc.addPage();
+ y = 20;
+ }
+ };
+
+ const finalYOf = (): number => (doc as unknown as { lastAutoTable: { finalY: number } }).lastAutoTable.finalY;
+
+ doc.setFillColor(15, 23, 42);
+ doc.rect(0, 0, pageWidth, 26, 'F');
+ doc.setTextColor(255, 255, 255);
+ doc.setFont('helvetica', 'bold');
+ doc.setFontSize(15);
+ doc.text('Loan Application Summary Report', pageWidth / 2, 12, { align: 'center' });
+ doc.setFontSize(8.5);
+ doc.setFont('helvetica', 'normal');
+ doc.text('Prepared for the Personal Discussion meeting', pageWidth / 2, 19, { align: 'center' });
+ doc.setTextColor(0, 0, 0);
+ y = 34;
+
+ doc.setFontSize(9);
+ doc.text(`Generated: ${todayDDMMYYYY()}`, margin, y);
+ doc.text(`Application No: ${a.applicationNo}`, pageWidth - margin, y, { align: 'right' });
+ y += 8;
+
+ y = sectionTitle('Application Details', y);
+ y = addLine('Applicant Name:', a.applicantName, y);
+ y = addLine('Branch:', `${a.branchName} (${a.branchCode})`, y);
+ y = addLine('Channel:', a.channel, y);
+ y = addLine('Customer Type:', a.customerType, y);
+ y = addLine('Product:', `${a.productName} (${a.productType === 'OVERDRAFT' ? 'Overdraft' : 'Term Loan'})`, y);
+ y = addLine('Requested Amount:', `Rs. ${a.requestedAmount.toLocaleString('en-IN')}`, y);
+ y = addLine('Tenure:', `${a.tenureMonths} months`, y);
+ y = addLine('Interest Rate:', `${a.interestRate}% p.a.`, y);
+ y += 2;
+
+ y = sectionTitle('Income & Eligibility', y);
+ y = addLine('Monthly Income:', `Rs. ${a.monthlyIncome.toLocaleString('en-IN')}`, y);
+ y = addLine('Eligible Loan Amount:', `Rs. ${a.eligibleLoanAmount.toLocaleString('en-IN')}`, y);
+ y = addLine(
+ 'Estimated EMI:',
+ this.isRevolving(a) ? 'No fixed EMI (revolving)' : `Rs. ${this.emiAmountFor(a).toLocaleString('en-IN')}/month`,
+ y
+ );
+ if (a.coBorrowerRequired) {
+ y = addLine('Co-Borrower:', a.coBorrowerName, y);
+ }
+ y += 2;
+
+ ensureSpace(45);
+ y = sectionTitle('KYC & Documents', y);
+ const kycRows: string[][] = [
+ [
+ 'Aadhaar Verified',
+ a.aadhaarVerified ? 'Yes' : 'No'
+ ],
+ [
+ 'PAN Verified',
+ a.panVerified ? 'Yes' : 'No'
+ ],
+ [
+ 'Income Proof',
+ a.incomeProofSubmitted ? 'Submitted' : 'Missing'
+ ],
+ [
+ 'Bank Statement',
+ a.bankStatementSubmitted ? 'Submitted' : 'Missing'
+ ],
+ [
+ 'Electricity Bill / Address Proof',
+ a.electricityBillSubmitted ? 'Submitted' : 'Missing'
+ ],
+ ...a.productDocuments.map((d) => [
+ d.label,
+ d.submitted ? 'Submitted' : 'Missing'
+ ])
+ ];
+ autoTable(doc, {
+ startY: y,
+ head: [[
+ 'Document',
+ 'Status'
+ ]],
+ body: kycRows,
+ margin: { left: margin, right: margin },
+ styles: { fontSize: 8.5 },
+ headStyles: { fillColor: [
+ 15,
+ 23,
+ 42
+ ] }
+ });
+ y = finalYOf() + 8;
+
+ ensureSpace(30);
+ y = sectionTitle('CIC / Credit Bureau Check', y);
+ y = addLine('Bureau:', a.cicBureau, y);
+ y = addLine('Credit Score:', a.cibilScore > 0 ? `${a.cibilScore} (${cibilBand(a.cibilScore)})` : 'Not checked', y);
+ y = addLine('Report:', a.cicReportUploaded ? a.cicReportFileName : 'Not uploaded', y);
+ y += 2;
+
+ ensureSpace(40);
+ y = sectionTitle('Field Investigation', y);
+ const fiRowsData = this.fiRows(a).map((key) => {
+ const v = a[key];
+ return [
+ this.fiCheckLabels[key],
+ v.status,
+ v.officerAssigned,
+ v.latitude && v.longitude ? `${v.latitude}, ${v.longitude}` : '—',
+ String(v.photoCount),
+ v.remarks
+ ];
+ });
+ autoTable(doc, {
+ startY: y,
+ head: [
+ [
+ 'Address',
+ 'Status',
+ 'Officer',
+ 'GPS',
+ 'Photos',
+ 'Remarks'
+ ]
+ ],
+ body: fiRowsData,
+ margin: { left: margin, right: margin },
+ styles: { fontSize: 7.5 },
+ headStyles: { fillColor: [
+ 15,
+ 23,
+ 42
+ ] }
+ });
+ y = finalYOf() + 8;
+
+ if (a.secured) {
+ ensureSpace(50);
+ y = sectionTitle('Property Valuation', y);
+ y = addLine('Property Type:', a.propertyType, y);
+ y = addLine('Built-Up Area:', `${a.builtUpAreaSqFt} sq ft`, y);
+ y = addLine('Market Rate:', `Rs. ${a.marketRatePerSqFt.toLocaleString('en-IN')}/sq ft`, y);
+ y = addLine('Market Value:', `Rs. ${a.propertyValuation.toLocaleString('en-IN')}`, y);
+ y = addLine('Guideline Value:', `Rs. ${this.guidelineValue(a).toLocaleString('en-IN')}`, y);
+ y = addLine('Realizable Value (90%):', `Rs. ${this.realizableValue(a).toLocaleString('en-IN')}`, y);
+ y = addLine('Loan-to-Value:', `${this.ltvPercent(a).toFixed(1)}%`, y);
+ y = addLine('Valuer:', a.valuerName, y);
+ y += 2;
+ }
+
+ ensureSpace(25);
+ y = sectionTitle('Agent Report', y);
+ y = addLine('Report File:', a.agentReportUploaded ? a.agentReportFileName : 'Not uploaded', y);
+ y = addLine('Remark:', a.agentReportRemark, y);
+ y += 2;
+
+ ensureSpace(30);
+ y = sectionTitle('Personal Discussion Meeting', y);
+ y = addLine('Scheduled With:', a.meetingWith, y);
+ y = addLine('Date & Time:', a.meetingScheduled ? `${a.meetingDate} ${a.meetingTime}` : 'Not scheduled', y);
+ y = addLine('Mode:', a.meetingMode || '—', y);
+ y += 2;
+
+ ensureSpace(30);
+ y = sectionTitle('Application Stage History', y);
+ const historyRows = a.stageHistory.map((h) => [
+ this.stageLabels[h.stage],
+ h.date,
+ h.actionBy,
+ h.outcome,
+ h.remarks
+ ]);
+ autoTable(doc, {
+ startY: y,
+ head: [
+ [
+ 'Stage',
+ 'Date',
+ 'By',
+ 'Outcome',
+ 'Remarks'
+ ]
+ ],
+ body: historyRows,
+ margin: { left: margin, right: margin },
+ styles: { fontSize: 7 },
+ headStyles: { fillColor: [
+ 15,
+ 23,
+ 42
+ ] }
+ });
+
+ const footerY = doc.internal.pageSize.getHeight() - 8;
+ doc.setFontSize(7);
+ doc.setTextColor(150, 150, 150);
+ doc.text(
+ 'This is a system-generated document. Generated from CBS Nexora — Loan Origination Module.',
+ pageWidth / 2,
+ footerY,
+ { align: 'center' }
+ );
+
+ return doc;
+ }
+
+ private applicationSummaryFileName(a: LoanApplication): string {
+ return `Application_Summary_${a.applicationNo}.pdf`;
+ }
+
+ printApplicationSummary(a: LoanApplication): void {
+ const doc = this.buildApplicationSummaryPdf(a);
+ doc.autoPrint();
+ window.open(doc.output('bloburl'), '_blank');
+ }
+
+ downloadApplicationSummary(a: LoanApplication): void {
+ this.buildApplicationSummaryPdf(a).save(this.applicationSummaryFileName(a));
+ }
+
clearPersonalDiscussion(a: LoanApplication): void {
clearPersonalDiscussionAndProceed(
a,
@@ -1131,19 +2058,37 @@ I confirm that:
}
uploadCicReport(a, file.name, this.currentUserName);
input.value = '';
+ this.openCicReportAnalysis(a);
+ }
+
+ /** No real bureau API or PDF parser sits behind this mock pipeline, so the analysis shown here
+ * is generated (deterministically, seeded off the application) from the cibilScore the "Check
+ * Credit Score" step already pulled -- not read from the actual uploaded file. Auto-opens right
+ * after upload, and reopenable afterwards via the "View Analysis" pill next to the Report row. */
+ openCicReportAnalysis(a: LoanApplication): void {
+ this.dialog.open(CicReportAnalysisDialogComponent, {
+ data: { fileName: a.cicReportFileName, analysis: generateCicReportAnalysis(a) },
+ width: '960px',
+ maxWidth: '95vw'
+ });
}
pushFieldInquiryNow(a: LoanApplication): void {
pushFieldInquiry(a, 'Field Verification Team', this.currentUserName);
}
+ /** Free-text reference/summary the officer types before attaching the Agent Report file --
+ * transient, cleared once the upload lands on the application (see onAgentReportFileSelected). */
+ agentReportRemarkInput = '';
+
onAgentReportFileSelected(event: Event, a: LoanApplication): void {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) {
return;
}
- uploadAgentReport(a, file.name, this.currentUserName);
+ uploadAgentReport(a, file.name, this.agentReportRemarkInput.trim(), this.currentUserName);
+ this.agentReportRemarkInput = '';
input.value = '';
}
@@ -1162,7 +2107,7 @@ I confirm that:
// -------------------- EMI Repayment Schedule (once disbursed) --------------------
isRevolving(a: LoanApplication): boolean {
- return isRevolvingProduct(a.productCode);
+ return a.productType === 'OVERDRAFT';
}
emiAmountFor(a: LoanApplication): number {
diff --git a/src/app/loan-origination/application-detail/bank-statement-analysis-dialog/bank-statement-analysis-dialog.component.html b/src/app/loan-origination/application-detail/bank-statement-analysis-dialog/bank-statement-analysis-dialog.component.html
new file mode 100644
index 000000000..b62fabf77
--- /dev/null
+++ b/src/app/loan-origination/application-detail/bank-statement-analysis-dialog/bank-statement-analysis-dialog.component.html
@@ -0,0 +1,126 @@
+
+
+
+ Bank Statement Analysis
+ {{ data.fileName }}
+
+
+
+
+
+ {{ summary.riskScore }}
+ / 100
+
+
+
+
{{ summary.riskRating }} RISK
+
+ @for (reason of summary.riskReasons; track reason) {
+ {{ reason }}
+ }
+
+
+
+
+ @if (summary.bouncedTransactions.length > 0) {
+
+
+ {{ summary.bouncedTransactions.length }} Bounced / Returned Transaction(s) Found
+
+
+ @for (b of summary.bouncedTransactions; track b.date) {
+ {{ b.date | date: 'dd-MM-yyyy' }} — {{ b.narration }} (₹{{ b.amount | number: '1.0-0' }})
+ }
+
+
+ }
+
+
+
+ Avg Monthly Income
+ ₹{{ summary.avgMonthlyCredit | number: '1.0-0' }}
+
+
+ Avg Monthly Expense
+ ₹{{ summary.avgMonthlyDebit | number: '1.0-0' }}
+
+
+ Average Balance
+ ₹{{ summary.avgBalance | number: '1.0-0' }}
+
+
+ Closing Balance
+ ₹{{ summary.closingBalance | number: '1.0-0' }}
+
+
+ Minimum Balance
+ ₹{{ summary.minBalance | number: '1.0-0' }}
+
+
+ Maximum Balance
+ ₹{{ summary.maxBalance | number: '1.0-0' }}
+
+
+ Existing EMI / Loan Obligations
+ ₹{{ summary.existingMonthlyObligations | number: '1.0-0' }}/mo
+
+
+ FOIR (Obligations ÷ Income)
+ {{ summary.foirPercent | number: '1.0-1' }}%
+
+
+ Income Stability
+ {{ summary.incomeStability === 'STABLE' ? 'Stable' : 'Irregular' }}
+
+
+ Positive Cash-Flow Months
+ {{ summary.positiveCashFlowMonths }} / {{ summary.monthsCovered }}
+
+
+ Negative Cash-Flow Months
+ {{ summary.negativeCashFlowMonths }} / {{ summary.monthsCovered }}
+
+
+ Salary Credits Detected
+ {{ summary.salaryCreditsDetected }}
+
+
+ Total Transactions
+ {{ summary.transactionCount }}
+
+
+ Period Covered
+ {{ summary.monthsCovered }} months
+
+
+
+
+
Monthly Income vs Expense
+
+
+
+
+
+
Account Balance Trend
+
+
+
+
+ @if (summary.categoryBreakdown.length > 0) {
+
+
Where the Money Goes
+
+
+
+
+ }
+
+
+ Close
+
diff --git a/src/app/loan-origination/application-detail/bank-statement-analysis-dialog/bank-statement-analysis-dialog.component.scss b/src/app/loan-origination/application-detail/bank-statement-analysis-dialog/bank-statement-analysis-dialog.component.scss
new file mode 100644
index 000000000..e35ffebfc
--- /dev/null
+++ b/src/app/loan-origination/application-detail/bank-statement-analysis-dialog/bank-statement-analysis-dialog.component.scss
@@ -0,0 +1,218 @@
+/**
+ * Copyright since 2025 Mifos Initiative
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+.bsa-title {
+ display: flex;
+ align-items: baseline;
+ gap: 10px;
+ flex-wrap: wrap;
+}
+
+.bsa-title-file {
+ font-size: 13px;
+ font-weight: 500;
+ color: #94a3b8;
+}
+
+.bsa-content {
+ min-width: 520px;
+ max-width: 900px;
+}
+
+// -- Risk hero: a CSS conic-gradient gauge ring (no canvas dependency, always pixel-correct) --
+.bsa-hero {
+ display: flex;
+ align-items: center;
+ gap: 24px;
+ padding: 20px 24px;
+ margin-bottom: 18px;
+ border-radius: 16px;
+ background: linear-gradient(135deg, #f8fafc, #eef2ff);
+ border: 1px solid #e2e8f0;
+}
+
+.bsa-gauge {
+ --bsa-gauge-pct: 0;
+ --bsa-gauge-color: #10b981;
+
+ flex-shrink: 0;
+ width: 128px;
+ height: 128px;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: conic-gradient(var(--bsa-gauge-color) calc(var(--bsa-gauge-pct) * 1%), #e2e8f0 0);
+ box-shadow: 0 4px 14px rgb(15 23 42 / 8%);
+}
+
+.bsa-gauge-inner {
+ width: 98px;
+ height: 98px;
+ border-radius: 50%;
+ background: #fff;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ box-shadow: inset 0 0 0 1px #f1f5f9;
+}
+
+.bsa-gauge-score {
+ font-size: 30px;
+ font-weight: 800;
+ color: #0f172a;
+ line-height: 1;
+}
+
+.bsa-gauge-outof {
+ font-size: 11px;
+ font-weight: 600;
+ color: #94a3b8;
+ margin-top: 2px;
+}
+
+.bsa-hero-details {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ min-width: 0;
+}
+
+.bsa-risk-badge {
+ display: inline-flex;
+ align-items: center;
+ align-self: flex-start;
+ padding: 5px 14px;
+ border-radius: 999px;
+ font-size: 12.5px;
+ font-weight: 800;
+ letter-spacing: 0.05em;
+
+ &--low {
+ background: #d1fae5;
+ color: #047857;
+ }
+
+ &--medium {
+ background: #fef3c7;
+ color: #b45309;
+ }
+
+ &--high {
+ background: #fee2e2;
+ color: #b91c1c;
+ }
+}
+
+.bsa-risk-reasons {
+ margin: 0;
+ padding-left: 18px;
+ font-size: 13px;
+ color: #334155;
+ line-height: 1.6;
+}
+
+// -- Bounced/returned transactions -- the strongest single red flag, called out on its own --
+.bsa-bounce-alert {
+ padding: 14px 18px;
+ margin-bottom: 18px;
+ border-radius: 14px;
+ border: 1px solid #fecaca;
+ background: #fef2f2;
+}
+
+.bsa-bounce-alert-title {
+ font-size: 13px;
+ font-weight: 800;
+ color: #b91c1c;
+ text-transform: uppercase;
+ letter-spacing: 0.03em;
+ margin-bottom: 6px;
+}
+
+.bsa-bounce-alert ul {
+ margin: 0;
+ padding-left: 18px;
+ font-size: 13px;
+ color: #7f1d1d;
+ line-height: 1.6;
+}
+
+// -- KPI cards --
+.bsa-kpi-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
+ gap: 12px;
+ margin-bottom: 24px;
+}
+
+.bsa-kpi {
+ display: flex;
+ flex-direction: column;
+ gap: 5px;
+ padding: 12px 14px;
+ border-radius: 12px;
+ background: #fff;
+ border: 1px solid #e2e8f0;
+ border-left: 3px solid #cbd5e1;
+
+ &--income {
+ border-left-color: #10b981;
+ }
+
+ &--expense {
+ border-left-color: #f43f5e;
+ }
+
+ &--neutral {
+ border-left-color: #6366f1;
+ }
+}
+
+.bsa-kpi-label {
+ font-size: 10.5px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: #94a3b8;
+}
+
+.bsa-kpi-value {
+ font-size: 17px;
+ font-weight: 700;
+ color: #0f172a;
+ font-variant-numeric: tabular-nums;
+}
+
+// -- Chart panels --
+.bsa-chart-panel {
+ padding: 18px 20px 8px;
+ margin-bottom: 18px;
+ border-radius: 16px;
+ background: #fff;
+ border: 1px solid #e2e8f0;
+}
+
+.bsa-chart-panel-title {
+ margin: 0 0 14px;
+ font-size: 13px;
+ font-weight: 700;
+ color: #334155;
+}
+
+.bsa-chart-canvas-wrap {
+ position: relative;
+ height: 240px;
+
+ &--category {
+ height: 280px;
+ max-width: 460px;
+ margin: 0 auto;
+ }
+}
diff --git a/src/app/loan-origination/application-detail/bank-statement-analysis-dialog/bank-statement-analysis-dialog.component.ts b/src/app/loan-origination/application-detail/bank-statement-analysis-dialog/bank-statement-analysis-dialog.component.ts
new file mode 100644
index 000000000..9785d3781
--- /dev/null
+++ b/src/app/loan-origination/application-detail/bank-statement-analysis-dialog/bank-statement-analysis-dialog.component.ts
@@ -0,0 +1,185 @@
+/**
+ * Copyright since 2025 Mifos Initiative
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+import { AfterViewInit, Component, OnDestroy, inject } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
+import { MatButtonModule } from '@angular/material/button';
+import { Chart, registerables } from 'chart.js';
+import { BankStatementSummary } from './bank-statement-analysis.util';
+
+Chart.register(...registerables);
+Chart.defaults.font.family = "'Inter', 'Segoe UI', -apple-system, sans-serif";
+Chart.defaults.color = '#64748b';
+Chart.defaults.plugins.legend.labels.usePointStyle = true;
+Chart.defaults.plugins.legend.labels.boxWidth = 8;
+
+export interface BankStatementAnalysisDialogData {
+ fileName: string;
+ summary: BankStatementSummary;
+}
+
+const CATEGORY_COLORS = [
+ '#6366f1',
+ '#10b981',
+ '#f43f5e',
+ '#f59e0b',
+ '#0ea5e9',
+ '#a855f7',
+ '#ec4899',
+ '#14b8a6',
+ '#94a3b8',
+ '#84cc16',
+ '#fb923c'
+];
+
+/**
+ * Shows a bank statement's transaction history rolled up into what a credit officer actually
+ * needs from it -- a plain-language risk read, existing-obligation load (FOIR), bounced-
+ * transaction red flags, monthly income vs expense, the balance trend, and where the money
+ * actually goes -- opened straight after a CSV bank statement is picked in the KYC step, no
+ * separate "analyze" action needed.
+ */
+@Component({
+ selector: 'mifosx-bank-statement-analysis-dialog',
+ standalone: true,
+ imports: [
+ CommonModule,
+ MatDialogModule,
+ MatButtonModule
+ ],
+ templateUrl: './bank-statement-analysis-dialog.component.html',
+ styleUrls: ['./bank-statement-analysis-dialog.component.scss']
+})
+export class BankStatementAnalysisDialogComponent implements AfterViewInit, OnDestroy {
+ data = inject(MAT_DIALOG_DATA);
+
+ private barChart: any = null;
+ private balanceChart: any = null;
+ private categoryChart: any = null;
+
+ get summary(): BankStatementSummary {
+ return this.data.summary;
+ }
+
+ get riskBadgeClass(): string {
+ return `bsa-risk-badge bsa-risk-badge--${this.summary.riskRating.toLowerCase()}`;
+ }
+
+ /** Drives the CSS conic-gradient gauge ring -- a plain custom-property, not a canvas chart, so
+ * it always renders pixel-correct without depending on Chart.js's own gauge quirks. */
+ get gaugeColor(): string {
+ return this.summary.riskRating === 'LOW' ? '#10b981' : this.summary.riskRating === 'MEDIUM' ? '#f59e0b' : '#f43f5e';
+ }
+
+ ngAfterViewInit(): void {
+ const s = this.summary;
+ const labels = s.monthly.map((m) => m.label);
+
+ const barCtx = (document.getElementById('bsaIncomeExpenseChart') as HTMLCanvasElement)?.getContext('2d');
+ const balanceCtx = (document.getElementById('bsaBalanceTrendChart') as HTMLCanvasElement)?.getContext('2d');
+
+ const balanceGradient = balanceCtx?.createLinearGradient(0, 0, 0, 260);
+ balanceGradient?.addColorStop(0, 'rgba(99, 102, 241, 0.35)');
+ balanceGradient?.addColorStop(1, 'rgba(99, 102, 241, 0)');
+
+ this.barChart = new Chart(barCtx ?? 'bsaIncomeExpenseChart', {
+ type: 'bar',
+ data: {
+ labels,
+ datasets: [
+ {
+ label: 'Credits (Income)',
+ data: s.monthly.map((m) => m.credit),
+ backgroundColor: '#10b981',
+ borderRadius: 6,
+ barPercentage: 0.7,
+ categoryPercentage: 0.6
+ },
+ {
+ label: 'Debits (Expense)',
+ data: s.monthly.map((m) => m.debit),
+ backgroundColor: '#f43f5e',
+ borderRadius: 6,
+ barPercentage: 0.7,
+ categoryPercentage: 0.6
+ }
+ ]
+ },
+ options: {
+ responsive: true,
+ maintainAspectRatio: false,
+ plugins: { legend: { position: 'top', align: 'end' } },
+ scales: {
+ x: { grid: { display: false } },
+ y: { beginAtZero: true, grid: { color: '#f1f5f9' } }
+ }
+ }
+ });
+
+ this.balanceChart = new Chart(balanceCtx ?? 'bsaBalanceTrendChart', {
+ type: 'line',
+ data: {
+ labels,
+ datasets: [
+ {
+ label: 'End-of-Month Balance',
+ data: s.monthly.map((m) => m.closingBalance),
+ borderColor: '#6366f1',
+ backgroundColor: balanceGradient ?? 'rgba(99, 102, 241, 0.15)',
+ pointBackgroundColor: '#6366f1',
+ pointRadius: 3,
+ pointHoverRadius: 6,
+ borderWidth: 2.5,
+ fill: true,
+ tension: 0.35
+ }
+ ]
+ },
+ options: {
+ responsive: true,
+ maintainAspectRatio: false,
+ plugins: { legend: { display: false } },
+ scales: {
+ x: { grid: { display: false } },
+ y: { beginAtZero: false, grid: { color: '#f1f5f9' } }
+ }
+ }
+ });
+
+ if (s.categoryBreakdown.length) {
+ this.categoryChart = new Chart('bsaCategoryChart', {
+ type: 'doughnut',
+ data: {
+ labels: s.categoryBreakdown.map((c) => c.category),
+ datasets: [
+ {
+ data: s.categoryBreakdown.map((c) => c.amount),
+ backgroundColor: s.categoryBreakdown.map((_, i) => CATEGORY_COLORS[i % CATEGORY_COLORS.length]),
+ borderWidth: 2,
+ borderColor: '#fff',
+ hoverOffset: 8
+ }
+ ]
+ },
+ options: {
+ responsive: true,
+ maintainAspectRatio: false,
+ cutout: '68%',
+ plugins: { legend: { position: 'right', labels: { padding: 14 } } }
+ }
+ });
+ }
+ }
+
+ ngOnDestroy(): void {
+ this.barChart?.destroy();
+ this.balanceChart?.destroy();
+ this.categoryChart?.destroy();
+ }
+}
diff --git a/src/app/loan-origination/application-detail/bank-statement-analysis-dialog/bank-statement-analysis.util.ts b/src/app/loan-origination/application-detail/bank-statement-analysis-dialog/bank-statement-analysis.util.ts
new file mode 100644
index 000000000..7e8f715a1
--- /dev/null
+++ b/src/app/loan-origination/application-detail/bank-statement-analysis-dialog/bank-statement-analysis.util.ts
@@ -0,0 +1,427 @@
+/**
+ * Copyright since 2025 Mifos Initiative
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+export interface BankTransaction {
+ date: Date;
+ narration: string;
+ debit: number;
+ credit: number;
+ balance: number;
+}
+
+export interface MonthlyBankSummary {
+ label: string;
+ credit: number;
+ debit: number;
+ closingBalance: number;
+ netCashFlow: number;
+}
+
+export interface BouncedTransaction {
+ date: Date;
+ narration: string;
+ amount: number;
+}
+
+export interface CategorySpend {
+ category: string;
+ amount: number;
+}
+
+export type RiskRating = 'LOW' | 'MEDIUM' | 'HIGH';
+
+export interface BankStatementSummary {
+ openingBalance: number;
+ closingBalance: number;
+ minBalance: number;
+ maxBalance: number;
+ avgBalance: number;
+ totalCredit: number;
+ totalDebit: number;
+ avgMonthlyCredit: number;
+ avgMonthlyDebit: number;
+ transactionCount: number;
+ monthsCovered: number;
+ salaryCreditsDetected: number;
+ monthly: MonthlyBankSummary[];
+
+ // -- Underwriting-focused signals a loan officer actually needs, not just cash-flow totals --
+ /** Bounced/returned debits (ECS return, cheque return, insufficient funds) -- the single
+ * strongest red flag a bank statement carries; a clean cash-flow summary can still hide these
+ * if nobody goes looking for the narration text specifically. */
+ bouncedTransactions: BouncedTransaction[];
+ /** Recurring EMI/loan-type debits already going out every month, annualised to a monthly
+ * average -- the "existing obligations" side of a FOIR (Fixed Obligation to Income Ratio)
+ * calculation. */
+ existingMonthlyObligations: number;
+ /** existingMonthlyObligations as a % of avgMonthlyCredit -- how much of this customer's income
+ * is already committed before a new EMI is even added. */
+ foirPercent: number;
+ /** Coefficient-of-variation based read on whether monthly income credits land at a
+ * similar amount each month (salaried-style) or swing widely (irregular/gig income). */
+ incomeStability: 'STABLE' | 'IRREGULAR';
+ /** How many of the covered months closed with more money in than out. */
+ positiveCashFlowMonths: number;
+ negativeCashFlowMonths: number;
+ /** Where the money actually goes -- narration-keyword-classified debit totals, for the
+ * expense-breakdown chart. */
+ categoryBreakdown: CategorySpend[];
+ /** A transparent, rule-based read (not a black-box score) an officer can act on immediately,
+ * plus the specific reasons behind it so it can be checked against the actual statement. */
+ riskRating: RiskRating;
+ riskScore: number;
+ riskReasons: string[];
+}
+
+/** Splits one CSV line into fields, honouring double-quoted values that may contain commas --
+ * a plain `.split(',')` would break on a quoted narration like "UPI-RENT, LANDLORD A/C". */
+function splitCsvLine(line: string): string[] {
+ const fields: string[] = [];
+ let current = '';
+ let inQuotes = false;
+ for (const ch of line) {
+ if (ch === '"') {
+ inQuotes = !inQuotes;
+ } else if (ch === ',' && !inQuotes) {
+ fields.push(current);
+ current = '';
+ } else {
+ current += ch;
+ }
+ }
+ fields.push(current);
+ return fields;
+}
+
+function parseDDMMYYYY(value: string | undefined): Date | null {
+ const match = (value || '').trim().match(/^(\d{1,2})-(\d{1,2})-(\d{4})$/);
+ if (!match) {
+ return null;
+ }
+ const [
+ ,
+ day,
+ month,
+ year
+ ] = match;
+ return new Date(+year, +month - 1, +day);
+}
+
+/** Parses a bank-statement CSV export (Date, Narration, Debit, Credit, Balance columns, in any
+ * order, matched case-insensitively) into transaction rows. Recognises a couple of common
+ * header aliases (e.g. "Withdrawal"/"Deposit") since not every bank's export uses "Debit"/
+ * "Credit" literally, but otherwise expects the same shape every Indian bank statement uses. */
+export function parseBankStatementCsv(text: string): BankTransaction[] {
+ const lines = text.split(/\r?\n/).filter((line) => line.trim().length > 0);
+ if (lines.length < 2) {
+ return [];
+ }
+ const header = splitCsvLine(lines[0]).map((h) => h.trim().toLowerCase());
+ const columnIndex = (aliases: string[]): number => {
+ for (const alias of aliases) {
+ const idx = header.indexOf(alias);
+ if (idx >= 0) {
+ return idx;
+ }
+ }
+ return -1;
+ };
+ const dateIdx = columnIndex([
+ 'date',
+ 'txn date',
+ 'transaction date'
+ ]);
+ const narrationIdx = columnIndex([
+ 'narration',
+ 'description',
+ 'particulars'
+ ]);
+ const debitIdx = columnIndex([
+ 'debit',
+ 'withdrawal amt.',
+ 'withdrawal'
+ ]);
+ const creditIdx = columnIndex([
+ 'credit',
+ 'deposit amt.',
+ 'deposit'
+ ]);
+ const balanceIdx = columnIndex([
+ 'balance',
+ 'closing balance'
+ ]);
+ if (dateIdx < 0 || balanceIdx < 0) {
+ return [];
+ }
+
+ const transactions: BankTransaction[] = [];
+ for (let i = 1; i < lines.length; i++) {
+ const cols = splitCsvLine(lines[i]);
+ const date = parseDDMMYYYY(cols[dateIdx]);
+ if (!date) {
+ continue;
+ }
+ transactions.push({
+ date,
+ narration: narrationIdx >= 0 ? (cols[narrationIdx] || '').trim() : '',
+ debit: parseFloat(cols[debitIdx]) || 0,
+ credit: parseFloat(cols[creditIdx]) || 0,
+ balance: parseFloat(cols[balanceIdx]) || 0
+ });
+ }
+ return transactions;
+}
+
+const BOUNCE_KEYWORDS = [
+ 'RETURN',
+ 'BOUNCE',
+ 'DISHONOUR',
+ 'DISHONOR',
+ 'INSUFFICIENT FUND',
+ 'INSUFFICIENT BALANCE'
+];
+
+function isBouncedTransaction(narration: string): boolean {
+ const n = narration.toUpperCase();
+ return BOUNCE_KEYWORDS.some((k) => n.includes(k));
+}
+
+function isExistingObligationDebit(narration: string): boolean {
+ const n = narration.toUpperCase();
+ return (n.includes('EMI') || n.includes('LOAN')) && !isBouncedTransaction(n);
+}
+
+/** Keyword categories checked in this order -- first match wins, so a more specific label (e.g.
+ * Insurance) is claimed before a generic one (e.g. Bank Charges) can grab it. */
+const CATEGORY_RULES: Array<{ category: string; keywords: string[] }> = [
+ { category: 'Bounced / Returned', keywords: BOUNCE_KEYWORDS },
+ { category: 'Insurance', keywords: [
+ 'LIC',
+ 'INSURANCE',
+ 'PREMIUM'
+ ] },
+ { category: 'EMI / Loan Repayment', keywords: [
+ 'EMI',
+ 'LOAN'
+ ] },
+ { category: 'Rent', keywords: ['RENT'] },
+ { category: 'Utilities & Bills', keywords: [
+ 'ELECTRICITY',
+ 'RECHARGE',
+ 'BBPS',
+ 'BILL',
+ 'JIO'
+ ] },
+ { category: 'Groceries', keywords: [
+ 'BIGBASKET',
+ 'DMART',
+ 'RELIANCE FRESH',
+ 'KIRANA',
+ 'SUPERMARKET',
+ 'GROCERY'
+ ] },
+ { category: 'Dining & Food Delivery', keywords: [
+ 'SWIGGY',
+ 'ZOMATO',
+ 'PIZZA',
+ 'CAFE',
+ 'RESTAURANT'
+ ] },
+ { category: 'Shopping', keywords: [
+ 'AMAZON',
+ 'FLIPKART',
+ 'MYNTRA',
+ 'AJIO',
+ 'GARMENTS',
+ 'SHOP'
+ ] },
+ { category: 'Fuel & Transport', keywords: [
+ 'PETROL',
+ 'FUEL',
+ 'AUTO FARE'
+ ] },
+ { category: 'Cash Withdrawal', keywords: ['ATM'] },
+ { category: 'Bank Charges', keywords: [
+ 'CHARGE',
+ 'FEE',
+ 'PENALTY'
+ ] }
+];
+
+function categorize(narration: string): string {
+ const n = narration.toUpperCase();
+ for (const rule of CATEGORY_RULES) {
+ if (rule.keywords.some((k) => n.includes(k))) {
+ return rule.category;
+ }
+ }
+ return 'Other';
+}
+
+/** Coefficient of variation on the months that actually saw a credit -- a salaried applicant's
+ * income lands at a similar figure every month (low variation); gig/business income swings
+ * much more (high variation), which is exactly the distinction a lender needs to know before
+ * trusting a monthly average. */
+function assessIncomeStability(monthly: MonthlyBankSummary[]): 'STABLE' | 'IRREGULAR' {
+ const credits = monthly.map((m) => m.credit).filter((c) => c > 0);
+ if (credits.length < 2) {
+ return 'STABLE';
+ }
+ const mean = credits.reduce((sum, c) => sum + c, 0) / credits.length;
+ const variance = credits.reduce((sum, c) => sum + (c - mean) ** 2, 0) / credits.length;
+ const coefficientOfVariation = mean > 0 ? Math.sqrt(variance) / mean : 0;
+ return coefficientOfVariation < 0.25 ? 'STABLE' : 'IRREGULAR';
+}
+
+/** A transparent, rule-based risk read instead of an opaque score -- every point lost is tied to
+ * a specific, checkable reason, which is what a real credit decision needs to be defensible. */
+function assessRisk(input: {
+ bounceCount: number;
+ foirPercent: number;
+ minBalance: number;
+ avgMonthlyCredit: number;
+ incomeStability: 'STABLE' | 'IRREGULAR';
+}): { rating: RiskRating; score: number; reasons: string[] } {
+ let score = 100;
+ const reasons: string[] = [];
+
+ if (input.bounceCount > 0) {
+ score -= Math.min(40, input.bounceCount * 20);
+ reasons.push(`${input.bounceCount} bounced/returned transaction(s) found`);
+ }
+ if (input.foirPercent > 50) {
+ score -= 25;
+ reasons.push(`Existing obligations are ${input.foirPercent.toFixed(0)}% of average income`);
+ } else if (input.foirPercent > 35) {
+ score -= 10;
+ reasons.push(`Existing obligations run moderate at ${input.foirPercent.toFixed(0)}% of average income`);
+ }
+ if (input.minBalance < 0) {
+ score -= 20;
+ reasons.push('Account balance went negative at least once');
+ } else if (input.avgMonthlyCredit > 0 && input.minBalance < input.avgMonthlyCredit * 0.1) {
+ score -= 10;
+ reasons.push('Minimum balance runs very thin relative to income');
+ }
+ if (input.incomeStability === 'IRREGULAR') {
+ score -= 15;
+ reasons.push('Monthly income credits are irregular, not a steady salaried pattern');
+ }
+ score = Math.max(0, Math.min(100, score));
+ if (reasons.length === 0) {
+ reasons.push('No red flags found in this statement');
+ }
+ const rating: RiskRating = score >= 75 ? 'LOW' : score >= 50 ? 'MEDIUM' : 'HIGH';
+ return { rating, score, reasons };
+}
+
+/** Rolls a year of transactions up into the numbers a credit officer actually reads a bank
+ * statement for: average monthly inflow/outflow, how tight the balance runs, how many salary-
+ * like credits show up, whether anything ever bounced, how much of the income is already
+ * committed to existing EMIs, and a plain-language risk read tying all of that together. */
+export function summarizeBankStatement(transactions: BankTransaction[]): BankStatementSummary {
+ const sorted = [...transactions].sort((a, b) => a.date.getTime() - b.date.getTime());
+ const monthlyMap = new Map();
+ const bouncedTransactions: BouncedTransaction[] = [];
+ const categoryTotals = new Map();
+ let totalCredit = 0;
+ let totalDebit = 0;
+ let salaryCreditsDetected = 0;
+ let existingMonthlyObligations = 0;
+ let minBalance = Infinity;
+ let maxBalance = -Infinity;
+ let balanceSum = 0;
+
+ for (const t of sorted) {
+ const key = `${t.date.getFullYear()}-${t.date.getMonth()}`;
+ if (!monthlyMap.has(key)) {
+ monthlyMap.set(key, {
+ label: t.date.toLocaleDateString('en-IN', { month: 'short', year: 'numeric' }),
+ credit: 0,
+ debit: 0,
+ closingBalance: t.balance,
+ netCashFlow: 0
+ });
+ }
+ const month = monthlyMap.get(key)!;
+ month.credit += t.credit;
+ month.debit += t.debit;
+ month.closingBalance = t.balance;
+ month.netCashFlow = month.credit - month.debit;
+
+ totalCredit += t.credit;
+ totalDebit += t.debit;
+ minBalance = Math.min(minBalance, t.balance);
+ maxBalance = Math.max(maxBalance, t.balance);
+ balanceSum += t.balance;
+
+ if (t.credit >= 20000 && /salary/i.test(t.narration)) {
+ salaryCreditsDetected++;
+ }
+ if (t.debit > 0) {
+ if (isBouncedTransaction(t.narration)) {
+ bouncedTransactions.push({ date: t.date, narration: t.narration, amount: t.debit });
+ } else if (isExistingObligationDebit(t.narration)) {
+ existingMonthlyObligations += t.debit;
+ }
+ const category = categorize(t.narration);
+ categoryTotals.set(category, (categoryTotals.get(category) ?? 0) + t.debit);
+ }
+ }
+
+ const monthly = [...monthlyMap.values()];
+ const monthsCovered = monthly.length || 1;
+ const first = sorted[0];
+ const avgMonthlyCredit = totalCredit / monthsCovered;
+ const avgMonthlyObligations = existingMonthlyObligations / monthsCovered;
+ const foirPercent = avgMonthlyCredit > 0 ? (avgMonthlyObligations / avgMonthlyCredit) * 100 : 0;
+ const incomeStability = assessIncomeStability(monthly);
+ const positiveCashFlowMonths = monthly.filter((m) => m.netCashFlow >= 0).length;
+ const negativeCashFlowMonths = monthly.length - positiveCashFlowMonths;
+ const categoryBreakdown: CategorySpend[] = [...categoryTotals.entries()]
+ .map(
+ ([
+ category,
+ amount
+ ]) => ({ category, amount })
+ )
+ .sort((a, b) => b.amount - a.amount);
+ const risk = assessRisk({
+ bounceCount: bouncedTransactions.length,
+ foirPercent,
+ minBalance: sorted.length ? minBalance : 0,
+ avgMonthlyCredit,
+ incomeStability
+ });
+
+ return {
+ openingBalance: first ? first.balance - first.credit + first.debit : 0,
+ closingBalance: sorted.length ? sorted[sorted.length - 1].balance : 0,
+ minBalance: sorted.length ? minBalance : 0,
+ maxBalance: sorted.length ? maxBalance : 0,
+ avgBalance: sorted.length ? balanceSum / sorted.length : 0,
+ totalCredit,
+ totalDebit,
+ avgMonthlyCredit,
+ avgMonthlyDebit: totalDebit / monthsCovered,
+ transactionCount: sorted.length,
+ monthsCovered,
+ salaryCreditsDetected,
+ monthly,
+ bouncedTransactions,
+ existingMonthlyObligations: avgMonthlyObligations,
+ foirPercent,
+ incomeStability,
+ positiveCashFlowMonths,
+ negativeCashFlowMonths,
+ categoryBreakdown,
+ riskRating: risk.rating,
+ riskScore: risk.score,
+ riskReasons: risk.reasons
+ };
+}
diff --git a/src/app/loan-origination/application-detail/cic-report-analysis-dialog/cic-report-analysis-dialog.component.html b/src/app/loan-origination/application-detail/cic-report-analysis-dialog/cic-report-analysis-dialog.component.html
new file mode 100644
index 000000000..1749ef389
--- /dev/null
+++ b/src/app/loan-origination/application-detail/cic-report-analysis-dialog/cic-report-analysis-dialog.component.html
@@ -0,0 +1,167 @@
+
+
+
+ {{ analysis.bureau }} Credit Report Analysis
+ {{ data.fileName }}
+
+
+
+
+
+ {{ analysis.score }}
+ / 900
+
+
+
+
{{ analysis.band }}
+
{{ analysis.recommendation }}
+
+ @for (factor of analysis.scoreFactors; track factor) {
+ {{ factor }}
+ }
+
+
+
+
+ @if (analysis.totalOverdueAmt > 0 || analysis.writtenOffAccounts > 0) {
+
+
+ @if (analysis.writtenOffAccounts > 0) {
+ {{ analysis.writtenOffAccounts }} Written-Off Account(s) Found
+ } @else {
+ ₹{{ analysis.totalOverdueAmt | number: '1.0-0' }} Currently Overdue
+ }
+
+
{{ analysis.recommendation }}
+
+ }
+
+
+
+ Total Accounts
+ {{ analysis.totalAccounts }}
+
+
+ Active Accounts
+ {{ analysis.activeAccounts }}
+
+
+ Closed Accounts
+ {{ analysis.closedAccounts }}
+
+
+ Written-Off Accounts
+ {{ analysis.writtenOffAccounts }}
+
+
+ Total Current Balance
+ ₹{{ analysis.totalCurrentBalance | number: '1.0-0' }}
+
+
+ Secured Accounts Amt
+ ₹{{ analysis.securedAmt | number: '1.0-0' }}
+
+
+ Unsecured Accounts Amt
+ ₹{{ analysis.unsecuredAmt | number: '1.0-0' }}
+
+
+ Total Overdue
+ ₹{{ analysis.totalOverdueAmt | number: '1.0-0' }}
+
+
+ Total Late Payments
+ {{ analysis.totalLatePayments }}
+
+
+ Enquiries (Last 90 Days)
+ {{ analysis.enquiriesLast90Days }}
+
+
+
+ @if (analysis.accounts.length > 0) {
+
+
Payment Behaviour by Account (last 12 months)
+
+
+
+
+
+
+
Credit Account Information
+
+
+ }
+
+ @if (analysis.enquiries.length > 0) {
+
+ } @else {
+ No credit enquiries found in the last 24 months.
+ }
+
+
+ Close
+
diff --git a/src/app/loan-origination/application-detail/cic-report-analysis-dialog/cic-report-analysis-dialog.component.scss b/src/app/loan-origination/application-detail/cic-report-analysis-dialog/cic-report-analysis-dialog.component.scss
new file mode 100644
index 000000000..9b1cf6093
--- /dev/null
+++ b/src/app/loan-origination/application-detail/cic-report-analysis-dialog/cic-report-analysis-dialog.component.scss
@@ -0,0 +1,288 @@
+/**
+ * Copyright since 2025 Mifos Initiative
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+.cra-title {
+ display: flex;
+ align-items: baseline;
+ gap: 10px;
+ flex-wrap: wrap;
+}
+
+.cra-title-file {
+ font-size: 13px;
+ font-weight: 500;
+ color: #94a3b8;
+}
+
+.cra-content {
+ min-width: 560px;
+ max-width: 960px;
+}
+
+// -- Score hero: a CSS conic-gradient gauge ring on the bureau's real 300-900 scale --
+.cra-hero {
+ display: flex;
+ align-items: center;
+ gap: 24px;
+ padding: 20px 24px;
+ margin-bottom: 18px;
+ border-radius: 16px;
+ background: linear-gradient(135deg, #f8fafc, #eef2ff);
+ border: 1px solid #e2e8f0;
+}
+
+.cra-gauge {
+ --cra-gauge-pct: 0;
+ --cra-gauge-color: #10b981;
+
+ flex-shrink: 0;
+ width: 128px;
+ height: 128px;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: conic-gradient(var(--cra-gauge-color) calc(var(--cra-gauge-pct) * 1%), #e2e8f0 0);
+ box-shadow: 0 4px 14px rgb(15 23 42 / 8%);
+}
+
+.cra-gauge-inner {
+ width: 98px;
+ height: 98px;
+ border-radius: 50%;
+ background: #fff;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ box-shadow: inset 0 0 0 1px #f1f5f9;
+}
+
+.cra-gauge-score {
+ font-size: 28px;
+ font-weight: 800;
+ color: #0f172a;
+ line-height: 1;
+}
+
+.cra-gauge-outof {
+ font-size: 11px;
+ font-weight: 600;
+ color: #94a3b8;
+ margin-top: 2px;
+}
+
+.cra-hero-details {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ min-width: 0;
+}
+
+.cra-band-badge {
+ display: inline-flex;
+ align-items: center;
+ align-self: flex-start;
+ padding: 5px 14px;
+ border-radius: 999px;
+ font-size: 12.5px;
+ font-weight: 800;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+
+ &--excellent {
+ background: #d1fae5;
+ color: #047857;
+ }
+
+ &--good {
+ background: #e0f2fe;
+ color: #0369a1;
+ }
+
+ &--fair {
+ background: #fef3c7;
+ color: #b45309;
+ }
+
+ &--poor {
+ background: #fee2e2;
+ color: #b91c1c;
+ }
+}
+
+.cra-recommendation {
+ margin: 0;
+ font-size: 13px;
+ color: #334155;
+ line-height: 1.5;
+}
+
+.cra-score-factors {
+ margin: 0;
+ padding-left: 18px;
+ font-size: 12px;
+ color: #64748b;
+ line-height: 1.6;
+}
+
+// -- Overdue / written-off alert -- the strongest single red flag, called out on its own --
+.cra-alert {
+ padding: 14px 18px;
+ margin-bottom: 18px;
+ border-radius: 14px;
+ border: 1px solid #fecaca;
+ background: #fef2f2;
+}
+
+.cra-alert-title {
+ font-size: 13px;
+ font-weight: 800;
+ color: #b91c1c;
+ text-transform: uppercase;
+ letter-spacing: 0.03em;
+ margin-bottom: 6px;
+}
+
+.cra-alert p {
+ margin: 0;
+ font-size: 13px;
+ color: #7f1d1d;
+ line-height: 1.5;
+}
+
+// -- KPI cards --
+.cra-kpi-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
+ gap: 12px;
+ margin-bottom: 24px;
+}
+
+.cra-kpi {
+ display: flex;
+ flex-direction: column;
+ gap: 5px;
+ padding: 12px 14px;
+ border-radius: 12px;
+ background: #fff;
+ border: 1px solid #e2e8f0;
+ border-left: 3px solid #cbd5e1;
+
+ &--income {
+ border-left-color: #10b981;
+ }
+
+ &--expense {
+ border-left-color: #f43f5e;
+ }
+
+ &--neutral {
+ border-left-color: #6366f1;
+ }
+}
+
+.cra-kpi-label {
+ font-size: 10.5px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: #94a3b8;
+}
+
+.cra-kpi-value {
+ font-size: 17px;
+ font-weight: 700;
+ color: #0f172a;
+ font-variant-numeric: tabular-nums;
+}
+
+// -- Chart / table panels --
+.cra-chart-panel,
+.cra-table-panel {
+ padding: 18px 20px;
+ margin-bottom: 18px;
+ border-radius: 16px;
+ background: #fff;
+ border: 1px solid #e2e8f0;
+}
+
+.cra-chart-panel-title {
+ margin: 0 0 14px;
+ font-size: 13px;
+ font-weight: 700;
+ color: #334155;
+}
+
+.cra-chart-canvas-wrap {
+ position: relative;
+ height: 240px;
+}
+
+.cra-table-scroll {
+ overflow-x: auto;
+}
+
+.cra-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 12.5px;
+
+ th {
+ text-align: left;
+ padding: 8px 10px;
+ font-size: 10.5px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ color: #94a3b8;
+ border-bottom: 1px solid #e2e8f0;
+ white-space: nowrap;
+ }
+
+ td {
+ padding: 9px 10px;
+ color: #334155;
+ border-bottom: 1px solid #f1f5f9;
+ white-space: nowrap;
+ }
+
+ tr:last-child td {
+ border-bottom: none;
+ }
+}
+
+.cra-status-badge {
+ display: inline-flex;
+ align-items: center;
+ padding: 3px 10px;
+ border-radius: 999px;
+ font-size: 11px;
+ font-weight: 700;
+
+ &--active {
+ background: #d1fae5;
+ color: #047857;
+ }
+
+ &--closed {
+ background: #e2e8f0;
+ color: #475569;
+ }
+
+ &--written-off {
+ background: #fee2e2;
+ color: #b91c1c;
+ }
+}
+
+.cra-no-enquiries {
+ font-size: 13px;
+ color: #64748b;
+ padding: 0 4px;
+}
diff --git a/src/app/loan-origination/application-detail/cic-report-analysis-dialog/cic-report-analysis-dialog.component.ts b/src/app/loan-origination/application-detail/cic-report-analysis-dialog/cic-report-analysis-dialog.component.ts
new file mode 100644
index 000000000..ace652324
--- /dev/null
+++ b/src/app/loan-origination/application-detail/cic-report-analysis-dialog/cic-report-analysis-dialog.component.ts
@@ -0,0 +1,137 @@
+/**
+ * Copyright since 2025 Mifos Initiative
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+import { AfterViewInit, Component, OnDestroy, inject } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
+import { MatButtonModule } from '@angular/material/button';
+import { Chart, registerables } from 'chart.js';
+import { CicReportAnalysis } from './cic-report-analysis.util';
+
+Chart.register(...registerables);
+Chart.defaults.font.family = "'Inter', 'Segoe UI', -apple-system, sans-serif";
+Chart.defaults.color = '#64748b';
+Chart.defaults.plugins.legend.labels.usePointStyle = true;
+Chart.defaults.plugins.legend.labels.boxWidth = 8;
+
+export interface CicReportAnalysisDialogData {
+ fileName: string;
+ analysis: CicReportAnalysis;
+}
+
+const BAND_COLORS: Record = {
+ Excellent: '#10b981',
+ Good: '#0ea5e9',
+ Fair: '#f59e0b',
+ Poor: '#f43f5e'
+};
+
+/**
+ * Shows a full bureau-style credit report analysis -- score gauge, score factors, account
+ * summary, per-account payment behaviour and enquiry history -- straight after a CIC/Credit
+ * Bureau report file is uploaded, the same "auto-opens on upload" pattern as the Bank Statement
+ * Analysis dialog. Everything shown is generated from this application's own cibilScore, not
+ * read from the actual uploaded file, since there's no real bureau API or PDF parser behind this
+ * mock pipeline -- see cic-report-analysis.util.ts for why it's still fully self-consistent.
+ */
+@Component({
+ selector: 'mifosx-cic-report-analysis-dialog',
+ standalone: true,
+ imports: [
+ CommonModule,
+ MatDialogModule,
+ MatButtonModule
+ ],
+ templateUrl: './cic-report-analysis-dialog.component.html',
+ styleUrls: ['./cic-report-analysis-dialog.component.scss']
+})
+export class CicReportAnalysisDialogComponent implements AfterViewInit, OnDestroy {
+ data = inject(MAT_DIALOG_DATA);
+
+ private dpdChart: any = null;
+
+ get analysis(): CicReportAnalysis {
+ return this.data.analysis;
+ }
+
+ get bandBadgeClass(): string {
+ return `cra-band-badge cra-band-badge--${this.analysis.band.toLowerCase()}`;
+ }
+
+ /** Drives the CSS conic-gradient gauge ring -- same pixel-correct approach used for the Bank
+ * Statement risk gauge, scaled to the bureau's real 300-900 score range. */
+ get gaugeColor(): string {
+ return BAND_COLORS[this.analysis.band];
+ }
+
+ get gaugePct(): number {
+ return Math.max(0, Math.min(100, ((this.analysis.score - 300) / 600) * 100));
+ }
+
+ accountStatusClass(status: string): string {
+ if (status === 'WRITTEN-OFF') {
+ return 'cra-status-badge cra-status-badge--written-off';
+ }
+ if (status === 'CLOSED') {
+ return 'cra-status-badge cra-status-badge--closed';
+ }
+ return 'cra-status-badge cra-status-badge--active';
+ }
+
+ worstDpdLabel(worstDpd: number): string {
+ return worstDpd === 0 ? 'Always on time' : `${worstDpd} days past due (worst)`;
+ }
+
+ ngAfterViewInit(): void {
+ const a = this.analysis;
+ const labels = a.accounts.map((acc) => `${acc.lender} — ${acc.accountType}`);
+ const onTimeCounts = a.accounts.map((acc) => acc.dpdHistory.filter((p) => p.dpd === 0).length);
+ const lateCounts = a.accounts.map((acc) => acc.dpdHistory.filter((p) => p.dpd > 0).length);
+
+ this.dpdChart = new Chart('craDpdChart', {
+ type: 'bar',
+ data: {
+ labels,
+ datasets: [
+ {
+ label: 'On-Time Months',
+ data: onTimeCounts,
+ backgroundColor: '#10b981',
+ borderRadius: 6,
+ barPercentage: 0.6,
+ categoryPercentage: 0.6,
+ stack: 'dpd'
+ },
+ {
+ label: 'Late Months',
+ data: lateCounts,
+ backgroundColor: '#f43f5e',
+ borderRadius: 6,
+ barPercentage: 0.6,
+ categoryPercentage: 0.6,
+ stack: 'dpd'
+ }
+ ]
+ },
+ options: {
+ indexAxis: 'y',
+ responsive: true,
+ maintainAspectRatio: false,
+ plugins: { legend: { position: 'top', align: 'end' } },
+ scales: {
+ x: { beginAtZero: true, grid: { color: '#f1f5f9' }, ticks: { stepSize: 1 } },
+ y: { grid: { display: false } }
+ }
+ }
+ });
+ }
+
+ ngOnDestroy(): void {
+ this.dpdChart?.destroy();
+ }
+}
diff --git a/src/app/loan-origination/application-detail/cic-report-analysis-dialog/cic-report-analysis.util.ts b/src/app/loan-origination/application-detail/cic-report-analysis-dialog/cic-report-analysis.util.ts
new file mode 100644
index 000000000..959b461c5
--- /dev/null
+++ b/src/app/loan-origination/application-detail/cic-report-analysis-dialog/cic-report-analysis.util.ts
@@ -0,0 +1,349 @@
+/**
+ * Copyright since 2025 Mifos Initiative
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+export interface CicDpdPoint {
+ label: string;
+ dpd: number;
+}
+
+export interface CicCreditAccount {
+ lender: string;
+ accountType: string;
+ accountNo: string;
+ ownership: 'Individual' | 'Joint';
+ secured: boolean;
+ dateOpened: string;
+ status: 'ACTIVE' | 'CLOSED' | 'WRITTEN-OFF';
+ sanctionedAmt: number;
+ currentBalance: number;
+ overdueAmt: number;
+ tenureMonths: number;
+ rateOfInterest: number;
+ dpdHistory: CicDpdPoint[];
+ worstDpd: number;
+}
+
+export interface CicEnquiry {
+ institution: string;
+ date: string;
+ purpose: string;
+ amountApplied: number;
+}
+
+export type CicRiskBand = 'Excellent' | 'Good' | 'Fair' | 'Poor';
+
+export interface CicReportAnalysis {
+ bureau: string;
+ reportDate: string;
+ applicantName: string;
+ score: number;
+ band: CicRiskBand;
+ scoreFactors: string[];
+ totalAccounts: number;
+ activeAccounts: number;
+ closedAccounts: number;
+ writtenOffAccounts: number;
+ totalCurrentBalance: number;
+ securedAmt: number;
+ unsecuredAmt: number;
+ totalOverdueAmt: number;
+ accounts: CicCreditAccount[];
+ enquiries: CicEnquiry[];
+ enquiriesLast90Days: number;
+ totalLatePayments: number;
+ recommendation: string;
+}
+
+/** A tiny string-seeded PRNG (not cryptographic, just deterministic) -- the same application
+ * should show the exact same "pulled" credit report every time an officer opens or re-checks it
+ * in this session, the same way a real bureau pull doesn't change on a second look. */
+function seededRng(seed: string): () => number {
+ let h = 0;
+ for (let i = 0; i < seed.length; i++) {
+ h = (Math.imul(31, h) + seed.charCodeAt(i)) | 0;
+ }
+ let state = h || 1;
+ return () => {
+ state = (Math.imul(1103515245, state) + 12345) & 0x7fffffff;
+ return state / 0x7fffffff;
+ };
+}
+
+const LENDERS = [
+ 'HDFC Bank',
+ 'ICICI Bank',
+ 'Axis Bank',
+ 'State Bank of India',
+ 'Bajaj Finserv',
+ 'Hero FinCorp Ltd',
+ 'Equitas Finance Pvt Ltd',
+ 'IDFC First Bank',
+ 'Muthoot Finance',
+ 'Tata Capital',
+ 'Cholamandalam Finance',
+ 'L&T Finance'
+];
+
+interface AccountTypeSpec {
+ type: string;
+ secured: boolean;
+ minAmt: number;
+ maxAmt: number;
+ minTenure: number;
+ maxTenure: number;
+ minRate: number;
+ maxRate: number;
+}
+
+const ACCOUNT_TYPES: AccountTypeSpec[] = [
+ {
+ type: 'TWO WHEELER LOAN',
+ secured: true,
+ minAmt: 30000,
+ maxAmt: 150000,
+ minTenure: 12,
+ maxTenure: 48,
+ minRate: 11,
+ maxRate: 19
+ },
+ {
+ type: 'PERSONAL LOAN',
+ secured: false,
+ minAmt: 50000,
+ maxAmt: 500000,
+ minTenure: 12,
+ maxTenure: 60,
+ minRate: 13,
+ maxRate: 22
+ },
+ {
+ type: 'BL GENERAL',
+ secured: false,
+ minAmt: 100000,
+ maxAmt: 900000,
+ minTenure: 24,
+ maxTenure: 120,
+ minRate: 15,
+ maxRate: 21
+ },
+ {
+ type: 'GOLD LOAN',
+ secured: true,
+ minAmt: 20000,
+ maxAmt: 300000,
+ minTenure: 6,
+ maxTenure: 24,
+ minRate: 9,
+ maxRate: 15
+ },
+ {
+ type: 'CONSUMER DURABLE LOAN',
+ secured: false,
+ minAmt: 5000,
+ maxAmt: 100000,
+ minTenure: 6,
+ maxTenure: 24,
+ minRate: 14,
+ maxRate: 24
+ },
+ {
+ type: 'CREDIT CARD',
+ secured: false,
+ minAmt: 20000,
+ maxAmt: 300000,
+ minTenure: 0,
+ maxTenure: 0,
+ minRate: 36,
+ maxRate: 42
+ },
+ {
+ type: 'HOUSING LOAN',
+ secured: true,
+ minAmt: 1000000,
+ maxAmt: 4000000,
+ minTenure: 120,
+ maxTenure: 240,
+ minRate: 8.5,
+ maxRate: 10.5
+ }
+];
+
+/** RBI/credit-bureau-standard CIBIL score bands -- kept in sync with loan-origination-data.ts's
+ * own cibilBand() rather than importing it, so this analysis util has no dependency on the
+ * pipeline's mock-data module and can be unit-tested/reused on its own. */
+function scoreBand(score: number): CicRiskBand {
+ if (score >= 750) {
+ return 'Excellent';
+ }
+ if (score >= 650) {
+ return 'Good';
+ }
+ if (score >= 550) {
+ return 'Fair';
+ }
+ return 'Poor';
+}
+
+const SCORE_FACTORS = [
+ 'Recency: Recent Credit Account Defaults',
+ 'Leverage: Credit Accounts with on-time re-payment history',
+ 'Coverage: Non-delinquent and delinquent Credit Accounts',
+ 'Delinquency Status: Defaults on Credit Accounts (current & recent periodic intervals)',
+ 'Credit Applications: Credit Account Applications over last 30 days'
+];
+
+function monthsAgoLabel(monthsBack: number): string {
+ const d = new Date();
+ d.setMonth(d.getMonth() - monthsBack);
+ return d.toLocaleDateString('en-IN', { month: 'short', year: 'numeric' });
+}
+
+function ddmmyyyyMonthsAgo(monthsBack: number, rng: () => number): string {
+ const d = new Date();
+ d.setMonth(d.getMonth() - monthsBack);
+ d.setDate(1 + Math.floor(rng() * 27));
+ const pad = (n: number): string => String(n).padStart(2, '0');
+ return `${pad(d.getDate())}-${pad(d.getMonth() + 1)}-${d.getFullYear()}`;
+}
+
+function maskAccountNo(rng: () => number): string {
+ const digits = Array.from({ length: 10 }, () => Math.floor(rng() * 10)).join('');
+ return `XXXXXXXXXX${digits.slice(-4)}`;
+}
+
+/** How badly (and how often) an account's monthly instalments have slipped -- driven by the
+ * score band, same shape as a real bureau's per-account DPD table: mostly clean, with the worse
+ * bands showing more months affected and higher day-counts on the ones that are. */
+function buildDpdHistory(band: CicRiskBand, monthsOpen: number, rng: () => number): CicDpdPoint[] {
+ const span = Math.min(12, Math.max(1, monthsOpen));
+ const lateChance = band === 'Excellent' ? 0 : band === 'Good' ? 0.08 : band === 'Fair' ? 0.22 : 0.4;
+ const maxDpd = band === 'Excellent' ? 0 : band === 'Good' ? 29 : band === 'Fair' ? 89 : 180;
+ const history: CicDpdPoint[] = [];
+ for (let i = span - 1; i >= 0; i--) {
+ const isLate = maxDpd > 0 && rng() < lateChance;
+ history.push({ label: monthsAgoLabel(i), dpd: isLate ? Math.round(10 + rng() * (maxDpd - 10)) : 0 });
+ }
+ return history;
+}
+
+function buildAccount(band: CicRiskBand, rng: () => number): CicCreditAccount {
+ const spec = ACCOUNT_TYPES[Math.floor(rng() * ACCOUNT_TYPES.length)];
+ const sanctionedAmt = Math.round((spec.minAmt + rng() * (spec.maxAmt - spec.minAmt)) / 1000) * 1000;
+ const monthsOpen = Math.round(3 + rng() * 48);
+ const tenureMonths = spec.maxTenure > 0 ? Math.round(spec.minTenure + rng() * (spec.maxTenure - spec.minTenure)) : 0;
+ const isClosed = tenureMonths > 0 && monthsOpen >= tenureMonths && rng() < 0.6;
+ const paidFraction = tenureMonths > 0 ? Math.min(1, monthsOpen / tenureMonths) : rng() * 0.6;
+ const dpdHistory = buildDpdHistory(band, monthsOpen, rng);
+ const worstDpd = dpdHistory.reduce((max, p) => Math.max(max, p.dpd), 0);
+ const isWrittenOff = band === 'Poor' && worstDpd >= 90 && rng() < 0.35;
+ const currentBalance = isClosed ? 0 : Math.round(sanctionedAmt * (1 - paidFraction) * (0.85 + rng() * 0.3));
+ const overdueAmt = worstDpd > 30 && !isClosed ? Math.round(currentBalance * (0.05 + rng() * 0.15)) : 0;
+ return {
+ lender: LENDERS[Math.floor(rng() * LENDERS.length)],
+ accountType: spec.type,
+ accountNo: maskAccountNo(rng),
+ ownership: rng() < 0.75 ? 'Individual' : 'Joint',
+ secured: spec.secured,
+ dateOpened: ddmmyyyyMonthsAgo(monthsOpen, rng),
+ status: isWrittenOff ? 'WRITTEN-OFF' : isClosed ? 'CLOSED' : 'ACTIVE',
+ sanctionedAmt,
+ currentBalance: Math.max(0, currentBalance),
+ overdueAmt,
+ tenureMonths,
+ rateOfInterest: Math.round((spec.minRate + rng() * (spec.maxRate - spec.minRate)) * 100) / 100,
+ dpdHistory,
+ worstDpd
+ };
+}
+
+function buildEnquiry(rng: () => number): CicEnquiry {
+ const spec = ACCOUNT_TYPES[Math.floor(rng() * ACCOUNT_TYPES.length)];
+ return {
+ institution: LENDERS[Math.floor(rng() * LENDERS.length)],
+ date: ddmmyyyyMonthsAgo(Math.round(rng() * 24), rng),
+ purpose: spec.type.replace('BL GENERAL', 'BUSINESS LOAN'),
+ amountApplied: Math.round((spec.minAmt + rng() * (spec.maxAmt - spec.minAmt)) / 1000) * 1000
+ };
+}
+
+function buildRecommendation(band: CicRiskBand, totalOverdueAmt: number, writtenOffAccounts: number): string {
+ if (writtenOffAccounts > 0) {
+ return `${writtenOffAccounts} written-off account(s) on record — this is a hard decline signal under most credit policies regardless of the numeric score.`;
+ }
+ if (totalOverdueAmt > 0) {
+ return `Score is ${band}, but ₹${totalOverdueAmt.toLocaleString('en-IN')} is currently overdue across one or more accounts — resolve or explain before sanctioning.`;
+ }
+ if (band === 'Excellent' || band === 'Good') {
+ return `Score is ${band} with a clean repayment record and no overdue amounts — proceed with standard underwriting.`;
+ }
+ return `Score is ${band} — no overdue amount currently, but the repayment history is inconsistent enough to warrant additional scrutiny (co-borrower, lower amount, or tighter tenure).`;
+}
+
+/**
+ * Builds a full, self-consistent mock bureau report for this applicant -- realistic account mix,
+ * per-account DPD history, enquiries and a plain-language recommendation -- all rolled up around
+ * the `cibilScore` this application already has from the "Check Credit Score" step. Deterministic
+ * per application (seeded off applicationNo + score), so re-opening it later shows the exact same
+ * "pulled" report rather than a new random one each time.
+ */
+export function generateCicReportAnalysis(app: {
+ applicationNo: string;
+ applicantName: string;
+ cicBureau: string;
+ cibilScore: number;
+}): CicReportAnalysis {
+ const rng = seededRng(`${app.applicationNo}-${app.cibilScore}`);
+ const score = app.cibilScore > 0 ? app.cibilScore : Math.round(300 + rng() * 600);
+ const band = scoreBand(score);
+ const accountCount = 1 + Math.floor(rng() * 4);
+ const accounts = Array.from({ length: accountCount }, () => buildAccount(band, rng));
+ const enquiryCount = band === 'Excellent' ? Math.floor(rng() * 2) : Math.floor(rng() * 4);
+ const enquiries = Array.from({ length: enquiryCount }, () => buildEnquiry(rng));
+
+ const activeAccounts = accounts.filter((a) => a.status === 'ACTIVE').length;
+ const closedAccounts = accounts.filter((a) => a.status === 'CLOSED').length;
+ const writtenOffAccounts = accounts.filter((a) => a.status === 'WRITTEN-OFF').length;
+ const totalCurrentBalance = accounts.reduce((sum, a) => sum + a.currentBalance, 0);
+ const securedAmt = accounts.filter((a) => a.secured).reduce((sum, a) => sum + a.currentBalance, 0);
+ const unsecuredAmt = totalCurrentBalance - securedAmt;
+ const totalOverdueAmt = accounts.reduce((sum, a) => sum + a.overdueAmt, 0);
+ const totalLatePayments = accounts.reduce((sum, a) => sum + a.dpdHistory.filter((p) => p.dpd > 0).length, 0);
+ const enquiriesLast90Days = enquiries.filter((e) => {
+ const [
+ d,
+ m,
+ y
+ ] = e.date.split('-').map(Number);
+ const enquiryDate = new Date(y, m - 1, d);
+ const cutoff = new Date();
+ cutoff.setDate(cutoff.getDate() - 90);
+ return enquiryDate >= cutoff;
+ }).length;
+
+ return {
+ bureau: app.cicBureau,
+ reportDate: new Date().toLocaleDateString('en-IN', { day: '2-digit', month: '2-digit', year: 'numeric' }),
+ applicantName: app.applicantName,
+ score,
+ band,
+ scoreFactors: SCORE_FACTORS,
+ totalAccounts: accounts.length,
+ activeAccounts,
+ closedAccounts,
+ writtenOffAccounts,
+ totalCurrentBalance,
+ securedAmt,
+ unsecuredAmt,
+ totalOverdueAmt,
+ accounts,
+ enquiries,
+ enquiriesLast90Days,
+ totalLatePayments,
+ recommendation: buildRecommendation(band, totalOverdueAmt, writtenOffAccounts)
+ };
+}
diff --git a/src/app/loan-origination/application-detail/co-owner-kyc-verify-dialog/co-owner-kyc-verify-dialog.component.html b/src/app/loan-origination/application-detail/co-owner-kyc-verify-dialog/co-owner-kyc-verify-dialog.component.html
new file mode 100644
index 000000000..fa994c5cd
--- /dev/null
+++ b/src/app/loan-origination/application-detail/co-owner-kyc-verify-dialog/co-owner-kyc-verify-dialog.component.html
@@ -0,0 +1,69 @@
+
+
+Co-Owner {{ docLabel }} Verification
+
+
+
+ {{ data.state.front ? 'Front ✓' : 'Upload Front' }}
+
+
+
+ {{ data.state.back ? 'Back ✓' : 'Upload Back' }}
+
+
+
+
+ @if (data.state.frontPreviewUrl || data.state.backPreviewUrl) {
+
+ @if (data.state.frontPreviewUrl) {
+
+ }
+ @if (data.state.backPreviewUrl) {
+
+ }
+
+ }
+
+ @if (data.state.verified) {
+ ✓ Verified
+ } @else {
+
+
+ @if (data.docType === 'aadhaar') {
+ Send OTP
+ } @else {
+ Verify PAN
+ }
+
+ @if (data.docType === 'aadhaar' && otpSent) {
+
+
+ @for (d of otpDigits; track $index) {
+
+ }
+
+
Verify
+
+ }
+ }
+
+
+ Done
+
diff --git a/src/app/loan-origination/application-detail/co-owner-kyc-verify-dialog/co-owner-kyc-verify-dialog.component.scss b/src/app/loan-origination/application-detail/co-owner-kyc-verify-dialog/co-owner-kyc-verify-dialog.component.scss
new file mode 100644
index 000000000..d930e5933
--- /dev/null
+++ b/src/app/loan-origination/application-detail/co-owner-kyc-verify-dialog/co-owner-kyc-verify-dialog.component.scss
@@ -0,0 +1,127 @@
+/**
+ * Copyright since 2025 Mifos Initiative
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+.cokv-content {
+ min-width: 380px;
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.cokv-files {
+ display: flex;
+ gap: 8px;
+}
+
+.cokv-upload-btn {
+ display: inline-flex;
+ align-items: center;
+ height: 36px;
+ padding: 0 16px;
+ border: 1px solid #cbd5e1;
+ border-radius: 999px;
+ background: #fff;
+ color: #334155;
+ font-size: 13px;
+ font-weight: 600;
+ cursor: pointer;
+
+ &:hover {
+ background: #f8fafc;
+ }
+}
+
+.cokv-thumbs {
+ display: flex;
+ gap: 8px;
+}
+
+.cokv-thumb {
+ width: 100px;
+ height: 70px;
+ object-fit: cover;
+ border: 1px solid #e2e8f0;
+ border-radius: 8px;
+}
+
+.cokv-verified-badge {
+ display: inline-flex;
+ align-self: flex-start;
+ align-items: center;
+ padding: 5px 14px;
+ border-radius: 999px;
+ background: #d1fae5;
+ color: #047857;
+ font-size: 12.5px;
+ font-weight: 800;
+}
+
+.cokv-verify-row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-wrap: wrap;
+
+ // stylelint-disable-next-line selector-no-qualifying-type
+ input[type='text'] {
+ height: 36px;
+ padding: 0 12px;
+ border: 1px solid #cbd5e1;
+ border-radius: 6px;
+ font-size: 13px;
+ width: 160px;
+ }
+}
+
+.cokv-otp-row {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.cokv-otp-digits {
+ display: flex;
+ gap: 5px;
+
+ // stylelint-disable-next-line selector-no-qualifying-type
+ input[type='text'] {
+ width: 30px;
+ height: 34px;
+ text-align: center;
+ padding: 0;
+ border: 1px solid #cbd5e1;
+ border-radius: 6px;
+ font-size: 14px;
+ }
+}
+
+.cokv-btn {
+ height: 36px;
+ padding: 0 16px;
+ border: 1px solid #cbd5e1;
+ border-radius: 999px;
+ background: #fff;
+ color: #334155;
+ font-size: 13px;
+ font-weight: 700;
+ cursor: pointer;
+
+ &:hover {
+ background: #f8fafc;
+ }
+
+ &--primary {
+ border-color: #4338ca;
+ background: #4338ca;
+ color: #fff;
+
+ &:hover {
+ background: #3730a3;
+ }
+ }
+}
diff --git a/src/app/loan-origination/application-detail/co-owner-kyc-verify-dialog/co-owner-kyc-verify-dialog.component.ts b/src/app/loan-origination/application-detail/co-owner-kyc-verify-dialog/co-owner-kyc-verify-dialog.component.ts
new file mode 100644
index 000000000..827b7149e
--- /dev/null
+++ b/src/app/loan-origination/application-detail/co-owner-kyc-verify-dialog/co-owner-kyc-verify-dialog.component.ts
@@ -0,0 +1,146 @@
+/**
+ * Copyright since 2025 Mifos Initiative
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+import { Component, inject } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormsModule } from '@angular/forms';
+import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
+import { MatButtonModule } from '@angular/material/button';
+import { KycUploadHelperService } from '../../../clients/shared/kyc-upload-helper.service';
+
+export interface CoOwnerDocSide {
+ front: File | null;
+ back: File | null;
+ frontPreviewUrl: string | null;
+ backPreviewUrl: string | null;
+ number: string;
+ verified: boolean;
+}
+
+export interface CoOwnerKycVerifyDialogData {
+ docType: 'aadhaar' | 'pan';
+ /** Passed by reference on purpose -- every capture/verify action here mutates the caller's own
+ * object directly, so the result is already in place the instant this dialog closes, no
+ * matter whether it was closed via the Done button, the backdrop, or Esc. */
+ state: CoOwnerDocSide;
+}
+
+/**
+ * Front/back capture + OTP (Aadhaar) or format (PAN) verification for a Housing Loan co-owner,
+ * lifted into its own popup for exactly the same reason the applicant's own Aadhaar/PAN capture
+ * needed one: that much UI (two upload buttons, thumbnails, a number field, six OTP boxes) makes
+ * a flat document-card grid balloon in height and break its layout.
+ */
+@Component({
+ selector: 'mifosx-co-owner-kyc-verify-dialog',
+ standalone: true,
+ imports: [
+ CommonModule,
+ FormsModule,
+ MatDialogModule,
+ MatButtonModule
+ ],
+ templateUrl: './co-owner-kyc-verify-dialog.component.html',
+ styleUrls: ['./co-owner-kyc-verify-dialog.component.scss']
+})
+export class CoOwnerKycVerifyDialogComponent {
+ data = inject(MAT_DIALOG_DATA);
+ private kycUploadHelper = inject(KycUploadHelperService);
+
+ otpSent = false;
+ otpDigits: string[] = [
+ '',
+ '',
+ '',
+ '',
+ '',
+ ''
+ ];
+
+ get docLabel(): string {
+ return this.data.docType === 'aadhaar' ? 'Aadhaar' : 'PAN';
+ }
+
+ async onFile(side: 'front' | 'back', event: Event): Promise {
+ const input = event.target as HTMLInputElement;
+ const file = input.files?.[0];
+ input.value = '';
+ if (!file) {
+ return;
+ }
+ const cropped = await this.kycUploadHelper.pickWithCrop(file, `Co-Owner ${this.docLabel} — ${side}`);
+ if (!cropped) {
+ return;
+ }
+ const state = this.data.state;
+ if (side === 'front') {
+ if (state.frontPreviewUrl) {
+ URL.revokeObjectURL(state.frontPreviewUrl);
+ }
+ state.front = cropped;
+ state.frontPreviewUrl = cropped.type.startsWith('image/') ? URL.createObjectURL(cropped) : null;
+ } else {
+ if (state.backPreviewUrl) {
+ URL.revokeObjectURL(state.backPreviewUrl);
+ }
+ state.back = cropped;
+ state.backPreviewUrl = cropped.type.startsWith('image/') ? URL.createObjectURL(cropped) : null;
+ }
+ }
+
+ sendOtp(): void {
+ if (this.data.state.number.trim().length !== 12) {
+ return;
+ }
+ this.otpSent = true;
+ this.otpDigits = [
+ '',
+ '',
+ '',
+ '',
+ '',
+ ''
+ ];
+ }
+
+ onOtpDigit(index: number, event: Event): void {
+ const input = event.target as HTMLInputElement;
+ const val = input.value.replace(/\D/g, '');
+ this.otpDigits[index] = val.charAt(0) || '';
+ input.value = this.otpDigits[index];
+ if (this.otpDigits[index] && index < 5) {
+ const next = input.parentElement?.querySelectorAll('input')[index + 1];
+ (next as HTMLInputElement)?.focus();
+ }
+ }
+
+ onOtpKeydown(index: number, event: KeyboardEvent): void {
+ if (event.key === 'Backspace' && !this.otpDigits[index] && index > 0) {
+ const inputs = (event.target as HTMLElement).parentElement?.querySelectorAll('input');
+ if (inputs) {
+ this.otpDigits[index - 1] = '';
+ (inputs[index - 1] as HTMLInputElement).value = '';
+ (inputs[index - 1] as HTMLInputElement).focus();
+ }
+ }
+ }
+
+ /** Local-only "verification" -- no real Aadhaar API is reachable here (no clientId exists for
+ * a co-owner who isn't even a client), same as the applicant's own capture flow. */
+ verifyAadhaar(): void {
+ if (this.otpDigits.every((d) => d !== '')) {
+ this.data.state.verified = true;
+ }
+ }
+
+ verifyPan(): void {
+ if (/^[A-Z]{5}[0-9]{4}[A-Z]$/.test(this.data.state.number.trim().toUpperCase())) {
+ this.data.state.verified = true;
+ }
+ }
+}
diff --git a/src/app/loan-origination/application-detail/field-visit-details-dialog/field-visit-details-dialog.component.html b/src/app/loan-origination/application-detail/field-visit-details-dialog/field-visit-details-dialog.component.html
new file mode 100644
index 000000000..92d8e32d6
--- /dev/null
+++ b/src/app/loan-origination/application-detail/field-visit-details-dialog/field-visit-details-dialog.component.html
@@ -0,0 +1,62 @@
+
+
+{{ data.label }} — Visit Details
+
+ {{ data.check.status }}
+
+
+
+
Officer Assigned
+ {{ data.check.officerAssigned }}
+
+
+
Assigned On
+ {{ data.check.assignedDate }}
+
+
+
GPS Coordinates
+ {{ data.check.latitude || '—' }}, {{ data.check.longitude || '—' }}
+
+
+
{{ data.check.status === 'FAILED' ? 'Rejected By' : 'Verified By' }}
+ {{ data.check.verifiedBy }}
+
+
+
{{ data.check.status === 'FAILED' ? 'Rejected On' : 'Verified On' }}
+ {{ data.check.verifiedDate }}
+
+
+
Remarks
+ {{ data.check.remarks }}
+
+
+
+ Site Photos ({{ data.check.photoCount }})
+ @if (data.photos.length > 0) {
+
+ @for (photo of data.photos; track $index) {
+
+ @if (photo.previewUrl) {
+
+ } @else {
+
{{ photo.file.name }}
+ }
+
+ }
+
+ } @else {
+
+ No photo files available in this browser session -- they were captured earlier and aren't retained after a page
+ refresh, though the count above is.
+
+ }
+
+
+ Close
+
diff --git a/src/app/loan-origination/application-detail/field-visit-details-dialog/field-visit-details-dialog.component.scss b/src/app/loan-origination/application-detail/field-visit-details-dialog/field-visit-details-dialog.component.scss
new file mode 100644
index 000000000..01dade83b
--- /dev/null
+++ b/src/app/loan-origination/application-detail/field-visit-details-dialog/field-visit-details-dialog.component.scss
@@ -0,0 +1,114 @@
+/**
+ * Copyright since 2025 Mifos Initiative
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+.fvd-content {
+ min-width: 420px;
+ max-width: 640px;
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+}
+
+.fvd-badge {
+ display: inline-flex;
+ align-items: center;
+ align-self: flex-start;
+ padding: 5px 14px;
+ border-radius: 999px;
+ font-size: 12px;
+ font-weight: 800;
+ letter-spacing: 0.05em;
+
+ &--verified {
+ background: #d1fae5;
+ color: #047857;
+ }
+
+ &--failed {
+ background: #fee2e2;
+ color: #b91c1c;
+ }
+
+ &--pending {
+ background: #fef3c7;
+ color: #b45309;
+ }
+}
+
+.fvd-kv-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
+ gap: 12px 18px;
+ margin: 0;
+}
+
+.fvd-kv-field {
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+
+ dt {
+ font-size: 10px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: #94a3b8;
+ }
+
+ dd {
+ margin: 0;
+ font-size: 13.5px;
+ font-weight: 600;
+ color: #1e293b;
+ }
+}
+
+.fvd-photos-title {
+ margin: 0;
+ font-size: 12.5px;
+ font-weight: 700;
+ color: #334155;
+}
+
+.fvd-photo-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
+ gap: 10px;
+}
+
+.fvd-photo {
+ height: 90px;
+ border-radius: 10px;
+ border: 1px solid #e2e8f0;
+ overflow: hidden;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: #f8fafc;
+
+ img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ }
+}
+
+.fvd-photo-label {
+ font-size: 11px;
+ color: #64748b;
+ padding: 0 6px;
+ text-align: center;
+ word-break: break-word;
+}
+
+.fvd-no-photos {
+ margin: 0;
+ font-size: 12.5px;
+ color: #64748b;
+ line-height: 1.5;
+}
diff --git a/src/app/loan-origination/application-detail/field-visit-details-dialog/field-visit-details-dialog.component.ts b/src/app/loan-origination/application-detail/field-visit-details-dialog/field-visit-details-dialog.component.ts
new file mode 100644
index 000000000..bf194628a
--- /dev/null
+++ b/src/app/loan-origination/application-detail/field-visit-details-dialog/field-visit-details-dialog.component.ts
@@ -0,0 +1,57 @@
+/**
+ * Copyright since 2025 Mifos Initiative
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+import { Component, inject } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
+import { MatButtonModule } from '@angular/material/button';
+import { VerificationCheck } from '../../loan-origination-data';
+
+export interface FieldVisitPhoto {
+ file: File;
+ previewUrl: string | null;
+}
+
+export interface FieldVisitDetailsDialogData {
+ label: string;
+ check: VerificationCheck;
+ photos: FieldVisitPhoto[];
+}
+
+/**
+ * Everything saved for one Field Investigation address visit -- officer, assignment date, GPS,
+ * status/remarks and every site photo -- in one popup, since the inline card only ever shows a
+ * short summary once the visit is Verified/Failed. Photos are local-only File objects (same as
+ * every other document capture in this mock pipeline), so a photo count with no files behind it
+ * just means the page was refreshed since the visit was captured, not that anything was lost from
+ * the application record itself.
+ */
+@Component({
+ selector: 'mifosx-field-visit-details-dialog',
+ standalone: true,
+ imports: [
+ CommonModule,
+ MatDialogModule,
+ MatButtonModule
+ ],
+ templateUrl: './field-visit-details-dialog.component.html',
+ styleUrls: ['./field-visit-details-dialog.component.scss']
+})
+export class FieldVisitDetailsDialogComponent {
+ data = inject(MAT_DIALOG_DATA);
+
+ get statusBadgeClass(): string {
+ if (this.data.check.status === 'VERIFIED') {
+ return 'fvd-badge fvd-badge--verified';
+ }
+ if (this.data.check.status === 'FAILED') {
+ return 'fvd-badge fvd-badge--failed';
+ }
+ return 'fvd-badge fvd-badge--pending';
+ }
+}
diff --git a/src/app/loan-origination/loan-origination-data.ts b/src/app/loan-origination/loan-origination-data.ts
index 8050becbe..03afc781f 100644
--- a/src/app/loan-origination/loan-origination-data.ts
+++ b/src/app/loan-origination/loan-origination-data.ts
@@ -152,6 +152,16 @@ export interface VerificationCheck {
verifiedBy: string;
verifiedDate: string;
remarks: string;
+ /** Field Investigation only -- the officer assigned to physically visit this address, when they
+ * were assigned (both a display date and a raw epoch-ms timestamp for the 48-hour SLA clock),
+ * and what they captured on the visit (GPS + a photo count -- the actual File objects are kept
+ * local-only in the component, same as every other document capture in this mock pipeline). */
+ officerAssigned: string;
+ assignedDate: string;
+ assignedAt: number;
+ latitude: string;
+ longitude: string;
+ photoCount: number;
}
export interface ApprovalRecord {
@@ -188,6 +198,9 @@ export interface LoanApplication {
productCode: string;
productName: string;
+ /** Term Loan (fixed tenure, EMI-based) vs Overdraft (revolving limit, renewed annually) --
+ * see allowedProductTypes() for which products can be which. */
+ productType: ProductType;
secured: boolean;
requestedAmount: number;
tenureMonths: number;
@@ -203,11 +216,11 @@ export interface LoanApplication {
panVerified: boolean;
incomeProofSubmitted: boolean;
bankStatementSubmitted: boolean;
- propertyDetailsSubmitted: boolean;
- taxReceiptSubmitted: boolean;
electricityBillSubmitted: boolean;
- /** Aadhaar e-KYC biometric authentication (UIDAI) — captured on top of the OVD paper check. */
- aadhaarFingerprintCaptured: boolean;
+ /** Documents specific to this application's loan product (e.g. property papers for a Housing
+ * Loan, land records for a Kisan Credit Card) -- seeded from PRODUCT_DOCUMENT_REQUIREMENTS at
+ * creation time, plus whatever the officer adds on top with "Add Custom Document". */
+ productDocuments: ProductDocumentEntry[];
monthlyIncome: number;
eligibleLoanAmount: number;
coBorrowerRequired: boolean;
@@ -248,12 +261,34 @@ export interface LoanApplication {
addressVerification: VerificationCheck;
officeVerification: VerificationCheck;
propertyVerification: VerificationCheck;
+ /** Whether this applicant runs a shop/business premises worth sending a field officer to --
+ * officer-ticked at Field Investigation itself (not derivable from anything else on the
+ * application), same conditional-row pattern propertyVerification already uses off `secured`. */
+ hasShop: boolean;
+ shopVerification: VerificationCheck;
+ /** propertyValuation itself is always derived (builtUpAreaSqFt × marketRatePerSqFt) -- kept as
+ * its own field since every other part of the app already reads it directly (stage-history
+ * remarks, Risk Approval), rather than making every one of those call sites recompute it. */
+ propertyType: string;
+ builtUpAreaSqFt: number;
+ marketRatePerSqFt: number;
+ guidelineRatePerSqFt: number;
+ valuerName: string;
+ valuationDate: string;
propertyValuation: number;
agentReportUploaded: boolean;
agentReportFileName: string;
+ agentReportRemark: string;
// Personal Discussion / discrepancy handling
discrepancyType: DiscrepancyType;
+ /** Scheduled before the meeting happens -- date/time/mode/officer -- so there's a real
+ * appointment on record instead of the discussion notes just appearing from nowhere. */
+ meetingScheduled: boolean;
+ meetingDate: string;
+ meetingTime: string;
+ meetingMode: 'ONLINE' | 'OFFLINE' | 'CALL' | '';
+ meetingWith: string;
personalDiscussionNotes: string;
personalDiscussionDate: string;
/** Customer's signed declaration taken after the personal discussion clears. */
@@ -468,6 +503,80 @@ export const ORIGINATION_PRODUCTS: OriginationProduct[] = [
}
];
+/** One product-specific document a loan officer has to collect for a given product's collateral/
+ * eligibility type -- distinct from the generic KYC docs (Aadhaar, PAN, Income Proof, ...) every
+ * application needs regardless of product. */
+export interface ProductDocumentEntry {
+ key: string;
+ label: string;
+ hint?: string;
+ /** True for a document the officer added on the spot with "Add Custom Document" -- these can
+ * be removed again; the product's own required list cannot. */
+ custom: boolean;
+ submitted: boolean;
+}
+
+interface ProductDocumentTemplate {
+ key: string;
+ label: string;
+ hint?: string;
+}
+
+/** What each product needs beyond the generic KYC checklist -- a Housing or Kisan Credit loan is
+ * secured against a specific asset (property / agricultural land), so the paperwork proving and
+ * encumbering that asset has to be collected before the loan can be sanctioned, on top of the
+ * borrower's own identity/income proof. */
+export const PRODUCT_DOCUMENT_REQUIREMENTS: Record = {
+ HL: [
+ { key: 'propertyTaxReceipt', label: 'Property Tax Receipt' },
+ { key: 'propertyRegistration', label: 'Property Registration Document (Sale Deed)' },
+ { key: 'propertyElectricityBill', label: 'Electricity Bill (of the Property)' },
+ {
+ key: 'coOwnerKyc',
+ label: 'KYC Documents of Co-Owners',
+ hint: 'If the registry lists a second co-owner, use "Add More Doc" below for their KYC too'
+ },
+ { key: 'mutationExtract', label: 'Property Mutation Extract (Namantaran)' },
+ { key: 'noDuesCertificate', label: 'No-Dues Certificate' },
+ { key: 'encumbranceCertificate', label: 'Encumbrance Certificate (EC)' },
+ { key: 'approvedBuildingPlan', label: 'Approved Building Plan / Sanctioned Layout' }
+ ],
+ KCC: [
+ { key: 'landRegistry', label: 'Land Registry / Title Deed' },
+ { key: 'khasraKhatauni', label: 'Khasra-Khatauni (Land Revenue Record)' },
+ { key: 'girdawari', label: 'Girdawari (Crop Inspection Report)' },
+ { key: 'noDuesOtherBank', label: 'No-Dues Certificate from Other Banks/Societies' }
+ ],
+ VL: [
+ { key: 'vehicleQuotation', label: 'Vehicle Quotation / Proforma Invoice' },
+ { key: 'drivingLicense', label: 'Driving License' },
+ { key: 'vehicleRC', label: 'RC Copy', hint: 'For a used vehicle' },
+ { key: 'vehicleInsurance', label: 'Insurance Copy' }
+ ],
+ CC: [
+ { key: 'udyamRegistration', label: 'Udyam / Business Registration Certificate' },
+ { key: 'gstCertificate', label: 'GST Registration Certificate' },
+ { key: 'businessFinancials', label: "Last 2 Years' Business Financial Statements" },
+ { key: 'businessAddressProof', label: 'Business Address Proof' },
+ { key: 'partnershipDeed', label: 'Partnership Deed / MOA-AOA', hint: 'If applicable' }
+ ],
+ EL: [
+ { key: 'admissionLetter', label: 'Admission Letter from Institution' },
+ { key: 'feeStructure', label: 'Fee Structure / Schedule' },
+ { key: 'marksheets', label: 'Previous Qualifying Exam Marksheets' },
+ { key: 'coBorrowerKyc', label: 'Co-Borrower (Parent/Guardian) KYC & Income Proof' }
+ ],
+ GL: [
+ { key: 'goldValuation', label: 'Gold Appraisal / Valuation Certificate' },
+ { key: 'goldPurchaseInvoice', label: 'Purchase Invoice of Gold Ornaments', hint: 'If available' }
+ ],
+ PL: [{ key: 'employmentProof', label: 'Employment Proof / Appointment Letter' }]
+};
+
+export function buildProductDocuments(productCode: string): ProductDocumentEntry[] {
+ return (PRODUCT_DOCUMENT_REQUIREMENTS[productCode] ?? []).map((doc) => ({ ...doc, custom: false, submitted: false }));
+}
+
const FIRST_NAMES = [
'Ramesh',
'Suresh',
@@ -534,7 +643,7 @@ const BC_AGENTS = [
'Digital Seva Kendra — Sangli'
];
-const MANAGERS = [
+export const MANAGERS = [
'Anil Deshpande (Branch Manager)',
'Sunil Kadam (Branch Manager)',
'Vikas Pathak (Credit Manager)',
@@ -558,6 +667,20 @@ const FI_AGENCIES = [
'VerifyIndia Field Services'
];
+export const PROPERTY_TYPES = [
+ 'Residential',
+ 'Commercial',
+ 'Plot',
+ 'Under Construction'
+];
+
+const VALUERS = [
+ 'Apex Valuers & Engineers',
+ 'Prime Property Valuation Services',
+ 'National Valuation Associates',
+ 'Metro Approved Valuers'
+];
+
// -------------------------------------------------------------------------
// Delegation of Powers — sanctioning authority by loan amount, exactly the kind
// of amount-based approval matrix every Indian bank/co-operative society runs.
@@ -607,7 +730,18 @@ export function stageBadgeClass(app: LoanApplication): string {
}
function blankVerification(): VerificationCheck {
- return { status: 'PENDING', verifiedBy: '—', verifiedDate: '—', remarks: '—' };
+ return {
+ status: 'PENDING',
+ verifiedBy: '—',
+ verifiedDate: '—',
+ remarks: '—',
+ officerAssigned: '—',
+ assignedDate: '—',
+ assignedAt: 0,
+ latitude: '',
+ longitude: '',
+ photoCount: 0
+ };
}
function blankApproval(): ApprovalRecord {
@@ -703,6 +837,14 @@ function generateApplications(count: number): LoanApplication[] {
const legalOfficer = pick(LEGAL_OFFICERS);
const fiAgency = pick(FI_AGENCIES);
+ // Reverse-engineered the same way monthlyIncome is -- area × rate lands close to a plausible
+ // multiple of the requested amount, rather than picking propertyValuation directly and having
+ // no consistent area/rate breakdown behind it.
+ const propertyBuiltUpAreaSqFt = product.secured ? round(between(600, 2400)) : 0;
+ const propertyMarketRatePerSqFt = product.secured
+ ? round((requestedAmount * between(1.1, 1.4)) / propertyBuiltUpAreaSqFt)
+ : 0;
+
const app: LoanApplication = {
applicationNo: `LN${branch.code.slice(2)}${product.code}${seq}`,
applicationDate,
@@ -714,6 +856,7 @@ function generateApplications(count: number): LoanApplication[] {
customerType,
productCode: product.code,
productName: product.name,
+ productType: pick(allowedProductTypes(product.code)),
secured: product.secured,
requestedAmount,
tenureMonths,
@@ -725,10 +868,8 @@ function generateApplications(count: number): LoanApplication[] {
panVerified: false,
incomeProofSubmitted: false,
bankStatementSubmitted: false,
- propertyDetailsSubmitted: false,
- taxReceiptSubmitted: false,
electricityBillSubmitted: false,
- aadhaarFingerprintCaptured: false,
+ productDocuments: buildProductDocuments(product.code),
monthlyIncome,
eligibleLoanAmount,
coBorrowerRequired: eligibleLoanAmount < requestedAmount,
@@ -750,10 +891,24 @@ function generateApplications(count: number): LoanApplication[] {
addressVerification: blankVerification(),
officeVerification: blankVerification(),
propertyVerification: blankVerification(),
- propertyValuation: product.secured ? round(requestedAmount * between(1.1, 1.4)) : 0,
+ hasShop: rng() < 0.3,
+ shopVerification: blankVerification(),
+ propertyType: product.secured ? pick(PROPERTY_TYPES) : '—',
+ builtUpAreaSqFt: propertyBuiltUpAreaSqFt,
+ marketRatePerSqFt: propertyMarketRatePerSqFt,
+ guidelineRatePerSqFt: product.secured ? round(propertyMarketRatePerSqFt * between(0.6, 0.85)) : 0,
+ valuerName: product.secured ? pick(VALUERS) : '—',
+ valuationDate: product.secured ? fmt(addDays(today, -round(between(5, 60)))) : '—',
+ propertyValuation: product.secured ? propertyBuiltUpAreaSqFt * propertyMarketRatePerSqFt : 0,
agentReportUploaded: false,
agentReportFileName: '—',
+ agentReportRemark: '—',
discrepancyType: 'NONE',
+ meetingScheduled: false,
+ meetingDate: '',
+ meetingTime: '',
+ meetingMode: '',
+ meetingWith: '',
personalDiscussionNotes: '—',
personalDiscussionDate: '—',
declarationTaken: false,
@@ -836,10 +991,8 @@ function generateApplications(count: number): LoanApplication[] {
app.panVerified = true;
app.incomeProofSubmitted = true;
app.bankStatementSubmitted = true;
- app.propertyDetailsSubmitted = product.secured;
- app.taxReceiptSubmitted = product.secured;
app.electricityBillSubmitted = true;
- app.aadhaarFingerprintCaptured = true;
+ app.productDocuments.forEach((doc) => (doc.submitted = true));
app.stageHistory.push({
stage,
date: fmt(cursor),
@@ -876,24 +1029,57 @@ function generateApplications(count: number): LoanApplication[] {
status: 'VERIFIED',
verifiedBy: fiAgency,
verifiedDate: fmt(cursor),
- remarks: 'Registered address confirmed on physical visit.'
+ remarks: 'Registered address confirmed on physical visit.',
+ officerAssigned: fiAgency,
+ assignedDate: fmt(cursor),
+ assignedAt: cursor.getTime(),
+ latitude: (23.2 + rng() * 5).toFixed(6),
+ longitude: (77.4 + rng() * 5).toFixed(6),
+ photoCount: 2 + Math.floor(rng() * 3)
};
app.officeVerification = {
status: 'VERIFIED',
verifiedBy: fiAgency,
verifiedDate: fmt(cursor),
- remarks: 'Employment/business confirmed — Agent Report filed.'
+ remarks: 'Employment/business confirmed — Agent Report filed.',
+ officerAssigned: fiAgency,
+ assignedDate: fmt(cursor),
+ assignedAt: cursor.getTime(),
+ latitude: (23.2 + rng() * 5).toFixed(6),
+ longitude: (77.4 + rng() * 5).toFixed(6),
+ photoCount: 2 + Math.floor(rng() * 3)
};
if (product.secured) {
app.propertyVerification = {
status: 'VERIFIED',
verifiedBy: fiAgency,
verifiedDate: fmt(cursor),
- remarks: `Property inspected and valued at ₹${app.propertyValuation.toLocaleString('en-IN')}.`
+ remarks: `Property inspected and valued at ₹${app.propertyValuation.toLocaleString('en-IN')}.`,
+ officerAssigned: fiAgency,
+ assignedDate: fmt(cursor),
+ assignedAt: cursor.getTime(),
+ latitude: (23.2 + rng() * 5).toFixed(6),
+ longitude: (77.4 + rng() * 5).toFixed(6),
+ photoCount: 2 + Math.floor(rng() * 3)
+ };
+ }
+ if (app.hasShop) {
+ app.shopVerification = {
+ status: 'VERIFIED',
+ verifiedBy: fiAgency,
+ verifiedDate: fmt(cursor),
+ remarks: 'Shop/business premises visited and confirmed operational.',
+ officerAssigned: fiAgency,
+ assignedDate: fmt(cursor),
+ assignedAt: cursor.getTime(),
+ latitude: (23.2 + rng() * 5).toFixed(6),
+ longitude: (77.4 + rng() * 5).toFixed(6),
+ photoCount: 2 + Math.floor(rng() * 3)
};
}
app.agentReportUploaded = true;
app.agentReportFileName = `AgentReport_${app.applicationNo}.pdf`;
+ app.agentReportRemark = 'All addresses visited and cross-verified — no discrepancies noted.';
app.stageHistory.push({
stage,
date: fmt(cursor),
@@ -906,6 +1092,15 @@ function generateApplications(count: number): LoanApplication[] {
break;
case 'PERSONAL_DISCUSSION':
+ app.meetingScheduled = true;
+ app.meetingDate = fmt(cursor);
+ app.meetingTime = `${String(10 + Math.floor(rng() * 6)).padStart(2, '0')}:${rng() < 0.5 ? '00' : '30'}`;
+ app.meetingMode = pick<'ONLINE' | 'OFFLINE' | 'CALL'>([
+ 'ONLINE',
+ 'OFFLINE',
+ 'CALL'
+ ]);
+ app.meetingWith = manager;
if (isHoldStage) {
app.discrepancyType = pick([
'DOCUMENT_DISCREPANCY',
@@ -1174,6 +1369,30 @@ export function moveToStage(app: LoanApplication, stage: OriginationStage, actio
app.currentStage = stage;
}
+/** Books the Personal Discussion appointment -- date, time, mode and which bank officer is
+ * taking it -- before any discussion notes exist, so there's a real scheduled meeting on record
+ * rather than a discussion report just appearing with no appointment behind it. */
+export function scheduleMeeting(
+ app: LoanApplication,
+ date: string,
+ time: string,
+ mode: 'ONLINE' | 'OFFLINE' | 'CALL',
+ withWhom: string,
+ actionBy: string
+): void {
+ app.meetingScheduled = true;
+ app.meetingDate = date;
+ app.meetingTime = time;
+ app.meetingMode = mode;
+ app.meetingWith = withWhom;
+ recordStageProgress(
+ app,
+ actionBy,
+ 'Meeting Scheduled',
+ `Personal Discussion scheduled with ${withWhom} on ${date} ${time} (${mode}).`
+ );
+}
+
export function flagDiscrepancy(app: LoanApplication, type: DiscrepancyType, actionBy: string, notes: string): void {
app.discrepancyType = type;
app.personalDiscussionDate = todayDDMMYYYY();
@@ -1264,20 +1483,48 @@ export function pushFieldInquiry(app: LoanApplication, agencyName: string, actio
recordStageProgress(app, actionBy, 'FI Pushed', `Field Inquiry request pushed to ${agencyName}.`);
}
-export function uploadAgentReport(app: LoanApplication, fileName: string, actionBy: string): void {
+export function uploadAgentReport(app: LoanApplication, fileName: string, remark: string, actionBy: string): void {
app.agentReportUploaded = true;
app.agentReportFileName = fileName;
- recordStageProgress(app, actionBy, 'Uploaded', `Agent Report "${fileName}" uploaded.`);
+ app.agentReportRemark = remark || 'Agent Report filed.';
+ recordStageProgress(app, actionBy, 'Uploaded', `Agent Report "${fileName}" uploaded — ${app.agentReportRemark}`);
+}
+
+export type FiCheckKey = 'addressVerification' | 'officeVerification' | 'propertyVerification' | 'shopVerification';
+
+export const FI_CHECK_LABELS: Record = {
+ addressVerification: 'Home Address',
+ officeVerification: 'Office / Employment Address',
+ propertyVerification: 'Property Address',
+ shopVerification: 'Shop / Business Premises'
+};
+
+/** Sends a field officer out to physically visit and verify one of the 3 FI addresses -- resets
+ * that address's whole visit cycle (GPS, photo count, prior remarks) fresh, since a newly
+ * assigned visit (including a retry after a FAILED one) starts the 48-hour SLA clock over. */
+export function assignFieldOfficer(app: LoanApplication, which: FiCheckKey, officerName: string): void {
+ app[which] = {
+ status: 'PENDING',
+ verifiedBy: '—',
+ verifiedDate: '—',
+ remarks: '—',
+ officerAssigned: officerName,
+ assignedDate: todayDDMMYYYY(),
+ assignedAt: Date.now(),
+ latitude: '',
+ longitude: '',
+ photoCount: 0
+ };
}
export function recordVerification(
app: LoanApplication,
- which: 'addressVerification' | 'officeVerification' | 'propertyVerification',
+ which: FiCheckKey,
status: VerificationStatus,
verifiedBy: string,
remarks: string
): void {
- app[which] = { status, verifiedBy, verifiedDate: todayDDMMYYYY(), remarks };
+ app[which] = { ...app[which], status, verifiedBy, verifiedDate: todayDDMMYYYY(), remarks };
}
/** Cooperative-bank membership can be allotted independently of the loan's own progress — a
@@ -1348,14 +1595,45 @@ export function disburseApplication(app: LoanApplication, actionBy: string, mode
}
// -------------------------------------------------------------------------
-// EMI Repayment Schedule — the standard reducing-balance amortisation table a real CBS shows
-// once a term loan is disbursed. Cash Credit / KCC are revolving limits renewed annually, not
-// repaid on a fixed instalment schedule, so they're excluded rather than shown a misleading one.
+// Product Type — Term Loan (fixed tenure, EMI-based) vs Overdraft (revolving limit, renewed
+// annually rather than repaid on a fixed instalment schedule).
// -------------------------------------------------------------------------
-export function isRevolvingProduct(productCode: string): boolean {
- return productCode === 'CC' || productCode === 'KCC';
+export type ProductType = 'TERM_LOAN' | 'OVERDRAFT';
+
+export const PRODUCT_TYPE_LABELS: Record = {
+ TERM_LOAN: 'Term Loan',
+ OVERDRAFT: 'Overdraft'
+};
+
+/** Which Product Type(s) a given product can be sanctioned as. Cash Credit and Kisan Credit Card
+ * are inherently revolving limits, so Overdraft is the only option. Gold Loan is commonly
+ * written up either way at a UCB -- a straight EMI term loan, or a 1-year overdraft/cash-credit
+ * limit renewed annually -- so both are offered, defaulting to Overdraft (the more common
+ * cooperative-bank practice for gold loans). Every other product here is a straight term loan. */
+export function allowedProductTypes(productCode: string): ProductType[] {
+ if (productCode === 'CC' || productCode === 'KCC') {
+ return ['OVERDRAFT'];
+ }
+ if (productCode === 'GL') {
+ return [
+ 'OVERDRAFT',
+ 'TERM_LOAN'
+ ];
+ }
+ return ['TERM_LOAN'];
}
+export function defaultProductType(productCode: string): ProductType {
+ return allowedProductTypes(productCode)[0];
+}
+
+// -------------------------------------------------------------------------
+// EMI Repayment Schedule — the standard reducing-balance amortisation table a real CBS shows
+// once a term loan is disbursed. Overdraft-type products are revolving limits renewed annually,
+// not repaid on a fixed instalment schedule, so they're excluded rather than shown a misleading
+// one (see LoanApplication.productType).
+// -------------------------------------------------------------------------
+
export interface EmiScheduleRow {
installmentNo: number;
dueDate: string;
@@ -1426,6 +1704,7 @@ export interface NewApplicationInput {
branchName: string;
productCode: string;
productName: string;
+ productType: ProductType;
secured: boolean;
requestedAmount: number;
tenureMonths: number;
@@ -1447,6 +1726,7 @@ export function createApplication(input: NewApplicationInput, seq: number): Loan
clientId: input.clientId ?? null,
productCode: input.productCode,
productName: input.productName,
+ productType: input.productType,
secured: input.secured,
requestedAmount: input.requestedAmount,
tenureMonths: input.tenureMonths,
@@ -1458,10 +1738,8 @@ export function createApplication(input: NewApplicationInput, seq: number): Loan
panVerified: false,
incomeProofSubmitted: false,
bankStatementSubmitted: false,
- propertyDetailsSubmitted: false,
- taxReceiptSubmitted: false,
electricityBillSubmitted: false,
- aadhaarFingerprintCaptured: false,
+ productDocuments: buildProductDocuments(input.productCode),
monthlyIncome: 0,
eligibleLoanAmount: 0,
coBorrowerRequired: false,
@@ -1483,10 +1761,24 @@ export function createApplication(input: NewApplicationInput, seq: number): Loan
addressVerification: blankVerification(),
officeVerification: blankVerification(),
propertyVerification: blankVerification(),
+ hasShop: false,
+ shopVerification: blankVerification(),
+ propertyType: '—',
+ builtUpAreaSqFt: 0,
+ marketRatePerSqFt: 0,
+ guidelineRatePerSqFt: 0,
+ valuerName: '—',
+ valuationDate: '—',
propertyValuation: 0,
agentReportUploaded: false,
agentReportFileName: '—',
+ agentReportRemark: '—',
discrepancyType: 'NONE',
+ meetingScheduled: false,
+ meetingDate: '',
+ meetingTime: '',
+ meetingMode: '',
+ meetingWith: '',
personalDiscussionNotes: '—',
personalDiscussionDate: '—',
declarationTaken: false,
diff --git a/src/app/loan-origination/loan-origination-shared.scss b/src/app/loan-origination/loan-origination-shared.scss
index 1de058338..ca07ccc24 100644
--- a/src/app/loan-origination/loan-origination-shared.scss
+++ b/src/app/loan-origination/loan-origination-shared.scss
@@ -268,6 +268,14 @@
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 12px 20px;
padding: 18px;
+
+ &--tight-bottom {
+ padding: 0 0 10px;
+ }
+
+ &--tight-top {
+ padding: 10px 0 0;
+ }
}
.loan-kv-field {
@@ -594,6 +602,53 @@
margin: -2px 0 0;
}
+// Field Investigation -- one card per address (Home/Office/Property/Shop) instead of stacked
+// full-width sections, since each visit's officer/SLA/GPS/photo capture is a self-contained unit.
+.loi-fi-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
+ gap: 14px;
+ margin-bottom: 14px;
+}
+
+.loi-fi-card {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ padding: 14px 16px;
+ border: 1px solid #e2e8f0;
+ border-radius: 12px;
+ background: #fff;
+}
+
+.loi-fi-card-title {
+ font-size: 12px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: #1e40af;
+}
+
+.loi-fi-view-details {
+ display: inline-flex;
+ align-items: center;
+ align-self: flex-start;
+ gap: 6px;
+ height: 30px;
+ padding: 0 12px;
+ border: 1px solid #c7d2fe;
+ border-radius: 999px;
+ background: #eef2ff;
+ color: #4338ca;
+ font-size: 12px;
+ font-weight: 700;
+ cursor: pointer;
+
+ &:hover {
+ background: #e0e7ff;
+ }
+}
+
.loi-otp-digits {
display: flex;
gap: 5px;
@@ -668,3 +723,53 @@
}
}
}
+
+// Re-opens the bank statement analysis dialog for a CSV already on file -- the analysis only
+// pops up once automatically (right after upload), so this is the only way back into it once an
+// officer has closed it and wants to double-check something.
+.loi-bsa-reopen {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ height: 34px;
+ padding: 0 14px;
+ border: 1px solid #c7d2fe;
+ border-radius: 999px;
+ background: #eef2ff;
+ color: #4338ca;
+ font-size: 12.5px;
+ font-weight: 700;
+ cursor: pointer;
+
+ &:hover {
+ background: #e0e7ff;
+ border-color: #a5b4fc;
+ }
+}
+
+// Loan-specific documents (Housing/KCC/Vehicle/... papers) -- an officer-added custom document
+// gets a small remove control right next to its title; the product's own required list never
+// shows one, since removeCustomProductDoc() is a no-op for a non-custom entry anyway.
+.loi-doc-remove {
+ margin-left: 6px;
+ border: none;
+ background: none;
+ color: #94a3b8;
+ font-size: 12px;
+ cursor: pointer;
+ padding: 0 2px;
+
+ &:hover {
+ color: #dc2626;
+ }
+}
+
+.loi-custom-doc-input {
+ height: 36px;
+ padding: 0 12px;
+ border: 1px solid #cbd5e1;
+ border-radius: 6px;
+ font-size: 13px;
+ min-width: 260px;
+ flex: 1;
+}
diff --git a/src/app/loan-origination/loan-pipeline/loan-pipeline.component.html b/src/app/loan-origination/loan-pipeline/loan-pipeline.component.html
index ddd878b60..c365f5953 100644
--- a/src/app/loan-origination/loan-pipeline/loan-pipeline.component.html
+++ b/src/app/loan-origination/loan-pipeline/loan-pipeline.component.html
@@ -297,12 +297,20 @@
* Product
-
+
@for (p of products; track p.code) {
{{ p.name }}
}
+
+ * Product Type
+
+ @for (t of newAppProductTypeOptions; track t) {
+ {{ productTypeLabels[t] }}
+ }
+
+
* Requested Amount
diff --git a/src/app/loan-origination/loan-pipeline/loan-pipeline.component.ts b/src/app/loan-origination/loan-pipeline/loan-pipeline.component.ts
index 9973815ca..121713ba5 100644
--- a/src/app/loan-origination/loan-pipeline/loan-pipeline.component.ts
+++ b/src/app/loan-origination/loan-pipeline/loan-pipeline.component.ts
@@ -27,12 +27,16 @@ import {
OriginationStage,
OverallStatus,
PRODUCT_TARGET_PCT,
+ PRODUCT_TYPE_LABELS,
+ ProductType,
STAGE_LABELS,
STAGE_ORDER,
STATUS_LABELS,
TARGET_STATUS_LABELS,
TargetStatus,
+ allowedProductTypes,
createApplication,
+ defaultProductType,
progressPct,
stageBadgeClass,
targetStatus,
@@ -138,10 +142,26 @@ export class LoanPipelineComponent implements OnInit {
clientId: null as number | null,
branchCode: this.branches[0].code,
productCode: this.products[0].code,
+ productType: defaultProductType(this.products[0].code),
requestedAmount: null as number | null,
tenureMonths: null as number | null
};
+ readonly productTypeLabels = PRODUCT_TYPE_LABELS;
+
+ /** Which Product Types the currently-selected product can be sanctioned as -- Cash Credit/KCC
+ * are Overdraft-only, Gold Loan offers both, everything else is Term Loan-only. */
+ get newAppProductTypeOptions(): ProductType[] {
+ return allowedProductTypes(this.newApp.productCode);
+ }
+
+ /** Switching Product can change which Product Types are even valid -- reset to that product's
+ * default rather than leaving a stale selection (e.g. "Overdraft" left over from Gold Loan)
+ * on a product that doesn't offer it. */
+ onProductChange(): void {
+ this.newApp.productType = defaultProductType(this.newApp.productCode);
+ }
+
/** Existing-customer search (real Fineract clients, unlike the rest of this mock pipeline). */
existingClientSearchText = '';
existingClientSearchResults: any[] = [];
@@ -513,6 +533,7 @@ export class LoanPipelineComponent implements OnInit {
branchName: branch.name,
productCode: product.code,
productName: product.name,
+ productType: this.newApp.productType,
secured: product.secured,
requestedAmount: this.newApp.requestedAmount,
tenureMonths: this.newApp.tenureMonths || product.minTenure,
@@ -530,6 +551,7 @@ export class LoanPipelineComponent implements OnInit {
clientId: null,
branchCode: this.isHeadOffice ? this.branches[0].code : this.ownBranchCode || this.branches[0].code,
productCode: this.products[0].code,
+ productType: defaultProductType(this.products[0].code),
requestedAmount: null,
tenureMonths: null
};