feat(loan-origination): add full Loan Origination Sanctioning workflow module
New standalone module modelling the pre-disbursement pipeline: Channel intake (Branch/BC/Self-Apply) -> KYC/OVD & e-KYC fingerprint -> CIC/Credit Bureau check -> Field Investigation -> Personal Discussion & declaration -> Loan Committee -> Risk Approval -> Legal Approval -> Customer Signing -> Cheque/ACH -> Post-Disbursement Check -> Disbursement, per RBI KYC Master Direction, the mandatory CIC check, and co-operative-bank share-membership rules. Includes a 12-stage stepper with real approve/reject actions and a date-wise audit trail, CIC upload + credit-score check, Field Inquiry push + Agent Report upload, Committee "Reports Required" / Risk "Verification Checklist", Head-Office-vs-branch scoping, a Pipeline screen (filters, New Application intake, Channel-wise/BC-wise/Share-Member reports, Disbursement Target vs Actual, Export to Excel), and a full EMI repayment schedule once disbursed.
This commit is contained in:
@@ -0,0 +1,558 @@
|
||||
<!--
|
||||
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/.
|
||||
-->
|
||||
|
||||
<div class="pg">
|
||||
<header class="pg-head">
|
||||
<div>
|
||||
<div class="pg-eyebrow">CREDIT · LOAN ORIGINATION · {{ scopeName | uppercase }}</div>
|
||||
<h1 class="pg-title">Loan Origination Pipeline</h1>
|
||||
<p class="pg-sub npa-report-note">
|
||||
Every sanctioning stage from application intake to disbursement — KYC/OVD, CIC credit check, Field
|
||||
Investigation, Personal Discussion, Credit Committee, Risk & Legal Approval — as on {{ asOnDate }}.
|
||||
</p>
|
||||
</div>
|
||||
<div class="pg-toolbar">
|
||||
@if (isHeadOffice) {
|
||||
<label class="pg-field">
|
||||
<span>Branch</span>
|
||||
<select class="pg-select" [ngModel]="branchFilter" (ngModelChange)="setBranch($event)">
|
||||
<option value="">-- All Branches --</option>
|
||||
@for (b of branches; track b.code) {
|
||||
<option [value]="b.code">{{ b.name }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
} @else {
|
||||
<label class="pg-field">
|
||||
<span>Branch</span>
|
||||
<span class="pg-chip">{{ scopeName }}</span>
|
||||
</label>
|
||||
}
|
||||
<button type="button" class="pg-btn pg-btn--ghost" [disabled]="generating" (click)="exportExcel()">
|
||||
{{ generating ? 'Exporting…' : 'Export to Excel' }}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="inv-kpis npa-kpis--ratio">
|
||||
<div class="inv-kpi">
|
||||
<span class="inv-kpi__label">Total Applications</span>
|
||||
<span class="inv-kpi__value">{{ totalApplications }}</span>
|
||||
<span class="inv-kpi__sub">in the active pipeline</span>
|
||||
</div>
|
||||
<div class="inv-kpi">
|
||||
<span class="inv-kpi__label">In Progress</span>
|
||||
<span class="inv-kpi__value">{{ inProgressCount }}</span>
|
||||
<span class="inv-kpi__sub">moving through the workflow</span>
|
||||
</div>
|
||||
<div class="inv-kpi">
|
||||
<span class="inv-kpi__label">On Hold</span>
|
||||
<span class="inv-kpi__value npa-warn">{{ onHoldCount }}</span>
|
||||
<span class="inv-kpi__sub">discrepancy under resolution</span>
|
||||
</div>
|
||||
<div class="inv-kpi">
|
||||
<span class="inv-kpi__label">Rejected</span>
|
||||
<span class="inv-kpi__value npa-warn">{{ rejectedCount }}</span>
|
||||
<span class="inv-kpi__sub">declined at Committee/Risk</span>
|
||||
</div>
|
||||
<div class="inv-kpi">
|
||||
<span class="inv-kpi__label">Disbursed</span>
|
||||
<span class="inv-kpi__value">{{ disbursedApplications.length }}</span>
|
||||
<span class="inv-kpi__sub">₹{{ disbursedAmount | number: '1.0-0' }} total</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cctr-tabs">
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="activeView === 'pipeline'"
|
||||
(click)="setView('pipeline')"
|
||||
>
|
||||
Pipeline
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="activeView === 'new'"
|
||||
(click)="setView('new')"
|
||||
>
|
||||
New Application
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="activeView === 'channel'"
|
||||
(click)="setView('channel')"
|
||||
>
|
||||
Channel-wise Report
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="activeView === 'bc'"
|
||||
(click)="setView('bc')"
|
||||
>
|
||||
BC-wise Report
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="activeView === 'share'"
|
||||
(click)="setView('share')"
|
||||
>
|
||||
Share Member Report
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="activeView === 'target'"
|
||||
(click)="setView('target')"
|
||||
>
|
||||
Target vs Actual
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ===================== PIPELINE ===================== -->
|
||||
@if (activeView === 'pipeline') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Filters</div>
|
||||
<div class="inv-form-grid">
|
||||
<label class="inv-form-field">
|
||||
<span>Channel</span>
|
||||
<select [(ngModel)]="channelFilter" (ngModelChange)="currentPage = 1">
|
||||
<option value="">-- All --</option>
|
||||
<option value="BRANCH">Branch</option>
|
||||
<option value="BC">Business Correspondent (BC)</option>
|
||||
<option value="SELF_APPLY">Self-Apply (Digital)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>Stage</span>
|
||||
<select [(ngModel)]="stageFilter" (ngModelChange)="currentPage = 1">
|
||||
<option value="">-- All --</option>
|
||||
@for (s of stageOrder; track s) {
|
||||
<option [value]="s">{{ stageLabels[s] }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>Status</span>
|
||||
<select [(ngModel)]="statusFilter" (ngModelChange)="currentPage = 1">
|
||||
<option value="">-- All --</option>
|
||||
<option value="IN_PROGRESS">In Progress</option>
|
||||
<option value="ON_HOLD">On Hold</option>
|
||||
<option value="REJECTED">Rejected</option>
|
||||
<option value="DISBURSED">Disbursed</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>Search (Application No / Applicant)</span>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="searchText"
|
||||
(ngModelChange)="currentPage = 1"
|
||||
placeholder="e.g. Ramesh, LN0102..."
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Applications ({{ filteredApplications.length }})</div>
|
||||
<div class="pg-table-scroll">
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Application No</th>
|
||||
<th>Applicant</th>
|
||||
<th>Branch</th>
|
||||
<th>Channel</th>
|
||||
<th>Product</th>
|
||||
<th class="num">Requested Amount</th>
|
||||
<th>Current Stage</th>
|
||||
<th class="num">Progress</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (a of paged(filteredApplications); track a.applicationNo) {
|
||||
<tr class="cctr-branch-row" (click)="openApplication(a)">
|
||||
<td>
|
||||
<span class="pg-chip">{{ a.applicationNo }}</span>
|
||||
</td>
|
||||
<td>{{ a.applicantName }}</td>
|
||||
<td>{{ a.branchName }}</td>
|
||||
<td>{{ channelLabels[a.channel] }}</td>
|
||||
<td>{{ a.productName }}</td>
|
||||
<td class="num">₹{{ a.requestedAmount | number: '1.0-0' }}</td>
|
||||
<td>{{ stageLabel(a.currentStage) }}</td>
|
||||
<td class="num">{{ progress(a) }}%</td>
|
||||
<td>
|
||||
<span [class]="badgeClass(a)">{{ statusLabel(a.overallStatus) }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="9" class="pg-empty">No applications match these filters.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@if (filteredApplications.length > pageSize) {
|
||||
<div class="pg-pagination">
|
||||
<span class="pg-pagination__info"
|
||||
>Showing {{ rangeStart(filteredApplications) }}–{{ rangeEnd(filteredApplications) }} of
|
||||
{{ filteredApplications.length }}</span
|
||||
>
|
||||
<div class="pg-pagination__controls">
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[disabled]="currentPage === 1"
|
||||
(click)="goToPage(currentPage - 1, filteredApplications)"
|
||||
>
|
||||
‹ Prev
|
||||
</button>
|
||||
<span class="pg-pagination__page">Page {{ currentPage }} of {{ pageCount(filteredApplications) }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[disabled]="currentPage === pageCount(filteredApplications)"
|
||||
(click)="goToPage(currentPage + 1, filteredApplications)"
|
||||
>
|
||||
Next ›
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ===================== NEW APPLICATION ===================== -->
|
||||
@if (activeView === 'new') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">New Application — Channel Intake</div>
|
||||
<div class="inv-form-grid">
|
||||
<label class="inv-form-field">
|
||||
<span>* Channel</span>
|
||||
<select [(ngModel)]="newApp.channel">
|
||||
<option value="BRANCH">Branch</option>
|
||||
<option value="BC">Business Correspondent (BC)</option>
|
||||
<option value="SELF_APPLY">Self-Apply (Digital)</option>
|
||||
</select>
|
||||
</label>
|
||||
@if (newApp.channel === 'BC') {
|
||||
<label class="inv-form-field">
|
||||
<span>* BC Agent / Point</span>
|
||||
<input type="text" [(ngModel)]="newApp.bcAgentName" placeholder="e.g. Suvidha BC Point — Andheri" />
|
||||
</label>
|
||||
}
|
||||
<label class="inv-form-field">
|
||||
<span>* Customer Type</span>
|
||||
<select [(ngModel)]="newApp.customerType">
|
||||
<option value="NEW">New Customer</option>
|
||||
<option value="EXISTING">Existing Customer</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>* Applicant Name</span>
|
||||
<input type="text" [(ngModel)]="newApp.applicantName" placeholder="Full name" />
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>* Branch</span>
|
||||
<select [(ngModel)]="newApp.branchCode" [disabled]="!isHeadOffice">
|
||||
@for (b of newApplicationBranches; track b.code) {
|
||||
<option [value]="b.code">{{ b.name }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>* Product</span>
|
||||
<select [(ngModel)]="newApp.productCode">
|
||||
@for (p of products; track p.code) {
|
||||
<option [value]="p.code">{{ p.name }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>* Requested Amount</span>
|
||||
<input type="number" [(ngModel)]="newApp.requestedAmount" placeholder="₹" />
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>Tenure (Months)</span>
|
||||
<input type="number" [(ngModel)]="newApp.tenureMonths" placeholder="e.g. 36" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="inv-form-actions">
|
||||
<button type="button" class="pg-btn pg-btn--primary" (click)="saveNewApplication()">Submit Application</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ===================== CHANNEL-WISE REPORT ===================== -->
|
||||
@if (activeView === 'channel') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Channel-wise Report — Branch / BC / Self-Apply</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Channel</th>
|
||||
<th class="num">Applications</th>
|
||||
<th class="num">Requested Amount</th>
|
||||
<th class="num col-in">Disbursed Count</th>
|
||||
<th class="num col-in">Disbursed Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (r of channelReport; track r.channel) {
|
||||
<tr>
|
||||
<td>{{ r.label }}</td>
|
||||
<td class="num">{{ r.count }}</td>
|
||||
<td class="num">₹{{ r.requestedAmount | number: '1.0-0' }}</td>
|
||||
<td class="num col-in">{{ r.disbursedCount }}</td>
|
||||
<td class="num col-in">₹{{ r.disbursedAmount | number: '1.0-0' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="cctr-total-row">
|
||||
<td>Total</td>
|
||||
<td class="num">{{ totalApplications }}</td>
|
||||
<td class="num">
|
||||
₹{{
|
||||
channelReport[0].requestedAmount + channelReport[1].requestedAmount + channelReport[2].requestedAmount
|
||||
| number: '1.0-0'
|
||||
}}
|
||||
</td>
|
||||
<td class="num">{{ disbursedApplications.length }}</td>
|
||||
<td class="num">₹{{ disbursedAmount | number: '1.0-0' }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ===================== BC-WISE REPORT ===================== -->
|
||||
@if (activeView === 'bc') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">
|
||||
BC-wise Report — Business Correspondent Sourced Applications ({{ bcApplications.length }})
|
||||
</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>BC Agent / Point</th>
|
||||
<th class="num">Applications</th>
|
||||
<th class="num">Requested Amount</th>
|
||||
<th class="num col-in">Disbursed Count</th>
|
||||
<th class="num col-in">Disbursed Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (r of bcReport; track r.bcAgentName) {
|
||||
<tr>
|
||||
<td>{{ r.bcAgentName }}</td>
|
||||
<td class="num">{{ r.count }}</td>
|
||||
<td class="num">₹{{ r.requestedAmount | number: '1.0-0' }}</td>
|
||||
<td class="num col-in">{{ r.disbursedCount }}</td>
|
||||
<td class="num col-in">₹{{ r.disbursedAmount | number: '1.0-0' }}</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="5" class="pg-empty">No BC-sourced applications in this branch.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ===================== SHARE MEMBER REPORT ===================== -->
|
||||
@if (activeView === 'share') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Share Member Report</div>
|
||||
<p class="pg-sub npa-report-note">
|
||||
A cooperative-bank borrower must hold membership shares in the society before a loan can be sanctioned.
|
||||
{{ shareMemberCount }} of {{ totalApplications }} applicants are already members; the rest need membership
|
||||
allotted before Credit Committee approval — done from the application's own detail screen.
|
||||
</p>
|
||||
<div class="pg-table-scroll">
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Application No</th>
|
||||
<th>Applicant</th>
|
||||
<th>Branch</th>
|
||||
<th>Current Stage</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (a of paged(nonMemberApplications); track a.applicationNo) {
|
||||
<tr class="cctr-branch-row" (click)="openApplication(a)">
|
||||
<td>
|
||||
<span class="pg-chip">{{ a.applicationNo }}</span>
|
||||
</td>
|
||||
<td>{{ a.applicantName }}</td>
|
||||
<td>{{ a.branchName }}</td>
|
||||
<td>{{ stageLabel(a.currentStage) }}</td>
|
||||
<td>
|
||||
<span [class]="badgeClass(a)">{{ statusLabel(a.overallStatus) }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="5" class="pg-empty">Every active applicant in this branch already holds membership.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@if (nonMemberApplications.length > pageSize) {
|
||||
<div class="pg-pagination">
|
||||
<span class="pg-pagination__info"
|
||||
>Showing {{ rangeStart(nonMemberApplications) }}–{{ rangeEnd(nonMemberApplications) }} of
|
||||
{{ nonMemberApplications.length }}</span
|
||||
>
|
||||
<div class="pg-pagination__controls">
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[disabled]="currentPage === 1"
|
||||
(click)="goToPage(currentPage - 1, nonMemberApplications)"
|
||||
>
|
||||
‹ Prev
|
||||
</button>
|
||||
<span class="pg-pagination__page">Page {{ currentPage }} of {{ pageCount(nonMemberApplications) }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[disabled]="currentPage === pageCount(nonMemberApplications)"
|
||||
(click)="goToPage(currentPage + 1, nonMemberApplications)"
|
||||
>
|
||||
Next ›
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ===================== TARGET VS ACTUAL ===================== -->
|
||||
@if (activeView === 'target') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Branch-wise Disbursement Target — Annual</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Branch</th>
|
||||
<th class="num">Target</th>
|
||||
<th class="num col-in">Disbursed (Actual)</th>
|
||||
<th class="num">Achievement %</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of branchTargetRows; track row.branchCode) {
|
||||
<tr>
|
||||
<td>{{ row.branchName }}</td>
|
||||
<td class="num">₹{{ row.target | number: '1.0-0' }}</td>
|
||||
<td class="num col-in">₹{{ row.actual | number: '1.0-0' }}</td>
|
||||
<td class="num">{{ row.achievementPct | number: '1.1-1' }}%</td>
|
||||
<td>
|
||||
<span
|
||||
class="loan-badge"
|
||||
[class.loan-badge--disbursed]="row.status !== 'BEHIND'"
|
||||
[class.loan-badge--hold]="row.status === 'BEHIND'"
|
||||
>
|
||||
{{ statusLabelOf(row.status) }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Product Mix — Target % vs Actual % (of Disbursed Amount)</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Product</th>
|
||||
<th class="num">Target %</th>
|
||||
<th class="num col-in">Actual %</th>
|
||||
<th class="num">Variance</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of productTargetRows; track row.code) {
|
||||
<tr>
|
||||
<td>{{ row.label }}</td>
|
||||
<td class="num">{{ row.targetPct | number: '1.0-0' }}%</td>
|
||||
<td class="num col-in">{{ row.actualPct | number: '1.1-1' }}%</td>
|
||||
<td class="num" [class.col-out]="row.variancePct < 0" [class.col-in]="row.variancePct > 0">
|
||||
{{ row.variancePct > 0 ? '+' : '' }}{{ row.variancePct | number: '1.1-1' }} pp
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
class="loan-badge"
|
||||
[class.loan-badge--disbursed]="row.status !== 'BEHIND'"
|
||||
[class.loan-badge--hold]="row.status === 'BEHIND'"
|
||||
>
|
||||
{{ statusLabelOf(row.status) }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Channel Mix — Target % vs Actual % (of Disbursed Amount)</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Channel</th>
|
||||
<th class="num">Target %</th>
|
||||
<th class="num col-in">Actual %</th>
|
||||
<th class="num">Variance</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of channelTargetRows; track row.code) {
|
||||
<tr>
|
||||
<td>{{ row.label }}</td>
|
||||
<td class="num">{{ row.targetPct | number: '1.0-0' }}%</td>
|
||||
<td class="num col-in">{{ row.actualPct | number: '1.1-1' }}%</td>
|
||||
<td class="num" [class.col-out]="row.variancePct < 0" [class.col-in]="row.variancePct > 0">
|
||||
{{ row.variancePct > 0 ? '+' : '' }}{{ row.variancePct | number: '1.1-1' }} pp
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
class="loan-badge"
|
||||
[class.loan-badge--disbursed]="row.status !== 'BEHIND'"
|
||||
[class.loan-badge--hold]="row.status === 'BEHIND'"
|
||||
>
|
||||
{{ statusLabelOf(row.status) }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="inv-toast" [class.show]="toastVisible">{{ toastMsg }}</div>
|
||||
@@ -0,0 +1,675 @@
|
||||
/**
|
||||
* 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, OnInit, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
import { AuthenticationService } from '../../core/authentication/authentication.service';
|
||||
import { ExportSheetSpec, exportWorkbook } from '../../cashier/cashier-export';
|
||||
import {
|
||||
BRANCHES,
|
||||
BranchMaster,
|
||||
branchTargetAmount,
|
||||
Channel,
|
||||
CHANNEL_LABELS,
|
||||
CHANNEL_TARGET_PCT,
|
||||
CustomerType,
|
||||
LoanApplication,
|
||||
LOAN_APPLICATIONS,
|
||||
ORIGINATION_PRODUCTS,
|
||||
OriginationStage,
|
||||
OverallStatus,
|
||||
PRODUCT_TARGET_PCT,
|
||||
STAGE_LABELS,
|
||||
STAGE_ORDER,
|
||||
STATUS_LABELS,
|
||||
TARGET_STATUS_LABELS,
|
||||
TargetStatus,
|
||||
createApplication,
|
||||
progressPct,
|
||||
stageBadgeClass,
|
||||
targetStatus,
|
||||
todayDDMMYYYY
|
||||
} from '../loan-origination-data';
|
||||
|
||||
interface ChannelReportRow {
|
||||
channel: Channel;
|
||||
label: string;
|
||||
count: number;
|
||||
requestedAmount: number;
|
||||
disbursedCount: number;
|
||||
disbursedAmount: number;
|
||||
}
|
||||
|
||||
interface BcReportRow {
|
||||
bcAgentName: string;
|
||||
count: number;
|
||||
requestedAmount: number;
|
||||
disbursedCount: number;
|
||||
disbursedAmount: number;
|
||||
}
|
||||
|
||||
interface BranchTargetRow {
|
||||
branchCode: string;
|
||||
branchName: string;
|
||||
target: number;
|
||||
actual: number;
|
||||
achievementPct: number;
|
||||
status: TargetStatus;
|
||||
}
|
||||
|
||||
interface MixTargetRow {
|
||||
code: string;
|
||||
label: string;
|
||||
targetPct: number;
|
||||
actualPct: number;
|
||||
variancePct: number;
|
||||
status: TargetStatus;
|
||||
}
|
||||
|
||||
type ViewId = 'pipeline' | 'new' | 'channel' | 'bc' | 'share' | 'target';
|
||||
|
||||
/**
|
||||
* Loan Origination pipeline — every application currently moving through the sanctioning
|
||||
* workflow (Application → KYC → CIC Check → Field Investigation → Personal Discussion → Credit
|
||||
* Committee → Risk Approval → Legal Approval → Customer Signing → Cheque/ACH → Post-Check →
|
||||
* Disbursed), plus the Channel-wise / BC-wise / Share Member reports and a New Application
|
||||
* intake form. Every count/total here is derived from the same LOAN_APPLICATIONS array the
|
||||
* Application Detail screen mutates, so the pipeline view can never drift from an application's
|
||||
* real state.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-loan-pipeline',
|
||||
templateUrl: './loan-pipeline.component.html',
|
||||
styleUrls: [
|
||||
'../../cashier/cashier-shared.scss',
|
||||
'../../investment/investment-shared.scss',
|
||||
'../../npa/npa-shared.scss',
|
||||
'../loan-origination-shared.scss'
|
||||
],
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
RouterModule
|
||||
]
|
||||
})
|
||||
export class LoanPipelineComponent implements OnInit {
|
||||
readonly stageOrder = STAGE_ORDER;
|
||||
readonly stageLabels = STAGE_LABELS;
|
||||
readonly statusLabels = STATUS_LABELS;
|
||||
readonly channelLabels = CHANNEL_LABELS;
|
||||
readonly branches = BRANCHES;
|
||||
readonly products = ORIGINATION_PRODUCTS;
|
||||
asOnDate = todayDDMMYYYY();
|
||||
|
||||
activeView: ViewId = 'pipeline';
|
||||
|
||||
isHeadOffice = false;
|
||||
currentOfficeName = 'Branch Office';
|
||||
private ownBranchCode: string | null = null;
|
||||
branchFilter = '';
|
||||
|
||||
channelFilter = '';
|
||||
stageFilter = '';
|
||||
statusFilter = '';
|
||||
searchText = '';
|
||||
|
||||
readonly pageSize = 20;
|
||||
currentPage = 1;
|
||||
|
||||
generating = false;
|
||||
|
||||
newApp = {
|
||||
channel: 'BRANCH' as Channel,
|
||||
bcAgentName: '',
|
||||
customerType: 'NEW' as CustomerType,
|
||||
applicantName: '',
|
||||
branchCode: this.branches[0].code,
|
||||
productCode: this.products[0].code,
|
||||
requestedAmount: null as number | null,
|
||||
tenureMonths: null as number | null
|
||||
};
|
||||
|
||||
toastMsg = '';
|
||||
toastVisible = false;
|
||||
private toastTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly auth = inject(AuthenticationService);
|
||||
|
||||
ngOnInit(): void {
|
||||
const requestedView = this.route.snapshot.queryParamMap.get('view') as ViewId | null;
|
||||
if (requestedView && [
|
||||
'pipeline',
|
||||
'new',
|
||||
'channel',
|
||||
'bc',
|
||||
'share',
|
||||
'target'
|
||||
].includes(requestedView)) {
|
||||
this.activeView = requestedView;
|
||||
}
|
||||
|
||||
const creds = this.auth.getCredentials();
|
||||
this.isHeadOffice = this.auth.isHeadOfficeUser();
|
||||
this.currentOfficeName = creds?.officeName || 'Branch Office';
|
||||
if (this.isHeadOffice) {
|
||||
this.branchFilter = '';
|
||||
} else {
|
||||
this.ownBranchCode =
|
||||
this.branches.find((b: BranchMaster) => this.currentOfficeName.toLowerCase().includes(b.name.toLowerCase()))
|
||||
?.code || null;
|
||||
this.branchFilter = this.ownBranchCode || this.branches[0].code;
|
||||
// A branch-level login can only originate applications for its own branch — locked here,
|
||||
// not just hidden, since the New Application form posts whatever branchCode is bound.
|
||||
this.newApp.branchCode = this.branchFilter;
|
||||
}
|
||||
}
|
||||
|
||||
/** Branches selectable on the New Application form — every branch for Head Office, only the
|
||||
* logged-in user's own branch otherwise. */
|
||||
get newApplicationBranches(): BranchMaster[] {
|
||||
return this.isHeadOffice ? this.branches : this.branches.filter((b) => b.code === this.branchFilter);
|
||||
}
|
||||
|
||||
setView(view: ViewId): void {
|
||||
this.activeView = view;
|
||||
this.currentPage = 1;
|
||||
}
|
||||
|
||||
setBranch(code: string): void {
|
||||
this.branchFilter = code;
|
||||
this.currentPage = 1;
|
||||
}
|
||||
|
||||
/** Name of the branch/scope currently being viewed, for display in the header. */
|
||||
get scopeName(): string {
|
||||
if (!this.isHeadOffice) {
|
||||
return this.currentOfficeName;
|
||||
}
|
||||
return this.branchFilter
|
||||
? this.branches.find((b) => b.code === this.branchFilter)?.name || ''
|
||||
: 'All Branches (Consolidated)';
|
||||
}
|
||||
|
||||
get scopedApplications(): LoanApplication[] {
|
||||
return this.branchFilter ? LOAN_APPLICATIONS.filter((a) => a.branchCode === this.branchFilter) : LOAN_APPLICATIONS;
|
||||
}
|
||||
|
||||
get filteredApplications(): LoanApplication[] {
|
||||
return this.scopedApplications.filter((a) => {
|
||||
if (this.channelFilter && a.channel !== this.channelFilter) {
|
||||
return false;
|
||||
}
|
||||
if (this.stageFilter && a.currentStage !== this.stageFilter) {
|
||||
return false;
|
||||
}
|
||||
if (this.statusFilter && a.overallStatus !== this.statusFilter) {
|
||||
return false;
|
||||
}
|
||||
if (this.searchText) {
|
||||
const q = this.searchText.toLowerCase();
|
||||
return a.applicationNo.toLowerCase().includes(q) || a.applicantName.toLowerCase().includes(q);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------- Pagination --------------------
|
||||
paged<T>(items: T[]): T[] {
|
||||
const start = (this.currentPage - 1) * this.pageSize;
|
||||
return items.slice(start, start + this.pageSize);
|
||||
}
|
||||
|
||||
pageCount(items: unknown[]): number {
|
||||
return Math.max(1, Math.ceil(items.length / this.pageSize));
|
||||
}
|
||||
|
||||
rangeStart(items: unknown[]): number {
|
||||
return items.length === 0 ? 0 : (this.currentPage - 1) * this.pageSize + 1;
|
||||
}
|
||||
|
||||
rangeEnd(items: unknown[]): number {
|
||||
return Math.min(this.currentPage * this.pageSize, items.length);
|
||||
}
|
||||
|
||||
goToPage(page: number, items: unknown[]): void {
|
||||
this.currentPage = Math.min(Math.max(1, page), this.pageCount(items));
|
||||
}
|
||||
|
||||
// -------------------- Portfolio KPIs --------------------
|
||||
get totalApplications(): number {
|
||||
return this.scopedApplications.length;
|
||||
}
|
||||
|
||||
get inProgressCount(): number {
|
||||
return this.scopedApplications.filter((a) => a.overallStatus === 'IN_PROGRESS').length;
|
||||
}
|
||||
|
||||
get onHoldCount(): number {
|
||||
return this.scopedApplications.filter((a) => a.overallStatus === 'ON_HOLD').length;
|
||||
}
|
||||
|
||||
get rejectedCount(): number {
|
||||
return this.scopedApplications.filter((a) => a.overallStatus === 'REJECTED').length;
|
||||
}
|
||||
|
||||
get disbursedApplications(): LoanApplication[] {
|
||||
return this.scopedApplications.filter((a) => a.overallStatus === 'DISBURSED');
|
||||
}
|
||||
|
||||
get disbursedAmount(): number {
|
||||
return this.disbursedApplications.reduce((s, a) => s + a.requestedAmount, 0);
|
||||
}
|
||||
|
||||
progress(a: LoanApplication): number {
|
||||
return progressPct(a);
|
||||
}
|
||||
|
||||
badgeClass(a: LoanApplication): string {
|
||||
return stageBadgeClass(a);
|
||||
}
|
||||
|
||||
statusLabel(status: OverallStatus): string {
|
||||
return STATUS_LABELS[status];
|
||||
}
|
||||
|
||||
stageLabel(stage: OriginationStage): string {
|
||||
return STAGE_LABELS[stage];
|
||||
}
|
||||
|
||||
openApplication(a: LoanApplication): void {
|
||||
this.router.navigate([
|
||||
'/loan-origination/application',
|
||||
a.applicationNo
|
||||
]);
|
||||
}
|
||||
|
||||
// -------------------- Channel-wise / BC-wise / Share Member reports --------------------
|
||||
get channelReport(): ChannelReportRow[] {
|
||||
const channels: Channel[] = [
|
||||
'BRANCH',
|
||||
'BC',
|
||||
'SELF_APPLY'
|
||||
];
|
||||
return channels.map((channel) => {
|
||||
const rows = this.scopedApplications.filter((a) => a.channel === channel);
|
||||
const disbursed = rows.filter((a) => a.overallStatus === 'DISBURSED');
|
||||
return {
|
||||
channel,
|
||||
label: CHANNEL_LABELS[channel],
|
||||
count: rows.length,
|
||||
requestedAmount: rows.reduce((s, a) => s + a.requestedAmount, 0),
|
||||
disbursedCount: disbursed.length,
|
||||
disbursedAmount: disbursed.reduce((s, a) => s + a.requestedAmount, 0)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
get bcApplications(): LoanApplication[] {
|
||||
return this.scopedApplications.filter((a) => a.channel === 'BC');
|
||||
}
|
||||
|
||||
get bcReport(): BcReportRow[] {
|
||||
const names = Array.from(new Set(this.bcApplications.map((a) => a.bcAgentName))).sort();
|
||||
return names.map((bcAgentName) => {
|
||||
const rows = this.bcApplications.filter((a) => a.bcAgentName === bcAgentName);
|
||||
const disbursed = rows.filter((a) => a.overallStatus === 'DISBURSED');
|
||||
return {
|
||||
bcAgentName,
|
||||
count: rows.length,
|
||||
requestedAmount: rows.reduce((s, a) => s + a.requestedAmount, 0),
|
||||
disbursedCount: disbursed.length,
|
||||
disbursedAmount: disbursed.reduce((s, a) => s + a.requestedAmount, 0)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
get shareMemberCount(): number {
|
||||
return this.scopedApplications.filter((a) => a.isShareMember).length;
|
||||
}
|
||||
|
||||
get nonMemberApplications(): LoanApplication[] {
|
||||
return this.scopedApplications.filter((a) => !a.isShareMember && a.overallStatus !== 'REJECTED');
|
||||
}
|
||||
|
||||
// -------------------- Disbursement Target vs Actual --------------------
|
||||
statusLabelOf(status: TargetStatus): string {
|
||||
return TARGET_STATUS_LABELS[status];
|
||||
}
|
||||
|
||||
/** Branches shown in the target table: every branch for Head Office's consolidated view, only
|
||||
* the drilled-into/own branch otherwise — same scoping the rest of the screen already uses. */
|
||||
get branchTargetRows(): BranchTargetRow[] {
|
||||
const branchesToShow =
|
||||
this.isHeadOffice && !this.branchFilter
|
||||
? this.branches
|
||||
: this.branches.filter((b) => b.code === this.branchFilter);
|
||||
return branchesToShow.map((b) => {
|
||||
const actual = LOAN_APPLICATIONS.filter((a) => a.branchCode === b.code && a.overallStatus === 'DISBURSED').reduce(
|
||||
(s, a) => s + a.requestedAmount,
|
||||
0
|
||||
);
|
||||
const target = branchTargetAmount(b.code);
|
||||
const achievementPct = target ? (actual / target) * 100 : 0;
|
||||
return {
|
||||
branchCode: b.code,
|
||||
branchName: b.name,
|
||||
target,
|
||||
actual,
|
||||
achievementPct,
|
||||
status: targetStatus(achievementPct, 100, 5)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private get disbursedInScope(): LoanApplication[] {
|
||||
return this.scopedApplications.filter((a) => a.overallStatus === 'DISBURSED');
|
||||
}
|
||||
|
||||
get productTargetRows(): MixTargetRow[] {
|
||||
const total = this.disbursedInScope.reduce((s, a) => s + a.requestedAmount, 0);
|
||||
return this.products.map((p) => {
|
||||
const productAmount = this.disbursedInScope
|
||||
.filter((a) => a.productCode === p.code)
|
||||
.reduce((s, a) => s + a.requestedAmount, 0);
|
||||
const actualPct = total ? (productAmount / total) * 100 : 0;
|
||||
const targetPct = PRODUCT_TARGET_PCT[p.code] || 0;
|
||||
return {
|
||||
code: p.code,
|
||||
label: p.name,
|
||||
targetPct,
|
||||
actualPct,
|
||||
variancePct: actualPct - targetPct,
|
||||
status: targetStatus(actualPct, targetPct, 3)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
get channelTargetRows(): MixTargetRow[] {
|
||||
const total = this.disbursedInScope.reduce((s, a) => s + a.requestedAmount, 0);
|
||||
const channels: Channel[] = [
|
||||
'BRANCH',
|
||||
'BC',
|
||||
'SELF_APPLY'
|
||||
];
|
||||
return channels.map((channel) => {
|
||||
const channelAmount = this.disbursedInScope
|
||||
.filter((a) => a.channel === channel)
|
||||
.reduce((s, a) => s + a.requestedAmount, 0);
|
||||
const actualPct = total ? (channelAmount / total) * 100 : 0;
|
||||
const targetPct = CHANNEL_TARGET_PCT[channel];
|
||||
return {
|
||||
code: channel,
|
||||
label: CHANNEL_LABELS[channel],
|
||||
targetPct,
|
||||
actualPct,
|
||||
variancePct: actualPct - targetPct,
|
||||
status: targetStatus(actualPct, targetPct, 3)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------- New Application intake --------------------
|
||||
productOf(code: string) {
|
||||
return this.products.find((p) => p.code === code) || this.products[0];
|
||||
}
|
||||
|
||||
saveNewApplication(): void {
|
||||
if (!this.newApp.applicantName.trim() || !this.newApp.requestedAmount || this.newApp.requestedAmount <= 0) {
|
||||
this.toast('Enter the applicant name and a valid requested amount.');
|
||||
return;
|
||||
}
|
||||
if (this.newApp.channel === 'BC' && !this.newApp.bcAgentName.trim()) {
|
||||
this.toast('Enter the BC agent/point name for a Business Correspondent application.');
|
||||
return;
|
||||
}
|
||||
// A branch-level login is locked to its own branch regardless of what the (disabled) select
|
||||
// is bound to — defence in depth against the DOM value ever being tampered with.
|
||||
if (!this.isHeadOffice && this.ownBranchCode) {
|
||||
this.newApp.branchCode = this.ownBranchCode;
|
||||
}
|
||||
const branch = this.branches.find((b) => b.code === this.newApp.branchCode) || this.branches[0];
|
||||
const product = this.productOf(this.newApp.productCode);
|
||||
const seq = LOAN_APPLICATIONS.length + 20000;
|
||||
const app = createApplication(
|
||||
{
|
||||
channel: this.newApp.channel,
|
||||
bcAgentName: this.newApp.bcAgentName || '—',
|
||||
customerType: this.newApp.customerType,
|
||||
applicantName: this.newApp.applicantName.trim(),
|
||||
branchCode: branch.code,
|
||||
branchName: branch.name,
|
||||
productCode: product.code,
|
||||
productName: product.name,
|
||||
secured: product.secured,
|
||||
requestedAmount: this.newApp.requestedAmount,
|
||||
tenureMonths: this.newApp.tenureMonths || product.minTenure,
|
||||
interestRate: +((product.minRate + product.maxRate) / 2).toFixed(2)
|
||||
},
|
||||
seq
|
||||
);
|
||||
LOAN_APPLICATIONS.unshift(app);
|
||||
this.toast(`Application ${app.applicationNo} received for ${app.applicantName}.`);
|
||||
this.newApp = {
|
||||
channel: 'BRANCH',
|
||||
bcAgentName: '',
|
||||
customerType: 'NEW',
|
||||
applicantName: '',
|
||||
branchCode: this.isHeadOffice ? this.branches[0].code : this.ownBranchCode || this.branches[0].code,
|
||||
productCode: this.products[0].code,
|
||||
requestedAmount: null,
|
||||
tenureMonths: null
|
||||
};
|
||||
this.router.navigate([
|
||||
'/loan-origination/application',
|
||||
app.applicationNo
|
||||
]);
|
||||
}
|
||||
|
||||
// -------------------- Export to Excel --------------------
|
||||
async exportExcel(): Promise<void> {
|
||||
this.generating = true;
|
||||
try {
|
||||
const subtitle = `Scope: ${this.scopeName} · As On: ${this.asOnDate}`;
|
||||
const sheets: ExportSheetSpec[] = [
|
||||
{
|
||||
sheetName: 'Pipeline',
|
||||
title: 'Loan Origination Pipeline',
|
||||
subtitle,
|
||||
headers: [
|
||||
'Application No',
|
||||
'Applicant',
|
||||
'Branch',
|
||||
'Channel',
|
||||
'Product',
|
||||
'Requested Amount',
|
||||
'Current Stage',
|
||||
'Status'
|
||||
],
|
||||
widths: [
|
||||
16,
|
||||
22,
|
||||
16,
|
||||
22,
|
||||
16,
|
||||
18,
|
||||
24,
|
||||
14
|
||||
],
|
||||
rows: this.filteredApplications.map((a) => [
|
||||
a.applicationNo,
|
||||
a.applicantName,
|
||||
a.branchName,
|
||||
CHANNEL_LABELS[a.channel],
|
||||
a.productName,
|
||||
a.requestedAmount,
|
||||
STAGE_LABELS[a.currentStage],
|
||||
STATUS_LABELS[a.overallStatus]
|
||||
])
|
||||
},
|
||||
{
|
||||
sheetName: 'Channel-wise',
|
||||
title: 'Channel-wise Report',
|
||||
subtitle,
|
||||
headers: [
|
||||
'Channel',
|
||||
'Applications',
|
||||
'Requested Amount',
|
||||
'Disbursed Count',
|
||||
'Disbursed Amount'
|
||||
],
|
||||
widths: [
|
||||
26,
|
||||
16,
|
||||
18,
|
||||
16,
|
||||
18
|
||||
],
|
||||
rows: this.channelReport.map((r) => [
|
||||
r.label,
|
||||
r.count,
|
||||
r.requestedAmount,
|
||||
r.disbursedCount,
|
||||
r.disbursedAmount
|
||||
])
|
||||
},
|
||||
{
|
||||
sheetName: 'BC-wise',
|
||||
title: 'BC-wise Report',
|
||||
subtitle,
|
||||
headers: [
|
||||
'BC Agent / Point',
|
||||
'Applications',
|
||||
'Requested Amount',
|
||||
'Disbursed Count',
|
||||
'Disbursed Amount'
|
||||
],
|
||||
widths: [
|
||||
30,
|
||||
16,
|
||||
18,
|
||||
16,
|
||||
18
|
||||
],
|
||||
rows: this.bcReport.map((r) => [
|
||||
r.bcAgentName,
|
||||
r.count,
|
||||
r.requestedAmount,
|
||||
r.disbursedCount,
|
||||
r.disbursedAmount
|
||||
])
|
||||
},
|
||||
{
|
||||
sheetName: 'Share Member',
|
||||
title: 'Share Member Report',
|
||||
subtitle,
|
||||
headers: [
|
||||
'Application No',
|
||||
'Applicant',
|
||||
'Branch',
|
||||
'Current Stage',
|
||||
'Status'
|
||||
],
|
||||
widths: [
|
||||
16,
|
||||
22,
|
||||
16,
|
||||
24,
|
||||
14
|
||||
],
|
||||
rows: this.nonMemberApplications.map((a) => [
|
||||
a.applicationNo,
|
||||
a.applicantName,
|
||||
a.branchName,
|
||||
STAGE_LABELS[a.currentStage],
|
||||
STATUS_LABELS[a.overallStatus]
|
||||
])
|
||||
},
|
||||
{
|
||||
sheetName: 'Branch Target',
|
||||
title: 'Branch-wise Disbursement Target — Annual',
|
||||
subtitle,
|
||||
headers: [
|
||||
'Branch',
|
||||
'Target',
|
||||
'Disbursed (Actual)',
|
||||
'Achievement %',
|
||||
'Status'
|
||||
],
|
||||
widths: [
|
||||
20,
|
||||
18,
|
||||
18,
|
||||
14,
|
||||
16
|
||||
],
|
||||
rows: this.branchTargetRows.map((r) => [
|
||||
r.branchName,
|
||||
r.target,
|
||||
r.actual,
|
||||
Number(r.achievementPct.toFixed(1)),
|
||||
this.statusLabelOf(r.status)
|
||||
])
|
||||
},
|
||||
{
|
||||
sheetName: 'Product-Channel Target',
|
||||
title: 'Product & Channel Mix — Target % vs Actual %',
|
||||
subtitle,
|
||||
headers: [
|
||||
'Mix',
|
||||
'Target %',
|
||||
'Actual %',
|
||||
'Variance (pp)',
|
||||
'Status'
|
||||
],
|
||||
widths: [
|
||||
26,
|
||||
12,
|
||||
12,
|
||||
14,
|
||||
16
|
||||
],
|
||||
rows: [
|
||||
...this.productTargetRows.map((r) => [
|
||||
`Product — ${r.label}`,
|
||||
r.targetPct,
|
||||
Number(r.actualPct.toFixed(1)),
|
||||
Number(r.variancePct.toFixed(1)),
|
||||
this.statusLabelOf(r.status)
|
||||
]),
|
||||
...this.channelTargetRows.map((r) => [
|
||||
`Channel — ${r.label}`,
|
||||
r.targetPct,
|
||||
Number(r.actualPct.toFixed(1)),
|
||||
Number(r.variancePct.toFixed(1)),
|
||||
this.statusLabelOf(r.status)
|
||||
])
|
||||
]
|
||||
}
|
||||
];
|
||||
await exportWorkbook(sheets, `Loan-Origination-${this.scopeName}-${this.asOnDate}`);
|
||||
} catch (err) {
|
||||
console.error('Loan Origination export failed', err);
|
||||
} finally {
|
||||
this.generating = false;
|
||||
}
|
||||
}
|
||||
|
||||
private toast(msg: string): void {
|
||||
this.toastMsg = msg;
|
||||
this.toastVisible = true;
|
||||
if (this.toastTimer) {
|
||||
clearTimeout(this.toastTimer);
|
||||
}
|
||||
this.toastTimer = setTimeout(() => (this.toastVisible = false), 3200);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user