Compare commits
57
Commits
uate
...
7b895a0e2b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b895a0e2b | ||
|
|
01e86173c1 | ||
|
|
4281a596af | ||
|
|
9f9c9bfab6 | ||
|
|
513e863bba | ||
|
|
13333088d7 | ||
|
|
89bb76455e | ||
|
|
7e69468995 | ||
|
|
63ab9643ce | ||
|
|
8255c8398d | ||
|
|
14c5f5070d | ||
|
|
fcf6665772 | ||
|
|
f72abb0921 | ||
|
|
a1a50ab8a3 | ||
|
|
858c277ae3 | ||
|
|
b609f19e9f | ||
|
|
7f836f6f71 | ||
|
|
a5124ca3e8 | ||
|
|
1a33f9d6ce | ||
|
|
983c613b8e | ||
|
|
ddb215decf | ||
|
|
2cc8d979ba | ||
|
|
569f17ef46 | ||
|
|
cc5cd4c6ab | ||
|
|
504d7ee7f6 | ||
|
|
26091c68b0 | ||
|
|
f8c532f74a | ||
|
|
44b41e504a | ||
|
|
e8d91c8f60 | ||
|
|
8dd9767bdf | ||
|
|
c868beb344 | ||
|
|
b43ed87771 | ||
|
|
ebe67cc410 | ||
|
|
d7f5bfe5f5 | ||
|
|
be6103e0b3 | ||
|
|
5cd9998cfc | ||
|
|
0ca6842bde | ||
|
|
3030e2f34e | ||
|
|
b4608cbbe4 | ||
|
|
27cdaf7fc2 | ||
|
|
90beea64cb | ||
|
|
1317a93838 | ||
|
|
0832017508 | ||
|
|
8ac4010967 | ||
|
|
1c125d97c5 | ||
|
|
c935e2a62a | ||
|
|
2a9df828ca | ||
|
|
bd753f4983 | ||
|
|
4261f9659e | ||
|
|
63ac900e42 | ||
|
|
fd28788d23 | ||
|
|
b9b37397fe | ||
|
|
f2525080c8 | ||
|
|
a982083b14 | ||
|
|
43e3c546a7 | ||
|
|
b68b25f789 | ||
|
|
1dc378c2dd |
+2
-2
@@ -69,8 +69,8 @@
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "13mb",
|
||||
"maximumError": "14mb"
|
||||
"maximumWarning": "15mb",
|
||||
"maximumError": "17mb"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"build": "node version.js && npm run env -s && ng build --configuration production --output-hashing=none",
|
||||
"build:prod": "node version.js && node --max-old-space-size=16384 ./node_modules/@angular/cli/bin/ng build --configuration production --output-hashing=none --base-href=/web-app/",
|
||||
"start": "npm run env -s && ng serve --proxy-config proxy.localhost.conf.js",
|
||||
"start:share": "npm run env -s && ng serve --host 0.0.0.0 --proxy-config proxy.localhost.conf.js",
|
||||
"start:demo": "npm run env -s && ng serve --proxy-config proxy.conf.js",
|
||||
"serve:dev": "npm run env -s && ng serve --source-map --proxy-config proxy.localhost.conf.js",
|
||||
"serve:sw": "npm run build -s && npx http-server ./dist -p 4200",
|
||||
|
||||
@@ -41,7 +41,6 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
|
||||
const STATE_KEY = 'gl_ledger_filter_state';
|
||||
|
||||
@Component({
|
||||
|
||||
@@ -34,6 +34,52 @@ export class AmlService {
|
||||
return this.http.post(`/aml/alerts/${alertId}/review`, { disposition, notes });
|
||||
}
|
||||
|
||||
// ═══ Recurring-alert review queue ═══
|
||||
|
||||
/**
|
||||
* Customers whose transactions repeatedly trip the engine. Each row carries the triggering
|
||||
* alerts, so the reviewer can see why the customer keeps surfacing.
|
||||
*/
|
||||
getRecurringAlerts(minAlerts: number, windowDays: number, onlyNeedingReview: boolean): Observable<any[]> {
|
||||
const params = new HttpParams()
|
||||
.set('minAlerts', String(minAlerts))
|
||||
.set('windowDays', String(windowDays))
|
||||
.set('onlyNeedingReview', String(onlyNeedingReview));
|
||||
return this.http.get<any[]>('/aml/recurring-alerts', { params });
|
||||
}
|
||||
|
||||
/** outcome: 'CLEARED' | 'MONITOR' | 'ESCALATED' */
|
||||
reviewRecurringAlert(
|
||||
clientId: number,
|
||||
outcome: 'CLEARED' | 'MONITOR' | 'ESCALATED',
|
||||
notes: string,
|
||||
windowDays: number
|
||||
): Observable<any> {
|
||||
return this.http.post(`/aml/recurring-alerts/${clientId}/review`, { outcome, notes, windowDays });
|
||||
}
|
||||
|
||||
// ═══ Manual referrals from the general ledger ═══
|
||||
|
||||
/**
|
||||
* Ledger entries a user has already marked, keyed on `journalEntryId`.
|
||||
*
|
||||
* Separate from `getAlerts()` because these are not engine alerts — they carry the
|
||||
* journal entry id rather than a savings or loan transaction id, so the ledger
|
||||
* screen can match them to the rows it is showing.
|
||||
*/
|
||||
getManualReferrals(): Observable<any[]> {
|
||||
return this.http.get<any[]>('/aml/alerts/manual-referrals');
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a ledger entry. WHITELIST clears it with no report; ESCALATE opens an STR
|
||||
* case for the Principal Officer, the same road an escalated engine alert takes.
|
||||
* The entry must have a customer behind it or the server rejects it.
|
||||
*/
|
||||
referJournalEntry(journalEntryId: number, disposition: 'WHITELIST' | 'ESCALATE', notes: string): Observable<any> {
|
||||
return this.http.post('/aml/alerts/manual-referral', { journalEntryId, disposition, notes });
|
||||
}
|
||||
|
||||
// ═══ STR cases (Principal Officer) ═══
|
||||
|
||||
getStrCases(): Observable<any[]> {
|
||||
|
||||
@@ -64,7 +64,12 @@ import { BankCalendarModule } from './bank-calendar/bank-calendar.module';
|
||||
import { DeafModule } from './deaf/deaf.module';
|
||||
import { DicgcModule } from './dicgc/dicgc.module';
|
||||
import { FiuModule } from './fiu/fiu.module';
|
||||
import { GlReportsModule } from './gl-reports/gl-reports.module';
|
||||
import { SftModule } from './sft/sft.module';
|
||||
import { InvestmentModule } from './investment/investment.module';
|
||||
import { NpaModule } from './npa/npa.module';
|
||||
import { ReconciliationModule } from './reconciliation/reconciliation.module';
|
||||
import { LoanOriginationModule } from './loan-origination/loan-origination.module';
|
||||
import { DigitalModule } from './digital/digital.module';
|
||||
import { LetterMgmtModule } from './letter-mgmt/letter-mgmt.module';
|
||||
import { AccountsModule } from './accounts/accounts.module';
|
||||
@@ -171,7 +176,12 @@ export function HttpLoaderFactory(http: HttpClient) {
|
||||
DeafModule,
|
||||
DicgcModule,
|
||||
FiuModule,
|
||||
GlReportsModule,
|
||||
SftModule,
|
||||
InvestmentModule,
|
||||
NpaModule,
|
||||
ReconciliationModule,
|
||||
LoanOriginationModule,
|
||||
DigitalModule,
|
||||
LetterMgmtModule,
|
||||
AccountsModule,
|
||||
|
||||
+292
@@ -0,0 +1,292 @@
|
||||
<!--
|
||||
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">SUB LEDGER · TRANSACTIONS · {{ scopeName | uppercase }}</div>
|
||||
<h1 class="pg-title">Cash / Clearing / Transfer - Summary Report</h1>
|
||||
<p class="pg-sub">
|
||||
Opening, credit, debit and closing position across Cash, Transfer, Clearing and Digital channels as on
|
||||
{{ asOnDate }}.
|
||||
</p>
|
||||
</div>
|
||||
<div class="pg-toolbar">
|
||||
@if (isHeadOffice) {
|
||||
<label class="pg-field">
|
||||
<span>Branch</span>
|
||||
<select class="pg-select" [ngModel]="selectedBranchCode" (ngModelChange)="setBranch($event)">
|
||||
<option value="ALL">All Branches (Consolidated)</option>
|
||||
@for (b of branches; track b.code) {
|
||||
<option [value]="b.code">{{ b.name }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
}
|
||||
<button type="button" class="pg-btn" [class.pg-btn--active]="showDetails" (click)="toggleDetails()">
|
||||
{{ showDetails ? 'Summary View' : 'Transaction Details' }}
|
||||
</button>
|
||||
<button type="button" class="pg-btn pg-btn--ghost" [disabled]="generating" (click)="exportExcel()">
|
||||
{{ generating ? 'Exporting…' : 'Export to Excel' }}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="cctr-grand-summary">
|
||||
<div class="cctr-grand-summary__item">
|
||||
<span class="cctr-grand-summary__label">Opening</span>
|
||||
<span class="cctr-grand-summary__value">₹{{ grandOpening | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
<div class="cctr-grand-summary__item">
|
||||
<span class="cctr-grand-summary__label">Credit</span>
|
||||
<span class="cctr-grand-summary__value col-in">₹{{ grandCredit | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
<div class="cctr-grand-summary__item">
|
||||
<span class="cctr-grand-summary__label">Debit</span>
|
||||
<span class="cctr-grand-summary__value col-out">₹{{ grandDebit | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
<div class="cctr-grand-summary__item">
|
||||
<span class="cctr-grand-summary__label">Closing</span>
|
||||
<span class="cctr-grand-summary__value">₹{{ grandClosing | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (isHeadOffice && selectedBranchCode === 'ALL') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Branch-wise Break-up — click a branch to drill in</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Branch</th>
|
||||
<th class="num">Opening</th>
|
||||
<th class="num col-in">Credit</th>
|
||||
<th class="num col-out">Debit</th>
|
||||
<th class="num col-net">Closing</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (b of branchBreakup; track b.code) {
|
||||
<tr class="cctr-branch-row" (click)="setBranch(b.code)">
|
||||
<td>{{ b.name }}</td>
|
||||
<td class="num">₹{{ b.opening | number: '1.2-2' }}</td>
|
||||
<td class="num col-in">₹{{ b.credit | number: '1.2-2' }}</td>
|
||||
<td class="num col-out">₹{{ b.debit | number: '1.2-2' }}</td>
|
||||
<td class="num col-net">₹{{ b.closing | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="cctr-total-row">
|
||||
<td>All Branches</td>
|
||||
<td class="num">₹{{ grandOpening | number: '1.2-2' }}</td>
|
||||
<td class="num">₹{{ grandCredit | number: '1.2-2' }}</td>
|
||||
<td class="num">₹{{ grandDebit | number: '1.2-2' }}</td>
|
||||
<td class="num">₹{{ grandClosing | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="cctr-tabs">
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="activeTab === 'cash'"
|
||||
(click)="setTab('cash')"
|
||||
>
|
||||
Cash
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="activeTab === 'transfer'"
|
||||
(click)="setTab('transfer')"
|
||||
>
|
||||
Transfer
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="activeTab === 'clearing'"
|
||||
(click)="setTab('clearing')"
|
||||
>
|
||||
Clearing
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="activeTab === 'digital'"
|
||||
(click)="setTab('digital')"
|
||||
>
|
||||
Digital
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Summary (Opening | Credit | Debit | Closing) -->
|
||||
@if (!showDetails) {
|
||||
<div class="pg-card">
|
||||
@if (activeTab === 'digital') {
|
||||
<div class="pos-section-label">RTGS · NEFT · UPI · ATM · NACH — Overall Position</div>
|
||||
}
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
@if (activeTab !== 'digital') {
|
||||
<th>GL Code</th>
|
||||
}
|
||||
<th>Particulars</th>
|
||||
<th class="num">Opening</th>
|
||||
<th class="num col-in">Credit</th>
|
||||
<th class="num col-out">Debit</th>
|
||||
<th class="num col-net">Closing</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (r of rowsForActiveTab; track r.code) {
|
||||
<tr>
|
||||
@if (activeTab !== 'digital') {
|
||||
<td>
|
||||
<span class="pg-chip">{{ r.code }}</span>
|
||||
</td>
|
||||
}
|
||||
<td>{{ r.name }}</td>
|
||||
<td class="num">₹{{ r.opening | number: '1.2-2' }}</td>
|
||||
<td class="num col-in">₹{{ rowCredit(r, txnsForActiveTab) | number: '1.2-2' }}</td>
|
||||
<td class="num col-out">₹{{ rowDebit(r, txnsForActiveTab) | number: '1.2-2' }}</td>
|
||||
<td class="num col-net">₹{{ closing(r, txnsForActiveTab) | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="cctr-total-row">
|
||||
<td [attr.colspan]="activeTab !== 'digital' ? 2 : 1">Total</td>
|
||||
<td class="num">₹{{ total(rowsForActiveTab, txnsForActiveTab, 'opening') | number: '1.2-2' }}</td>
|
||||
<td class="num">₹{{ total(rowsForActiveTab, txnsForActiveTab, 'credit') | number: '1.2-2' }}</td>
|
||||
<td class="num">₹{{ total(rowsForActiveTab, txnsForActiveTab, 'debit') | number: '1.2-2' }}</td>
|
||||
<td class="num">₹{{ totalClosing(rowsForActiveTab, txnsForActiveTab) | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Transaction Details (all txns for the selected category, optionally filtered by GL/channel) -->
|
||||
@if (showDetails) {
|
||||
@if (rowsForActiveTab.length > 1) {
|
||||
<div class="cctr-tabs">
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="glFilter === null"
|
||||
(click)="setGlFilter(null)"
|
||||
>
|
||||
All
|
||||
</button>
|
||||
@for (r of rowsForActiveTab; track r.code) {
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="glFilter === r.code"
|
||||
(click)="setGlFilter(r.code)"
|
||||
>
|
||||
{{ filterLabel(r) }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">
|
||||
{{
|
||||
activeTab === 'cash'
|
||||
? 'Cash'
|
||||
: activeTab === 'transfer'
|
||||
? 'Transfer'
|
||||
: activeTab === 'clearing'
|
||||
? 'Clearing'
|
||||
: 'Digital'
|
||||
}}
|
||||
— Transaction Details (Full Double Entry)
|
||||
@if (glFilter !== null) {
|
||||
· {{ glFilter }}
|
||||
}
|
||||
</div>
|
||||
<table class="pg-table cctr-journal">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Voucher / Ref No</th>
|
||||
<th>Dr / Cr</th>
|
||||
<th>GL / Account</th>
|
||||
<th class="num col-out">Debit</th>
|
||||
<th class="num col-in">Credit</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (t of filteredTxns; track t.voucherNo) {
|
||||
<tr class="cctr-journal-row cctr-journal-row--first">
|
||||
<td>{{ asOnDate }}</td>
|
||||
<td>
|
||||
<span class="pg-chip">{{ display(t.voucherNo) }}</span>
|
||||
</td>
|
||||
<td><span class="cctr-drcr cctr-drcr--dr">Dr</span></td>
|
||||
<td>
|
||||
@if (t.debit > 0) {
|
||||
<span class="pg-chip">{{ t.glCode }}</span> {{ glName(t.glCode) }} — {{ display(t.accountNo) }} ({{
|
||||
t.accountName
|
||||
}})
|
||||
} @else {
|
||||
<span class="cctr-contra">{{ contraLabel(t) }}</span>
|
||||
}
|
||||
</td>
|
||||
<td class="num col-out">₹{{ legAmount(t) | number: '1.2-2' }}</td>
|
||||
<td class="num"></td>
|
||||
<td rowspan="2">
|
||||
<span class="cctr-balanced-chip">✓ Balanced</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="cctr-journal-row cctr-journal-row--second">
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td><span class="cctr-drcr cctr-drcr--cr">Cr</span></td>
|
||||
<td>
|
||||
@if (t.credit > 0) {
|
||||
<span class="pg-chip">{{ t.glCode }}</span> {{ glName(t.glCode) }} — {{ display(t.accountNo) }} ({{
|
||||
t.accountName
|
||||
}})
|
||||
} @else {
|
||||
<span class="cctr-contra">{{ contraLabel(t) }}</span>
|
||||
}
|
||||
</td>
|
||||
<td class="num"></td>
|
||||
<td class="num col-in">₹{{ legAmount(t) | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="7" class="pg-empty">No transactions for this filter.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="cctr-total-row">
|
||||
<td colspan="4">Total ({{ filteredTxns.length }} vouchers)</td>
|
||||
<td class="num">
|
||||
₹{{ txnTotal(filteredTxns, 'debit') + txnTotal(filteredTxns, 'credit') | number: '1.2-2' }}
|
||||
</td>
|
||||
<td class="num">
|
||||
₹{{ txnTotal(filteredTxns, 'debit') + txnTotal(filteredTxns, 'credit') | number: '1.2-2' }}
|
||||
</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
.cctr-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.cctr-total-row {
|
||||
td {
|
||||
background: #f1f5f9;
|
||||
font-weight: 800;
|
||||
border-top: 2px solid #cbd5e1;
|
||||
border-bottom: none;
|
||||
color: #0f172a;
|
||||
}
|
||||
}
|
||||
|
||||
.cctr-grand-summary {
|
||||
display: flex;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
|
||||
&__item {
|
||||
flex: 1;
|
||||
padding: 14px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
border-right: 1px solid #e2e8f0;
|
||||
|
||||
&:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
&__value {
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: #0f172a;
|
||||
}
|
||||
}
|
||||
|
||||
.cctr-subtext {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.cctr-branch-row {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover td {
|
||||
background: #eff6ff;
|
||||
}
|
||||
}
|
||||
|
||||
// Both journal lines of one voucher share a tint and sit flush against each other (no dotted
|
||||
// divider between them) so the pair reads as a single entry; a hairline separates one voucher's
|
||||
// pair from the next.
|
||||
.cctr-journal-row {
|
||||
&--first td {
|
||||
border-bottom: none;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
&--second td {
|
||||
border-top: none;
|
||||
padding-top: 4px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
&--first,
|
||||
&--second {
|
||||
&:hover td {
|
||||
background: #f8fafc;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cctr-drcr {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 28px;
|
||||
padding: 1px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.03em;
|
||||
|
||||
&--dr {
|
||||
background: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
border: 1px solid #bfdbfe;
|
||||
}
|
||||
|
||||
&--cr {
|
||||
background: #f0fdf4;
|
||||
color: #15803d;
|
||||
border: 1px solid #bbf7d0;
|
||||
}
|
||||
}
|
||||
|
||||
.cctr-contra {
|
||||
color: #64748b;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.cctr-balanced-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
background: #f0fdf4;
|
||||
color: #15803d;
|
||||
border: 1px solid #bbf7d0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
+1802
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
// Shared "Export to Excel" builder for Cashier reports (mirrors ../investment/investment-export.ts)
|
||||
// — centralised so the ExcelJS boilerplate (title/meta/header rows, number formatting, safe sheet
|
||||
// names, Blob download) is written once and every cashier report's export behaves the same way.
|
||||
|
||||
import * as ExcelJS from 'exceljs';
|
||||
|
||||
export interface ExportSheetSpec {
|
||||
/** Worksheet tab name — sanitised for Excel's forbidden characters (\ / ? * [ ]) and length. */
|
||||
sheetName: string;
|
||||
/** Bold title row, merged across all columns. */
|
||||
title: string;
|
||||
/** Optional italic subtitle row (e.g. scope/date), merged across all columns. */
|
||||
subtitle?: string;
|
||||
headers: string[];
|
||||
rows: (string | number | null)[][];
|
||||
widths?: number[];
|
||||
}
|
||||
|
||||
/** Excel worksheet names can't contain \ / ? * [ ] or exceed 31 chars. */
|
||||
function safeSheetName(name: string): string {
|
||||
return name.replace(/[\\/?*[\]]/g, '-').slice(0, 31);
|
||||
}
|
||||
|
||||
function buildSheet(wb: ExcelJS.Workbook, spec: ExportSheetSpec): void {
|
||||
const sheet = wb.addWorksheet(safeSheetName(spec.sheetName));
|
||||
sheet.columns = (spec.widths || spec.headers.map(() => 18)).map((w) => ({ width: w }));
|
||||
|
||||
const title = sheet.addRow([spec.title]);
|
||||
sheet.mergeCells(title.number, 1, title.number, spec.headers.length);
|
||||
title.font = { bold: true, size: 14, color: { argb: 'FFFFFFFF' } };
|
||||
title.alignment = { horizontal: 'center', vertical: 'middle' };
|
||||
title.height = 24;
|
||||
title.getCell(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF0F4F3C' } };
|
||||
|
||||
if (spec.subtitle) {
|
||||
const meta = sheet.addRow([spec.subtitle]);
|
||||
sheet.mergeCells(meta.number, 1, meta.number, spec.headers.length);
|
||||
meta.font = { italic: true, size: 10 };
|
||||
}
|
||||
sheet.addRow([]);
|
||||
|
||||
const head = sheet.addRow(spec.headers);
|
||||
head.font = { bold: true };
|
||||
head.alignment = { wrapText: true, horizontal: 'center' };
|
||||
head.eachCell((c) => {
|
||||
c.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE2E8F0' } };
|
||||
c.border = { bottom: { style: 'thin' } };
|
||||
});
|
||||
|
||||
for (const r of spec.rows) {
|
||||
const row = sheet.addRow(r);
|
||||
r.forEach((v, idx) => {
|
||||
if (typeof v === 'number') {
|
||||
row.getCell(idx + 1).numFmt = '#,##0.00';
|
||||
}
|
||||
});
|
||||
}
|
||||
sheet.views = [{ state: 'frozen', ySplit: spec.subtitle ? 4 : 3 }];
|
||||
}
|
||||
|
||||
/** Builds a (possibly multi-sheet) workbook and triggers a browser download. */
|
||||
export async function exportWorkbook(sheets: ExportSheetSpec[], fileName: string): Promise<void> {
|
||||
const wb = new ExcelJS.Workbook();
|
||||
wb.creator = 'CBSNexora';
|
||||
wb.created = new Date();
|
||||
sheets.forEach((s) => buildSheet(wb, s));
|
||||
|
||||
const buffer = await wb.xlsx.writeBuffer();
|
||||
const blob = new Blob([buffer], {
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${fileName.replace(/[^\w]+/g, '-')}.xlsx`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
@@ -62,6 +62,22 @@ const routes: Routes = [
|
||||
),
|
||||
data: { title: 'My Disbursement Requests', breadcrumb: 'My Disbursement Requests' }
|
||||
},
|
||||
{
|
||||
path: 'share-withdrawal-request',
|
||||
loadComponent: () =>
|
||||
import('../clerk/share-withdrawal-request/share-withdrawal-request.component').then(
|
||||
(m) => m.ShareWithdrawalRequestComponent
|
||||
),
|
||||
data: { title: 'New Share Withdrawal Request', breadcrumb: 'New Share Withdrawal Request', mode: 'create' }
|
||||
},
|
||||
{
|
||||
path: 'share-withdrawal-requests',
|
||||
loadComponent: () =>
|
||||
import('../clerk/my-share-withdrawal-requests/my-share-withdrawal-requests.component').then(
|
||||
(m) => m.MyShareWithdrawalRequestsComponent
|
||||
),
|
||||
data: { title: 'My Share Requests', breadcrumb: 'My Share Requests' }
|
||||
},
|
||||
{
|
||||
path: 'account-facilities',
|
||||
loadComponent: () =>
|
||||
@@ -179,6 +195,17 @@ const routes: Routes = [
|
||||
loadComponent: () =>
|
||||
import('./my-deposit-requests/my-deposit-requests.component').then((m) => m.MyDepositRequestsComponent),
|
||||
data: { title: 'My Deposit Requests', breadcrumb: 'My Deposit Requests' }
|
||||
},
|
||||
{
|
||||
path: 'cash-clearing-transfer-summary',
|
||||
loadComponent: () =>
|
||||
import('./cash-clearing-transfer-summary/cash-clearing-transfer-summary.component').then(
|
||||
(m) => m.CashClearingTransferSummaryComponent
|
||||
),
|
||||
data: {
|
||||
title: 'Cash / Clearing / Transfer - Summary Report',
|
||||
breadcrumb: 'Cash / Clearing / Transfer - Summary Report'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* 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 { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
/** HTTP service for the Share Withdrawal (redeem shares) maker-checker workflow — mirrors
|
||||
* ClerkWithdrawalService (cash withdrawal), adapted for shares. */
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ClerkShareWithdrawalService {
|
||||
private http = inject(HttpClient);
|
||||
|
||||
submit(data: any): Observable<any> {
|
||||
return this.http.post('/clerk/share-withdrawal-requests', data);
|
||||
}
|
||||
|
||||
getMyRequests(status?: string, offset = 0, limit = 20, fromDate?: string, toDate?: string): Observable<any> {
|
||||
let params = new HttpParams().set('offset', String(offset)).set('limit', String(limit));
|
||||
if (status) params = params.set('status', status);
|
||||
if (fromDate) params = params.set('fromDate', fromDate);
|
||||
if (toDate) params = params.set('toDate', toDate);
|
||||
return this.http.get('/clerk/share-withdrawal-requests/my', { params });
|
||||
}
|
||||
|
||||
getPendingRequests(status = 'PENDING', offset = 0, limit = 20, fromDate?: string, toDate?: string): Observable<any> {
|
||||
let params = new HttpParams().set('status', status).set('offset', String(offset)).set('limit', String(limit));
|
||||
if (fromDate) params = params.set('fromDate', fromDate);
|
||||
if (toDate) params = params.set('toDate', toDate);
|
||||
return this.http.get('/clerk/share-withdrawal-requests', { params });
|
||||
}
|
||||
|
||||
getById(id: number): Observable<any> {
|
||||
return this.http.get(`/clerk/share-withdrawal-requests/${id}`);
|
||||
}
|
||||
|
||||
validate(id: number): Observable<any> {
|
||||
return this.http.post(`/clerk/share-withdrawal-requests/${id}/validate`, {});
|
||||
}
|
||||
|
||||
approve(id: number): Observable<any> {
|
||||
return this.http.post(`/clerk/share-withdrawal-requests/${id}/approve`, {});
|
||||
}
|
||||
|
||||
reject(id: number, reason: string): Observable<any> {
|
||||
return this.http.post(`/clerk/share-withdrawal-requests/${id}/reject`, { reason });
|
||||
}
|
||||
}
|
||||
@@ -146,7 +146,7 @@
|
||||
[class.ws-f__ctl--req]="!row.paymentTypeId"
|
||||
>
|
||||
<option [ngValue]="null">— Select —</option>
|
||||
@for (pt of paymentTypes; track pt.id) {
|
||||
@for (pt of paymentTypesForRow(row); track pt.id) {
|
||||
<option [ngValue]="pt.id">{{ pt.name }}</option>
|
||||
}
|
||||
</select>
|
||||
@@ -231,8 +231,11 @@
|
||||
<span class="ws-caption">Payment instrument</span>
|
||||
<div class="ws-group__fields">
|
||||
<div class="ws-f ws-f--amt">
|
||||
<label class="ws-f__lbl" title="Transaction Amount">
|
||||
AMOUNT
|
||||
<label
|
||||
class="ws-f__lbl"
|
||||
[title]="row.acTyp === 'SHR' ? 'Number of Shares' : 'Transaction Amount'"
|
||||
>
|
||||
{{ row.acTyp === 'SHR' ? 'SHARE NO.' : 'AMOUNT' }}
|
||||
@if (!isClerk && activeWithdrawalRequest) {
|
||||
<span class="ws-amt-locked-badge" title="Amount locked — approved withdrawal">🔒</span>
|
||||
}
|
||||
@@ -242,20 +245,48 @@
|
||||
[class.ws-amt-wrap--cash]="isCashPayment && selectedRow === row"
|
||||
[class.ws-amt-wrap--locked]="!isClerk && !!activeWithdrawalRequest"
|
||||
>
|
||||
<span class="ws-amt-prefix">₹</span>
|
||||
@if (row.acTyp !== 'SHR') {
|
||||
<span class="ws-amt-prefix">₹</span>
|
||||
}
|
||||
<input
|
||||
class="ws-f__ctl ws-amt-input"
|
||||
type="number"
|
||||
[value]="row.amount ?? ''"
|
||||
(input)="row.amount = +$any($event.target).value"
|
||||
(input)="row.amount = +$any($event.target).value; syncShareLinkedAmounts()"
|
||||
(click)="$event.stopPropagation()"
|
||||
placeholder="0.00"
|
||||
[placeholder]="row.acTyp === 'SHR' ? '0' : '0.00'"
|
||||
[readonly]="!isClerk && !!activeWithdrawalRequest"
|
||||
[class.ws-amt-input--locked]="!isClerk && !!activeWithdrawalRequest"
|
||||
/>
|
||||
</div>
|
||||
@if (row.acTyp === 'SHR' && rowSharePrices[row.acNo]) {
|
||||
<span class="ws-f__hint"
|
||||
>= ₹{{ (row.amount ?? 0) * rowSharePrices[row.acNo] | number: '1.2-2' }} @ ₹{{
|
||||
rowSharePrices[row.acNo] | number: '1.2-2'
|
||||
}}/share</span
|
||||
>
|
||||
}
|
||||
</div>
|
||||
@if (row.typ !== 'Withdrawal Slip') {
|
||||
@if (row.acTyp === 'SHR' && row.typ === 'Enter Bank Transfer') {
|
||||
<div class="ws-f ws-f--cheqno">
|
||||
<label class="ws-f__lbl" title="Board Resolution Number">RESOLUTION NO.</label>
|
||||
<input
|
||||
class="ws-f__ctl"
|
||||
[(ngModel)]="row.resolutionNumber"
|
||||
(click)="$event.stopPropagation()"
|
||||
placeholder="e.g. BR-2026-014"
|
||||
/>
|
||||
</div>
|
||||
<div class="ws-f ws-f--cheqdt">
|
||||
<label class="ws-f__lbl" title="Date of Meeting">MEETING DATE</label>
|
||||
<input
|
||||
class="ws-f__ctl"
|
||||
type="date"
|
||||
[(ngModel)]="row.meetingDate"
|
||||
(click)="$event.stopPropagation()"
|
||||
/>
|
||||
</div>
|
||||
} @else if (row.typ !== 'Withdrawal Slip') {
|
||||
<div class="ws-f ws-f--cheqno">
|
||||
<label class="ws-f__lbl" title="Cheque Number">CHEQUE NO.</label>
|
||||
<input
|
||||
@@ -712,6 +743,32 @@
|
||||
'—'
|
||||
}}</span>
|
||||
</div>
|
||||
@if (selectedRow?.acTyp === 'SHR') {
|
||||
<div class="te-field">
|
||||
<span class="te-label">No. of Shares Held</span
|
||||
><span class="te-value te-mono">{{ accountDetails.summary?.totalApprovedShares ?? 0 }}</span>
|
||||
</div>
|
||||
<div class="te-field">
|
||||
<span class="te-label">Market Price / Share</span
|
||||
><span class="te-value te-mono"
|
||||
>{{ currencySymbol }}{{ accountDetails.currentMarketPrice | number: '1.2-2' }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="te-field">
|
||||
<span class="te-label">Resolution No.</span
|
||||
><span class="te-value te-mono">{{ accountDetails.resolutionNumber || '—' }}</span>
|
||||
</div>
|
||||
<div class="te-field">
|
||||
<span class="te-label">Linked Savings A/C No.</span
|
||||
><span class="te-value te-mono">{{ accountDetails.savingsAccountNumber || '—' }}</span>
|
||||
</div>
|
||||
@if (accountDetails.linkedLoanAccountNo) {
|
||||
<div class="te-field">
|
||||
<span class="te-label">Linked Loan A/C No.</span
|
||||
><span class="te-value te-mono">{{ accountDetails.linkedLoanAccountNo }}</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="te-panel__header te-panel__header--mt">
|
||||
@@ -724,7 +781,13 @@
|
||||
<div class="te-panel__body">
|
||||
<div class="te-balance-row">
|
||||
<span class="te-bal-num">1.</span>
|
||||
<span class="te-bal-label">{{ accountDetails?.glCode ? 'GL Balance' : 'Current Balance' }}</span>
|
||||
<span class="te-bal-label">{{
|
||||
selectedRow?.acTyp === 'SHR'
|
||||
? 'Share Balance'
|
||||
: accountDetails?.glCode
|
||||
? 'GL Balance'
|
||||
: 'Current Balance'
|
||||
}}</span>
|
||||
<span class="te-bal-value te-bal-value--cr"
|
||||
>{{ currencySymbol }}{{ currentBalance | number: '1.2-2' }}</span
|
||||
>
|
||||
@@ -1281,7 +1344,7 @@
|
||||
[class.ws-f__ctl--req]="!row.paymentTypeId"
|
||||
>
|
||||
<option [ngValue]="null">— Select —</option>
|
||||
@for (pt of paymentTypes; track pt.id) {
|
||||
@for (pt of paymentTypesForRow(row); track pt.id) {
|
||||
<option [ngValue]="pt.id">{{ pt.name }}</option>
|
||||
}
|
||||
</select>
|
||||
@@ -1360,7 +1423,7 @@
|
||||
<div class="ws-group__fields">
|
||||
<div class="ws-f ws-f--amt">
|
||||
<label class="ws-f__lbl">
|
||||
AMOUNT
|
||||
{{ row.acTyp === 'SHR' ? 'SHARE NO.' : 'AMOUNT' }}
|
||||
@if (!isClerk && activeWithdrawalRequest) {
|
||||
<span class="ws-amt-locked-badge" title="Amount locked — approved withdrawal">🔒</span>
|
||||
}
|
||||
@@ -1370,27 +1433,51 @@
|
||||
[class.ws-amt-wrap--cash]="isCashPayment && selectedRow === row"
|
||||
[class.ws-amt-wrap--locked]="!isClerk && !!activeWithdrawalRequest"
|
||||
>
|
||||
<span class="ws-amt-prefix">₹</span>
|
||||
@if (row.acTyp !== 'SHR') {
|
||||
<span class="ws-amt-prefix">₹</span>
|
||||
}
|
||||
<input
|
||||
class="ws-f__ctl ws-amt-input"
|
||||
type="number"
|
||||
[value]="row.amount ?? ''"
|
||||
(input)="row.amount = +$any($event.target).value"
|
||||
(input)="row.amount = +$any($event.target).value; syncShareLinkedAmounts()"
|
||||
(click)="$event.stopPropagation()"
|
||||
placeholder="0.00"
|
||||
[placeholder]="row.acTyp === 'SHR' ? '0' : '0.00'"
|
||||
[readonly]="!isClerk && !!activeWithdrawalRequest"
|
||||
[class.ws-amt-input--locked]="!isClerk && !!activeWithdrawalRequest"
|
||||
/>
|
||||
</div>
|
||||
@if (row.acTyp === 'SHR' && rowSharePrices[row.acNo]) {
|
||||
<span class="ws-f__hint"
|
||||
>= ₹{{ (row.amount ?? 0) * rowSharePrices[row.acNo] | number: '1.2-2' }} @ ₹{{
|
||||
rowSharePrices[row.acNo] | number: '1.2-2'
|
||||
}}/share</span
|
||||
>
|
||||
}
|
||||
</div>
|
||||
<div class="ws-f ws-f--cheqno">
|
||||
<label class="ws-f__lbl">CHEQUE NO.</label>
|
||||
<input class="ws-f__ctl ws-f__ctl--mono" [(ngModel)]="row.chequeNo" placeholder="—" />
|
||||
</div>
|
||||
<div class="ws-f ws-f--cheqdt">
|
||||
<label class="ws-f__lbl">CHEQ. DATE</label>
|
||||
<input class="ws-f__ctl" [(ngModel)]="row.cheqDate" placeholder="dd-mm-yyyy" />
|
||||
</div>
|
||||
@if (row.acTyp === 'SHR' && row.typ === 'Enter Bank Transfer') {
|
||||
<div class="ws-f ws-f--cheqno">
|
||||
<label class="ws-f__lbl">RESOLUTION NO.</label>
|
||||
<input
|
||||
class="ws-f__ctl"
|
||||
[(ngModel)]="row.resolutionNumber"
|
||||
placeholder="e.g. BR-2026-014"
|
||||
/>
|
||||
</div>
|
||||
<div class="ws-f ws-f--cheqdt">
|
||||
<label class="ws-f__lbl">MEETING DATE</label>
|
||||
<input class="ws-f__ctl" type="date" [(ngModel)]="row.meetingDate" />
|
||||
</div>
|
||||
} @else {
|
||||
<div class="ws-f ws-f--cheqno">
|
||||
<label class="ws-f__lbl">CHEQUE NO.</label>
|
||||
<input class="ws-f__ctl ws-f__ctl--mono" [(ngModel)]="row.chequeNo" placeholder="—" />
|
||||
</div>
|
||||
<div class="ws-f ws-f--cheqdt">
|
||||
<label class="ws-f__lbl">CHEQ. DATE</label>
|
||||
<input class="ws-f__ctl" [(ngModel)]="row.cheqDate" placeholder="dd-mm-yyyy" />
|
||||
</div>
|
||||
}
|
||||
<div class="ws-f ws-f--tok">
|
||||
<label class="ws-f__lbl">TOK. NO.</label>
|
||||
<input
|
||||
@@ -1837,6 +1924,15 @@
|
||||
@for (line of alertModal.lines; track $index) {
|
||||
@if ($index === 0) {
|
||||
<p class="te-alert-box__desc">{{ line }}</p>
|
||||
} @else if (line.startsWith('⚠')) {
|
||||
<div class="te-alert-box__callout">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
|
||||
<line x1="12" y1="9" x2="12" y2="13" />
|
||||
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||
</svg>
|
||||
<span>{{ line.slice(1).trim() }}</span>
|
||||
</div>
|
||||
} @else if (line.includes(':') && $index < alertModal.lines.length - 1) {
|
||||
<div class="te-alert-box__kv">
|
||||
<span class="te-alert-box__kv-k">{{ line.split(':')[0].trim() }}</span>
|
||||
|
||||
@@ -961,6 +961,14 @@ input.ws-amt-input--locked {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ws-f__hint {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
font-size: 10px;
|
||||
color: $ws-muted;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ws-amt-input {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
@@ -1995,12 +2003,12 @@ select.te-cell {
|
||||
|
||||
.te-alert-box {
|
||||
display: flex;
|
||||
width: 480px;
|
||||
max-width: 96vw;
|
||||
border-radius: 16px;
|
||||
width: 400px;
|
||||
max-width: 94vw;
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
box-shadow:
|
||||
0 32px 80px rgb(0 0 0 / 35%),
|
||||
0 24px 60px rgb(0 0 0 / 32%),
|
||||
0 0 0 1px rgb(255 255 255 / 8%);
|
||||
animation: te-pop-in 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
|
||||
@@ -2010,32 +2018,35 @@ select.te-cell {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
width: 76px;
|
||||
gap: 8px;
|
||||
width: 56px;
|
||||
flex-shrink: 0;
|
||||
padding: 24px 0;
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
&__sidebar-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgb(255 255 255 / 20%);
|
||||
box-shadow:
|
||||
0 0 0 1px rgb(255 255 255 / 15%),
|
||||
inset 0 1px 2px rgb(255 255 255 / 25%);
|
||||
|
||||
svg {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
stroke: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
&__sidebar-type {
|
||||
font-size: 8px;
|
||||
font-size: 7px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.12em;
|
||||
letter-spacing: 0.1em;
|
||||
color: rgb(255 255 255 / 70%);
|
||||
text-transform: uppercase;
|
||||
writing-mode: vertical-lr;
|
||||
@@ -2045,6 +2056,7 @@ select.te-cell {
|
||||
// ── Right content ─────────────────────────────────────────────────────────
|
||||
&__content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -2054,12 +2066,12 @@ select.te-cell {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
padding: 20px 20px 12px;
|
||||
padding: 14px 16px 8px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-size: 13.5px;
|
||||
font-weight: 800;
|
||||
color: #0f172a;
|
||||
letter-spacing: -0.02em;
|
||||
@@ -2083,52 +2095,87 @@ select.te-cell {
|
||||
|
||||
&__lines {
|
||||
flex: 1;
|
||||
padding: 14px 20px;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
padding: 10px 16px;
|
||||
}
|
||||
|
||||
&__desc {
|
||||
margin: 0 0 10px;
|
||||
font-size: 12.5px;
|
||||
margin: 0 0 8px;
|
||||
font-size: 11.5px;
|
||||
color: #64748b;
|
||||
line-height: 1.55;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
&__kv {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 7px 12px;
|
||||
margin: 4px 0;
|
||||
gap: 10px;
|
||||
padding: 5px 10px;
|
||||
margin: 3px 0;
|
||||
background: #f8fafc;
|
||||
border-radius: 8px;
|
||||
border-radius: 7px;
|
||||
border-left: 3px solid #e2e8f0;
|
||||
transition: background-color 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
}
|
||||
|
||||
&__kv-k {
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
&__kv-v {
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
&__foot {
|
||||
margin: 10px 0 0;
|
||||
font-size: 11px;
|
||||
margin: 8px 0 0;
|
||||
font-size: 10px;
|
||||
color: #94a3b8;
|
||||
font-style: italic;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
&__callout {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin: 8px 0;
|
||||
padding: 8px 10px;
|
||||
background: #fffbeb;
|
||||
border: 1px solid #fde68a;
|
||||
border-left: 3px solid #d97706;
|
||||
border-radius: 9px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #92400e;
|
||||
line-height: 1.45;
|
||||
animation: te-pop-in 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
|
||||
svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
color: #d97706;
|
||||
}
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 14px 20px;
|
||||
padding: 10px 16px;
|
||||
border-top: 1px solid #f1f5f9;
|
||||
background: #fafafa;
|
||||
}
|
||||
@@ -2196,11 +2243,11 @@ select.te-cell {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 8px 20px;
|
||||
min-width: 90px;
|
||||
border-radius: 8px;
|
||||
padding: 7px 16px;
|
||||
min-width: 76px;
|
||||
border-radius: 7px;
|
||||
border: none;
|
||||
font-size: 12.5px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
|
||||
@@ -21,6 +21,7 @@ import { AuthenticationService } from '../../core/authentication/authentication.
|
||||
import { ClerkUiStateService, ClerkSavedAccount } from '../clerk-ui-state.service';
|
||||
import { ClerkWithdrawalService } from '../clerk-withdrawal.service';
|
||||
import { ClerkDisbursementService } from '../clerk-disbursement.service';
|
||||
import { ClerkShareWithdrawalService } from '../clerk-share-withdrawal.service';
|
||||
import { CashierDepositService } from '../cashier-deposit.service';
|
||||
import { SettingsService } from '../../settings/settings.service';
|
||||
import {
|
||||
@@ -47,6 +48,10 @@ interface TxnRow {
|
||||
routingCode: string;
|
||||
/** DD issuance charges/commission — only used when TYPE=DD, TXN TP=Cash, AC.TP=GL */
|
||||
ddCharges: number | null;
|
||||
/** Only used when this row is AC.TP=SHR inside an Enter Bank Transfer to its linked savings
|
||||
* account — governs the share redemption (see submitShareRedemptionTransfer()). */
|
||||
resolutionNumber?: string;
|
||||
meetingDate?: string;
|
||||
}
|
||||
|
||||
interface Denomination {
|
||||
@@ -753,6 +758,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
private clerkUiState = inject(ClerkUiStateService);
|
||||
private clerkWithdrawalService = inject(ClerkWithdrawalService);
|
||||
private clerkDisbursementService = inject(ClerkDisbursementService);
|
||||
private clerkShareWithdrawalService = inject(ClerkShareWithdrawalService);
|
||||
private cashierDepositService = inject(CashierDepositService);
|
||||
readonly router = inject(Router);
|
||||
private settingsService = inject(SettingsService);
|
||||
@@ -769,9 +775,41 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
loadingAccount = false;
|
||||
accountError = '';
|
||||
|
||||
/** Share redemption settlement is always a book-transfer to the linked savings account — never a
|
||||
* cash handover. A Share (SHR) row's own TXN TP. never offers a cash payment type, and neither
|
||||
* does its paired CR (savings) row in "Enter Bank Transfer" mode — that CR row's payment type is
|
||||
* the one that actually governs how the settlement credit posts (see submitShareRedemptionTransfer()).
|
||||
* Every other account type/mode keeps the full, unfiltered list. */
|
||||
paymentTypesForRow(row: TxnRow): any[] {
|
||||
if (row.acTyp === 'SHR') {
|
||||
return this.paymentTypes.filter((pt: any) => !pt.isCashPayment);
|
||||
}
|
||||
const isShareSettlementCredit =
|
||||
row.typ === 'Enter Bank Transfer' &&
|
||||
row.dbCr === 'Cr' &&
|
||||
this.rows.some((r) => r.typ === 'Enter Bank Transfer' && r.dbCr === 'Dr' && r.acTyp === 'SHR');
|
||||
if (isShareSettlementCredit) {
|
||||
return this.paymentTypes.filter((pt: any) => !pt.isCashPayment);
|
||||
}
|
||||
return this.paymentTypes;
|
||||
}
|
||||
|
||||
// Per-row account id/name map: acNo → Fineract id/name (used for multi-row disbursement)
|
||||
rowAccountIds: Record<string, number> = {};
|
||||
rowAccountNames: Record<string, string> = {};
|
||||
// Per-row Share account market price, keyed by acNo — lets a Share row's "SHARE NO." field
|
||||
// auto-fill the settlement amount on any other row added alongside it (calculator convenience
|
||||
// only; a Share row still can never itself be saved from this screen — see accountTypeConfig).
|
||||
rowSharePrices: Record<string, number> = {};
|
||||
// Per-row client id, keyed by acNo — needed to submit a Share Withdrawal Request from a row
|
||||
// that isn't necessarily the currently "selected" one.
|
||||
rowClientIds: Record<string, number> = {};
|
||||
// Per-row linked savings account number, keyed by the Share account's own acNo — used to make
|
||||
// sure a Share-to-linked-account Bank Transfer actually credits the right account.
|
||||
rowLinkedSavingsAcNo: Record<string, string> = {};
|
||||
// Per-row shares currently held, keyed by acNo — lets submitShareRedemptionTransfer() warn the
|
||||
// clerk when a withdrawal will bring the account's holding to zero (closing it on approval).
|
||||
rowShareHoldings: Record<string, number> = {};
|
||||
|
||||
// Full account + client data
|
||||
accountDetails: any = null;
|
||||
@@ -1205,6 +1243,9 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
};
|
||||
this.rows.push(mirror);
|
||||
this.selectedRow = mirror;
|
||||
if (active.acTyp === 'SHR') {
|
||||
this.syncShareLinkedAmounts();
|
||||
}
|
||||
return;
|
||||
}
|
||||
const row: TxnRow = {
|
||||
@@ -1499,6 +1540,191 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
/** One-step Share redemption from Bank Transfer mode: DR row is the Share account (its
|
||||
* "SHARE NO." field), CR row must be that account's own linked savings account. Submits a
|
||||
* governed Share Withdrawal Request (same maker-checker table/flow as the dedicated screen) —
|
||||
* approval both redeems the shares and settles the linked account automatically. */
|
||||
private submitShareRedemptionTransfer(shareRow: TxnRow, savingsRow: TxnRow): void {
|
||||
// Belt-and-braces: the TXN TP. dropdown already hides cash payment types for the CR (savings)
|
||||
// row (see paymentTypesForRow()), but re-check here too in case its payment type was set before
|
||||
// that filter applied — settlement is always a book-transfer to the linked savings account, and
|
||||
// it's the CR row's payment type that actually governs how that credit posts, never the DR
|
||||
// (Share) row's, which has no real payment-type concept.
|
||||
const settlementPaymentType = this.paymentTypes.find((p: any) => p.id === savingsRow.paymentTypeId);
|
||||
if (!savingsRow.paymentTypeId) {
|
||||
this.showAlert('error', 'Settlement Payment Type Required', [
|
||||
'Select a Payment Type on the CR (savings account) row — it governs how the settlement credit posts.'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
if (settlementPaymentType?.isCashPayment) {
|
||||
this.showAlert('error', 'Cash Not Allowed for Share Transactions', [
|
||||
'Share redemption settlement is always a book-transfer to the linked savings account, never cash.',
|
||||
'Select a non-cash Payment Type on the CR row (e.g. Transfer or Share Redemption Transfer).'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
const shareAccountId = this.rowAccountIds[shareRow.acNo];
|
||||
if (!shareAccountId) {
|
||||
this.showAlert('error', 'Share Account Not Loaded', [`Please load account details for: ${shareRow.acNo}`]);
|
||||
return;
|
||||
}
|
||||
if (!this.rowAccountIds[savingsRow.acNo]) {
|
||||
this.showAlert('error', 'Savings Account Not Loaded', [`Please load account details for: ${savingsRow.acNo}`]);
|
||||
return;
|
||||
}
|
||||
|
||||
const linkedAcNo = this.rowLinkedSavingsAcNo[shareRow.acNo];
|
||||
const enteredAcNo = savingsRow.acNo?.trim();
|
||||
const matchesLinked =
|
||||
!!linkedAcNo &&
|
||||
(linkedAcNo === enteredAcNo || linkedAcNo.endsWith(enteredAcNo) || savingsRow.ecsAcNo === linkedAcNo);
|
||||
if (linkedAcNo && !matchesLinked) {
|
||||
this.showAlert('error', 'Wrong Credit Account', [
|
||||
`The CR account must be this Share account's own linked savings account: ${linkedAcNo}`,
|
||||
`You entered: ${enteredAcNo}`
|
||||
]);
|
||||
return;
|
||||
}
|
||||
if (!linkedAcNo) {
|
||||
this.showAlert('error', 'No Linked Savings Account', [
|
||||
'This Share account has no linked savings account configured — settlement has nowhere to go.'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
const shares = Number(shareRow.amount) || 0;
|
||||
if (shares <= 0) {
|
||||
this.showAlert('error', 'Share No. Required', ['Enter the number of shares to withdraw (greater than zero).']);
|
||||
return;
|
||||
}
|
||||
if (!shareRow.resolutionNumber?.trim()) {
|
||||
this.showAlert('error', 'Board Resolution Number Required', [
|
||||
'A share withdrawal must be authorised by a Board Resolution.'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
if (!shareRow.meetingDate) {
|
||||
this.showAlert('error', 'Date of Meeting Required', ['Enter the Board meeting date that passed the resolution.']);
|
||||
return;
|
||||
}
|
||||
|
||||
const today = new Date();
|
||||
const requestedDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
||||
|
||||
// Full snapshot of the CR (settlement) row exactly as the clerk filled it in — saved verbatim
|
||||
// for audit purposes. Business logic still only uses settlementPaymentTypeId above; the rest of
|
||||
// these fields are not re-read by the backend, they are the audit record of what was on screen.
|
||||
const settlementRowSnapshot = {
|
||||
branch: savingsRow.br,
|
||||
accountType: savingsRow.acTyp,
|
||||
accountNo: savingsRow.acNo,
|
||||
ecsAccountNo: savingsRow.ecsAcNo || null,
|
||||
dbCr: savingsRow.dbCr,
|
||||
amount: savingsRow.amount ?? null,
|
||||
chequeNo: savingsRow.chequeNo || null,
|
||||
chequeDate: savingsRow.cheqDate || null,
|
||||
tokenNo: savingsRow.tokNo || null,
|
||||
description: savingsRow.description || null,
|
||||
bankName: savingsRow.bankName || null,
|
||||
routingCode: savingsRow.routingCode || null,
|
||||
paymentTypeId: savingsRow.paymentTypeId,
|
||||
paymentTypeName: settlementPaymentType?.name ?? null
|
||||
};
|
||||
|
||||
const payload = {
|
||||
shareAccountId,
|
||||
accountNo: shareRow.acNo,
|
||||
clientId: this.rowClientIds[shareRow.acNo] ?? null,
|
||||
clientName: this.rowAccountNames[shareRow.acNo] ?? null,
|
||||
requestedShares: shares,
|
||||
requestedDate,
|
||||
resolutionNumber: shareRow.resolutionNumber.trim(),
|
||||
meetingDate: shareRow.meetingDate,
|
||||
description: shareRow.description || null,
|
||||
settlementPaymentTypeId: savingsRow.paymentTypeId,
|
||||
settlementRowSnapshot
|
||||
};
|
||||
|
||||
const sharePrice = this.rowSharePrices[shareRow.acNo] ?? 0;
|
||||
const currentHolding = this.rowShareHoldings[shareRow.acNo] ?? 0;
|
||||
const remainingShares = currentHolding - shares;
|
||||
const willCloseAccount = remainingShares <= 0;
|
||||
const confirmLines = [
|
||||
`Share Account: ${shareRow.acNo} · Client: ${this.rowAccountNames[shareRow.acNo] ?? '—'}`,
|
||||
`Shares to Withdraw: ${shares} @ ₹${sharePrice.toLocaleString('en-IN', { minimumFractionDigits: 2 })} = ₹${(shares * sharePrice).toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
|
||||
`Remaining Shares After Withdrawal: ${Math.max(remainingShares, 0)}`,
|
||||
...(willCloseAccount
|
||||
? ['⚠ All shares withdrawn — this account will be CLOSED automatically when the Manager approves.']
|
||||
: []),
|
||||
`Board Resolution No.: ${payload.resolutionNumber}`,
|
||||
`Date of Meeting: ${payload.meetingDate}`,
|
||||
`Settlement Credit To: ${linkedAcNo}`,
|
||||
`Settlement Payment Type: ${settlementPaymentType?.name ?? '—'}`,
|
||||
...(settlementRowSnapshot.ecsAccountNo && settlementRowSnapshot.ecsAccountNo !== linkedAcNo
|
||||
? [`ECS A/C No.: ${settlementRowSnapshot.ecsAccountNo}`]
|
||||
: []),
|
||||
...(settlementRowSnapshot.chequeNo ? [`Cheque No.: ${settlementRowSnapshot.chequeNo}`] : []),
|
||||
...(settlementRowSnapshot.chequeDate ? [`Cheque Date: ${settlementRowSnapshot.chequeDate}`] : []),
|
||||
...(settlementRowSnapshot.tokenNo ? [`Token No.: ${settlementRowSnapshot.tokenNo}`] : []),
|
||||
...(settlementRowSnapshot.description ? [`CR Row Remarks: ${settlementRowSnapshot.description}`] : []),
|
||||
'',
|
||||
willCloseAccount
|
||||
? 'Pending Manager approval — redemption, settlement and account closure all post together on approval.'
|
||||
: 'Pending Manager approval — redemption and settlement both post together on approval.'
|
||||
];
|
||||
this.showConfirmAlert('info', 'Confirm Share Withdrawal Request', confirmLines, 'Submit for Approval', () =>
|
||||
this.doSubmitShareRedemptionTransfer(payload, shareRow, shares, linkedAcNo)
|
||||
);
|
||||
}
|
||||
|
||||
private doSubmitShareRedemptionTransfer(
|
||||
payload: Record<string, unknown>,
|
||||
shareRow: TxnRow,
|
||||
shares: number,
|
||||
linkedAcNo: string
|
||||
): void {
|
||||
this.saving = true;
|
||||
this.clerkShareWithdrawalService
|
||||
.submit(payload)
|
||||
.pipe(
|
||||
catchError((err: any) => {
|
||||
const msg =
|
||||
err?.error?.errors?.[0]?.defaultUserMessage ??
|
||||
err?.error?.developerMessage ??
|
||||
err?.error?.defaultUserMessage ??
|
||||
'Share withdrawal submission failed. Please try again.';
|
||||
this.showAlert('error', 'Submission Failed', [msg]);
|
||||
this.saving = false;
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
.subscribe((res: any) => {
|
||||
this.saving = false;
|
||||
if (res) {
|
||||
const willCloseAccount = (this.rowShareHoldings[shareRow.acNo] ?? 0) - shares <= 0;
|
||||
this.showAlert('success', 'Share Withdrawal Submitted', [
|
||||
`${shares} share(s) from ${shareRow.acNo} → settlement to ${linkedAcNo} on approval.`,
|
||||
willCloseAccount
|
||||
? 'Pending Manager approval — this will close the share account, since all shares will be withdrawn. Track status in "My Share Requests".'
|
||||
: 'Pending Manager approval. Track status in "My Share Requests".'
|
||||
]);
|
||||
this.rows = [];
|
||||
this.rowAccountIds = {};
|
||||
this.rowAccountNames = {};
|
||||
this.rowSharePrices = {};
|
||||
this.rowClientIds = {};
|
||||
this.rowLinkedSavingsAcNo = {};
|
||||
this.rowShareHoldings = {};
|
||||
this.addRow();
|
||||
this.selectedRow = this.rows[0];
|
||||
this.accountDetails = null;
|
||||
this.clientDetails = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Cashier enters a token number → look up the APPROVED withdrawal request for auto-fill. */
|
||||
onTokNoBlur(row: TxnRow): void {
|
||||
if (this.isClerk || !row.tokNo?.trim()) return;
|
||||
@@ -1535,6 +1761,25 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
/** When exactly one row on screen is a Share account with a known market price, auto-fills every
|
||||
* OTHER row's amount as (shares entered on that row) × (its market price) — a calculator
|
||||
* convenience for filling the paired settlement entry. The Share row itself still can never be
|
||||
* saved from this screen (see the SHR guard in confirmSubmit()) — this only helps compute the
|
||||
* correct amount to carry over into the separate manual Deposit Slip entry. */
|
||||
syncShareLinkedAmounts(): void {
|
||||
const shareRows = this.rows.filter((r) => r.acTyp?.toUpperCase() === 'SHR' && this.rowSharePrices[r.acNo]);
|
||||
if (shareRows.length !== 1) return;
|
||||
const shareRow = shareRows[0];
|
||||
const price = this.rowSharePrices[shareRow.acNo] ?? 0;
|
||||
const shares = Number(shareRow.amount) || 0;
|
||||
const total = Math.round(shares * price * 100) / 100;
|
||||
this.rows.forEach((r) => {
|
||||
if (r !== shareRow) {
|
||||
r.amount = total;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onAcNoBlur(row: TxnRow, index: number): void {
|
||||
if (!row.acNo.trim()) return;
|
||||
this.selectedRow = row;
|
||||
@@ -1633,12 +1878,14 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
if (token !== this.searchToken) return; // stale result — newer search started
|
||||
const allItems: any[] = res?.pageItems ?? (Array.isArray(res) ? res : []);
|
||||
const f = cfg.matchField;
|
||||
// Exact match first, then suffix/partial match (backend may return by last-N-digits)
|
||||
// Exact match first, then suffix/partial match. Some account types (e.g. Share — its
|
||||
// list endpoint only accepts offset/limit, no accountNo filter at all) always return the
|
||||
// FULL unfiltered list rather than a server-side-narrowed one, so a partial match can't be
|
||||
// gated on "exactly one row came back" — it never would be for those types.
|
||||
const exactMatch = allItems.find((x: any) => x[f] === acNo);
|
||||
const partialMatch =
|
||||
!exactMatch && allItems.length === 1
|
||||
? allItems.find((x: any) => x[f]?.endsWith(acNo) || x[f]?.includes(acNo))
|
||||
: null;
|
||||
const partialMatch = !exactMatch
|
||||
? (allItems.find((x: any) => x[f]?.endsWith(acNo)) ?? allItems.find((x: any) => x[f]?.includes(acNo)))
|
||||
: null;
|
||||
const match = exactMatch ?? partialMatch ?? null;
|
||||
if (match?.id) {
|
||||
const clientIdFromSearch = acTyp !== 'GL' && match.clientId != null ? String(match.clientId) : null;
|
||||
@@ -1659,6 +1906,17 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
siAccountType: number;
|
||||
txnBase: string;
|
||||
txnSearchSupported: boolean;
|
||||
/** True when this account type has no separate REST transactions sub-resource at all (e.g.
|
||||
* Share accounts — Fineract returns transactions embedded on the account detail response
|
||||
* itself via ?associations=all, never at /accounts/share/{id}/transactions). Skips the
|
||||
* "today's transactions" HTTP call entirely instead of hitting a 404. */
|
||||
txnFetchUnsupported?: boolean;
|
||||
/** True when this account type has no standing-instruction "from account type" mapping on
|
||||
* the backend at all (PortfolioAccountType only defines LOAN/SAVINGS/CURRENT — Share isn't
|
||||
* a valid standing-instruction source account). Skips that HTTP call entirely instead of
|
||||
* sending an fromAccountType value the backend's query-builder silently can't match,
|
||||
* which otherwise produces a malformed "WHERE <empty>" SQL statement server-side. */
|
||||
siFetchUnsupported?: boolean;
|
||||
maxLimit: number;
|
||||
skipGlobalSearch: boolean;
|
||||
listQueryParam: string;
|
||||
@@ -1693,6 +1951,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
siAccountType: 4,
|
||||
txnBase: '/accounts/share',
|
||||
txnSearchSupported: false,
|
||||
txnFetchUnsupported: true,
|
||||
siFetchUnsupported: true,
|
||||
maxLimit: 500,
|
||||
skipGlobalSearch: true,
|
||||
listQueryParam: 'accountNo',
|
||||
@@ -1805,6 +2065,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
siAccountType: number;
|
||||
txnBase: string;
|
||||
txnSearchSupported: boolean;
|
||||
txnFetchUnsupported?: boolean;
|
||||
siFetchUnsupported?: boolean;
|
||||
detailParams?: string;
|
||||
},
|
||||
token: number,
|
||||
@@ -1837,13 +2099,32 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
this.rowAccountIds[this.selectedRow.acNo] = acc.id;
|
||||
this.rowAccountNames[this.selectedRow.acNo] =
|
||||
acc.name ?? acc.displayName ?? acc.glAccountName ?? acc.accountNo ?? '';
|
||||
this.rowClientIds[this.selectedRow.acNo] = acc.clientId ?? null;
|
||||
}
|
||||
if (this.selectedRow) {
|
||||
this.selectedRow.ecsAcNo = acc.glCode ?? acc.savingsAccountNumber ?? acc.accountNo ?? '';
|
||||
// Share accounts: ECS field is the share account's own number — acc.savingsAccountNumber
|
||||
// is a different, unrelated account (the linked settlement savings account) and must
|
||||
// never be shown here.
|
||||
this.selectedRow.ecsAcNo =
|
||||
acTyp?.toUpperCase() === 'SHR'
|
||||
? (acc.accountNo ?? '')
|
||||
: (acc.glCode ?? acc.savingsAccountNumber ?? acc.accountNo ?? '');
|
||||
}
|
||||
if (this.isClerk) this.clerkUiState.setFacilities(this.facilities, acNo, acTyp);
|
||||
this.computeSameDayNeftRtgs(acc.transactions);
|
||||
this.computeSameDayChequeClearing(acc.transactions);
|
||||
if (acTyp?.toUpperCase() === 'SHR' && this.selectedRow?.acNo) {
|
||||
// Keyed by the row's own typed acNo (e.g. "0011"), not the resolved full account number
|
||||
// (e.g. "000000011") — rowAccountIds/rowAccountNames above use the same key, and every
|
||||
// lookup elsewhere (syncShareLinkedAmounts, submitShareRedemptionTransfer, the template's
|
||||
// rowSharePrices[row.acNo]) reads via row.acNo, so this must match or every lookup misses.
|
||||
this.rowSharePrices[this.selectedRow.acNo] = Number(acc.currentMarketPrice) || 0;
|
||||
this.rowLinkedSavingsAcNo[this.selectedRow.acNo] = acc.savingsAccountNumber ?? '';
|
||||
this.rowShareHoldings[this.selectedRow.acNo] = Number(
|
||||
acc.summary?.totalApprovedShares ?? acc.totalApprovedShares ?? 0
|
||||
);
|
||||
this.syncShareLinkedAmounts();
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -1913,18 +2194,22 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
documents: this.http.get<any[]>(`/clients/${clientId}/documents`).pipe(catchError(() => of([]))),
|
||||
notes: this.http.get<any>(`/savings/${accountId}/notes`).pipe(catchError(() => of([]))),
|
||||
linkedAcc: this.http.get<any>(`/clients/${clientId}/accounts`).pipe(catchError(() => of(null))),
|
||||
si: this.http
|
||||
.get<any>(`/standinginstructions?fromAccountType=${cfg.siAccountType}&fromAccountId=${accountId}&limit=10`)
|
||||
.pipe(catchError(() => of({ pageItems: [] }))),
|
||||
todayTxns: cfg.txnSearchSupported
|
||||
? this.http
|
||||
.get<any>(
|
||||
`${cfg.txnBase}/${accountId}/transactions/search?fromDate=${todayStr}&toDate=${todayStr}&limit=20`
|
||||
)
|
||||
.pipe(catchError(() => of({ pageItems: [] })))
|
||||
si: cfg.siFetchUnsupported
|
||||
? of({ pageItems: [] })
|
||||
: this.http
|
||||
.get<any>(`${cfg.txnBase}/${accountId}/transactions?limit=50`)
|
||||
.pipe(catchError(() => of({ pageItems: [] })))
|
||||
.get<any>(`/standinginstructions?fromAccountType=${cfg.siAccountType}&fromAccountId=${accountId}&limit=10`)
|
||||
.pipe(catchError(() => of({ pageItems: [] }))),
|
||||
todayTxns: cfg.txnFetchUnsupported
|
||||
? of({ pageItems: [] })
|
||||
: cfg.txnSearchSupported
|
||||
? this.http
|
||||
.get<any>(
|
||||
`${cfg.txnBase}/${accountId}/transactions/search?fromDate=${todayStr}&toDate=${todayStr}&limit=20`
|
||||
)
|
||||
.pipe(catchError(() => of({ pageItems: [] })))
|
||||
: this.http
|
||||
.get<any>(`${cfg.txnBase}/${accountId}/transactions?limit=50`)
|
||||
.pipe(catchError(() => of({ pageItems: [] })))
|
||||
});
|
||||
|
||||
if (clientIdHint) {
|
||||
@@ -2089,6 +2374,11 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
// ── Balance getters ────────────────────────────────────────────────────────
|
||||
|
||||
get currentBalance(): number {
|
||||
if (this.selectedRow?.acTyp === 'SHR') {
|
||||
const shares = Number(this.accountDetails?.summary?.totalApprovedShares ?? 0);
|
||||
const unitPrice = Number(this.accountDetails?.currentMarketPrice ?? 0);
|
||||
return shares * unitPrice;
|
||||
}
|
||||
return this.accountDetails?.summary?.accountBalance ?? this.accountDetails?.organizationRunningBalance ?? 0;
|
||||
}
|
||||
|
||||
@@ -2502,6 +2792,23 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
// Share redemption via linked-account transfer: DR=Share account, CR=its linked savings
|
||||
// account. A Share account can never be debited by a plain ledger transfer (no such Fineract
|
||||
// command exists) — so this exact one-DR/one-CR shape is routed to submitShareRedemptionTransfer()
|
||||
// instead, which submits a governed Share Withdrawal Request through the same maker-checker
|
||||
// table as the dedicated screen; approval both redeems the shares and settles the linked account.
|
||||
const shareDrRows = drRows.filter((r) => r.acTyp === 'SHR');
|
||||
if (shareDrRows.length > 0) {
|
||||
if (drRows.length !== 1 || crRows.length !== 1) {
|
||||
this.showAlert('error', 'Unsupported Share Transfer', [
|
||||
'A Share redemption transfer supports exactly one DR row (the Share account) and one CR row (its linked savings account).'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
this.submitShareRedemptionTransfer(shareDrRows[0], crRows[0]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Bank transfer: only savings (SD) or current (CA/CB) accounts allowed
|
||||
const allowedTypes = new Set([
|
||||
'SD',
|
||||
@@ -3004,6 +3311,24 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
const accountId = this.accountDetails.id;
|
||||
const cfg = this.accountTypeConfig(row.acTyp);
|
||||
|
||||
// Share accounts must never go through this generic Deposit/Withdrawal Slip flow — both
|
||||
// buildDepositPayload() and buildWithdrawalPayload() hardcode accountType: 'SAVINGS' and send
|
||||
// savingsAccountId: this.accountDetails.id, which for a Share account is the SHARE account's
|
||||
// own numeric id, not a real savings account id. Approving that request would call
|
||||
// savingsAccountWithdrawal/Deposit against whatever savings account happens to share that same
|
||||
// numeric id — a completely unrelated account. Share purchase/redemption must go through the
|
||||
// dedicated Buy Shares / Share Withdrawal Request screens, which carry the correct governance
|
||||
// (resolution number, meeting date, minimum-tenure and linked-loan checks).
|
||||
if (row.acTyp === 'SHR') {
|
||||
this.showAlert('error', 'Not Supported for Share Accounts', [
|
||||
isDeposit
|
||||
? 'Share purchases must be made from the Buy Shares screen on the Share Account itself.'
|
||||
: 'Share withdrawal (redeem shares) must be submitted from "New Share Withdrawal Request" in the Clerk Dashboard.',
|
||||
'This generic Deposit/Withdrawal Slip does not carry the Board Resolution Number or the governance checks required for shares.'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// GL withdrawal → direct journal entry (no maker-checker)
|
||||
// GL deposit → falls through to maker-checker block below
|
||||
if (row.acTyp === 'GL' && !isDeposit) {
|
||||
|
||||
@@ -12,16 +12,16 @@
|
||||
* have meaningful variety out of the box.
|
||||
*/
|
||||
export const SAMPLE_CKYC_FILE = `10|24401|IN4711|MH|30|31-08-2020|V1.1|||||
|
||||
20|1|01|01|||||||||||01|||01|10078|MR|RAVI|KUMAR|SHARMA|||||||01|MR|RAJESH|KUMAR|SHARMA||MRS|SUNITA|DEVI|||M|02|IN|B-01|10-08-1985|||||||||01|02|||||02|FLAT 402, SUNRISE TOWERS|MAROL ANDHERI EAST|MUMBAI|MUMBAI|MUMBAI|MH|IN|400069|01||Y|02|FLAT 402, SUNRISE TOWERS|MAROL ANDHERI EAST|MUMBAI|MUMBAI|MUMBAI|MH|IN|400069|01||||||||||||||||9892011001|||||31-08-2020|MUMBAI MAIN|31-08-2020|01|ANJALI MEHTA|ASSISTANT MANAGER|HEAD OFFICE|4|BHARAT URBAN BANK|IN4711|1|0||0|4||||||
|
||||
20|1|01|01|||||||||||01|||01|10078|MR|RAVI|KUMAR|SHARMA|||||||01|MR|RAJESH|KUMAR|SHARMA||MRS|SUNITA|DEVI|||M|02|IN|B-01|10-08-1985|||||||||01|02|||||02|FLAT 402, SUNRISE TOWERS|MAROL ANDHERI EAST|MUMBAI|MUMBAI|MUMBAI|MH|IN|400069|01||Y|02|FLAT 402, SUNRISE TOWERS|MAROL ANDHERI EAST|MUMBAI|MUMBAI|MUMBAI|MH|IN|400069|01||||||||||||||||9892011001|||||31-08-2020|MUMBAI MAIN|31-08-2020|01|ANJALI MEHTA|ASSISTANT MANAGER|HEAD OFFICE|4|CBSNEXORA BANK|IN4711|1|0||0|4||||||
|
||||
30|1|C|AAQPS8560J||01|02|||||
|
||||
70|1|10078_Sign.jpeg|09|01|01||||
|
||||
70|1|10078_Photo.jpeg|02|01|01||||
|
||||
70|1|10078_AP.jpg|04|01|01||||
|
||||
20|2|01|01|||||||||||01|||01|10079|MRS|PRIYA||PATEL|||||||01|MR|HARISH|PATEL|||MRS|MEENA|PATEL|||F|01|IN|O-04|22-03-1990|||||||||01|02|||||02|B-12, SHIVAJI NAGAR|FC ROAD|PUNE|PUNE|PUNE|MH|IN|411005|01||Y|02|B-12, SHIVAJI NAGAR|FC ROAD|PUNE|PUNE|PUNE|MH|IN|411005|01||||||||||||||||9822055002|||||31-08-2020|PUNE FC ROAD|31-08-2020|01|VIKRAM SINGH|BRANCH MANAGER|HEAD OFFICE|4|BHARAT URBAN BANK|IN4711|1|0||0|3||||||
|
||||
20|2|01|01|||||||||||01|||01|10079|MRS|PRIYA||PATEL|||||||01|MR|HARISH|PATEL|||MRS|MEENA|PATEL|||F|01|IN|O-04|22-03-1990|||||||||01|02|||||02|B-12, SHIVAJI NAGAR|FC ROAD|PUNE|PUNE|PUNE|MH|IN|411005|01||Y|02|B-12, SHIVAJI NAGAR|FC ROAD|PUNE|PUNE|PUNE|MH|IN|411005|01||||||||||||||||9822055002|||||31-08-2020|PUNE FC ROAD|31-08-2020|01|VIKRAM SINGH|BRANCH MANAGER|HEAD OFFICE|4|CBSNEXORA BANK|IN4711|1|0||0|3||||||
|
||||
30|2|E|XXXXXXXX2210||01|02|||||
|
||||
70|2|10079_Sign.jpeg|09|01|01||||
|
||||
70|2|10079_Photo.jpeg|02|01|01||||
|
||||
20|3|01|01|||||||||||01|||01|10080|MR|ARJUN||DESHMUKH|||||||01|MR|VIJAY|DESHMUKH|||MRS|REKHA|DESHMUKH|||M|01|IN|B-01|15-01-1978|||||||||01|02|||||02|HOUSE NO 7, DHARAMPETH|RAMDASPETH|NAGPUR|NAGPUR|NAGPUR|MH|IN|440010|01||Y|02|HOUSE NO 7, DHARAMPETH|RAMDASPETH|NAGPUR|NAGPUR|NAGPUR|MH|IN|440010|01||||||||||||||||9823033003|||||30-08-2020|NAGPUR DHARAMPETH|30-08-2020|01|RAHUL JOSHI|ASSISTANT MANAGER|HEAD OFFICE|4|BHARAT URBAN BANK|IN4711|1|0||0|3||||||
|
||||
20|3|01|01|||||||||||01|||01|10080|MR|ARJUN||DESHMUKH|||||||01|MR|VIJAY|DESHMUKH|||MRS|REKHA|DESHMUKH|||M|01|IN|B-01|15-01-1978|||||||||01|02|||||02|HOUSE NO 7, DHARAMPETH|RAMDASPETH|NAGPUR|NAGPUR|NAGPUR|MH|IN|440010|01||Y|02|HOUSE NO 7, DHARAMPETH|RAMDASPETH|NAGPUR|NAGPUR|NAGPUR|MH|IN|440010|01||||||||||||||||9823033003|||||30-08-2020|NAGPUR DHARAMPETH|30-08-2020|01|RAHUL JOSHI|ASSISTANT MANAGER|HEAD OFFICE|4|CBSNEXORA BANK|IN4711|1|0||0|3||||||
|
||||
30|3|C|AGTPD1144K||01|02|||||
|
||||
70|3|10080_Sign.jpeg|09|01|01||||
|
||||
70|3|10080_AP.jpg|04|01|01||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
/* Small shared layout/status-pill classes for the Share Withdrawal maker-checker screens —
|
||||
* self-contained (this branch has no separate "investment" module to borrow them from). */
|
||||
|
||||
.inv-form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.inv-form-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
height: 36px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
color: #0d1b2a;
|
||||
font-family: inherit;
|
||||
background: #fff;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
textarea {
|
||||
height: auto;
|
||||
padding: 8px 10px;
|
||||
resize: vertical;
|
||||
}
|
||||
}
|
||||
|
||||
.inv-form-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 12px 16px 16px;
|
||||
}
|
||||
|
||||
.inv-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
|
||||
&--active {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
&--matured {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
&--written_off {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
<!--
|
||||
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">CLERK · SHARE WITHDRAWAL</div>
|
||||
<h1 class="pg-title">My Share Requests</h1>
|
||||
<p class="pg-sub">Share withdrawal requests you have submitted, and their approval status.</p>
|
||||
</div>
|
||||
<div class="pg-toolbar">
|
||||
<button type="button" class="pg-btn pg-btn--primary" (click)="newRequest()">New Share Withdrawal</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="pg-card">
|
||||
<div class="inv-form-grid" style="padding: 12px 16px">
|
||||
<label class="inv-form-field">
|
||||
<span>Status</span>
|
||||
<select [(ngModel)]="filterStatus" (change)="onFilterChange()">
|
||||
@for (opt of statusOptions; track opt.value) {
|
||||
<option [value]="opt.value">{{ opt.label }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Account No</th>
|
||||
<th>Client</th>
|
||||
<th class="num">Shares</th>
|
||||
<th>Resolution No.</th>
|
||||
<th>Meeting Date</th>
|
||||
<th>Submitted At</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@if (loading) {
|
||||
<tr>
|
||||
<td colspan="7" class="pg-empty">Loading…</td>
|
||||
</tr>
|
||||
} @else if (loaded && requests.length === 0) {
|
||||
<tr>
|
||||
<td colspan="7" class="pg-empty">No share withdrawal requests found.</td>
|
||||
</tr>
|
||||
} @else {
|
||||
@for (r of requests; track r.id) {
|
||||
<tr>
|
||||
<td>
|
||||
<span class="pg-chip">{{ r.account_no }}</span>
|
||||
</td>
|
||||
<td>{{ r.client_name }}</td>
|
||||
<td class="num">{{ r.requested_shares }}</td>
|
||||
<td>{{ r.resolution_number }}</td>
|
||||
<td>{{ formatDateTime(r.meeting_date) }}</td>
|
||||
<td>{{ formatDateTime(r.submitted_at) }}</td>
|
||||
<td>
|
||||
<span [class]="statusClass(r.status)">{{ statusLabel(r.status) }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* 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 { Router, RouterModule } from '@angular/router';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { ClerkShareWithdrawalService } from '../../cashier/clerk-share-withdrawal.service';
|
||||
|
||||
/** Clerk Dashboard "My Share Requests" — lists the share withdrawal requests this clerk has
|
||||
* submitted and their approval status. Mirrors MyRequestsComponent/MyDisbursementRequestsComponent. */
|
||||
@Component({
|
||||
selector: 'mifosx-my-share-withdrawal-requests',
|
||||
templateUrl: './my-share-withdrawal-requests.component.html',
|
||||
styleUrls: [
|
||||
'../../cashier/cashier-shared.scss',
|
||||
'../clerk-shared.scss'
|
||||
],
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
RouterModule
|
||||
]
|
||||
})
|
||||
export class MyShareWithdrawalRequestsComponent implements OnInit {
|
||||
private service = inject(ClerkShareWithdrawalService);
|
||||
private router = inject(Router);
|
||||
|
||||
requests: any[] = [];
|
||||
totalRecords = 0;
|
||||
loading = false;
|
||||
loaded = false;
|
||||
|
||||
filterStatus = '';
|
||||
|
||||
get statusOptions(): { value: string; label: string }[] {
|
||||
return [
|
||||
{ value: '', label: 'All' },
|
||||
{ value: 'PENDING', label: 'Pending' },
|
||||
{ value: 'APPROVED', label: 'Approved' },
|
||||
{ value: 'REJECTED', label: 'Rejected' }
|
||||
];
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading = true;
|
||||
this.service
|
||||
.getMyRequests(this.filterStatus || undefined, 0, 50)
|
||||
.pipe(catchError(() => of({ pageItems: [], totalFilteredRecords: 0 })))
|
||||
.subscribe((res: any) => {
|
||||
this.requests = res?.pageItems ?? [];
|
||||
this.totalRecords = Number(res?.totalFilteredRecords ?? 0);
|
||||
this.loading = false;
|
||||
this.loaded = true;
|
||||
});
|
||||
}
|
||||
|
||||
onFilterChange(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
newRequest(): void {
|
||||
this.router.navigate(['/clerk/share-withdrawal-request']);
|
||||
}
|
||||
|
||||
statusClass(status: string): string {
|
||||
const map: Record<string, string> = {
|
||||
PENDING: 'inv-badge--active',
|
||||
APPROVED: 'inv-badge--matured',
|
||||
REJECTED: 'inv-badge--written_off'
|
||||
};
|
||||
return `inv-badge ${map[status] ?? ''}`;
|
||||
}
|
||||
|
||||
statusLabel(status: string): string {
|
||||
const map: Record<string, string> = {
|
||||
PENDING: 'Pending Approval',
|
||||
APPROVED: 'Approved',
|
||||
REJECTED: 'Rejected'
|
||||
};
|
||||
return map[status] ?? status;
|
||||
}
|
||||
|
||||
formatDateTime(val: any): string {
|
||||
if (!val) return '—';
|
||||
try {
|
||||
const d = new Date(val);
|
||||
return d.toLocaleString('en-IN', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
} catch {
|
||||
return String(val);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
<!--
|
||||
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">{{ isReview ? 'CHECKER · APPROVAL' : 'CLERK · SHARE WITHDRAWAL' }}</div>
|
||||
<h1 class="pg-title">{{ pageTitle }}</h1>
|
||||
<p class="pg-sub">{{ pageSubtitle }}</p>
|
||||
</div>
|
||||
<div class="pg-toolbar">
|
||||
<button type="button" class="pg-btn" (click)="goBack()">
|
||||
<mat-icon>arrow_back</mat-icon>
|
||||
{{ isReview ? 'Back to Requests' : 'My Share Requests' }}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Share Account</div>
|
||||
<div class="inv-form-grid" style="padding: 16px">
|
||||
@if (!isReview) {
|
||||
<label class="inv-form-field" style="grid-column: span 2">
|
||||
<span>* Search Account No. / Client Name</span>
|
||||
<mifosx-search-select
|
||||
[options]="accountOptions"
|
||||
[(ngModel)]="selectedAccountId"
|
||||
(ngModelChange)="onAccountSelected($event)"
|
||||
placeholder="Search share account no. or client name…"
|
||||
></mifosx-search-select>
|
||||
</label>
|
||||
}
|
||||
@if (account) {
|
||||
<label class="inv-form-field">
|
||||
<span>Account No</span>
|
||||
<input type="text" [value]="account.accountNo" readonly />
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>Client</span>
|
||||
<input type="text" [value]="account.clientName" readonly />
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>Shares Held</span>
|
||||
<input
|
||||
type="text"
|
||||
[value]="account.summary?.totalApprovedShares ?? account.totalApprovedShares ?? 0"
|
||||
readonly
|
||||
/>
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>Unit Price</span>
|
||||
<input type="text" [value]="'₹' + (unitPrice | number: '1.2-2')" readonly />
|
||||
</label>
|
||||
}
|
||||
@if (loadingAccount) {
|
||||
<div class="pg-empty">Loading account…</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (isReview && reviewRequest) {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Client Compliance</div>
|
||||
<div class="inv-form-grid" style="padding: 16px">
|
||||
<div class="inv-form-field">
|
||||
<span>KYC Status</span>
|
||||
<span
|
||||
class="swr-chip"
|
||||
[class.swr-chip--ok]="reviewRequest.client_kyc_verified"
|
||||
[class.swr-chip--warn]="!reviewRequest.client_kyc_verified"
|
||||
>
|
||||
{{ reviewRequest.client_kyc_status_label }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="inv-form-field">
|
||||
<span>Risk Category</span>
|
||||
<span
|
||||
class="swr-chip"
|
||||
[class.swr-chip--ok]="!reviewRequest.client_risk_high"
|
||||
[class.swr-chip--warn]="reviewRequest.client_risk_high"
|
||||
>
|
||||
{{ reviewRequest.client_risk_category_label }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="inv-form-field">
|
||||
<span>Open AML Alerts</span>
|
||||
<span
|
||||
class="swr-chip"
|
||||
[class.swr-chip--ok]="!reviewRequest.client_open_aml_alerts"
|
||||
[class.swr-chip--warn]="reviewRequest.client_open_aml_alerts > 0"
|
||||
>
|
||||
{{ reviewRequest.client_open_aml_alerts || 0 }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (account) {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Withdrawal Details</div>
|
||||
<div class="inv-form-grid" style="padding: 16px">
|
||||
<label class="inv-form-field">
|
||||
<span>* Shares to Withdraw</span>
|
||||
<input type="number" [(ngModel)]="requestedShares" min="1" [disabled]="isReview" />
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>* Requested Date</span>
|
||||
<input type="date" [(ngModel)]="requestedDate" [disabled]="isReview" />
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>Withdrawal Value</span>
|
||||
<input type="text" [value]="'₹' + (withdrawalValue | number: '1.2-2')" readonly />
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>Remaining Shares After Withdrawal</span>
|
||||
<input type="text" [value]="remainingShares" readonly />
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>* Board Resolution Number</span>
|
||||
<input type="text" [(ngModel)]="resolutionNumber" [disabled]="isReview" placeholder="e.g. BR-2026-014" />
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>* Date of Meeting</span>
|
||||
<input type="date" [(ngModel)]="meetingDate" [disabled]="isReview" />
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>* Settlement Payment Type</span>
|
||||
@if (!isReview) {
|
||||
<select [(ngModel)]="settlementPaymentTypeId">
|
||||
<option [ngValue]="null">Select…</option>
|
||||
@for (pt of paymentTypeOptions; track pt.id) {
|
||||
<option [ngValue]="pt.id">{{ pt.name }}</option>
|
||||
}
|
||||
</select>
|
||||
} @else {
|
||||
<input type="text" [value]="settlementPaymentTypeName" readonly />
|
||||
}
|
||||
</label>
|
||||
<label class="inv-form-field" style="grid-column: span 2">
|
||||
<span>Description / Remarks</span>
|
||||
<input type="text" [(ngModel)]="description" [disabled]="isReview" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@if (account && remainingShares === 0) {
|
||||
<div class="swr-close-notice">
|
||||
<mat-icon>lock</mat-icon>
|
||||
<span>
|
||||
This withdraws <strong>all</strong> shares on this account. Approving it will
|
||||
<strong>close the share account automatically</strong> — no separate closure step is needed.
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!isReview) {
|
||||
<div class="inv-form-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--primary"
|
||||
[disabled]="!isFormValid || submitting"
|
||||
(click)="openConfirmDialog()"
|
||||
>
|
||||
{{ submitting ? 'Submitting…' : 'Review & Submit' }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (isReview && reviewRequest?.status === 'PENDING') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Verification</div>
|
||||
<div class="swrv-trigger">
|
||||
<div class="swrv-trigger__icon">
|
||||
<mat-icon>fact_check</mat-icon>
|
||||
</div>
|
||||
<div class="swrv-trigger__text">
|
||||
<div class="swrv-trigger__title">Run a live backend validation</div>
|
||||
<div class="swrv-trigger__sub">
|
||||
Re-checks account status, share balance, linked savings account and GL mapping before you approve.
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="pg-btn pg-btn--primary" (click)="openValidateModal()">
|
||||
<mat-icon>play_circle</mat-icon>
|
||||
Validate Request
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="inv-form-actions">
|
||||
<button type="button" class="pg-btn pg-btn--primary" (click)="approveReview()">
|
||||
<mat-icon>check_circle</mat-icon>
|
||||
Approve & Process Withdrawal
|
||||
</button>
|
||||
<button type="button" class="pg-btn pg-btn--danger" (click)="openRejectDialog()">
|
||||
<mat-icon>cancel</mat-icon>
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (showValidateModal) {
|
||||
<div class="swrv-modal">
|
||||
<div class="swrv-modal__card">
|
||||
<div class="swrv-modal__header">
|
||||
<div class="swrv-modal__title">
|
||||
<span class="swrv-modal__title-icon"><mat-icon>fact_check</mat-icon></span>
|
||||
Validate Share Withdrawal Request
|
||||
</div>
|
||||
<button type="button" class="swrv-modal__close" [disabled]="validating" (click)="closeValidateModal()">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="swrv-modal__body">
|
||||
<p class="swrv-modal__intro">
|
||||
Re-checking <strong>{{ requestedShares }} share(s)</strong> withdrawal from
|
||||
<strong>{{ account?.accountNo }}</strong> ({{ account?.clientName }}) against the live account, savings link
|
||||
and GL configuration — not just what was captured at submission time.
|
||||
</p>
|
||||
|
||||
@if (backendChecks && !validating && !validateAllPassed) {
|
||||
<div class="swrv-modal__warn">
|
||||
<mat-icon>warning</mat-icon>
|
||||
<div>
|
||||
<strong>Some checks need review before approving.</strong>
|
||||
Scroll down to see which ones failed and why.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<ol class="swrv-steps">
|
||||
@for (step of validateSteps; track step.label; let i = $index) {
|
||||
<li
|
||||
class="swrv-step"
|
||||
[class.swrv-step--active]="step.status === 'active'"
|
||||
[class.swrv-step--done]="step.status === 'done'"
|
||||
[class.swrv-step--failed]="step.status === 'failed'"
|
||||
>
|
||||
<div class="swrv-step__indicator">
|
||||
@if (step.status === 'done') {
|
||||
<mat-icon>check</mat-icon>
|
||||
} @else if (step.status === 'active') {
|
||||
<mat-icon class="swrv-spin">autorenew</mat-icon>
|
||||
} @else if (step.status === 'failed') {
|
||||
<mat-icon>close</mat-icon>
|
||||
} @else {
|
||||
<span>{{ i + 1 }}</span>
|
||||
}
|
||||
</div>
|
||||
<div class="swrv-step__content">
|
||||
<div class="swrv-step__label">{{ step.label }}</div>
|
||||
<div class="swrv-step__msg">{{ step.detail }}</div>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
|
||||
@if (validating && validateSteps.length === 0) {
|
||||
<div class="pg-empty">Connecting to server…</div>
|
||||
}
|
||||
|
||||
@if (backendChecks && !validating && validateAllPassed) {
|
||||
<div class="swrv-modal__result">
|
||||
<mat-icon>verified</mat-icon>
|
||||
<span>All checks passed — safe to approve.</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="swrv-modal__footer">
|
||||
@if (!validating && backendChecks) {
|
||||
<button type="button" class="pg-btn" (click)="runBackendValidation()">
|
||||
<mat-icon>refresh</mat-icon>
|
||||
Re-run
|
||||
</button>
|
||||
}
|
||||
@if (!validating && backendChecks && validateAllPassed) {
|
||||
<button type="button" class="pg-btn pg-btn--primary" (click)="closeValidateModal(); approveReview()">
|
||||
<mat-icon>check_circle</mat-icon>
|
||||
Approve & Process Withdrawal
|
||||
</button>
|
||||
}
|
||||
<button type="button" class="pg-btn" [disabled]="validating" (click)="closeValidateModal()">
|
||||
{{ validating ? 'Validating…' : 'Close' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (isReview && reviewRequest?.status === 'APPROVED') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Settlement</div>
|
||||
<div class="inv-form-grid" style="padding: 16px">
|
||||
@if (reviewRequest?.settlement_amount) {
|
||||
<label class="inv-form-field">
|
||||
<span>Amount Credited</span>
|
||||
<input type="text" [value]="'₹' + (reviewRequest.settlement_amount | number: '1.2-2')" readonly />
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>Credited To (Linked Savings A/C)</span>
|
||||
<input type="text" [value]="account?.savingsAccountNumber || '—'" readonly />
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>Savings Transaction Ref.</span>
|
||||
<input type="text" [value]="reviewRequest.settlement_savings_transaction_id" readonly />
|
||||
</label>
|
||||
} @else {
|
||||
<div class="pg-empty" style="grid-column: span 2">
|
||||
Settlement is processed automatically on approval. Details aren't available when reopening an
|
||||
already-approved request — check the linked savings account's transaction history to confirm.
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (isReview && activityLog.length > 0) {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Activity Log</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Timestamp</th>
|
||||
<th>Action</th>
|
||||
<th>User</th>
|
||||
<th>Note</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (entry of activityLog; track entry.timestamp) {
|
||||
<tr>
|
||||
<td>{{ entry.timestamp | date: 'medium' }}</td>
|
||||
<td>{{ entry.action }}</td>
|
||||
<td>{{ entry.user }}</td>
|
||||
<td>{{ entry.note || '—' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (showConfirmDialog) {
|
||||
<div class="swr-modal-backdrop">
|
||||
<div class="swr-modal">
|
||||
<h3>Confirm Share Withdrawal Request</h3>
|
||||
<p class="pg-sub">Review the details below before submitting for Bank Manager approval.</p>
|
||||
<table class="pg-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Account No / Client</td>
|
||||
<td>{{ account?.accountNo }} — {{ account?.clientName }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Shares to Withdraw</td>
|
||||
<td>
|
||||
{{ requestedShares }} @ ₹{{ unitPrice | number: '1.2-2' }} = ₹{{ withdrawalValue | number: '1.2-2' }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr [class.swr-close-row]="remainingShares === 0">
|
||||
<td>Remaining Shares After Withdrawal</td>
|
||||
<td>
|
||||
{{ remainingShares }}
|
||||
@if (remainingShares === 0) {
|
||||
<span class="swr-close-tag"><mat-icon>lock</mat-icon> Account will be closed on approval</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Requested Date</td>
|
||||
<td>{{ requestedDate }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Board Resolution Number</td>
|
||||
<td>{{ resolutionNumber }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Date of Meeting</td>
|
||||
<td>{{ meetingDate }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Settlement Payment Type</td>
|
||||
<td>{{ settlementPaymentTypeName }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Linked Savings Account</td>
|
||||
<td>{{ account?.savingsAccountNumber || 'None — settlement will not run automatically' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="pos-section-label" style="margin-top: 12px">Validation Checks</div>
|
||||
<table class="pg-table">
|
||||
<tbody>
|
||||
@for (check of verificationChecks; track check.label) {
|
||||
<tr>
|
||||
<td>{{ check.label }}</td>
|
||||
<td [class.swr-check-pass]="check.pass" [class.swr-check-fail]="!check.pass">
|
||||
{{ check.pass ? 'Pass — ' : 'Review — ' }}{{ check.detail }}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="inv-form-actions">
|
||||
<button type="button" class="pg-btn pg-btn--primary" [disabled]="submitting" (click)="confirmSubmit()">
|
||||
{{ submitting ? 'Submitting…' : 'Confirm & Submit' }}
|
||||
</button>
|
||||
<button type="button" class="pg-btn" (click)="cancelConfirmDialog()">Back to Edit</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (showRejectDialog) {
|
||||
<div class="swr-modal-backdrop">
|
||||
<div class="swr-modal">
|
||||
<h3>Reject Share Withdrawal Request</h3>
|
||||
<label class="inv-form-field">
|
||||
<span>* Rejection Reason</span>
|
||||
<textarea rows="3" [(ngModel)]="rejectionReason"></textarea>
|
||||
</label>
|
||||
<div class="inv-form-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--danger"
|
||||
[disabled]="!rejectionReason.trim()"
|
||||
(click)="confirmRejectReview()"
|
||||
>
|
||||
Confirm Reject
|
||||
</button>
|
||||
<button type="button" class="pg-btn" (click)="showRejectDialog = false">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,480 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
.swr-modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgb(13 27 42 / 55%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.swr-modal {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
width: 420px;
|
||||
max-width: 90vw;
|
||||
max-height: 85vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 12px 32px rgb(15 23 42 / 20%);
|
||||
|
||||
h3 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 16px;
|
||||
color: #0d1b2a;
|
||||
}
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
resize: vertical;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
&:has(table) {
|
||||
width: 560px;
|
||||
}
|
||||
}
|
||||
|
||||
.swr-check-pass {
|
||||
color: #1a7f37;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.swr-check-fail {
|
||||
color: #b42318;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.swr-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: fit-content;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
|
||||
&--ok {
|
||||
background: #eafaf0;
|
||||
color: #1a7f37;
|
||||
}
|
||||
|
||||
&--warn {
|
||||
background: #fdecea;
|
||||
color: #b42318;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Account-will-close notice ---------- */
|
||||
.swr-close-notice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 4px 16px 16px;
|
||||
padding: 10px 16px;
|
||||
background: #fffbeb;
|
||||
border: 1px solid #fde68a;
|
||||
border-left: 3px solid #d97706;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
color: #92400e;
|
||||
|
||||
mat-icon {
|
||||
color: #d97706;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
strong {
|
||||
color: #7c2d12;
|
||||
}
|
||||
}
|
||||
|
||||
.swr-close-row td {
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.swr-close-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-left: 8px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: #fffbeb;
|
||||
color: #d97706;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
|
||||
mat-icon {
|
||||
font-size: 14px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Verification trigger card ---------- */
|
||||
.swrv-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 16px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
&__icon {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #3457d5, #6c8cff);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 4px 12px rgb(52 87 213 / 30%);
|
||||
}
|
||||
|
||||
&__text {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #0d1b2a;
|
||||
}
|
||||
|
||||
&__sub {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
margin-top: 2px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Validate pipeline modal ---------- */
|
||||
@keyframes swrv-fade {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes swrv-zoom {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(8px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes swrv-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.swrv-spin {
|
||||
animation: swrv-spin 0.9s linear infinite;
|
||||
}
|
||||
|
||||
.swrv-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
// No backdrop-filter: it would make this element a containing block for any position:fixed
|
||||
// descendant (e.g. if this modal is ever nested inside another fixed popup, or gains one of its
|
||||
// own later) — see the identical fix in share-withdrawal-requests.component.scss for the bug this
|
||||
// caused. Extra opacity compensates for the loss of the blur look.
|
||||
background: rgb(15 25 45 / 65%);
|
||||
z-index: 1200;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
animation: swrv-fade 0.2s ease-out;
|
||||
|
||||
&__card {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 24px 60px rgb(0 0 0 / 25%);
|
||||
animation: swrv-zoom 0.25s ease-out;
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 18px 22px;
|
||||
border-bottom: 1px solid #eef1f5;
|
||||
}
|
||||
|
||||
&__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #0d1b2a;
|
||||
}
|
||||
|
||||
&__title-icon {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, #3457d5, #6c8cff);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__close {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #fff;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.12s;
|
||||
|
||||
&:not(:disabled):hover {
|
||||
color: #b42318;
|
||||
border-color: #b42318;
|
||||
background: #fdecea;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
&__body {
|
||||
padding: 20px 22px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__intro {
|
||||
font-size: 13px;
|
||||
color: #475569;
|
||||
line-height: 1.55;
|
||||
margin: 0 0 16px;
|
||||
|
||||
strong {
|
||||
color: #0d1b2a;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
&__warn {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
background: #fef3e2;
|
||||
border: 1px solid rgb(217 119 6 / 30%);
|
||||
border-left: 3px solid #d97706;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 12px;
|
||||
color: #633806;
|
||||
line-height: 1.5;
|
||||
|
||||
mat-icon {
|
||||
color: #d97706;
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
strong {
|
||||
display: block;
|
||||
font-weight: 700;
|
||||
color: #4d2a05;
|
||||
}
|
||||
}
|
||||
|
||||
&__result {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 6px;
|
||||
padding: 12px 14px;
|
||||
background: #eafaf0;
|
||||
border: 1px solid rgb(26 127 55 / 25%);
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #1a7f37;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
&__footer {
|
||||
padding: 16px 22px;
|
||||
border-top: 1px solid #eef1f5;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
}
|
||||
|
||||
.swrv-steps {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.swrv-step {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
padding: 10px 0;
|
||||
position: relative;
|
||||
|
||||
&:not(:last-child)::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 15px;
|
||||
top: 38px;
|
||||
bottom: -4px;
|
||||
width: 2px;
|
||||
background: #e2e8f0;
|
||||
}
|
||||
|
||||
&__indicator {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
border: 2px solid #e2e8f0;
|
||||
color: #64748b;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
transition: all 0.25s;
|
||||
|
||||
mat-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
&__content {
|
||||
padding-top: 5px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
&__msg {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
&--active {
|
||||
.swrv-step__indicator {
|
||||
background: #3457d5;
|
||||
border-color: #3457d5;
|
||||
color: #fff;
|
||||
box-shadow: 0 0 0 4px rgb(52 87 213 / 15%);
|
||||
}
|
||||
|
||||
.swrv-step__label {
|
||||
color: #3457d5;
|
||||
}
|
||||
}
|
||||
|
||||
&--done {
|
||||
.swrv-step__indicator {
|
||||
background: #1a7f37;
|
||||
border-color: #1a7f37;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&::after {
|
||||
background: #1a7f37;
|
||||
}
|
||||
|
||||
.swrv-step__label {
|
||||
color: #0d1b2a;
|
||||
}
|
||||
}
|
||||
|
||||
&--failed {
|
||||
.swrv-step__indicator {
|
||||
background: #b42318;
|
||||
border-color: #b42318;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.swrv-step__label {
|
||||
color: #b42318;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.swrv-spin {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.swrv-modal,
|
||||
.swrv-modal__card {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
/**
|
||||
* 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, EventEmitter, Input, OnInit, Output, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { SharesService } from '../../shares/shares.service';
|
||||
import { ClerkShareWithdrawalService } from '../../cashier/clerk-share-withdrawal.service';
|
||||
import { CashierService } from '../../cashier/cashier.service';
|
||||
import { SearchSelectComponent, SearchSelectOption } from '../../shared/search-select/search-select.component';
|
||||
|
||||
export type ShareWdMode = 'create' | 'review';
|
||||
|
||||
interface ActivityEntry {
|
||||
timestamp: string;
|
||||
action: string;
|
||||
user: string;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Share Withdrawal (redeem shares) request — maker screen in the Clerk Dashboard. A clerk finds
|
||||
* the member's Share Account, enters how many shares to withdraw plus the governing Board
|
||||
* Resolution Number / Date of Meeting, and submits for Bank Manager approval. This is the ONLY
|
||||
* entry point a share withdrawal should go through — approval server-side executes the real
|
||||
* "redeemshares" command (see ClerkShareWithdrawalRequestApiResource on the backend).
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-share-withdrawal-request',
|
||||
templateUrl: './share-withdrawal-request.component.html',
|
||||
styleUrls: [
|
||||
'../../cashier/cashier-shared.scss',
|
||||
'../clerk-shared.scss',
|
||||
'./share-withdrawal-request.component.scss'
|
||||
],
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
RouterModule,
|
||||
MatIconModule,
|
||||
MatSnackBarModule,
|
||||
SearchSelectComponent
|
||||
]
|
||||
})
|
||||
export class ShareWithdrawalRequestComponent implements OnInit {
|
||||
private route = inject(ActivatedRoute);
|
||||
private router = inject(Router);
|
||||
private sharesService = inject(SharesService);
|
||||
private wdService = inject(ClerkShareWithdrawalService);
|
||||
private cashierService = inject(CashierService);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
|
||||
/** When embedded (e.g. opened as a popup from a list screen instead of routed to directly), mode
|
||||
* and requestId come from these inputs rather than the route, and success/close actions emit
|
||||
* events instead of navigating away. */
|
||||
@Input() embedded = false;
|
||||
@Input() overrideMode: ShareWdMode | null = null;
|
||||
@Input() overrideRequestId: number | null = null;
|
||||
@Output() closeRequested = new EventEmitter<void>();
|
||||
@Output() actionCompleted = new EventEmitter<void>();
|
||||
|
||||
mode: ShareWdMode = 'create';
|
||||
requestId: number | null = null;
|
||||
|
||||
submitting = false;
|
||||
loadingAccount = false;
|
||||
|
||||
selectedAccountId = '';
|
||||
accountOptions: SearchSelectOption[] = [];
|
||||
|
||||
account: any = null;
|
||||
|
||||
requestedShares: number | null = null;
|
||||
requestedDate = '';
|
||||
resolutionNumber = '';
|
||||
meetingDate = '';
|
||||
description = '';
|
||||
|
||||
paymentTypes: any[] = [];
|
||||
settlementPaymentTypeId: number | null = null;
|
||||
|
||||
activityLog: ActivityEntry[] = [];
|
||||
reviewRequest: any = null;
|
||||
showRejectDialog = false;
|
||||
rejectionReason = '';
|
||||
showConfirmDialog = false;
|
||||
|
||||
showValidateModal = false;
|
||||
validating = false;
|
||||
validateAllPassed = false;
|
||||
backendChecks: { label: string; pass: boolean; detail: string }[] | null = null;
|
||||
validateSteps: { label: string; detail: string; pass: boolean; status: 'pending' | 'active' | 'done' | 'failed' }[] =
|
||||
[];
|
||||
|
||||
get remainingShares(): number {
|
||||
const held = Number(this.account?.summary?.totalApprovedShares ?? this.account?.totalApprovedShares ?? 0);
|
||||
return held - (this.requestedShares || 0);
|
||||
}
|
||||
|
||||
get unitPrice(): number {
|
||||
return Number(this.account?.currentMarketPrice ?? 0);
|
||||
}
|
||||
|
||||
get withdrawalValue(): number {
|
||||
return (this.requestedShares || 0) * this.unitPrice;
|
||||
}
|
||||
|
||||
get paymentTypeOptions(): any[] {
|
||||
return this.paymentTypes.filter((pt: any) => !pt.isCashPayment);
|
||||
}
|
||||
|
||||
get selectedSettlementPaymentType(): any {
|
||||
return this.paymentTypes.find((pt: any) => pt.id === this.settlementPaymentTypeId);
|
||||
}
|
||||
|
||||
get settlementPaymentTypeName(): string {
|
||||
if (this.isReview) {
|
||||
return this.reviewRequest?.settlement_payment_type_name || '—';
|
||||
}
|
||||
return this.selectedSettlementPaymentType?.name || '—';
|
||||
}
|
||||
|
||||
get isFormValid(): boolean {
|
||||
if (!this.account) return false;
|
||||
if (!this.requestedShares || this.requestedShares <= 0) return false;
|
||||
const held = Number(this.account?.summary?.totalApprovedShares ?? this.account?.totalApprovedShares ?? 0);
|
||||
if (this.requestedShares > held) return false;
|
||||
if (!this.requestedDate) return false;
|
||||
if (!this.resolutionNumber.trim()) return false;
|
||||
if (!this.meetingDate) return false;
|
||||
if (!this.settlementPaymentTypeId) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Shown to the clerk before submit and to the manager before approval, so both can verify the same checks. */
|
||||
get verificationChecks(): { label: string; pass: boolean; detail: string }[] {
|
||||
const held = Number(this.account?.summary?.totalApprovedShares ?? this.account?.totalApprovedShares ?? 0);
|
||||
const shares = this.requestedShares || 0;
|
||||
return [
|
||||
{
|
||||
label: 'Board Resolution Number provided',
|
||||
pass: !!this.resolutionNumber?.trim(),
|
||||
detail: this.resolutionNumber?.trim() || 'Missing'
|
||||
},
|
||||
{ label: 'Date of Meeting provided', pass: !!this.meetingDate, detail: this.meetingDate || 'Missing' },
|
||||
{
|
||||
label: 'Shares requested within shares held',
|
||||
pass: shares > 0 && shares <= held,
|
||||
detail: `${shares} of ${held} shares held`
|
||||
},
|
||||
{
|
||||
label: 'Linked savings account confirmed',
|
||||
pass: !!this.account?.savingsAccountNumber,
|
||||
detail: this.account?.savingsAccountNumber || 'No linked savings account — settlement cannot run automatically'
|
||||
},
|
||||
{
|
||||
label: 'Settlement Payment Type selected (non-cash)',
|
||||
pass: this.settlementPaymentTypeName !== '—',
|
||||
detail: this.settlementPaymentTypeName
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
get isReview(): boolean {
|
||||
return this.mode === 'review';
|
||||
}
|
||||
|
||||
get pageTitle(): string {
|
||||
return this.isReview ? 'Review Share Withdrawal Request' : 'New Share Withdrawal Request';
|
||||
}
|
||||
|
||||
get pageSubtitle(): string {
|
||||
return this.isReview
|
||||
? "Approve or reject the clerk's share withdrawal request."
|
||||
: 'Submit a share withdrawal (redeem shares) request for Bank Manager approval.';
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.mode = this.overrideMode ?? (this.route.snapshot.data?.['mode'] as ShareWdMode) ?? 'create';
|
||||
const idParam = this.overrideRequestId ?? this.route.snapshot.params?.['id'];
|
||||
this.requestId = idParam ? Number(idParam) : null;
|
||||
|
||||
const today = new Date();
|
||||
this.requestedDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
||||
this.meetingDate = this.requestedDate;
|
||||
|
||||
this.sharesService
|
||||
.getShareAccountSummaries()
|
||||
.pipe(catchError(() => of([])))
|
||||
.subscribe((rows: any[]) => {
|
||||
this.accountOptions = (rows || [])
|
||||
.filter((r) => (r.status?.value || '').toLowerCase() === 'active')
|
||||
.map((r) => ({
|
||||
value: String(r.id),
|
||||
label: `${r.accountNo} — ${r.clientName} (${r.totalApprovedShares} shares)`,
|
||||
searchText: `${r.accountNo} ${r.clientName}`
|
||||
}));
|
||||
});
|
||||
|
||||
this.cashierService
|
||||
.getPaymentTypes()
|
||||
.pipe(catchError(() => of([])))
|
||||
.subscribe((types: any[]) => (this.paymentTypes = types ?? []));
|
||||
|
||||
if (this.requestId && this.mode === 'review') {
|
||||
this.loadReviewRequest(this.requestId);
|
||||
}
|
||||
}
|
||||
|
||||
onAccountSelected(accountId: string): void {
|
||||
this.selectedAccountId = accountId;
|
||||
if (!accountId) {
|
||||
this.account = null;
|
||||
return;
|
||||
}
|
||||
this.loadingAccount = true;
|
||||
this.sharesService
|
||||
.getSharesAccountData(accountId, false)
|
||||
.pipe(catchError(() => of(null)))
|
||||
.subscribe((acc: any) => {
|
||||
this.account = acc;
|
||||
this.loadingAccount = false;
|
||||
});
|
||||
}
|
||||
|
||||
private loadReviewRequest(id: number): void {
|
||||
this.wdService.getById(id).subscribe({
|
||||
next: (req: any) => {
|
||||
this.reviewRequest = req;
|
||||
this.requestedShares = Number(req.requested_shares);
|
||||
this.requestedDate = req.requested_date
|
||||
? String(req.requested_date).split('T')[0].split(' ')[0]
|
||||
: this.requestedDate;
|
||||
this.resolutionNumber = req.resolution_number ?? '';
|
||||
this.meetingDate = req.meeting_date ? String(req.meeting_date).split('T')[0].split(' ')[0] : this.meetingDate;
|
||||
this.description = req.description ?? '';
|
||||
|
||||
if (req.activity_log) {
|
||||
try {
|
||||
this.activityLog = JSON.parse(req.activity_log);
|
||||
} catch {
|
||||
this.activityLog = [];
|
||||
}
|
||||
}
|
||||
|
||||
this.sharesService
|
||||
.getSharesAccountData(String(req.share_account_id), false)
|
||||
.pipe(catchError(() => of(null)))
|
||||
.subscribe((acc: any) => (this.account = acc));
|
||||
},
|
||||
error: () => this.snackBar.open('Failed to load share withdrawal request.', 'Close', { duration: 5000 })
|
||||
});
|
||||
}
|
||||
|
||||
/** "Review before Save" per requirement — opens the confirm dialog instead of submitting directly. */
|
||||
openConfirmDialog(): void {
|
||||
if (!this.isFormValid || this.submitting) {
|
||||
return;
|
||||
}
|
||||
this.showConfirmDialog = true;
|
||||
}
|
||||
|
||||
cancelConfirmDialog(): void {
|
||||
this.showConfirmDialog = false;
|
||||
}
|
||||
|
||||
confirmSubmit(): void {
|
||||
this.showConfirmDialog = false;
|
||||
this.submitting = true;
|
||||
const payload = {
|
||||
shareAccountId: Number(this.selectedAccountId),
|
||||
accountNo: this.account?.accountNo,
|
||||
clientId: this.account?.clientId,
|
||||
clientName: this.account?.clientName,
|
||||
requestedShares: this.requestedShares,
|
||||
requestedDate: this.requestedDate,
|
||||
resolutionNumber: this.resolutionNumber.trim(),
|
||||
meetingDate: this.meetingDate,
|
||||
description: this.description,
|
||||
settlementPaymentTypeId: this.settlementPaymentTypeId
|
||||
};
|
||||
this.wdService.submit(payload).subscribe({
|
||||
next: () => {
|
||||
this.snackBar.open('Share withdrawal request submitted for approval.', 'Close', { duration: 4000 });
|
||||
if (this.embedded) {
|
||||
this.actionCompleted.emit();
|
||||
} else {
|
||||
this.router.navigate(['/clerk/dashboard']);
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
this.submitting = false;
|
||||
this.snackBar.open(err?.error?.defaultUserMessage || 'Could not submit the request.', 'Close', {
|
||||
duration: 6000
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
openValidateModal(): void {
|
||||
if (!this.reviewRequest) return;
|
||||
this.showValidateModal = true;
|
||||
this.runBackendValidation();
|
||||
}
|
||||
|
||||
closeValidateModal(): void {
|
||||
if (this.validating) return;
|
||||
this.showValidateModal = false;
|
||||
}
|
||||
|
||||
/** Re-checks live backend state (account still active, GL mapping still configured, etc.) rather
|
||||
* than relying on data already loaded in the browser — catches drift since submission. Results
|
||||
* are revealed one step at a time (see revealValidateSteps()) for the pipeline-style animation. */
|
||||
runBackendValidation(): void {
|
||||
if (!this.reviewRequest || this.validating) return;
|
||||
this.validating = true;
|
||||
this.backendChecks = null;
|
||||
this.validateSteps = [];
|
||||
this.wdService.validate(this.reviewRequest.id).subscribe({
|
||||
next: (res: any) => {
|
||||
const checks = (res?.checks ?? []) as { label: string; pass: boolean; detail: string }[];
|
||||
this.backendChecks = checks;
|
||||
this.validateAllPassed = !!res?.allPassed;
|
||||
this.validateSteps = checks.map((c) => ({ ...c, status: 'pending' as const }));
|
||||
this.revealValidateSteps(0);
|
||||
},
|
||||
error: (err) => {
|
||||
this.validating = false;
|
||||
this.snackBar.open(err?.error?.defaultUserMessage || 'Could not run backend validation.', 'Close', {
|
||||
duration: 6000
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private revealValidateSteps(index: number): void {
|
||||
if (index >= this.validateSteps.length) {
|
||||
this.validating = false;
|
||||
return;
|
||||
}
|
||||
this.validateSteps[index].status = 'active';
|
||||
setTimeout(() => {
|
||||
this.validateSteps[index].status = this.validateSteps[index].pass ? 'done' : 'failed';
|
||||
this.revealValidateSteps(index + 1);
|
||||
}, 320);
|
||||
}
|
||||
|
||||
approveReview(): void {
|
||||
if (!this.reviewRequest) return;
|
||||
this.wdService.approve(this.reviewRequest.id).subscribe({
|
||||
next: (res: any) => {
|
||||
let message =
|
||||
res?.settlementAmount != null
|
||||
? `Share withdrawal approved. ₹${Number(res.settlementAmount).toLocaleString('en-IN', { minimumFractionDigits: 2 })} credited to the linked savings account.`
|
||||
: (res?.settlementWarning ?? 'Share withdrawal approved and processed.');
|
||||
if (res?.accountClosed) {
|
||||
message += ' All shares withdrawn — the share account has been closed.';
|
||||
}
|
||||
this.snackBar.open(message, 'Close', { duration: 8000 });
|
||||
if (this.embedded) {
|
||||
this.actionCompleted.emit();
|
||||
} else {
|
||||
this.router.navigate(['/checker-inbox-and-tasks/share-withdrawal-requests']);
|
||||
}
|
||||
},
|
||||
error: (err) =>
|
||||
this.snackBar.open(err?.error?.defaultUserMessage || 'Could not approve the request.', 'Close', {
|
||||
duration: 6000
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
openRejectDialog(): void {
|
||||
this.showRejectDialog = true;
|
||||
this.rejectionReason = '';
|
||||
}
|
||||
|
||||
confirmRejectReview(): void {
|
||||
if (!this.reviewRequest || !this.rejectionReason.trim()) return;
|
||||
this.wdService.reject(this.reviewRequest.id, this.rejectionReason.trim()).subscribe({
|
||||
next: () => {
|
||||
this.showRejectDialog = false;
|
||||
this.snackBar.open('Share withdrawal request rejected.', 'Close', { duration: 4000 });
|
||||
if (this.embedded) {
|
||||
this.actionCompleted.emit();
|
||||
} else {
|
||||
this.router.navigate(['/checker-inbox-and-tasks/share-withdrawal-requests']);
|
||||
}
|
||||
},
|
||||
error: (err) =>
|
||||
this.snackBar.open(err?.error?.defaultUserMessage || 'Could not reject the request.', 'Close', {
|
||||
duration: 6000
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
goBack(): void {
|
||||
if (this.embedded) {
|
||||
this.closeRequested.emit();
|
||||
return;
|
||||
}
|
||||
this.router.navigate(this.isReview ? ['/checker-inbox-and-tasks/share-withdrawal-requests'] : ['/clerk/dashboard']);
|
||||
}
|
||||
}
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
<!--
|
||||
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="fm-dialog">
|
||||
<div class="fm-dialog__header" style="display: flex; align-items: center; gap: 8px">
|
||||
<mat-icon class="fm-dialog__icon">people</mat-icon>
|
||||
<span style="font-weight: 500; font-size: 18px">CHILDREN</span>
|
||||
</div>
|
||||
|
||||
<mat-dialog-content>
|
||||
<form [formGroup]="childrenForm">
|
||||
<div formArrayName="children" style="margin-top: 12px; background: #fff; padding: 12px">
|
||||
@for (child of children.controls; track $index) {
|
||||
<div
|
||||
[formGroupName]="$index"
|
||||
style="
|
||||
position: relative;
|
||||
padding: 24px 16px 16px;
|
||||
margin-top: 16px;
|
||||
margin-bottom: 24px;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
"
|
||||
>
|
||||
<div class="fm-grid">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Child Full Name*</mat-label>
|
||||
<input matInput formControlName="fullName" required placeholder="Child Full Name*" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Gender*</mat-label>
|
||||
<mat-select formControlName="genderId" required>
|
||||
@for (g of genderIdOptions; track g.id) {
|
||||
<mat-option [value]="g.id">{{ g.name }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" (click)="childDobPicker.open()">
|
||||
<mat-label>Date of Birth</mat-label>
|
||||
<input matInput [max]="maxDate" [matDatepicker]="childDobPicker" formControlName="dateOfBirth" />
|
||||
<mat-datepicker-toggle matSuffix [for]="childDobPicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #childDobPicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Nationality</mat-label>
|
||||
<mat-select formControlName="nationality">
|
||||
<mat-option value="INDIAN">Indian</mat-option>
|
||||
<mat-option value="NRI">NRI</mat-option>
|
||||
<mat-option value="FOREIGN_NATIONAL">Foreign National</mat-option>
|
||||
<mat-option value="OTHER">Other</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Aadhaar Number (Optional)</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="aadhaarNumber"
|
||||
maxlength="12"
|
||||
placeholder="12-digit Aadhaar"
|
||||
(keypress)="$event.charCode >= 48 && $event.charCode <= 57 ? true : $event.preventDefault()"
|
||||
/>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>PAN Number (Optional)</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="panNumber"
|
||||
maxlength="10"
|
||||
placeholder="ABCDE1234F"
|
||||
style="text-transform: uppercase"
|
||||
(input)="
|
||||
$any(children.at($index))
|
||||
.get('panNumber')
|
||||
?.setValue($any($event.target).value.toUpperCase(), { emitEvent: false })
|
||||
"
|
||||
/>
|
||||
@if (
|
||||
$any(children.at($index)).get('panNumber')?.hasError('pattern') &&
|
||||
$any(children.at($index)).get('panNumber')?.touched
|
||||
) {
|
||||
<mat-error>Invalid PAN format</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
@if ($index < children.controls.length - 1) {
|
||||
<div style="border-bottom: 1px dashed #ccc; margin-bottom: 16px"></div>
|
||||
}
|
||||
}
|
||||
<div style="border-bottom: 1px dashed #ccc; margin-bottom: 16px"></div>
|
||||
<button mat-stroked-button type="button" color="primary" (click)="addChild()">
|
||||
<mat-icon>add</mat-icon> Add Another Child
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-stroked-button mat-dialog-close type="button">Cancel</button>
|
||||
<button mat-flat-button color="primary" type="button" [disabled]="childrenForm.invalid" (click)="submit()">
|
||||
Add Member
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
</div>
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* 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 { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { UntypedFormBuilder, UntypedFormGroup, UntypedFormArray, Validators } from '@angular/forms';
|
||||
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatDatepickerModule } from '@angular/material/datepicker';
|
||||
import { MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { ReactiveFormsModule } from '@angular/forms';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-client-family-member-child-dialog',
|
||||
templateUrl: './client-family-member-child-dialog.component.html',
|
||||
styleUrls: ['../client-family-member-dialog/client-family-member-dialog.component.scss'],
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
MatDialogModule,
|
||||
MatIcon,
|
||||
MatButtonModule,
|
||||
MatInputModule,
|
||||
MatSelectModule,
|
||||
MatDatepickerModule,
|
||||
MatTooltipModule
|
||||
]
|
||||
})
|
||||
export class ClientFamilyMemberChildDialogComponent implements OnInit {
|
||||
dialogRef = inject(MatDialogRef<ClientFamilyMemberChildDialogComponent>);
|
||||
data = inject(MAT_DIALOG_DATA);
|
||||
private formBuilder = inject(UntypedFormBuilder);
|
||||
|
||||
childrenForm: UntypedFormGroup;
|
||||
genderIdOptions: any[];
|
||||
maxDate: Date = new Date();
|
||||
|
||||
constructor() {
|
||||
this.genderIdOptions = this.data.genderIdOptions;
|
||||
|
||||
this.childrenForm = this.formBuilder.group({
|
||||
children: this.formBuilder.array([])
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
if (this.data.children && this.data.children.length > 0) {
|
||||
this.data.children.forEach((child: any) => {
|
||||
this.children.push(
|
||||
this.formBuilder.group({
|
||||
id: [child.id],
|
||||
fullName: [
|
||||
child.fullName,
|
||||
Validators.required
|
||||
],
|
||||
genderId: [
|
||||
child.genderId,
|
||||
Validators.required
|
||||
],
|
||||
dateOfBirth: [child.dateOfBirth ? new Date(child.dateOfBirth) : ''],
|
||||
nationality: [child.nationality || 'INDIAN'],
|
||||
aadhaarNumber: [child.aadhaarNumber || ''],
|
||||
panNumber: [
|
||||
child.panNumber || '',
|
||||
Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)
|
||||
]
|
||||
})
|
||||
);
|
||||
});
|
||||
} else {
|
||||
this.addChild();
|
||||
}
|
||||
}
|
||||
|
||||
get children(): UntypedFormArray {
|
||||
return this.childrenForm.get('children') as UntypedFormArray;
|
||||
}
|
||||
|
||||
createChildFormGroup(): UntypedFormGroup {
|
||||
return this.formBuilder.group({
|
||||
id: [''],
|
||||
fullName: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
genderId: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
dateOfBirth: [''],
|
||||
nationality: ['INDIAN'],
|
||||
aadhaarNumber: [''],
|
||||
panNumber: [
|
||||
'',
|
||||
Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
addChild(): void {
|
||||
this.children.push(this.createChildFormGroup());
|
||||
}
|
||||
|
||||
removeChild(index: number): void {
|
||||
this.children.removeAt(index);
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
if (this.childrenForm.valid) {
|
||||
this.dialogRef.close(this.children.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
+34
@@ -139,6 +139,21 @@
|
||||
<span class="fm-readonly-value">{{ familyMemberForm.get('remarks')?.value }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (isSpouse && familyMemberForm.get('children')?.value?.length > 0) {
|
||||
<div class="fm-readonly-remarks">
|
||||
<span class="fm-readonly-label">Children</span>
|
||||
<div class="fm-children-list">
|
||||
@for (child of familyMemberForm.get('children')?.value; track $index) {
|
||||
<div class="fm-child-item" style="font-size: 13px; color: #555; margin-top: 4px">
|
||||
<span>{{ child.fullName }}</span>
|
||||
@if (child.dateOfBirth) {
|
||||
<span> · DOB: {{ child.dateOfBirth | date: 'dd MMM yyyy' }}</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<!-- ═══ ADD / EDIT MODE ═══ -->
|
||||
@@ -447,6 +462,25 @@
|
||||
<mat-label>Remarks</mat-label>
|
||||
<textarea matInput formControlName="remarks" rows="2"></textarea>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Children Section -->
|
||||
@if (isSpouse) {
|
||||
<div class="fm-section-label" style="margin-top: 16px">Children</div>
|
||||
<div class="fm-checkbox-row" style="display: flex; align-items: center; justify-content: space-between">
|
||||
<mat-checkbox formControlName="hasChildren">Does this family member have a child/children?</mat-checkbox>
|
||||
@if (familyMemberForm.get('hasChildren')?.value) {
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
color="primary"
|
||||
(click)="openChildrenDialog()"
|
||||
style="margin-left: 16px"
|
||||
>
|
||||
<mat-icon>edit</mat-icon> Edit Children Details ({{ children.length }})
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</form>
|
||||
</mat-dialog-content>
|
||||
|
||||
+143
-2
@@ -17,7 +17,7 @@ import {
|
||||
MatDialogClose,
|
||||
MatDialogContent
|
||||
} from '@angular/material/dialog';
|
||||
import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms';
|
||||
import { UntypedFormGroup, UntypedFormBuilder, UntypedFormArray, Validators } from '@angular/forms';
|
||||
import { SettingsService } from 'app/settings/settings.service';
|
||||
import { Dates } from 'app/core/utils/dates';
|
||||
import { ClientsService } from 'app/clients/clients.service';
|
||||
@@ -58,6 +58,8 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
|
||||
private dialog = inject(MatDialog);
|
||||
|
||||
maxDate = new Date();
|
||||
|
||||
hasChildrenChecked = false;
|
||||
familyMemberForm: UntypedFormGroup;
|
||||
|
||||
/** Client search */
|
||||
@@ -197,6 +199,32 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
|
||||
// Re-fetch linked client details (profile, signature, KYC status)
|
||||
this.fetchLinkedClientData(m.linkedClientId.toString());
|
||||
}
|
||||
if (m.children && m.children.length > 0) {
|
||||
this.hasChildrenChecked = true;
|
||||
this.familyMemberForm.patchValue({ hasChildren: true });
|
||||
m.children.forEach((child: any) => {
|
||||
this.children.push(
|
||||
this.formBuilder.group({
|
||||
id: [child.id],
|
||||
fullName: [
|
||||
child.fullName,
|
||||
Validators.required
|
||||
],
|
||||
genderId: [
|
||||
child.genderId,
|
||||
Validators.required
|
||||
],
|
||||
dateOfBirth: [child.dateOfBirth ? new Date(child.dateOfBirth) : ''],
|
||||
nationality: [child.nationality || 'INDIAN'],
|
||||
aadhaarNumber: [child.aadhaarNumber || ''],
|
||||
panNumber: [
|
||||
child.panNumber || '',
|
||||
Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)
|
||||
]
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
if (this.isViewMode) {
|
||||
this.familyMemberForm.disable();
|
||||
}
|
||||
@@ -225,8 +253,102 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
|
||||
isAlive: [true],
|
||||
marriageDate: [''],
|
||||
marriageAuthority: [''],
|
||||
linkedClientId: ['']
|
||||
linkedClientId: [''],
|
||||
hasChildren: [false],
|
||||
children: this.formBuilder.array([])
|
||||
});
|
||||
|
||||
this.familyMemberForm.get('hasChildren')?.valueChanges.subscribe((value) => {
|
||||
if (value && !this.hasChildrenChecked) {
|
||||
this.openChildrenDialog();
|
||||
} else if (!value) {
|
||||
this.hasChildrenChecked = false;
|
||||
this.children.clear();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
get children(): UntypedFormArray {
|
||||
return this.familyMemberForm.get('children') as UntypedFormArray;
|
||||
}
|
||||
|
||||
async openChildrenDialog() {
|
||||
// Dynamically import the dialog to avoid a circular dependency between the two dialogs.
|
||||
const { ClientFamilyMemberChildDialogComponent } =
|
||||
await import('../client-family-member-child-dialog/client-family-member-child-dialog.component');
|
||||
|
||||
const dialogRef = this.dialog.open(ClientFamilyMemberChildDialogComponent, {
|
||||
data: {
|
||||
children: this.children.value,
|
||||
genderIdOptions: this.data.options.genderIdOptions
|
||||
},
|
||||
width: '500px'
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe((result: any[]) => {
|
||||
if (result) {
|
||||
this.children.clear();
|
||||
result.forEach((child) => {
|
||||
this.children.push(
|
||||
this.formBuilder.group({
|
||||
id: [child.id],
|
||||
fullName: [
|
||||
child.fullName,
|
||||
Validators.required
|
||||
],
|
||||
genderId: [
|
||||
child.genderId,
|
||||
Validators.required
|
||||
],
|
||||
dateOfBirth: [child.dateOfBirth],
|
||||
nationality: [child.nationality || 'INDIAN'],
|
||||
aadhaarNumber: [child.aadhaarNumber || ''],
|
||||
panNumber: [
|
||||
child.panNumber || '',
|
||||
Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)
|
||||
]
|
||||
})
|
||||
);
|
||||
});
|
||||
this.hasChildrenChecked = true;
|
||||
this.familyMemberForm.patchValue({ hasChildren: true }, { emitEvent: false });
|
||||
} else if (this.children.length === 0) {
|
||||
this.hasChildrenChecked = false;
|
||||
this.familyMemberForm.patchValue({ hasChildren: false }, { emitEvent: false });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
createChildFormGroup(): UntypedFormGroup {
|
||||
return this.formBuilder.group({
|
||||
id: [''],
|
||||
fullName: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
genderId: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
dateOfBirth: [''],
|
||||
nationality: ['INDIAN'],
|
||||
aadhaarNumber: [''],
|
||||
panNumber: [
|
||||
'',
|
||||
Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
addChild(): void {
|
||||
this.children.push(this.createChildFormGroup());
|
||||
}
|
||||
|
||||
removeChild(index: number): void {
|
||||
this.children.removeAt(index);
|
||||
if (this.children.length === 0) {
|
||||
this.familyMemberForm.patchValue({ hasChildren: false });
|
||||
}
|
||||
}
|
||||
|
||||
/** Handle marriage certificate file selection */
|
||||
@@ -481,6 +603,25 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
|
||||
member._linkedClient = this.selectedClient;
|
||||
}
|
||||
|
||||
// Format children dates
|
||||
if (this.isSpouse && member.children && member.children.length > 0) {
|
||||
member.children = member.children.map((child: any) => {
|
||||
const c = { ...child };
|
||||
if (c.dateOfBirth instanceof Date) {
|
||||
c.dateOfBirth = this.dateUtils.formatDate(c.dateOfBirth, dateFormat);
|
||||
} else if (!c.dateOfBirth) {
|
||||
delete c.dateOfBirth;
|
||||
}
|
||||
if (c.panNumber) {
|
||||
c.panNumber = c.panNumber.toUpperCase();
|
||||
}
|
||||
return c;
|
||||
});
|
||||
} else {
|
||||
delete member.children;
|
||||
}
|
||||
delete member.hasChildren;
|
||||
|
||||
// Attach marriage certificate file if present
|
||||
if (this.marriageCertFile) {
|
||||
member._marriageCertFile = this.marriageCertFile;
|
||||
|
||||
+116
-6
@@ -533,6 +533,8 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
||||
screeningLogId: number | null = null;
|
||||
/** Snapshot of the name that was last verified — used to invalidate verification when name changes */
|
||||
private verifiedName: string = '';
|
||||
/** True while prefillForEdit() is patching historical monthly/annual values — see linkMonthlyAnnual(). */
|
||||
private suppressMonthlyAnnualLink = false;
|
||||
|
||||
/**
|
||||
* @param {FormBuilder} formBuilder Form Builder
|
||||
@@ -709,6 +711,12 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
||||
const data = this.clientData;
|
||||
this.loadExistingSeniorPwdDocuments();
|
||||
|
||||
// Loading an existing client's independently-saved monthly/annual values must not run
|
||||
// through the auto-calc in linkMonthlyAnnual() — patching monthlyIncome then annualIncome
|
||||
// (or vice versa) would otherwise have each overwrite the other via its valueChanges,
|
||||
// instead of keeping the exact historical values as saved.
|
||||
this.suppressMonthlyAnnualLink = true;
|
||||
|
||||
// Patch all base controls that exist on the form
|
||||
this.createClientForm.patchValue({
|
||||
officeId: data.officeId,
|
||||
@@ -743,6 +751,7 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
||||
? new Date(data.timeline.submittedOnDate)
|
||||
: this.settingsService.businessDate
|
||||
});
|
||||
this.suppressMonthlyAnnualLink = false;
|
||||
|
||||
// Patch name fields (already created by buildDependencies via legalFormId subscription)
|
||||
const legalFormId = data.legalForm?.id || LegalFormId.PERSON;
|
||||
@@ -954,6 +963,75 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
||||
Validators.required
|
||||
]
|
||||
});
|
||||
|
||||
this.linkMonthlyAnnual('monthlyIncome', 'annualIncome');
|
||||
this.linkMonthlyAnnual('monthlyTurnover', 'annualTurnover');
|
||||
this.linkIncomeToTurnover();
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-calculates turnover based on income (doubling monthly income for monthly turnover,
|
||||
* and annual income for annual/yearly turnover).
|
||||
*/
|
||||
private linkIncomeToTurnover(): void {
|
||||
const monthlyIncome = this.createClientForm.get('monthlyIncome');
|
||||
const monthlyTurnover = this.createClientForm.get('monthlyTurnover');
|
||||
const annualIncome = this.createClientForm.get('annualIncome');
|
||||
const annualTurnover = this.createClientForm.get('annualTurnover');
|
||||
|
||||
if (monthlyIncome && monthlyTurnover) {
|
||||
monthlyIncome.valueChanges.subscribe((value) => {
|
||||
if (this.suppressMonthlyAnnualLink) {
|
||||
return;
|
||||
}
|
||||
const num = parseFloat(value);
|
||||
monthlyTurnover.setValue(!isNaN(num) && num >= 0 ? this.round2(num * 2) : '', { emitEvent: true });
|
||||
});
|
||||
}
|
||||
|
||||
if (annualIncome && annualTurnover) {
|
||||
annualIncome.valueChanges.subscribe((value) => {
|
||||
if (this.suppressMonthlyAnnualLink) {
|
||||
return;
|
||||
}
|
||||
const num = parseFloat(value);
|
||||
annualTurnover.setValue(!isNaN(num) && num >= 0 ? this.round2(num * 2) : '', { emitEvent: true });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps a Monthly/Annual pair of amount fields in sync at a 12x ratio — typing into either
|
||||
* one auto-fills the other. Uses { emitEvent: false } on the write side so the two
|
||||
* subscriptions don't trigger each other back and forth.
|
||||
*/
|
||||
/** Rounds to at most 2 decimals without padding whole numbers with a trailing ".00". */
|
||||
private round2(n: number): string {
|
||||
return (Math.round(n * 100) / 100).toString();
|
||||
}
|
||||
|
||||
private linkMonthlyAnnual(monthlyControlName: string, annualControlName: string): void {
|
||||
const monthly = this.createClientForm.get(monthlyControlName);
|
||||
const annual = this.createClientForm.get(annualControlName);
|
||||
if (!monthly || !annual) {
|
||||
return;
|
||||
}
|
||||
|
||||
monthly.valueChanges.subscribe((value) => {
|
||||
if (this.suppressMonthlyAnnualLink) {
|
||||
return;
|
||||
}
|
||||
const num = parseFloat(value);
|
||||
annual.setValue(!isNaN(num) && num >= 0 ? this.round2(num * 12) : '', { emitEvent: false });
|
||||
});
|
||||
|
||||
annual.valueChanges.subscribe((value) => {
|
||||
if (this.suppressMonthlyAnnualLink) {
|
||||
return;
|
||||
}
|
||||
const num = parseFloat(value);
|
||||
monthly.setValue(!isNaN(num) && num >= 0 ? this.round2(num / 12) : '', { emitEvent: false });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1672,6 +1750,38 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
||||
return salutation ? `${salutation} ${full}` : full;
|
||||
}
|
||||
|
||||
/**
|
||||
* The name PAN and Aadhaar are actually matched against.
|
||||
*
|
||||
* Deliberately not {@link fullNamePreview}: that prefixes the salutation, while
|
||||
* ClientKycWritePlatformServiceJpaRepositoryImpl builds the verification name from firstname +
|
||||
* middlename + lastname only. Values are passed through untouched rather than capitalised, so what
|
||||
* the KYC step displays is exactly what goes to the provider.
|
||||
*/
|
||||
get verificationName(): string {
|
||||
return [
|
||||
this.createClientForm?.get('firstname')?.value,
|
||||
this.createClientForm?.get('middlename')?.value,
|
||||
this.createClientForm?.get('lastname')?.value
|
||||
]
|
||||
.map((part: any) => (part || '').toString().trim())
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
/** Date of birth as captured, sent to the provider formatted dd/MM/yyyy. */
|
||||
get verificationDateOfBirth(): Date | null {
|
||||
const value = this.createClientForm?.get('dateOfBirth')?.value;
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
if (value instanceof Date) {
|
||||
return value;
|
||||
}
|
||||
const parsed = new Date(value);
|
||||
return isNaN(parsed.getTime()) ? null : parsed;
|
||||
}
|
||||
|
||||
getSelectedGender(): any {
|
||||
const val = this.createClientForm.get('genderId')?.value;
|
||||
return this.genderOptions?.find((g: any) => g.id === val) || null;
|
||||
@@ -2190,26 +2300,26 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
||||
}
|
||||
|
||||
// Match monthly income ranges
|
||||
if (rule.minMonthlyIncome != null && monthlyIncome < rule.minMonthlyIncome) {
|
||||
if (rule.minMonthlyIncome != null && rule.minMonthlyIncome > 0 && monthlyIncome < rule.minMonthlyIncome) {
|
||||
continue;
|
||||
}
|
||||
if (rule.maxMonthlyIncome != null && monthlyIncome > rule.maxMonthlyIncome) {
|
||||
if (rule.maxMonthlyIncome != null && rule.maxMonthlyIncome > 0 && monthlyIncome > rule.maxMonthlyIncome) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Match annual income ranges (evaluated against totalAnnualIncome)
|
||||
if (rule.minAnnualIncome != null && totalAnnualIncome < rule.minAnnualIncome) {
|
||||
if (rule.minAnnualIncome != null && rule.minAnnualIncome > 0 && totalAnnualIncome < rule.minAnnualIncome) {
|
||||
continue;
|
||||
}
|
||||
if (rule.maxAnnualIncome != null && totalAnnualIncome > rule.maxAnnualIncome) {
|
||||
if (rule.maxAnnualIncome != null && rule.maxAnnualIncome > 0 && totalAnnualIncome > rule.maxAnnualIncome) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Match turnover ranges
|
||||
if (rule.minAnnualTurnover != null && annualTurnover < rule.minAnnualTurnover) {
|
||||
if (rule.minAnnualTurnover != null && rule.minAnnualTurnover > 0 && annualTurnover < rule.minAnnualTurnover) {
|
||||
continue;
|
||||
}
|
||||
if (rule.maxAnnualTurnover != null && annualTurnover > rule.maxAnnualTurnover) {
|
||||
if (rule.maxAnnualTurnover != null && rule.maxAnnualTurnover > 0 && annualTurnover > rule.maxAnnualTurnover) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
+52
-14
@@ -38,10 +38,17 @@ import { ClientsService } from 'app/clients/clients.service';
|
||||
}
|
||||
</div>
|
||||
<p class="sanctions-msg">
|
||||
Screening <strong>{{ clientName }}</strong> against<br />
|
||||
<strong>UN Security Council</strong> &
|
||||
<strong>Financial Intelligence Unit - India (FIU-IND under UNSC Resolution 2231)</strong>
|
||||
Screening <strong>{{ clientName }}</strong> against the<br />
|
||||
<strong>UN Security Council Consolidated List</strong><br />
|
||||
<span class="sanctions-lists"
|
||||
>ISIL (Da’esh) & Al-Qaida (1267/1989/2253) and Taliban (1988) sanctions lists</span
|
||||
>
|
||||
</p>
|
||||
@if (listDataAsOf) {
|
||||
<p class="list-as-of">
|
||||
Sanctions list data as of <strong>{{ listDataAsOf }}</strong>
|
||||
</p>
|
||||
}
|
||||
<p class="regulatory-note">{{ regulatoryBasis }}</p>
|
||||
</div>
|
||||
}
|
||||
@@ -54,8 +61,8 @@ import { ClientsService } from 'app/clients/clients.service';
|
||||
<h3 class="approve-title">No Sanctions Match Found</h3>
|
||||
<p class="approve-detail">
|
||||
<strong>{{ clientName }}</strong> has been screened against the
|
||||
<strong>UN Security Council Consolidated List</strong> and <strong>FIU-IND</strong> records. No confirmed
|
||||
match was found.
|
||||
<strong>UN Security Council Consolidated List</strong> (ISIL & Al-Qaida and Taliban sanctions lists). No
|
||||
confirmed match was found.
|
||||
</p>
|
||||
<div class="approve-meta">
|
||||
<div class="meta-row">
|
||||
@@ -222,6 +229,20 @@ import { ClientsService } from 'app/clients/clients.service';
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.list-as-of {
|
||||
font-size: 12px;
|
||||
color: #374151;
|
||||
text-align: center;
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
.sanctions-lists {
|
||||
display: inline-block;
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.regulatory-note {
|
||||
font-size: 11px;
|
||||
color: #9ca3af;
|
||||
@@ -550,16 +571,24 @@ export class SanctionsVerifyDialogComponent implements OnInit, OnDestroy {
|
||||
screeningLogId: number | null = null;
|
||||
listDataAsOf: string | null = null;
|
||||
|
||||
/**
|
||||
* Cites the direction actually in force. Chapter IX ("Requirements/obligations under International
|
||||
* Agreements") is the clause that puts the UNSC lists in scope, and the 2025 Directions superseded
|
||||
* the older Master Direction this screen used to name.
|
||||
*/
|
||||
regulatoryBasis =
|
||||
'Screened as per Section 51-A of the Unlawful Activities (Prevention) Act, 1967 and the RBI Master Direction on KYC.';
|
||||
'Screened under Section 51-A of the Unlawful Activities (Prevention) Act, 1967 and Chapter IX of the ' +
|
||||
'RBI Know Your Customer Directions, 2025 (amended as on December 29, 2025).';
|
||||
|
||||
screenedDate = new Date().toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' });
|
||||
progressValue = 0;
|
||||
currentStep = 0;
|
||||
// Steps describe the screening that actually runs against the UNSC consolidated list; there is no
|
||||
// FIU-IND lookup behind this call, so it is no longer claimed here.
|
||||
progressSteps = [
|
||||
{ label: 'Connecting to UN Security Council database...', icon: 'globe' as any },
|
||||
{ label: 'Screening name against consolidated list...', icon: 'search' as any },
|
||||
{ label: 'Cross-checking FIU-IND records...', icon: 'database' as any },
|
||||
{ label: 'Cross-checking listed aliases and entities...', icon: 'database' as any },
|
||||
{ label: 'Analyzing results...', icon: 'chart-bar' as any }
|
||||
];
|
||||
|
||||
@@ -577,6 +606,9 @@ export class SanctionsVerifyDialogComponent implements OnInit, OnDestroy {
|
||||
next: (result: any) => {
|
||||
this.apiResult = result;
|
||||
this.apiDone = true;
|
||||
// Surfaced while the screening is still running, so the operator can see which snapshot of
|
||||
// the UNSC list the check is being made against rather than only learning it afterwards.
|
||||
this.listDataAsOf = this.formatListDate(result?.listDataAsOf);
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.apiError = err;
|
||||
@@ -652,13 +684,7 @@ export class SanctionsVerifyDialogComponent implements OnInit, OnDestroy {
|
||||
'Unable to connect to sanctions screening service.';
|
||||
} else {
|
||||
this.screeningLogId = this.apiResult?.screeningLogId || null;
|
||||
this.listDataAsOf = this.apiResult?.listDataAsOf
|
||||
? new Date(this.apiResult.listDataAsOf).toLocaleDateString('en-IN', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric'
|
||||
})
|
||||
: null;
|
||||
this.listDataAsOf = this.formatListDate(this.apiResult?.listDataAsOf);
|
||||
if (this.apiResult?.matchFound) {
|
||||
this.matchFound = true;
|
||||
this.matchCount = this.apiResult.matchCount || 0;
|
||||
@@ -668,6 +694,18 @@ export class SanctionsVerifyDialogComponent implements OnInit, OnDestroy {
|
||||
}, 400);
|
||||
}
|
||||
|
||||
/** Formats the date the UNSC snapshot was loaded; null when the backend did not report one. */
|
||||
private formatListDate(value: string | null | undefined): string | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const parsed = new Date(value);
|
||||
if (isNaN(parsed.getTime())) {
|
||||
return null;
|
||||
}
|
||||
return parsed.toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' });
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
clearInterval(this.progressTimer);
|
||||
}
|
||||
|
||||
@@ -56,6 +56,42 @@
|
||||
}
|
||||
}
|
||||
|
||||
<!-- What PAN / Aadhaar are matched against. Shown before the officer clicks Verify, because
|
||||
the provider matches on exactly these two values and returns nothing identifiable when
|
||||
they disagree — a blind check that fails is much harder to act on than a visible one. -->
|
||||
@if (!isNonIndividual) {
|
||||
<div
|
||||
class="identity-basis full-width"
|
||||
[class.identity-basis--incomplete]="!verificationName || !verificationDateOfBirth"
|
||||
>
|
||||
<mat-icon class="identity-basis__icon">badge</mat-icon>
|
||||
<div class="identity-basis__body">
|
||||
<span class="identity-basis__legend">Documents are verified against the General tab</span>
|
||||
<div class="identity-basis__pairs">
|
||||
<span class="identity-basis__pair">
|
||||
<span class="identity-basis__k">Name</span>
|
||||
<span class="identity-basis__v">{{ verificationName || 'Not entered' }}</span>
|
||||
</span>
|
||||
<span class="identity-basis__pair">
|
||||
<span class="identity-basis__k">Date of Birth</span>
|
||||
<span class="identity-basis__v">
|
||||
{{ verificationDateOfBirth ? (verificationDateOfBirth | date: 'dd MMM yyyy') : 'Not entered' }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
@if (!verificationName || !verificationDateOfBirth) {
|
||||
<span class="identity-basis__warn">
|
||||
Both are required for PAN and Aadhaar verification — complete them on the General tab first.
|
||||
</span>
|
||||
} @else {
|
||||
<span class="identity-basis__hint">
|
||||
Must match the card exactly. Salutation is excluded; a mismatch in either fails verification.
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Individual PAN with inline verify -->
|
||||
@if (!isMinor && kycForm.get('hasPan')?.value) {
|
||||
<div class="form-field verify-field">
|
||||
@@ -105,8 +141,109 @@
|
||||
@if (panVerifyStatus === 'VERIFIED') {
|
||||
<span class="verify-chip verified"><fa-icon icon="check-circle"></fa-icon> Verified</span>
|
||||
}
|
||||
@if (panVerifyStatus !== 'VERIFIED' && panDeclarationTaken) {
|
||||
<span class="verify-chip declared"><fa-icon icon="file-signature"></fa-icon> Declaration</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (panVerifyStatus === 'VERIFIED') {
|
||||
<div
|
||||
class="pal-card full-width"
|
||||
[class.pal-card--ok]="panAadhaarSeedingStatus === 'Y'"
|
||||
[class.pal-card--fail]="panAadhaarSeedingStatus === 'N'"
|
||||
[class.pal-card--info]="panAadhaarSeedingStatus === 'E'"
|
||||
[class.pal-card--pending]="
|
||||
panAadhaarSeedingStatus !== 'Y' && panAadhaarSeedingStatus !== 'N' && panAadhaarSeedingStatus !== 'E'
|
||||
"
|
||||
style="margin-bottom: 16px"
|
||||
>
|
||||
<div class="pal-card__left">
|
||||
<div class="pal-card__icon">
|
||||
<mat-icon>credit_card</mat-icon>
|
||||
</div>
|
||||
<div class="pal-card__txt">
|
||||
<div class="pal-card__small">Verification Status</div>
|
||||
<div class="pal-card__title">PAN ↔ Aadhaar</div>
|
||||
<div class="pal-card__status">
|
||||
@if (panAadhaarSeedingStatus === 'Y') {
|
||||
<mat-icon>check_circle</mat-icon> Linked (Seeded)
|
||||
} @else if (panAadhaarSeedingStatus === 'N') {
|
||||
<mat-icon>cancel</mat-icon> Not Linked
|
||||
} @else if (panAadhaarSeedingStatus === 'E') {
|
||||
<mat-icon>info</mat-icon> Exempt
|
||||
} @else {
|
||||
<mat-icon>radio_button_unchecked</mat-icon> Pending
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pal-card__badge">
|
||||
@if (panAadhaarSeedingStatus === 'Y') {
|
||||
<mat-icon>check</mat-icon>
|
||||
} @else if (panAadhaarSeedingStatus === 'N') {
|
||||
<mat-icon>close</mat-icon>
|
||||
} @else if (panAadhaarSeedingStatus === 'E') {
|
||||
<mat-icon>info</mat-icon>
|
||||
} @else {
|
||||
<mat-icon>schedule</mat-icon>
|
||||
}
|
||||
</div>
|
||||
<div class="pal-card__hint">As per Income Tax Portal — Section 139AA</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!--
|
||||
PAN could not be verified but a declaration is on file. Shown in full because it is the
|
||||
only thing standing between this client and a PAN KYC exception — an officer reviewing
|
||||
the record has to be able to see what was declared, not just that something was.
|
||||
-->
|
||||
@if (panVerifyStatus !== 'VERIFIED' && panDeclarationTaken) {
|
||||
<div class="pan-decl-card">
|
||||
<div class="pan-decl-card__head">
|
||||
<mat-icon class="pan-decl-card__icon">assignment_turned_in</mat-icon>
|
||||
<span class="pan-decl-card__title">PAN Declaration Taken</span>
|
||||
@if (panDeclarationTakenOn) {
|
||||
<span class="pan-decl-card__date">{{ panDeclarationTakenOn }}</span>
|
||||
}
|
||||
</div>
|
||||
<div class="pan-decl-card__rows">
|
||||
<div class="pan-decl-card__row">
|
||||
<span class="k">Does not match</span>
|
||||
<span class="v strong">{{ panDeclarationMismatchLabel }}</span>
|
||||
</div>
|
||||
@if (panDeclaration?.nameOnPan) {
|
||||
<div class="pan-decl-card__row">
|
||||
<span class="k">Name on PAN</span>
|
||||
<span class="v">{{ panDeclaration?.nameOnPan }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (panDeclaration?.dobOnPan) {
|
||||
<div class="pan-decl-card__row">
|
||||
<span class="k">DOB on PAN</span>
|
||||
<span class="v">{{ panDeclaration?.dobOnPan | date: 'dd MMMM yyyy' }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (panDeclaration?.fileName) {
|
||||
<div class="pan-decl-card__row">
|
||||
<span class="k">Declaration file</span>
|
||||
<span class="v">{{ panDeclaration?.fileName }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="pan-decl-card__note">
|
||||
PAN remains unverified. No KYC exception is raised for PAN while this declaration is on file.
|
||||
</div>
|
||||
@if (!readOnly) {
|
||||
<div class="pan-decl-card__actions">
|
||||
<button mat-stroked-button type="button" (click)="openPanDeclaration()">Edit Declaration</button>
|
||||
<button mat-stroked-button type="button" class="danger" (click)="removePanDeclaration()">
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<!-- Aadhaar with independent verify (Individual only) -->
|
||||
@@ -175,9 +312,68 @@
|
||||
@if (aadhaarVerifyStatus === 'VERIFIED') {
|
||||
<span class="verify-chip verified"><fa-icon icon="check-circle"></fa-icon> Verified</span>
|
||||
}
|
||||
@if (aadhaarVerifyStatus !== 'VERIFIED' && aadhaarReceiptTaken) {
|
||||
<span class="verify-chip declared"><fa-icon icon="receipt"></fa-icon> Receipt</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
A customer who has applied for Aadhaar but not received it has no number to verify, so no
|
||||
amount of retrying the OTP will clear the check. Offer the enrolment slip instead rather
|
||||
than leaving the officer unable to finish the client.
|
||||
-->
|
||||
@if (aadhaarVerifyStatus !== 'VERIFIED' && !aadhaarReceiptTaken && !aadhaarOtpSent && !readOnly) {
|
||||
<div class="aad-rcpt-offer full-width">
|
||||
<mat-icon>receipt_long</mat-icon>
|
||||
<span>Customer has applied for Aadhaar but not received it yet?</span>
|
||||
<button mat-stroked-button type="button" (click)="openAadhaarReceipt()">Record Enrolment Receipt</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!--
|
||||
Aadhaar is unverified but an enrolment receipt is on file. Shown in full because it is the
|
||||
only thing standing between this client and an Aadhaar KYC exception.
|
||||
-->
|
||||
@if (aadhaarVerifyStatus !== 'VERIFIED' && aadhaarReceiptTaken) {
|
||||
<div class="aad-rcpt-card full-width">
|
||||
<div class="aad-rcpt-card__head">
|
||||
<mat-icon class="aad-rcpt-card__icon">receipt_long</mat-icon>
|
||||
<span class="aad-rcpt-card__title">Aadhaar Enrolment Receipt Recorded</span>
|
||||
@if (aadhaarReceiptTakenOn) {
|
||||
<span class="aad-rcpt-card__date">{{ aadhaarReceiptTakenOn }}</span>
|
||||
}
|
||||
</div>
|
||||
<div class="aad-rcpt-card__rows">
|
||||
<div class="aad-rcpt-card__row">
|
||||
<span class="k">Enrolment ID</span>
|
||||
<span class="v mono">{{ aadhaarReceipt?.enrolmentId }}</span>
|
||||
</div>
|
||||
@if (aadhaarReceipt?.enrolmentDate) {
|
||||
<div class="aad-rcpt-card__row">
|
||||
<span class="k">Enrolment date</span>
|
||||
<span class="v">{{ aadhaarReceipt?.enrolmentDate | date: 'dd MMMM yyyy' }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (aadhaarReceipt?.remarks) {
|
||||
<div class="aad-rcpt-card__row">
|
||||
<span class="k">Remarks</span>
|
||||
<span class="v">{{ aadhaarReceipt?.remarks }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="aad-rcpt-card__note">
|
||||
Aadhaar remains unverified. No KYC exception is raised for Aadhaar while this receipt is on file.
|
||||
</div>
|
||||
@if (!readOnly) {
|
||||
<div class="aad-rcpt-card__actions">
|
||||
<button mat-stroked-button type="button" (click)="openAadhaarReceipt()">Edit Receipt</button>
|
||||
<button mat-stroked-button type="button" class="danger" (click)="removeAadhaarReceipt()">Remove</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Aadhaar OTP Section -->
|
||||
@if (aadhaarOtpSent && aadhaarVerifyStatus !== 'VERIFIED') {
|
||||
<div class="otp-card full-width">
|
||||
@@ -723,6 +919,55 @@
|
||||
<span>PAN verification requires client to be created first. Complete General tab and click Next to save.</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- KYC Exception Flag -->
|
||||
@if (!kycAllDone) {
|
||||
<div
|
||||
class="exception-flag-box"
|
||||
style="
|
||||
margin-top: 20px;
|
||||
padding: 16px;
|
||||
border: 1px solid #f59e0b;
|
||||
background: #fffbeb;
|
||||
border-radius: 8px;
|
||||
clear: both;
|
||||
"
|
||||
>
|
||||
<h5
|
||||
style="
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #b45309;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
"
|
||||
>
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||
KYC Exception Flag
|
||||
</h5>
|
||||
<div style="display: flex; align-items: center; gap: 20px; flex-wrap: wrap">
|
||||
<mat-checkbox formControlName="isException">Mark as KYC Exception</mat-checkbox>
|
||||
|
||||
@if (kycForm.get('isException')?.value) {
|
||||
<mat-form-field appearance="outline" style="flex: 1; min-width: 280px; margin-bottom: -1.25em">
|
||||
<mat-label>Exception Reasons</mat-label>
|
||||
<mat-select formControlName="exceptionReason" multiple>
|
||||
@if (!isNonIndividual) {
|
||||
<mat-option value="PAN Card Verification Failed">PAN Card Verification Failed</mat-option>
|
||||
<mat-option value="Aadhaar Verification Failed">Aadhaar Verification Failed</mat-option>
|
||||
} @else {
|
||||
<mat-option value="Enterprise PAN Verification Failed">Enterprise PAN Verification Failed</mat-option>
|
||||
<mat-option value="GSTIN Verification Failed">GSTIN Verification Failed</mat-option>
|
||||
<mat-option value="CIN Verification Failed">CIN Verification Failed</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- ═══ SECTION: KYC Document Management ═══ -->
|
||||
@@ -800,19 +1045,23 @@
|
||||
<!-- Existing saved ID-proof doc — locked by default. Replace flow
|
||||
unlocks automatically when the section-level Edit button toggles
|
||||
`readOnly` off. -->
|
||||
@if (existingKycDocuments.length > 0 && !kycDocFiles.front && readOnly) {
|
||||
@if (existingKycDocSides.front && !kycDocFiles.front && readOnly) {
|
||||
<div class="kyc-doc-dropzone kyc-doc-dropzone--has kyc-doc-dropzone--locked">
|
||||
<div class="kyc-doc-dropzone__existing">
|
||||
@if (kycDocMergedPreview) {
|
||||
<img [src]="kycDocMergedPreview" alt="Saved KYC document" class="kyc-doc-dropzone__thumb" />
|
||||
@if (existingKycDocSides.front?.previewUrl) {
|
||||
<img
|
||||
[src]="existingKycDocSides.front?.previewUrl"
|
||||
alt="Saved front side"
|
||||
class="kyc-doc-dropzone__thumb"
|
||||
/>
|
||||
} @else {
|
||||
<mat-icon class="kyc-doc-dropzone__doc-ic">description</mat-icon>
|
||||
}
|
||||
<div class="kyc-doc-dropzone__existing-meta">
|
||||
<span class="kyc-doc-dropzone__existing-name">
|
||||
{{ existingKycDocuments[0].fileName || existingKycDocuments[0].name }}
|
||||
{{ existingKycDocSides.front?.fileName }}
|
||||
</span>
|
||||
<button type="button" class="kyc-doc-dropzone__view" (click)="viewExistingKycDoc()">
|
||||
<button type="button" class="kyc-doc-dropzone__view" (click)="viewExistingKycDoc('front')">
|
||||
<mat-icon>visibility</mat-icon>
|
||||
Click here to view
|
||||
</button>
|
||||
@@ -822,8 +1071,8 @@
|
||||
} @else {
|
||||
<label
|
||||
class="kyc-doc-dropzone"
|
||||
[class.kyc-doc-dropzone--has]="kycDocFiles.front || existingKycDocuments.length > 0"
|
||||
[class.kyc-doc-dropzone--req]="!kycDocFiles.front && existingKycDocuments.length === 0"
|
||||
[class.kyc-doc-dropzone--has]="kycDocFiles.front || existingKycDocSides.front"
|
||||
[class.kyc-doc-dropzone--req]="!kycDocFiles.front && !existingKycDocSides.front"
|
||||
>
|
||||
<input type="file" accept=".jpg,.jpeg,.png,.pdf" hidden (change)="onKycDocFile($event, 'front')" />
|
||||
@if (kycDocFiles.front) {
|
||||
@@ -839,24 +1088,28 @@
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
} @else if (existingKycDocuments.length > 0) {
|
||||
} @else if (existingKycDocSides.front) {
|
||||
<!-- Edit mode WITH existing doc — let the user view it OR
|
||||
click anywhere to replace. -->
|
||||
<div class="kyc-doc-dropzone__existing">
|
||||
@if (kycDocMergedPreview) {
|
||||
<img [src]="kycDocMergedPreview" alt="Saved KYC document" class="kyc-doc-dropzone__thumb" />
|
||||
@if (existingKycDocSides.front?.previewUrl) {
|
||||
<img
|
||||
[src]="existingKycDocSides.front?.previewUrl"
|
||||
alt="Saved front side"
|
||||
class="kyc-doc-dropzone__thumb"
|
||||
/>
|
||||
} @else {
|
||||
<mat-icon class="kyc-doc-dropzone__doc-ic">description</mat-icon>
|
||||
}
|
||||
<div class="kyc-doc-dropzone__existing-meta">
|
||||
<span class="kyc-doc-dropzone__existing-name">
|
||||
{{ existingKycDocuments[0].fileName || existingKycDocuments[0].name }}
|
||||
{{ existingKycDocSides.front?.fileName }}
|
||||
</span>
|
||||
<div class="kyc-doc-dropzone__existing-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="kyc-doc-dropzone__view"
|
||||
(click)="$event.preventDefault(); $event.stopPropagation(); viewExistingKycDoc()"
|
||||
(click)="$event.preventDefault(); $event.stopPropagation(); viewExistingKycDoc('front')"
|
||||
>
|
||||
<mat-icon>visibility</mat-icon>
|
||||
View
|
||||
@@ -883,25 +1136,45 @@
|
||||
<div class="kyc-doc-upload-slot">
|
||||
<div class="kyc-doc-upload-head">
|
||||
<span class="kyc-doc-upload-title"> <mat-icon>image</mat-icon> Back side </span>
|
||||
<!-- Hide Required pill when saved combined doc covers both sides -->
|
||||
@if (!(existingKycDocuments.length > 0 && !kycDocFiles.back)) {
|
||||
<!-- Hide Required pill when a back scan (or a stacked scan holding it) is on file -->
|
||||
@if (!((existingKycDocSides.back || kycDocIsLegacyCombined) && !kycDocFiles.back)) {
|
||||
<span class="kyc-doc-upload-req">Required</span>
|
||||
}
|
||||
</div>
|
||||
<!-- Locked view: saved combined doc covers the back side too. -->
|
||||
@if (existingKycDocuments.length > 0 && !kycDocFiles.back && readOnly) {
|
||||
<!--
|
||||
A stacked scan that could not be cut in two has no separate back image. Say so rather
|
||||
than repeating the front slot's picture here.
|
||||
-->
|
||||
@if (kycDocIsLegacyCombined && !kycDocFiles.back) {
|
||||
<div class="kyc-doc-dropzone kyc-doc-dropzone--has kyc-doc-dropzone--locked">
|
||||
<div class="kyc-doc-dropzone__existing">
|
||||
@if (kycDocMergedPreview) {
|
||||
<img [src]="kycDocMergedPreview" alt="Saved KYC document" class="kyc-doc-dropzone__thumb" />
|
||||
<mat-icon class="kyc-doc-dropzone__doc-ic">filter_none</mat-icon>
|
||||
<div class="kyc-doc-dropzone__existing-meta">
|
||||
<span class="kyc-doc-dropzone__existing-name">Included in the combined scan</span>
|
||||
<button type="button" class="kyc-doc-dropzone__view" (click)="viewExistingKycDoc('front')">
|
||||
<mat-icon>visibility</mat-icon>
|
||||
View both sides
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
} @else if (existingKycDocSides.back && !kycDocFiles.back && readOnly) {
|
||||
<div class="kyc-doc-dropzone kyc-doc-dropzone--has kyc-doc-dropzone--locked">
|
||||
<div class="kyc-doc-dropzone__existing">
|
||||
@if (existingKycDocSides.back?.previewUrl) {
|
||||
<img
|
||||
[src]="existingKycDocSides.back?.previewUrl"
|
||||
alt="Saved back side"
|
||||
class="kyc-doc-dropzone__thumb"
|
||||
/>
|
||||
} @else {
|
||||
<mat-icon class="kyc-doc-dropzone__doc-ic">description</mat-icon>
|
||||
}
|
||||
<div class="kyc-doc-dropzone__existing-meta">
|
||||
<span class="kyc-doc-dropzone__existing-name">
|
||||
{{ existingKycDocuments[0].fileName || existingKycDocuments[0].name }}
|
||||
{{ existingKycDocSides.back?.fileName }}
|
||||
</span>
|
||||
<button type="button" class="kyc-doc-dropzone__view" (click)="viewExistingKycDoc()">
|
||||
<button type="button" class="kyc-doc-dropzone__view" (click)="viewExistingKycDoc('back')">
|
||||
<mat-icon>visibility</mat-icon>
|
||||
Click here to view
|
||||
</button>
|
||||
@@ -911,8 +1184,8 @@
|
||||
} @else {
|
||||
<label
|
||||
class="kyc-doc-dropzone"
|
||||
[class.kyc-doc-dropzone--has]="kycDocFiles.back || existingKycDocuments.length > 0"
|
||||
[class.kyc-doc-dropzone--req]="!kycDocFiles.back && existingKycDocuments.length === 0"
|
||||
[class.kyc-doc-dropzone--has]="kycDocFiles.back || existingKycDocSides.back"
|
||||
[class.kyc-doc-dropzone--req]="!kycDocFiles.back && !existingKycDocSides.back"
|
||||
>
|
||||
<input type="file" accept=".jpg,.jpeg,.png,.pdf" hidden (change)="onKycDocFile($event, 'back')" />
|
||||
@if (kycDocFiles.back) {
|
||||
@@ -928,23 +1201,27 @@
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
} @else if (existingKycDocuments.length > 0) {
|
||||
<!-- Edit mode WITH existing combined doc — view + replace. -->
|
||||
} @else if (existingKycDocSides.back) {
|
||||
<!-- Edit mode WITH an existing back scan — view + replace. -->
|
||||
<div class="kyc-doc-dropzone__existing">
|
||||
@if (kycDocMergedPreview) {
|
||||
<img [src]="kycDocMergedPreview" alt="Saved KYC document" class="kyc-doc-dropzone__thumb" />
|
||||
@if (existingKycDocSides.back?.previewUrl) {
|
||||
<img
|
||||
[src]="existingKycDocSides.back?.previewUrl"
|
||||
alt="Saved back side"
|
||||
class="kyc-doc-dropzone__thumb"
|
||||
/>
|
||||
} @else {
|
||||
<mat-icon class="kyc-doc-dropzone__doc-ic">description</mat-icon>
|
||||
}
|
||||
<div class="kyc-doc-dropzone__existing-meta">
|
||||
<span class="kyc-doc-dropzone__existing-name">
|
||||
{{ existingKycDocuments[0].fileName || existingKycDocuments[0].name }}
|
||||
{{ existingKycDocSides.back?.fileName }}
|
||||
</span>
|
||||
<div class="kyc-doc-dropzone__existing-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="kyc-doc-dropzone__view"
|
||||
(click)="$event.preventDefault(); $event.stopPropagation(); viewExistingKycDoc()"
|
||||
(click)="$event.preventDefault(); $event.stopPropagation(); viewExistingKycDoc('back')"
|
||||
>
|
||||
<mat-icon>visibility</mat-icon>
|
||||
View
|
||||
@@ -980,7 +1257,9 @@
|
||||
<mat-icon>filter_none</mat-icon>
|
||||
Combined preview (front + back)
|
||||
</span>
|
||||
<span class="kyc-merged__hint">Saved as a single PNG. Re-crop a side to update the merged file.</span>
|
||||
<span class="kyc-merged__hint">
|
||||
Preview only — each side is saved as its own file. Re-crop a side to update it.
|
||||
</span>
|
||||
</div>
|
||||
<div class="kyc-merged__preview">
|
||||
<img [src]="kycDocMergedPreview" alt="Merged KYC document" />
|
||||
@@ -1150,27 +1429,36 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
<mat-form-field appearance="outline" class="form-field full-width">
|
||||
<mat-label>{{ isKycVerified ? 'Reason for Re-edit *' : 'Remarks' }}</mat-label>
|
||||
<textarea
|
||||
matInput
|
||||
formControlName="remarks"
|
||||
rows="2"
|
||||
[placeholder]="isKycVerified ? 'Mandatory — explain why KYC is being re-edited' : ''"
|
||||
></textarea>
|
||||
@if (isKycVerified && !kycForm.get('remarks')?.value?.trim()) {
|
||||
<mat-error>Remarks are mandatory when updating a verified KYC record</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
@if (isKycVerified) {
|
||||
<div class="kyc-reedit-alert">
|
||||
<mat-icon class="kyc-reedit-alert__icon">info</mat-icon>
|
||||
<span
|
||||
>KYC is <strong>VERIFIED</strong>. Any update will reset the status to <strong>IN PROGRESS</strong> and
|
||||
require PAN re-verification.</span
|
||||
>
|
||||
</div>
|
||||
@if (kycForm.get('allowRiskOverride')?.value || isKycVerified) {
|
||||
<mat-form-field appearance="outline" class="form-field full-width">
|
||||
<mat-label>
|
||||
{{
|
||||
isKycVerified
|
||||
? 'Reason for Re-edit'
|
||||
: kycForm.get('allowRiskOverride')?.value
|
||||
? 'Remarks (Required for Risk Override)'
|
||||
: 'Remarks'
|
||||
}}
|
||||
</mat-label>
|
||||
<textarea
|
||||
matInput
|
||||
formControlName="remarks"
|
||||
rows="2"
|
||||
[placeholder]="
|
||||
isKycVerified
|
||||
? 'Mandatory — explain why KYC is being re-edited'
|
||||
: kycForm.get('allowRiskOverride')?.value
|
||||
? 'Mandatory — explain why you are overriding the auto-calculated risk category'
|
||||
: ''
|
||||
"
|
||||
></textarea>
|
||||
@if ((isKycVerified || kycForm.get('allowRiskOverride')?.value) && !kycForm.get('remarks')?.value?.trim()) {
|
||||
<mat-error>
|
||||
Remarks are mandatory when
|
||||
{{ isKycVerified ? 'updating a verified KYC record' : 'overriding the risk category' }}
|
||||
</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -1346,6 +1634,39 @@
|
||||
<mat-option value="REGULATORY">Regulatory Authority</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="form-field" (click)="freezeDatePicker.open()">
|
||||
<mat-label>Freeze Date</mat-label>
|
||||
<span matPrefix class="field-prefix-icon"><mat-icon>calendar_today</mat-icon></span>
|
||||
<input matInput required [max]="today" [matDatepicker]="freezeDatePicker" formControlName="freezeDate" />
|
||||
<mat-datepicker-toggle matSuffix [for]="freezeDatePicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #freezeDatePicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
<mat-label>Freeze Amount</mat-label>
|
||||
<span matPrefix class="field-prefix-icon"><mat-icon>currency_rupee</mat-icon></span>
|
||||
<input matInput type="number" min="0" formControlName="freezeAmount" />
|
||||
<mat-hint>Leave blank for a full freeze</mat-hint>
|
||||
</mat-form-field>
|
||||
}
|
||||
|
||||
@if (kycForm.get('accountStatus')?.value === 'UNFREEZE') {
|
||||
<mat-form-field appearance="outline" class="form-field" (click)="unfreezeDatePicker.open()">
|
||||
<mat-label>Unfreeze Date</mat-label>
|
||||
<span matPrefix class="field-prefix-icon"><mat-icon>lock_open</mat-icon></span>
|
||||
<input matInput required [max]="today" [matDatepicker]="unfreezeDatePicker" formControlName="unfreezeDate" />
|
||||
<mat-datepicker-toggle matSuffix [for]="unfreezeDatePicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #unfreezeDatePicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
}
|
||||
|
||||
@if (kycForm.get('accountStatus')?.value === 'NO_DEBIT') {
|
||||
<mat-form-field appearance="outline" class="form-field" (click)="noDebitDatePicker.open()">
|
||||
<mat-label>No Debit From Date</mat-label>
|
||||
<span matPrefix class="field-prefix-icon"><mat-icon>money_off</mat-icon></span>
|
||||
<input matInput required [matDatepicker]="noDebitDatePicker" formControlName="noDebitDate" />
|
||||
<mat-datepicker-toggle matSuffix [for]="noDebitDatePicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #noDebitDatePicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1498,4 +1819,34 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Form Validation Summary -->
|
||||
@if (kycForm && kycForm.invalid) {
|
||||
<div
|
||||
class="kyc-validation-summary"
|
||||
style="
|
||||
margin-top: 24px;
|
||||
padding: 16px;
|
||||
background: #fff5f5;
|
||||
border: 1px solid #fee2e2;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
clear: both;
|
||||
"
|
||||
>
|
||||
<mat-icon style="color: #ef4444; flex-shrink: 0">error_outline</mat-icon>
|
||||
<div style="display: flex; flex-direction: column; gap: 4px">
|
||||
<span style="font-size: 14px; font-weight: 600; color: #991b1b"
|
||||
>Please complete/correct the following fields to proceed:</span
|
||||
>
|
||||
<ul style="margin: 4px 0 0 16px; padding: 0; font-size: 13px; color: #b91c1c; line-height: 1.5">
|
||||
@for (label of invalidFieldLabels; track label) {
|
||||
<li>{{ label }}</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -304,6 +304,206 @@
|
||||
color: #059669;
|
||||
border: 1px solid #a7f3d0;
|
||||
}
|
||||
|
||||
// Not verified, but covered by a declaration — deliberately amber rather than green so it never
|
||||
// reads as a passing check.
|
||||
&.declared {
|
||||
background: #fffbeb;
|
||||
color: #b45309;
|
||||
border: 1px solid #fcd34d;
|
||||
}
|
||||
}
|
||||
|
||||
// ── PAN declaration summary ────────────────────────────────────────────────
|
||||
.pan-decl-card {
|
||||
margin: -4px 0 16px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #fcd34d;
|
||||
border-radius: 8px;
|
||||
background: #fffbeb;
|
||||
}
|
||||
|
||||
.pan-decl-card__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.pan-decl-card__icon {
|
||||
color: #b45309;
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.pan-decl-card__title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.pan-decl-card__date {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.pan-decl-card__rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.pan-decl-card__row {
|
||||
display: grid;
|
||||
grid-template-columns: 130px 1fr;
|
||||
gap: 10px;
|
||||
font-size: 12.5px;
|
||||
|
||||
.k {
|
||||
color: #92400e;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.v {
|
||||
color: #451a03;
|
||||
word-break: break-word;
|
||||
|
||||
&.strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pan-decl-card__note {
|
||||
margin-top: 10px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid #fde68a;
|
||||
font-size: 11.5px;
|
||||
line-height: 1.5;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.pan-decl-card__actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
|
||||
.danger {
|
||||
color: #b91c1c;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Aadhaar enrolment receipt ──────────────────────────────────────────────
|
||||
// Blue rather than the declaration's amber: nothing has failed here, the customer simply does not
|
||||
// hold the document yet.
|
||||
.aad-rcpt-offer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin: -4px 0 16px;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 8px;
|
||||
background: #eff6ff;
|
||||
font-size: 12.5px;
|
||||
color: #1e40af;
|
||||
|
||||
mat-icon {
|
||||
color: #2563eb;
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
button {
|
||||
margin-left: auto;
|
||||
color: #1d4ed8 !important;
|
||||
border-color: #bfdbfe !important;
|
||||
}
|
||||
}
|
||||
|
||||
.aad-rcpt-card {
|
||||
margin: -4px 0 16px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 8px;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.aad-rcpt-card__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.aad-rcpt-card__icon {
|
||||
color: #2563eb;
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.aad-rcpt-card__title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #1e3a8a;
|
||||
}
|
||||
|
||||
.aad-rcpt-card__date {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.aad-rcpt-card__rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.aad-rcpt-card__row {
|
||||
display: grid;
|
||||
grid-template-columns: 130px 1fr;
|
||||
gap: 10px;
|
||||
font-size: 12.5px;
|
||||
|
||||
.k {
|
||||
color: #1e40af;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.v {
|
||||
color: #0f172a;
|
||||
word-break: break-word;
|
||||
|
||||
&.mono {
|
||||
font-family: monospace;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.aad-rcpt-card__note {
|
||||
margin-top: 10px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid #bfdbfe;
|
||||
font-size: 11.5px;
|
||||
line-height: 1.5;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.aad-rcpt-card__actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
|
||||
.danger {
|
||||
color: #b91c1c;
|
||||
}
|
||||
}
|
||||
|
||||
.verify-icon-ok {
|
||||
@@ -2431,3 +2631,358 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* The name and DOB that PAN / Aadhaar are matched against, surfaced from the General tab so a
|
||||
mismatch is visible before the check runs rather than only after it fails. */
|
||||
.identity-basis {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 11px 14px;
|
||||
margin-bottom: 4px;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 8px;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.identity-basis--incomplete {
|
||||
border-color: #fdba74;
|
||||
background: #fff7ed;
|
||||
}
|
||||
|
||||
.identity-basis__icon {
|
||||
flex: 0 0 auto;
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: #1d4ed8;
|
||||
|
||||
.identity-basis--incomplete & {
|
||||
color: #c2410c;
|
||||
}
|
||||
}
|
||||
|
||||
.identity-basis__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.identity-basis__legend {
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.identity-basis__pairs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 24px;
|
||||
}
|
||||
|
||||
.identity-basis__pair {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.identity-basis__k {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.identity-basis__v {
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.identity-basis__hint {
|
||||
font-size: 11.5px;
|
||||
color: #64748b;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.identity-basis__warn {
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
color: #c2410c;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
PAN ↔ Aadhaar premium status card
|
||||
─── Replaces the legacy table-row treatment. Dark gradient with
|
||||
offset glow circles, large gradient icon, and a state-driven
|
||||
accent (green / red / blue / amber).
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
.pal-card {
|
||||
margin-top: 16px;
|
||||
width: 100%;
|
||||
min-height: 88px;
|
||||
padding: 14px 18px 30px;
|
||||
border-radius: 18px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: linear-gradient(135deg, #111827, #0f172a);
|
||||
border: 1px solid rgb(255 255 255 / 8%);
|
||||
box-shadow:
|
||||
0 12px 32px rgb(0 0 0 / 35%),
|
||||
inset 0 1px 1px rgb(255 255 255 / 5%);
|
||||
font-family: Inter, 'Segoe UI', Roboto, sans-serif;
|
||||
|
||||
/* Decorative glows — colors swap with the state variant */
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
background: #2563eb;
|
||||
border-radius: 50%;
|
||||
top: -110px;
|
||||
right: -70px;
|
||||
opacity: 0.18;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
background: #22c55e;
|
||||
border-radius: 50%;
|
||||
bottom: -70px;
|
||||
left: -50px;
|
||||
opacity: 0.12;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(145deg, #2563eb, #06b6d4);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-size: 22px;
|
||||
position: relative;
|
||||
box-shadow: 0 10px 20px rgb(37 99 235 / 35%);
|
||||
flex-shrink: 0;
|
||||
|
||||
mat-icon {
|
||||
font-size: 26px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgb(255 255 255 / 20%);
|
||||
}
|
||||
}
|
||||
|
||||
&__txt {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
&__small {
|
||||
font-size: 10.5px;
|
||||
color: #94a3b8;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
&__title {
|
||||
color: #fff;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
&__status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
padding: 4px 11px;
|
||||
border-radius: 50px;
|
||||
background: rgb(34 197 94 / 12%);
|
||||
border: 1px solid rgb(74 222 128 / 25%);
|
||||
color: #4ade80;
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
backdrop-filter: blur(5px);
|
||||
width: fit-content;
|
||||
|
||||
mat-icon {
|
||||
font-size: 13px;
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
&__badge {
|
||||
z-index: 2;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #22c55e, #16a34a);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
box-shadow: 0 0 18px rgb(34 197 94 / 45%);
|
||||
flex-shrink: 0;
|
||||
|
||||
mat-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
&__hint {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
right: 16px;
|
||||
font-size: 10px;
|
||||
color: rgb(255 255 255 / 35%);
|
||||
font-style: italic;
|
||||
letter-spacing: 0.02em;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* ─── State variants ─── */
|
||||
&--ok {
|
||||
&::before {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
&::after {
|
||||
background: #22c55e;
|
||||
}
|
||||
}
|
||||
|
||||
&--fail {
|
||||
border-color: rgb(239 68 68 / 22%);
|
||||
|
||||
&::before {
|
||||
background: #ef4444;
|
||||
opacity: 0.22;
|
||||
}
|
||||
|
||||
&::after {
|
||||
background: #f87171;
|
||||
opacity: 0.16;
|
||||
}
|
||||
|
||||
.pal-card__status {
|
||||
background: rgb(239 68 68 / 14%);
|
||||
border-color: rgb(248 113 113 / 32%);
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.pal-card__badge {
|
||||
background: linear-gradient(135deg, #ef4444, #b91c1c);
|
||||
box-shadow: 0 0 18px rgb(239 68 68 / 45%);
|
||||
}
|
||||
|
||||
.pal-card__icon {
|
||||
background: linear-gradient(145deg, #ef4444, #f97316);
|
||||
box-shadow: 0 10px 20px rgb(239 68 68 / 35%);
|
||||
}
|
||||
}
|
||||
|
||||
&--info {
|
||||
border-color: rgb(59 130 246 / 22%);
|
||||
|
||||
&::before {
|
||||
background: #3b82f6;
|
||||
opacity: 0.22;
|
||||
}
|
||||
|
||||
&::after {
|
||||
background: #60a5fa;
|
||||
opacity: 0.14;
|
||||
}
|
||||
|
||||
.pal-card__status {
|
||||
background: rgb(59 130 246 / 14%);
|
||||
border-color: rgb(96 165 250 / 32%);
|
||||
color: #93c5fd;
|
||||
}
|
||||
|
||||
.pal-card__badge {
|
||||
background: linear-gradient(135deg, #3b82f6, #1d4ed8);
|
||||
box-shadow: 0 0 18px rgb(59 130 246 / 45%);
|
||||
}
|
||||
}
|
||||
|
||||
&--pending {
|
||||
border-color: rgb(245 158 11 / 22%);
|
||||
|
||||
&::before {
|
||||
background: #f59e0b;
|
||||
opacity: 0.22;
|
||||
}
|
||||
|
||||
&::after {
|
||||
background: #fbbf24;
|
||||
opacity: 0.14;
|
||||
}
|
||||
|
||||
.pal-card__status {
|
||||
background: rgb(245 158 11 / 14%);
|
||||
border-color: rgb(251 191 36 / 32%);
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.pal-card__badge {
|
||||
background: linear-gradient(135deg, #f59e0b, #b45309);
|
||||
box-shadow: 0 0 18px rgb(245 158 11 / 45%);
|
||||
}
|
||||
|
||||
.pal-card__icon {
|
||||
background: linear-gradient(145deg, #f59e0b, #d97706);
|
||||
box-shadow: 0 10px 20px rgb(245 158 11 / 35%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 540px) {
|
||||
.pal-card {
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
padding-bottom: 36px;
|
||||
|
||||
&__hint {
|
||||
position: static;
|
||||
width: 100%;
|
||||
text-align: right;
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -60,6 +60,7 @@ import { ClientCollateralResolver } from './common-resolvers/client-collateral.r
|
||||
import { KycTabComponent } from './clients-view/kyc-tab/kyc-tab.component';
|
||||
import { CkycTabComponent } from './clients-view/ckyc-tab/ckyc-tab.component';
|
||||
import { Form60TabComponent } from './clients-view/form60-tab/form60-tab.component';
|
||||
import { Form121TabComponent } from './clients-view/form121-tab/form121-tab.component';
|
||||
import { EntityClassificationTabComponent } from './clients-view/entity-classification-tab/entity-classification-tab.component';
|
||||
import { familyAllowedGuard, nomineeAllowedGuard } from './utils/family-nominee.guard';
|
||||
|
||||
@@ -193,6 +194,11 @@ const routes: Routes = [
|
||||
component: Form60TabComponent,
|
||||
data: { title: 'Form 97', breadcrumb: 'Form 97', routeParamBreadcrumb: false }
|
||||
},
|
||||
{
|
||||
path: 'form121',
|
||||
component: Form121TabComponent,
|
||||
data: { title: 'Form 121 / TDS', breadcrumb: 'Form 121 / TDS', routeParamBreadcrumb: false }
|
||||
},
|
||||
{
|
||||
path: 'documents',
|
||||
component: DocumentsTabComponent,
|
||||
|
||||
@@ -545,6 +545,15 @@
|
||||
>
|
||||
<i class="fa fa-file-text cv-tab-icon"></i> {{ 'labels.inputs.Form 60' | translate }}
|
||||
</a>
|
||||
<a
|
||||
mat-tab-link
|
||||
[routerLink]="['./form121']"
|
||||
routerLinkActive
|
||||
#form121="routerLinkActive"
|
||||
[active]="form121.isActive"
|
||||
>
|
||||
<i class="fa fa-percent cv-tab-icon"></i> Form 121 / TDS
|
||||
</a>
|
||||
<a
|
||||
mat-tab-link
|
||||
[routerLink]="['./documents']"
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
<!--
|
||||
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="f121">
|
||||
@if (loading) {
|
||||
<div class="f121__loading">
|
||||
<mat-spinner [diameter]="36"></mat-spinner>
|
||||
<span>Reading the customer's deposit ledger…</span>
|
||||
</div>
|
||||
} @else if (loadError) {
|
||||
<div class="f121__note f121__note--warn">
|
||||
<mat-icon>error_outline</mat-icon>
|
||||
<div>Could not load the customer's accounts. Try again, or check the deposit service.</div>
|
||||
</div>
|
||||
} @else {
|
||||
<!-- ── Header ── -->
|
||||
<div class="f121__head">
|
||||
<div>
|
||||
<h3 class="f121__title">Form 121 & TDS on deposit interest</h3>
|
||||
<div class="f121__sub">
|
||||
Tax deducted at source under {{ statute.section }} on fixed and recurring deposit interest.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="f121__controls">
|
||||
@if (availableFys.length) {
|
||||
<label class="f121__field">
|
||||
<span>Financial year</span>
|
||||
<select [(ngModel)]="fy" (ngModelChange)="recompute()">
|
||||
@for (year of availableFys; track year) {
|
||||
<option [value]="year">FY {{ year }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
}
|
||||
<div class="f121__field">
|
||||
<span>Settlement</span>
|
||||
<div
|
||||
class="f121__mode"
|
||||
[matTooltip]="'Set by the deposit product\'s interest-posting frequency: tax falls due when interest is credited, so the branch cannot defer it. Not a customer preference.'"
|
||||
>
|
||||
<mat-icon>schedule</mat-icon>
|
||||
{{ mode === 'QUARTERLY' ? 'Quarterly' : 'At year end' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!schedule) {
|
||||
<div class="f121__note">
|
||||
<mat-icon>info_outline</mat-icon>
|
||||
<div>
|
||||
@if (!dateOfBirth) {
|
||||
Date of birth is not on record, so the senior-citizen threshold cannot be determined. Add it on the Personal
|
||||
Data tab.
|
||||
} @else {
|
||||
No interest has been credited to this customer's deposits yet.
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<!-- ── KPI strip ── -->
|
||||
<div class="f121__kpis">
|
||||
<div class="f121__kpi">
|
||||
<div class="f121__kpi-n">₹{{ schedule.totalInterest | number: '1.0-0' }}</div>
|
||||
<div class="f121__kpi-l">Interest earned (FY {{ schedule.fy }})</div>
|
||||
</div>
|
||||
<div class="f121__kpi" [class.f121__kpi--warn]="actualTds > 0">
|
||||
<div class="f121__kpi-n">₹{{ actualTds | number: '1.0-0' }}</div>
|
||||
<div class="f121__kpi-l">TDS deducted</div>
|
||||
</div>
|
||||
<div class="f121__kpi">
|
||||
<div class="f121__kpi-n">₹{{ schedule.threshold | number: '1.0-0' }}</div>
|
||||
<div class="f121__kpi-l">Threshold ({{ schedule.senior ? 'senior citizen' : 'general' }})</div>
|
||||
</div>
|
||||
@if (thresholdCrossed) {
|
||||
<div class="f121__kpi f121__kpi--warn">
|
||||
<div class="f121__kpi-n">Crossed</div>
|
||||
<div class="f121__kpi-l">TDS applies this year</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="f121__kpi f121__kpi--ok">
|
||||
<div class="f121__kpi-n">₹{{ headroom | number: '1.0-0' }}</div>
|
||||
<div class="f121__kpi-l">More interest before TDS starts</div>
|
||||
</div>
|
||||
}
|
||||
@if (tdsSaved > 0) {
|
||||
<div class="f121__kpi f121__kpi--ok">
|
||||
<div class="f121__kpi-n">₹{{ tdsSaved | number: '1.0-0' }}</div>
|
||||
<div class="f121__kpi-l">Saved by Form 121</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- ── Alerts ── -->
|
||||
@if (variance !== 0) {
|
||||
<div class="f121__note f121__note--warn">
|
||||
<mat-icon>warning_amber</mat-icon>
|
||||
<div>
|
||||
<strong>Ledger and computed tax differ by ₹{{ variance | number: '1.0-0' }}.</strong>
|
||||
The ledger shows ₹{{ actualTds | number: '1.0-0' }} withheld; {{ statute.section }} on this year's interest
|
||||
comes to ₹{{ schedule.totalTds | number: '1.0-0' }}. Investigate before the quarterly return is filed.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (declarationNotAppliedToAccounts) {
|
||||
<div class="f121__note f121__note--warn">
|
||||
<mat-icon>warning_amber</mat-icon>
|
||||
<div>
|
||||
A Form 121 declaration is on record for this year, but withholding is still switched on for one or more
|
||||
deposits. Turn it off on those accounts, or tax will keep being deducted.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (declarationUselessOnJointAccounts) {
|
||||
<div class="f121__note f121__note--warn">
|
||||
<mat-icon>warning_amber</mat-icon>
|
||||
<div>
|
||||
This customer has filed Form 121, but tax on
|
||||
{{ jointHolderAccounts.length }} joint deposit{{ jointHolderAccounts.length === 1 ? '' : 's' }} is deducted
|
||||
against the first-named holder. Their declaration does not cover those — the primary holder must file their
|
||||
own.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (shouldSuggestDeclaration) {
|
||||
<div class="f121__note f121__note--tip">
|
||||
<mat-icon>lightbulb_outline</mat-icon>
|
||||
<div>
|
||||
₹{{ schedule.totalTds | number: '1.0-0' }} of tax falls due this year. If the customer's total tax liability
|
||||
for the year is nil, a {{ formLabel }} declaration would stop it being deducted at all — better than
|
||||
reclaiming it later as a refund.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!panWellFormed) {
|
||||
<div class="f121__note f121__note--warn">
|
||||
<mat-icon>error_outline</mat-icon>
|
||||
<div>
|
||||
No valid PAN on record, so tax is deducted at the higher section 206AA rate of
|
||||
{{ schedule.rateApplied * 100 | number: '1.0-0' }}%. A Form 121 declaration is void without a valid PAN.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@for (note of schedule.notes; track note) {
|
||||
<div class="f121__note">
|
||||
<mat-icon>info_outline</mat-icon>
|
||||
<div>{{ note }}</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ── Declaration panel ── -->
|
||||
<div class="f121__card">
|
||||
<div class="f121__card-head">
|
||||
<mat-icon>assignment_turned_in</mat-icon>
|
||||
<span>Declaration</span>
|
||||
</div>
|
||||
<div class="f121__kv">
|
||||
<div class="f121__kv-row">
|
||||
<span>Status</span>
|
||||
<span>
|
||||
@if (declarationOnRecord) {
|
||||
<span class="f121__pill f121__pill--ok">On record — {{ formLabel }}</span>
|
||||
} @else {
|
||||
<span class="f121__pill">Not filed</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div class="f121__kv-row">
|
||||
<span>Form & threshold band</span>
|
||||
<span>{{ formLabel }} · {{ bandLabel }} band · {{ age }} yrs in this FY</span>
|
||||
</div>
|
||||
<div class="f121__kv-row">
|
||||
<span>PAN</span>
|
||||
<span class="f121__mono">
|
||||
{{ pan || '—' }}
|
||||
@if (pan && !panWellFormed) {
|
||||
<span class="f121__pill f121__pill--warn">invalid</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
@if (declarationOnRecord && declarationFy) {
|
||||
<div class="f121__kv-row">
|
||||
<span>Declared for</span>
|
||||
<span>FY {{ declarationFy }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (declarationOnRecord && !acceptanceDateKnown) {
|
||||
<div class="f121__kv-row f121__kv-row--muted">
|
||||
<span>Acceptance date</span>
|
||||
<span>
|
||||
Not captured — the declaration is treated as covering the whole year. A mid-year acceptance would change
|
||||
the figures below.
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
@if (eligibility) {
|
||||
@for (reason of eligibility.reasons; track reason) {
|
||||
<div class="f121__kv-row f121__kv-row--warn">
|
||||
<span>Eligibility</span>
|
||||
<span>{{ reason }}</span>
|
||||
</div>
|
||||
}
|
||||
@for (note of eligibility.unassessed; track note) {
|
||||
<div class="f121__kv-row f121__kv-row--muted">
|
||||
<span>Not assessed</span>
|
||||
<span>{{ note }}</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Quarter-wise ── -->
|
||||
<div class="f121__card">
|
||||
<div class="f121__card-head">
|
||||
<mat-icon>table_chart</mat-icon>
|
||||
<span>Quarter by quarter</span>
|
||||
</div>
|
||||
<div class="f121__table-wrap">
|
||||
<table class="f121__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Quarter</th>
|
||||
<th class="num">Interest</th>
|
||||
<th class="num">Cumulative</th>
|
||||
<th class="num">Liability to date</th>
|
||||
<th class="num">TDS this quarter</th>
|
||||
<th>Return due</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (line of schedule.lines; track trackLine($index, line)) {
|
||||
<tr [class.f121__row--warn]="line.thresholdCrossedHere">
|
||||
<td>
|
||||
{{ line.label }}
|
||||
@if (line.thresholdCrossedHere) {
|
||||
<span class="f121__pill f121__pill--warn">threshold crossed</span>
|
||||
}
|
||||
@if (line.declarationActive) {
|
||||
<span class="f121__pill f121__pill--ok">Form 121 active</span>
|
||||
}
|
||||
</td>
|
||||
<td class="num">₹{{ line.interest | number: '1.0-0' }}</td>
|
||||
<td class="num">₹{{ line.cumInterest | number: '1.0-0' }}</td>
|
||||
<td class="num">₹{{ line.cumLiability | number: '1.0-0' }}</td>
|
||||
<td class="num">
|
||||
@if (line.tds !== 0) {
|
||||
<strong>₹{{ line.tds | number: '1.0-0' }}</strong>
|
||||
} @else {
|
||||
<span class="muted">—</span>
|
||||
}
|
||||
</td>
|
||||
<td class="muted">{{ line.dueDate }}</td>
|
||||
</tr>
|
||||
}
|
||||
<tr class="f121__row--total">
|
||||
<td><strong>Total</strong></td>
|
||||
<td class="num">
|
||||
<strong>₹{{ schedule.totalInterest | number: '1.0-0' }}</strong>
|
||||
</td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td class="num">
|
||||
<strong>₹{{ schedule.totalTds | number: '1.0-0' }}</strong>
|
||||
</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@if (schedule.shieldedInterest > 0) {
|
||||
<div class="f121__foot">
|
||||
₹{{ schedule.shieldedInterest | number: '1.0-0' }} was paid without deduction on the strength of the
|
||||
declaration. This amount is still reportable in the quarterly {{ statute.statement }} return.
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- ── Section 1: accounts that count ── -->
|
||||
<div class="f121__card">
|
||||
<div class="f121__card-head">
|
||||
<mat-icon>account_balance</mat-icon>
|
||||
<span>Deposits counted towards this customer's TDS</span>
|
||||
</div>
|
||||
@if (liableAccounts.length) {
|
||||
<div class="f121__table-wrap">
|
||||
<table class="f121__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Account</th>
|
||||
<th>Type</th>
|
||||
<th class="num">Interest (FY)</th>
|
||||
<th class="num">TDS deducted</th>
|
||||
<th>Withholding</th>
|
||||
<th>Settles</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (a of liableAccounts; track trackAccount($index, a)) {
|
||||
<tr>
|
||||
<td>
|
||||
<div class="f121__mono">{{ a.accountNo }}</div>
|
||||
<div class="muted">{{ a.productName }}</div>
|
||||
</td>
|
||||
<td>{{ a.kind }}</td>
|
||||
<td class="num">₹{{ a.interest | number: '1.0-0' }}</td>
|
||||
<td class="num">₹{{ a.tdsDeducted | number: '1.0-0' }}</td>
|
||||
<td>
|
||||
@if (a.withHoldTax) {
|
||||
<span class="f121__pill f121__pill--warn">On</span>
|
||||
@if (a.taxGroup) {
|
||||
<div class="muted">{{ a.taxGroup }}</div>
|
||||
}
|
||||
} @else {
|
||||
<span class="f121__pill">Off</span>
|
||||
}
|
||||
</td>
|
||||
<td>{{ a.mode === 'QUARTERLY' ? 'Quarterly' : 'Year end' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="f121__foot">
|
||||
Aggregated across every branch — a bank on core banking computes the deduction threshold for the bank as a
|
||||
whole, not branch by branch.
|
||||
</div>
|
||||
} @else {
|
||||
<div class="f121__empty">No fixed or recurring deposits where this customer is the deductee.</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- ── Section 2: joint holdings ── -->
|
||||
@if (jointHolderAccounts.length) {
|
||||
<div class="f121__card">
|
||||
<div class="f121__card-head">
|
||||
<mat-icon>group</mat-icon>
|
||||
<span>Deposits where this customer is a joint holder</span>
|
||||
</div>
|
||||
<div class="f121__table-wrap">
|
||||
<table class="f121__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Account</th>
|
||||
<th>Type</th>
|
||||
<th class="num">Interest (FY)</th>
|
||||
<th>Deducted against</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (a of jointHolderAccounts; track trackAccount($index, a)) {
|
||||
<tr>
|
||||
<td>
|
||||
<div class="f121__mono">{{ a.accountNo }}</div>
|
||||
<div class="muted">{{ a.productName }}</div>
|
||||
</td>
|
||||
<td>{{ a.kind }}</td>
|
||||
<td class="num">₹{{ a.interest | number: '1.0-0' }}</td>
|
||||
<td class="muted">
|
||||
First-named holder
|
||||
@if (a.deducteeClientId) {
|
||||
(client {{ a.deducteeClientId }})
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="f121__foot">
|
||||
Tax on a joint deposit is deducted against the first-named holder's PAN, so this interest is
|
||||
<strong>not</strong> counted in the figures above and does not use up this customer's threshold.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ── Section 3: exempt accounts ── -->
|
||||
@if (exemptAccounts.length) {
|
||||
<div class="f121__card">
|
||||
<div class="f121__card-head">
|
||||
<mat-icon>savings</mat-icon>
|
||||
<span>Savings and current accounts</span>
|
||||
</div>
|
||||
<div class="f121__table-wrap">
|
||||
<table class="f121__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Account</th>
|
||||
<th>Type</th>
|
||||
<th class="num">Interest (FY)</th>
|
||||
<th>TDS</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (a of exemptAccounts; track trackAccount($index, a)) {
|
||||
<tr>
|
||||
<td>
|
||||
<div class="f121__mono">{{ a.accountNo }}</div>
|
||||
<div class="muted">{{ a.productName }}</div>
|
||||
</td>
|
||||
<td>{{ a.kind }}</td>
|
||||
<td class="num">₹{{ a.interest | number: '1.0-0' }}</td>
|
||||
<td><span class="f121__pill">Not applicable</span></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="f121__foot">
|
||||
The charge applies to time deposits. Savings bank interest is outside it and is excluded from the threshold
|
||||
— the customer claims the 80TTA or 80TTB deduction on it in their own return instead.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,334 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
.f121 {
|
||||
--f121-line: #e5e7eb;
|
||||
--f121-bg: #f9fafb;
|
||||
--f121-ink: #111827;
|
||||
--f121-muted: #6b7280;
|
||||
--f121-blue: #1d4ed8;
|
||||
--f121-green: #15803d;
|
||||
--f121-amber: #b45309;
|
||||
|
||||
padding: 16px;
|
||||
font-size: 13px;
|
||||
color: var(--f121-ink);
|
||||
}
|
||||
|
||||
.f121__loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 40px 16px;
|
||||
color: var(--f121-muted);
|
||||
}
|
||||
|
||||
/* ── Header ── */
|
||||
.f121__head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.f121__title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.f121__sub {
|
||||
margin-top: 4px;
|
||||
color: var(--f121-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.f121__controls {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.f121__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
> span {
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--f121-muted);
|
||||
}
|
||||
|
||||
select {
|
||||
padding: 7px 10px;
|
||||
border: 1px solid var(--f121-line);
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
font-size: 13px;
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.f121__mode {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid var(--f121-line);
|
||||
border-radius: 6px;
|
||||
background: var(--f121-bg);
|
||||
font-weight: 600;
|
||||
cursor: help;
|
||||
|
||||
mat-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--f121-muted);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── KPI strip ── */
|
||||
.f121__kpis {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.f121__kpi {
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--f121-line);
|
||||
border-left: 3px solid var(--f121-blue);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
|
||||
&--ok {
|
||||
border-left-color: var(--f121-green);
|
||||
}
|
||||
|
||||
&--warn {
|
||||
border-left-color: var(--f121-amber);
|
||||
}
|
||||
}
|
||||
|
||||
.f121__kpi-n {
|
||||
font-size: 19px;
|
||||
font-weight: 800;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.f121__kpi-l {
|
||||
margin-top: 2px;
|
||||
font-size: 11px;
|
||||
color: var(--f121-muted);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
/* ── Notes ── */
|
||||
.f121__note {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 12px;
|
||||
padding: 11px 13px;
|
||||
border-radius: 8px;
|
||||
border-left: 3px solid var(--f121-blue);
|
||||
background: color-mix(in srgb, var(--f121-blue) 6%, transparent);
|
||||
line-height: 1.5;
|
||||
|
||||
mat-icon {
|
||||
flex-shrink: 0;
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--f121-blue);
|
||||
}
|
||||
|
||||
&--warn {
|
||||
border-left-color: var(--f121-amber);
|
||||
background: color-mix(in srgb, var(--f121-amber) 8%, transparent);
|
||||
|
||||
mat-icon {
|
||||
color: var(--f121-amber);
|
||||
}
|
||||
}
|
||||
|
||||
&--tip {
|
||||
border-left-color: var(--f121-green);
|
||||
background: color-mix(in srgb, var(--f121-green) 7%, transparent);
|
||||
|
||||
mat-icon {
|
||||
color: var(--f121-green);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Cards ── */
|
||||
.f121__card {
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid var(--f121-line);
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.f121__card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 11px 14px;
|
||||
background: var(--f121-bg);
|
||||
border-bottom: 1px solid var(--f121-line);
|
||||
font-weight: 700;
|
||||
font-size: 12.5px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--f121-muted);
|
||||
}
|
||||
}
|
||||
|
||||
.f121__foot {
|
||||
padding: 10px 14px;
|
||||
border-top: 1px solid var(--f121-line);
|
||||
background: var(--f121-bg);
|
||||
color: var(--f121-muted);
|
||||
font-size: 11.5px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.f121__empty {
|
||||
padding: 20px 14px;
|
||||
color: var(--f121-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ── Key/value list ── */
|
||||
.f121__kv-row {
|
||||
display: grid;
|
||||
grid-template-columns: 190px 1fr;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
line-height: 1.5;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
> span:first-child {
|
||||
color: var(--f121-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&--muted > span:last-child {
|
||||
color: var(--f121-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
&--warn > span:last-child {
|
||||
color: var(--f121-amber);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Tables ── */
|
||||
.f121__table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.f121__table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12.5px;
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
padding: 10px 14px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid var(--f121-line);
|
||||
color: var(--f121-muted);
|
||||
font-weight: 700;
|
||||
font-size: 10.5px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.num {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--f121-muted);
|
||||
font-size: 11.5px;
|
||||
}
|
||||
}
|
||||
|
||||
.f121__row--warn > td {
|
||||
background: color-mix(in srgb, var(--f121-amber) 7%, transparent);
|
||||
}
|
||||
|
||||
.f121__row--total > td {
|
||||
background: var(--f121-bg);
|
||||
border-top: 1px solid var(--f121-line);
|
||||
}
|
||||
|
||||
/* ── Bits ── */
|
||||
.f121__pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 9px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--f121-line);
|
||||
background: var(--f121-bg);
|
||||
color: var(--f121-muted);
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
|
||||
& + & {
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
&--ok {
|
||||
border-color: color-mix(in srgb, var(--f121-green) 35%, transparent);
|
||||
background: color-mix(in srgb, var(--f121-green) 10%, transparent);
|
||||
color: var(--f121-green);
|
||||
}
|
||||
|
||||
&--warn {
|
||||
border-color: color-mix(in srgb, var(--f121-amber) 35%, transparent);
|
||||
background: color-mix(in srgb, var(--f121-amber) 12%, transparent);
|
||||
color: var(--f121-amber);
|
||||
}
|
||||
}
|
||||
|
||||
.f121__mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -0,0 +1,500 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
/** Angular Imports */
|
||||
import { Component, OnDestroy, OnInit, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { Observable, Subject, forkJoin, of } from 'rxjs';
|
||||
import { catchError, switchMap, takeUntil } from 'rxjs/operators';
|
||||
|
||||
/** Custom Services */
|
||||
import { ClientsService } from '../../clients.service';
|
||||
import { SavingsService } from 'app/savings/savings.service';
|
||||
import { FixedDepositsService } from 'app/deposits/fixed-deposits/fixed-deposits.service';
|
||||
import { RecurringDepositsService } from 'app/deposits/recurring-deposits/recurring-deposits.service';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
/** Domain logic */
|
||||
import {
|
||||
Declaration,
|
||||
DeclarationForm,
|
||||
EligibilityResult,
|
||||
FY_TAX_CONFIG,
|
||||
InterestCredit,
|
||||
TdsAccountInput,
|
||||
TdsMode,
|
||||
TdsQuarterLine,
|
||||
TdsSchedule,
|
||||
ageInFy,
|
||||
buildSchedule,
|
||||
checkEligibility,
|
||||
collectTdsCredits,
|
||||
declarationFormFor,
|
||||
formKindFor,
|
||||
fyOf,
|
||||
isTdsApplicable,
|
||||
isWellFormedPan,
|
||||
normaliseFy,
|
||||
tdsDeducteeOf,
|
||||
tdsModeFor
|
||||
} from 'app/letter-mgmt/certificates/form-15gh/tds-15gh.compute';
|
||||
|
||||
/** One of the customer's interest-bearing accounts, with its FY figures. */
|
||||
export interface Form121AccountRow {
|
||||
accountNo: string;
|
||||
productName: string;
|
||||
/** 'Fixed Deposit', 'Recurring Deposit' or 'Savings'. */
|
||||
kind: string;
|
||||
/** Interest credited to this account inside the selected FY. */
|
||||
interest: number;
|
||||
/** Tax Fineract has actually withheld on this account inside the FY. */
|
||||
tdsDeducted: number;
|
||||
/** Whether Fineract is currently withholding tax on this account. */
|
||||
withHoldTax: boolean;
|
||||
taxGroup: string | null;
|
||||
/** When tax on this account settles — from the product, not a preference. */
|
||||
mode: TdsMode;
|
||||
/** Interest here attracts TDS at all (a time deposit, not a savings account). */
|
||||
tdsApplicable: boolean;
|
||||
/** This customer is the deductee — false when they are a joint holder only. */
|
||||
isDeductee: boolean;
|
||||
/** Who the tax is deducted against, when it is not this customer. */
|
||||
deducteeClientId: number | null;
|
||||
}
|
||||
|
||||
/** Fineract hands dates back as [yyyy, m, d]; normalise to ISO. */
|
||||
function toIso(value: unknown): string | null {
|
||||
if (Array.isArray(value) && value.length >= 3) {
|
||||
const [
|
||||
y,
|
||||
m,
|
||||
d
|
||||
] = value as number[];
|
||||
return `${y}-${`${m}`.padStart(2, '0')}-${`${d}`.padStart(2, '0')}`;
|
||||
}
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
const parsed = new Date(value);
|
||||
if (!isNaN(parsed.getTime())) {
|
||||
return parsed.toISOString().slice(0, 10);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-form121-tab',
|
||||
templateUrl: './form121-tab.component.html',
|
||||
styleUrls: ['./form121-tab.component.scss'],
|
||||
standalone: true,
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatIcon,
|
||||
MatProgressSpinner,
|
||||
MatTooltip
|
||||
]
|
||||
})
|
||||
export class Form121TabComponent implements OnInit, OnDestroy {
|
||||
private route = inject(ActivatedRoute);
|
||||
private clientsService = inject(ClientsService);
|
||||
private savingsService = inject(SavingsService);
|
||||
private fixedDepositsService = inject(FixedDepositsService);
|
||||
private recurringDepositsService = inject(RecurringDepositsService);
|
||||
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
clientId = '';
|
||||
loading = true;
|
||||
loadError = false;
|
||||
|
||||
// ── Customer ──
|
||||
clientName = '';
|
||||
dateOfBirth = '';
|
||||
pan = '';
|
||||
panWellFormed = false;
|
||||
|
||||
// ── Declaration on record ──
|
||||
declarationOnRecord = false;
|
||||
/**
|
||||
* The form actually in force for the selected year. From FY 2026-27 a single
|
||||
* Form 121 replaced the 15G/15H split, so this is derived from the year rather
|
||||
* than from the payee's age.
|
||||
*/
|
||||
declarationForm: DeclarationForm = '121';
|
||||
/** Kind recorded at KYC, meaningful only for a pre-2026 year. */
|
||||
declarationKind: '15G' | '15H' = '15G';
|
||||
declarationFy = '';
|
||||
/**
|
||||
* The KYC step records only the year, not the date the branch accepted the
|
||||
* declaration, so it has to be treated as covering the whole year. Surfaced on
|
||||
* screen rather than hidden — a mid-year acceptance would change the figures.
|
||||
*/
|
||||
acceptanceDateKnown = false;
|
||||
|
||||
eligibility: EligibilityResult | null = null;
|
||||
|
||||
// ── Selection ──
|
||||
availableFys: string[] = [];
|
||||
fy = '';
|
||||
|
||||
// ── Computed ──
|
||||
accounts: Form121AccountRow[] = [];
|
||||
schedule: TdsSchedule | null = null;
|
||||
actualTds = 0;
|
||||
tdsSaved = 0;
|
||||
|
||||
/** Raw account inputs, kept so the year selector can recompute cheaply. */
|
||||
private accountInputs: (TdsAccountInput & { meta: Form121AccountRow })[] = [];
|
||||
/** Withheld-tax postings by FY, from the ledger. */
|
||||
private actualTdsByFy = new Map<string, number>();
|
||||
|
||||
ngOnInit(): void {
|
||||
this.route.parent.params.pipe(takeUntil(this.destroy$)).subscribe((params: any) => {
|
||||
this.clientId = params['clientId'];
|
||||
this.load();
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
// ────────────────────────────── Loading ──────────────────────────────
|
||||
|
||||
private load(): void {
|
||||
this.loading = true;
|
||||
this.loadError = false;
|
||||
|
||||
forkJoin({
|
||||
client: this.clientsService.getClientData(this.clientId, true).pipe(catchError(() => of(null))),
|
||||
kyc: this.clientsService.getClientKyc(this.clientId, true).pipe(catchError(() => of(null))),
|
||||
accounts: this.clientsService.getClientAccountData(this.clientId).pipe(catchError(() => of(null)))
|
||||
})
|
||||
.pipe(
|
||||
takeUntil(this.destroy$),
|
||||
switchMap(({ client, kyc, accounts }) => {
|
||||
this.applyClient(client);
|
||||
this.applyKyc(kyc);
|
||||
const deposits = this.depositAccountsOf(accounts);
|
||||
return deposits.length ? forkJoin(deposits.map((a) => this.fetchAccount(a))) : of([] as any[]);
|
||||
}),
|
||||
catchError(() => {
|
||||
this.loadError = true;
|
||||
return of([] as any[]);
|
||||
})
|
||||
)
|
||||
.subscribe((details: any[]) => {
|
||||
this.loading = false;
|
||||
this.ingest(details.filter(Boolean));
|
||||
this.recompute();
|
||||
});
|
||||
}
|
||||
|
||||
private applyClient(client: any): void {
|
||||
this.clientName = client?.displayName ?? '';
|
||||
this.dateOfBirth = toIso(client?.dateOfBirth) ?? '';
|
||||
}
|
||||
|
||||
private applyKyc(kyc: any): void {
|
||||
this.pan = (kyc?.panNumber ?? '').toUpperCase();
|
||||
this.panWellFormed = isWellFormedPan(this.pan);
|
||||
this.declarationOnRecord = !!kyc?.form15Applicable;
|
||||
this.declarationKind = kyc?.form15Type === '15H' ? '15H' : '15G';
|
||||
this.declarationForm = this.fy ? declarationFormFor(this.fy, this.dateOfBirth) : '121';
|
||||
this.declarationFy = kyc?.form15FY ?? '';
|
||||
this.acceptanceDateKnown = false;
|
||||
}
|
||||
|
||||
/** Savings, FD and RD accounts — everything that can credit interest. */
|
||||
private depositAccountsOf(accounts: any): any[] {
|
||||
return (accounts?.savingsAccounts ?? []).filter(
|
||||
(a: any) => a.status?.active || a.status?.matured || a.status?.closed
|
||||
);
|
||||
}
|
||||
|
||||
private fetchAccount(a: any): Observable<any> {
|
||||
const id = `${a.id}`;
|
||||
const code = a.depositType?.code ?? '';
|
||||
let call: Observable<any>;
|
||||
if (code.includes('fixedDeposit')) {
|
||||
call = this.fixedDepositsService.getFixedDepositsAccountData(id);
|
||||
} else if (code.includes('recurringDeposit')) {
|
||||
call = this.recurringDepositsService.getRecurringDepositsAccountData(id);
|
||||
} else {
|
||||
call = this.savingsService.getSavingsAccountData(id);
|
||||
}
|
||||
// One unreadable account must not blank the whole tab.
|
||||
return call.pipe(catchError(() => of(null)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns each account payload into an engine input.
|
||||
*
|
||||
* Joint and minor attributes are read defensively: they are captured on the
|
||||
* account stepper but may not surface on every read endpoint, and an absent
|
||||
* field must default to a single-name account rather than throw.
|
||||
*/
|
||||
private ingest(details: any[]): void {
|
||||
this.accountInputs = [];
|
||||
this.actualTdsByFy = new Map<string, number>();
|
||||
const fys = new Set<string>();
|
||||
const clientIdNum = Number(this.clientId);
|
||||
|
||||
for (const d of details) {
|
||||
const code: string = d.depositType?.code ?? '';
|
||||
const extra = d.extraAttributes ?? d.externalAttributes ?? d;
|
||||
|
||||
const ref = {
|
||||
id: d.id,
|
||||
accountNo: d.accountNo ?? `${d.id}`,
|
||||
depositTypeCode: code,
|
||||
accountStatus: extra.accountStatus ?? d.accountStatus ?? 'SINGLE',
|
||||
clientId: d.clientId ?? clientIdNum,
|
||||
primaryClientId: extra.primaryClientId ?? d.primaryClientId ?? null,
|
||||
guardianClientId: extra.guardianClientId ?? d.guardianClientId ?? null,
|
||||
jointHolders: extra.jointHolders ?? d.jointHolders ?? [],
|
||||
interestPostingPeriodType: d.interestPostingPeriodType ?? null
|
||||
};
|
||||
|
||||
const credits: InterestCredit[] = [];
|
||||
let tdsDeducted = 0;
|
||||
|
||||
for (const t of d.transactions ?? []) {
|
||||
if (t.reversed || t.isReversal) {
|
||||
continue;
|
||||
}
|
||||
const iso = toIso(t.date);
|
||||
if (!iso) {
|
||||
continue;
|
||||
}
|
||||
fys.add(fyOf(iso));
|
||||
|
||||
if (t.transactionType?.interestPosting) {
|
||||
credits.push({ date: iso, amount: t.amount ?? 0, accountNo: ref.accountNo });
|
||||
} else if (t.transactionType?.withholdTax) {
|
||||
const amount = t.amount ?? 0;
|
||||
tdsDeducted += amount;
|
||||
const txnFy = fyOf(iso);
|
||||
this.actualTdsByFy.set(txnFy, (this.actualTdsByFy.get(txnFy) ?? 0) + amount);
|
||||
}
|
||||
}
|
||||
|
||||
const deductee = tdsDeducteeOf(ref);
|
||||
const meta: Form121AccountRow = {
|
||||
accountNo: ref.accountNo,
|
||||
productName: d.savingsProductName ?? d.depositProductName ?? d.productName ?? '—',
|
||||
kind: code.includes('fixedDeposit')
|
||||
? 'Fixed Deposit'
|
||||
: code.includes('recurringDeposit')
|
||||
? 'Recurring Deposit'
|
||||
: 'Savings',
|
||||
interest: 0,
|
||||
tdsDeducted,
|
||||
withHoldTax: !!d.withHoldTax,
|
||||
taxGroup: d.taxGroup?.name ?? null,
|
||||
mode: tdsModeFor(ref),
|
||||
tdsApplicable: isTdsApplicable(ref),
|
||||
isDeductee: deductee === clientIdNum,
|
||||
deducteeClientId: deductee
|
||||
};
|
||||
|
||||
this.accountInputs.push({ ...ref, credits, meta });
|
||||
}
|
||||
|
||||
this.availableFys = [...fys].sort().reverse();
|
||||
if (!this.availableFys.includes(this.fy)) {
|
||||
this.fy = this.availableFys[0] ?? (this.declarationFy ? normaliseFy(this.declarationFy) : '');
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────────── Computation ─────────────────────────────
|
||||
|
||||
/** Re-runs the engine for the selected year. */
|
||||
recompute(): void {
|
||||
this.schedule = null;
|
||||
this.eligibility = null;
|
||||
this.tdsSaved = 0;
|
||||
this.accounts = this.accountInputs.map((a) => a.meta);
|
||||
|
||||
if (!this.fy) {
|
||||
return;
|
||||
}
|
||||
|
||||
const year = normaliseFy(this.fy);
|
||||
|
||||
// Per-account interest for the selected year, for the three lists.
|
||||
for (const a of this.accountInputs) {
|
||||
a.meta.interest = a.credits.filter((c) => fyOf(c.date) === year).reduce((s, c) => s + c.amount, 0);
|
||||
}
|
||||
this.actualTds = this.actualTdsByFy.get(year) ?? 0;
|
||||
|
||||
if (!this.dateOfBirth) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The one place rules 8 to 12 are applied: savings interest dropped, joint
|
||||
// deposits attributed to the primary holder, every branch aggregated.
|
||||
const credits = collectTdsCredits(Number(this.clientId), this.accountInputs, year);
|
||||
const ctx = { fy: year, dateOfBirth: this.dateOfBirth, pan: this.pan, resident: true };
|
||||
const declaration = this.declarationForSelectedFy();
|
||||
|
||||
this.schedule = buildSchedule(this.overallMode, credits, { ...ctx, declaration });
|
||||
|
||||
if (declaration) {
|
||||
const without = buildSchedule(this.overallMode, credits, { ...ctx, declaration: null });
|
||||
this.tdsSaved = Math.max(0, without.totalTds - this.schedule.totalTds);
|
||||
}
|
||||
|
||||
// The branch cannot compute income earned outside the bank, so the income
|
||||
// limb is reported as unassessed rather than silently passed.
|
||||
this.eligibility = checkEligibility(ctx, {
|
||||
estimatedInterest: this.schedule.totalInterest,
|
||||
estimatedIncome: 0,
|
||||
incomeOnRecord: false
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* When tax settles for this customer overall.
|
||||
*
|
||||
* Timing is per account, but the customer needs one view. A quarterly account
|
||||
* anywhere in the book means a deduction falls due at the first quarter end, so
|
||||
* that is the honest headline; the per-account list carries the detail.
|
||||
*/
|
||||
private get overallMode(): TdsMode {
|
||||
const applicable = this.accountInputs.filter((a) => a.meta.tdsApplicable && a.meta.isDeductee);
|
||||
return applicable.some((a) => a.meta.mode === 'QUARTERLY') ? 'QUARTERLY' : 'ANNUAL';
|
||||
}
|
||||
|
||||
/** The declaration, but only when it was filed for the year being shown. */
|
||||
private declarationForSelectedFy(): Declaration | null {
|
||||
if (!this.declarationOnRecord || !this.fy) {
|
||||
return null;
|
||||
}
|
||||
const year = normaliseFy(this.fy);
|
||||
if (this.declarationFy && normaliseFy(this.declarationFy) !== year) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
kind: this.declarationKind,
|
||||
// No acceptance date on record — treated as covering the whole year.
|
||||
acceptedOn: `${year.slice(0, 4)}-04-01`,
|
||||
fy: year
|
||||
};
|
||||
}
|
||||
|
||||
// ───────────────────────── Template helpers ─────────────────────────
|
||||
|
||||
get mode(): TdsMode {
|
||||
return this.schedule?.mode ?? 'QUARTERLY';
|
||||
}
|
||||
|
||||
get age(): number {
|
||||
return this.dateOfBirth && this.fy ? ageInFy(this.dateOfBirth, this.fy) : 0;
|
||||
}
|
||||
|
||||
get formKind(): string {
|
||||
return this.dateOfBirth && this.fy ? formKindFor(this.dateOfBirth, this.fy) : '—';
|
||||
}
|
||||
|
||||
/** The form the payee would file for the selected year. */
|
||||
get formLabel(): string {
|
||||
if (!this.fy) {
|
||||
return '—';
|
||||
}
|
||||
const form = declarationFormFor(this.fy, this.dateOfBirth);
|
||||
return form === '121' ? 'Form 121' : `Form ${form}`;
|
||||
}
|
||||
|
||||
/** Which deduction trigger applies, now that the forms no longer split by age. */
|
||||
get bandLabel(): string {
|
||||
return this.schedule?.senior ? 'Senior citizen' : 'General';
|
||||
}
|
||||
|
||||
/**
|
||||
* Provision and return references for the selected year. Both changed on
|
||||
* 01 April 2026, so the template reads them rather than hard-coding them.
|
||||
*/
|
||||
get statute(): { section: string; statement: string; rule: string; frequency: string } {
|
||||
const cfg = this.fy ? FY_TAX_CONFIG[normaliseFy(this.fy)] : undefined;
|
||||
return {
|
||||
section: cfg?.interestTdsSection ?? '—',
|
||||
statement: cfg?.tdsStatementForm ?? '—',
|
||||
rule: cfg?.declarationRule ?? '—',
|
||||
frequency: cfg?.declarationStatementFrequency === 'MONTHLY' ? 'monthly' : 'quarterly'
|
||||
};
|
||||
}
|
||||
|
||||
/** Accounts whose interest counts towards this customer's deduction aggregate. */
|
||||
get liableAccounts(): Form121AccountRow[] {
|
||||
return this.accounts.filter((a) => a.tdsApplicable && a.isDeductee);
|
||||
}
|
||||
|
||||
/** Deposits where this customer is a joint holder but not the deductee. */
|
||||
get jointHolderAccounts(): Form121AccountRow[] {
|
||||
return this.accounts.filter((a) => a.tdsApplicable && !a.isDeductee);
|
||||
}
|
||||
|
||||
/** Savings and current accounts — interest outside the charge on time deposits. */
|
||||
get exemptAccounts(): Form121AccountRow[] {
|
||||
return this.accounts.filter((a) => !a.tdsApplicable);
|
||||
}
|
||||
|
||||
/** Engine figure less what the ledger shows was actually withheld. */
|
||||
get variance(): number {
|
||||
return (this.schedule?.totalTds ?? 0) - this.actualTds;
|
||||
}
|
||||
|
||||
/** How much more interest before TDS starts. */
|
||||
get headroom(): number {
|
||||
if (!this.schedule) {
|
||||
return 0;
|
||||
}
|
||||
return Math.max(0, this.schedule.threshold - this.schedule.totalInterest);
|
||||
}
|
||||
|
||||
get thresholdCrossed(): boolean {
|
||||
return !!this.schedule && this.schedule.totalInterest > this.schedule.threshold;
|
||||
}
|
||||
|
||||
/**
|
||||
* A declaration is on record but deposit accounts are still set to withhold —
|
||||
* the branch has not switched them off.
|
||||
*/
|
||||
get declarationNotAppliedToAccounts(): boolean {
|
||||
return !!this.declarationForSelectedFy() && this.liableAccounts.some((a) => a.withHoldTax);
|
||||
}
|
||||
|
||||
/**
|
||||
* The customer filed a declaration but holds joint deposits where someone else
|
||||
* is the deductee — their form does nothing for those.
|
||||
*/
|
||||
get declarationUselessOnJointAccounts(): boolean {
|
||||
return !!this.declarationForSelectedFy() && this.jointHolderAccounts.length > 0;
|
||||
}
|
||||
|
||||
/** Worth prompting the branch to collect a declaration. */
|
||||
get shouldSuggestDeclaration(): boolean {
|
||||
return !this.declarationForSelectedFy() && (this.schedule?.totalTds ?? 0) > 0;
|
||||
}
|
||||
|
||||
trackLine(_: number, line: TdsQuarterLine): number {
|
||||
return line.quarter;
|
||||
}
|
||||
|
||||
trackAccount(_: number, account: Form121AccountRow): string {
|
||||
return account.accountNo;
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,58 @@
|
||||
|
||||
<!-- KYC Summary (read-only when not in edit mode) -->
|
||||
@if (!isEditMode && kycData) {
|
||||
@if (kycData.isException && !kycData.exceptionResolved) {
|
||||
<div
|
||||
class="kyc-exception-banner"
|
||||
style="
|
||||
background: #fef3c7;
|
||||
border: 1px solid #f59e0b;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
"
|
||||
>
|
||||
<mat-icon style="color: #d97706; margin-top: 2px">warning</mat-icon>
|
||||
<div>
|
||||
<strong style="color: #92400e; font-size: 14px">KYC Exception Active</strong>
|
||||
<p style="color: #b45309; font-size: 13px; margin: 4px 0 0 0">
|
||||
Account is open under a temporary KYC Exception. Reason:
|
||||
<strong>{{ kycData.exceptionReason || 'Other' }}</strong
|
||||
>. Please contact the customer and complete verification as soon as possible.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!--
|
||||
The OVD captured at onboarding has gone stale, or is about to. Sits beside the exception
|
||||
banner because it is the same kind of thing: something staff must act on before the account
|
||||
drifts out of compliance.
|
||||
-->
|
||||
@if (showKycDocumentAlert) {
|
||||
<div class="kyc-doc-alert" [class.kyc-doc-alert--critical]="kycDocumentAlertSeverity === 'critical'">
|
||||
<mat-icon class="kyc-doc-alert__icon">{{
|
||||
kycDocumentAlertSeverity === 'critical' ? 'error' : 'schedule'
|
||||
}}</mat-icon>
|
||||
<div class="kyc-doc-alert__body">
|
||||
<strong class="kyc-doc-alert__title">{{ kycDocumentAlertTitle }}</strong>
|
||||
<p class="kyc-doc-alert__text">{{ kycDocumentAlertMessage }}</p>
|
||||
</div>
|
||||
<button
|
||||
mat-flat-button
|
||||
color="primary"
|
||||
type="button"
|
||||
class="kyc-doc-alert__action"
|
||||
(click)="toggleEditMode()"
|
||||
>
|
||||
Update Document
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Verification Table -->
|
||||
<div class="kv-table">
|
||||
<div class="kv-table__header">
|
||||
@@ -61,12 +113,21 @@
|
||||
<span class="kv-table__col kv-table__col--status">
|
||||
@if (kycData.panVerified) {
|
||||
<span class="kv-status kv-status--ok"><mat-icon>check_circle</mat-icon> Verified</span>
|
||||
} @else if (panDeclarationTaken) {
|
||||
<!-- Deliberately not "Verified": the check never passed, paperwork covers it. -->
|
||||
<span class="kv-status kv-status--declared"><mat-icon>assignment_turned_in</mat-icon> Declaration</span>
|
||||
} @else {
|
||||
<span class="kv-status kv-status--pending"><mat-icon>radio_button_unchecked</mat-icon> Pending</span>
|
||||
}
|
||||
</span>
|
||||
<span class="kv-table__col kv-table__col--date">{{
|
||||
kycData.panVerifiedOn ? formatDate(kycData.panVerifiedOn) : '—'
|
||||
kycData.panVerified
|
||||
? kycData.panVerifiedOn
|
||||
? formatDate(kycData.panVerifiedOn)
|
||||
: '—'
|
||||
: panDeclarationTaken && kycData.panDeclarationTakenOn
|
||||
? formatDate(kycData.panDeclarationTakenOn)
|
||||
: '—'
|
||||
}}</span>
|
||||
<span class="kv-table__col kv-table__col--action">
|
||||
<button
|
||||
@@ -82,9 +143,80 @@
|
||||
Re-Verify
|
||||
}
|
||||
</button>
|
||||
<!--
|
||||
A PAN that has never passed is not always fixable by another attempt. Offer the
|
||||
declaration here too, so an existing client sitting in the exception list can be
|
||||
cleared without the officer having to run a verification they know will fail.
|
||||
-->
|
||||
@if (!kycData.panVerified && !panDeclarationTaken && kycData.panNumber) {
|
||||
<button
|
||||
mat-stroked-button
|
||||
class="kv-action-btn"
|
||||
type="button"
|
||||
[disabled]="!kycId || saving"
|
||||
(click)="takePanDeclaration()"
|
||||
>
|
||||
Declaration
|
||||
</button>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
PAN is unverified but a declaration is on file. Shown in full because it is the only
|
||||
reason PAN carries no KYC exception — a reviewer has to see what was declared.
|
||||
-->
|
||||
@if (!kycData.panVerified && panDeclarationTaken) {
|
||||
<div class="kv-decl">
|
||||
<div class="kv-decl__head">
|
||||
<mat-icon>assignment_turned_in</mat-icon>
|
||||
<span class="kv-decl__title">PAN Declaration</span>
|
||||
@if (!saving) {
|
||||
<span class="kv-decl__actions">
|
||||
<button mat-stroked-button type="button" class="kv-action-btn" (click)="takePanDeclaration()">
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
class="kv-action-btn danger"
|
||||
(click)="removePanDeclaration()"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<div class="kv-decl__rows">
|
||||
<div class="kv-decl__row">
|
||||
<span class="k">Does not match</span>
|
||||
<span class="v strong">{{ panDeclarationMismatchLabel }}</span>
|
||||
</div>
|
||||
@if (kycData.panDeclarationNameOnPan) {
|
||||
<div class="kv-decl__row">
|
||||
<span class="k">Name on PAN</span>
|
||||
<span class="v">{{ kycData.panDeclarationNameOnPan }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (kycData.panDeclarationDobOnPan) {
|
||||
<div class="kv-decl__row">
|
||||
<span class="k">DOB on PAN</span>
|
||||
<span class="v">{{ formatDate(kycData.panDeclarationDobOnPan) }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (kycData.panDeclarationRemarks) {
|
||||
<div class="kv-decl__row">
|
||||
<span class="k">Declaration file</span>
|
||||
<span class="v">{{ kycData.panDeclarationRemarks }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="kv-decl__note">
|
||||
PAN remains unverified. No KYC exception is raised for PAN while this declaration is on file.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Aadhaar Row -->
|
||||
<div class="kv-table__row">
|
||||
<span class="kv-table__col kv-table__col--doc">
|
||||
@@ -94,12 +226,21 @@
|
||||
<span class="kv-table__col kv-table__col--status">
|
||||
@if (kycData.aadhaarVerified) {
|
||||
<span class="kv-status kv-status--ok"><mat-icon>check_circle</mat-icon> Verified</span>
|
||||
} @else if (aadhaarReceiptTaken) {
|
||||
<!-- Deliberately not "Verified": no Aadhaar exists yet, the slip covers it. -->
|
||||
<span class="kv-status kv-status--receipt"><mat-icon>receipt_long</mat-icon> Receipt</span>
|
||||
} @else {
|
||||
<span class="kv-status kv-status--pending"><mat-icon>radio_button_unchecked</mat-icon> Pending</span>
|
||||
}
|
||||
</span>
|
||||
<span class="kv-table__col kv-table__col--date">{{
|
||||
kycData.aadhaarVerifiedOn ? formatDate(kycData.aadhaarVerifiedOn) : '—'
|
||||
kycData.aadhaarVerified
|
||||
? kycData.aadhaarVerifiedOn
|
||||
? formatDate(kycData.aadhaarVerifiedOn)
|
||||
: '—'
|
||||
: aadhaarReceiptTaken && kycData.aadhaarReceiptTakenOn
|
||||
? formatDate(kycData.aadhaarReceiptTakenOn)
|
||||
: '—'
|
||||
}}</span>
|
||||
<span class="kv-table__col kv-table__col--action">
|
||||
@if (!showAadhaarReVerify && !aadhaarOtpSent) {
|
||||
@@ -113,9 +254,142 @@
|
||||
Re-Verify
|
||||
</button>
|
||||
}
|
||||
<!--
|
||||
No Aadhaar number means the OTP check has nothing to run against. Offer the enrolment
|
||||
slip here so an existing client sitting in the exception list can be cleared.
|
||||
-->
|
||||
@if (!kycData.aadhaarVerified && !aadhaarReceiptTaken) {
|
||||
<button
|
||||
mat-stroked-button
|
||||
class="kv-action-btn"
|
||||
type="button"
|
||||
[disabled]="!kycId || saving"
|
||||
(click)="takeAadhaarReceipt()"
|
||||
>
|
||||
Receipt
|
||||
</button>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
Aadhaar is unverified but an enrolment receipt is on file. Shown in full because it is the
|
||||
only reason Aadhaar carries no KYC exception.
|
||||
-->
|
||||
@if (!kycData.aadhaarVerified && aadhaarReceiptTaken) {
|
||||
<div class="kv-rcpt">
|
||||
<div class="kv-rcpt__head">
|
||||
<mat-icon>receipt_long</mat-icon>
|
||||
<span class="kv-rcpt__title">Aadhaar Enrolment Receipt</span>
|
||||
@if (!saving) {
|
||||
<span class="kv-rcpt__actions">
|
||||
<button mat-stroked-button type="button" class="kv-action-btn" (click)="takeAadhaarReceipt()">
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
class="kv-action-btn danger"
|
||||
(click)="removeAadhaarReceipt()"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<div class="kv-rcpt__rows">
|
||||
<div class="kv-rcpt__row">
|
||||
<span class="k">Enrolment ID</span>
|
||||
<span class="v mono">{{ kycData.aadhaarEnrolmentId || '—' }}</span>
|
||||
</div>
|
||||
@if (kycData.aadhaarEnrolmentDate) {
|
||||
<div class="kv-rcpt__row">
|
||||
<span class="k">Enrolment date</span>
|
||||
<span class="v">{{ formatDate(kycData.aadhaarEnrolmentDate) }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (kycData.aadhaarReceiptRemarks) {
|
||||
<div class="kv-rcpt__row">
|
||||
<span class="k">Remarks</span>
|
||||
<span class="v">{{ kycData.aadhaarReceiptRemarks }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="kv-rcpt__note">
|
||||
Aadhaar remains unverified. No KYC exception is raised for Aadhaar while this receipt is on file.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!--
|
||||
The OVD collected at onboarding. It has never appeared here, so the dates driving the
|
||||
renewal alert were invisible — an officer told to update the document could not see which
|
||||
one, nor when it was issued.
|
||||
-->
|
||||
@if (kycData.kycDocumentName) {
|
||||
<div class="kv-table__row">
|
||||
<span class="kv-table__col kv-table__col--doc">
|
||||
<mat-icon class="kv-doc-icon">description</mat-icon> {{ kycData.kycDocumentName }}
|
||||
</span>
|
||||
<span class="kv-table__col kv-table__col--num kv-mono">{{ kycData.kycDocumentNumber || '—' }}</span>
|
||||
<span class="kv-table__col kv-table__col--status">
|
||||
@switch (kycDocumentRenewal.state) {
|
||||
@case ('EXPIRED') {
|
||||
<span class="kv-status kv-status--fail"><mat-icon>error</mat-icon> Expired</span>
|
||||
}
|
||||
@case ('EXPIRING_SOON') {
|
||||
<span class="kv-status kv-status--declared"><mat-icon>schedule</mat-icon> Expires Soon</span>
|
||||
}
|
||||
@case ('DUE') {
|
||||
<span class="kv-status kv-status--declared"><mat-icon>update</mat-icon> Update Due</span>
|
||||
}
|
||||
@case ('OK') {
|
||||
<span class="kv-status kv-status--ok"><mat-icon>check_circle</mat-icon> Current</span>
|
||||
}
|
||||
@default {
|
||||
<span class="kv-status kv-status--pending"><mat-icon>help_outline</mat-icon> No dates</span>
|
||||
}
|
||||
}
|
||||
</span>
|
||||
<!--
|
||||
Issued-on, not verified-on: this document is sighted, never API-verified.
|
||||
|
||||
Every document shows an expiry, printed or not: one with no expiry on the card is
|
||||
still only good for five years from issue, so the derived date is shown rather than a
|
||||
blank. It is tagged so a calculated date is never mistaken for one on the document.
|
||||
-->
|
||||
<span class="kv-table__col kv-table__col--date">
|
||||
@if (kycData.kycDocIssueDate || kycData.kycDocExpiryDate) {
|
||||
<span class="kv-doc-dates">
|
||||
@if (kycData.kycDocIssueDate) {
|
||||
<span>Issued {{ formatDate(kycData.kycDocIssueDate) }}</span>
|
||||
}
|
||||
@if (kycDocumentExpiresOn) {
|
||||
<span
|
||||
class="kv-doc-dates__expiry"
|
||||
[class.kv-doc-dates__expiry--derived]="kycDocumentExpiryIsDerived"
|
||||
>
|
||||
Expires {{ kycDocumentExpiresOn }}
|
||||
@if (kycDocumentExpiryIsDerived) {
|
||||
<span
|
||||
class="kv-doc-dates__tag"
|
||||
matTooltip="No expiry printed on this document — calculated as 5 years from the issue date"
|
||||
>5-yr</span
|
||||
>
|
||||
}
|
||||
</span>
|
||||
}
|
||||
</span>
|
||||
} @else {
|
||||
—
|
||||
}
|
||||
</span>
|
||||
<span class="kv-table__col kv-table__col--action">
|
||||
<button mat-stroked-button class="kv-action-btn" type="button" (click)="toggleEditMode()">Update</button>
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- GSTIN Row (Non-Individual only) -->
|
||||
@if (isNonIndividual) {
|
||||
<div class="kv-table__row">
|
||||
@@ -334,39 +608,72 @@
|
||||
<!-- KYC Meta Grid -->
|
||||
<div class="kv-meta-grid">
|
||||
<div class="kv-meta-item">
|
||||
<span class="kv-meta-label">KYC Type</span>
|
||||
<span class="kv-meta-value">{{
|
||||
kycData.kycType === 1 ? 'Individual' : kycData.kycType === 2 ? 'Non-Individual' : '—'
|
||||
}}</span>
|
||||
<mat-icon class="kv-meta-icon icon-blue">assignment_ind</mat-icon>
|
||||
<div class="kv-meta-info">
|
||||
<span class="kv-meta-label">KYC Type</span>
|
||||
<span class="kv-meta-value">
|
||||
{{ kycData.kycType === 1 ? 'Individual' : kycData.kycType === 2 ? 'Non-Individual' : '—' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="kv-meta-item">
|
||||
<span class="kv-meta-label">Risk Category</span>
|
||||
<span class="kv-meta-value">{{
|
||||
kycData.riskCategory === 1
|
||||
? 'Low'
|
||||
: kycData.riskCategory === 2
|
||||
? 'Medium'
|
||||
: kycData.riskCategory === 3
|
||||
? 'High'
|
||||
: '—'
|
||||
}}</span>
|
||||
<mat-icon
|
||||
class="kv-meta-icon"
|
||||
[ngClass]="{
|
||||
'icon-green': kycData.riskCategory === 1,
|
||||
'icon-orange': kycData.riskCategory === 2,
|
||||
'icon-red': kycData.riskCategory === 3
|
||||
}"
|
||||
>shield</mat-icon
|
||||
>
|
||||
<div class="kv-meta-info">
|
||||
<span class="kv-meta-label">Risk Category</span>
|
||||
<span class="kv-meta-value">
|
||||
{{
|
||||
kycData.riskCategory === 1
|
||||
? 'Low'
|
||||
: kycData.riskCategory === 2
|
||||
? 'Medium'
|
||||
: kycData.riskCategory === 3
|
||||
? 'High'
|
||||
: '—'
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="kv-meta-item">
|
||||
<span class="kv-meta-label">Initiated On</span>
|
||||
<span class="kv-meta-value">{{ formatDate(kycData.initiatedOn) }}</span>
|
||||
<mat-icon class="kv-meta-icon icon-grey">calendar_today</mat-icon>
|
||||
<div class="kv-meta-info">
|
||||
<span class="kv-meta-label">Initiated On</span>
|
||||
<span class="kv-meta-value">{{ formatDate(kycData.initiatedOn) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="kv-meta-item">
|
||||
<span class="kv-meta-label">Last Verified</span>
|
||||
<span class="kv-meta-value">{{ formatDate(kycData.verifiedOn) }}</span>
|
||||
<mat-icon class="kv-meta-icon icon-green">check_circle</mat-icon>
|
||||
<div class="kv-meta-info">
|
||||
<span class="kv-meta-label">Last Verified</span>
|
||||
<span class="kv-meta-value">{{ formatDate(kycData.verifiedOn) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="kv-meta-item">
|
||||
<span class="kv-meta-label">Expiry Date</span>
|
||||
<span class="kv-meta-value">{{ formatDate(kycData.expiryDate) }}</span>
|
||||
<mat-icon class="kv-meta-icon icon-red">history_toggle_off</mat-icon>
|
||||
<div class="kv-meta-info">
|
||||
<span class="kv-meta-label">Expiry Date</span>
|
||||
<span class="kv-meta-value">{{ formatDate(kycData.expiryDate) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (kycData.remarks) {
|
||||
<div class="kv-meta-item kv-meta-item--full">
|
||||
<span class="kv-meta-label">Remarks</span>
|
||||
<span class="kv-meta-value">{{ kycData.remarks }}</span>
|
||||
<mat-icon class="kv-meta-icon icon-grey">notes</mat-icon>
|
||||
<div class="kv-meta-info">
|
||||
<span class="kv-meta-label">Remarks</span>
|
||||
<span class="kv-meta-value">{{ kycData.remarks }}</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -541,6 +848,22 @@
|
||||
<mat-icon class="kyc-error-icon">error_outline</mat-icon>
|
||||
<span>{{ verificationError.pan }}</span>
|
||||
</div>
|
||||
<!--
|
||||
The check can fail for reasons no retry will fix — a PAN card that spells the
|
||||
name differently, or a vendor that is simply down. The declaration is the way
|
||||
out, so it is offered here rather than leaving the officer at a dead end.
|
||||
-->
|
||||
<div class="kyc-decl-offer">
|
||||
<span>Customer's PAN card genuinely carries different details, or PAN cannot be checked?</span>
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
[disabled]="!kycId || saving"
|
||||
(click)="takePanDeclaration()"
|
||||
>
|
||||
{{ panDeclarationTaken ? 'Edit Declaration' : 'Take Declaration' }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
@if (verificationStatus.pan === 'VERIFIED' && verificationDetails.pan?.name) {
|
||||
<span class="verification-detail-text">{{ verificationDetails.pan.name }}</span>
|
||||
@@ -652,6 +975,22 @@
|
||||
<mat-icon class="kyc-error-icon">error_outline</mat-icon>
|
||||
<span>{{ verificationError.aadhaar }}</span>
|
||||
</div>
|
||||
<!--
|
||||
A failed OTP is not always something a retry can fix — a customer who has only
|
||||
applied for Aadhaar has no number to verify at all. The enrolment slip is the way
|
||||
out, so it is offered here rather than leaving the officer at a dead end.
|
||||
-->
|
||||
<div class="kyc-decl-offer">
|
||||
<span>Customer has applied for Aadhaar but not received it yet?</span>
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
[disabled]="!kycId || saving"
|
||||
(click)="takeAadhaarReceipt()"
|
||||
>
|
||||
{{ aadhaarReceiptTaken ? 'Edit Receipt' : 'Record Receipt' }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -740,6 +1079,32 @@
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- KYC Exception Flag -->
|
||||
<div
|
||||
class="full-span exception-controls-row"
|
||||
style="display: flex; align-items: center; gap: 20px; margin-bottom: 12px; flex-wrap: wrap"
|
||||
>
|
||||
<mat-checkbox formControlName="isException" style="margin-top: 8px">Mark as KYC Exception</mat-checkbox>
|
||||
|
||||
@if (kycForm.get('isException')?.value) {
|
||||
<mat-form-field appearance="outline" style="flex: 1; min-width: 250px">
|
||||
<mat-label>Exception Reasons</mat-label>
|
||||
<mat-select formControlName="exceptionReason" multiple>
|
||||
@if (!isNonIndividual) {
|
||||
<mat-option value="PAN Card Verification Failed">PAN Card Verification Failed</mat-option>
|
||||
<mat-option value="Aadhaar Verification Failed">Aadhaar Verification Failed</mat-option>
|
||||
} @else {
|
||||
<mat-option value="Enterprise PAN Verification Failed"
|
||||
>Enterprise PAN Verification Failed</mat-option
|
||||
>
|
||||
<mat-option value="GSTIN Verification Failed">GSTIN Verification Failed</mat-option>
|
||||
<mat-option value="CIN Verification Failed">CIN Verification Failed</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Remarks -->
|
||||
<mat-form-field appearance="outline" class="full-span">
|
||||
<mat-label>{{ 'labels.inputs.Remarks' | translate }}</mat-label>
|
||||
|
||||
@@ -559,6 +559,19 @@
|
||||
background: #eff6ff;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
// Unverified, covered by a declaration — amber, never green, so it cannot read as a pass.
|
||||
&--declared {
|
||||
background: #fffbeb;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
// Covered by an enrolment slip. Blue rather than amber: nothing failed, the customer simply
|
||||
// does not hold the document yet.
|
||||
&--receipt {
|
||||
background: #eff6ff;
|
||||
color: #2563eb;
|
||||
}
|
||||
}
|
||||
|
||||
.kv-action-btn {
|
||||
@@ -570,6 +583,265 @@
|
||||
font-weight: 600 !important;
|
||||
color: #1074b9 !important;
|
||||
border-color: #1074b9 !important;
|
||||
|
||||
&.danger {
|
||||
color: #b91c1c !important;
|
||||
border-color: #fca5a5 !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* PAN declaration detail, shown under the PAN row when the check did not pass */
|
||||
.kv-decl {
|
||||
margin: 4px 0 8px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #fcd34d;
|
||||
border-radius: 8px;
|
||||
background: #fffbeb;
|
||||
}
|
||||
|
||||
.kv-decl__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
|
||||
mat-icon {
|
||||
color: #b45309;
|
||||
font-size: 18px !important;
|
||||
width: 18px !important;
|
||||
height: 18px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.kv-decl__title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.kv-decl__actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.kv-decl__rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.kv-decl__row {
|
||||
display: grid;
|
||||
grid-template-columns: 140px 1fr;
|
||||
gap: 10px;
|
||||
font-size: 12.5px;
|
||||
|
||||
.k {
|
||||
color: #92400e;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.v {
|
||||
color: #451a03;
|
||||
word-break: break-word;
|
||||
|
||||
&.strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.kv-decl__note {
|
||||
margin-top: 10px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid #fde68a;
|
||||
font-size: 11.5px;
|
||||
line-height: 1.5;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
/* KYC document renewal alert — amber for a stale document, red once it has actually expired */
|
||||
.kyc-doc-alert {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
margin-bottom: 20px;
|
||||
border: 1px solid #f59e0b;
|
||||
border-radius: 8px;
|
||||
background: #fef3c7;
|
||||
|
||||
&--critical {
|
||||
border-color: #ef4444;
|
||||
background: #fee2e2;
|
||||
}
|
||||
}
|
||||
|
||||
.kyc-doc-alert__icon {
|
||||
flex: 0 0 auto;
|
||||
margin-top: 2px;
|
||||
color: #d97706;
|
||||
|
||||
.kyc-doc-alert--critical & {
|
||||
color: #dc2626;
|
||||
}
|
||||
}
|
||||
|
||||
.kyc-doc-alert__body {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.kyc-doc-alert__title {
|
||||
font-size: 14px;
|
||||
color: #92400e;
|
||||
|
||||
.kyc-doc-alert--critical & {
|
||||
color: #991b1b;
|
||||
}
|
||||
}
|
||||
|
||||
.kyc-doc-alert__text {
|
||||
margin: 4px 0 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: #b45309;
|
||||
|
||||
.kyc-doc-alert--critical & {
|
||||
color: #b91c1c;
|
||||
}
|
||||
}
|
||||
|
||||
.kyc-doc-alert__action {
|
||||
flex: 0 0 auto;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
/* Issue / expiry pair in the document row */
|
||||
.kv-doc-dates {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.kv-doc-dates__expiry {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #64748b;
|
||||
|
||||
/* Calculated from the issue date rather than printed on the document. */
|
||||
&--derived {
|
||||
color: #94a3b8;
|
||||
}
|
||||
}
|
||||
|
||||
.kv-doc-dates__tag {
|
||||
padding: 0 5px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 4px;
|
||||
background: #f8fafc;
|
||||
font-size: 9.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: #94a3b8;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Aadhaar enrolment receipt detail, shown under the Aadhaar row when the check did not pass */
|
||||
.kv-rcpt {
|
||||
margin: 4px 0 8px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 8px;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.kv-rcpt__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
|
||||
mat-icon {
|
||||
color: #2563eb;
|
||||
font-size: 18px !important;
|
||||
width: 18px !important;
|
||||
height: 18px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.kv-rcpt__title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #1e3a8a;
|
||||
}
|
||||
|
||||
.kv-rcpt__actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.kv-rcpt__rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.kv-rcpt__row {
|
||||
display: grid;
|
||||
grid-template-columns: 140px 1fr;
|
||||
gap: 10px;
|
||||
font-size: 12.5px;
|
||||
|
||||
.k {
|
||||
color: #1e40af;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.v {
|
||||
color: #0f172a;
|
||||
word-break: break-word;
|
||||
|
||||
&.mono {
|
||||
font-family: monospace;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.kv-rcpt__note {
|
||||
margin-top: 10px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid #bfdbfe;
|
||||
font-size: 11.5px;
|
||||
line-height: 1.5;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
/* Offer shown beneath a failed PAN verification in edit mode */
|
||||
.kyc-decl-offer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 8px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #fde68a;
|
||||
border-radius: 6px;
|
||||
background: #fffbeb;
|
||||
font-size: 12.5px;
|
||||
color: #92400e;
|
||||
|
||||
button {
|
||||
margin-left: auto;
|
||||
color: #b45309 !important;
|
||||
border-color: #fcd34d !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* OTP Bar (below table) */
|
||||
@@ -622,27 +894,76 @@
|
||||
.kv-meta-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
margin-top: 16px;
|
||||
border-radius: 12px;
|
||||
padding: 16px 20px;
|
||||
gap: 20px;
|
||||
margin-top: 20px;
|
||||
box-shadow:
|
||||
0 1px 3px rgb(0 0 0 / 2%),
|
||||
0 4px 6px -1px rgb(0 0 0 / 5%);
|
||||
}
|
||||
|
||||
.kv-meta-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
padding: 12px 14px;
|
||||
border-right: 1px solid #f1f5f9;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:nth-child(5n) {
|
||||
border-right: none;
|
||||
&:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
&--full {
|
||||
grid-column: 1 / -1;
|
||||
border-right: none;
|
||||
border-top: 1px solid #f1f5f9;
|
||||
padding-top: 16px;
|
||||
margin-top: 4px;
|
||||
border-radius: 0;
|
||||
align-items: flex-start;
|
||||
|
||||
&:hover {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.kv-meta-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.kv-meta-icon {
|
||||
font-size: 24px !important;
|
||||
width: 24px !important;
|
||||
height: 24px !important;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.icon-blue {
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
&.icon-green {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
&.icon-orange {
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
&.icon-red {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
&.icon-grey {
|
||||
color: #64748b;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -656,7 +977,7 @@
|
||||
|
||||
.kv-meta-value {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
@@ -677,14 +998,7 @@
|
||||
|
||||
.kv-meta-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
.kv-meta-item:nth-child(5n) {
|
||||
border-right: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.kv-meta-item:nth-child(3n) {
|
||||
border-right: none;
|
||||
gap: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,10 +10,26 @@
|
||||
import { Component, OnInit, OnDestroy, inject } from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { UntypedFormBuilder, UntypedFormGroup, Validators, FormsModule } from '@angular/forms';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { catchError, of, Subject, takeUntil } from 'rxjs';
|
||||
|
||||
/** Custom Services */
|
||||
import { ClientsService } from '../../clients.service';
|
||||
import { deriveKycExceptionFields } from '../../kyc-exception.util';
|
||||
import {
|
||||
deriveKycDocumentRenewal,
|
||||
kycDocumentNeedsAttention,
|
||||
KycDocumentRenewalStatus,
|
||||
KYC_DOCUMENT_RENEWAL_YEARS
|
||||
} from '../../kyc-document-renewal.util';
|
||||
import {
|
||||
PanDeclarationDialogComponent,
|
||||
PanDeclarationResult
|
||||
} from '../../shared/pan-declaration-dialog/pan-declaration-dialog.component';
|
||||
import {
|
||||
AadhaarReceiptDialogComponent,
|
||||
AadhaarReceiptResult
|
||||
} from '../../shared/aadhaar-receipt-dialog/aadhaar-receipt-dialog.component';
|
||||
import { Dates } from 'app/core/utils/dates';
|
||||
import { jsPDF } from 'jspdf';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
@@ -46,6 +62,7 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
||||
private clientsService = inject(ClientsService);
|
||||
private formBuilder = inject(UntypedFormBuilder);
|
||||
private dateUtils = inject(Dates);
|
||||
private dialog = inject(MatDialog);
|
||||
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
@@ -121,7 +138,9 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
||||
'',
|
||||
[Validators.pattern(/^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z]{1}[1-9A-Z]{1}Z[0-9A-Z]{1}$/)]
|
||||
],
|
||||
remarks: ['']
|
||||
remarks: [''],
|
||||
isException: [false],
|
||||
exceptionReason: [[]]
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
@@ -157,7 +176,9 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
||||
// the full 12-digit number to re-verify, so don't pre-fill.
|
||||
aadhaarNumber: '',
|
||||
gstinNumber: data.gstinNumber || '',
|
||||
remarks: data.remarks || ''
|
||||
remarks: data.remarks || '',
|
||||
isException: data.isException || false,
|
||||
exceptionReason: data.exceptionReason ? data.exceptionReason.split(', ') : []
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -195,6 +216,153 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
/** True when the stored record carries a signed declaration standing in for the PAN check. */
|
||||
get panDeclarationTaken(): boolean {
|
||||
return this.kycData?.panDeclarationTaken === true;
|
||||
}
|
||||
|
||||
/** "Name", "Date of Birth", "Name and Date of Birth", or the not-checked-at-all case. */
|
||||
get panDeclarationMismatchLabel(): string {
|
||||
const fields: string = this.kycData?.panDeclarationMismatchFields;
|
||||
if (!fields || fields === 'NONE') {
|
||||
return 'PAN could not be verified';
|
||||
}
|
||||
return fields
|
||||
.split(',')
|
||||
.map((f) => (f === 'NAME' ? 'Name' : 'Date of Birth'))
|
||||
.join(' and ');
|
||||
}
|
||||
|
||||
/** True when the stored record carries a UIDAI enrolment slip standing in for the OTP check. */
|
||||
get aadhaarReceiptTaken(): boolean {
|
||||
return this.kycData?.aadhaarReceiptTaken === true;
|
||||
}
|
||||
|
||||
// ── KYC document renewal ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Whether the OVD captured at onboarding is still current — see `deriveKycDocumentRenewal()`.
|
||||
*
|
||||
* The issue and expiry dates have always been captured on the KYC step but never read back, so
|
||||
* a client onboarded on a ten-year-old licence looked identical to one onboarded yesterday.
|
||||
*/
|
||||
get kycDocumentRenewal(): KycDocumentRenewalStatus {
|
||||
return deriveKycDocumentRenewal({
|
||||
issueDate: this.parseApiDate(this.kycData?.kycDocIssueDate),
|
||||
expiryDate: this.parseApiDate(this.kycData?.kycDocExpiryDate),
|
||||
today: new Date()
|
||||
});
|
||||
}
|
||||
|
||||
/** Only raise the banner for a document that actually exists and actually needs action. */
|
||||
get showKycDocumentAlert(): boolean {
|
||||
return !!this.kycData?.kycDocumentName && kycDocumentNeedsAttention(this.kycDocumentRenewal.state);
|
||||
}
|
||||
|
||||
/** Heading for the alert — the three states call for visibly different things. */
|
||||
get kycDocumentAlertTitle(): string {
|
||||
switch (this.kycDocumentRenewal.state) {
|
||||
case 'EXPIRED':
|
||||
return 'KYC Document Expired';
|
||||
case 'EXPIRING_SOON':
|
||||
return 'KYC Document Expires Soon';
|
||||
default:
|
||||
return 'KYC Document Due for Update';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The sentence staff act on. Each case names the date that triggered it, because "update the
|
||||
* document" without saying why leaves the officer checking the form to work out which clock ran
|
||||
* out.
|
||||
*/
|
||||
get kycDocumentAlertMessage(): string {
|
||||
const status = this.kycDocumentRenewal;
|
||||
const docName = this.kycData?.kycDocumentName || 'The KYC document';
|
||||
// `formatDate()` only understands Fineract's [y, m, d] arrays; these are real Date objects.
|
||||
const dueOn = status.dueOn ? this.dateUtils.formatDate(status.dueOn, 'dd MMMM yyyy') : '';
|
||||
const days = status.daysRemaining ?? 0;
|
||||
|
||||
switch (status.state) {
|
||||
case 'EXPIRED':
|
||||
return `${docName} expired on ${dueOn} and is no longer a valid officially valid document. Collect a renewed document from the customer and update the KYC record.`;
|
||||
case 'EXPIRING_SOON':
|
||||
return days === 0
|
||||
? `${docName} expires today (${dueOn}). Ask the customer for the renewed document before it lapses.`
|
||||
: `${docName} expires on ${dueOn} — ${days} day${days === 1 ? '' : 's'} from now. Ask the customer for the renewed document before it lapses.`;
|
||||
default:
|
||||
return `${docName} was issued more than ${KYC_DOCUMENT_RENEWAL_YEARS} years ago and fell due for update on ${dueOn}. Re-collect the document from the customer and update the KYC record.`;
|
||||
}
|
||||
}
|
||||
|
||||
/** Drives the banner's colour — an expired document is a harder failure than a stale one. */
|
||||
get kycDocumentAlertSeverity(): 'critical' | 'warning' {
|
||||
return this.kycDocumentRenewal.state === 'EXPIRED' ? 'critical' : 'warning';
|
||||
}
|
||||
|
||||
/**
|
||||
* The date the document stops counting as current, whether or not one is printed on it.
|
||||
*
|
||||
* A Voter ID or NREGA card carries no expiry, but it is not valid forever for KYC purposes —
|
||||
* the five-year rule is its expiry. Showing the derived date rather than a blank means every
|
||||
* document answers "valid until when?", and the officer sees the same date the alert will fire on
|
||||
* instead of having to add five years in their head.
|
||||
*/
|
||||
get kycDocumentExpiresOn(): string {
|
||||
const dueOn = this.kycDocumentRenewal.dueOn;
|
||||
return dueOn ? this.dateUtils.formatDate(dueOn, 'dd MMMM yyyy') : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the date above was calculated from the issue date rather than read off the document.
|
||||
* Flagged in the UI so a derived date is never mistaken for one printed on the card.
|
||||
*/
|
||||
get kycDocumentExpiryIsDerived(): boolean {
|
||||
return this.kycDocumentRenewal.reason === 'AGE';
|
||||
}
|
||||
|
||||
/** Compact chip label for the document row in the verification table. */
|
||||
get kycDocumentStatusLabel(): string {
|
||||
switch (this.kycDocumentRenewal.state) {
|
||||
case 'EXPIRED':
|
||||
return 'Expired';
|
||||
case 'EXPIRING_SOON':
|
||||
return 'Expires Soon';
|
||||
case 'DUE':
|
||||
return 'Update Due';
|
||||
case 'OK':
|
||||
return 'Current';
|
||||
default:
|
||||
return '—';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exception columns derived from verification status — see `deriveKycExceptionFields()`.
|
||||
*
|
||||
* A field counts as verified if it was verified in this session OR is already verified on the
|
||||
* stored record; either way the customer has cleared that check. Verifying one of two
|
||||
* outstanding items narrows the exception to the remaining one rather than closing it.
|
||||
*/
|
||||
private buildExceptionFields() {
|
||||
const panVerified = this.verificationStatus.pan === 'VERIFIED' || this.kycData?.panVerified === true;
|
||||
const aadhaarVerified = this.verificationStatus.aadhaar === 'VERIFIED' || this.kycData?.aadhaarVerified === true;
|
||||
|
||||
return deriveKycExceptionFields({
|
||||
panVerified,
|
||||
// A signed declaration closes the PAN item by paperwork when the API never can.
|
||||
panDeclarationTaken: this.panDeclarationTaken,
|
||||
aadhaarVerified,
|
||||
// An enrolment slip closes the Aadhaar item when there is no number to verify yet.
|
||||
aadhaarReceiptTaken: this.aadhaarReceiptTaken,
|
||||
aadhaarApplicable: !this.isNonIndividual,
|
||||
hadException: this.kycData?.isException === true,
|
||||
previousReason: this.kycData?.exceptionReason || null,
|
||||
manualReasons: Array.isArray(this.kycForm.value.exceptionReason) ? this.kycForm.value.exceptionReason : [],
|
||||
todayFormatted: this.dateUtils.formatDate(new Date(), 'dd MMMM yyyy')
|
||||
});
|
||||
}
|
||||
|
||||
saveKyc(): void {
|
||||
if (this.kycForm.invalid) {
|
||||
return;
|
||||
@@ -210,7 +378,10 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
||||
panNumber: this.kycForm.value.panNumber || null,
|
||||
aadhaarNumberMasked: aadhaarMasked,
|
||||
gstinNumber: this.kycForm.value.gstinNumber || null,
|
||||
remarks: this.kycForm.value.remarks || null
|
||||
remarks: this.kycForm.value.remarks || null,
|
||||
// dateFormat is required so the backend can parse exceptionResolvedOn when an exception closes.
|
||||
dateFormat: 'dd MMMM yyyy',
|
||||
...this.buildExceptionFields()
|
||||
};
|
||||
|
||||
if (!this.kycId) {
|
||||
@@ -244,6 +415,38 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
private saveKycBeforeVerify(callback: () => void, field: 'pan' | 'aadhaar' | 'gstin'): void {
|
||||
if (this.isEditMode) {
|
||||
const aadhaarRaw = this.kycForm.value.aadhaarNumber || '';
|
||||
const aadhaarMasked = aadhaarRaw.length === 12 ? 'XXXXXXXX' + aadhaarRaw.slice(-4) : aadhaarRaw || null;
|
||||
const payload: any = {
|
||||
locale: 'en',
|
||||
panNumber: this.kycForm.value.panNumber || null,
|
||||
aadhaarNumberMasked: aadhaarMasked,
|
||||
gstinNumber: this.kycForm.value.gstinNumber || null,
|
||||
remarks: this.kycForm.value.remarks || null,
|
||||
riskCategory: this.kycForm.value.riskCategory || 1,
|
||||
dateFormat: 'dd MMMM yyyy',
|
||||
...this.buildExceptionFields()
|
||||
};
|
||||
|
||||
this.clientsService.updateClientKyc(this.clientId, this.kycId, payload).subscribe({
|
||||
next: () => {
|
||||
this.loadKycData();
|
||||
callback();
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.verifying[field] = false;
|
||||
this.verificationStatus[field] = 'FAILED';
|
||||
this.verificationError[field] =
|
||||
err?.error?.defaultUserMessage || `Failed to update ${field.toUpperCase()} number before verification.`;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
}
|
||||
|
||||
verifyPAN(): void {
|
||||
if (!this.kycId) {
|
||||
this.verificationError.pan = 'KYC record not found. Save KYC details first.';
|
||||
@@ -254,33 +457,317 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
||||
this.verificationError.pan = null;
|
||||
this.verificationDetails.pan = null;
|
||||
|
||||
this.saveKycBeforeVerify(() => {
|
||||
this.clientsService
|
||||
.verifyPAN(this.clientId, this.kycId)
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: (response: any) => {
|
||||
this.verifying.pan = false;
|
||||
if (response?.changes?.panVerified) {
|
||||
this.verificationStatus.pan = 'VERIFIED';
|
||||
this.verificationDetails.pan = response.changes;
|
||||
// A PAN that now verifies makes any earlier declaration moot.
|
||||
if (this.panDeclarationTaken) {
|
||||
this.writePanDeclaration(null);
|
||||
} else {
|
||||
this.loadKycData();
|
||||
}
|
||||
} else {
|
||||
this.verificationStatus.pan = 'FAILED';
|
||||
this.verificationError.pan = 'PAN verification failed.';
|
||||
}
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.verifying.pan = false;
|
||||
this.verificationStatus.pan = 'FAILED';
|
||||
this.verificationError.pan =
|
||||
err?.error?.developerMessage ||
|
||||
err?.error?.defaultUserMessage ||
|
||||
err?.message ||
|
||||
'PAN verification failed. Please try again.';
|
||||
}
|
||||
});
|
||||
}, 'pan');
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the customer declaration that stands in for a PAN check the client cannot pass.
|
||||
*
|
||||
* Offered for any PAN failure, not just a name/DOB mismatch — an officer facing an unreachable
|
||||
* vendor is just as stuck as one facing a misspelt PAN card, and the same signed paperwork
|
||||
* resolves both. When the failure was not a comparable mismatch there is no PAN card to compare
|
||||
* against, so the dialog only asks why the declaration is being taken.
|
||||
*/
|
||||
takePanDeclaration(): void {
|
||||
if (!this.kycId) {
|
||||
this.verificationError.pan = 'KYC record not found. Save KYC details first.';
|
||||
return;
|
||||
}
|
||||
const failureMessage = this.verificationError.pan || 'PAN has not been verified.';
|
||||
const mismatch = failureMessage.toLowerCase().includes('does not match');
|
||||
|
||||
this.dialog
|
||||
.open(PanDeclarationDialogComponent, {
|
||||
width: '720px',
|
||||
maxWidth: '95vw',
|
||||
disableClose: true,
|
||||
data: {
|
||||
submittedName: this.kycData?.clientDisplayName || this.clientData?.displayName || '',
|
||||
submittedDob: this.clientDateOfBirth || '',
|
||||
panNumber: this.kycForm.value.panNumber || this.kycData?.panNumber || '',
|
||||
failureMessage,
|
||||
// Only a reported name/DOB mismatch gives the officer something to compare against.
|
||||
unverifiable: !mismatch,
|
||||
existing: this.panDeclarationTaken
|
||||
? {
|
||||
mismatchFields: this.kycData?.panDeclarationMismatchFields || 'NONE',
|
||||
nameOnPan: this.kycData?.panDeclarationNameOnPan || null,
|
||||
dobOnPan: this.parseApiDate(this.kycData?.panDeclarationDobOnPan),
|
||||
// The scan itself lives with the client's documents; the record keeps its name.
|
||||
file: null,
|
||||
fileName: this.kycData?.panDeclarationRemarks || ''
|
||||
}
|
||||
: null
|
||||
}
|
||||
})
|
||||
.afterClosed()
|
||||
.subscribe((result: PanDeclarationResult | undefined) => {
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
// The scan has to be filed against the client before the record says a declaration exists —
|
||||
// otherwise the PAN exception is waived by a document nobody can produce.
|
||||
if (result.file) {
|
||||
this.uploadPanDeclarationFile(result.file, () => this.writePanDeclaration(result));
|
||||
} else {
|
||||
this.writePanDeclaration(result);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Files the signed declaration under the client's documents, then continues with `onDone`. */
|
||||
private uploadPanDeclarationFile(file: File, onDone: () => void): void {
|
||||
this.saving = true;
|
||||
this.errorMessage = null;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('name', 'PAN Declaration');
|
||||
formData.append(
|
||||
'description',
|
||||
`Signed PAN declaration for ${this.kycForm.value.panNumber || this.kycData?.panNumber || 'PAN'}`
|
||||
);
|
||||
|
||||
this.clientsService
|
||||
.verifyPAN(this.clientId, this.kycId)
|
||||
.uploadClientDocument(this.clientId, formData)
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: (response: any) => {
|
||||
this.verifying.pan = false;
|
||||
if (response?.changes?.panVerified) {
|
||||
this.verificationStatus.pan = 'VERIFIED';
|
||||
this.verificationDetails.pan = response.changes;
|
||||
this.loadKycData();
|
||||
} else {
|
||||
this.verificationStatus.pan = 'FAILED';
|
||||
this.verificationError.pan = 'PAN verification failed.';
|
||||
}
|
||||
next: () => {
|
||||
this.saving = false;
|
||||
onDone();
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.verifying.pan = false;
|
||||
this.verificationStatus.pan = 'FAILED';
|
||||
this.verificationError.pan =
|
||||
this.saving = false;
|
||||
this.errorMessage =
|
||||
err?.error?.defaultUserMessage ||
|
||||
err?.error?.developerMessage ||
|
||||
err?.message ||
|
||||
'Could not upload the signed declaration. Please try again.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Officer-initiated removal — the PAN exception is raised again on this same save. */
|
||||
removePanDeclaration(): void {
|
||||
if (this.panDeclarationTaken) {
|
||||
this.writePanDeclaration(null);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists (or clears) the declaration on the KYC record.
|
||||
*
|
||||
* `buildExceptionFields()` reads the declaration off `kycData`, so that has to be updated before
|
||||
* the payload is built — otherwise the save would carry the declaration and the PAN exception at
|
||||
* the same time, which is exactly the contradiction the declaration exists to prevent.
|
||||
*/
|
||||
private writePanDeclaration(declaration: PanDeclarationResult | null): void {
|
||||
this.saving = true;
|
||||
this.errorMessage = null;
|
||||
|
||||
const previous = {
|
||||
panDeclarationTaken: this.kycData?.panDeclarationTaken,
|
||||
panDeclarationMismatchFields: this.kycData?.panDeclarationMismatchFields,
|
||||
panDeclarationNameOnPan: this.kycData?.panDeclarationNameOnPan,
|
||||
panDeclarationDobOnPan: this.kycData?.panDeclarationDobOnPan,
|
||||
panDeclarationRemarks: this.kycData?.panDeclarationRemarks,
|
||||
panDeclarationTakenOn: this.kycData?.panDeclarationTakenOn
|
||||
};
|
||||
|
||||
const dateFormat = 'dd MMMM yyyy';
|
||||
const declarationFields = {
|
||||
panDeclarationTaken: !!declaration,
|
||||
panDeclarationMismatchFields: declaration?.mismatchFields || null,
|
||||
panDeclarationNameOnPan: declaration?.nameOnPan || null,
|
||||
panDeclarationDobOnPan: declaration?.dobOnPan
|
||||
? this.dateUtils.formatDate(declaration.dobOnPan, dateFormat)
|
||||
: null,
|
||||
// Names the uploaded declaration filed under the client's documents.
|
||||
panDeclarationRemarks: declaration?.fileName || null,
|
||||
panDeclarationTakenOn: declaration ? this.dateUtils.formatDate(new Date(), dateFormat) : null
|
||||
};
|
||||
|
||||
this.kycData = { ...(this.kycData || {}), ...declarationFields };
|
||||
|
||||
const payload: any = {
|
||||
locale: 'en',
|
||||
dateFormat,
|
||||
riskCategory: this.kycForm.value.riskCategory || 1,
|
||||
...declarationFields,
|
||||
...this.buildExceptionFields()
|
||||
};
|
||||
|
||||
this.clientsService
|
||||
.updateClientKyc(this.clientId, this.kycId, payload)
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.saving = false;
|
||||
this.loadKycData();
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.saving = false;
|
||||
// Put the record back so the tab never shows a declaration the server did not accept.
|
||||
this.kycData = { ...(this.kycData || {}), ...previous };
|
||||
this.errorMessage =
|
||||
err?.error?.developerMessage ||
|
||||
err?.error?.defaultUserMessage ||
|
||||
err?.message ||
|
||||
'PAN verification failed. Please try again.';
|
||||
'Failed to save the PAN declaration.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the UIDAI enrolment receipt accepted in place of a verified Aadhaar.
|
||||
*
|
||||
* A customer who has applied for Aadhaar but has not been issued one has no number to put through
|
||||
* the OTP flow, so the check can never pass. The slip closes the item instead.
|
||||
*/
|
||||
takeAadhaarReceipt(): void {
|
||||
if (!this.kycId) {
|
||||
this.verificationError.aadhaar = 'KYC record not found. Save KYC details first.';
|
||||
return;
|
||||
}
|
||||
this.dialog
|
||||
.open(AadhaarReceiptDialogComponent, {
|
||||
width: '560px',
|
||||
maxWidth: '95vw',
|
||||
disableClose: true,
|
||||
data: {
|
||||
clientName: this.kycData?.clientDisplayName || this.clientData?.displayName || '',
|
||||
reason:
|
||||
this.verificationError.aadhaar ||
|
||||
'Aadhaar has not been verified. Record the enrolment slip if the customer has applied but not yet received their Aadhaar.',
|
||||
existing: this.aadhaarReceiptTaken
|
||||
? {
|
||||
enrolmentId: this.kycData?.aadhaarEnrolmentId || '',
|
||||
enrolmentDate: this.parseApiDate(this.kycData?.aadhaarEnrolmentDate),
|
||||
remarks: this.kycData?.aadhaarReceiptRemarks || ''
|
||||
}
|
||||
: null
|
||||
}
|
||||
})
|
||||
.afterClosed()
|
||||
.subscribe((result: AadhaarReceiptResult | undefined) => {
|
||||
if (result) {
|
||||
this.writeAadhaarReceipt(result);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Officer-initiated removal — the Aadhaar exception is raised again on this same save. */
|
||||
removeAadhaarReceipt(): void {
|
||||
if (this.aadhaarReceiptTaken) {
|
||||
this.writeAadhaarReceipt(null);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists (or clears) the enrolment receipt on the KYC record.
|
||||
*
|
||||
* `buildExceptionFields()` reads the receipt off `kycData`, so that has to be updated before the
|
||||
* payload is built — otherwise the save would carry the receipt and the Aadhaar exception at the
|
||||
* same time, the contradiction the receipt exists to prevent.
|
||||
*/
|
||||
private writeAadhaarReceipt(receipt: AadhaarReceiptResult | null): void {
|
||||
this.saving = true;
|
||||
this.errorMessage = null;
|
||||
|
||||
const previous = {
|
||||
aadhaarReceiptTaken: this.kycData?.aadhaarReceiptTaken,
|
||||
aadhaarEnrolmentId: this.kycData?.aadhaarEnrolmentId,
|
||||
aadhaarEnrolmentDate: this.kycData?.aadhaarEnrolmentDate,
|
||||
aadhaarReceiptRemarks: this.kycData?.aadhaarReceiptRemarks,
|
||||
aadhaarReceiptTakenOn: this.kycData?.aadhaarReceiptTakenOn
|
||||
};
|
||||
|
||||
const dateFormat = 'dd MMMM yyyy';
|
||||
const receiptFields = {
|
||||
aadhaarReceiptTaken: !!receipt,
|
||||
aadhaarEnrolmentId: receipt?.enrolmentId || null,
|
||||
aadhaarEnrolmentDate: receipt?.enrolmentDate
|
||||
? this.dateUtils.formatDate(receipt.enrolmentDate, dateFormat)
|
||||
: null,
|
||||
aadhaarReceiptRemarks: receipt?.remarks || null,
|
||||
aadhaarReceiptTakenOn: receipt ? this.dateUtils.formatDate(new Date(), dateFormat) : null
|
||||
};
|
||||
|
||||
this.kycData = { ...(this.kycData || {}), ...receiptFields };
|
||||
|
||||
const payload: any = {
|
||||
locale: 'en',
|
||||
dateFormat,
|
||||
riskCategory: this.kycForm.value.riskCategory || 1,
|
||||
...receiptFields,
|
||||
...this.buildExceptionFields()
|
||||
};
|
||||
|
||||
this.clientsService
|
||||
.updateClientKyc(this.clientId, this.kycId, payload)
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.saving = false;
|
||||
this.verificationError.aadhaar = null;
|
||||
this.loadKycData();
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.saving = false;
|
||||
// Put the record back so the tab never shows a receipt the server did not accept.
|
||||
this.kycData = { ...(this.kycData || {}), ...previous };
|
||||
this.errorMessage =
|
||||
err?.error?.developerMessage ||
|
||||
err?.error?.defaultUserMessage ||
|
||||
err?.message ||
|
||||
'Failed to save the Aadhaar enrolment receipt.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Fineract serialises dates as `[yyyy, M, d]`; the same field can also arrive as a string. */
|
||||
private parseApiDate(raw: any): Date | null {
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
if (Array.isArray(raw) && raw.length >= 3) {
|
||||
return new Date(raw[0], raw[1] - 1, raw[2]);
|
||||
}
|
||||
const parsed = new Date(raw);
|
||||
return isNaN(parsed.getTime()) ? null : parsed;
|
||||
}
|
||||
|
||||
/** Show Aadhaar input in summary view for re-verification */
|
||||
startAadhaarReVerify(): void {
|
||||
this.showAadhaarReVerify = true;
|
||||
@@ -305,31 +792,33 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
||||
this.aadhaarOtpSent = false;
|
||||
this.aadhaarOtp = '';
|
||||
|
||||
this.clientsService
|
||||
.sendAadhaarOtp(this.clientId, this.kycId, aadhaar)
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: (response: any) => {
|
||||
this.verifying.aadhaar = false;
|
||||
const refNumber =
|
||||
response?.changes?.aadhaarRefNumber ||
|
||||
response?.aadhaarRefNumber ||
|
||||
response?.changes?.referenceNumber ||
|
||||
response?.resourceId;
|
||||
this.aadhaarOtpSent = true;
|
||||
this.aadhaarOtpReferenceId = refNumber || null;
|
||||
this.verificationStatus.aadhaar = 'PENDING';
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.verifying.aadhaar = false;
|
||||
this.verificationStatus.aadhaar = 'FAILED';
|
||||
this.verificationError.aadhaar =
|
||||
err?.error?.developerMessage ||
|
||||
err?.error?.defaultUserMessage ||
|
||||
err?.message ||
|
||||
'Failed to send OTP. Please try again.';
|
||||
}
|
||||
});
|
||||
this.saveKycBeforeVerify(() => {
|
||||
this.clientsService
|
||||
.sendAadhaarOtp(this.clientId, this.kycId, aadhaar)
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: (response: any) => {
|
||||
this.verifying.aadhaar = false;
|
||||
const refNumber =
|
||||
response?.changes?.aadhaarRefNumber ||
|
||||
response?.aadhaarRefNumber ||
|
||||
response?.changes?.referenceNumber ||
|
||||
response?.resourceId;
|
||||
this.aadhaarOtpSent = true;
|
||||
this.aadhaarOtpReferenceId = refNumber || null;
|
||||
this.verificationStatus.aadhaar = 'PENDING';
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.verifying.aadhaar = false;
|
||||
this.verificationStatus.aadhaar = 'FAILED';
|
||||
this.verificationError.aadhaar =
|
||||
err?.error?.developerMessage ||
|
||||
err?.error?.defaultUserMessage ||
|
||||
err?.message ||
|
||||
'Failed to send OTP. Please try again.';
|
||||
}
|
||||
});
|
||||
}, 'aadhaar');
|
||||
}
|
||||
|
||||
/** Step 2: Verify OTP entered by user */
|
||||
@@ -350,7 +839,13 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
||||
this.verificationStatus.aadhaar = 'VERIFIED';
|
||||
this.verificationDetails.aadhaar = response.changes;
|
||||
this.resetOtpState();
|
||||
this.loadKycData();
|
||||
// Aadhaar has been issued and verified, so the enrolment slip has served its purpose —
|
||||
// drop it rather than leave the record claiming the customer is still waiting.
|
||||
if (this.aadhaarReceiptTaken) {
|
||||
this.writeAadhaarReceipt(null);
|
||||
} else {
|
||||
this.loadKycData();
|
||||
}
|
||||
} else {
|
||||
this.verificationStatus.aadhaar = 'FAILED';
|
||||
this.verificationError.aadhaar = 'OTP verification failed.';
|
||||
@@ -380,31 +875,33 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
||||
this.verificationError.gstin = null;
|
||||
this.verificationDetails.gstin = null;
|
||||
|
||||
this.clientsService
|
||||
.verifyGSTIN(this.clientId, this.kycId)
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: (response: any) => {
|
||||
this.verifying.gstin = false;
|
||||
if (response?.changes?.gstinVerified) {
|
||||
this.verificationStatus.gstin = 'VERIFIED';
|
||||
this.verificationDetails.gstin = response.changes;
|
||||
this.loadKycData();
|
||||
} else {
|
||||
this.saveKycBeforeVerify(() => {
|
||||
this.clientsService
|
||||
.verifyGSTIN(this.clientId, this.kycId)
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: (response: any) => {
|
||||
this.verifying.gstin = false;
|
||||
if (response?.changes?.gstinVerified) {
|
||||
this.verificationStatus.gstin = 'VERIFIED';
|
||||
this.verificationDetails.gstin = response.changes;
|
||||
this.loadKycData();
|
||||
} else {
|
||||
this.verificationStatus.gstin = 'FAILED';
|
||||
this.verificationError.gstin = 'GSTIN verification failed.';
|
||||
}
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.verifying.gstin = false;
|
||||
this.verificationStatus.gstin = 'FAILED';
|
||||
this.verificationError.gstin = 'GSTIN verification failed.';
|
||||
this.verificationError.gstin =
|
||||
err?.error?.developerMessage ||
|
||||
err?.error?.defaultUserMessage ||
|
||||
err?.message ||
|
||||
'GSTIN verification failed. Please try again.';
|
||||
}
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.verifying.gstin = false;
|
||||
this.verificationStatus.gstin = 'FAILED';
|
||||
this.verificationError.gstin =
|
||||
err?.error?.developerMessage ||
|
||||
err?.error?.defaultUserMessage ||
|
||||
err?.message ||
|
||||
'GSTIN verification failed. Please try again.';
|
||||
}
|
||||
});
|
||||
});
|
||||
}, 'gstin');
|
||||
}
|
||||
|
||||
formatDate(date: any): string {
|
||||
@@ -551,13 +1048,43 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
||||
|
||||
// PAN
|
||||
y = addLine('PAN Number:', kyc.panNumber || '—', y);
|
||||
y = addLine('PAN Status:', kyc.panVerified ? 'Verified' : 'Pending', y);
|
||||
// "Pending" would misdescribe a PAN that was checked, failed, and was settled by declaration —
|
||||
// nothing is pending on that record, and the certificate has to say what actually happened.
|
||||
y = addLine(
|
||||
'PAN Status:',
|
||||
kyc.panVerified ? 'Verified' : this.panDeclarationTaken ? 'Not Verified — Declaration Taken' : 'Pending',
|
||||
y
|
||||
);
|
||||
if (kyc.panVerifiedOn) y = addLine('PAN Verified On:', this.formatDate(kyc.panVerifiedOn), y);
|
||||
if (!kyc.panVerified && this.panDeclarationTaken) {
|
||||
y = addLine('Declared Mismatch:', this.panDeclarationMismatchLabel, y);
|
||||
if (kyc.panDeclarationNameOnPan) y = addLine('Name on PAN:', kyc.panDeclarationNameOnPan, y);
|
||||
if (kyc.panDeclarationDobOnPan) {
|
||||
y = addLine('DOB on PAN:', this.formatDate(kyc.panDeclarationDobOnPan), y);
|
||||
}
|
||||
if (kyc.panDeclarationTakenOn) {
|
||||
y = addLine('Declaration Taken On:', this.formatDate(kyc.panDeclarationTakenOn), y);
|
||||
}
|
||||
}
|
||||
|
||||
// Aadhaar
|
||||
y = addLine('Aadhaar Number:', kyc.aadhaarNumberMasked || '—', y);
|
||||
y = addLine('Aadhaar Status:', kyc.aadhaarVerified ? 'Verified' : 'Pending', y);
|
||||
// As with PAN: "Pending" would misdescribe a client who holds only an enrolment slip.
|
||||
y = addLine(
|
||||
'Aadhaar Status:',
|
||||
kyc.aadhaarVerified ? 'Verified' : this.aadhaarReceiptTaken ? 'Not Verified — Enrolment Receipt' : 'Pending',
|
||||
y
|
||||
);
|
||||
if (kyc.aadhaarVerifiedOn) y = addLine('Aadhaar Verified On:', this.formatDate(kyc.aadhaarVerifiedOn), y);
|
||||
if (!kyc.aadhaarVerified && this.aadhaarReceiptTaken) {
|
||||
if (kyc.aadhaarEnrolmentId) y = addLine('Enrolment ID:', kyc.aadhaarEnrolmentId, y);
|
||||
if (kyc.aadhaarEnrolmentDate) {
|
||||
y = addLine('Enrolment Date:', this.formatDate(kyc.aadhaarEnrolmentDate), y);
|
||||
}
|
||||
if (kyc.aadhaarReceiptTakenOn) {
|
||||
y = addLine('Receipt Recorded On:', this.formatDate(kyc.aadhaarReceiptTakenOn), y);
|
||||
}
|
||||
}
|
||||
|
||||
// PAN-Aadhaar Link
|
||||
const seedingStatus =
|
||||
|
||||
@@ -98,12 +98,6 @@
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Client ID Column -->
|
||||
<ng-container matColumnDef="accountNumber">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>Client ID</th>
|
||||
<td mat-cell *matCellDef="let row" class="cell-mono">{{ row.id }}</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- CIF Number Column -->
|
||||
<ng-container matColumnDef="cifNumber">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'labels.inputs.CIF Number' | translate }}</th>
|
||||
|
||||
@@ -88,7 +88,6 @@ export class ClientsComponent implements OnInit {
|
||||
|
||||
displayedColumns = [
|
||||
'displayName',
|
||||
'accountNumber',
|
||||
'cifNumber',
|
||||
'legalForm',
|
||||
'status',
|
||||
|
||||
@@ -62,6 +62,7 @@ import { ClientFamilyMembersStepComponent } from './client-stepper/client-family
|
||||
import { ClientPreviewStepComponent } from './client-stepper/client-preview-step/client-preview-step.component';
|
||||
import { ClientAddressStepComponent } from './client-stepper/client-address-step/client-address-step.component';
|
||||
import { ClientFamilyMemberDialogComponent } from './client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component';
|
||||
import { ClientFamilyMemberChildDialogComponent } from './client-stepper/client-family-members-step/client-family-member-child-dialog/client-family-member-child-dialog.component';
|
||||
import { CaptureImageDialogComponent } from './clients-view/custom-dialogs/capture-image-dialog/capture-image-dialog.component';
|
||||
import { AddClientCollateralComponent } from './clients-view/client-actions/add-client-collateral/add-client-collateral.component';
|
||||
import { ClientDatatableStepComponent } from './client-stepper/client-datatable-step/client-datatable-step.component';
|
||||
@@ -127,6 +128,7 @@ import { Form60TabComponent } from './clients-view/form60-tab/form60-tab.compone
|
||||
ClientPreviewStepComponent,
|
||||
ClientAddressStepComponent,
|
||||
ClientFamilyMemberDialogComponent,
|
||||
ClientFamilyMemberChildDialogComponent,
|
||||
CaptureImageDialogComponent,
|
||||
AddClientCollateralComponent,
|
||||
ClientDatatableStepComponent,
|
||||
|
||||
@@ -123,6 +123,20 @@ export class ClientsService {
|
||||
return this.http.post(`/clients/${clientId}/edit/reject`, { rejectReason: rejectReason || '' });
|
||||
}
|
||||
|
||||
/** Fetches the exact email content (subject, body, sentAt, openedAt) for a given pending edit.
|
||||
* Only populated after the Checker has approved and the send succeeded. */
|
||||
getClientEditEmailPreview(clientId: string | number, pendingEditId: string | number) {
|
||||
return this.http.get<{
|
||||
emailSubject: string | null;
|
||||
emailBody: string | null;
|
||||
emailSent: boolean;
|
||||
emailSentAt: string | null;
|
||||
emailOpenedAt: string | null;
|
||||
emailFrom: string | null;
|
||||
emailTo: string | null;
|
||||
}>(`/clients/${clientId}/edit/${pendingEditId}/email-preview`);
|
||||
}
|
||||
|
||||
getClientDataAndTemplate(clientId: string) {
|
||||
const httpParams = new HttpParams().set('template', 'true').set('staffInSelectedOfficeOnly', 'true');
|
||||
return this.http.get(`/clients/${clientId}`, { params: httpParams });
|
||||
@@ -795,6 +809,15 @@ export class ClientsService {
|
||||
return this.http.get<any[]>('/kyc/risk-rules');
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a rule to the AML catalog. Amount/window details in the payload also create the
|
||||
* matching live-transaction rule server side; the response is the persisted rule in the same
|
||||
* shape as {@link getClientRiskRules}.
|
||||
*/
|
||||
createClientRiskRule(rule: any): Observable<any> {
|
||||
return this.http.post('/kyc/risk-rules', rule);
|
||||
}
|
||||
|
||||
/** Fetch code values by code ID */
|
||||
getCodeValuesById(codeId: number): Observable<any[]> {
|
||||
return this.http.get<any[]>(`/codes/${codeId}/codevalues`).pipe(catchError(() => of([])));
|
||||
@@ -958,4 +981,33 @@ export class ClientsService {
|
||||
dispatchWelcomeKit(clientId: string | number, data: any): Observable<any> {
|
||||
return this.http.post(`/clients/${clientId}/welcomekit`, data);
|
||||
}
|
||||
|
||||
updateWelcomeKitStatus(welcomeKitId: number, status: string): Observable<any> {
|
||||
return this.http.put(`/welcomekit/${welcomeKitId}`, { status });
|
||||
}
|
||||
|
||||
getKycExceptions(resolved = false): Observable<any[]> {
|
||||
return this.http.get<any[]>(`/kyc/exceptions?resolved=${resolved}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a KYC-exception outreach attempt against a customer, and — for the SMS, EMAIL and
|
||||
* WHATSAPP channels — actually transmits the notice.
|
||||
*
|
||||
* The destination is resolved server-side from the client record; it is deliberately not part of
|
||||
* this payload, so a tampered request cannot redirect a KYC notice elsewhere.
|
||||
*
|
||||
* Resolves with HTTP 200 even when delivery fails — inspect `deliveryStatus` / `delivered` on the
|
||||
* response rather than assuming success, since the log row is written either way.
|
||||
*/
|
||||
saveKycContactLog(
|
||||
clientId: string | number,
|
||||
data: { channel: string; notes: string; officerName?: string }
|
||||
): Observable<any> {
|
||||
return this.http.post(`/kyc/exceptions/${clientId}/contact-log`, data);
|
||||
}
|
||||
|
||||
getKycContactLog(clientId: string | number): Observable<any[]> {
|
||||
return this.http.get<any[]>(`/kyc/exceptions/${clientId}/contact-log`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,6 +75,8 @@
|
||||
[clientId]="createdClientId"
|
||||
[constitutionMeta]="clientGeneralStep?.selectedConstitution"
|
||||
[entityName]="clientGeneralStep?.createClientForm?.get('fullname')?.value || ''"
|
||||
[verificationName]="clientGeneralStep?.verificationName || ''"
|
||||
[verificationDateOfBirth]="clientGeneralStep?.verificationDateOfBirth || null"
|
||||
[readOnly]="isTabReadOnly(1)"
|
||||
[calculatedRisk]="clientGeneralStep?.calculatedRisk"
|
||||
></mifosx-client-kyc-step>
|
||||
|
||||
@@ -223,6 +223,15 @@ export class CreateClientComponent {
|
||||
// KYC tab — block when the kyc form has unfilled required fields
|
||||
// (kycDocumentName / kycDocumentNumber / pattern errors on PAN / Aadhaar etc.)
|
||||
const kf = this.clientKycStep?.kycForm;
|
||||
if (kf && !kf.valid) {
|
||||
const invalid = [];
|
||||
for (const name in kf.controls) {
|
||||
if (kf.controls[name].invalid) {
|
||||
invalid.push({ name, errors: kf.controls[name].errors, value: kf.controls[name].value });
|
||||
}
|
||||
}
|
||||
console.log('--- KYC Form is INVALID. Offending controls:', invalid);
|
||||
}
|
||||
return !kf || kf.valid;
|
||||
}
|
||||
if (this.legalFormType === 2) {
|
||||
@@ -430,8 +439,22 @@ export class CreateClientComponent {
|
||||
}
|
||||
this.validationError = '';
|
||||
// Save KYC
|
||||
if (this.clientKycStep?.hasKycData && !this.clientKycStep.kycId) {
|
||||
this.clientsService.saveClientKyc(clientId, this.clientKycStep.kycData).subscribe({ error: () => {} });
|
||||
if (this.clientKycStep?.hasKycData) {
|
||||
if (!this.clientKycStep.kycId) {
|
||||
this.clientsService.saveClientKyc(clientId, this.clientKycStep.kycData).subscribe({
|
||||
next: (res: any) => {
|
||||
if (this.clientKycStep) {
|
||||
this.clientKycStep.kycId = res?.resourceId?.toString() || res?.entityId?.toString() || '';
|
||||
}
|
||||
},
|
||||
error: () => {}
|
||||
});
|
||||
} else {
|
||||
const { kycType, ...updatePayload } = this.clientKycStep.kycData;
|
||||
this.clientsService
|
||||
.updateClientKyc(clientId, this.clientKycStep.kycId, updatePayload)
|
||||
.subscribe({ error: () => {} });
|
||||
}
|
||||
}
|
||||
// Upload photo & signature
|
||||
if (this.clientKycStep?.photographFile) {
|
||||
@@ -696,12 +719,20 @@ export class CreateClientComponent {
|
||||
// Persist the KYC document picker selection + uploaded files as a client identifier
|
||||
this.saveKycDocProof(clientId);
|
||||
|
||||
// Save KYC only if it wasn't already created during the KYC step
|
||||
if (this.clientKycStep?.hasKycData && !this.clientKycStep.kycId) {
|
||||
this.clientsService.saveClientKyc(clientId, this.clientKycStep.kycData).subscribe({
|
||||
next: () => navigateToClient(),
|
||||
error: () => navigateToClient()
|
||||
});
|
||||
// Save/update KYC to ensure latest exception fields are persisted
|
||||
if (this.clientKycStep?.hasKycData) {
|
||||
if (!this.clientKycStep.kycId) {
|
||||
this.clientsService.saveClientKyc(clientId, this.clientKycStep.kycData).subscribe({
|
||||
next: () => navigateToClient(),
|
||||
error: () => navigateToClient()
|
||||
});
|
||||
} else {
|
||||
const { kycType, ...updatePayload } = this.clientKycStep.kycData;
|
||||
this.clientsService.updateClientKyc(clientId, this.clientKycStep.kycId, updatePayload).subscribe({
|
||||
next: () => navigateToClient(),
|
||||
error: () => navigateToClient()
|
||||
});
|
||||
}
|
||||
} else {
|
||||
navigateToClient();
|
||||
}
|
||||
@@ -802,6 +833,9 @@ export class CreateClientComponent {
|
||||
* Mirrors identities-tab.createIdentifierWithDocs.
|
||||
*/
|
||||
private saveAddressProof(clientId: string) {
|
||||
if (this.clientIndiaAddressStep?.existingAddressProofIdentifierId) {
|
||||
return;
|
||||
}
|
||||
const payload = this.clientIndiaAddressStep?.addressProofPayload;
|
||||
if (!payload?.proofId) return;
|
||||
|
||||
@@ -817,6 +851,9 @@ export class CreateClientComponent {
|
||||
next: (res: any) => {
|
||||
const identifierId = res?.resourceId;
|
||||
if (!identifierId) return;
|
||||
if (this.clientIndiaAddressStep) {
|
||||
this.clientIndiaAddressStep.existingAddressProofIdentifierId = identifierId.toString();
|
||||
}
|
||||
for (const entry of payload.files) {
|
||||
const formData = new FormData();
|
||||
formData.append('name', `${proofName} — ${entry.slot}`);
|
||||
@@ -833,6 +870,9 @@ export class CreateClientComponent {
|
||||
* + upload each selected file as a document attached to that identifier.
|
||||
*/
|
||||
private saveKycDocProof(clientId: string) {
|
||||
if (this.clientKycStep?.existingKycIdentifierId) {
|
||||
return;
|
||||
}
|
||||
const payload = this.clientKycStep?.kycDocPayload;
|
||||
if (!payload?.proofId || !payload?.files?.length) return;
|
||||
|
||||
@@ -860,6 +900,9 @@ export class CreateClientComponent {
|
||||
next: (res: any) => {
|
||||
const identifierId = res?.resourceId;
|
||||
if (!identifierId) return;
|
||||
if (this.clientKycStep) {
|
||||
this.clientKycStep.existingKycIdentifierId = identifierId.toString();
|
||||
}
|
||||
for (const entry of payload.files) {
|
||||
const formData = new FormData();
|
||||
formData.append('name', `${proofName} — ${entry.slot}`);
|
||||
@@ -884,8 +927,45 @@ export class CreateClientComponent {
|
||||
return this.clientsService.getKycProofTypes('id').pipe(
|
||||
map((res: any) => {
|
||||
const list: any[] = res?.idProofs || [];
|
||||
const targetName = (proofName || '').trim().toLowerCase();
|
||||
const match = list.find((p: any) => (p?.name || '').trim().toLowerCase() === targetName);
|
||||
const cleanStr = (s: string) => (s || '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
const target = cleanStr(proofName || proofId);
|
||||
|
||||
// 1. Try exact code/name match
|
||||
let match = list.find((p: any) => {
|
||||
const pcode = cleanStr(p.code);
|
||||
const pname = cleanStr(p.name);
|
||||
return pcode === target || pname === target;
|
||||
});
|
||||
|
||||
// 2. Try loose contains/includes match
|
||||
if (!match) {
|
||||
match = list.find((p: any) => {
|
||||
const pcode = cleanStr(p.code);
|
||||
const pname = cleanStr(p.name);
|
||||
return pcode.includes(target) || pname.includes(target) || target.includes(pcode) || target.includes(pname);
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Specific fallbacks for common keywords
|
||||
if (!match && target.includes('voter')) {
|
||||
match = list.find((p: any) => {
|
||||
const name = cleanStr(p.name);
|
||||
return name.includes('voter') || name.includes('epic');
|
||||
});
|
||||
}
|
||||
if (!match && target.includes('aadhaar')) {
|
||||
match = list.find((p: any) => cleanStr(p.name).includes('aadhaar'));
|
||||
}
|
||||
if (!match && target.includes('passport')) {
|
||||
match = list.find((p: any) => cleanStr(p.name).includes('passport'));
|
||||
}
|
||||
if (!match && target.includes('driving')) {
|
||||
match = list.find((p: any) => cleanStr(p.name).includes('drive') || cleanStr(p.name).includes('licen'));
|
||||
}
|
||||
if (!match && target.includes('pan')) {
|
||||
match = list.find((p: any) => cleanStr(p.name).includes('pan'));
|
||||
}
|
||||
|
||||
return (match?.id ?? null) as number | null;
|
||||
})
|
||||
);
|
||||
|
||||
@@ -84,14 +84,70 @@
|
||||
<mat-icon>phone_iphone</mat-icon>
|
||||
<span>Mobile Number</span>
|
||||
</div>
|
||||
<div class="contact-verify-panel__exception-row" style="margin-bottom: 12px">
|
||||
<mat-checkbox
|
||||
[checked]="oldMobileNotAccessible"
|
||||
(change)="onOldMobileNotAccessibleToggle($event.checked)"
|
||||
color="primary"
|
||||
>
|
||||
Old mobile number not accessible (lost / deactivated SIM)
|
||||
</mat-checkbox>
|
||||
</div>
|
||||
<div class="contact-verify-panel__grid">
|
||||
<mifosx-client-contact-otp-block
|
||||
[clientId]="clientId"
|
||||
channel="MOBILE"
|
||||
[destination]="contactChanges.oldMobile"
|
||||
label="Old Mobile Number"
|
||||
(stateChange)="onOldMobileOtpState($event)"
|
||||
></mifosx-client-contact-otp-block>
|
||||
@if (!oldMobileNotAccessible) {
|
||||
<mifosx-client-contact-otp-block
|
||||
[clientId]="clientId"
|
||||
channel="MOBILE"
|
||||
[destination]="contactChanges.oldMobile"
|
||||
label="Old Mobile Number"
|
||||
(stateChange)="onOldMobileOtpState($event)"
|
||||
></mifosx-client-contact-otp-block>
|
||||
} @else {
|
||||
<div
|
||||
class="fir-upload-box"
|
||||
style="padding: 12px; border: 1px dashed #cbd5e1; border-radius: 8px; background: #f8fafc"
|
||||
>
|
||||
<div style="font-weight: 500; font-size: 13px; margin-bottom: 6px; color: #475569">
|
||||
Upload Police Complaint / FIR Copy for Lost Mobile
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 12px">
|
||||
<input
|
||||
type="file"
|
||||
(change)="onFirFileSelected($event)"
|
||||
accept=".pdf,.jpg,.jpeg,.png"
|
||||
#firFileInput
|
||||
hidden
|
||||
/>
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
(click)="firFileInput.click()"
|
||||
[disabled]="uploadingFir"
|
||||
>
|
||||
<mat-icon>upload_file</mat-icon>
|
||||
{{ firFileName ? firFileName : 'Select FIR Document' }}
|
||||
</button>
|
||||
@if (uploadingFir) {
|
||||
<mat-progress-spinner mode="indeterminate" diameter="20"></mat-progress-spinner>
|
||||
}
|
||||
@if (firDocumentId && !uploadingFir) {
|
||||
<span
|
||||
style="
|
||||
color: #16a34a;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
"
|
||||
>
|
||||
<mat-icon style="font-size: 16px; width: 16px; height: 16px">check_circle</mat-icon> FIR
|
||||
Uploaded Successfully
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<mifosx-client-contact-otp-block
|
||||
[clientId]="clientId"
|
||||
channel="MOBILE"
|
||||
@@ -164,6 +220,8 @@
|
||||
[isNonIndividual]="legalFormType === 2"
|
||||
[isMinor]="clientGeneralStep?.isMinor || false"
|
||||
[clientId]="clientId"
|
||||
[verificationName]="clientGeneralStep?.verificationName || ''"
|
||||
[verificationDateOfBirth]="clientGeneralStep?.verificationDateOfBirth || null"
|
||||
[readOnly]="isTabReadOnly(1)"
|
||||
[calculatedRisk]="clientGeneralStep?.calculatedRisk"
|
||||
></mifosx-client-kyc-step>
|
||||
@@ -286,7 +344,6 @@
|
||||
[degreeQualificationOptions]="clientGeneralStep?.degreeQualificationOptions || []"
|
||||
[religionOptions]="clientGeneralStep?.religionOptions || []"
|
||||
[socialCategoryOptions]="clientGeneralStep?.socialCategoryOptions || []"
|
||||
(submitEvent)="submit()"
|
||||
></mifosx-client-preview-step>
|
||||
</div>
|
||||
</mat-tab>
|
||||
@@ -294,9 +351,11 @@
|
||||
|
||||
<!-- Bottom Action Bar -->
|
||||
<div class="action-bar">
|
||||
<button mat-raised-button [routerLink]="['../']" class="cancel-btn">
|
||||
{{ 'labels.buttons.Cancel' | translate }}
|
||||
</button>
|
||||
@if (selectedTabIndex < totalTabs - 1) {
|
||||
<button mat-raised-button [routerLink]="['../']" class="cancel-btn">
|
||||
{{ 'labels.buttons.Cancel' | translate }}
|
||||
</button>
|
||||
}
|
||||
<div class="action-bar-right">
|
||||
@if (selectedTabIndex > 0) {
|
||||
<button mat-raised-button (click)="selectedTabIndex = selectedTabIndex - 1">
|
||||
@@ -305,15 +364,7 @@
|
||||
</button>
|
||||
}
|
||||
@if (selectedTabIndex < totalTabs - 1) {
|
||||
<button
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
(click)="onNext()"
|
||||
[disabled]="!currentStepValid"
|
||||
[matTooltip]="currentStepBlockerMessage"
|
||||
[matTooltipDisabled]="currentStepValid"
|
||||
matTooltipPosition="above"
|
||||
>
|
||||
<button mat-raised-button color="primary" (click)="onNext()" [disabled]="savingClient || !currentStepValid">
|
||||
@if (savingClient) {
|
||||
<mat-progress-spinner diameter="18" mode="indeterminate" class="m-r-10"></mat-progress-spinner>
|
||||
Saving...
|
||||
@@ -327,15 +378,10 @@
|
||||
}
|
||||
</button>
|
||||
}
|
||||
@if (selectedTabIndex === totalTabs - 1 && areFormvalids()) {
|
||||
<button mat-raised-button color="primary" (click)="submit()" [disabled]="savingClient">
|
||||
@if (savingClient) {
|
||||
<mat-progress-spinner diameter="18" mode="indeterminate" class="m-r-10"></mat-progress-spinner>
|
||||
Saving...
|
||||
} @else {
|
||||
<fa-icon icon="check" class="m-r-10"></fa-icon>
|
||||
{{ 'labels.buttons.Submit' | translate }}
|
||||
}
|
||||
@if (selectedTabIndex === totalTabs - 1) {
|
||||
<button mat-raised-button color="primary" [routerLink]="['../']">
|
||||
<fa-icon icon="check" class="m-r-10"></fa-icon>
|
||||
Finish
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -33,6 +33,7 @@ import { ContactOtpBlockComponent, ContactOtpState } from './contact-otp-block/c
|
||||
|
||||
import { MatTabsModule } from '@angular/material/tabs';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatDivider } from '@angular/material/divider';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
||||
@@ -51,6 +52,7 @@ import { ORIGINAL_VALUES_PAYLOAD_KEY, buildOriginalValuesSnapshot } from '../sha
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
MatTabsModule,
|
||||
MatIconModule,
|
||||
MatCheckboxModule,
|
||||
MatDivider,
|
||||
FaIconComponent,
|
||||
MatProgressSpinner,
|
||||
@@ -108,6 +110,13 @@ export class EditClientComponent {
|
||||
newMobileOtpId: number | null = null;
|
||||
oldMobileVerified = false;
|
||||
newMobileVerified = false;
|
||||
|
||||
// FIR exception flow properties for inaccessible old mobile number
|
||||
oldMobileNotAccessible = false;
|
||||
firFileName = '';
|
||||
firDocumentId: number | null = null;
|
||||
uploadingFir = false;
|
||||
|
||||
oldEmailOtpId: number | null = null;
|
||||
newEmailOtpId: number | null = null;
|
||||
oldEmailVerified = false;
|
||||
@@ -215,11 +224,15 @@ export class EditClientComponent {
|
||||
return this.detectSensitiveFieldChanges(this.clientDataAndTemplate);
|
||||
}
|
||||
|
||||
/** Whether every OTP block required by the fields that actually changed has been verified. */
|
||||
/** Whether every OTP block required by the fields that actually changed has been verified
|
||||
* (or the FIR exception has been uploaded in place of old-mobile OTP verification). */
|
||||
get contactVerificationComplete(): boolean {
|
||||
const changes = this.contactChanges;
|
||||
if (changes.mobileChanged && !(this.oldMobileVerified && this.newMobileVerified)) {
|
||||
return false;
|
||||
if (changes.mobileChanged) {
|
||||
const isOldSatisfied = this.oldMobileNotAccessible ? this.firDocumentId != null : this.oldMobileVerified;
|
||||
if (!isOldSatisfied || !this.newMobileVerified) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (changes.emailChanged && !(this.oldEmailVerified && this.newEmailVerified)) {
|
||||
return false;
|
||||
@@ -227,6 +240,43 @@ export class EditClientComponent {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Toggle the "old mobile not accessible" exception — clears any in-progress old-mobile OTP. */
|
||||
onOldMobileNotAccessibleToggle(checked?: boolean): void {
|
||||
if (checked !== undefined) {
|
||||
this.oldMobileNotAccessible = checked;
|
||||
}
|
||||
if (this.oldMobileNotAccessible) {
|
||||
this.oldMobileOtpId = null;
|
||||
this.oldMobileVerified = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Upload the FIR / police-complaint copy that substitutes for old-mobile OTP verification. */
|
||||
onFirFileSelected(event: any): void {
|
||||
const file: File = event.target?.files?.[0];
|
||||
if (!file) return;
|
||||
this.firFileName = file.name;
|
||||
this.uploadingFir = true;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('name', 'FIR - Lost Mobile Number');
|
||||
formData.append('description', 'FIR police complaint copy for lost mobile number verification exception');
|
||||
|
||||
this.clientsService.uploadClientDocument(this.clientId, formData).subscribe({
|
||||
next: (res: any) => {
|
||||
this.uploadingFir = false;
|
||||
this.firDocumentId = res?.resourceId || res?.id || null;
|
||||
this.snackBar.open('FIR document uploaded successfully', 'Close', { duration: 3000 });
|
||||
},
|
||||
error: () => {
|
||||
this.uploadingFir = false;
|
||||
this.firDocumentId = null;
|
||||
this.snackBar.open('Failed to upload FIR document', 'Close', { duration: 4000 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onOldMobileOtpState(state: ContactOtpState): void {
|
||||
this.oldMobileOtpId = state.otpId;
|
||||
this.oldMobileVerified = state.verified;
|
||||
@@ -254,6 +304,10 @@ export class EditClientComponent {
|
||||
this.newMobileOtpId = null;
|
||||
this.oldMobileVerified = false;
|
||||
this.newMobileVerified = false;
|
||||
this.oldMobileNotAccessible = false;
|
||||
this.firFileName = '';
|
||||
this.firDocumentId = null;
|
||||
this.uploadingFir = false;
|
||||
this.oldEmailOtpId = null;
|
||||
this.newEmailOtpId = null;
|
||||
this.oldEmailVerified = false;
|
||||
@@ -439,7 +493,11 @@ export class EditClientComponent {
|
||||
// email's when BOTH changed in the same save. See the class-level JSDoc above the OTP
|
||||
// state fields for the full naming rationale.
|
||||
if (changedFields.mobileChanged) {
|
||||
clientData.mobileOldOtpId = this.oldMobileOtpId;
|
||||
if (this.oldMobileNotAccessible && this.firDocumentId) {
|
||||
(clientData as any).oldMobileVerificationExceptionDocumentId = this.firDocumentId;
|
||||
} else {
|
||||
clientData.mobileOldOtpId = this.oldMobileOtpId;
|
||||
}
|
||||
clientData.mobileNewOtpId = this.newMobileOtpId;
|
||||
}
|
||||
if (changedFields.emailChanged) {
|
||||
@@ -630,16 +688,36 @@ export class EditClientComponent {
|
||||
|
||||
private detectSensitiveFieldChanges(originalData: any) {
|
||||
const form = this.clientGeneralForm;
|
||||
if (!form || !originalData) {
|
||||
return {
|
||||
nameChanged: false,
|
||||
mobileChanged: false,
|
||||
emailChanged: false,
|
||||
dobChanged: false,
|
||||
oldName: '',
|
||||
newName: '',
|
||||
oldMobile: '',
|
||||
newMobile: '',
|
||||
oldEmail: '',
|
||||
newEmail: '',
|
||||
oldDob: '',
|
||||
newDob: ''
|
||||
};
|
||||
}
|
||||
|
||||
const mobileControl = form.get('mobileNo');
|
||||
const emailControl = form.get('emailAddress');
|
||||
|
||||
// Trimmed so browser autofill adding invisible leading/trailing whitespace never reads as
|
||||
// a "change" — the masked preview shown in the Verify Contact Change panel only reveals the
|
||||
// first couple of characters, so a whitespace-only difference is otherwise invisible to the
|
||||
// user and just looks like the panel appeared for no reason. These trimmed-but-original-case
|
||||
// values are what get returned/sent as the actual OTP destination and saved value — only the
|
||||
// *comparison* (below) is further case-normalized for email, so we never silently rewrite the
|
||||
// customer's email casing into the saved record or the OTP destination.
|
||||
const newMobile = (form?.get('mobileNo')?.value || '').trim();
|
||||
const newEmail = (form?.get('emailAddress')?.value || '').trim();
|
||||
const newDobRaw = form?.get('dateOfBirth')?.value;
|
||||
// *comparison* (below) is further normalized for mobile/email, so we never silently rewrite the
|
||||
// customer's saved value or the OTP destination.
|
||||
const newMobile = (mobileControl?.value || '').toString().trim();
|
||||
const newEmail = (emailControl?.value || '').toString().trim();
|
||||
const newDobRaw = form.get('dateOfBirth')?.value;
|
||||
// Local calendar date, NOT `.toISOString()` — that converts to UTC first, which silently
|
||||
// shifts the date by a day in any positive-UTC-offset timezone (e.g. India, UTC+5:30:
|
||||
// midnight local on the 15th becomes 18:30 UTC on the 14th). That made this comparison
|
||||
@@ -647,8 +725,8 @@ export class EditClientComponent {
|
||||
// touched the date field, purely because the Date object round-tripped through the picker.
|
||||
const newDob = newDobRaw instanceof Date ? this.toLocalDateString(newDobRaw) : newDobRaw || '';
|
||||
|
||||
const oldMobile = (originalData.mobileNo || '').trim();
|
||||
const oldEmail = (originalData.emailAddress || '').trim();
|
||||
const oldMobile = (originalData.mobileNo || '').toString().trim();
|
||||
const oldEmail = (originalData.emailAddress || '').toString().trim();
|
||||
const oldDobRaw = originalData.dateOfBirth;
|
||||
const oldDob = Array.isArray(oldDobRaw)
|
||||
? `${oldDobRaw[0]}-${String(oldDobRaw[1]).padStart(2, '0')}-${String(oldDobRaw[2]).padStart(2, '0')}`
|
||||
@@ -681,12 +759,25 @@ export class EditClientComponent {
|
||||
fullname: originalData.fullname || ''
|
||||
});
|
||||
|
||||
// Normalize phone numbers by stripping non-digit characters (+91 9009009009 -> 9009009009)
|
||||
const normPhone = (val: string) => val.replace(/\D/g, '');
|
||||
const newMobileNorm = normPhone(newMobile);
|
||||
const oldMobileNorm = normPhone(oldMobile);
|
||||
|
||||
// Mobile/Email are considered changed ONLY when the control has been edited (dirty) AND
|
||||
// normalized values differ — otherwise a re-render/reformat of the control by the framework
|
||||
// (never actually touched by the user) would falsely surface the dual-OTP verification panel.
|
||||
const mobileChanged =
|
||||
!!oldMobileNorm && !!newMobileNorm && !!mobileControl?.dirty && newMobileNorm !== oldMobileNorm;
|
||||
// Case-insensitive: emails are not case-sensitive in practice, and comparing raw case
|
||||
// would flag a change (surfacing the dual-OTP panel) even when nothing meaningful changed.
|
||||
const emailChanged =
|
||||
!!oldEmail && !!newEmail && !!emailControl?.dirty && newEmail.toLowerCase() !== oldEmail.toLowerCase();
|
||||
|
||||
return {
|
||||
nameChanged: newName !== oldName,
|
||||
mobileChanged: newMobile !== oldMobile,
|
||||
// Case-insensitive: emails are not case-sensitive in practice, and comparing raw case
|
||||
// would flag a change (surfacing the dual-OTP panel) even when nothing meaningful changed.
|
||||
emailChanged: newEmail.toLowerCase() !== oldEmail.toLowerCase(),
|
||||
mobileChanged,
|
||||
emailChanged,
|
||||
dobChanged: newDob !== oldDob,
|
||||
oldName,
|
||||
newName,
|
||||
@@ -930,6 +1021,9 @@ export class EditClientComponent {
|
||||
* Mirrors identities-tab.createIdentifierWithDocs.
|
||||
*/
|
||||
private saveAddressProof(clientId: string) {
|
||||
if (this.clientIndiaAddressStep?.existingAddressProofIdentifierId) {
|
||||
return;
|
||||
}
|
||||
const payload = this.clientIndiaAddressStep?.addressProofPayload;
|
||||
if (!payload?.proofId) return;
|
||||
|
||||
@@ -945,6 +1039,9 @@ export class EditClientComponent {
|
||||
next: (res: any) => {
|
||||
const identifierId = res?.resourceId;
|
||||
if (!identifierId) return;
|
||||
if (this.clientIndiaAddressStep) {
|
||||
this.clientIndiaAddressStep.existingAddressProofIdentifierId = identifierId.toString();
|
||||
}
|
||||
for (const entry of payload.files) {
|
||||
const formData = new FormData();
|
||||
formData.append('name', `${proofName} — ${entry.slot}`);
|
||||
@@ -965,6 +1062,21 @@ export class EditClientComponent {
|
||||
if (!payload?.proofId || !payload?.files?.length) return;
|
||||
|
||||
const proofName = payload.proof?.name || 'KYC Document';
|
||||
|
||||
const uploadFiles = (identifierId: string) => {
|
||||
for (const entry of payload.files) {
|
||||
const formData = new FormData();
|
||||
formData.append('name', `${proofName} — ${entry.slot}`);
|
||||
formData.append('file', entry.file);
|
||||
this.clientsService.uploadClientIdentifierDocument(identifierId, formData).subscribe({ error: () => {} });
|
||||
}
|
||||
};
|
||||
|
||||
if (this.clientKycStep?.existingKycIdentifierId) {
|
||||
uploadFiles(this.clientKycStep.existingKycIdentifierId);
|
||||
return;
|
||||
}
|
||||
|
||||
// The KYC step's chip-picker carries `id` as a doc-type CODE string (e.g. "AADHAAR").
|
||||
// The backend ClientIdentifierRequest.documentTypeId is a Long, so a string proofId
|
||||
// trips Jackson → HttpMessageNotReadableException → 400. Resolve to numeric CodeValue
|
||||
@@ -984,12 +1096,10 @@ export class EditClientComponent {
|
||||
next: (res: any) => {
|
||||
const identifierId = res?.resourceId;
|
||||
if (!identifierId) return;
|
||||
for (const entry of payload.files) {
|
||||
const formData = new FormData();
|
||||
formData.append('name', `${proofName} — ${entry.slot}`);
|
||||
formData.append('file', entry.file);
|
||||
this.clientsService.uploadClientIdentifierDocument(identifierId, formData).subscribe({ error: () => {} });
|
||||
if (this.clientKycStep) {
|
||||
this.clientKycStep.existingKycIdentifierId = identifierId.toString();
|
||||
}
|
||||
uploadFiles(identifierId.toString());
|
||||
},
|
||||
error: () => {}
|
||||
});
|
||||
@@ -1007,8 +1117,45 @@ export class EditClientComponent {
|
||||
return this.clientsService.getKycProofTypes('id').pipe(
|
||||
map((res: any) => {
|
||||
const list: any[] = res?.idProofs || [];
|
||||
const targetName = (proofName || '').trim().toLowerCase();
|
||||
const match = list.find((p: any) => (p?.name || '').trim().toLowerCase() === targetName);
|
||||
const cleanStr = (s: string) => (s || '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
const target = cleanStr(proofName || proofId);
|
||||
|
||||
// 1. Try exact code/name match
|
||||
let match = list.find((p: any) => {
|
||||
const pcode = cleanStr(p.code);
|
||||
const pname = cleanStr(p.name);
|
||||
return pcode === target || pname === target;
|
||||
});
|
||||
|
||||
// 2. Try loose contains/includes match
|
||||
if (!match) {
|
||||
match = list.find((p: any) => {
|
||||
const pcode = cleanStr(p.code);
|
||||
const pname = cleanStr(p.name);
|
||||
return pcode.includes(target) || pname.includes(target) || target.includes(pcode) || target.includes(pname);
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Specific fallbacks for common keywords
|
||||
if (!match && target.includes('voter')) {
|
||||
match = list.find((p: any) => {
|
||||
const name = cleanStr(p.name);
|
||||
return name.includes('voter') || name.includes('epic');
|
||||
});
|
||||
}
|
||||
if (!match && target.includes('aadhaar')) {
|
||||
match = list.find((p: any) => cleanStr(p.name).includes('aadhaar'));
|
||||
}
|
||||
if (!match && target.includes('passport')) {
|
||||
match = list.find((p: any) => cleanStr(p.name).includes('passport'));
|
||||
}
|
||||
if (!match && target.includes('driving')) {
|
||||
match = list.find((p: any) => cleanStr(p.name).includes('drive') || cleanStr(p.name).includes('licen'));
|
||||
}
|
||||
if (!match && target.includes('pan')) {
|
||||
match = list.find((p: any) => cleanStr(p.name).includes('pan'));
|
||||
}
|
||||
|
||||
return (match?.id ?? null) as number | null;
|
||||
})
|
||||
);
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* 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 {
|
||||
deriveKycDocumentRenewal,
|
||||
kycDocumentNeedsAttention,
|
||||
KYC_DOCUMENT_EXPIRY_WARNING_DAYS,
|
||||
KYC_DOCUMENT_RENEWAL_YEARS
|
||||
} from './kyc-document-renewal.util';
|
||||
|
||||
const TODAY = new Date(2026, 7, 7); // 07 Aug 2026
|
||||
|
||||
const at = (y: number, m: number, d: number) => new Date(y, m - 1, d);
|
||||
|
||||
const check = (issueDate: Date | null, expiryDate: Date | null = null, today: Date = TODAY) =>
|
||||
deriveKycDocumentRenewal({ issueDate, expiryDate, today });
|
||||
|
||||
describe('deriveKycDocumentRenewal', () => {
|
||||
describe('the five-year rule', () => {
|
||||
it('says nothing about a document with neither date captured', () => {
|
||||
const result = check(null, null);
|
||||
expect(result.state).toBe('UNKNOWN');
|
||||
expect(result.dueOn).toBeNull();
|
||||
expect(result.daysRemaining).toBeNull();
|
||||
});
|
||||
|
||||
it('leaves a recently issued document alone', () => {
|
||||
const result = check(at(2025, 1, 15));
|
||||
expect(result.state).toBe('OK');
|
||||
expect(result.reason).toBe('AGE');
|
||||
});
|
||||
|
||||
it('falls due exactly five years after issue', () => {
|
||||
const result = check(at(2021, 8, 7));
|
||||
expect(result.dueOn).toEqual(at(2026, 8, 7));
|
||||
expect(result.daysRemaining).toBe(0);
|
||||
// Due today is not yet past — the branch still has the day to act.
|
||||
expect(result.state).toBe('OK');
|
||||
});
|
||||
|
||||
it('becomes DUE the day after the fifth anniversary', () => {
|
||||
const result = check(at(2021, 8, 6));
|
||||
expect(result.state).toBe('DUE');
|
||||
expect(result.reason).toBe('AGE');
|
||||
});
|
||||
|
||||
it('is DUE, not EXPIRED, once past five years — the document is still valid, just stale', () => {
|
||||
const result = check(at(2020, 3, 10));
|
||||
expect(result.state).toBe('DUE');
|
||||
expect(result.reason).toBe('AGE');
|
||||
expect(result.daysRemaining).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it('does not warn ahead of the five-year mark — a document one day short is entirely fine', () => {
|
||||
// Due tomorrow on age alone, and still OK: only expiry gets an advance warning.
|
||||
const result = check(at(2021, 8, 8));
|
||||
expect(result.daysRemaining).toBe(1);
|
||||
expect(result.state).toBe('OK');
|
||||
});
|
||||
});
|
||||
|
||||
describe('an explicit expiry date', () => {
|
||||
it('warns seven days before expiry', () => {
|
||||
const result = check(at(2025, 6, 1), at(2026, 8, 14));
|
||||
expect(result.reason).toBe('EXPIRY');
|
||||
expect(result.daysRemaining).toBe(7);
|
||||
expect(result.state).toBe('EXPIRING_SOON');
|
||||
});
|
||||
|
||||
it('is still OK eight days out', () => {
|
||||
expect(check(at(2025, 6, 1), at(2026, 8, 15)).state).toBe('OK');
|
||||
});
|
||||
|
||||
it('warns on the day of expiry itself', () => {
|
||||
const result = check(at(2025, 6, 1), at(2026, 8, 7));
|
||||
expect(result.daysRemaining).toBe(0);
|
||||
expect(result.state).toBe('EXPIRING_SOON');
|
||||
});
|
||||
|
||||
it('marks a passed expiry as EXPIRED — no longer a valid OVD', () => {
|
||||
const result = check(at(2024, 1, 1), at(2026, 1, 1));
|
||||
expect(result.state).toBe('EXPIRED');
|
||||
expect(result.reason).toBe('EXPIRY');
|
||||
});
|
||||
|
||||
it('governs when it falls before the five-year mark', () => {
|
||||
// Issued last year, so the age clock is years away — expiry is what matters.
|
||||
const result = check(at(2025, 6, 1), at(2026, 10, 1));
|
||||
expect(result.reason).toBe('EXPIRY');
|
||||
expect(result.dueOn).toEqual(at(2026, 10, 1));
|
||||
});
|
||||
|
||||
it('yields to the five-year rule when it falls later', () => {
|
||||
// A passport issued in 2019 expiring in 2029 is stale long before it expires.
|
||||
const result = check(at(2019, 5, 20), at(2029, 5, 20));
|
||||
expect(result.reason).toBe('AGE');
|
||||
expect(result.dueOn).toEqual(at(2024, 5, 20));
|
||||
expect(result.state).toBe('DUE');
|
||||
});
|
||||
|
||||
it('is judged on expiry alone when no issue date was captured', () => {
|
||||
const result = check(null, at(2027, 1, 1));
|
||||
expect(result.reason).toBe('EXPIRY');
|
||||
expect(result.state).toBe('OK');
|
||||
});
|
||||
});
|
||||
|
||||
describe('leap-year issue dates', () => {
|
||||
it('pins a 29 February issue to 28 February, not 1 March', () => {
|
||||
// 2020-02-29 + 5 years has no real anniversary; rolling into March would push the
|
||||
// document into the wrong month.
|
||||
const result = check(at(2020, 2, 29));
|
||||
expect(result.dueOn).toEqual(at(2025, 2, 28));
|
||||
});
|
||||
});
|
||||
|
||||
describe('kycDocumentNeedsAttention', () => {
|
||||
it('flags only the states staff must act on', () => {
|
||||
expect(kycDocumentNeedsAttention('UNKNOWN')).toBe(false);
|
||||
expect(kycDocumentNeedsAttention('OK')).toBe(false);
|
||||
expect(kycDocumentNeedsAttention('EXPIRING_SOON')).toBe(true);
|
||||
expect(kycDocumentNeedsAttention('EXPIRED')).toBe(true);
|
||||
expect(kycDocumentNeedsAttention('DUE')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('uses a five-year age window and a seven-day expiry warning', () => {
|
||||
expect(KYC_DOCUMENT_RENEWAL_YEARS).toBe(5);
|
||||
expect(KYC_DOCUMENT_EXPIRY_WARNING_DAYS).toBe(7);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Decides when the officially valid document (OVD) captured at KYC needs updating.
|
||||
*
|
||||
* The KYC step records a document type, its number, an issue date and — for documents that carry
|
||||
* one — an expiry date. Nothing has ever read those dates back, so a client onboarded on a
|
||||
* ten-year-old driving licence looked exactly like one onboarded yesterday. This module turns the
|
||||
* two dates into a single answer to "does this document need updating?", so the KYC tab can say so
|
||||
* rather than leaving staff to work it out from a date on a form.
|
||||
*
|
||||
* Two separate clocks run, and they warn differently because they mean different things:
|
||||
*
|
||||
* - **Age.** Five years after issue the document is stale and must be re-collected. It is still a
|
||||
* valid document, so there is nothing to pre-empt — the alert appears once the date passes.
|
||||
* - **Expiry.** A printed expiry date is a hard stop: the day after, the document is not a valid
|
||||
* OVD at all and the account is out of compliance. That one is worth heading off, so it warns
|
||||
* seven days ahead while the customer can still be asked to bring the renewed document in.
|
||||
*/
|
||||
|
||||
/** A document is treated as due for refresh five years after it was issued. */
|
||||
export const KYC_DOCUMENT_RENEWAL_YEARS = 5;
|
||||
|
||||
/** How many days before a printed expiry date the "expires soon" warning starts. */
|
||||
export const KYC_DOCUMENT_EXPIRY_WARNING_DAYS = 7;
|
||||
|
||||
export type KycDocumentRenewalState =
|
||||
/** No dates captured — nothing can be said about this document. */
|
||||
| 'UNKNOWN'
|
||||
/** Within its life, and not close to either threshold. */
|
||||
| 'OK'
|
||||
/** Printed expiry date falls within the next seven days. */
|
||||
| 'EXPIRING_SOON'
|
||||
/** Past its printed expiry date: no longer a valid OVD. */
|
||||
| 'EXPIRED'
|
||||
/** Past the five-year mark: still a valid document, but must be re-collected. */
|
||||
| 'DUE';
|
||||
|
||||
/** Which of the two clocks decided the outcome. */
|
||||
export type KycDocumentRenewalReason = 'EXPIRY' | 'AGE' | null;
|
||||
|
||||
export interface KycDocumentRenewalInput {
|
||||
/** Issue date printed on the document. */
|
||||
issueDate: Date | null;
|
||||
/** Expiry date printed on the document — only some carry one (passport, DL, visa). */
|
||||
expiryDate: Date | null;
|
||||
/** Today, injected so the calculation is testable and matches the tenant's date. */
|
||||
today: Date;
|
||||
}
|
||||
|
||||
export interface KycDocumentRenewalStatus {
|
||||
state: KycDocumentRenewalState;
|
||||
/** The date the document falls due — the earlier of its expiry and its fifth anniversary. */
|
||||
dueOn: Date | null;
|
||||
/** Days until `dueOn`; negative once it has passed. Null when nothing can be judged. */
|
||||
daysRemaining: number | null;
|
||||
reason: KycDocumentRenewalReason;
|
||||
}
|
||||
|
||||
/** Midnight-aligned, so a document due today is not counted as a fraction of a day overdue. */
|
||||
function startOfDay(date: Date): Date {
|
||||
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds whole years, clamping to the end of the month.
|
||||
*
|
||||
* A 29 February issue date has no fifth anniversary. Letting the Date constructor roll it into
|
||||
* 1 March would push the document into the wrong month, so it is pinned to 28 February instead.
|
||||
*/
|
||||
function addYears(date: Date, years: number): Date {
|
||||
const target = new Date(date.getFullYear() + years, date.getMonth(), date.getDate());
|
||||
if (target.getMonth() !== date.getMonth()) {
|
||||
return new Date(target.getFullYear(), target.getMonth(), 0);
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
function daysBetween(from: Date, to: Date): number {
|
||||
return Math.round((to.getTime() - from.getTime()) / MS_PER_DAY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Works out whether the captured document needs updating.
|
||||
*
|
||||
* The two dates are not alternatives — whichever falls first governs. A passport issued last year
|
||||
* but expiring in six months is due in six months, not in four years: treating the five-year rule
|
||||
* as the only clock would leave an expired passport on file looking current. A document with no
|
||||
* printed expiry is judged purely on age.
|
||||
*/
|
||||
export function deriveKycDocumentRenewal(input: KycDocumentRenewalInput): KycDocumentRenewalStatus {
|
||||
const today = startOfDay(input.today);
|
||||
const issue = input.issueDate ? startOfDay(input.issueDate) : null;
|
||||
const expiry = input.expiryDate ? startOfDay(input.expiryDate) : null;
|
||||
|
||||
if (!issue && !expiry) {
|
||||
return { state: 'UNKNOWN', dueOn: null, daysRemaining: null, reason: null };
|
||||
}
|
||||
|
||||
const ageDue = issue ? addYears(issue, KYC_DOCUMENT_RENEWAL_YEARS) : null;
|
||||
|
||||
// Whichever comes first governs — see the doc comment above.
|
||||
let dueOn: Date;
|
||||
let reason: KycDocumentRenewalReason;
|
||||
if (expiry && ageDue) {
|
||||
if (expiry.getTime() <= ageDue.getTime()) {
|
||||
dueOn = expiry;
|
||||
reason = 'EXPIRY';
|
||||
} else {
|
||||
dueOn = ageDue;
|
||||
reason = 'AGE';
|
||||
}
|
||||
} else if (expiry) {
|
||||
dueOn = expiry;
|
||||
reason = 'EXPIRY';
|
||||
} else {
|
||||
dueOn = ageDue as Date;
|
||||
reason = 'AGE';
|
||||
}
|
||||
|
||||
const daysRemaining = daysBetween(today, dueOn);
|
||||
|
||||
let state: KycDocumentRenewalState;
|
||||
if (reason === 'EXPIRY') {
|
||||
if (daysRemaining < 0) {
|
||||
state = 'EXPIRED';
|
||||
} else if (daysRemaining <= KYC_DOCUMENT_EXPIRY_WARNING_DAYS) {
|
||||
state = 'EXPIRING_SOON';
|
||||
} else {
|
||||
state = 'OK';
|
||||
}
|
||||
} else {
|
||||
// Age alone: a document one day short of five years is entirely fine, so there is nothing to
|
||||
// warn about in advance. It becomes due the moment the anniversary passes.
|
||||
state = daysRemaining < 0 ? 'DUE' : 'OK';
|
||||
}
|
||||
|
||||
return { state, dueOn, daysRemaining, reason };
|
||||
}
|
||||
|
||||
/** Whether this state warrants surfacing an alert to staff at all. */
|
||||
export function kycDocumentNeedsAttention(state: KycDocumentRenewalState): boolean {
|
||||
return state === 'EXPIRING_SOON' || state === 'EXPIRED' || state === 'DUE';
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* 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 {
|
||||
deriveKycExceptionFields,
|
||||
KycExceptionInput,
|
||||
KYC_EXCEPTION_AADHAAR,
|
||||
KYC_EXCEPTION_PAN
|
||||
} from './kyc-exception.util';
|
||||
|
||||
const TODAY = '05 August 2026';
|
||||
|
||||
/** Individual client, nothing previously recorded — the client-creation case. */
|
||||
const newIndividual = (panVerified: boolean, aadhaarVerified: boolean): KycExceptionInput => ({
|
||||
panVerified,
|
||||
aadhaarVerified,
|
||||
aadhaarApplicable: true,
|
||||
hadException: false,
|
||||
previousReason: null,
|
||||
todayFormatted: TODAY
|
||||
});
|
||||
|
||||
/** Individual client that already carries an open exception. */
|
||||
const existingException = (
|
||||
panVerified: boolean,
|
||||
aadhaarVerified: boolean,
|
||||
previousReason: string
|
||||
): KycExceptionInput => ({
|
||||
panVerified,
|
||||
aadhaarVerified,
|
||||
aadhaarApplicable: true,
|
||||
hadException: true,
|
||||
previousReason,
|
||||
todayFormatted: TODAY
|
||||
});
|
||||
|
||||
describe('deriveKycExceptionFields', () => {
|
||||
describe('at client creation', () => {
|
||||
it('marks both PAN and Aadhaar when neither is entered or verified', () => {
|
||||
expect(deriveKycExceptionFields(newIndividual(false, false))).toEqual({
|
||||
isException: true,
|
||||
exceptionReason: `${KYC_EXCEPTION_PAN}, ${KYC_EXCEPTION_AADHAAR}`,
|
||||
exceptionResolved: false,
|
||||
exceptionResolvedOn: null
|
||||
});
|
||||
});
|
||||
|
||||
it('marks only PAN when Aadhaar verified but PAN did not', () => {
|
||||
expect(deriveKycExceptionFields(newIndividual(false, true))).toEqual({
|
||||
isException: true,
|
||||
exceptionReason: KYC_EXCEPTION_PAN,
|
||||
exceptionResolved: false,
|
||||
exceptionResolvedOn: null
|
||||
});
|
||||
});
|
||||
|
||||
it('marks only Aadhaar when PAN verified but Aadhaar did not', () => {
|
||||
expect(deriveKycExceptionFields(newIndividual(true, false))).toEqual({
|
||||
isException: true,
|
||||
exceptionReason: KYC_EXCEPTION_AADHAAR,
|
||||
exceptionResolved: false,
|
||||
exceptionResolvedOn: null
|
||||
});
|
||||
});
|
||||
|
||||
it('raises nothing when both verify, and does not mark the client resolved', () => {
|
||||
// A clean client never had an exception, so it must not appear in the Resolved tab either.
|
||||
expect(deriveKycExceptionFields(newIndividual(true, true))).toEqual({
|
||||
isException: false,
|
||||
exceptionReason: null,
|
||||
exceptionResolved: false,
|
||||
exceptionResolvedOn: null
|
||||
});
|
||||
});
|
||||
|
||||
it('does not raise an Aadhaar exception for a non-individual client', () => {
|
||||
// Companies submit a dummy masked Aadhaar; checking it would mean a permanent exception.
|
||||
expect(
|
||||
deriveKycExceptionFields({
|
||||
panVerified: true,
|
||||
aadhaarVerified: false,
|
||||
aadhaarApplicable: false,
|
||||
hadException: false,
|
||||
previousReason: null,
|
||||
todayFormatted: TODAY
|
||||
}).isException
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PAN declaration', () => {
|
||||
it('waives the PAN exception once a declaration is taken', () => {
|
||||
// The client's name/DOB will never match the PAN record, so the API can never pass. The
|
||||
// signed declaration closes the item instead — the client must not sit in the exception list.
|
||||
expect(
|
||||
deriveKycExceptionFields({
|
||||
...newIndividual(false, true),
|
||||
panDeclarationTaken: true
|
||||
})
|
||||
).toEqual({
|
||||
isException: false,
|
||||
exceptionReason: null,
|
||||
exceptionResolved: false,
|
||||
exceptionResolvedOn: null
|
||||
});
|
||||
});
|
||||
|
||||
it('still raises the PAN exception when no declaration has been taken', () => {
|
||||
expect(
|
||||
deriveKycExceptionFields({
|
||||
...newIndividual(false, true),
|
||||
panDeclarationTaken: false
|
||||
}).exceptionReason
|
||||
).toBe(KYC_EXCEPTION_PAN);
|
||||
});
|
||||
|
||||
it('leaves an outstanding Aadhaar untouched — a PAN declaration covers PAN only', () => {
|
||||
const result = deriveKycExceptionFields({
|
||||
...newIndividual(false, false),
|
||||
panDeclarationTaken: true
|
||||
});
|
||||
expect(result.isException).toBe(true);
|
||||
expect(result.exceptionReason).toBe(KYC_EXCEPTION_AADHAAR);
|
||||
});
|
||||
|
||||
it('closes an existing PAN exception when a declaration is taken afterwards', () => {
|
||||
expect(
|
||||
deriveKycExceptionFields({
|
||||
...existingException(false, true, KYC_EXCEPTION_PAN),
|
||||
panDeclarationTaken: true
|
||||
})
|
||||
).toEqual({
|
||||
isException: false,
|
||||
exceptionReason: KYC_EXCEPTION_PAN,
|
||||
exceptionResolved: true,
|
||||
exceptionResolvedOn: TODAY
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Aadhaar enrolment receipt', () => {
|
||||
it('waives the Aadhaar exception once the enrolment receipt is recorded', () => {
|
||||
// The client holds no Aadhaar yet, so there is no number to run the OTP check against. The
|
||||
// UIDAI enrolment slip closes the item instead.
|
||||
expect(
|
||||
deriveKycExceptionFields({
|
||||
...newIndividual(true, false),
|
||||
aadhaarReceiptTaken: true
|
||||
})
|
||||
).toEqual({
|
||||
isException: false,
|
||||
exceptionReason: null,
|
||||
exceptionResolved: false,
|
||||
exceptionResolvedOn: null
|
||||
});
|
||||
});
|
||||
|
||||
it('still raises the Aadhaar exception when no receipt has been taken', () => {
|
||||
expect(
|
||||
deriveKycExceptionFields({
|
||||
...newIndividual(true, false),
|
||||
aadhaarReceiptTaken: false
|
||||
}).exceptionReason
|
||||
).toBe(KYC_EXCEPTION_AADHAAR);
|
||||
});
|
||||
|
||||
it('leaves an outstanding PAN untouched — a receipt covers Aadhaar only', () => {
|
||||
const result = deriveKycExceptionFields({
|
||||
...newIndividual(false, false),
|
||||
aadhaarReceiptTaken: true
|
||||
});
|
||||
expect(result.isException).toBe(true);
|
||||
expect(result.exceptionReason).toBe(KYC_EXCEPTION_PAN);
|
||||
});
|
||||
|
||||
it('closes an existing Aadhaar exception when a receipt is taken afterwards', () => {
|
||||
expect(
|
||||
deriveKycExceptionFields({
|
||||
...existingException(true, false, KYC_EXCEPTION_AADHAAR),
|
||||
aadhaarReceiptTaken: true
|
||||
})
|
||||
).toEqual({
|
||||
isException: false,
|
||||
exceptionReason: KYC_EXCEPTION_AADHAAR,
|
||||
exceptionResolved: true,
|
||||
exceptionResolvedOn: TODAY
|
||||
});
|
||||
});
|
||||
|
||||
it('clears both items when a PAN declaration and an Aadhaar receipt are both on file', () => {
|
||||
// Neither check can pass, yet the branch holds paperwork for both — nothing is outstanding.
|
||||
const result = deriveKycExceptionFields({
|
||||
...newIndividual(false, false),
|
||||
panDeclarationTaken: true,
|
||||
aadhaarReceiptTaken: true
|
||||
});
|
||||
expect(result.isException).toBe(false);
|
||||
expect(result.exceptionReason).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolving', () => {
|
||||
it('closes the exception once both checks pass, keeping the reason for the audit trail', () => {
|
||||
const both = `${KYC_EXCEPTION_PAN}, ${KYC_EXCEPTION_AADHAAR}`;
|
||||
expect(deriveKycExceptionFields(existingException(true, true, both))).toEqual({
|
||||
isException: false,
|
||||
exceptionReason: both,
|
||||
exceptionResolved: true,
|
||||
exceptionResolvedOn: TODAY
|
||||
});
|
||||
});
|
||||
|
||||
it('narrows to the outstanding item when only one of two verifies', () => {
|
||||
const both = `${KYC_EXCEPTION_PAN}, ${KYC_EXCEPTION_AADHAAR}`;
|
||||
// PAN verified, Aadhaar still outstanding — stays open, listing Aadhaar alone.
|
||||
expect(deriveKycExceptionFields(existingException(true, false, both))).toEqual({
|
||||
isException: true,
|
||||
exceptionReason: KYC_EXCEPTION_AADHAAR,
|
||||
exceptionResolved: false,
|
||||
exceptionResolvedOn: null
|
||||
});
|
||||
});
|
||||
|
||||
it('narrows the other way round too', () => {
|
||||
const both = `${KYC_EXCEPTION_PAN}, ${KYC_EXCEPTION_AADHAAR}`;
|
||||
expect(deriveKycExceptionFields(existingException(false, true, both)).exceptionReason).toBe(KYC_EXCEPTION_PAN);
|
||||
});
|
||||
});
|
||||
|
||||
describe('re-opening', () => {
|
||||
it('clears a prior resolution so a new exception returns to Pending', () => {
|
||||
// Regression: without exceptionResolved=false the record still matched the Resolved query
|
||||
// and a brand-new exception appeared as already closed.
|
||||
const result = deriveKycExceptionFields({
|
||||
panVerified: false,
|
||||
aadhaarVerified: true,
|
||||
aadhaarApplicable: true,
|
||||
hadException: false,
|
||||
previousReason: 'PAN Card Verification Failed',
|
||||
todayFormatted: TODAY
|
||||
});
|
||||
expect(result.exceptionResolved).toBe(false);
|
||||
expect(result.exceptionResolvedOn).toBeNull();
|
||||
expect(result.isException).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('officer-entered reasons', () => {
|
||||
it('keeps a manual reason alongside the automatic ones', () => {
|
||||
const result = deriveKycExceptionFields({
|
||||
...newIndividual(true, true),
|
||||
manualReasons: ['Address proof pending']
|
||||
});
|
||||
expect(result).toEqual({
|
||||
isException: true,
|
||||
exceptionReason: 'Address proof pending',
|
||||
exceptionResolved: false,
|
||||
exceptionResolvedOn: null
|
||||
});
|
||||
});
|
||||
|
||||
it('drops a stale automatic reason left in the form once the check passes', () => {
|
||||
// The form still holds 'PAN Card Verification Failed' from a previous load; PAN has since
|
||||
// verified, so it must not survive into the payload.
|
||||
const result = deriveKycExceptionFields({
|
||||
...newIndividual(true, true),
|
||||
manualReasons: [KYC_EXCEPTION_PAN]
|
||||
});
|
||||
expect(result.isException).toBe(false);
|
||||
expect(result.exceptionReason).toBeNull();
|
||||
});
|
||||
|
||||
it('does not duplicate a manual reason that matches an outstanding automatic one', () => {
|
||||
const result = deriveKycExceptionFields({
|
||||
...newIndividual(false, true),
|
||||
manualReasons: [KYC_EXCEPTION_PAN]
|
||||
});
|
||||
expect(result.exceptionReason).toBe(KYC_EXCEPTION_PAN);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Derives a client's KYC exception state from their verification status.
|
||||
*
|
||||
* A KYC exception is not something an officer should have to remember to tick — it is simply the
|
||||
* fact that a mandatory identity check has not passed. Deriving it means the exception list can
|
||||
* never drift out of step with reality: raising, narrowing and closing all fall out of whether PAN
|
||||
* and Aadhaar are verified at the moment of the save.
|
||||
*
|
||||
* Shared by the client-creation stepper and the client-view KYC tab so both write identical state.
|
||||
*/
|
||||
|
||||
export const KYC_EXCEPTION_PAN = 'PAN Card Verification Failed';
|
||||
export const KYC_EXCEPTION_AADHAAR = 'Aadhaar Verification Failed';
|
||||
|
||||
/** The two reasons this module owns. Anything else on a record was added by an officer by hand. */
|
||||
export const AUTO_MANAGED_KYC_EXCEPTION_REASONS: string[] = [
|
||||
KYC_EXCEPTION_PAN,
|
||||
KYC_EXCEPTION_AADHAAR
|
||||
];
|
||||
|
||||
export interface KycExceptionInput {
|
||||
/** PAN verified against the Income Tax database. Unverified OR simply not entered → exception. */
|
||||
panVerified: boolean;
|
||||
/**
|
||||
* A signed customer declaration has been accepted in place of a passing PAN check.
|
||||
*
|
||||
* The Income Tax check matches on name *and* date of birth, so a client whose PAN card carries a
|
||||
* different spelling — or a different DOB — can never clear it, however many times the officer
|
||||
* retries. Once the branch holds a declaration recording exactly what differs, the outstanding
|
||||
* item is closed by paperwork rather than by the API, and it must not keep the client sitting in
|
||||
* the exception list. PAN still reads as unverified everywhere else; only the exception is waived.
|
||||
*/
|
||||
panDeclarationTaken?: boolean;
|
||||
/** Aadhaar verified against UIDAI. Unverified OR simply not entered → exception. */
|
||||
aadhaarVerified: boolean;
|
||||
/**
|
||||
* The customer's Aadhaar enrolment slip has been sighted and recorded.
|
||||
*
|
||||
* A customer who has applied for Aadhaar but has not been issued one yet holds only a UIDAI
|
||||
* enrolment receipt. There is no Aadhaar number to put through the OTP flow, so the check cannot
|
||||
* pass however long the branch waits — the same dead end the PAN declaration exists to resolve.
|
||||
* Once the receipt is on file the outstanding item is closed by paperwork, and the client must
|
||||
* not keep sitting in the exception list. Aadhaar still reads as unverified everywhere else.
|
||||
*/
|
||||
aadhaarReceiptTaken?: boolean;
|
||||
/**
|
||||
* False for non-individual clients, where no Aadhaar is collected (the payload sends a dummy
|
||||
* masked value). Without this guard every company would carry a permanent Aadhaar exception.
|
||||
*/
|
||||
aadhaarApplicable: boolean;
|
||||
/** Whether an exception is already recorded — decides raise vs. resolve on a clean save. */
|
||||
hadException: boolean;
|
||||
/** Reason currently persisted, kept verbatim when closing so the audit trail survives. */
|
||||
previousReason: string | null;
|
||||
/** Officer-entered reasons beyond the two automatic ones; carried through untouched. */
|
||||
manualReasons?: string[];
|
||||
/** Today in the tenant's date format — stamped as exceptionResolvedOn when closing. */
|
||||
todayFormatted: string;
|
||||
}
|
||||
|
||||
export interface KycExceptionFields {
|
||||
isException: boolean;
|
||||
exceptionReason: string | null;
|
||||
exceptionResolved: boolean;
|
||||
exceptionResolvedOn: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps verification status to the four exception columns the API expects.
|
||||
*
|
||||
* Three outcomes:
|
||||
* - **Something outstanding** → raise/keep the exception, listing only what is still failing. A
|
||||
* record that had both PAN and Aadhaar outstanding and has since verified PAN comes back with
|
||||
* Aadhaar alone, which is what narrows the entry rather than closing it.
|
||||
* - **Nothing outstanding, exception existed** → close it. `exceptionReason` is retained on
|
||||
* purpose: the Resolved tab shows what was resolved, and clearing it would erase the record of
|
||||
* why the exception was ever raised.
|
||||
* - **Nothing outstanding, no exception** → leave every flag off, so a clean client never appears
|
||||
* in either tab.
|
||||
*/
|
||||
export function deriveKycExceptionFields(input: KycExceptionInput): KycExceptionFields {
|
||||
const outstanding: string[] = [];
|
||||
|
||||
// A declaration settles the PAN item without the API ever passing — see `panDeclarationTaken`.
|
||||
if (!input.panVerified && !input.panDeclarationTaken) {
|
||||
outstanding.push(KYC_EXCEPTION_PAN);
|
||||
}
|
||||
// An enrolment receipt settles the Aadhaar item when the OTP check has nothing to run against —
|
||||
// see `aadhaarReceiptTaken`.
|
||||
if (input.aadhaarApplicable && !input.aadhaarVerified && !input.aadhaarReceiptTaken) {
|
||||
outstanding.push(KYC_EXCEPTION_AADHAAR);
|
||||
}
|
||||
|
||||
// Officer-added reasons ride along, but the two automatic ones are filtered out first — a stale
|
||||
// 'PAN Card Verification Failed' left in the form must not survive PAN actually being verified.
|
||||
for (const reason of input.manualReasons || []) {
|
||||
const trimmed = (reason || '').trim();
|
||||
if (trimmed && !AUTO_MANAGED_KYC_EXCEPTION_REASONS.includes(trimmed) && !outstanding.includes(trimmed)) {
|
||||
outstanding.push(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
if (outstanding.length > 0) {
|
||||
return {
|
||||
isException: true,
|
||||
exceptionReason: outstanding.join(', '),
|
||||
exceptionResolved: false,
|
||||
exceptionResolvedOn: null
|
||||
};
|
||||
}
|
||||
|
||||
if (input.hadException) {
|
||||
return {
|
||||
isException: false,
|
||||
exceptionReason: input.previousReason || null,
|
||||
exceptionResolved: true,
|
||||
exceptionResolvedOn: input.todayFormatted
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isException: false,
|
||||
exceptionReason: null,
|
||||
exceptionResolved: false,
|
||||
exceptionResolvedOn: null
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<!--
|
||||
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="aad-rcpt">
|
||||
<div class="aad-rcpt__header">
|
||||
<mat-icon class="aad-rcpt__header-icon">receipt_long</mat-icon>
|
||||
<div>
|
||||
<div class="aad-rcpt__title">Aadhaar Enrolment Receipt</div>
|
||||
<div class="aad-rcpt__subtitle">{{ data.clientName || 'Client' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<mat-dialog-content class="aad-rcpt__body">
|
||||
<div class="aad-rcpt__reason">
|
||||
<mat-icon>info</mat-icon>
|
||||
<span>{{ data.reason }}</span>
|
||||
</div>
|
||||
|
||||
<p class="aad-rcpt__intro">
|
||||
For a customer who has applied for Aadhaar but has not been issued one yet, there is no Aadhaar number to run the
|
||||
OTP check against. Record the UIDAI enrolment slip instead — the client can then be created without an Aadhaar KYC
|
||||
exception being raised.
|
||||
</p>
|
||||
|
||||
<mat-form-field appearance="outline" class="aad-rcpt__field">
|
||||
<mat-label>Enrolment number (EID)</mat-label>
|
||||
<span matPrefix class="aad-rcpt__prefix"><mat-icon>confirmation_number</mat-icon></span>
|
||||
<input matInput [(ngModel)]="enrolmentId" placeholder="1234/56789/01234" maxlength="20" autocomplete="off" />
|
||||
<mat-hint [class.aad-rcpt__hint--bad]="enrolmentIdEntered && !enrolmentIdValid">
|
||||
{{ enrolmentIdHint }}
|
||||
</mat-hint>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="aad-rcpt__field">
|
||||
<mat-label>Enrolment date</mat-label>
|
||||
<input matInput [matDatepicker]="enrolPicker" [max]="today" [(ngModel)]="enrolmentDate" />
|
||||
<mat-datepicker-toggle matSuffix [for]="enrolPicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #enrolPicker></mat-datepicker>
|
||||
<mat-hint>Date printed on the slip</mat-hint>
|
||||
</mat-form-field>
|
||||
|
||||
@if (monthsSinceEnrolment !== null) {
|
||||
<div class="aad-rcpt__age" [class.aad-rcpt__age--stale]="receiptIsStale">
|
||||
<mat-icon>{{ receiptIsStale ? 'warning' : 'schedule' }}</mat-icon>
|
||||
<span>
|
||||
@if (receiptIsStale) {
|
||||
Enrolled {{ monthsSinceEnrolment }} months ago. An enrolment normally completes well inside six months —
|
||||
chase the customer for the Aadhaar itself. The receipt can still be recorded.
|
||||
} @else {
|
||||
Enrolled {{ monthsSinceEnrolment }} month{{ monthsSinceEnrolment === 1 ? '' : 's' }} ago.
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<mat-form-field appearance="outline" class="aad-rcpt__remarks">
|
||||
<mat-label>Remarks</mat-label>
|
||||
<textarea
|
||||
matInput
|
||||
rows="3"
|
||||
[(ngModel)]="remarks"
|
||||
maxlength="500"
|
||||
placeholder="What was sighted, and when the Aadhaar is expected"
|
||||
></textarea>
|
||||
<mat-hint>{{ remarks.length }}/500</mat-hint>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-checkbox class="aad-rcpt__sighted" [(ngModel)]="sighted">
|
||||
The original UIDAI enrolment slip has been sighted and a copy filed with the account opening form.
|
||||
</mat-checkbox>
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions align="end" class="aad-rcpt__actions">
|
||||
<button mat-stroked-button mat-dialog-close type="button">Cancel</button>
|
||||
<button mat-flat-button color="primary" type="button" [disabled]="!canSubmit" (click)="submit()">
|
||||
Save Receipt
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
</div>
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
.aad-rcpt {
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
.aad-rcpt__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px 24px 12px;
|
||||
border-bottom: 1px solid #bfdbfe;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.aad-rcpt__header-icon {
|
||||
color: #2563eb;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.aad-rcpt__title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1e3a8a;
|
||||
}
|
||||
|
||||
.aad-rcpt__subtitle {
|
||||
font-size: 12px;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.aad-rcpt__body {
|
||||
padding: 18px 24px !important;
|
||||
font-size: 14px;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.aad-rcpt__reason {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 14px;
|
||||
border-radius: 6px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
color: #475569;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex: 0 0 auto;
|
||||
color: #64748b;
|
||||
}
|
||||
}
|
||||
|
||||
.aad-rcpt__intro {
|
||||
margin: 0 0 16px;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.aad-rcpt__field,
|
||||
.aad-rcpt__remarks {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.aad-rcpt__prefix {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-right: 8px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.aad-rcpt__hint--bad {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
// Age of the slip — informational, so it reads as context rather than a blocking error.
|
||||
.aad-rcpt__age {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin: 2px 0 14px;
|
||||
padding: 9px 12px;
|
||||
border-radius: 6px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
color: #475569;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex: 0 0 auto;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
&--stale {
|
||||
background: #fffbeb;
|
||||
border-color: #fde68a;
|
||||
color: #92400e;
|
||||
|
||||
mat-icon {
|
||||
color: #d97706;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.aad-rcpt__sighted {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
|
||||
::ng-deep .mdc-form-field > label {
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
|
||||
.aad-rcpt__actions {
|
||||
padding: 10px 24px 16px !important;
|
||||
margin: 0 !important;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* 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 { FormsModule } from '@angular/forms';
|
||||
import {
|
||||
MAT_DIALOG_DATA,
|
||||
MatDialogRef,
|
||||
MatDialogContent,
|
||||
MatDialogActions,
|
||||
MatDialogClose
|
||||
} from '@angular/material/dialog';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
export interface AadhaarReceiptDialogData {
|
||||
/** Client name, shown so the officer can check the slip belongs to the right person. */
|
||||
clientName: string;
|
||||
/** Message the Aadhaar step came back with, or why the officer is recording a receipt. */
|
||||
reason: string;
|
||||
/** Existing receipt, when the officer re-opens one already taken. */
|
||||
existing?: AadhaarReceiptResult | null;
|
||||
}
|
||||
|
||||
export interface AadhaarReceiptResult {
|
||||
/** 14-digit enrolment number as printed on the slip, separators kept. */
|
||||
enrolmentId: string;
|
||||
enrolmentDate: Date | null;
|
||||
remarks: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the UIDAI enrolment receipt accepted in place of a verified Aadhaar.
|
||||
*
|
||||
* A customer who has applied for Aadhaar but has not been issued one holds only an enrolment slip.
|
||||
* There is no Aadhaar number to send to the OTP flow, so no amount of retrying will clear the
|
||||
* check — the same dead end the PAN declaration resolves. Recording the slip lets the client be
|
||||
* created and waives the Aadhaar exception, while Aadhaar itself stays unverified.
|
||||
*
|
||||
* Only the 14-digit enrolment number is taken. The date and time printed beside it on the slip are
|
||||
* captured by the enrolment-date field, so asking for them again in the same box would just be a
|
||||
* second chance to mistype them.
|
||||
*
|
||||
* The number is validated on digit count rather than an exact layout: slips print it with varying
|
||||
* separators, and rejecting one because of a slash or a space would block a customer over
|
||||
* formatting rather than substance.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-aadhaar-receipt-dialog',
|
||||
templateUrl: './aadhaar-receipt-dialog.component.html',
|
||||
styleUrls: ['./aadhaar-receipt-dialog.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
FormsModule,
|
||||
MatIcon,
|
||||
MatDialogContent,
|
||||
MatDialogActions,
|
||||
MatDialogClose
|
||||
]
|
||||
})
|
||||
export class AadhaarReceiptDialogComponent {
|
||||
data = inject<AadhaarReceiptDialogData>(MAT_DIALOG_DATA);
|
||||
private dialogRef = inject(MatDialogRef<AadhaarReceiptDialogComponent>);
|
||||
|
||||
enrolmentId = '';
|
||||
enrolmentDate: Date | null = null;
|
||||
remarks = '';
|
||||
/** The officer confirms the original slip was sighted — nothing is saved without it. */
|
||||
sighted = false;
|
||||
|
||||
today = new Date();
|
||||
|
||||
constructor() {
|
||||
const existing = this.data.existing;
|
||||
if (existing) {
|
||||
this.enrolmentId = existing.enrolmentId || '';
|
||||
this.enrolmentDate = existing.enrolmentDate || null;
|
||||
this.remarks = existing.remarks || '';
|
||||
}
|
||||
}
|
||||
|
||||
/** Digits only — separators on the printed slip are cosmetic and vary between forms. */
|
||||
private get enrolmentDigits(): string {
|
||||
return (this.enrolmentId || '').replace(/\D/g, '');
|
||||
}
|
||||
|
||||
get enrolmentIdEntered(): boolean {
|
||||
return this.enrolmentDigits.length > 0;
|
||||
}
|
||||
|
||||
/** UIDAI's enrolment number is 14 digits — the timestamp beside it is not part of it. */
|
||||
get enrolmentIdValid(): boolean {
|
||||
return this.enrolmentDigits.length === 14;
|
||||
}
|
||||
|
||||
/** How many digits are still missing, so the officer is told what to fix, not just that it failed. */
|
||||
get enrolmentIdHint(): string {
|
||||
const count = this.enrolmentDigits.length;
|
||||
if (!count) {
|
||||
return 'The 14-digit enrolment number only, e.g. 1234/56789/01234';
|
||||
}
|
||||
return `${count} of 14 digits`;
|
||||
}
|
||||
|
||||
/** A slip dated in the future is a keying error, not a valid enrolment. */
|
||||
get enrolmentDateValid(): boolean {
|
||||
return !!this.enrolmentDate && this.enrolmentDate.getTime() <= this.today.getTime();
|
||||
}
|
||||
|
||||
/** Whole months since enrolment — shown because a receipt is temporary by nature. */
|
||||
get monthsSinceEnrolment(): number | null {
|
||||
if (!this.enrolmentDate) {
|
||||
return null;
|
||||
}
|
||||
const months =
|
||||
(this.today.getFullYear() - this.enrolmentDate.getFullYear()) * 12 +
|
||||
(this.today.getMonth() - this.enrolmentDate.getMonth()) -
|
||||
(this.today.getDate() < this.enrolmentDate.getDate() ? 1 : 0);
|
||||
return Math.max(0, months);
|
||||
}
|
||||
|
||||
/**
|
||||
* An enrolment normally completes well inside six months, so an older slip is a prompt to chase
|
||||
* the customer for the Aadhaar itself. It is a warning, not a block: the branch may have good
|
||||
* reason to accept it, and the record shows the age either way.
|
||||
*/
|
||||
get receiptIsStale(): boolean {
|
||||
const months = this.monthsSinceEnrolment;
|
||||
return months !== null && months >= 6;
|
||||
}
|
||||
|
||||
get canSubmit(): boolean {
|
||||
return this.enrolmentIdValid && this.enrolmentDateValid && !!this.remarks.trim() && this.sighted;
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
if (!this.canSubmit) {
|
||||
return;
|
||||
}
|
||||
this.dialogRef.close({
|
||||
enrolmentId: this.enrolmentId.trim(),
|
||||
enrolmentDate: this.enrolmentDate,
|
||||
remarks: this.remarks.trim()
|
||||
} as AadhaarReceiptResult);
|
||||
}
|
||||
}
|
||||
@@ -84,6 +84,121 @@ export class KycUploadHelperService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a stacked front-above-back scan back into its two sides.
|
||||
*
|
||||
* Clients saved before the sides were stored separately carry one merged PNG, so a form that
|
||||
* wants a front image and a back image has nothing to show in the second slot. `mergeFrontBack`
|
||||
* lays the sides on a white canvas with a 20px gap between them, which leaves a band of
|
||||
* completely white rows across the full width — that band is the seam, and it is the only thing
|
||||
* in the file that says where one side ends and the other begins.
|
||||
*
|
||||
* Resolves null when no such seam is found, which is the honest answer for an image that was not
|
||||
* produced by that merge: better to show one scan than to cut a document in half at a guess.
|
||||
*/
|
||||
splitFrontBack(blob: Blob): Promise<{ front: Blob; back: Blob } | null> {
|
||||
if (!blob.type.startsWith('image/')) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
type Sides = { front: Blob; back: Blob } | null;
|
||||
return this.blobToImage(blob)
|
||||
.then((img): Sides | Promise<Sides> => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = img.naturalWidth || img.width;
|
||||
canvas.height = img.naturalHeight || img.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx || canvas.width === 0 || canvas.height === 0) {
|
||||
return null;
|
||||
}
|
||||
ctx.drawImage(img, 0, 0);
|
||||
|
||||
const seam = this.findWhiteSeam(ctx, canvas.width, canvas.height);
|
||||
if (!seam) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Promise.all([
|
||||
this.cropToBlob(img, canvas.width, 0, seam.start),
|
||||
this.cropToBlob(img, canvas.width, seam.end, canvas.height - seam.end)
|
||||
]).then(
|
||||
([
|
||||
front,
|
||||
back
|
||||
]): Sides => (front && back ? { front, back } : null)
|
||||
);
|
||||
})
|
||||
.catch((): Sides => null);
|
||||
}
|
||||
|
||||
/**
|
||||
* The tallest run of all-white rows in the middle of the image.
|
||||
*
|
||||
* Confined to the middle half because a scan often has white margins at the top and bottom, and
|
||||
* those are part of the document rather than the join between two of them.
|
||||
*/
|
||||
private findWhiteSeam(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
width: number,
|
||||
height: number
|
||||
): { start: number; end: number } | null {
|
||||
const from = Math.floor(height * 0.25);
|
||||
const to = Math.ceil(height * 0.75);
|
||||
const step = Math.max(1, Math.floor(width / 64)); // sampling is enough to spot a non-white row
|
||||
|
||||
let bestStart = -1;
|
||||
let bestLength = 0;
|
||||
let runStart = -1;
|
||||
|
||||
for (let y = from; y <= to; y++) {
|
||||
let white = y < to;
|
||||
if (white) {
|
||||
const row = ctx.getImageData(0, y, width, 1).data;
|
||||
for (let x = 0; x < width; x += step) {
|
||||
const i = x * 4;
|
||||
if (row[i] < 245 || row[i + 1] < 245 || row[i + 2] < 245) {
|
||||
white = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (white) {
|
||||
if (runStart < 0) {
|
||||
runStart = y;
|
||||
}
|
||||
} else if (runStart >= 0) {
|
||||
if (y - runStart > bestLength) {
|
||||
bestStart = runStart;
|
||||
bestLength = y - runStart;
|
||||
}
|
||||
runStart = -1;
|
||||
}
|
||||
}
|
||||
|
||||
// The gap the merge inserts is 20px; anything much thinner is white space inside a document.
|
||||
return bestLength >= 12 ? { start: bestStart, end: bestStart + bestLength } : null;
|
||||
}
|
||||
|
||||
private cropToBlob(img: HTMLImageElement, width: number, top: number, height: number): Promise<Blob | null> {
|
||||
if (height <= 0) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
ctx.drawImage(img, 0, top, width, height, 0, 0, width, height);
|
||||
return new Promise((resolve) => canvas.toBlob((out) => resolve(out), 'image/png'));
|
||||
}
|
||||
|
||||
private blobToImage(blob: Blob): Promise<HTMLImageElement> {
|
||||
return this.fileToImage(blob as File);
|
||||
}
|
||||
|
||||
private fileToImage(file: File): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = URL.createObjectURL(file);
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
<!--
|
||||
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="pan-decl">
|
||||
<div class="pan-decl__header">
|
||||
<mat-icon class="pan-decl__header-icon">assignment_late</mat-icon>
|
||||
<div>
|
||||
<div class="pan-decl__title">PAN Verification Failed — Take Declaration</div>
|
||||
<div class="pan-decl__subtitle">PAN {{ data.panNumber }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<mat-dialog-content class="pan-decl__body">
|
||||
<div class="pan-decl__reason">
|
||||
<mat-icon>info</mat-icon>
|
||||
<span>{{ data.failureMessage }}</span>
|
||||
</div>
|
||||
|
||||
@if (!data.unverifiable) {
|
||||
<p class="pan-decl__intro">
|
||||
The Income Tax check matches on <strong>name and date of birth together</strong> and does not report which of
|
||||
the two differs. Enter the details exactly as printed on the customer's PAN card so the declaration records what
|
||||
actually does not match.
|
||||
</p>
|
||||
|
||||
<!-- Comparison: what we submitted vs what the PAN card says -->
|
||||
<div class="pan-decl__compare">
|
||||
<div class="pan-decl__compare-head">
|
||||
<span class="lbl"></span>
|
||||
<span class="col">Submitted</span>
|
||||
<span class="col">As on PAN card</span>
|
||||
<span class="res"></span>
|
||||
</div>
|
||||
|
||||
<div class="pan-decl__compare-row" [class.mismatch]="nameMismatch">
|
||||
<span class="lbl">Name</span>
|
||||
<span class="col submitted">{{ data.submittedName || '—' }}</span>
|
||||
<span class="col">
|
||||
<mat-form-field appearance="outline" class="pan-decl__field">
|
||||
<mat-label>Name on PAN</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[(ngModel)]="nameOnPan"
|
||||
(ngModelChange)="onCompare()"
|
||||
placeholder="e.g. RAJESH KUMAR SHARMA"
|
||||
maxlength="200"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</mat-form-field>
|
||||
</span>
|
||||
<span class="res">
|
||||
@if (compared && nameOnPan.trim()) {
|
||||
@if (nameMismatch) {
|
||||
<mat-icon class="res-icon res-icon--bad" aria-label="Does not match">cancel</mat-icon>
|
||||
} @else {
|
||||
<mat-icon class="res-icon res-icon--ok" aria-label="Matches">check_circle</mat-icon>
|
||||
}
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="pan-decl__compare-row" [class.mismatch]="dobMismatch">
|
||||
<span class="lbl">Date of Birth</span>
|
||||
<span class="col submitted">{{ data.submittedDob || '—' }}</span>
|
||||
<span class="col">
|
||||
<mat-form-field appearance="outline" class="pan-decl__field">
|
||||
<mat-label>DOB on PAN</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[matDatepicker]="dobPicker"
|
||||
[max]="today"
|
||||
[(ngModel)]="dobOnPan"
|
||||
(ngModelChange)="onCompare()"
|
||||
/>
|
||||
<mat-datepicker-toggle matSuffix [for]="dobPicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #dobPicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
</span>
|
||||
<span class="res">
|
||||
@if (compared && dobOnPan) {
|
||||
@if (dobMismatch) {
|
||||
<mat-icon class="res-icon res-icon--bad" aria-label="Does not match">cancel</mat-icon>
|
||||
} @else {
|
||||
<mat-icon class="res-icon res-icon--ok" aria-label="Matches">check_circle</mat-icon>
|
||||
}
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- What the declaration will state -->
|
||||
<div
|
||||
class="pan-decl__verdict"
|
||||
[class.pan-decl__verdict--pending]="!comparisonComplete"
|
||||
[class.pan-decl__verdict--clean]="nothingToDeclare"
|
||||
>
|
||||
<span class="pan-decl__verdict-label">Does not match:</span>
|
||||
<span class="pan-decl__verdict-value">{{ mismatchLabel }}</span>
|
||||
</div>
|
||||
|
||||
@if (!comparisonComplete) {
|
||||
<p class="pan-decl__note">Enter both PAN-card values to see exactly which detail differs.</p>
|
||||
}
|
||||
|
||||
@if (nothingToDeclare) {
|
||||
<p class="pan-decl__warn">
|
||||
<mat-icon>warning</mat-icon>
|
||||
The PAN-card details you entered are the same as the ones submitted, so there is nothing to declare. Correct
|
||||
the client's name or date of birth on the General step and verify PAN again.
|
||||
</p>
|
||||
}
|
||||
} @else {
|
||||
<p class="pan-decl__intro">
|
||||
PAN could not be checked against the Income Tax records. Record the declaration taken from the customer so the
|
||||
client can be created without a KYC exception being raised for PAN.
|
||||
</p>
|
||||
}
|
||||
|
||||
<!-- The signed paper is the declaration; it is uploaded, not described. -->
|
||||
<div class="pan-decl__upload">
|
||||
<div class="pan-decl__upload-label">Signed declaration</div>
|
||||
<p class="pan-decl__upload-help">
|
||||
Get the declaration signed by the customer and upload the scanned copy. It is filed against the client as the
|
||||
proof that holds the PAN exception off.
|
||||
</p>
|
||||
|
||||
@if (!hasDeclarationFile) {
|
||||
<label class="pan-decl__dropzone">
|
||||
<input type="file" [accept]="acceptedTypes" (change)="onDeclarationFile($event)" hidden />
|
||||
<mat-icon class="pan-decl__dropzone-icon">cloud_upload</mat-icon>
|
||||
<span class="pan-decl__dropzone-text">Click to upload signed declaration</span>
|
||||
<span class="pan-decl__dropzone-hint">PDF, JPG or PNG — up to 5 MB</span>
|
||||
</label>
|
||||
} @else {
|
||||
<div class="pan-decl__file">
|
||||
<mat-icon class="pan-decl__file-icon">description</mat-icon>
|
||||
<span class="pan-decl__file-meta">
|
||||
<span class="pan-decl__file-name">{{ fileName }}</span>
|
||||
<span class="pan-decl__file-sub">{{ fileSizeLabel }}</span>
|
||||
</span>
|
||||
<label class="pan-decl__file-replace">
|
||||
<input type="file" [accept]="acceptedTypes" (change)="onDeclarationFile($event)" hidden />
|
||||
Replace
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
class="pan-decl__file-remove"
|
||||
aria-label="Remove file"
|
||||
(click)="removeDeclarationFile()"
|
||||
>
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (fileError) {
|
||||
<p class="pan-decl__file-error">
|
||||
<mat-icon>error_outline</mat-icon>
|
||||
<span>{{ fileError }}</span>
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions align="end" class="pan-decl__actions">
|
||||
<button mat-stroked-button mat-dialog-close type="button">Cancel</button>
|
||||
<button mat-flat-button color="primary" type="button" [disabled]="!canSubmit" (click)="submit()">
|
||||
Save Declaration
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
</div>
|
||||
@@ -0,0 +1,361 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
.pan-decl {
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.pan-decl__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px 24px 12px;
|
||||
border-bottom: 1px solid #fde68a;
|
||||
background: #fffbeb;
|
||||
}
|
||||
|
||||
.pan-decl__header-icon {
|
||||
color: #d97706;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.pan-decl__title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.pan-decl__subtitle {
|
||||
font-size: 12px;
|
||||
color: #b45309;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.pan-decl__body {
|
||||
padding: 18px 24px !important;
|
||||
font-size: 14px;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.pan-decl__reason {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 14px;
|
||||
border-radius: 6px;
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
color: #991b1b;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
.pan-decl__intro {
|
||||
margin: 0 0 16px;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
// ── Submitted vs PAN-card comparison ───────────────────────────────────────
|
||||
.pan-decl__compare {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pan-decl__compare-head,
|
||||
.pan-decl__compare-row {
|
||||
display: grid;
|
||||
grid-template-columns: 110px 1fr 1fr 36px;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 8px 14px;
|
||||
}
|
||||
|
||||
.pan-decl__compare-head {
|
||||
background: #f8fafc;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.pan-decl__compare-row {
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
// A row the declaration will name — tinted so the officer can see the finding at a glance.
|
||||
&.mismatch {
|
||||
background: #fff7ed;
|
||||
}
|
||||
|
||||
.lbl {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.submitted {
|
||||
font-size: 13px;
|
||||
color: #0f172a;
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
.pan-decl__field {
|
||||
width: 100%;
|
||||
|
||||
::ng-deep .mat-mdc-form-field-subscript-wrapper {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.res {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.res-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.res-icon--ok {
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.res-icon--bad {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
// ── Verdict ────────────────────────────────────────────────────────────────
|
||||
.pan-decl__verdict {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 6px;
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
|
||||
&--pending {
|
||||
background: #f8fafc;
|
||||
border-color: #e2e8f0;
|
||||
}
|
||||
|
||||
&--clean {
|
||||
background: #f0fdf4;
|
||||
border-color: #bbf7d0;
|
||||
}
|
||||
}
|
||||
|
||||
.pan-decl__verdict-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.pan-decl__verdict-value {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.pan-decl__note {
|
||||
margin: 8px 0 0;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.pan-decl__warn {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin: 10px 0 0;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
color: #b45309;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Signed declaration upload ──────────────────────────────────────────────
|
||||
.pan-decl__upload {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.pan-decl__upload-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.pan-decl__upload-help {
|
||||
margin: 4px 0 10px;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.pan-decl__dropzone {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding: 18px 24px;
|
||||
background: #fff;
|
||||
border: 2px dashed #cbd5e1;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #f8fafc;
|
||||
border-color: #93c5fd;
|
||||
}
|
||||
}
|
||||
|
||||
.pan-decl__dropzone-icon {
|
||||
font-size: 28px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.pan-decl__dropzone-text {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.pan-decl__dropzone-hint {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.pan-decl__file {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
background: #f0fdf4;
|
||||
border: 1px solid #bbf7d0;
|
||||
}
|
||||
|
||||
.pan-decl__file-icon {
|
||||
color: #16a34a;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.pan-decl__file-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.pan-decl__file-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #166534;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pan-decl__file-sub {
|
||||
font-size: 11px;
|
||||
color: #4d7c69;
|
||||
}
|
||||
|
||||
.pan-decl__file-replace {
|
||||
flex: 0 0 auto;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid #86efac;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #166534;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: #ecfdf5;
|
||||
}
|
||||
}
|
||||
|
||||
.pan-decl__file-remove {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 4px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: #dc2626;
|
||||
}
|
||||
}
|
||||
|
||||
.pan-decl__file-error {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
margin: 8px 0 0;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
color: #b91c1c;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
.pan-decl__actions {
|
||||
padding: 10px 24px 16px !important;
|
||||
margin: 0 !important;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* 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 { FormsModule } from '@angular/forms';
|
||||
import {
|
||||
MAT_DIALOG_DATA,
|
||||
MatDialogRef,
|
||||
MatDialogContent,
|
||||
MatDialogActions,
|
||||
MatDialogClose
|
||||
} from '@angular/material/dialog';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
/** Which client details differ from the PAN card. */
|
||||
export type PanMismatchField = 'NAME' | 'DOB';
|
||||
|
||||
export interface PanDeclarationDialogData {
|
||||
/** Name submitted on the General step — the value sent to the Income Tax check. */
|
||||
submittedName: string;
|
||||
/** Date of birth submitted on the General step, in the tenant's display format. */
|
||||
submittedDob: string;
|
||||
/** PAN being declared against. */
|
||||
panNumber: string;
|
||||
/** Message the verification came back with, shown so the officer knows why they are here. */
|
||||
failureMessage: string;
|
||||
/**
|
||||
* True when the check failed on something other than name/DOB (invalid PAN, vendor unreachable).
|
||||
* The PAN-card fields then have nothing to compare against, so the mismatch reads NONE and the
|
||||
* officer only has to record why the declaration is being taken.
|
||||
*/
|
||||
unverifiable?: boolean;
|
||||
/** Existing declaration, when the officer re-opens one already taken. */
|
||||
existing?: PanDeclarationResult | null;
|
||||
}
|
||||
|
||||
export interface PanDeclarationResult {
|
||||
/** 'NAME', 'DOB', 'NAME,DOB', or 'NONE' when PAN could not be checked at all. */
|
||||
mismatchFields: string;
|
||||
nameOnPan: string | null;
|
||||
dobOnPan: Date | null;
|
||||
/**
|
||||
* The scanned signed declaration, to be filed against the client. Null when an existing
|
||||
* declaration was re-opened and the officer did not replace the document already on file.
|
||||
*/
|
||||
file: File | null;
|
||||
/** Name of the declaration document on record — what the KYC record keeps as the reference. */
|
||||
fileName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the customer declaration that stands in for a passing PAN check.
|
||||
*
|
||||
* The Income Tax API matches on name *and* date of birth and tells us only that the pair failed —
|
||||
* never which half. So rather than show the officer a dead-end error, this dialog asks them to key
|
||||
* in the two values printed on the physical PAN card in front of them. Comparing those against what
|
||||
* was submitted is what produces a specific, defensible "NAME does not match" on the declaration,
|
||||
* and the PAN-card values are kept alongside it as the evidence for that statement.
|
||||
*
|
||||
* Name comparison is deliberately loose about case and internal spacing — "RAJESH KUMAR" and
|
||||
* "Rajesh Kumar" are the same name, and flagging them as a mismatch would put a declaration on file
|
||||
* that says something untrue.
|
||||
*
|
||||
* The declaration itself is taken as an upload rather than typed: what waives the PAN exception is
|
||||
* the paper the customer signed, so the scan of it is the record — an officer's typed note is not.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-pan-declaration-dialog',
|
||||
templateUrl: './pan-declaration-dialog.component.html',
|
||||
styleUrls: ['./pan-declaration-dialog.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
FormsModule,
|
||||
MatIcon,
|
||||
MatDialogContent,
|
||||
MatDialogActions,
|
||||
MatDialogClose
|
||||
]
|
||||
})
|
||||
export class PanDeclarationDialogComponent {
|
||||
data = inject<PanDeclarationDialogData>(MAT_DIALOG_DATA);
|
||||
private dialogRef = inject(MatDialogRef<PanDeclarationDialogComponent>);
|
||||
|
||||
/** Name exactly as printed on the PAN card. */
|
||||
nameOnPan = '';
|
||||
/** Date of birth exactly as printed on the PAN card. */
|
||||
dobOnPan: Date | null = null;
|
||||
|
||||
/** The scan the officer just picked. Null while an earlier upload is still the one on file. */
|
||||
declarationFile: File | null = null;
|
||||
/** Name of the document already filed, when re-opening a declaration that was taken before. */
|
||||
existingFileName = '';
|
||||
/** Why the picked file was rejected — shown in place of failing silently. */
|
||||
fileError = '';
|
||||
|
||||
/** Officer has not yet typed anything to compare against. */
|
||||
compared = false;
|
||||
|
||||
today = new Date();
|
||||
|
||||
readonly acceptedTypes = '.pdf,.jpg,.jpeg,.png';
|
||||
private readonly maxFileBytes = 5 * 1024 * 1024;
|
||||
private readonly acceptedExtensions = [
|
||||
'pdf',
|
||||
'jpg',
|
||||
'jpeg',
|
||||
'png'
|
||||
];
|
||||
|
||||
constructor() {
|
||||
const existing = this.data.existing;
|
||||
if (existing) {
|
||||
this.nameOnPan = existing.nameOnPan || '';
|
||||
this.dobOnPan = existing.dobOnPan || null;
|
||||
this.existingFileName = existing.fileName || '';
|
||||
this.compared = true;
|
||||
}
|
||||
}
|
||||
|
||||
/** The document the declaration will be saved against — newly picked, or the one already filed. */
|
||||
get fileName(): string {
|
||||
return this.declarationFile?.name || this.existingFileName;
|
||||
}
|
||||
|
||||
get hasDeclarationFile(): boolean {
|
||||
return !!this.fileName;
|
||||
}
|
||||
|
||||
/** Size of the picked scan, or a note that the document on file is being kept as it is. */
|
||||
get fileSizeLabel(): string {
|
||||
if (!this.declarationFile) {
|
||||
return 'Already on file';
|
||||
}
|
||||
const kb = this.declarationFile.size / 1024;
|
||||
return kb < 1024 ? `${Math.max(1, Math.round(kb))} KB` : `${(kb / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
onDeclarationFile(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0] || null;
|
||||
input.value = ''; // allow re-picking the same filename
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
const extension = (file.name.split('.').pop() || '').toLowerCase();
|
||||
if (!this.acceptedExtensions.includes(extension)) {
|
||||
this.fileError = 'Upload the signed declaration as a PDF, JPG or PNG.';
|
||||
return;
|
||||
}
|
||||
if (file.size > this.maxFileBytes) {
|
||||
this.fileError = 'The file is larger than 5 MB. Scan at a lower resolution and try again.';
|
||||
return;
|
||||
}
|
||||
this.fileError = '';
|
||||
this.declarationFile = file;
|
||||
}
|
||||
|
||||
/** Drops the picked scan — and the one already filed, since neither may be left implied. */
|
||||
removeDeclarationFile(): void {
|
||||
this.declarationFile = null;
|
||||
this.existingFileName = '';
|
||||
this.fileError = '';
|
||||
}
|
||||
|
||||
/** Case- and spacing-insensitive: only a genuinely different name counts as a mismatch. */
|
||||
private normalizeName(value: string): string {
|
||||
return (value || '').trim().replace(/\s+/g, ' ').toUpperCase();
|
||||
}
|
||||
|
||||
private formatDob(value: Date | null): string {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
const dd = value.getDate().toString().padStart(2, '0');
|
||||
const mm = (value.getMonth() + 1).toString().padStart(2, '0');
|
||||
return `${dd}/${mm}/${value.getFullYear()}`;
|
||||
}
|
||||
|
||||
get nameMismatch(): boolean {
|
||||
if (this.data.unverifiable || !this.nameOnPan.trim()) {
|
||||
return false;
|
||||
}
|
||||
return this.normalizeName(this.nameOnPan) !== this.normalizeName(this.data.submittedName);
|
||||
}
|
||||
|
||||
get dobMismatch(): boolean {
|
||||
if (this.data.unverifiable || !this.dobOnPan) {
|
||||
return false;
|
||||
}
|
||||
// Compare on the rendered day/month/year so a time component on either value cannot matter.
|
||||
return this.formatDob(this.dobOnPan) !== this.normalizeDisplayDob(this.data.submittedDob);
|
||||
}
|
||||
|
||||
/** The submitted DOB arrives pre-formatted for display; reduce it to dd/MM/yyyy for comparison. */
|
||||
private normalizeDisplayDob(display: string): string {
|
||||
const parsed = new Date(display);
|
||||
return isNaN(parsed.getTime()) ? (display || '').trim() : this.formatDob(parsed);
|
||||
}
|
||||
|
||||
/** The fields the declaration will state as differing. */
|
||||
get mismatchFields(): PanMismatchField[] {
|
||||
const fields: PanMismatchField[] = [];
|
||||
if (this.nameMismatch) {
|
||||
fields.push('NAME');
|
||||
}
|
||||
if (this.dobMismatch) {
|
||||
fields.push('DOB');
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
get mismatchLabel(): string {
|
||||
if (this.data.unverifiable) {
|
||||
return 'PAN could not be verified';
|
||||
}
|
||||
const fields = this.mismatchFields;
|
||||
if (!fields.length) {
|
||||
return this.compared ? 'No difference found' : 'Not yet compared';
|
||||
}
|
||||
return fields.map((f) => (f === 'NAME' ? 'Name' : 'Date of Birth')).join(' and ');
|
||||
}
|
||||
|
||||
/** Both PAN-card values must be keyed in before we can honestly say what differs. */
|
||||
get comparisonComplete(): boolean {
|
||||
return !!this.nameOnPan.trim() && !!this.dobOnPan;
|
||||
}
|
||||
|
||||
onCompare(): void {
|
||||
this.compared = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* A declaration must actually declare something. When the officer keys in PAN-card values that
|
||||
* match what was submitted, the mismatch is somewhere else — nothing to sign off here, and the
|
||||
* right move is to retry the verification rather than paper over it.
|
||||
*/
|
||||
get nothingToDeclare(): boolean {
|
||||
return !this.data.unverifiable && this.comparisonComplete && this.mismatchFields.length === 0;
|
||||
}
|
||||
|
||||
get canSubmit(): boolean {
|
||||
// The signed scan is the declaration — without it there is nothing to waive the exception on.
|
||||
if (!this.hasDeclarationFile) {
|
||||
return false;
|
||||
}
|
||||
if (this.data.unverifiable) {
|
||||
return true;
|
||||
}
|
||||
return this.comparisonComplete && this.mismatchFields.length > 0;
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
if (!this.canSubmit) {
|
||||
return;
|
||||
}
|
||||
const result: PanDeclarationResult = {
|
||||
mismatchFields: this.data.unverifiable ? 'NONE' : this.mismatchFields.join(','),
|
||||
nameOnPan: this.data.unverifiable ? null : this.nameOnPan.trim(),
|
||||
dobOnPan: this.data.unverifiable ? null : this.dobOnPan,
|
||||
file: this.declarationFile,
|
||||
fileName: this.fileName
|
||||
};
|
||||
this.dialogRef.close(result);
|
||||
}
|
||||
}
|
||||
@@ -12,4 +12,10 @@
|
||||
export interface Alert {
|
||||
type: string;
|
||||
message: string;
|
||||
/**
|
||||
* Errors are shown as a centred modal that has to be dismissed, because a corner toast that
|
||||
* disappears on its own is too easy to miss for something the user has to act on. Anything
|
||||
* without this keeps the unobtrusive snack bar.
|
||||
*/
|
||||
severity?: 'error';
|
||||
}
|
||||
|
||||
@@ -440,6 +440,18 @@ export class AuthenticationService {
|
||||
return roleNames.some((n) => n === 'cashier');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the logged-in user belongs to the Head Office.
|
||||
* Fineract seeds Head Office as officeId 1 with hierarchy "."; office name is
|
||||
* checked too since some deployments rename/reseed the root office.
|
||||
*/
|
||||
isHeadOfficeUser(): boolean {
|
||||
const creds = this.getCredentials();
|
||||
if (!creds) return false;
|
||||
if (creds.officeId === 1) return true;
|
||||
return (creds.officeName || '').toLowerCase().includes('head office');
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the user credentials.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* 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 { Injectable, inject } from '@angular/core';
|
||||
import { AuthenticationService } from './authentication/authentication.service';
|
||||
import { GL_BRANCHES, GlBranch } from 'app/gl-reports/gl-reports-data';
|
||||
|
||||
/**
|
||||
* Resolves what branch data the logged-in user is allowed to see.
|
||||
*
|
||||
* Head Office sees every branch and can drill into any one of them; a branch login is
|
||||
* locked to its own book. Previously this logic was copy-pasted into the Cash/Clearing
|
||||
* Summary and the NPA report with slightly different branch code lists; this service is
|
||||
* the single place it lives so new reports cannot drift.
|
||||
*
|
||||
* The logged-in office is matched to a branch by name, since session credentials carry
|
||||
* only officeId and officeName. Office names are seeded to match the GL branch names
|
||||
* exactly (see Liquibase 0442).
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class BranchScopeService {
|
||||
private auth = inject(AuthenticationService);
|
||||
|
||||
/** Sentinel used when a branch login cannot be matched to a known branch. */
|
||||
static readonly UNRESOLVED = '__unresolved__';
|
||||
|
||||
get isHeadOffice(): boolean {
|
||||
return this.auth.isHeadOfficeUser();
|
||||
}
|
||||
|
||||
/** The office name on the session, for labelling the scope in report headers. */
|
||||
get officeName(): string {
|
||||
return this.auth.getCredentials()?.officeName || 'Branch Office';
|
||||
}
|
||||
|
||||
/**
|
||||
* Branch code the user is confined to, or null for Head Office.
|
||||
*
|
||||
* Deliberately returns the UNRESOLVED sentinel rather than falling back to the first
|
||||
* branch when the office cannot be matched: showing an unrecognised branch someone
|
||||
* else's figures would be worse than showing them nothing.
|
||||
*/
|
||||
get ownBranchCode(): string | null {
|
||||
if (this.isHeadOffice) {
|
||||
return null;
|
||||
}
|
||||
const name = this.officeName.toLowerCase().trim();
|
||||
const match = GL_BRANCHES.find((b) => b.code !== 'HO' && name === b.name.toLowerCase());
|
||||
// Fall back to a substring match for offices named e.g. "Andheri (E) Branch".
|
||||
const loose = match ?? GL_BRANCHES.find((b) => b.code !== 'HO' && name.includes(b.name.toLowerCase()));
|
||||
return loose?.code ?? BranchScopeService.UNRESOLVED;
|
||||
}
|
||||
|
||||
/** True when the user may choose which branch to view. */
|
||||
get canSelectBranch(): boolean {
|
||||
return this.isHeadOffice;
|
||||
}
|
||||
|
||||
/** Branches offered in a selector — everything for Head Office, nothing for a branch login. */
|
||||
get selectableBranches(): GlBranch[] {
|
||||
return this.isHeadOffice ? GL_BRANCHES : [];
|
||||
}
|
||||
|
||||
/** Branch code a report should start on. '' means all branches consolidated. */
|
||||
get defaultBranchCode(): string {
|
||||
return this.isHeadOffice ? '' : (this.ownBranchCode ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Forces a requested branch code into what the user is actually allowed to see.
|
||||
* Call this wherever a branch code reaches a data query, so a stale or hand-edited
|
||||
* selection cannot widen the user's scope.
|
||||
*/
|
||||
enforce(requested: string): string {
|
||||
return this.isHeadOffice ? requested : (this.ownBranchCode ?? BranchScopeService.UNRESOLVED);
|
||||
}
|
||||
|
||||
/** True when a branch login's office matched no known branch, so it sees no data. */
|
||||
get isUnresolved(): boolean {
|
||||
return this.ownBranchCode === BranchScopeService.UNRESOLVED;
|
||||
}
|
||||
|
||||
/** Human label for the current scope, for report headers. */
|
||||
scopeLabel(selectedCode: string): string {
|
||||
if (!this.isHeadOffice) {
|
||||
return this.officeName;
|
||||
}
|
||||
if (!selectedCode) {
|
||||
return 'All Branches (Consolidated)';
|
||||
}
|
||||
return GL_BRANCHES.find((b) => b.code === selectedCode)?.name ?? selectedCode;
|
||||
}
|
||||
}
|
||||
@@ -88,42 +88,38 @@ export class ErrorHandlerInterceptor implements HttpInterceptor {
|
||||
throw response;
|
||||
}
|
||||
|
||||
// Resolved once and emitted in one place below, so every failure here is consistently tagged
|
||||
// as an error and therefore surfaces as a centred modal rather than a corner toast.
|
||||
let alert: { type: string; message: string } | null = null;
|
||||
if (status === 401 || (environment.oauth.enabled && status === 400)) {
|
||||
this.alertService.alert({ type: 'Authentication Error', message: 'Invalid User Details. Please try again!' });
|
||||
alert = { type: 'Authentication Error', message: 'Invalid User Details. Please try again!' };
|
||||
} else if (status === 403 && errorMessage === 'The provided one time token is invalid') {
|
||||
this.alertService.alert({ type: 'Invalid Token', message: 'Invalid Token. Please try again!' });
|
||||
alert = { type: 'Invalid Token', message: 'Invalid Token. Please try again!' };
|
||||
} else if (status === 400) {
|
||||
this.alertService.alert({
|
||||
type: 'Bad Request',
|
||||
message: errorMessage || 'Invalid parameters were passed in the request!'
|
||||
});
|
||||
alert = { type: 'Bad Request', message: errorMessage || 'Invalid parameters were passed in the request!' };
|
||||
} else if (status === 403) {
|
||||
this.alertService.alert({
|
||||
type: 'Unauthorized Request',
|
||||
message: errorMessage || 'You are not authorized for this request!'
|
||||
});
|
||||
alert = { type: 'Unauthorized Request', message: errorMessage || 'You are not authorized for this request!' };
|
||||
} else if (status === 404) {
|
||||
{
|
||||
this.alertService.alert({
|
||||
type: this.translate.instant('error.resource.not.found'),
|
||||
message: errorMessage || 'Resource does not exist!'
|
||||
});
|
||||
}
|
||||
alert = {
|
||||
type: this.translate.instant('error.resource.not.found'),
|
||||
message: errorMessage || 'Resource does not exist!'
|
||||
};
|
||||
} else if (status === 500) {
|
||||
this.alertService.alert({
|
||||
type: 'Internal Server Error',
|
||||
message: 'Internal Server Error. Please try again later.'
|
||||
});
|
||||
alert = { type: 'Internal Server Error', message: 'Internal Server Error. Please try again later.' };
|
||||
} else if (status === 501) {
|
||||
this.alertService.alert({
|
||||
alert = {
|
||||
type: this.translate.instant('error.resource.notImplemented.type'),
|
||||
message: this.translate.instant('error.resource.notImplemented.message')
|
||||
});
|
||||
};
|
||||
} else if (status === 503) {
|
||||
this.alertService.alert({
|
||||
alert = {
|
||||
type: 'Service Unavailable',
|
||||
message: errorMessage || 'The server is currently unable to handle the request. Please try after some time.'
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
if (alert) {
|
||||
this.alertService.alert({ ...alert, severity: 'error' });
|
||||
}
|
||||
|
||||
throw response;
|
||||
|
||||
@@ -99,6 +99,19 @@
|
||||
</svg>
|
||||
My Disbursements
|
||||
</a>
|
||||
<a
|
||||
class="cashier-nav__item cashier-nav__item--sec"
|
||||
[class.cashier-nav__item--active]="currentUrl.includes('/clerk/share-withdrawal-request')"
|
||||
[routerLink]="['/clerk/share-withdrawal-requests']"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path
|
||||
d="M9.5 9a2.5 2.5 0 0 1 2.5-1.5c1.4 0 2.5.8 2.5 2s-1 1.5-2.5 1.9c-1.5.4-2.5.9-2.5 2.1s1.1 2 2.5 2 2.5-.7 2.5-1.6"
|
||||
/>
|
||||
</svg>
|
||||
My Share Requests
|
||||
</a>
|
||||
@if (clerkAccountLoaded) {
|
||||
<div class="cashier-nav__item-wrap">
|
||||
<a
|
||||
@@ -350,6 +363,10 @@
|
||||
<fa-icon icon="exclamation-triangle" class="ni-ico"></fa-icon>NPA
|
||||
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
|
||||
</a>
|
||||
<a class="ni" tabindex="0" [matMenuTriggerFor]="loanOriginationMenu" *mifosxHasPermission="'MENU_ADVANCES'">
|
||||
<fa-icon icon="clipboard-check" class="ni-ico"></fa-icon>Loan Origination
|
||||
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
|
||||
</a>
|
||||
<a class="ni" tabindex="0" [matMenuTriggerFor]="advancesMenu" *mifosxHasPermission="'MENU_ADVANCES'">
|
||||
<fa-icon icon="hand-holding-usd" class="ni-ico"></fa-icon>Advances
|
||||
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
|
||||
@@ -362,6 +379,10 @@
|
||||
<fa-icon icon="university" class="ni-ico"></fa-icon>RBI
|
||||
<fa-icon [icon]="rbiMenuOpen ? 'caret-up' : 'caret-down'" class="ni-caret"></fa-icon>
|
||||
</a>
|
||||
<a class="ni" tabindex="0" (click)="investmentMenuOpen = !investmentMenuOpen; rbiMenuOpen = false; glMenuOpen = false" *mifosxHasPermission="'MENU_RBI'">
|
||||
<fa-icon icon="chart-line" class="ni-ico"></fa-icon>Investment
|
||||
<fa-icon [icon]="investmentMenuOpen ? 'caret-up' : 'caret-down'" class="ni-caret"></fa-icon>
|
||||
</a>
|
||||
<a class="ni" tabindex="0" [matMenuTriggerFor]="digitalMenu" *mifosxHasPermission="'MENU_DIGITAL'">
|
||||
<fa-icon icon="mobile-alt" class="ni-ico"></fa-icon>Digital
|
||||
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
|
||||
@@ -438,6 +459,10 @@
|
||||
<fa-icon icon="exclamation-triangle" class="ni-ico"></fa-icon>NPA
|
||||
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
|
||||
</a>
|
||||
<a class="ni" tabindex="0" [matMenuTriggerFor]="loanOriginationMenu" *mifosxHasPermission="'MENU_ADVANCES'">
|
||||
<fa-icon icon="clipboard-check" class="ni-ico"></fa-icon>Loan Origination
|
||||
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
|
||||
</a>
|
||||
<a
|
||||
class="ni"
|
||||
tabindex="0"
|
||||
@@ -492,6 +517,15 @@
|
||||
<fa-icon icon="university" class="ni-ico"></fa-icon>RBI
|
||||
<fa-icon [icon]="rbiMenuOpen ? 'caret-up' : 'caret-down'" class="ni-caret"></fa-icon>
|
||||
</a>
|
||||
<a
|
||||
class="ni"
|
||||
tabindex="0"
|
||||
(click)="investmentMenuOpen = !investmentMenuOpen; rbiMenuOpen = false; glMenuOpen = false"
|
||||
*mifosxHasPermission="'MENU_RBI'"
|
||||
>
|
||||
<fa-icon icon="chart-line" class="ni-ico"></fa-icon>Investment
|
||||
<fa-icon [icon]="investmentMenuOpen ? 'caret-up' : 'caret-down'" class="ni-caret"></fa-icon>
|
||||
</a>
|
||||
<a class="ni" tabindex="0" [matMenuTriggerFor]="reportsMenu" *mifosxHasPermission="'MENU_REPORTS'">
|
||||
<fa-icon icon="chart-bar" class="ni-ico"></fa-icon>Reports
|
||||
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
|
||||
@@ -566,6 +600,19 @@
|
||||
<button mat-menu-item [routerLink]="['/reports', 'Accounting']" tabindex="0">
|
||||
<i class="fa fa-calculator menu-item-icon"></i> Accounting
|
||||
</button>
|
||||
<mat-divider></mat-divider>
|
||||
<button mat-menu-item routerLink="/reports/form-97-98" tabindex="0">
|
||||
<i class="fa fa-file-invoice menu-item-icon"></i> Form 97/98 Transaction Report
|
||||
</button>
|
||||
<button mat-menu-item routerLink="/reports/risk-review" tabindex="0">
|
||||
<i class="fa fa-user-shield menu-item-icon"></i> Risk Review Report
|
||||
</button>
|
||||
<button mat-menu-item routerLink="/reports/tds-interest" tabindex="0">
|
||||
<i class="fa fa-percent menu-item-icon"></i> Deposit Interest & TDS
|
||||
</button>
|
||||
<button mat-menu-item routerLink="/reports/tds-remittance" tabindex="0">
|
||||
<i class="fa fa-landmark menu-item-icon"></i> TDS Remitted to Government
|
||||
</button>
|
||||
</mat-menu>
|
||||
|
||||
<!-- Admin Mega Menu — 3-column rectangular panel grouping Setup,
|
||||
@@ -653,6 +700,7 @@
|
||||
<a
|
||||
class="gl-link"
|
||||
[class.hidden]="!matchesSubLedgerSearch('Cash / Clearing / Transfer')"
|
||||
routerLink="/cashier/cash-clearing-transfer-summary"
|
||||
(click)="subLedgerOpen = false"
|
||||
><fa-icon icon="money-bill" class="gl-icon"></fa-icon> Cash / Clearing / Transfer</a
|
||||
>
|
||||
@@ -701,8 +749,11 @@
|
||||
</div>
|
||||
<div class="gl-group">
|
||||
<div class="gl-group-title">Deposits</div>
|
||||
<a class="gl-link" [class.hidden]="!matchesSubLedgerSearch('FD / RD Entry')" (click)="subLedgerOpen = false"
|
||||
><fa-icon icon="piggy-bank" class="gl-icon"></fa-icon> FD / RD Entry</a
|
||||
<a
|
||||
class="gl-link"
|
||||
[class.hidden]="!matchesSubLedgerSearch('FD / RD Entry')"
|
||||
(click)="subLedgerOpen = false; openFdRdEntryDialog()"
|
||||
><fa-icon icon="piggy-bank" class="gl-icon" style="color: #0f766e"></fa-icon> FD / RD Entry</a
|
||||
>
|
||||
<a class="gl-link" [class.hidden]="!matchesSubLedgerSearch('DDS Entry')" (click)="subLedgerOpen = false"
|
||||
><fa-icon icon="coins" class="gl-icon"></fa-icon> DDS Entry</a
|
||||
@@ -808,50 +859,72 @@
|
||||
<div class="gl-columns">
|
||||
<div class="gl-group">
|
||||
<div class="gl-group-title">Financial Statements</div>
|
||||
<a class="gl-link" [class.hidden]="!matchesGlSearch('Trial Balance')" (click)="glMenuOpen = false"
|
||||
><fa-icon icon="balance-scale" class="gl-icon"></fa-icon> Trial Balance</a
|
||||
<a
|
||||
class="gl-link"
|
||||
[class.hidden]="!matchesGlSearch('Trial Balance')"
|
||||
routerLink="/gl/reports/trial-balance"
|
||||
(click)="glMenuOpen = false"
|
||||
><fa-icon icon="balance-scale" class="gl-icon" style="color: #b45309"></fa-icon> Trial Balance</a
|
||||
>
|
||||
<a
|
||||
class="gl-link"
|
||||
[class.hidden]="!matchesGlSearch('Profit and Loss')"
|
||||
(click)="glMenuOpen = false; openAccountingReportsDialog('PNL')"
|
||||
routerLink="/gl/reports/profit-and-loss"
|
||||
(click)="glMenuOpen = false"
|
||||
><fa-icon icon="chart-line" class="gl-icon" style="color: #b45309"></fa-icon> Profit and Loss</a
|
||||
>
|
||||
<a
|
||||
class="gl-link"
|
||||
[class.hidden]="!matchesGlSearch('Consolidate Balance Sheet')"
|
||||
(click)="glMenuOpen = false; openAccountingReportsDialog('BALANCE-SHEET')"
|
||||
><fa-icon icon="file-alt" class="gl-icon" style="color: #b45309"></fa-icon> Consolidate Balance Sheet</a
|
||||
routerLink="/gl/reports/balance-sheet"
|
||||
(click)="glMenuOpen = false"
|
||||
><fa-icon icon="file-alt" class="gl-icon" style="color: #b45309"></fa-icon> Consolidated Balance Sheet</a
|
||||
>
|
||||
<a
|
||||
class="gl-link"
|
||||
[class.hidden]="!matchesGlSearch('Comparative Balance Sheet')"
|
||||
(click)="glMenuOpen = false; openAccountingReportsDialog('COMPARATIVE-BS')"
|
||||
routerLink="/gl/reports/comparative-balance-sheet"
|
||||
(click)="glMenuOpen = false"
|
||||
><fa-icon icon="balance-scale" class="gl-icon" style="color: #b45309"></fa-icon> Comparative Balance
|
||||
Sheet</a
|
||||
>
|
||||
</div>
|
||||
<div class="gl-group">
|
||||
<div class="gl-group-title">GL Reports</div>
|
||||
<a class="gl-link" [class.hidden]="!matchesGlSearch('GL Average Balance')" (click)="glMenuOpen = false"
|
||||
><fa-icon icon="calculator" class="gl-icon"></fa-icon> GL Average Balance</a
|
||||
<a
|
||||
class="gl-link"
|
||||
[class.hidden]="!matchesGlSearch('GL Average Balance')"
|
||||
routerLink="/gl/reports/gl-average-balance"
|
||||
(click)="glMenuOpen = false"
|
||||
><fa-icon icon="calculator" class="gl-icon" style="color: #b45309"></fa-icon> GL Average Balance</a
|
||||
>
|
||||
<a class="gl-link" [class.hidden]="!matchesGlSearch('GL Mapping for Report')" (click)="glMenuOpen = false"
|
||||
><fa-icon icon="project-diagram" class="gl-icon"></fa-icon> GL Mapping for Report</a
|
||||
<a
|
||||
class="gl-link"
|
||||
[class.hidden]="!matchesGlSearch('GL Mapping for Report')"
|
||||
routerLink="/gl/reports/gl-mapping"
|
||||
(click)="glMenuOpen = false"
|
||||
><fa-icon icon="project-diagram" class="gl-icon" style="color: #b45309"></fa-icon> GL Mapping for
|
||||
Report</a
|
||||
>
|
||||
<a
|
||||
class="gl-link"
|
||||
[class.hidden]="!matchesGlSearch('Account wise Balance List')"
|
||||
routerLink="/gl/reports/account-wise-balance"
|
||||
(click)="glMenuOpen = false"
|
||||
><fa-icon icon="list" class="gl-icon"></fa-icon> Account wise Balance List</a
|
||||
><fa-icon icon="list" class="gl-icon" style="color: #b45309"></fa-icon> Account wise Balance List</a
|
||||
>
|
||||
<a class="gl-link" [class.hidden]="!matchesGlSearch('Distribution of Balance')" (click)="glMenuOpen = false"
|
||||
><fa-icon icon="chart-pie" class="gl-icon"></fa-icon> Distribution of Balance</a
|
||||
<a
|
||||
class="gl-link"
|
||||
[class.hidden]="!matchesGlSearch('Distribution of Balance')"
|
||||
routerLink="/gl/reports/distribution-of-balance"
|
||||
(click)="glMenuOpen = false"
|
||||
><fa-icon icon="chart-pie" class="gl-icon" style="color: #b45309"></fa-icon> Distribution of Balance</a
|
||||
>
|
||||
<a
|
||||
class="gl-link"
|
||||
[class.hidden]="!matchesGlSearch('GL Wise DR CR')"
|
||||
(click)="glMenuOpen = false; openAccountingReportsDialog('GL-DR-CR')"
|
||||
routerLink="/gl/reports/gl-wise-dr-cr"
|
||||
(click)="glMenuOpen = false"
|
||||
><fa-icon icon="project-diagram" class="gl-icon" style="color: #b45309"></fa-icon> GL Wise DR / CR
|
||||
Report</a
|
||||
>
|
||||
@@ -861,28 +934,47 @@
|
||||
<a
|
||||
class="gl-link"
|
||||
[class.hidden]="!matchesGlSearch('Client wise Balance Report')"
|
||||
routerLink="/gl/reports/client-wise-balance"
|
||||
(click)="glMenuOpen = false"
|
||||
><fa-icon icon="users" class="gl-icon"></fa-icon> Client wise Balance Report</a
|
||||
><fa-icon icon="users" class="gl-icon" style="color: #b45309"></fa-icon> Client wise Balance Report</a
|
||||
>
|
||||
<a class="gl-link" [class.hidden]="!matchesGlSearch('Contingent Expense')" (click)="glMenuOpen = false"
|
||||
><fa-icon icon="exclamation-circle" class="gl-icon"></fa-icon> Contingent Expense</a
|
||||
<a
|
||||
class="gl-link"
|
||||
[class.hidden]="!matchesGlSearch('Contingent Liabilities')"
|
||||
routerLink="/gl/reports/contingent-liabilities"
|
||||
(click)="glMenuOpen = false"
|
||||
><fa-icon icon="exclamation-circle" class="gl-icon" style="color: #b45309"></fa-icon> Contingent
|
||||
Liabilities</a
|
||||
>
|
||||
<a class="gl-link" [class.hidden]="!matchesGlSearch('Banker Head Report')" (click)="glMenuOpen = false"
|
||||
><fa-icon icon="university" class="gl-icon"></fa-icon> Banker Head Report</a
|
||||
<a
|
||||
class="gl-link"
|
||||
[class.hidden]="!matchesGlSearch('Banker Head Report')"
|
||||
routerLink="/gl/reports/banker-head"
|
||||
(click)="glMenuOpen = false"
|
||||
><fa-icon icon="university" class="gl-icon" style="color: #b45309"></fa-icon> Banker Head Report</a
|
||||
>
|
||||
</div>
|
||||
<div class="gl-group">
|
||||
<div class="gl-group-title">Registers</div>
|
||||
<a class="gl-link" [class.hidden]="!matchesGlSearch('Sundry Register')" (click)="glMenuOpen = false"
|
||||
><fa-icon icon="clipboard-list" class="gl-icon"></fa-icon> Sundry Register</a
|
||||
<a
|
||||
class="gl-link"
|
||||
[class.hidden]="!matchesGlSearch('Sundry Register')"
|
||||
routerLink="/gl/reports/sundry-register"
|
||||
(click)="glMenuOpen = false"
|
||||
><fa-icon icon="clipboard-list" class="gl-icon" style="color: #b45309"></fa-icon> Sundry Register</a
|
||||
>
|
||||
<a class="gl-link" [class.hidden]="!matchesGlSearch('Voucher Register')" (click)="glMenuOpen = false"
|
||||
><fa-icon icon="receipt" class="gl-icon"></fa-icon> Voucher Register</a
|
||||
<a
|
||||
class="gl-link"
|
||||
[class.hidden]="!matchesGlSearch('Voucher Register')"
|
||||
routerLink="/gl/reports/voucher-register"
|
||||
(click)="glMenuOpen = false"
|
||||
><fa-icon icon="receipt" class="gl-icon" style="color: #b45309"></fa-icon> Voucher Register</a
|
||||
>
|
||||
<a
|
||||
class="gl-link"
|
||||
[class.hidden]="!matchesGlSearch('Misc Expense Ledger')"
|
||||
(click)="glMenuOpen = false; openAccountingReportsDialog('MISC-EXP')"
|
||||
routerLink="/gl/reports/misc-expense-ledger"
|
||||
(click)="glMenuOpen = false"
|
||||
><fa-icon icon="receipt" class="gl-icon" style="color: #b45309"></fa-icon> Misc Expense Ledger</a
|
||||
>
|
||||
</div>
|
||||
@@ -1155,7 +1247,7 @@
|
||||
<fa-icon icon="user-slash" class="menu-item-icon" style="color: #c2410c"></fa-icon> Non-Customer Entry
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0"><fa-icon icon="lock" class="menu-item-icon"></fa-icon> Locker</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<button mat-menu-item routerLink="/investment/bonds" tabindex="0">
|
||||
<fa-icon icon="chart-line" class="menu-item-icon"></fa-icon> SLR and Non SLR Investment Report
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0" (click)="openCicReportingDialog()">
|
||||
@@ -1171,13 +1263,13 @@
|
||||
<!-- Investment -->
|
||||
<div class="deposit-menu-group">
|
||||
<div class="deposit-group-title">Investment</div>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<button mat-menu-item routerLink="/investment/dashboard" tabindex="0">
|
||||
<fa-icon icon="calendar-alt" class="menu-item-icon"></fa-icon> Due Date Diary for All Investment
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<button mat-menu-item routerLink="/investment/reports" tabindex="0">
|
||||
<fa-icon icon="dollar-sign" class="menu-item-icon"></fa-icon> Total Int Received & Receivable Report
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<button mat-menu-item routerLink="/investment/reports" tabindex="0">
|
||||
<fa-icon icon="piggy-bank" class="menu-item-icon"></fa-icon> Bucket Wise Investment
|
||||
</button>
|
||||
<button mat-menu-item routerLink="/deaf" tabindex="0">
|
||||
@@ -1208,39 +1300,96 @@
|
||||
<!-- NPA Reports -->
|
||||
<div class="deposit-menu-group">
|
||||
<div class="deposit-group-title">NPA Reports</div>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<fa-icon icon="chart-line" class="menu-item-icon"></fa-icon> NPA Interest
|
||||
<button mat-menu-item tabindex="0" routerLink="/npa/reports/interest">
|
||||
<fa-icon icon="chart-line" class="menu-item-icon"></fa-icon> NPA Report
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<button mat-menu-item tabindex="0" routerLink="/npa/reports/status">
|
||||
<fa-icon icon="file-alt" class="menu-item-icon"></fa-icon> NPA Status Report
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<button mat-menu-item tabindex="0" routerLink="/npa/reports/recovery">
|
||||
<fa-icon icon="undo" class="menu-item-icon"></fa-icon> NPA Recovery Report
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<button mat-menu-item tabindex="0" routerLink="/npa/reports/summary">
|
||||
<fa-icon icon="clipboard-list" class="menu-item-icon"></fa-icon> NPA Summary Report
|
||||
</button>
|
||||
</div>
|
||||
<!-- NPA Analysis -->
|
||||
<div class="deposit-menu-group">
|
||||
<div class="deposit-group-title">NPA Analysis</div>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<button mat-menu-item tabindex="0" routerLink="/npa/reports/account-detail">
|
||||
<fa-icon icon="list-alt" class="menu-item-icon"></fa-icon> NPA Account Detail Report
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<button mat-menu-item tabindex="0" routerLink="/npa/reports/movement">
|
||||
<fa-icon icon="exchange" class="menu-item-icon"></fa-icon> NPA Movement Report
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<button mat-menu-item tabindex="0" routerLink="/npa/reports/provision">
|
||||
<fa-icon icon="hand-holding-usd" class="menu-item-icon"></fa-icon> NPA with Provision and Required Provision
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0" routerLink="/npa/reports/undebited-interest">
|
||||
<fa-icon icon="coins" class="menu-item-icon"></fa-icon> Undebited Interest Recovery Report
|
||||
</button>
|
||||
</div>
|
||||
<!-- NPA Classification -->
|
||||
<div class="deposit-menu-group">
|
||||
<div class="deposit-group-title">NPA Classification</div>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<button mat-menu-item tabindex="0" routerLink="/npa/reports/probable">
|
||||
<fa-icon icon="exclamation-circle" class="menu-item-icon"></fa-icon> Probable NPA
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0"><fa-icon icon="flag" class="menu-item-icon"></fa-icon> SMA Report</button>
|
||||
<button mat-menu-item tabindex="0" routerLink="/npa/reports/sma">
|
||||
<fa-icon icon="flag" class="menu-item-icon"></fa-icon> SMA Report
|
||||
</button>
|
||||
</div>
|
||||
<!-- Recovery Management -->
|
||||
<div class="deposit-menu-group">
|
||||
<div class="deposit-group-title">Recovery Management</div>
|
||||
<button mat-menu-item tabindex="0" routerLink="/npa/reports/recovery-dashboard">
|
||||
<fa-icon icon="tachometer-alt" class="menu-item-icon"></fa-icon> Recovery Management Dashboard
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0" routerLink="/npa/reports/daywise-overdue">
|
||||
<fa-icon icon="calendar-alt" class="menu-item-icon"></fa-icon> Day-wise Overdue Report
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0" routerLink="/npa/reports/daywise-overdrawn">
|
||||
<fa-icon icon="credit-card" class="menu-item-icon"></fa-icon> Day-wise Overdrawn Report
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0" routerLink="/npa/reports/agri-gold-value">
|
||||
<fa-icon icon="crop" class="menu-item-icon"></fa-icon> Agriculture / Gold Market Value Report
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0" routerLink="/npa/reports/exception-check">
|
||||
<fa-icon icon="balance-scale" class="menu-item-icon"></fa-icon> Exception Check — Security Value vs Loan
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</mat-menu>
|
||||
|
||||
<mat-menu #loanOriginationMenu="matMenu" class="npa-mega-panel" [overlapTrigger]="false">
|
||||
<div class="deposit-menu-grid npa-grid" (click)="$event.stopPropagation()">
|
||||
<!-- Loan Origination -->
|
||||
<div class="deposit-menu-group">
|
||||
<div class="deposit-group-title">Loan Origination</div>
|
||||
<button mat-menu-item tabindex="0" routerLink="/loan-origination/pipeline">
|
||||
<fa-icon icon="stream" class="menu-item-icon"></fa-icon> Application Pipeline
|
||||
</button>
|
||||
</div>
|
||||
<!-- Reports -->
|
||||
<div class="deposit-menu-group">
|
||||
<div class="deposit-group-title">Reports</div>
|
||||
<button
|
||||
mat-menu-item
|
||||
tabindex="0"
|
||||
routerLink="/loan-origination/pipeline"
|
||||
[queryParams]="{ view: 'channel' }"
|
||||
>
|
||||
<fa-icon icon="chart-pie" class="menu-item-icon"></fa-icon> Channel-wise Report
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0" routerLink="/loan-origination/pipeline" [queryParams]="{ view: 'bc' }">
|
||||
<fa-icon icon="mobile-alt" class="menu-item-icon"></fa-icon> BC-wise Report
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0" routerLink="/loan-origination/pipeline" [queryParams]="{ view: 'share' }">
|
||||
<fa-icon icon="coins" class="menu-item-icon"></fa-icon> Share Member Report
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0" routerLink="/loan-origination/pipeline" [queryParams]="{ view: 'target' }">
|
||||
<fa-icon icon="chart-column" class="menu-item-icon"></fa-icon> Target vs Actual
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</mat-menu>
|
||||
@@ -1273,27 +1422,27 @@
|
||||
<!-- Core / Books -->
|
||||
<div class="deposit-menu-group">
|
||||
<div class="deposit-group-title">Core & Books</div>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<button mat-menu-item tabindex="0" routerLink="/reconciliation/voucher">
|
||||
<fa-icon icon="receipt" class="menu-item-icon" style="color: #0d9488"></fa-icon>
|
||||
Voucher Reconciliation
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<button mat-menu-item tabindex="0" routerLink="/reconciliation/sundry-dr-cr">
|
||||
<fa-icon icon="random" class="menu-item-icon" style="color: #0d9488"></fa-icon>
|
||||
Sundry Dr / Cr Reconciliation
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<button mat-menu-item tabindex="0" routerLink="/reconciliation/pnl-impact">
|
||||
<fa-icon icon="chart-line" class="menu-item-icon" style="color: #0d9488"></fa-icon>
|
||||
P&L Impact Reconciliation
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<button mat-menu-item tabindex="0" routerLink="/reconciliation/gl-reconciliation">
|
||||
<fa-icon icon="book-open" class="menu-item-icon" style="color: #0d9488"></fa-icon>
|
||||
GL Reconciliation
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<button mat-menu-item tabindex="0" routerLink="/reconciliation/fixed-assets">
|
||||
<fa-icon icon="cube" class="menu-item-icon" style="color: #0d9488"></fa-icon>
|
||||
Fixed Assets Reconciliation
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<button mat-menu-item tabindex="0" routerLink="/reconciliation/inter-company">
|
||||
<fa-icon icon="sitemap" class="menu-item-icon" style="color: #0d9488"></fa-icon>
|
||||
Inter-Company Reconciliation
|
||||
</button>
|
||||
@@ -1302,19 +1451,19 @@
|
||||
<!-- Bank & Clearing -->
|
||||
<div class="deposit-menu-group">
|
||||
<div class="deposit-group-title">Bank & Clearing</div>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<button mat-menu-item tabindex="0" routerLink="/reconciliation/bank-reco">
|
||||
<fa-icon icon="university" class="menu-item-icon" style="color: #0d9488"></fa-icon>
|
||||
Bank Reconciliation
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<button mat-menu-item tabindex="0" routerLink="/reconciliation/api-bank-reco">
|
||||
<fa-icon icon="code-branch" class="menu-item-icon" style="color: #0d9488"></fa-icon>
|
||||
API Based Bank Reconciliation
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<button mat-menu-item tabindex="0" routerLink="/reconciliation/neft-rtgs">
|
||||
<fa-icon icon="exchange" class="menu-item-icon" style="color: #0d9488"></fa-icon>
|
||||
NEFT / RTGS Reconciliation
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<button mat-menu-item tabindex="0" routerLink="/reconciliation/clearing-reco">
|
||||
<fa-icon icon="hand-holding-usd" class="menu-item-icon" style="color: #0d9488"></fa-icon>
|
||||
Clearing Reconciliation
|
||||
</button>
|
||||
@@ -1323,15 +1472,15 @@
|
||||
<!-- Digital / Payments -->
|
||||
<div class="deposit-menu-group">
|
||||
<div class="deposit-group-title">Digital & Payments</div>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<button mat-menu-item tabindex="0" routerLink="/reconciliation/udir">
|
||||
<fa-icon icon="mobile-alt" class="menu-item-icon" style="color: #0d9488"></fa-icon>
|
||||
UDIR (IMPS / UPI / ATM)
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<button mat-menu-item tabindex="0" routerLink="/reconciliation/three-way-digital">
|
||||
<fa-icon icon="project-diagram" class="menu-item-icon" style="color: #0d9488"></fa-icon>
|
||||
Three-way Digital Reconciliation
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<button mat-menu-item tabindex="0" routerLink="/reconciliation/nach-reco">
|
||||
<fa-icon icon="sync-alt" class="menu-item-icon" style="color: #0d9488"></fa-icon>
|
||||
NACH Reconciliation
|
||||
</button>
|
||||
@@ -1344,7 +1493,7 @@
|
||||
<fa-icon icon="money-check-alt" class="menu-item-icon" style="color: #0d9488"></fa-icon>
|
||||
DD / Pay Order Reconciliation
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<button mat-menu-item tabindex="0" routerLink="/reconciliation/share-member">
|
||||
<fa-icon icon="coins" class="menu-item-icon" style="color: #0d9488"></fa-icon>
|
||||
Share Member Reconciliation
|
||||
</button>
|
||||
@@ -1353,11 +1502,11 @@
|
||||
<!-- Compliance & Financials -->
|
||||
<div class="deposit-menu-group">
|
||||
<div class="deposit-group-title">Compliance & Financials</div>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<button mat-menu-item tabindex="0" routerLink="/reconciliation/invoice-payment">
|
||||
<fa-icon icon="file-invoice" class="menu-item-icon" style="color: #0d9488"></fa-icon>
|
||||
Invoice Payment
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<button mat-menu-item tabindex="0" routerLink="/reconciliation/tax-reco">
|
||||
<fa-icon icon="percentage" class="menu-item-icon" style="color: #0d9488"></fa-icon>
|
||||
Tax Reconciliation
|
||||
</button>
|
||||
@@ -1469,9 +1618,20 @@
|
||||
<button mat-menu-item tabindex="0">
|
||||
<fa-icon icon="file-signature" class="menu-item-icon" style="color: #15803d"></fa-icon> Form 98
|
||||
</button>
|
||||
<button mat-menu-item routerLink="/reports/form-97-98" tabindex="0">
|
||||
<fa-icon icon="file-invoice" class="menu-item-icon" style="color: #15803d"></fa-icon> Form 97/98 Txn Report
|
||||
</button>
|
||||
<button mat-menu-item routerLink="/letter-mgmt/form-15gh" tabindex="0">
|
||||
<fa-icon icon="file-signature" class="menu-item-icon" style="color: #15803d"></fa-icon> Form 121
|
||||
</button>
|
||||
<button mat-menu-item routerLink="/reports/tds-interest" tabindex="0">
|
||||
<fa-icon icon="percentage" class="menu-item-icon" style="color: #15803d"></fa-icon> Deposit Interest &
|
||||
TDS
|
||||
</button>
|
||||
<button mat-menu-item routerLink="/reports/tds-remittance" tabindex="0">
|
||||
<fa-icon icon="building-columns" class="menu-item-icon" style="color: #15803d"></fa-icon> TDS Remitted to
|
||||
Govt
|
||||
</button>
|
||||
<button mat-menu-item routerLink="/letter-mgmt/hypothecation-certificate" tabindex="0">
|
||||
<fa-icon icon="file-alt" class="menu-item-icon" style="color: #15803d"></fa-icon>
|
||||
Hypothecation Certificate
|
||||
@@ -1673,28 +1833,41 @@
|
||||
>
|
||||
<fa-icon icon="shield-alt" class="gl-icon"></fa-icon> AML
|
||||
</a>
|
||||
<a
|
||||
class="gl-link"
|
||||
[class.hidden]="!matchesRbiSearch('Risk Review Report')"
|
||||
routerLink="/reports/risk-review"
|
||||
(click)="rbiMenuOpen = false"
|
||||
>
|
||||
<fa-icon icon="user-shield" class="gl-icon"></fa-icon> Risk Review Report
|
||||
</a>
|
||||
</div>
|
||||
<!-- OSS Reports -->
|
||||
<div class="gl-group">
|
||||
<div class="gl-group-title">OSS Reports</div>
|
||||
<a class="gl-link" [class.hidden]="!matchesRbiSearch('OSS 1 XBRL/CIMS')" (click)="rbiMenuOpen = false">
|
||||
<fa-icon icon="file-alt" class="gl-icon"></fa-icon> OSS 1 XBRL/CIMS
|
||||
<a
|
||||
class="gl-link"
|
||||
[class.hidden]="!matchesRbiSearch('OSS 1 XBRL/CIMS')"
|
||||
routerLink="/rbi/oss1"
|
||||
(click)="rbiMenuOpen = false"
|
||||
>
|
||||
<fa-icon icon="file-alt" class="gl-icon" style="color: #0891b2"></fa-icon> OSS 1 XBRL/CIMS
|
||||
</a>
|
||||
<a
|
||||
class="gl-link"
|
||||
[class.hidden]="!matchesRbiSearch('OSS 2 XBRL')"
|
||||
(click)="rbiMenuOpen = false; openCimsXbrlDialog('OSS-2')"
|
||||
routerLink="/rbi/oss2"
|
||||
(click)="rbiMenuOpen = false"
|
||||
>
|
||||
<fa-icon icon="file-alt" class="gl-icon" style="color: #0891b2"></fa-icon> OSS 2 XBRL
|
||||
</a>
|
||||
<a class="gl-link" [class.hidden]="!matchesRbiSearch('OSS 3 XBRL/CIMS')" (click)="rbiMenuOpen = false">
|
||||
<fa-icon icon="file-alt" class="gl-icon"></fa-icon> OSS 3 XBRL/CIMS
|
||||
</a>
|
||||
<a class="gl-link" [class.hidden]="!matchesRbiSearch('OSS 4/OSS 5/OSS 6')" (click)="rbiMenuOpen = false">
|
||||
<fa-icon icon="file-alt" class="gl-icon"></fa-icon> OSS 4 / OSS 5 / OSS 6
|
||||
</a>
|
||||
<a class="gl-link" [class.hidden]="!matchesRbiSearch('OSS 7')" (click)="rbiMenuOpen = false">
|
||||
<fa-icon icon="file-alt" class="gl-icon"></fa-icon> OSS 7
|
||||
<a
|
||||
class="gl-link"
|
||||
[class.hidden]="!matchesRbiSearch('OSS 3 XBRL/CIMS')"
|
||||
routerLink="/rbi/oss3"
|
||||
(click)="rbiMenuOpen = false"
|
||||
>
|
||||
<fa-icon icon="file-alt" class="gl-icon" style="color: #0891b2"></fa-icon> OSS 3 XBRL/CIMS
|
||||
</a>
|
||||
</div>
|
||||
<!-- CIMS / XBRL Returns -->
|
||||
@@ -1825,6 +1998,161 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Investment (Treasury) Mega Menu -->
|
||||
@if (investmentMenuOpen) {
|
||||
<div class="gl-backdrop" (click)="investmentMenuOpen = false"></div>
|
||||
<div class="gl-mega-menu rbi-mega-menu">
|
||||
<div class="gl-mega-menu-header">
|
||||
<fa-icon icon="chart-line" class="gl-mega-menu-header-icon"></fa-icon>
|
||||
<span>Investment</span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
class="gl-search"
|
||||
placeholder="Search Investment..."
|
||||
(input)="investmentSearch = $any($event.target).value"
|
||||
/>
|
||||
<div class="rbi-columns">
|
||||
<!-- Dashboard -->
|
||||
<div class="gl-group">
|
||||
<div class="gl-group-title">Dashboard</div>
|
||||
<a
|
||||
class="gl-link"
|
||||
[class.hidden]="!matchesInvestmentSearch('Investment Dashboard')"
|
||||
routerLink="/investment/dashboard"
|
||||
(click)="investmentMenuOpen = false"
|
||||
>
|
||||
<fa-icon icon="chart-pie" class="gl-icon"></fa-icon> Portfolio Dashboard
|
||||
</a>
|
||||
<a
|
||||
class="gl-link"
|
||||
[class.hidden]="!matchesInvestmentSearch('Due Date Diary for All Investment')"
|
||||
routerLink="/investment/dashboard"
|
||||
(click)="investmentMenuOpen = false"
|
||||
>
|
||||
<fa-icon icon="calendar-check" class="gl-icon"></fa-icon> Due Date Diary for All Investment
|
||||
</a>
|
||||
<a
|
||||
class="gl-link"
|
||||
[class.hidden]="!matchesInvestmentSearch('Bucket Wise Investment')"
|
||||
routerLink="/investment/reports"
|
||||
(click)="investmentMenuOpen = false"
|
||||
>
|
||||
<fa-icon icon="layer-group" class="gl-icon"></fa-icon> Bucket Wise Investment
|
||||
</a>
|
||||
</div>
|
||||
<!-- Entry -->
|
||||
<div class="gl-group">
|
||||
<div class="gl-group-title">Entry</div>
|
||||
<a
|
||||
class="gl-link"
|
||||
[class.hidden]="!matchesInvestmentSearch('FD Investment')"
|
||||
routerLink="/investment/fd"
|
||||
(click)="investmentMenuOpen = false"
|
||||
>
|
||||
<fa-icon icon="piggy-bank" class="gl-icon"></fa-icon> FD Investment
|
||||
</a>
|
||||
<a
|
||||
class="gl-link"
|
||||
[class.hidden]="!matchesInvestmentSearch('Govt Security / Non-SLR / Bond / Mutual Fund')"
|
||||
routerLink="/investment/bonds"
|
||||
(click)="investmentMenuOpen = false"
|
||||
>
|
||||
<fa-icon icon="university" class="gl-icon"></fa-icon> Govt Security / Non-SLR / Bond / Mutual Fund
|
||||
</a>
|
||||
<a
|
||||
class="gl-link"
|
||||
[class.hidden]="!matchesInvestmentSearch('SLR and Non SLR Investment Report')"
|
||||
routerLink="/investment/bonds"
|
||||
(click)="investmentMenuOpen = false"
|
||||
>
|
||||
<fa-icon icon="balance-scale" class="gl-icon"></fa-icon> SLR and Non SLR Investment Report
|
||||
</a>
|
||||
<a
|
||||
class="gl-link"
|
||||
[class.hidden]="!matchesInvestmentSearch('Share Entry')"
|
||||
routerLink="/investment/share"
|
||||
(click)="investmentMenuOpen = false"
|
||||
>
|
||||
<fa-icon icon="chart-line" class="gl-icon"></fa-icon> Share Entry
|
||||
</a>
|
||||
</div>
|
||||
<!-- Masters -->
|
||||
<div class="gl-group">
|
||||
<div class="gl-group-title">Masters</div>
|
||||
<a
|
||||
class="gl-link"
|
||||
[class.hidden]="
|
||||
!matchesInvestmentSearch(
|
||||
'Bank Party Script Broker SubType Control Parameter Daily Script LIEN Reason Mutual Fund Product SGL Bank Script Market Detail Masters'
|
||||
)
|
||||
"
|
||||
routerLink="/investment/masters"
|
||||
(click)="investmentMenuOpen = false"
|
||||
>
|
||||
<fa-icon icon="cogs" class="gl-icon"></fa-icon> Bank/Party, Script, Broker & Other Masters
|
||||
</a>
|
||||
</div>
|
||||
<!-- Interest -->
|
||||
<div class="gl-group">
|
||||
<div class="gl-group-title">Interest</div>
|
||||
<a
|
||||
class="gl-link"
|
||||
[class.hidden]="!matchesInvestmentSearch('FD Interest Calculation')"
|
||||
routerLink="/investment/interest"
|
||||
(click)="investmentMenuOpen = false"
|
||||
>
|
||||
<fa-icon icon="calculator" class="gl-icon"></fa-icon> FD Interest Calculation
|
||||
</a>
|
||||
<a
|
||||
class="gl-link"
|
||||
[class.hidden]="!matchesInvestmentSearch('Govt Bond Interest Calculation')"
|
||||
routerLink="/investment/interest"
|
||||
(click)="investmentMenuOpen = false"
|
||||
>
|
||||
<fa-icon icon="calculator" class="gl-icon"></fa-icon> Govt./Bond Int. Calculation
|
||||
</a>
|
||||
<a
|
||||
class="gl-link"
|
||||
[class.hidden]="!matchesInvestmentSearch('Government Bond Int Register FD Interest Register FD Register')"
|
||||
routerLink="/investment/interest"
|
||||
(click)="investmentMenuOpen = false"
|
||||
>
|
||||
<fa-icon icon="list-alt" class="gl-icon"></fa-icon> Bond Int / FD Int / FD Registers
|
||||
</a>
|
||||
</div>
|
||||
<!-- Reports -->
|
||||
<div class="gl-group">
|
||||
<div class="gl-group-title">Reports</div>
|
||||
<a
|
||||
class="gl-link"
|
||||
[class.hidden]="!matchesInvestmentSearch('Balance Ledger Maturity Investment Reports')"
|
||||
routerLink="/investment/reports"
|
||||
(click)="investmentMenuOpen = false"
|
||||
>
|
||||
<fa-icon icon="file-invoice" class="gl-icon"></fa-icon> Balance / Ledger / Maturity Reports
|
||||
</a>
|
||||
<a
|
||||
class="gl-link"
|
||||
[class.hidden]="!matchesInvestmentSearch('Total Int Received & Receivable Report')"
|
||||
routerLink="/investment/reports"
|
||||
(click)="investmentMenuOpen = false"
|
||||
>
|
||||
<fa-icon icon="hand-holding-usd" class="gl-icon"></fa-icon> Total Int Received & Receivable Report
|
||||
</a>
|
||||
<a
|
||||
class="gl-link"
|
||||
[class.hidden]="!matchesInvestmentSearch('Investment Portfolio')"
|
||||
routerLink="/investment/reports"
|
||||
(click)="investmentMenuOpen = false"
|
||||
>
|
||||
<fa-icon icon="briefcase" class="gl-icon"></fa-icon> Investment Portfolio
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Progress Bar -->
|
||||
@if (progressBarMode !== 'none') {
|
||||
<div>
|
||||
|
||||
@@ -677,6 +677,26 @@ $gray-700: #334155;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
// Marks a `mat-menu-item` as actually wired to a working screen/dialog (vs a still-unwired
|
||||
// placeholder) — same "green dot" convention as .gl-link--live, positioned absolutely since
|
||||
// mat-menu-item's internal layout isn't a plain flex row we control directly.
|
||||
[mat-menu-item].menu-item-live {
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: 14px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #16a34a;
|
||||
box-shadow: 0 0 0 2px rgb(22 163 74 / 15%);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Nav Dropdown Menus =====
|
||||
::ng-deep .nav-dropdown {
|
||||
border-radius: 8px !important;
|
||||
@@ -729,6 +749,13 @@ $gray-700: #334155;
|
||||
}
|
||||
}
|
||||
|
||||
/* NPA Mega Panel — 4 columns (Reports / Analysis / Classification / Recovery Management) */
|
||||
.npa-grid {
|
||||
grid-template-columns: repeat(4, minmax(200px, 1fr));
|
||||
min-width: 880px;
|
||||
max-width: 96vw;
|
||||
}
|
||||
|
||||
/* Reconciliation Mega Panel — 5 columns, teal accent to match the icon palette */
|
||||
.reconciliation-grid {
|
||||
grid-template-columns: repeat(5, minmax(190px, 1fr));
|
||||
@@ -856,6 +883,20 @@ $gray-700: #334155;
|
||||
}
|
||||
}
|
||||
|
||||
// Marks a menu link as actually wired to a working screen/dialog (vs a still-unwired
|
||||
// placeholder) — a small green dot at the end of the row, so a reviewer can tell at a glance
|
||||
// which items in a mega-menu are functional without having to click every one of them.
|
||||
.gl-link--live::after {
|
||||
content: '';
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
background: #16a34a;
|
||||
box-shadow: 0 0 0 2px rgb(22 163 74 / 15%);
|
||||
}
|
||||
|
||||
/* RBI Mega Menu */
|
||||
.rbi-mega-menu {
|
||||
width: 1100px;
|
||||
|
||||
@@ -98,6 +98,10 @@ export class ShellComponent implements OnInit, OnDestroy {
|
||||
rbiMenuOpen = false;
|
||||
rbiSearch = '';
|
||||
|
||||
/** Investment (Treasury) mega menu state */
|
||||
investmentMenuOpen = false;
|
||||
investmentSearch = '';
|
||||
|
||||
/* Live KPI ticker — markup is static inside the template; the marquee
|
||||
animation, LIVE badge, and pause-on-hover handle the live feel. */
|
||||
|
||||
@@ -116,6 +120,11 @@ export class ShellComponent implements OnInit, OnDestroy {
|
||||
return label.toLowerCase().includes(this.rbiSearch.toLowerCase());
|
||||
}
|
||||
|
||||
matchesInvestmentSearch(label: string): boolean {
|
||||
if (!this.investmentSearch) return true;
|
||||
return label.toLowerCase().includes(this.investmentSearch.toLowerCase());
|
||||
}
|
||||
|
||||
/** Route for a facility entry in the Account Facilities hover dropdown — only "Cheque Book" has a
|
||||
* dedicated action page today; every other facility falls back to the existing overview page. */
|
||||
facilityRoute(label: string): string[] {
|
||||
@@ -179,9 +188,9 @@ export class ShellComponent implements OnInit, OnDestroy {
|
||||
return this.router.url;
|
||||
}
|
||||
|
||||
/** True on the manager's full-screen withdrawal-request review screen. */
|
||||
/** True on the manager's full-screen withdrawal-request (cash or share) review screen. */
|
||||
get isWithdrawalReviewUrl(): boolean {
|
||||
return /\/checker-inbox-and-tasks\/withdrawal-requests\/\d+\/review/.test(this.router.url);
|
||||
return /\/checker-inbox-and-tasks\/(share-)?withdrawal-requests\/\d+\/review/.test(this.router.url);
|
||||
}
|
||||
|
||||
/** ESC key — exit fullscreen at Clerk Dashboard / Election / review screen, restoring full shell header. */
|
||||
@@ -242,7 +251,7 @@ export class ShellComponent implements OnInit, OnDestroy {
|
||||
* GL Wise DR/CR / Misc Expense Ledger). Initial report selected via menu link.
|
||||
*/
|
||||
async openAccountingReportsDialog(
|
||||
initialReport: 'PNL' | 'BALANCE-SHEET' | 'COMPARATIVE-BS' | 'GL-DR-CR' | 'MISC-EXP' = 'PNL'
|
||||
initialReport: 'PNL' | 'BALANCE-SHEET' | 'COMPARATIVE-BS' | 'CONTINGENT-LIAB' | 'GL-DR-CR' | 'MISC-EXP' = 'PNL'
|
||||
): Promise<void> {
|
||||
const { AccountingReportsDialogComponent } =
|
||||
await import('../../home/dashboard/accounting-reports-dialog/accounting-reports-dialog.component');
|
||||
@@ -285,7 +294,9 @@ export class ShellComponent implements OnInit, OnDestroy {
|
||||
* is selected based on the menu link the user clicked; user can switch between
|
||||
* forms via the form-pill selector inside the dialog.
|
||||
*/
|
||||
async openCimsXbrlDialog(initialForm: 'OSS-2' | 'PSA' | 'RAL' | 'RCP-UCB' = 'OSS-2'): Promise<void> {
|
||||
async openCimsXbrlDialog(
|
||||
initialForm: 'OSS-2' | 'PSA' | 'RAL' | 'RCP-UCB' | 'OSS-4' | 'OSS-5' | 'OSS-6' | 'OSS-7' = 'OSS-2'
|
||||
): Promise<void> {
|
||||
const { CimsXbrlDialogComponent } =
|
||||
await import('../../home/dashboard/cims-xbrl-dialog/cims-xbrl-dialog.component');
|
||||
this.dialog.open(CimsXbrlDialogComponent, {
|
||||
@@ -301,6 +312,26 @@ export class ShellComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
/** Opens the FD / RD Entry dialog under Sub Ledger → Deposits. */
|
||||
async openFdRdEntryDialog(): Promise<void> {
|
||||
const { FdRdEntryDialogComponent } =
|
||||
await import('../../home/dashboard/fd-rd-entry-dialog/fd-rd-entry-dialog.component');
|
||||
this.dialog.open(FdRdEntryDialogComponent, {
|
||||
width: '100vw',
|
||||
height: '100vh',
|
||||
maxWidth: '100vw',
|
||||
maxHeight: '100vh',
|
||||
panelClass: [
|
||||
'crr-slr-dialog-panel',
|
||||
'fdrd-fullscreen-panel'
|
||||
],
|
||||
autoFocus: false,
|
||||
disableClose: false,
|
||||
hasBackdrop: true,
|
||||
backdropClass: 'fdrd-dlg-backdrop'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the Asset Liability Management dialog (Form ALM-1).
|
||||
* Covers maturity ladder, monthly snapshot, and structural liquidity returns.
|
||||
|
||||
@@ -266,7 +266,7 @@
|
||||
<a
|
||||
class="subledger-link"
|
||||
[class.hidden]="!matchesSearch('Cash / Clearing / Transfer')"
|
||||
(click)="subLedgerOpen = false"
|
||||
(click)="navigateMenu('/cashier/cash-clearing-transfer-summary'); subLedgerOpen = false"
|
||||
>
|
||||
<fa-icon icon="money-bill" class="subledger-icon"></fa-icon> Cash / Clearing / Transfer
|
||||
</a>
|
||||
@@ -403,17 +403,26 @@
|
||||
<div class="subledger-columns">
|
||||
<div class="subledger-group">
|
||||
<div class="subledger-group-title">Financial Statements</div>
|
||||
<a class="subledger-link" [class.hidden]="!matchesGlSearch('Trial Balance')" (click)="glMenuOpen = false"
|
||||
<a
|
||||
class="subledger-link"
|
||||
[class.hidden]="!matchesGlSearch('Trial Balance')"
|
||||
routerLink="/gl/reports/trial-balance"
|
||||
(click)="glMenuOpen = false"
|
||||
><fa-icon icon="balance-scale" class="subledger-icon"></fa-icon> Trial Balance</a
|
||||
>
|
||||
<a class="subledger-link" [class.hidden]="!matchesGlSearch('Profit and Loss')" (click)="glMenuOpen = false"
|
||||
<a
|
||||
class="subledger-link"
|
||||
[class.hidden]="!matchesGlSearch('Profit and Loss')"
|
||||
routerLink="/gl/reports/profit-and-loss"
|
||||
(click)="glMenuOpen = false"
|
||||
><fa-icon icon="chart-line" class="subledger-icon"></fa-icon> Profit and Loss</a
|
||||
>
|
||||
<a
|
||||
class="subledger-link"
|
||||
[class.hidden]="!matchesGlSearch('Consolidate Balance Sheet')"
|
||||
[class.hidden]="!matchesGlSearch('Consolidated Balance Sheet')"
|
||||
routerLink="/gl/reports/balance-sheet"
|
||||
(click)="glMenuOpen = false"
|
||||
><fa-icon icon="file-alt" class="subledger-icon"></fa-icon> Consolidate Balance Sheet</a
|
||||
><fa-icon icon="file-alt" class="subledger-icon"></fa-icon> Consolidated Balance Sheet</a
|
||||
>
|
||||
</div>
|
||||
<div class="subledger-group">
|
||||
@@ -448,8 +457,12 @@
|
||||
(click)="glMenuOpen = false"
|
||||
><fa-icon icon="users" class="subledger-icon"></fa-icon> Client wise Balance Report</a
|
||||
>
|
||||
<a class="subledger-link" [class.hidden]="!matchesGlSearch('Contingent Expense')" (click)="glMenuOpen = false"
|
||||
><fa-icon icon="exclamation-circle" class="subledger-icon"></fa-icon> Contingent Expense</a
|
||||
<a
|
||||
class="subledger-link"
|
||||
[class.hidden]="!matchesGlSearch('Contingent Liabilities')"
|
||||
routerLink="/gl/reports/contingent-liabilities"
|
||||
(click)="glMenuOpen = false"
|
||||
><fa-icon icon="exclamation-circle" class="subledger-icon"></fa-icon> Contingent Liabilities</a
|
||||
>
|
||||
<a class="subledger-link" [class.hidden]="!matchesGlSearch('Banker Head Report')" (click)="glMenuOpen = false"
|
||||
><fa-icon icon="university" class="subledger-icon"></fa-icon> Banker Head Report</a
|
||||
|
||||
+15
@@ -365,6 +365,21 @@
|
||||
</mat-form-field>
|
||||
</form>
|
||||
}
|
||||
|
||||
<!-- Compliance / AML Action Triage -->
|
||||
<div class="compliance-triage" style="margin-top: 16px; padding: 12px; background: #fff8f8; border: 1px dashed #f5c2c2; border-radius: 8px;">
|
||||
<h4 style="margin: 0 0 8px; font-weight: 600; color: #b91c1c; display: flex; align-items: center; gap: 6px;">
|
||||
Compliance & AML Triage
|
||||
</h4>
|
||||
<div style="display: flex; flex-direction: column; gap: 8px;">
|
||||
<mat-checkbox [(ngModel)]="flagAsSuspicious" [ngModelOptions]="{standalone: true}">
|
||||
Flag as suspicious (escalate to STR cases under RBI compliance)
|
||||
</mat-checkbox>
|
||||
<mat-checkbox [(ngModel)]="markAsWhitelisted" [ngModelOptions]="{standalone: true}">
|
||||
Mark as whitelisted (prevent future AML alerts for this account)
|
||||
</mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div #lightboxRoot style="display: none"></div>
|
||||
|
||||
+86
@@ -131,6 +131,90 @@ export class ItemDecisionDialogComponent implements OnInit, OnDestroy {
|
||||
/** 'any' = mode of operation lets any one signer sign (Either/Anyone or Survivor); 'all' = every mandatory signer must sign. */
|
||||
signerSelectionMode: 'any' | 'all' = 'any';
|
||||
manualOverrideChecked = false;
|
||||
flagAsSuspicious = false;
|
||||
markAsWhitelisted = false;
|
||||
|
||||
handleComplianceActions(): void {
|
||||
if (this.flagAsSuspicious) {
|
||||
const existingStrData = localStorage.getItem('fiu_str_cases');
|
||||
let cases: any[] = [];
|
||||
if (existingStrData) {
|
||||
try {
|
||||
cases = JSON.parse(existingStrData);
|
||||
} catch (e) {
|
||||
cases = [];
|
||||
}
|
||||
}
|
||||
|
||||
const newRef = 101 + cases.length;
|
||||
const newCase: any = {
|
||||
reportType: 'STR',
|
||||
reportRefNo: newRef,
|
||||
branchCode: this.item.presentingBranchCode || '1',
|
||||
accountNumber: this.item.accountNo || this.item.accountId || '110001101000838',
|
||||
txnBranchCode: this.item.presentingBranchCode || '1',
|
||||
depositOrWithdrawal: 'Withdrawal',
|
||||
txnId: this.item.id,
|
||||
txnDate: new Date().toLocaleDateString('en-GB').replace(/\//g, '-'),
|
||||
txnTime: new Date().toLocaleTimeString('en-US', { hour12: false }),
|
||||
txnAmount: this.item.amount,
|
||||
instrumentType: 'Cheque',
|
||||
instrumentId: this.item.chequeNumber,
|
||||
nonCustomerRef: '',
|
||||
relationshipFlag: 'Customer',
|
||||
thirdPartyPan: '',
|
||||
declaration: 'NA',
|
||||
customerName: this.item.clientName || 'UNKNOWN CLIENT',
|
||||
detectedOn: new Date().toLocaleDateString('en-GB').replace(/\//g, '-'),
|
||||
flaggedBy: 'CTS Clearing Officer',
|
||||
riskCategory: 'unusual-pattern',
|
||||
narrative: `CTS Cheque #${this.item.chequeNumber} for amount ₹${this.item.amount} flagged as suspicious by Clearing Officer during inward presentment review. Action: ${this.mode}. Reason/Notes: ${this.mode === 'RETURN' ? this.returnForm.value.reason : this.passForm.value.note || 'Manual compliance escalation'}`,
|
||||
status: 'po-review'
|
||||
};
|
||||
|
||||
cases.unshift(newCase);
|
||||
localStorage.setItem('fiu_str_cases', JSON.stringify(cases));
|
||||
|
||||
const storedCtr = localStorage.getItem('fiu_ctr_txns');
|
||||
if (storedCtr) {
|
||||
try {
|
||||
const ctrList = JSON.parse(storedCtr);
|
||||
const matching = ctrList.find((t: any) => t.accountNumber === newCase.accountNumber);
|
||||
if (matching) {
|
||||
matching.complianceStatus = 'SUSPICIOUS';
|
||||
localStorage.setItem('fiu_ctr_txns', JSON.stringify(ctrList));
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
} else if (this.markAsWhitelisted) {
|
||||
const existingWhitelist = localStorage.getItem('compliance_whitelisted_accounts');
|
||||
let whitelisted: string[] = [];
|
||||
if (existingWhitelist) {
|
||||
try {
|
||||
whitelisted = JSON.parse(existingWhitelist);
|
||||
} catch (e) {
|
||||
whitelisted = [];
|
||||
}
|
||||
}
|
||||
const account = this.item.accountNo || this.item.accountId;
|
||||
if (account && !whitelisted.includes(account.toString())) {
|
||||
whitelisted.push(account.toString());
|
||||
localStorage.setItem('compliance_whitelisted_accounts', JSON.stringify(whitelisted));
|
||||
}
|
||||
|
||||
const storedCtr = localStorage.getItem('fiu_ctr_txns');
|
||||
if (storedCtr) {
|
||||
try {
|
||||
const ctrList = JSON.parse(storedCtr);
|
||||
const matching = ctrList.find((t: any) => t.accountNumber === (account ? account.toString() : ''));
|
||||
if (matching) {
|
||||
matching.complianceStatus = 'WHITELISTED';
|
||||
localStorage.setItem('fiu_ctr_txns', JSON.stringify(ctrList));
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Every automatic validation rule InwardClearingValidationService already computes, shown so the clerk sees WHY, not just a decision. */
|
||||
readonly validationRules: { key: string; label: string; goodWhenTrue: boolean }[] = [
|
||||
@@ -652,6 +736,7 @@ export class ItemDecisionDialogComponent implements OnInit, OnDestroy {
|
||||
this.ctsService.passInwardItem(this.item.id, payload).subscribe({
|
||||
next: () => {
|
||||
this.busy = false;
|
||||
this.handleComplianceActions();
|
||||
this.notify('Cheque passed');
|
||||
this.dialogRef.close({ actioned: true });
|
||||
},
|
||||
@@ -676,6 +761,7 @@ export class ItemDecisionDialogComponent implements OnInit, OnDestroy {
|
||||
this.ctsService.returnInwardItem(this.item.id, payload).subscribe({
|
||||
next: () => {
|
||||
this.busy = false;
|
||||
this.handleComplianceActions();
|
||||
this.notify('Cheque returned');
|
||||
this.dialogRef.close({ actioned: true });
|
||||
},
|
||||
|
||||
@@ -81,7 +81,7 @@ export interface DeafEligibleAccount {
|
||||
|
||||
/** Form I — Dec 2025 cycle */
|
||||
export const FORM_I: DeafForm = {
|
||||
bankName: 'Bharat Urban Bank Ltd, Mumbai Main Branch',
|
||||
bankName: 'CBSNexora Bank Ltd, Mumbai Main Branch',
|
||||
bankDeafCode: '1100',
|
||||
remittedThroughSponsor: 'Y',
|
||||
sponsorBankName: 'State Bank of India',
|
||||
@@ -170,7 +170,7 @@ export const FORM_I: DeafForm = {
|
||||
|
||||
/** Form II — Jan 2026 (head office consolidated) */
|
||||
export const FORM_II: DeafForm = {
|
||||
bankName: 'Bharat Urban Bank Ltd, Head Office',
|
||||
bankName: 'CBSNexora Bank Ltd, Head Office',
|
||||
bankDeafCode: '1100',
|
||||
remittedThroughSponsor: 'Y',
|
||||
sponsorBankName: 'State Bank of India',
|
||||
@@ -271,7 +271,7 @@ export const REFUND_CLAIMS: {
|
||||
auditor: string;
|
||||
rows: DeafClaim[];
|
||||
} = {
|
||||
bankName: 'Bharat Urban Bank Ltd',
|
||||
bankName: 'CBSNexora Bank Ltd',
|
||||
bankDeafCode: '1100',
|
||||
currentAcWithRbi: '03010200099001',
|
||||
sponsorBank: 'State Bank of India',
|
||||
|
||||
@@ -53,7 +53,7 @@ export interface SahayDepositor {
|
||||
}
|
||||
|
||||
export const DICGC_BANK = {
|
||||
name: 'Bharat Urban Bank Ltd',
|
||||
name: 'CBSNexora Bank Ltd',
|
||||
branch: 'Head Office',
|
||||
reportAsOn: '29-01-2026',
|
||||
printDate: '29-01-2026 04:19 PM',
|
||||
|
||||
+732
-300
File diff suppressed because it is too large
Load Diff
+625
-179
@@ -14,12 +14,25 @@
|
||||
<div>
|
||||
<h2 class="fiu-head-t">Financial Intelligence Unit – India (FIU-IND)</h2>
|
||||
<div class="fiu-head-s">
|
||||
{{ bank.name }} · {{ bank.branch }} · Reporting Month <strong>{{ bank.reportingMonth }}</strong>
|
||||
{{ bank.name }} · {{ scopeName }} · Reporting Month <strong>{{ bank.reportingMonth }}</strong>
|
||||
· PMLA 2002 / FINnet 2.0
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fiu-head-meta">
|
||||
<div>
|
||||
<span class="fiu-meta-l">Branch</span>
|
||||
@if (isHeadOffice) {
|
||||
<select class="fiu-inp" [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>
|
||||
} @else {
|
||||
<span class="fiu-meta-v">{{ scopeName }}</span>
|
||||
}
|
||||
</div>
|
||||
<div>
|
||||
<span class="fiu-meta-l">Principal Officer</span><span class="fiu-meta-v">{{ bank.principalOfficer }}</span>
|
||||
</div>
|
||||
@@ -71,17 +84,19 @@
|
||||
<div class="fiu-kpi fiu-kpi--amber">
|
||||
<div class="fiu-kpi-ic"><fa-icon icon="exclamation-triangle"></fa-icon></div>
|
||||
<div>
|
||||
<div class="fiu-kpi-n">{{ pendingStr | number }}</div>
|
||||
<div class="fiu-kpi-n">{{ liveStrPending | number }}</div>
|
||||
<div class="fiu-kpi-l">STR Pending</div>
|
||||
<div class="fiu-kpi-sub">draft / PO review</div>
|
||||
<div class="fiu-kpi-sub">
|
||||
{{ liveStrOpen }} to investigate · {{ liveStrAwaitingFiling }} awaiting filing
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fiu-kpi fiu-kpi--green">
|
||||
<div class="fiu-kpi-ic"><fa-icon icon="check-circle"></fa-icon></div>
|
||||
<div>
|
||||
<div class="fiu-kpi-n">{{ filedStr | number }}</div>
|
||||
<div class="fiu-kpi-n">{{ liveStrFiled | number }}</div>
|
||||
<div class="fiu-kpi-l">STR Filed</div>
|
||||
<div class="fiu-kpi-sub">acknowledged by FIU</div>
|
||||
<div class="fiu-kpi-sub">{{ liveStrCleared }} concluded clear</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fiu-kpi fiu-kpi--purple">
|
||||
@@ -94,6 +109,52 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- WHEN TO REPORT — the five PMLA obligations -->
|
||||
<div class="ob-block">
|
||||
<div class="ob-head">
|
||||
<div>
|
||||
<h3 class="ob-title"><fa-icon icon="gavel"></fa-icon> When to report to FIU-IND</h3>
|
||||
<p class="ob-sub">
|
||||
The five reports a reporting entity owes under the Prevention of Money Laundering Act. All are filed
|
||||
electronically through the FINnet 2.0 portal.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ob-grid">
|
||||
@for (o of obligations; track o.code) {
|
||||
<div class="ob-card" [class.ob-card--off]="!o.handled">
|
||||
<div class="ob-card-h">
|
||||
<fa-icon [icon]="o.icon" class="ob-ic"></fa-icon>
|
||||
<span class="ob-code">{{ o.code }}</span>
|
||||
<span class="ob-name">{{ o.name }}</span>
|
||||
@if (o.handled) {
|
||||
<span class="ob-flag ob-flag--on">Filed here</span>
|
||||
} @else {
|
||||
<span class="ob-flag">Tracked</span>
|
||||
}
|
||||
</div>
|
||||
<div class="ob-row">
|
||||
<label>Report when</label><span>{{ o.trigger }}</span>
|
||||
</div>
|
||||
<div class="ob-row">
|
||||
<label>Deadline</label><span class="ob-due">{{ o.deadline }}</span>
|
||||
</div>
|
||||
<div class="ob-note">{{ o.note }}</div>
|
||||
@if (o.code === 'CTR') {
|
||||
<button class="dc-btn" (click)="showTab('ctr')">
|
||||
<fa-icon icon="external-link-alt"></fa-icon> Open CTR
|
||||
</button>
|
||||
} @else if (o.code === 'STR') {
|
||||
<button class="dc-btn" (click)="showTab('str')">
|
||||
<fa-icon icon="external-link-alt"></fa-icon> Open STR
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Compliance cards -->
|
||||
<div class="fiu-compliance">
|
||||
<div class="dc-card">
|
||||
@@ -112,7 +173,12 @@
|
||||
</div>
|
||||
<div class="dc-meta">Filing window: <strong>within 7 days of detection</strong></div>
|
||||
<div class="dc-meta">
|
||||
Pending: <strong>{{ pendingStr }}</strong> · Filed: <strong>{{ filedStr }}</strong>
|
||||
To investigate: <strong>{{ liveStrOpen }}</strong> · Awaiting filing:
|
||||
<strong>{{ liveStrAwaitingFiling }}</strong>
|
||||
</div>
|
||||
<div class="dc-meta">
|
||||
Filed: <strong>{{ liveStrFiled }}</strong> · Concluded clear:
|
||||
<strong>{{ liveStrCleared }}</strong>
|
||||
</div>
|
||||
<button class="dc-btn" (click)="showTab('str')"><fa-icon icon="external-link-alt"></fa-icon> Open</button>
|
||||
</div>
|
||||
@@ -129,77 +195,105 @@
|
||||
<!-- CTR -->
|
||||
@if (activeTab === 'ctr') {
|
||||
<div class="fiu-section">
|
||||
<!-- LIVE: cash-related AML alerts awaiting AML Officer review -->
|
||||
<div class="fiu-form-meta">
|
||||
<div class="fiu-form-title">
|
||||
<h3>Live Cash Alerts</h3>
|
||||
<p>
|
||||
Raised by the transaction-monitoring rule engine — review and whitelist or escalate to Principal Officer
|
||||
</p>
|
||||
<!-- FINNET FILE SEQUENCE — the CTR submission, in prescribed order -->
|
||||
<div class="seq-block">
|
||||
<div class="fiu-form-meta">
|
||||
<div class="fiu-form-title">
|
||||
<h3>CTR Submission Files — FINnet 2.0</h3>
|
||||
<p>
|
||||
The CTR is built in this order: the account is described first, then who is behind it, then the two
|
||||
transaction files. Every grid below is the exact content of its CSV. The customer cards (KC1 / KC2) travel
|
||||
with the same bundle and are worked on the Customer Cards tab.
|
||||
</p>
|
||||
</div>
|
||||
<button class="fiu-btn fiu-btn-xls" (click)="exportAll()">
|
||||
<fa-icon icon="file-csv"></fa-icon> Download full bundle
|
||||
</button>
|
||||
</div>
|
||||
<select class="fiu-inp" [(ngModel)]="amlAlertStatusFilter" (ngModelChange)="loadAmlAlerts()">
|
||||
<option value="">All statuses</option>
|
||||
<option value="RAISED">Raised</option>
|
||||
<option value="UNDER_REVIEW">Under Review</option>
|
||||
<option value="WHITELISTED">Whitelisted</option>
|
||||
<option value="ESCALATED">Escalated</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="fiu-tbl-wrap">
|
||||
<table class="fiu-tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Client</th>
|
||||
<th>Type</th>
|
||||
<th>Rule</th>
|
||||
<th class="ta-r">Amount</th>
|
||||
<th>Raised On</th>
|
||||
<th>Status</th>
|
||||
<th>Notes</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (alert of amlAlerts; track alert.id) {
|
||||
<tr>
|
||||
<td>{{ alert.id }}</td>
|
||||
<td>{{ alert.clientDisplayName }}</td>
|
||||
<td>{{ alert.transactionType }}</td>
|
||||
<td>{{ alert.ruleCode }} — {{ alert.ruleDescription }}</td>
|
||||
<td class="ta-r mono">₹{{ alert.amount | number: '1.0-0' }}</td>
|
||||
<td>{{ alert.raisedOn | date: 'medium' }}</td>
|
||||
<td>
|
||||
<span [class]="amlStatusClass(alert.status)">{{ alert.status }}</span>
|
||||
</td>
|
||||
<td>
|
||||
@if (alert.status === 'RAISED' || alert.status === 'UNDER_REVIEW') {
|
||||
<input
|
||||
type="text"
|
||||
class="fiu-inp"
|
||||
placeholder="Review notes"
|
||||
[(ngModel)]="amlReviewNotes[alert.id]"
|
||||
/>
|
||||
} @else {
|
||||
<span class="muted">{{ alert.reviewNotes }}</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@if (alert.status === 'RAISED' || alert.status === 'UNDER_REVIEW') {
|
||||
<div class="fiu-action-group" *mifosxHasPermission="'REVIEW_AML_ALERT'">
|
||||
<button class="fiu-btn fiu-btn-g" (click)="whitelistAlert(alert)">Whitelist</button>
|
||||
<button class="fiu-btn fiu-btn-xml" (click)="escalateAlert(alert)">Escalate</button>
|
||||
</div>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="9" class="fiu-empty">No live alerts.</td>
|
||||
</tr>
|
||||
|
||||
<!-- Scope: these controls decide which transactions the six files carry. -->
|
||||
<div class="fiu-filters">
|
||||
<div class="fiu-filter">
|
||||
<label>Type</label>
|
||||
<select class="fiu-inp" [(ngModel)]="ctrFilterMode">
|
||||
<option value="">All</option>
|
||||
<option value="Deposit">Deposit</option>
|
||||
<option value="Withdrawal">Withdrawal</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="fiu-filter">
|
||||
<label>Account contains</label>
|
||||
<input class="fiu-inp" type="text" [(ngModel)]="ctrFilterAccount" placeholder="A/c partial" />
|
||||
</div>
|
||||
<div class="fiu-filter">
|
||||
<label>Min Amount (₹)</label>
|
||||
<input class="fiu-inp" type="number" [(ngModel)]="ctrFilterMinAmount" placeholder="0" />
|
||||
</div>
|
||||
<button class="fiu-btn fiu-btn-g" (click)="resetCtrFilters()">Reset</button>
|
||||
</div>
|
||||
|
||||
<!-- Step rail -->
|
||||
<div class="seq-rail">
|
||||
@for (f of finnetSequence; track f.key) {
|
||||
<button class="seq-step" [class.active]="activeFinnetFile === f.key" (click)="showFinnetFile(f.key)">
|
||||
<span class="seq-n">{{ f.step }}</span>
|
||||
<span class="seq-txt">
|
||||
<strong>{{ f.name }}</strong>
|
||||
<em>{{ f.table.rows.length }} {{ f.table.rows.length === 1 ? 'row' : 'rows' }}</em>
|
||||
</span>
|
||||
</button>
|
||||
@if (!$last) {
|
||||
<fa-icon icon="chevron-right" class="seq-arrow"></fa-icon>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Open file -->
|
||||
<div class="seq-panel">
|
||||
<div class="seq-panel-h">
|
||||
<div>
|
||||
<h4>Step {{ openFinnetFile.step }} — {{ openFinnetFile.title }}</h4>
|
||||
<p>{{ openFinnetFile.purpose }}</p>
|
||||
</div>
|
||||
<button class="fiu-btn fiu-btn-csv" (click)="downloadOpenFinnetFile()">
|
||||
<fa-icon icon="file-csv"></fa-icon> {{ openFinnetFile.name }}.csv
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="seq-tbl-wrap">
|
||||
<table class="seq-tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="seq-rownum">#</th>
|
||||
@for (h of openFinnetFile.table.headers; track $index) {
|
||||
<th>{{ h }}</th>
|
||||
}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (r of openFinnetFile.table.rows; track $index) {
|
||||
<tr>
|
||||
<td class="seq-rownum">{{ $index + 1 }}</td>
|
||||
@for (c of r; track $index) {
|
||||
<td>{{ c }}</td>
|
||||
}
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td [attr.colspan]="openFinnetFile.table.headers.length + 1" class="seq-empty">
|
||||
@if (openFinnetFile.key === 'tc1') {
|
||||
No ATM or card transactions match the current scope. When the bank books none at all, TC1 is
|
||||
still submitted as a header-only file — that is what FIU-IND expects.
|
||||
} @else {
|
||||
No rows for the current filters.
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- LIVE: monthly CTR reports (mechanical, threshold-based) -->
|
||||
@@ -248,100 +342,145 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- FINnet export template (separate concern — TC1/TC2 file-format generation) -->
|
||||
<div class="fiu-form-meta">
|
||||
<div class="fiu-form-title">
|
||||
<h3>FINnet TC2 Export Template</h3>
|
||||
<p>
|
||||
All cash transactions ≥ ₹{{ bank.cashThreshold | number }} (single or aggregate per month per account)
|
||||
</p>
|
||||
</div>
|
||||
<button class="fiu-btn fiu-btn-csv" (click)="exportTc2()">
|
||||
<fa-icon icon="file-csv"></fa-icon> Download TC2.csv
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="fiu-filters">
|
||||
<div class="fiu-filter">
|
||||
<label>Type</label>
|
||||
<select class="fiu-inp" [(ngModel)]="ctrFilterMode">
|
||||
<option value="">All</option>
|
||||
<option value="Deposit">Deposit</option>
|
||||
<option value="Withdrawal">Withdrawal</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="fiu-filter">
|
||||
<label>Account contains</label>
|
||||
<input class="fiu-inp" type="text" [(ngModel)]="ctrFilterAccount" placeholder="A/c partial" />
|
||||
</div>
|
||||
<div class="fiu-filter">
|
||||
<label>Min Amount (₹)</label>
|
||||
<input class="fiu-inp" type="number" [(ngModel)]="ctrFilterMinAmount" placeholder="0" />
|
||||
</div>
|
||||
<button class="fiu-btn fiu-btn-g" (click)="resetCtrFilters()">Reset</button>
|
||||
</div>
|
||||
|
||||
<div class="fiu-tbl-wrap">
|
||||
<table class="fiu-tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="ta-c">Ref</th>
|
||||
<th>Account</th>
|
||||
<th>Customer</th>
|
||||
<th class="ta-c">Type</th>
|
||||
<th>Date / Time</th>
|
||||
<th class="ta-r">Amount</th>
|
||||
<th>Instrument</th>
|
||||
<th>Branch</th>
|
||||
<th class="ta-c">Relationship</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (t of filteredCtr; track t.txnId) {
|
||||
<tr>
|
||||
<td class="ta-c">{{ t.reportRefNo }}</td>
|
||||
<td class="mono">{{ t.accountNumber }}</td>
|
||||
<td>{{ t.customerName }}</td>
|
||||
<td class="ta-c">
|
||||
<span
|
||||
class="dir-pill"
|
||||
[class.deposit]="t.depositOrWithdrawal === 'Deposit'"
|
||||
[class.withdrawal]="t.depositOrWithdrawal === 'Withdrawal'"
|
||||
>
|
||||
{{ t.depositOrWithdrawal }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{{ t.txnDate }} <span class="muted">{{ t.txnTime }}</span>
|
||||
</td>
|
||||
<td class="ta-r mono">
|
||||
<strong>₹{{ t.txnAmount | number: '1.0-0' }}</strong>
|
||||
</td>
|
||||
<td>
|
||||
{{ t.instrumentType }} <span class="muted">{{ t.instrumentId }}</span>
|
||||
</td>
|
||||
<td class="mono">{{ t.branchCode }}</td>
|
||||
<td class="ta-c">
|
||||
<span class="rel-pill" [class.non-cust]="t.relationshipFlag === 'Non-Customer'">{{
|
||||
t.relationshipFlag
|
||||
}}</span>
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="9" class="fiu-empty">No transactions match the filters.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- STR -->
|
||||
@if (activeTab === 'str') {
|
||||
<div class="fiu-section">
|
||||
<!-- LIVE: suspicious AML alerts awaiting AML Officer review (Structuring, PEP, etc.) -->
|
||||
<div class="fiu-form-meta">
|
||||
<div class="fiu-form-title">
|
||||
<h3>Live AML Alerts (Awaiting Triage)</h3>
|
||||
<p>Flagged by the monitoring engine for suspicious behavioral patterns — triage and whitelist or escalate</p>
|
||||
</div>
|
||||
<!-- Whitelisted is deliberately absent: cleared alerts are not STR business and have
|
||||
their own register below. -->
|
||||
<select class="fiu-inp" [(ngModel)]="amlAlertStatusFilter">
|
||||
<option value="">All statuses</option>
|
||||
<option value="RAISED">Raised</option>
|
||||
<option value="UNDER_REVIEW">Under Review</option>
|
||||
<option value="ESCALATED">Escalated</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="fiu-tbl-wrap">
|
||||
<table class="fiu-tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Client</th>
|
||||
<th>Type</th>
|
||||
<th>Rule</th>
|
||||
<th class="ta-r">Amount</th>
|
||||
<th>Raised On</th>
|
||||
<th>Status</th>
|
||||
<th class="ta-c">Routes To</th>
|
||||
<th>Notes</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (alert of amlSuspiciousAlerts; track alert.id) {
|
||||
<tr>
|
||||
<td>{{ alert.id }}</td>
|
||||
<td>{{ alert.clientDisplayName }}</td>
|
||||
<td>{{ alert.transactionType }}</td>
|
||||
<td>{{ alert.ruleCode }} — {{ alert.ruleDescription }}</td>
|
||||
<td class="ta-r mono">₹{{ alert.amount | number: '1.0-0' }}</td>
|
||||
<td>{{ alert.raisedOn | date: 'medium' }}</td>
|
||||
<td>
|
||||
<span [class]="amlStatusClass(alert.status)">{{ alert.status }}</span>
|
||||
</td>
|
||||
<td class="ta-c">
|
||||
<span [class]="alertRouteClass(alert.status, false)">{{ alertRouteLabel(alert.status, false) }}</span>
|
||||
</td>
|
||||
<td>
|
||||
@if (alert.status === 'RAISED' || alert.status === 'UNDER_REVIEW') {
|
||||
<input
|
||||
type="text"
|
||||
class="fiu-inp"
|
||||
placeholder="Review notes"
|
||||
[(ngModel)]="amlReviewNotes[alert.id]"
|
||||
/>
|
||||
} @else {
|
||||
<span class="muted">{{ alert.reviewNotes }}</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@if (alert.status === 'RAISED' || alert.status === 'UNDER_REVIEW') {
|
||||
<div class="fiu-action-group" *mifosxHasPermission="'REVIEW_AML_ALERT'">
|
||||
<button class="fiu-btn fiu-btn-g" (click)="whitelistAlert(alert)">Whitelist</button>
|
||||
<button class="fiu-btn fiu-btn-xml" (click)="escalateAlert(alert)">Escalate</button>
|
||||
</div>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="10" class="fiu-empty">No suspicious behavioral alerts awaiting triage.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="fiu-tbl-legend">
|
||||
<span class="report-pill report-pending">Pending</span> awaiting Whitelist/Escalate decision ·
|
||||
<span class="report-pill report-str">→ STR</span> escalated to Principal Officer as an STR case
|
||||
</p>
|
||||
|
||||
<hr style="border: 0; border-top: 1px solid var(--f-g200); margin: 8px 0" />
|
||||
|
||||
<!-- Cleared register: whitelisted alerts, kept out of the STR queue but retained with the
|
||||
evidence recorded at review time. -->
|
||||
<div class="fiu-form-meta">
|
||||
<div class="fiu-form-title">
|
||||
<h3>Cleared Alerts (Whitelisted)</h3>
|
||||
<p>Reviewed and closed as false positives — no STR filed. Retained with the evidence relied on.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fiu-tbl-wrap">
|
||||
<table class="fiu-tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Client</th>
|
||||
<th>Type</th>
|
||||
<th>Rule</th>
|
||||
<th class="ta-r">Amount</th>
|
||||
<th>Raised On</th>
|
||||
<th>Cleared By</th>
|
||||
<th>Evidence</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (alert of amlWhitelistedAlerts; track alert.id) {
|
||||
<tr>
|
||||
<td>{{ alert.id }}</td>
|
||||
<td>{{ alert.clientDisplayName }}</td>
|
||||
<td>{{ alert.transactionType }}</td>
|
||||
<td>{{ alert.ruleCode }} — {{ alert.ruleDescription }}</td>
|
||||
<td class="ta-r mono">₹{{ alert.amount | number: '1.0-0' }}</td>
|
||||
<td>{{ alert.raisedOn | date: 'medium' }}</td>
|
||||
<td>{{ alert.reviewedByUsername || '—' }}</td>
|
||||
<td>
|
||||
<span class="muted">{{ alert.reviewNotes || '—' }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="8" class="fiu-empty">No alerts have been cleared.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="fiu-tbl-legend">
|
||||
<span class="report-pill report-cleared">Cleared</span> whitelisted — no report filed, evidence retained for
|
||||
audit
|
||||
</p>
|
||||
|
||||
<hr style="border: 0; border-top: 1px solid var(--f-g200); margin: 8px 0" />
|
||||
|
||||
<!-- LIVE: STR cases with full Principal Officer actions -->
|
||||
<div class="fiu-form-meta">
|
||||
<div class="fiu-form-title">
|
||||
@@ -407,6 +546,17 @@
|
||||
<button class="fiu-btn fiu-btn-g" (click)="closeStrCaseDetail()">Close</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="case-outcome"
|
||||
[class.case-outcome--clear]="selectedStrCase.status === 'CONCLUDED_CLEAR'"
|
||||
[class.case-outcome--suspicious]="
|
||||
selectedStrCase.status === 'CONCLUDED_SUSPICIOUS' || selectedStrCase.status === 'FILED'
|
||||
"
|
||||
>
|
||||
<span [class]="amlStatusClass(selectedStrCase.status)">{{ selectedStrCase.status }}</span>
|
||||
<span>{{ strCaseOutcome(selectedStrCase) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="case-form" *mifosxHasPermission="'CONCLUDE_AML_STR'">
|
||||
<div class="form-row">
|
||||
<label for="fiuInvestigationNotes">Investigation notes (source-of-transaction history reviewed)</label>
|
||||
@@ -416,17 +566,99 @@
|
||||
<label for="fiuSourceOfFundsNotes">Source-of-funds notes</label>
|
||||
<textarea id="fiuSourceOfFundsNotes" rows="2" [(ngModel)]="concludeSourceOfFundsNotes"></textarea>
|
||||
</div>
|
||||
<div class="form-row conclusion-row">
|
||||
<label>
|
||||
<input type="radio" name="fiuConclusion" value="NOT_SUSPICIOUS" [(ngModel)]="concludeConclusion" />
|
||||
Not suspicious (whitelist)
|
||||
</label>
|
||||
<label>
|
||||
<input type="radio" name="fiuConclusion" value="SUSPICIOUS" [(ngModel)]="concludeConclusion" />
|
||||
Suspicious (file STR)
|
||||
</label>
|
||||
<!-- Verification checklist — the verdict is derived, not chosen. -->
|
||||
<div class="checklist">
|
||||
<div class="checklist-head">
|
||||
<span>Principal Officer verification — mark each point as found</span>
|
||||
<span class="checklist-progress" [class.done]="allAnswered">
|
||||
{{ answeredCount }} / {{ strChecklist.length }} answered
|
||||
</span>
|
||||
</div>
|
||||
<table class="checklist-tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Verification point</th>
|
||||
<th class="ta-c col-yes">Genuine</th>
|
||||
<th class="ta-c col-no">Not satisfied</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (item of strChecklist; track item.key) {
|
||||
<tr
|
||||
[class.row-yes]="strFindings[item.key] === true"
|
||||
[class.row-no]="strFindings[item.key] === false"
|
||||
[class.row-pending]="strFindings[item.key] === null"
|
||||
>
|
||||
<td>
|
||||
<div class="chk-label">{{ item.label }}</div>
|
||||
<div class="chk-hint">{{ item.hint }}</div>
|
||||
</td>
|
||||
<td class="ta-c">
|
||||
<button
|
||||
type="button"
|
||||
class="chk-box chk-box--yes"
|
||||
[class.on]="strFindings[item.key] === true"
|
||||
[attr.aria-pressed]="strFindings[item.key] === true"
|
||||
[attr.aria-label]="'Mark ' + item.label + ' as genuine'"
|
||||
(click)="setFinding(item.key, true)"
|
||||
>
|
||||
<fa-icon icon="check-circle"></fa-icon>
|
||||
</button>
|
||||
</td>
|
||||
<td class="ta-c">
|
||||
<button
|
||||
type="button"
|
||||
class="chk-box chk-box--no"
|
||||
[class.on]="strFindings[item.key] === false"
|
||||
[attr.aria-pressed]="strFindings[item.key] === false"
|
||||
[attr.aria-label]="'Mark ' + item.label + ' as not satisfied'"
|
||||
(click)="setFinding(item.key, false)"
|
||||
>
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<button class="fiu-btn fiu-btn-xml" (click)="concludeStrCase()">Conclude</button>
|
||||
|
||||
<!-- Derived verdict -->
|
||||
<div
|
||||
class="verdict"
|
||||
[class.verdict--clear]="allAnswered && derivedConclusion === 'NOT_SUSPICIOUS'"
|
||||
[class.verdict--suspicious]="allAnswered && derivedConclusion === 'SUSPICIOUS'"
|
||||
>
|
||||
@if (!allAnswered) {
|
||||
<fa-icon icon="info-circle"></fa-icon>
|
||||
<span>
|
||||
Answer every verification point to determine the outcome —
|
||||
{{ strChecklist.length - answeredCount }} still pending.
|
||||
</span>
|
||||
} @else if (derivedConclusion === 'NOT_SUSPICIOUS') {
|
||||
<fa-icon icon="check-circle"></fa-icon>
|
||||
<span>
|
||||
<strong>Not suspicious.</strong> All {{ strChecklist.length }} points found genuine — the case will be
|
||||
closed and nothing filed with FIU-IND.
|
||||
</span>
|
||||
} @else {
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||
<span>
|
||||
<strong>Suspicious — STR must be filed.</strong>
|
||||
{{ failedFindings.length }} of {{ strChecklist.length }} points not satisfied:
|
||||
{{ failedFindings[0].label }}
|
||||
@if (failedFindings.length > 1) {
|
||||
<span> and {{ failedFindings.length - 1 }} more</span>
|
||||
}
|
||||
.
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<button class="fiu-btn fiu-btn-xml" [disabled]="!allAnswered" (click)="concludeStrCase()">
|
||||
Conclude as
|
||||
{{ allAnswered ? (derivedConclusion === 'SUSPICIOUS' ? 'Suspicious' : 'Not Suspicious') : '…' }}
|
||||
</button>
|
||||
|
||||
@if (selectedStrCase.status === 'CONCLUDED_SUSPICIOUS') {
|
||||
<div class="file-str-row">
|
||||
@@ -457,8 +689,9 @@
|
||||
<option value="">All</option>
|
||||
<option value="draft">Draft</option>
|
||||
<option value="po-review">PO Review</option>
|
||||
<option value="awaiting-filing">Awaiting Filing</option>
|
||||
<option value="filed">Filed</option>
|
||||
<option value="rejected">Rejected</option>
|
||||
<option value="rejected">Closed — Clean</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="fiu-filter">
|
||||
@@ -478,7 +711,12 @@
|
||||
|
||||
<div class="fiu-str-list">
|
||||
@for (s of filteredStr; track s.reportRefNo) {
|
||||
<div class="str-card" [class.str-card--filed]="s.status === 'filed'">
|
||||
<div
|
||||
class="str-card"
|
||||
[id]="'str-card-' + s.reportRefNo"
|
||||
[class.str-card--filed]="s.status === 'filed'"
|
||||
[class.str-card--highlight]="highlightedStrRef === s.reportRefNo"
|
||||
>
|
||||
<div class="str-card-h">
|
||||
<div class="str-card-id">
|
||||
<span class="str-ref">STR-{{ s.reportRefNo }}</span>
|
||||
@@ -510,6 +748,9 @@
|
||||
<div>
|
||||
<label>Instrument</label><span>{{ s.instrumentType }} {{ s.instrumentId }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<label>Branch</label><span class="mono">{{ s.branchCode }} {{ branchNameFor(s.branchCode) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="str-narrative">
|
||||
<label>Narrative</label>
|
||||
@@ -521,6 +762,208 @@
|
||||
<p>{{ s.poRemarks }}</p>
|
||||
</div>
|
||||
}
|
||||
@if (s.poFinding) {
|
||||
<div class="str-finding" [class.str-finding--suspicious]="s.poFinding === 'SUSPICIOUS'">
|
||||
<label>
|
||||
<fa-icon [icon]="s.poFinding === 'CLEAN' ? 'check-circle' : 'exclamation-triangle'"></fa-icon>
|
||||
Principal Officer Finding
|
||||
</label>
|
||||
<p>{{ poFindingLabel(s.poFinding) }}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Concluded suspicious but not yet submitted to FIU-IND -->
|
||||
@if (s.status === 'awaiting-filing') {
|
||||
<div class="str-filing" *mifosxHasPermission="'CONCLUDE_AML_STR'">
|
||||
<label>
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon> Awaiting submission to FIU-IND — due within 7 days
|
||||
of {{ s.detectedOn }}
|
||||
</label>
|
||||
<p class="filing-hint">
|
||||
Generate the FINnet STR XML, upload it on the FIU-IND portal under
|
||||
{{ bank.principalOfficer }}'s digital signature, then record the acknowledgement number here.
|
||||
</p>
|
||||
<div class="filing-row">
|
||||
<button class="fiu-btn" (click)="exportSingleStrXml(s)">
|
||||
<fa-icon icon="file-alt"></fa-icon> Generate STR XML
|
||||
</button>
|
||||
<input
|
||||
class="fiu-inp"
|
||||
type="text"
|
||||
placeholder="FIU-IND acknowledgement no."
|
||||
[(ngModel)]="localFileRefs[s.reportRefNo]"
|
||||
/>
|
||||
<button class="fiu-btn fiu-btn-xml" (click)="fileLocalStr(s)">Mark STR Filed</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (s.status === 'filed' && s.fiuReferenceNo) {
|
||||
<div class="str-filed-ack">
|
||||
<fa-icon icon="check-circle"></fa-icon>
|
||||
<span>
|
||||
Filed with FIU-IND on <strong>{{ s.filedOn }}</strong> · acknowledgement
|
||||
<strong class="mono">{{ s.fiuReferenceNo }}</strong>
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!s.poFinding && investigatingStr?.reportRefNo !== s.reportRefNo) {
|
||||
<div class="str-po-action" *mifosxHasPermission="'CONCLUDE_AML_STR'">
|
||||
<label>Principal Officer investigation — is the source of funds legitimate?</label>
|
||||
<button class="fiu-btn fiu-btn-xml" (click)="investigateLocalStr(s)">
|
||||
<fa-icon icon="search"></fa-icon> Investigate this case
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Investigation workspace for this case -->
|
||||
@if (investigatingStr?.reportRefNo === s.reportRefNo) {
|
||||
<div class="str-investigation" *mifosxHasPermission="'CONCLUDE_AML_STR'">
|
||||
<div class="inv-head">
|
||||
<span>Principal Officer investigation — STR-{{ s.reportRefNo }}</span>
|
||||
<button class="fiu-link-btn fiu-link-btn--muted" (click)="closeLocalStrInvestigation()">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Step 1 — call for evidence -->
|
||||
<div class="inv-step">
|
||||
<div class="inv-step-t"><span class="inv-num">1</span> Documentary evidence called for</div>
|
||||
<div class="inv-grid">
|
||||
<div class="form-row">
|
||||
<label for="invDocType">Document requested from the customer</label>
|
||||
<select id="invDocType" class="fiu-inp" [(ngModel)]="evidenceDocType">
|
||||
<option value="">Select document…</option>
|
||||
@for (d of evidenceDocTypes; track d) {
|
||||
<option [value]="d">{{ d }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="invReceived">Was it produced?</label>
|
||||
<select id="invReceived" class="fiu-inp" [(ngModel)]="evidenceReceived">
|
||||
<option value="">Select…</option>
|
||||
<option value="YES">Yes — complete</option>
|
||||
<option value="PARTIAL">Partially — incomplete</option>
|
||||
<option value="NO">No — not produced</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="invRef">Document / reference number</label>
|
||||
<input id="invRef" class="fiu-inp" type="text" [(ngModel)]="evidenceReference" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2 — customer's account of the funds -->
|
||||
<div class="inv-step">
|
||||
<div class="inv-step-t"><span class="inv-num">2</span> Customer’s explanation of the source</div>
|
||||
<textarea
|
||||
rows="2"
|
||||
[(ngModel)]="customerExplanation"
|
||||
placeholder="e.g. Proceeds of a flat sold in June; sale deed produced and matched to the deposit amount and date."
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Step 3 — verification checklist -->
|
||||
<div class="inv-step">
|
||||
<div class="inv-step-t">
|
||||
<span class="inv-num">3</span> Verification checklist
|
||||
<span class="checklist-progress" [class.done]="allAnswered">
|
||||
{{ answeredCount }} / {{ strChecklist.length }} answered
|
||||
</span>
|
||||
</div>
|
||||
<table class="checklist-tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Verification point</th>
|
||||
<th class="ta-c col-yes">Genuine</th>
|
||||
<th class="ta-c col-no">Not satisfied</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (item of strChecklist; track item.key) {
|
||||
<tr
|
||||
[class.row-yes]="strFindings[item.key] === true"
|
||||
[class.row-no]="strFindings[item.key] === false"
|
||||
[class.row-pending]="strFindings[item.key] === null"
|
||||
>
|
||||
<td>
|
||||
<div class="chk-label">{{ item.label }}</div>
|
||||
<div class="chk-hint">{{ item.hint }}</div>
|
||||
</td>
|
||||
<td class="ta-c">
|
||||
<button
|
||||
type="button"
|
||||
class="chk-box chk-box--yes"
|
||||
[class.on]="strFindings[item.key] === true"
|
||||
[attr.aria-label]="'Mark ' + item.label + ' as genuine'"
|
||||
(click)="setFinding(item.key, true)"
|
||||
>
|
||||
<fa-icon icon="check-circle"></fa-icon>
|
||||
</button>
|
||||
</td>
|
||||
<td class="ta-c">
|
||||
<button
|
||||
type="button"
|
||||
class="chk-box chk-box--no"
|
||||
[class.on]="strFindings[item.key] === false"
|
||||
[attr.aria-label]="'Mark ' + item.label + ' as not satisfied'"
|
||||
(click)="setFinding(item.key, false)"
|
||||
>
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Derived outcome -->
|
||||
<div
|
||||
class="verdict"
|
||||
[class.verdict--clear]="canConcludeLocal && derivedConclusion === 'NOT_SUSPICIOUS'"
|
||||
[class.verdict--suspicious]="canConcludeLocal && derivedConclusion === 'SUSPICIOUS'"
|
||||
>
|
||||
@if (!canConcludeLocal) {
|
||||
<fa-icon icon="info-circle"></fa-icon>
|
||||
<span>
|
||||
Complete all three steps to determine the outcome.
|
||||
@if (!evidenceRecorded) {
|
||||
<span> Evidence not yet recorded.</span>
|
||||
}
|
||||
@if (!allAnswered) {
|
||||
<span> {{ strChecklist.length - answeredCount }} verification point(s) pending.</span>
|
||||
}
|
||||
</span>
|
||||
} @else if (derivedConclusion === 'NOT_SUSPICIOUS') {
|
||||
<fa-icon icon="check-circle"></fa-icon>
|
||||
<span>
|
||||
<strong>Source of funds established.</strong> Case closes as clean — the transaction stays in
|
||||
the CTR report and no STR is filed with FIU-IND.
|
||||
</span>
|
||||
} @else {
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||
<span>
|
||||
<strong>Source of funds not established.</strong> {{ failedFindings.length }} point(s)
|
||||
unsatisfied — an STR will be filed with FIU-IND in addition to the CTR.
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<button class="fiu-btn fiu-btn-xml" [disabled]="!canConcludeLocal" (click)="concludeLocalStr()">
|
||||
{{
|
||||
canConcludeLocal
|
||||
? derivedConclusion === 'SUSPICIOUS'
|
||||
? 'Conclude — file STR with FIU-IND'
|
||||
: 'Conclude — close as clean (CTR only)'
|
||||
: 'Conclude'
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
} @empty {
|
||||
@@ -626,10 +1069,13 @@
|
||||
<div class="fiu-form-meta">
|
||||
<div class="fiu-form-title">
|
||||
<h3>FINnet 2.0 Submission Files</h3>
|
||||
<p>6 CSV files in FIU-IND prescribed schema, plus STR XML</p>
|
||||
<p>
|
||||
6 CSV files in FIU-IND prescribed schema, plus STR XML. Counts reflect any filters set on the CTR and STR
|
||||
tabs, so every file in the bundle covers the same transactions.
|
||||
</p>
|
||||
</div>
|
||||
<button class="fiu-btn fiu-btn-xls" (click)="exportAll()">
|
||||
<fa-icon icon="file-csv"></fa-icon> Download All
|
||||
<fa-icon icon="file-csv"></fa-icon> Download All 7
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -641,7 +1087,7 @@
|
||||
</div>
|
||||
<div class="file-card">
|
||||
<div class="file-card-h"><fa-icon icon="file-csv"></fa-icon>TC2.csv</div>
|
||||
<p>Branch / cash transactions ({{ ctrTxns.length }} rows)</p>
|
||||
<p>Branch / cash transactions ({{ filteredCtr.length }} rows)</p>
|
||||
<button class="fiu-btn fiu-btn-csv" (click)="exportTc2()">
|
||||
<fa-icon icon="file-csv"></fa-icon> Download
|
||||
</button>
|
||||
@@ -676,7 +1122,7 @@
|
||||
</div>
|
||||
<div class="file-card file-card--xml">
|
||||
<div class="file-card-h"><fa-icon icon="file-alt"></fa-icon>STR.xml</div>
|
||||
<p>Suspicious Transaction Report ({{ filedStr + pendingStr }} suspects)</p>
|
||||
<p>Suspicious Transaction Report ({{ strSuspects.length }} suspects — cases concluded clean are excluded)</p>
|
||||
<button class="fiu-btn fiu-btn-xml" (click)="exportStrXml()">
|
||||
<fa-icon icon="file-alt"></fa-icon> Download XML
|
||||
</button>
|
||||
|
||||
@@ -386,6 +386,172 @@
|
||||
align-self: end;
|
||||
}
|
||||
|
||||
/* CTR outcome 1: source of funds evidenced at the counter — CTR only. */
|
||||
.fiu-btn-evidence {
|
||||
padding: 5px 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--f-green);
|
||||
border-color: var(--f-green);
|
||||
background: var(--f-green-l);
|
||||
|
||||
&:hover {
|
||||
background: var(--f-green);
|
||||
border-color: var(--f-green);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
/* Inline evidence capture row inside the TC2 table. */
|
||||
.evidence-row td {
|
||||
background: var(--f-green-l) !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.evidence-panel {
|
||||
padding: 12px 16px;
|
||||
border-left: 3px solid var(--f-green);
|
||||
}
|
||||
|
||||
.evidence-t {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #14532d;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.evidence-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr auto;
|
||||
gap: 12px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.evidence-note {
|
||||
margin: 8px 0 0;
|
||||
font-size: 11px;
|
||||
color: var(--f-g700);
|
||||
}
|
||||
|
||||
@media (width <= 900px) {
|
||||
.evidence-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* CTR outcome 2: evidence unavailable — refer the transaction onward to STR. */
|
||||
.fiu-btn-refer {
|
||||
padding: 5px 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--f-red);
|
||||
border-color: var(--f-red);
|
||||
background: var(--f-red-l);
|
||||
|
||||
&:hover {
|
||||
background: var(--f-red);
|
||||
border-color: var(--f-red);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.fiu-link-btn {
|
||||
display: inline-block;
|
||||
margin-top: 3px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--f-red);
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
|
||||
&:hover {
|
||||
color: #9a2f22;
|
||||
}
|
||||
|
||||
&--muted {
|
||||
color: var(--f-g500);
|
||||
|
||||
&:hover {
|
||||
color: var(--f-red);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* PRINCIPAL OFFICER INVESTIGATION */
|
||||
.str-po-action {
|
||||
background: var(--f-amber-l);
|
||||
border: 1px dashed #fcd34d;
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
> label {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: #92400e;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
}
|
||||
|
||||
.fiu-btn-clean {
|
||||
background: var(--f-green);
|
||||
border-color: var(--f-green);
|
||||
color: #fff;
|
||||
|
||||
&:hover {
|
||||
background: #166534;
|
||||
border-color: #166534;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.str-finding {
|
||||
background: var(--f-green-l);
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
|
||||
label {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: var(--f-green);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: #14532d;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
&--suspicious {
|
||||
background: var(--f-red-l);
|
||||
|
||||
label {
|
||||
color: var(--f-red);
|
||||
}
|
||||
|
||||
p {
|
||||
color: #7f1d1d;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* INPUTS */
|
||||
.fiu-inp {
|
||||
width: 100%;
|
||||
@@ -538,6 +704,50 @@
|
||||
}
|
||||
}
|
||||
|
||||
.report-pill {
|
||||
display: inline-block;
|
||||
padding: 2px 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
|
||||
&.report-ctr {
|
||||
background: var(--f-blue-l);
|
||||
color: var(--f-blue);
|
||||
}
|
||||
|
||||
&.report-str {
|
||||
background: var(--f-red-l);
|
||||
color: var(--f-red);
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
&.report-pending {
|
||||
background: var(--f-amber-l);
|
||||
color: var(--f-amber);
|
||||
}
|
||||
|
||||
&.report-cleared {
|
||||
background: var(--f-green-l);
|
||||
color: var(--f-green);
|
||||
}
|
||||
}
|
||||
|
||||
.fiu-tbl-legend {
|
||||
margin: -6px 2px 0;
|
||||
font-size: 11px;
|
||||
color: var(--f-g500);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.report-pill {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.rel-pill {
|
||||
display: inline-block;
|
||||
padding: 2px 9px;
|
||||
@@ -570,6 +780,11 @@
|
||||
color: var(--f-amber);
|
||||
}
|
||||
|
||||
&.st-awaiting-filing {
|
||||
background: #ffedd5;
|
||||
color: #9a3412;
|
||||
}
|
||||
|
||||
&.st-filed {
|
||||
background: var(--f-green-l);
|
||||
color: var(--f-green);
|
||||
@@ -697,6 +912,11 @@
|
||||
&--filed {
|
||||
border-color: #86efac;
|
||||
}
|
||||
|
||||
&--highlight {
|
||||
border-color: var(--f-red);
|
||||
box-shadow: 0 0 0 3px rgb(192 57 43 / 20%);
|
||||
}
|
||||
}
|
||||
|
||||
.str-card-h {
|
||||
@@ -956,6 +1176,29 @@
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.case-outcome {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin: 12px 0;
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
background: var(--f-g100);
|
||||
font-size: 13px;
|
||||
color: var(--f-g700);
|
||||
|
||||
&--clear {
|
||||
background: var(--f-green-l);
|
||||
color: #14532d;
|
||||
}
|
||||
|
||||
&--suspicious {
|
||||
background: var(--f-red-l);
|
||||
color: #7f1d1d;
|
||||
}
|
||||
}
|
||||
|
||||
.case-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -995,6 +1238,303 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Principal Officer verification checklist ───────────────────────────── */
|
||||
.checklist {
|
||||
border: 1px solid var(--f-g200);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.checklist-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
background: var(--f-g50);
|
||||
border-bottom: 1px solid var(--f-g200);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--f-g700);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.checklist-progress {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
padding: 2px 10px;
|
||||
border-radius: 999px;
|
||||
background: var(--f-amber-l);
|
||||
color: var(--f-amber);
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
white-space: nowrap;
|
||||
|
||||
&.done {
|
||||
background: var(--f-green-l);
|
||||
color: var(--f-green);
|
||||
}
|
||||
}
|
||||
|
||||
.checklist-tbl {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
|
||||
th {
|
||||
padding: 8px 12px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: var(--f-g500);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
background: var(--f-g50);
|
||||
border-bottom: 1px solid var(--f-g200);
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
th.col-yes {
|
||||
color: var(--f-green);
|
||||
width: 96px;
|
||||
}
|
||||
|
||||
th.col-no {
|
||||
color: var(--f-red);
|
||||
width: 112px;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 9px 12px;
|
||||
border-bottom: 1px solid var(--f-g100);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Answered rows tint so an unresolved finding is obvious at a glance. */
|
||||
.row-yes td {
|
||||
background: rgb(220 252 231 / 45%);
|
||||
}
|
||||
|
||||
.row-no td {
|
||||
background: rgb(253 236 234 / 60%);
|
||||
}
|
||||
|
||||
.row-pending td {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.chk-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--f-g900);
|
||||
}
|
||||
|
||||
.chk-hint {
|
||||
font-size: 11px;
|
||||
color: var(--f-g500);
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
/* Two-sided toggle: one box on the genuine side, one on the not-satisfied side. */
|
||||
.chk-box {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 7px;
|
||||
border: 2px solid var(--f-g300);
|
||||
background: #fff;
|
||||
color: var(--f-g300);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
&--yes:hover {
|
||||
border-color: var(--f-green);
|
||||
color: var(--f-green);
|
||||
}
|
||||
|
||||
&--no:hover {
|
||||
border-color: var(--f-red);
|
||||
color: var(--f-red);
|
||||
}
|
||||
|
||||
&--yes.on {
|
||||
border-color: var(--f-green);
|
||||
background: var(--f-green);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&--no.on {
|
||||
border-color: var(--f-red);
|
||||
background: var(--f-red);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
/* Concluded suspicious, still to be submitted on the FINnet portal. */
|
||||
.str-filing {
|
||||
background: #fff7ed;
|
||||
border: 1px solid #fdba74;
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #9a3412;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
}
|
||||
|
||||
.filing-hint {
|
||||
margin: 4px 0 8px;
|
||||
font-size: 12px;
|
||||
color: var(--f-g700);
|
||||
}
|
||||
|
||||
.filing-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.fiu-inp {
|
||||
max-width: 260px;
|
||||
}
|
||||
}
|
||||
|
||||
.str-filed-ack {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: var(--f-green-l);
|
||||
border: 1px solid #86efac;
|
||||
border-radius: 8px;
|
||||
padding: 9px 14px;
|
||||
font-size: 12px;
|
||||
color: #14532d;
|
||||
}
|
||||
|
||||
/* ── CTR-referred case: Principal Officer investigation workspace ───────── */
|
||||
.str-investigation {
|
||||
margin-top: 12px;
|
||||
border: 1px solid var(--f-purple);
|
||||
border-radius: 10px;
|
||||
background: #fdfcff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.inv-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--f-purple);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.inv-step {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--f-g300);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.inv-step-t {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--f-g900);
|
||||
}
|
||||
|
||||
.inv-num {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: var(--f-purple);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.inv-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@media (width <= 900px) {
|
||||
.inv-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Derived verdict banner */
|
||||
.verdict {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 11px 15px;
|
||||
border-radius: 9px;
|
||||
font-size: 13px;
|
||||
background: var(--f-g100);
|
||||
border: 1px solid var(--f-g200);
|
||||
color: var(--f-g700);
|
||||
|
||||
&--clear {
|
||||
background: var(--f-green-l);
|
||||
border-color: #86efac;
|
||||
color: #14532d;
|
||||
}
|
||||
|
||||
&--suspicious {
|
||||
background: var(--f-red-l);
|
||||
border-color: #fca5a5;
|
||||
color: #7f1d1d;
|
||||
}
|
||||
}
|
||||
|
||||
.case-form .fiu-btn-xml:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.file-str-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1009,3 +1549,301 @@
|
||||
color: var(--f-g500);
|
||||
}
|
||||
}
|
||||
|
||||
/* ═══ WHEN TO REPORT — the five PMLA obligations ═══ */
|
||||
.ob-block {
|
||||
margin: 18px 0 20px;
|
||||
}
|
||||
|
||||
.ob-head {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.ob-title {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ob-sub {
|
||||
margin: 3px 0 0;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
max-width: 860px;
|
||||
}
|
||||
|
||||
.ob-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.ob-card {
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-left: 4px solid #1a237e;
|
||||
border-radius: 11px;
|
||||
padding: 13px 15px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
|
||||
/* Obligations the module only tracks read back a shade quieter than the
|
||||
two it actually files, so the distinction is visible at a glance. */
|
||||
&--off {
|
||||
border-left-color: #cbd5e1;
|
||||
background: #fcfcfd;
|
||||
}
|
||||
}
|
||||
|
||||
.ob-card-h {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.ob-ic {
|
||||
color: #1a237e;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.ob-code {
|
||||
font-weight: 800;
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.ob-name {
|
||||
font-size: 12px;
|
||||
color: #334155;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ob-flag {
|
||||
margin-left: auto;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
padding: 1px 8px;
|
||||
border-radius: 999px;
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
white-space: nowrap;
|
||||
|
||||
&--on {
|
||||
background: #dcfce7;
|
||||
color: #15803d;
|
||||
}
|
||||
}
|
||||
|
||||
.ob-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
|
||||
label {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 12.5px;
|
||||
color: #0f172a;
|
||||
line-height: 1.45;
|
||||
}
|
||||
}
|
||||
|
||||
.ob-due {
|
||||
font-weight: 700;
|
||||
color: #b45309 !important;
|
||||
}
|
||||
|
||||
.ob-note {
|
||||
font-size: 11.5px;
|
||||
color: #64748b;
|
||||
font-style: italic;
|
||||
border-top: 1px dashed #e2e8f0;
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
/* ═══ FINNET FILE SEQUENCE ═══ */
|
||||
.seq-block {
|
||||
margin-top: 26px;
|
||||
padding-top: 18px;
|
||||
border-top: 2px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.seq-rail {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.seq-step {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 7px 13px;
|
||||
border: 1px solid #cbd5e1;
|
||||
background: #fff;
|
||||
border-radius: 9px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
border-color: #1a237e;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: #1a237e;
|
||||
border-color: #1a237e;
|
||||
|
||||
.seq-n {
|
||||
background: #fff;
|
||||
color: #1a237e;
|
||||
}
|
||||
|
||||
.seq-txt strong,
|
||||
.seq-txt em {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.seq-n {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 21px;
|
||||
height: 21px;
|
||||
border-radius: 50%;
|
||||
background: #1a237e;
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
flex: 0 0 21px;
|
||||
}
|
||||
|
||||
.seq-txt {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.25;
|
||||
|
||||
strong {
|
||||
font-size: 12.5px;
|
||||
color: #0f172a;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
em {
|
||||
font-size: 10.5px;
|
||||
color: #64748b;
|
||||
font-style: normal;
|
||||
}
|
||||
}
|
||||
|
||||
.seq-arrow {
|
||||
color: #cbd5e1;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.seq-panel {
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 11px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.seq-panel-h {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
padding: 12px 16px;
|
||||
background: #f8fafc;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
flex-wrap: wrap;
|
||||
|
||||
h4 {
|
||||
margin: 0;
|
||||
font-size: 13.5px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 2px 0 0;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
max-width: 780px;
|
||||
}
|
||||
}
|
||||
|
||||
.seq-tbl-wrap {
|
||||
max-height: 460px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.seq-tbl {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 11.5px;
|
||||
white-space: nowrap;
|
||||
|
||||
th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
text-align: left;
|
||||
padding: 8px 10px;
|
||||
background: #eef2f7;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: #334155;
|
||||
border-bottom: 1px solid #cbd5e1;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 6px 10px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
font-family: 'DM Mono', monospace;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
tbody tr:hover td {
|
||||
background: #f8fafc;
|
||||
}
|
||||
}
|
||||
|
||||
.seq-rownum {
|
||||
color: #94a3b8 !important;
|
||||
font-weight: 700;
|
||||
text-align: right;
|
||||
position: sticky;
|
||||
left: 0;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.seq-tbl thead .seq-rownum {
|
||||
background: #eef2f7;
|
||||
}
|
||||
|
||||
.seq-empty {
|
||||
padding: 36px !important;
|
||||
text-align: center;
|
||||
color: #64748b;
|
||||
font-family: inherit !important;
|
||||
white-space: normal;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
+945
-81
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,638 @@
|
||||
/**
|
||||
* 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 { GL_BRANCHES, GL_PERIOD } from '../gl-reports/gl-reports-data';
|
||||
|
||||
/**
|
||||
* Transactions of customers who banked on a Form 97 / Form 98 declaration.
|
||||
*
|
||||
* Rule 114B of the Income-tax Rules lists transactions where quoting PAN is
|
||||
* mandatory. A customer who has no PAN gives a declaration instead:
|
||||
*
|
||||
* Form 97 — person with no PAN (income may be taxable).
|
||||
* Form 98 — person whose only income is agricultural and who has no PAN.
|
||||
*
|
||||
* The bank must be able to produce, for any period, every transaction put
|
||||
* through on the strength of such a declaration. That is what this report is.
|
||||
*/
|
||||
|
||||
export type FormType = 'FORM_60' | 'FORM_61';
|
||||
|
||||
/** Screen and workbook labels — this bank numbers the declarations 97 and 98. */
|
||||
export const FORM_LABEL: Record<FormType, string> = {
|
||||
FORM_60: 'Form 97',
|
||||
FORM_61: 'Form 98'
|
||||
};
|
||||
|
||||
export interface FormDeclarant {
|
||||
/** Declaration acknowledgement number, as issued by the branch. */
|
||||
ackNo: string;
|
||||
formType: FormType;
|
||||
name: string;
|
||||
fatherName: string;
|
||||
dateOfBirth: string;
|
||||
address: string;
|
||||
branchCode: string;
|
||||
accountNo: string;
|
||||
/** Why the customer has no PAN — printed on the declaration. */
|
||||
reasonForNoPan: string;
|
||||
estimatedIncome: number;
|
||||
declaredOn: string;
|
||||
aadhaarLast4: string;
|
||||
}
|
||||
|
||||
export type TxnMode = 'CASH' | 'TRANSFER' | 'CLEARING' | 'NEFT' | 'RTGS' | 'UPI' | 'ATM';
|
||||
|
||||
export interface FormDeclarantTxn {
|
||||
txnId: string;
|
||||
ackNo: string;
|
||||
date: string;
|
||||
branchCode: string;
|
||||
accountNo: string;
|
||||
mode: TxnMode;
|
||||
drCr: 'DR' | 'CR';
|
||||
amount: number;
|
||||
/** What the transaction was — a Rule 114B category or ordinary activity. */
|
||||
nature: string;
|
||||
/** Coarse bucket the nature belongs to, e.g. Cash, Clearing, Loan. */
|
||||
group: string;
|
||||
/** True when the nature is one of the categories listed in Rule 114B. */
|
||||
rule114B: boolean;
|
||||
particulars: string;
|
||||
voucherNo: string;
|
||||
}
|
||||
|
||||
/** Deterministic linear congruential generator — same figures on every load. */
|
||||
function lcg(seed: number): () => number {
|
||||
let s = seed >>> 0;
|
||||
return () => {
|
||||
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
|
||||
return s / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
function pick<T>(rand: () => number, list: T[]): T {
|
||||
return list[Math.floor(rand() * list.length)];
|
||||
}
|
||||
|
||||
function isoDate(d: Date): string {
|
||||
const y = d.getUTCFullYear();
|
||||
const m = `${d.getUTCMonth() + 1}`.padStart(2, '0');
|
||||
const day = `${d.getUTCDate()}`.padStart(2, '0');
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Thresholds that make a row worth a second look.
|
||||
*
|
||||
* `RULE_114B_CASH` is the cash-deposit limit above which a declaration is
|
||||
* required at all; `CTR` is the aggregate cash figure that puts the customer
|
||||
* into the monthly Cash Transaction Report.
|
||||
*/
|
||||
export const FORM_THRESHOLD = {
|
||||
RULE_114B_CASH: 50_000,
|
||||
CTR: 1_000_000
|
||||
};
|
||||
|
||||
const FIRST_NAMES = [
|
||||
'Ramesh',
|
||||
'Sunita',
|
||||
'Vitthal',
|
||||
'Kamala',
|
||||
'Ganpat',
|
||||
'Shobha',
|
||||
'Dnyaneshwar',
|
||||
'Mangala',
|
||||
'Bhaskar',
|
||||
'Anita',
|
||||
'Tukaram',
|
||||
'Sarika',
|
||||
'Nivrutti',
|
||||
'Rekha',
|
||||
'Popat',
|
||||
'Chhaya',
|
||||
'Ashok',
|
||||
'Vaishali',
|
||||
'Bapu',
|
||||
'Nanda',
|
||||
'Sitaram',
|
||||
'Jayshree',
|
||||
'Madhukar',
|
||||
'Pushpa',
|
||||
'Eknath',
|
||||
'Sulochana',
|
||||
'Baban',
|
||||
'Indubai',
|
||||
'Hanumant',
|
||||
'Kalpana'
|
||||
];
|
||||
|
||||
const SURNAMES = [
|
||||
'Jadhav',
|
||||
'Pawar',
|
||||
'Shinde',
|
||||
'Gaikwad',
|
||||
'More',
|
||||
'Kadam',
|
||||
'Bhosale',
|
||||
'Patil',
|
||||
'Sawant',
|
||||
'Chavan',
|
||||
'Salunkhe',
|
||||
'Mane',
|
||||
'Deshmukh',
|
||||
'Thorat',
|
||||
'Nikam',
|
||||
'Wagh',
|
||||
'Kale',
|
||||
'Ghadge',
|
||||
'Bhoir',
|
||||
'Tandel'
|
||||
];
|
||||
|
||||
const VILLAGES = [
|
||||
'Wadgaon',
|
||||
'Kolhewadi',
|
||||
'Shirgaon',
|
||||
'Nandgaon',
|
||||
'Pimpalgaon',
|
||||
'Bhosari',
|
||||
'Rajgurunagar',
|
||||
'Talegaon',
|
||||
'Karjat',
|
||||
'Murbad',
|
||||
'Shahapur',
|
||||
'Vasind'
|
||||
];
|
||||
|
||||
/** Reasons a Form 97 declarant gives — Form 98 has its own single reason. */
|
||||
const NO_PAN_REASONS = [
|
||||
'Applied for PAN, allotment awaited',
|
||||
'Total income below taxable limit',
|
||||
'Not required to file return of income',
|
||||
'First-time account holder, PAN not yet obtained'
|
||||
];
|
||||
|
||||
/**
|
||||
* Every kind of transaction one of these customers puts through — not only the
|
||||
* ones that triggered a declaration.
|
||||
*
|
||||
* `rule114B` marks the categories listed in Rule 114B, where quoting PAN (and
|
||||
* therefore the Form 97/98 declaration) is mandatory above the limit. The rest
|
||||
* are ordinary account activity, which the report still has to show: the point
|
||||
* is the customer's whole picture, not just the declared slice.
|
||||
*
|
||||
* `drCr` is fixed where the category only goes one way; 'EITHER' lets the
|
||||
* generator choose. `cashOnly` forces counter cash, `nonCash` excludes it.
|
||||
*/
|
||||
interface TxnKind {
|
||||
nature: string;
|
||||
group: string;
|
||||
rule114B: boolean;
|
||||
drCr: 'DR' | 'CR' | 'EITHER';
|
||||
cashOnly?: boolean;
|
||||
nonCash?: boolean;
|
||||
/** Relative frequency — small routine postings outnumber big declared ones. */
|
||||
weight: number;
|
||||
/** Typical value band, before the occasional large-value tail. */
|
||||
min: number;
|
||||
max: number;
|
||||
}
|
||||
|
||||
const TXN_KINDS: TxnKind[] = [
|
||||
// ── Rule 114B categories: the transactions the declaration was taken for ──
|
||||
{
|
||||
nature: 'Cash deposit exceeding ₹50,000 in a day',
|
||||
group: 'Cash',
|
||||
rule114B: true,
|
||||
drCr: 'CR',
|
||||
cashOnly: true,
|
||||
weight: 14,
|
||||
min: 50_000,
|
||||
max: 470_000
|
||||
},
|
||||
{
|
||||
nature: 'Cash withdrawal exceeding ₹50,000',
|
||||
group: 'Cash',
|
||||
rule114B: true,
|
||||
drCr: 'DR',
|
||||
cashOnly: true,
|
||||
weight: 8,
|
||||
min: 50_000,
|
||||
max: 380_000
|
||||
},
|
||||
{
|
||||
nature: 'Opening of deposit account',
|
||||
group: 'Account',
|
||||
rule114B: true,
|
||||
drCr: 'CR',
|
||||
cashOnly: true,
|
||||
weight: 3,
|
||||
min: 5_000,
|
||||
max: 50_000
|
||||
},
|
||||
{
|
||||
nature: 'Term deposit exceeding ₹50,000',
|
||||
group: 'Deposit',
|
||||
rule114B: true,
|
||||
drCr: 'DR',
|
||||
weight: 5,
|
||||
min: 50_000,
|
||||
max: 900_000
|
||||
},
|
||||
{
|
||||
nature: 'Purchase of demand draft in cash',
|
||||
group: 'Remittance',
|
||||
rule114B: true,
|
||||
drCr: 'DR',
|
||||
cashOnly: true,
|
||||
weight: 4,
|
||||
min: 50_000,
|
||||
max: 260_000
|
||||
},
|
||||
{
|
||||
nature: 'Sale/purchase of goods or services above ₹2,00,000',
|
||||
group: 'Trade',
|
||||
rule114B: true,
|
||||
drCr: 'EITHER',
|
||||
weight: 3,
|
||||
min: 200_000,
|
||||
max: 850_000
|
||||
},
|
||||
|
||||
// ── Ordinary account activity ────────────────────────────────────────────
|
||||
{
|
||||
nature: 'Cash deposit',
|
||||
group: 'Cash',
|
||||
rule114B: false,
|
||||
drCr: 'CR',
|
||||
cashOnly: true,
|
||||
weight: 16,
|
||||
min: 500,
|
||||
max: 49_900
|
||||
},
|
||||
{
|
||||
nature: 'Cash withdrawal',
|
||||
group: 'Cash',
|
||||
rule114B: false,
|
||||
drCr: 'DR',
|
||||
cashOnly: true,
|
||||
weight: 15,
|
||||
min: 500,
|
||||
max: 49_900
|
||||
},
|
||||
{
|
||||
nature: 'Cheque deposit — outward clearing',
|
||||
group: 'Clearing',
|
||||
rule114B: false,
|
||||
drCr: 'CR',
|
||||
nonCash: true,
|
||||
weight: 7,
|
||||
min: 2_000,
|
||||
max: 180_000
|
||||
},
|
||||
{
|
||||
nature: 'Cheque paid — inward clearing',
|
||||
group: 'Clearing',
|
||||
rule114B: false,
|
||||
drCr: 'DR',
|
||||
nonCash: true,
|
||||
weight: 6,
|
||||
min: 2_000,
|
||||
max: 160_000
|
||||
},
|
||||
{
|
||||
nature: 'NEFT inward credit',
|
||||
group: 'Remittance',
|
||||
rule114B: false,
|
||||
drCr: 'CR',
|
||||
nonCash: true,
|
||||
weight: 6,
|
||||
min: 1_000,
|
||||
max: 220_000
|
||||
},
|
||||
{
|
||||
nature: 'NEFT outward remittance',
|
||||
group: 'Remittance',
|
||||
rule114B: false,
|
||||
drCr: 'DR',
|
||||
nonCash: true,
|
||||
weight: 5,
|
||||
min: 1_000,
|
||||
max: 200_000
|
||||
},
|
||||
{
|
||||
nature: 'RTGS inward credit',
|
||||
group: 'Remittance',
|
||||
rule114B: false,
|
||||
drCr: 'CR',
|
||||
nonCash: true,
|
||||
weight: 2,
|
||||
min: 200_000,
|
||||
max: 1_200_000
|
||||
},
|
||||
{
|
||||
nature: 'UPI / mobile banking transfer',
|
||||
group: 'Digital',
|
||||
rule114B: false,
|
||||
drCr: 'EITHER',
|
||||
nonCash: true,
|
||||
weight: 10,
|
||||
min: 100,
|
||||
max: 24_000
|
||||
},
|
||||
{
|
||||
nature: 'ATM cash withdrawal',
|
||||
group: 'Digital',
|
||||
rule114B: false,
|
||||
drCr: 'DR',
|
||||
cashOnly: true,
|
||||
weight: 9,
|
||||
min: 500,
|
||||
max: 20_000
|
||||
},
|
||||
{
|
||||
nature: 'Transfer between own accounts',
|
||||
group: 'Transfer',
|
||||
rule114B: false,
|
||||
drCr: 'EITHER',
|
||||
nonCash: true,
|
||||
weight: 5,
|
||||
min: 1_000,
|
||||
max: 120_000
|
||||
},
|
||||
{
|
||||
nature: 'Loan instalment (EMI) recovery',
|
||||
group: 'Loan',
|
||||
rule114B: false,
|
||||
drCr: 'DR',
|
||||
weight: 7,
|
||||
min: 2_500,
|
||||
max: 42_000
|
||||
},
|
||||
{
|
||||
nature: 'Loan disbursement credited',
|
||||
group: 'Loan',
|
||||
rule114B: false,
|
||||
drCr: 'CR',
|
||||
nonCash: true,
|
||||
weight: 2,
|
||||
min: 50_000,
|
||||
max: 700_000
|
||||
},
|
||||
{
|
||||
nature: 'Interest credited on deposit',
|
||||
group: 'Interest',
|
||||
rule114B: false,
|
||||
drCr: 'CR',
|
||||
nonCash: true,
|
||||
weight: 6,
|
||||
min: 120,
|
||||
max: 14_000
|
||||
},
|
||||
{
|
||||
nature: 'Interest debited on loan',
|
||||
group: 'Interest',
|
||||
rule114B: false,
|
||||
drCr: 'DR',
|
||||
nonCash: true,
|
||||
weight: 4,
|
||||
min: 200,
|
||||
max: 11_000
|
||||
},
|
||||
{
|
||||
nature: 'Term deposit matured and credited',
|
||||
group: 'Deposit',
|
||||
rule114B: false,
|
||||
drCr: 'CR',
|
||||
nonCash: true,
|
||||
weight: 3,
|
||||
min: 25_000,
|
||||
max: 620_000
|
||||
},
|
||||
{
|
||||
nature: 'Recurring deposit instalment',
|
||||
group: 'Deposit',
|
||||
rule114B: false,
|
||||
drCr: 'DR',
|
||||
weight: 5,
|
||||
min: 500,
|
||||
max: 12_000
|
||||
},
|
||||
{
|
||||
nature: 'Service charges / commission recovered',
|
||||
group: 'Charges',
|
||||
rule114B: false,
|
||||
drCr: 'DR',
|
||||
nonCash: true,
|
||||
weight: 6,
|
||||
min: 25,
|
||||
max: 2_400
|
||||
},
|
||||
{
|
||||
nature: 'Cheque return charges',
|
||||
group: 'Charges',
|
||||
rule114B: false,
|
||||
drCr: 'DR',
|
||||
nonCash: true,
|
||||
weight: 2,
|
||||
min: 100,
|
||||
max: 900
|
||||
},
|
||||
{
|
||||
nature: 'Locker rent recovered',
|
||||
group: 'Charges',
|
||||
rule114B: false,
|
||||
drCr: 'DR',
|
||||
nonCash: true,
|
||||
weight: 1,
|
||||
min: 800,
|
||||
max: 4_500
|
||||
},
|
||||
{
|
||||
nature: 'Dividend credited on shares',
|
||||
group: 'Shares',
|
||||
rule114B: false,
|
||||
drCr: 'CR',
|
||||
nonCash: true,
|
||||
weight: 2,
|
||||
min: 200,
|
||||
max: 9_000
|
||||
},
|
||||
{
|
||||
nature: 'Share capital subscription',
|
||||
group: 'Shares',
|
||||
rule114B: false,
|
||||
drCr: 'DR',
|
||||
weight: 2,
|
||||
min: 500,
|
||||
max: 25_000
|
||||
}
|
||||
];
|
||||
|
||||
/** Flattened by weight so a single draw picks a category at the right rate. */
|
||||
const KIND_POOL: TxnKind[] = TXN_KINDS.flatMap((k) => Array<TxnKind>(k.weight).fill(k));
|
||||
|
||||
/** The distinct groups, for the report's category filter. */
|
||||
export const TXN_GROUPS: string[] = [...new Set(TXN_KINDS.map((k) => k.group))].sort();
|
||||
|
||||
let declarantCache: FormDeclarant[] | null = null;
|
||||
let txnCache: FormDeclarantTxn[] | null = null;
|
||||
|
||||
/**
|
||||
* The customers who filed a declaration. Roughly one in five is a Form 98
|
||||
* (agricultural income only), which matches the mix a semi-urban co-operative
|
||||
* bank sees — the rural branches carry most of them.
|
||||
*/
|
||||
export function formDeclarants(): FormDeclarant[] {
|
||||
if (declarantCache) {
|
||||
return declarantCache;
|
||||
}
|
||||
const rand = lcg(31071998);
|
||||
const branches = GL_BRANCHES.filter((b) => b.code !== 'HO');
|
||||
const out: FormDeclarant[] = [];
|
||||
|
||||
for (let i = 0; i < 148; i++) {
|
||||
const branch = branches[i % branches.length];
|
||||
// Form 98 is the agricultural-income declaration, so it clusters in the
|
||||
// outlying branches rather than being spread evenly.
|
||||
const rural = branch.code === '0004' || branch.code === '0006';
|
||||
const formType: FormType = rand() < (rural ? 0.42 : 0.08) ? 'FORM_61' : 'FORM_60';
|
||||
|
||||
const first = pick(rand, FIRST_NAMES);
|
||||
const last = pick(rand, SURNAMES);
|
||||
const seq = `${i + 1}`.padStart(3, '0');
|
||||
|
||||
// Declarations are taken in the run-up to and during the ledger period.
|
||||
const declOffset = Math.floor(rand() * 150);
|
||||
const declDate = new Date(`${GL_PERIOD.fyStart}T00:00:00Z`);
|
||||
declDate.setUTCDate(declDate.getUTCDate() - 60 + declOffset);
|
||||
|
||||
const birthYear = 1955 + Math.floor(rand() * 45);
|
||||
const birthMonth = 1 + Math.floor(rand() * 12);
|
||||
const birthDay = 1 + Math.floor(rand() * 28);
|
||||
|
||||
out.push({
|
||||
ackNo: `${branch.code}/F${formType === 'FORM_60' ? '97' : '98'}/${seq}`,
|
||||
formType,
|
||||
name: `${first} ${last}`,
|
||||
fatherName: `${pick(rand, FIRST_NAMES)} ${last}`,
|
||||
dateOfBirth: `${birthYear}-${`${birthMonth}`.padStart(2, '0')}-${`${birthDay}`.padStart(2, '0')}`,
|
||||
address: `${pick(rand, VILLAGES)}, Tal. ${pick(rand, VILLAGES)}, Dist. Thane`,
|
||||
branchCode: branch.code,
|
||||
accountNo: `${branch.code}${(4100000 + i * 137).toString().padStart(8, '0')}`,
|
||||
reasonForNoPan:
|
||||
formType === 'FORM_61' ? 'Income consists only of agricultural income' : pick(rand, NO_PAN_REASONS),
|
||||
// Form 98 declarants report agricultural income, which the Act exempts, so
|
||||
// their declared figures sit below the Form 97 band.
|
||||
estimatedIncome:
|
||||
formType === 'FORM_61' ? 90_000 + Math.floor(rand() * 160_000) : 140_000 + Math.floor(rand() * 380_000),
|
||||
declaredOn: isoDate(declDate),
|
||||
aadhaarLast4: `${1000 + Math.floor(rand() * 9000)}`
|
||||
});
|
||||
}
|
||||
|
||||
declarantCache = out;
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every transaction these customers put through inside the ledger period.
|
||||
*
|
||||
* A declaration is taken per transaction category, so an active customer shows
|
||||
* several rows; a customer who only opened an account shows one.
|
||||
*/
|
||||
export function formDeclarantTxns(): FormDeclarantTxn[] {
|
||||
if (txnCache) {
|
||||
return txnCache;
|
||||
}
|
||||
const rand = lcg(19980731);
|
||||
const start = new Date(`${GL_PERIOD.fyStart}T00:00:00Z`);
|
||||
const end = new Date(`${GL_PERIOD.fyEnd}T00:00:00Z`);
|
||||
const span = Math.round((end.getTime() - start.getTime()) / 86_400_000);
|
||||
|
||||
const out: FormDeclarantTxn[] = [];
|
||||
let voucher = 1;
|
||||
|
||||
for (const d of formDeclarants()) {
|
||||
// Weighted low — most declarants are occasional users and a handful bank
|
||||
// every week. Squaring the draw gives that long tail rather than a flat
|
||||
// spread, while still leaving enough volume for a day-wise filter to show.
|
||||
const count = 1 + Math.floor(rand() * rand() * 22);
|
||||
|
||||
for (let n = 0; n < count; n++) {
|
||||
const day = new Date(start.getTime());
|
||||
day.setUTCDate(day.getUTCDate() + Math.floor(rand() * span));
|
||||
if (day.getUTCDay() === 0) {
|
||||
day.setUTCDate(day.getUTCDate() + 1); // Sunday — no counter business.
|
||||
}
|
||||
|
||||
const kind = pick(rand, KIND_POOL);
|
||||
const drCr: 'DR' | 'CR' = kind.drCr === 'EITHER' ? (rand() < 0.5 ? 'DR' : 'CR') : kind.drCr;
|
||||
|
||||
// The channel follows the category: cash categories go over the counter,
|
||||
// clearing goes through the house, and the rest split across the
|
||||
// electronic rails a co-operative bank offers.
|
||||
let mode: TxnMode;
|
||||
if (kind.cashOnly) {
|
||||
mode = kind.nature.startsWith('ATM') ? 'ATM' : 'CASH';
|
||||
} else if (kind.nonCash) {
|
||||
mode =
|
||||
kind.group === 'Clearing'
|
||||
? 'CLEARING'
|
||||
: kind.nature.startsWith('RTGS')
|
||||
? 'RTGS'
|
||||
: kind.nature.startsWith('NEFT')
|
||||
? 'NEFT'
|
||||
: kind.group === 'Digital'
|
||||
? 'UPI'
|
||||
: 'TRANSFER';
|
||||
} else {
|
||||
// Categories that can go either way — a term deposit or an EMI is paid
|
||||
// in cash as often as it is transferred at this kind of bank.
|
||||
mode = rand() < 0.55 ? 'CASH' : 'TRANSFER';
|
||||
}
|
||||
|
||||
// Inside the category's own band, with a thin large-value tail on the
|
||||
// Rule 114B rows so the report has CTR-sized entries to flag.
|
||||
let amount = kind.min + Math.floor(rand() * (kind.max - kind.min));
|
||||
if (kind.rule114B && kind.cashOnly && rand() < 0.09) {
|
||||
amount = FORM_THRESHOLD.CTR + Math.floor(rand() * 1_400_000);
|
||||
}
|
||||
amount = Math.round(amount / 100) * 100 || 100;
|
||||
|
||||
const cashWord = mode === 'CASH' || mode === 'ATM';
|
||||
out.push({
|
||||
txnId: `TXN${`${out.length + 1}`.padStart(5, '0')}`,
|
||||
ackNo: d.ackNo,
|
||||
date: isoDate(day),
|
||||
branchCode: d.branchCode,
|
||||
accountNo: d.accountNo,
|
||||
mode,
|
||||
drCr,
|
||||
amount,
|
||||
nature: kind.nature,
|
||||
group: kind.group,
|
||||
rule114B: kind.rule114B,
|
||||
particulars:
|
||||
drCr === 'DR'
|
||||
? `${cashWord ? 'Cash paid to' : 'Debit to account of'} ${d.name} — ${kind.nature.toLowerCase()}`
|
||||
: `${cashWord ? 'Cash received from' : 'Credit to account of'} ${d.name} — ${kind.nature.toLowerCase()}`,
|
||||
voucherNo: `${d.branchCode}/V/${`${voucher++}`.padStart(5, '0')}`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
out.sort((a, b) => (a.date === b.date ? a.txnId.localeCompare(b.txnId) : a.date.localeCompare(b.date)));
|
||||
txnCache = out;
|
||||
return out;
|
||||
}
|
||||
|
||||
export function declarantByAck(ackNo: string): FormDeclarant | undefined {
|
||||
return formDeclarants().find((d) => d.ackNo === ackNo);
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
<!--
|
||||
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="fd-root">
|
||||
<!-- HEADER -->
|
||||
<div class="fd-head">
|
||||
<div class="fd-head-left">
|
||||
<fa-icon icon="file-signature" class="fd-head-ic"></fa-icon>
|
||||
<div>
|
||||
<h2 class="fd-head-t">Form 97 / 98 Transaction Report</h2>
|
||||
<div class="fd-head-s">
|
||||
Every transaction put through on a declaration under Rule 114B, for customers who have no PAN. Form 97 is the
|
||||
general declaration; Form 98 is for a person whose only income is agricultural.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="fd-btn fd-btn--primary" [disabled]="generating" (click)="exportExcel()">
|
||||
<fa-icon icon="file-excel"></fa-icon> {{ generating ? 'Generating…' : 'Download Excel' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- FILTERS -->
|
||||
<div class="fd-filters">
|
||||
<div class="fd-filter-row">
|
||||
<div class="fd-modes">
|
||||
@for (m of periodModes; track m.key) {
|
||||
<button class="fd-chip" [class.active]="periodMode === m.key" (click)="periodMode = m.key">
|
||||
{{ m.label }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@switch (periodMode) {
|
||||
@case ('day') {
|
||||
<label class="fd-field">
|
||||
<span>Date</span>
|
||||
<input type="date" [(ngModel)]="filterDay" [min]="period.fyStart" [max]="period.fyEnd" />
|
||||
</label>
|
||||
}
|
||||
@case ('week') {
|
||||
<label class="fd-field">
|
||||
<span>Any date in week</span>
|
||||
<input type="date" [(ngModel)]="filterWeek" [min]="period.fyStart" [max]="period.fyEnd" />
|
||||
</label>
|
||||
<span class="fd-resolved">Week: {{ rangeLabel }}</span>
|
||||
}
|
||||
@case ('month') {
|
||||
<label class="fd-field">
|
||||
<span>Month</span>
|
||||
<input type="month" [(ngModel)]="filterMonth" min="2026-04" max="2026-07" />
|
||||
</label>
|
||||
}
|
||||
@default {
|
||||
<label class="fd-field">
|
||||
<span>From</span>
|
||||
<input type="date" [(ngModel)]="filterFrom" [min]="period.fyStart" [max]="period.fyEnd" />
|
||||
</label>
|
||||
<label class="fd-field">
|
||||
<span>To</span>
|
||||
<input type="date" [(ngModel)]="filterTo" [min]="period.fyStart" [max]="period.fyEnd" />
|
||||
</label>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="fd-filter-row">
|
||||
<label class="fd-field">
|
||||
<span>Branch</span>
|
||||
<select [(ngModel)]="branchCode" [disabled]="!canSelectBranch">
|
||||
@if (canSelectBranch) {
|
||||
<option value="">All Branches</option>
|
||||
}
|
||||
@for (b of branches; track b.code) {
|
||||
<option [value]="b.code">{{ b.code }} — {{ b.name }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="fd-field">
|
||||
<span>Declaration</span>
|
||||
<select [(ngModel)]="formType">
|
||||
<option value="">Both</option>
|
||||
<option value="FORM_60">Form 97 — no PAN</option>
|
||||
<option value="FORM_61">Form 98 — agricultural income</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="fd-field">
|
||||
<span>Transaction type</span>
|
||||
<select [(ngModel)]="group">
|
||||
<option value="">All types</option>
|
||||
@for (g of txnGroups; track g) {
|
||||
<option [value]="g">{{ g }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="fd-field fd-field--grow">
|
||||
<span>Search</span>
|
||||
<input type="text" [(ngModel)]="search" placeholder="Name, account number or acknowledgement no…" />
|
||||
</label>
|
||||
|
||||
<label class="fd-check">
|
||||
<input type="checkbox" [(ngModel)]="flaggedOnly" />
|
||||
Only CTR reportable
|
||||
</label>
|
||||
|
||||
<button class="fd-btn" (click)="resetFilters()"><fa-icon icon="undo"></fa-icon> Reset</button>
|
||||
</div>
|
||||
|
||||
@if (!canSelectBranch) {
|
||||
<div class="fd-scope-note">
|
||||
<fa-icon icon="lock"></fa-icon>
|
||||
Showing {{ branchLabel }} only. Head Office logins can select any branch.
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- SUMMARY -->
|
||||
<div class="fd-kpis">
|
||||
<div class="fd-kpi">
|
||||
<div class="fd-kpi-n">{{ summary.persons }}</div>
|
||||
<div class="fd-kpi-l">Declarants</div>
|
||||
<div class="fd-kpi-sub">{{ summary.form60Persons }} on Form 97 · {{ summary.form61Persons }} on Form 98</div>
|
||||
</div>
|
||||
<div class="fd-kpi fd-kpi--blue">
|
||||
<div class="fd-kpi-n">{{ summary.txnCount }}</div>
|
||||
<div class="fd-kpi-l">Transactions — all types</div>
|
||||
<div class="fd-kpi-sub">{{ summary.declarationRequired }} in a Rule 114B category</div>
|
||||
</div>
|
||||
<div class="fd-kpi fd-kpi--amber">
|
||||
<div class="fd-kpi-n">{{ summary.cashTotal | number: '1.0-0' }}</div>
|
||||
<div class="fd-kpi-l">Cash turnover (₹)</div>
|
||||
<div class="fd-kpi-sub">{{ summary.cashCount }} cash transactions</div>
|
||||
</div>
|
||||
<div class="fd-kpi" [class.fd-kpi--red]="summary.ctrReportable > 0">
|
||||
<div class="fd-kpi-n">{{ summary.ctrReportable }}</div>
|
||||
<div class="fd-kpi-l">CTR reportable</div>
|
||||
<div class="fd-kpi-sub">cash at or above ₹10,00,000</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CATEGORY MIX — what these customers actually do, beyond the declared rows -->
|
||||
@if (summary.byGroup.length) {
|
||||
<div class="fd-mix">
|
||||
<span class="fd-mix-t">Transaction mix</span>
|
||||
@for (g of summary.byGroup; track g.group) {
|
||||
<button
|
||||
class="fd-mix-item"
|
||||
[class.active]="group === g.group"
|
||||
(click)="group = group === g.group ? '' : g.group"
|
||||
>
|
||||
{{ g.group }} <strong>{{ g.count }}</strong>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- VIEW SWITCH -->
|
||||
<div class="fd-views">
|
||||
<button class="fd-chip" [class.active]="view === 'person'" (click)="view = 'person'">
|
||||
<fa-icon icon="users"></fa-icon> Person wise
|
||||
</button>
|
||||
<button class="fd-chip" [class.active]="view === 'transaction'" (click)="view = 'transaction'">
|
||||
<fa-icon icon="list"></fa-icon> Transaction wise
|
||||
</button>
|
||||
<span class="fd-spacer"></span>
|
||||
<span class="fd-count">
|
||||
{{ view === 'person' ? personRows.length + ' declarants' : txnRows.length + ' transactions' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- PERSON WISE -->
|
||||
@if (view === 'person') {
|
||||
<div class="fd-table-wrap">
|
||||
<table class="fd-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Acknowledgement</th>
|
||||
<th>Form</th>
|
||||
<th>Name of declarant</th>
|
||||
<th>Account No</th>
|
||||
<th>Branch</th>
|
||||
<th class="num">Txns</th>
|
||||
<th class="num">Rule 114B</th>
|
||||
<th class="num">Cash turnover</th>
|
||||
<th class="num">Credit</th>
|
||||
<th class="num">Debit</th>
|
||||
<th class="num">Declared income</th>
|
||||
<th class="num">Cash ÷ income</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (p of personRows; track p.declarant.ackNo) {
|
||||
<tr class="fd-person" [class.open]="expanded === p.declarant.ackNo" (click)="toggle(p.declarant.ackNo)">
|
||||
<td>
|
||||
<fa-icon [icon]="expanded === p.declarant.ackNo ? 'caret-down' : 'caret-right'"></fa-icon>
|
||||
</td>
|
||||
<td class="mono">{{ p.declarant.ackNo }}</td>
|
||||
<td>
|
||||
<span class="fd-form" [class.fd-form--61]="p.declarant.formType === 'FORM_61'">
|
||||
{{ formLabel[p.declarant.formType] }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<strong>{{ p.declarant.name }}</strong>
|
||||
@if (p.ctrCount > 0) {
|
||||
<span class="fd-tag fd-tag--ctr">{{ p.ctrCount }} CTR</span>
|
||||
}
|
||||
</td>
|
||||
<td class="mono">{{ p.declarant.accountNo }}</td>
|
||||
<td>{{ p.declarant.branchCode }}</td>
|
||||
<td class="num">{{ p.txnCount }}</td>
|
||||
<td class="num">{{ p.declaredCount }}</td>
|
||||
<td class="num strong">{{ p.cashTotal | number: '1.0-0' }}</td>
|
||||
<td class="num">{{ p.totalCredit | number: '1.0-0' }}</td>
|
||||
<td class="num">{{ p.totalDebit | number: '1.0-0' }}</td>
|
||||
<td class="num">{{ p.declarant.estimatedIncome | number: '1.0-0' }}</td>
|
||||
<td class="num">
|
||||
<span [class]="ratioClass(p.incomeRatio)">{{ p.incomeRatio | number: '1.1-1' }}×</span>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@if (expanded === p.declarant.ackNo) {
|
||||
<tr class="fd-detail">
|
||||
<td colspan="13">
|
||||
<div class="fd-decl">
|
||||
<div>
|
||||
<label>Father's name</label><span>{{ p.declarant.fatherName }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<label>Date of birth</label><span>{{ p.declarant.dateOfBirth }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<label>Aadhaar</label><span class="mono">XXXX XXXX {{ p.declarant.aadhaarLast4 }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<label>Declared on</label><span>{{ p.declarant.declaredOn }}</span>
|
||||
</div>
|
||||
<div class="wide">
|
||||
<label>Address</label><span>{{ p.declarant.address }}</span>
|
||||
</div>
|
||||
<div class="wide">
|
||||
<label>Reason for no PAN</label><span>{{ p.declarant.reasonForNoPan }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class="fd-subtable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Txn ID</th>
|
||||
<th>Mode</th>
|
||||
<th>Dr/Cr</th>
|
||||
<th class="num">Amount</th>
|
||||
<th>Nature of transaction (Rule 114B)</th>
|
||||
<th>Voucher</th>
|
||||
<th>Flag</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (t of p.txns; track t.txnId) {
|
||||
<tr>
|
||||
<td class="mono">{{ t.date }}</td>
|
||||
<td class="mono">{{ t.txnId }}</td>
|
||||
<td>
|
||||
<span class="fd-mode">{{ t.mode }}</span>
|
||||
</td>
|
||||
<td [class.cr]="t.drCr === 'CR'" [class.dr]="t.drCr === 'DR'">{{ t.drCr }}</td>
|
||||
<td class="num strong">{{ t.amount | number: '1.0-0' }}</td>
|
||||
<td>{{ t.nature }}</td>
|
||||
<td class="mono">{{ t.voucherNo }}</td>
|
||||
<td>
|
||||
@if (t.ctrReportable) {
|
||||
<span class="fd-tag fd-tag--ctr">CTR</span>
|
||||
} @else if (t.declarationRequired) {
|
||||
<span class="fd-tag">Rule 114B</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="13" class="fd-empty">
|
||||
No Form 97 or Form 98 transactions for {{ rangeLabel }} at {{ branchLabel }}.
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- TRANSACTION WISE -->
|
||||
@if (view === 'transaction') {
|
||||
<div class="fd-table-wrap">
|
||||
<table class="fd-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Txn ID</th>
|
||||
<th>Acknowledgement</th>
|
||||
<th>Form</th>
|
||||
<th>Name of declarant</th>
|
||||
<th>Account No</th>
|
||||
<th>Branch</th>
|
||||
<th>Category</th>
|
||||
<th>Mode</th>
|
||||
<th>Dr/Cr</th>
|
||||
<th class="num">Amount</th>
|
||||
<th>Nature of transaction</th>
|
||||
<th>Voucher</th>
|
||||
<th>Flag</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (t of txnRows; track t.txnId) {
|
||||
<tr [class.fd-row--ctr]="t.ctrReportable">
|
||||
<td class="mono">{{ t.date }}</td>
|
||||
<td class="mono">{{ t.txnId }}</td>
|
||||
<td class="mono">{{ t.ackNo }}</td>
|
||||
<td>
|
||||
<span class="fd-form" [class.fd-form--61]="t.formType === 'FORM_61'">{{ formLabel[t.formType] }}</span>
|
||||
</td>
|
||||
<td>{{ t.declarantName }}</td>
|
||||
<td class="mono">{{ t.accountNo }}</td>
|
||||
<td>{{ t.branchCode }}</td>
|
||||
<td>
|
||||
<span class="fd-group">{{ t.group }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="fd-mode">{{ t.mode }}</span>
|
||||
</td>
|
||||
<td [class.cr]="t.drCr === 'CR'" [class.dr]="t.drCr === 'DR'">{{ t.drCr }}</td>
|
||||
<td class="num strong">{{ t.amount | number: '1.0-0' }}</td>
|
||||
<td>{{ t.nature }}</td>
|
||||
<td class="mono">{{ t.voucherNo }}</td>
|
||||
<td>
|
||||
@if (t.ctrReportable) {
|
||||
<span class="fd-tag fd-tag--ctr">CTR</span>
|
||||
} @else if (t.declarationRequired) {
|
||||
<span class="fd-tag">Rule 114B</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="14" class="fd-empty">
|
||||
No Form 97 or Form 98 transactions for {{ rangeLabel }} at {{ branchLabel }}.
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="fd-toast" [class.show]="toastVisible">{{ toastMsg }}</div>
|
||||
</div>
|
||||
@@ -0,0 +1,608 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
:host {
|
||||
--f-navy: #1a237e;
|
||||
--f-blue: #1d4ed8;
|
||||
--f-blue-l: #eff6ff;
|
||||
--f-green: #15803d;
|
||||
--f-green-l: #dcfce7;
|
||||
--f-amber: #b45309;
|
||||
--f-amber-l: #fef3c7;
|
||||
--f-red: #c0392b;
|
||||
--f-red-l: #fdecea;
|
||||
--f-g50: #f8fafc;
|
||||
--f-g100: #f1f5f9;
|
||||
--f-g200: #e2e8f0;
|
||||
--f-g300: #cbd5e1;
|
||||
--f-g500: #64748b;
|
||||
--f-g700: #334155;
|
||||
--f-g900: #0f172a;
|
||||
|
||||
display: block;
|
||||
font-family: 'DM Sans', system-ui, sans-serif;
|
||||
background: var(--f-g50);
|
||||
color: var(--f-g900);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.fd-root {
|
||||
padding: 18px 22px;
|
||||
max-width: 1700px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* HEADER */
|
||||
.fd-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.fd-head-left {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.fd-head-ic {
|
||||
font-size: 26px;
|
||||
color: var(--f-navy);
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.fd-head-t {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.fd-head-s {
|
||||
font-size: 12px;
|
||||
color: var(--f-g500);
|
||||
max-width: 820px;
|
||||
}
|
||||
|
||||
/* BUTTONS */
|
||||
.fd-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 15px;
|
||||
border: 1px solid var(--f-g300);
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: var(--f-g700);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--f-g50);
|
||||
border-color: var(--f-g500);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&--primary {
|
||||
background: var(--f-green);
|
||||
border-color: var(--f-green);
|
||||
color: #fff;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #12692f;
|
||||
border-color: #12692f;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* FILTERS */
|
||||
.fd-filters {
|
||||
background: #fff;
|
||||
border: 1px solid var(--f-g200);
|
||||
border-radius: 11px;
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.fd-filter-row {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.fd-modes {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.fd-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 14px;
|
||||
border: 1px solid var(--f-g300);
|
||||
background: #fff;
|
||||
border-radius: 999px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: var(--f-g700);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--f-navy);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--f-navy);
|
||||
border-color: var(--f-navy);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.fd-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
|
||||
span {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--f-g500);
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
padding: 7px 10px;
|
||||
border: 1px solid var(--f-g300);
|
||||
border-radius: 7px;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
background: #fff;
|
||||
color: var(--f-g900);
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--f-navy);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: var(--f-g100);
|
||||
color: var(--f-g500);
|
||||
}
|
||||
}
|
||||
|
||||
&--grow {
|
||||
flex: 1;
|
||||
min-width: 240px;
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.fd-resolved {
|
||||
font-size: 12px;
|
||||
color: var(--f-g500);
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.fd-check {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: var(--f-g700);
|
||||
padding-bottom: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fd-scope-note {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
font-size: 12px;
|
||||
color: var(--f-amber);
|
||||
background: var(--f-amber-l);
|
||||
border: 1px solid #fcd34d;
|
||||
border-radius: 7px;
|
||||
padding: 6px 11px;
|
||||
}
|
||||
|
||||
/* KPIs */
|
||||
.fd-kpis {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.fd-kpi {
|
||||
background: #fff;
|
||||
border: 1px solid var(--f-g200);
|
||||
border-left: 4px solid var(--f-navy);
|
||||
border-radius: 11px;
|
||||
padding: 12px 16px;
|
||||
|
||||
&--blue {
|
||||
border-left-color: var(--f-blue);
|
||||
}
|
||||
|
||||
&--amber {
|
||||
border-left-color: var(--f-amber);
|
||||
}
|
||||
|
||||
&--red {
|
||||
border-left-color: var(--f-red);
|
||||
}
|
||||
}
|
||||
|
||||
.fd-kpi-n {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
font-family: 'DM Mono', monospace;
|
||||
}
|
||||
|
||||
.fd-kpi-l {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--f-g700);
|
||||
}
|
||||
|
||||
.fd-kpi-sub {
|
||||
font-size: 11px;
|
||||
color: var(--f-g500);
|
||||
}
|
||||
|
||||
/* VIEW SWITCH */
|
||||
.fd-views {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.fd-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.fd-count {
|
||||
font-size: 12px;
|
||||
color: var(--f-g500);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* TABLE */
|
||||
.fd-table-wrap {
|
||||
background: #fff;
|
||||
border: 1px solid var(--f-g200);
|
||||
border-radius: 11px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.fd-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12.5px;
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
padding: 9px 10px;
|
||||
background: var(--f-g100);
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--f-g700);
|
||||
white-space: nowrap;
|
||||
border-bottom: 1px solid var(--f-g200);
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--f-g100);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.num {
|
||||
text-align: right;
|
||||
font-family: 'DM Mono', monospace;
|
||||
}
|
||||
|
||||
.strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
tbody tr:hover:not(.fd-detail) {
|
||||
background: var(--f-g50);
|
||||
}
|
||||
}
|
||||
|
||||
.fd-person {
|
||||
cursor: pointer;
|
||||
|
||||
&.open {
|
||||
background: var(--f-blue-l);
|
||||
}
|
||||
}
|
||||
|
||||
.fd-row--ctr {
|
||||
background: var(--f-red-l);
|
||||
}
|
||||
|
||||
.fd-detail {
|
||||
background: #fcfcfe;
|
||||
|
||||
td {
|
||||
padding: 12px 16px 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.fd-decl {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 8px 18px;
|
||||
margin-bottom: 12px;
|
||||
padding: 10px 14px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--f-g200);
|
||||
border-radius: 8px;
|
||||
|
||||
> div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.wide {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
label {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--f-g500);
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 12.5px;
|
||||
}
|
||||
}
|
||||
|
||||
.fd-subtable {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--f-g200);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
padding: 7px 10px;
|
||||
background: var(--f-g50);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--f-g500);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 6px 10px;
|
||||
border-top: 1px solid var(--f-g100);
|
||||
}
|
||||
|
||||
.num {
|
||||
text-align: right;
|
||||
font-family: 'DM Mono', monospace;
|
||||
}
|
||||
|
||||
.strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
/* Badges */
|
||||
.fd-form {
|
||||
display: inline-block;
|
||||
padding: 1px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
background: var(--f-blue-l);
|
||||
color: var(--f-blue);
|
||||
white-space: nowrap;
|
||||
|
||||
&--61 {
|
||||
background: var(--f-green-l);
|
||||
color: var(--f-green);
|
||||
}
|
||||
}
|
||||
|
||||
.fd-mode {
|
||||
display: inline-block;
|
||||
padding: 1px 7px;
|
||||
border-radius: 5px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
background: var(--f-g100);
|
||||
color: var(--f-g700);
|
||||
}
|
||||
|
||||
.fd-group {
|
||||
display: inline-block;
|
||||
padding: 1px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
background: #fff;
|
||||
border: 1px solid var(--f-g300);
|
||||
color: var(--f-g700);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Category mix — doubles as a one-click filter. */
|
||||
.fd-mix {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.fd-mix-t {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--f-g500);
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.fd-mix-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 11px;
|
||||
border: 1px solid var(--f-g200);
|
||||
background: #fff;
|
||||
border-radius: 7px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
color: var(--f-g700);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
|
||||
strong {
|
||||
font-family: 'DM Mono', monospace;
|
||||
color: var(--f-navy);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(--f-navy);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--f-navy);
|
||||
border-color: var(--f-navy);
|
||||
color: #fff;
|
||||
|
||||
strong {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.fd-tag {
|
||||
display: inline-block;
|
||||
margin-left: 6px;
|
||||
padding: 1px 7px;
|
||||
border-radius: 999px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
background: var(--f-amber-l);
|
||||
color: var(--f-amber);
|
||||
white-space: nowrap;
|
||||
|
||||
&--ctr {
|
||||
background: var(--f-red-l);
|
||||
color: var(--f-red);
|
||||
}
|
||||
}
|
||||
|
||||
.fd-ratio {
|
||||
font-weight: 600;
|
||||
|
||||
&--mid {
|
||||
color: var(--f-amber);
|
||||
}
|
||||
|
||||
&--high {
|
||||
color: var(--f-red);
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
.cr {
|
||||
color: var(--f-green);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.dr {
|
||||
color: var(--f-red);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: 'DM Mono', monospace;
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
.fd-empty {
|
||||
padding: 44px;
|
||||
text-align: center;
|
||||
color: var(--f-g500);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* TOAST */
|
||||
.fd-toast {
|
||||
position: fixed;
|
||||
bottom: 32px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(20px);
|
||||
background: var(--f-navy);
|
||||
color: #fff;
|
||||
padding: 12px 22px;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
box-shadow: 0 6px 20px rgb(0 0 0 / 25%);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: all 0.25s;
|
||||
z-index: 1500;
|
||||
|
||||
&.show {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 1200px) {
|
||||
.fd-kpis {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.fd-decl {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,474 @@
|
||||
/**
|
||||
* 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 { FormsModule } from '@angular/forms';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import * as ExcelJS from 'exceljs';
|
||||
|
||||
import { BranchScopeService } from '../core/branch-scope.service';
|
||||
import { GL_BRANCHES, GL_PERIOD, glBranchName } from '../gl-reports/gl-reports-data';
|
||||
import { FORM_LABEL, FORM_THRESHOLD, FormType, TXN_GROUPS } from './form-declaration-data';
|
||||
import {
|
||||
DateRange,
|
||||
FormPersonRow,
|
||||
FormReportFilter,
|
||||
FormReportSummary,
|
||||
FormTxnRow,
|
||||
formPersonRows,
|
||||
formReportSummary,
|
||||
formTxnRows,
|
||||
monthRange,
|
||||
weekRange
|
||||
} from './form-declaration.compute';
|
||||
|
||||
type PeriodMode = 'day' | 'week' | 'month' | 'range';
|
||||
type ViewMode = 'person' | 'transaction';
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-form-declaration-report',
|
||||
templateUrl: './form-declaration-report.component.html',
|
||||
styleUrls: ['./form-declaration-report.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
FaIconComponent,
|
||||
FormsModule
|
||||
]
|
||||
})
|
||||
export class FormDeclarationReportComponent implements OnInit {
|
||||
private scope = inject(BranchScopeService);
|
||||
|
||||
readonly period = GL_PERIOD;
|
||||
readonly branches = GL_BRANCHES;
|
||||
readonly formLabel = FORM_LABEL;
|
||||
readonly threshold = FORM_THRESHOLD;
|
||||
readonly txnGroups = TXN_GROUPS;
|
||||
|
||||
readonly periodModes: { key: PeriodMode; label: string }[] = [
|
||||
{ key: 'day', label: 'Day wise' },
|
||||
{ key: 'week', label: 'Week wise' },
|
||||
{ key: 'month', label: 'Month wise' },
|
||||
{ key: 'range', label: 'Between dates' }
|
||||
];
|
||||
|
||||
// ── Filter state ───────────────────────────────────────────────────────────
|
||||
periodMode: PeriodMode = 'month';
|
||||
filterDay = '2026-07-31';
|
||||
/** Any date inside the wanted week; resolved to Mon–Sat. */
|
||||
filterWeek = '2026-07-27';
|
||||
filterMonth = '2026-07';
|
||||
filterFrom = GL_PERIOD.fyStart;
|
||||
filterTo = GL_PERIOD.fyEnd;
|
||||
branchCode = '';
|
||||
formType: FormType | '' = '';
|
||||
group = '';
|
||||
search = '';
|
||||
flaggedOnly = false;
|
||||
|
||||
view: ViewMode = 'person';
|
||||
/** Acknowledgement number of the person row currently expanded. */
|
||||
expanded: string | null = null;
|
||||
|
||||
generating = false;
|
||||
toastMsg = '';
|
||||
toastVisible = false;
|
||||
private toastTimer: any;
|
||||
|
||||
ngOnInit(): void {
|
||||
// A branch login opens on its own book and cannot move off it.
|
||||
this.branchCode = this.scope.defaultBranchCode;
|
||||
}
|
||||
|
||||
// ── Filter resolution ──────────────────────────────────────────────────────
|
||||
|
||||
get range(): DateRange {
|
||||
if (this.periodMode === 'day') {
|
||||
return { from: this.filterDay, to: this.filterDay };
|
||||
}
|
||||
if (this.periodMode === 'week') {
|
||||
return weekRange(this.filterWeek);
|
||||
}
|
||||
if (this.periodMode === 'month') {
|
||||
return monthRange(this.filterMonth);
|
||||
}
|
||||
return { from: this.filterFrom, to: this.filterTo };
|
||||
}
|
||||
|
||||
get rangeLabel(): string {
|
||||
const r = this.range;
|
||||
return r.from === r.to ? r.from : `${r.from} to ${r.to}`;
|
||||
}
|
||||
|
||||
get canSelectBranch(): boolean {
|
||||
return this.scope.canSelectBranch;
|
||||
}
|
||||
|
||||
get branchLabel(): string {
|
||||
if (!this.canSelectBranch) {
|
||||
return this.scope.officeName;
|
||||
}
|
||||
return this.branchCode ? glBranchName(this.branchCode) : 'All Branches (Consolidated)';
|
||||
}
|
||||
|
||||
/**
|
||||
* Applied at the data boundary rather than only in the UI, so a branch login
|
||||
* cannot widen its own scope by tampering with component state.
|
||||
*/
|
||||
private get effectiveBranch(): string {
|
||||
return this.scope.enforce(this.branchCode);
|
||||
}
|
||||
|
||||
private get filter(): FormReportFilter {
|
||||
return {
|
||||
range: this.range,
|
||||
branchCode: this.effectiveBranch,
|
||||
formType: this.formType,
|
||||
group: this.group,
|
||||
search: this.search,
|
||||
flaggedOnly: this.flaggedOnly
|
||||
};
|
||||
}
|
||||
|
||||
resetFilters(): void {
|
||||
this.periodMode = 'month';
|
||||
this.filterDay = '2026-07-31';
|
||||
this.filterWeek = '2026-07-27';
|
||||
this.filterMonth = '2026-07';
|
||||
this.filterFrom = GL_PERIOD.fyStart;
|
||||
this.filterTo = GL_PERIOD.fyEnd;
|
||||
this.formType = '';
|
||||
this.group = '';
|
||||
this.search = '';
|
||||
this.flaggedOnly = false;
|
||||
this.expanded = null;
|
||||
// Reset returns a branch login to its own branch, never to "all".
|
||||
this.branchCode = this.scope.defaultBranchCode;
|
||||
}
|
||||
|
||||
// ── Data ───────────────────────────────────────────────────────────────────
|
||||
|
||||
get summary(): FormReportSummary {
|
||||
return formReportSummary(this.filter);
|
||||
}
|
||||
|
||||
get personRows(): FormPersonRow[] {
|
||||
return formPersonRows(this.filter);
|
||||
}
|
||||
|
||||
get txnRows(): FormTxnRow[] {
|
||||
return formTxnRows(this.filter);
|
||||
}
|
||||
|
||||
toggle(ackNo: string): void {
|
||||
this.expanded = this.expanded === ackNo ? null : ackNo;
|
||||
}
|
||||
|
||||
/** Cash turnover far above declared income is the point of the report. */
|
||||
ratioClass(ratio: number): string {
|
||||
if (ratio >= 5) {
|
||||
return 'fd-ratio fd-ratio--high';
|
||||
}
|
||||
if (ratio >= 2) {
|
||||
return 'fd-ratio fd-ratio--mid';
|
||||
}
|
||||
return 'fd-ratio';
|
||||
}
|
||||
|
||||
// ── Excel ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async exportExcel(): Promise<void> {
|
||||
this.generating = true;
|
||||
try {
|
||||
const wb = new ExcelJS.Workbook();
|
||||
wb.creator = 'CBSNexora';
|
||||
wb.created = new Date();
|
||||
|
||||
this.writeSummarySheet(wb);
|
||||
this.writePersonSheet(wb);
|
||||
this.writeTransactionSheet(wb);
|
||||
|
||||
const buffer = await wb.xlsx.writeBuffer();
|
||||
const blob = new Blob([buffer], {
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `Form-97-98-Transaction-Report-${this.range.from}-to-${this.range.to}.xlsx`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
this.toast(`Exported ${this.summary.txnCount} transactions for ${this.rangeLabel}`);
|
||||
} catch {
|
||||
this.toast('Could not generate the workbook — please retry');
|
||||
} finally {
|
||||
this.generating = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Title band + filter caption, repeated on every sheet so pages stand alone. */
|
||||
private banner(sheet: ExcelJS.Worksheet, title: string, cols: number): void {
|
||||
const t = sheet.addRow([title]);
|
||||
sheet.mergeCells(t.number, 1, t.number, cols);
|
||||
t.font = { bold: true, size: 14, color: { argb: 'FFFFFFFF' } };
|
||||
t.alignment = { horizontal: 'center', vertical: 'middle' };
|
||||
t.height = 24;
|
||||
t.getCell(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF1A237E' } };
|
||||
|
||||
const formCaption = this.formType ? FORM_LABEL[this.formType] : 'Form 97 and Form 98';
|
||||
const meta = sheet.addRow([
|
||||
`Period: ${this.rangeLabel} · Branch: ${this.branchLabel} · Declaration: ${formCaption}` +
|
||||
` · Category: ${this.group || 'All transaction types'}`
|
||||
]);
|
||||
sheet.mergeCells(meta.number, 1, meta.number, cols);
|
||||
meta.font = { italic: true, size: 10 };
|
||||
sheet.addRow([]);
|
||||
}
|
||||
|
||||
private headerRow(sheet: ExcelJS.Worksheet, headers: string[]): void {
|
||||
const head = sheet.addRow(headers);
|
||||
head.font = { bold: true };
|
||||
head.alignment = { wrapText: true, horizontal: 'center' };
|
||||
head.eachCell((c) => {
|
||||
c.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE2E8F0' } };
|
||||
c.border = { bottom: { style: 'thin' } };
|
||||
});
|
||||
}
|
||||
|
||||
private writeSummarySheet(wb: ExcelJS.Workbook): void {
|
||||
const s = this.summary;
|
||||
const sheet = wb.addWorksheet('Summary');
|
||||
sheet.columns = [
|
||||
{ width: 52 },
|
||||
{ width: 24 }];
|
||||
this.banner(sheet, 'Form 97 / 98 Transaction Report — Summary', 2);
|
||||
this.headerRow(sheet, [
|
||||
'Particulars',
|
||||
'Value'
|
||||
]);
|
||||
|
||||
const money = new Set([
|
||||
'Total cash turnover',
|
||||
'Total credits',
|
||||
'Total debits'
|
||||
]);
|
||||
|
||||
const lines: [string, string | number][] = [
|
||||
[
|
||||
'Declarants transacting in the period',
|
||||
s.persons
|
||||
],
|
||||
[
|
||||
' of which Form 97 (no PAN)',
|
||||
s.form60Persons
|
||||
],
|
||||
[
|
||||
' of which Form 98 (agricultural income only)',
|
||||
s.form61Persons
|
||||
],
|
||||
[
|
||||
'Total transactions — all types',
|
||||
s.txnCount
|
||||
],
|
||||
[
|
||||
' of which cash (counter and ATM)',
|
||||
s.cashCount
|
||||
],
|
||||
[
|
||||
' of which in a Rule 114B category',
|
||||
s.declarationRequired
|
||||
],
|
||||
[
|
||||
'Total cash turnover',
|
||||
s.cashTotal
|
||||
],
|
||||
[
|
||||
'Total credits',
|
||||
s.totalCredit
|
||||
],
|
||||
[
|
||||
'Total debits',
|
||||
s.totalDebit
|
||||
],
|
||||
[
|
||||
`Cash transactions at or above ₹${FORM_THRESHOLD.CTR.toLocaleString('en-IN')} (CTR reportable)`,
|
||||
s.ctrReportable
|
||||
]
|
||||
];
|
||||
|
||||
for (const [
|
||||
label,
|
||||
value
|
||||
] of lines) {
|
||||
const row = sheet.addRow([
|
||||
label,
|
||||
value
|
||||
]);
|
||||
if (money.has(label)) {
|
||||
row.getCell(2).numFmt = '#,##0.00';
|
||||
}
|
||||
}
|
||||
|
||||
// Category mix — what the customer group actually does, beyond the
|
||||
// declared transactions.
|
||||
sheet.addRow([]);
|
||||
this.headerRow(sheet, [
|
||||
'Transaction category',
|
||||
'Count'
|
||||
]);
|
||||
for (const g of s.byGroup) {
|
||||
sheet.addRow([
|
||||
g.group,
|
||||
g.count
|
||||
]);
|
||||
}
|
||||
sheet.views = [{ state: 'frozen', ySplit: 4 }];
|
||||
}
|
||||
|
||||
private writePersonSheet(wb: ExcelJS.Workbook): void {
|
||||
const sheet = wb.addWorksheet('Person wise');
|
||||
const widths = [
|
||||
18,
|
||||
12,
|
||||
26,
|
||||
24,
|
||||
18,
|
||||
14,
|
||||
10,
|
||||
10,
|
||||
10,
|
||||
16,
|
||||
16,
|
||||
16,
|
||||
14,
|
||||
10,
|
||||
34
|
||||
];
|
||||
sheet.columns = widths.map((w) => ({ width: w }));
|
||||
this.banner(sheet, 'Form 97 / 98 Transaction Report — Person wise', widths.length);
|
||||
this.headerRow(sheet, [
|
||||
'Acknowledgement',
|
||||
'Form',
|
||||
'Name of declarant',
|
||||
"Father's name",
|
||||
'Account No',
|
||||
'Branch',
|
||||
'Total txns',
|
||||
'Cash txns',
|
||||
'Rule 114B txns',
|
||||
'Cash turnover',
|
||||
'Total credit',
|
||||
'Total debit',
|
||||
'Declared income',
|
||||
'CTR rows',
|
||||
'Categories transacted'
|
||||
]);
|
||||
|
||||
for (const p of this.personRows) {
|
||||
const row = sheet.addRow([
|
||||
p.declarant.ackNo,
|
||||
FORM_LABEL[p.declarant.formType],
|
||||
p.declarant.name,
|
||||
p.declarant.fatherName,
|
||||
p.declarant.accountNo,
|
||||
glBranchName(p.declarant.branchCode),
|
||||
p.txnCount,
|
||||
p.cashCount,
|
||||
p.declaredCount,
|
||||
p.cashTotal,
|
||||
p.totalCredit,
|
||||
p.totalDebit,
|
||||
p.declarant.estimatedIncome,
|
||||
p.ctrCount,
|
||||
p.groups.join(', ')
|
||||
]);
|
||||
for (const c of [
|
||||
10,
|
||||
11,
|
||||
12,
|
||||
13
|
||||
]) {
|
||||
row.getCell(c).numFmt = '#,##0.00';
|
||||
}
|
||||
}
|
||||
sheet.views = [{ state: 'frozen', ySplit: 4 }];
|
||||
}
|
||||
|
||||
private writeTransactionSheet(wb: ExcelJS.Workbook): void {
|
||||
const sheet = wb.addWorksheet('Transactions');
|
||||
const widths = [
|
||||
12,
|
||||
12,
|
||||
18,
|
||||
12,
|
||||
26,
|
||||
18,
|
||||
14,
|
||||
12,
|
||||
10,
|
||||
6,
|
||||
16,
|
||||
46,
|
||||
16,
|
||||
12
|
||||
];
|
||||
sheet.columns = widths.map((w) => ({ width: w }));
|
||||
this.banner(sheet, 'Form 97 / 98 Transaction Report — Transaction wise', widths.length);
|
||||
this.headerRow(sheet, [
|
||||
'Date',
|
||||
'Txn ID',
|
||||
'Acknowledgement',
|
||||
'Form',
|
||||
'Name of declarant',
|
||||
'Account No',
|
||||
'Branch',
|
||||
'Category',
|
||||
'Mode',
|
||||
'Dr/Cr',
|
||||
'Amount',
|
||||
'Nature of transaction',
|
||||
'Voucher No',
|
||||
'Flag'
|
||||
]);
|
||||
|
||||
for (const t of this.txnRows) {
|
||||
const row = sheet.addRow([
|
||||
t.date,
|
||||
t.txnId,
|
||||
t.ackNo,
|
||||
FORM_LABEL[t.formType],
|
||||
t.declarantName,
|
||||
t.accountNo,
|
||||
glBranchName(t.branchCode),
|
||||
t.group,
|
||||
t.mode,
|
||||
t.drCr,
|
||||
t.amount,
|
||||
t.nature,
|
||||
t.voucherNo,
|
||||
t.ctrReportable ? 'CTR' : t.declarationRequired ? 'Rule 114B' : ''
|
||||
]);
|
||||
row.getCell(11).numFmt = '#,##0.00';
|
||||
if (t.ctrReportable) {
|
||||
row.getCell(14).font = { bold: true, color: { argb: 'FFC0392B' } };
|
||||
}
|
||||
}
|
||||
sheet.views = [{ state: 'frozen', ySplit: 4 }];
|
||||
}
|
||||
|
||||
private toast(msg: string): void {
|
||||
this.toastMsg = msg;
|
||||
this.toastVisible = true;
|
||||
if (this.toastTimer) {
|
||||
clearTimeout(this.toastTimer);
|
||||
}
|
||||
this.toastTimer = setTimeout(() => (this.toastVisible = false), 3200);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* 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 { GL_PERIOD } from '../gl-reports/gl-reports-data';
|
||||
import {
|
||||
FORM_THRESHOLD,
|
||||
FormDeclarant,
|
||||
FormDeclarantTxn,
|
||||
FormType,
|
||||
formDeclarants,
|
||||
formDeclarantTxns
|
||||
} from './form-declaration-data';
|
||||
|
||||
export interface DateRange {
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
export interface FormReportFilter {
|
||||
range: DateRange;
|
||||
/** '' means every branch. */
|
||||
branchCode: string;
|
||||
/** '' means both declaration types. */
|
||||
formType: FormType | '';
|
||||
/** '' means every transaction category (Cash, Clearing, Loan, …). */
|
||||
group: string;
|
||||
/** Free text over name, account number and acknowledgement number. */
|
||||
search: string;
|
||||
/** Show only rows a compliance officer would need to look at. */
|
||||
flaggedOnly: boolean;
|
||||
}
|
||||
|
||||
/** One transaction, joined to the declarant who filed for it. */
|
||||
export interface FormTxnRow extends FormDeclarantTxn {
|
||||
declarantName: string;
|
||||
formType: FormType;
|
||||
fatherName: string;
|
||||
declaredOn: string;
|
||||
reasonForNoPan: string;
|
||||
estimatedIncome: number;
|
||||
/** Counter or ATM cash, as opposed to a book or electronic entry. */
|
||||
isCash: boolean;
|
||||
/**
|
||||
* The transaction is one of the categories in Rule 114B, so PAN — and in its
|
||||
* absence the Form 97/98 declaration — was mandatory for it.
|
||||
*/
|
||||
declarationRequired: boolean;
|
||||
/** Cash at or above the CTR limit — also lands in the monthly CTR. */
|
||||
ctrReportable: boolean;
|
||||
}
|
||||
|
||||
/** A declarant with their totals for the period. */
|
||||
export interface FormPersonRow {
|
||||
declarant: FormDeclarant;
|
||||
txns: FormTxnRow[];
|
||||
txnCount: number;
|
||||
cashCount: number;
|
||||
totalCredit: number;
|
||||
totalDebit: number;
|
||||
cashTotal: number;
|
||||
/** Largest single transaction, used to sort the riskiest to the top. */
|
||||
maxAmount: number;
|
||||
/** How many of the period's transactions needed the declaration. */
|
||||
declaredCount: number;
|
||||
ctrCount: number;
|
||||
/** Distinct categories the customer transacted in during the period. */
|
||||
groups: string[];
|
||||
/**
|
||||
* Aggregate cash for the period against the income the customer declared.
|
||||
* A ratio well above 1 is the classic "turnover inconsistent with declared
|
||||
* income" flag, which is why the declaration is collected in the first place.
|
||||
*/
|
||||
incomeRatio: number;
|
||||
}
|
||||
|
||||
export interface FormReportSummary {
|
||||
persons: number;
|
||||
form60Persons: number;
|
||||
form61Persons: number;
|
||||
txnCount: number;
|
||||
cashTotal: number;
|
||||
cashCount: number;
|
||||
totalCredit: number;
|
||||
totalDebit: number;
|
||||
/** Transactions falling in a Rule 114B category. */
|
||||
declarationRequired: number;
|
||||
ctrReportable: number;
|
||||
/** Transaction count by category, largest first — drives the mix panel. */
|
||||
byGroup: { group: string; count: number; amount: number }[];
|
||||
}
|
||||
|
||||
function clamp(range: DateRange): DateRange {
|
||||
const from = range.from < GL_PERIOD.fyStart ? GL_PERIOD.fyStart : range.from;
|
||||
const to = range.to > GL_PERIOD.fyEnd ? GL_PERIOD.fyEnd : range.to;
|
||||
// A backwards range would silently return nothing; collapse it to a single day.
|
||||
return from > to ? { from: to, to: to } : { from, to };
|
||||
}
|
||||
|
||||
/** Monday-to-Saturday banking week containing the given date. */
|
||||
export function weekRange(anchor: string): DateRange {
|
||||
const d = new Date(`${anchor}T00:00:00Z`);
|
||||
const dow = d.getUTCDay(); // 0 = Sunday
|
||||
const monday = new Date(d.getTime());
|
||||
monday.setUTCDate(d.getUTCDate() - (dow === 0 ? 6 : dow - 1));
|
||||
const saturday = new Date(monday.getTime());
|
||||
saturday.setUTCDate(monday.getUTCDate() + 5);
|
||||
const iso = (x: Date) => x.toISOString().slice(0, 10);
|
||||
return { from: iso(monday), to: iso(saturday) };
|
||||
}
|
||||
|
||||
export function monthRange(month: string): DateRange {
|
||||
const [
|
||||
y,
|
||||
m
|
||||
] = month.split('-').map(Number);
|
||||
const last = new Date(Date.UTC(y, m, 0)).getUTCDate();
|
||||
return { from: `${month}-01`, to: `${month}-${`${last}`.padStart(2, '0')}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Transactions matching the filter, each joined to its declarant.
|
||||
*
|
||||
* Memoised on the filter signature: the getters that feed the template are read
|
||||
* many times per change-detection pass, and re-scanning several hundred rows on
|
||||
* every read is what made the GL reports stall.
|
||||
*/
|
||||
let rowCacheKey = '';
|
||||
let rowCache: FormTxnRow[] = [];
|
||||
|
||||
export function formTxnRows(filter: FormReportFilter): FormTxnRow[] {
|
||||
const range = clamp(filter.range);
|
||||
const key = [
|
||||
range.from,
|
||||
range.to,
|
||||
filter.branchCode,
|
||||
filter.formType,
|
||||
filter.group,
|
||||
filter.search.trim().toLowerCase(),
|
||||
filter.flaggedOnly ? '1' : '0'
|
||||
].join('|');
|
||||
|
||||
if (key === rowCacheKey) {
|
||||
return rowCache;
|
||||
}
|
||||
|
||||
const byAck = new Map(
|
||||
formDeclarants().map((d) => [
|
||||
d.ackNo,
|
||||
d
|
||||
])
|
||||
);
|
||||
const needle = filter.search.trim().toLowerCase();
|
||||
|
||||
const rows: FormTxnRow[] = [];
|
||||
for (const t of formDeclarantTxns()) {
|
||||
if (t.date < range.from || t.date > range.to) {
|
||||
continue;
|
||||
}
|
||||
if (filter.branchCode && t.branchCode !== filter.branchCode) {
|
||||
continue;
|
||||
}
|
||||
if (filter.group && t.group !== filter.group) {
|
||||
continue;
|
||||
}
|
||||
const d = byAck.get(t.ackNo);
|
||||
if (!d) {
|
||||
continue;
|
||||
}
|
||||
if (filter.formType && d.formType !== filter.formType) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
needle &&
|
||||
!d.name.toLowerCase().includes(needle) &&
|
||||
!t.accountNo.toLowerCase().includes(needle) &&
|
||||
!d.ackNo.toLowerCase().includes(needle)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// ATM dispensing is cash in the customer's hands, so it counts towards the
|
||||
// cash view even though it never crossed the counter.
|
||||
const isCash = t.mode === 'CASH' || t.mode === 'ATM';
|
||||
const ctrReportable = isCash && t.amount >= FORM_THRESHOLD.CTR;
|
||||
|
||||
if (filter.flaggedOnly && !ctrReportable) {
|
||||
continue;
|
||||
}
|
||||
|
||||
rows.push({
|
||||
...t,
|
||||
declarantName: d.name,
|
||||
formType: d.formType,
|
||||
fatherName: d.fatherName,
|
||||
declaredOn: d.declaredOn,
|
||||
reasonForNoPan: d.reasonForNoPan,
|
||||
estimatedIncome: d.estimatedIncome,
|
||||
isCash,
|
||||
declarationRequired: t.rule114B,
|
||||
ctrReportable
|
||||
});
|
||||
}
|
||||
|
||||
rowCacheKey = key;
|
||||
rowCache = rows;
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** The same rows grouped by declarant, heaviest customer first. */
|
||||
export function formPersonRows(filter: FormReportFilter): FormPersonRow[] {
|
||||
const byAck = new Map(
|
||||
formDeclarants().map((d) => [
|
||||
d.ackNo,
|
||||
d
|
||||
])
|
||||
);
|
||||
const grouped = new Map<string, FormTxnRow[]>();
|
||||
|
||||
for (const r of formTxnRows(filter)) {
|
||||
const list = grouped.get(r.ackNo);
|
||||
if (list) {
|
||||
list.push(r);
|
||||
} else {
|
||||
grouped.set(r.ackNo, [r]);
|
||||
}
|
||||
}
|
||||
|
||||
const out: FormPersonRow[] = [];
|
||||
for (const [
|
||||
ackNo,
|
||||
txns
|
||||
] of grouped) {
|
||||
const declarant = byAck.get(ackNo);
|
||||
if (!declarant) {
|
||||
continue;
|
||||
}
|
||||
const cash = txns.filter((t) => t.isCash);
|
||||
const cashTotal = cash.reduce((s, t) => s + t.amount, 0);
|
||||
out.push({
|
||||
declarant,
|
||||
txns,
|
||||
txnCount: txns.length,
|
||||
cashCount: cash.length,
|
||||
totalCredit: txns.filter((t) => t.drCr === 'CR').reduce((s, t) => s + t.amount, 0),
|
||||
totalDebit: txns.filter((t) => t.drCr === 'DR').reduce((s, t) => s + t.amount, 0),
|
||||
cashTotal,
|
||||
maxAmount: txns.reduce((m, t) => Math.max(m, t.amount), 0),
|
||||
declaredCount: txns.filter((t) => t.declarationRequired).length,
|
||||
ctrCount: txns.filter((t) => t.ctrReportable).length,
|
||||
groups: [...new Set(txns.map((t) => t.group))].sort(),
|
||||
incomeRatio: declarant.estimatedIncome > 0 ? cashTotal / declarant.estimatedIncome : 0
|
||||
});
|
||||
}
|
||||
|
||||
out.sort((a, b) => b.cashTotal - a.cashTotal);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function formReportSummary(filter: FormReportFilter): FormReportSummary {
|
||||
const rows = formTxnRows(filter);
|
||||
const acks = new Set(rows.map((r) => r.ackNo));
|
||||
const form61 = new Set(rows.filter((r) => r.formType === 'FORM_61').map((r) => r.ackNo));
|
||||
const form60 = new Set(rows.filter((r) => r.formType === 'FORM_60').map((r) => r.ackNo));
|
||||
|
||||
const groupMap = new Map<string, { group: string; count: number; amount: number }>();
|
||||
for (const r of rows) {
|
||||
const g = groupMap.get(r.group);
|
||||
if (g) {
|
||||
g.count++;
|
||||
g.amount += r.amount;
|
||||
} else {
|
||||
groupMap.set(r.group, { group: r.group, count: 1, amount: r.amount });
|
||||
}
|
||||
}
|
||||
|
||||
const cash = rows.filter((r) => r.isCash);
|
||||
|
||||
return {
|
||||
persons: acks.size,
|
||||
form60Persons: form60.size,
|
||||
form61Persons: form61.size,
|
||||
txnCount: rows.length,
|
||||
cashTotal: cash.reduce((s, r) => s + r.amount, 0),
|
||||
cashCount: cash.length,
|
||||
totalCredit: rows.filter((r) => r.drCr === 'CR').reduce((s, r) => s + r.amount, 0),
|
||||
totalDebit: rows.filter((r) => r.drCr === 'DR').reduce((s, r) => s + r.amount, 0),
|
||||
declarationRequired: rows.filter((r) => r.declarationRequired).length,
|
||||
ctrReportable: rows.filter((r) => r.ctrReportable).length,
|
||||
byGroup: [...groupMap.values()].sort((a, b) => b.count - a.count)
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,723 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Data layer for the General Ledger report suite.
|
||||
*
|
||||
* Rather than hard-coding seven finished reports, this file holds a chart of
|
||||
* accounts plus a generated ledger of double-entry postings. Every report is
|
||||
* then computed from those postings, which is what makes the day / month /
|
||||
* branch filters genuinely recompute instead of just re-labelling a static table.
|
||||
*
|
||||
* The generator is deterministic — a fixed-seed LCG, never Math.random — so the
|
||||
* figures are identical on every reload and across users. Amounts are in rupees.
|
||||
*/
|
||||
|
||||
export type GlAccountType = 'ASSET' | 'LIABILITY' | 'INCOME' | 'EXPENSE' | 'CONTINGENT';
|
||||
|
||||
export interface GlAccount {
|
||||
code: string;
|
||||
name: string;
|
||||
type: GlAccountType;
|
||||
/** Balance-sheet / P&L grouping used as a sub-heading in the reports. */
|
||||
group: string;
|
||||
/** Included in the Misc Expense Ledger report. */
|
||||
miscExpense?: boolean;
|
||||
}
|
||||
|
||||
export interface GlBranch {
|
||||
code: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** One side of a double-entry voucher. */
|
||||
export interface GlPosting {
|
||||
/** ISO yyyy-MM-dd — the posting (value) date. */
|
||||
date: string;
|
||||
branchCode: string;
|
||||
accountCode: string;
|
||||
debit: number;
|
||||
credit: number;
|
||||
voucherNo: string;
|
||||
narration: string;
|
||||
}
|
||||
|
||||
export const GL_BRANCHES: GlBranch[] = [
|
||||
{ code: 'HO', name: 'Head Office' },
|
||||
{ code: '0002', name: 'Andheri (E)' },
|
||||
{ code: '0003', name: 'Bandra (W)' },
|
||||
{ code: '0004', name: 'Borivali' },
|
||||
{ code: '0005', name: 'Dadar' },
|
||||
{ code: '0006', name: 'Thane' }
|
||||
];
|
||||
|
||||
export const GL_ACCOUNTS: GlAccount[] = [
|
||||
// ── Assets ────────────────────────────────────────────────────────────────
|
||||
{ code: '110101', name: 'Cash in Hand', type: 'ASSET', group: 'Cash & Bank Balances' },
|
||||
{ code: '110102', name: 'Balance with RBI', type: 'ASSET', group: 'Cash & Bank Balances' },
|
||||
{ code: '110103', name: 'Balance with Other Banks — Current', type: 'ASSET', group: 'Cash & Bank Balances' },
|
||||
{ code: '110104', name: 'Balance with Other Banks — Fixed', type: 'ASSET', group: 'Cash & Bank Balances' },
|
||||
{ code: '120101', name: 'SLR Investments — Govt Securities', type: 'ASSET', group: 'Investments' },
|
||||
{ code: '120102', name: 'SLR Investments — State Govt Securities', type: 'ASSET', group: 'Investments' },
|
||||
{ code: '120201', name: 'Non-SLR Investments — Bonds', type: 'ASSET', group: 'Investments' },
|
||||
{ code: '120202', name: 'Non-SLR Investments — Mutual Funds', type: 'ASSET', group: 'Investments' },
|
||||
{ code: '130101', name: 'Cash Credit & Overdrafts', type: 'ASSET', group: 'Loans & Advances' },
|
||||
{ code: '130102', name: 'Term Loans — Short Term', type: 'ASSET', group: 'Loans & Advances' },
|
||||
{ code: '130103', name: 'Term Loans — Medium Term', type: 'ASSET', group: 'Loans & Advances' },
|
||||
{ code: '130104', name: 'Term Loans — Long Term', type: 'ASSET', group: 'Loans & Advances' },
|
||||
{ code: '130105', name: 'Housing Loans', type: 'ASSET', group: 'Loans & Advances' },
|
||||
{ code: '130106', name: 'Gold Loans', type: 'ASSET', group: 'Loans & Advances' },
|
||||
{ code: '140101', name: 'Interest Receivable', type: 'ASSET', group: 'Other Assets' },
|
||||
{ code: '140102', name: 'Premises (net of depreciation)', type: 'ASSET', group: 'Fixed Assets' },
|
||||
{ code: '140103', name: 'Furniture & Fixtures (net)', type: 'ASSET', group: 'Fixed Assets' },
|
||||
{ code: '140104', name: 'Sundry Debtors', type: 'ASSET', group: 'Other Assets' },
|
||||
{ code: '140105', name: 'Advance Tax & TDS', type: 'ASSET', group: 'Other Assets' },
|
||||
{ code: '140199', name: 'Opening Balance Suspense (unreconciled)', type: 'ASSET', group: 'Other Assets' },
|
||||
|
||||
// ── Liabilities & Capital ─────────────────────────────────────────────────
|
||||
{ code: '210101', name: 'Share Capital — Individuals', type: 'LIABILITY', group: 'Capital & Reserves' },
|
||||
{ code: '210201', name: 'Statutory Reserve Fund', type: 'LIABILITY', group: 'Capital & Reserves' },
|
||||
{ code: '210202', name: 'Building Fund', type: 'LIABILITY', group: 'Capital & Reserves' },
|
||||
{ code: '210203', name: 'Investment Depreciation Reserve', type: 'LIABILITY', group: 'Capital & Reserves' },
|
||||
{ code: '210204', name: 'BDDR / Special BDDR', type: 'LIABILITY', group: 'Capital & Reserves' },
|
||||
{ code: '220101', name: 'Savings Bank Deposits', type: 'LIABILITY', group: 'Deposits' },
|
||||
{ code: '220102', name: 'Current Deposits', type: 'LIABILITY', group: 'Deposits' },
|
||||
{ code: '220103', name: 'Term Deposits', type: 'LIABILITY', group: 'Deposits' },
|
||||
{ code: '220104', name: 'Recurring Deposits', type: 'LIABILITY', group: 'Deposits' },
|
||||
{ code: '230101', name: 'Interest Payable on Deposits', type: 'LIABILITY', group: 'Other Liabilities' },
|
||||
{ code: '230102', name: 'Bills Payable', type: 'LIABILITY', group: 'Other Liabilities' },
|
||||
{ code: '230103', name: 'Sundry Creditors', type: 'LIABILITY', group: 'Other Liabilities' },
|
||||
{ code: '230104', name: 'TDS / GST Payable', type: 'LIABILITY', group: 'Other Liabilities' },
|
||||
{ code: '230105', name: 'Overdue Interest Reserve', type: 'LIABILITY', group: 'Other Liabilities' },
|
||||
|
||||
// ── Income ────────────────────────────────────────────────────────────────
|
||||
{ code: '310101', name: 'Interest on Loans & Advances', type: 'INCOME', group: 'Interest Income' },
|
||||
{ code: '310102', name: 'Interest on Investments', type: 'INCOME', group: 'Interest Income' },
|
||||
{ code: '310103', name: 'Interest on Inter-bank Deposits', type: 'INCOME', group: 'Interest Income' },
|
||||
{ code: '320101', name: 'Commission & Exchange', type: 'INCOME', group: 'Other Income' },
|
||||
{ code: '320102', name: 'Locker Rent', type: 'INCOME', group: 'Other Income' },
|
||||
{ code: '320103', name: 'Processing Fees', type: 'INCOME', group: 'Other Income' },
|
||||
{ code: '320104', name: 'Profit on Sale of Securities', type: 'INCOME', group: 'Other Income' },
|
||||
{ code: '320105', name: 'Miscellaneous Income', type: 'INCOME', group: 'Other Income' },
|
||||
|
||||
// ── Expenditure ───────────────────────────────────────────────────────────
|
||||
{ code: '410101', name: 'Interest on Savings Deposits', type: 'EXPENSE', group: 'Interest Expended' },
|
||||
{ code: '410102', name: 'Interest on Term Deposits', type: 'EXPENSE', group: 'Interest Expended' },
|
||||
{ code: '410103', name: 'Interest on Borrowings', type: 'EXPENSE', group: 'Interest Expended' },
|
||||
{ code: '420101', name: 'Salaries & Allowances', type: 'EXPENSE', group: 'Operating Expenses' },
|
||||
{ code: '420102', name: "Directors' Fees", type: 'EXPENSE', group: 'Operating Expenses' },
|
||||
{ code: '420103', name: "Auditors' Fees", type: 'EXPENSE', group: 'Operating Expenses' },
|
||||
{ code: '420104', name: 'Rent, Taxes & Lighting', type: 'EXPENSE', group: 'Operating Expenses' },
|
||||
{ code: '420105', name: 'Depreciation on Bank Property', type: 'EXPENSE', group: 'Operating Expenses' },
|
||||
{
|
||||
code: '430101',
|
||||
name: 'Printing & Stationery',
|
||||
type: 'EXPENSE',
|
||||
group: 'Miscellaneous Expenses',
|
||||
miscExpense: true
|
||||
},
|
||||
{
|
||||
code: '430102',
|
||||
name: 'Postage, Telegram & Telephone',
|
||||
type: 'EXPENSE',
|
||||
group: 'Miscellaneous Expenses',
|
||||
miscExpense: true
|
||||
},
|
||||
{
|
||||
code: '430103',
|
||||
name: 'Advertisement & Publicity',
|
||||
type: 'EXPENSE',
|
||||
group: 'Miscellaneous Expenses',
|
||||
miscExpense: true
|
||||
},
|
||||
{
|
||||
code: '430104',
|
||||
name: 'Repairs & Maintenance',
|
||||
type: 'EXPENSE',
|
||||
group: 'Miscellaneous Expenses',
|
||||
miscExpense: true
|
||||
},
|
||||
{
|
||||
code: '430105',
|
||||
name: 'Travelling & Conveyance',
|
||||
type: 'EXPENSE',
|
||||
group: 'Miscellaneous Expenses',
|
||||
miscExpense: true
|
||||
},
|
||||
{
|
||||
code: '430106',
|
||||
name: 'Computer & Software Expenses',
|
||||
type: 'EXPENSE',
|
||||
group: 'Miscellaneous Expenses',
|
||||
miscExpense: true
|
||||
},
|
||||
{
|
||||
code: '430107',
|
||||
name: 'Legal & Professional Charges',
|
||||
type: 'EXPENSE',
|
||||
group: 'Miscellaneous Expenses',
|
||||
miscExpense: true
|
||||
},
|
||||
{
|
||||
code: '430108',
|
||||
name: 'Insurance (DICGC & Other)',
|
||||
type: 'EXPENSE',
|
||||
group: 'Miscellaneous Expenses',
|
||||
miscExpense: true
|
||||
},
|
||||
{
|
||||
code: '430109',
|
||||
name: 'Bank Charges & Commission Paid',
|
||||
type: 'EXPENSE',
|
||||
group: 'Miscellaneous Expenses',
|
||||
miscExpense: true
|
||||
},
|
||||
{
|
||||
code: '430110',
|
||||
name: 'AGM & Meeting Expenses',
|
||||
type: 'EXPENSE',
|
||||
group: 'Miscellaneous Expenses',
|
||||
miscExpense: true
|
||||
},
|
||||
{ code: '430111', name: 'Electricity Charges', type: 'EXPENSE', group: 'Miscellaneous Expenses', miscExpense: true },
|
||||
{
|
||||
code: '430112',
|
||||
name: 'Other Miscellaneous Expenses',
|
||||
type: 'EXPENSE',
|
||||
group: 'Miscellaneous Expenses',
|
||||
miscExpense: true
|
||||
},
|
||||
|
||||
// ── Contingent (memoranda, not part of the balance sheet totals) ──────────
|
||||
{ code: '910101', name: 'Guarantees Issued — Financial', type: 'CONTINGENT', group: 'Guarantees' },
|
||||
{ code: '910102', name: 'Guarantees Issued — Performance', type: 'CONTINGENT', group: 'Guarantees' },
|
||||
{ code: '910103', name: 'Letters of Credit Outstanding', type: 'CONTINGENT', group: 'Letters of Credit' },
|
||||
{ code: '910104', name: 'Bills for Collection', type: 'CONTINGENT', group: 'Bills & Claims' },
|
||||
{ code: '910105', name: 'Claims Against Bank Not Acknowledged as Debt', type: 'CONTINGENT', group: 'Bills & Claims' },
|
||||
{ code: '910106', name: 'Forward Exchange Contracts', type: 'CONTINGENT', group: 'Other Commitments' },
|
||||
{ code: '910107', name: 'Capital Commitments Not Provided For', type: 'CONTINGENT', group: 'Other Commitments' }
|
||||
];
|
||||
|
||||
/** Financial year covered by the generated ledger. */
|
||||
export const GL_PERIOD = {
|
||||
/** FY 2026-27 opens here. */
|
||||
fyStart: '2026-04-01',
|
||||
/** Postings run to the day before the app's "today" (01 Aug 2026). */
|
||||
fyEnd: '2026-07-31',
|
||||
label: 'FY 2026-27'
|
||||
};
|
||||
|
||||
/**
|
||||
* Opening balances as at 01-Apr-2026, keyed by account code. Positive means the
|
||||
* account opens on its natural side (debit for assets/expenses, credit for the
|
||||
* rest). Head Office carries the investments and reserves; branches carry the
|
||||
* customer-facing book.
|
||||
*/
|
||||
const OPENING_BALANCES: Record<string, number> = {
|
||||
'110101': 9_996_000,
|
||||
'110102': 12_400_000,
|
||||
'110103': 13_667_000,
|
||||
// Surplus deposits parked with correspondent banks. Sized so the opening
|
||||
// balance sheet tallies exactly — see the assertion in glPostings().
|
||||
'110104': 54_206_000,
|
||||
'120101': 63_839_000,
|
||||
'120102': 147_895_000,
|
||||
'120201': 22_500_000,
|
||||
'120202': 24_234_000,
|
||||
'130101': 166_614_000,
|
||||
'130102': 125_082_000,
|
||||
'130103': 70_236_000,
|
||||
'130104': 29_356_000,
|
||||
'130105': 10_241_000,
|
||||
'130106': 8_450_000,
|
||||
'140101': 33_541_000,
|
||||
'140102': 3_185_000,
|
||||
'140103': 1_763_000,
|
||||
'140104': 1_240_000,
|
||||
'140105': 657_000,
|
||||
'210101': 27_278_000,
|
||||
'210201': 31_149_000,
|
||||
'210202': 13_642_000,
|
||||
'210203': 3_750_000,
|
||||
'210204': 24_341_000,
|
||||
'220101': 208_116_000,
|
||||
'220102': 19_592_000,
|
||||
'220103': 406_601_000,
|
||||
'220104': 24_880_000,
|
||||
'230101': 731_000,
|
||||
'230102': 291_000,
|
||||
'230103': 5_515_000,
|
||||
'230104': 1_120_000,
|
||||
'230105': 32_096_000,
|
||||
'910101': 1_450_000,
|
||||
'910102': 980_000,
|
||||
'910103': 620_000,
|
||||
'910104': 1_180_000,
|
||||
'910105': 2_638_000,
|
||||
'910106': 0,
|
||||
'910107': 340_000
|
||||
};
|
||||
|
||||
/** Deterministic linear congruential generator — same figures on every load. */
|
||||
function lcg(seed: number): () => number {
|
||||
let s = seed >>> 0;
|
||||
return () => {
|
||||
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
|
||||
return s / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
function isoDate(d: Date): string {
|
||||
const y = d.getUTCFullYear();
|
||||
const m = `${d.getUTCMonth() + 1}`.padStart(2, '0');
|
||||
const day = `${d.getUTCDate()}`.padStart(2, '0');
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
/** Every posting date in the ledger period, Sundays excluded (bank holiday). */
|
||||
export function ledgerDates(): string[] {
|
||||
const out: string[] = [];
|
||||
const cur = new Date(`${GL_PERIOD.fyStart}T00:00:00Z`);
|
||||
const end = new Date(`${GL_PERIOD.fyEnd}T00:00:00Z`);
|
||||
while (cur <= end) {
|
||||
if (cur.getUTCDay() !== 0) {
|
||||
out.push(isoDate(cur));
|
||||
}
|
||||
cur.setUTCDate(cur.getUTCDate() + 1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Templates for the vouchers the generator posts each day. */
|
||||
const VOUCHER_TEMPLATES: {
|
||||
debit: string;
|
||||
credit: string;
|
||||
narration: string;
|
||||
/** Rough rupee scale of the voucher. */
|
||||
scale: number;
|
||||
/** Chance the voucher is raised on any given branch-day. */
|
||||
frequency: number;
|
||||
}[] = [
|
||||
{ debit: '110101', credit: '220101', narration: 'Cash deposit — savings accounts', scale: 900_000, frequency: 1 },
|
||||
{ debit: '220101', credit: '110101', narration: 'Cash withdrawal — savings accounts', scale: 780_000, frequency: 1 },
|
||||
{ debit: '110101', credit: '220103', narration: 'Term deposit received', scale: 1_400_000, frequency: 0.75 },
|
||||
{ debit: '220103', credit: '110101', narration: 'Term deposit matured and paid', scale: 1_150_000, frequency: 0.6 },
|
||||
{ debit: '110101', credit: '220102', narration: 'Current account deposit', scale: 620_000, frequency: 0.8 },
|
||||
{ debit: '220102', credit: '110101', narration: 'Current account withdrawal', scale: 560_000, frequency: 0.8 },
|
||||
{ debit: '110101', credit: '220104', narration: 'Recurring deposit instalment', scale: 180_000, frequency: 0.7 },
|
||||
{ debit: '130101', credit: '110101', narration: 'Cash credit disbursed', scale: 1_250_000, frequency: 0.55 },
|
||||
{ debit: '110101', credit: '130101', narration: 'Cash credit repayment', scale: 1_100_000, frequency: 0.6 },
|
||||
{ debit: '130102', credit: '110101', narration: 'Short term loan disbursed', scale: 850_000, frequency: 0.4 },
|
||||
{ debit: '110101', credit: '130102', narration: 'Short term loan repayment', scale: 760_000, frequency: 0.5 },
|
||||
{ debit: '130103', credit: '110101', narration: 'Medium term loan disbursed', scale: 640_000, frequency: 0.3 },
|
||||
{ debit: '110101', credit: '130104', narration: 'Long term loan repayment', scale: 410_000, frequency: 0.35 },
|
||||
{ debit: '130105', credit: '110101', narration: 'Housing loan disbursed', scale: 520_000, frequency: 0.2 },
|
||||
{ debit: '130106', credit: '110101', narration: 'Gold loan disbursed', scale: 260_000, frequency: 0.4 },
|
||||
// Interest on advances is the dominant earning line — sized so the bank runs a
|
||||
// ~13% net margin rather than the loss the first-cut figures produced.
|
||||
{ debit: '110101', credit: '310101', narration: 'Interest recovered on advances', scale: 430_000, frequency: 1 },
|
||||
{ debit: '140101', credit: '310102', narration: 'Interest accrued on investments', scale: 148_000, frequency: 0.5 },
|
||||
{ debit: '110103', credit: '310103', narration: 'Interest on inter-bank deposits', scale: 42_000, frequency: 0.25 },
|
||||
{ debit: '110101', credit: '320101', narration: 'Commission and exchange collected', scale: 26_000, frequency: 0.9 },
|
||||
{ debit: '110101', credit: '320102', narration: 'Locker rent collected', scale: 9_000, frequency: 0.3 },
|
||||
{ debit: '110101', credit: '320103', narration: 'Loan processing fees', scale: 18_000, frequency: 0.5 },
|
||||
{ debit: '110101', credit: '320105', narration: 'Miscellaneous income', scale: 7_500, frequency: 0.4 },
|
||||
{
|
||||
debit: '410101',
|
||||
credit: '230101',
|
||||
narration: 'Interest provided on savings deposits',
|
||||
scale: 128_000,
|
||||
frequency: 0.8
|
||||
},
|
||||
{
|
||||
debit: '410102',
|
||||
credit: '230101',
|
||||
narration: 'Interest provided on term deposits',
|
||||
scale: 265_000,
|
||||
frequency: 0.9
|
||||
},
|
||||
{ debit: '420101', credit: '110101', narration: 'Salaries and allowances paid', scale: 210_000, frequency: 0.35 },
|
||||
{ debit: '420104', credit: '110101', narration: 'Rent, taxes and lighting', scale: 46_000, frequency: 0.3 },
|
||||
{ debit: '430101', credit: '110101', narration: 'Printing and stationery purchased', scale: 9_400, frequency: 0.3 },
|
||||
{ debit: '430102', credit: '110101', narration: 'Postage and telephone charges', scale: 6_200, frequency: 0.35 },
|
||||
{ debit: '430103', credit: '110101', narration: 'Advertisement and publicity', scale: 12_500, frequency: 0.15 },
|
||||
{ debit: '430104', credit: '110101', narration: 'Repairs and maintenance', scale: 15_800, frequency: 0.25 },
|
||||
{ debit: '430105', credit: '110101', narration: 'Travelling and conveyance', scale: 7_100, frequency: 0.3 },
|
||||
{ debit: '430106', credit: '110101', narration: 'Computer and software expenses', scale: 22_400, frequency: 0.2 },
|
||||
{ debit: '430107', credit: '110101', narration: 'Legal and professional charges', scale: 11_300, frequency: 0.15 },
|
||||
{ debit: '430109', credit: '110101', narration: 'Bank charges and commission paid', scale: 4_800, frequency: 0.3 },
|
||||
{ debit: '430111', credit: '110101', narration: 'Electricity charges', scale: 13_600, frequency: 0.25 },
|
||||
{ debit: '430112', credit: '110101', narration: 'Other miscellaneous expenses', scale: 8_900, frequency: 0.3 },
|
||||
{
|
||||
debit: '110102',
|
||||
credit: '110101',
|
||||
narration: 'Cash remitted to RBI currency chest',
|
||||
scale: 1_800_000,
|
||||
frequency: 0.2
|
||||
},
|
||||
{
|
||||
debit: '110101',
|
||||
credit: '110103',
|
||||
narration: 'Funds drawn from correspondent bank',
|
||||
scale: 1_400_000,
|
||||
frequency: 0.2
|
||||
}
|
||||
];
|
||||
|
||||
/** Head-Office-only vouchers — investments, reserves and centralised charges. */
|
||||
const HO_TEMPLATES: typeof VOUCHER_TEMPLATES = [
|
||||
{ debit: '120101', credit: '110102', narration: 'Purchase of Govt securities', scale: 2_400_000, frequency: 0.12 },
|
||||
{
|
||||
debit: '120102',
|
||||
credit: '110102',
|
||||
narration: 'Purchase of State Govt securities',
|
||||
scale: 1_900_000,
|
||||
frequency: 0.12
|
||||
},
|
||||
{ debit: '110102', credit: '320104', narration: 'Profit on sale of securities', scale: 78_000, frequency: 0.1 },
|
||||
{ debit: '420102', credit: '110101', narration: "Directors' sitting fees", scale: 32_000, frequency: 0.08 },
|
||||
{ debit: '420103', credit: '110101', narration: "Statutory auditors' fees", scale: 45_000, frequency: 0.06 },
|
||||
{ debit: '420105', credit: '140103', narration: 'Depreciation charged on assets', scale: 64_000, frequency: 0.12 },
|
||||
{ debit: '430108', credit: '110101', narration: 'DICGC premium and other insurance', scale: 38_000, frequency: 0.1 },
|
||||
{ debit: '430110', credit: '110101', narration: 'AGM and meeting expenses', scale: 26_000, frequency: 0.06 },
|
||||
{ debit: '410103', credit: '230101', narration: 'Interest on borrowings', scale: 54_000, frequency: 0.1 },
|
||||
{ debit: '230104', credit: '110102', narration: 'TDS and GST remitted', scale: 145_000, frequency: 0.15 }
|
||||
];
|
||||
|
||||
/**
|
||||
* Contingent-account movements are memoranda, each posted single-sided.
|
||||
* Guarantees, LCs and collection bills are raised at the counter, so these run
|
||||
* at every branch — not just Head Office.
|
||||
*/
|
||||
const CONTINGENT_TEMPLATES: { account: string; narration: string; scale: number; frequency: number }[] = [
|
||||
{ account: '910101', narration: 'Financial guarantee issued', scale: 320_000, frequency: 0.1 },
|
||||
{ account: '910102', narration: 'Performance guarantee issued', scale: 240_000, frequency: 0.08 },
|
||||
{ account: '910103', narration: 'Letter of credit opened', scale: 180_000, frequency: 0.07 },
|
||||
{ account: '910104', narration: 'Bills lodged for collection', scale: 95_000, frequency: 0.12 },
|
||||
{ account: '910105', narration: 'Claim lodged against the bank', scale: 140_000, frequency: 0.03 },
|
||||
{ account: '910107', narration: 'Capital commitment contracted', scale: 85_000, frequency: 0.04 }
|
||||
];
|
||||
|
||||
/**
|
||||
* How the opening contingent book is spread across the network. Head Office
|
||||
* carries the capital commitments and large claims; the branches carry the
|
||||
* counter-issued guarantees. Weights are applied per account and the rounding
|
||||
* remainder is absorbed by Head Office so each account's total stays exact.
|
||||
*/
|
||||
const CONTINGENT_BRANCH_WEIGHTS: Record<string, number> = {
|
||||
HO: 0.2,
|
||||
'0002': 0.18,
|
||||
'0003': 0.16,
|
||||
'0004': 0.16,
|
||||
'0005': 0.15,
|
||||
'0006': 0.15
|
||||
};
|
||||
|
||||
let cachedPostings: GlPosting[] | null = null;
|
||||
|
||||
/**
|
||||
* The full generated ledger. Built once and memoised — every report reads from
|
||||
* this array, so the reports can never disagree with one another.
|
||||
*/
|
||||
export function glPostings(): GlPosting[] {
|
||||
if (cachedPostings) {
|
||||
return cachedPostings;
|
||||
}
|
||||
const rand = lcg(20260401);
|
||||
const out: GlPosting[] = [];
|
||||
const dates = ledgerDates();
|
||||
let voucherSeq = 1;
|
||||
|
||||
// Opening entries, posted at Head Office on day one so the books start in balance.
|
||||
const openingVoucher = `OB/${GL_PERIOD.label.replace(/\s/g, '')}`;
|
||||
let openingDebit = 0;
|
||||
let openingCredit = 0;
|
||||
|
||||
for (const [
|
||||
code,
|
||||
amount
|
||||
] of Object.entries(OPENING_BALANCES)) {
|
||||
if (!amount) {
|
||||
continue;
|
||||
}
|
||||
const account = GL_ACCOUNTS.find((a) => a.code === code);
|
||||
if (!account) {
|
||||
continue;
|
||||
}
|
||||
const debitSide = account.type === 'ASSET' || account.type === 'EXPENSE' || account.type === 'CONTINGENT';
|
||||
|
||||
// Contingent accounts are single-sided memoranda held at the issuing branch,
|
||||
// so their opening book is spread across the network rather than sat at HO.
|
||||
if (account.type === 'CONTINGENT') {
|
||||
let allocated = 0;
|
||||
for (const branch of GL_BRANCHES) {
|
||||
if (branch.code === 'HO') {
|
||||
continue;
|
||||
}
|
||||
const share = Math.round((amount * (CONTINGENT_BRANCH_WEIGHTS[branch.code] ?? 0)) / 100) * 100;
|
||||
if (!share) {
|
||||
continue;
|
||||
}
|
||||
allocated += share;
|
||||
out.push({
|
||||
date: GL_PERIOD.fyStart,
|
||||
branchCode: branch.code,
|
||||
accountCode: code,
|
||||
debit: share,
|
||||
credit: 0,
|
||||
voucherNo: openingVoucher,
|
||||
narration: 'Opening balance brought forward'
|
||||
});
|
||||
}
|
||||
// Head Office takes its own share plus whatever rounding left behind.
|
||||
out.push({
|
||||
date: GL_PERIOD.fyStart,
|
||||
branchCode: 'HO',
|
||||
accountCode: code,
|
||||
debit: amount - allocated,
|
||||
credit: 0,
|
||||
voucherNo: openingVoucher,
|
||||
narration: 'Opening balance brought forward'
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
out.push({
|
||||
date: GL_PERIOD.fyStart,
|
||||
branchCode: 'HO',
|
||||
accountCode: code,
|
||||
debit: debitSide ? amount : 0,
|
||||
credit: debitSide ? 0 : amount,
|
||||
voucherNo: openingVoucher,
|
||||
narration: 'Opening balance brought forward'
|
||||
});
|
||||
if (debitSide) {
|
||||
openingDebit += amount;
|
||||
} else {
|
||||
openingCredit += amount;
|
||||
}
|
||||
}
|
||||
|
||||
// If OPENING_BALANCES is ever edited so the two sides diverge, park the residual
|
||||
// in a visibly-named suspense account rather than letting the trial balance drift.
|
||||
const openingResidual = openingCredit - openingDebit;
|
||||
if (openingResidual !== 0) {
|
||||
out.push({
|
||||
date: GL_PERIOD.fyStart,
|
||||
branchCode: 'HO',
|
||||
accountCode: '140199',
|
||||
debit: openingResidual > 0 ? openingResidual : 0,
|
||||
credit: openingResidual < 0 ? -openingResidual : 0,
|
||||
voucherNo: openingVoucher,
|
||||
narration: 'Unreconciled opening balance difference'
|
||||
});
|
||||
}
|
||||
|
||||
const round = (n: number): number => Math.round(n / 100) * 100;
|
||||
|
||||
for (const date of dates) {
|
||||
for (const branch of GL_BRANCHES) {
|
||||
// Head Office posts fewer counter transactions than the branches.
|
||||
const weight = branch.code === 'HO' ? 0.45 : 1;
|
||||
const templates = branch.code === 'HO' ? [
|
||||
...VOUCHER_TEMPLATES,
|
||||
...HO_TEMPLATES
|
||||
] : VOUCHER_TEMPLATES;
|
||||
|
||||
for (const tpl of templates) {
|
||||
if (rand() > tpl.frequency * weight) {
|
||||
continue;
|
||||
}
|
||||
// 0.55x .. 1.45x of the template scale keeps daily figures varied but plausible.
|
||||
const amount = round(tpl.scale * (0.55 + rand() * 0.9));
|
||||
if (amount <= 0) {
|
||||
continue;
|
||||
}
|
||||
const voucherNo = `${branch.code}/${date.slice(5, 7)}/${`${voucherSeq++}`.padStart(5, '0')}`;
|
||||
out.push({
|
||||
date,
|
||||
branchCode: branch.code,
|
||||
accountCode: tpl.debit,
|
||||
debit: amount,
|
||||
credit: 0,
|
||||
voucherNo,
|
||||
narration: tpl.narration
|
||||
});
|
||||
out.push({
|
||||
date,
|
||||
branchCode: branch.code,
|
||||
accountCode: tpl.credit,
|
||||
debit: 0,
|
||||
credit: amount,
|
||||
voucherNo,
|
||||
narration: tpl.narration
|
||||
});
|
||||
}
|
||||
|
||||
// Contingent memoranda are raised at every branch, weighted by its size.
|
||||
const contingentWeight = CONTINGENT_BRANCH_WEIGHTS[branch.code] ?? 0.15;
|
||||
for (const tpl of CONTINGENT_TEMPLATES) {
|
||||
// Claims against the bank and capital commitments are Head Office matters.
|
||||
const headOfficeOnly = tpl.account === '910105' || tpl.account === '910107';
|
||||
if (headOfficeOnly && branch.code !== 'HO') {
|
||||
continue;
|
||||
}
|
||||
const frequency = headOfficeOnly ? tpl.frequency : tpl.frequency * (contingentWeight / 0.2);
|
||||
if (rand() > frequency) {
|
||||
continue;
|
||||
}
|
||||
const amount = round(tpl.scale * (0.55 + rand() * 0.9));
|
||||
out.push({
|
||||
date,
|
||||
branchCode: branch.code,
|
||||
accountCode: tpl.account,
|
||||
debit: amount,
|
||||
credit: 0,
|
||||
voucherNo: `CNT/${branch.code}/${date.slice(5, 7)}/${`${voucherSeq++}`.padStart(5, '0')}`,
|
||||
narration: tpl.narration
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cachedPostings = out;
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Customer sub-ledger.
|
||||
*
|
||||
* The GL is account-level, so a client-wise report needs a customer dimension on top.
|
||||
* Each customer holds a share of one GL head at one branch; balances are not stored,
|
||||
* they are derived at query time as `share x that head's GL balance`. That way the
|
||||
* client-wise report always reconciles to the General Ledger exactly, whatever period
|
||||
* or branch is selected, instead of drifting as a second source of truth.
|
||||
*/
|
||||
export interface GlCustomer {
|
||||
cif: string;
|
||||
name: string;
|
||||
branchCode: string;
|
||||
/** GL head this relationship sits under. */
|
||||
accountCode: string;
|
||||
accountNo: string;
|
||||
/** Fraction of the branch's balance in that head. Shares per (branch, head) sum to 1. */
|
||||
share: number;
|
||||
}
|
||||
|
||||
const FIRST_NAMES = [
|
||||
'Ravi',
|
||||
'Priya',
|
||||
'Vikram',
|
||||
'Nirav',
|
||||
'Kavita',
|
||||
'Arjun',
|
||||
'Lakshmi',
|
||||
'Sameer',
|
||||
'Anjali',
|
||||
'Rohan',
|
||||
'Meera',
|
||||
'Karan',
|
||||
'Divya',
|
||||
'Sanjay',
|
||||
'Pooja',
|
||||
'Amit',
|
||||
'Sneha',
|
||||
'Manish',
|
||||
'Rekha',
|
||||
'Deepak'
|
||||
];
|
||||
const LAST_NAMES = [
|
||||
'Sharma',
|
||||
'Patel',
|
||||
'Reddy',
|
||||
'Shah',
|
||||
'Verma',
|
||||
'Deshmukh',
|
||||
'Iyer',
|
||||
'Kapoor',
|
||||
'Nair',
|
||||
'Joshi',
|
||||
'Mehta',
|
||||
'Gupta',
|
||||
'Rao',
|
||||
'Bhatt',
|
||||
'Chauhan'
|
||||
];
|
||||
const FIRMS = [
|
||||
'Traders',
|
||||
'Enterprises',
|
||||
'Agencies',
|
||||
'& Sons',
|
||||
'Industries',
|
||||
'Stores',
|
||||
'Corporation'
|
||||
];
|
||||
|
||||
/** Customer-facing heads a client relationship can sit under. */
|
||||
const CUSTOMER_HEADS = [
|
||||
'220101',
|
||||
'220102',
|
||||
'220103',
|
||||
'220104',
|
||||
'130101',
|
||||
'130102',
|
||||
'130103',
|
||||
'130104',
|
||||
'130105',
|
||||
'130106'
|
||||
];
|
||||
|
||||
let cachedCustomers: GlCustomer[] | null = null;
|
||||
|
||||
export function glCustomers(): GlCustomer[] {
|
||||
if (cachedCustomers) {
|
||||
return cachedCustomers;
|
||||
}
|
||||
const rand = lcg(76543210);
|
||||
const out: GlCustomer[] = [];
|
||||
let seq = 1;
|
||||
|
||||
for (const branch of GL_BRANCHES) {
|
||||
for (const head of CUSTOMER_HEADS) {
|
||||
// Deposits are held by many customers; advances by fewer, larger ones.
|
||||
const isAdvance = head.startsWith('13');
|
||||
const count = isAdvance ? 2 + Math.floor(rand() * 3) : 3 + Math.floor(rand() * 4);
|
||||
const weights: number[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
weights.push(0.35 + rand() * 0.65);
|
||||
}
|
||||
const weightTotal = weights.reduce((a, b) => a + b, 0);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const corporate = isAdvance && rand() > 0.55;
|
||||
const name = corporate
|
||||
? `${LAST_NAMES[Math.floor(rand() * LAST_NAMES.length)]} ${FIRMS[Math.floor(rand() * FIRMS.length)]}`
|
||||
: `${FIRST_NAMES[Math.floor(rand() * FIRST_NAMES.length)]} ${LAST_NAMES[Math.floor(rand() * LAST_NAMES.length)]}`;
|
||||
out.push({
|
||||
cif: `CIF${`${100000 + seq}`}`,
|
||||
name,
|
||||
branchCode: branch.code,
|
||||
accountCode: head,
|
||||
accountNo: `${branch.code === 'HO' ? '0001' : branch.code}${head}${`${seq}`.padStart(4, '0')}`,
|
||||
share: weights[i] / weightTotal
|
||||
});
|
||||
seq++;
|
||||
}
|
||||
}
|
||||
}
|
||||
cachedCustomers = out;
|
||||
return out;
|
||||
}
|
||||
|
||||
export function glAccount(code: string): GlAccount | undefined {
|
||||
return GL_ACCOUNTS.find((a) => a.code === code);
|
||||
}
|
||||
|
||||
export function glBranchName(code: string): string {
|
||||
return GL_BRANCHES.find((b) => b.code === code)?.name ?? code;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* 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 { NgModule } from '@angular/core';
|
||||
import { Routes, RouterModule } from '@angular/router';
|
||||
|
||||
import { Route } from '../core/route/route.service';
|
||||
|
||||
const routes: Routes = [
|
||||
Route.withShell([
|
||||
{
|
||||
path: 'gl/reports',
|
||||
// Lazy — the generated ledger and ExcelJS should not weigh down the eager bundle.
|
||||
loadComponent: () => import('./gl-reports.component').then((m) => m.GlReportsComponent),
|
||||
data: { title: 'General Ledger Reports', breadcrumb: 'GL Reports' }
|
||||
},
|
||||
{
|
||||
path: 'gl/reports/:report',
|
||||
loadComponent: () => import('./gl-reports.component').then((m) => m.GlReportsComponent),
|
||||
data: { title: 'General Ledger Reports', breadcrumb: 'GL Reports' }
|
||||
}
|
||||
])
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule]
|
||||
})
|
||||
export class GlReportsRoutingModule {}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,560 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
:host {
|
||||
--g-navy: #1a237e;
|
||||
--g-blue: #1d4ed8;
|
||||
--g-blue-l: #eff6ff;
|
||||
--g-teal: #0e7e6b;
|
||||
--g-teal-l: #e0f4f0;
|
||||
--g-green: #15803d;
|
||||
--g-green-l: #dcfce7;
|
||||
--g-amber: #b45309;
|
||||
--g-amber-l: #fef3c7;
|
||||
--g-red: #c0392b;
|
||||
--g-red-l: #fdecea;
|
||||
--g-g50: #f8fafc;
|
||||
--g-g100: #f1f5f9;
|
||||
--g-g200: #e2e8f0;
|
||||
--g-g300: #cbd5e1;
|
||||
--g-g500: #64748b;
|
||||
--g-g700: #334155;
|
||||
--g-g900: #0f172a;
|
||||
|
||||
display: block;
|
||||
font-family:
|
||||
'DM Sans',
|
||||
system-ui,
|
||||
-apple-system,
|
||||
sans-serif;
|
||||
background: var(--g-g50);
|
||||
color: var(--g-g900);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.glr-root {
|
||||
padding: 18px 22px;
|
||||
max-width: 1700px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* HEADER */
|
||||
.glr-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.glr-head-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.glr-head-ic {
|
||||
font-size: 28px;
|
||||
color: var(--g-navy);
|
||||
}
|
||||
|
||||
.glr-head-t {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.glr-head-s {
|
||||
font-size: 12px;
|
||||
color: var(--g-g500);
|
||||
}
|
||||
|
||||
/* BUTTONS */
|
||||
.glr-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 9px 16px;
|
||||
border: 1px solid var(--g-g300);
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--g-g700);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: all 0.15s;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--g-g50);
|
||||
border-color: var(--g-g500);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.glr-btn-xls {
|
||||
background: #16a34a;
|
||||
border-color: #16a34a;
|
||||
color: #fff;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #15803d;
|
||||
border-color: #15803d;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
/* FILTER BAR */
|
||||
.glr-filters {
|
||||
background: #fff;
|
||||
border: 1px solid var(--g-g200);
|
||||
border-radius: 12px;
|
||||
padding: 14px;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: 0 1px 3px rgb(0 0 0 / 4%);
|
||||
}
|
||||
|
||||
.glr-filter {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
|
||||
label {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--g-g500);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
}
|
||||
|
||||
.glr-inp {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--g-g200);
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
background: #fff;
|
||||
font-family: inherit;
|
||||
min-width: 190px;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--g-navy);
|
||||
box-shadow: 0 0 0 3px rgb(26 35 126 / 12%);
|
||||
}
|
||||
}
|
||||
|
||||
/* Read-only branch shown to a branch-level login in place of the selector. */
|
||||
.glr-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 8px 14px;
|
||||
min-width: 190px;
|
||||
border: 1px solid var(--g-g200);
|
||||
border-radius: 8px;
|
||||
background: var(--g-g100);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--g-g700);
|
||||
}
|
||||
|
||||
/* Day / Month / Range segmented control */
|
||||
.glr-seg {
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--g-g200);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
|
||||
button {
|
||||
padding: 8px 14px;
|
||||
border: none;
|
||||
background: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--g-g500);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: all 0.15s;
|
||||
|
||||
+ button {
|
||||
border-left: 1px solid var(--g-g200);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--g-g50);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--g-navy);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* REPORT SELECTOR */
|
||||
.glr-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--g-g200);
|
||||
border-radius: 10px;
|
||||
padding: 4px;
|
||||
margin-bottom: 16px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.glr-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 9px 14px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--g-g500);
|
||||
cursor: pointer;
|
||||
border-radius: 7px;
|
||||
white-space: nowrap;
|
||||
transition: all 0.15s;
|
||||
font-family: inherit;
|
||||
|
||||
&:hover {
|
||||
color: var(--g-g900);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--g-navy);
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 6px rgb(26 35 126 / 25%);
|
||||
}
|
||||
}
|
||||
|
||||
.glr-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* BALANCE BANNER */
|
||||
.glr-balance {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 16px;
|
||||
border-radius: 10px;
|
||||
background: var(--g-green-l);
|
||||
border: 1px solid #86efac;
|
||||
color: #14532d;
|
||||
font-size: 13px;
|
||||
|
||||
&--bad {
|
||||
background: var(--g-red-l);
|
||||
border-color: #fca5a5;
|
||||
color: #7f1d1d;
|
||||
}
|
||||
}
|
||||
|
||||
.glr-note {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--g-g500);
|
||||
}
|
||||
|
||||
/* KPIs */
|
||||
.glr-kpis {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.glr-kpi {
|
||||
background: #fff;
|
||||
border: 1px solid var(--g-g200);
|
||||
border-left: 4px solid var(--g-g300);
|
||||
border-radius: 12px;
|
||||
padding: 14px 18px;
|
||||
box-shadow: 0 1px 3px rgb(0 0 0 / 5%);
|
||||
|
||||
&--teal {
|
||||
border-left-color: var(--g-teal);
|
||||
}
|
||||
|
||||
&--amber {
|
||||
border-left-color: var(--g-amber);
|
||||
}
|
||||
|
||||
&--green {
|
||||
border-left-color: var(--g-green);
|
||||
}
|
||||
|
||||
&--red {
|
||||
border-left-color: var(--g-red);
|
||||
}
|
||||
}
|
||||
|
||||
.glr-kpi-n {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
font-family: 'DM Mono', monospace;
|
||||
color: var(--g-g900);
|
||||
}
|
||||
|
||||
.glr-kpi-l {
|
||||
font-size: 12px;
|
||||
color: var(--g-g700);
|
||||
font-weight: 600;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.glr-kpi-sub {
|
||||
font-size: 11px;
|
||||
color: var(--g-g500);
|
||||
}
|
||||
|
||||
/* TABLES */
|
||||
.glr-two-col {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.glr-tbl-wrap {
|
||||
background: #fff;
|
||||
border: 1px solid var(--g-g200);
|
||||
border-radius: 12px;
|
||||
overflow: auto;
|
||||
box-shadow: 0 1px 3px rgb(0 0 0 / 4%);
|
||||
|
||||
&--tall {
|
||||
max-height: 70vh;
|
||||
}
|
||||
}
|
||||
|
||||
.glr-tbl-h {
|
||||
background: var(--g-blue-l);
|
||||
color: var(--g-navy);
|
||||
padding: 10px 16px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
border-bottom: 1px solid var(--g-g200);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.glr-tbl {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
padding: 9px 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--g-g500);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
background: var(--g-g50);
|
||||
border-bottom: 1px solid var(--g-g200);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--g-g100);
|
||||
font-size: 13px;
|
||||
color: var(--g-g700);
|
||||
}
|
||||
|
||||
tbody tr:hover td {
|
||||
background: var(--g-g50);
|
||||
}
|
||||
|
||||
tfoot td {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.total-row td {
|
||||
background: var(--g-g100) !important;
|
||||
font-weight: 700;
|
||||
color: var(--g-g900);
|
||||
border-top: 2px solid var(--g-g300);
|
||||
}
|
||||
|
||||
.glr-profit-row td {
|
||||
background: var(--g-green-l) !important;
|
||||
color: #14532d;
|
||||
}
|
||||
|
||||
.glr-loss-row td {
|
||||
background: var(--g-red-l) !important;
|
||||
color: #7f1d1d;
|
||||
}
|
||||
|
||||
.ta-c {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ta-r {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: 'DM Mono', monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--g-g500);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.var-up {
|
||||
color: var(--g-green);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.var-down {
|
||||
color: var(--g-red);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.glr-empty {
|
||||
text-align: center;
|
||||
padding: 34px;
|
||||
color: var(--g-g500);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* A voucher whose debits and credits disagree. */
|
||||
.glr-unbalanced td {
|
||||
background: var(--g-red-l) !important;
|
||||
color: #7f1d1d;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* GL code chips on the mapping report */
|
||||
.glr-gl-chip {
|
||||
display: inline-block;
|
||||
padding: 1px 8px;
|
||||
margin: 1px 3px 1px 0;
|
||||
border-radius: 999px;
|
||||
background: #e0f2fe;
|
||||
color: #075985;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Share bars on the distribution report */
|
||||
.glr-bar-col {
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
.glr-bar {
|
||||
height: 9px;
|
||||
border-radius: 999px;
|
||||
background: var(--g-g100);
|
||||
overflow: hidden;
|
||||
min-width: 110px;
|
||||
}
|
||||
|
||||
.glr-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(90deg, var(--g-navy), var(--g-blue));
|
||||
transition: width 0.25s;
|
||||
}
|
||||
|
||||
/* Account-type pills on the GL Wise report */
|
||||
.type-pill {
|
||||
display: inline-block;
|
||||
padding: 2px 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
background: var(--g-g100);
|
||||
color: var(--g-g700);
|
||||
|
||||
&.type-asset {
|
||||
background: var(--g-blue-l);
|
||||
color: var(--g-blue);
|
||||
}
|
||||
|
||||
&.type-liability {
|
||||
background: var(--g-teal-l);
|
||||
color: var(--g-teal);
|
||||
}
|
||||
|
||||
&.type-income {
|
||||
background: var(--g-green-l);
|
||||
color: var(--g-green);
|
||||
}
|
||||
|
||||
&.type-expense {
|
||||
background: var(--g-amber-l);
|
||||
color: var(--g-amber);
|
||||
}
|
||||
|
||||
&.type-contingent {
|
||||
background: var(--g-red-l);
|
||||
color: var(--g-red);
|
||||
}
|
||||
}
|
||||
|
||||
/* TOAST */
|
||||
.glr-toast {
|
||||
position: fixed;
|
||||
bottom: 32px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(20px);
|
||||
background: var(--g-navy);
|
||||
color: #fff;
|
||||
padding: 12px 22px;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 6px 20px rgb(0 0 0 / 25%);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: all 0.25s;
|
||||
z-index: 1500;
|
||||
|
||||
&.show {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 1200px) {
|
||||
.glr-two-col {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.glr-kpis {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,843 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Derives all seven General Ledger reports from the posting ledger.
|
||||
*
|
||||
* Each report is a pure function of (postings, date range, branch), so changing
|
||||
* the day / month / branch filter genuinely recomputes the figures. Results are
|
||||
* memoised per filter key because the ledger runs to roughly twenty thousand
|
||||
* postings and Angular re-evaluates template getters on every change detection.
|
||||
*/
|
||||
|
||||
import { GL_ACCOUNTS, GL_PERIOD, GlAccount, GlPosting, glAccount, glCustomers, glPostings } from './gl-reports-data';
|
||||
|
||||
export interface GlDateRange {
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
export interface TrialBalanceRow {
|
||||
code: string;
|
||||
name: string;
|
||||
group: string;
|
||||
openingDebit: number;
|
||||
openingCredit: number;
|
||||
periodDebit: number;
|
||||
periodCredit: number;
|
||||
closingDebit: number;
|
||||
closingCredit: number;
|
||||
}
|
||||
|
||||
export interface PnlRow {
|
||||
code: string;
|
||||
name: string;
|
||||
group: string;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
export interface BalanceSheetRow {
|
||||
code: string;
|
||||
name: string;
|
||||
group: string;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
export interface ComparativeRow {
|
||||
code: string;
|
||||
name: string;
|
||||
group: string;
|
||||
side: 'ASSET' | 'LIABILITY';
|
||||
current: number;
|
||||
previous: number;
|
||||
variance: number;
|
||||
variancePct: number | null;
|
||||
}
|
||||
|
||||
export interface GlWiseRow {
|
||||
code: string;
|
||||
name: string;
|
||||
group: string;
|
||||
type: string;
|
||||
debit: number;
|
||||
credit: number;
|
||||
net: number;
|
||||
vouchers: number;
|
||||
}
|
||||
|
||||
export interface MiscExpenseRow {
|
||||
date: string;
|
||||
voucherNo: string;
|
||||
branchCode: string;
|
||||
code: string;
|
||||
name: string;
|
||||
narration: string;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
/** Natural side of an account — assets and expenses sit on the debit side. */
|
||||
function isDebitSide(account: GlAccount): boolean {
|
||||
return account.type === 'ASSET' || account.type === 'EXPENSE' || account.type === 'CONTINGENT';
|
||||
}
|
||||
|
||||
function matchesBranch(p: GlPosting, branchCode: string): boolean {
|
||||
return !branchCode || p.branchCode === branchCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Both scans below walk the whole ~21k-posting ledger, so their results are cached
|
||||
* per (date, branch). Angular re-evaluates template getters on every change-detection
|
||||
* pass; without this a screen with a few hundred mapped lines would spend seconds per
|
||||
* click rescanning the ledger and appear frozen.
|
||||
*/
|
||||
const balanceCache = new Map<string, Map<string, number>>();
|
||||
const turnoverCache = new Map<string, Map<string, { debit: number; credit: number; vouchers: Set<string> }>>();
|
||||
|
||||
/** Signed balance on the account's natural side, for postings up to and including `to`. */
|
||||
function cumulativeBalances(to: string, branchCode: string): Map<string, number> {
|
||||
const key = `${to}|${branchCode}`;
|
||||
const hit = balanceCache.get(key);
|
||||
if (hit) {
|
||||
return hit;
|
||||
}
|
||||
const out = new Map<string, number>();
|
||||
for (const p of glPostings()) {
|
||||
if (p.date > to || !matchesBranch(p, branchCode)) {
|
||||
continue;
|
||||
}
|
||||
const account = glAccount(p.accountCode);
|
||||
if (!account) {
|
||||
continue;
|
||||
}
|
||||
const delta = isDebitSide(account) ? p.debit - p.credit : p.credit - p.debit;
|
||||
out.set(p.accountCode, (out.get(p.accountCode) ?? 0) + delta);
|
||||
}
|
||||
balanceCache.set(key, out);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Debit and credit turnover strictly inside the range. */
|
||||
function periodTurnover(
|
||||
range: GlDateRange,
|
||||
branchCode: string
|
||||
): Map<string, { debit: number; credit: number; vouchers: Set<string> }> {
|
||||
const key = `${range.from}|${range.to}|${branchCode}`;
|
||||
const hit = turnoverCache.get(key);
|
||||
if (hit) {
|
||||
return hit;
|
||||
}
|
||||
const out = new Map<string, { debit: number; credit: number; vouchers: Set<string> }>();
|
||||
for (const p of glPostings()) {
|
||||
if (p.date < range.from || p.date > range.to || !matchesBranch(p, branchCode)) {
|
||||
continue;
|
||||
}
|
||||
let entry = out.get(p.accountCode);
|
||||
if (!entry) {
|
||||
entry = { debit: 0, credit: 0, vouchers: new Set<string>() };
|
||||
out.set(p.accountCode, entry);
|
||||
}
|
||||
entry.debit += p.debit;
|
||||
entry.credit += p.credit;
|
||||
entry.vouchers.add(p.voucherNo);
|
||||
}
|
||||
turnoverCache.set(key, out);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The day before `date`, ISO formatted — used for opening balances. */
|
||||
function previousDay(date: string): string {
|
||||
const d = new Date(`${date}T00:00:00Z`);
|
||||
d.setUTCDate(d.getUTCDate() - 1);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
// ── Reports ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export function trialBalance(range: GlDateRange, branchCode: string): TrialBalanceRow[] {
|
||||
const opening = cumulativeBalances(previousDay(range.from), branchCode);
|
||||
const turnover = periodTurnover(range, branchCode);
|
||||
const rows: TrialBalanceRow[] = [];
|
||||
|
||||
for (const account of GL_ACCOUNTS) {
|
||||
// Contingent accounts are memoranda and never appear in the trial balance.
|
||||
if (account.type === 'CONTINGENT') {
|
||||
continue;
|
||||
}
|
||||
const open = opening.get(account.code) ?? 0;
|
||||
const move = turnover.get(account.code);
|
||||
const periodDebit = move?.debit ?? 0;
|
||||
const periodCredit = move?.credit ?? 0;
|
||||
if (!open && !periodDebit && !periodCredit) {
|
||||
continue;
|
||||
}
|
||||
const debitSide = isDebitSide(account);
|
||||
const closingNatural = open + (debitSide ? periodDebit - periodCredit : periodCredit - periodDebit);
|
||||
const closingSigned = debitSide ? closingNatural : -closingNatural;
|
||||
|
||||
rows.push({
|
||||
code: account.code,
|
||||
name: account.name,
|
||||
group: account.group,
|
||||
openingDebit: debitSide && open > 0 ? open : !debitSide && open < 0 ? -open : 0,
|
||||
openingCredit: !debitSide && open > 0 ? open : debitSide && open < 0 ? -open : 0,
|
||||
periodDebit,
|
||||
periodCredit,
|
||||
closingDebit: closingSigned > 0 ? closingSigned : 0,
|
||||
closingCredit: closingSigned < 0 ? -closingSigned : 0
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function profitAndLoss(range: GlDateRange, branchCode: string): { income: PnlRow[]; expense: PnlRow[] } {
|
||||
const turnover = periodTurnover(range, branchCode);
|
||||
const income: PnlRow[] = [];
|
||||
const expense: PnlRow[] = [];
|
||||
|
||||
for (const account of GL_ACCOUNTS) {
|
||||
if (account.type !== 'INCOME' && account.type !== 'EXPENSE') {
|
||||
continue;
|
||||
}
|
||||
const move = turnover.get(account.code);
|
||||
if (!move) {
|
||||
continue;
|
||||
}
|
||||
const amount = account.type === 'INCOME' ? move.credit - move.debit : move.debit - move.credit;
|
||||
if (!amount) {
|
||||
continue;
|
||||
}
|
||||
const row: PnlRow = { code: account.code, name: account.name, group: account.group, amount };
|
||||
(account.type === 'INCOME' ? income : expense).push(row);
|
||||
}
|
||||
return { income, expense };
|
||||
}
|
||||
|
||||
/**
|
||||
* Balance sheet as at the period end. The current-period surplus is folded in as
|
||||
* a liability line so the two sides tally the way the printed statement does.
|
||||
*/
|
||||
export function balanceSheet(
|
||||
range: GlDateRange,
|
||||
branchCode: string
|
||||
): {
|
||||
assets: BalanceSheetRow[];
|
||||
liabilities: BalanceSheetRow[];
|
||||
surplus: number;
|
||||
} {
|
||||
const closing = cumulativeBalances(range.to, branchCode);
|
||||
const assets: BalanceSheetRow[] = [];
|
||||
const liabilities: BalanceSheetRow[] = [];
|
||||
let income = 0;
|
||||
let expense = 0;
|
||||
|
||||
for (const account of GL_ACCOUNTS) {
|
||||
const bal = closing.get(account.code) ?? 0;
|
||||
if (account.type === 'INCOME') {
|
||||
income += bal;
|
||||
continue;
|
||||
}
|
||||
if (account.type === 'EXPENSE') {
|
||||
expense += bal;
|
||||
continue;
|
||||
}
|
||||
if (account.type === 'CONTINGENT' || !bal) {
|
||||
continue;
|
||||
}
|
||||
const row: BalanceSheetRow = { code: account.code, name: account.name, group: account.group, amount: bal };
|
||||
(account.type === 'ASSET' ? assets : liabilities).push(row);
|
||||
}
|
||||
return { assets, liabilities, surplus: income - expense };
|
||||
}
|
||||
|
||||
/** Current period against the equivalent span immediately before it. */
|
||||
export function comparativeBalanceSheet(range: GlDateRange, branchCode: string): ComparativeRow[] {
|
||||
const priorTo = previousDay(range.from);
|
||||
const current = cumulativeBalances(range.to, branchCode);
|
||||
const previous = cumulativeBalances(priorTo, branchCode);
|
||||
const rows: ComparativeRow[] = [];
|
||||
|
||||
for (const account of GL_ACCOUNTS) {
|
||||
if (account.type !== 'ASSET' && account.type !== 'LIABILITY') {
|
||||
continue;
|
||||
}
|
||||
const cur = current.get(account.code) ?? 0;
|
||||
const prev = previous.get(account.code) ?? 0;
|
||||
if (!cur && !prev) {
|
||||
continue;
|
||||
}
|
||||
const variance = cur - prev;
|
||||
rows.push({
|
||||
code: account.code,
|
||||
name: account.name,
|
||||
group: account.group,
|
||||
side: account.type,
|
||||
current: cur,
|
||||
previous: prev,
|
||||
variance,
|
||||
variancePct: prev ? (variance / Math.abs(prev)) * 100 : null
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function contingentLiabilities(range: GlDateRange, branchCode: string): BalanceSheetRow[] {
|
||||
const closing = cumulativeBalances(range.to, branchCode);
|
||||
return GL_ACCOUNTS.filter((a) => a.type === 'CONTINGENT')
|
||||
.map((a) => ({ code: a.code, name: a.name, group: a.group, amount: closing.get(a.code) ?? 0 }))
|
||||
.filter((r) => r.amount !== 0);
|
||||
}
|
||||
|
||||
export function glWiseDrCr(range: GlDateRange, branchCode: string): GlWiseRow[] {
|
||||
const turnover = periodTurnover(range, branchCode);
|
||||
const rows: GlWiseRow[] = [];
|
||||
for (const account of GL_ACCOUNTS) {
|
||||
const move = turnover.get(account.code);
|
||||
if (!move || (!move.debit && !move.credit)) {
|
||||
continue;
|
||||
}
|
||||
rows.push({
|
||||
code: account.code,
|
||||
name: account.name,
|
||||
group: account.group,
|
||||
type: account.type,
|
||||
debit: move.debit,
|
||||
credit: move.credit,
|
||||
net: move.debit - move.credit,
|
||||
vouchers: move.vouchers.size
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function miscExpenseLedger(range: GlDateRange, branchCode: string): MiscExpenseRow[] {
|
||||
const miscCodes = new Set(GL_ACCOUNTS.filter((a) => a.miscExpense).map((a) => a.code));
|
||||
const rows: MiscExpenseRow[] = [];
|
||||
for (const p of glPostings()) {
|
||||
if (p.date < range.from || p.date > range.to || !matchesBranch(p, branchCode)) {
|
||||
continue;
|
||||
}
|
||||
if (!miscCodes.has(p.accountCode) || !p.debit) {
|
||||
continue;
|
||||
}
|
||||
rows.push({
|
||||
date: p.date,
|
||||
voucherNo: p.voucherNo,
|
||||
branchCode: p.branchCode,
|
||||
code: p.accountCode,
|
||||
name: glAccount(p.accountCode)?.name ?? p.accountCode,
|
||||
narration: p.narration,
|
||||
amount: p.debit
|
||||
});
|
||||
}
|
||||
return rows.sort((a, b) =>
|
||||
a.date === b.date ? a.voucherNo.localeCompare(b.voucherNo) : a.date.localeCompare(b.date)
|
||||
);
|
||||
}
|
||||
|
||||
export interface AverageBalanceRow {
|
||||
code: string;
|
||||
name: string;
|
||||
group: string;
|
||||
type: string;
|
||||
opening: number;
|
||||
closing: number;
|
||||
average: number;
|
||||
peak: number;
|
||||
lowest: number;
|
||||
/** Days on which the account moved. */
|
||||
activeDays: number;
|
||||
}
|
||||
|
||||
export interface AccountBranchRow {
|
||||
code: string;
|
||||
name: string;
|
||||
group: string;
|
||||
/** Closing balance per branch code, plus a `total`. */
|
||||
byBranch: Record<string, number>;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface DistributionRow {
|
||||
group: string;
|
||||
amount: number;
|
||||
share: number;
|
||||
accounts: number;
|
||||
}
|
||||
|
||||
/** Calendar days in the range, inclusive. */
|
||||
function daysInRange(range: GlDateRange): string[] {
|
||||
const out: string[] = [];
|
||||
const cur = new Date(`${range.from}T00:00:00Z`);
|
||||
const end = new Date(`${range.to}T00:00:00Z`);
|
||||
while (cur <= end) {
|
||||
out.push(cur.toISOString().slice(0, 10));
|
||||
cur.setUTCDate(cur.getUTCDate() + 1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Average daily balance per account across the period, plus the peak and lowest
|
||||
* balance touched. Banks use average balances rather than closing snapshots for
|
||||
* interest, CRR/SLR and profitability work, because a closing figure can be
|
||||
* window-dressed on the last day.
|
||||
*
|
||||
* Computed by carrying a running balance forward day by day rather than
|
||||
* recomputing a cumulative balance per day, which would be quadratic.
|
||||
*/
|
||||
export function averageBalances(range: GlDateRange, branchCode: string): AverageBalanceRow[] {
|
||||
const opening = cumulativeBalances(previousDay(range.from), branchCode);
|
||||
const days = daysInRange(range);
|
||||
|
||||
// Net movement per account per day, on the account's natural side.
|
||||
const dailyDelta = new Map<string, Map<string, number>>();
|
||||
const movedOn = new Map<string, Set<string>>();
|
||||
for (const p of glPostings()) {
|
||||
if (p.date < range.from || p.date > range.to || !matchesBranch(p, branchCode)) {
|
||||
continue;
|
||||
}
|
||||
const account = glAccount(p.accountCode);
|
||||
if (!account) {
|
||||
continue;
|
||||
}
|
||||
const delta = isDebitSide(account) ? p.debit - p.credit : p.credit - p.debit;
|
||||
let perDay = dailyDelta.get(p.accountCode);
|
||||
if (!perDay) {
|
||||
perDay = new Map<string, number>();
|
||||
dailyDelta.set(p.accountCode, perDay);
|
||||
}
|
||||
perDay.set(p.date, (perDay.get(p.date) ?? 0) + delta);
|
||||
let seen = movedOn.get(p.accountCode);
|
||||
if (!seen) {
|
||||
seen = new Set<string>();
|
||||
movedOn.set(p.accountCode, seen);
|
||||
}
|
||||
seen.add(p.date);
|
||||
}
|
||||
|
||||
const rows: AverageBalanceRow[] = [];
|
||||
for (const account of GL_ACCOUNTS) {
|
||||
const open = opening.get(account.code) ?? 0;
|
||||
const perDay = dailyDelta.get(account.code);
|
||||
if (!open && !perDay) {
|
||||
continue;
|
||||
}
|
||||
let running = open;
|
||||
let total = 0;
|
||||
let peak = open;
|
||||
let lowest = open;
|
||||
for (const d of days) {
|
||||
running += perDay?.get(d) ?? 0;
|
||||
total += running;
|
||||
peak = Math.max(peak, running);
|
||||
lowest = Math.min(lowest, running);
|
||||
}
|
||||
rows.push({
|
||||
code: account.code,
|
||||
name: account.name,
|
||||
group: account.group,
|
||||
type: account.type,
|
||||
opening: open,
|
||||
closing: running,
|
||||
average: days.length ? total / days.length : 0,
|
||||
peak,
|
||||
lowest,
|
||||
activeDays: movedOn.get(account.code)?.size ?? 0
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Closing balance of every account broken out by branch. Answers "which branch is
|
||||
* carrying this balance", which a single consolidated column cannot.
|
||||
*/
|
||||
export function accountWiseByBranch(range: GlDateRange, branchCodes: string[]): AccountBranchRow[] {
|
||||
const perBranch = new Map<string, Map<string, number>>();
|
||||
for (const b of branchCodes) {
|
||||
perBranch.set(b, cumulativeBalances(range.to, b));
|
||||
}
|
||||
const rows: AccountBranchRow[] = [];
|
||||
for (const account of GL_ACCOUNTS) {
|
||||
const byBranch: Record<string, number> = {};
|
||||
let total = 0;
|
||||
let any = false;
|
||||
for (const b of branchCodes) {
|
||||
const v = perBranch.get(b)?.get(account.code) ?? 0;
|
||||
byBranch[b] = v;
|
||||
total += v;
|
||||
if (v) {
|
||||
any = true;
|
||||
}
|
||||
}
|
||||
if (!any) {
|
||||
continue;
|
||||
}
|
||||
rows.push({ code: account.code, name: account.name, group: account.group, byBranch, total });
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Share of the closing book held by each account group, for one side of the
|
||||
* balance sheet. Used to see concentration at a glance.
|
||||
*/
|
||||
export function balanceDistribution(
|
||||
range: GlDateRange,
|
||||
branchCode: string,
|
||||
side: 'ASSET' | 'LIABILITY'
|
||||
): DistributionRow[] {
|
||||
const closing = cumulativeBalances(range.to, branchCode);
|
||||
const byGroup = new Map<string, { amount: number; accounts: number }>();
|
||||
let grand = 0;
|
||||
|
||||
for (const account of GL_ACCOUNTS) {
|
||||
if (account.type !== side) {
|
||||
continue;
|
||||
}
|
||||
const v = closing.get(account.code) ?? 0;
|
||||
if (!v) {
|
||||
continue;
|
||||
}
|
||||
const entry = byGroup.get(account.group) ?? { amount: 0, accounts: 0 };
|
||||
entry.amount += v;
|
||||
entry.accounts += 1;
|
||||
byGroup.set(account.group, entry);
|
||||
grand += v;
|
||||
}
|
||||
|
||||
return [...byGroup.entries()]
|
||||
.map(
|
||||
([
|
||||
group,
|
||||
e
|
||||
]) => ({
|
||||
group,
|
||||
amount: e.amount,
|
||||
accounts: e.accounts,
|
||||
share: grand ? (e.amount / grand) * 100 : 0
|
||||
})
|
||||
)
|
||||
.sort((a, b) => b.amount - a.amount);
|
||||
}
|
||||
|
||||
export interface ClientBalanceRow {
|
||||
cif: string;
|
||||
name: string;
|
||||
branchCode: string;
|
||||
accountNo: string;
|
||||
accountCode: string;
|
||||
product: string;
|
||||
/** DEPOSIT or ADVANCE — which side of the relationship this is. */
|
||||
side: 'DEPOSIT' | 'ADVANCE';
|
||||
balance: number;
|
||||
}
|
||||
|
||||
export interface BankerHeadRow {
|
||||
head: string;
|
||||
opening: number;
|
||||
debit: number;
|
||||
credit: number;
|
||||
closing: number;
|
||||
accounts: number;
|
||||
}
|
||||
|
||||
export interface SundryItemRow {
|
||||
date: string;
|
||||
voucherNo: string;
|
||||
branchCode: string;
|
||||
code: string;
|
||||
name: string;
|
||||
narration: string;
|
||||
amount: number;
|
||||
/** Days outstanding as at the period end. */
|
||||
ageDays: number;
|
||||
bucket: string;
|
||||
}
|
||||
|
||||
export interface VoucherRow {
|
||||
voucherNo: string;
|
||||
date: string;
|
||||
branchCode: string;
|
||||
narration: string;
|
||||
debitAccounts: string;
|
||||
creditAccounts: string;
|
||||
amount: number;
|
||||
legs: number;
|
||||
/** False when debits and credits on the voucher do not agree. */
|
||||
balanced: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-wise balances, derived from the GL rather than stored separately so the two
|
||||
* can never disagree. Each customer's balance is its share of the branch's closing
|
||||
* balance in the head it sits under.
|
||||
*/
|
||||
export function clientWiseBalances(range: GlDateRange, branchCode: string): ClientBalanceRow[] {
|
||||
const rows: ClientBalanceRow[] = [];
|
||||
const branchClosings = new Map<string, Map<string, number>>();
|
||||
|
||||
for (const c of glCustomers()) {
|
||||
if (branchCode && c.branchCode !== branchCode) {
|
||||
continue;
|
||||
}
|
||||
let closing = branchClosings.get(c.branchCode);
|
||||
if (!closing) {
|
||||
closing = cumulativeBalances(range.to, c.branchCode);
|
||||
branchClosings.set(c.branchCode, closing);
|
||||
}
|
||||
const headBalance = closing.get(c.accountCode) ?? 0;
|
||||
const balance = headBalance * c.share;
|
||||
if (Math.abs(balance) < 0.005) {
|
||||
continue;
|
||||
}
|
||||
const account = glAccount(c.accountCode);
|
||||
rows.push({
|
||||
cif: c.cif,
|
||||
name: c.name,
|
||||
branchCode: c.branchCode,
|
||||
accountNo: c.accountNo,
|
||||
accountCode: c.accountCode,
|
||||
product: account?.name ?? c.accountCode,
|
||||
side: c.accountCode.startsWith('13') ? 'ADVANCE' : 'DEPOSIT',
|
||||
balance
|
||||
});
|
||||
}
|
||||
return rows.sort((a, b) => b.balance - a.balance);
|
||||
}
|
||||
|
||||
/**
|
||||
* Head-wise abstract: the trial balance rolled up to principal heads, the summary a
|
||||
* banker reads before the detail. Opening, turnover and closing per head.
|
||||
*/
|
||||
export function bankerHeadSummary(range: GlDateRange, branchCode: string): BankerHeadRow[] {
|
||||
const opening = cumulativeBalances(previousDay(range.from), branchCode);
|
||||
const turnover = periodTurnover(range, branchCode);
|
||||
const byHead = new Map<string, BankerHeadRow>();
|
||||
|
||||
for (const account of GL_ACCOUNTS) {
|
||||
// Contingents are single-sided off-balance-sheet memoranda. Including them would
|
||||
// make the abstract's debit and credit turnover disagree and stop it reconciling
|
||||
// to the trial balance, which also excludes them. They have their own report.
|
||||
if (account.type === 'CONTINGENT') {
|
||||
continue;
|
||||
}
|
||||
const open = opening.get(account.code) ?? 0;
|
||||
const move = turnover.get(account.code);
|
||||
if (!open && !move) {
|
||||
continue;
|
||||
}
|
||||
const debit = move?.debit ?? 0;
|
||||
const credit = move?.credit ?? 0;
|
||||
const closing = open + (isDebitSide(account) ? debit - credit : credit - debit);
|
||||
|
||||
const row = byHead.get(account.group) ?? {
|
||||
head: account.group,
|
||||
opening: 0,
|
||||
debit: 0,
|
||||
credit: 0,
|
||||
closing: 0,
|
||||
accounts: 0
|
||||
};
|
||||
row.opening += open;
|
||||
row.debit += debit;
|
||||
row.credit += credit;
|
||||
row.closing += closing;
|
||||
row.accounts += 1;
|
||||
byHead.set(account.group, row);
|
||||
}
|
||||
return [...byHead.values()];
|
||||
}
|
||||
|
||||
const SUNDRY_CODES = [
|
||||
'140104',
|
||||
'230103',
|
||||
'140199',
|
||||
'230102'
|
||||
];
|
||||
|
||||
/**
|
||||
* Outstanding items in the sundry and suspense heads, aged from the posting date.
|
||||
* These accounts are where unreconciled entries accumulate, so ageing is the point
|
||||
* of the register.
|
||||
*/
|
||||
export function sundryRegister(range: GlDateRange, branchCode: string): SundryItemRow[] {
|
||||
const wanted = new Set(SUNDRY_CODES);
|
||||
const end = new Date(`${range.to}T00:00:00Z`).getTime();
|
||||
const rows: SundryItemRow[] = [];
|
||||
|
||||
for (const p of glPostings()) {
|
||||
if (p.date > range.to || !matchesBranch(p, branchCode) || !wanted.has(p.accountCode)) {
|
||||
continue;
|
||||
}
|
||||
const ageDays = Math.max(0, Math.round((end - new Date(`${p.date}T00:00:00Z`).getTime()) / 86400000));
|
||||
const bucket =
|
||||
ageDays <= 30
|
||||
? 'Up to 30 days'
|
||||
: ageDays <= 90
|
||||
? '31 to 90 days'
|
||||
: ageDays <= 180
|
||||
? '91 to 180 days'
|
||||
: 'Over 180 days';
|
||||
rows.push({
|
||||
date: p.date,
|
||||
voucherNo: p.voucherNo,
|
||||
branchCode: p.branchCode,
|
||||
code: p.accountCode,
|
||||
name: glAccount(p.accountCode)?.name ?? p.accountCode,
|
||||
narration: p.narration,
|
||||
amount: p.debit || p.credit,
|
||||
ageDays,
|
||||
bucket
|
||||
});
|
||||
}
|
||||
return rows.sort((a, b) => b.ageDays - a.ageDays);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every voucher raised in the period with its debit and credit legs collapsed onto
|
||||
* one line, plus a per-voucher double-entry check.
|
||||
*/
|
||||
export function voucherRegister(range: GlDateRange, branchCode: string): VoucherRow[] {
|
||||
const grouped = new Map<string, GlPosting[]>();
|
||||
for (const p of glPostings()) {
|
||||
if (p.date < range.from || p.date > range.to || !matchesBranch(p, branchCode)) {
|
||||
continue;
|
||||
}
|
||||
const list = grouped.get(p.voucherNo);
|
||||
if (list) {
|
||||
list.push(p);
|
||||
} else {
|
||||
grouped.set(p.voucherNo, [p]);
|
||||
}
|
||||
}
|
||||
|
||||
const rows: VoucherRow[] = [];
|
||||
for (const [
|
||||
voucherNo,
|
||||
legs
|
||||
] of grouped) {
|
||||
const debits = legs.filter((l) => l.debit > 0);
|
||||
const credits = legs.filter((l) => l.credit > 0);
|
||||
const drTotal = debits.reduce((s, l) => s + l.debit, 0);
|
||||
const crTotal = credits.reduce((s, l) => s + l.credit, 0);
|
||||
const nameOf = (codes: GlPosting[], side: 'debit' | 'credit') =>
|
||||
[...new Set(codes.map((l) => `${l.accountCode} ${glAccount(l.accountCode)?.name ?? ''}`.trim()))].join(', ');
|
||||
|
||||
rows.push({
|
||||
voucherNo,
|
||||
date: legs[0].date,
|
||||
branchCode: legs[0].branchCode,
|
||||
narration: legs[0].narration,
|
||||
debitAccounts: nameOf(debits, 'debit') || '—',
|
||||
creditAccounts: nameOf(credits, 'credit') || '—',
|
||||
amount: Math.max(drTotal, crTotal),
|
||||
legs: legs.length,
|
||||
// Contingent and opening memoranda are single-sided by design.
|
||||
balanced: voucherNo.startsWith('CNT/') || voucherNo.startsWith('OB/') || Math.round(drTotal - crTotal) === 0
|
||||
});
|
||||
}
|
||||
return rows.sort((a, b) =>
|
||||
a.date === b.date ? a.voucherNo.localeCompare(b.voucherNo) : a.date.localeCompare(b.date)
|
||||
);
|
||||
}
|
||||
|
||||
// ── Memoisation ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface GlReportSet {
|
||||
trialBalance: TrialBalanceRow[];
|
||||
pnl: { income: PnlRow[]; expense: PnlRow[] };
|
||||
balanceSheet: { assets: BalanceSheetRow[]; liabilities: BalanceSheetRow[]; surplus: number };
|
||||
comparative: ComparativeRow[];
|
||||
contingent: BalanceSheetRow[];
|
||||
glWise: GlWiseRow[];
|
||||
miscExpense: MiscExpenseRow[];
|
||||
}
|
||||
|
||||
let cacheKey = '';
|
||||
let cached: GlReportSet | null = null;
|
||||
|
||||
/** All seven reports for one filter selection, computed once per selection. */
|
||||
export function buildReports(range: GlDateRange, branchCode: string): GlReportSet {
|
||||
const key = `${range.from}|${range.to}|${branchCode}`;
|
||||
if (cached && cacheKey === key) {
|
||||
return cached;
|
||||
}
|
||||
cached = {
|
||||
trialBalance: trialBalance(range, branchCode),
|
||||
pnl: profitAndLoss(range, branchCode),
|
||||
balanceSheet: balanceSheet(range, branchCode),
|
||||
comparative: comparativeBalanceSheet(range, branchCode),
|
||||
contingent: contingentLiabilities(range, branchCode),
|
||||
glWise: glWiseDrCr(range, branchCode),
|
||||
miscExpense: miscExpenseLedger(range, branchCode)
|
||||
};
|
||||
cacheKey = key;
|
||||
return cached;
|
||||
}
|
||||
|
||||
/** Clamp a range to the generated ledger so filters cannot query empty space. */
|
||||
export function clampToLedger(range: GlDateRange): GlDateRange {
|
||||
return {
|
||||
from: range.from < GL_PERIOD.fyStart ? GL_PERIOD.fyStart : range.from,
|
||||
to: range.to > GL_PERIOD.fyEnd ? GL_PERIOD.fyEnd : range.to
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Value of a set of GL accounts for a period, used when an RBI return line item is
|
||||
* mapped to the chart of accounts.
|
||||
*
|
||||
* Balance-sheet accounts contribute their closing balance as at the period end;
|
||||
* income and expenditure accounts contribute their movement within the period.
|
||||
* That mirrors how the two kinds of figure are read on a return.
|
||||
*/
|
||||
export function glValueForCodes(codes: string[], range: GlDateRange, branchCode = ''): number {
|
||||
if (!codes.length) {
|
||||
return 0;
|
||||
}
|
||||
const wanted = new Set(codes);
|
||||
const closing = cumulativeBalances(range.to, branchCode);
|
||||
const movement = periodTurnover(range, branchCode);
|
||||
let total = 0;
|
||||
|
||||
for (const code of wanted) {
|
||||
const account = glAccount(code);
|
||||
if (!account) {
|
||||
continue;
|
||||
}
|
||||
if (account.type === 'INCOME' || account.type === 'EXPENSE') {
|
||||
const move = movement.get(code);
|
||||
if (!move) {
|
||||
continue;
|
||||
}
|
||||
total += account.type === 'INCOME' ? move.credit - move.debit : move.debit - move.credit;
|
||||
} else {
|
||||
total += closing.get(code) ?? 0;
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
export function sum<T>(rows: T[], pick: (row: T) => number): number {
|
||||
return rows.reduce((acc, r) => acc + pick(r), 0);
|
||||
}
|
||||
|
||||
/** Groups rows by their `group` field, preserving chart-of-accounts order. */
|
||||
export function groupBy<T extends { group: string }>(rows: T[]): { group: string; rows: T[]; total: number }[] {
|
||||
const order: string[] = [];
|
||||
const map = new Map<string, T[]>();
|
||||
for (const r of rows) {
|
||||
if (!map.has(r.group)) {
|
||||
map.set(r.group, []);
|
||||
order.push(r.group);
|
||||
}
|
||||
map.get(r.group)!.push(r);
|
||||
}
|
||||
return order.map((g) => ({ group: g, rows: map.get(g)!, total: 0 }));
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* 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 { NgModule } from '@angular/core';
|
||||
|
||||
import { SharedModule } from '../shared/shared.module';
|
||||
import { GlReportsRoutingModule } from './gl-reports-routing.module';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
SharedModule,
|
||||
GlReportsRoutingModule
|
||||
]
|
||||
})
|
||||
export class GlReportsModule {}
|
||||
+58
@@ -397,6 +397,64 @@
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<!-- ════════════ CONTINGENT LIABILITIES ════════════ -->
|
||||
<ng-container *ngIf="activeReport === 'CONTINGENT-LIAB'">
|
||||
<div class="acr-stats">
|
||||
<div class="acr-stat-card alert">
|
||||
<div class="acr-stat-l">Total Contingent Liabilities — {{ bank.fiscalYear }}</div>
|
||||
<div class="acr-stat-v">₹ {{ contingentLiab.current | number: '1.2-2' }} Lakh</div>
|
||||
<div class="acr-stat-meta">vs {{ contingentLiab.previous | number: '1.2-2' }} Lakh (PY)</div>
|
||||
</div>
|
||||
<div class="acr-stat-card" [ngClass]="clVariation <= 0 ? 'good' : 'alert'">
|
||||
<div class="acr-stat-l">Variation over Previous Year</div>
|
||||
<div class="acr-stat-v">{{ clVariation >= 0 ? '+' : '' }}{{ clVariation | number: '1.2-2' }} Lakh</div>
|
||||
<div class="acr-stat-meta">{{ clVariationPct >= 0 ? '+' : '' }}{{ clVariationPct.toFixed(2) }}%</div>
|
||||
</div>
|
||||
<div class="acr-stat-card">
|
||||
<div class="acr-stat-l">As On Date</div>
|
||||
<div class="acr-stat-v" style="font-size: 16px">{{ bank.asOnDate }}</div>
|
||||
<div class="acr-stat-meta">Schedule 12</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="acr-section">
|
||||
<div class="acr-sec-h">
|
||||
<h3 class="acr-sec-t">Contingent Liabilities — As on {{ bank.asOnDate }} (₹ in Lakhs)</h3>
|
||||
<button mat-stroked-button class="acr-btn-xls" (click)="exportContingentLiabCsv()">
|
||||
<fa-icon icon="file-csv"></fa-icon> Export CSV
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="acr-tbl-wrap">
|
||||
<table class="acr-tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th colspan="2" class="acr-sub-h">Items Covered Under This Schedule</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let item of contingentLiabItems">
|
||||
<td class="mono">{{ item.code }}</td>
|
||||
<td>{{ item.label }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="row-total expense">
|
||||
<td colspan="2">
|
||||
<strong>Total Contingent Liabilities: ₹ {{ contingentLiab.current | number: '1.2-2' }} Lakh</strong>
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
<p class="acr-note">
|
||||
<fa-icon icon="info-circle"></fa-icon> Individual item-wise figures for this schedule are not broken
|
||||
out here — only the audited total is shown, to avoid presenting a sub-item split that could not be
|
||||
independently verified against the source statement.
|
||||
</p>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<!-- ════════════ GL DR/CR ════════════ -->
|
||||
<ng-container *ngIf="activeReport === 'GL-DR-CR'">
|
||||
<div class="acr-stats">
|
||||
|
||||
+10
@@ -318,6 +318,16 @@
|
||||
border-bottom: 1px dashed var(--acr-g200);
|
||||
}
|
||||
|
||||
.acr-note {
|
||||
margin: 12px 0 0;
|
||||
font-size: 12px;
|
||||
font-style: italic;
|
||||
color: var(--acr-g500);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.acr-btn-xls {
|
||||
font-size: 12px !important;
|
||||
height: 34px !important;
|
||||
|
||||
+157
-218
@@ -14,7 +14,7 @@ import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
|
||||
export type AcctReportCode = 'PNL' | 'BALANCE-SHEET' | 'COMPARATIVE-BS' | 'GL-DR-CR' | 'MISC-EXP';
|
||||
export type AcctReportCode = 'PNL' | 'BALANCE-SHEET' | 'COMPARATIVE-BS' | 'CONTINGENT-LIAB' | 'GL-DR-CR' | 'MISC-EXP';
|
||||
|
||||
export interface AcctReportsDialogData {
|
||||
initialReport?: AcctReportCode;
|
||||
@@ -47,6 +47,11 @@ interface GlLine {
|
||||
closing: number;
|
||||
}
|
||||
|
||||
interface ContingentLiabItem {
|
||||
code: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface MiscExpRow {
|
||||
txnDate: string;
|
||||
branch: string;
|
||||
@@ -81,7 +86,7 @@ export class AccountingReportsDialogComponent {
|
||||
activeReport: AcctReportCode;
|
||||
|
||||
bank = {
|
||||
name: 'Bharat Urban Bank Ltd',
|
||||
name: 'CBSNexora Bank Ltd',
|
||||
branch: 'Head Office',
|
||||
asOnDate: '31-03-2026',
|
||||
period: '01-04-2025 to 31-03-2026',
|
||||
@@ -94,7 +99,7 @@ export class AccountingReportsDialogComponent {
|
||||
poDesignation: 'Chief Finance Officer',
|
||||
auditor: 'M/s Sharma & Associates, Chartered Accountants',
|
||||
reportRefNo: '157/2',
|
||||
userCode: 'BUBL01',
|
||||
userCode: 'CBSN01',
|
||||
branchCount: 15
|
||||
};
|
||||
|
||||
@@ -120,6 +125,13 @@ export class AccountingReportsDialogComponent {
|
||||
description: 'Side-by-side comparison of current vs previous fiscal year.',
|
||||
group: 'Financial Statement'
|
||||
},
|
||||
{
|
||||
code: 'CONTINGENT-LIAB' as AcctReportCode,
|
||||
name: 'Contingent Liabilities',
|
||||
icon: 'exclamation-triangle',
|
||||
description: 'Off-balance-sheet claims, guarantees & contingent exposures (Schedule 12).',
|
||||
group: 'Financial Statement'
|
||||
},
|
||||
{
|
||||
code: 'GL-DR-CR' as AcctReportCode,
|
||||
name: 'GL Wise DR/CR',
|
||||
@@ -136,309 +148,192 @@ export class AccountingReportsDialogComponent {
|
||||
}
|
||||
];
|
||||
|
||||
// -------------------- P&L Data (₹ in Lakhs) --------------------
|
||||
// -------------------- P&L Data (₹ in Lakhs) — FY 2025-26 audited figures --------------------
|
||||
pnl: PnlLine[] = [
|
||||
{ code: 'I-01', group: 'INCOME', subgroup: 'Interest Earned', label: 'Interest on Advances', amount: 38420 },
|
||||
{ code: 'I-02', group: 'INCOME', subgroup: 'Interest Earned', label: 'Interest on Investments', amount: 14860 },
|
||||
{ code: 'I-01', group: 'INCOME', subgroup: 'Interest Earned', label: 'Interest on Advances & Bills', amount: 102641.73 },
|
||||
{ code: 'I-02', group: 'INCOME', subgroup: 'Interest Earned', label: 'Income on Investments', amount: 41936.75 },
|
||||
{
|
||||
code: 'I-03',
|
||||
group: 'INCOME',
|
||||
subgroup: 'Interest Earned',
|
||||
label: 'Interest on Balances with RBI / Other Banks',
|
||||
amount: 1240
|
||||
},
|
||||
{
|
||||
code: 'I-04',
|
||||
group: 'INCOME',
|
||||
subgroup: 'Other Income',
|
||||
label: 'Commission, Exchange & Brokerage',
|
||||
amount: 1820
|
||||
amount: 144.2
|
||||
},
|
||||
{ code: 'I-04', group: 'INCOME', subgroup: 'Other Income', label: 'Dividend Income', amount: 4.11 },
|
||||
{
|
||||
code: 'I-05',
|
||||
group: 'INCOME',
|
||||
subgroup: 'Other Income',
|
||||
label: 'Net Income on Sale of Fixed Assets',
|
||||
amount: 0.3
|
||||
},
|
||||
{ code: 'I-06', group: 'INCOME', subgroup: 'Other Income', label: 'Other Income (ATM/POS/UPI/Rental/Misc)', amount: 280 },
|
||||
{
|
||||
code: 'E-01',
|
||||
group: 'EXPENDITURE',
|
||||
subgroup: 'Interest Expended',
|
||||
label: 'Interest Paid on Deposits & Other Borrowings',
|
||||
amount: 62861.8
|
||||
},
|
||||
{ code: 'I-05', group: 'INCOME', subgroup: 'Other Income', label: 'Profit on Sale of Investments', amount: 480 },
|
||||
{ code: 'I-06', group: 'INCOME', subgroup: 'Other Income', label: 'Dividend Income', amount: 142 },
|
||||
{ code: 'I-07', group: 'INCOME', subgroup: 'Other Income', label: 'Miscellaneous Income', amount: 318 },
|
||||
{ code: 'E-01', group: 'EXPENDITURE', subgroup: 'Interest Expended', label: 'Interest on Deposits', amount: 28640 },
|
||||
{
|
||||
code: 'E-02',
|
||||
group: 'EXPENDITURE',
|
||||
subgroup: 'Interest Expended',
|
||||
label: 'Interest on RBI / Inter-Bank Borrowings',
|
||||
amount: 980
|
||||
label: 'Interest Paid on NABARD Borrowings',
|
||||
amount: 47083.71
|
||||
},
|
||||
{
|
||||
code: 'E-03',
|
||||
group: 'EXPENDITURE',
|
||||
subgroup: 'Operating Expenses',
|
||||
label: 'Payments to & Provisions for Employees',
|
||||
amount: 8420
|
||||
label: 'Operating Expenses (Staff, Admin, Establishment)',
|
||||
amount: 14503.79
|
||||
},
|
||||
{
|
||||
code: 'E-04',
|
||||
group: 'EXPENDITURE',
|
||||
subgroup: 'Operating Expenses',
|
||||
label: 'Rent, Taxes & Lighting',
|
||||
amount: 1240
|
||||
subgroup: 'Provisions',
|
||||
label: 'Provision for Standard Assets',
|
||||
amount: 730
|
||||
},
|
||||
{
|
||||
code: 'E-05',
|
||||
group: 'EXPENDITURE',
|
||||
subgroup: 'Provisions',
|
||||
label: 'Provision for Doubtful & Bad Debts (Loans & Advances)',
|
||||
amount: 2068.2
|
||||
},
|
||||
{ code: 'E-05', group: 'EXPENDITURE', subgroup: 'Operating Expenses', label: 'Printing & Stationery', amount: 184 },
|
||||
{
|
||||
code: 'E-06',
|
||||
group: 'EXPENDITURE',
|
||||
subgroup: 'Operating Expenses',
|
||||
label: 'Depreciation on Bank Property',
|
||||
amount: 642
|
||||
subgroup: 'Provisions',
|
||||
label: 'Bad Debts Written Off',
|
||||
amount: 906.44
|
||||
},
|
||||
{
|
||||
code: 'E-07',
|
||||
group: 'EXPENDITURE',
|
||||
subgroup: 'Operating Expenses',
|
||||
label: 'Auditors Fees & Expenses',
|
||||
amount: 124
|
||||
},
|
||||
{
|
||||
code: 'E-08',
|
||||
group: 'EXPENDITURE',
|
||||
subgroup: 'Operating Expenses',
|
||||
label: 'Repairs, Maintenance & Insurance',
|
||||
amount: 386
|
||||
},
|
||||
{
|
||||
code: 'E-09',
|
||||
group: 'EXPENDITURE',
|
||||
subgroup: 'Operating Expenses',
|
||||
label: 'Other Operating Expenses',
|
||||
amount: 824
|
||||
},
|
||||
{
|
||||
code: 'E-10',
|
||||
group: 'EXPENDITURE',
|
||||
subgroup: 'Provisions',
|
||||
label: 'Provisions for NPA / Standard Assets',
|
||||
amount: 4280
|
||||
label: 'Provision for Staff Training Reserve',
|
||||
amount: 245.06
|
||||
},
|
||||
{ code: 'E-11', group: 'EXPENDITURE', subgroup: 'Provisions', label: 'Provisions for Tax', amount: 2120 }
|
||||
{ code: 'E-08', group: 'EXPENDITURE', subgroup: 'Provisions', label: 'Provision for Income Tax', amount: 4600 }
|
||||
];
|
||||
|
||||
// -------------------- Balance Sheet (₹ in Lakhs) --------------------
|
||||
// -------------------- Balance Sheet (₹ in Lakhs) — as on 31-03-2026, audited --------------------
|
||||
bs: BsLine[] = [
|
||||
{
|
||||
code: 'L-01',
|
||||
side: 'CAPITAL-LIAB',
|
||||
group: 'Capital & Reserves',
|
||||
label: 'Capital (Paid-up Share Capital)',
|
||||
amount: 22000,
|
||||
prevAmount: 20800
|
||||
amount: 92837.18,
|
||||
prevAmount: 88842.87
|
||||
},
|
||||
{
|
||||
code: 'L-02',
|
||||
side: 'CAPITAL-LIAB',
|
||||
group: 'Capital & Reserves',
|
||||
label: 'Reserves & Surplus',
|
||||
amount: 32400,
|
||||
prevAmount: 28640
|
||||
amount: 155847.97,
|
||||
prevAmount: 145524.37
|
||||
},
|
||||
{
|
||||
code: 'L-03',
|
||||
side: 'CAPITAL-LIAB',
|
||||
group: 'Deposits',
|
||||
label: 'Demand Deposits — Current Accounts',
|
||||
amount: 84200,
|
||||
prevAmount: 78420
|
||||
label: 'Deposits',
|
||||
amount: 1154054.96,
|
||||
prevAmount: 997239.51
|
||||
},
|
||||
{
|
||||
code: 'L-04',
|
||||
side: 'CAPITAL-LIAB',
|
||||
group: 'Deposits',
|
||||
label: 'Savings Bank Deposits',
|
||||
amount: 218600,
|
||||
prevAmount: 198400
|
||||
group: 'Borrowings',
|
||||
label: 'Borrowings (NABARD / Other Banks)',
|
||||
amount: 1113954.48,
|
||||
prevAmount: 993301.8
|
||||
},
|
||||
{
|
||||
code: 'L-05',
|
||||
side: 'CAPITAL-LIAB',
|
||||
group: 'Deposits',
|
||||
label: 'Term Deposits',
|
||||
amount: 462800,
|
||||
prevAmount: 412400
|
||||
},
|
||||
{
|
||||
code: 'L-06',
|
||||
side: 'CAPITAL-LIAB',
|
||||
group: 'Borrowings',
|
||||
label: 'Borrowings from RBI / NABARD',
|
||||
amount: 14180,
|
||||
prevAmount: 16240
|
||||
},
|
||||
{
|
||||
code: 'L-07',
|
||||
side: 'CAPITAL-LIAB',
|
||||
group: 'Borrowings',
|
||||
label: 'Other Borrowings',
|
||||
amount: 10000,
|
||||
prevAmount: 9420
|
||||
},
|
||||
{
|
||||
code: 'L-08',
|
||||
side: 'CAPITAL-LIAB',
|
||||
group: 'Other Liabilities',
|
||||
label: 'Bills Payable',
|
||||
amount: 4420,
|
||||
prevAmount: 3980
|
||||
},
|
||||
{
|
||||
code: 'L-09',
|
||||
side: 'CAPITAL-LIAB',
|
||||
group: 'Other Liabilities',
|
||||
label: 'Inter-Office Adjustments (Net)',
|
||||
amount: 1820,
|
||||
prevAmount: 1640
|
||||
},
|
||||
{
|
||||
code: 'L-10',
|
||||
side: 'CAPITAL-LIAB',
|
||||
group: 'Other Liabilities',
|
||||
label: 'Interest Accrued',
|
||||
amount: 6240,
|
||||
prevAmount: 5840
|
||||
},
|
||||
{
|
||||
code: 'L-11',
|
||||
side: 'CAPITAL-LIAB',
|
||||
group: 'Other Liabilities',
|
||||
label: 'Other Liabilities & Provisions',
|
||||
amount: 5940,
|
||||
prevAmount: 5420
|
||||
amount: 121037.61,
|
||||
prevAmount: 124706.3
|
||||
},
|
||||
{
|
||||
code: 'A-01',
|
||||
side: 'ASSETS',
|
||||
group: 'Cash & Bank Balances',
|
||||
label: 'Cash on Hand',
|
||||
amount: 8420,
|
||||
prevAmount: 7820
|
||||
label: 'Cash & Balances with RBI',
|
||||
amount: 32627.16,
|
||||
prevAmount: 33361.93
|
||||
},
|
||||
{
|
||||
code: 'A-02',
|
||||
side: 'ASSETS',
|
||||
group: 'Cash & Bank Balances',
|
||||
label: 'Balances with RBI',
|
||||
amount: 38400,
|
||||
prevAmount: 34200
|
||||
group: 'Money at Call',
|
||||
label: 'Balances with Other Banks & Money at Call/Short Notice',
|
||||
amount: 604619.45,
|
||||
prevAmount: 549599.47
|
||||
},
|
||||
{
|
||||
code: 'A-03',
|
||||
side: 'ASSETS',
|
||||
group: 'Cash & Bank Balances',
|
||||
label: 'Balances with Other Banks — Current',
|
||||
amount: 14800,
|
||||
prevAmount: 12400
|
||||
group: 'Investments',
|
||||
label: 'Investments',
|
||||
amount: 316953.61,
|
||||
prevAmount: 248274.61
|
||||
},
|
||||
{
|
||||
code: 'A-04',
|
||||
side: 'ASSETS',
|
||||
group: 'Cash & Bank Balances',
|
||||
label: 'Balances with Other Banks — Term Deposits',
|
||||
amount: 10000,
|
||||
prevAmount: 9200
|
||||
group: 'Advances',
|
||||
label: 'Loans & Advances',
|
||||
amount: 1627711.4,
|
||||
prevAmount: 1466401.79
|
||||
},
|
||||
{
|
||||
code: 'A-05',
|
||||
side: 'ASSETS',
|
||||
group: 'Money at Call',
|
||||
label: 'Money at Call & Short Notice',
|
||||
amount: 12400,
|
||||
prevAmount: 10800
|
||||
group: 'Fixed Assets',
|
||||
label: 'Fixed Assets (Property, Plant & Equipment)',
|
||||
amount: 33791.75,
|
||||
prevAmount: 33727.66
|
||||
},
|
||||
{
|
||||
code: 'A-06',
|
||||
side: 'ASSETS',
|
||||
group: 'Investments',
|
||||
label: 'Government Securities (SLR)',
|
||||
amount: 142400,
|
||||
prevAmount: 128600
|
||||
},
|
||||
{
|
||||
code: 'A-07',
|
||||
side: 'ASSETS',
|
||||
group: 'Investments',
|
||||
label: 'Other Approved Securities',
|
||||
amount: 28200,
|
||||
prevAmount: 24800
|
||||
},
|
||||
{
|
||||
code: 'A-08',
|
||||
side: 'ASSETS',
|
||||
group: 'Investments',
|
||||
label: 'Shares / Debentures / Bonds',
|
||||
amount: 14000,
|
||||
prevAmount: 12200
|
||||
},
|
||||
{
|
||||
code: 'A-09',
|
||||
side: 'ASSETS',
|
||||
group: 'Advances',
|
||||
label: 'Cash Credit, Overdraft & Demand Loans',
|
||||
amount: 168400,
|
||||
prevAmount: 152800
|
||||
},
|
||||
{
|
||||
code: 'A-10',
|
||||
side: 'ASSETS',
|
||||
group: 'Advances',
|
||||
label: 'Term Loans (Secured)',
|
||||
amount: 217865,
|
||||
prevAmount: 198400
|
||||
},
|
||||
{
|
||||
code: 'A-11',
|
||||
side: 'ASSETS',
|
||||
group: 'Advances',
|
||||
label: 'Term Loans (Unsecured)',
|
||||
amount: 30000,
|
||||
prevAmount: 27200
|
||||
},
|
||||
{
|
||||
code: 'A-12',
|
||||
side: 'ASSETS',
|
||||
group: 'Fixed Assets',
|
||||
label: 'Premises (Net of Depreciation)',
|
||||
amount: 9420,
|
||||
prevAmount: 8820
|
||||
},
|
||||
{
|
||||
code: 'A-13',
|
||||
side: 'ASSETS',
|
||||
group: 'Fixed Assets',
|
||||
label: 'Other Fixed Assets',
|
||||
amount: 4780,
|
||||
prevAmount: 4620
|
||||
},
|
||||
{
|
||||
code: 'A-14',
|
||||
side: 'ASSETS',
|
||||
group: 'Other Assets',
|
||||
label: 'Inter-Office Adjustments (Net)',
|
||||
amount: 0,
|
||||
prevAmount: 0
|
||||
},
|
||||
{
|
||||
code: 'A-15',
|
||||
side: 'ASSETS',
|
||||
group: 'Other Assets',
|
||||
label: 'Tax Paid in Advance & TDS',
|
||||
amount: 5420,
|
||||
prevAmount: 4820
|
||||
},
|
||||
{ code: 'A-16', side: 'ASSETS', group: 'Other Assets', label: 'Stationery & Stamps', amount: 184, prevAmount: 142 },
|
||||
{
|
||||
code: 'A-17',
|
||||
side: 'ASSETS',
|
||||
group: 'Other Assets',
|
||||
label: 'Sundry Debtors & Other Assets',
|
||||
amount: 2876,
|
||||
prevAmount: 2540
|
||||
label: 'Other Assets',
|
||||
amount: 22028.83,
|
||||
prevAmount: 18249.4
|
||||
}
|
||||
];
|
||||
|
||||
// -------------------- Contingent Liabilities (Schedule 12, ₹ in Lakhs) --------------------
|
||||
// Only the audited TOTAL is reported here — the schedule's individual line items (claims not
|
||||
// acknowledged as debts, letter of guarantees, DEAF transfers, gratuity/RBI-penalty/GST disputes)
|
||||
// could not be re-verified against the source figures with confidence, so they are omitted
|
||||
// rather than risk showing an unverified breakdown.
|
||||
contingentLiabItems: ContingentLiabItem[] = [
|
||||
{ code: 'I', label: 'Claims against the bank not acknowledged as debts' },
|
||||
{ code: 'II', label: 'Letter of Guarantees' },
|
||||
{ code: 'III', label: 'Amounts transferred to Depositor Education and Awareness Fund (DEAF)' },
|
||||
{ code: 'IV', label: 'Other items for which the bank is contingently liable (Gratuity / RBI Penalty / GST Demand)' }
|
||||
];
|
||||
contingentLiab = {
|
||||
current: 1633.3,
|
||||
previous: 1522.13
|
||||
};
|
||||
get clVariation(): number {
|
||||
return this.variation(this.contingentLiab.current, this.contingentLiab.previous);
|
||||
}
|
||||
get clVariationPct(): number {
|
||||
return this.variationPct(this.contingentLiab.current, this.contingentLiab.previous);
|
||||
}
|
||||
|
||||
// -------------------- GL Wise DR/CR Data --------------------
|
||||
glReport: GlLine[] = [
|
||||
{
|
||||
@@ -1008,6 +903,7 @@ export class AccountingReportsDialogComponent {
|
||||
if (this.activeReport === 'PNL') return this.exportPnlCsv();
|
||||
if (this.activeReport === 'BALANCE-SHEET') return this.exportBsCsv(false);
|
||||
if (this.activeReport === 'COMPARATIVE-BS') return this.exportBsCsv(true);
|
||||
if (this.activeReport === 'CONTINGENT-LIAB') return this.exportContingentLiabCsv();
|
||||
if (this.activeReport === 'GL-DR-CR') return this.exportGlCsv();
|
||||
if (this.activeReport === 'MISC-EXP') return this.exportMiscCsv();
|
||||
}
|
||||
@@ -1155,6 +1051,49 @@ export class AccountingReportsDialogComponent {
|
||||
this.saveCsv(rows, `${comparative ? 'Comparative-' : ''}Balance-Sheet-${this.dateTag()}.csv`);
|
||||
}
|
||||
|
||||
exportContingentLiabCsv(): void {
|
||||
const rows: string[][] = [];
|
||||
rows.push([
|
||||
this.bank.name,
|
||||
this.bank.branch
|
||||
]);
|
||||
rows.push([
|
||||
'CONTINGENT LIABILITIES (SCHEDULE 12)',
|
||||
`As on ${this.bank.asOnDate}`
|
||||
]);
|
||||
rows.push([]);
|
||||
rows.push(['Items covered:']);
|
||||
for (const item of this.contingentLiabItems) {
|
||||
rows.push([
|
||||
item.code,
|
||||
item.label
|
||||
]);
|
||||
}
|
||||
rows.push([]);
|
||||
rows.push([
|
||||
'',
|
||||
'TOTAL CONTINGENT LIABILITIES',
|
||||
`FY ${this.bank.fiscalYear} (₹ Lakh)`,
|
||||
`FY ${this.bank.prevFiscalYear} (₹ Lakh)`,
|
||||
'Variation',
|
||||
'Variation %'
|
||||
]);
|
||||
rows.push([
|
||||
'',
|
||||
'',
|
||||
this.contingentLiab.current.toFixed(2),
|
||||
this.contingentLiab.previous.toFixed(2),
|
||||
this.clVariation.toFixed(2),
|
||||
this.clVariationPct.toFixed(2) + '%'
|
||||
]);
|
||||
rows.push([]);
|
||||
rows.push([
|
||||
'Certified by',
|
||||
this.bank.principalOfficer + ' — ' + this.bank.poDesignation
|
||||
]);
|
||||
this.saveCsv(rows, `Contingent-Liabilities-${this.dateTag()}.csv`);
|
||||
}
|
||||
|
||||
exportGlCsv(): void {
|
||||
const rows: string[][] = [];
|
||||
rows.push([
|
||||
|
||||
@@ -40,6 +40,9 @@
|
||||
<button class="alm-tab" [class.active]="activeTab === 'monthly'" (click)="setTab('monthly')">
|
||||
<fa-icon icon="calendar-alt"></fa-icon> Monthly Snapshot
|
||||
</button>
|
||||
<button class="alm-tab" [class.active]="activeTab === 'avgbal'" (click)="setTab('avgbal')">
|
||||
<fa-icon icon="table"></fa-icon> Avg. Liabilities & Assets
|
||||
</button>
|
||||
<button class="alm-tab" [class.active]="activeTab === 'returns'" (click)="setTab('returns')">
|
||||
<fa-icon icon="file-invoice"></fa-icon> Form ALM-1
|
||||
</button>
|
||||
@@ -344,6 +347,87 @@
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<!-- ════════════ AVERAGE LIABILITIES AND ASSETS ════════════ -->
|
||||
<ng-container *ngIf="activeTab === 'avgbal'">
|
||||
<div class="alm-section">
|
||||
<div class="alm-sec-h">
|
||||
<h3 class="alm-sec-t">Average Liabilities and Assets Month-wise during the year</h3>
|
||||
<button mat-stroked-button class="alm-btn-xls" (click)="exportAvgBalancesXls()">
|
||||
<fa-icon icon="file-excel"></fa-icon> Export Excel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Printed-statement banner: institution and office come off the session, not the report. -->
|
||||
<div class="alm-stmt">
|
||||
<div class="alm-stmt-bank">{{ statementHeading }}</div>
|
||||
<div class="alm-stmt-title">Average Liabilities and Assets Month-wise during the year</div>
|
||||
|
||||
<div class="alm-tbl-wrap">
|
||||
<table class="alm-tbl alm-stmt-tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th rowspan="2">Month</th>
|
||||
<th rowspan="2" class="ta-r">Capital</th>
|
||||
<th rowspan="2" class="ta-r">Reserve/Prov<br />Exclude OIR</th>
|
||||
<th rowspan="2" class="ta-r">Deposits</th>
|
||||
<th rowspan="2" class="ta-r">Borrowings</th>
|
||||
<th rowspan="2" class="ta-r">Other Liabilities</th>
|
||||
<th rowspan="2" class="ta-r">Cash on Hand</th>
|
||||
<th colspan="2" class="ta-c">Bank Balances</th>
|
||||
<th rowspan="2" class="ta-r">Investments<br />including</th>
|
||||
<th rowspan="2" class="ta-r">Advances</th>
|
||||
<th rowspan="2" class="ta-r">Other Assets<br />Exclude IRA</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="ta-r">Current</th>
|
||||
<th class="ta-r">FDs</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let r of avgBalanceRows">
|
||||
<td class="mono">
|
||||
<strong>{{ r.label }}</strong
|
||||
><br /><span class="alm-stmt-sub">{{ r.sub }}</span>
|
||||
</td>
|
||||
<td class="ta-r">{{ r.capital | number: '1.2-2' }}</td>
|
||||
<td class="ta-r">{{ r.reserveProv | number: '1.2-2' }}</td>
|
||||
<td class="ta-r">{{ r.deposits | number: '1.2-2' }}</td>
|
||||
<td class="ta-r">{{ r.borrowings | number: '1.2-2' }}</td>
|
||||
<td class="ta-r">{{ r.otherLiabilities | number: '1.2-2' }}</td>
|
||||
<td class="ta-r">{{ r.cashOnHand | number: '1.2-2' }}</td>
|
||||
<td class="ta-r">{{ r.bankCurrent | number: '1.2-2' }}</td>
|
||||
<td class="ta-r">{{ r.bankFds | number: '1.2-2' }}</td>
|
||||
<td class="ta-r">{{ r.investments | number: '1.2-2' }}</td>
|
||||
<td class="ta-r">{{ r.advances | number: '1.2-2' }}</td>
|
||||
<td class="ta-r">{{ r.otherAssets | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
|
||||
<tr class="row-total" *ngFor="let r of [avgBalanceTotal, avgBalanceAverage]">
|
||||
<td>{{ r.label }}</td>
|
||||
<td class="ta-r">{{ r.capital | number: '1.2-2' }}</td>
|
||||
<td class="ta-r">{{ r.reserveProv | number: '1.2-2' }}</td>
|
||||
<td class="ta-r">{{ r.deposits | number: '1.2-2' }}</td>
|
||||
<td class="ta-r">{{ r.borrowings | number: '1.2-2' }}</td>
|
||||
<td class="ta-r">{{ r.otherLiabilities | number: '1.2-2' }}</td>
|
||||
<td class="ta-r">{{ r.cashOnHand | number: '1.2-2' }}</td>
|
||||
<td class="ta-r">{{ r.bankCurrent | number: '1.2-2' }}</td>
|
||||
<td class="ta-r">{{ r.bankFds | number: '1.2-2' }}</td>
|
||||
<td class="ta-r">{{ r.investments | number: '1.2-2' }}</td>
|
||||
<td class="ta-r">{{ r.advances | number: '1.2-2' }}</td>
|
||||
<td class="ta-r">{{ r.otherAssets | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="alm-stmt-foot">
|
||||
<div>Print Date : {{ statementPrintedAt }}</div>
|
||||
<div>Report Ref No : {{ bank.reportRefNo }} User Code:{{ statementUserCode }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<!-- ════════════ RETURNS / FORM ALM-1 ════════════ -->
|
||||
<ng-container *ngIf="activeTab === 'returns'">
|
||||
<div class="alm-section">
|
||||
|
||||
@@ -597,6 +597,58 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* AVERAGE LIABILITIES AND ASSETS — laid out as the statement prints */
|
||||
.alm-stmt {
|
||||
padding: 16px;
|
||||
border: 1px solid var(--alm-g200);
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.alm-stmt-bank {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--alm-g900);
|
||||
}
|
||||
|
||||
.alm-stmt-title {
|
||||
margin: 2px 0 14px;
|
||||
font-size: 12px;
|
||||
color: var(--alm-g500);
|
||||
}
|
||||
|
||||
.alm-stmt-tbl {
|
||||
th,
|
||||
td {
|
||||
padding: 7px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
th {
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
td.ta-r {
|
||||
font-family: 'DM Mono', monospace;
|
||||
}
|
||||
}
|
||||
|
||||
.alm-stmt-sub {
|
||||
font-size: 10px;
|
||||
color: var(--alm-g500);
|
||||
}
|
||||
|
||||
.alm-stmt-foot {
|
||||
margin-top: 14px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--alm-g200);
|
||||
font-size: 11px;
|
||||
line-height: 1.7;
|
||||
color: var(--alm-g500);
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
display: inline-block;
|
||||
padding: 3px 10px;
|
||||
|
||||
@@ -13,6 +13,8 @@ import { MatDialogRef, MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { BranchScopeService } from 'app/core/branch-scope.service';
|
||||
import { AuthenticationService } from 'app/core/authentication/authentication.service';
|
||||
|
||||
interface MaturityBucket {
|
||||
code: string;
|
||||
@@ -31,6 +33,37 @@ interface MonthSnapshot {
|
||||
cashOnHand: number;
|
||||
investments: number;
|
||||
otherAssets: number;
|
||||
// ── Columns the month-wise average statement adds to the snapshot above ──
|
||||
// The statement is a full balance sheet averaged over each month, so it needs the own-funds
|
||||
// and deposit sides the liquidity tabs do not use, and it splits bank balances from cash.
|
||||
/** Paid-up share capital. */
|
||||
capital: number;
|
||||
/** Total deposits — the FD component above is part of this figure. */
|
||||
deposits: number;
|
||||
otherLiabilities: number;
|
||||
/** Balances with other banks, current accounts. */
|
||||
bankCurrent: number;
|
||||
/** Balances with other banks, held as fixed deposits. */
|
||||
bankFds: number;
|
||||
advances: number;
|
||||
}
|
||||
|
||||
/** One row of the month-wise average statement — a month, or the Total / Average line. */
|
||||
interface AvgBalanceRow {
|
||||
label: string;
|
||||
/** Sub-label under the month, e.g. the year, as the printed statement prints it. */
|
||||
sub: string;
|
||||
capital: number;
|
||||
reserveProv: number;
|
||||
deposits: number;
|
||||
borrowings: number;
|
||||
otherLiabilities: number;
|
||||
cashOnHand: number;
|
||||
bankCurrent: number;
|
||||
bankFds: number;
|
||||
investments: number;
|
||||
advances: number;
|
||||
otherAssets: number;
|
||||
}
|
||||
|
||||
@Component({
|
||||
@@ -50,11 +83,13 @@ interface MonthSnapshot {
|
||||
})
|
||||
export class AlmReportDialogComponent {
|
||||
private dialogRef = inject(MatDialogRef<AlmReportDialogComponent>);
|
||||
private branchScope = inject(BranchScopeService);
|
||||
private auth = inject(AuthenticationService);
|
||||
|
||||
activeTab: 'dashboard' | 'ladder' | 'monthly' | 'returns' = 'dashboard';
|
||||
activeTab: 'dashboard' | 'ladder' | 'monthly' | 'avgbal' | 'returns' = 'dashboard';
|
||||
|
||||
bank = {
|
||||
name: 'Bharat Urban Bank Ltd',
|
||||
name: 'CBSNexora Bank Ltd',
|
||||
branch: 'Head Office',
|
||||
asOnDate: '31-03-2026',
|
||||
quarter: 'Q4 FY 2025-26',
|
||||
@@ -63,7 +98,7 @@ export class AlmReportDialogComponent {
|
||||
principalOfficer: 'Karthik Kamath',
|
||||
formCode: 'Form ALM-1',
|
||||
reportRefNo: '1036',
|
||||
userCode: 'BUBL01',
|
||||
userCode: 'CBSN01',
|
||||
/** Net Demand & Time Liabilities — denominator for ratios */
|
||||
ndtl: 740000, // ₹7,400 Cr in Lakhs
|
||||
/** RBI tolerance: cumulative negative gap up to 14 days ≤ 5%, 28 days ≤ 10% */
|
||||
@@ -95,7 +130,13 @@ export class AlmReportDialogComponent {
|
||||
fds: 462800,
|
||||
cashOnHand: 8420,
|
||||
investments: 184600,
|
||||
otherAssets: 12480
|
||||
otherAssets: 12480,
|
||||
capital: 24200,
|
||||
deposits: 602800,
|
||||
otherLiabilities: 28400,
|
||||
bankCurrent: 18900,
|
||||
bankFds: 46200,
|
||||
advances: 372400
|
||||
},
|
||||
{
|
||||
month: 'MAY-2025',
|
||||
@@ -104,7 +145,13 @@ export class AlmReportDialogComponent {
|
||||
fds: 468200,
|
||||
cashOnHand: 8680,
|
||||
investments: 186400,
|
||||
otherAssets: 12620
|
||||
otherAssets: 12620,
|
||||
capital: 24280,
|
||||
deposits: 608600,
|
||||
otherLiabilities: 28560,
|
||||
bankCurrent: 19240,
|
||||
bankFds: 46600,
|
||||
advances: 376800
|
||||
},
|
||||
{
|
||||
month: 'JUN-2025',
|
||||
@@ -113,7 +160,13 @@ export class AlmReportDialogComponent {
|
||||
fds: 472400,
|
||||
cashOnHand: 9120,
|
||||
investments: 188200,
|
||||
otherAssets: 12840
|
||||
otherAssets: 12840,
|
||||
capital: 24360,
|
||||
deposits: 613400,
|
||||
otherLiabilities: 28720,
|
||||
bankCurrent: 19580,
|
||||
bankFds: 47000,
|
||||
advances: 381200
|
||||
},
|
||||
{
|
||||
month: 'JUL-2025',
|
||||
@@ -122,7 +175,13 @@ export class AlmReportDialogComponent {
|
||||
fds: 478600,
|
||||
cashOnHand: 9280,
|
||||
investments: 190400,
|
||||
otherAssets: 13020
|
||||
otherAssets: 13020,
|
||||
capital: 24460,
|
||||
deposits: 619800,
|
||||
otherLiabilities: 28880,
|
||||
bankCurrent: 19860,
|
||||
bankFds: 47400,
|
||||
advances: 385600
|
||||
},
|
||||
{
|
||||
month: 'AUG-2025',
|
||||
@@ -131,7 +190,13 @@ export class AlmReportDialogComponent {
|
||||
fds: 482400,
|
||||
cashOnHand: 9420,
|
||||
investments: 192800,
|
||||
otherAssets: 13280
|
||||
otherAssets: 13280,
|
||||
capital: 24540,
|
||||
deposits: 624200,
|
||||
otherLiabilities: 29040,
|
||||
bankCurrent: 20180,
|
||||
bankFds: 47800,
|
||||
advances: 390200
|
||||
},
|
||||
{
|
||||
month: 'SEP-2025',
|
||||
@@ -140,7 +205,13 @@ export class AlmReportDialogComponent {
|
||||
fds: 488200,
|
||||
cashOnHand: 9640,
|
||||
investments: 195200,
|
||||
otherAssets: 13420
|
||||
otherAssets: 13420,
|
||||
capital: 24620,
|
||||
deposits: 630400,
|
||||
otherLiabilities: 29200,
|
||||
bankCurrent: 20460,
|
||||
bankFds: 48200,
|
||||
advances: 394800
|
||||
},
|
||||
{
|
||||
month: 'OCT-2025',
|
||||
@@ -149,7 +220,13 @@ export class AlmReportDialogComponent {
|
||||
fds: 492600,
|
||||
cashOnHand: 9820,
|
||||
investments: 197600,
|
||||
otherAssets: 13620
|
||||
otherAssets: 13620,
|
||||
capital: 24720,
|
||||
deposits: 635200,
|
||||
otherLiabilities: 29380,
|
||||
bankCurrent: 20740,
|
||||
bankFds: 48600,
|
||||
advances: 399400
|
||||
},
|
||||
{
|
||||
month: 'NOV-2025',
|
||||
@@ -158,7 +235,13 @@ export class AlmReportDialogComponent {
|
||||
fds: 498400,
|
||||
cashOnHand: 9980,
|
||||
investments: 200200,
|
||||
otherAssets: 13820
|
||||
otherAssets: 13820,
|
||||
capital: 24800,
|
||||
deposits: 641000,
|
||||
otherLiabilities: 29540,
|
||||
bankCurrent: 21020,
|
||||
bankFds: 49000,
|
||||
advances: 404200
|
||||
},
|
||||
{
|
||||
month: 'DEC-2025',
|
||||
@@ -167,7 +250,13 @@ export class AlmReportDialogComponent {
|
||||
fds: 504200,
|
||||
cashOnHand: 10240,
|
||||
investments: 202800,
|
||||
otherAssets: 14040
|
||||
otherAssets: 14040,
|
||||
capital: 24900,
|
||||
deposits: 646800,
|
||||
otherLiabilities: 29720,
|
||||
bankCurrent: 21340,
|
||||
bankFds: 49400,
|
||||
advances: 409000
|
||||
},
|
||||
{
|
||||
month: 'JAN-2026',
|
||||
@@ -176,7 +265,13 @@ export class AlmReportDialogComponent {
|
||||
fds: 508800,
|
||||
cashOnHand: 10420,
|
||||
investments: 205200,
|
||||
otherAssets: 14240
|
||||
otherAssets: 14240,
|
||||
capital: 24980,
|
||||
deposits: 651600,
|
||||
otherLiabilities: 29880,
|
||||
bankCurrent: 21620,
|
||||
bankFds: 49800,
|
||||
advances: 413800
|
||||
},
|
||||
{
|
||||
month: 'FEB-2026',
|
||||
@@ -185,7 +280,13 @@ export class AlmReportDialogComponent {
|
||||
fds: 514600,
|
||||
cashOnHand: 10620,
|
||||
investments: 207800,
|
||||
otherAssets: 14460
|
||||
otherAssets: 14460,
|
||||
capital: 25060,
|
||||
deposits: 657400,
|
||||
otherLiabilities: 30040,
|
||||
bankCurrent: 21900,
|
||||
bankFds: 50200,
|
||||
advances: 418600
|
||||
},
|
||||
{
|
||||
month: 'MAR-2026',
|
||||
@@ -194,7 +295,13 @@ export class AlmReportDialogComponent {
|
||||
fds: 520400,
|
||||
cashOnHand: 10820,
|
||||
investments: 210400,
|
||||
otherAssets: 14680
|
||||
otherAssets: 14680,
|
||||
capital: 25160,
|
||||
deposits: 663200,
|
||||
otherLiabilities: 30220,
|
||||
bankCurrent: 22240,
|
||||
bankFds: 50600,
|
||||
advances: 423600
|
||||
}
|
||||
];
|
||||
|
||||
@@ -256,6 +363,100 @@ export class AlmReportDialogComponent {
|
||||
return 'balanced';
|
||||
}
|
||||
|
||||
// ── Average Liabilities and Assets, month-wise ────────────────────────────
|
||||
//
|
||||
// The printed statement carries the institution and office it was taken for, and who took it.
|
||||
// Those come off the session rather than being typed into the report, so a branch login prints
|
||||
// its own office and its own user code instead of whatever the last person hard-coded.
|
||||
|
||||
/** "<BANK> , <OFFICE>" — the banner line the printed statement opens with. */
|
||||
get statementHeading(): string {
|
||||
return `${this.bank.name.toUpperCase()} , ${this.branchScope.officeName.toUpperCase()}`;
|
||||
}
|
||||
|
||||
/** Who ran the report — the login's username, which is what the printed footer calls a code. */
|
||||
get statementUserCode(): string {
|
||||
return (this.auth.getCredentials()?.username || this.bank.userCode).toUpperCase();
|
||||
}
|
||||
|
||||
/** Stamped when the tab is opened, so the footer records when the figures were taken off. */
|
||||
readonly statementPrintedAt = this.formatPrintDate(new Date());
|
||||
|
||||
/**
|
||||
* Month rows, then the Total and Average lines the statement closes with.
|
||||
*
|
||||
* Average is the mean of the months shown, not Total ÷ 12 — a statement printed part-way
|
||||
* through the year would otherwise divide by months that have not happened yet.
|
||||
*/
|
||||
get avgBalanceRows(): AvgBalanceRow[] {
|
||||
return this.monthly.map((m) => {
|
||||
const [
|
||||
name,
|
||||
year
|
||||
] = m.month.split('-');
|
||||
return {
|
||||
label: name,
|
||||
sub: year,
|
||||
capital: m.capital,
|
||||
reserveProv: m.reserveProv,
|
||||
deposits: m.deposits,
|
||||
borrowings: m.borrowings,
|
||||
otherLiabilities: m.otherLiabilities,
|
||||
cashOnHand: m.cashOnHand,
|
||||
bankCurrent: m.bankCurrent,
|
||||
bankFds: m.bankFds,
|
||||
investments: m.investments,
|
||||
advances: m.advances,
|
||||
otherAssets: m.otherAssets
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private readonly avgBalanceColumns: (keyof AvgBalanceRow)[] = [
|
||||
'capital',
|
||||
'reserveProv',
|
||||
'deposits',
|
||||
'borrowings',
|
||||
'otherLiabilities',
|
||||
'cashOnHand',
|
||||
'bankCurrent',
|
||||
'bankFds',
|
||||
'investments',
|
||||
'advances',
|
||||
'otherAssets'
|
||||
];
|
||||
|
||||
get avgBalanceTotal(): AvgBalanceRow {
|
||||
const rows = this.avgBalanceRows;
|
||||
const out = { label: 'Total', sub: '' } as AvgBalanceRow;
|
||||
for (const key of this.avgBalanceColumns) {
|
||||
out[key] = rows.reduce((s, r) => s + (r[key] as number), 0) as never;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
get avgBalanceAverage(): AvgBalanceRow {
|
||||
const rows = this.avgBalanceRows;
|
||||
const total = this.avgBalanceTotal;
|
||||
const out = { label: 'Average', sub: '' } as AvgBalanceRow;
|
||||
for (const key of this.avgBalanceColumns) {
|
||||
out[key] = (rows.length ? +((total[key] as number) / rows.length).toFixed(2) : 0) as never;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** dd/MM/yyyy hh:mm:ssAM — the format the printed statement stamps its footer with. */
|
||||
private formatPrintDate(d: Date): string {
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
const hours24 = d.getHours();
|
||||
const meridiem = hours24 < 12 ? 'AM' : 'PM';
|
||||
const hours12 = hours24 % 12 === 0 ? 12 : hours24 % 12;
|
||||
return (
|
||||
`${pad(d.getDate())}/${pad(d.getMonth() + 1)}/${d.getFullYear()} ` +
|
||||
`${pad(hours12)}:${pad(d.getMinutes())}:${pad(d.getSeconds())}${meridiem}`
|
||||
);
|
||||
}
|
||||
|
||||
/** Monthly totals helpers */
|
||||
monthTotalLiab(m: MonthSnapshot): number {
|
||||
return m.reserveProv + m.borrowings + m.fds;
|
||||
@@ -407,6 +608,49 @@ export class AlmReportDialogComponent {
|
||||
this.saveTsv(rows, `ALM-Monthly-${this.dateTag()}.xls`);
|
||||
}
|
||||
|
||||
/** Average Liabilities and Assets — the printed statement, column for column. */
|
||||
exportAvgBalancesXls(): void {
|
||||
const rows: string[][] = [];
|
||||
rows.push([this.statementHeading]);
|
||||
rows.push(['Average Liabilities and Assets Month-wise during the year']);
|
||||
rows.push([]);
|
||||
rows.push([
|
||||
'Month',
|
||||
'Capital',
|
||||
'Reserve/Prov Exclude OIR',
|
||||
'Deposits',
|
||||
'Borrowings',
|
||||
'Other Liabilities',
|
||||
'Cash on Hand',
|
||||
'Bank Balances Current',
|
||||
'Bank Balances FDs',
|
||||
'Investments including',
|
||||
'Advances',
|
||||
'Other Assets Exclude IRA'
|
||||
]);
|
||||
const line = (r: AvgBalanceRow): string[] => [
|
||||
`${r.label} ${r.sub}`.trim(),
|
||||
r.capital.toFixed(2),
|
||||
r.reserveProv.toFixed(2),
|
||||
r.deposits.toFixed(2),
|
||||
r.borrowings.toFixed(2),
|
||||
r.otherLiabilities.toFixed(2),
|
||||
r.cashOnHand.toFixed(2),
|
||||
r.bankCurrent.toFixed(2),
|
||||
r.bankFds.toFixed(2),
|
||||
r.investments.toFixed(2),
|
||||
r.advances.toFixed(2),
|
||||
r.otherAssets.toFixed(2)
|
||||
];
|
||||
this.avgBalanceRows.forEach((r) => rows.push(line(r)));
|
||||
rows.push(line(this.avgBalanceTotal));
|
||||
rows.push(line(this.avgBalanceAverage));
|
||||
rows.push([]);
|
||||
rows.push([`Print Date : ${this.statementPrintedAt}`]);
|
||||
rows.push([`Report Ref No : ${this.bank.reportRefNo} User Code:${this.statementUserCode}`]);
|
||||
this.saveTsv(rows, `ALM-AverageLiabilitiesAssets-${this.dateTag()}.xls`);
|
||||
}
|
||||
|
||||
/** Maturity ladder CSV */
|
||||
exportLadderCsv(): void {
|
||||
const headers = [
|
||||
@@ -431,7 +675,7 @@ export class AlmReportDialogComponent {
|
||||
this.saveCsv(rows, `ALM-Ladder-${this.dateTag()}.csv`);
|
||||
}
|
||||
|
||||
setTab(tab: 'dashboard' | 'ladder' | 'monthly' | 'returns'): void {
|
||||
setTab(tab: 'dashboard' | 'ladder' | 'monthly' | 'avgbal' | 'returns'): void {
|
||||
this.activeTab = tab;
|
||||
}
|
||||
close(): void {
|
||||
|
||||
@@ -14,7 +14,7 @@ import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
|
||||
export type CimsFormCode = 'OSS-2' | 'PSA' | 'RAL' | 'RCP-UCB';
|
||||
export type CimsFormCode = 'OSS-2' | 'PSA' | 'RAL' | 'RCP-UCB' | 'OSS-4' | 'OSS-5' | 'OSS-6' | 'OSS-7';
|
||||
|
||||
export interface CimsXbrlDialogData {
|
||||
initialForm?: CimsFormCode;
|
||||
@@ -64,20 +64,20 @@ export class CimsXbrlDialogComponent {
|
||||
activeTab: 'filing' | 'data' | 'preview' = 'filing';
|
||||
|
||||
bank = {
|
||||
name: 'Bharat Urban Bank Ltd',
|
||||
name: 'CBSNexora Bank Ltd',
|
||||
branch: 'Head Office',
|
||||
bsrCode: 'BUBL0000123',
|
||||
ifsc: 'BUBL0000123',
|
||||
legalEntityId: 'BUBL-LEI-2025',
|
||||
bsrCode: 'CBSN0000123',
|
||||
ifsc: 'CBSN0000123',
|
||||
legalEntityId: 'CBSN-LEI-2025',
|
||||
pan: 'AAACB1234L',
|
||||
asOnDate: '31-03-2026',
|
||||
quarter: 'Q4 FY 2025-26',
|
||||
fiscalYear: '2025-26',
|
||||
principalOfficer: 'Karthik Kamath',
|
||||
poDesignation: 'Chief Compliance Officer',
|
||||
poEmail: 'compliance@bharaturbanbank.in',
|
||||
poEmail: 'compliance@cbsnexorabank.in',
|
||||
poMobile: '+91-9988002211',
|
||||
cimsUserId: 'BUBL_CIMS_001',
|
||||
cimsUserId: 'CBSN_CIMS_001',
|
||||
cimsPortal: 'https://cims.rbi.org.in/'
|
||||
};
|
||||
|
||||
@@ -97,7 +97,7 @@ export class CimsXbrlDialogComponent {
|
||||
ref: 'oss2:CapAdq.CRAR',
|
||||
section: 'Capital Adequacy',
|
||||
label: 'CRAR (Total)',
|
||||
value: 14.82,
|
||||
value: 21.82,
|
||||
unit: 'Percent',
|
||||
type: 'percent'
|
||||
},
|
||||
@@ -105,7 +105,7 @@ export class CimsXbrlDialogComponent {
|
||||
ref: 'oss2:CapAdq.Tier1',
|
||||
section: 'Capital Adequacy',
|
||||
label: 'Tier-I CRAR',
|
||||
value: 12.18,
|
||||
value: 20.51,
|
||||
unit: 'Percent',
|
||||
type: 'percent'
|
||||
},
|
||||
@@ -113,7 +113,7 @@ export class CimsXbrlDialogComponent {
|
||||
ref: 'oss2:CapAdq.Tier2',
|
||||
section: 'Capital Adequacy',
|
||||
label: 'Tier-II CRAR',
|
||||
value: 2.64,
|
||||
value: 1.31,
|
||||
unit: 'Percent',
|
||||
type: 'percent'
|
||||
},
|
||||
@@ -121,7 +121,7 @@ export class CimsXbrlDialogComponent {
|
||||
ref: 'oss2:Asset.Total',
|
||||
section: 'Asset Classification',
|
||||
label: 'Total Advances (₹ Lakh)',
|
||||
value: 416265,
|
||||
value: 4112.04,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
@@ -129,7 +129,7 @@ export class CimsXbrlDialogComponent {
|
||||
ref: 'oss2:Asset.Standard',
|
||||
section: 'Asset Classification',
|
||||
label: 'Standard Assets (₹ Lakh)',
|
||||
value: 401820,
|
||||
value: 3845.88,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
@@ -137,7 +137,7 @@ export class CimsXbrlDialogComponent {
|
||||
ref: 'oss2:Asset.SubStd',
|
||||
section: 'Asset Classification',
|
||||
label: 'Sub-Standard (₹ Lakh)',
|
||||
value: 6220,
|
||||
value: 102.41,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
@@ -145,7 +145,7 @@ export class CimsXbrlDialogComponent {
|
||||
ref: 'oss2:Asset.Doubtful',
|
||||
section: 'Asset Classification',
|
||||
label: 'Doubtful (₹ Lakh)',
|
||||
value: 5840,
|
||||
value: 163.75,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
@@ -153,7 +153,7 @@ export class CimsXbrlDialogComponent {
|
||||
ref: 'oss2:Asset.Loss',
|
||||
section: 'Asset Classification',
|
||||
label: 'Loss (₹ Lakh)',
|
||||
value: 2385,
|
||||
value: 0,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
@@ -161,7 +161,7 @@ export class CimsXbrlDialogComponent {
|
||||
ref: 'oss2:Asset.GNPA',
|
||||
section: 'NPA Position',
|
||||
label: 'Gross NPA (₹ Lakh)',
|
||||
value: 14445,
|
||||
value: 266.16,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
@@ -169,7 +169,7 @@ export class CimsXbrlDialogComponent {
|
||||
ref: 'oss2:Asset.NNPA',
|
||||
section: 'NPA Position',
|
||||
label: 'Net NPA (₹ Lakh)',
|
||||
value: 5212,
|
||||
value: -96.33,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
@@ -177,7 +177,7 @@ export class CimsXbrlDialogComponent {
|
||||
ref: 'oss2:Asset.GNPAPct',
|
||||
section: 'NPA Position',
|
||||
label: 'GNPA %',
|
||||
value: 3.47,
|
||||
value: 6.47,
|
||||
unit: 'Percent',
|
||||
type: 'percent'
|
||||
},
|
||||
@@ -185,7 +185,7 @@ export class CimsXbrlDialogComponent {
|
||||
ref: 'oss2:Asset.NNPAPct',
|
||||
section: 'NPA Position',
|
||||
label: 'NNPA %',
|
||||
value: 1.27,
|
||||
value: -2.6,
|
||||
unit: 'Percent',
|
||||
type: 'percent'
|
||||
},
|
||||
@@ -193,7 +193,7 @@ export class CimsXbrlDialogComponent {
|
||||
ref: 'oss2:Asset.PCR',
|
||||
section: 'NPA Position',
|
||||
label: 'Provision Coverage Ratio',
|
||||
value: 64.13,
|
||||
value: 136.24,
|
||||
unit: 'Percent',
|
||||
type: 'percent'
|
||||
},
|
||||
@@ -201,7 +201,7 @@ export class CimsXbrlDialogComponent {
|
||||
ref: 'oss2:Profit.NIM',
|
||||
section: 'Profitability',
|
||||
label: 'Net Interest Margin',
|
||||
value: 3.21,
|
||||
value: 0.51,
|
||||
unit: 'Percent',
|
||||
type: 'percent'
|
||||
},
|
||||
@@ -209,7 +209,7 @@ export class CimsXbrlDialogComponent {
|
||||
ref: 'oss2:Profit.ROA',
|
||||
section: 'Profitability',
|
||||
label: 'Return on Assets',
|
||||
value: 0.92,
|
||||
value: 0.67,
|
||||
unit: 'Percent',
|
||||
type: 'percent'
|
||||
},
|
||||
@@ -217,7 +217,7 @@ export class CimsXbrlDialogComponent {
|
||||
ref: 'oss2:Profit.ROE',
|
||||
section: 'Profitability',
|
||||
label: 'Return on Equity',
|
||||
value: 11.48,
|
||||
value: 5.38,
|
||||
unit: 'Percent',
|
||||
type: 'percent'
|
||||
},
|
||||
@@ -254,7 +254,7 @@ export class CimsXbrlDialogComponent {
|
||||
ref: 'psa:Reference.ANBC',
|
||||
section: 'Reference',
|
||||
label: 'Adjusted Net Bank Credit (₹ Lakh)',
|
||||
value: 600000,
|
||||
value: 3274.83,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
@@ -262,7 +262,7 @@ export class CimsXbrlDialogComponent {
|
||||
ref: 'psa:Reference.OBE',
|
||||
section: 'Reference',
|
||||
label: 'Credit Equivalent of OBE (₹ Lakh)',
|
||||
value: 25000,
|
||||
value: 0,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
@@ -270,7 +270,7 @@ export class CimsXbrlDialogComponent {
|
||||
ref: 'psa:PSL.Total',
|
||||
section: 'PSL Achievement',
|
||||
label: 'Total PSL Outstanding (₹ Lakh)',
|
||||
value: 241700,
|
||||
value: 2485.67,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
@@ -278,7 +278,7 @@ export class CimsXbrlDialogComponent {
|
||||
ref: 'psa:PSL.TotalPct',
|
||||
section: 'PSL Achievement',
|
||||
label: 'Total PSL % of ANBC',
|
||||
value: 40.28,
|
||||
value: 75.9,
|
||||
unit: 'Percent',
|
||||
type: 'percent'
|
||||
},
|
||||
@@ -286,7 +286,7 @@ export class CimsXbrlDialogComponent {
|
||||
ref: 'psa:PSL.Agri',
|
||||
section: 'Sub-targets',
|
||||
label: 'Agriculture (₹ Lakh)',
|
||||
value: 124000,
|
||||
value: 39.0,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
@@ -294,7 +294,7 @@ export class CimsXbrlDialogComponent {
|
||||
ref: 'psa:PSL.AgriPct',
|
||||
section: 'Sub-targets',
|
||||
label: 'Agriculture % of ANBC',
|
||||
value: 20.67,
|
||||
value: 1.19,
|
||||
unit: 'Percent',
|
||||
type: 'percent'
|
||||
},
|
||||
@@ -302,7 +302,7 @@ export class CimsXbrlDialogComponent {
|
||||
ref: 'psa:PSL.SMF',
|
||||
section: 'Sub-targets',
|
||||
label: 'Small & Marginal Farmers (₹ Lakh)',
|
||||
value: 72000,
|
||||
value: 0,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
@@ -310,23 +310,23 @@ export class CimsXbrlDialogComponent {
|
||||
ref: 'psa:PSL.SMFPct',
|
||||
section: 'Sub-targets',
|
||||
label: 'SMF % of ANBC',
|
||||
value: 12.0,
|
||||
value: 0,
|
||||
unit: 'Percent',
|
||||
type: 'percent'
|
||||
},
|
||||
{
|
||||
ref: 'psa:PSL.MicroEnt',
|
||||
section: 'Sub-targets',
|
||||
label: 'Micro Enterprises (₹ Lakh)',
|
||||
value: 51000,
|
||||
label: 'MSME Enterprises (₹ Lakh)',
|
||||
value: 2313.83,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
{
|
||||
ref: 'psa:PSL.MicroPct',
|
||||
section: 'Sub-targets',
|
||||
label: 'Micro Enterprises % of ANBC',
|
||||
value: 8.5,
|
||||
label: 'MSME Enterprises % of ANBC',
|
||||
value: 70.65,
|
||||
unit: 'Percent',
|
||||
type: 'percent'
|
||||
},
|
||||
@@ -334,7 +334,7 @@ export class CimsXbrlDialogComponent {
|
||||
ref: 'psa:PSL.Weaker',
|
||||
section: 'Sub-targets',
|
||||
label: 'Weaker Sections (₹ Lakh)',
|
||||
value: 85500,
|
||||
value: 990.28,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
@@ -342,7 +342,7 @@ export class CimsXbrlDialogComponent {
|
||||
ref: 'psa:PSL.WeakerPct',
|
||||
section: 'Sub-targets',
|
||||
label: 'Weaker Sections % of ANBC',
|
||||
value: 14.25,
|
||||
value: 30.22,
|
||||
unit: 'Percent',
|
||||
type: 'percent'
|
||||
},
|
||||
@@ -350,7 +350,7 @@ export class CimsXbrlDialogComponent {
|
||||
ref: 'psa:PSL.Housing',
|
||||
section: 'Other Categories',
|
||||
label: 'Housing (₹ Lakh)',
|
||||
value: 9200,
|
||||
value: 115.12,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
@@ -358,7 +358,7 @@ export class CimsXbrlDialogComponent {
|
||||
ref: 'psa:PSL.Education',
|
||||
section: 'Other Categories',
|
||||
label: 'Education (₹ Lakh)',
|
||||
value: 4200,
|
||||
value: 17.32,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
@@ -366,7 +366,7 @@ export class CimsXbrlDialogComponent {
|
||||
ref: 'psa:PSL.Export',
|
||||
section: 'Other Categories',
|
||||
label: 'Export Credit (₹ Lakh)',
|
||||
value: 3800,
|
||||
value: 0,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
@@ -374,7 +374,7 @@ export class CimsXbrlDialogComponent {
|
||||
ref: 'psa:PSL.RenEnergy',
|
||||
section: 'Other Categories',
|
||||
label: 'Renewable Energy (₹ Lakh)',
|
||||
value: 2400,
|
||||
value: 0,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
}
|
||||
@@ -395,15 +395,15 @@ export class CimsXbrlDialogComponent {
|
||||
ref: 'ral:Liab.Capital',
|
||||
section: 'Liabilities',
|
||||
label: 'Capital (₹ Lakh)',
|
||||
value: 22000,
|
||||
value: 272.78,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
{
|
||||
ref: 'ral:Liab.Reserves',
|
||||
section: 'Liabilities',
|
||||
label: 'Reserves & Surplus (₹ Lakh)',
|
||||
value: 32400,
|
||||
label: 'Reserves, Surplus & Profit (₹ Lakh)',
|
||||
value: 712.6,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
@@ -411,7 +411,7 @@ export class CimsXbrlDialogComponent {
|
||||
ref: 'ral:Liab.DepDemand',
|
||||
section: 'Liabilities',
|
||||
label: 'Demand Deposits (₹ Lakh)',
|
||||
value: 84200,
|
||||
value: 195.92,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
@@ -419,7 +419,7 @@ export class CimsXbrlDialogComponent {
|
||||
ref: 'ral:Liab.DepSavings',
|
||||
section: 'Liabilities',
|
||||
label: 'Savings Deposits (₹ Lakh)',
|
||||
value: 218600,
|
||||
value: 2081.16,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
@@ -427,7 +427,7 @@ export class CimsXbrlDialogComponent {
|
||||
ref: 'ral:Liab.DepTerm',
|
||||
section: 'Liabilities',
|
||||
label: 'Term Deposits (₹ Lakh)',
|
||||
value: 462800,
|
||||
value: 4066.01,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
@@ -435,7 +435,7 @@ export class CimsXbrlDialogComponent {
|
||||
ref: 'ral:Liab.Borrow',
|
||||
section: 'Liabilities',
|
||||
label: 'Borrowings (₹ Lakh)',
|
||||
value: 24180,
|
||||
value: 0,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
@@ -443,7 +443,7 @@ export class CimsXbrlDialogComponent {
|
||||
ref: 'ral:Liab.Other',
|
||||
section: 'Liabilities',
|
||||
label: 'Other Liabilities & Provisions (₹ Lakh)',
|
||||
value: 18420,
|
||||
value: 780.73,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
@@ -451,7 +451,7 @@ export class CimsXbrlDialogComponent {
|
||||
ref: 'ral:Asset.Cash',
|
||||
section: 'Assets',
|
||||
label: 'Cash on Hand (₹ Lakh)',
|
||||
value: 8420,
|
||||
value: 99.96,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
@@ -459,15 +459,15 @@ export class CimsXbrlDialogComponent {
|
||||
ref: 'ral:Asset.RBI',
|
||||
section: 'Assets',
|
||||
label: 'Balances with RBI (₹ Lakh)',
|
||||
value: 38400,
|
||||
value: 0,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
{
|
||||
ref: 'ral:Asset.Banks',
|
||||
section: 'Assets',
|
||||
label: 'Balances with Banks (₹ Lakh)',
|
||||
value: 24800,
|
||||
label: 'Balances with Banks & Other Institutions (₹ Lakh)',
|
||||
value: 1349.53,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
@@ -475,7 +475,7 @@ export class CimsXbrlDialogComponent {
|
||||
ref: 'ral:Asset.Money',
|
||||
section: 'Assets',
|
||||
label: 'Money at Call & Short Notice (₹ Lakh)',
|
||||
value: 12400,
|
||||
value: 0,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
@@ -483,7 +483,7 @@ export class CimsXbrlDialogComponent {
|
||||
ref: 'ral:Asset.Invest',
|
||||
section: 'Assets',
|
||||
label: 'Investments (₹ Lakh)',
|
||||
value: 184600,
|
||||
value: 2342.97,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
@@ -491,7 +491,7 @@ export class CimsXbrlDialogComponent {
|
||||
ref: 'ral:Asset.Adv',
|
||||
section: 'Assets',
|
||||
label: 'Advances (₹ Lakh)',
|
||||
value: 416265,
|
||||
value: 3912.88,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
@@ -499,23 +499,23 @@ export class CimsXbrlDialogComponent {
|
||||
ref: 'ral:Asset.Fixed',
|
||||
section: 'Assets',
|
||||
label: 'Fixed Assets (₹ Lakh)',
|
||||
value: 14200,
|
||||
value: 49.48,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
{
|
||||
ref: 'ral:Asset.Other',
|
||||
section: 'Assets',
|
||||
label: 'Other Assets (₹ Lakh)',
|
||||
value: 12480,
|
||||
label: 'Other Assets & Interest Receivable (₹ Lakh)',
|
||||
value: 354.38,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
{
|
||||
ref: 'ral:OBE.Guar',
|
||||
section: 'Off-Balance Sheet',
|
||||
label: 'Bank Guarantees (₹ Lakh)',
|
||||
value: 18420,
|
||||
label: 'Contingent Liabilities (₹ Lakh)',
|
||||
value: 26.38,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
@@ -523,7 +523,7 @@ export class CimsXbrlDialogComponent {
|
||||
ref: 'ral:OBE.LC',
|
||||
section: 'Off-Balance Sheet',
|
||||
label: 'Letters of Credit (₹ Lakh)',
|
||||
value: 6580,
|
||||
value: 0,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
}
|
||||
@@ -653,6 +653,293 @@ export class CimsXbrlDialogComponent {
|
||||
type: 'monetary'
|
||||
}
|
||||
]
|
||||
},
|
||||
'OSS-4': {
|
||||
code: 'OSS-4',
|
||||
name: 'OSS-4',
|
||||
fullName: 'Connected Lending & Large Exposure Statement',
|
||||
description:
|
||||
'Quarterly statement of loans to directors/relatives, senior officers, and exposures exceeding prudential thresholds. Illustrative figures — no source filing was provided for this return; values are modelled on this bank\'s own capital/advances scale for consistency.',
|
||||
cimsCode: 'CIMS_OSS_004',
|
||||
taxonomy: 'rbi-cims-oss4-2024-04-01',
|
||||
periodicity: 'Quarterly',
|
||||
lastFiled: '15-01-2026',
|
||||
nextDue: '15-04-2026',
|
||||
fields: [
|
||||
{
|
||||
ref: 'oss4:Connected.Directors',
|
||||
section: 'Connected Lending',
|
||||
label: 'Loans to Directors & Relatives (₹ Lakh)',
|
||||
value: 42.5,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
{
|
||||
ref: 'oss4:Connected.SrOfficers',
|
||||
section: 'Connected Lending',
|
||||
label: 'Loans to Senior Officers (₹ Lakh)',
|
||||
value: 18.75,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
{
|
||||
ref: 'oss4:Connected.Accounts',
|
||||
section: 'Connected Lending',
|
||||
label: 'Number of Connected Lending Accounts',
|
||||
value: 12,
|
||||
unit: 'Number',
|
||||
type: 'numeric'
|
||||
},
|
||||
{
|
||||
ref: 'oss4:LargeExp.Amount',
|
||||
section: 'Large Exposures',
|
||||
label: 'Exposures > 10% of Capital Funds (₹ Lakh)',
|
||||
value: 285.0,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
{
|
||||
ref: 'oss4:LargeExp.Pct',
|
||||
section: 'Large Exposures',
|
||||
label: 'Large Exposures as % of Capital Funds',
|
||||
value: 30.58,
|
||||
unit: 'Percent',
|
||||
type: 'percent'
|
||||
},
|
||||
{
|
||||
ref: 'oss4:LargeExp.SingleBreach',
|
||||
section: 'Large Exposures',
|
||||
label: 'Single Borrower Limit Breaches',
|
||||
value: 0,
|
||||
unit: 'Number',
|
||||
type: 'numeric'
|
||||
},
|
||||
{
|
||||
ref: 'oss4:LargeExp.GroupBreach',
|
||||
section: 'Large Exposures',
|
||||
label: 'Group Borrower Limit Breaches',
|
||||
value: 0,
|
||||
unit: 'Number',
|
||||
type: 'numeric'
|
||||
}
|
||||
]
|
||||
},
|
||||
'OSS-5': {
|
||||
code: 'OSS-5',
|
||||
name: 'OSS-5',
|
||||
fullName: 'Institutional & Membership Profile',
|
||||
description:
|
||||
'Annual statement of branch network, staff strength, membership and depositor/borrower base. Illustrative figures — no source filing was provided for this return; membership share capital is aligned to this bank\'s real paid-up capital figure.',
|
||||
cimsCode: 'CIMS_OSS_005',
|
||||
taxonomy: 'rbi-cims-oss5-2024-04-01',
|
||||
periodicity: 'Annual',
|
||||
lastFiled: '30-04-2025',
|
||||
nextDue: '30-04-2026',
|
||||
fields: [
|
||||
{
|
||||
ref: 'oss5:Profile.Branches',
|
||||
section: 'Network',
|
||||
label: 'Number of Branches',
|
||||
value: 8,
|
||||
unit: 'Number',
|
||||
type: 'numeric'
|
||||
},
|
||||
{
|
||||
ref: 'oss5:Profile.Employees',
|
||||
section: 'Network',
|
||||
label: 'Number of Employees',
|
||||
value: 64,
|
||||
unit: 'Number',
|
||||
type: 'numeric'
|
||||
},
|
||||
{
|
||||
ref: 'oss5:Profile.AreaOfOperation',
|
||||
section: 'Network',
|
||||
label: 'Area of Operation',
|
||||
value: 'Single District',
|
||||
type: 'string'
|
||||
},
|
||||
{
|
||||
ref: 'oss5:Profile.Members',
|
||||
section: 'Membership',
|
||||
label: 'Number of Regular Members',
|
||||
value: 18450,
|
||||
unit: 'Number',
|
||||
type: 'numeric'
|
||||
},
|
||||
{
|
||||
ref: 'oss5:Profile.ShareCapital',
|
||||
section: 'Membership',
|
||||
label: 'Membership Share Capital (₹ Lakh)',
|
||||
value: 272.78,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
{
|
||||
ref: 'oss5:Profile.Depositors',
|
||||
section: 'Customer Base',
|
||||
label: 'Number of Depositors',
|
||||
value: 42180,
|
||||
unit: 'Number',
|
||||
type: 'numeric'
|
||||
},
|
||||
{
|
||||
ref: 'oss5:Profile.Borrowers',
|
||||
section: 'Customer Base',
|
||||
label: 'Number of Borrowers',
|
||||
value: 3860,
|
||||
unit: 'Number',
|
||||
type: 'numeric'
|
||||
}
|
||||
]
|
||||
},
|
||||
'OSS-6': {
|
||||
code: 'OSS-6',
|
||||
name: 'OSS-6',
|
||||
fullName: 'Structural Liquidity & Interest Rate Sensitivity Statement',
|
||||
description:
|
||||
'Fortnightly maturity-bucket gap analysis of rate-sensitive assets and liabilities. Illustrative figures — no source filing was provided for this return.',
|
||||
cimsCode: 'CIMS_OSS_006',
|
||||
taxonomy: 'rbi-cims-oss6-2024-04-01',
|
||||
periodicity: 'Monthly',
|
||||
lastFiled: '14-04-2026',
|
||||
nextDue: '28-04-2026',
|
||||
fields: [
|
||||
{
|
||||
ref: 'oss6:Gap.1to14Days',
|
||||
section: 'Maturity Gap (₹ Lakh)',
|
||||
label: '1-14 Days',
|
||||
value: -45.2,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
{
|
||||
ref: 'oss6:Gap.15to28Days',
|
||||
section: 'Maturity Gap (₹ Lakh)',
|
||||
label: '15-28 Days',
|
||||
value: -12.8,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
{
|
||||
ref: 'oss6:Gap.29DaysTo3M',
|
||||
section: 'Maturity Gap (₹ Lakh)',
|
||||
label: '29 Days to 3 Months',
|
||||
value: 88.4,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
{
|
||||
ref: 'oss6:Gap.Over3To6M',
|
||||
section: 'Maturity Gap (₹ Lakh)',
|
||||
label: 'Over 3 Months to 6 Months',
|
||||
value: 62.15,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
{
|
||||
ref: 'oss6:Gap.Over6MTo1Y',
|
||||
section: 'Maturity Gap (₹ Lakh)',
|
||||
label: 'Over 6 Months to 1 Year',
|
||||
value: -28.9,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
{
|
||||
ref: 'oss6:Gap.Over1To3Y',
|
||||
section: 'Maturity Gap (₹ Lakh)',
|
||||
label: 'Over 1 Year to 3 Years',
|
||||
value: 210.6,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
{
|
||||
ref: 'oss6:Gap.Over3To5Y',
|
||||
section: 'Maturity Gap (₹ Lakh)',
|
||||
label: 'Over 3 Years to 5 Years',
|
||||
value: 95.3,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
{
|
||||
ref: 'oss6:Gap.Over5Y',
|
||||
section: 'Maturity Gap (₹ Lakh)',
|
||||
label: 'Over 5 Years',
|
||||
value: -18.75,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
{
|
||||
ref: 'oss6:Liq.CumNegGapPct',
|
||||
section: 'Liquidity Ratios',
|
||||
label: 'Cumulative Negative Gap (14-Day) as % of Cumulative Outflows',
|
||||
value: 4.85,
|
||||
unit: 'Percent',
|
||||
type: 'percent'
|
||||
}
|
||||
]
|
||||
},
|
||||
'OSS-7': {
|
||||
code: 'OSS-7',
|
||||
name: 'OSS-7',
|
||||
fullName: 'Statement of Frauds & Customer Complaints',
|
||||
description:
|
||||
'Quarterly statement of frauds reported and customer-complaint disposal. Illustrative figures — no source filing was provided for this return.',
|
||||
cimsCode: 'CIMS_OSS_007',
|
||||
taxonomy: 'rbi-cims-oss7-2024-04-01',
|
||||
periodicity: 'Quarterly',
|
||||
lastFiled: '15-01-2026',
|
||||
nextDue: '15-04-2026',
|
||||
fields: [
|
||||
{
|
||||
ref: 'oss7:Fraud.Count',
|
||||
section: 'Frauds',
|
||||
label: 'Number of Frauds Reported',
|
||||
value: 1,
|
||||
unit: 'Number',
|
||||
type: 'numeric'
|
||||
},
|
||||
{
|
||||
ref: 'oss7:Fraud.Amount',
|
||||
section: 'Frauds',
|
||||
label: 'Amount Involved in Frauds (₹ Lakh)',
|
||||
value: 2.4,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
{
|
||||
ref: 'oss7:Complaints.Received',
|
||||
section: 'Customer Complaints',
|
||||
label: 'Complaints Received (Quarter)',
|
||||
value: 14,
|
||||
unit: 'Number',
|
||||
type: 'numeric'
|
||||
},
|
||||
{
|
||||
ref: 'oss7:Complaints.Resolved',
|
||||
section: 'Customer Complaints',
|
||||
label: 'Complaints Resolved',
|
||||
value: 12,
|
||||
unit: 'Number',
|
||||
type: 'numeric'
|
||||
},
|
||||
{
|
||||
ref: 'oss7:Complaints.Pending',
|
||||
section: 'Customer Complaints',
|
||||
label: 'Complaints Pending',
|
||||
value: 2,
|
||||
unit: 'Number',
|
||||
type: 'numeric'
|
||||
},
|
||||
{
|
||||
ref: 'oss7:Complaints.PendingOver30',
|
||||
section: 'Customer Complaints',
|
||||
label: 'Complaints Pending > 30 Days',
|
||||
value: 0,
|
||||
unit: 'Number',
|
||||
type: 'numeric'
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
@@ -660,7 +947,11 @@ export class CimsXbrlDialogComponent {
|
||||
'OSS-2',
|
||||
'PSA',
|
||||
'RAL',
|
||||
'RCP-UCB'
|
||||
'RCP-UCB',
|
||||
'OSS-4',
|
||||
'OSS-5',
|
||||
'OSS-6',
|
||||
'OSS-7'
|
||||
];
|
||||
|
||||
data = inject<CimsXbrlDialogData | null>(MAT_DIALOG_DATA, { optional: true });
|
||||
|
||||
@@ -76,7 +76,7 @@ export class CkycReportsDialogComponent {
|
||||
activeReport: CkycReportCode;
|
||||
|
||||
bank = {
|
||||
name: 'Bharat Urban Bank Ltd',
|
||||
name: 'CBSNexora Bank Ltd',
|
||||
branch: 'Head Office',
|
||||
asOnDate: '29-01-2026',
|
||||
fiscalYear: '2025-26',
|
||||
@@ -84,7 +84,7 @@ export class CkycReportsDialogComponent {
|
||||
principalOfficer: 'Karthik Kamath',
|
||||
poDesignation: 'Principal Compliance Officer',
|
||||
reportRefNo: '2075/1',
|
||||
userCode: 'BUBL01',
|
||||
userCode: 'CBSN01',
|
||||
fyStart: '01-04-2025',
|
||||
fyEnd: '31-03-2026'
|
||||
};
|
||||
@@ -121,7 +121,7 @@ export class CkycReportsDialogComponent {
|
||||
|
||||
rekyc: RekycRow[] = [
|
||||
{
|
||||
ucode: 'BUBL000201',
|
||||
ucode: 'CBSN000201',
|
||||
name: 'Ravi Kumar Sharma',
|
||||
customerType: 'INDIVIDUAL',
|
||||
risk: 'LOW',
|
||||
@@ -132,7 +132,7 @@ export class CkycReportsDialogComponent {
|
||||
segment: 'BELOW-25-LACS'
|
||||
},
|
||||
{
|
||||
ucode: 'BUBL000418',
|
||||
ucode: 'CBSN000418',
|
||||
name: 'Priya Patel',
|
||||
customerType: 'INDIVIDUAL',
|
||||
risk: 'MEDIUM',
|
||||
@@ -143,7 +143,7 @@ export class CkycReportsDialogComponent {
|
||||
segment: 'ABOVE-25-LACS'
|
||||
},
|
||||
{
|
||||
ucode: 'BUBL000522',
|
||||
ucode: 'CBSN000522',
|
||||
name: 'Arjun Deshmukh',
|
||||
customerType: 'INDIVIDUAL',
|
||||
risk: 'LOW',
|
||||
@@ -154,7 +154,7 @@ export class CkycReportsDialogComponent {
|
||||
segment: 'BELOW-25-LACS'
|
||||
},
|
||||
{
|
||||
ucode: 'BUBL000711',
|
||||
ucode: 'CBSN000711',
|
||||
name: 'Vikram Reddy',
|
||||
customerType: 'INDIVIDUAL',
|
||||
risk: 'HIGH',
|
||||
@@ -165,7 +165,7 @@ export class CkycReportsDialogComponent {
|
||||
segment: 'ABOVE-25-LACS'
|
||||
},
|
||||
{
|
||||
ucode: 'BUBL000814',
|
||||
ucode: 'CBSN000814',
|
||||
name: 'Lakshmi Iyer',
|
||||
customerType: 'INDIVIDUAL',
|
||||
risk: 'LOW',
|
||||
@@ -176,7 +176,7 @@ export class CkycReportsDialogComponent {
|
||||
segment: 'BELOW-25-LACS'
|
||||
},
|
||||
{
|
||||
ucode: 'BUBL001012',
|
||||
ucode: 'CBSN001012',
|
||||
name: 'Nirav Shah',
|
||||
customerType: 'INDIVIDUAL',
|
||||
risk: 'MEDIUM',
|
||||
@@ -187,7 +187,7 @@ export class CkycReportsDialogComponent {
|
||||
segment: 'BELOW-25-LACS'
|
||||
},
|
||||
{
|
||||
ucode: 'BUBL001145',
|
||||
ucode: 'CBSN001145',
|
||||
name: 'Kavita Verma',
|
||||
customerType: 'INDIVIDUAL',
|
||||
risk: 'HIGH',
|
||||
@@ -198,7 +198,7 @@ export class CkycReportsDialogComponent {
|
||||
segment: 'ABOVE-25-LACS'
|
||||
},
|
||||
{
|
||||
ucode: 'BUBL001458',
|
||||
ucode: 'CBSN001458',
|
||||
name: 'Sameer Kapoor',
|
||||
customerType: 'INDIVIDUAL',
|
||||
risk: 'MEDIUM',
|
||||
@@ -209,7 +209,7 @@ export class CkycReportsDialogComponent {
|
||||
segment: 'ABOVE-25-LACS'
|
||||
},
|
||||
{
|
||||
ucode: 'BUBL001721',
|
||||
ucode: 'CBSN001721',
|
||||
name: 'Karthik Subramanian',
|
||||
customerType: 'INDIVIDUAL',
|
||||
risk: 'LOW',
|
||||
@@ -220,7 +220,7 @@ export class CkycReportsDialogComponent {
|
||||
segment: 'BELOW-25-LACS'
|
||||
},
|
||||
{
|
||||
ucode: 'BUBL002012',
|
||||
ucode: 'CBSN002012',
|
||||
name: 'Abhishek Banerjee',
|
||||
customerType: 'INDIVIDUAL',
|
||||
risk: 'MEDIUM',
|
||||
@@ -231,7 +231,7 @@ export class CkycReportsDialogComponent {
|
||||
segment: 'ABOVE-25-LACS'
|
||||
},
|
||||
{
|
||||
ucode: 'BUBL002314',
|
||||
ucode: 'CBSN002314',
|
||||
name: 'Gaurav Thakur',
|
||||
customerType: 'INDIVIDUAL',
|
||||
risk: 'LOW',
|
||||
@@ -242,7 +242,7 @@ export class CkycReportsDialogComponent {
|
||||
segment: 'BELOW-25-LACS'
|
||||
},
|
||||
{
|
||||
ucode: 'BUBL002615',
|
||||
ucode: 'CBSN002615',
|
||||
name: 'Manmohan Singh Gill',
|
||||
customerType: 'INDIVIDUAL',
|
||||
risk: 'HIGH',
|
||||
@@ -253,7 +253,7 @@ export class CkycReportsDialogComponent {
|
||||
segment: 'BELOW-25-LACS'
|
||||
},
|
||||
{
|
||||
ucode: 'BUBL002918',
|
||||
ucode: 'CBSN002918',
|
||||
name: 'Sunita Devi',
|
||||
customerType: 'INDIVIDUAL',
|
||||
risk: 'LOW',
|
||||
@@ -264,7 +264,7 @@ export class CkycReportsDialogComponent {
|
||||
segment: 'BELOW-25-LACS'
|
||||
},
|
||||
{
|
||||
ucode: 'BUBL003221',
|
||||
ucode: 'CBSN003221',
|
||||
name: 'Deepak Tiwari',
|
||||
customerType: 'INDIVIDUAL',
|
||||
risk: 'MEDIUM',
|
||||
@@ -275,7 +275,7 @@ export class CkycReportsDialogComponent {
|
||||
segment: 'ABOVE-25-LACS'
|
||||
},
|
||||
{
|
||||
ucode: 'BUBL003524',
|
||||
ucode: 'CBSN003524',
|
||||
name: 'Harpreet Kaur',
|
||||
customerType: 'INDIVIDUAL',
|
||||
risk: 'MEDIUM',
|
||||
@@ -286,7 +286,7 @@ export class CkycReportsDialogComponent {
|
||||
segment: 'ABOVE-25-LACS'
|
||||
},
|
||||
{
|
||||
ucode: 'BUBL003812',
|
||||
ucode: 'CBSN003812',
|
||||
name: 'Saraswati Mahila Co-op Society',
|
||||
customerType: 'SOCIETY',
|
||||
risk: 'MEDIUM',
|
||||
@@ -297,7 +297,7 @@ export class CkycReportsDialogComponent {
|
||||
segment: 'ABOVE-25-LACS'
|
||||
},
|
||||
{
|
||||
ucode: 'BUBL004115',
|
||||
ucode: 'CBSN004115',
|
||||
name: 'Aditya Enterprises',
|
||||
customerType: 'FIRM',
|
||||
risk: 'HIGH',
|
||||
@@ -308,7 +308,7 @@ export class CkycReportsDialogComponent {
|
||||
segment: 'ABOVE-25-LACS'
|
||||
},
|
||||
{
|
||||
ucode: 'BUBL004318',
|
||||
ucode: 'CBSN004318',
|
||||
name: 'Vasudha Trading HUF',
|
||||
customerType: 'HUF',
|
||||
risk: 'MEDIUM',
|
||||
@@ -332,7 +332,7 @@ export class CkycReportsDialogComponent {
|
||||
branchCode: '01',
|
||||
branch: 'Mumbai Main',
|
||||
acType: 'SB',
|
||||
clientId: 'BUBL000201',
|
||||
clientId: 'CBSN000201',
|
||||
customerName: 'Ravi Kumar Sharma',
|
||||
oldRisk: 'LOW',
|
||||
newRisk: 'LOW',
|
||||
@@ -345,7 +345,7 @@ export class CkycReportsDialogComponent {
|
||||
branchCode: '02',
|
||||
branch: 'Pune FC Road',
|
||||
acType: 'CA',
|
||||
clientId: 'BUBL000418',
|
||||
clientId: 'CBSN000418',
|
||||
customerName: 'Priya Patel',
|
||||
oldRisk: 'LOW',
|
||||
newRisk: 'MEDIUM',
|
||||
@@ -358,7 +358,7 @@ export class CkycReportsDialogComponent {
|
||||
branchCode: '03',
|
||||
branch: 'Nagpur Dharampeth',
|
||||
acType: 'TL',
|
||||
clientId: 'BUBL000522',
|
||||
clientId: 'CBSN000522',
|
||||
customerName: 'Arjun Deshmukh',
|
||||
oldRisk: 'MEDIUM',
|
||||
newRisk: 'LOW',
|
||||
@@ -371,7 +371,7 @@ export class CkycReportsDialogComponent {
|
||||
branchCode: '04',
|
||||
branch: 'Bengaluru Jayanagar',
|
||||
acType: 'OD',
|
||||
clientId: 'BUBL000711',
|
||||
clientId: 'CBSN000711',
|
||||
customerName: 'Vikram Reddy',
|
||||
oldRisk: 'MEDIUM',
|
||||
newRisk: 'HIGH',
|
||||
@@ -384,7 +384,7 @@ export class CkycReportsDialogComponent {
|
||||
branchCode: '05',
|
||||
branch: 'Bengaluru Indiranagar',
|
||||
acType: 'SB',
|
||||
clientId: 'BUBL000814',
|
||||
clientId: 'CBSN000814',
|
||||
customerName: 'Lakshmi Iyer',
|
||||
oldRisk: 'LOW',
|
||||
newRisk: 'LOW',
|
||||
@@ -397,7 +397,7 @@ export class CkycReportsDialogComponent {
|
||||
branchCode: '06',
|
||||
branch: 'Ahmedabad Satellite',
|
||||
acType: 'CC',
|
||||
clientId: 'BUBL001012',
|
||||
clientId: 'CBSN001012',
|
||||
customerName: 'Nirav Shah',
|
||||
oldRisk: 'LOW',
|
||||
newRisk: 'MEDIUM',
|
||||
@@ -410,7 +410,7 @@ export class CkycReportsDialogComponent {
|
||||
branchCode: '07',
|
||||
branch: 'Lucknow Gomti Nagar',
|
||||
acType: 'CA',
|
||||
clientId: 'BUBL001145',
|
||||
clientId: 'CBSN001145',
|
||||
customerName: 'Kavita Verma',
|
||||
oldRisk: 'MEDIUM',
|
||||
newRisk: 'HIGH',
|
||||
@@ -423,7 +423,7 @@ export class CkycReportsDialogComponent {
|
||||
branchCode: '08',
|
||||
branch: 'Delhi Greater Kailash',
|
||||
acType: 'OD',
|
||||
clientId: 'BUBL001458',
|
||||
clientId: 'CBSN001458',
|
||||
customerName: 'Sameer Kapoor',
|
||||
oldRisk: 'MEDIUM',
|
||||
newRisk: 'MEDIUM',
|
||||
@@ -436,7 +436,7 @@ export class CkycReportsDialogComponent {
|
||||
branchCode: '09',
|
||||
branch: 'Chennai Valasaravakkam',
|
||||
acType: 'SB',
|
||||
clientId: 'BUBL001721',
|
||||
clientId: 'CBSN001721',
|
||||
customerName: 'Karthik Subramanian',
|
||||
oldRisk: 'LOW',
|
||||
newRisk: 'LOW',
|
||||
@@ -449,7 +449,7 @@ export class CkycReportsDialogComponent {
|
||||
branchCode: '10',
|
||||
branch: 'Kolkata Salt Lake',
|
||||
acType: 'TL',
|
||||
clientId: 'BUBL002012',
|
||||
clientId: 'CBSN002012',
|
||||
customerName: 'Abhishek Banerjee',
|
||||
oldRisk: 'LOW',
|
||||
newRisk: 'MEDIUM',
|
||||
@@ -462,7 +462,7 @@ export class CkycReportsDialogComponent {
|
||||
branchCode: '11',
|
||||
branch: 'Shimla Sector 4',
|
||||
acType: 'SB',
|
||||
clientId: 'BUBL002314',
|
||||
clientId: 'CBSN002314',
|
||||
customerName: 'Gaurav Thakur',
|
||||
oldRisk: 'LOW',
|
||||
newRisk: 'LOW',
|
||||
@@ -475,7 +475,7 @@ export class CkycReportsDialogComponent {
|
||||
branchCode: '12',
|
||||
branch: 'Amritsar Model Town',
|
||||
acType: 'CC',
|
||||
clientId: 'BUBL002615',
|
||||
clientId: 'CBSN002615',
|
||||
customerName: 'Manmohan Singh Gill',
|
||||
oldRisk: 'HIGH',
|
||||
newRisk: 'HIGH',
|
||||
@@ -488,7 +488,7 @@ export class CkycReportsDialogComponent {
|
||||
branchCode: '13',
|
||||
branch: 'Bhopal Arera Colony',
|
||||
acType: 'SB',
|
||||
clientId: 'BUBL002918',
|
||||
clientId: 'CBSN002918',
|
||||
customerName: 'Sunita Devi',
|
||||
oldRisk: 'LOW',
|
||||
newRisk: 'LOW',
|
||||
@@ -501,7 +501,7 @@ export class CkycReportsDialogComponent {
|
||||
branchCode: '14',
|
||||
branch: 'Indore Vijay Nagar',
|
||||
acType: 'OD',
|
||||
clientId: 'BUBL003221',
|
||||
clientId: 'CBSN003221',
|
||||
customerName: 'Deepak Tiwari',
|
||||
oldRisk: 'MEDIUM',
|
||||
newRisk: 'HIGH',
|
||||
@@ -514,7 +514,7 @@ export class CkycReportsDialogComponent {
|
||||
branchCode: '15',
|
||||
branch: 'Ludhiana Sarabha Nagar',
|
||||
acType: 'TL',
|
||||
clientId: 'BUBL003524',
|
||||
clientId: 'CBSN003524',
|
||||
customerName: 'Harpreet Kaur',
|
||||
oldRisk: 'MEDIUM',
|
||||
newRisk: 'MEDIUM',
|
||||
|
||||
@@ -165,12 +165,12 @@
|
||||
<div class="da-compare">
|
||||
<div class="da-item">
|
||||
<div class="da-label">Total Deposits</div>
|
||||
<div class="da-value deposit">₹ 3,264.42 Cr</div>
|
||||
<div class="da-bar"><div class="da-fill deposit-fill" style="width: 78%"></div></div>
|
||||
<div class="da-value deposit">₹ 11,540.55 Cr</div>
|
||||
<div class="da-bar"><div class="da-fill deposit-fill" style="width: 71%"></div></div>
|
||||
</div>
|
||||
<div class="da-item">
|
||||
<div class="da-label">Total Advances</div>
|
||||
<div class="da-value advance">₹ 4,162.65 Cr</div>
|
||||
<div class="da-value advance">₹ 16,277.11 Cr</div>
|
||||
<div class="da-bar"><div class="da-fill advance-fill" style="width: 100%"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
<!--
|
||||
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="fdrd-dlg">
|
||||
<!-- HEADER -->
|
||||
<div class="fdrd-head">
|
||||
<div class="fdrd-head-left">
|
||||
<div class="fdrd-head-ic"><fa-icon icon="piggy-bank"></fa-icon></div>
|
||||
<div>
|
||||
<h2 class="fdrd-title">FD / RD Entry</h2>
|
||||
<p class="fdrd-sub">Open a new Fixed Deposit or Recurring Deposit account.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fdrd-head-actions">
|
||||
<span class="fdrd-meta"> <fa-icon icon="university"></fa-icon> {{ bank.name }} </span>
|
||||
<span class="fdrd-meta"> <fa-icon icon="map-marker-alt"></fa-icon> {{ bank.branch }} </span>
|
||||
<button class="fdrd-close" (click)="close()" matTooltip="Close" aria-label="Close">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fdrd-body">
|
||||
<!-- Success banner -->
|
||||
<div class="fdrd-success" *ngIf="saved">
|
||||
<fa-icon icon="check-circle"></fa-icon>
|
||||
<div>
|
||||
<strong>{{ lastAccountNo }}</strong> created for <strong>{{ lastClientName }}</strong>
|
||||
<div class="fdrd-success-sub">You can enter another deposit below, or close this dialog.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- FORM -->
|
||||
<div class="fdrd-section">
|
||||
<!-- Row 1: Deposit Info | Client & Amount -->
|
||||
<div class="ws-group-row">
|
||||
<div class="ws-group">
|
||||
<span class="ws-caption">Deposit Info</span>
|
||||
<div class="ws-group__fields">
|
||||
<div class="ws-f" style="width: 200px">
|
||||
<label class="ws-f__lbl">Deposit Type</label>
|
||||
<select class="ws-f__ctl" [(ngModel)]="depositType" (ngModelChange)="onDepositTypeChange()">
|
||||
<option *ngFor="let dt of depositTypes" [value]="dt.code">{{ dt.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="ws-f" style="width: 150px">
|
||||
<label class="ws-f__lbl">Pay Mode</label>
|
||||
<select class="ws-f__ctl" [(ngModel)]="payMode">
|
||||
<option *ngFor="let m of payModes" [value]="m">{{ m }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ws-vdivider"></div>
|
||||
|
||||
<div class="ws-group ws-group--flex">
|
||||
<span class="ws-caption">Client & Amount</span>
|
||||
<div class="ws-group__fields">
|
||||
<div class="ws-f" style="flex: 1.5; min-width: 180px">
|
||||
<label class="ws-f__lbl">Client</label>
|
||||
<select class="ws-f__ctl" [(ngModel)]="clientId">
|
||||
<option value="" disabled>-- Select Client --</option>
|
||||
<option *ngFor="let c of clients" [value]="c.id">{{ c.name }} ({{ c.id }})</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="ws-f" style="flex: 1; min-width: 120px">
|
||||
<label class="ws-f__lbl">{{ isRecurring ? 'Monthly Instalment (₹)' : 'Deposit Amount (₹)' }}</label>
|
||||
<input
|
||||
class="ws-f__ctl ws-f__ctl--mono"
|
||||
type="number"
|
||||
min="0"
|
||||
[(ngModel)]="principal"
|
||||
placeholder="e.g. 100000"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Row 2: Term & Rate | Instructions -->
|
||||
<div class="ws-group-row ws-group-row--mt">
|
||||
<div class="ws-group">
|
||||
<span class="ws-caption">Interest & Tenure</span>
|
||||
<div class="ws-group__fields">
|
||||
<div class="ws-f" style="width: 110px">
|
||||
<label class="ws-f__lbl">Int. Rate (% p.a.)</label>
|
||||
<input class="ws-f__ctl" type="number" min="0" step="0.05" [(ngModel)]="interestRate" />
|
||||
</div>
|
||||
<div class="ws-f" style="width: 240px">
|
||||
<label class="ws-f__lbl">Tenure</label>
|
||||
<div class="fdrd-tenure-row">
|
||||
<input class="ws-f__ctl" type="number" min="0" [(ngModel)]="tenureYears" />
|
||||
<span>Yrs</span>
|
||||
<input class="ws-f__ctl" type="number" min="0" max="11" [(ngModel)]="tenureMonths" />
|
||||
<span>Mo</span>
|
||||
<input class="ws-f__ctl" type="number" min="0" max="30" [(ngModel)]="tenureDays" />
|
||||
<span>Days</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ws-vdivider"></div>
|
||||
|
||||
<div class="ws-group ws-group--flex">
|
||||
<span class="ws-caption">Nominee & Maturity Details</span>
|
||||
<div class="ws-group__fields">
|
||||
<div class="ws-f" style="flex: 1; min-width: 120px">
|
||||
<label class="ws-f__lbl">Interest Payout</label>
|
||||
<select class="ws-f__ctl" [(ngModel)]="interestPayout">
|
||||
<option *ngFor="let o of interestPayoutOptions" [value]="o">{{ o }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="ws-f" style="flex: 1; min-width: 130px">
|
||||
<label class="ws-f__lbl">Nominee Name (optional)</label>
|
||||
<input class="ws-f__ctl" type="text" [(ngModel)]="nomineeName" placeholder="Spouse / Child name" />
|
||||
</div>
|
||||
<div class="ws-f" style="flex: 1.2; min-width: 160px">
|
||||
<label class="ws-f__lbl">Maturity Instruction</label>
|
||||
<select class="ws-f__ctl" [(ngModel)]="maturityInstruction">
|
||||
<option *ngFor="let m of maturityInstructions" [value]="m">{{ m }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SUMMARY -->
|
||||
<div class="fdrd-summary">
|
||||
<div class="fdrd-stat">
|
||||
<div class="fdrd-stat-l">Principal</div>
|
||||
<div class="fdrd-stat-v">₹ {{ principal || 0 | number: '1.0-2' }}</div>
|
||||
</div>
|
||||
<div class="fdrd-stat">
|
||||
<div class="fdrd-stat-l">Tenure</div>
|
||||
<div class="fdrd-stat-v">{{ tenureYears }}y {{ tenureMonths }}m {{ tenureDays }}d</div>
|
||||
</div>
|
||||
<div class="fdrd-stat">
|
||||
<div class="fdrd-stat-l">Maturity Date</div>
|
||||
<div class="fdrd-stat-v">{{ maturityDate | date: 'dd-MM-yyyy' }}</div>
|
||||
</div>
|
||||
<div class="fdrd-stat fdrd-stat-good">
|
||||
<div class="fdrd-stat-l">Maturity Amount</div>
|
||||
<div class="fdrd-stat-v">₹ {{ maturityAmount | number: '1.2-2' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- FOOTER -->
|
||||
<div class="fdrd-foot">
|
||||
<div class="fdrd-foot-l">
|
||||
<fa-icon icon="info-circle"></fa-icon> Demo entry — quarterly-compounded interest, no real account is
|
||||
created.
|
||||
</div>
|
||||
<div class="fdrd-foot-actions">
|
||||
<button mat-stroked-button class="fdrd-btn-ghost" (click)="resetForm()">
|
||||
<fa-icon icon="undo"></fa-icon> Reset
|
||||
</button>
|
||||
<button mat-stroked-button class="fdrd-btn-save" [disabled]="!isValid" (click)="save()">
|
||||
<fa-icon icon="save"></fa-icon> Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,395 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
font-family: 'Hanken Grotesk', Inter, sans-serif;
|
||||
color: #1a2a3e;
|
||||
}
|
||||
|
||||
.fdrd-dlg {
|
||||
background: #fff;
|
||||
border-radius: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* HEADER - Matches .ws-header in clerk dashboard */
|
||||
.fdrd-head {
|
||||
padding: 0 12px;
|
||||
height: 32px;
|
||||
background: linear-gradient(90deg, #0d1b2e 0%, #162542 100%);
|
||||
border-bottom: 2px solid #e87722;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.fdrd-head-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.fdrd-head-ic {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 6px;
|
||||
background: #e87722;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.fdrd-title {
|
||||
margin: 0;
|
||||
font-family: Newsreader, Georgia, serif;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #e8f0fb;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.fdrd-sub {
|
||||
display: none; /* Hide subtitle in header to match clean style */
|
||||
}
|
||||
|
||||
.fdrd-head-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.fdrd-meta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-size: 11px;
|
||||
color: #7a95b0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
|
||||
fa-icon {
|
||||
color: #5a7589;
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.fdrd-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #7a95b0;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: rgb(255 255 255 / 10%);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
/* BODY - Styled like canvas + card grid */
|
||||
.fdrd-body {
|
||||
padding: 16px 20px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
background: #f0f4f9;
|
||||
}
|
||||
|
||||
.fdrd-success {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 10px;
|
||||
background: #dcfce7;
|
||||
color: #15803d;
|
||||
margin-bottom: 14px;
|
||||
font-size: 13px;
|
||||
|
||||
fa-icon {
|
||||
font-size: 16px;
|
||||
margin-top: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.fdrd-success-sub {
|
||||
margin-top: 2px;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* Card container for form */
|
||||
.fdrd-section {
|
||||
background: #fff;
|
||||
border: 1px solid #dce4ef;
|
||||
border-radius: 10px;
|
||||
padding: 16px 20px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* Transaction Entry Style Layout mappings */
|
||||
.ws-group-row {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
|
||||
&--mt {
|
||||
margin-top: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.ws-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
|
||||
&--flex {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__fields {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
flex-wrap: nowrap;
|
||||
height: 100%;
|
||||
align-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
.ws-caption {
|
||||
font-family: Newsreader, Georgia, serif;
|
||||
font-style: italic;
|
||||
font-size: 11px;
|
||||
color: #7a95b0;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.ws-vdivider {
|
||||
width: 1px;
|
||||
background: #dce4ef;
|
||||
margin: 12px 10px 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ws-f {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
flex-shrink: 0;
|
||||
|
||||
&__lbl {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #7a95b0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__ctl {
|
||||
height: 26px;
|
||||
padding: 0 7px;
|
||||
border: 1px solid #c8d8eb;
|
||||
border-radius: 6px;
|
||||
font-family: 'Hanken Grotesk', Inter, sans-serif;
|
||||
font-size: 11.5px;
|
||||
color: #1a2a3e;
|
||||
background: #f4f7fc;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
box-shadow 0.15s;
|
||||
|
||||
&:hover {
|
||||
border-color: #9ab5d0;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #2563eb;
|
||||
box-shadow: 0 0 0 3px rgb(37 99 235 / 12%);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
&--mono {
|
||||
font-family: 'Roboto Mono', monospace;
|
||||
font-size: 11.5px;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.fdrd-tenure-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
|
||||
.ws-f__ctl {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: #7a95b0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
/* SUMMARY - Styled like info summary block */
|
||||
.fdrd-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
background: #f4f7fc;
|
||||
border: 1px solid #c8d8eb;
|
||||
border-radius: 10px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.fdrd-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.fdrd-stat-l {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: #7a95b0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.fdrd-stat-v {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #1a2a3e;
|
||||
}
|
||||
|
||||
.fdrd-stat-good .fdrd-stat-v {
|
||||
color: #1a7a4a;
|
||||
}
|
||||
|
||||
/* FOOTER - Styled like action panel */
|
||||
.fdrd-foot {
|
||||
padding: 10px 16px;
|
||||
border-top: 1px solid #dce4ef;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
background: #f0f4f9;
|
||||
}
|
||||
|
||||
.fdrd-foot-l {
|
||||
font-size: 11px;
|
||||
color: #7a95b0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.fdrd-foot-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.fdrd-btn-ghost {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 28px;
|
||||
padding: 0 12px !important;
|
||||
border-radius: 7px;
|
||||
border: 1px solid #dce4ef !important;
|
||||
background: #fff !important;
|
||||
color: #1a2a3e !important;
|
||||
font-family: 'Hanken Grotesk', sans-serif;
|
||||
font-size: 11.5px !important;
|
||||
font-weight: 600 !important;
|
||||
cursor: pointer;
|
||||
box-shadow: none !important;
|
||||
|
||||
&:hover {
|
||||
background: #f0f4f9 !important;
|
||||
border-color: #a8bdd4 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.fdrd-btn-save {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 28px;
|
||||
padding: 0 16px !important;
|
||||
border-radius: 7px;
|
||||
border: none !important;
|
||||
background: #e87722 !important;
|
||||
color: #fff !important;
|
||||
font-family: 'Hanken Grotesk', sans-serif;
|
||||
font-size: 11.5px !important;
|
||||
font-weight: 700 !important;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 8px rgb(232 119 34 / 30%) !important;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
background: #c8d8eb !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 768px) {
|
||||
.ws-group-row {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.ws-vdivider {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ws-group__fields {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.fdrd-summary {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
@@ -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, DecimalPipe } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatDialogRef, MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
|
||||
type DepositType = 'FD-SINGLE' | 'FD-JOINT' | 'RD-MONTHLY' | 'RD-QUARTERLY';
|
||||
|
||||
interface DemoClient {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-fd-rd-entry-dialog',
|
||||
standalone: true,
|
||||
templateUrl: './fd-rd-entry-dialog.component.html',
|
||||
styleUrls: ['./fd-rd-entry-dialog.component.scss'],
|
||||
imports: [CommonModule, DecimalPipe, FormsModule, MatDialogModule, MatButtonModule, MatTooltipModule, FaIconComponent]
|
||||
})
|
||||
export class FdRdEntryDialogComponent {
|
||||
private dialogRef = inject(MatDialogRef<FdRdEntryDialogComponent>);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
|
||||
bank = {
|
||||
name: 'CBSNexora Bank Ltd',
|
||||
branch: 'Head Office'
|
||||
};
|
||||
|
||||
depositTypes: { code: DepositType; label: string; defaultRate: number }[] = [
|
||||
{ code: 'FD-SINGLE', label: 'Fixed Deposit — Single A/c', defaultRate: 7.25 },
|
||||
{ code: 'FD-JOINT', label: 'Fixed Deposit — Joint A/c', defaultRate: 7.25 },
|
||||
{ code: 'RD-MONTHLY', label: 'Recurring Deposit — Monthly', defaultRate: 7.0 },
|
||||
{ code: 'RD-QUARTERLY', label: 'Recurring Deposit — Quarterly', defaultRate: 7.0 }
|
||||
];
|
||||
|
||||
clients: DemoClient[] = [
|
||||
{ id: 'C-10231', name: 'Ramesh Kumar Sharma' },
|
||||
{ id: 'C-10344', name: 'Priya Enterprises' },
|
||||
{ id: 'C-10412', name: 'Anjali Verma' },
|
||||
{ id: 'C-10567', name: 'Suresh & Sons HUF' },
|
||||
{ id: 'C-10689', name: 'Vikram Traders' }
|
||||
];
|
||||
|
||||
payModes = ['Cash', 'Cheque', 'Transfer from Savings A/c'];
|
||||
interestPayoutOptions = ['Cumulative (Paid on Maturity)', 'Monthly Payout', 'Quarterly Payout'];
|
||||
maturityInstructions = [
|
||||
'Renew Principal & Interest',
|
||||
'Renew Principal Only, Payout Interest',
|
||||
'Payout on Maturity (No Renewal)'
|
||||
];
|
||||
|
||||
depositType: DepositType = 'FD-SINGLE';
|
||||
clientId = '';
|
||||
principal: number | null = null;
|
||||
interestRate = 7.25;
|
||||
tenureYears = 1;
|
||||
tenureMonths = 0;
|
||||
tenureDays = 0;
|
||||
interestPayout = this.interestPayoutOptions[0];
|
||||
payMode = this.payModes[0];
|
||||
nomineeName = '';
|
||||
maturityInstruction = this.maturityInstructions[0];
|
||||
|
||||
saved = false;
|
||||
lastAccountNo = '';
|
||||
lastClientName = '';
|
||||
|
||||
get selectedClient(): DemoClient | undefined {
|
||||
return this.clients.find((c) => c.id === this.clientId);
|
||||
}
|
||||
|
||||
onDepositTypeChange(): void {
|
||||
const dt = this.depositTypes.find((d) => d.code === this.depositType);
|
||||
if (dt) this.interestRate = dt.defaultRate;
|
||||
}
|
||||
|
||||
get isRecurring(): boolean {
|
||||
return this.depositType.startsWith('RD');
|
||||
}
|
||||
|
||||
get tenureInYears(): number {
|
||||
return this.tenureYears + this.tenureMonths / 12 + this.tenureDays / 365;
|
||||
}
|
||||
|
||||
get maturityDate(): Date {
|
||||
const d = new Date();
|
||||
d.setFullYear(d.getFullYear() + this.tenureYears);
|
||||
d.setMonth(d.getMonth() + this.tenureMonths);
|
||||
d.setDate(d.getDate() + this.tenureDays);
|
||||
return d;
|
||||
}
|
||||
|
||||
/** Quarterly-compounded maturity value, the standard FD/RD convention */
|
||||
get maturityAmount(): number {
|
||||
const p = this.principal || 0;
|
||||
const t = this.tenureInYears;
|
||||
if (p <= 0 || t <= 0) return 0;
|
||||
return p * Math.pow(1 + this.interestRate / 100 / 4, 4 * t);
|
||||
}
|
||||
|
||||
get isValid(): boolean {
|
||||
return !!this.clientId && (this.principal ?? 0) > 0 && this.tenureInYears > 0;
|
||||
}
|
||||
|
||||
save(): void {
|
||||
if (!this.isValid) return;
|
||||
const prefix = this.isRecurring ? 'RD' : 'FD';
|
||||
this.lastAccountNo = `${prefix}${Math.floor(1000000 + Math.random() * 8999999)}`;
|
||||
this.lastClientName = this.selectedClient?.name ?? '';
|
||||
this.saved = true;
|
||||
this.snackBar.open(`${prefix} account ${this.lastAccountNo} created for ${this.lastClientName}`, 'Close', {
|
||||
duration: 4000
|
||||
});
|
||||
}
|
||||
|
||||
resetForm(): void {
|
||||
this.depositType = 'FD-SINGLE';
|
||||
this.clientId = '';
|
||||
this.principal = null;
|
||||
this.interestRate = 7.25;
|
||||
this.tenureYears = 1;
|
||||
this.tenureMonths = 0;
|
||||
this.tenureDays = 0;
|
||||
this.nomineeName = '';
|
||||
this.interestPayout = this.interestPayoutOptions[0];
|
||||
this.payMode = this.payModes[0];
|
||||
this.maturityInstruction = this.maturityInstructions[0];
|
||||
this.saved = false;
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
}
|
||||
@@ -63,7 +63,7 @@ export class PslReportDialogComponent {
|
||||
activeTab: 'dashboard' | 'categories' | 'register' | 'returns' = 'dashboard';
|
||||
|
||||
bank = {
|
||||
name: 'Bharat Urban Bank Ltd',
|
||||
name: 'CBSNexora Bank Ltd',
|
||||
branch: 'Head Office',
|
||||
quarter: 'Q3 FY 2025-26',
|
||||
asOnDate: '29-01-2026',
|
||||
|
||||
@@ -7,7 +7,16 @@
|
||||
-->
|
||||
|
||||
<h1 mat-dialog-title>{{ 'labels.heading.Session Timeout' | translate }}</h1>
|
||||
<mat-dialog-content>{{ 'labels.text.Session timed out after a period of inactivity' | translate }}.</mat-dialog-content>
|
||||
<mat-dialog-actions>
|
||||
<button mat-raised-button color="primary" mat-dialog-close>{{ 'labels.buttons.Close' | translate }}</button>
|
||||
<mat-dialog-content>
|
||||
<div style="display: flex; flex-direction: column; gap: 8px; padding-top: 8px">
|
||||
<p>{{ 'labels.text.Session timed out after a period of inactivity' | translate }}.</p>
|
||||
<p>
|
||||
For your security, you will be logged out in
|
||||
<strong style="color: #f44336; font-size: 1.1em">{{ countdown }}</strong> seconds.
|
||||
</p>
|
||||
</div>
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions align="end" style="gap: 8px">
|
||||
<button mat-button color="warn" (click)="logoutNow()">Logout</button>
|
||||
<button mat-raised-button color="primary" (click)="keepSessionActive()">Stay Logged In</button>
|
||||
</mat-dialog-actions>
|
||||
|
||||
@@ -6,13 +6,14 @@
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { Component, inject, OnInit, OnDestroy } from '@angular/core';
|
||||
import {
|
||||
MatDialogRef,
|
||||
MatDialogTitle,
|
||||
MatDialogContent,
|
||||
MatDialogActions,
|
||||
MatDialogClose
|
||||
MatDialogClose,
|
||||
MAT_DIALOG_DATA
|
||||
} from '@angular/material/dialog';
|
||||
import { CdkScrollable } from '@angular/cdk/scrolling';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
@@ -30,6 +31,43 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
MatDialogClose
|
||||
]
|
||||
})
|
||||
export class SessionTimeoutDialogComponent {
|
||||
export class SessionTimeoutDialogComponent implements OnInit, OnDestroy {
|
||||
dialogRef = inject<MatDialogRef<SessionTimeoutDialogComponent>>(MatDialogRef);
|
||||
data = inject<{ countdownSeconds?: number }>(MAT_DIALOG_DATA, { optional: true });
|
||||
|
||||
countdown = this.data?.countdownSeconds ?? 60;
|
||||
private intervalId: any;
|
||||
|
||||
ngOnInit() {
|
||||
this.startCountdown();
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.clearTimer();
|
||||
}
|
||||
|
||||
private startCountdown() {
|
||||
this.intervalId = setInterval(() => {
|
||||
this.countdown--;
|
||||
if (this.countdown <= 0) {
|
||||
this.clearTimer();
|
||||
this.dialogRef.close('logout');
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
private clearTimer() {
|
||||
if (this.intervalId) {
|
||||
clearInterval(this.intervalId);
|
||||
this.intervalId = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
keepSessionActive() {
|
||||
this.dialogRef.close('keep-alive');
|
||||
}
|
||||
|
||||
logoutNow() {
|
||||
this.dialogRef.close('logout');
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user