This commit is contained in:
2026-08-02 14:23:46 +05:30
37 changed files with 9292 additions and 620 deletions
+98
View File
@@ -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;
}
}
+48 -14
View File
@@ -587,6 +587,10 @@
<button mat-menu-item [routerLink]="['/reports', 'Accounting']" tabindex="0"> <button mat-menu-item [routerLink]="['/reports', 'Accounting']" tabindex="0">
<i class="fa fa-calculator menu-item-icon"></i> Accounting <i class="fa fa-calculator menu-item-icon"></i> Accounting
</button> </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> </mat-menu>
<!-- Admin Mega Menu — 3-column rectangular panel grouping Setup, <!-- Admin Mega Menu — 3-column rectangular panel grouping Setup,
@@ -865,20 +869,34 @@
</div> </div>
<div class="gl-group"> <div class="gl-group">
<div class="gl-group-title">GL Reports</div> <div class="gl-group-title">GL Reports</div>
<a class="gl-link" [class.hidden]="!matchesGlSearch('GL Average Balance')" (click)="glMenuOpen = false" <a
><fa-icon icon="calculator" class="gl-icon"></fa-icon> GL Average Balance</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" <a
><fa-icon icon="project-diagram" class="gl-icon"></fa-icon> GL Mapping for Report</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 <a
class="gl-link" class="gl-link"
[class.hidden]="!matchesGlSearch('Account wise Balance List')" [class.hidden]="!matchesGlSearch('Account wise Balance List')"
routerLink="/gl/reports/account-wise-balance"
(click)="glMenuOpen = false" (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" <a
><fa-icon icon="chart-pie" class="gl-icon"></fa-icon> Distribution of Balance</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 <a
class="gl-link" class="gl-link"
@@ -894,8 +912,9 @@
<a <a
class="gl-link" class="gl-link"
[class.hidden]="!matchesGlSearch('Client wise Balance Report')" [class.hidden]="!matchesGlSearch('Client wise Balance Report')"
routerLink="/gl/reports/client-wise-balance"
(click)="glMenuOpen = false" (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 <a
class="gl-link" class="gl-link"
@@ -905,17 +924,29 @@
><fa-icon icon="exclamation-circle" class="gl-icon" style="color: #b45309"></fa-icon> Contingent ><fa-icon icon="exclamation-circle" class="gl-icon" style="color: #b45309"></fa-icon> Contingent
Liabilities</a Liabilities</a
> >
<a class="gl-link" [class.hidden]="!matchesGlSearch('Banker Head Report')" (click)="glMenuOpen = false" <a
><fa-icon icon="university" class="gl-icon"></fa-icon> Banker Head Report</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>
<div class="gl-group"> <div class="gl-group">
<div class="gl-group-title">Registers</div> <div class="gl-group-title">Registers</div>
<a class="gl-link" [class.hidden]="!matchesGlSearch('Sundry Register')" (click)="glMenuOpen = false" <a
><fa-icon icon="clipboard-list" class="gl-icon"></fa-icon> Sundry Register</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" <a
><fa-icon icon="receipt" class="gl-icon"></fa-icon> Voucher Register</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 <a
class="gl-link" class="gl-link"
@@ -1565,6 +1596,9 @@
<button mat-menu-item tabindex="0"> <button mat-menu-item tabindex="0">
<fa-icon icon="file-signature" class="menu-item-icon" style="color: #15803d"></fa-icon> Form 98 <fa-icon icon="file-signature" class="menu-item-icon" style="color: #15803d"></fa-icon> Form 98
</button> </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"> <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 <fa-icon icon="file-signature" class="menu-item-icon" style="color: #15803d"></fa-icon> Form 121
</button> </button>
+24 -2
View File
@@ -20,7 +20,15 @@
*/ */
export type ReportType = 'CTR' | 'STR' | 'CCR' | 'NTR' | 'CBWTR'; 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 */ /** TC2 — Branch / cash transaction record */
export interface CtrTransaction { export interface CtrTransaction {
@@ -42,7 +50,17 @@ export interface CtrTransaction {
declaration: string; declaration: string;
/** Customer name resolved from KC1 (UI convenience, not in actual TC2 schema) */ /** Customer name resolved from KC1 (UI convenience, not in actual TC2 schema) */
customerName?: string; 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 */ /** Outcome of the Principal Officer's investigation into an STR case */
@@ -64,6 +82,10 @@ export interface StrTransaction extends CtrTransaction {
poRemarks?: string; poRemarks?: string;
/** Set once the Principal Officer concludes the investigation */ /** Set once the Principal Officer concludes the investigation */
poFinding?: PoFinding; 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) */ /** KC1 — Individual customer card (subset of the 70+ fields, only what UI shows) */
+274 -47
View File
@@ -141,7 +141,8 @@
<div class="fiu-form-title"> <div class="fiu-form-title">
<h3>Live CTR Threshold Alerts</h3> <h3>Live CTR Threshold Alerts</h3>
<p> <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> </p>
</div> </div>
<select class="fiu-inp" [(ngModel)]="amlAlertStatusFilter" (ngModelChange)="loadAmlAlerts()"> <select class="fiu-inp" [(ngModel)]="amlAlertStatusFilter" (ngModelChange)="loadAmlAlerts()">
@@ -198,8 +199,7 @@
<td> <td>
@if (alert.status === 'RAISED' || alert.status === 'UNDER_REVIEW') { @if (alert.status === 'RAISED' || alert.status === 'UNDER_REVIEW') {
<div class="fiu-action-group" *mifosxHasPermission="'REVIEW_AML_ALERT'"> <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)">Send to STR</button>
<button class="fiu-btn fiu-btn-xml" (click)="escalateAlert(alert)">Escalate</button>
</div> </div>
} }
</td> </td>
@@ -213,9 +213,9 @@
</table> </table>
</div> </div>
<p class="fiu-tbl-legend"> <p class="fiu-tbl-legend">
<span class="report-pill report-pending">Pending</span> awaiting Whitelist/Escalate decision &middot; <span class="report-pill report-pending">Pending</span> cash breach awaiting review &middot;
<span class="report-pill report-ctr">CTR only</span> whitelisted — cleared, no STR needed &middot; <span class="report-pill report-str">Referred to STR</span> source of funds not evidenced — sent to the
<span class="report-pill report-str">→ STR</span> escalated to Principal Officer as an STR case Principal Officer, who decides there whether to whitelist it or file an STR
</p> </p>
<!-- LIVE: monthly CTR reports (mechanical, threshold-based) --> <!-- LIVE: monthly CTR reports (mechanical, threshold-based) -->
@@ -347,32 +347,75 @@
</td> </td>
<td class="ta-c"> <td class="ta-c">
<span class="report-pill report-ctr" title="Always included in the monthly CTR / TC2 file">CTR</span> <span class="report-pill report-ctr" title="Always included in the monthly CTR / TC2 file">CTR</span>
@if (t.complianceStatus === 'SUSPICIOUS') { @if (t.complianceStatus === 'REFERRED') {
<span class="report-pill report-str" title="Also escalated to an STR case for Principal Officer review">+ STR</span> <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>
<td> <td>
@if (!t.complianceStatus || t.complianceStatus === 'PENDING') { @if (t.complianceStatus === 'REFERRED') {
<div class="fiu-action-group" style="gap: 4px;"> <span class="status-pill status-escalated">REFERRED TO STR</span>
<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)"> <button class="fiu-link-btn" (click)="openStrCase(t)">View in STR tab &rarr;</button>
Whitelist } @else if (t.complianceStatus === 'EVIDENCED') {
</button> <span class="status-pill status-whitelisted">EVIDENCE ON FILE</span>
<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)"> <div class="muted" style="margin-top: 3px">
Flag STR {{ t.evidenceDocType }}
</button> @if (t.evidenceReference) {
<span> · {{ t.evidenceReference }}</span>
}
</div> </div>
} @else { } @else {
<span [class]="t.complianceStatus === 'WHITELISTED' ? 'status-pill status-whitelisted' : 'status-pill status-escalated'"> <div class="fiu-action-group" style="gap: 4px" *mifosxHasPermission="'REVIEW_AML_ALERT'">
{{ t.complianceStatus }} <button class="fiu-btn fiu-btn-evidence" (click)="startCtrEvidence(t)">Record evidence</button>
</span> <button class="fiu-btn fiu-btn-refer" (click)="referCtrTxnToStr(t)">Send to STR</button>
@if (t.complianceStatus === 'WHITELISTED') { </div>
<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 &rarr;</button>
}
} }
</td> </td>
</tr> </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 }} &mdash; &#8377;{{
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 { } @empty {
<tr> <tr>
<td colspan="11" class="fiu-empty">No transactions match the filters.</td> <td colspan="11" class="fiu-empty">No transactions match the filters.</td>
@@ -382,10 +425,11 @@
</table> </table>
</div> </div>
<p class="fiu-tbl-legend"> <p class="fiu-tbl-legend">
<span class="report-pill report-ctr">CTR</span> every cash txn ≥ threshold stays in the monthly CTR / TC2 <span class="report-pill report-ctr">CTR</span> every cash txn ≥ threshold is reported here regardless of
file &middot; outcome. <strong>Record evidence</strong> when the customer produces valid documents — CTR only, no STR &middot;
<span class="report-pill report-str">+ STR</span> also flagged as suspicious and escalated to a separate STR <span class="report-pill report-str">+ STR</span> <strong>Send to STR</strong> when the source of funds cannot
case (see the STR tab) 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> </p>
</div> </div>
} }
@@ -397,9 +441,7 @@
<div class="fiu-form-meta"> <div class="fiu-form-meta">
<div class="fiu-form-title"> <div class="fiu-form-title">
<h3>Live AML Alerts (Awaiting Triage)</h3> <h3>Live AML Alerts (Awaiting Triage)</h3>
<p> <p>Flagged by the monitoring engine for suspicious behavioral patterns — triage and whitelist or escalate</p>
Flagged by the monitoring engine for suspicious behavioral patterns — triage and whitelist or escalate
</p>
</div> </div>
</div> </div>
<div class="fiu-tbl-wrap"> <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 <span class="report-pill report-str">→ STR</span> escalated to Principal Officer as an STR case
</p> </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 --> <!-- LIVE: STR cases with full Principal Officer actions -->
<div class="fiu-form-meta"> <div class="fiu-form-meta">
@@ -627,23 +669,26 @@
} @else if (derivedConclusion === 'NOT_SUSPICIOUS') { } @else if (derivedConclusion === 'NOT_SUSPICIOUS') {
<fa-icon icon="check-circle"></fa-icon> <fa-icon icon="check-circle"></fa-icon>
<span> <span>
<strong>Not suspicious.</strong> All {{ strChecklist.length }} points found genuine — the case will <strong>Not suspicious.</strong> All {{ strChecklist.length }} points found genuine — the case will be
be closed and nothing filed with FIU-IND. closed and nothing filed with FIU-IND.
</span> </span>
} @else { } @else {
<fa-icon icon="exclamation-triangle"></fa-icon> <fa-icon icon="exclamation-triangle"></fa-icon>
<span> <span>
<strong>Suspicious — STR must be filed.</strong> <strong>Suspicious — STR must be filed.</strong>
{{ failedFindings.length }} of {{ strChecklist.length }} points not satisfied: {{ 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> and {{ failedFindings.length - 1 }} more</span>
}. }
.
</span> </span>
} }
</div> </div>
<button class="fiu-btn fiu-btn-xml" [disabled]="!allAnswered" (click)="concludeStrCase()"> <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> </button>
@if (selectedStrCase.status === 'CONCLUDED_SUSPICIOUS') { @if (selectedStrCase.status === 'CONCLUDED_SUSPICIOUS') {
@@ -675,8 +720,9 @@
<option value="">All</option> <option value="">All</option>
<option value="draft">Draft</option> <option value="draft">Draft</option>
<option value="po-review">PO Review</option> <option value="po-review">PO Review</option>
<option value="awaiting-filing">Awaiting Filing</option>
<option value="filed">Filed</option> <option value="filed">Filed</option>
<option value="rejected">Rejected</option> <option value="rejected">Closed — Clean</option>
</select> </select>
</div> </div>
<div class="fiu-filter"> <div class="fiu-filter">
@@ -752,17 +798,198 @@
</label> </label>
<p>{{ poFindingLabel(s.poFinding) }}</p> <p>{{ poFindingLabel(s.poFinding) }}</p>
</div> </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> &middot; 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'"> <div class="str-po-action" *mifosxHasPermission="'CONCLUDE_AML_STR'">
<label>Principal Officer investigation — is the source of funds legitimate?</label> <label>Principal Officer investigation — is the source of funds legitimate?</label>
<div class="fiu-action-group"> <button class="fiu-btn fiu-btn-xml" (click)="investigateLocalStr(s)">
<button class="fiu-btn fiu-btn-clean" (click)="concludeLocalStr(s, 'CLEAN')"> <fa-icon icon="search"></fa-icon> Investigate this case
Clean — close case </button>
</button> </div>
<button class="fiu-btn fiu-btn-xml" (click)="concludeLocalStr(s, 'SUSPICIOUS')"> }
Suspicious — file STR with FIU-IND
<!-- 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> </button>
</div> </div>
<!-- Step 1 — call for evidence -->
<div class="inv-step">
<div class="inv-step-t"><span class="inv-num">1</span> Documentary evidence called for</div>
<div class="inv-grid">
<div class="form-row">
<label for="invDocType">Document requested from the customer</label>
<select id="invDocType" class="fiu-inp" [(ngModel)]="evidenceDocType">
<option value="">Select document…</option>
@for (d of evidenceDocTypes; track d) {
<option [value]="d">{{ d }}</option>
}
</select>
</div>
<div class="form-row">
<label for="invReceived">Was it produced?</label>
<select id="invReceived" class="fiu-inp" [(ngModel)]="evidenceReceived">
<option value="">Select…</option>
<option value="YES">Yes — complete</option>
<option value="PARTIAL">Partially — incomplete</option>
<option value="NO">No — not produced</option>
</select>
</div>
<div class="form-row">
<label for="invRef">Document / reference number</label>
<input id="invRef" class="fiu-inp" type="text" [(ngModel)]="evidenceReference" />
</div>
</div>
</div>
<!-- Step 2 — customer's account of the funds -->
<div class="inv-step">
<div class="inv-step-t"><span class="inv-num">2</span> Customer’s explanation of the source</div>
<textarea
rows="2"
[(ngModel)]="customerExplanation"
placeholder="e.g. Proceeds of a flat sold in June; sale deed produced and matched to the deposit amount and date."
></textarea>
</div>
<!-- Step 3 — verification checklist -->
<div class="inv-step">
<div class="inv-step-t">
<span class="inv-num">3</span> Verification checklist
<span class="checklist-progress" [class.done]="allAnswered">
{{ answeredCount }} / {{ strChecklist.length }} answered
</span>
</div>
<table class="checklist-tbl">
<thead>
<tr>
<th>Verification point</th>
<th class="ta-c col-yes">Genuine</th>
<th class="ta-c col-no">Not satisfied</th>
</tr>
</thead>
<tbody>
@for (item of strChecklist; track item.key) {
<tr
[class.row-yes]="strFindings[item.key] === true"
[class.row-no]="strFindings[item.key] === false"
[class.row-pending]="strFindings[item.key] === null"
>
<td>
<div class="chk-label">{{ item.label }}</div>
<div class="chk-hint">{{ item.hint }}</div>
</td>
<td class="ta-c">
<button
type="button"
class="chk-box chk-box--yes"
[class.on]="strFindings[item.key] === true"
[attr.aria-label]="'Mark ' + item.label + ' as genuine'"
(click)="setFinding(item.key, true)"
>
<fa-icon icon="check-circle"></fa-icon>
</button>
</td>
<td class="ta-c">
<button
type="button"
class="chk-box chk-box--no"
[class.on]="strFindings[item.key] === false"
[attr.aria-label]="'Mark ' + item.label + ' as not satisfied'"
(click)="setFinding(item.key, false)"
>
<fa-icon icon="exclamation-triangle"></fa-icon>
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
<!-- Derived outcome -->
<div
class="verdict"
[class.verdict--clear]="canConcludeLocal && derivedConclusion === 'NOT_SUSPICIOUS'"
[class.verdict--suspicious]="canConcludeLocal && derivedConclusion === 'SUSPICIOUS'"
>
@if (!canConcludeLocal) {
<fa-icon icon="info-circle"></fa-icon>
<span>
Complete all three steps to determine the outcome.
@if (!evidenceRecorded) {
<span> Evidence not yet recorded.</span>
}
@if (!allAnswered) {
<span> {{ strChecklist.length - answeredCount }} verification point(s) pending.</span>
}
</span>
} @else if (derivedConclusion === 'NOT_SUSPICIOUS') {
<fa-icon icon="check-circle"></fa-icon>
<span>
<strong>Source of funds established.</strong> Case closes as clean — the transaction stays in
the CTR report and no STR is filed with FIU-IND.
</span>
} @else {
<fa-icon icon="exclamation-triangle"></fa-icon>
<span>
<strong>Source of funds not established.</strong> {{ failedFindings.length }} point(s)
unsatisfied — an STR will be filed with FIU-IND in addition to the CTR.
</span>
}
</div>
<button class="fiu-btn fiu-btn-xml" [disabled]="!canConcludeLocal" (click)="concludeLocalStr()">
{{
canConcludeLocal
? derivedConclusion === 'SUSPICIOUS'
? 'Conclude — file STR with FIU-IND'
: 'Conclude — close as clean (CTR only)'
: 'Conclude'
}}
</button>
</div> </div>
} }
</div> </div>
@@ -871,8 +1098,8 @@
<div class="fiu-form-title"> <div class="fiu-form-title">
<h3>FINnet 2.0 Submission Files</h3> <h3>FINnet 2.0 Submission Files</h3>
<p> <p>
6 CSV files in FIU-IND prescribed schema, plus STR XML. Counts reflect any filters set on the CTR and 6 CSV files in FIU-IND prescribed schema, plus STR XML. Counts reflect any filters set on the CTR and STR
STR tabs, so every file in the bundle covers the same transactions. tabs, so every file in the bundle covers the same transactions.
</p> </p>
</div> </div>
<button class="fiu-btn fiu-btn-xls" (click)="exportAll()"> <button class="fiu-btn fiu-btn-xls" (click)="exportAll()">
+199
View File
@@ -386,6 +386,78 @@
align-self: end; 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 { .fiu-link-btn {
display: inline-block; display: inline-block;
margin-top: 3px; margin-top: 3px;
@@ -708,6 +780,11 @@
color: var(--f-amber); color: var(--f-amber);
} }
&.st-awaiting-filing {
background: #ffedd5;
color: #9a3412;
}
&.st-filed { &.st-filed {
background: var(--f-green-l); background: var(--f-green-l);
color: var(--f-green); 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 */ /* Derived verdict banner */
.verdict { .verdict {
display: flex; display: flex;
+218 -37
View File
@@ -135,6 +135,54 @@ export class FiuComponent implements OnInit {
/** null = not yet answered, true = genuine, false = not satisfied. */ /** null = not yet answered, true = genuine, false = not satisfied. */
strFindings: Record<string, boolean | null> = {}; 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 { private resetChecklist(): void {
this.strFindings = {}; this.strFindings = {};
for (const item of this.strChecklist) { for (const item of this.strChecklist) {
@@ -202,14 +250,51 @@ export class FiuComponent implements OnInit {
} }
} }
whitelistCtrTxn(txn: CtrTransaction): void { // ── CTR-side evidence capture ──────────────────────────────────────────────
txn.complianceStatus = 'WHITELISTED'; /** Transaction whose source-of-funds evidence is being recorded, if any. */
localStorage.setItem('fiu_ctr_txns', JSON.stringify(this.ctrTxns)); evidencingTxn: CtrTransaction | null = null;
this.toast(`Transaction for ${txn.customerName} whitelisted — stays in the CTR report only`); ctrEvidenceDocType = '';
ctrEvidenceRef = '';
startCtrEvidence(txn: CtrTransaction): void {
this.evidencingTxn = txn;
this.ctrEvidenceDocType = '';
this.ctrEvidenceRef = '';
} }
flagCtrTxnSuspicious(txn: CtrTransaction): void { cancelCtrEvidence(): void {
txn.complianceStatus = 'SUSPICIOUS'; 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)); localStorage.setItem('fiu_ctr_txns', JSON.stringify(this.ctrTxns));
const newRef = Math.max(100, ...this.strTxns.map((s) => s.reportRefNo)) + 1; const newRef = Math.max(100, ...this.strTxns.map((s) => s.reportRefNo)) + 1;
@@ -222,34 +307,112 @@ export class FiuComponent implements OnInit {
riskCategory: 'unusual-pattern', riskCategory: 'unusual-pattern',
narrative: narrative:
`Cash ${txn.depositOrWithdrawal.toLowerCase()} of ₹${txn.txnAmount.toLocaleString('en-IN')} breached the ` + `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. ` + `₹${this.bank.cashThreshold.toLocaleString('en-IN')} CTR limit and genuine evidence of the source of funds ` +
`Referred to the Principal Officer to establish whether the source of funds is legitimate or linked to terror financing.`, `could not be established at the branch. Referred to the Principal Officer for investigation.`,
status: 'po-review', status: 'po-review',
poFinding: undefined poFinding: undefined
}; };
this.strTxns.unshift(newStrCase); this.strTxns.unshift(newStrCase);
localStorage.setItem('fiu_str_cases', JSON.stringify(this.strTxns)); 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; rec.poFinding = finding;
if (finding === 'CLEAN') { if (finding === 'CLEAN') {
rec.status = 'rejected'; rec.status = 'rejected';
rec.poRemarks = rec.poRemarks =
'Source-of-funds documentation reviewed and verified by the Principal Officer. No link to terror financing or ' + `${evidenceLine} ${explanationLine} All ${this.strChecklist.length} verification points found genuine, ` +
'any other predicate offence. Case closed — no STR filed with FIU-IND.'; `so the source of funds is established. Case closed — the transaction remains in the CTR report and no STR ` +
this.toast(`STR-${rec.reportRefNo} closed as clean — no STR filed`); `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 { } 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 = rec.poRemarks =
'Principal Officer investigation could not establish a legitimate source of funds. STR filed with FIU-IND under ' + `${evidenceLine} ${explanationLine} ${failed.length} of ${this.strChecklist.length} verification points not ` +
'PMLA s.12 within the 7-day window; account placed under enhanced monitoring and reported to the Nodal Officer.'; `satisfied: ${failed.map((f) => f.label).join('; ')}. Source of funds not established — STR to be filed with ` +
this.toast(`STR-${rec.reportRefNo} confirmed suspicious — filed with FIU-IND`); `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)); 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 { 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.concludeInvestigationNotes.trim()}\n\nAll ${this.strChecklist.length} verification points found genuine.`;
this.amlService this.amlService
.concludeStrCase( .concludeStrCase(this.selectedStrCase.id, this.concludeConclusion, notes, this.concludeSourceOfFundsNotes)
this.selectedStrCase.id,
this.concludeConclusion,
notes,
this.concludeSourceOfFundsNotes
)
.subscribe({ .subscribe({
next: (updated) => { next: (updated) => {
this.toast( 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. * Where a live AML alert stands — drives the "Routes To" column.
* `isCtrRule` distinguishes mechanical CTR-threshold alerts (whitelisting just keeps them * Whitelisting is an STR-side decision, so a CTR alert can only be pending or
* in the CTR file) from behavioral alerts (whitelisting clears them with no report at all). * referred onward. Historic WHITELISTED rows are still rendered for the record.
*/ */
alertRouteLabel(status: string, isCtrRule: boolean = true): string { alertRouteLabel(status: string, isCtrRule: boolean = true): string {
switch (status) { switch (status) {
case 'WHITELISTED': case 'WHITELISTED':
return isCtrRule ? 'CTR only' : 'Cleared'; return isCtrRule ? 'CTR only' : 'Cleared';
case 'ESCALATED': case 'ESCALATED':
return '→ STR'; return isCtrRule ? 'Referred to STR' : '→ STR';
default: default:
return 'Pending'; return 'Pending';
} }
@@ -450,9 +608,7 @@ export class FiuComponent implements OnInit {
this.resetStrFilters(); this.resetStrFilters();
this.highlightedStrRef = match.reportRefNo; this.highlightedStrRef = match.reportRefNo;
setTimeout(() => { setTimeout(() => {
document document.getElementById(`str-card-${match.reportRefNo}`)?.scrollIntoView({ behavior: 'smooth', block: 'center' });
.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); return this.ctrTxns.reduce((s, t) => s + t.txnAmount, 0);
} }
get pendingStr(): number { 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 { get filedStr(): number {
return this.strTxns.filter((s) => s.status === 'filed').length; return this.strTxns.filter((s) => s.status === 'filed').length;
@@ -496,12 +654,20 @@ export class FiuComponent implements OnInit {
} }
get ctrAlerts(): any[] { 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)); return this.amlAlerts.filter((a) => ctrRuleCodes.includes(a.ruleCode));
} }
get amlSuspiciousAlerts(): any[] { 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)); return this.amlAlerts.filter((a) => !ctrRuleCodes.includes(a.ruleCode));
} }
@@ -544,7 +710,14 @@ export class FiuComponent implements OnInit {
// -------------------- Helpers -------------------- // -------------------- Helpers --------------------
statusLabel(s: StrStatus): string { 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 { riskLabel(c: StrTransaction['riskCategory']): string {
const map: Record<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'); this.toast('No suspect cases to export — nothing matches the current filters');
return; 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[] = []; const xml: string[] = [];
xml.push('<?xml version="1.0" encoding="UTF-8"?>'); xml.push('<?xml version="1.0" encoding="UTF-8"?>');
xml.push(`<STR xmlns="http://fiuind.gov.in/finnet2/str" reportingMonth="${this.bank.reportingMonth}">`); 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(` <Detection date="${s.detectedOn}" by="${this.escapeXml(s.flaggedBy)}" />`);
xml.push(` <Narrative>${this.escapeXml(s.narrative)}</Narrative>`); xml.push(` <Narrative>${this.escapeXml(s.narrative)}</Narrative>`);
if (s.poRemarks) xml.push(` <PORemarks>${this.escapeXml(s.poRemarks)}</PORemarks>`); 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(' </Suspect>');
} }
xml.push('</STR>'); xml.push('</STR>');
@@ -934,10 +1116,9 @@ export class FiuComponent implements OnInit {
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
a.href = url; a.href = url;
a.download = `STR-${this.bank.reportingMonth.replace(/\s+/g, '-')}.xml`; a.download = filename;
a.click(); a.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
this.toast(`STR XML exported (${suspects.length} suspects)`);
} }
/** Bundle: the 6 CSVs plus the STR XML — emitted as separate downloads sequentially */ /** 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)
};
}
+245 -18
View File
@@ -114,18 +114,84 @@ export const GL_ACCOUNTS: GlAccount[] = [
{ code: '420103', name: "Auditors' Fees", type: 'EXPENSE', group: 'Operating Expenses' }, { code: '420103', name: "Auditors' Fees", type: 'EXPENSE', group: 'Operating Expenses' },
{ code: '420104', name: 'Rent, Taxes & Lighting', type: 'EXPENSE', group: 'Operating Expenses' }, { code: '420104', name: 'Rent, Taxes & Lighting', type: 'EXPENSE', group: 'Operating Expenses' },
{ code: '420105', name: 'Depreciation on Bank Property', 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: '430101',
{ code: '430103', name: 'Advertisement & Publicity', type: 'EXPENSE', group: 'Miscellaneous Expenses', miscExpense: true }, name: 'Printing & Stationery',
{ code: '430104', name: 'Repairs & Maintenance', type: 'EXPENSE', group: 'Miscellaneous Expenses', miscExpense: true }, type: 'EXPENSE',
{ code: '430105', name: 'Travelling & Conveyance', type: 'EXPENSE', group: 'Miscellaneous Expenses', miscExpense: true }, group: 'Miscellaneous Expenses',
{ code: '430106', name: 'Computer & Software Expenses', type: 'EXPENSE', group: 'Miscellaneous Expenses', miscExpense: true }, 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: '430102',
{ code: '430110', name: 'AGM & Meeting Expenses', type: 'EXPENSE', group: 'Miscellaneous Expenses', miscExpense: true }, 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: '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) ────────── // ── Contingent (memoranda, not part of the balance sheet totals) ──────────
{ code: '910101', name: 'Guarantees Issued — Financial', type: 'CONTINGENT', group: 'Guarantees' }, { 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: '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: '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: '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: '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: '420104', credit: '110101', narration: 'Rent, taxes and lighting', scale: 46_000, frequency: 0.3 },
{ debit: '430101', credit: '110101', narration: 'Printing and stationery purchased', scale: 9_400, frequency: 0.3 }, { debit: '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: '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: '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: '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. */ /** Head-Office-only vouchers — investments, reserves and centralised charges. */
const HO_TEMPLATES: typeof VOUCHER_TEMPLATES = [ const HO_TEMPLATES: typeof VOUCHER_TEMPLATES = [
{ debit: '120101', credit: '110102', narration: 'Purchase of Govt securities', scale: 2_400_000, frequency: 0.12 }, { 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: '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: '420102', credit: '110101', narration: "Directors' sitting fees", scale: 32_000, frequency: 0.08 },
{ debit: '420103', credit: '110101', narration: "Statutory auditors' fees", scale: 45_000, frequency: 0.06 }, { debit: '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 openingDebit = 0;
let openingCredit = 0; let openingCredit = 0;
for (const [code, amount] of Object.entries(OPENING_BALANCES)) { for (const [
code,
amount
] of Object.entries(OPENING_BALANCES)) {
if (!amount) { if (!amount) {
continue; continue;
} }
@@ -425,7 +524,10 @@ export function glPostings(): GlPosting[] {
for (const branch of GL_BRANCHES) { for (const branch of GL_BRANCHES) {
// Head Office posts fewer counter transactions than the branches. // Head Office posts fewer counter transactions than the branches.
const weight = branch.code === 'HO' ? 0.45 : 1; 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) { for (const tpl of templates) {
if (rand() > tpl.frequency * weight) { if (rand() > tpl.frequency * weight) {
@@ -487,6 +589,131 @@ export function glPostings(): GlPosting[] {
return out; return out;
} }
/**
* Customer sub-ledger.
*
* The GL is account-level, so a client-wise report needs a customer dimension on top.
* Each customer holds a share of one GL head at one branch; balances are not stored,
* they are derived at query time as `share x that head's GL balance`. That way the
* client-wise report always reconciles to the General Ledger exactly, whatever period
* or branch is selected, instead of drifting as a second source of truth.
*/
export interface GlCustomer {
cif: string;
name: string;
branchCode: string;
/** GL head this relationship sits under. */
accountCode: string;
accountNo: string;
/** Fraction of the branch's balance in that head. Shares per (branch, head) sum to 1. */
share: number;
}
const FIRST_NAMES = [
'Ravi',
'Priya',
'Vikram',
'Nirav',
'Kavita',
'Arjun',
'Lakshmi',
'Sameer',
'Anjali',
'Rohan',
'Meera',
'Karan',
'Divya',
'Sanjay',
'Pooja',
'Amit',
'Sneha',
'Manish',
'Rekha',
'Deepak'
];
const LAST_NAMES = [
'Sharma',
'Patel',
'Reddy',
'Shah',
'Verma',
'Deshmukh',
'Iyer',
'Kapoor',
'Nair',
'Joshi',
'Mehta',
'Gupta',
'Rao',
'Bhatt',
'Chauhan'
];
const FIRMS = [
'Traders',
'Enterprises',
'Agencies',
'& Sons',
'Industries',
'Stores',
'Corporation'
];
/** Customer-facing heads a client relationship can sit under. */
const CUSTOMER_HEADS = [
'220101',
'220102',
'220103',
'220104',
'130101',
'130102',
'130103',
'130104',
'130105',
'130106'
];
let cachedCustomers: GlCustomer[] | null = null;
export function glCustomers(): GlCustomer[] {
if (cachedCustomers) {
return cachedCustomers;
}
const rand = lcg(76543210);
const out: GlCustomer[] = [];
let seq = 1;
for (const branch of GL_BRANCHES) {
for (const head of CUSTOMER_HEADS) {
// Deposits are held by many customers; advances by fewer, larger ones.
const isAdvance = head.startsWith('13');
const count = isAdvance ? 2 + Math.floor(rand() * 3) : 3 + Math.floor(rand() * 4);
const weights: number[] = [];
for (let i = 0; i < count; i++) {
weights.push(0.35 + rand() * 0.65);
}
const weightTotal = weights.reduce((a, b) => a + b, 0);
for (let i = 0; i < count; i++) {
const corporate = isAdvance && rand() > 0.55;
const name = corporate
? `${LAST_NAMES[Math.floor(rand() * LAST_NAMES.length)]} ${FIRMS[Math.floor(rand() * FIRMS.length)]}`
: `${FIRST_NAMES[Math.floor(rand() * FIRST_NAMES.length)]} ${LAST_NAMES[Math.floor(rand() * LAST_NAMES.length)]}`;
out.push({
cif: `CIF${`${100000 + seq}`}`,
name,
branchCode: branch.code,
accountCode: head,
accountNo: `${branch.code === 'HO' ? '0001' : branch.code}${head}${`${seq}`.padStart(4, '0')}`,
share: weights[i] / weightTotal
});
seq++;
}
}
}
cachedCustomers = out;
return out;
}
export function glAccount(code: string): GlAccount | undefined { export function glAccount(code: string): GlAccount | undefined {
return GL_ACCOUNTS.find((a) => a.code === code); return GL_ACCOUNTS.find((a) => a.code === code);
} }
+552 -20
View File
@@ -79,17 +79,33 @@
<div class="glr-filter"> <div class="glr-filter">
<label>Branch</label> <label>Branch</label>
<select class="glr-inp" [(ngModel)]="branchCode"> @if (canSelectBranch) {
<option value="">All Branches (Consolidated)</option> <select class="glr-inp" [(ngModel)]="branchCode">
@for (b of branches; track b.code) { <option value="">All Branches (Consolidated)</option>
<option [value]="b.code">{{ b.code }} — {{ b.name }}</option> @for (b of branches; track b.code) {
} <option [value]="b.code">{{ b.code }} — {{ b.name }}</option>
</select> }
</select>
} @else {
<span class="glr-chip" title="Your login is restricted to this branch">
<fa-icon icon="university"></fa-icon> {{ scopeName }}
</span>
}
</div> </div>
<button class="glr-btn" (click)="resetFilters()">Reset</button> <button class="glr-btn" (click)="resetFilters()">Reset</button>
</div> </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 --> <!-- REPORT SELECTOR -->
<div class="glr-tabs"> <div class="glr-tabs">
@for (r of reports; track r.code) { @for (r of reports; track r.code) {
@@ -193,7 +209,9 @@
@for (r of pnlExpense; track r.code) { @for (r of pnlExpense; track r.code) {
<tr> <tr>
<td class="mono">{{ r.code }}</td> <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> <td class="ta-r mono">{{ r.amount | number: '1.2-2' }}</td>
</tr> </tr>
} @empty { } @empty {
@@ -229,7 +247,9 @@
@for (r of pnlIncome; track r.code) { @for (r of pnlIncome; track r.code) {
<tr> <tr>
<td class="mono">{{ r.code }}</td> <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> <td class="ta-r mono">{{ r.amount | number: '1.2-2' }}</td>
</tr> </tr>
} @empty { } @empty {
@@ -281,7 +301,9 @@
@for (r of bsLiabilities; track r.code) { @for (r of bsLiabilities; track r.code) {
<tr> <tr>
<td class="mono">{{ r.code }}</td> <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> <td class="ta-r mono">{{ r.amount | number: '1.2-2' }}</td>
</tr> </tr>
} }
@@ -312,7 +334,9 @@
@for (r of bsAssets; track r.code) { @for (r of bsAssets; track r.code) {
<tr> <tr>
<td class="mono">{{ r.code }}</td> <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> <td class="ta-r mono">{{ r.amount | number: '1.2-2' }}</td>
</tr> </tr>
} }
@@ -331,11 +355,17 @@
@if (activeReport === 'comparative-balance-sheet') { @if (activeReport === 'comparative-balance-sheet') {
<div class="glr-section"> <div class="glr-section">
<p class="glr-note"> <p class="glr-note">
Compares the closing position as at <strong>{{ range.to }}</strong> against the position immediately before Compares the closing position as at <strong>{{ range.to }}</strong> against the position immediately before this
this period began. Change the filter above to compare a different span. period began. Change the filter above to compare a different span.
</p> </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-wrap">
<div class="glr-tbl-h">{{ block.title }}</div> <div class="glr-tbl-h">{{ block.title }}</div>
<table class="glr-tbl"> <table class="glr-tbl">
@@ -353,7 +383,9 @@
@for (r of block.rows; track r.code) { @for (r of block.rows; track r.code) {
<tr> <tr>
<td class="mono">{{ r.code }}</td> <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.current | number: '1.2-2' }}</td>
<td class="ta-r mono">{{ r.previous | 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"> <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') { @if (activeReport === 'contingent-liabilities') {
<div class="glr-section"> <div class="glr-section">
<p class="glr-note"> <p class="glr-note">
Off-balance-sheet commitments outstanding as at <strong>{{ range.to }}</strong>. These are memoranda accounts Off-balance-sheet commitments outstanding as at <strong>{{ range.to }}</strong
and are deliberately excluded from the Trial Balance and Balance Sheet totals. >. These are memoranda accounts and are deliberately excluded from the Trial Balance and Balance Sheet totals.
</p> </p>
<div class="glr-tbl-wrap"> <div class="glr-tbl-wrap">
<table class="glr-tbl"> <table class="glr-tbl">
@@ -419,8 +451,8 @@
@if (activeReport === 'gl-wise-dr-cr') { @if (activeReport === 'gl-wise-dr-cr') {
<div class="glr-section"> <div class="glr-section">
<p class="glr-note"> <p class="glr-note">
Debit and credit turnover posted between <strong>{{ rangeLabel }}</strong> for {{ branchLabel }}. Every Debit and credit turnover posted between <strong>{{ rangeLabel }}</strong> for {{ branchLabel }}. Every voucher
voucher is double-entry, so the debit and credit totals below must match. is double-entry, so the debit and credit totals below must match.
</p> </p>
<div class="glr-tbl-wrap"> <div class="glr-tbl-wrap">
<table class="glr-tbl"> <table class="glr-tbl">
@@ -474,8 +506,8 @@
@if (activeReport === 'misc-expense-ledger') { @if (activeReport === 'misc-expense-ledger') {
<div class="glr-section"> <div class="glr-section">
<p class="glr-note"> <p class="glr-note">
Every miscellaneous-expense voucher posted between <strong>{{ rangeLabel }}</strong> for {{ branchLabel }} Every miscellaneous-expense voucher posted between <strong>{{ rangeLabel }}</strong> for
&mdash; {{ miscRows.length | number }} entries. {{ branchLabel }} &mdash; {{ miscRows.length | number }} entries.
</p> </p>
<div class="glr-tbl-wrap glr-tbl-wrap--tall"> <div class="glr-tbl-wrap glr-tbl-wrap--tall">
<table class="glr-tbl"> <table class="glr-tbl">
@@ -518,6 +550,506 @@
</div> </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 (&#8377; 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 (&#8377;)</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">&#8377;{{ 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">&#8377;{{ 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 (&#8377;)</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">&#8377;{{ 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 (&#8377;)</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 (&#8377;)</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 --> <!-- TOAST -->
<div class="glr-toast" [class.show]="toastVisible">{{ toastMsg }}</div> <div class="glr-toast" [class.show]="toastVisible">{{ toastMsg }}</div>
</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 */ /* Day / Month / Range segmented control */
.glr-seg { .glr-seg {
display: inline-flex; display: inline-flex;
@@ -434,6 +449,45 @@
font-style: italic; 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 */ /* Account-type pills on the GL Wise report */
.type-pill { .type-pill {
display: inline-block; display: inline-block;
+743 -33
View File
@@ -6,24 +6,42 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. * 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 { ActivatedRoute, Router } from '@angular/router';
import { BranchScopeService } from 'app/core/branch-scope.service';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import * as ExcelJS from 'exceljs'; 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 { import {
AccountBranchRow,
AverageBalanceRow,
BalanceSheetRow, BalanceSheetRow,
ComparativeRow, ComparativeRow,
DistributionRow,
GlDateRange, GlDateRange,
GlReportSet, GlReportSet,
MiscExpenseRow, MiscExpenseRow,
PnlRow, PnlRow,
BankerHeadRow,
ClientBalanceRow,
SundryItemRow,
VoucherRow,
accountWiseByBranch,
averageBalances,
balanceDistribution,
bankerHeadSummary,
clientWiseBalances,
sundryRegister,
voucherRegister,
buildReports, buildReports,
clampToLedger, clampToLedger,
glValueForCodes,
sum sum
} from './gl-reports.compute'; } from './gl-reports.compute';
import { GlMappingEntry, allMappings, glLabel, returnName } from 'app/rbi-circulars/gl-mapping';
export type GlReportCode = export type GlReportCode =
| 'trial-balance' | 'trial-balance'
@@ -32,7 +50,15 @@ export type GlReportCode =
| 'comparative-balance-sheet' | 'comparative-balance-sheet'
| 'contingent-liabilities' | 'contingent-liabilities'
| 'gl-wise-dr-cr' | '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'; type PeriodMode = 'day' | 'month' | 'range';
@@ -49,6 +75,19 @@ type PeriodMode = 'day' | 'month' | 'range';
export class GlReportsComponent implements OnInit { export class GlReportsComponent implements OnInit {
private route = inject(ActivatedRoute); private route = inject(ActivatedRoute);
private router = inject(Router); 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 }[] = [ readonly reports: { code: GlReportCode; label: string; icon: string }[] = [
{ code: 'trial-balance', label: 'Trial Balance', icon: 'balance-scale' }, { 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: 'comparative-balance-sheet', label: 'Comparative Balance Sheet', icon: 'balance-scale' },
{ code: 'contingent-liabilities', label: 'Contingent Liabilities', icon: 'exclamation-circle' }, { code: 'contingent-liabilities', label: 'Contingent Liabilities', icon: 'exclamation-circle' },
{ code: 'gl-wise-dr-cr', label: 'GL Wise DR / CR', icon: 'project-diagram' }, { 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'; activeReport: GlReportCode = 'trial-balance';
@@ -80,16 +127,29 @@ export class GlReportsComponent implements OnInit {
private toastTimer: any; private toastTimer: any;
ngOnInit(): void { ngOnInit(): void {
const param = this.route.snapshot.paramMap.get('report') as GlReportCode | null; // Subscribe rather than read the snapshot: navigating between GL menu items only
if (param && this.reports.some((r) => r.code === param)) { // changes the route parameter, so Angular reuses this component and ngOnInit would
this.activeReport = param; // 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 { showReport(code: GlReportCode): void {
this.activeReport = code; this.activeReport = code;
// Keep the URL deep-linkable so the GL menu items land on the right report. // 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 { get reportLabel(): string {
@@ -120,21 +180,33 @@ export class GlReportsComponent implements OnInit {
} }
get branchLabel(): string { get branchLabel(): string {
if (!this.canSelectBranch) {
return this.scopeName;
}
return this.branchCode ? glBranchName(this.branchCode) : 'All Branches (Consolidated)'; 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 { resetFilters(): void {
this.periodMode = 'month'; this.periodMode = 'month';
this.filterMonth = '2026-07'; this.filterMonth = '2026-07';
this.filterDay = '2026-07-31'; this.filterDay = '2026-07-31';
this.filterFrom = GL_PERIOD.fyStart; this.filterFrom = GL_PERIOD.fyStart;
this.filterTo = GL_PERIOD.fyEnd; 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. */ /** All seven reports for the current filter — memoised inside buildReports. */
get data(): GlReportSet { get data(): GlReportSet {
return buildReports(this.range, this.branchCode); return buildReports(this.range, this.effectiveBranch);
} }
// ── Trial Balance ────────────────────────────────────────────────────────── // ── Trial Balance ──────────────────────────────────────────────────────────
@@ -234,6 +306,139 @@ export class GlReportsComponent implements OnInit {
return glBranchName(code); 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 ─────────────────────────────────────────────────────────── // ── Excel export ───────────────────────────────────────────────────────────
/** Exports whichever report is on screen, carrying the active filter into the header. */ /** 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 Dr',
'Closing Cr' 'Closing Cr'
], ],
widths: [12, 42, 26, 16, 16, 16, 16, 16, 16], widths: [
12,
42,
26,
16,
16,
16,
16,
16,
16
],
rows: [ rows: [
...this.data.trialBalance.map((r) => [ ...this.data.trialBalance.map((r) => [
r.code, r.code,
@@ -349,13 +564,49 @@ export class GlReportsComponent implements OnInit {
'Group', 'Group',
'Amount' 'Amount'
], ],
widths: [14, 12, 42, 26, 18], widths: [
14,
12,
42,
26,
18
],
rows: [ rows: [
...this.pnlExpense.map((r) => ['Expenditure', r.code, r.name, r.group, r.amount]), ...this.pnlExpense.map((r) => [
['Total Expenditure', '', '', '', this.totalExpense], 'Expenditure',
...this.pnlIncome.map((r) => ['Income', r.code, r.name, r.group, r.amount]), r.code,
['Total Income', '', '', '', this.totalIncome], r.name,
['Net Profit', '', '', '', this.netProfit] 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': case 'balance-sheet':
@@ -367,13 +618,49 @@ export class GlReportsComponent implements OnInit {
'Group', 'Group',
'Amount' 'Amount'
], ],
widths: [14, 12, 42, 26, 18], widths: [
14,
12,
42,
26,
18
],
rows: [ rows: [
...this.bsLiabilities.map((r) => ['Liability', r.code, r.name, r.group, r.amount]), ...this.bsLiabilities.map((r) => [
['Liability', '', 'Surplus for the period', 'Profit & Loss', this.bsSurplus], 'Liability',
['Total Liabilities', '', '', '', this.bsTotalLiabilities], r.code,
...this.bsAssets.map((r) => ['Asset', r.code, r.name, r.group, r.amount]), r.name,
['Total Assets', '', '', '', this.bsTotalAssets] 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': case 'comparative-balance-sheet':
@@ -387,7 +674,15 @@ export class GlReportsComponent implements OnInit {
'Variance', 'Variance',
'Variance %' 'Variance %'
], ],
widths: [12, 12, 42, 18, 18, 18, 14], widths: [
12,
12,
42,
18,
18,
18,
14
],
rows: this.data.comparative.map((r) => [ rows: this.data.comparative.map((r) => [
r.side, r.side,
r.code, r.code,
@@ -406,10 +701,25 @@ export class GlReportsComponent implements OnInit {
'Group', 'Group',
'Amount' 'Amount'
], ],
widths: [12, 48, 26, 18], widths: [
12,
48,
26,
18
],
rows: [ rows: [
...this.data.contingent.map((r) => [r.code, r.name, r.group, r.amount]), ...this.data.contingent.map((r) => [
['TOTAL', '', '', this.contingentTotal] r.code,
r.name,
r.group,
r.amount
]),
[
'TOTAL',
'',
'',
this.contingentTotal
]
] ]
}; };
case 'gl-wise-dr-cr': case 'gl-wise-dr-cr':
@@ -424,10 +734,394 @@ export class GlReportsComponent implements OnInit {
'Net (Dr-Cr)', 'Net (Dr-Cr)',
'Vouchers' 'Vouchers'
], ],
widths: [12, 42, 14, 26, 18, 18, 18, 12], widths: [
12,
42,
14,
26,
18,
18,
18,
12
],
rows: [ rows: [
...this.data.glWise.map((r) => [r.code, r.name, r.type, r.group, r.debit, r.credit, r.net, r.vouchers]), ...this.data.glWise.map((r) => [
['TOTAL', '', '', '', this.glWiseDebit, this.glWiseCredit, this.glWiseDebit - this.glWiseCredit, null] 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': case 'misc-expense-ledger':
@@ -442,7 +1136,15 @@ export class GlReportsComponent implements OnInit {
'Narration', 'Narration',
'Amount' 'Amount'
], ],
widths: [14, 20, 18, 12, 36, 46, 18], widths: [
14,
20,
18,
12,
36,
46,
18
],
rows: [ rows: [
...this.miscRows.map((r) => [ ...this.miscRows.map((r) => [
r.date, r.date,
@@ -453,7 +1155,15 @@ export class GlReportsComponent implements OnInit {
r.narration, r.narration,
r.amount r.amount
]), ]),
['TOTAL', '', '', '', '', '', this.miscTotal] [
'TOTAL',
'',
'',
'',
'',
'',
this.miscTotal
]
] ]
}; };
} }
+477 -13
View File
@@ -15,14 +15,7 @@
* postings and Angular re-evaluates template getters on every change detection. * postings and Angular re-evaluates template getters on every change detection.
*/ */
import { import { GL_ACCOUNTS, GL_PERIOD, GlAccount, GlPosting, glAccount, glCustomers, glPostings } from './gl-reports-data';
GL_ACCOUNTS,
GL_PERIOD,
GlAccount,
GlPosting,
glAccount,
glPostings
} from './gl-reports-data';
export interface GlDateRange { export interface GlDateRange {
from: string; from: string;
@@ -96,8 +89,22 @@ function matchesBranch(p: GlPosting, branchCode: string): boolean {
return !branchCode || p.branchCode === branchCode; 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`. */ /** Signed balance on the account's natural side, for postings up to and including `to`. */
function cumulativeBalances(to: string, branchCode: string): Map<string, number> { 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>(); const out = new Map<string, number>();
for (const p of glPostings()) { for (const p of glPostings()) {
if (p.date > to || !matchesBranch(p, branchCode)) { 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; const delta = isDebitSide(account) ? p.debit - p.credit : p.credit - p.debit;
out.set(p.accountCode, (out.get(p.accountCode) ?? 0) + delta); out.set(p.accountCode, (out.get(p.accountCode) ?? 0) + delta);
} }
balanceCache.set(key, out);
return out; return out;
} }
/** Debit and credit turnover strictly inside the range. */ /** 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> }>(); const out = new Map<string, { debit: number; credit: number; vouchers: Set<string> }>();
for (const p of glPostings()) { for (const p of glPostings()) {
if (p.date < range.from || p.date > range.to || !matchesBranch(p, branchCode)) { 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.credit += p.credit;
entry.vouchers.add(p.voucherNo); entry.vouchers.add(p.voucherNo);
} }
turnoverCache.set(key, out);
return out; return out;
} }
@@ -190,8 +207,7 @@ export function profitAndLoss(range: GlDateRange, branchCode: string): { income:
if (!move) { if (!move) {
continue; continue;
} }
const amount = const amount = account.type === 'INCOME' ? move.credit - move.debit : move.debit - move.credit;
account.type === 'INCOME' ? move.credit - move.debit : move.debit - move.credit;
if (!amount) { if (!amount) {
continue; 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 * 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. * 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[]; assets: BalanceSheetRow[];
liabilities: BalanceSheetRow[]; liabilities: BalanceSheetRow[];
surplus: number; surplus: number;
@@ -315,7 +334,417 @@ export function miscExpenseLedger(range: GlDateRange, branchCode: string): MiscE
amount: p.debit 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 ────────────────────────────────────────────────────────────── // ── 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 { export function sum<T>(rows: T[], pick: (row: T) => number): number {
return rows.reduce((acc, r) => acc + pick(r), 0); return rows.reduce((acc, r) => acc + pick(r), 0);
} }
@@ -21,6 +21,22 @@
</mat-card-header> </mat-card-header>
<mat-card-content class="aml-card-content"> <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"> <table mat-table [dataSource]="rules" class="aml-table mat-elevation-z1">
<!-- Order Position Column --> <!-- Order Position Column -->
<ng-container matColumnDef="orderPosition"> <ng-container matColumnDef="orderPosition">
@@ -45,10 +61,37 @@
</td> </td>
</ng-container> </ng-container>
<!-- Income / Occupation Limit Column --> <!-- What the min/max figures are measured against -->
<ng-container matColumnDef="incomeLimit"> <ng-container matColumnDef="limitBasis">
<th mat-header-cell *matHeaderCellDef>Income / Occupation Limit</th> <th mat-header-cell *matHeaderCellDef>Limit Basis</th>
<td mat-cell *matCellDef="let element" class="income-limit-cell">{{ getIncomeLimitLabel(element) }}</td> <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> </ng-container>
<!-- Reason Column --> <!-- Reason Column -->
@@ -114,13 +114,141 @@
max-width: 400px; 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; color: #334155;
font-size: 13px; font-size: 12px;
font-weight: 500; font-weight: 600;
white-space: normal; white-space: normal;
max-width: 200px; max-width: 150px;
line-height: 1.35; 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 */ /* Badges for Risk Category */
@@ -42,24 +42,185 @@ export class AmlRulesComponent implements OnInit {
'orderPosition', 'orderPosition',
'ruleName', 'ruleName',
'riskCategory', 'riskCategory',
'incomeLimit', 'limitBasis',
'minLimit',
'maxLimit',
'appliesOver',
'reason', 'reason',
'amlCheckStatus', 'amlCheckStatus',
'bannedEntityMatch' '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; @ViewChild(MatPaginator) paginator!: MatPaginator;
ngOnInit(): void { ngOnInit(): void {
this.clientsService.getClientRiskRules().subscribe({ this.clientsService.getClientRiskRules().subscribe({
next: (data) => { next: (data) => {
this.rules.data = data || []; this.allRules = data || [];
this.applyPeriodFilter();
this.rules.paginator = this.paginator; this.rules.paginator = this.paginator;
}, },
error: () => {} 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 { getRiskCategoryLabel(category: number): string {
switch (category) { switch (category) {
case 1: case 1:
@@ -85,39 +246,4 @@ export class AmlRulesComponent implements OnInit {
return ''; 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 }} &middot; {{ 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);
}
}
+140
View File
@@ -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);
}
});
}
}
+170
View File
@@ -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;
}
+152
View File
@@ -360,6 +360,158 @@ td.derived {
color: var(--o-g500); 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). */ /* Tie-up status pills (cross-return reconciliation checks). */
.report-pill { .report-pill {
display: inline-block; display: inline-block;
+36 -25
View File
@@ -7,13 +7,7 @@
*/ */
import * as ExcelJS from 'exceljs'; import * as ExcelJS from 'exceljs';
import { import { MatrixTable, ReturnGeneralInfoRow, ReturnLine, ReturnSignatory, SIGNATORY_FIELDS } from './oss-return.model';
MatrixTable,
ReturnGeneralInfoRow,
ReturnLine,
ReturnSignatory,
SIGNATORY_FIELDS
} from './oss-return.model';
/** Palette lifted from the RBI CIMS workbooks so exports look like the originals. */ /** Palette lifted from the RBI CIMS workbooks so exports look like the originals. */
export const XL = { export const XL = {
@@ -45,12 +39,7 @@ export function addBanner(sheet: ExcelJS.Worksheet, title: string, lastCol: stri
sheet.addRow([]); sheet.addRow([]);
} }
export function boxAllCells( export function boxAllCells(sheet: ExcelJS.Worksheet, from: number, to: number, lastColIndex: number): void {
sheet: ExcelJS.Worksheet,
from: number,
to: number,
lastColIndex: number
): void {
for (let r = from; r <= to; r++) { for (let r = from; r <= to; r++) {
for (let c = 1; c <= lastColIndex; c++) { for (let c = 1; c <= lastColIndex; c++) {
sheet.getRow(r).getCell(c).border = { sheet.getRow(r).getCell(c).border = {
@@ -64,7 +53,10 @@ export function boxAllCells(
} }
export function addSectionHeader(sheet: ExcelJS.Worksheet, label: string, unitLabel?: string): void { 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.font = { bold: true };
row.eachCell((cell) => (cell.fill = solidFill(XL.headerGrey))); row.eachCell((cell) => (cell.fill = solidFill(XL.headerGrey)));
row.getCell(2).alignment = { horizontal: 'right' }; 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. */ /** Renders a labelled block; indents by level and shades derived totals. */
export function addLineRows(sheet: ExcelJS.Worksheet, lines: ReturnLine[]): void { export function addLineRows(sheet: ExcelJS.Worksheet, lines: ReturnLine[]): void {
for (const line of lines) { 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 labelCell = row.getCell(1);
const valueCell = row.getCell(2); 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 colCount = table.columns.length + 1;
const start = sheet.rowCount + 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) { if (!table.unit) {
sheet.mergeCells(titleRow.number, 1, titleRow.number, colCount); 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); 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.font = { bold: true };
head.alignment = { wrapText: true, horizontal: 'center', vertical: 'middle' }; head.alignment = { wrapText: true, horizontal: 'center', vertical: 'middle' };
head.eachCell((c) => (c.fill = solidFill(XL.headerGrey))); head.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
for (const row of table.rows) { 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); const labelCell = excelRow.getCell(1);
labelCell.alignment = { wrapText: true, vertical: 'middle', indent: row.level }; labelCell.alignment = { wrapText: true, vertical: 'middle', indent: row.level };
if (row.level === 0 || row.header) { if (row.level === 0 || row.header) {
@@ -158,13 +162,15 @@ export function buildGeneralInfoSheet(wb: ExcelJS.Workbook, rows: ReturnGeneralI
const sheet = wb.addWorksheet('General Information'); const sheet = wb.addWorksheet('General Information');
sheet.columns = [ sheet.columns = [
{ width: 26 }, { width: 26 },
{ width: 58 } { width: 58 }];
];
addBanner(sheet, 'General Information', 'B'); addBanner(sheet, 'General Information', 'B');
const first = sheet.rowCount + 1; const first = sheet.rowCount + 1;
for (const item of rows) { 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); row.getCell(1).fill = solidFill(XL.keyCell);
// The workbook tints the audit flag yellow and leaves unfilled cells grey. // 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; 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); const sheet = wb.addWorksheet(sheetName);
sheet.columns = [ sheet.columns = [
{ width: 44 }, { width: 44 },
{ width: 24 } { width: 24 }];
];
addBanner(sheet, sheetName, 'B'); addBanner(sheet, sheetName, 'B');
for (const person of signatories) { for (const person of signatories) {
const start = sheet.rowCount + 1; const start = sheet.rowCount + 1;
const head = sheet.addRow([person.heading, '']); const head = sheet.addRow([
person.heading,
''
]);
head.font = { bold: true }; head.font = { bold: true };
head.alignment = { horizontal: 'center' }; head.alignment = { horizontal: 'center' };
head.getCell(1).fill = solidFill(XL.keyCell); head.getCell(1).fill = solidFill(XL.keyCell);
for (const f of SIGNATORY_FIELDS) { 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).font = { bold: true };
row.getCell(2).alignment = { horizontal: 'center' }; row.getCell(2).alignment = { horizontal: 'center' };
row.getCell(2).fill = solidFill(XL.keyCell); row.getCell(2).fill = solidFill(XL.keyCell);
+100 -20
View File
@@ -153,8 +153,7 @@ export const RAL_LIABILITIES: RalLine[] = [
{ label: 'ii. Borrowings', level: 1, value: null }, { label: 'ii. Borrowings', level: 1, value: null },
{ {
label: label: '12. Risk Provisions Other than netted off assets (BDDR, Spl BDDR, Depreciation on fixed assets etc.,)',
'12. Risk Provisions Other than netted off assets (BDDR, Spl BDDR, Depreciation on fixed assets etc.,)',
level: 0, level: 0,
value: 39440, value: 39440,
total: true total: true
@@ -192,7 +191,12 @@ export const RAL_ASSETS: RalLine[] = [
{ label: '2. I. Due from Banks', level: 0, value: 57600, total: true }, { label: '2. I. Due from Banks', level: 0, value: 57600, total: true },
{ label: 'A. Balances with Banks', level: 1, 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: 'a. Current', level: 2, value: 5980 },
{ label: 'b. Savings', level: 2, value: null }, { label: 'b. Savings', level: 2, value: null },
{ label: 'c. Fixed', level: 2, value: 26288 }, { 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 */ /** Sheet 3 — Annexure 1 :: 3. Age-wise Inter Branch Adjustment Account */
export const RAL_INTER_BRANCH_AGEING: RalAgeingRow[] = [ 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: '3 months to 6 months',
{ period: 'Above 1 year to 2 years', noOfDebitEntries: null, amtOfDebitEntries: null, noOfCreditEntries: null, amtOfCreditEntries: null }, noOfDebitEntries: null,
{ period: 'Above 2 years', noOfDebitEntries: null, amtOfDebitEntries: null, noOfCreditEntries: null, amtOfCreditEntries: 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 } { period: 'Total', noOfDebitEntries: 0, amtOfDebitEntries: 0, noOfCreditEntries: 0, amtOfCreditEntries: 0 }
]; ];
/** Sheet 3 — Annexure 1 :: 4. Age-wise Inter Bank Reconciliation Account */ /** Sheet 3 — Annexure 1 :: 4. Age-wise Inter Bank Reconciliation Account */
export const RAL_INTER_BANK_AGEING: RalAgeingRow[] = [ 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: '3 months to 6 months',
{ period: 'Above 1 year to 2 years', noOfDebitEntries: null, amtOfDebitEntries: null, noOfCreditEntries: null, amtOfCreditEntries: null }, noOfDebitEntries: null,
{ period: 'Above 2 years', noOfDebitEntries: null, amtOfDebitEntries: null, noOfCreditEntries: null, amtOfCreditEntries: 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 } { period: 'Total', noOfDebitEntries: 0, amtOfDebitEntries: 0, noOfCreditEntries: 0, amtOfCreditEntries: 0 }
]; ];
/** Sheet 3 — Annexure 1 :: 5. Unsecured guarantees issued by the bank */ /** Sheet 3 — Annexure 1 :: 5. Unsecured guarantees issued by the bank */
export const RAL_UNSECURED_GUARANTEES: RalUnsecuredGuarantee[] = [ 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; export const RAL_UNSECURED_GUARANTEES_TOTAL = 0;
/** Sheet 4 — Annexure 2 :: Sundry Debtors ageing buckets */ /** Sheet 4 — Annexure 2 :: Sundry Debtors ageing buckets */
@@ -401,20 +452,49 @@ export const RAL_SUNDRY_DEBTORS_NOTE =
export const RAL_DEPOSITORS_BORROWERS: RalLine[] = [ export const RAL_DEPOSITORS_BORROWERS: RalLine[] = [
{ label: '1.Total No of Depositors', level: 0, value: 8156, total: true, count: true }, { 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: '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: '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: '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: '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: '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: '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: '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: '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 } { label: 'ii) Of which total Loans and Advances Amount', level: 1, value: 21260 }
]; ];
@@ -81,11 +81,16 @@
<div class="oss-section"> <div class="oss-section">
<div class="oss-sheet-banner">Assets &amp; Liabilities</div> <div class="oss-sheet-banner">Assets &amp; Liabilities</div>
<ng-container [ngTemplateOutlet]="glBar"></ng-container>
<div class="oss-tbl-wrap"> <div class="oss-tbl-wrap">
<table class="oss-tbl"> <table class="oss-tbl">
<thead> <thead>
<tr> <tr>
<th>Liabilities</th> <th>Liabilities</th>
@if (glMapMode) {
<th class="oss-gl-col">Mapped GL accounts</th>
}
<th class="ta-r">Rs in Thousands</th> <th class="ta-r">Rs in Thousands</th>
</tr> </tr>
</thead> </thead>
@@ -93,8 +98,25 @@
@for (line of liabilities; track $index) { @for (line of liabilities; track $index) {
<tr [class.total-row]="line.total"> <tr [class.total-row]="line.total">
<td [class]="'lvl-' + line.level">{{ line.label }}</td> <td [class]="'lvl-' + line.level">{{ line.label }}</td>
<td class="ta-r mono" [class.derived]="line.total"> @if (glMapMode) {
{{ line.value === null ? '' : (line.value | number: line.count ? '1.0-0' : '1.2-2') }} <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> </td>
</tr> </tr>
} }
@@ -107,6 +129,9 @@
<thead> <thead>
<tr> <tr>
<th>Assets</th> <th>Assets</th>
@if (glMapMode) {
<th class="oss-gl-col">Mapped GL accounts</th>
}
<th class="ta-r">Rs in Thousands</th> <th class="ta-r">Rs in Thousands</th>
</tr> </tr>
</thead> </thead>
@@ -114,8 +139,25 @@
@for (line of assets; track $index) { @for (line of assets; track $index) {
<tr [class.total-row]="line.total"> <tr [class.total-row]="line.total">
<td [class]="'lvl-' + line.level">{{ line.label }}</td> <td [class]="'lvl-' + line.level">{{ line.label }}</td>
<td class="ta-r mono" [class.derived]="line.total"> @if (glMapMode) {
{{ line.value === null ? '' : (line.value | number: line.count ? '1.0-0' : '1.2-2') }} <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> </td>
</tr> </tr>
} }
@@ -343,7 +385,7 @@
<tr [class.total-row]="line.total"> <tr [class.total-row]="line.total">
<td [class]="'lvl-' + line.level">{{ line.label }}</td> <td [class]="'lvl-' + line.level">{{ line.label }}</td>
<td class="ta-r mono" [class.derived]="line.total"> <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> </td>
</tr> </tr>
} }
@@ -417,6 +459,68 @@
</div> </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 --> <!-- TOAST -->
<div class="oss-toast" [class.show]="toastVisible">{{ toastMsg }}</div> <div class="oss-toast" [class.show]="toastVisible">{{ toastMsg }}</div>
</div> </div>
@@ -11,6 +11,7 @@ import { FormsModule } from '@angular/forms';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import * as ExcelJS from 'exceljs'; import * as ExcelJS from 'exceljs';
import { GlMappingHost } from '../gl-mapping.host';
import { import {
addBanner, addBanner,
addLineRows, addLineRows,
@@ -55,7 +56,14 @@ type Oss1Tab = 'general' | 'balance-sheet' | 'annexure1' | 'annexure2' | 'signat
FormsModule 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'; activeTab: Oss1Tab = 'general';
generalInfo = RAL_GENERAL_INFO; generalInfo = RAL_GENERAL_INFO;
@@ -146,8 +154,7 @@ export class Oss1RalComponent {
const sheet = wb.addWorksheet('Assets & Liabilities'); const sheet = wb.addWorksheet('Assets & Liabilities');
sheet.columns = [ sheet.columns = [
{ width: 78 }, { width: 78 },
{ width: 20 } { width: 20 }];
];
addBanner(sheet, 'Assets & Liabilities', 'B'); addBanner(sheet, 'Assets & Liabilities', 'B');
const first = sheet.rowCount + 1; const first = sheet.rowCount + 1;
@@ -187,18 +194,25 @@ export class Oss1RalComponent {
{ width: 16 }, { width: 16 },
{ width: 12 }, { width: 12 },
{ width: 12 }, { width: 12 },
{ width: 12 } { width: 12 }];
];
addBanner(sheet, 'Annexure 1', 'H'); addBanner(sheet, 'Annexure 1', 'H');
// 1. Break-up of Other Funds and Reserves // 1. Break-up of Other Funds and Reserves
let start = sheet.rowCount + 1; 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}`); sheet.mergeCells(`A${fundsTitle.number}:B${fundsTitle.number}`);
fundsTitle.font = { bold: true }; fundsTitle.font = { bold: true };
fundsTitle.eachCell((c) => (c.fill = solidFill(XL.headerGrey))); fundsTitle.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
for (const line of this.otherFunds) { 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}`); sheet.mergeCells(`A${row.number}:B${row.number}`);
row.getCell(1).alignment = { wrapText: true, vertical: 'middle' }; row.getCell(1).alignment = { wrapText: true, vertical: 'middle' };
row.getCell(3).numFmt = '#,##0.00'; row.getCell(3).numFmt = '#,##0.00';
@@ -281,10 +295,20 @@ export class Oss1RalComponent {
gHead.alignment = { wrapText: true, horizontal: 'center', vertical: 'middle' }; gHead.alignment = { wrapText: true, horizontal: 'center', vertical: 'middle' };
gHead.eachCell((c) => (c.fill = solidFill(XL.headerGrey))); gHead.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
for (const g of this.unsecuredGuarantees) { 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'; 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.font = { bold: true };
gTotal.getCell(4).numFmt = '#,##0.00'; gTotal.getCell(4).numFmt = '#,##0.00';
gTotal.getCell(4).fill = solidFill(XL.derivedBlue); gTotal.getCell(4).fill = solidFill(XL.derivedBlue);
@@ -327,9 +351,7 @@ export class Oss1RalComponent {
3, 3,
4, 4,
5 5
].forEach( ].forEach((c) => (row.getCell(c).fill = solidFill(XL.derivedBlue)));
(c) => (row.getCell(c).fill = solidFill(XL.derivedBlue))
);
} }
} }
boxAllCells(sheet, start, sheet.rowCount, 5); boxAllCells(sheet, start, sheet.rowCount, 5);
@@ -341,8 +363,7 @@ export class Oss1RalComponent {
{ width: 10 }, { width: 10 },
{ width: 62 }, { width: 62 },
{ width: 20 }, { width: 20 },
{ width: 20 } { width: 20 }];
];
addBanner(sheet, 'Annexure 2', 'D'); addBanner(sheet, 'Annexure 2', 'D');
for (const block of this.sundryDebtors) { for (const block of this.sundryDebtors) {
@@ -357,13 +378,23 @@ export class Oss1RalComponent {
s.font = { bold: true, size: 10 }; s.font = { bold: true, size: 10 };
s.getCell(1).fill = solidFill(XL.headerGrey); 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.font = { bold: true };
head.alignment = { horizontal: 'center' }; head.alignment = { horizontal: 'center' };
head.eachCell((c) => (c.fill = solidFill(XL.headerGrey))); head.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
for (const r of block.rows) { 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(3).numFmt = '#,##0.00';
row.getCell(4).numFmt = '#,##0.00'; row.getCell(4).numFmt = '#,##0.00';
} }
@@ -378,14 +409,22 @@ export class Oss1RalComponent {
// Details of Depositors & Borrowers // Details of Depositors & Borrowers
let start = sheet.rowCount + 1; 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}`); sheet.mergeCells(`A${dbTitle.number}:B${dbTitle.number}`);
dbTitle.font = { bold: true }; dbTitle.font = { bold: true };
dbTitle.eachCell((c) => (c.fill = solidFill(XL.headerGrey))); dbTitle.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
dbTitle.getCell(3).alignment = { horizontal: 'right' }; dbTitle.getCell(3).alignment = { horizontal: 'right' };
for (const line of this.depositorsBorrowers) { 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}`); sheet.mergeCells(`A${row.number}:B${row.number}`);
row.getCell(1).alignment = { wrapText: true, vertical: 'middle', indent: line.level }; row.getCell(1).alignment = { wrapText: true, vertical: 'middle', indent: line.level };
row.getCell(3).numFmt = line.count ? '#,##0' : '#,##0.00'; row.getCell(3).numFmt = line.count ? '#,##0' : '#,##0.00';
@@ -404,7 +443,11 @@ export class Oss1RalComponent {
cTitle.font = { bold: true }; cTitle.font = { bold: true };
cTitle.getCell(1).fill = solidFill(XL.headerGrey); cTitle.getCell(1).fill = solidFill(XL.headerGrey);
for (const line of this.complaints) { 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}`); sheet.mergeCells(`A${row.number}:B${row.number}`);
row.getCell(1).alignment = { wrapText: true, vertical: 'middle' }; row.getCell(1).alignment = { wrapText: true, vertical: 'middle' };
row.getCell(3).numFmt = '#,##0'; row.getCell(3).numFmt = '#,##0';
@@ -122,11 +122,52 @@
</table> </table>
</div> </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"> <div class="oss-tbl-wrap">
<table class="oss-tbl"> <table class="oss-tbl">
<thead> <thead>
<tr> <tr>
<th>Particulars</th> <th>Particulars</th>
@if (glMapMode) {
<th class="oss-gl-col">Mapped GL accounts</th>
}
<th class="ta-r">Rs in Thousand</th> <th class="ta-r">Rs in Thousand</th>
</tr> </tr>
</thead> </thead>
@@ -134,8 +175,44 @@
@for (line of earnings; track $index) { @for (line of earnings; track $index) {
<tr [class.total-row]="line.total"> <tr [class.total-row]="line.total">
<td [class]="'lvl-' + line.level">{{ line.label }}</td> <td [class]="'lvl-' + line.level">{{ line.label }}</td>
<td class="ta-r mono" [class.derived]="line.total"> @if (glMapMode) {
{{ line.value === null ? '' : (line.value | number: line.count ? '1.0-0' : '1.2-2') }} <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> </td>
</tr> </tr>
} }
@@ -186,9 +263,10 @@
<div class="oss-balance" [class.oss-balance--bad]="!otherExpensesTally"> <div class="oss-balance" [class.oss-balance--bad]="!otherExpensesTally">
<fa-icon [icon]="otherExpensesTally ? 'check-circle' : 'exclamation-triangle'"></fa-icon> <fa-icon [icon]="otherExpensesTally ? 'check-circle' : 'exclamation-triangle'"></fa-icon>
<span> <span>
Detail total <strong>&#8377;{{ otherExpensesTotal | number: '1.2-2' }}</strong> vs Earnings line 12 ix) Detail total <strong>&#8377;{{ otherExpensesTotal | number: '1.2-2' }}</strong> vs Earnings line 12 ix) Others
Others <strong
<strong>&#8377;{{ otherExpensesLineValue === null ? '—' : (otherExpensesLineValue | number: '1.2-2') }}</strong> >&#8377;{{ otherExpensesLineValue === null ? '—' : (otherExpensesLineValue | number: '1.2-2') }}</strong
>
— {{ otherExpensesTally ? 'tallies' : 'MISMATCH — resolve before filing' }} — {{ otherExpensesTally ? 'tallies' : 'MISMATCH — resolve before filing' }}
</span> </span>
</div> </div>
@@ -11,6 +11,7 @@ import { FormsModule } from '@angular/forms';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import * as ExcelJS from 'exceljs'; import * as ExcelJS from 'exceljs';
import { GlMappingHost } from '../gl-mapping.host';
import { import {
addBanner, addBanner,
addLineRows, addLineRows,
@@ -54,7 +55,14 @@ interface TieUp {
FormsModule 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'; activeTab: Oss2Tab = 'general';
generalInfo = ROE_GENERAL_INFO; generalInfo = ROE_GENERAL_INFO;
@@ -178,8 +186,7 @@ export class Oss2RoeComponent {
const sheet = wb.addWorksheet('Earnings'); const sheet = wb.addWorksheet('Earnings');
sheet.columns = [ sheet.columns = [
{ width: 90 }, { width: 90 },
{ width: 20 } { width: 20 }];
];
addBanner(sheet, 'Earnings', 'B'); addBanner(sheet, 'Earnings', 'B');
const first = sheet.rowCount + 1; const first = sheet.rowCount + 1;
@@ -205,23 +212,31 @@ export class Oss2RoeComponent {
const sheet = wb.addWorksheet('Interest Receivable Not Recog'); const sheet = wb.addWorksheet('Interest Receivable Not Recog');
sheet.columns = [ sheet.columns = [
{ width: 72 }, { width: 72 },
{ width: 22 } { width: 22 }];
];
addBanner(sheet, 'Interest Receivable Not Recognized', 'B'); addBanner(sheet, 'Interest Receivable Not Recognized', 'B');
const start = sheet.rowCount + 1; const start = sheet.rowCount + 1;
const unit = sheet.addRow(['', 'Rs in Thousand']); const unit = sheet.addRow([
'',
'Rs in Thousand'
]);
unit.font = { bold: true }; unit.font = { bold: true };
unit.alignment = { horizontal: 'center' }; unit.alignment = { horizontal: 'center' };
unit.eachCell((c) => (c.fill = solidFill(XL.headerGrey))); 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.font = { bold: true };
head.getCell(2).alignment = { horizontal: 'center' }; head.getCell(2).alignment = { horizontal: 'center' };
head.eachCell((c) => (c.fill = solidFill(XL.headerGrey))); head.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
for (const r of this.interestNotRecognized) { 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).alignment = { wrapText: true, vertical: 'middle' };
row.getCell(1).fill = solidFill(XL.headerGrey); row.getCell(1).fill = solidFill(XL.headerGrey);
row.getCell(2).numFmt = '#,##0.00'; row.getCell(2).numFmt = '#,##0.00';
@@ -238,28 +253,43 @@ export class Oss2RoeComponent {
sheet.columns = [ sheet.columns = [
{ width: 10 }, { width: 10 },
{ width: 56 }, { width: 56 },
{ width: 20 } { width: 20 }];
];
addBanner(sheet, 'Other Operating Expenses', 'C'); addBanner(sheet, 'Other Operating Expenses', 'C');
const start = sheet.rowCount + 1; 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}`); sheet.mergeCells(`A${title.number}:B${title.number}`);
title.font = { bold: true }; title.font = { bold: true };
title.eachCell((c) => (c.fill = solidFill(XL.headerGrey))); title.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
title.getCell(3).alignment = { horizontal: 'center' }; 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.font = { bold: true };
head.alignment = { horizontal: 'center' }; head.alignment = { horizontal: 'center' };
head.eachCell((c) => (c.fill = solidFill(XL.headerGrey))); head.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
for (const e of this.otherExpenses) { 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'; 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}`); sheet.mergeCells(`A${total.number}:B${total.number}`);
total.font = { bold: true }; total.font = { bold: true };
total.getCell(1).fill = solidFill(XL.headerGrey); total.getCell(1).fill = solidFill(XL.headerGrey);
@@ -268,7 +298,11 @@ export class Oss2RoeComponent {
boxAllCells(sheet, start, sheet.rowCount, 3); 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 }; ref.getCell(3).font = { italic: true, size: 10 };
} }
File diff suppressed because it is too large Load Diff
@@ -52,6 +52,46 @@
} }
</div> </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 --> <!-- FILING INFORMATION -->
@if (activeTab === 'filing') { @if (activeTab === 'filing') {
<div class="oss-section"> <div class="oss-section">
@@ -74,7 +114,9 @@
<!-- PORTFOLIO ANALYSIS --> <!-- PORTFOLIO ANALYSIS -->
@if (activeTab === 'portfolio') { @if (activeTab === 'portfolio') {
<div class="oss-section"> <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: portfolioAnalysis }"></ng-container>
<ng-container [ngTemplateOutlet]="matrix" [ngTemplateOutletContext]="{ t: portfolioMemo }"></ng-container> <ng-container [ngTemplateOutlet]="matrix" [ngTemplateOutletContext]="{ t: portfolioMemo }"></ng-container>
</div> </div>
@@ -117,7 +159,9 @@
<ng-container [ngTemplateOutlet]="matrix" [ngTemplateOutletContext]="{ t: capitalMemo }"></ng-container> <ng-container [ngTemplateOutlet]="matrix" [ngTemplateOutletContext]="{ t: capitalMemo }"></ng-container>
<div class="oss-tbl-wrap"> <div class="oss-tbl-wrap">
<div class="oss-tbl-h">Table 5: Top Fifty Impaired Credit or more than Rs.100 lakhs &mdash; Rs in Thousands</div> <div class="oss-tbl-h">
Table 5: Top Fifty Impaired Credit or more than Rs.100 lakhs &mdash; Rs in Thousands
</div>
<table class="oss-tbl"> <table class="oss-tbl">
<thead> <thead>
<tr> <tr>
@@ -240,7 +284,8 @@
Sectoral gross advances <strong>&#8377;{{ sectoralGross | number: '1.2-2' }}</strong> and NPAs Sectoral gross advances <strong>&#8377;{{ sectoralGross | number: '1.2-2' }}</strong> and NPAs
<strong>&#8377;{{ sectoralNpa | number: '1.2-2' }}</strong> do not match the closing position reported by <strong>&#8377;{{ sectoralNpa | number: '1.2-2' }}</strong> do not match the closing position reported by
Classification (<strong>&#8377;{{ classificationTotal | number: '1.2-2' }}</strong> / Classification (<strong>&#8377;{{ classificationTotal | number: '1.2-2' }}</strong> /
<strong>&#8377;{{ grossNpa | number: '1.2-2' }}</strong>). <strong>&#8377;{{ grossNpa | number: '1.2-2' }}</strong
>).
@if (sectoralMatchesOpening) { @if (sectoralMatchesOpening) {
They match the <em>opening</em> balances in Movement Parts 1 and 2 instead — this statement appears to 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. have been prepared on the previous quarter's book.
@@ -326,7 +371,10 @@
<ng-template #matrix let-t="t"> <ng-template #matrix let-t="t">
<div class="oss-tbl-wrap"> <div class="oss-tbl-wrap">
<div class="oss-tbl-h"> <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> </div>
@if (t.subtitle) { @if (t.subtitle) {
<div class="oss-tbl-sub">{{ t.subtitle }}</div> <div class="oss-tbl-sub">{{ t.subtitle }}</div>
@@ -335,6 +383,9 @@
<thead> <thead>
<tr> <tr>
<th class="oss-col-label">Particulars</th> <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) { @for (c of t.columns; track $index) {
<th class="ta-r">{{ c }}</th> <th class="ta-r">{{ c }}</th>
} }
@@ -344,12 +395,48 @@
@for (row of t.rows; track $index) { @for (row of t.rows; track $index) {
<tr [class.total-row]="row.total" [class.section-row]="row.header"> <tr [class.total-row]="row.total" [class.section-row]="row.header">
<td [class]="'lvl-' + row.level">{{ row.label }}</td> <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) { @if (row.header) {
<td [attr.colspan]="t.columns.length"></td> <td [attr.colspan]="t.columns.length"></td>
} @else { } @else {
@for (v of row.values; track $index) { @for (v of row.values; track $index) {
<td class="ta-r mono" [class.derived]="row.total"> <td
{{ v === null ? '' : (v | number: '1.2-2') }}{{ v !== null && row.percent ? '%' : '' }} 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> </td>
} }
} }
@@ -12,6 +12,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import * as ExcelJS from 'exceljs'; import * as ExcelJS from 'exceljs';
import { MatrixTable } from '../oss-return.model'; import { MatrixTable } from '../oss-return.model';
import { GlMappingHost } from '../gl-mapping.host';
import { import {
addBanner, addBanner,
addMatrixTable, addMatrixTable,
@@ -77,7 +78,14 @@ type Oss3Tab =
FormsModule 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'; activeTab: Oss3Tab = 'filing';
/** Tab strip definition — keeps the template free of a dozen near-identical buttons. */ /** Tab strip definition — keeps the template free of a dozen near-identical buttons. */
@@ -313,8 +321,7 @@ export class Oss3RaqComponent {
{ width: 16 }, { width: 16 },
{ width: 18 }, { width: 18 },
{ width: 18 }, { width: 18 },
{ width: 18 } { width: 18 }];
];
addBanner(sheet, 'TopImpairedCredits - Top Impaired Credits', 'I'); addBanner(sheet, 'TopImpairedCredits - Top Impaired Credits', 'I');
addMatrixTable(sheet, this.capitalMemo); addMatrixTable(sheet, this.capitalMemo);
for (const t of this.recoveryTables) { for (const t of this.recoveryTables) {
@@ -398,8 +405,7 @@ export class Oss3RaqComponent {
{ width: 18 }, { width: 18 },
{ width: 20 }, { width: 20 },
{ width: 22 }, { width: 22 },
{ width: 20 } { width: 20 }];
];
addBanner(sheet, 'ExposureLimits - Single and Group Borrower Exposure Limit', 'G'); addBanner(sheet, 'ExposureLimits - Single and Group Borrower Exposure Limit', 'G');
addMatrixTable(sheet, this.capitalPreviousMarch); addMatrixTable(sheet, this.capitalPreviousMarch);
+10
View File
@@ -43,6 +43,16 @@ const routes: Routes = [
data: { title: 'Sanctions Logs', breadcrumb: 'Sanctions Logs' }, data: { title: 'Sanctions Logs', breadcrumb: 'Sanctions Logs' },
component: SanctionsLogsComponent 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', path: ':filter',
data: { routeParamBreadcrumb: 'filter' }, data: { routeParamBreadcrumb: 'filter' },
+2
View File
@@ -24,6 +24,7 @@ import {
faCalendar, faCalendar,
faCalendarAlt, faCalendarAlt,
faCaretDown, faCaretDown,
faCaretRight,
faChartBar, faChartBar,
faCheck, faCheck,
faCheckCircle, faCheckCircle,
@@ -261,6 +262,7 @@ export class IconsModule {
faCalendarAlt, faCalendarAlt,
faCalendarCheck, faCalendarCheck,
faCaretDown, faCaretDown,
faCaretRight,
faChartBar, faChartBar,
faCheck, faCheck,
faCheckCircle, faCheckCircle,
+1 -1
View File
@@ -10,7 +10,7 @@
export default { export default {
'mifos_x': { 'mifos_x': {
'version': '260801', 'version': '260801',
'hash': '1c125d97' 'hash': '2a9df828'
}, },
'allow_switching_backend_instance': true 'allow_switching_backend_instance': true
}; };