Merge branch 'demo' of https://git.lincpay.tech/CBSNexora/cbsnexora into demo
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -587,6 +587,10 @@
|
||||
<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>
|
||||
</mat-menu>
|
||||
|
||||
<!-- Admin Mega Menu — 3-column rectangular panel grouping Setup,
|
||||
@@ -865,20 +869,34 @@
|
||||
</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"
|
||||
@@ -894,8 +912,9 @@
|
||||
<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"
|
||||
@@ -905,17 +924,29 @@
|
||||
><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"
|
||||
@@ -1565,6 +1596,9 @@
|
||||
<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>
|
||||
|
||||
+24
-2
@@ -20,7 +20,15 @@
|
||||
*/
|
||||
|
||||
export type ReportType = 'CTR' | 'STR' | 'CCR' | 'NTR' | 'CBWTR';
|
||||
export type StrStatus = 'draft' | 'po-review' | 'filed' | 'rejected';
|
||||
/**
|
||||
* Lifecycle of an STR case.
|
||||
* draft — raised, not yet with the Principal Officer
|
||||
* po-review — under Principal Officer investigation
|
||||
* awaiting-filing — concluded suspicious; STR XML must still be submitted to FIU-IND
|
||||
* filed — submitted and FIU-IND acknowledgement recorded
|
||||
* rejected — concluded clean; nothing filed
|
||||
*/
|
||||
export type StrStatus = 'draft' | 'po-review' | 'awaiting-filing' | 'filed' | 'rejected';
|
||||
|
||||
/** TC2 — Branch / cash transaction record */
|
||||
export interface CtrTransaction {
|
||||
@@ -42,7 +50,17 @@ export interface CtrTransaction {
|
||||
declaration: string;
|
||||
/** Customer name resolved from KC1 (UI convenience, not in actual TC2 schema) */
|
||||
customerName?: string;
|
||||
complianceStatus?: 'PENDING' | 'WHITELISTED' | 'SUSPICIOUS';
|
||||
/**
|
||||
* CTR does not judge suspicion. It records only whether the source of funds was
|
||||
* evidenced at the counter (CTR filed, nothing further) or could not be, in which
|
||||
* case the transaction is referred to STR. The suspicious/clean verdict itself
|
||||
* belongs to the Principal Officer on the STR side.
|
||||
*/
|
||||
complianceStatus?: 'PENDING' | 'EVIDENCED' | 'REFERRED';
|
||||
/** Document that evidenced the source of funds, when verified at the counter. */
|
||||
evidenceDocType?: string;
|
||||
evidenceReference?: string;
|
||||
evidencedOn?: string;
|
||||
}
|
||||
|
||||
/** Outcome of the Principal Officer's investigation into an STR case */
|
||||
@@ -64,6 +82,10 @@ export interface StrTransaction extends CtrTransaction {
|
||||
poRemarks?: string;
|
||||
/** Set once the Principal Officer concludes the investigation */
|
||||
poFinding?: PoFinding;
|
||||
/** FIU-IND acknowledgement number, recorded only after the STR is actually submitted */
|
||||
fiuReferenceNo?: string;
|
||||
/** Date the STR was submitted on the FINnet portal */
|
||||
filedOn?: string;
|
||||
}
|
||||
|
||||
/** KC1 — Individual customer card (subset of the 70+ fields, only what UI shows) */
|
||||
|
||||
+274
-47
@@ -141,7 +141,8 @@
|
||||
<div class="fiu-form-title">
|
||||
<h3>Live CTR Threshold Alerts</h3>
|
||||
<p>
|
||||
Mechanical cash transaction threshold breaches — review and whitelist or escalate to Principal Officer
|
||||
Mechanical cash threshold breaches. CTR is filed regardless of outcome — where the source of funds cannot be
|
||||
evidenced, send the transaction to STR for the Principal Officer to investigate.
|
||||
</p>
|
||||
</div>
|
||||
<select class="fiu-inp" [(ngModel)]="amlAlertStatusFilter" (ngModelChange)="loadAmlAlerts()">
|
||||
@@ -198,8 +199,7 @@
|
||||
<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>
|
||||
<button class="fiu-btn fiu-btn-xml" (click)="escalateAlert(alert)">Send to STR</button>
|
||||
</div>
|
||||
}
|
||||
</td>
|
||||
@@ -213,9 +213,9 @@
|
||||
</table>
|
||||
</div>
|
||||
<p class="fiu-tbl-legend">
|
||||
<span class="report-pill report-pending">Pending</span> awaiting Whitelist/Escalate decision ·
|
||||
<span class="report-pill report-ctr">CTR only</span> whitelisted — cleared, no STR needed ·
|
||||
<span class="report-pill report-str">→ STR</span> escalated to Principal Officer as an STR case
|
||||
<span class="report-pill report-pending">Pending</span> cash breach awaiting review ·
|
||||
<span class="report-pill report-str">Referred to STR</span> source of funds not evidenced — sent to the
|
||||
Principal Officer, who decides there whether to whitelist it or file an STR
|
||||
</p>
|
||||
|
||||
<!-- LIVE: monthly CTR reports (mechanical, threshold-based) -->
|
||||
@@ -347,32 +347,75 @@
|
||||
</td>
|
||||
<td class="ta-c">
|
||||
<span class="report-pill report-ctr" title="Always included in the monthly CTR / TC2 file">CTR</span>
|
||||
@if (t.complianceStatus === 'SUSPICIOUS') {
|
||||
<span class="report-pill report-str" title="Also escalated to an STR case for Principal Officer review">+ STR</span>
|
||||
@if (t.complianceStatus === 'REFERRED') {
|
||||
<span
|
||||
class="report-pill report-str"
|
||||
title="Also under investigation as an STR case — the transaction remains in the CTR file"
|
||||
>+ STR</span
|
||||
>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@if (!t.complianceStatus || t.complianceStatus === 'PENDING') {
|
||||
<div class="fiu-action-group" style="gap: 4px;">
|
||||
<button class="fiu-btn" style="padding: 4px 8px; font-size: 11px; color: var(--f-green); border-color: var(--f-green); background: var(--f-green-l);" (click)="whitelistCtrTxn(t)">
|
||||
Whitelist
|
||||
</button>
|
||||
<button class="fiu-btn" style="padding: 4px 8px; font-size: 11px; color: var(--f-red); border-color: var(--f-red); background: var(--f-red-l);" (click)="flagCtrTxnSuspicious(t)">
|
||||
Flag STR
|
||||
</button>
|
||||
@if (t.complianceStatus === 'REFERRED') {
|
||||
<span class="status-pill status-escalated">REFERRED TO STR</span>
|
||||
<button class="fiu-link-btn" (click)="openStrCase(t)">View in STR tab →</button>
|
||||
} @else if (t.complianceStatus === 'EVIDENCED') {
|
||||
<span class="status-pill status-whitelisted">EVIDENCE ON FILE</span>
|
||||
<div class="muted" style="margin-top: 3px">
|
||||
{{ t.evidenceDocType }}
|
||||
@if (t.evidenceReference) {
|
||||
<span> · {{ t.evidenceReference }}</span>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<span [class]="t.complianceStatus === 'WHITELISTED' ? 'status-pill status-whitelisted' : 'status-pill status-escalated'">
|
||||
{{ t.complianceStatus }}
|
||||
</span>
|
||||
@if (t.complianceStatus === 'WHITELISTED') {
|
||||
<div class="muted" style="margin-top: 3px;">Stays in CTR only</div>
|
||||
} @else {
|
||||
<button class="fiu-link-btn" (click)="openStrCase(t)">View in STR tab →</button>
|
||||
}
|
||||
<div class="fiu-action-group" style="gap: 4px" *mifosxHasPermission="'REVIEW_AML_ALERT'">
|
||||
<button class="fiu-btn fiu-btn-evidence" (click)="startCtrEvidence(t)">Record evidence</button>
|
||||
<button class="fiu-btn fiu-btn-refer" (click)="referCtrTxnToStr(t)">Send to STR</button>
|
||||
</div>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Inline evidence capture for this transaction -->
|
||||
@if (evidencingTxn?.txnId === t.txnId) {
|
||||
<tr class="evidence-row">
|
||||
<td colspan="12">
|
||||
<div class="evidence-panel">
|
||||
<div class="evidence-t">
|
||||
<fa-icon icon="check-circle"></fa-icon>
|
||||
Record source-of-funds evidence for {{ t.customerName }} — ₹{{
|
||||
t.txnAmount | number: '1.0-0'
|
||||
}}
|
||||
</div>
|
||||
<div class="evidence-grid">
|
||||
<div class="form-row">
|
||||
<label for="ctrDoc{{ t.txnId }}">Document produced</label>
|
||||
<select id="ctrDoc{{ t.txnId }}" class="fiu-inp" [(ngModel)]="ctrEvidenceDocType">
|
||||
<option value="">Select document…</option>
|
||||
@for (d of evidenceDocTypes; track d) {
|
||||
<option [value]="d">{{ d }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="ctrRef{{ t.txnId }}">Document / reference number</label>
|
||||
<input id="ctrRef{{ t.txnId }}" class="fiu-inp" type="text" [(ngModel)]="ctrEvidenceRef" />
|
||||
</div>
|
||||
<div class="fiu-action-group">
|
||||
<button class="fiu-btn fiu-btn-evidence" (click)="confirmCtrEvidence()">
|
||||
Confirm — CTR only
|
||||
</button>
|
||||
<button class="fiu-btn" (click)="cancelCtrEvidence()">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="evidence-note">
|
||||
Recording evidence is a documentation step, not a suspicion verdict. The transaction is reported
|
||||
in the CTR either way — this simply confirms no STR referral is needed.
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="11" class="fiu-empty">No transactions match the filters.</td>
|
||||
@@ -382,10 +425,11 @@
|
||||
</table>
|
||||
</div>
|
||||
<p class="fiu-tbl-legend">
|
||||
<span class="report-pill report-ctr">CTR</span> every cash txn ≥ threshold stays in the monthly CTR / TC2
|
||||
file ·
|
||||
<span class="report-pill report-str">+ STR</span> also flagged as suspicious and escalated to a separate STR
|
||||
case (see the STR tab)
|
||||
<span class="report-pill report-ctr">CTR</span> every cash txn ≥ threshold is reported here regardless of
|
||||
outcome. <strong>Record evidence</strong> when the customer produces valid documents — CTR only, no STR ·
|
||||
<span class="report-pill report-str">+ STR</span> <strong>Send to STR</strong> when the source of funds cannot
|
||||
be evidenced; the Principal Officer investigates and decides whether to file with FIU-IND. A referred
|
||||
transaction stays in the CTR file as well.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
@@ -397,9 +441,7 @@
|
||||
<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>
|
||||
<p>Flagged by the monitoring engine for suspicious behavioral patterns — triage and whitelist or escalate</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fiu-tbl-wrap">
|
||||
@@ -468,7 +510,7 @@
|
||||
<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;" />
|
||||
<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">
|
||||
@@ -627,23 +669,26 @@
|
||||
} @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.
|
||||
<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) {
|
||||
{{ 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') : '…' }}
|
||||
Conclude as
|
||||
{{ allAnswered ? (derivedConclusion === 'SUSPICIOUS' ? 'Suspicious' : 'Not Suspicious') : '…' }}
|
||||
</button>
|
||||
|
||||
@if (selectedStrCase.status === 'CONCLUDED_SUSPICIOUS') {
|
||||
@@ -675,8 +720,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">
|
||||
@@ -752,17 +798,198 @@
|
||||
</label>
|
||||
<p>{{ poFindingLabel(s.poFinding) }}</p>
|
||||
</div>
|
||||
} @else {
|
||||
}
|
||||
|
||||
<!-- 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>
|
||||
<div class="fiu-action-group">
|
||||
<button class="fiu-btn fiu-btn-clean" (click)="concludeLocalStr(s, 'CLEAN')">
|
||||
Clean — close case
|
||||
</button>
|
||||
<button class="fiu-btn fiu-btn-xml" (click)="concludeLocalStr(s, 'SUSPICIOUS')">
|
||||
Suspicious — file STR with FIU-IND
|
||||
<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>
|
||||
@@ -871,8 +1098,8 @@
|
||||
<div class="fiu-form-title">
|
||||
<h3>FINnet 2.0 Submission Files</h3>
|
||||
<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.
|
||||
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()">
|
||||
|
||||
@@ -386,6 +386,78 @@
|
||||
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;
|
||||
@@ -708,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);
|
||||
@@ -1306,6 +1383,128 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* 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;
|
||||
|
||||
+218
-37
@@ -135,6 +135,54 @@ export class FiuComponent implements OnInit {
|
||||
/** null = not yet answered, true = genuine, false = not satisfied. */
|
||||
strFindings: Record<string, boolean | null> = {};
|
||||
|
||||
// ── Investigation of a CTR-referred STR case ───────────────────────────────
|
||||
/** The local STR card currently under investigation, if any. */
|
||||
investigatingStr: StrTransaction | null = null;
|
||||
|
||||
/** Documents the Principal Officer can call for to evidence the source of funds. */
|
||||
readonly evidenceDocTypes: string[] = [
|
||||
'Sale deed / agreement for immovable property',
|
||||
'Salary slips and Form 16',
|
||||
'Audited financials / ITR of the business',
|
||||
'Loan sanction or disbursement letter',
|
||||
'Gift deed with donor PAN and relationship proof',
|
||||
'Maturity advice of a deposit or insurance policy',
|
||||
'Sale bill for gold, vehicle or other asset',
|
||||
'Agricultural income certificate / mandi receipts',
|
||||
'No document produced by the customer'
|
||||
];
|
||||
|
||||
evidenceDocType = '';
|
||||
evidenceReceived: 'YES' | 'PARTIAL' | 'NO' | '' = '';
|
||||
evidenceReference = '';
|
||||
customerExplanation = '';
|
||||
|
||||
/** Opens the investigation panel for a CTR-referred case with a clean slate. */
|
||||
investigateLocalStr(rec: StrTransaction): void {
|
||||
this.investigatingStr = rec;
|
||||
this.selectedStrCase = null;
|
||||
this.resetChecklist();
|
||||
this.evidenceDocType = '';
|
||||
this.evidenceReceived = '';
|
||||
this.evidenceReference = '';
|
||||
this.customerExplanation = '';
|
||||
this.concludeInvestigationNotes = '';
|
||||
this.concludeSourceOfFundsNotes = '';
|
||||
}
|
||||
|
||||
closeLocalStrInvestigation(): void {
|
||||
this.investigatingStr = null;
|
||||
}
|
||||
|
||||
/** Documentary evidence is a precondition for clearing a case, not an optional note. */
|
||||
get evidenceRecorded(): boolean {
|
||||
return !!this.evidenceDocType && !!this.evidenceReceived;
|
||||
}
|
||||
|
||||
get canConcludeLocal(): boolean {
|
||||
return this.evidenceRecorded && this.allAnswered && !!this.customerExplanation.trim();
|
||||
}
|
||||
|
||||
private resetChecklist(): void {
|
||||
this.strFindings = {};
|
||||
for (const item of this.strChecklist) {
|
||||
@@ -202,14 +250,51 @@ export class FiuComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
whitelistCtrTxn(txn: CtrTransaction): void {
|
||||
txn.complianceStatus = 'WHITELISTED';
|
||||
localStorage.setItem('fiu_ctr_txns', JSON.stringify(this.ctrTxns));
|
||||
this.toast(`Transaction for ${txn.customerName} whitelisted — stays in the CTR report only`);
|
||||
// ── CTR-side evidence capture ──────────────────────────────────────────────
|
||||
/** Transaction whose source-of-funds evidence is being recorded, if any. */
|
||||
evidencingTxn: CtrTransaction | null = null;
|
||||
ctrEvidenceDocType = '';
|
||||
ctrEvidenceRef = '';
|
||||
|
||||
startCtrEvidence(txn: CtrTransaction): void {
|
||||
this.evidencingTxn = txn;
|
||||
this.ctrEvidenceDocType = '';
|
||||
this.ctrEvidenceRef = '';
|
||||
}
|
||||
|
||||
flagCtrTxnSuspicious(txn: CtrTransaction): void {
|
||||
txn.complianceStatus = 'SUSPICIOUS';
|
||||
cancelCtrEvidence(): void {
|
||||
this.evidencingTxn = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Records that the source of funds was evidenced at the counter. This is a
|
||||
* documentation step, not a suspicion verdict — the transaction is reported in
|
||||
* the CTR either way; recording evidence simply means no STR referral is needed.
|
||||
*/
|
||||
confirmCtrEvidence(): void {
|
||||
const txn = this.evidencingTxn;
|
||||
if (!txn) {
|
||||
return;
|
||||
}
|
||||
if (!this.ctrEvidenceDocType) {
|
||||
this.toast('Select the document that evidences the source of funds');
|
||||
return;
|
||||
}
|
||||
txn.complianceStatus = 'EVIDENCED';
|
||||
txn.evidenceDocType = this.ctrEvidenceDocType;
|
||||
txn.evidenceReference = this.ctrEvidenceRef.trim();
|
||||
txn.evidencedOn = new Date().toLocaleDateString('en-GB').replace(/\//g, '-');
|
||||
localStorage.setItem('fiu_ctr_txns', JSON.stringify(this.ctrTxns));
|
||||
this.evidencingTxn = null;
|
||||
this.toast(`${txn.customerName} — evidence recorded. Reported in CTR only, no STR referral.`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Referral to STR. Used when the source of funds could not be evidenced; the
|
||||
* Principal Officer then investigates and decides whether to file with FIU-IND.
|
||||
*/
|
||||
referCtrTxnToStr(txn: CtrTransaction): void {
|
||||
txn.complianceStatus = 'REFERRED';
|
||||
localStorage.setItem('fiu_ctr_txns', JSON.stringify(this.ctrTxns));
|
||||
|
||||
const newRef = Math.max(100, ...this.strTxns.map((s) => s.reportRefNo)) + 1;
|
||||
@@ -222,34 +307,112 @@ export class FiuComponent implements OnInit {
|
||||
riskCategory: 'unusual-pattern',
|
||||
narrative:
|
||||
`Cash ${txn.depositOrWithdrawal.toLowerCase()} of ₹${txn.txnAmount.toLocaleString('en-IN')} breached the ` +
|
||||
`₹${this.bank.cashThreshold.toLocaleString('en-IN')} CTR limit and was flagged as suspicious by the AML Officer. ` +
|
||||
`Referred to the Principal Officer to establish whether the source of funds is legitimate or linked to terror financing.`,
|
||||
`₹${this.bank.cashThreshold.toLocaleString('en-IN')} CTR limit and genuine evidence of the source of funds ` +
|
||||
`could not be established at the branch. Referred to the Principal Officer for investigation.`,
|
||||
status: 'po-review',
|
||||
poFinding: undefined
|
||||
};
|
||||
|
||||
this.strTxns.unshift(newStrCase);
|
||||
localStorage.setItem('fiu_str_cases', JSON.stringify(this.strTxns));
|
||||
this.toast(`STR-${newRef} opened for ${txn.customerName} — awaiting Principal Officer investigation`);
|
||||
this.toast(`${txn.customerName} referred to STR-${newRef} — awaiting Principal Officer investigation`);
|
||||
}
|
||||
|
||||
/** Principal Officer closes an STR case as legitimate, or confirms it and files an STR. */
|
||||
concludeLocalStr(rec: StrTransaction, finding: PoFinding): void {
|
||||
/**
|
||||
* Concludes a CTR-referred case. The verdict follows the evidence and the
|
||||
* verification checklist rather than a free choice, and the full trail is written
|
||||
* into the case remarks so the filing carries its own justification.
|
||||
*/
|
||||
concludeLocalStr(): void {
|
||||
const rec = this.investigatingStr;
|
||||
if (!rec) {
|
||||
return;
|
||||
}
|
||||
if (!this.evidenceRecorded) {
|
||||
this.toast('Record which document was called for and whether it was produced');
|
||||
return;
|
||||
}
|
||||
if (!this.customerExplanation.trim()) {
|
||||
this.toast('Record the explanation the customer gave for the source of funds');
|
||||
return;
|
||||
}
|
||||
if (!this.allAnswered) {
|
||||
this.toast(`Answer all verification points — ${this.strChecklist.length - this.answeredCount} still pending`);
|
||||
return;
|
||||
}
|
||||
|
||||
const finding: PoFinding = this.derivedConclusion === 'SUSPICIOUS' ? 'SUSPICIOUS' : 'CLEAN';
|
||||
const failed = this.failedFindings;
|
||||
const evidenceLine =
|
||||
`Document called for: ${this.evidenceDocType}. Produced: ${this.evidenceReceived}` +
|
||||
(this.evidenceReference.trim() ? ` (ref ${this.evidenceReference.trim()})` : '') +
|
||||
'.';
|
||||
const explanationLine = `Customer's explanation: ${this.customerExplanation.trim()}`;
|
||||
|
||||
rec.poFinding = finding;
|
||||
if (finding === 'CLEAN') {
|
||||
rec.status = 'rejected';
|
||||
rec.poRemarks =
|
||||
'Source-of-funds documentation reviewed and verified by the Principal Officer. No link to terror financing or ' +
|
||||
'any other predicate offence. Case closed — no STR filed with FIU-IND.';
|
||||
this.toast(`STR-${rec.reportRefNo} closed as clean — no STR filed`);
|
||||
`${evidenceLine} ${explanationLine} All ${this.strChecklist.length} verification points found genuine, ` +
|
||||
`so the source of funds is established. Case closed — the transaction remains in the CTR report and no STR ` +
|
||||
`is filed with FIU-IND.`;
|
||||
// The originating CTR row must stop advertising a live STR once cleared.
|
||||
const origin = this.ctrTxns.find((t) => t.txnId === rec.txnId);
|
||||
if (origin) {
|
||||
origin.complianceStatus = 'EVIDENCED';
|
||||
origin.evidenceDocType = this.evidenceDocType;
|
||||
origin.evidenceReference = this.evidenceReference.trim();
|
||||
origin.evidencedOn = new Date().toLocaleDateString('en-GB').replace(/\//g, '-');
|
||||
localStorage.setItem('fiu_ctr_txns', JSON.stringify(this.ctrTxns));
|
||||
}
|
||||
this.toast(`STR-${rec.reportRefNo} closed as clean — CTR only, no STR filed`);
|
||||
} else {
|
||||
rec.status = 'filed';
|
||||
// Concluding suspicious does not file anything — the STR still has to be
|
||||
// submitted on the FINnet portal and its acknowledgement recorded.
|
||||
rec.status = 'awaiting-filing';
|
||||
rec.poRemarks =
|
||||
'Principal Officer investigation could not establish a legitimate source of funds. STR filed with FIU-IND under ' +
|
||||
'PMLA s.12 within the 7-day window; account placed under enhanced monitoring and reported to the Nodal Officer.';
|
||||
this.toast(`STR-${rec.reportRefNo} confirmed suspicious — filed with FIU-IND`);
|
||||
`${evidenceLine} ${explanationLine} ${failed.length} of ${this.strChecklist.length} verification points not ` +
|
||||
`satisfied: ${failed.map((f) => f.label).join('; ')}. Source of funds not established — STR to be filed with ` +
|
||||
`FIU-IND under PMLA s.12 within 7 days and the account placed under enhanced monitoring.`;
|
||||
this.toast(`STR-${rec.reportRefNo} concluded suspicious — now awaiting submission to FIU-IND`);
|
||||
}
|
||||
localStorage.setItem('fiu_str_cases', JSON.stringify(this.strTxns));
|
||||
this.investigatingStr = null;
|
||||
}
|
||||
|
||||
/** Acknowledgement number captured against a case awaiting submission. */
|
||||
localFileRefs: Record<number, string> = {};
|
||||
|
||||
/**
|
||||
* Records that the STR was actually submitted on the FINnet portal. Only this
|
||||
* step moves a case to "filed" and counts it in the filed KPI.
|
||||
*/
|
||||
fileLocalStr(rec: StrTransaction): void {
|
||||
const ref = (this.localFileRefs[rec.reportRefNo] || '').trim();
|
||||
if (!ref) {
|
||||
this.toast('Enter the FIU-IND acknowledgement number received on the FINnet portal');
|
||||
return;
|
||||
}
|
||||
rec.status = 'filed';
|
||||
rec.fiuReferenceNo = ref;
|
||||
rec.filedOn = new Date().toLocaleDateString('en-GB').replace(/\//g, '-');
|
||||
rec.poRemarks = `${rec.poRemarks ?? ''} Submitted to FIU-IND on ${rec.filedOn}; acknowledgement ${ref}.`.trim();
|
||||
localStorage.setItem('fiu_str_cases', JSON.stringify(this.strTxns));
|
||||
this.toast(`STR-${rec.reportRefNo} filed with FIU-IND — acknowledgement ${ref}`);
|
||||
}
|
||||
|
||||
/** Generates the FINnet STR XML for one case, ready to upload to the portal. */
|
||||
exportSingleStrXml(rec: StrTransaction): void {
|
||||
const saved = this.strFilterStatus;
|
||||
const savedRisk = this.strFilterRisk;
|
||||
// Temporarily narrow the export to this case only, then restore the filters.
|
||||
this.strFilterStatus = '';
|
||||
this.strFilterRisk = '';
|
||||
const only = [rec];
|
||||
this.writeStrXml(only, `STR-${rec.reportRefNo}-${rec.customerName ?? 'case'}.xml`);
|
||||
this.strFilterStatus = saved;
|
||||
this.strFilterRisk = savedRisk;
|
||||
this.toast(`STR-${rec.reportRefNo} XML generated — upload it on the FINnet portal`);
|
||||
}
|
||||
|
||||
poFindingLabel(finding: PoFinding): string {
|
||||
@@ -363,12 +526,7 @@ export class FiuComponent implements OnInit {
|
||||
: `${this.concludeInvestigationNotes.trim()}\n\nAll ${this.strChecklist.length} verification points found genuine.`;
|
||||
|
||||
this.amlService
|
||||
.concludeStrCase(
|
||||
this.selectedStrCase.id,
|
||||
this.concludeConclusion,
|
||||
notes,
|
||||
this.concludeSourceOfFundsNotes
|
||||
)
|
||||
.concludeStrCase(this.selectedStrCase.id, this.concludeConclusion, notes, this.concludeSourceOfFundsNotes)
|
||||
.subscribe({
|
||||
next: (updated) => {
|
||||
this.toast(
|
||||
@@ -406,16 +564,16 @@ export class FiuComponent implements OnInit {
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a live AML alert will end up once triaged — drives the "Routes To" column.
|
||||
* `isCtrRule` distinguishes mechanical CTR-threshold alerts (whitelisting just keeps them
|
||||
* in the CTR file) from behavioral alerts (whitelisting clears them with no report at all).
|
||||
* Where a live AML alert stands — drives the "Routes To" column.
|
||||
* Whitelisting is an STR-side decision, so a CTR alert can only be pending or
|
||||
* referred onward. Historic WHITELISTED rows are still rendered for the record.
|
||||
*/
|
||||
alertRouteLabel(status: string, isCtrRule: boolean = true): string {
|
||||
switch (status) {
|
||||
case 'WHITELISTED':
|
||||
return isCtrRule ? 'CTR only' : 'Cleared';
|
||||
case 'ESCALATED':
|
||||
return '→ STR';
|
||||
return isCtrRule ? 'Referred to STR' : '→ STR';
|
||||
default:
|
||||
return 'Pending';
|
||||
}
|
||||
@@ -450,9 +608,7 @@ export class FiuComponent implements OnInit {
|
||||
this.resetStrFilters();
|
||||
this.highlightedStrRef = match.reportRefNo;
|
||||
setTimeout(() => {
|
||||
document
|
||||
.getElementById(`str-card-${match.reportRefNo}`)
|
||||
?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
document.getElementById(`str-card-${match.reportRefNo}`)?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -467,7 +623,9 @@ export class FiuComponent implements OnInit {
|
||||
return this.ctrTxns.reduce((s, t) => s + t.txnAmount, 0);
|
||||
}
|
||||
get pendingStr(): number {
|
||||
return this.strTxns.filter((s) => s.status === 'draft' || s.status === 'po-review').length;
|
||||
return this.strTxns.filter(
|
||||
(s) => s.status === 'draft' || s.status === 'po-review' || s.status === 'awaiting-filing'
|
||||
).length;
|
||||
}
|
||||
get filedStr(): number {
|
||||
return this.strTxns.filter((s) => s.status === 'filed').length;
|
||||
@@ -496,12 +654,20 @@ export class FiuComponent implements OnInit {
|
||||
}
|
||||
|
||||
get ctrAlerts(): any[] {
|
||||
const ctrRuleCodes = ['CTR_THRESHOLD_10L', 'STRUCTURED_CTR_10L_MONTHLY', 'LARGE_CASH_TXN_25L'];
|
||||
const ctrRuleCodes = [
|
||||
'CTR_THRESHOLD_10L',
|
||||
'STRUCTURED_CTR_10L_MONTHLY',
|
||||
'LARGE_CASH_TXN_25L'
|
||||
];
|
||||
return this.amlAlerts.filter((a) => ctrRuleCodes.includes(a.ruleCode));
|
||||
}
|
||||
|
||||
get amlSuspiciousAlerts(): any[] {
|
||||
const ctrRuleCodes = ['CTR_THRESHOLD_10L', 'STRUCTURED_CTR_10L_MONTHLY', 'LARGE_CASH_TXN_25L'];
|
||||
const ctrRuleCodes = [
|
||||
'CTR_THRESHOLD_10L',
|
||||
'STRUCTURED_CTR_10L_MONTHLY',
|
||||
'LARGE_CASH_TXN_25L'
|
||||
];
|
||||
return this.amlAlerts.filter((a) => !ctrRuleCodes.includes(a.ruleCode));
|
||||
}
|
||||
|
||||
@@ -544,7 +710,14 @@ export class FiuComponent implements OnInit {
|
||||
|
||||
// -------------------- Helpers --------------------
|
||||
statusLabel(s: StrStatus): string {
|
||||
return s === 'draft' ? 'Draft' : s === 'po-review' ? 'PO Review' : s === 'filed' ? 'Filed' : 'Rejected';
|
||||
const map: Record<StrStatus, string> = {
|
||||
draft: 'Draft',
|
||||
'po-review': 'PO Review',
|
||||
'awaiting-filing': 'Awaiting Filing',
|
||||
filed: 'Filed',
|
||||
rejected: 'Closed — Clean'
|
||||
};
|
||||
return map[s] ?? s;
|
||||
}
|
||||
riskLabel(c: StrTransaction['riskCategory']): string {
|
||||
const map: Record<StrTransaction['riskCategory'], string> = {
|
||||
@@ -912,6 +1085,12 @@ export class FiuComponent implements OnInit {
|
||||
this.toast('No suspect cases to export — nothing matches the current filters');
|
||||
return;
|
||||
}
|
||||
this.writeStrXml(suspects, `STR-${this.bank.reportingMonth.replace(/\s+/g, '-')}.xml`);
|
||||
this.toast(`STR XML exported (${suspects.length} suspects)`);
|
||||
}
|
||||
|
||||
/** Serialises a set of suspect cases as FINnet 2.0 STR XML and downloads it. */
|
||||
private writeStrXml(suspects: StrTransaction[], filename: string): void {
|
||||
const xml: string[] = [];
|
||||
xml.push('<?xml version="1.0" encoding="UTF-8"?>');
|
||||
xml.push(`<STR xmlns="http://fiuind.gov.in/finnet2/str" reportingMonth="${this.bank.reportingMonth}">`);
|
||||
@@ -927,6 +1106,9 @@ export class FiuComponent implements OnInit {
|
||||
xml.push(` <Detection date="${s.detectedOn}" by="${this.escapeXml(s.flaggedBy)}" />`);
|
||||
xml.push(` <Narrative>${this.escapeXml(s.narrative)}</Narrative>`);
|
||||
if (s.poRemarks) xml.push(` <PORemarks>${this.escapeXml(s.poRemarks)}</PORemarks>`);
|
||||
if (s.fiuReferenceNo) {
|
||||
xml.push(` <Acknowledgement ref="${this.escapeXml(s.fiuReferenceNo)}" filedOn="${s.filedOn ?? ''}" />`);
|
||||
}
|
||||
xml.push(' </Suspect>');
|
||||
}
|
||||
xml.push('</STR>');
|
||||
@@ -934,10 +1116,9 @@ export class FiuComponent implements OnInit {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `STR-${this.bank.reportingMonth.replace(/\s+/g, '-')}.xml`;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
this.toast(`STR XML exported (${suspects.length} suspects)`);
|
||||
}
|
||||
|
||||
/** Bundle: the 6 CSVs plus the STR XML — emitted as separate downloads sequentially */
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
@@ -114,18 +114,84 @@ export const GL_ACCOUNTS: GlAccount[] = [
|
||||
{ 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: '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 },
|
||||
{
|
||||
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' },
|
||||
@@ -261,8 +327,20 @@ const VOUCHER_TEMPLATES: {
|
||||
{ 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: '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 },
|
||||
@@ -275,14 +353,32 @@ const VOUCHER_TEMPLATES: {
|
||||
{ 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 }
|
||||
{
|
||||
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: '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 },
|
||||
@@ -342,7 +438,10 @@ export function glPostings(): GlPosting[] {
|
||||
let openingDebit = 0;
|
||||
let openingCredit = 0;
|
||||
|
||||
for (const [code, amount] of Object.entries(OPENING_BALANCES)) {
|
||||
for (const [
|
||||
code,
|
||||
amount
|
||||
] of Object.entries(OPENING_BALANCES)) {
|
||||
if (!amount) {
|
||||
continue;
|
||||
}
|
||||
@@ -425,7 +524,10 @@ export function glPostings(): GlPosting[] {
|
||||
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;
|
||||
const templates = branch.code === 'HO' ? [
|
||||
...VOUCHER_TEMPLATES,
|
||||
...HO_TEMPLATES
|
||||
] : VOUCHER_TEMPLATES;
|
||||
|
||||
for (const tpl of templates) {
|
||||
if (rand() > tpl.frequency * weight) {
|
||||
@@ -487,6 +589,131 @@ export function glPostings(): GlPosting[] {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -79,17 +79,33 @@
|
||||
|
||||
<div class="glr-filter">
|
||||
<label>Branch</label>
|
||||
<select class="glr-inp" [(ngModel)]="branchCode">
|
||||
<option value="">All Branches (Consolidated)</option>
|
||||
@for (b of branches; track b.code) {
|
||||
<option [value]="b.code">{{ b.code }} — {{ b.name }}</option>
|
||||
}
|
||||
</select>
|
||||
@if (canSelectBranch) {
|
||||
<select class="glr-inp" [(ngModel)]="branchCode">
|
||||
<option value="">All Branches (Consolidated)</option>
|
||||
@for (b of branches; track b.code) {
|
||||
<option [value]="b.code">{{ b.code }} — {{ b.name }}</option>
|
||||
}
|
||||
</select>
|
||||
} @else {
|
||||
<span class="glr-chip" title="Your login is restricted to this branch">
|
||||
<fa-icon icon="university"></fa-icon> {{ scopeName }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<button class="glr-btn" (click)="resetFilters()">Reset</button>
|
||||
</div>
|
||||
|
||||
@if (scopeIsUnresolved) {
|
||||
<div class="glr-balance glr-balance--bad">
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||
<span>
|
||||
Your office <strong>{{ scopeName }}</strong> does not match any branch in the ledger, so no figures can be
|
||||
shown. Ask an administrator to align your office name with a branch.
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- REPORT SELECTOR -->
|
||||
<div class="glr-tabs">
|
||||
@for (r of reports; track r.code) {
|
||||
@@ -193,7 +209,9 @@
|
||||
@for (r of pnlExpense; track r.code) {
|
||||
<tr>
|
||||
<td class="mono">{{ r.code }}</td>
|
||||
<td>{{ r.name }}<span class="muted"> · {{ r.group }}</span></td>
|
||||
<td>
|
||||
{{ r.name }}<span class="muted"> · {{ r.group }}</span>
|
||||
</td>
|
||||
<td class="ta-r mono">{{ r.amount | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
@@ -229,7 +247,9 @@
|
||||
@for (r of pnlIncome; track r.code) {
|
||||
<tr>
|
||||
<td class="mono">{{ r.code }}</td>
|
||||
<td>{{ r.name }}<span class="muted"> · {{ r.group }}</span></td>
|
||||
<td>
|
||||
{{ r.name }}<span class="muted"> · {{ r.group }}</span>
|
||||
</td>
|
||||
<td class="ta-r mono">{{ r.amount | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
@@ -281,7 +301,9 @@
|
||||
@for (r of bsLiabilities; track r.code) {
|
||||
<tr>
|
||||
<td class="mono">{{ r.code }}</td>
|
||||
<td>{{ r.name }}<span class="muted"> · {{ r.group }}</span></td>
|
||||
<td>
|
||||
{{ r.name }}<span class="muted"> · {{ r.group }}</span>
|
||||
</td>
|
||||
<td class="ta-r mono">{{ r.amount | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
}
|
||||
@@ -312,7 +334,9 @@
|
||||
@for (r of bsAssets; track r.code) {
|
||||
<tr>
|
||||
<td class="mono">{{ r.code }}</td>
|
||||
<td>{{ r.name }}<span class="muted"> · {{ r.group }}</span></td>
|
||||
<td>
|
||||
{{ r.name }}<span class="muted"> · {{ r.group }}</span>
|
||||
</td>
|
||||
<td class="ta-r mono">{{ r.amount | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
}
|
||||
@@ -331,11 +355,17 @@
|
||||
@if (activeReport === 'comparative-balance-sheet') {
|
||||
<div class="glr-section">
|
||||
<p class="glr-note">
|
||||
Compares the closing position as at <strong>{{ range.to }}</strong> against the position immediately before
|
||||
this period began. Change the filter above to compare a different span.
|
||||
Compares the closing position as at <strong>{{ range.to }}</strong> against the position immediately before this
|
||||
period began. Change the filter above to compare a different span.
|
||||
</p>
|
||||
|
||||
@for (block of [{ title: 'Capital & Liabilities', rows: compLiabilities }, { title: 'Assets', rows: compAssets }]; track block.title) {
|
||||
@for (
|
||||
block of [
|
||||
{ title: 'Capital & Liabilities', rows: compLiabilities },
|
||||
{ title: 'Assets', rows: compAssets }
|
||||
];
|
||||
track block.title
|
||||
) {
|
||||
<div class="glr-tbl-wrap">
|
||||
<div class="glr-tbl-h">{{ block.title }}</div>
|
||||
<table class="glr-tbl">
|
||||
@@ -353,7 +383,9 @@
|
||||
@for (r of block.rows; track r.code) {
|
||||
<tr>
|
||||
<td class="mono">{{ r.code }}</td>
|
||||
<td>{{ r.name }}<span class="muted"> · {{ r.group }}</span></td>
|
||||
<td>
|
||||
{{ r.name }}<span class="muted"> · {{ r.group }}</span>
|
||||
</td>
|
||||
<td class="ta-r mono">{{ r.current | number: '1.2-2' }}</td>
|
||||
<td class="ta-r mono">{{ r.previous | number: '1.2-2' }}</td>
|
||||
<td class="ta-r mono" [class.var-up]="r.variance > 0" [class.var-down]="r.variance < 0">
|
||||
@@ -379,8 +411,8 @@
|
||||
@if (activeReport === 'contingent-liabilities') {
|
||||
<div class="glr-section">
|
||||
<p class="glr-note">
|
||||
Off-balance-sheet commitments outstanding as at <strong>{{ range.to }}</strong>. These are memoranda accounts
|
||||
and are deliberately excluded from the Trial Balance and Balance Sheet totals.
|
||||
Off-balance-sheet commitments outstanding as at <strong>{{ range.to }}</strong
|
||||
>. These are memoranda accounts and are deliberately excluded from the Trial Balance and Balance Sheet totals.
|
||||
</p>
|
||||
<div class="glr-tbl-wrap">
|
||||
<table class="glr-tbl">
|
||||
@@ -419,8 +451,8 @@
|
||||
@if (activeReport === 'gl-wise-dr-cr') {
|
||||
<div class="glr-section">
|
||||
<p class="glr-note">
|
||||
Debit and credit turnover posted between <strong>{{ rangeLabel }}</strong> for {{ branchLabel }}. Every
|
||||
voucher is double-entry, so the debit and credit totals below must match.
|
||||
Debit and credit turnover posted between <strong>{{ rangeLabel }}</strong> for {{ branchLabel }}. Every voucher
|
||||
is double-entry, so the debit and credit totals below must match.
|
||||
</p>
|
||||
<div class="glr-tbl-wrap">
|
||||
<table class="glr-tbl">
|
||||
@@ -474,8 +506,8 @@
|
||||
@if (activeReport === 'misc-expense-ledger') {
|
||||
<div class="glr-section">
|
||||
<p class="glr-note">
|
||||
Every miscellaneous-expense voucher posted between <strong>{{ rangeLabel }}</strong> for {{ branchLabel }}
|
||||
— {{ miscRows.length | number }} entries.
|
||||
Every miscellaneous-expense voucher posted between <strong>{{ rangeLabel }}</strong> for
|
||||
{{ branchLabel }} — {{ miscRows.length | number }} entries.
|
||||
</p>
|
||||
<div class="glr-tbl-wrap glr-tbl-wrap--tall">
|
||||
<table class="glr-tbl">
|
||||
@@ -518,6 +550,506 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ══════════════ GL AVERAGE BALANCE ══════════════ -->
|
||||
@if (activeReport === 'gl-average-balance') {
|
||||
<div class="glr-section">
|
||||
<p class="glr-note">
|
||||
Average of each account's closing balance across every day in
|
||||
<strong>{{ rangeLabel }}</strong> for {{ branchLabel }}. Banks work from average balances rather than a closing
|
||||
snapshot for interest, CRR/SLR and profitability, because a single day's figure can be dressed up.
|
||||
</p>
|
||||
<div class="glr-tbl-wrap glr-tbl-wrap--tall">
|
||||
<table class="glr-tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>GL Code</th>
|
||||
<th>Account</th>
|
||||
<th>Group</th>
|
||||
<th class="ta-r">Opening</th>
|
||||
<th class="ta-r">Closing</th>
|
||||
<th class="ta-r">Average Daily</th>
|
||||
<th class="ta-r">Peak</th>
|
||||
<th class="ta-r">Lowest</th>
|
||||
<th class="ta-c">Active Days</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (r of averageRows; track r.code) {
|
||||
<tr>
|
||||
<td class="mono">{{ r.code }}</td>
|
||||
<td>{{ r.name }}</td>
|
||||
<td class="muted">{{ r.group }}</td>
|
||||
<td class="ta-r mono">{{ r.opening | number: '1.2-2' }}</td>
|
||||
<td class="ta-r mono">{{ r.closing | number: '1.2-2' }}</td>
|
||||
<td class="ta-r mono">
|
||||
<strong>{{ r.average | number: '1.2-2' }}</strong>
|
||||
</td>
|
||||
<td class="ta-r mono">{{ r.peak | number: '1.2-2' }}</td>
|
||||
<td class="ta-r mono">{{ r.lowest | number: '1.2-2' }}</td>
|
||||
<td class="ta-c mono">{{ r.activeDays }}</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="9" class="glr-empty">No ledger activity for this period and branch.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="total-row">
|
||||
<td colspan="5">TOTAL ({{ averageRows.length }} accounts)</td>
|
||||
<td class="ta-r mono">{{ avgTotalAverage | number: '1.2-2' }}</td>
|
||||
<td colspan="3"></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ══════════════ GL MAPPING FOR REPORT ══════════════ -->
|
||||
@if (activeReport === 'gl-mapping') {
|
||||
<div class="glr-section">
|
||||
<p class="glr-note">
|
||||
Which General Ledger accounts feed each RBI return line. Mappings are created on the OSS 1, OSS 2 and OSS 3
|
||||
screens using <strong>Add / Remove GLs</strong>; this is the consolidated view of them, with the value each
|
||||
mapping currently produces from the ledger.
|
||||
</p>
|
||||
|
||||
<div class="glr-tbl-wrap">
|
||||
<div class="glr-tbl-h">Mapped return lines ({{ mappingRows.length }})</div>
|
||||
<table class="glr-tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Return</th>
|
||||
<th>Return line</th>
|
||||
<th>Mapped GL accounts</th>
|
||||
<th class="ta-r">Ledger value (₹ thousands)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (r of mappingRows; track r.returnCode + r.lineKey) {
|
||||
<tr>
|
||||
<td>{{ r.returnLabel }}</td>
|
||||
<td class="mono">{{ r.lineKey }}</td>
|
||||
<td>
|
||||
@for (c of r.codes; track c) {
|
||||
<span class="glr-gl-chip mono">{{ c }}</span>
|
||||
}
|
||||
</td>
|
||||
<td class="ta-r mono">{{ r.value | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="4" class="glr-empty">
|
||||
No return lines mapped yet. Open OSS 1, 2 or 3 and use “Add / Remove GLs” to pin accounts to a line.
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="glr-tbl-wrap glr-tbl-wrap--tall">
|
||||
<div class="glr-tbl-h">Unmapped GL accounts ({{ unmappedAccounts.length }}) — not feeding any return line</div>
|
||||
<table class="glr-tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>GL Code</th>
|
||||
<th>Account</th>
|
||||
<th>Group</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (a of unmappedAccounts; track a.code) {
|
||||
<tr>
|
||||
<td class="mono">{{ a.code }}</td>
|
||||
<td>{{ a.name }}</td>
|
||||
<td class="muted">{{ a.group }}</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="3" class="glr-empty">Every GL account feeds at least one return line.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ══════════════ ACCOUNT WISE BALANCE LIST ══════════════ -->
|
||||
@if (activeReport === 'account-wise-balance') {
|
||||
<div class="glr-section">
|
||||
<p class="glr-note">
|
||||
Closing balance of every account as at <strong>{{ range.to }}</strong
|
||||
>, broken out by branch — which branch is actually carrying each balance.
|
||||
</p>
|
||||
<div class="glr-tbl-wrap glr-tbl-wrap--tall">
|
||||
<table class="glr-tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>GL Code</th>
|
||||
<th>Account</th>
|
||||
<th>Group</th>
|
||||
@for (c of branchColumns; track c) {
|
||||
<th class="ta-r">{{ branchName(c) }}</th>
|
||||
}
|
||||
<th class="ta-r">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (r of accountWiseRows; track r.code) {
|
||||
<tr>
|
||||
<td class="mono">{{ r.code }}</td>
|
||||
<td>{{ r.name }}</td>
|
||||
<td class="muted">{{ r.group }}</td>
|
||||
@for (c of branchColumns; track c) {
|
||||
<td class="ta-r mono">{{ r.byBranch[c] ? (r.byBranch[c] | number: '1.2-2') : '' }}</td>
|
||||
}
|
||||
<td class="ta-r mono">
|
||||
<strong>{{ r.total | number: '1.2-2' }}</strong>
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td [attr.colspan]="branchColumns.length + 4" class="glr-empty">
|
||||
No balances for this period and branch.
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="total-row">
|
||||
<td colspan="3">TOTAL ({{ accountWiseRows.length }} accounts)</td>
|
||||
@for (c of branchColumns; track c) {
|
||||
<td class="ta-r mono">{{ branchColumnTotal(c) | number: '1.2-2' }}</td>
|
||||
}
|
||||
<td class="ta-r mono">{{ accountWiseGrandTotal | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ══════════════ DISTRIBUTION OF BALANCE ══════════════ -->
|
||||
@if (activeReport === 'distribution-of-balance') {
|
||||
<div class="glr-section">
|
||||
<p class="glr-note">
|
||||
Composition of the book as at <strong>{{ range.to }}</strong> for {{ branchLabel }} — how concentrated each side
|
||||
of the balance sheet is by account group.
|
||||
</p>
|
||||
|
||||
<div class="glr-two-col">
|
||||
@for (
|
||||
block of [
|
||||
{ title: 'Capital & Liabilities', rows: liabilityDistribution, total: liabilityDistTotal },
|
||||
{ title: 'Assets', rows: assetDistribution, total: assetDistTotal }
|
||||
];
|
||||
track block.title
|
||||
) {
|
||||
<div class="glr-tbl-wrap">
|
||||
<div class="glr-tbl-h">{{ block.title }}</div>
|
||||
<table class="glr-tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Group</th>
|
||||
<th class="ta-c">A/cs</th>
|
||||
<th class="ta-r">Amount (₹)</th>
|
||||
<th class="ta-r">Share</th>
|
||||
<th class="glr-bar-col">Distribution</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (r of block.rows; track r.group) {
|
||||
<tr>
|
||||
<td>{{ r.group }}</td>
|
||||
<td class="ta-c mono">{{ r.accounts }}</td>
|
||||
<td class="ta-r mono">{{ r.amount | number: '1.2-2' }}</td>
|
||||
<td class="ta-r mono">
|
||||
<strong>{{ r.share | number: '1.2-2' }}%</strong>
|
||||
</td>
|
||||
<td>
|
||||
<div class="glr-bar">
|
||||
<div class="glr-bar-fill" [style.width.%]="r.share"></div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="5" class="glr-empty">No balances on this side.</td>
|
||||
</tr>
|
||||
}
|
||||
<tr class="total-row">
|
||||
<td colspan="2">Total</td>
|
||||
<td class="ta-r mono">{{ block.total | number: '1.2-2' }}</td>
|
||||
<td class="ta-r mono">100.00%</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ══════════════ CLIENT WISE BALANCE ══════════════ -->
|
||||
@if (activeReport === 'client-wise-balance') {
|
||||
<div class="glr-section">
|
||||
<div class="glr-kpis">
|
||||
<div class="glr-kpi glr-kpi--teal">
|
||||
<div class="glr-kpi-n">₹{{ clientDeposits | number: '1.2-2' }}</div>
|
||||
<div class="glr-kpi-l">Customer Deposits</div>
|
||||
</div>
|
||||
<div class="glr-kpi glr-kpi--amber">
|
||||
<div class="glr-kpi-n">₹{{ clientAdvances | number: '1.2-2' }}</div>
|
||||
<div class="glr-kpi-l">Customer Advances</div>
|
||||
</div>
|
||||
<div class="glr-kpi glr-kpi--green">
|
||||
<div class="glr-kpi-n">{{ clientRows.length | number }}</div>
|
||||
<div class="glr-kpi-l">Relationships</div>
|
||||
<div class="glr-kpi-sub">as at {{ range.to }} · {{ branchLabel }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="glr-note">
|
||||
Customer balances are derived from the General Ledger — each relationship holds a share of its branch's balance
|
||||
in that head — so this report always reconciles to the GL rather than drifting as a second source.
|
||||
</p>
|
||||
|
||||
<div class="glr-tbl-wrap glr-tbl-wrap--tall">
|
||||
<table class="glr-tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>CIF</th>
|
||||
<th>Customer</th>
|
||||
<th>Branch</th>
|
||||
<th>Account No</th>
|
||||
<th>Product</th>
|
||||
<th class="ta-c">Side</th>
|
||||
<th class="ta-r">Balance (₹)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (r of clientRows; track r.cif) {
|
||||
<tr>
|
||||
<td class="mono">{{ r.cif }}</td>
|
||||
<td>{{ r.name }}</td>
|
||||
<td>{{ branchName(r.branchCode) }}</td>
|
||||
<td class="mono">{{ r.accountNo }}</td>
|
||||
<td class="muted">{{ r.product }}</td>
|
||||
<td class="ta-c">
|
||||
<span
|
||||
class="type-pill"
|
||||
[class.type-liability]="r.side === 'DEPOSIT'"
|
||||
[class.type-asset]="r.side === 'ADVANCE'"
|
||||
>
|
||||
{{ r.side }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="ta-r mono">{{ r.balance | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="7" class="glr-empty">No customer relationships for this period and branch.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ══════════════ BANKER HEAD REPORT ══════════════ -->
|
||||
@if (activeReport === 'banker-head') {
|
||||
<div class="glr-section">
|
||||
<p class="glr-note">
|
||||
Head-wise abstract for <strong>{{ rangeLabel }}</strong> — the trial balance rolled up to principal heads, with
|
||||
opening, turnover and closing for each. Debit and credit turnover must agree.
|
||||
</p>
|
||||
<div class="glr-tbl-wrap">
|
||||
<table class="glr-tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Principal Head</th>
|
||||
<th class="ta-c">Accounts</th>
|
||||
<th class="ta-r">Opening</th>
|
||||
<th class="ta-r">Debit</th>
|
||||
<th class="ta-r">Credit</th>
|
||||
<th class="ta-r">Closing</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (r of bankerHeadRows; track r.head) {
|
||||
<tr>
|
||||
<td>
|
||||
<strong>{{ r.head }}</strong>
|
||||
</td>
|
||||
<td class="ta-c mono">{{ r.accounts }}</td>
|
||||
<td class="ta-r mono">{{ r.opening | number: '1.2-2' }}</td>
|
||||
<td class="ta-r mono">{{ r.debit ? (r.debit | number: '1.2-2') : '' }}</td>
|
||||
<td class="ta-r mono">{{ r.credit ? (r.credit | number: '1.2-2') : '' }}</td>
|
||||
<td class="ta-r mono">
|
||||
<strong>{{ r.closing | number: '1.2-2' }}</strong>
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="6" class="glr-empty">No activity for this period and branch.</td>
|
||||
</tr>
|
||||
}
|
||||
<tr class="total-row">
|
||||
<td colspan="2">TOTAL ({{ bankerHeadRows.length }} heads)</td>
|
||||
<td></td>
|
||||
<td class="ta-r mono">{{ headDebitTotal | number: '1.2-2' }}</td>
|
||||
<td class="ta-r mono">{{ headCreditTotal | number: '1.2-2' }}</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ══════════════ SUNDRY REGISTER ══════════════ -->
|
||||
@if (activeReport === 'sundry-register') {
|
||||
<div class="glr-section">
|
||||
<p class="glr-note">
|
||||
Outstanding items in the sundry, suspense and bills-payable heads as at <strong>{{ range.to }}</strong
|
||||
>, aged from their posting date. These heads are where unreconciled entries collect, so ageing is the point of
|
||||
the register.
|
||||
</p>
|
||||
|
||||
@if (sundryBuckets.length) {
|
||||
<div class="glr-kpis">
|
||||
@for (b of sundryBuckets; track b.bucket) {
|
||||
<div
|
||||
class="glr-kpi"
|
||||
[class.glr-kpi--green]="b.bucket === 'Up to 30 days'"
|
||||
[class.glr-kpi--amber]="b.bucket === '31 to 90 days'"
|
||||
[class.glr-kpi--red]="b.bucket === 'Over 180 days'"
|
||||
>
|
||||
<div class="glr-kpi-n">₹{{ b.amount | number: '1.2-2' }}</div>
|
||||
<div class="glr-kpi-l">{{ b.bucket }}</div>
|
||||
<div class="glr-kpi-sub">{{ b.count }} item(s)</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="glr-tbl-wrap glr-tbl-wrap--tall">
|
||||
<table class="glr-tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Voucher No</th>
|
||||
<th>Branch</th>
|
||||
<th>GL Code</th>
|
||||
<th>Account</th>
|
||||
<th>Narration</th>
|
||||
<th class="ta-r">Amount (₹)</th>
|
||||
<th class="ta-c">Age</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (r of sundryRows; track r.voucherNo + r.code + r.date) {
|
||||
<tr>
|
||||
<td class="mono">{{ r.date }}</td>
|
||||
<td class="mono">{{ r.voucherNo }}</td>
|
||||
<td>{{ branchName(r.branchCode) }}</td>
|
||||
<td class="mono">{{ r.code }}</td>
|
||||
<td>{{ r.name }}</td>
|
||||
<td class="muted">{{ r.narration }}</td>
|
||||
<td class="ta-r mono">{{ r.amount | number: '1.2-2' }}</td>
|
||||
<td class="ta-c">
|
||||
<span
|
||||
class="type-pill"
|
||||
[class.type-income]="r.bucket === 'Up to 30 days'"
|
||||
[class.type-expense]="r.bucket === '31 to 90 days'"
|
||||
[class.type-contingent]="r.bucket === 'Over 180 days'"
|
||||
>
|
||||
{{ r.ageDays }}d
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="8" class="glr-empty">No sundry or suspense items outstanding for this selection.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="total-row">
|
||||
<td colspan="6">Total outstanding ({{ sundryRows.length }} items)</td>
|
||||
<td class="ta-r mono">{{ sundryTotal | number: '1.2-2' }}</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ══════════════ VOUCHER REGISTER ══════════════ -->
|
||||
@if (activeReport === 'voucher-register') {
|
||||
<div class="glr-section">
|
||||
<div class="glr-balance" [class.glr-balance--bad]="unbalancedVouchers > 0">
|
||||
<fa-icon [icon]="unbalancedVouchers ? 'exclamation-triangle' : 'check-circle'"></fa-icon>
|
||||
<span>
|
||||
{{ voucherRows.length | number }} voucher(s) raised in {{ rangeLabel }} for {{ branchLabel }} —
|
||||
{{
|
||||
unbalancedVouchers
|
||||
? unbalancedVouchers + ' voucher(s) do NOT balance and must be investigated'
|
||||
: 'every voucher is double-entry and balances'
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="glr-tbl-wrap glr-tbl-wrap--tall">
|
||||
<table class="glr-tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Voucher No</th>
|
||||
<th>Branch</th>
|
||||
<th>Narration</th>
|
||||
<th>Debit account(s)</th>
|
||||
<th>Credit account(s)</th>
|
||||
<th class="ta-r">Amount (₹)</th>
|
||||
<th class="ta-c">Legs</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (r of voucherRows; track r.voucherNo) {
|
||||
<tr [class.glr-unbalanced]="!r.balanced">
|
||||
<td class="mono">{{ r.date }}</td>
|
||||
<td class="mono">{{ r.voucherNo }}</td>
|
||||
<td>{{ branchName(r.branchCode) }}</td>
|
||||
<td class="muted">{{ r.narration }}</td>
|
||||
<td class="mono">{{ r.debitAccounts }}</td>
|
||||
<td class="mono">{{ r.creditAccounts }}</td>
|
||||
<td class="ta-r mono">{{ r.amount | number: '1.2-2' }}</td>
|
||||
<td class="ta-c mono">{{ r.legs }}</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="8" class="glr-empty">No vouchers raised in this period for this branch.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="total-row">
|
||||
<td colspan="6">Total ({{ voucherRows.length }} vouchers)</td>
|
||||
<td class="ta-r mono">{{ voucherTotal | number: '1.2-2' }}</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- TOAST -->
|
||||
<div class="glr-toast" [class.show]="toastVisible">{{ toastMsg }}</div>
|
||||
</div>
|
||||
|
||||
@@ -160,6 +160,21 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* 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;
|
||||
@@ -434,6 +449,45 @@
|
||||
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;
|
||||
|
||||
@@ -6,24 +6,42 @@
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { Component, DestroyRef, OnInit, inject } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { BranchScopeService } from 'app/core/branch-scope.service';
|
||||
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 { GL_BRANCHES, GL_PERIOD, glBranchName } from './gl-reports-data';
|
||||
import { GL_ACCOUNTS, GL_BRANCHES, GL_PERIOD, glBranchName } from './gl-reports-data';
|
||||
import {
|
||||
AccountBranchRow,
|
||||
AverageBalanceRow,
|
||||
BalanceSheetRow,
|
||||
ComparativeRow,
|
||||
DistributionRow,
|
||||
GlDateRange,
|
||||
GlReportSet,
|
||||
MiscExpenseRow,
|
||||
PnlRow,
|
||||
BankerHeadRow,
|
||||
ClientBalanceRow,
|
||||
SundryItemRow,
|
||||
VoucherRow,
|
||||
accountWiseByBranch,
|
||||
averageBalances,
|
||||
balanceDistribution,
|
||||
bankerHeadSummary,
|
||||
clientWiseBalances,
|
||||
sundryRegister,
|
||||
voucherRegister,
|
||||
buildReports,
|
||||
clampToLedger,
|
||||
glValueForCodes,
|
||||
sum
|
||||
} from './gl-reports.compute';
|
||||
import { GlMappingEntry, allMappings, glLabel, returnName } from 'app/rbi-circulars/gl-mapping';
|
||||
|
||||
export type GlReportCode =
|
||||
| 'trial-balance'
|
||||
@@ -32,7 +50,15 @@ export type GlReportCode =
|
||||
| 'comparative-balance-sheet'
|
||||
| 'contingent-liabilities'
|
||||
| 'gl-wise-dr-cr'
|
||||
| 'misc-expense-ledger';
|
||||
| 'misc-expense-ledger'
|
||||
| 'gl-average-balance'
|
||||
| 'gl-mapping'
|
||||
| 'account-wise-balance'
|
||||
| 'distribution-of-balance'
|
||||
| 'client-wise-balance'
|
||||
| 'banker-head'
|
||||
| 'sundry-register'
|
||||
| 'voucher-register';
|
||||
|
||||
type PeriodMode = 'day' | 'month' | 'range';
|
||||
|
||||
@@ -49,6 +75,19 @@ type PeriodMode = 'day' | 'month' | 'range';
|
||||
export class GlReportsComponent implements OnInit {
|
||||
private route = inject(ActivatedRoute);
|
||||
private router = inject(Router);
|
||||
private scope = inject(BranchScopeService);
|
||||
private destroyRef = inject(DestroyRef);
|
||||
|
||||
/** Head Office may pick any branch; a branch login is locked to its own book. */
|
||||
get canSelectBranch(): boolean {
|
||||
return this.scope.canSelectBranch;
|
||||
}
|
||||
get scopeIsUnresolved(): boolean {
|
||||
return this.scope.isUnresolved;
|
||||
}
|
||||
get scopeName(): string {
|
||||
return this.scope.scopeLabel(this.branchCode);
|
||||
}
|
||||
|
||||
readonly reports: { code: GlReportCode; label: string; icon: string }[] = [
|
||||
{ code: 'trial-balance', label: 'Trial Balance', icon: 'balance-scale' },
|
||||
@@ -57,7 +96,15 @@ export class GlReportsComponent implements OnInit {
|
||||
{ code: 'comparative-balance-sheet', label: 'Comparative Balance Sheet', icon: 'balance-scale' },
|
||||
{ code: 'contingent-liabilities', label: 'Contingent Liabilities', icon: 'exclamation-circle' },
|
||||
{ code: 'gl-wise-dr-cr', label: 'GL Wise DR / CR', icon: 'project-diagram' },
|
||||
{ code: 'misc-expense-ledger', label: 'Misc Expense Ledger', icon: 'receipt' }
|
||||
{ code: 'misc-expense-ledger', label: 'Misc Expense Ledger', icon: 'receipt' },
|
||||
{ code: 'gl-average-balance', label: 'GL Average Balance', icon: 'calculator' },
|
||||
{ code: 'gl-mapping', label: 'GL Mapping for Report', icon: 'project-diagram' },
|
||||
{ code: 'account-wise-balance', label: 'Account wise Balance List', icon: 'list' },
|
||||
{ code: 'distribution-of-balance', label: 'Distribution of Balance', icon: 'chart-pie' },
|
||||
{ code: 'client-wise-balance', label: 'Client wise Balance', icon: 'users' },
|
||||
{ code: 'banker-head', label: 'Banker Head Report', icon: 'university' },
|
||||
{ code: 'sundry-register', label: 'Sundry Register', icon: 'clipboard-list' },
|
||||
{ code: 'voucher-register', label: 'Voucher Register', icon: 'receipt' }
|
||||
];
|
||||
|
||||
activeReport: GlReportCode = 'trial-balance';
|
||||
@@ -80,16 +127,29 @@ export class GlReportsComponent implements OnInit {
|
||||
private toastTimer: any;
|
||||
|
||||
ngOnInit(): void {
|
||||
const param = this.route.snapshot.paramMap.get('report') as GlReportCode | null;
|
||||
if (param && this.reports.some((r) => r.code === param)) {
|
||||
this.activeReport = param;
|
||||
}
|
||||
// Subscribe rather than read the snapshot: navigating between GL menu items only
|
||||
// changes the route parameter, so Angular reuses this component and ngOnInit would
|
||||
// never fire again — leaving the previously selected report on screen.
|
||||
this.route.paramMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params) => {
|
||||
const param = params.get('report') as GlReportCode | null;
|
||||
if (param && this.reports.some((r) => r.code === param)) {
|
||||
this.activeReport = param;
|
||||
}
|
||||
});
|
||||
// A branch login opens on its own book and cannot move off it.
|
||||
this.branchCode = this.scope.defaultBranchCode;
|
||||
}
|
||||
|
||||
showReport(code: GlReportCode): void {
|
||||
this.activeReport = code;
|
||||
// Keep the URL deep-linkable so the GL menu items land on the right report.
|
||||
this.router.navigate(['/gl/reports', code], { replaceUrl: true });
|
||||
this.router.navigate(
|
||||
[
|
||||
'/gl/reports',
|
||||
code
|
||||
],
|
||||
{ replaceUrl: true }
|
||||
);
|
||||
}
|
||||
|
||||
get reportLabel(): string {
|
||||
@@ -120,21 +180,33 @@ export class GlReportsComponent implements OnInit {
|
||||
}
|
||||
|
||||
get branchLabel(): string {
|
||||
if (!this.canSelectBranch) {
|
||||
return this.scopeName;
|
||||
}
|
||||
return this.branchCode ? glBranchName(this.branchCode) : 'All Branches (Consolidated)';
|
||||
}
|
||||
|
||||
/**
|
||||
* The branch actually used for every query. Applied at the data boundary rather than
|
||||
* only in the UI so a branch login cannot widen its scope by tampering with state.
|
||||
*/
|
||||
private get effectiveBranch(): string {
|
||||
return this.scope.enforce(this.branchCode);
|
||||
}
|
||||
|
||||
resetFilters(): void {
|
||||
this.periodMode = 'month';
|
||||
this.filterMonth = '2026-07';
|
||||
this.filterDay = '2026-07-31';
|
||||
this.filterFrom = GL_PERIOD.fyStart;
|
||||
this.filterTo = GL_PERIOD.fyEnd;
|
||||
this.branchCode = '';
|
||||
// Reset returns a branch login to its own branch, never to "all".
|
||||
this.branchCode = this.scope.defaultBranchCode;
|
||||
}
|
||||
|
||||
/** All seven reports for the current filter — memoised inside buildReports. */
|
||||
get data(): GlReportSet {
|
||||
return buildReports(this.range, this.branchCode);
|
||||
return buildReports(this.range, this.effectiveBranch);
|
||||
}
|
||||
|
||||
// ── Trial Balance ──────────────────────────────────────────────────────────
|
||||
@@ -234,6 +306,139 @@ export class GlReportsComponent implements OnInit {
|
||||
return glBranchName(code);
|
||||
}
|
||||
|
||||
// ── GL Average Balance ─────────────────────────────────────────────────────
|
||||
get averageRows(): AverageBalanceRow[] {
|
||||
return averageBalances(this.range, this.effectiveBranch);
|
||||
}
|
||||
get avgTotalAverage(): number {
|
||||
return sum(this.averageRows, (r) => r.average);
|
||||
}
|
||||
|
||||
// ── GL Mapping for Report ──────────────────────────────────────────────────
|
||||
/** Every RBI return line currently pinned to GL accounts, with its ledger value. */
|
||||
get mappingRows(): {
|
||||
returnCode: string;
|
||||
returnLabel: string;
|
||||
lineKey: string;
|
||||
codes: string[];
|
||||
codeLabels: string;
|
||||
value: number;
|
||||
}[] {
|
||||
return allMappings()
|
||||
.map((m: GlMappingEntry) => ({
|
||||
returnCode: m.returnCode,
|
||||
returnLabel: returnName(m.returnCode),
|
||||
lineKey: m.lineKey,
|
||||
codes: m.codes,
|
||||
codeLabels: m.codes.map((c) => glLabel(c)).join(', '),
|
||||
// Returns are in thousands; the ledger is in rupees.
|
||||
value: glValueForCodes(m.codes, this.range, this.effectiveBranch) / 1000
|
||||
}))
|
||||
.sort((a, b) => a.returnCode.localeCompare(b.returnCode) || a.lineKey.localeCompare(b.lineKey));
|
||||
}
|
||||
|
||||
/** GL accounts not pinned to any return line — the gap a mapping review looks for. */
|
||||
get unmappedAccounts(): { code: string; name: string; group: string }[] {
|
||||
const used = new Set(allMappings().flatMap((m) => m.codes));
|
||||
return this.chartOfAccounts
|
||||
.filter((a) => !used.has(a.code))
|
||||
.map((a) => ({ code: a.code, name: a.name, group: a.group }));
|
||||
}
|
||||
|
||||
readonly chartOfAccounts = GL_ACCOUNTS;
|
||||
|
||||
// ── Account wise Balance List ──────────────────────────────────────────────
|
||||
/** Branch columns shown — every branch for Head Office, just their own otherwise. */
|
||||
get branchColumns(): string[] {
|
||||
return this.canSelectBranch ? GL_BRANCHES.map((b) => b.code) : [this.effectiveBranch];
|
||||
}
|
||||
get accountWiseRows(): AccountBranchRow[] {
|
||||
return accountWiseByBranch(this.range, this.branchColumns);
|
||||
}
|
||||
branchColumnTotal(code: string): number {
|
||||
return sum(this.accountWiseRows, (r) => r.byBranch[code] ?? 0);
|
||||
}
|
||||
get accountWiseGrandTotal(): number {
|
||||
return sum(this.accountWiseRows, (r) => r.total);
|
||||
}
|
||||
|
||||
// ── Client wise Balance ────────────────────────────────────────────────────
|
||||
get clientRows(): ClientBalanceRow[] {
|
||||
return clientWiseBalances(this.range, this.effectiveBranch);
|
||||
}
|
||||
get clientDeposits(): number {
|
||||
return sum(
|
||||
this.clientRows.filter((r) => r.side === 'DEPOSIT'),
|
||||
(r) => r.balance
|
||||
);
|
||||
}
|
||||
get clientAdvances(): number {
|
||||
return sum(
|
||||
this.clientRows.filter((r) => r.side === 'ADVANCE'),
|
||||
(r) => r.balance
|
||||
);
|
||||
}
|
||||
|
||||
// ── Banker Head Report ─────────────────────────────────────────────────────
|
||||
get bankerHeadRows(): BankerHeadRow[] {
|
||||
return bankerHeadSummary(this.range, this.effectiveBranch);
|
||||
}
|
||||
get headDebitTotal(): number {
|
||||
return sum(this.bankerHeadRows, (r) => r.debit);
|
||||
}
|
||||
get headCreditTotal(): number {
|
||||
return sum(this.bankerHeadRows, (r) => r.credit);
|
||||
}
|
||||
|
||||
// ── Sundry Register ────────────────────────────────────────────────────────
|
||||
get sundryRows(): SundryItemRow[] {
|
||||
return sundryRegister(this.range, this.effectiveBranch);
|
||||
}
|
||||
get sundryTotal(): number {
|
||||
return sum(this.sundryRows, (r) => r.amount);
|
||||
}
|
||||
/** Ageing summary shown above the register. */
|
||||
get sundryBuckets(): { bucket: string; count: number; amount: number }[] {
|
||||
const order = [
|
||||
'Up to 30 days',
|
||||
'31 to 90 days',
|
||||
'91 to 180 days',
|
||||
'Over 180 days'
|
||||
];
|
||||
return order
|
||||
.map((bucket) => {
|
||||
const rows = this.sundryRows.filter((r) => r.bucket === bucket);
|
||||
return { bucket, count: rows.length, amount: sum(rows, (r) => r.amount) };
|
||||
})
|
||||
.filter((b) => b.count > 0);
|
||||
}
|
||||
|
||||
// ── Voucher Register ───────────────────────────────────────────────────────
|
||||
get voucherRows(): VoucherRow[] {
|
||||
return voucherRegister(this.range, this.effectiveBranch);
|
||||
}
|
||||
get voucherTotal(): number {
|
||||
return sum(this.voucherRows, (r) => r.amount);
|
||||
}
|
||||
/** Vouchers whose debits and credits do not agree — should always be zero. */
|
||||
get unbalancedVouchers(): number {
|
||||
return this.voucherRows.filter((r) => !r.balanced).length;
|
||||
}
|
||||
|
||||
// ── Distribution of Balance ────────────────────────────────────────────────
|
||||
get assetDistribution(): DistributionRow[] {
|
||||
return balanceDistribution(this.range, this.effectiveBranch, 'ASSET');
|
||||
}
|
||||
get liabilityDistribution(): DistributionRow[] {
|
||||
return balanceDistribution(this.range, this.effectiveBranch, 'LIABILITY');
|
||||
}
|
||||
get assetDistTotal(): number {
|
||||
return sum(this.assetDistribution, (r) => r.amount);
|
||||
}
|
||||
get liabilityDistTotal(): number {
|
||||
return sum(this.liabilityDistribution, (r) => r.amount);
|
||||
}
|
||||
|
||||
// ── Excel export ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Exports whichever report is on screen, carrying the active filter into the header. */
|
||||
@@ -314,7 +519,17 @@ export class GlReportsComponent implements OnInit {
|
||||
'Closing Dr',
|
||||
'Closing Cr'
|
||||
],
|
||||
widths: [12, 42, 26, 16, 16, 16, 16, 16, 16],
|
||||
widths: [
|
||||
12,
|
||||
42,
|
||||
26,
|
||||
16,
|
||||
16,
|
||||
16,
|
||||
16,
|
||||
16,
|
||||
16
|
||||
],
|
||||
rows: [
|
||||
...this.data.trialBalance.map((r) => [
|
||||
r.code,
|
||||
@@ -349,13 +564,49 @@ export class GlReportsComponent implements OnInit {
|
||||
'Group',
|
||||
'Amount'
|
||||
],
|
||||
widths: [14, 12, 42, 26, 18],
|
||||
widths: [
|
||||
14,
|
||||
12,
|
||||
42,
|
||||
26,
|
||||
18
|
||||
],
|
||||
rows: [
|
||||
...this.pnlExpense.map((r) => ['Expenditure', r.code, r.name, r.group, r.amount]),
|
||||
['Total Expenditure', '', '', '', this.totalExpense],
|
||||
...this.pnlIncome.map((r) => ['Income', r.code, r.name, r.group, r.amount]),
|
||||
['Total Income', '', '', '', this.totalIncome],
|
||||
['Net Profit', '', '', '', this.netProfit]
|
||||
...this.pnlExpense.map((r) => [
|
||||
'Expenditure',
|
||||
r.code,
|
||||
r.name,
|
||||
r.group,
|
||||
r.amount
|
||||
]),
|
||||
[
|
||||
'Total Expenditure',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
this.totalExpense
|
||||
],
|
||||
...this.pnlIncome.map((r) => [
|
||||
'Income',
|
||||
r.code,
|
||||
r.name,
|
||||
r.group,
|
||||
r.amount
|
||||
]),
|
||||
[
|
||||
'Total Income',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
this.totalIncome
|
||||
],
|
||||
[
|
||||
'Net Profit',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
this.netProfit
|
||||
]
|
||||
]
|
||||
};
|
||||
case 'balance-sheet':
|
||||
@@ -367,13 +618,49 @@ export class GlReportsComponent implements OnInit {
|
||||
'Group',
|
||||
'Amount'
|
||||
],
|
||||
widths: [14, 12, 42, 26, 18],
|
||||
widths: [
|
||||
14,
|
||||
12,
|
||||
42,
|
||||
26,
|
||||
18
|
||||
],
|
||||
rows: [
|
||||
...this.bsLiabilities.map((r) => ['Liability', r.code, r.name, r.group, r.amount]),
|
||||
['Liability', '', 'Surplus for the period', 'Profit & Loss', this.bsSurplus],
|
||||
['Total Liabilities', '', '', '', this.bsTotalLiabilities],
|
||||
...this.bsAssets.map((r) => ['Asset', r.code, r.name, r.group, r.amount]),
|
||||
['Total Assets', '', '', '', this.bsTotalAssets]
|
||||
...this.bsLiabilities.map((r) => [
|
||||
'Liability',
|
||||
r.code,
|
||||
r.name,
|
||||
r.group,
|
||||
r.amount
|
||||
]),
|
||||
[
|
||||
'Liability',
|
||||
'',
|
||||
'Surplus for the period',
|
||||
'Profit & Loss',
|
||||
this.bsSurplus
|
||||
],
|
||||
[
|
||||
'Total Liabilities',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
this.bsTotalLiabilities
|
||||
],
|
||||
...this.bsAssets.map((r) => [
|
||||
'Asset',
|
||||
r.code,
|
||||
r.name,
|
||||
r.group,
|
||||
r.amount
|
||||
]),
|
||||
[
|
||||
'Total Assets',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
this.bsTotalAssets
|
||||
]
|
||||
]
|
||||
};
|
||||
case 'comparative-balance-sheet':
|
||||
@@ -387,7 +674,15 @@ export class GlReportsComponent implements OnInit {
|
||||
'Variance',
|
||||
'Variance %'
|
||||
],
|
||||
widths: [12, 12, 42, 18, 18, 18, 14],
|
||||
widths: [
|
||||
12,
|
||||
12,
|
||||
42,
|
||||
18,
|
||||
18,
|
||||
18,
|
||||
14
|
||||
],
|
||||
rows: this.data.comparative.map((r) => [
|
||||
r.side,
|
||||
r.code,
|
||||
@@ -406,10 +701,25 @@ export class GlReportsComponent implements OnInit {
|
||||
'Group',
|
||||
'Amount'
|
||||
],
|
||||
widths: [12, 48, 26, 18],
|
||||
widths: [
|
||||
12,
|
||||
48,
|
||||
26,
|
||||
18
|
||||
],
|
||||
rows: [
|
||||
...this.data.contingent.map((r) => [r.code, r.name, r.group, r.amount]),
|
||||
['TOTAL', '', '', this.contingentTotal]
|
||||
...this.data.contingent.map((r) => [
|
||||
r.code,
|
||||
r.name,
|
||||
r.group,
|
||||
r.amount
|
||||
]),
|
||||
[
|
||||
'TOTAL',
|
||||
'',
|
||||
'',
|
||||
this.contingentTotal
|
||||
]
|
||||
]
|
||||
};
|
||||
case 'gl-wise-dr-cr':
|
||||
@@ -424,10 +734,394 @@ export class GlReportsComponent implements OnInit {
|
||||
'Net (Dr-Cr)',
|
||||
'Vouchers'
|
||||
],
|
||||
widths: [12, 42, 14, 26, 18, 18, 18, 12],
|
||||
widths: [
|
||||
12,
|
||||
42,
|
||||
14,
|
||||
26,
|
||||
18,
|
||||
18,
|
||||
18,
|
||||
12
|
||||
],
|
||||
rows: [
|
||||
...this.data.glWise.map((r) => [r.code, r.name, r.type, r.group, r.debit, r.credit, r.net, r.vouchers]),
|
||||
['TOTAL', '', '', '', this.glWiseDebit, this.glWiseCredit, this.glWiseDebit - this.glWiseCredit, null]
|
||||
...this.data.glWise.map((r) => [
|
||||
r.code,
|
||||
r.name,
|
||||
r.type,
|
||||
r.group,
|
||||
r.debit,
|
||||
r.credit,
|
||||
r.net,
|
||||
r.vouchers
|
||||
]),
|
||||
[
|
||||
'TOTAL',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
this.glWiseDebit,
|
||||
this.glWiseCredit,
|
||||
this.glWiseDebit - this.glWiseCredit,
|
||||
null
|
||||
]
|
||||
]
|
||||
};
|
||||
case 'gl-average-balance':
|
||||
return {
|
||||
headers: [
|
||||
'GL Code',
|
||||
'Account',
|
||||
'Group',
|
||||
'Opening',
|
||||
'Closing',
|
||||
'Average Daily Balance',
|
||||
'Peak',
|
||||
'Lowest',
|
||||
'Active Days'
|
||||
],
|
||||
widths: [
|
||||
12,
|
||||
42,
|
||||
26,
|
||||
18,
|
||||
18,
|
||||
22,
|
||||
18,
|
||||
18,
|
||||
12
|
||||
],
|
||||
rows: [
|
||||
...this.averageRows.map((r) => [
|
||||
r.code,
|
||||
r.name,
|
||||
r.group,
|
||||
r.opening,
|
||||
r.closing,
|
||||
r.average,
|
||||
r.peak,
|
||||
r.lowest,
|
||||
r.activeDays
|
||||
]),
|
||||
[
|
||||
'TOTAL',
|
||||
'',
|
||||
'',
|
||||
null,
|
||||
null,
|
||||
this.avgTotalAverage,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
]
|
||||
]
|
||||
};
|
||||
case 'gl-mapping':
|
||||
return {
|
||||
headers: [
|
||||
'Return',
|
||||
'Return Line',
|
||||
'Mapped GL Accounts',
|
||||
'Ledger Value (Rs Thousands)'
|
||||
],
|
||||
widths: [
|
||||
42,
|
||||
46,
|
||||
60,
|
||||
24
|
||||
],
|
||||
rows: [
|
||||
...this.mappingRows.map((r) => [
|
||||
r.returnLabel,
|
||||
r.lineKey,
|
||||
r.codeLabels,
|
||||
r.value
|
||||
]),
|
||||
...(this.mappingRows.length ? [] : [[
|
||||
'— no return lines mapped —',
|
||||
'',
|
||||
'',
|
||||
null
|
||||
]]),
|
||||
[
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
null
|
||||
],
|
||||
[
|
||||
'UNMAPPED GL ACCOUNTS',
|
||||
'',
|
||||
'',
|
||||
null
|
||||
],
|
||||
...this.unmappedAccounts.map((a) => [
|
||||
'',
|
||||
a.code + ' — ' + a.name,
|
||||
a.group,
|
||||
null
|
||||
])
|
||||
]
|
||||
};
|
||||
case 'account-wise-balance':
|
||||
return {
|
||||
headers: [
|
||||
'GL Code',
|
||||
'Account',
|
||||
'Group',
|
||||
...this.branchColumns.map((c) => this.branchName(c)),
|
||||
'Total'
|
||||
],
|
||||
widths: [
|
||||
12,
|
||||
40,
|
||||
24,
|
||||
...this.branchColumns.map(() => 18),
|
||||
20
|
||||
],
|
||||
rows: [
|
||||
...this.accountWiseRows.map((r) => [
|
||||
r.code,
|
||||
r.name,
|
||||
r.group,
|
||||
...this.branchColumns.map((c) => r.byBranch[c] ?? 0),
|
||||
r.total
|
||||
]),
|
||||
[
|
||||
'TOTAL',
|
||||
'',
|
||||
'',
|
||||
...this.branchColumns.map((c) => this.branchColumnTotal(c)),
|
||||
this.accountWiseGrandTotal
|
||||
]
|
||||
]
|
||||
};
|
||||
case 'distribution-of-balance':
|
||||
return {
|
||||
headers: [
|
||||
'Side',
|
||||
'Group',
|
||||
'Accounts',
|
||||
'Amount',
|
||||
'Share %'
|
||||
],
|
||||
widths: [
|
||||
16,
|
||||
40,
|
||||
14,
|
||||
22,
|
||||
14
|
||||
],
|
||||
rows: [
|
||||
...this.liabilityDistribution.map((r) => [
|
||||
'Liability',
|
||||
r.group,
|
||||
r.accounts,
|
||||
r.amount,
|
||||
Number(r.share.toFixed(2))
|
||||
]),
|
||||
[
|
||||
'Total Liabilities',
|
||||
'',
|
||||
null,
|
||||
this.liabilityDistTotal,
|
||||
100
|
||||
],
|
||||
...this.assetDistribution.map((r) => [
|
||||
'Asset',
|
||||
r.group,
|
||||
r.accounts,
|
||||
r.amount,
|
||||
Number(r.share.toFixed(2))
|
||||
]),
|
||||
[
|
||||
'Total Assets',
|
||||
'',
|
||||
null,
|
||||
this.assetDistTotal,
|
||||
100
|
||||
]
|
||||
]
|
||||
};
|
||||
case 'client-wise-balance':
|
||||
return {
|
||||
headers: [
|
||||
'CIF',
|
||||
'Customer',
|
||||
'Branch',
|
||||
'Account No',
|
||||
'Product',
|
||||
'Side',
|
||||
'Balance'
|
||||
],
|
||||
widths: [
|
||||
14,
|
||||
34,
|
||||
18,
|
||||
24,
|
||||
34,
|
||||
12,
|
||||
20
|
||||
],
|
||||
rows: [
|
||||
...this.clientRows.map((r) => [
|
||||
r.cif,
|
||||
r.name,
|
||||
this.branchName(r.branchCode),
|
||||
r.accountNo,
|
||||
r.product,
|
||||
r.side,
|
||||
r.balance
|
||||
]),
|
||||
[
|
||||
'TOTAL DEPOSITS',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
this.clientDeposits
|
||||
],
|
||||
[
|
||||
'TOTAL ADVANCES',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
this.clientAdvances
|
||||
]
|
||||
]
|
||||
};
|
||||
case 'banker-head':
|
||||
return {
|
||||
headers: [
|
||||
'Principal Head',
|
||||
'Accounts',
|
||||
'Opening',
|
||||
'Debit',
|
||||
'Credit',
|
||||
'Closing'
|
||||
],
|
||||
widths: [
|
||||
34,
|
||||
12,
|
||||
20,
|
||||
20,
|
||||
20,
|
||||
20
|
||||
],
|
||||
rows: [
|
||||
...this.bankerHeadRows.map((r) => [
|
||||
r.head,
|
||||
r.accounts,
|
||||
r.opening,
|
||||
r.debit,
|
||||
r.credit,
|
||||
r.closing
|
||||
]),
|
||||
[
|
||||
'TOTAL',
|
||||
null,
|
||||
null,
|
||||
this.headDebitTotal,
|
||||
this.headCreditTotal,
|
||||
null
|
||||
]
|
||||
]
|
||||
};
|
||||
case 'sundry-register':
|
||||
return {
|
||||
headers: [
|
||||
'Date',
|
||||
'Voucher No',
|
||||
'Branch',
|
||||
'GL Code',
|
||||
'Account',
|
||||
'Narration',
|
||||
'Amount',
|
||||
'Age (days)',
|
||||
'Ageing Bucket'
|
||||
],
|
||||
widths: [
|
||||
14,
|
||||
20,
|
||||
18,
|
||||
12,
|
||||
32,
|
||||
42,
|
||||
18,
|
||||
12,
|
||||
18
|
||||
],
|
||||
rows: [
|
||||
...this.sundryRows.map((r) => [
|
||||
r.date,
|
||||
r.voucherNo,
|
||||
this.branchName(r.branchCode),
|
||||
r.code,
|
||||
r.name,
|
||||
r.narration,
|
||||
r.amount,
|
||||
r.ageDays,
|
||||
r.bucket
|
||||
]),
|
||||
[
|
||||
'TOTAL',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
this.sundryTotal,
|
||||
null,
|
||||
''
|
||||
]
|
||||
]
|
||||
};
|
||||
case 'voucher-register':
|
||||
return {
|
||||
headers: [
|
||||
'Date',
|
||||
'Voucher No',
|
||||
'Branch',
|
||||
'Narration',
|
||||
'Debit Account(s)',
|
||||
'Credit Account(s)',
|
||||
'Amount',
|
||||
'Legs'
|
||||
],
|
||||
widths: [
|
||||
14,
|
||||
20,
|
||||
18,
|
||||
42,
|
||||
38,
|
||||
38,
|
||||
20,
|
||||
10
|
||||
],
|
||||
rows: [
|
||||
...this.voucherRows.map((r) => [
|
||||
r.date,
|
||||
r.voucherNo,
|
||||
this.branchName(r.branchCode),
|
||||
r.narration,
|
||||
r.debitAccounts,
|
||||
r.creditAccounts,
|
||||
r.amount,
|
||||
r.legs
|
||||
]),
|
||||
[
|
||||
'TOTAL',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
this.voucherTotal,
|
||||
null
|
||||
]
|
||||
]
|
||||
};
|
||||
case 'misc-expense-ledger':
|
||||
@@ -442,7 +1136,15 @@ export class GlReportsComponent implements OnInit {
|
||||
'Narration',
|
||||
'Amount'
|
||||
],
|
||||
widths: [14, 20, 18, 12, 36, 46, 18],
|
||||
widths: [
|
||||
14,
|
||||
20,
|
||||
18,
|
||||
12,
|
||||
36,
|
||||
46,
|
||||
18
|
||||
],
|
||||
rows: [
|
||||
...this.miscRows.map((r) => [
|
||||
r.date,
|
||||
@@ -453,7 +1155,15 @@ export class GlReportsComponent implements OnInit {
|
||||
r.narration,
|
||||
r.amount
|
||||
]),
|
||||
['TOTAL', '', '', '', '', '', this.miscTotal]
|
||||
[
|
||||
'TOTAL',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
this.miscTotal
|
||||
]
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
@@ -15,14 +15,7 @@
|
||||
* postings and Angular re-evaluates template getters on every change detection.
|
||||
*/
|
||||
|
||||
import {
|
||||
GL_ACCOUNTS,
|
||||
GL_PERIOD,
|
||||
GlAccount,
|
||||
GlPosting,
|
||||
glAccount,
|
||||
glPostings
|
||||
} from './gl-reports-data';
|
||||
import { GL_ACCOUNTS, GL_PERIOD, GlAccount, GlPosting, glAccount, glCustomers, glPostings } from './gl-reports-data';
|
||||
|
||||
export interface GlDateRange {
|
||||
from: string;
|
||||
@@ -96,8 +89,22 @@ 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)) {
|
||||
@@ -110,11 +117,20 @@ function cumulativeBalances(to: string, branchCode: string): Map<string, number>
|
||||
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> }> {
|
||||
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)) {
|
||||
@@ -129,6 +145,7 @@ function periodTurnover(range: GlDateRange, branchCode: string): Map<string, { d
|
||||
entry.credit += p.credit;
|
||||
entry.vouchers.add(p.voucherNo);
|
||||
}
|
||||
turnoverCache.set(key, out);
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -190,8 +207,7 @@ export function profitAndLoss(range: GlDateRange, branchCode: string): { income:
|
||||
if (!move) {
|
||||
continue;
|
||||
}
|
||||
const amount =
|
||||
account.type === 'INCOME' ? move.credit - move.debit : move.debit - move.credit;
|
||||
const amount = account.type === 'INCOME' ? move.credit - move.debit : move.debit - move.credit;
|
||||
if (!amount) {
|
||||
continue;
|
||||
}
|
||||
@@ -205,7 +221,10 @@ export function profitAndLoss(range: GlDateRange, branchCode: string): { income:
|
||||
* 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): {
|
||||
export function balanceSheet(
|
||||
range: GlDateRange,
|
||||
branchCode: string
|
||||
): {
|
||||
assets: BalanceSheetRow[];
|
||||
liabilities: BalanceSheetRow[];
|
||||
surplus: number;
|
||||
@@ -315,7 +334,417 @@ export function miscExpenseLedger(range: GlDateRange, branchCode: string): MiscE
|
||||
amount: p.debit
|
||||
});
|
||||
}
|
||||
return rows.sort((a, b) => (a.date === b.date ? a.voucherNo.localeCompare(b.voucherNo) : a.date.localeCompare(b.date)));
|
||||
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 ──────────────────────────────────────────────────────────────
|
||||
@@ -360,6 +789,41 @@ export function clampToLedger(range: GlDateRange): GlDateRange {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,22 @@
|
||||
</mat-card-header>
|
||||
|
||||
<mat-card-content class="aml-card-content">
|
||||
<!-- Separate the rules by the window they are measured over -->
|
||||
<div class="period-filters">
|
||||
<span class="period-filters-label">Applies over</span>
|
||||
<button
|
||||
*ngFor="let p of periodFilters"
|
||||
type="button"
|
||||
class="period-chip"
|
||||
[class.active]="activePeriod === p.key"
|
||||
[disabled]="periodCount(p.key) === 0"
|
||||
(click)="setPeriod(p.key)"
|
||||
>
|
||||
{{ p.label }}
|
||||
<span class="period-chip-n">{{ periodCount(p.key) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<table mat-table [dataSource]="rules" class="aml-table mat-elevation-z1">
|
||||
<!-- Order Position Column -->
|
||||
<ng-container matColumnDef="orderPosition">
|
||||
@@ -45,10 +61,37 @@
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Income / Occupation Limit Column -->
|
||||
<ng-container matColumnDef="incomeLimit">
|
||||
<th mat-header-cell *matHeaderCellDef>Income / Occupation Limit</th>
|
||||
<td mat-cell *matCellDef="let element" class="income-limit-cell">{{ getIncomeLimitLabel(element) }}</td>
|
||||
<!-- What the min/max figures are measured against -->
|
||||
<ng-container matColumnDef="limitBasis">
|
||||
<th mat-header-cell *matHeaderCellDef>Limit Basis</th>
|
||||
<td mat-cell *matCellDef="let element" class="basis-cell">
|
||||
{{ getLimitBasis(element) }}
|
||||
<span *ngIf="getNonNumericCriterion(element)" class="basis-sub">
|
||||
{{ getNonNumericCriterion(element) }}
|
||||
</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Minimum Limit Column -->
|
||||
<ng-container matColumnDef="minLimit">
|
||||
<th mat-header-cell *matHeaderCellDef class="col-right">Minimum Limit</th>
|
||||
<td mat-cell *matCellDef="let element" class="col-right amount-cell">{{ getMinLimit(element) }}</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Maximum Limit Column -->
|
||||
<ng-container matColumnDef="maxLimit">
|
||||
<th mat-header-cell *matHeaderCellDef class="col-right">Maximum Limit</th>
|
||||
<td mat-cell *matCellDef="let element" class="col-right amount-cell">
|
||||
<span [class.no-ceiling]="getMaxLimit(element) === 'No upper limit'">{{ getMaxLimit(element) }}</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Observation window the rule is measured over -->
|
||||
<ng-container matColumnDef="appliesOver">
|
||||
<th mat-header-cell *matHeaderCellDef class="col-center">Applies Over</th>
|
||||
<td mat-cell *matCellDef="let element" class="col-center">
|
||||
<span [ngClass]="periodClass(element)">{{ periodLabel(element) }}</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Reason Column -->
|
||||
|
||||
@@ -114,13 +114,141 @@
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.income-limit-cell {
|
||||
.col-right {
|
||||
text-align: right;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* What the min/max amounts are measured against. */
|
||||
.basis-cell {
|
||||
color: #334155;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
white-space: normal;
|
||||
max-width: 200px;
|
||||
line-height: 1.35;
|
||||
max-width: 150px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.basis-sub {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.amount-cell {
|
||||
font-family: 'DM Mono', monospace;
|
||||
font-size: 12.5px;
|
||||
color: #0f172a;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* An open-ended threshold rule has a floor but no ceiling. */
|
||||
.no-ceiling {
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
font-style: italic;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
/* ── Observation-window badges ─────────────────────────────────────────── */
|
||||
.period-badge {
|
||||
display: inline-block;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
letter-spacing: 0.02em;
|
||||
|
||||
&.period-txn {
|
||||
background: #e2e8f0;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
&.period-day {
|
||||
background: #dbeafe;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
&.period-week {
|
||||
background: #fef3c7;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
&.period-month {
|
||||
background: #ede9fe;
|
||||
color: #6d28d9;
|
||||
}
|
||||
|
||||
&.period-other {
|
||||
background: #fee2e2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
}
|
||||
|
||||
/* Chips that separate the rule list by window. */
|
||||
.period-filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
padding: 0 0 14px;
|
||||
}
|
||||
|
||||
.period-filters-label {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #64748b;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.period-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 6px 13px;
|
||||
border: 1px solid #cbd5e1;
|
||||
background: #fff;
|
||||
border-radius: 999px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: #1a237e;
|
||||
color: #1a237e;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: #1a237e;
|
||||
border-color: #1a237e;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.period-chip-n {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
padding: 1px 7px;
|
||||
border-radius: 999px;
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
|
||||
.period-chip.active & {
|
||||
background: rgb(255 255 255 / 22%);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
/* Badges for Risk Category */
|
||||
|
||||
@@ -42,24 +42,185 @@ export class AmlRulesComponent implements OnInit {
|
||||
'orderPosition',
|
||||
'ruleName',
|
||||
'riskCategory',
|
||||
'incomeLimit',
|
||||
'limitBasis',
|
||||
'minLimit',
|
||||
'maxLimit',
|
||||
'appliesOver',
|
||||
'reason',
|
||||
'amlCheckStatus',
|
||||
'bannedEntityMatch'
|
||||
];
|
||||
|
||||
/** Every rule as returned by the API, before the period filter is applied. */
|
||||
private allRules: any[] = [];
|
||||
|
||||
/** Period buckets the rules can be separated into. */
|
||||
readonly periodFilters: { key: string; label: string }[] = [
|
||||
{ key: '', label: 'All rules' },
|
||||
{ key: 'TXN', label: 'Per transaction' },
|
||||
{ key: 'DAY', label: 'Daily' },
|
||||
{ key: 'WEEK', label: 'Weekly' },
|
||||
{ key: 'MONTH', label: 'Monthly' },
|
||||
{ key: 'OTHER', label: 'Other window' }
|
||||
];
|
||||
activePeriod = '';
|
||||
|
||||
@ViewChild(MatPaginator) paginator!: MatPaginator;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.clientsService.getClientRiskRules().subscribe({
|
||||
next: (data) => {
|
||||
this.rules.data = data || [];
|
||||
this.allRules = data || [];
|
||||
this.applyPeriodFilter();
|
||||
this.rules.paginator = this.paginator;
|
||||
},
|
||||
error: () => {}
|
||||
});
|
||||
}
|
||||
|
||||
setPeriod(key: string): void {
|
||||
this.activePeriod = key;
|
||||
this.applyPeriodFilter();
|
||||
}
|
||||
|
||||
private applyPeriodFilter(): void {
|
||||
this.rules.data = this.activePeriod
|
||||
? this.allRules.filter((r) => this.periodKey(r) === this.activePeriod)
|
||||
: this.allRules;
|
||||
if (this.rules.paginator) {
|
||||
this.rules.paginator.firstPage();
|
||||
}
|
||||
}
|
||||
|
||||
/** How many rules fall in each period bucket — shown on the filter chips. */
|
||||
periodCount(key: string): number {
|
||||
return key ? this.allRules.filter((r) => this.periodKey(r) === key).length : this.allRules.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Buckets a rule by the observation window it is measured over.
|
||||
* A rule with no window is evaluated on the single transaction itself, so it is
|
||||
* in force every day rather than accumulating over a period.
|
||||
*/
|
||||
periodKey(rule: any): string {
|
||||
const days = rule.txnWindowDays;
|
||||
if (days == null || days === 0) {
|
||||
return 'TXN';
|
||||
}
|
||||
if (days === 1) {
|
||||
return 'DAY';
|
||||
}
|
||||
if (days === 7) {
|
||||
return 'WEEK';
|
||||
}
|
||||
if (days >= 28 && days <= 31) {
|
||||
return 'MONTH';
|
||||
}
|
||||
return 'OTHER';
|
||||
}
|
||||
|
||||
periodLabel(rule: any): string {
|
||||
const days = rule.txnWindowDays;
|
||||
switch (this.periodKey(rule)) {
|
||||
case 'TXN':
|
||||
return 'Per transaction';
|
||||
case 'DAY':
|
||||
return 'Daily';
|
||||
case 'WEEK':
|
||||
return 'Weekly';
|
||||
case 'MONTH':
|
||||
return 'Monthly';
|
||||
default:
|
||||
return `${days} days`;
|
||||
}
|
||||
}
|
||||
|
||||
periodClass(rule: any): string {
|
||||
return 'period-badge period-' + this.periodKey(rule).toLowerCase();
|
||||
}
|
||||
|
||||
/** Sub-caption telling the reader what the min/max figures actually measure. */
|
||||
getLimitBasis(rule: any): string {
|
||||
if (rule.minMonthlyIncome > 0 || rule.maxMonthlyIncome > 0) {
|
||||
return 'Monthly Income';
|
||||
}
|
||||
if (rule.minAnnualIncome > 0 || rule.maxAnnualIncome > 0) {
|
||||
return 'Annual Income';
|
||||
}
|
||||
if (rule.minAnnualTurnover > 0 || rule.maxAnnualTurnover > 0) {
|
||||
return 'Annual Turnover';
|
||||
}
|
||||
if (rule.txnThresholdAmountMediumRisk != null || rule.txnThresholdAmountHighRisk != null) {
|
||||
return 'Txn Amount (tiered)';
|
||||
}
|
||||
if (rule.txnThresholdAmount != null) {
|
||||
return 'Txn Amount';
|
||||
}
|
||||
if (rule.occupationName) {
|
||||
return 'Occupation';
|
||||
}
|
||||
if (rule.occupationCategoryName) {
|
||||
return 'Occupation Category';
|
||||
}
|
||||
return 'Behavioural pattern';
|
||||
}
|
||||
|
||||
private inr(n: number): string {
|
||||
return '₹' + Number(n).toLocaleString('en-IN');
|
||||
}
|
||||
|
||||
/** Lower bound of the rule, or an em dash where the criterion is not numeric. */
|
||||
getMinLimit(rule: any): string {
|
||||
if (rule.minMonthlyIncome > 0) {
|
||||
return this.inr(rule.minMonthlyIncome);
|
||||
}
|
||||
if (rule.minAnnualIncome > 0) {
|
||||
return this.inr(rule.minAnnualIncome);
|
||||
}
|
||||
if (rule.minAnnualTurnover > 0) {
|
||||
return this.inr(rule.minAnnualTurnover);
|
||||
}
|
||||
// Declarative band from m_aml_transaction_rule takes precedence over the raw threshold.
|
||||
if (rule.txnMinLimitAmount != null) {
|
||||
return this.inr(rule.txnMinLimitAmount);
|
||||
}
|
||||
if (rule.txnThresholdAmount != null) {
|
||||
return this.inr(rule.txnThresholdAmount);
|
||||
}
|
||||
return '—';
|
||||
}
|
||||
|
||||
/** Upper bound, or "No upper limit" where the rule is genuinely open-ended. */
|
||||
getMaxLimit(rule: any): string {
|
||||
if (rule.maxMonthlyIncome > 0) {
|
||||
return this.inr(rule.maxMonthlyIncome);
|
||||
}
|
||||
if (rule.maxAnnualIncome > 0) {
|
||||
return this.inr(rule.maxAnnualIncome);
|
||||
}
|
||||
if (rule.maxAnnualTurnover > 0) {
|
||||
return this.inr(rule.maxAnnualTurnover);
|
||||
}
|
||||
if (rule.txnMaxLimitAmount != null) {
|
||||
return this.inr(rule.txnMaxLimitAmount);
|
||||
}
|
||||
// A floor with no configured ceiling means the rule has no upper bound.
|
||||
if (
|
||||
rule.minMonthlyIncome > 0 ||
|
||||
rule.minAnnualIncome > 0 ||
|
||||
rule.minAnnualTurnover > 0 ||
|
||||
rule.txnThresholdAmount != null
|
||||
) {
|
||||
return 'No upper limit';
|
||||
}
|
||||
return '—';
|
||||
}
|
||||
|
||||
/** Occupation-driven rules carry no amount; show the criterion instead. */
|
||||
getNonNumericCriterion(rule: any): string {
|
||||
return rule.occupationName || rule.occupationCategoryName || '';
|
||||
}
|
||||
|
||||
getRiskCategoryLabel(category: number): string {
|
||||
switch (category) {
|
||||
case 1:
|
||||
@@ -85,39 +246,4 @@ export class AmlRulesComponent implements OnInit {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/** Formats whichever income/turnover/occupation criterion drives this rule's risk mark. */
|
||||
getIncomeLimitLabel(rule: any): string {
|
||||
const inr = (n: number) => '₹' + Number(n).toLocaleString('en-IN');
|
||||
if (rule.minMonthlyIncome != null && rule.minMonthlyIncome > 0) {
|
||||
return rule.maxMonthlyIncome != null && rule.maxMonthlyIncome > 0
|
||||
? `Monthly Income ${inr(rule.minMonthlyIncome)} - ${inr(rule.maxMonthlyIncome)}`
|
||||
: `Monthly Income > ${inr(rule.minMonthlyIncome)}`;
|
||||
}
|
||||
if (rule.minAnnualIncome != null && rule.minAnnualIncome > 0) {
|
||||
return rule.maxAnnualIncome != null && rule.maxAnnualIncome > 0
|
||||
? `Annual Income ${inr(rule.minAnnualIncome)} - ${inr(rule.maxAnnualIncome)}`
|
||||
: `Annual Income > ${inr(rule.minAnnualIncome)}`;
|
||||
}
|
||||
if (rule.minAnnualTurnover != null && rule.minAnnualTurnover > 0) {
|
||||
return rule.maxAnnualTurnover != null && rule.maxAnnualTurnover > 0
|
||||
? `Annual Turnover ${inr(rule.minAnnualTurnover)} - ${inr(rule.maxAnnualTurnover)}`
|
||||
: `Annual Turnover > ${inr(rule.minAnnualTurnover)}`;
|
||||
}
|
||||
if (rule.occupationName) return `Occupation: ${rule.occupationName}`;
|
||||
if (rule.occupationCategoryName) return `Occupation Category: ${rule.occupationCategoryName}`;
|
||||
if (rule.txnThresholdAmountMediumRisk != null || rule.txnThresholdAmountHighRisk != null) {
|
||||
const parts: string[] = [];
|
||||
if (rule.txnThresholdAmountMediumRisk != null) parts.push(`${inr(rule.txnThresholdAmountMediumRisk)} (Medium Risk)`);
|
||||
if (rule.txnThresholdAmountHighRisk != null) parts.push(`${inr(rule.txnThresholdAmountHighRisk)} (High Risk)`);
|
||||
return `Txn Amount ≥ ${parts.join(' / ')}`;
|
||||
}
|
||||
if (rule.txnThresholdAmount != null) {
|
||||
const amt = inr(rule.txnThresholdAmount);
|
||||
if (rule.txnRuleType === 'THRESHOLD') return `Txn Amount ≥ ${amt}`;
|
||||
if (rule.txnWindowDays != null) return `${amt} within ${rule.txnWindowDays} days`;
|
||||
return `Txn Amount ≥ ${amt}`;
|
||||
}
|
||||
return 'Behavioral / Transaction Pattern';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
<!--
|
||||
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="glm-root">
|
||||
<div class="glm-head">
|
||||
<div>
|
||||
<h3>Map General Ledger accounts</h3>
|
||||
<p>{{ data.returnCode }} · {{ data.lineLabel }}</p>
|
||||
</div>
|
||||
<button class="glm-btn" (click)="cancel()"><fa-icon icon="times"></fa-icon></button>
|
||||
</div>
|
||||
|
||||
<!-- Live comparison between the ledger and the figure on the return -->
|
||||
<div
|
||||
class="glm-compare"
|
||||
[class.glm-compare--match]="selected.length && matchesFiled"
|
||||
[class.glm-compare--diff]="selected.length && !matchesFiled && variance !== null"
|
||||
>
|
||||
<div class="glm-fig">
|
||||
<span class="glm-fig-l">On the return</span>
|
||||
<span class="glm-fig-v">{{ data.filedValue === null ? '—' : (data.filedValue | number: '1.2-2') }}</span>
|
||||
</div>
|
||||
<fa-icon icon="exchange-alt" class="glm-fig-ic"></fa-icon>
|
||||
<div class="glm-fig">
|
||||
<span class="glm-fig-l">From the ledger</span>
|
||||
<span class="glm-fig-v">{{ selected.length ? (derivedValue | number: '1.2-2') : '—' }}</span>
|
||||
</div>
|
||||
@if (selected.length && variance !== null) {
|
||||
<div class="glm-fig glm-fig--var">
|
||||
<span class="glm-fig-l">Variance</span>
|
||||
<span class="glm-fig-v">{{ variance | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
}
|
||||
<div class="glm-compare-note">
|
||||
@if (!selected.length) {
|
||||
Select accounts below to derive this line from the ledger.
|
||||
} @else if (matchesFiled) {
|
||||
Ledger agrees with the filed figure.
|
||||
} @else {
|
||||
Ledger differs from the filed figure — the mapped value will be shown on the return.
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Currently pinned accounts -->
|
||||
<div class="glm-selected">
|
||||
<div class="glm-selected-h">
|
||||
<span>Mapped accounts ({{ selected.length }})</span>
|
||||
@if (selected.length) {
|
||||
<button class="glm-link" (click)="clearAll()">Remove all</button>
|
||||
}
|
||||
</div>
|
||||
@if (selected.length) {
|
||||
<div class="glm-chips">
|
||||
@for (code of selected; track code) {
|
||||
<span class="glm-chip">
|
||||
<span class="mono">{{ code }}</span> {{ accountName(code) }}
|
||||
<button class="glm-chip-x" (click)="removeSelected(code)" [attr.aria-label]="'Remove ' + code">
|
||||
<fa-icon icon="times"></fa-icon>
|
||||
</button>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<p class="glm-empty">No accounts mapped — this line still uses the figure filed on the return.</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Chart of accounts picker -->
|
||||
<div class="glm-filters">
|
||||
<input class="glm-inp" type="text" placeholder="Search by code, name or group…" [(ngModel)]="search" />
|
||||
<select class="glm-inp glm-inp--sm" [(ngModel)]="typeFilter">
|
||||
<option value="">All types</option>
|
||||
@for (t of types; track t) {
|
||||
<option [value]="t">{{ t }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="glm-list">
|
||||
<table class="glm-tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="ta-c">Map</th>
|
||||
<th>GL Code</th>
|
||||
<th>Account</th>
|
||||
<th>Type</th>
|
||||
<th>Group</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (a of filtered; track a.code) {
|
||||
<tr [class.on]="isSelected(a.code)" (click)="toggle(a.code)">
|
||||
<td class="ta-c">
|
||||
<span class="glm-check" [class.on]="isSelected(a.code)">
|
||||
@if (isSelected(a.code)) {
|
||||
<fa-icon icon="check-circle"></fa-icon>
|
||||
}
|
||||
</span>
|
||||
</td>
|
||||
<td class="mono">{{ a.code }}</td>
|
||||
<td>{{ a.name }}</td>
|
||||
<td>
|
||||
<span class="glm-type" [class]="'t-' + a.type.toLowerCase()">{{ a.type }}</span>
|
||||
</td>
|
||||
<td class="muted">{{ a.group }}</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="5" class="glm-empty-row">No accounts match that search.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="glm-actions">
|
||||
<button class="glm-btn" (click)="cancel()">Cancel</button>
|
||||
<button class="glm-btn glm-btn-primary" (click)="save()">Save mapping ({{ selected.length }})</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,358 @@
|
||||
/**
|
||||
* 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 {
|
||||
--m-navy: #002f5f;
|
||||
--m-green: #15803d;
|
||||
--m-green-l: #dcfce7;
|
||||
--m-amber: #b45309;
|
||||
--m-amber-l: #fef3c7;
|
||||
--m-red: #c0392b;
|
||||
--m-red-l: #fdecea;
|
||||
--m-g50: #f8fafc;
|
||||
--m-g100: #f1f5f9;
|
||||
--m-g200: #e2e8f0;
|
||||
--m-g300: #cbd5e1;
|
||||
--m-g500: #64748b;
|
||||
--m-g700: #334155;
|
||||
--m-g900: #0f172a;
|
||||
|
||||
display: block;
|
||||
font-family: 'DM Sans', system-ui, sans-serif;
|
||||
color: var(--m-g900);
|
||||
}
|
||||
|
||||
.glm-root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
padding: 18px 20px;
|
||||
max-height: 88vh;
|
||||
}
|
||||
|
||||
.glm-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 2px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--m-g500);
|
||||
}
|
||||
}
|
||||
|
||||
/* Ledger vs filed figure */
|
||||
.glm-compare {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
flex-wrap: wrap;
|
||||
padding: 12px 16px;
|
||||
border-radius: 10px;
|
||||
background: var(--m-g100);
|
||||
border: 1px solid var(--m-g200);
|
||||
|
||||
&--match {
|
||||
background: var(--m-green-l);
|
||||
border-color: #86efac;
|
||||
}
|
||||
|
||||
&--diff {
|
||||
background: var(--m-amber-l);
|
||||
border-color: #fcd34d;
|
||||
}
|
||||
}
|
||||
|
||||
.glm-fig {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&--var .glm-fig-v {
|
||||
color: var(--m-amber);
|
||||
}
|
||||
}
|
||||
|
||||
.glm-fig-l {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--m-g500);
|
||||
}
|
||||
|
||||
.glm-fig-v {
|
||||
font-family: 'DM Mono', monospace;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.glm-fig-ic {
|
||||
color: var(--m-g500);
|
||||
}
|
||||
|
||||
.glm-compare-note {
|
||||
flex: 1 1 220px;
|
||||
font-size: 12px;
|
||||
color: var(--m-g700);
|
||||
}
|
||||
|
||||
/* Pinned accounts */
|
||||
.glm-selected-h {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--m-g500);
|
||||
margin-bottom: 7px;
|
||||
}
|
||||
|
||||
.glm-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.glm-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 6px 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: #e0f2fe;
|
||||
color: #075985;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.glm-chip-x {
|
||||
border: none;
|
||||
background: rgb(7 89 133 / 12%);
|
||||
color: #075985;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
|
||||
&:hover {
|
||||
background: var(--m-red);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.glm-empty {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--m-g500);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Picker */
|
||||
.glm-filters {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.glm-inp {
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--m-g200);
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
|
||||
&--sm {
|
||||
flex: 0 0 160px;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--m-navy);
|
||||
}
|
||||
}
|
||||
|
||||
.glm-list {
|
||||
flex: 1;
|
||||
min-height: 180px;
|
||||
max-height: 40vh;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--m-g200);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.glm-tbl {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
|
||||
th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
text-align: left;
|
||||
padding: 8px 12px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--m-g500);
|
||||
background: var(--m-g50);
|
||||
border-bottom: 1px solid var(--m-g200);
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 7px 12px;
|
||||
border-bottom: 1px solid var(--m-g100);
|
||||
font-size: 13px;
|
||||
color: var(--m-g700);
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover td {
|
||||
background: var(--m-g50);
|
||||
}
|
||||
|
||||
&.on td {
|
||||
background: #eff6ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.glm-check {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid var(--m-g300);
|
||||
border-radius: 5px;
|
||||
color: transparent;
|
||||
font-size: 11px;
|
||||
|
||||
&.on {
|
||||
background: var(--m-green);
|
||||
border-color: var(--m-green);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.glm-type {
|
||||
display: inline-block;
|
||||
padding: 1px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
background: var(--m-g100);
|
||||
color: var(--m-g700);
|
||||
|
||||
&.t-asset {
|
||||
background: #dbeafe;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
&.t-liability {
|
||||
background: #e0f4f0;
|
||||
color: #0e7e6b;
|
||||
}
|
||||
|
||||
&.t-income {
|
||||
background: var(--m-green-l);
|
||||
color: var(--m-green);
|
||||
}
|
||||
|
||||
&.t-expense {
|
||||
background: var(--m-amber-l);
|
||||
color: var(--m-amber);
|
||||
}
|
||||
|
||||
&.t-contingent {
|
||||
background: var(--m-red-l);
|
||||
color: var(--m-red);
|
||||
}
|
||||
}
|
||||
|
||||
.glm-empty-row {
|
||||
text-align: center;
|
||||
padding: 26px;
|
||||
color: var(--m-g500);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: 'DM Mono', monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--m-g500);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.ta-c {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Actions */
|
||||
.glm-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.glm-btn {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid var(--m-g300);
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--m-g700);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
|
||||
&:hover {
|
||||
background: var(--m-g50);
|
||||
}
|
||||
}
|
||||
|
||||
.glm-btn-primary {
|
||||
background: var(--m-navy);
|
||||
border-color: var(--m-navy);
|
||||
color: #fff;
|
||||
|
||||
&:hover {
|
||||
background: #013a75;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.glm-link {
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--m-red);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { GlAccount } from 'app/gl-reports/gl-reports-data';
|
||||
import { GlDateRange, glValueForCodes } from 'app/gl-reports/gl-reports.compute';
|
||||
import { RETURN_SCALE, glAccountsForPicker } from './gl-mapping';
|
||||
|
||||
export interface GlMappingDialogData {
|
||||
returnCode: string;
|
||||
lineLabel: string;
|
||||
/** The figure currently printed on the return, in thousands. */
|
||||
filedValue: number | null;
|
||||
selected: string[];
|
||||
range: GlDateRange;
|
||||
branchCode: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lets a user pin one or more GL accounts to a single return line, showing live what
|
||||
* the ledger would produce for the selection against the figure currently on the return.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-gl-mapping-dialog',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatDialogModule,
|
||||
FaIconComponent
|
||||
],
|
||||
templateUrl: './gl-mapping-dialog.component.html',
|
||||
styleUrls: ['./gl-mapping-dialog.component.scss']
|
||||
})
|
||||
export class GlMappingDialogComponent {
|
||||
private dialogRef = inject(MatDialogRef<GlMappingDialogComponent>);
|
||||
data = inject<GlMappingDialogData>(MAT_DIALOG_DATA);
|
||||
|
||||
readonly accounts: GlAccount[] = glAccountsForPicker();
|
||||
search = '';
|
||||
typeFilter = '';
|
||||
selected: string[] = [];
|
||||
|
||||
readonly types = [
|
||||
'ASSET',
|
||||
'LIABILITY',
|
||||
'INCOME',
|
||||
'EXPENSE',
|
||||
'CONTINGENT'
|
||||
];
|
||||
|
||||
constructor() {
|
||||
this.selected = [...(this.data.selected ?? [])];
|
||||
}
|
||||
|
||||
get filtered(): GlAccount[] {
|
||||
const q = this.search.trim().toLowerCase();
|
||||
return this.accounts.filter((a) => {
|
||||
if (this.typeFilter && a.type !== this.typeFilter) {
|
||||
return false;
|
||||
}
|
||||
if (!q) {
|
||||
return true;
|
||||
}
|
||||
return a.code.includes(q) || a.name.toLowerCase().includes(q) || a.group.toLowerCase().includes(q);
|
||||
});
|
||||
}
|
||||
|
||||
isSelected(code: string): boolean {
|
||||
return this.selected.includes(code);
|
||||
}
|
||||
|
||||
toggle(code: string): void {
|
||||
this.selected = this.isSelected(code) ? this.selected.filter((c) => c !== code) : [
|
||||
...this.selected,
|
||||
code
|
||||
];
|
||||
}
|
||||
|
||||
removeSelected(code: string): void {
|
||||
this.selected = this.selected.filter((c) => c !== code);
|
||||
}
|
||||
|
||||
clearAll(): void {
|
||||
this.selected = [];
|
||||
}
|
||||
|
||||
accountName(code: string): string {
|
||||
return this.accounts.find((a) => a.code === code)?.name ?? code;
|
||||
}
|
||||
|
||||
/** Live ledger figure for the current selection, in the return's scale. */
|
||||
get derivedValue(): number {
|
||||
return glValueForCodes(this.selected, this.data.range, this.data.branchCode) / RETURN_SCALE;
|
||||
}
|
||||
|
||||
get variance(): number | null {
|
||||
return this.data.filedValue == null ? null : this.derivedValue - this.data.filedValue;
|
||||
}
|
||||
|
||||
get matchesFiled(): boolean {
|
||||
return this.variance !== null && Math.abs(this.variance) < 0.005;
|
||||
}
|
||||
|
||||
save(): void {
|
||||
this.dialogRef.close(this.selected);
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.dialogRef.close(undefined);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* 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 { inject } from '@angular/core';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { BranchScopeService } from 'app/core/branch-scope.service';
|
||||
import { GlDateRange } from 'app/gl-reports/gl-reports.compute';
|
||||
import { GL_BRANCHES } from 'app/gl-reports/gl-reports-data';
|
||||
import { GlMappingDialogComponent, GlMappingDialogData } from './gl-mapping-dialog.component';
|
||||
import {
|
||||
clearReturnMappings,
|
||||
getMapping,
|
||||
lineKeyFor,
|
||||
mappedLineCount,
|
||||
mappedValue,
|
||||
removeGl,
|
||||
setMapping
|
||||
} from './gl-mapping';
|
||||
|
||||
/**
|
||||
* Shared GL-mapping behaviour for the OSS return screens.
|
||||
*
|
||||
* Each return line can be pinned to GL accounts; where it is, the figure shown comes
|
||||
* from the ledger rather than the static filed value. Subclasses only supply their
|
||||
* return code — everything else, including the ledger period and branch scope, is
|
||||
* common.
|
||||
*/
|
||||
export abstract class GlMappingHost {
|
||||
private dialog = inject(MatDialog);
|
||||
private branchScope = inject(BranchScopeService);
|
||||
|
||||
/** e.g. 'R070'. Used to namespace this return's mappings. */
|
||||
abstract readonly glReturnCode: string;
|
||||
|
||||
/** Ledger period the mapped figures are drawn from. */
|
||||
abstract get glRange(): GlDateRange;
|
||||
|
||||
/** Whether the per-line mapping controls are visible. */
|
||||
glMapMode = false;
|
||||
|
||||
/** '' = all branches consolidated. Seeded from the login's own scope. */
|
||||
glBranchCode = this.branchScope.defaultBranchCode;
|
||||
readonly glBranches = GL_BRANCHES;
|
||||
|
||||
/** Head Office may derive figures from any branch; a branch login cannot. */
|
||||
get canSelectGlBranch(): boolean {
|
||||
return this.branchScope.canSelectBranch;
|
||||
}
|
||||
get glScopeName(): string {
|
||||
return this.branchScope.scopeLabel(this.glBranchCode);
|
||||
}
|
||||
|
||||
/** Branch actually used for every ledger lookup, enforced rather than trusted. */
|
||||
private get effectiveGlBranch(): string {
|
||||
return this.branchScope.enforce(this.glBranchCode);
|
||||
}
|
||||
|
||||
toggleGlMapMode(): void {
|
||||
this.glMapMode = !this.glMapMode;
|
||||
}
|
||||
|
||||
glLineKey(label: string): string {
|
||||
return lineKeyFor(label);
|
||||
}
|
||||
|
||||
glCodesFor(label: string): string[] {
|
||||
return getMapping(this.glReturnCode, this.glLineKey(label));
|
||||
}
|
||||
|
||||
isGlMapped(label: string): boolean {
|
||||
return this.glCodesFor(label).length > 0;
|
||||
}
|
||||
|
||||
get glMappedCount(): number {
|
||||
return mappedLineCount(this.glReturnCode);
|
||||
}
|
||||
|
||||
/** Ledger figure for a line, or null when it has no mapping. */
|
||||
glValue(label: string): number | null {
|
||||
return mappedValue(this.glReturnCode, this.glLineKey(label), this.glRange, this.effectiveGlBranch);
|
||||
}
|
||||
|
||||
/**
|
||||
* The number to print for a line: the ledger value when mapped, otherwise the
|
||||
* value filed on the return.
|
||||
*/
|
||||
effectiveValue(label: string, filed: number | null): number | null {
|
||||
const mapped = this.glValue(label);
|
||||
return mapped === null ? filed : mapped;
|
||||
}
|
||||
|
||||
/** True when a mapped line disagrees with the figure originally filed. */
|
||||
glDiffers(label: string, filed: number | null): boolean {
|
||||
const mapped = this.glValue(label);
|
||||
if (mapped === null || filed === null) {
|
||||
return false;
|
||||
}
|
||||
return Math.abs(mapped - filed) >= 0.005;
|
||||
}
|
||||
|
||||
removeGlFromLine(label: string, code: string): void {
|
||||
removeGl(this.glReturnCode, this.glLineKey(label), code);
|
||||
}
|
||||
|
||||
clearAllGlMappings(): void {
|
||||
clearReturnMappings(this.glReturnCode);
|
||||
}
|
||||
|
||||
/** Opens the chart-of-accounts picker for one return line. */
|
||||
openGlPicker(label: string, filed: number | null): void {
|
||||
const key = this.glLineKey(label);
|
||||
const data: GlMappingDialogData = {
|
||||
returnCode: this.glReturnCode,
|
||||
lineLabel: label,
|
||||
filedValue: filed,
|
||||
selected: getMapping(this.glReturnCode, key),
|
||||
range: this.glRange,
|
||||
branchCode: this.effectiveGlBranch
|
||||
};
|
||||
this.dialog
|
||||
.open(GlMappingDialogComponent, {
|
||||
width: '92vw',
|
||||
maxWidth: '980px',
|
||||
maxHeight: '92vh',
|
||||
autoFocus: false,
|
||||
data
|
||||
})
|
||||
.afterClosed()
|
||||
.subscribe((codes: string[] | undefined) => {
|
||||
if (codes) {
|
||||
setMapping(this.glReturnCode, key, codes);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Maps RBI return line items to General Ledger accounts.
|
||||
*
|
||||
* A return line can be pinned to one or more GL codes; the line's figure is then
|
||||
* derived from the ledger instead of being a static number, so the filed return and
|
||||
* the books cannot drift apart silently. Mappings are keyed by return code plus a
|
||||
* stable line key and persisted locally.
|
||||
*
|
||||
* The OSS returns are denominated in thousands while the ledger is in rupees, so
|
||||
* every derived figure is scaled on the way out.
|
||||
*/
|
||||
|
||||
import { GL_ACCOUNTS, GlAccount } from 'app/gl-reports/gl-reports-data';
|
||||
import { GlDateRange, glValueForCodes } from 'app/gl-reports/gl-reports.compute';
|
||||
|
||||
const STORAGE_KEY = 'rbi_return_gl_mappings';
|
||||
|
||||
/** Returns are reported in thousands of rupees. */
|
||||
export const RETURN_SCALE = 1000;
|
||||
|
||||
export type GlMappingStore = Record<string, string[]>;
|
||||
|
||||
function mapKey(returnCode: string, lineKey: string): string {
|
||||
return `${returnCode}::${lineKey}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The store is read from template getters on every change-detection pass, so it is
|
||||
* held in memory and only re-parsed when a write invalidates it. Parsing JSON a few
|
||||
* hundred times per click is otherwise a measurable cost on the larger returns.
|
||||
*/
|
||||
let storeCache: GlMappingStore | null = null;
|
||||
|
||||
function readStore(): GlMappingStore {
|
||||
if (storeCache) {
|
||||
return storeCache;
|
||||
}
|
||||
try {
|
||||
storeCache = JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}') as GlMappingStore;
|
||||
} catch {
|
||||
storeCache = {};
|
||||
}
|
||||
return storeCache;
|
||||
}
|
||||
|
||||
function writeStore(store: GlMappingStore): void {
|
||||
storeCache = store;
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(store));
|
||||
}
|
||||
|
||||
/** GL codes currently pinned to a return line. */
|
||||
export function getMapping(returnCode: string, lineKey: string): string[] {
|
||||
return readStore()[mapKey(returnCode, lineKey)] ?? [];
|
||||
}
|
||||
|
||||
export function setMapping(returnCode: string, lineKey: string, codes: string[]): void {
|
||||
const store = readStore();
|
||||
const key = mapKey(returnCode, lineKey);
|
||||
if (codes.length) {
|
||||
store[key] = codes;
|
||||
} else {
|
||||
delete store[key];
|
||||
}
|
||||
writeStore(store);
|
||||
}
|
||||
|
||||
export function addGl(returnCode: string, lineKey: string, code: string): void {
|
||||
const current = getMapping(returnCode, lineKey);
|
||||
if (!current.includes(code)) {
|
||||
setMapping(returnCode, lineKey, [
|
||||
...current,
|
||||
code
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
export function removeGl(returnCode: string, lineKey: string, code: string): void {
|
||||
setMapping(
|
||||
returnCode,
|
||||
lineKey,
|
||||
getMapping(returnCode, lineKey).filter((c) => c !== code)
|
||||
);
|
||||
}
|
||||
|
||||
/** Every line in a return that currently has a mapping. */
|
||||
export function mappedLineCount(returnCode: string): number {
|
||||
const prefix = `${returnCode}::`;
|
||||
return Object.keys(readStore()).filter((k) => k.startsWith(prefix)).length;
|
||||
}
|
||||
|
||||
export function clearReturnMappings(returnCode: string): void {
|
||||
const store = readStore();
|
||||
const prefix = `${returnCode}::`;
|
||||
for (const k of Object.keys(store)) {
|
||||
if (k.startsWith(prefix)) {
|
||||
delete store[k];
|
||||
}
|
||||
}
|
||||
writeStore(store);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ledger value for a mapped line, expressed in the return's own scale (thousands).
|
||||
* Returns null when the line has no mapping, so callers can fall back to the filed figure.
|
||||
*/
|
||||
export function mappedValue(returnCode: string, lineKey: string, range: GlDateRange, branchCode = ''): number | null {
|
||||
const codes = getMapping(returnCode, lineKey);
|
||||
if (!codes.length) {
|
||||
return null;
|
||||
}
|
||||
return glValueForCodes(codes, range, branchCode) / RETURN_SCALE;
|
||||
}
|
||||
|
||||
/** A line key that stays stable across reorderings — derived from the label itself. */
|
||||
export function lineKeyFor(label: string): string {
|
||||
return label
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
.slice(0, 80);
|
||||
}
|
||||
|
||||
export interface GlMappingEntry {
|
||||
returnCode: string;
|
||||
lineKey: string;
|
||||
codes: string[];
|
||||
}
|
||||
|
||||
/** Every mapping across all returns — drives the GL Mapping for Report listing. */
|
||||
export function allMappings(): GlMappingEntry[] {
|
||||
return Object.entries(readStore()).map(
|
||||
([
|
||||
key,
|
||||
codes
|
||||
]) => {
|
||||
const [
|
||||
returnCode,
|
||||
lineKey
|
||||
] = key.split('::');
|
||||
return { returnCode, lineKey, codes };
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/** Human-readable name for a return code. */
|
||||
export function returnName(code: string): string {
|
||||
const names: Record<string, string> = {
|
||||
R070: 'OSS 1 — Return on Assets and Liabilities',
|
||||
R071: 'OSS 2 — Return on Earnings',
|
||||
R323: 'OSS 3 — Return on Asset Quality'
|
||||
};
|
||||
return names[code] ?? code;
|
||||
}
|
||||
|
||||
export function glAccountsForPicker(): GlAccount[] {
|
||||
return GL_ACCOUNTS;
|
||||
}
|
||||
|
||||
export function glLabel(code: string): string {
|
||||
const a = GL_ACCOUNTS.find((x) => x.code === code);
|
||||
return a ? `${a.code} — ${a.name}` : code;
|
||||
}
|
||||
@@ -360,6 +360,158 @@ td.derived {
|
||||
color: var(--o-g500);
|
||||
}
|
||||
|
||||
/* ── General Ledger mapping ─────────────────────────────────────────────── */
|
||||
.oss-gl-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
padding: 10px 16px;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--o-g200);
|
||||
|
||||
&.on {
|
||||
background: var(--o-blue-l);
|
||||
border-color: #93c5fd;
|
||||
}
|
||||
}
|
||||
|
||||
.oss-gl-bar-l {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
font-size: 13px;
|
||||
color: var(--o-g700);
|
||||
}
|
||||
|
||||
.oss-gl-bar-r {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.oss-gl-count {
|
||||
display: inline-block;
|
||||
margin-left: 8px;
|
||||
padding: 2px 9px;
|
||||
border-radius: 999px;
|
||||
background: var(--o-green-l);
|
||||
color: var(--o-green);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.oss-gl-inp {
|
||||
padding: 7px 11px;
|
||||
border: 1px solid var(--o-g300);
|
||||
border-radius: 8px;
|
||||
font-size: 12.5px;
|
||||
font-family: inherit;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.oss-btn-clear {
|
||||
color: var(--o-red);
|
||||
border-color: var(--o-red);
|
||||
}
|
||||
|
||||
.oss-gl-col {
|
||||
min-width: 240px;
|
||||
}
|
||||
|
||||
/* Read-only branch shown to a branch-level login in place of the selector. */
|
||||
.oss-gl-scope {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 12px;
|
||||
border: 1px solid var(--o-g300);
|
||||
border-radius: 8px;
|
||||
background: var(--o-g100);
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: var(--o-g700);
|
||||
}
|
||||
|
||||
.oss-gl-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.oss-gl-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 4px 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: #e0f2fe;
|
||||
color: #075985;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.oss-gl-chip-x {
|
||||
border: none;
|
||||
background: rgb(7 89 133 / 14%);
|
||||
color: #075985;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 8px;
|
||||
|
||||
&:hover {
|
||||
background: var(--o-red);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.oss-gl-add {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 9px;
|
||||
border: 1px dashed var(--o-g300);
|
||||
background: #fff;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--o-g500);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--o-navy);
|
||||
color: var(--o-navy);
|
||||
border-style: solid;
|
||||
}
|
||||
}
|
||||
|
||||
.oss-gl-warn {
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
color: var(--o-amber-d);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* A figure sourced from the ledger rather than the filed return. */
|
||||
td.gl-sourced {
|
||||
background: #ecfdf5;
|
||||
box-shadow: inset 3px 0 0 var(--o-green);
|
||||
}
|
||||
|
||||
td.gl-diff {
|
||||
background: #fffbeb;
|
||||
box-shadow: inset 3px 0 0 #d97706;
|
||||
}
|
||||
|
||||
/* Tie-up status pills (cross-return reconciliation checks). */
|
||||
.report-pill {
|
||||
display: inline-block;
|
||||
|
||||
@@ -7,13 +7,7 @@
|
||||
*/
|
||||
|
||||
import * as ExcelJS from 'exceljs';
|
||||
import {
|
||||
MatrixTable,
|
||||
ReturnGeneralInfoRow,
|
||||
ReturnLine,
|
||||
ReturnSignatory,
|
||||
SIGNATORY_FIELDS
|
||||
} from './oss-return.model';
|
||||
import { MatrixTable, ReturnGeneralInfoRow, ReturnLine, ReturnSignatory, SIGNATORY_FIELDS } from './oss-return.model';
|
||||
|
||||
/** Palette lifted from the RBI CIMS workbooks so exports look like the originals. */
|
||||
export const XL = {
|
||||
@@ -45,12 +39,7 @@ export function addBanner(sheet: ExcelJS.Worksheet, title: string, lastCol: stri
|
||||
sheet.addRow([]);
|
||||
}
|
||||
|
||||
export function boxAllCells(
|
||||
sheet: ExcelJS.Worksheet,
|
||||
from: number,
|
||||
to: number,
|
||||
lastColIndex: number
|
||||
): void {
|
||||
export function boxAllCells(sheet: ExcelJS.Worksheet, from: number, to: number, lastColIndex: number): void {
|
||||
for (let r = from; r <= to; r++) {
|
||||
for (let c = 1; c <= lastColIndex; c++) {
|
||||
sheet.getRow(r).getCell(c).border = {
|
||||
@@ -64,7 +53,10 @@ export function boxAllCells(
|
||||
}
|
||||
|
||||
export function addSectionHeader(sheet: ExcelJS.Worksheet, label: string, unitLabel?: string): void {
|
||||
const row = sheet.addRow([label, unitLabel ?? '']);
|
||||
const row = sheet.addRow([
|
||||
label,
|
||||
unitLabel ?? ''
|
||||
]);
|
||||
row.font = { bold: true };
|
||||
row.eachCell((cell) => (cell.fill = solidFill(XL.headerGrey)));
|
||||
row.getCell(2).alignment = { horizontal: 'right' };
|
||||
@@ -73,7 +65,10 @@ export function addSectionHeader(sheet: ExcelJS.Worksheet, label: string, unitLa
|
||||
/** Renders a labelled block; indents by level and shades derived totals. */
|
||||
export function addLineRows(sheet: ExcelJS.Worksheet, lines: ReturnLine[]): void {
|
||||
for (const line of lines) {
|
||||
const row = sheet.addRow([line.label, line.value]);
|
||||
const row = sheet.addRow([
|
||||
line.label,
|
||||
line.value
|
||||
]);
|
||||
const labelCell = row.getCell(1);
|
||||
const valueCell = row.getCell(2);
|
||||
|
||||
@@ -98,7 +93,10 @@ export function addMatrixTable(sheet: ExcelJS.Worksheet, table: MatrixTable): vo
|
||||
const colCount = table.columns.length + 1;
|
||||
const start = sheet.rowCount + 1;
|
||||
|
||||
const titleRow = sheet.addRow([table.title, ...(table.unit ? [table.unit] : [])]);
|
||||
const titleRow = sheet.addRow([
|
||||
table.title,
|
||||
...(table.unit ? [table.unit] : [])
|
||||
]);
|
||||
if (!table.unit) {
|
||||
sheet.mergeCells(titleRow.number, 1, titleRow.number, colCount);
|
||||
}
|
||||
@@ -115,13 +113,19 @@ export function addMatrixTable(sheet: ExcelJS.Worksheet, table: MatrixTable): vo
|
||||
sub.getCell(1).fill = solidFill(XL.headerGrey);
|
||||
}
|
||||
|
||||
const head = sheet.addRow(['Particulars', ...table.columns]);
|
||||
const head = sheet.addRow([
|
||||
'Particulars',
|
||||
...table.columns
|
||||
]);
|
||||
head.font = { bold: true };
|
||||
head.alignment = { wrapText: true, horizontal: 'center', vertical: 'middle' };
|
||||
head.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
|
||||
|
||||
for (const row of table.rows) {
|
||||
const excelRow = sheet.addRow([row.label, ...row.values]);
|
||||
const excelRow = sheet.addRow([
|
||||
row.label,
|
||||
...row.values
|
||||
]);
|
||||
const labelCell = excelRow.getCell(1);
|
||||
labelCell.alignment = { wrapText: true, vertical: 'middle', indent: row.level };
|
||||
if (row.level === 0 || row.header) {
|
||||
@@ -158,13 +162,15 @@ export function buildGeneralInfoSheet(wb: ExcelJS.Workbook, rows: ReturnGeneralI
|
||||
const sheet = wb.addWorksheet('General Information');
|
||||
sheet.columns = [
|
||||
{ width: 26 },
|
||||
{ width: 58 }
|
||||
];
|
||||
{ width: 58 }];
|
||||
addBanner(sheet, 'General Information', 'B');
|
||||
|
||||
const first = sheet.rowCount + 1;
|
||||
for (const item of rows) {
|
||||
const row = sheet.addRow([item.label, item.value]);
|
||||
const row = sheet.addRow([
|
||||
item.label,
|
||||
item.value
|
||||
]);
|
||||
row.getCell(1).fill = solidFill(XL.keyCell);
|
||||
// The workbook tints the audit flag yellow and leaves unfilled cells grey.
|
||||
const fill = item.label === 'Audit Flag' ? XL.auditYellow : item.value ? XL.derivedBlue : XL.headerGrey;
|
||||
@@ -182,19 +188,24 @@ export function buildSignatorySheet(
|
||||
const sheet = wb.addWorksheet(sheetName);
|
||||
sheet.columns = [
|
||||
{ width: 44 },
|
||||
{ width: 24 }
|
||||
];
|
||||
{ width: 24 }];
|
||||
addBanner(sheet, sheetName, 'B');
|
||||
|
||||
for (const person of signatories) {
|
||||
const start = sheet.rowCount + 1;
|
||||
const head = sheet.addRow([person.heading, '']);
|
||||
const head = sheet.addRow([
|
||||
person.heading,
|
||||
''
|
||||
]);
|
||||
head.font = { bold: true };
|
||||
head.alignment = { horizontal: 'center' };
|
||||
head.getCell(1).fill = solidFill(XL.keyCell);
|
||||
|
||||
for (const f of SIGNATORY_FIELDS) {
|
||||
const row = sheet.addRow([person[f.key], f.caption]);
|
||||
const row = sheet.addRow([
|
||||
person[f.key],
|
||||
f.caption
|
||||
]);
|
||||
row.getCell(2).font = { bold: true };
|
||||
row.getCell(2).alignment = { horizontal: 'center' };
|
||||
row.getCell(2).fill = solidFill(XL.keyCell);
|
||||
|
||||
@@ -153,8 +153,7 @@ export const RAL_LIABILITIES: RalLine[] = [
|
||||
{ label: 'ii. Borrowings', level: 1, value: null },
|
||||
|
||||
{
|
||||
label:
|
||||
'12. Risk Provisions Other than netted off assets (BDDR, Spl BDDR, Depreciation on fixed assets etc.,)',
|
||||
label: '12. Risk Provisions Other than netted off assets (BDDR, Spl BDDR, Depreciation on fixed assets etc.,)',
|
||||
level: 0,
|
||||
value: 39440,
|
||||
total: true
|
||||
@@ -192,7 +191,12 @@ export const RAL_ASSETS: RalLine[] = [
|
||||
|
||||
{ label: '2. I. Due from Banks', level: 0, value: 57600, total: true },
|
||||
{ label: 'A. Balances with Banks', level: 1, value: 57600, total: true },
|
||||
{ label: 'i. State/ Central Cooperative Bank of the State/District concerned in', level: 2, value: 32268, total: true },
|
||||
{
|
||||
label: 'i. State/ Central Cooperative Bank of the State/District concerned in',
|
||||
level: 2,
|
||||
value: 32268,
|
||||
total: true
|
||||
},
|
||||
{ label: 'a. Current', level: 2, value: 5980 },
|
||||
{ label: 'b. Savings', level: 2, value: null },
|
||||
{ label: 'c. Fixed', level: 2, value: 26288 },
|
||||
@@ -353,26 +357,73 @@ export const RAL_AGEING_HEADERS: string[] = [
|
||||
|
||||
/** Sheet 3 — Annexure 1 :: 3. Age-wise Inter Branch Adjustment Account */
|
||||
export const RAL_INTER_BRANCH_AGEING: RalAgeingRow[] = [
|
||||
{ period: '3 months to 6 months', noOfDebitEntries: null, amtOfDebitEntries: null, noOfCreditEntries: null, amtOfCreditEntries: null },
|
||||
{ period: 'Above 6 months to 1 year', noOfDebitEntries: null, amtOfDebitEntries: null, noOfCreditEntries: null, amtOfCreditEntries: null },
|
||||
{ period: 'Above 1 year to 2 years', noOfDebitEntries: null, amtOfDebitEntries: null, noOfCreditEntries: null, amtOfCreditEntries: null },
|
||||
{ period: 'Above 2 years', noOfDebitEntries: null, amtOfDebitEntries: null, noOfCreditEntries: null, amtOfCreditEntries: null },
|
||||
{
|
||||
period: '3 months to 6 months',
|
||||
noOfDebitEntries: null,
|
||||
amtOfDebitEntries: null,
|
||||
noOfCreditEntries: null,
|
||||
amtOfCreditEntries: null
|
||||
},
|
||||
{
|
||||
period: 'Above 6 months to 1 year',
|
||||
noOfDebitEntries: null,
|
||||
amtOfDebitEntries: null,
|
||||
noOfCreditEntries: null,
|
||||
amtOfCreditEntries: null
|
||||
},
|
||||
{
|
||||
period: 'Above 1 year to 2 years',
|
||||
noOfDebitEntries: null,
|
||||
amtOfDebitEntries: null,
|
||||
noOfCreditEntries: null,
|
||||
amtOfCreditEntries: null
|
||||
},
|
||||
{
|
||||
period: 'Above 2 years',
|
||||
noOfDebitEntries: null,
|
||||
amtOfDebitEntries: null,
|
||||
noOfCreditEntries: null,
|
||||
amtOfCreditEntries: null
|
||||
},
|
||||
{ period: 'Total', noOfDebitEntries: 0, amtOfDebitEntries: 0, noOfCreditEntries: 0, amtOfCreditEntries: 0 }
|
||||
];
|
||||
|
||||
/** Sheet 3 — Annexure 1 :: 4. Age-wise Inter Bank Reconciliation Account */
|
||||
export const RAL_INTER_BANK_AGEING: RalAgeingRow[] = [
|
||||
{ period: '3 months to 6 months', noOfDebitEntries: null, amtOfDebitEntries: null, noOfCreditEntries: null, amtOfCreditEntries: null },
|
||||
{ period: 'Above 6 months to 1 year', noOfDebitEntries: null, amtOfDebitEntries: null, noOfCreditEntries: null, amtOfCreditEntries: null },
|
||||
{ period: 'Above 1 year to 2 years', noOfDebitEntries: null, amtOfDebitEntries: null, noOfCreditEntries: null, amtOfCreditEntries: null },
|
||||
{ period: 'Above 2 years', noOfDebitEntries: null, amtOfDebitEntries: null, noOfCreditEntries: null, amtOfCreditEntries: null },
|
||||
{
|
||||
period: '3 months to 6 months',
|
||||
noOfDebitEntries: null,
|
||||
amtOfDebitEntries: null,
|
||||
noOfCreditEntries: null,
|
||||
amtOfCreditEntries: null
|
||||
},
|
||||
{
|
||||
period: 'Above 6 months to 1 year',
|
||||
noOfDebitEntries: null,
|
||||
amtOfDebitEntries: null,
|
||||
noOfCreditEntries: null,
|
||||
amtOfCreditEntries: null
|
||||
},
|
||||
{
|
||||
period: 'Above 1 year to 2 years',
|
||||
noOfDebitEntries: null,
|
||||
amtOfDebitEntries: null,
|
||||
noOfCreditEntries: null,
|
||||
amtOfCreditEntries: null
|
||||
},
|
||||
{
|
||||
period: 'Above 2 years',
|
||||
noOfDebitEntries: null,
|
||||
amtOfDebitEntries: null,
|
||||
noOfCreditEntries: null,
|
||||
amtOfCreditEntries: null
|
||||
},
|
||||
{ period: 'Total', noOfDebitEntries: 0, amtOfDebitEntries: 0, noOfCreditEntries: 0, amtOfCreditEntries: 0 }
|
||||
];
|
||||
|
||||
/** Sheet 3 — Annexure 1 :: 5. Unsecured guarantees issued by the bank */
|
||||
export const RAL_UNSECURED_GUARANTEES: RalUnsecuredGuarantee[] = [
|
||||
{ srNo: 1, borrower: '', beneficiary: '', amountOutstanding: null }
|
||||
];
|
||||
{ srNo: 1, borrower: '', beneficiary: '', amountOutstanding: null }];
|
||||
export const RAL_UNSECURED_GUARANTEES_TOTAL = 0;
|
||||
|
||||
/** Sheet 4 — Annexure 2 :: Sundry Debtors ageing buckets */
|
||||
@@ -401,20 +452,49 @@ export const RAL_SUNDRY_DEBTORS_NOTE =
|
||||
export const RAL_DEPOSITORS_BORROWERS: RalLine[] = [
|
||||
{ label: '1.Total No of Depositors', level: 0, value: 8156, total: true, count: true },
|
||||
{ label: 'Total Customer Deposits', level: 0, value: 634309, total: true },
|
||||
{ label: 'a. i) Of which Total No. of Depositors with Deposits less than Rs. 1 Lakh', level: 1, value: 6568, count: true },
|
||||
{
|
||||
label: 'a. i) Of which Total No. of Depositors with Deposits less than Rs. 1 Lakh',
|
||||
level: 1,
|
||||
value: 6568,
|
||||
count: true
|
||||
},
|
||||
{ label: 'ii) Of which total Deposit Amount', level: 1, value: 109992 },
|
||||
{ label: 'b. i) Of which Total No. of Depositors with Deposits Between Rs 1 Lakh to Rs. 2 Lakh', level: 1, value: 739, count: true },
|
||||
{
|
||||
label: 'b. i) Of which Total No. of Depositors with Deposits Between Rs 1 Lakh to Rs. 2 Lakh',
|
||||
level: 1,
|
||||
value: 739,
|
||||
count: true
|
||||
},
|
||||
{ label: 'ii) Of which total Deposit Amount', level: 1, value: 102475 },
|
||||
{ label: 'c. i) Of which Total No. of Depositors with Deposits Between Rs. 2 Lakh to Rs 5 Lakh', level: 1, value: 580, count: true },
|
||||
{
|
||||
label: 'c. i) Of which Total No. of Depositors with Deposits Between Rs. 2 Lakh to Rs 5 Lakh',
|
||||
level: 1,
|
||||
value: 580,
|
||||
count: true
|
||||
},
|
||||
{ label: 'ii) Of which total Deposit Amount', level: 1, value: 176820 },
|
||||
|
||||
{ label: '2.Total No of Borrowers', level: 0, value: 363, total: true, count: true },
|
||||
{ label: 'Total Loans and Advances (Other than Banks)', level: 0, value: 391288, total: true },
|
||||
{ label: 'a. i) Of which Total No. of Borrowers with Loans and Advances less than Rs. 1 Lakh', level: 1, value: 55, count: true },
|
||||
{
|
||||
label: 'a. i) Of which Total No. of Borrowers with Loans and Advances less than Rs. 1 Lakh',
|
||||
level: 1,
|
||||
value: 55,
|
||||
count: true
|
||||
},
|
||||
{ label: 'ii) Of which total Loans and Advances Amount', level: 1, value: 2061 },
|
||||
{ label: 'b. i) Of which Total No. of Borrowers with Loans and Advances Between Rs 1 Lakh to Rs. 2 Lakh', level: 1, value: 18, count: true },
|
||||
{
|
||||
label: 'b. i) Of which Total No. of Borrowers with Loans and Advances Between Rs 1 Lakh to Rs. 2 Lakh',
|
||||
level: 1,
|
||||
value: 18,
|
||||
count: true
|
||||
},
|
||||
{ label: 'ii) Of which total Loans and Advances Amount', level: 1, value: 2837 },
|
||||
{ label: 'c. i) Of which Total No. of Borrowers with Loans and Advances Between Rs. 2 Lakh to Rs 5 Lakh', level: 1, value: 55, count: true },
|
||||
{
|
||||
label: 'c. i) Of which Total No. of Borrowers with Loans and Advances Between Rs. 2 Lakh to Rs 5 Lakh',
|
||||
level: 1,
|
||||
value: 55,
|
||||
count: true
|
||||
},
|
||||
{ label: 'ii) Of which total Loans and Advances Amount', level: 1, value: 21260 }
|
||||
];
|
||||
|
||||
|
||||
@@ -81,11 +81,16 @@
|
||||
<div class="oss-section">
|
||||
<div class="oss-sheet-banner">Assets & Liabilities</div>
|
||||
|
||||
<ng-container [ngTemplateOutlet]="glBar"></ng-container>
|
||||
|
||||
<div class="oss-tbl-wrap">
|
||||
<table class="oss-tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Liabilities</th>
|
||||
@if (glMapMode) {
|
||||
<th class="oss-gl-col">Mapped GL accounts</th>
|
||||
}
|
||||
<th class="ta-r">Rs in Thousands</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -93,8 +98,25 @@
|
||||
@for (line of liabilities; track $index) {
|
||||
<tr [class.total-row]="line.total">
|
||||
<td [class]="'lvl-' + line.level">{{ line.label }}</td>
|
||||
<td class="ta-r mono" [class.derived]="line.total">
|
||||
{{ line.value === null ? '' : (line.value | number: line.count ? '1.0-0' : '1.2-2') }}
|
||||
@if (glMapMode) {
|
||||
<td>
|
||||
<ng-container
|
||||
[ngTemplateOutlet]="glCell"
|
||||
[ngTemplateOutletContext]="{ label: line.label, filed: line.value }"
|
||||
></ng-container>
|
||||
</td>
|
||||
}
|
||||
<td
|
||||
class="ta-r mono"
|
||||
[class.derived]="line.total"
|
||||
[class.gl-sourced]="isGlMapped(line.label)"
|
||||
[class.gl-diff]="glDiffers(line.label, line.value)"
|
||||
>
|
||||
{{
|
||||
effectiveValue(line.label, line.value) === null
|
||||
? ''
|
||||
: (effectiveValue(line.label, line.value) | number: (line.count ? '1.0-0' : '1.2-2'))
|
||||
}}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@@ -107,6 +129,9 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Assets</th>
|
||||
@if (glMapMode) {
|
||||
<th class="oss-gl-col">Mapped GL accounts</th>
|
||||
}
|
||||
<th class="ta-r">Rs in Thousands</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -114,8 +139,25 @@
|
||||
@for (line of assets; track $index) {
|
||||
<tr [class.total-row]="line.total">
|
||||
<td [class]="'lvl-' + line.level">{{ line.label }}</td>
|
||||
<td class="ta-r mono" [class.derived]="line.total">
|
||||
{{ line.value === null ? '' : (line.value | number: line.count ? '1.0-0' : '1.2-2') }}
|
||||
@if (glMapMode) {
|
||||
<td>
|
||||
<ng-container
|
||||
[ngTemplateOutlet]="glCell"
|
||||
[ngTemplateOutletContext]="{ label: line.label, filed: line.value }"
|
||||
></ng-container>
|
||||
</td>
|
||||
}
|
||||
<td
|
||||
class="ta-r mono"
|
||||
[class.derived]="line.total"
|
||||
[class.gl-sourced]="isGlMapped(line.label)"
|
||||
[class.gl-diff]="glDiffers(line.label, line.value)"
|
||||
>
|
||||
{{
|
||||
effectiveValue(line.label, line.value) === null
|
||||
? ''
|
||||
: (effectiveValue(line.label, line.value) | number: (line.count ? '1.0-0' : '1.2-2'))
|
||||
}}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@@ -343,7 +385,7 @@
|
||||
<tr [class.total-row]="line.total">
|
||||
<td [class]="'lvl-' + line.level">{{ line.label }}</td>
|
||||
<td class="ta-r mono" [class.derived]="line.total">
|
||||
{{ line.value === null ? '' : (line.value | number: line.count ? '1.0-0' : '1.2-2') }}
|
||||
{{ line.value === null ? '' : (line.value | number: (line.count ? '1.0-0' : '1.2-2')) }}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@@ -417,6 +459,68 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- GL mapping toolbar, shown above any mappable statement -->
|
||||
<ng-template #glBar>
|
||||
<div class="oss-gl-bar" [class.on]="glMapMode">
|
||||
<div class="oss-gl-bar-l">
|
||||
<fa-icon icon="project-diagram"></fa-icon>
|
||||
<span>
|
||||
<strong>General Ledger mapping</strong>
|
||||
@if (glMappedCount) {
|
||||
<span class="oss-gl-count">{{ glMappedCount }} line(s) mapped</span>
|
||||
} @else {
|
||||
<span class="muted"> — pin GL accounts to a line and its figure comes from the ledger</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div class="oss-gl-bar-r">
|
||||
@if (glMapMode) {
|
||||
@if (canSelectGlBranch) {
|
||||
<select class="oss-gl-inp" [(ngModel)]="glBranchCode">
|
||||
<option value="">All branches</option>
|
||||
@for (b of glBranches; track b.code) {
|
||||
<option [value]="b.code">{{ b.name }}</option>
|
||||
}
|
||||
</select>
|
||||
} @else {
|
||||
<span class="oss-gl-scope" title="Your login is restricted to this branch">
|
||||
<fa-icon icon="university"></fa-icon> {{ glScopeName }}
|
||||
</span>
|
||||
}
|
||||
@if (glMappedCount) {
|
||||
<button class="oss-btn oss-btn-clear" (click)="clearAllGlMappings()">Clear all</button>
|
||||
}
|
||||
}
|
||||
<button class="oss-btn" [class.oss-btn-xls]="!glMapMode" (click)="toggleGlMapMode()">
|
||||
<fa-icon [icon]="glMapMode ? 'times' : 'plus'"></fa-icon>
|
||||
{{ glMapMode ? 'Done' : 'Add / Remove GLs' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<!-- Per-line GL chips and picker trigger -->
|
||||
<ng-template #glCell let-label="label" let-filed="filed">
|
||||
<div class="oss-gl-cell">
|
||||
@for (code of glCodesFor(label); track code) {
|
||||
<span class="oss-gl-chip">
|
||||
<span class="mono">{{ code }}</span>
|
||||
<button class="oss-gl-chip-x" (click)="removeGlFromLine(label, code)" [attr.aria-label]="'Remove ' + code">
|
||||
<fa-icon icon="times"></fa-icon>
|
||||
</button>
|
||||
</span>
|
||||
}
|
||||
<button class="oss-gl-add" (click)="openGlPicker(label, filed)">
|
||||
<fa-icon icon="plus"></fa-icon> {{ glCodesFor(label).length ? 'Edit' : 'Add GL' }}
|
||||
</button>
|
||||
@if (glDiffers(label, filed)) {
|
||||
<span class="oss-gl-warn" title="Ledger figure differs from the value filed on the return">
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon> differs from filed
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<!-- TOAST -->
|
||||
<div class="oss-toast" [class.show]="toastVisible">{{ toastMsg }}</div>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@ 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 { GlMappingHost } from '../gl-mapping.host';
|
||||
import {
|
||||
addBanner,
|
||||
addLineRows,
|
||||
@@ -55,7 +56,14 @@ type Oss1Tab = 'general' | 'balance-sheet' | 'annexure1' | 'annexure2' | 'signat
|
||||
FormsModule
|
||||
]
|
||||
})
|
||||
export class Oss1RalComponent {
|
||||
export class Oss1RalComponent extends GlMappingHost {
|
||||
readonly glReturnCode = 'R070';
|
||||
|
||||
/** RAL covers the reporting quarter; mapped figures are drawn from the same span. */
|
||||
get glRange() {
|
||||
return { from: '2026-04-01', to: '2026-06-30' };
|
||||
}
|
||||
|
||||
activeTab: Oss1Tab = 'general';
|
||||
|
||||
generalInfo = RAL_GENERAL_INFO;
|
||||
@@ -146,8 +154,7 @@ export class Oss1RalComponent {
|
||||
const sheet = wb.addWorksheet('Assets & Liabilities');
|
||||
sheet.columns = [
|
||||
{ width: 78 },
|
||||
{ width: 20 }
|
||||
];
|
||||
{ width: 20 }];
|
||||
addBanner(sheet, 'Assets & Liabilities', 'B');
|
||||
|
||||
const first = sheet.rowCount + 1;
|
||||
@@ -187,18 +194,25 @@ export class Oss1RalComponent {
|
||||
{ width: 16 },
|
||||
{ width: 12 },
|
||||
{ width: 12 },
|
||||
{ width: 12 }
|
||||
];
|
||||
{ width: 12 }];
|
||||
addBanner(sheet, 'Annexure 1', 'H');
|
||||
|
||||
// 1. Break-up of Other Funds and Reserves
|
||||
let start = sheet.rowCount + 1;
|
||||
const fundsTitle = sheet.addRow(['1. Break-up of Other Funds and Reserves', '', 'Rs in Thousands']);
|
||||
const fundsTitle = sheet.addRow([
|
||||
'1. Break-up of Other Funds and Reserves',
|
||||
'',
|
||||
'Rs in Thousands'
|
||||
]);
|
||||
sheet.mergeCells(`A${fundsTitle.number}:B${fundsTitle.number}`);
|
||||
fundsTitle.font = { bold: true };
|
||||
fundsTitle.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
|
||||
for (const line of this.otherFunds) {
|
||||
const row = sheet.addRow([line.label, '', line.value]);
|
||||
const row = sheet.addRow([
|
||||
line.label,
|
||||
'',
|
||||
line.value
|
||||
]);
|
||||
sheet.mergeCells(`A${row.number}:B${row.number}`);
|
||||
row.getCell(1).alignment = { wrapText: true, vertical: 'middle' };
|
||||
row.getCell(3).numFmt = '#,##0.00';
|
||||
@@ -281,10 +295,20 @@ export class Oss1RalComponent {
|
||||
gHead.alignment = { wrapText: true, horizontal: 'center', vertical: 'middle' };
|
||||
gHead.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
|
||||
for (const g of this.unsecuredGuarantees) {
|
||||
const row = sheet.addRow([g.srNo, g.borrower, g.beneficiary, g.amountOutstanding]);
|
||||
const row = sheet.addRow([
|
||||
g.srNo,
|
||||
g.borrower,
|
||||
g.beneficiary,
|
||||
g.amountOutstanding
|
||||
]);
|
||||
row.getCell(4).numFmt = '#,##0.00';
|
||||
}
|
||||
const gTotal = sheet.addRow(['Total', '', '', this.unsecuredGuaranteesTotal]);
|
||||
const gTotal = sheet.addRow([
|
||||
'Total',
|
||||
'',
|
||||
'',
|
||||
this.unsecuredGuaranteesTotal
|
||||
]);
|
||||
gTotal.font = { bold: true };
|
||||
gTotal.getCell(4).numFmt = '#,##0.00';
|
||||
gTotal.getCell(4).fill = solidFill(XL.derivedBlue);
|
||||
@@ -327,9 +351,7 @@ export class Oss1RalComponent {
|
||||
3,
|
||||
4,
|
||||
5
|
||||
].forEach(
|
||||
(c) => (row.getCell(c).fill = solidFill(XL.derivedBlue))
|
||||
);
|
||||
].forEach((c) => (row.getCell(c).fill = solidFill(XL.derivedBlue)));
|
||||
}
|
||||
}
|
||||
boxAllCells(sheet, start, sheet.rowCount, 5);
|
||||
@@ -341,8 +363,7 @@ export class Oss1RalComponent {
|
||||
{ width: 10 },
|
||||
{ width: 62 },
|
||||
{ width: 20 },
|
||||
{ width: 20 }
|
||||
];
|
||||
{ width: 20 }];
|
||||
addBanner(sheet, 'Annexure 2', 'D');
|
||||
|
||||
for (const block of this.sundryDebtors) {
|
||||
@@ -357,13 +378,23 @@ export class Oss1RalComponent {
|
||||
s.font = { bold: true, size: 10 };
|
||||
s.getCell(1).fill = solidFill(XL.headerGrey);
|
||||
|
||||
const head = sheet.addRow(['Sr. No', 'Due from Name', 'Amount due', 'Provision made']);
|
||||
const head = sheet.addRow([
|
||||
'Sr. No',
|
||||
'Due from Name',
|
||||
'Amount due',
|
||||
'Provision made'
|
||||
]);
|
||||
head.font = { bold: true };
|
||||
head.alignment = { horizontal: 'center' };
|
||||
head.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
|
||||
|
||||
for (const r of block.rows) {
|
||||
const row = sheet.addRow([r.srNo, r.dueFromName, r.amountDue, r.provisionMade]);
|
||||
const row = sheet.addRow([
|
||||
r.srNo,
|
||||
r.dueFromName,
|
||||
r.amountDue,
|
||||
r.provisionMade
|
||||
]);
|
||||
row.getCell(3).numFmt = '#,##0.00';
|
||||
row.getCell(4).numFmt = '#,##0.00';
|
||||
}
|
||||
@@ -378,14 +409,22 @@ export class Oss1RalComponent {
|
||||
|
||||
// Details of Depositors & Borrowers
|
||||
let start = sheet.rowCount + 1;
|
||||
const dbTitle = sheet.addRow(['Details of Depositors & Borrowers', '', 'Rs. In Thousands']);
|
||||
const dbTitle = sheet.addRow([
|
||||
'Details of Depositors & Borrowers',
|
||||
'',
|
||||
'Rs. In Thousands'
|
||||
]);
|
||||
sheet.mergeCells(`A${dbTitle.number}:B${dbTitle.number}`);
|
||||
dbTitle.font = { bold: true };
|
||||
dbTitle.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
|
||||
dbTitle.getCell(3).alignment = { horizontal: 'right' };
|
||||
|
||||
for (const line of this.depositorsBorrowers) {
|
||||
const row = sheet.addRow([line.label, '', line.value]);
|
||||
const row = sheet.addRow([
|
||||
line.label,
|
||||
'',
|
||||
line.value
|
||||
]);
|
||||
sheet.mergeCells(`A${row.number}:B${row.number}`);
|
||||
row.getCell(1).alignment = { wrapText: true, vertical: 'middle', indent: line.level };
|
||||
row.getCell(3).numFmt = line.count ? '#,##0' : '#,##0.00';
|
||||
@@ -404,7 +443,11 @@ export class Oss1RalComponent {
|
||||
cTitle.font = { bold: true };
|
||||
cTitle.getCell(1).fill = solidFill(XL.headerGrey);
|
||||
for (const line of this.complaints) {
|
||||
const row = sheet.addRow([line.label, '', line.value]);
|
||||
const row = sheet.addRow([
|
||||
line.label,
|
||||
'',
|
||||
line.value
|
||||
]);
|
||||
sheet.mergeCells(`A${row.number}:B${row.number}`);
|
||||
row.getCell(1).alignment = { wrapText: true, vertical: 'middle' };
|
||||
row.getCell(3).numFmt = '#,##0';
|
||||
|
||||
@@ -122,11 +122,52 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- GL mapping toolbar -->
|
||||
<div class="oss-gl-bar" [class.on]="glMapMode">
|
||||
<div class="oss-gl-bar-l">
|
||||
<fa-icon icon="project-diagram"></fa-icon>
|
||||
<span>
|
||||
<strong>General Ledger mapping</strong>
|
||||
@if (glMappedCount) {
|
||||
<span class="oss-gl-count">{{ glMappedCount }} line(s) mapped</span>
|
||||
} @else {
|
||||
<span class="muted"> — pin GL accounts to a line and its figure comes from the ledger</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div class="oss-gl-bar-r">
|
||||
@if (glMapMode) {
|
||||
@if (canSelectGlBranch) {
|
||||
<select class="oss-gl-inp" [(ngModel)]="glBranchCode">
|
||||
<option value="">All branches</option>
|
||||
@for (b of glBranches; track b.code) {
|
||||
<option [value]="b.code">{{ b.name }}</option>
|
||||
}
|
||||
</select>
|
||||
} @else {
|
||||
<span class="oss-gl-scope" title="Your login is restricted to this branch">
|
||||
<fa-icon icon="university"></fa-icon> {{ glScopeName }}
|
||||
</span>
|
||||
}
|
||||
@if (glMappedCount) {
|
||||
<button class="oss-btn oss-btn-clear" (click)="clearAllGlMappings()">Clear all</button>
|
||||
}
|
||||
}
|
||||
<button class="oss-btn" [class.oss-btn-xls]="!glMapMode" (click)="toggleGlMapMode()">
|
||||
<fa-icon [icon]="glMapMode ? 'times' : 'plus'"></fa-icon>
|
||||
{{ glMapMode ? 'Done' : 'Add / Remove GLs' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="oss-tbl-wrap">
|
||||
<table class="oss-tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Particulars</th>
|
||||
@if (glMapMode) {
|
||||
<th class="oss-gl-col">Mapped GL accounts</th>
|
||||
}
|
||||
<th class="ta-r">Rs in Thousand</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -134,8 +175,44 @@
|
||||
@for (line of earnings; track $index) {
|
||||
<tr [class.total-row]="line.total">
|
||||
<td [class]="'lvl-' + line.level">{{ line.label }}</td>
|
||||
<td class="ta-r mono" [class.derived]="line.total">
|
||||
{{ line.value === null ? '' : (line.value | number: line.count ? '1.0-0' : '1.2-2') }}
|
||||
@if (glMapMode) {
|
||||
<td>
|
||||
<div class="oss-gl-cell">
|
||||
@for (code of glCodesFor(line.label); track code) {
|
||||
<span class="oss-gl-chip">
|
||||
<span class="mono">{{ code }}</span>
|
||||
<button
|
||||
class="oss-gl-chip-x"
|
||||
(click)="removeGlFromLine(line.label, code)"
|
||||
[attr.aria-label]="'Remove ' + code"
|
||||
>
|
||||
<fa-icon icon="times"></fa-icon>
|
||||
</button>
|
||||
</span>
|
||||
}
|
||||
<button class="oss-gl-add" (click)="openGlPicker(line.label, line.value)">
|
||||
<fa-icon icon="plus"></fa-icon>
|
||||
{{ glCodesFor(line.label).length ? 'Edit' : 'Add GL' }}
|
||||
</button>
|
||||
@if (glDiffers(line.label, line.value)) {
|
||||
<span class="oss-gl-warn" title="Ledger figure differs from the value filed on the return">
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon> differs from filed
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
}
|
||||
<td
|
||||
class="ta-r mono"
|
||||
[class.derived]="line.total"
|
||||
[class.gl-sourced]="isGlMapped(line.label)"
|
||||
[class.gl-diff]="glDiffers(line.label, line.value)"
|
||||
>
|
||||
{{
|
||||
effectiveValue(line.label, line.value) === null
|
||||
? ''
|
||||
: (effectiveValue(line.label, line.value) | number: (line.count ? '1.0-0' : '1.2-2'))
|
||||
}}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@@ -186,9 +263,10 @@
|
||||
<div class="oss-balance" [class.oss-balance--bad]="!otherExpensesTally">
|
||||
<fa-icon [icon]="otherExpensesTally ? 'check-circle' : 'exclamation-triangle'"></fa-icon>
|
||||
<span>
|
||||
Detail total <strong>₹{{ otherExpensesTotal | number: '1.2-2' }}</strong> vs Earnings line 12 ix)
|
||||
Others
|
||||
<strong>₹{{ otherExpensesLineValue === null ? '—' : (otherExpensesLineValue | number: '1.2-2') }}</strong>
|
||||
Detail total <strong>₹{{ otherExpensesTotal | number: '1.2-2' }}</strong> vs Earnings line 12 ix) Others
|
||||
<strong
|
||||
>₹{{ otherExpensesLineValue === null ? '—' : (otherExpensesLineValue | number: '1.2-2') }}</strong
|
||||
>
|
||||
— {{ otherExpensesTally ? 'tallies' : 'MISMATCH — resolve before filing' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@ 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 { GlMappingHost } from '../gl-mapping.host';
|
||||
import {
|
||||
addBanner,
|
||||
addLineRows,
|
||||
@@ -54,7 +55,14 @@ interface TieUp {
|
||||
FormsModule
|
||||
]
|
||||
})
|
||||
export class Oss2RoeComponent {
|
||||
export class Oss2RoeComponent extends GlMappingHost {
|
||||
readonly glReturnCode = 'R071';
|
||||
|
||||
/** RoE reports the quarter; mapped income and expense figures use the same span. */
|
||||
get glRange() {
|
||||
return { from: '2026-04-01', to: '2026-06-30' };
|
||||
}
|
||||
|
||||
activeTab: Oss2Tab = 'general';
|
||||
|
||||
generalInfo = ROE_GENERAL_INFO;
|
||||
@@ -178,8 +186,7 @@ export class Oss2RoeComponent {
|
||||
const sheet = wb.addWorksheet('Earnings');
|
||||
sheet.columns = [
|
||||
{ width: 90 },
|
||||
{ width: 20 }
|
||||
];
|
||||
{ width: 20 }];
|
||||
addBanner(sheet, 'Earnings', 'B');
|
||||
|
||||
const first = sheet.rowCount + 1;
|
||||
@@ -205,23 +212,31 @@ export class Oss2RoeComponent {
|
||||
const sheet = wb.addWorksheet('Interest Receivable Not Recog');
|
||||
sheet.columns = [
|
||||
{ width: 72 },
|
||||
{ width: 22 }
|
||||
];
|
||||
{ width: 22 }];
|
||||
addBanner(sheet, 'Interest Receivable Not Recognized', 'B');
|
||||
|
||||
const start = sheet.rowCount + 1;
|
||||
const unit = sheet.addRow(['', 'Rs in Thousand']);
|
||||
const unit = sheet.addRow([
|
||||
'',
|
||||
'Rs in Thousand'
|
||||
]);
|
||||
unit.font = { bold: true };
|
||||
unit.alignment = { horizontal: 'center' };
|
||||
unit.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
|
||||
|
||||
const head = sheet.addRow(['Interest receivable on NPAs not recognized as income', this.quarterEndDate]);
|
||||
const head = sheet.addRow([
|
||||
'Interest receivable on NPAs not recognized as income',
|
||||
this.quarterEndDate
|
||||
]);
|
||||
head.font = { bold: true };
|
||||
head.getCell(2).alignment = { horizontal: 'center' };
|
||||
head.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
|
||||
|
||||
for (const r of this.interestNotRecognized) {
|
||||
const row = sheet.addRow([r.label, r.value]);
|
||||
const row = sheet.addRow([
|
||||
r.label,
|
||||
r.value
|
||||
]);
|
||||
row.getCell(1).alignment = { wrapText: true, vertical: 'middle' };
|
||||
row.getCell(1).fill = solidFill(XL.headerGrey);
|
||||
row.getCell(2).numFmt = '#,##0.00';
|
||||
@@ -238,28 +253,43 @@ export class Oss2RoeComponent {
|
||||
sheet.columns = [
|
||||
{ width: 10 },
|
||||
{ width: 56 },
|
||||
{ width: 20 }
|
||||
];
|
||||
{ width: 20 }];
|
||||
addBanner(sheet, 'Other Operating Expenses', 'C');
|
||||
|
||||
const start = sheet.rowCount + 1;
|
||||
const title = sheet.addRow(['Details of Other Operating Expenses', '', 'Rs in Thousand']);
|
||||
const title = sheet.addRow([
|
||||
'Details of Other Operating Expenses',
|
||||
'',
|
||||
'Rs in Thousand'
|
||||
]);
|
||||
sheet.mergeCells(`A${title.number}:B${title.number}`);
|
||||
title.font = { bold: true };
|
||||
title.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
|
||||
title.getCell(3).alignment = { horizontal: 'center' };
|
||||
|
||||
const head = sheet.addRow(['Sr No', 'Particulars', 'Amount']);
|
||||
const head = sheet.addRow([
|
||||
'Sr No',
|
||||
'Particulars',
|
||||
'Amount'
|
||||
]);
|
||||
head.font = { bold: true };
|
||||
head.alignment = { horizontal: 'center' };
|
||||
head.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
|
||||
|
||||
for (const e of this.otherExpenses) {
|
||||
const row = sheet.addRow([e.srNo, e.particulars, e.amount]);
|
||||
const row = sheet.addRow([
|
||||
e.srNo,
|
||||
e.particulars,
|
||||
e.amount
|
||||
]);
|
||||
row.getCell(3).numFmt = '#,##0.00';
|
||||
}
|
||||
|
||||
const total = sheet.addRow(['Total', '', this.otherExpensesTotal]);
|
||||
const total = sheet.addRow([
|
||||
'Total',
|
||||
'',
|
||||
this.otherExpensesTotal
|
||||
]);
|
||||
sheet.mergeCells(`A${total.number}:B${total.number}`);
|
||||
total.font = { bold: true };
|
||||
total.getCell(1).fill = solidFill(XL.headerGrey);
|
||||
@@ -268,7 +298,11 @@ export class Oss2RoeComponent {
|
||||
|
||||
boxAllCells(sheet, start, sheet.rowCount, 3);
|
||||
|
||||
const ref = sheet.addRow(['', '', 'Refer Earnings Sheet — line 12 ix) Others']);
|
||||
const ref = sheet.addRow([
|
||||
'',
|
||||
'',
|
||||
'Refer Earnings Sheet — line 12 ix) Others'
|
||||
]);
|
||||
ref.getCell(3).font = { italic: true, size: 10 };
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -52,6 +52,46 @@
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- GL mapping toolbar (applies to the first figure column of each statement) -->
|
||||
@if (activeTab !== 'filing' && activeTab !== 'signatory') {
|
||||
<div class="oss-gl-bar" [class.on]="glMapMode">
|
||||
<div class="oss-gl-bar-l">
|
||||
<fa-icon icon="project-diagram"></fa-icon>
|
||||
<span>
|
||||
<strong>General Ledger mapping</strong>
|
||||
@if (glMappedCount) {
|
||||
<span class="oss-gl-count">{{ glMappedCount }} line(s) mapped</span>
|
||||
} @else {
|
||||
<span class="muted"> — pin GL accounts to a line and its first figure comes from the ledger</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div class="oss-gl-bar-r">
|
||||
@if (glMapMode) {
|
||||
@if (canSelectGlBranch) {
|
||||
<select class="oss-gl-inp" [(ngModel)]="glBranchCode">
|
||||
<option value="">All branches</option>
|
||||
@for (b of glBranches; track b.code) {
|
||||
<option [value]="b.code">{{ b.name }}</option>
|
||||
}
|
||||
</select>
|
||||
} @else {
|
||||
<span class="oss-gl-scope" title="Your login is restricted to this branch">
|
||||
<fa-icon icon="university"></fa-icon> {{ glScopeName }}
|
||||
</span>
|
||||
}
|
||||
@if (glMappedCount) {
|
||||
<button class="oss-btn oss-btn-clear" (click)="clearAllGlMappings()">Clear all</button>
|
||||
}
|
||||
}
|
||||
<button class="oss-btn" [class.oss-btn-xls]="!glMapMode" (click)="toggleGlMapMode()">
|
||||
<fa-icon [icon]="glMapMode ? 'times' : 'plus'"></fa-icon>
|
||||
{{ glMapMode ? 'Done' : 'Add / Remove GLs' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- FILING INFORMATION -->
|
||||
@if (activeTab === 'filing') {
|
||||
<div class="oss-section">
|
||||
@@ -74,7 +114,9 @@
|
||||
<!-- PORTFOLIO ANALYSIS -->
|
||||
@if (activeTab === 'portfolio') {
|
||||
<div class="oss-section">
|
||||
<div class="oss-sheet-banner">PortfolioAnalysis(loans_advncs) — Portfolio Analysis (By delinquency in Interest Payment)</div>
|
||||
<div class="oss-sheet-banner">
|
||||
PortfolioAnalysis(loans_advncs) — Portfolio Analysis (By delinquency in Interest Payment)
|
||||
</div>
|
||||
<ng-container [ngTemplateOutlet]="matrix" [ngTemplateOutletContext]="{ t: portfolioAnalysis }"></ng-container>
|
||||
<ng-container [ngTemplateOutlet]="matrix" [ngTemplateOutletContext]="{ t: portfolioMemo }"></ng-container>
|
||||
</div>
|
||||
@@ -117,7 +159,9 @@
|
||||
<ng-container [ngTemplateOutlet]="matrix" [ngTemplateOutletContext]="{ t: capitalMemo }"></ng-container>
|
||||
|
||||
<div class="oss-tbl-wrap">
|
||||
<div class="oss-tbl-h">Table 5: Top Fifty Impaired Credit or more than Rs.100 lakhs — Rs in Thousands</div>
|
||||
<div class="oss-tbl-h">
|
||||
Table 5: Top Fifty Impaired Credit or more than Rs.100 lakhs — Rs in Thousands
|
||||
</div>
|
||||
<table class="oss-tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -240,7 +284,8 @@
|
||||
Sectoral gross advances <strong>₹{{ sectoralGross | number: '1.2-2' }}</strong> and NPAs
|
||||
<strong>₹{{ sectoralNpa | number: '1.2-2' }}</strong> do not match the closing position reported by
|
||||
Classification (<strong>₹{{ classificationTotal | number: '1.2-2' }}</strong> /
|
||||
<strong>₹{{ grossNpa | number: '1.2-2' }}</strong>).
|
||||
<strong>₹{{ grossNpa | number: '1.2-2' }}</strong
|
||||
>).
|
||||
@if (sectoralMatchesOpening) {
|
||||
They match the <em>opening</em> balances in Movement Parts 1 and 2 instead — this statement appears to
|
||||
have been prepared on the previous quarter's book.
|
||||
@@ -326,7 +371,10 @@
|
||||
<ng-template #matrix let-t="t">
|
||||
<div class="oss-tbl-wrap">
|
||||
<div class="oss-tbl-h">
|
||||
{{ t.title }}@if (t.unit) {<span class="oss-tbl-unit">{{ t.unit }}</span>}
|
||||
{{ t.title }}
|
||||
@if (t.unit) {
|
||||
<span class="oss-tbl-unit">{{ t.unit }}</span>
|
||||
}
|
||||
</div>
|
||||
@if (t.subtitle) {
|
||||
<div class="oss-tbl-sub">{{ t.subtitle }}</div>
|
||||
@@ -335,6 +383,9 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="oss-col-label">Particulars</th>
|
||||
@if (glMapMode) {
|
||||
<th class="oss-gl-col">Mapped GL accounts</th>
|
||||
}
|
||||
@for (c of t.columns; track $index) {
|
||||
<th class="ta-r">{{ c }}</th>
|
||||
}
|
||||
@@ -344,12 +395,48 @@
|
||||
@for (row of t.rows; track $index) {
|
||||
<tr [class.total-row]="row.total" [class.section-row]="row.header">
|
||||
<td [class]="'lvl-' + row.level">{{ row.label }}</td>
|
||||
@if (glMapMode) {
|
||||
<td>
|
||||
@if (!row.header) {
|
||||
<div class="oss-gl-cell">
|
||||
@for (code of glCodesFor(t.title + ' / ' + row.label); track code) {
|
||||
<span class="oss-gl-chip">
|
||||
<span class="mono">{{ code }}</span>
|
||||
<button
|
||||
class="oss-gl-chip-x"
|
||||
(click)="removeGlFromLine(t.title + ' / ' + row.label, code)"
|
||||
[attr.aria-label]="'Remove ' + code"
|
||||
>
|
||||
<fa-icon icon="times"></fa-icon>
|
||||
</button>
|
||||
</span>
|
||||
}
|
||||
<button
|
||||
class="oss-gl-add"
|
||||
(click)="openGlPicker(t.title + ' / ' + row.label, row.values[0] ?? null)"
|
||||
>
|
||||
<fa-icon icon="plus"></fa-icon>
|
||||
{{ glCodesFor(t.title + ' / ' + row.label).length ? 'Edit' : 'Add GL' }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</td>
|
||||
}
|
||||
@if (row.header) {
|
||||
<td [attr.colspan]="t.columns.length"></td>
|
||||
} @else {
|
||||
@for (v of row.values; track $index) {
|
||||
<td class="ta-r mono" [class.derived]="row.total">
|
||||
{{ v === null ? '' : (v | number: '1.2-2') }}{{ v !== null && row.percent ? '%' : '' }}
|
||||
<td
|
||||
class="ta-r mono"
|
||||
[class.derived]="row.total"
|
||||
[class.gl-sourced]="$index === 0 && isGlMapped(t.title + ' / ' + row.label)"
|
||||
[class.gl-diff]="$index === 0 && glDiffers(t.title + ' / ' + row.label, v)"
|
||||
>
|
||||
@if ($index === 0 && isGlMapped(t.title + ' / ' + row.label)) {
|
||||
{{ effectiveValue(t.title + ' / ' + row.label, v) | number: '1.2-2' }}{{ row.percent ? '%' : '' }}
|
||||
} @else {
|
||||
{{ v === null ? '' : (v | number: '1.2-2') }}{{ v !== null && row.percent ? '%' : '' }}
|
||||
}
|
||||
</td>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import * as ExcelJS from 'exceljs';
|
||||
import { MatrixTable } from '../oss-return.model';
|
||||
import { GlMappingHost } from '../gl-mapping.host';
|
||||
import {
|
||||
addBanner,
|
||||
addMatrixTable,
|
||||
@@ -77,7 +78,14 @@ type Oss3Tab =
|
||||
FormsModule
|
||||
]
|
||||
})
|
||||
export class Oss3RaqComponent {
|
||||
export class Oss3RaqComponent extends GlMappingHost {
|
||||
readonly glReturnCode = 'R323';
|
||||
|
||||
/** RAQ reports the quarter; mapped figures are drawn from the same span. */
|
||||
get glRange() {
|
||||
return { from: '2026-04-01', to: '2026-06-30' };
|
||||
}
|
||||
|
||||
activeTab: Oss3Tab = 'filing';
|
||||
|
||||
/** Tab strip definition — keeps the template free of a dozen near-identical buttons. */
|
||||
@@ -313,8 +321,7 @@ export class Oss3RaqComponent {
|
||||
{ width: 16 },
|
||||
{ width: 18 },
|
||||
{ width: 18 },
|
||||
{ width: 18 }
|
||||
];
|
||||
{ width: 18 }];
|
||||
addBanner(sheet, 'TopImpairedCredits - Top Impaired Credits', 'I');
|
||||
addMatrixTable(sheet, this.capitalMemo);
|
||||
for (const t of this.recoveryTables) {
|
||||
@@ -398,8 +405,7 @@ export class Oss3RaqComponent {
|
||||
{ width: 18 },
|
||||
{ width: 20 },
|
||||
{ width: 22 },
|
||||
{ width: 20 }
|
||||
];
|
||||
{ width: 20 }];
|
||||
addBanner(sheet, 'ExposureLimits - Single and Group Borrower Exposure Limit', 'G');
|
||||
addMatrixTable(sheet, this.capitalPreviousMarch);
|
||||
|
||||
|
||||
@@ -43,6 +43,16 @@ const routes: Routes = [
|
||||
data: { title: 'Sanctions Logs', breadcrumb: 'Sanctions Logs' },
|
||||
component: SanctionsLogsComponent
|
||||
},
|
||||
// Must sit above ':filter', which would otherwise match this path and
|
||||
// render an empty report category instead.
|
||||
{
|
||||
path: 'form-97-98',
|
||||
data: { title: 'Form 97 / 98 Transaction Report', breadcrumb: 'Form 97 / 98 Transaction Report' },
|
||||
loadComponent: () =>
|
||||
import('../form-declarations/form-declaration-report.component').then(
|
||||
(m) => m.FormDeclarationReportComponent
|
||||
)
|
||||
},
|
||||
{
|
||||
path: ':filter',
|
||||
data: { routeParamBreadcrumb: 'filter' },
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
faCalendar,
|
||||
faCalendarAlt,
|
||||
faCaretDown,
|
||||
faCaretRight,
|
||||
faChartBar,
|
||||
faCheck,
|
||||
faCheckCircle,
|
||||
@@ -261,6 +262,7 @@ export class IconsModule {
|
||||
faCalendarAlt,
|
||||
faCalendarCheck,
|
||||
faCaretDown,
|
||||
faCaretRight,
|
||||
faChartBar,
|
||||
faCheck,
|
||||
faCheckCircle,
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
export default {
|
||||
'mifos_x': {
|
||||
'version': '260801',
|
||||
'hash': '1c125d97'
|
||||
'hash': '2a9df828'
|
||||
},
|
||||
'allow_switching_backend_instance': true
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user