Compare commits

..
Author SHA1 Message Date
niteshandClaude Opus 5.5 a87cf42c5c feat(ui): compact navigation with grouped mega menus and branded loaders
Navigation bar (shell.component.html/.scss)
- Restyle top-level nav items as compact tabs and add responsive
  breakpoints (1400px, 1200px, 1080px, 880px) that shrink icon, caret
  and padding so the full menu fits on smaller laptop screens.
- Long labels now carry a short form shown on narrow widths:
  Loan Origination -> Loans, General Ledger -> GL, Sub Ledger -> Sub GL,
  Reconciliation -> Recon, Documents Hub -> Docs Hub.
- Reports and Insurance menus are regrouped into titled mega panels
  (reports-mega-panel, insurance-mega-panel) using deposit-menu-group
  sections instead of one long flat list.
- No route was added or removed: all 64 menu links (Account Register,
  Day Book, End of the Day, Form 97/98, Risk Review, Re-KYC, Dormancy,
  Savings/FD Interest Posting, TDS, BOR, DIP lookups, digital payment
  reports, CKYC, DICGC, Clerk Desk, ...) still point to the same routes.

Global mega panel styles (main.scss)
- Shared grid and overlay styling for every Material mega menu
  (cdk-overlay-pane with a menu panel) plus a coloured top accent per
  panel: admin, cashier, clerk, branch-ops, hrm, digital, reports,
  share, others, npa, letter, insurance, ckyc and reconciliation.
- Fixed column layouts for the dense panels (NPA 3 columns,
  Reconciliation 5 columns, Reports, CKYC).

Toolbar (toolbar.component.html/.scss)
- User account dropdown gets its own t-user-menu panel style and opens
  towards the left (xPosition="before") so it no longer clips at the
  screen edge.
- Restyled AI assistant menu cards.

Density and products page
- Material density lowered from 0 to -1 (mifosx-theme.scss) for a
  tighter, more data-dense UI.
- Products page cards: wider container (1280px), smaller icons, fonts,
  gaps and padding to match the compact look.

Loaders (index.html, loading-screen component)
- Boot splash in index.html and the fullscreen mifosx-loading-screen now
  show the CBS Nexora logo image (assets/images/cbsnexora-logo.png)
  instead of the generic four-square SVG and text title.

Lint clean-up required by the pre-commit hook
- Prettier formatting and stylelint auto-fixes (modern rgb() notation,
  range media queries, shorthand properties, empty lines before rules).
- Merged duplicate selectors into their first block without changing
  the cascade: the second .reports-mega-panel (top accent border) in
  main.scss and the second ::ng-deep .ai-menu (AI card styles) in
  toolbar.component.scss.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:55:03 +05:30
niteshandClaude Opus 5.5 3928936b93 feat(ui): animated boot splash and loading screen
Show a Lottie-animated splash while the app bundle boots and dismiss it
once WebAppComponent initialises, plus a reusable mifosx-loading-screen
component with loading and error animations. lottie.min.js is served
from the lottie-web package instead of being vendored into src/assets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 15:34:31 +05:30
niteshandClaude Sonnet 5 799a6930b8 revert(fixed-deposits): remove FD maturity approval UI
Removes the FD Maturity Reports screen, its route and nav entry, and
the maturing-today section on Branch Day Closing — the backend report
and approval gate behind them are gone, and a matured deposit's final
interest already surfaces under FD Interest Posting Reports'
"Posted outside approval".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-09 12:50:24 +05:30
niteshandClaude Sonnet 5 1cebb84906 feat(fixed-deposits): FD Maturity Reports component (missed from prior commit)
The component, service and template for the FD Maturity Reports screen
itself — the previous commit wired up its route, nav entry and the
day-close/product-summary integration, but the new directory was never
staged alongside them.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-08 22:38:56 +05:30
niteshandClaude Sonnet 5 c90528e109 feat(fixed-deposits): maturity approval UI, scoped to deposits with no closure instruction
Adds the FD Maturity Reports screen (due-without-report, generate/
approve, branch and head-office scope, detail with closure instruction
and estimated interest per deposit), a nav entry, the route, and
Branch Day Closing's maturing-today section and approval-pending
warning banner.

Adds "Matured automatically" to the maturity screen: deposits that
settled via their own Transfer-to-Savings or Reinvest instruction,
needing no approval, listed for visibility with product totals and
per-deposit detail.

Adds "Product summary" to the FD Interest Posting Reports screen
(head office): interest by product and scheme — posted through the
report, still pending, and posted directly at maturity instead — added
up to what each product has paid out in total over a period.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-08 19:06:57 +05:30
niteshandClaude Opus 5 79bffaca69 chore(tenants): register the new local "mybank" tenant in the UI tenant list
Added 'mybank' to fineractPlatformTenantIds in all three places the tenant
selector reads it from, so the local database + tenant-store registration just
created (fineract_mybank, identifier "mybank", name "MyBank", copied from
lincpay's local connection settings onto a fresh empty schema) shows up as a
login option:

- src/assets/env.js (window['env'] default, read before Angular bootstraps)
- src/environments/environment.ts (dev build fallback)
- src/environments/environment.prod.ts (prod build fallback)

Each already listed utkarshbank, lincpay, utkarshnagari, testbank, humarwork,
default -- this only appends mybank, nothing else in the list changes.

.env.ts is the auto-generated build stamp (npm run env) and picked up
incidentally alongside; it carries no logic.

Not included in this commit: the fd-interest-posting / fd-maturity-report /
branch-day / shell.component.html changes sitting in the working tree are a
separate, unrelated, in-progress feature and are deliberately left out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-08 18:51:22 +05:30
niteshandClaude Sonnet 5 4acee3e63b feat(fixed-deposits): show the posting period and GL-reconciled maturity interest
Each report line now shows the span its interest covers — "01 Oct to
31 Dec" rather than just the end date — and the report header shows
the product's posting cycle (Monthly, Quarterly, Half-yearly, Yearly).

Adds a "Posted outside approval" view: deposit interest posted without
this report's approval, chiefly a deposit that matured before its own
posting cycle completed. Product-wise totals plus a detail list with
when each posting actually reached the account.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-08 00:07:41 +05:30
niteshandClaude Sonnet 5 cec5223378 feat(fixed-deposits): FD interest posting approval UI
Adds the FD Interest Posting Reports screen (due-without-report,
generate/approve, branch and head-office scope, posting detail,
live preview, head-office summary), a nav entry under Reports, the
route, and Branch Day Closing's fixed-deposit-due section and
approval-pending warning banner that blocks closing the day.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-08 00:07:18 +05:30
niteshandClaude Sonnet 5 d6d7d912c0 fix(savings-product): guard against duplicate submission on create
The product name is unique in the database, so a repeat click on Submit
while the first request is still in flight fails with a duplicate key
error even though the first request already created the product. Disables
the button and ignores repeat calls until the request settles; also drops
a stray debug console.log from the submit payload.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-06 12:39:43 +05:30
nitesh 454988d803 Merge remote-tracking branch 'origin/dev-latest' into dev-latest 2026-10-06 11:01:00 +05:30
niteshandClaude Sonnet 5 17bfe4c7e6 fix(tasks): separate Pending Share Accounts from the Pending Account screen
Removes the Pending Share Accounts section that had been bolted onto the
Pending Account screen (duplicate data/actions alongside the normal-account
table), restoring it as its own standalone queue. The standalone
pending-share-accounts route/component already existed but had no tab link
anywhere in the Approval Center nav, so it was unreachable from the UI -
added a "Pending Share Accounts" tab for it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-06 10:56:25 +05:30
keshav 5ab4e9eaf0 Merge remote-tracking branch 'origin/dev-latest' into dev-latest 2026-10-05 12:41:38 +05:30
keshav 4fc6784695 feat: interest posting 2026-10-05 12:39:14 +05:30
nitesh 174a2e7b6b fix(accounting,deposits): allow future dates in premature close and GL search, fix FD/RD accounting mappings 2026-10-04 16:02:15 +05:30
nitesh 9f7d9d28fd feat: Fixed Deposit - premature close, payment dialog, account actions, translations and env updates 2026-10-04 09:12:06 +05:30
keshav 1ffac4b1e5 fix(savings-product): move savings interest paid to expenses mapping 2026-10-01 14:01:24 +05:30
65 changed files with 11682 additions and 1069 deletions
+7
View File
@@ -31,6 +31,8 @@
"@angular/localize/init"
],
"assets": [
"src/favicon.ico",
"src/apple-touch-icon.png",
"src/assets/images/cbsnexora-favicon.svg",
"src/robots.txt",
"src/manifest.json",
@@ -39,6 +41,11 @@
"glob": "**/*",
"input": "node_modules/tinymce",
"output": "assets/tinymce"
},
{
"glob": "lottie.min.js",
"input": "node_modules/lottie-web/build/player",
"output": "assets/js"
}
],
"styles": [
+7
View File
@@ -41,6 +41,7 @@
"jspdf-autotable": "^5.0.2",
"lightgallery": "^2.9.0",
"lodash": "4.17.23",
"lottie-web": "^5.13.0",
"moment": "^2.29.4",
"ngx-image-cropper": "^9.1.6",
"ngx-mat-select-search": "^8.0.2",
@@ -18380,6 +18381,12 @@
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/lottie-web": {
"version": "5.13.0",
"resolved": "https://registry.npmjs.org/lottie-web/-/lottie-web-5.13.0.tgz",
"integrity": "sha512-+gfBXl6sxXMPe8tKQm7qzLnUy5DUPJPKIyRHwtpCpyUEYjHYRJC/5gjUvdkuO2c3JllrPtHXH5UJJK8LRYl5yQ==",
"license": "MIT"
},
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+1
View File
@@ -71,6 +71,7 @@
"jspdf-autotable": "^5.0.2",
"lightgallery": "^2.9.0",
"lodash": "4.17.23",
"lottie-web": "^5.13.0",
"moment": "^2.29.4",
"ngx-image-cropper": "^9.1.6",
"ngx-mat-select-search": "^8.0.2",
@@ -279,7 +279,7 @@
<div class="gl-fgroup">
<label class="gl-flabel"><fa-icon icon="calendar-alt"></fa-icon> From Date</label>
<mat-form-field appearance="outline" class="gl-ffield">
<input matInput [matDatepicker]="dpFrom" [min]="minDate" [max]="maxDate" [formControl]="transactionDateFrom" />
<input matInput [matDatepicker]="dpFrom" [min]="minDate" [formControl]="transactionDateFrom" />
<mat-datepicker-toggle matSuffix [for]="dpFrom"></mat-datepicker-toggle>
<mat-datepicker #dpFrom></mat-datepicker>
</mat-form-field>
@@ -288,7 +288,7 @@
<div class="gl-fgroup">
<label class="gl-flabel"><fa-icon icon="calendar-alt"></fa-icon> To Date</label>
<mat-form-field appearance="outline" class="gl-ffield">
<input matInput [matDatepicker]="dpTo" [min]="minDate" [max]="maxDate" [formControl]="transactionDateTo" />
<input matInput [matDatepicker]="dpTo" [min]="minDate" [formControl]="transactionDateTo" />
<mat-datepicker-toggle matSuffix [for]="dpTo"></mat-datepicker-toggle>
<mat-datepicker #dpTo></mat-datepicker>
</mat-form-field>
@@ -353,13 +353,7 @@
<div class="gl-fgroup">
<label class="gl-flabel"><fa-icon icon="calendar-check"></fa-icon> Submitted From</label>
<mat-form-field appearance="outline" class="gl-ffield">
<input
matInput
[matDatepicker]="dpSubFrom"
[min]="minDate"
[max]="maxDate"
[formControl]="submittedOnDateFrom"
/>
<input matInput [matDatepicker]="dpSubFrom" [min]="minDate" [formControl]="submittedOnDateFrom" />
<mat-datepicker-toggle matSuffix [for]="dpSubFrom"></mat-datepicker-toggle>
<mat-datepicker #dpSubFrom></mat-datepicker>
</mat-form-field>
@@ -368,7 +362,7 @@
<div class="gl-fgroup">
<label class="gl-flabel"><fa-icon icon="calendar-check"></fa-icon> Submitted To</label>
<mat-form-field appearance="outline" class="gl-ffield">
<input matInput [matDatepicker]="dpSubTo" [min]="minDate" [max]="maxDate" [formControl]="submittedOnDateTo" />
<input matInput [matDatepicker]="dpSubTo" [min]="minDate" [formControl]="submittedOnDateTo" />
<mat-datepicker-toggle matSuffix [for]="dpSubTo"></mat-datepicker-toggle>
<mat-datepicker #dpSubTo></mat-datepicker>
</mat-form-field>
@@ -90,7 +90,7 @@ export class SearchJournalEntryComponent implements OnInit, OnDestroy, AfterView
// ── Misc ──────────────────────────────────────────────────────────────────
minDate = new Date(2000, 0, 1);
maxDate = new Date();
maxDate: Date = null;
/** Accounting features switched off for this installation — see disabled-features.ts. */
disabledFeatures = DISABLED_ACCOUNTING_FEATURES;
@@ -208,7 +208,7 @@ export class SearchJournalEntryComponent implements OnInit, OnDestroy, AfterView
}
ngOnInit() {
this.maxDate = this.settingsService.businessDate;
this.maxDate = null;
this.paginator.pageSize = 10;
this.restoreState();
this.setFilteredOffices();
+2
View File
@@ -96,6 +96,7 @@ import { TokenInterceptor as ZitadelTokenInterceptor } from './zitadel/token.int
import { AuthService } from './zitadel/auth.service';
import { environment } from '../environments/environment';
import { CallbackComponent } from './zitadel/callback/callback.component';
import { LoadingScreenComponent } from './shared/loading-screen/loading-screen.component';
import { OAuthModule } from 'angular-oauth2-oidc';
export class CustomMissingTranslationHandler implements MissingTranslationHandler {
@@ -195,6 +196,7 @@ export function HttpLoaderFactory(http: HttpClient) {
AppRoutingModule,
NotFoundComponent,
CallbackComponent,
LoadingScreenComponent,
OAuthModule.forRoot()
],
providers: [
@@ -352,6 +352,51 @@
</div>
}
<!-- Interest waiting to be posted as this branch closes. Posting runs as part of the close, so
anything unapproved stops it: the branch sees here what it has to approve and where, rather
than finding out from a refusal after filling in the whole count. -->
@if (preview?.fdInterestDue?.length) {
<div class="bd-calc bd-calc--fd">
<div class="bd-calc__head">
<mat-icon>savings</mat-icon>
<strong>Fixed deposit interest due today</strong>
</div>
@for (d of preview.fdInterestDue; track d.productId) {
<div class="bd-calc__row">
<span>
{{ d.productName }} · {{ d.fdType }} · {{ d.accountCount }} deposit(s)
@if (d.reportStatus === 'APPROVED' || d.reportStatus === 'POSTED') {
<span class="bd-fd-ok">{{ d.reportStatus }}</span>
} @else {
<span class="bd-fd-pending">{{ d.reportStatus === 'NONE' ? 'NO REPORT' : d.reportStatus }}</span>
}
</span>
<span class="bd-calc__amt">₹{{ d.totalInterest | number: '1.2-2' }}</span>
</div>
}
</div>
}
@if (preview?.fdInterestPending?.length) {
<div class="bd-warn">
<mat-icon>warning</mat-icon>
<div>
<strong>The day cannot be closed yet.</strong> Fixed deposit interest is not approved for:
{{ preview.fdInterestPending.join(', ') }}. Approve it under Reports → FD Interest Posting Reports, then
close the day. The user who generated a report cannot approve it.
</div>
</div>
}
@if (preview?.savingsInterestPending?.length) {
<div class="bd-warn">
<mat-icon>warning</mat-icon>
<div>
<strong>The day cannot be closed yet.</strong> Savings interest is not approved for:
{{ preview.savingsInterestPending.join(', ') }}. Approve it under Reports → Interest Posting Reports, then
close the day.
</div>
</div>
}
<!-- What every cash point under this branch is actually holding right now, fetched
automatically — so closing the day doesn't mean separately checking Vault Position and
every cashier's own Cashier Position screen by hand. -->
@@ -195,6 +195,75 @@
font-size: 11px;
color: #94a3b8;
}
/* Names the block, for the interest panels that sit beside the cash arithmetic. */
&__head {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
padding-bottom: 6px;
border-bottom: 1px solid #e2e8f0;
font-size: 13px;
color: #0d1b2a;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
color: #1d4ed8;
}
}
}
/* Interest is money leaving as the day closes, not cash counted in the drawer. */
.bd-calc--fd {
border-left: 3px solid #1d4ed8;
}
.bd-fd-ok,
.bd-fd-pending {
display: inline-block;
margin-left: 6px;
padding: 1px 7px;
border-radius: 999px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.02em;
}
.bd-fd-ok {
background: #dcfce7;
color: #15803d;
}
.bd-fd-pending {
background: #fef3c7;
color: #b45309;
}
/* Why the close will be refused, said before the count is filled in rather than after. */
.bd-warn {
display: flex;
align-items: flex-start;
gap: 8px;
max-width: 640px;
margin-bottom: 14px;
padding: 10px 14px;
background: #fffbeb;
border: 1px solid #fcd34d;
border-radius: 8px;
font-size: 12px;
line-height: 1.5;
color: #78350f;
mat-icon {
flex-shrink: 0;
font-size: 20px;
width: 20px;
height: 20px;
color: #b45309;
}
}
.bd-hint {
+9 -1
View File
@@ -36,7 +36,15 @@ export class CacheInterceptor implements HttpInterceptor {
'/rbireserveconfig',
'/tdsconfig',
'/tds/deposit-interest',
'/savingsinterestpostingreports'
'/savingsinterestpostingreports',
// Cash in vaults and drawers changes with every transaction; a cached teller list or position
// kept showing a branch's old tellers and figures at day close.
'/tellers',
'/cashreports',
'/cashreconciliation',
'/cashsettlement',
'/cashtransfer',
'/branchday'
];
private isCacheable(url: string): boolean {
+369 -275
View File
@@ -503,7 +503,8 @@
[matMenuTriggerFor]="loanOriginationMenu"
*mifosxHasPermission="'MENU_LOAN_ORIGINATION'"
>
<fa-icon icon="clipboard-check" class="ni-ico"></fa-icon>Loan Origination
<fa-icon icon="clipboard-check" class="ni-ico"></fa-icon><span class="ni-full">Loan Origination</span
><span class="ni-short">Loans</span>
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a>
<a
@@ -512,7 +513,8 @@
(click)="glMenuOpen = !glMenuOpen; subLedgerOpen = false"
*mifosxHasPermission="'MENU_GENERAL_LEDGER'"
>
<fa-icon icon="book-open" class="ni-ico"></fa-icon>General Ledger
<fa-icon icon="book-open" class="ni-ico"></fa-icon><span class="ni-full">General Ledger</span
><span class="ni-short">GL</span>
<fa-icon [icon]="glMenuOpen ? 'caret-up' : 'caret-down'" class="ni-caret"></fa-icon>
</a>
<a
@@ -521,7 +523,8 @@
(click)="subLedgerOpen = !subLedgerOpen; glMenuOpen = false"
*mifosxHasPermission="'MENU_SUB_LEDGER'"
>
<fa-icon icon="book" class="ni-ico"></fa-icon>Sub Ledger
<fa-icon icon="book" class="ni-ico"></fa-icon><span class="ni-full">Sub Ledger</span
><span class="ni-short">Sub GL</span>
<fa-icon [icon]="subLedgerOpen ? 'caret-up' : 'caret-down'" class="ni-caret"></fa-icon>
</a>
</div>
@@ -583,14 +586,16 @@
[matMenuTriggerFor]="reconciliationMenu"
*mifosxHasPermission="'MENU_CONFIG_WIZARD'"
>
<fa-icon icon="sync-alt" class="ni-ico"></fa-icon>Reconciliation
<fa-icon icon="sync-alt" class="ni-ico"></fa-icon><span class="ni-full">Reconciliation</span
><span class="ni-short">Recon</span>
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a>
<a class="ni" tabindex="0" routerLink="/chequebook" *mifosxHasPermission="'MENU_CHEQUEBOOK'">
<fa-icon icon="book" class="ni-ico"></fa-icon>Cheque Book
</a>
<a class="ni" tabindex="0" [matMenuTriggerFor]="letterMgmtMenu" *mifosxHasPermission="'MENU_LETTER_MGMT'">
<fa-icon icon="envelope" class="ni-ico"></fa-icon>Documents Hub
<fa-icon icon="envelope" class="ni-ico"></fa-icon><span class="ni-full">Documents Hub</span
><span class="ni-short">Docs Hub</span>
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a>
</div>
@@ -617,77 +622,89 @@
</button>
</mat-menu>
<mat-menu #reportsMenu="matMenu" [overlapTrigger]="false" class="nav-dropdown">
<button mat-menu-item [routerLink]="['/branch-ops/account-register']" tabindex="0">
<i class="fa fa-book menu-item-icon"></i> Account Register
</button>
<button mat-menu-item [routerLink]="['/reports']" tabindex="0">
<i class="fa fa-th-list menu-item-icon"></i> All Reports
</button>
<button mat-menu-item [routerLink]="['/reports', 'Client']" tabindex="0">
<i class="fa fa-user menu-item-icon"></i> Clients
</button>
<button mat-menu-item [routerLink]="['/reports', 'Loan']" tabindex="0">
<i class="fa fa-credit-card menu-item-icon"></i> Loans
</button>
<button mat-menu-item [routerLink]="['/reports', 'Savings']" tabindex="0">
<i class="fa fa-piggy-bank menu-item-icon"></i> Savings
</button>
<button mat-menu-item [routerLink]="['/reports', 'Fund']" tabindex="0">
<i class="fa fa-hand-holding-usd menu-item-icon"></i> Funds
</button>
<button mat-menu-item [routerLink]="['/reports', 'Accounting']" tabindex="0">
<i class="fa fa-calculator menu-item-icon"></i> Accounting
</button>
<mat-divider></mat-divider>
<!-- Listed here as well as under Branch Ops: an officer looking for the day's figures reaches
for Reports, and the same screen answers both. -->
<button mat-menu-item routerLink="/branch-ops/day-book" tabindex="0">
<i class="fa fa-book menu-item-icon"></i> Day Book &amp; Officer Verification
</button>
<button mat-menu-item routerLink="/branch-ops/end-of-day" tabindex="0">
<i class="fa fa-clipboard-check menu-item-icon"></i> End of the Day Report
</button>
<button mat-menu-item routerLink="/reports/form-97-98" tabindex="0">
<i class="fa fa-file-invoice menu-item-icon"></i> Form 97/98 Transaction Report
</button>
<button mat-menu-item routerLink="/reports/risk-review" tabindex="0">
<i class="fa fa-user-shield menu-item-icon"></i> Risk Review Report
</button>
<button mat-menu-item routerLink="/reports/kyc-expiry" tabindex="0">
<i class="fa fa-id-card menu-item-icon"></i> Re-KYC Expiry &amp; Outreach
</button>
<button mat-menu-item routerLink="/reports/dormancy" tabindex="0">
<i class="fa fa-hourglass-half menu-item-icon"></i> Inactive, Dormant &amp; DEAF
</button>
<button mat-menu-item routerLink="/reports/savings-interest" tabindex="0">
<i class="fa fa-percentage menu-item-icon"></i> Savings Interest Posted
</button>
<button mat-menu-item routerLink="/reports/interest-posting" tabindex="0">
<i class="fa fa-clipboard-check menu-item-icon"></i> Interest Posting Reports
</button>
<button mat-menu-item routerLink="/reports/interest-posting-summary" tabindex="0">
<i class="fa fa-table menu-item-icon"></i> Interest Posting Summary (Branch-wise)
</button>
<!--
The Principal Officer clears flagged UNSC screenings here. It is not a runnable report,
so it never shows up under the report categories above and had no nav entry at all.
-->
<button mat-menu-item routerLink="/reports/sanctions-logs" tabindex="0">
<i class="fa fa-shield-alt menu-item-icon"></i> Sanctions Screening &amp; Alerts
</button>
<button mat-menu-item routerLink="/reports/dip-lookups" tabindex="0">
<i class="fa fa-satellite-dish menu-item-icon"></i> DIP / MNRL Mobile Lookups
</button>
<button mat-menu-item routerLink="/reports/tds-interest" tabindex="0">
<i class="fa fa-percent menu-item-icon"></i> Deposit Interest &amp; TDS
</button>
<button mat-menu-item routerLink="/reports/tds-remittance" tabindex="0">
<i class="fa fa-landmark menu-item-icon"></i> TDS Remitted to Government
</button>
<button mat-menu-item routerLink="/reports/bor" tabindex="0">
<i class="fa fa-gavel menu-item-icon"></i> BOR Register &mdash; Share Capital
</button>
<mat-menu #reportsMenu="matMenu" class="reports-mega-panel" [overlapTrigger]="false">
<div class="deposit-menu-grid reports-grid" (click)="$event.stopPropagation()">
<!-- Core Reports -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Core Reports</div>
<button mat-menu-item [routerLink]="['/branch-ops/account-register']" tabindex="0">
<i class="fa fa-book menu-item-icon"></i> Account Register
</button>
<button mat-menu-item [routerLink]="['/reports']" tabindex="0">
<i class="fa fa-th-list menu-item-icon"></i> All Reports
</button>
<button mat-menu-item [routerLink]="['/reports', 'Client']" tabindex="0">
<i class="fa fa-user menu-item-icon"></i> Clients
</button>
<button mat-menu-item [routerLink]="['/reports', 'Loan']" tabindex="0">
<i class="fa fa-credit-card menu-item-icon"></i> Loans
</button>
<button mat-menu-item [routerLink]="['/reports', 'Savings']" tabindex="0">
<i class="fa fa-piggy-bank menu-item-icon"></i> Savings
</button>
<button mat-menu-item [routerLink]="['/reports', 'Fund']" tabindex="0">
<i class="fa fa-hand-holding-usd menu-item-icon"></i> Funds
</button>
<button mat-menu-item [routerLink]="['/reports', 'Accounting']" tabindex="0">
<i class="fa fa-calculator menu-item-icon"></i> Accounting
</button>
</div>
<!-- Operations & Interest -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Operations &amp; Interest</div>
<button mat-menu-item routerLink="/branch-ops/day-book" tabindex="0">
<i class="fa fa-book menu-item-icon"></i> Day Book &amp; Officer Verification
</button>
<button mat-menu-item routerLink="/branch-ops/end-of-day" tabindex="0">
<i class="fa fa-clipboard-check menu-item-icon"></i> End of the Day Report
</button>
<button mat-menu-item routerLink="/reports/savings-interest" tabindex="0">
<i class="fa fa-percentage menu-item-icon"></i> Savings Interest Posted
</button>
<button mat-menu-item routerLink="/reports/interest-posting" tabindex="0">
<i class="fa fa-clipboard-check menu-item-icon"></i> Interest Posting Reports
</button>
<button mat-menu-item routerLink="/reports/fd-interest-posting" tabindex="0">
<i class="fa fa-clipboard-check menu-item-icon"></i> FD Interest Posting Reports
</button>
<button mat-menu-item routerLink="/reports/interest-posting-summary" tabindex="0">
<i class="fa fa-table menu-item-icon"></i> Interest Posting Summary (Branch-wise)
</button>
<button mat-menu-item routerLink="/reports/tds-interest" tabindex="0">
<i class="fa fa-percent menu-item-icon"></i> Deposit Interest &amp; TDS
</button>
</div>
<!-- Compliance & Registers -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Compliance &amp; Registers</div>
<button mat-menu-item routerLink="/reports/form-97-98" tabindex="0">
<i class="fa fa-file-invoice menu-item-icon"></i> Form 97/98 Transaction Report
</button>
<button mat-menu-item routerLink="/reports/risk-review" tabindex="0">
<i class="fa fa-user-shield menu-item-icon"></i> Risk Review Report
</button>
<button mat-menu-item routerLink="/reports/kyc-expiry" tabindex="0">
<i class="fa fa-id-card menu-item-icon"></i> Re-KYC Expiry &amp; Outreach
</button>
<button mat-menu-item routerLink="/reports/dormancy" tabindex="0">
<i class="fa fa-hourglass-half menu-item-icon"></i> Inactive, Dormant &amp; DEAF
</button>
<button mat-menu-item routerLink="/reports/sanctions-logs" tabindex="0">
<i class="fa fa-shield-alt menu-item-icon"></i> Sanctions Screening &amp; Alerts
</button>
<button mat-menu-item routerLink="/reports/dip-lookups" tabindex="0">
<i class="fa fa-satellite-dish menu-item-icon"></i> DIP / MNRL Mobile Lookups
</button>
<button mat-menu-item routerLink="/reports/tds-remittance" tabindex="0">
<i class="fa fa-landmark menu-item-icon"></i> TDS Remitted to Government
</button>
<button mat-menu-item routerLink="/reports/bor" tabindex="0">
<i class="fa fa-gavel menu-item-icon"></i> BOR Register &mdash; Share Capital
</button>
</div>
</div>
</mat-menu>
<!-- Admin Mega Menu — 3-column rectangular panel grouping Setup,
@@ -1060,121 +1077,146 @@
<!-- Sub Menu Dropdowns -->
<!-- Branch-level operations. Starts with the day register; other branch-wide controls belong
here rather than under Cashier, which is one role's workspace. -->
<mat-menu #branchOpsMenu="matMenu" [overlapTrigger]="false" class="nav-dropdown">
<button mat-menu-item tabindex="0" [routerLink]="['/branch-ops/day-register']">
<fa-icon icon="calendar-check" class="menu-item-icon"></fa-icon> Day Open / Day Close
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/branch-ops/missed-close']">
<fa-icon icon="exclamation-triangle" class="menu-item-icon"></fa-icon> Missed Close Register
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/branch-ops/vault-position']">
<fa-icon icon="lock" class="menu-item-icon"></fa-icon> Vault Position
</button>
<!-- The day book carries the Officer Verification Sheet — each person's credit, debit and net
for the day. It was reachable only from the Clerk and Cashier menus, which is the one
place a principal officer signing the day off does not look. -->
<button mat-menu-item tabindex="0" [routerLink]="['/branch-ops/day-book']">
<fa-icon icon="book" class="menu-item-icon"></fa-icon> Day Book &amp; Verification
</button>
<!-- The signed sheet, as against the day book's working list: the day's modes, the drawer
against the books, and what is still open. This is what goes in the file. -->
<button mat-menu-item tabindex="0" [routerLink]="['/branch-ops/end-of-day']">
<fa-icon icon="clipboard-check" class="menu-item-icon"></fa-icon> End of the Day Report
</button>
<mat-menu #branchOpsMenu="matMenu" class="branch-ops-mega-panel" [overlapTrigger]="false">
<div class="deposit-menu-grid branch-ops-grid" (click)="$event.stopPropagation()">
<div class="deposit-menu-group">
<div class="deposit-group-title">Day Operations</div>
<button mat-menu-item tabindex="0" [routerLink]="['/branch-ops/day-register']">
<fa-icon icon="calendar-check" class="menu-item-icon"></fa-icon> Day Open / Day Close
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/branch-ops/missed-close']">
<fa-icon icon="exclamation-triangle" class="menu-item-icon"></fa-icon> Missed Close Register
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/branch-ops/vault-position']">
<fa-icon icon="lock" class="menu-item-icon"></fa-icon> Vault Position
</button>
</div>
<div class="deposit-menu-group">
<div class="deposit-group-title">Verification &amp; Reports</div>
<button mat-menu-item tabindex="0" [routerLink]="['/branch-ops/day-book']">
<fa-icon icon="book" class="menu-item-icon"></fa-icon> Day Book &amp; Verification
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/branch-ops/end-of-day']">
<fa-icon icon="clipboard-check" class="menu-item-icon"></fa-icon> End of the Day Report
</button>
</div>
</div>
</mat-menu>
<!-- Clerk was a bare link straight into the fullscreen dashboard, which left the clerk's other
screens reachable only from inside that fullscreen nav. Anything a clerk needs from the
normal shell — an open voucher exception above all — belongs here. -->
<mat-menu #clerkMenu="matMenu" [overlapTrigger]="false" class="nav-dropdown">
<button mat-menu-item tabindex="0" routerLink="/clerk/dashboard" (click)="hideShellLayout = true">
<fa-icon icon="id-card" class="menu-item-icon"></fa-icon> Clerk Dashboard
</button>
<!-- The clerk reads the day as a sheet to sign off, not as a working list of vouchers: the
day's summary, the counter counted against the books, and what is still open. The day
book's own row-by-row view stays reachable from Branch Ops and from the Cashier menu. -->
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/end-of-day']">
<fa-icon icon="clipboard-check" class="menu-item-icon"></fa-icon> End of the Day Report
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/withdrawal-requests']">
<fa-icon icon="money-bill-wave" class="menu-item-icon"></fa-icon> My Withdrawal Requests
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/my-disbursement-requests']">
<fa-icon icon="exchange" class="menu-item-icon"></fa-icon> Disbursements &amp; Transfer
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/dividend-dashboard']">
<fa-icon icon="chart-pie" class="menu-item-icon"></fa-icon> Dividend Dashboard
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/my-work']">
<fa-icon icon="user-check" class="menu-item-icon"></fa-icon> My Vouchers
</button>
<!-- Sits under My Vouchers because it answers the same question for the one worker who has no
own list: the system. A nightly run belongs to nobody, so it appears on no person's page. -->
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/system-vouchers']">
<fa-icon icon="robot" class="menu-item-icon"></fa-icon> System Vouchers
</button>
<!-- A clerk supervises the counter, so they need to see what each cashier is holding and what
went through their drawer. Both screens already return every cashier for a non-cashier
caller (CashierRoleHelper scopes only a cashier to themselves) — they were simply not
reachable from this menu. -->
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cashier-position']">
<fa-icon icon="wallet" class="menu-item-icon"></fa-icon> Cashier Position
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/closing-report']">
<fa-icon icon="file-invoice" class="menu-item-icon"></fa-icon> Closing Cash Report
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/received-report']">
<fa-icon icon="receipt" class="menu-item-icon"></fa-icon> Received Vouchers
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/payment-report']">
<fa-icon icon="receipt" class="menu-item-icon"></fa-icon> Payment Vouchers
</button>
<!-- Clerk Desk -->
<mat-menu #clerkMenu="matMenu" class="clerk-mega-panel" [overlapTrigger]="false">
<div class="deposit-menu-grid clerk-grid" (click)="$event.stopPropagation()">
<!-- Clerk Desk -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Clerk Desk</div>
<button mat-menu-item tabindex="0" routerLink="/clerk/dashboard" (click)="hideShellLayout = true">
<fa-icon icon="id-card" class="menu-item-icon"></fa-icon> Clerk Dashboard
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/withdrawal-requests']">
<fa-icon icon="money-bill-wave" class="menu-item-icon"></fa-icon> My Withdrawal Requests
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/my-disbursement-requests']">
<fa-icon icon="exchange" class="menu-item-icon"></fa-icon> Disbursements &amp; Transfer
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/dividend-dashboard']">
<fa-icon icon="chart-pie" class="menu-item-icon"></fa-icon> Dividend Dashboard
</button>
</div>
<!-- Vouchers -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Vouchers</div>
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/my-work']">
<fa-icon icon="user-check" class="menu-item-icon"></fa-icon> My Vouchers
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/system-vouchers']">
<fa-icon icon="robot" class="menu-item-icon"></fa-icon> System Vouchers
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/received-report']">
<fa-icon icon="receipt" class="menu-item-icon"></fa-icon> Received Vouchers
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/payment-report']">
<fa-icon icon="receipt" class="menu-item-icon"></fa-icon> Payment Vouchers
</button>
</div>
<!-- Counter & Reports -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Counter &amp; Reports</div>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cashier-position']">
<fa-icon icon="wallet" class="menu-item-icon"></fa-icon> Cashier Position
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/closing-report']">
<fa-icon icon="file-invoice" class="menu-item-icon"></fa-icon> Closing Cash Report
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/end-of-day']">
<fa-icon icon="clipboard-check" class="menu-item-icon"></fa-icon> End of the Day Report
</button>
</div>
</div>
</mat-menu>
<mat-menu #cashierMenu="matMenu" [overlapTrigger]="false" class="nav-dropdown">
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cash-exchange']">
<fa-icon icon="exchange" class="menu-item-icon"></fa-icon> Cash Exchange Entry
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/system/denomination-master']">
<fa-icon icon="coins" class="menu-item-icon"></fa-icon> Cash Denomination
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/closing-report']">
<fa-icon icon="file-alt" class="menu-item-icon"></fa-icon> Closing Cash Report
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/exchange-detail']">
<fa-icon icon="globe" class="menu-item-icon"></fa-icon> Currency Exchange Detail
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cash-transfer']">
<fa-icon icon="random" class="menu-item-icon"></fa-icon> Cash Transfer
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/excess-report']">
<fa-icon icon="exclamation-circle" class="menu-item-icon"></fa-icon> Excess Transaction Report
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cash-settlement']">
<fa-icon icon="check-circle" class="menu-item-icon"></fa-icon> Cash Settlement
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/inter-cashier']">
<fa-icon icon="exchange-alt" class="menu-item-icon"></fa-icon> Inter-Cashier Transfer
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cashier-position']">
<fa-icon icon="wallet" class="menu-item-icon"></fa-icon> Cashier Position
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/reconciliation']">
<fa-icon icon="balance-scale" class="menu-item-icon"></fa-icon> Reconciliation
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cash-history']">
<fa-icon icon="history" class="menu-item-icon"></fa-icon> Cash History
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/day-book']">
<fa-icon icon="book" class="menu-item-icon"></fa-icon> Day Book
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/transaction-entry']">
<fa-icon icon="keyboard" class="menu-item-icon"></fa-icon> Transaction Entry
</button>
<!-- Cashier Desk -->
<mat-menu #cashierMenu="matMenu" class="cashier-mega-panel" [overlapTrigger]="false">
<div class="deposit-menu-grid cashier-grid" (click)="$event.stopPropagation()">
<!-- Cash Entries & Exchange -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Cash &amp; Counter</div>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cash-exchange']">
<fa-icon icon="exchange" class="menu-item-icon"></fa-icon> Cash Exchange Entry
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/exchange-detail']">
<fa-icon icon="globe" class="menu-item-icon"></fa-icon> Currency Exchange Detail
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/transaction-entry']">
<fa-icon icon="keyboard" class="menu-item-icon"></fa-icon> Transaction Entry
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/system/denomination-master']">
<fa-icon icon="coins" class="menu-item-icon"></fa-icon> Cash Denomination
</button>
</div>
<!-- Transfers & Settlement -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Transfers &amp; Settlement</div>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cash-transfer']">
<fa-icon icon="random" class="menu-item-icon"></fa-icon> Cash Transfer
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/inter-cashier']">
<fa-icon icon="exchange-alt" class="menu-item-icon"></fa-icon> Inter-Cashier Transfer
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cash-settlement']">
<fa-icon icon="check-circle" class="menu-item-icon"></fa-icon> Cash Settlement
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cashier-position']">
<fa-icon icon="wallet" class="menu-item-icon"></fa-icon> Cashier Position
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/day-book']">
<fa-icon icon="book" class="menu-item-icon"></fa-icon> Day Book
</button>
</div>
<!-- Reports & Audit -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Reports &amp; Audit</div>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/closing-report']">
<fa-icon icon="file-alt" class="menu-item-icon"></fa-icon> Closing Cash Report
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/excess-report']">
<fa-icon icon="exclamation-circle" class="menu-item-icon"></fa-icon> Excess Transaction Report
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/reconciliation']">
<fa-icon icon="balance-scale" class="menu-item-icon"></fa-icon> Reconciliation
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cash-history']">
<fa-icon icon="history" class="menu-item-icon"></fa-icon> Cash History
</button>
</div>
</div>
</mat-menu>
<mat-menu #depositsMenu="matMenu" class="deposits-mega-panel" [overlapTrigger]="false">
<div class="deposit-menu-grid" (click)="$event.stopPropagation()">
<div class="deposit-menu-grid deposits-grid" (click)="$event.stopPropagation()">
<div class="deposit-menu-group">
<div class="deposit-group-title">Accounts</div>
<button mat-menu-item tabindex="0" [routerLink]="['/accounts/savings']">
@@ -1236,34 +1278,54 @@
</div>
</mat-menu>
<mat-menu #clearingMenu="matMenu" [overlapTrigger]="false" class="nav-dropdown">
<button mat-menu-item tabindex="0" [routerLink]="['/ctsclearing/inward']">
<fa-icon icon="arrow-left" class="menu-item-icon"></fa-icon> Inward Clearing (CTS)
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/ctsclearing/outward']">
<fa-icon icon="arrow-right" class="menu-item-icon"></fa-icon> Outward Clearing (CTS)
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/ctsclearing/positive-pay']">
<fa-icon icon="check-circle" class="menu-item-icon"></fa-icon> Positive Pay (PPS)
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/ctsclearing/settlement']">
<fa-icon icon="check-circle" class="menu-item-icon"></fa-icon> Settlement
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/ctsclearing/reconciliation']">
<fa-icon icon="check-circle" class="menu-item-icon"></fa-icon> Reconciliation
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/nachclearing']">
<fa-icon icon="money-check" class="menu-item-icon"></fa-icon> NACH Clearing
</button>
<button mat-menu-item tabindex="0"><fa-icon icon="arrow-right" class="menu-item-icon"></fa-icon> Outward</button>
<button mat-menu-item tabindex="0"><fa-icon icon="arrow-left" class="menu-item-icon"></fa-icon> Inward</button>
<button mat-menu-item [routerLink]="['/sponsor-dd/inventory']" tabindex="0">
<fa-icon icon="receipt" class="menu-item-icon"></fa-icon> Sponsor DD Inventory
</button>
<mat-menu #clearingMenu="matMenu" class="clearing-mega-panel" [overlapTrigger]="false">
<div class="deposit-menu-grid clearing-grid" (click)="$event.stopPropagation()">
<!-- CTS Clearing -->
<div class="deposit-menu-group">
<div class="deposit-group-title">CTS Clearing</div>
<button mat-menu-item tabindex="0" [routerLink]="['/ctsclearing/inward']">
<fa-icon icon="arrow-left" class="menu-item-icon"></fa-icon> Inward Clearing (CTS)
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/ctsclearing/outward']">
<fa-icon icon="arrow-right" class="menu-item-icon"></fa-icon> Outward Clearing (CTS)
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/ctsclearing/positive-pay']">
<fa-icon icon="check-circle" class="menu-item-icon"></fa-icon> Positive Pay (PPS)
</button>
</div>
<!-- Settlement & Recon -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Settlement &amp; Recon</div>
<button mat-menu-item tabindex="0" [routerLink]="['/ctsclearing/settlement']">
<fa-icon icon="check-circle" class="menu-item-icon"></fa-icon> Settlement
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/ctsclearing/reconciliation']">
<fa-icon icon="check-circle" class="menu-item-icon"></fa-icon> Reconciliation
</button>
<button mat-menu-item [routerLink]="['/sponsor-dd/inventory']" tabindex="0">
<fa-icon icon="receipt" class="menu-item-icon"></fa-icon> Sponsor DD Inventory
</button>
</div>
<!-- NACH & Other -->
<div class="deposit-menu-group">
<div class="deposit-group-title">NACH &amp; Direct</div>
<button mat-menu-item tabindex="0" [routerLink]="['/nachclearing']">
<fa-icon icon="money-check" class="menu-item-icon"></fa-icon> NACH Clearing
</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="arrow-right" class="menu-item-icon"></fa-icon> Outward
</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="arrow-left" class="menu-item-icon"></fa-icon> Inward
</button>
</div>
</div>
</mat-menu>
<mat-menu #hrmMenu="matMenu" class="hrm-mega-panel" [overlapTrigger]="false">
<div class="deposit-menu-grid" (click)="$event.stopPropagation()">
<div class="deposit-menu-grid hrm-grid" (click)="$event.stopPropagation()">
<!-- Payroll -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Payroll</div>
@@ -1570,23 +1632,33 @@
</div>
</mat-menu>
<mat-menu #ckycMenu="matMenu" [overlapTrigger]="false" class="nav-dropdown">
<button mat-menu-item routerLink="/ckyc" tabindex="0">
<fa-icon icon="id-card" class="menu-item-icon"></fa-icon> CKYC Management
</button>
<button mat-menu-item tabindex="0" (click)="openCkycReportsDialog('REKYC')">
<fa-icon icon="sync-alt" class="menu-item-icon" style="color: #be185d"></fa-icon> Re-KYC Report
</button>
<button mat-menu-item tabindex="0" (click)="openCkycReportsDialog('RISK-REVIEW')">
<fa-icon icon="history" class="menu-item-icon" style="color: #be185d"></fa-icon> Risk Review
</button>
<button mat-menu-item tabindex="0" (click)="openCkycReportsDialog('CERSAI')">
<fa-icon icon="shield-alt" class="menu-item-icon" style="color: #be185d"></fa-icon> CERSAI Report
</button>
<mat-divider></mat-divider>
<button mat-menu-item tabindex="0">
<fa-icon icon="database" class="menu-item-icon" style="color: #be185d"></fa-icon> Dump KYC &amp; Loan Data
</button>
<mat-menu #ckycMenu="matMenu" class="ckyc-mega-panel" [overlapTrigger]="false">
<div class="deposit-menu-grid ckyc-grid" (click)="$event.stopPropagation()">
<!-- KYC Operations -->
<div class="deposit-menu-group">
<div class="deposit-group-title">KYC Operations</div>
<button mat-menu-item routerLink="/ckyc" tabindex="0">
<fa-icon icon="id-card" class="menu-item-icon"></fa-icon> CKYC Management
</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="database" class="menu-item-icon" style="color: #be185d"></fa-icon> Dump KYC &amp; Loan Data
</button>
</div>
<!-- Reports & Screening -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Reports &amp; Screening</div>
<button mat-menu-item tabindex="0" (click)="openCkycReportsDialog('REKYC')">
<fa-icon icon="sync-alt" class="menu-item-icon" style="color: #be185d"></fa-icon> Re-KYC Report
</button>
<button mat-menu-item tabindex="0" (click)="openCkycReportsDialog('RISK-REVIEW')">
<fa-icon icon="history" class="menu-item-icon" style="color: #be185d"></fa-icon> Risk Review
</button>
<button mat-menu-item tabindex="0" (click)="openCkycReportsDialog('CERSAI')">
<fa-icon icon="shield-alt" class="menu-item-icon" style="color: #be185d"></fa-icon> CERSAI Report
</button>
</div>
</div>
</mat-menu>
<!-- CFCFRMS — single landing page (CfcfrmsShell) hosts the full
@@ -1604,11 +1676,11 @@
</button>
<button mat-menu-item tabindex="0" routerLink="/reconciliation/sundry-dr-cr">
<fa-icon icon="random" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Sundry Dr / Cr Reconciliation
Sundry Dr / Cr
</button>
<button mat-menu-item tabindex="0" routerLink="/reconciliation/pnl-impact">
<fa-icon icon="chart-line" class="menu-item-icon" style="color: #0d9488"></fa-icon>
P&amp;L Impact Reconciliation
P&amp;L Impact
</button>
<button mat-menu-item tabindex="0" routerLink="/reconciliation/gl-reconciliation">
<fa-icon icon="book-open" class="menu-item-icon" style="color: #0d9488"></fa-icon>
@@ -1616,11 +1688,11 @@
</button>
<button mat-menu-item tabindex="0" routerLink="/reconciliation/fixed-assets">
<fa-icon icon="cube" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Fixed Assets Reconciliation
Fixed Assets
</button>
<button mat-menu-item tabindex="0" routerLink="/reconciliation/inter-company">
<fa-icon icon="sitemap" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Inter-Company Reconciliation
Inter-Company
</button>
</div>
@@ -1633,15 +1705,15 @@
</button>
<button mat-menu-item tabindex="0" routerLink="/reconciliation/api-bank-reco">
<fa-icon icon="code-branch" class="menu-item-icon" style="color: #0d9488"></fa-icon>
API Based Bank Reconciliation
API Bank Reco
</button>
<button mat-menu-item tabindex="0" routerLink="/reconciliation/neft-rtgs">
<fa-icon icon="exchange" class="menu-item-icon" style="color: #0d9488"></fa-icon>
NEFT / RTGS Reconciliation
NEFT / RTGS Reco
</button>
<button mat-menu-item tabindex="0" routerLink="/reconciliation/clearing-reco">
<fa-icon icon="hand-holding-usd" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Clearing Reconciliation
Clearing Reco
</button>
</div>
@@ -1654,11 +1726,11 @@
</button>
<button mat-menu-item tabindex="0" routerLink="/reconciliation/three-way-digital">
<fa-icon icon="project-diagram" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Three-way Digital Reconciliation
Three-way Digital
</button>
<button mat-menu-item tabindex="0" routerLink="/reconciliation/nach-reco">
<fa-icon icon="sync-alt" class="menu-item-icon" style="color: #0d9488"></fa-icon>
NACH Reconciliation
NACH Reco
</button>
</div>
@@ -1667,11 +1739,11 @@
<div class="deposit-group-title">Instruments &amp; Members</div>
<button mat-menu-item [routerLink]="['/sponsor-dd-reconciliation']" tabindex="0">
<fa-icon icon="money-check-alt" class="menu-item-icon" style="color: #0d9488"></fa-icon>
DD / Pay Order Reconciliation
DD / Pay Order
</button>
<button mat-menu-item tabindex="0" routerLink="/reconciliation/share-member">
<fa-icon icon="coins" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Share Member Reconciliation
Share Member
</button>
</div>
@@ -1847,46 +1919,68 @@
</div>
</mat-menu>
<mat-menu #insuranceMenu="matMenu" [overlapTrigger]="false" class="nav-dropdown">
<button mat-menu-item routerLink="/dicgc" tabindex="0">
<fa-icon icon="building" class="menu-item-icon" style="color: #c2410c"></fa-icon> DICGC
</button>
<button mat-menu-item tabindex="0"><fa-icon icon="heart" class="menu-item-icon"></fa-icon> PMJBY</button>
<button mat-menu-item tabindex="0"><fa-icon icon="heart" class="menu-item-icon"></fa-icon> PMSBY</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="hand-holding-heart" class="menu-item-icon"></fa-icon> Indemnity
</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="home" class="menu-item-icon"></fa-icon> Assets Insurance
</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="laptop" class="menu-item-icon"></fa-icon> Cyber Insurance
</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="university" class="menu-item-icon"></fa-icon> Bank Insurance
</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="file-invoice" class="menu-item-icon"></fa-icon> Loan Insurance
</button>
<mat-menu #insuranceMenu="matMenu" class="insurance-mega-panel" [overlapTrigger]="false">
<div class="deposit-menu-grid insurance-grid" (click)="$event.stopPropagation()">
<!-- Social & Policy Schemes -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Schemes &amp; Social</div>
<button mat-menu-item routerLink="/dicgc" tabindex="0">
<fa-icon icon="building" class="menu-item-icon" style="color: #c2410c"></fa-icon> DICGC
</button>
<button mat-menu-item tabindex="0"><fa-icon icon="heart" class="menu-item-icon"></fa-icon> PMJBY</button>
<button mat-menu-item tabindex="0"><fa-icon icon="heart" class="menu-item-icon"></fa-icon> PMSBY</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="hand-holding-heart" class="menu-item-icon"></fa-icon> Indemnity
</button>
</div>
<!-- Institutional & Loans -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Institutional &amp; Loans</div>
<button mat-menu-item tabindex="0">
<fa-icon icon="file-invoice" class="menu-item-icon"></fa-icon> Loan Insurance
</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="home" class="menu-item-icon"></fa-icon> Assets Insurance
</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="laptop" class="menu-item-icon"></fa-icon> Cyber Insurance
</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="university" class="menu-item-icon"></fa-icon> Bank Insurance
</button>
</div>
</div>
</mat-menu>
<mat-menu #digitalMenu="matMenu" [overlapTrigger]="false" class="nav-dropdown">
<button mat-menu-item routerLink="/digital/neft-rtgs-report" tabindex="0">
<fa-icon icon="exchange" class="menu-item-icon"></fa-icon> NEFT / RTGS
</button>
<button mat-menu-item routerLink="/digital/imps-report" tabindex="0">
<fa-icon icon="bolt" class="menu-item-icon"></fa-icon> IMPS
</button>
<button mat-menu-item routerLink="/digital/upi-report" tabindex="0">
<fa-icon icon="mobile-alt" class="menu-item-icon"></fa-icon> UPI Reports
</button>
<button mat-menu-item routerLink="/digital/atm-pos-ecom" tabindex="0">
<fa-icon icon="credit-card" class="menu-item-icon"></fa-icon> ATM / POS / ECOM Reports
</button>
<button mat-menu-item tabindex="0"><fa-icon icon="sync" class="menu-item-icon"></fa-icon> NACH</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="file-signature" class="menu-item-icon"></fa-icon> Mandate
</button>
<mat-menu #digitalMenu="matMenu" class="digital-mega-panel" [overlapTrigger]="false">
<div class="deposit-menu-grid digital-grid" (click)="$event.stopPropagation()">
<!-- Payment Systems -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Payments</div>
<button mat-menu-item routerLink="/digital/neft-rtgs-report" tabindex="0">
<fa-icon icon="exchange" class="menu-item-icon"></fa-icon> NEFT / RTGS
</button>
<button mat-menu-item routerLink="/digital/imps-report" tabindex="0">
<fa-icon icon="bolt" class="menu-item-icon"></fa-icon> IMPS
</button>
<button mat-menu-item routerLink="/digital/upi-report" tabindex="0">
<fa-icon icon="mobile-alt" class="menu-item-icon"></fa-icon> UPI Reports
</button>
</div>
<!-- Cards & Mandates -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Cards &amp; Mandates</div>
<button mat-menu-item routerLink="/digital/atm-pos-ecom" tabindex="0">
<fa-icon icon="credit-card" class="menu-item-icon"></fa-icon> ATM / POS / ECOM Reports
</button>
<button mat-menu-item tabindex="0"><fa-icon icon="sync" class="menu-item-icon"></fa-icon> NACH</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="file-signature" class="menu-item-icon"></fa-icon> Mandate
</button>
</div>
</div>
</mat-menu>
<!-- RBI Mega Menu -->
+197 -124
View File
@@ -266,7 +266,7 @@ $color: #e2e4ec;
max-width: 100%;
overflow-x: hidden;
box-sizing: border-box;
padding: 16px 28px;
padding: 10px 18px;
}
}
@@ -282,21 +282,15 @@ $gray-700: #334155;
/* ============================================================
Main header (top 2-row navigation)
--------------------------------------------------------------
Redesigned 2026 — clean, modern enterprise SaaS feel:
• Both rows live on the same white surface separated only by a
hairline; r2 has a barely-there blue-50 wash so frequent vs
secondary menus read at a glance without dark/light contrast.
• Items are now equal-width "tabs" with NO vertical dividers
between them (the old cell borders made it look spreadsheet-y).
• Hover / active = a 3 px brand-coloured TOP bar + brand text +
coloured icon. Reads instantly without colour-flooding the row.
• Icons larger (12.5 px) and full-opacity always — easier to scan
during demos. Caret is subtle and rotates on open.
Compact, modern enterprise SaaS navigation:
• Height reduced to 32px per row (64px total) to save screen space.
• Smooth horizontal scrolling prevents menu items from getting cut off.
• Compact typography, icons and paddings fit all banking modules cleanly.
============================================================ */
.main-header {
background: #fff;
border-bottom: 2.5px solid $brand;
box-shadow: 0 1px 4px rgb(0 0 0 / 7%);
border-bottom: 2px solid $brand;
box-shadow: 0 1px 3px rgb(0 0 0 / 6%);
position: sticky;
/* Sit directly under the sticky toolbar (32px tall) so both stay visible. */
@@ -306,19 +300,19 @@ $gray-700: #334155;
}
.nav-row {
height: 44px;
height: 32px;
display: flex;
align-items: stretch;
overflow: visible;
overflow: auto visible;
position: relative;
/* Outer frame; vertical dividers between cells are drawn as `border-right`
on each `.ni` below. Switched away from the equal-column grid so each
cell can size to its own label — long names (General Ledger, Documents
Hub) no longer overflow and short ones (Approvals, HRM) no longer waste
space. */
border: 1px solid $gray-200;
background: #fff;
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar {
display: none;
}
}
.r2.nav-row {
@@ -334,25 +328,17 @@ $gray-700: #334155;
background: #f8fafc;
}
// ===== Nav Item — modern tab style =====
// Typography spec — same family / size / weight / color for BOTH rows.
// ===== Nav Item — compact modern tab style =====
.ni {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
gap: 2.5px;
height: 100%;
/* Each cell sizes to its own label (min-width: max-content) and grows
to share any leftover row width. Short labels stay compact, long ones
get the room they need — no overlap, no wasted gap. */
flex: 1 0 auto;
min-width: max-content;
padding: 0 14px;
/* Vertical divider between adjacent cells; outer frame is the row's
own border, and the last cell drops its right border below. */
min-width: 0;
padding: 0 4px;
border-right: 1px solid $gray-200;
cursor: pointer;
white-space: nowrap;
@@ -360,11 +346,11 @@ $gray-700: #334155;
'IBM Plex Sans',
-apple-system,
sans-serif;
font-size: 12px;
font-size: 10px;
font-weight: 500;
color: #0a1a2e;
line-height: 1.3;
letter-spacing: 0.01em;
line-height: 1.2;
letter-spacing: -0.015em;
text-decoration: none;
user-select: none;
transition:
@@ -373,17 +359,15 @@ $gray-700: #334155;
box-shadow 0.15s ease;
border-radius: 0;
/* 3px accent strip that fills on hover / active (uses ::before so we
don't reflow the row when toggling). */
&::before {
content: '';
position: absolute;
left: 4px;
right: 4px;
left: 2px;
right: 2px;
top: 0;
height: 3px;
height: 2px;
background: transparent;
border-radius: 0 0 3px 3px;
border-radius: 0 0 2px 2px;
transition: background 0.15s ease;
}
@@ -410,17 +394,19 @@ $gray-700: #334155;
outline-offset: -2px;
}
/* Last cell in a row sits flush against the row's own right edge, so
drop its divider to avoid a double 2 px line. */
&:last-child {
border-right: none;
}
.ni-short {
display: none;
}
}
.ni-ico {
width: 12px;
height: 12px;
font-size: 12px;
width: 9.5px;
height: 9.5px;
font-size: 9.5px;
color: $gray-500;
flex-shrink: 0;
transition: color 0.15s ease;
@@ -432,11 +418,12 @@ $gray-700: #334155;
}
.ni-caret {
width: 7px;
height: 7px;
font-size: 7px;
width: 5px;
height: 5px;
font-size: 5px;
color: $gray-400;
flex-shrink: 0;
margin-left: 0.5px;
transition:
transform 0.18s ease,
color 0.18s ease;
@@ -447,17 +434,89 @@ $gray-700: #334155;
}
}
@media (width <= 1200px) {
@media (width >= 1400px) {
.ni {
padding: 0 8px;
gap: 5px;
padding: 0 6px;
gap: 3.5px;
font-size: 10.8px;
letter-spacing: 0;
}
.ni-ico {
width: 10px;
height: 10px;
font-size: 10px;
}
.ni-caret {
width: 5.5px;
height: 5.5px;
font-size: 5.5px;
margin-left: 1px;
}
}
@media (width <= 980px) {
@media (width <= 1200px) {
.ni {
padding: 0 3px;
gap: 2px;
font-size: 9.8px;
letter-spacing: -0.02em;
}
.ni-ico {
width: 9px;
height: 9px;
font-size: 9px;
}
.ni-caret {
width: 4.5px;
height: 4.5px;
font-size: 4.5px;
}
}
@media (width <= 1080px) {
.ni {
padding: 0 2.5px;
gap: 1.5px;
font-size: 9.4px;
letter-spacing: -0.025em;
.ni-full {
display: none;
}
.ni-short {
display: inline;
}
}
.ni-ico {
width: 8.5px;
height: 8.5px;
font-size: 8.5px;
}
.ni-caret {
width: 4px;
height: 4px;
font-size: 4px;
}
}
@media (width <= 880px) {
.nav-row {
overflow-x: auto;
scrollbar-width: thin;
}
.ni {
flex: 0 0 auto;
padding: 0 6px;
gap: 4px;
gap: 3px;
font-size: 10px;
}
}
@@ -699,21 +758,29 @@ $gray-700: #334155;
// ===== Nav Dropdown Menus =====
::ng-deep .nav-dropdown {
border-radius: 8px !important;
border-radius: 6px !important;
border: 1px solid $gray-200 !important;
box-shadow: 0 6px 24px rgb(0 0 0 / 10%) !important;
min-width: 180px !important;
padding: 4px 0 !important;
margin-top: 2px !important;
box-shadow: 0 4px 16px rgb(0 0 0 / 12%) !important;
min-width: 170px !important;
padding: 3px 0 !important;
margin-top: 1px !important;
.mat-mdc-menu-item {
font-size: 12.5px !important;
font-size: 10.5px !important;
font-weight: 500 !important;
height: 34px !important;
min-height: 34px !important;
padding: 0 14px !important;
height: 25px !important;
min-height: 25px !important;
padding: 0 6px !important;
line-height: 25px !important;
color: $gray-700 !important;
font-family: 'DM Sans', Roboto, sans-serif !important;
font-family: 'IBM Plex Sans', Roboto, sans-serif !important;
white-space: nowrap !important;
.menu-item-icon {
font-size: 10.5px !important;
margin-right: 5px !important;
opacity: 0.8;
}
&:hover {
background: $brand-hover !important;
@@ -728,39 +795,44 @@ $gray-700: #334155;
}
/* Deposits Mega Panel (inside mat-menu overlay) */
.deposit-menu-grid {
::ng-deep .deposit-menu-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px 24px;
padding: 8px 12px;
min-width: 600px;
gap: 4px 12px;
padding: 6px 10px;
min-width: 0;
width: 100%;
box-sizing: border-box;
overflow-x: hidden;
}
.deposit-menu-group {
::ng-deep .deposit-menu-group {
.deposit-group-title {
font-weight: 700;
font-size: 11px;
font-size: 9.5px;
text-transform: uppercase;
letter-spacing: 0.6px;
letter-spacing: 0.5px;
color: #1074b9;
padding: 4px 16px 6px;
padding: 2px 6px 3px;
margin-bottom: 2px;
border-bottom: 2px solid #1074b9;
border-bottom: 1.5px solid #1074b9;
white-space: nowrap;
}
}
/* NPA Mega Panel — 4 columns (Reports / Analysis / Classification / Recovery Management) */
.npa-grid {
grid-template-columns: repeat(4, minmax(200px, 1fr));
min-width: 880px;
max-width: 96vw;
/* NPA Mega Panel — 3 columns */
::ng-deep .npa-grid {
grid-template-columns: repeat(3, minmax(210px, 1fr));
min-width: 0;
max-width: 100%;
overflow-x: hidden;
}
/* Reconciliation Mega Panel — 5 columns, teal accent to match the icon palette */
.reconciliation-grid {
grid-template-columns: repeat(5, minmax(190px, 1fr));
min-width: 1040px;
max-width: 96vw;
/* Reconciliation Mega Panel — 5 columns */
::ng-deep .reconciliation-grid {
grid-template-columns: repeat(5, minmax(170px, 1fr));
min-width: 0;
max-width: 100%;
overflow-x: hidden;
.deposit-group-title {
color: #0d9488;
@@ -780,47 +852,49 @@ $gray-700: #334155;
.gl-mega-menu {
position: absolute;
top: 100px;
top: 96px;
left: 50%;
transform: translateX(-50%);
background: #fff;
width: 860px;
max-width: 95vw;
padding: 20px 24px;
box-shadow: 0 6px 24px rgb(0 0 0 / 22%);
border-radius: 0 0 10px 10px;
width: 760px;
max-width: 94vw;
max-height: 72vh;
overflow-y: auto;
padding: 12px 16px;
box-shadow: 0 6px 20px rgb(0 0 0 / 18%);
border-radius: 0 0 8px 8px;
z-index: 1000;
border-top: 3px solid #0d47a1;
border-top: 2.5px solid #0d47a1;
}
.gl-mega-menu-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 14px;
padding-bottom: 12px;
gap: 8px;
margin-bottom: 8px;
padding-bottom: 6px;
border-bottom: 1px solid #e5e7eb;
span {
font-size: 18px;
font-size: 14px;
font-weight: 700;
color: #0d47a1;
letter-spacing: 0.02em;
letter-spacing: 0.01em;
}
}
.gl-mega-menu-header-icon {
font-size: 18px;
font-size: 14px;
color: #0d47a1;
}
.gl-search {
width: 100%;
padding: 10px 14px;
margin-bottom: 16px;
padding: 6px 10px;
margin-bottom: 10px;
border: 1px solid #d0d5dd;
border-radius: 6px;
font-size: 14px;
border-radius: 5px;
font-size: 12px;
box-sizing: border-box;
background: #f9fafb;
@@ -828,36 +902,37 @@ $gray-700: #334155;
outline: none;
border-color: #0d47a1;
background: #fff;
box-shadow: 0 0 0 3px rgb(13 71 161 / 10%);
box-shadow: 0 0 0 2px rgb(13 71 161 / 10%);
}
}
.gl-columns {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 24px;
gap: 10px 16px;
}
.gl-group-title {
font-weight: 700;
font-size: 11px;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.8px;
letter-spacing: 0.6px;
color: #0d47a1;
margin-bottom: 10px;
padding-bottom: 6px;
border-bottom: 2px solid #0d47a1;
margin-bottom: 5px;
padding-bottom: 3px;
border-bottom: 1.5px solid #0d47a1;
}
.gl-link {
display: flex;
align-items: center;
padding: 7px 6px;
padding: 3.5px 5px;
text-decoration: none;
color: #344054;
font-size: 13px;
font-size: 11.5px;
line-height: 1.3;
cursor: pointer;
border-radius: 5px;
border-radius: 4px;
transition:
background 0.15s,
color 0.15s;
@@ -873,23 +948,21 @@ $gray-700: #334155;
}
.gl-icon {
width: 18px;
margin-right: 8px;
width: 14px;
margin-right: 5px;
color: #667085;
font-size: 13px;
font-size: 11px;
flex-shrink: 0;
.gl-link:hover & {
color: #0d47a1;
}
}
// Marks a menu link as actually wired to a working screen/dialog (vs a still-unwired
// placeholder) — a small green dot at the end of the row, so a reviewer can tell at a glance
// which items in a mega-menu are functional without having to click every one of them.
.gl-link--live::after {
content: '';
width: 6px;
height: 6px;
width: 5px;
height: 5px;
margin-left: auto;
flex-shrink: 0;
border-radius: 50%;
@@ -899,9 +972,9 @@ $gray-700: #334155;
/* RBI Mega Menu */
.rbi-mega-menu {
width: 1100px;
max-width: 97vw;
max-height: 80vh;
width: 880px;
max-width: 94vw;
max-height: 72vh;
overflow-y: auto;
border-top-color: #b71c1c;
}
@@ -909,7 +982,7 @@ $gray-700: #334155;
.rbi-columns {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20px;
gap: 10px 16px;
}
@keyframes move {
@@ -522,7 +522,7 @@
</mat-menu>
<!-- Application User Help Menu -->
<mat-menu #applicationMenu="matMenu" [overlapTrigger]="false">
<mat-menu #applicationMenu="matMenu" [overlapTrigger]="false" class="t-user-menu" xPosition="before">
<button mat-menu-item (click)="help()" tabindex="0">
<mat-icon matListIcon>
<fa-icon icon="question-circle" size="sm"></fa-icon>
+187 -59
View File
@@ -390,6 +390,103 @@ $icon-hover-border: rgb(255 255 255 / 28%);
max-height: 80vh;
overflow-y: auto;
}
// Compact card per AI module
.ai-card.mat-mdc-menu-item {
display: flex !important;
align-items: flex-start !important;
gap: 10px !important;
padding: 10px !important;
min-height: 52px !important;
height: auto !important;
border-radius: 9px !important;
font-family: inherit !important;
line-height: 1.3 !important;
white-space: normal !important;
overflow: visible !important;
transition: background 0.16s ease !important;
.mat-mdc-menu-item-text,
.mdc-list-item__primary-text,
.mdc-list-item__content {
display: flex !important;
align-items: flex-start !important;
gap: 10px !important;
width: 100% !important;
white-space: normal !important;
overflow: visible !important;
}
.ai-card-ic {
width: 32px !important;
height: 32px !important;
min-width: 32px !important;
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
border-radius: 8px !important;
font-size: 13px !important;
flex-shrink: 0 !important;
background: var(--c-bg) !important;
color: var(--c-color) !important;
margin: 0 !important;
opacity: 1 !important;
transition:
background 0.16s ease,
color 0.16s ease,
transform 0.16s ease !important;
fa-icon {
font-size: 13px !important;
width: auto !important;
min-width: auto !important;
margin: 0 !important;
color: inherit !important;
opacity: 1 !important;
}
}
.ai-card-body {
display: flex !important;
flex-direction: column !important;
flex: 1 !important;
min-width: 0 !important;
text-align: left !important;
white-space: normal !important;
}
.ai-card-t {
font-size: 12.5px !important;
font-weight: 600 !important;
color: #0f172a !important;
line-height: 1.25 !important;
white-space: normal !important;
display: block !important;
}
.ai-card-d {
font-size: 10.5px !important;
color: #64748b !important;
margin-top: 3px !important;
line-height: 1.35 !important;
white-space: normal !important;
display: block !important;
}
&:hover {
background: var(--c-bg) !important;
.ai-card-ic {
background: var(--c-hover) !important;
color: #fff !important;
transform: scale(1.06) !important;
}
.ai-card-t {
color: var(--c-color) !important;
}
}
}
}
.ai-menu-header {
@@ -488,70 +585,101 @@ $icon-hover-border: rgb(255 255 255 / 28%);
font-size: 11px;
}
// Compact card per AI module
.ai-card.mat-mdc-menu-item {
display: flex !important;
align-items: flex-start;
gap: 10px;
padding: 9px 10px;
min-height: 0;
height: auto;
border-radius: 9px;
font-family: inherit;
line-height: 1.3;
white-space: normal;
transition: background 0.16s ease;
// ===== User Account Dropdown Menu =====
::ng-deep .t-user-menu.mat-mdc-menu-panel {
min-width: 190px !important;
border-radius: 10px !important;
box-shadow:
0 10px 25px rgb(15 23 42 / 12%),
0 2px 6px rgb(15 23 42 / 6%) !important;
border: 1px solid #e2e8f0 !important;
padding: 6px 0 !important;
background: #fff !important;
.ai-card-ic {
width: 32px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 8px;
font-size: 13px;
flex-shrink: 0;
background: var(--c-bg);
color: var(--c-color);
.mat-mdc-menu-content {
padding: 4px 0 !important;
}
.mat-mdc-menu-item {
height: 38px !important;
min-height: 38px !important;
line-height: 38px !important;
padding: 0 14px !important;
display: flex !important;
align-items: center !important;
font-size: 13px !important;
font-weight: 500 !important;
color: #334155 !important;
transition:
background 0.16s ease,
color 0.16s ease,
transform 0.16s ease;
}
background-color 0.15s ease,
color 0.15s ease !important;
.ai-card-body {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
text-align: left;
}
.ai-card-t {
font-size: 12.5px;
font-weight: 600;
color: #0f172a;
line-height: 1.25;
}
.ai-card-d {
font-size: 10.5px;
color: #64748b;
margin-top: 2px;
line-height: 1.35;
}
&:hover {
background: var(--c-bg);
.ai-card-ic {
background: var(--c-hover);
color: #fff;
transform: scale(1.06);
.mat-mdc-menu-item-text {
display: inline-flex !important;
align-items: center !important;
width: 100% !important;
font-size: 13px !important;
color: inherit !important;
font-weight: 500 !important;
}
.ai-card-t {
color: var(--c-color);
mat-icon {
margin-right: 10px !important;
width: 18px !important;
height: 18px !important;
min-width: 18px !important;
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
color: #64748b !important;
font-size: 14px !important;
flex-shrink: 0 !important;
fa-icon {
font-size: 14px !important;
width: 14px !important;
min-width: 14px !important;
text-align: center !important;
margin-right: 0 !important;
color: inherit !important;
opacity: 1 !important;
}
}
span {
font-size: 13px !important;
color: inherit !important;
font-weight: 500 !important;
white-space: nowrap !important;
}
&:hover {
background-color: #f1f5f9 !important;
color: #0d47a1 !important;
mat-icon {
color: #0d47a1 !important;
}
}
&#logout {
color: #dc2626 !important;
border-top: 1px solid #f1f5f9 !important;
margin-top: 4px !important;
padding-top: 2px !important;
mat-icon {
color: #dc2626 !important;
}
&:hover {
background-color: #fef2f2 !important;
color: #b91c1c !important;
mat-icon {
color: #b91c1c !important;
}
}
}
}
}
@@ -24,6 +24,7 @@ import { UntypedFormBuilder, UntypedFormGroup, UntypedFormControl, ValidatorFn,
/** Custom Services */
import { FixedDepositsService } from '../../../fixed-deposits.service';
import { SettingsService } from 'app/settings/settings.service';
import { AlertService } from 'app/core/alert/alert.service';
import { Dates } from 'app/core/utils/dates';
import { FormatNumberPipe } from '../../../../../pipes/format-number.pipe';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@@ -97,6 +98,7 @@ export class PaymentDialogComponent implements OnInit {
private fixedDepositsService = inject(FixedDepositsService);
private settingsService = inject(SettingsService);
private dateUtils = inject(Dates);
private alertService = inject(AlertService);
paymentForm: UntypedFormGroup;
/** Loaded once, on open, from the same closure template the existing Close action already uses. */
@@ -375,7 +377,16 @@ export class PaymentDialogComponent implements OnInit {
this.fixedDepositsService
.executeFixedDepositsAccountCommand(this.data.accountId as string, 'payment', data)
.subscribe({
next: () => {
next: (response: any) => {
// Parked in the Checker Inbox rather than executed -- see the same handling in
// PrematureCloseDialogComponent for why an HTTP 200 here can still mean "not applied yet".
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message:
'This payment has been submitted for approval. The deposit is unchanged until a checker approves it.'
});
}
this.dialogRef.close({ confirm: true });
},
// Keep the dialog open with the entered values and say what went wrong, rather than leaving a
@@ -140,7 +140,9 @@
<span class="pcd__preview-label"
>Interest Earned (to {{ 'labels.inputs.Premature Close Date' | translate }})</span
>
<span class="pcd__preview-value">+ {{ interestEarned | formatNumber }}</span>
<span class="pcd__preview-value"
>{{ interestEarned >= 0 ? '+' : '' }} {{ interestEarned | formatNumber }}</span
>
</div>
}
<div class="pcd__preview-row pcd__preview-row--total">
@@ -205,8 +207,8 @@
@if (noDestinationAccounts) {
<p class="pcd__hint pcd__hint--warn" role="alert">
<fa-icon icon="info-circle" class="m-r-5"></fa-icon>
This customer has no active savings account to transfer into. Open one first, or choose an action that
pays out in cash instead.
This customer has no active savings account to transfer into. Premature closure has to be paid into a
savings account, so open one for this customer before closing this deposit.
</p>
}
@@ -229,6 +231,16 @@
</mat-form-field>
}
@if (prematureCloseForm.contains('toGlAccountId')) {
<mifosx-gl-account-selector
[inputFormControl]="toGlAccountControl"
[glAccountList]="glAccountsData"
[required]="true"
[inputLabel]="'Transfer to GL Account'"
>
</mifosx-gl-account-selector>
}
@if (prematureCloseForm.contains('transferDescription')) {
<mat-form-field appearance="outline">
<mat-label>{{ 'labels.inputs.Transfer Description' | translate }}</mat-label>
@@ -23,13 +23,19 @@ import { UntypedFormBuilder, UntypedFormGroup, UntypedFormControl, Validators }
/** Custom Services */
import { FixedDepositsService } from '../../../fixed-deposits.service';
import { SettingsService } from 'app/settings/settings.service';
import { AccountingService } from 'app/accounting/accounting.service';
import { AlertService } from 'app/core/alert/alert.service';
import { Dates } from 'app/core/utils/dates';
import { FormatNumberPipe } from '../../../../../pipes/format-number.pipe';
import { GlAccountSelectorComponent } from 'app/shared/accounting/gl-account-selector/gl-account-selector.component';
import { GLAccount } from 'app/shared/models/general.model';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* "Premature Close" dialog -- Active-only, no reinvest option (the deposit hasn't matured, so there's
* nothing to renew): pay out in cash, or transfer the pre-maturity balance to a savings account. Same
* nothing to renew): transfer the pre-maturity balance to a savings account, or post it to a GL account.
* There is deliberately no cash payout -- the backend does not offer or accept one for a premature
* closure, so the proceeds always stay traceable to an account. Same
* `.pcd__*` visual language as the Payment dialog ({@link PaymentDialogComponent}), in this Danger Zone
* action's own red accent rather than Payment's blue, since it's an irreversible, usually
* penalty-bearing action rather than a routine one.
@@ -49,22 +55,26 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
MatDialogClose,
CdkTextareaAutosize,
FaIconComponent,
FormatNumberPipe
FormatNumberPipe,
GlAccountSelectorComponent
]
})
export class PrematureCloseDialogComponent implements OnInit {
/** The `onAccountClosureId` codes Premature Close can offer -- same codes Payment/Close use. */
private static readonly WITHDRAW_DEPOSIT_ID = 100;
private static readonly TRANSFER_TO_SAVINGS_ID = 200;
private static readonly TRANSFER_TO_GL_ID = 600;
private static readonly ACTION_ICONS: Record<number, string> = {
[PrematureCloseDialogComponent.WITHDRAW_DEPOSIT_ID]: 'money-bill-wave',
[PrematureCloseDialogComponent.TRANSFER_TO_SAVINGS_ID]: 'wallet'
[PrematureCloseDialogComponent.TRANSFER_TO_SAVINGS_ID]: 'wallet',
[PrematureCloseDialogComponent.TRANSFER_TO_GL_ID]: 'book'
};
private static readonly ACTION_DESCRIPTIONS: Record<number, string> = {
[PrematureCloseDialogComponent.WITHDRAW_DEPOSIT_ID]: 'Pay out the full balance in cash',
[PrematureCloseDialogComponent.TRANSFER_TO_SAVINGS_ID]: 'Move the full balance to another savings account'
[PrematureCloseDialogComponent.TRANSFER_TO_SAVINGS_ID]: 'Move the full balance to another savings account',
[PrematureCloseDialogComponent.TRANSFER_TO_GL_ID]: 'Post the full balance to a GL account'
};
/**
@@ -108,11 +118,15 @@ export class PrematureCloseDialogComponent implements OnInit {
private formBuilder = inject(UntypedFormBuilder);
private fixedDepositsService = inject(FixedDepositsService);
private settingsService = inject(SettingsService);
private accountingService = inject(AccountingService);
private alertService = inject(AlertService);
private dateUtils = inject(Dates);
prematureCloseForm: UntypedFormGroup;
onAccountClosureOptions: any[] = [];
savingsAccountsData: any[] = [];
/** Postable GL accounts, loaded once -- only read when the GL action is chosen. */
glAccountsData: GLAccount[] = [];
maturityAmount: number = null;
/** The rate actually resolved for THIS preview by the backend (actual-period vs contracted,
* penalty-deducted or not) -- distinct from data.nominalAnnualInterestRate, which is always the
@@ -228,19 +242,50 @@ export class PrematureCloseDialogComponent implements OnInit {
});
}
/** Adds/removes the destination-account control to match what the chosen type needs. */
/** Adds/removes the destination control to match what the chosen type needs. */
private onClosureTypeChange(id: number): void {
if (id === PrematureCloseDialogComponent.TRANSFER_TO_SAVINGS_ID) {
if (!this.prematureCloseForm.contains('toSavingsAccountId')) {
this.prematureCloseForm.addControl('toSavingsAccountId', new UntypedFormControl('', Validators.required));
this.prematureCloseForm.addControl('transferDescription', new UntypedFormControl(''));
}
} else {
const toSavings = id === PrematureCloseDialogComponent.TRANSFER_TO_SAVINGS_ID;
const toGl = id === PrematureCloseDialogComponent.TRANSFER_TO_GL_ID;
if (toSavings && !this.prematureCloseForm.contains('toSavingsAccountId')) {
this.prematureCloseForm.addControl('toSavingsAccountId', new UntypedFormControl('', Validators.required));
} else if (!toSavings) {
this.prematureCloseForm.removeControl('toSavingsAccountId');
}
if (toGl) {
if (!this.prematureCloseForm.contains('toGlAccountId')) {
this.prematureCloseForm.addControl('toGlAccountId', new UntypedFormControl('', Validators.required));
}
this.loadGlAccounts();
} else {
this.prematureCloseForm.removeControl('toGlAccountId');
}
// Both destinations carry the description through -- to the account transfer, or to the narration on
// the journal entry that credits the GL account.
if ((toSavings || toGl) && !this.prematureCloseForm.contains('transferDescription')) {
this.prematureCloseForm.addControl('transferDescription', new UntypedFormControl(''));
} else if (!toSavings && !toGl) {
this.prematureCloseForm.removeControl('transferDescription');
}
}
/** Fetched on first use rather than with the dialog, since most closures never pick the GL action. */
private loadGlAccounts(): void {
if (this.glAccountsData.length) {
return;
}
this.accountingService.getGlAccounts().subscribe((accounts: GLAccount[]) => {
this.glAccountsData = accounts ?? [];
});
}
/** The GL selector takes a control instance, not a control name. */
get toGlAccountControl(): UntypedFormControl {
return this.prematureCloseForm.get('toGlAccountId') as UntypedFormControl;
}
/** The gap between the deposit and the computed maturity amount -- the actual interest this premature
* closure earns, at whatever rate (contracted, or the lower pre-closure-penalty one below) applied. */
get interestEarned(): number | null {
@@ -373,7 +418,18 @@ export class PrematureCloseDialogComponent implements OnInit {
this.fixedDepositsService
.executeFixedDepositsAccountCommand(this.data.accountId as string, 'prematureClose', data)
.subscribe({
next: () => {
next: (response: any) => {
// A command on a maker-checker permission is parked in the Checker Inbox, not executed:
// the platform rolls its transaction back and still answers HTTP 200 with
// rollbackTransaction: true. Without saying so, the dialog closes and the account reloads
// unchanged -- which reads as "nothing happened" rather than "waiting for approval".
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message:
'Premature closure has been submitted for approval. The deposit is unchanged until a checker approves it.'
});
}
this.dialogRef.close({ confirm: true });
},
// Keep the dialog open with the entered values and say what went wrong, rather than leaving a
@@ -30,6 +30,7 @@ import { AccountsService } from 'app/accounts/accounts.service';
import { Currency } from 'app/shared/models/general.model';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
import { AlertService } from 'app/core/alert/alert.service';
import { AuthenticationService } from '../../../core/authentication/authentication.service';
import { MatTooltip } from '@angular/material/tooltip';
import { NgClass, CurrencyPipe } from '@angular/common';
@@ -74,6 +75,7 @@ import { DormancyAlertPanelComponent } from 'app/shared/dormancy-alert-panel/dor
export class FixedDepositAccountViewComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private alertService = inject(AlertService);
private fixedDepositsService = inject(FixedDepositsService);
private savingsService = inject(SavingsService);
private accountsService = inject(AccountsService);
@@ -419,7 +421,16 @@ export class FixedDepositAccountViewComponent implements OnInit {
if (response.confirm) {
this.fixedDepositsService
.executeFixedDepositsAccountCommand(this.fixedDepositsAccountData.id, 'postInterest', {})
.subscribe(() => {
.subscribe((commandResponse: any) => {
// Parked in the Checker Inbox rather than executed (HTTP 200 with
// rollbackTransaction: true), so the reload below shows no new interest posting.
if (commandResponse?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message:
'Interest posting has been sent for checker approval. No interest is posted until a checker approves it.'
});
}
this.reload();
});
}
@@ -11,6 +11,7 @@ import { Component, OnInit, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { Dates } from 'app/core/utils/dates';
import { AlertService } from 'app/core/alert/alert.service';
/** Custom Services */
import { FixedDepositsService } from 'app/deposits/fixed-deposits/fixed-deposits.service';
@@ -37,6 +38,7 @@ export class ApproveFixedDepositsAccountComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private settingsService = inject(SettingsService);
private alertService = inject(AlertService);
/** Minimum date allowed. */
minDate = new Date(2000, 0, 1);
@@ -98,17 +100,31 @@ export class ApproveFixedDepositsAccountComponent implements OnInit {
dateFormat,
locale
};
this.fixedDepositsService.executeFixedDepositsAccountCommand(this.accountId, 'approve', data).subscribe(() => {
const activationData = {
activatedOnDate: approveFixedDepositsAccountFormData.approvedOnDate,
dateFormat,
locale
};
this.fixedDepositsService
.executeFixedDepositsAccountCommand(this.accountId, 'activate', activationData)
.subscribe(() => {
this.fixedDepositsService
.executeFixedDepositsAccountCommand(this.accountId, 'approve', data)
.subscribe((response: any) => {
// Parked in the Checker Inbox rather than executed (HTTP 200 with rollbackTransaction: true).
// The account is therefore still pending, so chaining activation onto it would be applied to an
// unapproved account -- stop here and say what is actually waiting.
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message:
'This approval has been sent for checker approval. The account stays pending until a checker approves it.'
});
this.router.navigate(['../../'], { relativeTo: this.route });
});
});
return;
}
const activationData = {
activatedOnDate: approveFixedDepositsAccountFormData.approvedOnDate,
dateFormat,
locale
};
this.fixedDepositsService
.executeFixedDepositsAccountCommand(this.accountId, 'activate', activationData)
.subscribe(() => {
this.router.navigate(['../../'], { relativeTo: this.route });
});
});
}
}
@@ -21,6 +21,7 @@ import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { FixedDepositsService } from '../../fixed-deposits.service';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
import { AlertService } from 'app/core/alert/alert.service';
import { CdkTextareaAutosize } from '@angular/cdk/text-field';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@@ -43,6 +44,7 @@ export class CloseFixedDepositsAccountComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private settingsService = inject(SettingsService);
private alertService = inject(AlertService);
/** Minimum date allowed. */
minDate = new Date(2000, 0, 1);
@@ -139,8 +141,18 @@ export class CloseFixedDepositsAccountComponent implements OnInit {
dateFormat,
locale
};
this.fixedDepositsService.executeFixedDepositsAccountCommand(this.accountId, 'close', data).subscribe(() => {
this.router.navigate(['../../'], { relativeTo: this.route });
});
this.fixedDepositsService
.executeFixedDepositsAccountCommand(this.accountId, 'close', data)
.subscribe((response: any) => {
// Parked in the Checker Inbox rather than executed (HTTP 200 with rollbackTransaction: true),
// so the deposit reloads unchanged -- say so instead of letting that read as "nothing happened".
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message: 'Closure has been sent for checker approval. The deposit is unchanged until a checker approves it.'
});
}
this.router.navigate(['../../'], { relativeTo: this.route });
});
}
}
@@ -11,6 +11,7 @@ import { Component, OnInit, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { Dates } from 'app/core/utils/dates';
import { AlertService } from 'app/core/alert/alert.service';
/** Custom Services */
import { FixedDepositsService } from 'app/deposits/fixed-deposits/fixed-deposits.service';
@@ -37,6 +38,7 @@ export class RejectFixedDepositsAccountComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private settingsService = inject(SettingsService);
private alertService = inject(AlertService);
/** Minimum date allowed. */
minDate = new Date(2000, 0, 1);
@@ -97,8 +99,19 @@ export class RejectFixedDepositsAccountComponent implements OnInit {
dateFormat,
locale
};
this.fixedDepositsService.executeFixedDepositsAccountCommand(this.accountId, 'reject', data).subscribe(() => {
this.router.navigate(['../../'], { relativeTo: this.route });
});
this.fixedDepositsService
.executeFixedDepositsAccountCommand(this.accountId, 'reject', data)
.subscribe((response: any) => {
// Parked in the Checker Inbox rather than executed (HTTP 200 with rollbackTransaction: true),
// so the application is still open -- say so instead of letting that read as "nothing happened".
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message:
'Rejection has been sent for checker approval. The application is unchanged until a checker approves it.'
});
}
this.router.navigate(['../../'], { relativeTo: this.route });
});
}
}
@@ -12,6 +12,7 @@ import { UntypedFormGroup, UntypedFormBuilder, ReactiveFormsModule } from '@angu
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
/** Custom Services */
import { AlertService } from 'app/core/alert/alert.service';
import { SavingsService } from 'app/savings/savings.service';
import { FixedDepositsService } from '../../fixed-deposits.service';
import { CdkTextareaAutosize } from '@angular/cdk/text-field';
@@ -35,6 +36,7 @@ export class UndoApprovalFixedDepositsAccountComponent implements OnInit {
private fixedDepositsService = inject(FixedDepositsService);
private route = inject(ActivatedRoute);
private router = inject(Router);
private alertService = inject(AlertService);
/** Undo Approval Fixed Deposits Account form. */
undoApprovalFixedDepositsAccountForm: UntypedFormGroup;
@@ -87,12 +89,30 @@ export class UndoApprovalFixedDepositsAccountComponent implements OnInit {
if (this.undoAction === 'Undo Activation') {
this.fixedDepositsService
.executeFixedDepositsAccountCommand(this.accountId, this.undoCommand, data)
.subscribe(() => {
.subscribe((response: any) => {
this.notifyIfSentForApproval(response);
this.router.navigate(['../../'], { relativeTo: this.route });
});
} else {
this.savingsService.executeSavingsAccountCommand(this.accountId, this.undoCommand, data).subscribe(() => {
this.router.navigate(['../../'], { relativeTo: this.route });
this.savingsService
.executeSavingsAccountCommand(this.accountId, this.undoCommand, data)
.subscribe((response: any) => {
this.notifyIfSentForApproval(response);
this.router.navigate(['../../'], { relativeTo: this.route });
});
}
}
/**
* Both undo commands sit on maker-checker permissions, so either can come back parked in the
* Checker Inbox rather than executed -- HTTP 200 with rollbackTransaction: true, and the account
* reloaded unchanged. Without this the navigation away reads as "it worked".
*/
private notifyIfSentForApproval(response: any): void {
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message: `${this.undoAction} has been sent for checker approval. The account is unchanged until a checker approves it.`
});
}
}
@@ -11,6 +11,7 @@ import { Component, OnInit, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { Dates } from 'app/core/utils/dates';
import { AlertService } from 'app/core/alert/alert.service';
/** Custom Services */
import { FixedDepositsService } from 'app/deposits/fixed-deposits/fixed-deposits.service';
@@ -37,6 +38,7 @@ export class WithdrawByClientFixedDepositsAccountComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private settingsService = inject(SettingsService);
private alertService = inject(AlertService);
/** Minimum date allowed. */
minDate = new Date(2000, 0, 1);
@@ -99,7 +101,16 @@ export class WithdrawByClientFixedDepositsAccountComponent implements OnInit {
};
this.fixedDepositsService
.executeFixedDepositsAccountCommand(this.accountId, 'withdrawnByApplicant', data)
.subscribe(() => {
.subscribe((response: any) => {
// Parked in the Checker Inbox rather than executed (HTTP 200 with rollbackTransaction: true),
// so the application is still open -- say so instead of letting that read as "nothing happened".
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message:
'Withdrawal has been sent for checker approval. The application is unchanged until a checker approves it.'
});
}
this.router.navigate(['../../'], { relativeTo: this.route });
});
}
@@ -0,0 +1,646 @@
<!--
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">
<div class="fd-head">
<div>
<h2 class="fd-head-t"><fa-icon icon="percentage"></fa-icon> FD Interest Posting Reports</h2>
<div class="fd-head-s">
Each branch gets its own report for each fixed deposit scheme, generated automatically at 12:00 noon on the
posting date. One user puts the figures up and a second approves them — whoever generated a report cannot
approve it. Approved interest posts when the branch closes its day, and it cannot close until then.
</div>
</div>
<div class="fd-head-a">
<label class="fd-date">
Posting date
<input type="date" [(ngModel)]="postingDate" (change)="load()" />
</label>
<button class="fd-btn" (click)="load()" [disabled]="loading"><fa-icon icon="undo"></fa-icon>Reload</button>
<button class="fd-btn fd-btn--primary" (click)="openPreview()" [disabled]="previewLoading">
<fa-icon icon="eye"></fa-icon>Preview interest (today)
</button>
<button class="fd-btn" (click)="openOutsideApproval()" [disabled]="outsideApprovalLoading">
<fa-icon icon="bolt"></fa-icon>Posted outside approval
</button>
@if (isHeadOffice) {
<button class="fd-btn" (click)="openProductSummary()" [disabled]="productSummaryLoading">
<fa-icon icon="table"></fa-icon>Product summary
</button>
}
</div>
</div>
@if (!approvalEnabled) {
<div class="fd-alert fd-alert--info">
<fa-icon icon="info-circle"></fa-icon>
<div>
Fixed deposit interest is currently posted directly by the nightly job, so there is nothing to approve here.
Turn on <strong>fd-interest-posting-approval</strong> under System &rarr; Configurations to put deposits behind
this report.
</div>
</div>
}
@if (isHeadOffice) {
<div class="fd-tabs">
<button class="fd-tab" [class.is-active]="tab === 'own'" (click)="switchTab('own')">
<fa-icon icon="building"></fa-icon>My branch{{ myBranch ? ' — ' + myBranch : '' }}
</button>
<button class="fd-tab" [class.is-active]="tab === 'others'" (click)="switchTab('others')">
<fa-icon icon="sitemap"></fa-icon>Other branches
</button>
</div>
@if (tab === 'others') {
<div class="fd-alert fd-alert--info">
<fa-icon icon="info-circle"></fa-icon>
<div>
Each branch generates and approves its own reports. The head office can act here only for a branch that has
not opened the day, and only once every other open branch has closed. Anything it approves is marked (HO).
</div>
</div>
}
}
@if (errorMessage) {
<div class="fd-alert fd-alert--error">
<fa-icon icon="exclamation-triangle"></fa-icon>
<div>{{ errorMessage }}</div>
</div>
}
@if (notice) {
<div class="fd-alert fd-alert--ok">
<fa-icon icon="check"></fa-icon>
<div>{{ notice }}</div>
</div>
}
@if (pendingCount > 0) {
<div class="fd-alert fd-alert--warn">
<fa-icon icon="clock"></fa-icon>
<div>
<strong>{{ pendingCount }} report(s) waiting for approval.</strong> The day cannot be closed until they are
approved, and the user who generated a report cannot be the one to approve it.
</div>
</div>
}
@if (due.length > 0) {
<div class="fd-alert fd-alert--warn">
<fa-icon icon="exclamation-triangle"></fa-icon>
<div class="fd-due-body">
<div>Fixed deposit interest is due but no report has been generated yet for:</div>
@for (d of due; track d.productId + '-' + d.officeId) {
<div class="fd-due-row">
<strong>{{ d.productName }}</strong>
<span class="fd-chip" [ngClass]="fdTypeClass(d.fdType)">{{ fdTypeShort(d.fdType) }}</span>
<span>
&middot; {{ d.officeName }} &middot; {{ d.postingDate }} &middot; {{ d.accountCount }} deposit(s) &middot;
&#8377;{{ d.totalInterest | number: '1.2-2' }}
</span>
@if (d.canAct) {
<button class="fd-btn fd-btn--primary fd-btn--slim" (click)="generate(d)" [disabled]="busy">
<fa-icon icon="sync"></fa-icon>{{ tab === 'others' ? 'Generate on behalf' : 'Generate report' }}
</button>
} @else if (d.blockedReason) {
<span class="fd-blocked">{{ d.blockedReason }}</span>
}
</div>
}
</div>
</div>
}
<!-- Summary strip -->
<div class="fd-summary">
<div class="fd-stat">
<div class="fd-stat__label">Interest due, no report</div>
<div class="fd-stat__value">&#8377;{{ dueTotal | number: '1.2-2' }}</div>
<div class="fd-stat__sub">{{ dueAccountTotal }} deposit(s) &middot; {{ due.length }} product(s)</div>
</div>
<div class="fd-stat fd-stat--warn">
<div class="fd-stat__label">Awaiting approval</div>
<div class="fd-stat__value">&#8377;{{ awaitingTotal | number: '1.2-2' }}</div>
<div class="fd-stat__sub">{{ awaitingReports.length }} report(s) &middot; blocking day close</div>
</div>
<div class="fd-stat fd-stat--ok">
<div class="fd-stat__label">Approved &mdash; posts at close</div>
<div class="fd-stat__value">&#8377;{{ approvedTotal | number: '1.2-2' }}</div>
<div class="fd-stat__sub">{{ approvedReports.length }} report(s)</div>
</div>
<div class="fd-stat fd-stat--posted">
<div class="fd-stat__label">Posted today</div>
<div class="fd-stat__value">&#8377;{{ postedTotal | number: '1.2-2' }}</div>
<div class="fd-stat__sub">{{ postedReports.length }} report(s)</div>
</div>
</div>
@if (showPreview) {
<div class="fd-card">
<div class="fd-detail-head">
<div>
<h3>FD interest preview &mdash; earned up to {{ previewAsOf }}</h3>
<div class="muted">
What each deposit would be paid if interest were posted today. View only &mdash; nothing is saved, approved
or posted.
</div>
</div>
<div class="fd-total">
<span>Total so far</span>
<strong>&#8377;{{ previewTotal | number: '1.2-2' }}</strong>
<span class="muted">{{ previewRows.length }} deposit(s)</span>
</div>
</div>
<div
style="
display: flex;
flex-wrap: wrap;
gap: 10px;
padding: 14px 20px;
border-bottom: 1px solid var(--fd-border-soft);
"
>
<button class="fd-btn fd-btn--slim" (click)="openPreview()" [disabled]="previewLoading">
<fa-icon icon="undo"></fa-icon>Refresh
</button>
<button
class="fd-btn fd-btn--slim"
(click)="exportPreview()"
[disabled]="previewLoading || previewRows.length === 0"
>
<fa-icon icon="file-excel"></fa-icon>Export Excel
</button>
<button class="fd-btn fd-btn--slim" (click)="closePreview()"><fa-icon icon="times"></fa-icon>Close</button>
</div>
@if (previewLoading) {
<div class="fd-empty">Working out what each deposit has earned up to today&hellip;</div>
} @else {
<div class="fd-table-wrap">
<table class="fd-table">
<thead>
<tr>
<th>Branch</th>
<th>Product</th>
<th>Scheme</th>
<th class="num">Deposits</th>
<th class="num">Interest so far</th>
</tr>
</thead>
<tbody>
@for (g of previewGroups; track g.officeName + g.productName) {
<tr>
<td>{{ g.officeName }}</td>
<td>
<strong>{{ g.productName }}</strong>
</td>
<td>
<span class="fd-chip" [ngClass]="fdTypeClass(g.fdType)">{{ fdTypeShort(g.fdType) }}</span>
</td>
<td class="num">{{ g.accounts }}</td>
<td class="num">&#8377;{{ g.interest | number: '1.2-2' }}</td>
</tr>
} @empty {
<tr>
<td colspan="5" class="fd-empty">No fixed deposit has interest waiting to be posted.</td>
</tr>
}
</tbody>
</table>
</div>
<div class="fd-table-wrap">
<table class="fd-table">
<thead>
<tr>
<th>Account no</th>
<th>Customer</th>
<th>Scheme</th>
<th>Interest for</th>
<th class="num">Deposit</th>
<th class="num">Rate %</th>
<th>Maturity</th>
<th>Pays into</th>
<th class="num">Interest so far</th>
</tr>
</thead>
<tbody>
@for (r of previewRows; track r.fdAccountId + '-' + r.interestPostingDate) {
<tr>
<td class="fd-mono">{{ r.accountNo }}</td>
<td>{{ r.clientName }}</td>
<td>
<span class="fd-chip" [ngClass]="fdTypeClass(r.fdType)">{{ fdTypeShort(r.fdType) }}</span>
</td>
<td class="fd-mono">{{ r.interestPeriodFrom || '—' }} &rarr; {{ r.interestPostingDate }}</td>
<td class="num">&#8377;{{ r.depositAmount | number: '1.2-2' }}</td>
<td class="num">{{ r.interestRate | number: '1.2-2' }}</td>
<td class="fd-mono">{{ r.maturityDate || '—' }}</td>
<td class="fd-sub">{{ r.payoutAccountNo || 'Stays in the deposit' }}</td>
<td class="num">&#8377;{{ r.interest | number: '1.2-2' }}</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="8"><strong>Total</strong></td>
<td class="num">
<strong>&#8377;{{ previewTotal | number: '1.2-2' }}</strong>
</td>
</tr>
</tfoot>
</table>
</div>
}
</div>
}
@if (showOutsideApproval) {
<div class="fd-card">
<div class="fd-detail-head">
<div>
<h3>Fixed deposit interest posted outside this report</h3>
<div class="muted">
A deposit that matured before its own posting cycle completed has its last period settled directly, the
moment it matures &mdash; before any report here could be generated or approved for it. Listed here so
nothing paid to a customer is invisible, approved or not.
</div>
</div>
<div class="fd-total">
<span>Total</span>
<strong>&#8377;{{ outsideApprovalTotal | number: '1.2-2' }}</strong>
<span class="muted">{{ outsideApprovalRows.length }} posting(s)</span>
</div>
</div>
<div
style="
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 10px;
padding: 14px 20px;
border-bottom: 1px solid var(--fd-border-soft);
"
>
<label class="fd-date">
From
<input type="date" [(ngModel)]="outsideApprovalFrom" (change)="loadOutsideApproval()" />
</label>
<label class="fd-date">
To
<input type="date" [(ngModel)]="outsideApprovalTo" (change)="loadOutsideApproval()" />
</label>
<button class="fd-btn fd-btn--slim" (click)="loadOutsideApproval()" [disabled]="outsideApprovalLoading">
<fa-icon icon="undo"></fa-icon>Refresh
</button>
<button class="fd-btn fd-btn--slim" (click)="closeOutsideApproval()">
<fa-icon icon="times"></fa-icon>Close
</button>
</div>
@if (outsideApprovalLoading) {
<div class="fd-empty">Looking for deposit interest posted outside this report&hellip;</div>
} @else {
<div class="fd-table-wrap">
<table class="fd-table">
<thead>
<tr>
<th>Product</th>
<th>Scheme</th>
<th class="num">Deposits</th>
<th class="num">Interest</th>
</tr>
</thead>
<tbody>
@for (g of outsideApprovalGroups; track g.productName) {
<tr>
<td>
<strong>{{ g.productName }}</strong>
</td>
<td>
<span class="fd-chip" [ngClass]="fdTypeClass(g.fdType)">{{ fdTypeShort(g.fdType) }}</span>
</td>
<td class="num">{{ g.accounts }}</td>
<td class="num">&#8377;{{ g.interest | number: '1.2-2' }}</td>
</tr>
} @empty {
<tr>
<td colspan="4" class="fd-empty">
No fixed deposit interest was posted outside this report in this period.
</td>
</tr>
}
</tbody>
</table>
</div>
@if (outsideApprovalRows.length > 0) {
<div class="fd-table-wrap">
<table class="fd-table">
<thead>
<tr>
<th>Account no</th>
<th>Customer</th>
<th>Product</th>
<th>Scheme</th>
<th>Interest for</th>
<th class="num">Interest</th>
<th>Posted at</th>
</tr>
</thead>
<tbody>
@for (r of outsideApprovalRows; track r.transactionId) {
<tr>
<td class="fd-mono">{{ r.accountNo }}</td>
<td>{{ r.clientName }}</td>
<td>{{ r.productName }}</td>
<td>
<span class="fd-chip" [ngClass]="fdTypeClass(r.fdType)">{{ fdTypeShort(r.fdType) }}</span>
</td>
<td class="fd-mono">{{ r.interestFor }}</td>
<td class="num">&#8377;{{ r.interestAmount | number: '1.2-2' }}</td>
<td class="fd-sub">{{ r.postedAt | date: 'dd MMM yyyy, HH:mm' }}</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="5"><strong>Total</strong></td>
<td class="num">
<strong>&#8377;{{ outsideApprovalTotal | number: '1.2-2' }}</strong>
</td>
<td></td>
</tr>
</tfoot>
</table>
</div>
}
}
</div>
}
@if (showProductSummary) {
<div class="fd-card">
<div class="fd-detail-head">
<div>
<h3>Interest by product</h3>
<div class="muted">
Posted through this report, still awaiting approval or posting, and posted directly at maturity instead —
added up to what the product has paid out in total over the period.
</div>
</div>
<div class="fd-total">
<span>Total, all products</span>
<strong>&#8377;{{ productSummaryGrandTotal.total | number: '1.2-2' }}</strong>
</div>
</div>
<div
style="
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 10px;
padding: 14px 20px;
border-bottom: 1px solid var(--fd-border-soft);
"
>
<label class="fd-date">
From
<input type="date" [(ngModel)]="productSummaryFrom" (change)="loadProductSummary()" />
</label>
<label class="fd-date">
To
<input type="date" [(ngModel)]="productSummaryTo" (change)="loadProductSummary()" />
</label>
<button class="fd-btn fd-btn--slim" (click)="loadProductSummary()" [disabled]="productSummaryLoading">
<fa-icon icon="undo"></fa-icon>Refresh
</button>
<button class="fd-btn fd-btn--slim" (click)="closeProductSummary()">
<fa-icon icon="times"></fa-icon>Close
</button>
</div>
@if (productSummaryLoading) {
<div class="fd-empty">Adding up interest by product&hellip;</div>
} @else {
<div class="fd-table-wrap">
<table class="fd-table">
<thead>
<tr>
<th>Product</th>
<th>Scheme</th>
<th>Posting period</th>
<th class="num">Posted (report)</th>
<th class="num">Pending approval</th>
<th class="num">Posted at maturity</th>
<th class="num">Total</th>
</tr>
</thead>
<tbody>
@for (row of productSummaryRows; track row.productId) {
<tr>
<td>
<strong>{{ row.productName }}</strong>
</td>
<td>
<span class="fd-chip" [ngClass]="fdTypeClass(row.fdType)">{{ fdTypeShort(row.fdType) }}</span>
</td>
<td class="fd-sub">{{ row.postingPeriodLabel || '—' }}</td>
<td class="num">&#8377;{{ row.posted | number: '1.2-2' }}</td>
<td class="num">&#8377;{{ row.pending | number: '1.2-2' }}</td>
<td class="num">&#8377;{{ row.maturityPosted | number: '1.2-2' }}</td>
<td class="num">
<strong>&#8377;{{ row.total | number: '1.2-2' }}</strong>
</td>
</tr>
} @empty {
<tr>
<td colspan="7" class="fd-empty">No fixed deposit interest in this period.</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="3"><strong>Total</strong></td>
<td class="num">
<strong>&#8377;{{ productSummaryGrandTotal.posted | number: '1.2-2' }}</strong>
</td>
<td class="num">
<strong>&#8377;{{ productSummaryGrandTotal.pending | number: '1.2-2' }}</strong>
</td>
<td class="num">
<strong>&#8377;{{ productSummaryGrandTotal.maturityPosted | number: '1.2-2' }}</strong>
</td>
<td class="num">
<strong>&#8377;{{ productSummaryGrandTotal.total | number: '1.2-2' }}</strong>
</td>
</tr>
</tfoot>
</table>
</div>
}
</div>
}
<div class="fd-card">
<div class="fd-card-h">
<h2>Reports{{ postingDate ? ' for ' + postingDate : '' }}</h2>
<span class="fd-card-sub">One report per scheme, per branch</span>
</div>
<div class="fd-table-wrap">
<table class="fd-table">
<thead>
<tr>
<th>Branch</th>
<th>Scheme</th>
<th>Type</th>
<th>Posting date</th>
<th class="num">Deposits</th>
<th class="num">Total interest</th>
<th>Status</th>
<th>Maker</th>
<th>Checker</th>
<th></th>
</tr>
</thead>
<tbody>
@for (r of reports; track r.id) {
<tr class="is-clickable" [class.is-selected]="selected?.id === r.id" (click)="open(r)">
<td>{{ r.officeName }}</td>
<td>
<strong>{{ r.productName }}</strong>
</td>
<td [title]="fdTypeLabel(r.fdType)">
<span class="fd-chip" [ngClass]="fdTypeClass(r.fdType)">{{ fdTypeShort(r.fdType) }}</span>
</td>
<td class="fd-mono">{{ r.postingDate }}</td>
<td class="num">{{ r.accountCount }}</td>
<td class="num">&#8377;{{ r.totalInterest | number: '1.2-2' }}</td>
<td>
<span class="fd-badge" [ngClass]="statusClass(r.status)">{{ r.status }}</span>
@if (r.failureReason) {
<div class="fd-fail" [title]="r.failureReason">Posting failed: {{ r.failureReason }}</div>
}
</td>
<td class="fd-sub">
{{ r.generatedBy || 'System' }} &middot; {{ r.generationMode }}<br />{{
r.generatedAt | date: 'dd MMM, HH:mm'
}}
</td>
<td class="fd-sub">
@if (r.approvedAt) {
{{ r.approvedBy }}
@if (r.approvedOnBehalf) {
<span class="fd-behalf">(HO)</span>
}
<br />{{ r.approvedAt | date: 'dd MMM, HH:mm' }}
} @else {
&mdash;
}
</td>
<td (click)="$event.stopPropagation()">
<div style="display: flex; flex-wrap: wrap; gap: 6px; justify-content: flex-end">
@if (canRegenerate(r)) {
<button class="fd-btn fd-btn--slim" (click)="regenerate(r)" [disabled]="busy">
<fa-icon icon="sync"></fa-icon>Regenerate
</button>
}
@if (canApprove(r)) {
<button class="fd-btn fd-btn--primary fd-btn--slim" (click)="approve(r)" [disabled]="busy">
<fa-icon icon="check"></fa-icon>{{ tab === 'others' ? 'Approve on behalf' : 'Approve' }}
</button>
} @else if (r.status === 'GENERATED' && r.approveBlockedReason) {
<span class="fd-blocked">{{ r.approveBlockedReason }}</span>
}
</div>
</td>
</tr>
} @empty {
<tr>
<td colspan="10" class="fd-empty">
@if (loading) {
Loading&hellip;
} @else {
No fixed deposit interest posting report for {{ postingDate || 'this date' }}. Reports are generated
at 12:00 noon on a product's posting date.
}
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
@if (selected) {
<div class="fd-card">
<div class="fd-detail-head">
<div>
<h3>
{{ selected.officeName }} &middot; {{ selected.productName }} &mdash; {{ selected.postingDate }}
<span class="fd-chip" [ngClass]="fdTypeClass(selected.fdType)">{{ fdTypeShort(selected.fdType) }}</span>
<span class="fd-badge fd-badge--muted">{{ selected.postingPeriodLabel }}</span>
<span class="fd-badge" [ngClass]="statusClass(selected.status)">{{ selected.status }}</span>
</h3>
<div class="muted">{{ fdTypeLabel(selected.fdType) }}</div>
<div class="muted">
Dr Interest Expense &rarr; Cr {{ selected.payableGlCode }} {{ selected.payableGlName }}; Dr
{{ selected.payableGlCode }} &rarr; Cr {{ selected.paidGlCode }} {{ selected.paidGlName }}; then Dr
{{ selected.paidGlCode }} &rarr; Cr each deposit below
</div>
</div>
<div class="fd-total">
<span>Total interest</span>
<strong>&#8377;{{ selected.totalInterest | number: '1.2-2' }}</strong>
<span class="muted">{{ selected.accountCount }} deposit(s)</span>
</div>
</div>
<div class="fd-table-wrap">
<table class="fd-table">
<thead>
<tr>
<th>Account no</th>
<th>Account holder</th>
<th>Interest for</th>
<th class="num">Deposit</th>
<th class="num">Rate %</th>
<th>Maturity</th>
<th class="num">Interest</th>
<th class="num">Balance before</th>
<th class="num">Balance after</th>
<th>Pays into</th>
<th>Posted txn</th>
</tr>
</thead>
<tbody>
@for (l of selected.lines; track l.fdAccountId + '-' + l.interestPostingDate) {
<tr>
<td class="fd-mono">{{ l.accountNo }}</td>
<td>{{ l.clientName }}</td>
<td class="fd-mono">{{ l.interestPeriodFrom || '—' }} &rarr; {{ l.interestPostingDate }}</td>
<td class="num">&#8377;{{ l.depositAmount | number: '1.2-2' }}</td>
<td class="num">{{ l.interestRate | number: '1.2-2' }}</td>
<td class="fd-mono">{{ l.maturityDate || '—' }}</td>
<td class="num">&#8377;{{ l.interestAmount | number: '1.2-2' }}</td>
<td class="num">&#8377;{{ l.balanceBefore | number: '1.2-2' }}</td>
<td class="num">&#8377;{{ l.balanceAfter | number: '1.2-2' }}</td>
<td class="fd-sub">{{ l.payoutAccountNo || 'Stays in the deposit' }}</td>
<td class="fd-sub">{{ l.fdTransactionId ? '#' + l.fdTransactionId : '—' }}</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="6"><strong>Total</strong></td>
<td class="num">
<strong>&#8377;{{ selected.totalInterest | number: '1.2-2' }}</strong>
</td>
<td colspan="4"></td>
</tr>
</tfoot>
</table>
</div>
</div>
}
</div>
@@ -0,0 +1,559 @@
/**
* 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 {
--fd-navy: #101723;
--fd-accent: #1b4d8f;
--fd-accent-dark: #123c70;
--fd-ground: #f7f8fa;
--fd-border: #dfe4eb;
--fd-border-soft: #edf0f4;
--fd-muted: #5b6676;
--fd-text: #3c4757;
--fd-amber: #6b4600;
--fd-amber-dark: #5a3b00;
--fd-amber-tint: #fdf3dc;
--fd-amber-border: #efd9a3;
--fd-amber-wash: #fffcf4;
--fd-green: #185e38;
--fd-green-dark: #1d6f42;
--fd-green-tint: #e2f3e8;
--fd-green-wash: #f3faf5;
--fd-blue-tint: #e6eef8;
--fd-slate-tint: #edf0f4;
display: block;
font-family: 'IBM Plex Sans', system-ui, sans-serif;
color: var(--fd-navy);
}
.fd {
max-width: 1320px;
margin: 0 auto;
padding: 20px 4px 56px;
}
// ── Header ──────────────────────────────────────────────────────────────
.fd-head {
display: flex;
flex-wrap: wrap;
gap: 24px;
align-items: flex-end;
justify-content: space-between;
margin-bottom: 22px;
}
.fd-head-t {
margin: 0;
font-family: 'IBM Plex Serif', serif;
font-weight: 600;
font-size: 26px;
letter-spacing: -0.01em;
display: flex;
align-items: center;
gap: 10px;
fa-icon {
color: var(--fd-accent);
font-size: 20px;
}
}
.fd-head-s {
max-width: 720px;
margin-top: 8px;
font-size: 13.5px;
line-height: 1.6;
color: var(--fd-muted);
}
.fd-head-a {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 10px;
}
.fd-date {
display: flex;
flex-direction: column;
gap: 5px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--fd-muted);
input {
font-family: 'IBM Plex Mono', monospace;
font-size: 13px;
padding: 9px 11px;
min-height: 44px;
border: 1px solid #cdd5df;
border-radius: 7px;
background: #fff;
color: var(--fd-navy);
}
}
// ── Buttons ─────────────────────────────────────────────────────────────
.fd-btn {
font-family: inherit;
font-size: 12.5px;
font-weight: 500;
padding: 8px 14px;
min-height: 44px;
border-radius: 7px;
border: 1px solid #cdd5df;
background: #fff;
color: var(--fd-navy);
cursor: pointer;
white-space: nowrap;
fa-icon {
margin-right: 6px;
}
&:hover:not([disabled]) {
background: #f2f5f9;
}
&[disabled] {
opacity: 0.5;
cursor: not-allowed;
}
&--primary {
background: var(--fd-accent);
border-color: var(--fd-accent);
color: #fff;
&:hover:not([disabled]) {
background: var(--fd-accent-dark);
}
}
&--slim {
min-height: 34px;
padding: 6px 11px;
font-size: 12px;
}
}
// ── Tabs ────────────────────────────────────────────────────────────────
.fd-tabs {
display: flex;
gap: 4px;
margin-bottom: 20px;
border-bottom: 1px solid var(--fd-border);
}
.fd-tab {
font-family: inherit;
font-size: 13px;
font-weight: 500;
padding: 10px 6px;
min-height: 44px;
border: 0;
border-bottom: 2px solid transparent;
background: transparent;
color: var(--fd-muted);
cursor: pointer;
fa-icon {
margin-right: 7px;
}
&.is-active {
border-bottom-color: var(--fd-accent);
color: var(--fd-accent);
font-weight: 600;
}
& + .fd-tab {
margin-left: 18px;
}
}
// ── Alerts ──────────────────────────────────────────────────────────────
.fd-alert {
display: flex;
gap: 12px;
align-items: flex-start;
margin-bottom: 18px;
padding: 14px 18px;
border-radius: 10px;
font-size: 13px;
line-height: 1.6;
fa-icon {
margin-top: 2px;
flex-shrink: 0;
}
&--info {
background: #eef3fa;
border: 1px solid #c8d8ec;
color: #1f3a5c;
}
&--warn {
background: var(--fd-amber-tint);
border: 1px solid var(--fd-amber-border);
color: var(--fd-amber-dark);
strong {
color: var(--fd-amber-dark);
}
}
&--ok {
background: var(--fd-green-wash);
border: 1px solid #bfe3cc;
color: var(--fd-green);
}
&--error {
background: #fdecea;
border: 1px solid #f3b8b1;
color: #8a2b22;
}
}
.fd-due-body {
display: flex;
flex-direction: column;
gap: 10px;
width: 100%;
}
.fd-due-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
padding: 8px 0;
& + .fd-due-row {
border-top: 1px dashed var(--fd-amber-border);
}
}
.fd-blocked {
font-size: 11.5px;
color: var(--fd-muted);
}
// ── Summary strip ───────────────────────────────────────────────────────
.fd-summary {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
margin-bottom: 20px;
@media (width <= 860px) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.fd-stat {
background: #fff;
border: 1px solid var(--fd-border);
border-radius: 12px;
padding: 18px 20px;
&__label {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--fd-muted);
}
&__value {
font-family: 'IBM Plex Mono', monospace;
font-variant-numeric: tabular-nums;
font-size: 25px;
font-weight: 500;
letter-spacing: -0.02em;
margin-top: 9px;
}
&__sub {
font-size: 11.5px;
color: var(--fd-muted);
margin-top: 4px;
}
&--warn {
border-color: var(--fd-amber-border);
.fd-stat__label,
.fd-stat__sub {
color: var(--fd-amber);
}
.fd-stat__value {
color: var(--fd-amber-dark);
}
}
&--ok {
.fd-stat__label {
color: var(--fd-green);
}
.fd-stat__value {
color: var(--fd-green);
}
}
&--posted {
.fd-stat__label {
color: var(--fd-accent);
}
.fd-stat__value {
color: var(--fd-accent);
}
}
}
// ── Cards ───────────────────────────────────────────────────────────────
.fd-card {
background: #fff;
border: 1px solid var(--fd-border);
border-radius: 12px;
margin-bottom: 20px;
overflow: hidden;
}
.fd-card-h {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: baseline;
padding: 16px 20px;
border-bottom: 1px solid var(--fd-border);
h2,
h3 {
margin: 0;
font-size: 15px;
font-weight: 600;
}
}
.fd-card-sub {
font-size: 12px;
color: var(--fd-muted);
}
// ── Tables ──────────────────────────────────────────────────────────────
.fd-table-wrap {
overflow-x: auto;
}
.fd-table {
border-collapse: collapse;
width: 100%;
th {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--fd-muted);
text-align: left;
padding: 10px 14px;
border-bottom: 1px solid var(--fd-border);
white-space: nowrap;
}
td {
font-size: 13px;
padding: 12px 14px;
border-bottom: 1px solid var(--fd-border-soft);
vertical-align: top;
}
tbody tr:last-child td {
border-bottom: 0;
}
tbody tr.is-clickable {
cursor: pointer;
&:hover td {
background: #fafbfd;
}
}
tbody tr.is-selected td {
background: var(--fd-blue-tint);
}
tfoot tr {
background: var(--fd-ground);
}
.num {
text-align: right;
font-family: 'IBM Plex Mono', monospace;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
}
.fd-mono {
font-family: 'IBM Plex Mono', monospace;
font-variant-numeric: tabular-nums;
}
.fd-sub {
font-size: 11.5px;
color: var(--fd-muted);
margin-top: 3px;
}
.fd-empty {
text-align: center;
padding: 30px 16px;
color: var(--fd-muted);
font-size: 13px;
}
// ── Chips & badges ──────────────────────────────────────────────────────
.fd-chip {
display: inline-block;
padding: 2px 9px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.02em;
white-space: nowrap;
&--single {
background: var(--fd-slate-tint);
color: var(--fd-text);
}
&--double {
background: var(--fd-blue-tint);
color: var(--fd-accent);
}
&--special {
background: var(--fd-amber-tint);
color: var(--fd-amber);
}
}
.fd-badge {
display: inline-block;
padding: 2px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.02em;
white-space: nowrap;
&--pending {
background: var(--fd-amber-tint);
color: var(--fd-amber);
}
&--approved {
background: var(--fd-green-tint);
color: var(--fd-green);
}
&--posted {
background: var(--fd-blue-tint);
color: var(--fd-accent);
}
&--muted {
background: var(--fd-slate-tint);
color: var(--fd-muted);
}
}
.fd-behalf {
font-size: 10.5px;
font-weight: 700;
color: var(--fd-accent);
margin-left: 4px;
}
.fd-fail {
margin-top: 4px;
font-size: 11px;
color: #a32b22;
}
// ── Detail panel ────────────────────────────────────────────────────────
.fd-detail-head {
display: flex;
flex-wrap: wrap;
gap: 20px;
justify-content: space-between;
align-items: flex-start;
padding: 22px 20px;
border-bottom: 1px solid var(--fd-border-soft);
background: #fbfcfd;
h3 {
margin: 0;
font-family: 'IBM Plex Serif', serif;
font-weight: 600;
font-size: 19px;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 9px;
}
}
.fd-total {
text-align: right;
span:first-child {
display: block;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--fd-muted);
}
strong {
display: block;
font-family: 'IBM Plex Mono', monospace;
font-variant-numeric: tabular-nums;
font-size: 26px;
font-weight: 500;
margin-top: 6px;
letter-spacing: -0.02em;
}
}
.muted {
color: var(--fd-muted);
font-size: 12.5px;
margin-top: 4px;
}
@@ -0,0 +1,686 @@
/**
* 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 { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { SettingsService } from 'app/settings/settings.service';
import { exportWorkbook } from '../cashier/cashier-export';
import {
FdInterestDue,
FdInterestPreviewRow,
FdInterestReport,
FdInterestReportDetail,
FdInterestPostingReportService,
FdInterestSummaryRow,
FdOutsideApprovalRow,
FdReportScope,
FdType
} from './fd-interest-posting-report.service';
/**
* Fixed deposit interest posting reports.
*
* On a product's posting date the system generates, at noon, the list of what each deposit will be
* paid. One user of the branch reviews it and a second approves it — the maker cannot be the checker,
* because posting interest moves money to customers and, for a special deposit, straight out to their
* savings account. The approved amounts are posted when the branch closes the day, and until then it
* cannot close. Regenerating replaces the report and needs approving again.
*/
@Component({
selector: 'mifosx-fd-interest-posting-report',
templateUrl: './fd-interest-posting-report.component.html',
styleUrls: ['./fd-interest-posting-report.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule,
FaIconComponent
]
})
export class FdInterestPostingReportComponent implements OnInit {
private reportService = inject(FdInterestPostingReportService);
private settingsService = inject(SettingsService);
postingDate = '';
loading = false;
busy = false;
errorMessage = '';
notice = '';
reports: FdInterestReport[] = [];
selected: FdInterestReportDetail | null = null;
/** Products with interest due and no report yet — the noon job ran before they had anything due, or failed. */
due: FdInterestDue[] = [];
/** 'own' — the user's own branch; 'others' — every other branch, a head-office-only tab. */
tab: FdReportScope = 'own';
isHeadOffice = false;
myBranch = '';
/** False where the bank has deposits on the old direct-posting path; every list here is then empty by design. */
approvalEnabled = true;
ngOnInit(): void {
this.postingDate = this.businessDateIso();
this.reportService.context().subscribe({
next: (ctx) => {
this.isHeadOffice = !!ctx?.headOffice;
this.myBranch = ctx?.officeName ?? '';
this.approvalEnabled = ctx?.approvalEnabled !== false;
},
error: () => (this.isHeadOffice = false)
});
this.load();
}
switchTab(tab: FdReportScope): void {
if (this.tab === tab) {
return;
}
this.tab = tab;
this.selected = null;
this.notice = '';
this.load();
}
/** Acting for another branch is the head office's exception, so it is spelt out before it happens. */
private confirmOnBehalf(officeName: string, action: string): boolean {
if (this.tab !== 'others') {
return true;
}
return confirm(
`${officeName} has not opened today. You are about to ${action} its fixed deposit interest posting report on its ` +
'behalf, from the head office. Continue?'
);
}
load(): void {
this.loading = true;
this.errorMessage = '';
this.reportService.list(this.postingDate || undefined, undefined, undefined, this.tab).subscribe({
next: (reports) => {
this.reports = reports ?? [];
this.loading = false;
const stillThere = this.selected && this.reports.find((r) => r.id === this.selected?.id);
if (stillThere) {
this.open(stillThere);
} else {
this.selected = null;
}
},
error: (err) => {
this.loading = false;
this.errorMessage = this.messageOf(err, 'Could not load the fixed deposit interest posting reports.');
}
});
this.reportService.dueWithoutReport(this.postingDate || undefined, this.tab).subscribe({
next: (due) => (this.due = due ?? []),
error: () => (this.due = [])
});
}
generate(item: FdInterestDue): void {
if (!this.confirmOnBehalf(item.officeName, 'generate')) {
return;
}
this.busy = true;
this.notice = '';
this.errorMessage = '';
this.reportService.regenerate(item.productId, item.officeId, item.postingDate).subscribe({
next: (res) => {
this.busy = false;
this.notice = `Report generated for ${item.productName} — ${item.officeName}. Another user of the branch has to approve it.`;
this.load();
this.reportService.detail(res.reportId).subscribe((detail) => (this.selected = detail));
},
error: (err) => {
this.busy = false;
this.errorMessage = this.messageOf(err, 'Could not generate the report.');
}
});
}
open(report: FdInterestReport): void {
this.errorMessage = '';
this.reportService.detail(report.id).subscribe({
next: (detail) => (this.selected = detail),
error: (err) => (this.errorMessage = this.messageOf(err, 'Could not load the report.'))
});
}
regenerate(report: FdInterestReport): void {
if (
!confirm(
`Regenerate the fixed deposit interest posting report for ${report.productName} — ${report.officeName}? ` +
'It will need approving again.'
) ||
!this.confirmOnBehalf(report.officeName, 'regenerate')
) {
return;
}
this.busy = true;
this.notice = '';
this.errorMessage = '';
this.reportService.regenerate(report.productId, report.officeId, report.postingDate).subscribe({
next: (res) => {
this.busy = false;
this.notice = `Report regenerated for ${report.productName}. Another user of the branch has to approve it.`;
this.selected = null;
this.load();
this.reportService.detail(res.reportId).subscribe((detail) => (this.selected = detail));
},
error: (err) => {
this.busy = false;
this.errorMessage = this.messageOf(err, 'Could not regenerate the report.');
}
});
}
approve(report: FdInterestReport): void {
const payout =
report.fdType === 'SPECIAL'
? ' This is a monthly-income product, so the interest is paid out to the customers’ linked savings accounts.'
: '';
if (
!confirm(
`Approve interest of ₹${report.totalInterest} across ${report.accountCount} deposit(s) for ${report.productName}?` +
`${payout} It will be posted when the branch closes the day.`
) ||
!this.confirmOnBehalf(report.officeName, 'approve')
) {
return;
}
this.busy = true;
this.notice = '';
this.errorMessage = '';
this.reportService.approve(report.id).subscribe({
next: () => {
this.busy = false;
this.notice = `Approved. Interest for ${report.productName} — ${report.officeName} will be posted when the branch closes the day.`;
this.load();
},
error: (err) => {
this.busy = false;
this.errorMessage = this.messageOf(err, 'Could not approve the report.');
}
});
}
canRegenerate(report: FdInterestReport): boolean {
return report.canAct && (report.status === 'GENERATED' || report.status === 'APPROVED');
}
/**
* The server decides this, not the screen: it alone knows who generated the report, and the maker
* cannot be the checker.
*/
canApprove(report: FdInterestReport): boolean {
return !!report.canApprove;
}
statusClass(status: string): string {
return (
{
GENERATED: 'fd-badge--pending',
APPROVED: 'fd-badge--approved',
POSTED: 'fd-badge--posted',
SUPERSEDED: 'fd-badge--muted'
}[status] ?? 'fd-badge--muted'
);
}
/** What the scheme means for the customer, spelt out rather than left as a code. */
fdTypeLabel(fdType: FdType | string): string {
return (
{
SINGLE: 'Single — interest paid at maturity',
DOUBLE: 'Double — interest compounded into the deposit',
SPECIAL: 'Special — interest paid monthly to linked savings'
}[fdType] ?? String(fdType ?? '')
);
}
fdTypeShort(fdType: FdType | string): string {
return (
{
SINGLE: 'Single',
DOUBLE: 'Double',
SPECIAL: 'Special'
}[fdType] ?? String(fdType ?? '')
);
}
fdTypeClass(fdType: FdType | string): string {
return (
{
SINGLE: 'fd-chip--single',
DOUBLE: 'fd-chip--double',
SPECIAL: 'fd-chip--special'
}[fdType] ?? 'fd-chip--single'
);
}
/** Pending reports that block this user's own day close; another branch's pending reports block only that branch. */
get pendingCount(): number {
return this.tab === 'own' ? this.reports.filter((r) => r.status === 'GENERATED').length : 0;
}
// ── Summary strip: the day's reports, at a glance ──
get dueTotal(): number {
return this.due.reduce((sum, d) => sum + Number(d.totalInterest || 0), 0);
}
get dueAccountTotal(): number {
return this.due.reduce((sum, d) => sum + Number(d.accountCount || 0), 0);
}
private reportsWithStatus(status: string): FdInterestReport[] {
return this.reports.filter((r) => r.status === status);
}
private totalOf(list: FdInterestReport[]): number {
return list.reduce((sum, r) => sum + Number(r.totalInterest || 0), 0);
}
get awaitingReports(): FdInterestReport[] {
return this.reportsWithStatus('GENERATED');
}
get approvedReports(): FdInterestReport[] {
return this.reportsWithStatus('APPROVED');
}
get postedReports(): FdInterestReport[] {
return this.reportsWithStatus('POSTED');
}
get awaitingTotal(): number {
return this.totalOf(this.awaitingReports);
}
get approvedTotal(): number {
return this.totalOf(this.approvedReports);
}
get postedTotal(): number {
return this.totalOf(this.postedReports);
}
// ── Preview: what each deposit has earned so far, as if posted today. View only. ──
showPreview = false;
previewLoading = false;
previewRows: FdInterestPreviewRow[] = [];
previewAsOf = '';
openPreview(): void {
this.showPreview = true;
this.previewLoading = true;
this.errorMessage = '';
this.previewAsOf = this.businessDateIso();
this.reportService.preview().subscribe({
next: (rows) => {
this.previewRows = rows ?? [];
this.previewLoading = false;
},
error: (err) => {
this.previewLoading = false;
this.previewRows = [];
this.errorMessage = this.messageOf(err, 'Could not load the fixed deposit interest preview.');
}
});
}
closePreview(): void {
this.showPreview = false;
}
get previewTotal(): number {
return this.previewRows.reduce((sum, r) => sum + Number(r.interest || 0), 0);
}
/** Totals by branch, product and scheme, for the summary above the deposit list and the export. */
get previewGroups(): {
officeName: string;
productName: string;
fdType: string;
accounts: number;
interest: number;
}[] {
const map = new Map<
string,
{ officeName: string; productName: string; fdType: string; accounts: number; interest: number }
>();
this.previewRows.forEach((r) => {
const key = `${r.officeName}|${r.productName}`;
const g = map.get(key) ?? {
officeName: r.officeName,
productName: r.productName,
fdType: r.fdType,
accounts: 0,
interest: 0
};
g.accounts += 1;
g.interest += Number(r.interest || 0);
map.set(key, g);
});
return [...map.values()];
}
exportPreview(): void {
const stamp = `Fixed deposit interest earned to ${this.previewAsOf} — preview, not posted`;
exportWorkbook(
[
{
sheetName: 'By deposit',
title: 'Fixed Deposit Interest Preview',
subtitle: stamp,
headers: [
'Branch',
'Product',
'Scheme',
'Account No',
'Customer',
'Interest For',
'Deposit',
'Rate %',
'Maturity',
'Balance',
'Pays Into',
'Interest So Far'
],
widths: [
18,
22,
10,
20,
26,
12,
16,
8,
12,
16,
18,
16
],
rows: [
...this.previewRows.map((r) => [
r.officeName,
r.productName,
this.fdTypeShort(r.fdType),
r.accountNo,
r.clientName,
r.interestPostingDate ?? '',
Number(r.depositAmount || 0),
Number(r.interestRate || 0),
r.maturityDate ?? '',
Number(r.balance || 0),
r.payoutAccountNo ?? '',
Number(r.interest || 0)
]),
[
'TOTAL',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
this.previewTotal
]
]
},
{
sheetName: 'By branch & product',
title: 'Fixed Deposit Interest Preview — Totals',
subtitle: stamp,
headers: [
'Branch',
'Product',
'Scheme',
'Deposits',
'Interest So Far'
],
widths: [
18,
22,
10,
10,
16
],
rows: [
...this.previewGroups.map((g) => [
g.officeName,
g.productName,
this.fdTypeShort(g.fdType),
g.accounts,
g.interest
]),
[
'TOTAL',
'',
'',
this.previewRows.length,
this.previewTotal
]
]
}
],
`fd-interest-preview-${this.previewAsOf}`
);
}
// ── Outside approval: deposit interest posted without this report, chiefly at maturity ──
showOutsideApproval = false;
outsideApprovalLoading = false;
outsideApprovalRows: FdOutsideApprovalRow[] = [];
outsideApprovalFrom = '';
outsideApprovalTo = '';
openOutsideApproval(): void {
this.showOutsideApproval = true;
if (!this.outsideApprovalTo) {
this.outsideApprovalTo = this.businessDateIso();
this.outsideApprovalFrom = `${this.outsideApprovalTo.slice(0, 7)}-01`;
}
this.loadOutsideApproval();
}
loadOutsideApproval(): void {
this.outsideApprovalLoading = true;
this.errorMessage = '';
this.reportService
.outsideApproval(this.outsideApprovalFrom || undefined, this.outsideApprovalTo || undefined, undefined, this.tab)
.subscribe({
next: (rows) => {
this.outsideApprovalRows = rows ?? [];
this.outsideApprovalLoading = false;
},
error: (err) => {
this.outsideApprovalLoading = false;
this.outsideApprovalRows = [];
this.errorMessage = this.messageOf(err, 'Could not load deposit interest posted outside this report.');
}
});
}
closeOutsideApproval(): void {
this.showOutsideApproval = false;
}
get outsideApprovalTotal(): number {
return this.outsideApprovalRows.reduce((sum, r) => sum + Number(r.interestAmount || 0), 0);
}
/** Totals by product and scheme, so a branch can see at a glance where its unapproved interest sits. */
get outsideApprovalGroups(): { productName: string; fdType: string; accounts: number; interest: number }[] {
const map = new Map<string, { productName: string; fdType: string; accounts: number; interest: number }>();
this.outsideApprovalRows.forEach((r) => {
const g = map.get(r.productName) ?? { productName: r.productName, fdType: r.fdType, accounts: 0, interest: 0 };
g.accounts += 1;
g.interest += Number(r.interestAmount || 0);
map.set(r.productName, g);
});
return [...map.values()];
}
// ── Product summary: due for the product vs posted through this report vs posted at maturity ──
// Head office only, same as the branch-wise summary it reuses — a branch reads its own due and
// posted totals off the strip and tables above, which already cover it.
showProductSummary = false;
productSummaryLoading = false;
productSummaryFrom = '';
productSummaryTo = '';
private summaryRows: FdInterestSummaryRow[] = [];
private productOutsideRows: FdOutsideApprovalRow[] = [];
openProductSummary(): void {
this.showProductSummary = true;
if (!this.productSummaryTo) {
this.productSummaryTo = this.businessDateIso();
this.productSummaryFrom = `${this.productSummaryTo.slice(0, 7)}-01`;
}
this.loadProductSummary();
}
loadProductSummary(): void {
this.productSummaryLoading = true;
this.errorMessage = '';
let pending = 2;
const done = () => {
pending -= 1;
if (pending === 0) this.productSummaryLoading = false;
};
this.reportService.summary(this.productSummaryFrom || undefined, this.productSummaryTo || undefined).subscribe({
next: (rows) => {
this.summaryRows = rows ?? [];
done();
},
error: (err) => {
this.summaryRows = [];
this.errorMessage = this.messageOf(err, 'Could not load the product summary.');
done();
}
});
this.reportService
.outsideApproval(this.productSummaryFrom || undefined, this.productSummaryTo || undefined)
.subscribe({
next: (rows) => {
this.productOutsideRows = rows ?? [];
done();
},
error: () => {
this.productOutsideRows = [];
done();
}
});
}
closeProductSummary(): void {
this.showProductSummary = false;
}
/**
* One row per product and scheme: what posted through this report (and what is still pending
* approval or posting), plus what posted outside it — chiefly a deposit's last interest, paid the
* moment it matured rather than through a periodic report.
*/
get productSummaryRows(): {
productId: number;
productName: string;
fdType: string;
postingPeriodLabel: string;
posted: number;
pending: number;
maturityPosted: number;
total: number;
}[] {
const map = new Map<
number,
{
productId: number;
productName: string;
fdType: string;
postingPeriodLabel: string;
posted: number;
pending: number;
maturityPosted: number;
total: number;
}
>();
const get = (productId: number, productName: string, fdType: string, label: string) => {
const existing = map.get(productId);
if (existing) return existing;
const created = {
productId,
productName,
fdType,
postingPeriodLabel: label,
posted: 0,
pending: 0,
maturityPosted: 0,
total: 0
};
map.set(productId, created);
return created;
};
this.summaryRows.forEach((r) => {
const row = get(r.productId, r.productName, r.fdType, r.postingPeriodLabel);
const amount = Number(r.totalInterest || 0);
if (r.status === 'POSTED') {
row.posted += amount;
} else if (r.status === 'GENERATED' || r.status === 'APPROVED') {
row.pending += amount;
}
});
this.productOutsideRows.forEach((r) => {
const row = get(r.productId, r.productName, r.fdType, '');
row.maturityPosted += Number(r.interestAmount || 0);
});
map.forEach((row) => {
row.total = row.posted + row.pending + row.maturityPosted;
});
return [...map.values()].sort((a, b) => a.productName.localeCompare(b.productName));
}
get productSummaryGrandTotal(): { posted: number; pending: number; maturityPosted: number; total: number } {
return this.productSummaryRows.reduce(
(sum, r) => ({
posted: sum.posted + r.posted,
pending: sum.pending + r.pending,
maturityPosted: sum.maturityPosted + r.maturityPosted,
total: sum.total + r.total
}),
{ posted: 0, pending: 0, maturityPosted: 0, total: 0 }
);
}
private businessDateIso(): string {
const businessDate = this.settingsService.businessDate as any;
const parsed = businessDate ? new Date(businessDate) : new Date();
if (isNaN(parsed.getTime())) {
return new Date().toISOString().slice(0, 10);
}
const month = String(parsed.getMonth() + 1).padStart(2, '0');
const day = String(parsed.getDate()).padStart(2, '0');
return `${parsed.getFullYear()}-${month}-${day}`;
}
private messageOf(err: any, fallback: string): string {
return (
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.defaultUserMessage ?? err?.error?.message ?? fallback
);
}
}
@@ -0,0 +1,280 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
export type FdInterestReportStatus = 'GENERATED' | 'APPROVED' | 'POSTED' | 'SUPERSEDED';
/** Which branches a list covers: the user's own, or (head office only) every other branch. */
export type FdReportScope = 'own' | 'others';
/** The deposit scheme, which decides where a posting of interest ends up. */
export type FdType = 'SINGLE' | 'DOUBLE' | 'SPECIAL';
export interface FdInterestReport {
id: number;
productId: number;
productName: string;
fdType: FdType;
officeId: number;
officeName: string;
postingDate: string;
status: FdInterestReportStatus;
totalInterest: number;
accountCount: number;
generationMode: 'AUTO' | 'MANUAL';
generatedAt: string;
generatedBy: string | null;
approvedAt: string | null;
approvedBy: string | null;
approvedOnBehalf: boolean;
postedAt: string | null;
failureReason: string | null;
/** The product's posting cycle in words — Monthly, Quarterly, Half-yearly, Yearly. */
postingPeriodLabel: string;
/** Whether this user may regenerate it: their own branch, or the head office for a branch that did not open. */
canAct: boolean;
blockedReason: string | null;
/** Whether this user may approve it — false for the user who generated it, who cannot be its checker. */
canApprove: boolean;
approveBlockedReason: string | null;
}
export interface FdInterestReportLine {
fdAccountId: number;
accountNo: string;
clientName: string;
/**
* The day after the deposit's last paid period ended, or its activation date for a first-ever
* posting — null for a line posted before this was tracked. Paired with interestPostingDate, it is
* the span the interest was earned over.
*/
interestPeriodFrom: string | null;
/**
* The period this interest is for, as the interest engine dated it — not the day the branch closes.
* A deposit owed two unpaid periods appears on two lines, one per date.
*/
interestPostingDate: string;
interestAmount: number;
balanceBefore: number;
balanceAfter: number;
depositAmount: number | null;
interestRate: number | null;
maturityDate: string | null;
/** SPECIAL only: the linked savings account this interest is swept to after posting. */
payoutSavingsAccountId: number | null;
payoutAccountNo: string | null;
payoutTransactionId: number | null;
fdTransactionId: number | null;
}
export interface FdInterestReportDetail extends FdInterestReport {
payableGlCode: string | null;
payableGlName: string | null;
paidGlCode: string | null;
paidGlName: string | null;
lines: FdInterestReportLine[];
}
export interface FdInterestDue {
productId: number;
productName: string;
fdType: FdType;
officeId: number;
officeName: string;
postingDate: string;
accountCount: number;
totalInterest: number;
canAct: boolean;
blockedReason: string | null;
}
export interface FdInterestSummaryRow {
id: number;
postingDate: string;
officeId: number;
officeName: string;
productId: number;
productName: string;
fdType: FdType;
accountCount: number;
totalInterest: number;
status: FdInterestReportStatus;
approvedBy: string | null;
approvedOnBehalf: boolean;
approvedAt: string | null;
postedAt: string | null;
postingPeriodLabel: string;
}
/**
* One fixed deposit interest transaction that reached the customer's account without this report's
* approval — chiefly a deposit that matured before its own posting cycle completed, whose last period
* is settled directly by the maturity job. View only, for visibility.
*/
export interface FdOutsideApprovalRow {
transactionId: number;
fdAccountId: number;
accountNo: string;
clientName: string;
officeId: number;
officeName: string;
productId: number;
productName: string;
fdType: FdType;
interestFor: string;
interestAmount: number;
/** When the posting actually reached the account — not the period it is for. */
postedAt: string;
}
export interface FdInterestPreviewRow {
fdAccountId: number;
accountNo: string;
clientName: string;
officeId: number;
officeName: string;
productId: number;
productName: string;
fdType: FdType;
/** The day after the deposit's last paid period ended, or its activation date for the first one. */
interestPeriodFrom: string | null;
/** The period this interest belongs to, as the engine dated it. */
interestPostingDate: string;
depositAmount: number | null;
interestRate: number | null;
maturityDate: string | null;
balance: number;
interest: number;
payoutAccountNo: string | null;
}
/** One product's deposits due on the day being closed, as the day-close screen shows them. */
export interface FdDayCloseDueRow {
productId: number;
productName: string;
fdType: FdType;
accountCount: number;
totalInterest: number;
reportStatus: FdInterestReportStatus | 'NONE';
}
export interface FdDayCloseCheck {
businessDate: string;
canClose: boolean;
pendingProducts: string[];
due: FdDayCloseDueRow[];
}
/**
* The fixed deposit interest posting reports, one per branch and product: generated at noon on a
* posting date, approved by a second user of the branch, posted when the branch closes the day.
*/
@Injectable({ providedIn: 'root' })
export class FdInterestPostingReportService {
private http = inject(HttpClient);
private readonly base = '/fixeddepositinterestpostingreports';
/** scope 'own' is the user's own branch; 'others' (head office only) every other branch. */
list(
postingDate?: string,
status?: string,
officeId?: number,
scope?: FdReportScope
): Observable<FdInterestReport[]> {
let params = new HttpParams();
if (scope) params = params.set('scope', scope);
if (postingDate) params = params.set('postingDate', postingDate);
if (status) params = params.set('status', status);
if (officeId) params = params.set('officeId', String(officeId));
return this.http.get<FdInterestReport[]>(this.base, { params });
}
/** Branch and product pairs with deposit interest due by the date and no report yet. */
dueWithoutReport(postingDate?: string, scope?: FdReportScope): Observable<FdInterestDue[]> {
let params = new HttpParams();
if (scope) params = params.set('scope', scope);
if (postingDate) params = params.set('postingDate', postingDate);
return this.http.get<FdInterestDue[]>(`${this.base}/due-without-report`, { params });
}
/**
* The signed-in user's branch, whether it is the head office, and whether deposits are on the
* approval path at all — with that switched off these screens are empty by design.
*/
context(): Observable<{ officeId: number; officeName: string; headOffice: boolean; approvalEnabled: boolean }> {
return this.http.get<{ officeId: number; officeName: string; headOffice: boolean; approvalEnabled: boolean }>(
`${this.base}/context`
);
}
detail(reportId: number): Observable<FdInterestReportDetail> {
return this.http.get<FdInterestReportDetail>(`${this.base}/${reportId}`);
}
regenerate(
productId: number,
officeId: number,
postingDate: string
): Observable<{ reportId: number; status: string }> {
return this.http.post<{ reportId: number; status: string }>(
this.base,
{ productId, officeId, postingDate },
{ params: new HttpParams().set('command', 'regenerate') }
);
}
approve(reportId: number): Observable<{ reportId: number; status: string }> {
return this.http.post<{ reportId: number; status: string }>(
`${this.base}/${reportId}`,
{},
{ params: new HttpParams().set('command', 'approve') }
);
}
/** What each deposit has earned and not been paid, as if posted today. View only. */
preview(officeId?: number): Observable<FdInterestPreviewRow[]> {
let params = new HttpParams();
if (officeId) params = params.set('officeId', String(officeId));
return this.http.get<FdInterestPreviewRow[]>(`${this.base}/preview`, { params });
}
/** What stops a branch closing its day, and the deposits behind it. */
dayCloseCheck(date?: string, officeId?: number): Observable<FdDayCloseCheck> {
let params = new HttpParams();
if (date) params = params.set('date', date);
if (officeId) params = params.set('officeId', String(officeId));
return this.http.get<FdDayCloseCheck>(`${this.base}/day-close-check`, { params });
}
/** The head office's branch-wise view over a date range. */
summary(fromDate?: string, toDate?: string, productId?: number): Observable<FdInterestSummaryRow[]> {
let params = new HttpParams();
if (fromDate) params = params.set('fromDate', fromDate);
if (toDate) params = params.set('toDate', toDate);
if (productId) params = params.set('productId', String(productId));
return this.http.get<FdInterestSummaryRow[]>(`${this.base}/summary`, { params });
}
/** Deposit interest posted without this report's approval — a deposit maturing mid-cycle, chiefly. */
outsideApproval(
fromDate?: string,
toDate?: string,
officeId?: number,
scope?: FdReportScope
): Observable<FdOutsideApprovalRow[]> {
let params = new HttpParams();
if (scope) params = params.set('scope', scope);
if (fromDate) params = params.set('fromDate', fromDate);
if (toDate) params = params.set('toDate', toDate);
if (officeId) params = params.set('officeId', String(officeId));
return this.http.get<FdOutsideApprovalRow[]>(`${this.base}/outside-approval`, { params });
}
}
@@ -22,9 +22,134 @@
<input type="date" [(ngModel)]="postingDate" (change)="load()" />
</label>
<button class="ipr-btn" (click)="load()" [disabled]="loading"><fa-icon icon="undo"></fa-icon> Reload</button>
<button class="ipr-btn ipr-btn--primary" (click)="openPreview()" [disabled]="previewLoading">
<fa-icon icon="eye"></fa-icon> Preview interest (today)
</button>
</div>
</div>
@if (isHeadOffice) {
<div class="ipr-tabs">
<button class="ipr-tab" [class.is-active]="tab === 'own'" (click)="switchTab('own')">
<fa-icon icon="building"></fa-icon> My branch{{ myBranch ? ' — ' + myBranch : '' }}
</button>
<button class="ipr-tab" [class.is-active]="tab === 'others'" (click)="switchTab('others')">
<fa-icon icon="sitemap"></fa-icon> Other branches
</button>
</div>
@if (tab === 'others') {
<div class="ipr-alert ipr-alert--info">
<fa-icon icon="info-circle"></fa-icon>
<div>
Each branch generates and approves its own reports. The head office can act here only for a branch that has
not opened the day, and only once every other open branch has closed. Anything it approves is marked (HO).
</div>
</div>
}
}
@if (showPreview) {
<div class="ipr-card">
<div class="ipr-detail-head">
<div>
<h3>Interest preview — earned up to {{ previewAsOf }}</h3>
<div class="muted">
What each account would be paid if interest were posted today. View only — nothing is saved, approved or
posted.
</div>
</div>
<div class="ipr-total">
<span>Total so far</span>
<strong>₹{{ previewTotal | number: '1.2-2' }}</strong>
<span class="muted">{{ previewRows.length }} account(s)</span>
</div>
</div>
<div class="ipr-preview-actions">
<button class="ipr-btn ipr-btn--slim" (click)="openPreview()" [disabled]="previewLoading">
<fa-icon icon="undo"></fa-icon> Refresh
</button>
<button
class="ipr-btn ipr-btn--slim"
(click)="exportPreview()"
[disabled]="previewLoading || previewRows.length === 0"
>
<fa-icon icon="file-excel"></fa-icon> Export Excel
</button>
<button class="ipr-btn ipr-btn--slim" (click)="closePreview()"><fa-icon icon="times"></fa-icon> Close</button>
</div>
@if (previewLoading) {
<div class="ipr-empty">Calculating interest up to today…</div>
} @else {
<table class="ipr-table">
<thead>
<tr>
<th>Branch</th>
<th>Product</th>
<th>Posting date</th>
<th class="num">Accounts</th>
<th class="num">Interest so far</th>
</tr>
</thead>
<tbody>
@for (g of previewGroups; track g.officeName + g.productName) {
<tr>
<td>{{ g.officeName }}</td>
<td>
<strong>{{ g.productName }}</strong>
</td>
<td>{{ g.postingDate }}</td>
<td class="num">{{ g.accounts }}</td>
<td class="num">₹{{ g.interest | number: '1.2-2' }}</td>
</tr>
} @empty {
<tr>
<td colspan="5" class="ipr-empty">No savings account has interest accrued in its current cycle.</td>
</tr>
}
</tbody>
</table>
@if (previewRows.length > 0) {
<table class="ipr-table">
<thead>
<tr>
<th>Branch</th>
<th>Product</th>
<th>Account no</th>
<th>Account holder</th>
<th class="num">Balance</th>
<th class="num">Rate %</th>
<th>Cycle</th>
<th class="num">Interest so far</th>
</tr>
</thead>
<tbody>
@for (r of previewRows; track r.savingsAccountId) {
<tr>
<td>{{ r.officeName }}</td>
<td>{{ r.productName }}</td>
<td>{{ r.accountNo }}</td>
<td>{{ r.clientName }}</td>
<td class="num">₹{{ r.balance | number: '1.2-2' }}</td>
<td class="num">{{ r.rate | number: '1.2-2' }}</td>
<td class="muted">{{ r.cycleStart }} → {{ r.postingDate }}</td>
<td class="num">₹{{ r.interest | number: '1.2-2' }}</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="7"><strong>Total</strong></td>
<td class="num">
<strong>₹{{ previewTotal | number: '1.2-2' }}</strong>
</td>
</tr>
</tfoot>
</table>
}
}
</div>
}
@if (errorMessage) {
<div class="ipr-alert ipr-alert--error">
<fa-icon icon="exclamation-triangle"></fa-icon>
@@ -52,9 +177,13 @@
@for (d of due; track d.productId + '-' + d.officeId) {
<div class="ipr-due-row">
<strong>{{ d.productName }}</strong> <span>· {{ d.officeName }} · {{ d.postingDate }}</span>
<button class="ipr-btn ipr-btn--primary ipr-btn--slim" (click)="generate(d)" [disabled]="busy">
<fa-icon icon="sync"></fa-icon> Generate report
</button>
@if (d.canAct) {
<button class="ipr-btn ipr-btn--primary ipr-btn--slim" (click)="generate(d)" [disabled]="busy">
<fa-icon icon="sync"></fa-icon> {{ tab === 'others' ? 'Generate on behalf' : 'Generate report' }}
</button>
} @else if (d.blockedReason) {
<span class="ipr-blocked">{{ d.blockedReason }}</span>
}
</div>
}
</div>
@@ -114,9 +243,12 @@
}
@if (canApprove(r)) {
<button class="ipr-btn ipr-btn--primary ipr-btn--slim" (click)="approve(r)" [disabled]="busy">
<fa-icon icon="check"></fa-icon> Approve
<fa-icon icon="check"></fa-icon> {{ tab === 'others' ? 'Approve on behalf' : 'Approve' }}
</button>
}
@if (!r.canAct && r.blockedReason && (r.status === 'GENERATED' || r.status === 'APPROVED')) {
<span class="ipr-blocked">{{ r.blockedReason }}</span>
}
</td>
</tr>
} @empty {
@@ -274,3 +274,54 @@
font-weight: 600;
color: var(--i-amber);
}
// Preview: refresh / export / close, above the totals.
.ipr-preview-actions {
display: flex;
gap: 6px;
justify-content: flex-end;
padding: 0 16px 10px;
}
.ipr-tabs {
display: flex;
gap: 4px;
margin-bottom: 12px;
border-bottom: 1px solid var(--i-g200);
}
.ipr-tab {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
margin-bottom: -1px;
font-size: 13px;
font-weight: 600;
color: var(--i-g500);
cursor: pointer;
background: transparent;
border: 1px solid transparent;
border-radius: 6px 6px 0 0;
&.is-active {
color: var(--i-navy);
background: #fff;
border-color: var(--i-g200);
border-bottom-color: #fff;
}
}
.ipr-alert--info {
background: var(--i-blue-l);
color: var(--i-blue);
}
.ipr-blocked {
display: inline-block;
max-width: 320px;
font-size: 11px;
line-height: 1.35;
color: var(--i-g500);
white-space: normal;
}
@@ -11,11 +11,14 @@ import { FormsModule } from '@angular/forms';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { SettingsService } from 'app/settings/settings.service';
import { exportWorkbook } from '../cashier/cashier-export';
import {
InterestPostingDue,
InterestPostingReport,
InterestPostingReportDetail,
InterestPostingReportService
InterestPostingReportService,
InterestPreviewRow,
ReportScope
} from './interest-posting-report.service';
/**
@@ -51,15 +54,48 @@ export class InterestPostingReportComponent implements OnInit {
/** Products with interest due and no report yet — the noon job ran before they had anything due, or failed. */
due: InterestPostingDue[] = [];
/** 'own' — the user's own branch; 'others' — every other branch, a head-office-only tab. */
tab: ReportScope = 'own';
isHeadOffice = false;
myBranch = '';
ngOnInit(): void {
this.postingDate = this.businessDateIso();
this.reportService.context().subscribe({
next: (ctx) => {
this.isHeadOffice = !!ctx?.headOffice;
this.myBranch = ctx?.officeName ?? '';
},
error: () => (this.isHeadOffice = false)
});
this.load();
}
switchTab(tab: ReportScope): void {
if (this.tab === tab) {
return;
}
this.tab = tab;
this.selected = null;
this.notice = '';
this.load();
}
/** Acting for another branch is the head office's exception, so it is spelt out before it happens. */
private confirmOnBehalf(officeName: string, action: string): boolean {
if (this.tab !== 'others') {
return true;
}
return confirm(
`${officeName} has not opened today. You are about to ${action} its interest posting report on its behalf, ` +
'from the head office. Continue?'
);
}
load(): void {
this.loading = true;
this.errorMessage = '';
this.reportService.list(this.postingDate || undefined).subscribe({
this.reportService.list(this.postingDate || undefined, undefined, undefined, this.tab).subscribe({
next: (reports) => {
this.reports = reports ?? [];
this.loading = false;
@@ -75,13 +111,16 @@ export class InterestPostingReportComponent implements OnInit {
this.errorMessage = this.messageOf(err, 'Could not load the interest posting reports.');
}
});
this.reportService.dueWithoutReport(this.postingDate || undefined).subscribe({
this.reportService.dueWithoutReport(this.postingDate || undefined, this.tab).subscribe({
next: (due) => (this.due = due ?? []),
error: () => (this.due = [])
});
}
generate(item: InterestPostingDue): void {
if (!this.confirmOnBehalf(item.officeName, 'generate')) {
return;
}
this.busy = true;
this.notice = '';
this.errorMessage = '';
@@ -111,7 +150,8 @@ export class InterestPostingReportComponent implements OnInit {
if (
!confirm(
`Regenerate the interest posting report for ${report.productName} — ${report.officeName}? It will need approving again.`
)
) ||
!this.confirmOnBehalf(report.officeName, 'regenerate')
) {
return;
}
@@ -138,7 +178,8 @@ export class InterestPostingReportComponent implements OnInit {
!confirm(
`Approve interest of ₹${report.totalInterest} across ${report.accountCount} account(s) for ${report.productName}? ` +
'It will be posted when the branch closes the day.'
)
) ||
!this.confirmOnBehalf(report.officeName, 'approve')
) {
return;
}
@@ -177,8 +218,165 @@ export class InterestPostingReportComponent implements OnInit {
);
}
/** Pending reports that block this user's own day close; another branch's pending reports block only that branch. */
get pendingCount(): number {
return this.reports.filter((r) => r.status === 'GENERATED').length;
return this.tab === 'own' ? this.reports.filter((r) => r.status === 'GENERATED').length : 0;
}
// ── Preview: what each account has earned so far, as if posted today. View only. ──
showPreview = false;
previewLoading = false;
previewRows: InterestPreviewRow[] = [];
previewAsOf = '';
openPreview(): void {
this.showPreview = true;
this.previewLoading = true;
this.errorMessage = '';
this.reportService.preview().subscribe({
next: (rows) => {
this.previewRows = rows ?? [];
this.previewAsOf = this.previewRows[0]?.asOf ?? this.businessDateIso();
this.previewLoading = false;
},
error: (err) => {
this.previewLoading = false;
this.previewRows = [];
this.errorMessage = this.messageOf(err, 'Could not load the interest preview.');
}
});
}
closePreview(): void {
this.showPreview = false;
}
get previewTotal(): number {
return this.previewRows.reduce((sum, r) => sum + Number(r.interest || 0), 0);
}
/** Totals by branch and product, for the summary above the account list and the export. */
get previewGroups(): {
officeName: string;
productName: string;
postingDate: string;
accounts: number;
interest: number;
}[] {
const map = new Map<
string,
{ officeName: string; productName: string; postingDate: string; accounts: number; interest: number }
>();
this.previewRows.forEach((r) => {
const key = `${r.officeName}|${r.productName}`;
const g = map.get(key) ?? {
officeName: r.officeName,
productName: r.productName,
postingDate: r.postingDate,
accounts: 0,
interest: 0
};
g.accounts += 1;
g.interest += Number(r.interest || 0);
map.set(key, g);
});
return [...map.values()];
}
exportPreview(): void {
const stamp = `Interest earned to ${this.previewAsOf} — preview, not posted`;
exportWorkbook(
[
{
sheetName: 'By account',
title: 'Savings Interest Preview',
subtitle: stamp,
headers: [
'Branch',
'Product',
'Account No',
'Customer',
'Balance',
'Rate %',
'Cycle Start',
'Posting Date',
'Interest So Far'
],
widths: [
18,
22,
20,
26,
16,
8,
12,
12,
16
],
rows: [
...this.previewRows.map((r) => [
r.officeName,
r.productName,
r.accountNo,
r.clientName,
Number(r.balance || 0),
Number(r.rate || 0),
r.cycleStart,
r.postingDate,
Number(r.interest || 0)
]),
[
'TOTAL',
'',
'',
'',
'',
'',
'',
'',
this.previewTotal
]
]
},
{
sheetName: 'By branch & product',
title: 'Savings Interest Preview — Totals',
subtitle: stamp,
headers: [
'Branch',
'Product',
'Posting Date',
'Accounts',
'Interest So Far'
],
widths: [
18,
22,
12,
10,
16
],
rows: [
...this.previewGroups.map((g) => [
g.officeName,
g.productName,
g.postingDate,
g.accounts,
g.interest
]),
[
'TOTAL',
'',
'',
this.previewRows.length,
this.previewTotal
]
]
}
],
`savings-interest-preview-${this.previewAsOf}`
);
}
private businessDateIso(): string {
@@ -12,6 +12,9 @@ import { Observable } from 'rxjs';
export type InterestPostingReportStatus = 'GENERATED' | 'APPROVED' | 'POSTED' | 'SUPERSEDED';
/** Which branches a list covers: the user's own, or (head office only) every other branch. */
export type ReportScope = 'own' | 'others';
export interface InterestPostingReport {
id: number;
productId: number;
@@ -32,6 +35,8 @@ export interface InterestPostingReport {
failureReason: string | null;
/** Whether the signed-in user may regenerate or approve it: their own branch, or the head office for a branch that did not open. */
canAct: boolean;
/** Why not, where canAct is false. */
blockedReason: string | null;
}
export interface InterestPostingReportLine {
@@ -60,6 +65,8 @@ export interface InterestPostingDue {
officeId: number;
officeName: string;
postingDate: string;
canAct: boolean;
blockedReason: string | null;
}
export interface InterestPostingSummaryRow {
@@ -78,6 +85,22 @@ export interface InterestPostingSummaryRow {
postedAt: string | null;
}
export interface InterestPreviewRow {
officeId: number;
officeName: string;
productId: number;
productName: string;
savingsAccountId: number;
accountNo: string;
clientName: string;
balance: number;
rate: number;
cycleStart: string;
postingDate: string;
asOf: string;
interest: number;
}
/**
* The savings interest posting reports, one per branch and product: generated at noon on a posting
* date, approved by the branch, posted when the branch closes the day.
@@ -87,8 +110,15 @@ export class InterestPostingReportService {
private http = inject(HttpClient);
private readonly base = '/savingsinterestpostingreports';
list(postingDate?: string, status?: string, officeId?: number): Observable<InterestPostingReport[]> {
/** scope 'own' is the user's own branch; 'others' (head office only) every other branch. */
list(
postingDate?: string,
status?: string,
officeId?: number,
scope?: ReportScope
): Observable<InterestPostingReport[]> {
let params = new HttpParams();
if (scope) params = params.set('scope', scope);
if (postingDate) params = params.set('postingDate', postingDate);
if (status) params = params.set('status', status);
if (officeId) params = params.set('officeId', String(officeId));
@@ -96,12 +126,18 @@ export class InterestPostingReportService {
}
/** Branch and product pairs with interest due by the date and no report yet, that this user may generate. */
dueWithoutReport(postingDate?: string): Observable<InterestPostingDue[]> {
dueWithoutReport(postingDate?: string, scope?: ReportScope): Observable<InterestPostingDue[]> {
let params = new HttpParams();
if (scope) params = params.set('scope', scope);
if (postingDate) params = params.set('postingDate', postingDate);
return this.http.get<InterestPostingDue[]>(`${this.base}/due-without-report`, { params });
}
/** The signed-in user's branch, and whether it is the head office. */
context(): Observable<{ officeId: number; officeName: string; headOffice: boolean }> {
return this.http.get<{ officeId: number; officeName: string; headOffice: boolean }>(`${this.base}/context`);
}
detail(reportId: number): Observable<InterestPostingReportDetail> {
return this.http.get<InterestPostingReportDetail>(`${this.base}/${reportId}`);
}
@@ -126,6 +162,13 @@ export class InterestPostingReportService {
);
}
/** Interest each account has earned so far this cycle, as if posted today. View only. */
preview(officeId?: number): Observable<InterestPreviewRow[]> {
let params = new HttpParams();
if (officeId) params = params.set('officeId', String(officeId));
return this.http.get<InterestPreviewRow[]>(`${this.base}/preview`, { params });
}
/** The head office's branch-wise view over a date range. */
summary(fromDate?: string, toDate?: string, productId?: number): Observable<InterestPostingSummaryRow[]> {
let params = new HttpParams();
@@ -24,6 +24,10 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
type AccountingSectionKey = 'assets' | 'liabilities' | 'expenses' | 'income';
const SAVINGS_INTEREST_PAYABLE_ACCOUNT_NAME = 'Savings Interest Payable';
const FIXED_DEPOSIT_INTEREST_PAID_ACCOUNT_NAME = 'Fixed Deposit Interest Paid';
const SAVINGS_INTEREST_PAID_ACCOUNT_NAME = 'Savings Interest Paid';
type AccountingRowConfig = {
key: string;
label: string;
@@ -128,6 +132,7 @@ export class FixedDepositProductAccountingStepComponent implements OnInit, OnCha
feesReceivableAccountId: [''],
penaltiesReceivableAccountId: [''],
interestPayableAccountId: [''],
savingsInterestPaidAccountId: [''],
escheatLiabilityId: [''],
assetsOtherGlMappings: this.formBuilder.array([]),
liabilitiesOtherGlMappings: this.formBuilder.array([]),
@@ -158,6 +163,20 @@ export class FixedDepositProductAccountingStepComponent implements OnInit, OnCha
return '';
}
private extractNamedGlMapping(
otherGlMappings: any[],
accountName: string,
controlName: string,
options: GLAccount[]
): any[] {
const match = (otherGlMappings || []).find((mapping) => mapping?.accountName === accountName);
if (match) {
const glAccountId = match.glAccountId || options.find((option) => option.glCode === match.glCode)?.id || '';
this.fixedDepositProductAccountingForm.get(controlName)?.patchValue(glAccountId);
}
return (otherGlMappings || []).filter((mapping) => mapping?.accountName !== accountName);
}
private patchAccountingValues(mappings: any) {
this.fixedDepositProductAccountingForm.patchValue({
savingsReferenceAccountId: this.extractAccountId(
@@ -200,7 +219,18 @@ export class FixedDepositProductAccountingStepComponent implements OnInit, OnCha
glMappings?.liabilities || [],
this.liabilityAccountOptions
);
this.setOtherGlMappings('expensesOtherGlMappings', glMappings?.expenses || [], this.expenseAccountOptions);
const fromExpenses = this.extractNamedGlMapping(
this.extractNamedGlMapping(
glMappings?.expenses || [],
FIXED_DEPOSIT_INTEREST_PAID_ACCOUNT_NAME,
'savingsInterestPaidAccountId',
this.expenseAccountOptions
),
SAVINGS_INTEREST_PAID_ACCOUNT_NAME,
'savingsInterestPaidAccountId',
this.expenseAccountOptions
);
this.setOtherGlMappings('expensesOtherGlMappings', fromExpenses, this.expenseAccountOptions);
this.setOtherGlMappings('incomeOtherGlMappings', glMappings?.income || [], this.incomeAccountOptions);
}
}
@@ -266,10 +296,26 @@ export class FixedDepositProductAccountingStepComponent implements OnInit, OnCha
}))
.filter((item: { accountName: string; glCode: string }) => !!item.accountName || !!item.glCode);
const namedAsOtherGlMapping = (controlName: string, accountName: string, options: GLAccount[]) => {
const glAccountId = this.fixedDepositProductAccountingForm.get(controlName)?.value;
if (!glAccountId) {
return [];
}
const glCode = options.find((o) => o.id === glAccountId)?.glCode || '';
return glCode ? [{ accountName, glCode }] : [];
};
const otherGlMappings = {
assets: map('assetsOtherGlMappings', this.assetAccountOptions),
liabilities: map('liabilitiesOtherGlMappings', this.liabilityAccountOptions),
expenses: map('expensesOtherGlMappings', this.expenseAccountOptions),
expenses: [
...namedAsOtherGlMapping(
'savingsInterestPaidAccountId',
FIXED_DEPOSIT_INTEREST_PAID_ACCOUNT_NAME,
this.expenseAccountOptions
),
...map('expensesOtherGlMappings', this.expenseAccountOptions)
],
income: map('incomeOtherGlMappings', this.incomeAccountOptions)
};
return Object.values(otherGlMappings).some((v) => v.length) ? otherGlMappings : null;
@@ -416,6 +462,13 @@ export class FixedDepositProductAccountingStepComponent implements OnInit, OnCha
controlName: 'interestOnSavingsAccountId',
options: this.expenseAccountOptions,
required: true
},
{
key: 'savingsInterestPaidAccountId',
label: 'Fixed Deposit Interest Paid',
inputLabel: 'Fixed deposit interest paid',
controlName: 'savingsInterestPaidAccountId',
options: this.expenseAccountOptions
}
],
otherMappingsControlName: 'expensesOtherGlMappings',
@@ -456,6 +509,7 @@ export class FixedDepositProductAccountingStepComponent implements OnInit, OnCha
liabilitiesOtherGlMappings,
expensesOtherGlMappings,
incomeOtherGlMappings,
savingsInterestPaidAccountId,
...accounting
} = this.fixedDepositProductAccountingForm.value;
const otherGlMappings = this.getOtherGlMappings();
+27 -27
View File
@@ -15,43 +15,43 @@ $gray-900: #0f172a;
// ── Container ──
.pd-container {
max-width: 1100px;
max-width: 1280px;
margin: 0 auto;
padding: 24px 20px;
padding: 12px 16px;
}
// ── Page Header ──
.pd-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 28px;
gap: 10px;
margin-bottom: 14px;
&__icon {
width: 44px;
height: 44px;
border-radius: 10px;
width: 34px;
height: 34px;
border-radius: 7px;
background: linear-gradient(135deg, #0d3d78, #1a5276);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 18px;
font-size: 14px;
flex-shrink: 0;
}
&__title {
font-size: 20px;
font-size: 16px;
font-weight: 700;
color: $gray-900;
margin: 0;
letter-spacing: -0.3px;
letter-spacing: -0.2px;
}
&__sub {
font-size: 13px;
font-size: 11.5px;
color: $gray-500;
margin: 2px 0 0;
margin: 1px 0 0;
}
}
@@ -59,7 +59,7 @@ $gray-900: #0f172a;
.pd-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
gap: 8px;
@media (width <= 768px) {
grid-template-columns: 1fr;
@@ -70,11 +70,11 @@ $gray-900: #0f172a;
.pd-card {
display: flex;
align-items: center;
gap: 14px;
padding: 14px 16px;
gap: 10px;
padding: 8px 12px;
background: #fff;
border: 1px solid $border;
border-radius: 10px;
border-radius: 7px;
text-decoration: none;
cursor: pointer;
transition: all 0.15s ease;
@@ -82,7 +82,7 @@ $gray-900: #0f172a;
&:hover {
border-color: #cbd5e1;
background: $gray-50;
box-shadow: 0 2px 8px rgb(0 0 0 / 4%);
box-shadow: 0 1px 6px rgb(0 0 0 / 4%);
.pd-card__arrow {
color: $gray-700;
@@ -91,13 +91,13 @@ $gray-900: #0f172a;
}
&__icon {
width: 40px;
height: 40px;
border-radius: 9px;
width: 30px;
height: 30px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
font-size: 15px;
font-size: 12.5px;
flex-shrink: 0;
&--blue {
@@ -167,25 +167,25 @@ $gray-900: #0f172a;
}
&__title {
font-size: 13.5px;
font-size: 12px;
font-weight: 600;
color: $gray-900;
margin: 0;
line-height: 1.3;
line-height: 1.25;
}
&__desc {
font-size: 11.5px;
font-size: 10.5px;
color: $gray-400;
margin: 3px 0 0;
line-height: 1.35;
margin: 1px 0 0;
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&__arrow {
font-size: 11px;
font-size: 10px;
color: #cbd5e1;
flex-shrink: 0;
transition: all 0.15s ease;
@@ -24,6 +24,10 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
type AccountingSectionKey = 'assets' | 'liabilities' | 'expenses' | 'income';
const SAVINGS_INTEREST_PAYABLE_ACCOUNT_NAME = 'Savings Interest Payable';
const RECURRING_DEPOSIT_INTEREST_PAID_ACCOUNT_NAME = 'Recurring Deposit Interest Paid';
const SAVINGS_INTEREST_PAID_ACCOUNT_NAME = 'Savings Interest Paid';
type AccountingRowConfig = {
key: string;
label: string;
@@ -122,6 +126,7 @@ export class RecurringDepositProductAccountingStepComponent implements OnInit {
feesReceivableAccountId: [''],
penaltiesReceivableAccountId: [''],
interestPayableAccountId: [''],
savingsInterestPaidAccountId: [''],
escheatLiabilityId: [''],
assetsOtherGlMappings: this.formBuilder.array([]),
liabilitiesOtherGlMappings: this.formBuilder.array([]),
@@ -136,6 +141,20 @@ export class RecurringDepositProductAccountingStepComponent implements OnInit {
});
}
private extractNamedGlMapping(
otherGlMappings: any[],
accountName: string,
controlName: string,
options: GLAccount[]
): any[] {
const match = (otherGlMappings || []).find((mapping) => mapping?.accountName === accountName);
if (match) {
const glAccountId = match.glAccountId || options.find((option) => option.glCode === match.glCode)?.id || '';
this.recurringDepositProductAccountingForm.get(controlName)?.patchValue(glAccountId);
}
return (otherGlMappings || []).filter((mapping) => mapping?.accountName !== accountName);
}
private patchAccountingValues(mappings: any) {
this.recurringDepositProductAccountingForm.patchValue({
savingsReferenceAccountId: mappings.savingsReferenceAccount?.id || '',
@@ -158,7 +177,18 @@ export class RecurringDepositProductAccountingStepComponent implements OnInit {
glMappings?.liabilities || [],
this.liabilityAccountOptions
);
this.setOtherGlMappings('expensesOtherGlMappings', glMappings?.expenses || [], this.expenseAccountOptions);
const fromExpenses = this.extractNamedGlMapping(
this.extractNamedGlMapping(
glMappings?.expenses || [],
RECURRING_DEPOSIT_INTEREST_PAID_ACCOUNT_NAME,
'savingsInterestPaidAccountId',
this.expenseAccountOptions
),
SAVINGS_INTEREST_PAID_ACCOUNT_NAME,
'savingsInterestPaidAccountId',
this.expenseAccountOptions
);
this.setOtherGlMappings('expensesOtherGlMappings', fromExpenses, this.expenseAccountOptions);
this.setOtherGlMappings('incomeOtherGlMappings', glMappings?.income || [], this.incomeAccountOptions);
}
}
@@ -224,10 +254,26 @@ export class RecurringDepositProductAccountingStepComponent implements OnInit {
}))
.filter((item: { accountName: string; glCode: string }) => !!item.accountName || !!item.glCode);
const namedAsOtherGlMapping = (controlName: string, accountName: string, options: GLAccount[]) => {
const glAccountId = this.recurringDepositProductAccountingForm.get(controlName)?.value;
if (!glAccountId) {
return [];
}
const glCode = options.find((o) => o.id === glAccountId)?.glCode || '';
return glCode ? [{ accountName, glCode }] : [];
};
const otherGlMappings = {
assets: map('assetsOtherGlMappings', this.assetAccountOptions),
liabilities: map('liabilitiesOtherGlMappings', this.liabilityAccountOptions),
expenses: map('expensesOtherGlMappings', this.expenseAccountOptions),
expenses: [
...namedAsOtherGlMapping(
'savingsInterestPaidAccountId',
RECURRING_DEPOSIT_INTEREST_PAID_ACCOUNT_NAME,
this.expenseAccountOptions
),
...map('expensesOtherGlMappings', this.expenseAccountOptions)
],
income: map('incomeOtherGlMappings', this.incomeAccountOptions)
};
return Object.values(otherGlMappings).some((v) => v.length) ? otherGlMappings : null;
@@ -360,11 +406,18 @@ export class RecurringDepositProductAccountingStepComponent implements OnInit {
rows: [
{
key: 'interestOnSavingsAccountId',
label: 'Interest On Savings Account',
inputLabel: 'Interest on savings account',
label: 'Interest On Recurring Deposits',
inputLabel: 'Interest on recurring deposits',
controlName: 'interestOnSavingsAccountId',
options: this.expenseAccountOptions,
required: true
},
{
key: 'savingsInterestPaidAccountId',
label: 'Recurring Deposit Interest Paid',
inputLabel: 'Recurring deposit interest paid',
controlName: 'savingsInterestPaidAccountId',
options: this.expenseAccountOptions
}
],
otherMappingsControlName: 'expensesOtherGlMappings',
@@ -405,6 +458,7 @@ export class RecurringDepositProductAccountingStepComponent implements OnInit {
liabilitiesOtherGlMappings,
expensesOtherGlMappings,
incomeOtherGlMappings,
savingsInterestPaidAccountId,
...accounting
} = this.recurringDepositProductAccountingForm.value;
const otherGlMappings = this.getOtherGlMappings();
@@ -144,7 +144,7 @@
</button>
}
@if (savingProductFormValid && selectedTabIndex === totalTabs - 1) {
<button mat-raised-button color="primary" (click)="submit()">
<button mat-raised-button color="primary" [disabled]="submitting" (click)="submit()">
<i class="fas fa-check" style="margin-right: 8px"></i>
{{ 'labels.buttons.Submit' | translate }}
</button>
@@ -70,6 +70,10 @@ export class CreateSavingProductComponent {
savingProductsTemplate: any;
accountingRuleData: string[] = [];
selectedTabIndex = 0;
/** Guards against repeat clicks on Submit: the product name is unique in the database, so a
* second request for the same form fails with a duplicate key error even though the first one
* created the product. */
submitting = false;
/**
* @param {ActivatedRoute} route Activated Route.
@@ -162,8 +166,11 @@ export class CreateSavingProductComponent {
}
submit() {
if (this.submitting) {
return;
}
this.submitting = true;
const savingProduct = this.prepareSavingProductPayload();
console.log('Saving product submit payload:', savingProduct);
this.productsService.createSavingProduct(savingProduct).subscribe({
next: (response: any) => {
if (response?.rollbackTransaction) {
@@ -181,6 +188,11 @@ export class CreateSavingProductComponent {
{ relativeTo: this.route }
);
}
},
error: () => {
// the error itself is reported by the global error handler; just let the user correct the
// form and try again
this.submitting = false;
}
});
}
@@ -88,7 +88,7 @@ export class SavingProductAccountingStepComponent implements OnInit {
liabilityAccountOptions: GLAccount[] = [];
expenseAccountOptions: GLAccount[] = [];
incomeAccountOptions: GLAccount[] = [];
/** Savings Interest Paid nets to nil each posting, so it may be a liability clearing GL or an expense GL. */
/** Savings Interest Paid sits under Expenses and offers expense GLs; it nets to nil at each posting. */
paidAccountOptions: GLAccount[] = [];
assetAccountOptions: GLAccount[] = [];
paymentTypeOptions: any[] = [];
@@ -116,7 +116,7 @@ export class SavingProductAccountingStepComponent implements OnInit {
this.liabilityAccountOptions = accountingMappingOptions.liabilityAccountOptions || [];
this.expenseAccountOptions = accountingMappingOptions.expenseAccountOptions || [];
this.incomeAccountOptions = accountingMappingOptions.incomeAccountOptions || [];
this.paidAccountOptions = this.liabilityAccountOptions.concat(this.expenseAccountOptions);
this.paidAccountOptions = this.expenseAccountOptions;
this.paymentTypeOptions = this.savingProductsTemplate?.paymentTypeOptions || [];
this.fundSourceAccountOptions = this.assetAccountOptions.concat(this.liabilityAccountOptions);
this.buildAccountingSectionConfigs();
@@ -204,9 +204,9 @@ export class SavingProductAccountingStepComponent implements OnInit {
// Backend serializes as 'otherGlMappingsMap'; fall back to 'otherGlMappings' for compatibility
const glMappings = mappings.otherGlMappingsMap || mappings.otherGlMappings;
this.setOtherGlMappings('assetsOtherGlMappings', glMappings?.assets || [], this.assetAccountOptions);
// Payable and Paid are liability rows, saved under liabilities. A product saved while they sat
// under expenses still carries them there, so both buckets are read and the named entries are
// stripped out of either one's "Other" rows.
// Payable is a liability row, saved under liabilities; Paid an expense row, saved under
// expenses. Products saved before either move carry them in the other bucket, so both buckets
// are read and the named entries are stripped out of either one's "Other" rows.
const fromLiabilities = this.extractNamedGlMapping(
this.extractNamedGlMapping(
glMappings?.liabilities || [],
@@ -219,12 +219,13 @@ export class SavingProductAccountingStepComponent implements OnInit {
this.paidAccountOptions
);
this.setOtherGlMappings('liabilitiesOtherGlMappings', fromLiabilities, this.liabilityAccountOptions);
const fromExpenses = [
SAVINGS_INTEREST_PAYABLE_ACCOUNT_NAME,
SAVINGS_INTEREST_PAID_ACCOUNT_NAME
].reduce(
(remaining, name) => remaining.filter((mapping: any) => mapping?.accountName !== name),
glMappings?.expenses || []
const fromExpenses = this.extractNamedGlMapping(
(glMappings?.expenses || []).filter(
(mapping: any) => mapping?.accountName !== SAVINGS_INTEREST_PAYABLE_ACCOUNT_NAME
),
SAVINGS_INTEREST_PAID_ACCOUNT_NAME,
'savingsInterestPaidAccountId',
this.paidAccountOptions
);
this.setOtherGlMappings('expensesOtherGlMappings', fromExpenses, this.expenseAccountOptions);
this.setOtherGlMappings('incomeOtherGlMappings', glMappings?.income || [], this.incomeAccountOptions);
@@ -340,14 +341,18 @@ export class SavingProductAccountingStepComponent implements OnInit {
SAVINGS_INTEREST_PAYABLE_ACCOUNT_NAME,
this.liabilityAccountOptions
),
...mapOtherGlMappings('liabilitiesOtherGlMappings', this.liabilityAccountOptions)
],
// The backend finds Savings Interest Paid by name in any bucket; it sits under expenses
// because that is where the screen shows it.
expenses: [
...namedAsOtherGlMapping(
'savingsInterestPaidAccountId',
SAVINGS_INTEREST_PAID_ACCOUNT_NAME,
this.paidAccountOptions
),
...mapOtherGlMappings('liabilitiesOtherGlMappings', this.liabilityAccountOptions)
...mapOtherGlMappings('expensesOtherGlMappings', this.expenseAccountOptions)
],
expenses: mapOtherGlMappings('expensesOtherGlMappings', this.expenseAccountOptions),
income: mapOtherGlMappings('incomeOtherGlMappings', this.incomeAccountOptions)
};
@@ -537,16 +542,6 @@ export class SavingProductAccountingStepComponent implements OnInit {
options: this.liabilityAccountOptions,
required: true
},
{
// A pass-through between the payable and the members that closes each posting at nil, so a
// liability clearing GL and an expense GL both work; both kinds are offered.
key: 'savingsInterestPaidAccountId',
label: 'Savings Interest Paid',
inputLabel: 'Savings interest paid',
controlName: 'savingsInterestPaidAccountId',
options: this.paidAccountOptions,
required: true
},
{
key: 'interestPayableAccountId',
label: 'Interest Payable Account',
@@ -582,6 +577,15 @@ export class SavingProductAccountingStepComponent implements OnInit {
options: this.expenseAccountOptions,
required: true
},
{
// A pass-through between the payable and the members that closes each posting at nil.
key: 'savingsInterestPaidAccountId',
label: 'Savings Interest Paid',
inputLabel: 'Savings interest paid',
controlName: 'savingsInterestPaidAccountId',
options: this.paidAccountOptions,
required: true
},
{
// Cash has no paise, so a closing balance of 9,883.79 is handed over as 9,883 and the
// 79 paise land here rather than on the member's own ledger.
@@ -92,6 +92,14 @@ const routes: Routes = [
(m) => m.InterestPostingReportComponent
)
},
{
path: 'fd-interest-posting',
data: { title: 'FD Interest Posting Reports', breadcrumb: 'FD Interest Posting Reports' },
loadComponent: () =>
import('../fd-interest-posting-report/fd-interest-posting-report.component').then(
(m) => m.FdInterestPostingReportComponent
)
},
{
path: 'dormancy',
data: { title: 'Inactive, Dormant & DEAF', breadcrumb: 'Inactive, Dormant & DEAF' },
@@ -0,0 +1,80 @@
<!--
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/.
-->
@if (active) {
@if (currentMode === 'overlay') {
<!-- Mifos Mobile Transaction / Action Progress Overlay -->
<div class="cbs-loading-backdrop" (click)="preventDismiss($event)">
<div class="cbs-loading-dialog" role="dialog" aria-modal="true" [attr.aria-label]="title || 'Loading'">
<div class="cbs-lottie-container" #lottieContainer>
<!-- Vector Fallback if Lottie is loading -->
@if (!lottieLoaded) {
<div class="cbs-vector-spinner">
<div class="spinner-ring ring-outer"></div>
<div class="spinner-ring ring-mid"></div>
<div class="spinner-ring ring-inner"></div>
</div>
}
</div>
<div class="cbs-loading-dialog__content">
<h3 class="cbs-loading-dialog__title">{{ title || 'CBS Nexora' }}</h3>
<p class="cbs-loading-dialog__message">{{ message || 'Please wait...' }}</p>
</div>
<div class="cbs-loading-pulse-bar">
<div class="cbs-pulse-bar__indicator"></div>
</div>
</div>
</div>
}
@if (currentMode === 'fullscreen') {
<!-- Full-Screen App Boot / Splash Screen -->
<div class="cbs-fullscreen-loader">
<div class="cbs-fullscreen-loader__brand">
<img src="assets/images/cbsnexora-logo.png" alt="CBS Nexora" class="cbs-fullscreen-loader__logo-img" />
<span class="cbs-brand-sub">Core Banking System</span>
</div>
<div class="cbs-lottie-container cbs-lottie-container--large" #lottieContainer>
@if (!lottieLoaded) {
<div class="cbs-vector-spinner cbs-vector-spinner--large">
<div class="spinner-ring ring-outer"></div>
<div class="spinner-ring ring-mid"></div>
<div class="spinner-ring ring-inner"></div>
</div>
}
</div>
<div class="cbs-progress-track">
<div class="cbs-progress-fill"></div>
</div>
<p class="cbs-fullscreen-loader__status">{{ message || 'Connecting to Core Banking Engine...' }}</p>
</div>
}
@if (currentMode === 'inline') {
<!-- Inline Component Loader -->
<div class="cbs-inline-loader">
<div class="cbs-lottie-container cbs-lottie-container--compact" #lottieContainer>
@if (!lottieLoaded) {
<div class="cbs-vector-spinner cbs-vector-spinner--compact">
<div class="spinner-ring ring-outer"></div>
<div class="spinner-ring ring-mid"></div>
<div class="spinner-ring ring-inner"></div>
</div>
}
</div>
@if (message) {
<p class="cbs-inline-loader__message">{{ message }}</p>
}
</div>
}
}
@@ -0,0 +1,349 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
:host {
display: block;
}
/* ==========================================================================
Overlay Mode (MifosProgressIndicatorOverlay / MifosLoadingDialog)
========================================================================== */
.cbs-loading-backdrop {
position: fixed;
inset: 0;
z-index: 99999;
display: flex;
align-items: center;
justify-content: center;
background: rgb(9 13 22 / 72%);
backdrop-filter: blur(8px);
animation: backdrop-fade-in 0.25s ease-out forwards;
}
.cbs-loading-dialog {
position: relative;
width: 90%;
max-width: 340px;
padding: 28px 24px;
background: #111827;
color: #f8fafc;
border-radius: 20px;
border: 1px solid rgb(255 255 255 / 12%);
box-shadow:
0 25px 60px -15px rgb(0 0 0 / 65%),
0 0 35px rgb(37 99 235 / 18%);
text-align: center;
overflow: hidden;
animation: dialog-scale-in 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
:host-context([data-theme='light']) .cbs-loading-dialog,
:host-context(.light-theme) .cbs-loading-dialog {
background: #fff;
color: #0f172a;
border-color: rgb(0 0 0 / 8%);
box-shadow:
0 25px 50px -12px rgb(0 0 0 / 25%),
0 0 25px rgb(37 99 235 / 12%);
}
.cbs-loading-dialog__content {
margin-top: 10px;
margin-bottom: 16px;
}
.cbs-loading-dialog__title {
font-size: 1.05rem;
font-weight: 700;
letter-spacing: -0.01em;
margin: 0 0 6px;
}
.cbs-loading-dialog__message {
font-size: 0.82rem;
color: #94a3b8;
line-height: 1.45;
margin: 0;
}
:host-context([data-theme='light']) .cbs-loading-dialog__message,
:host-context(.light-theme) .cbs-loading-dialog__message {
color: #64748b;
}
/* Pulse bar at dialog bottom */
.cbs-loading-pulse-bar {
width: 100%;
height: 3px;
background: rgb(255 255 255 / 8%);
border-radius: 99px;
overflow: hidden;
position: relative;
margin-top: 6px;
}
.cbs-pulse-bar__indicator {
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 40%;
background: linear-gradient(90deg, #2563eb, #38bdf8);
border-radius: 99px;
animation: pulse-bar-slide 1.4s infinite ease-in-out;
}
/* ==========================================================================
Full-Screen Mode (App Boot / Splash)
========================================================================== */
.cbs-fullscreen-loader {
position: fixed;
inset: 0;
z-index: 99999;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: radial-gradient(circle at center, #0f172a 0%, #090d16 100%);
color: #f8fafc;
padding: 24px;
text-align: center;
}
:host-context([data-theme='light']) .cbs-fullscreen-loader,
:host-context(.light-theme) .cbs-fullscreen-loader {
background: radial-gradient(circle at center, #fff 0%, #f1f5f9 100%);
color: #0f172a;
}
.cbs-fullscreen-loader__brand {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
margin-bottom: 16px;
}
.cbs-fullscreen-loader__logo-img {
height: 52px;
width: auto;
max-width: 260px;
object-fit: contain;
filter: drop-shadow(0 6px 18px rgb(0 0 0 / 45%));
}
.cbs-brand-icon {
width: 64px;
height: 64px;
border-radius: 16px;
background: linear-gradient(135deg, #1e3a8a 0%, #0284c7 100%);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 10px 24px rgb(14 165 233 / 35%);
margin-bottom: 6px;
}
.cbs-brand-name {
font-size: 1.5rem;
font-weight: 800;
letter-spacing: -0.02em;
margin: 0;
}
.cbs-brand-sub {
font-size: 0.8rem;
color: #94a3b8;
font-weight: 500;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.cbs-progress-track {
width: 240px;
height: 5px;
background: rgb(255 255 255 / 10%);
border-radius: 99px;
overflow: hidden;
margin: 18px 0 12px;
position: relative;
}
.cbs-progress-fill {
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 45%;
background: linear-gradient(90deg, #2563eb, #38bdf8);
border-radius: 99px;
box-shadow: 0 0 12px rgb(56 189 248 / 50%);
animation: pulse-bar-slide 1.5s infinite ease-in-out;
}
.cbs-fullscreen-loader__status {
font-size: 0.82rem;
color: #94a3b8;
font-family: inherit;
margin: 0;
}
/* ==========================================================================
Inline Mode
========================================================================== */
.cbs-inline-loader {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 32px 16px;
text-align: center;
}
.cbs-inline-loader__message {
font-size: 0.82rem;
color: #94a3b8;
margin-top: 10px;
}
/* ==========================================================================
Lottie & Vector Spinner Dimensions
========================================================================== */
.cbs-lottie-container {
width: 110px;
height: 110px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: center;
}
.cbs-lottie-container--large {
width: 140px;
height: 140px;
}
.cbs-lottie-container--compact {
width: 64px;
height: 64px;
}
/* Vector Spinner Fallback (CSS Multi-Ring Loader) */
.cbs-vector-spinner {
width: 72px;
height: 72px;
position: relative;
}
.cbs-vector-spinner--large {
width: 96px;
height: 96px;
}
.cbs-vector-spinner--compact {
width: 44px;
height: 44px;
}
.spinner-ring {
position: absolute;
inset: 0;
border-radius: 50%;
border: 3px solid transparent;
}
.ring-outer {
border-top-color: #2563eb;
border-right-color: #2563eb;
animation: spin-outer 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;
}
.ring-mid {
inset: 6px;
border-bottom-color: #0ea5e9;
border-left-color: #0ea5e9;
animation: spin-mid 1.6s cubic-bezier(0.5, 0, 0.5, 1) infinite reverse;
}
.ring-inner {
inset: 13px;
border-top-color: #10b981;
animation: spin-inner 0.9s cubic-bezier(0.5, 0, 0.5, 1) infinite;
}
/* ==========================================================================
Keyframe Animations
========================================================================== */
@keyframes backdrop-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes dialog-scale-in {
from {
opacity: 0;
transform: scale(0.92);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes pulse-bar-slide {
0% {
left: -40%;
width: 30%;
}
50% {
left: 35%;
width: 50%;
}
100% {
left: 100%;
width: 30%;
}
}
@keyframes spin-outer {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes spin-mid {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes spin-inner {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@@ -0,0 +1,168 @@
/**
* 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,
OnDestroy,
Input,
ElementRef,
ViewChild,
AfterViewInit,
inject,
ChangeDetectorRef
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { Subscription } from 'rxjs';
import { LoadingScreenService, LoadingConfig } from './loading-screen.service';
declare const lottie: any;
@Component({
selector: 'mifosx-loading-screen',
standalone: true,
imports: [CommonModule],
templateUrl: './loading-screen.component.html',
styleUrls: ['./loading-screen.component.scss']
})
export class LoadingScreenComponent implements OnInit, AfterViewInit, OnDestroy {
private readonly loadingService = inject(LoadingScreenService);
private readonly cdr = inject(ChangeDetectorRef);
@Input() mode: 'overlay' | 'inline' | 'fullscreen' = 'overlay';
@Input() title: string = 'CBS Nexora';
@Input() message: string = 'Processing transaction...';
@Input() active: boolean = false;
@Input() isGlobalOverlay: boolean = true;
@ViewChild('lottieContainer') lottieContainerRef?: ElementRef<HTMLDivElement>;
currentMode: 'overlay' | 'inline' | 'fullscreen' = 'overlay';
lottieLoaded: boolean = false;
private animInstance: any = null;
private subscription?: Subscription;
ngOnInit(): void {
this.currentMode = this.mode;
if (this.isGlobalOverlay) {
this.subscription = new Subscription();
this.subscription.add(
this.loadingService.isVisible$.subscribe((visible) => {
this.active = visible;
this.cdr.markForCheck();
if (visible) {
setTimeout(() => this.initLottie(), 50);
} else {
this.destroyLottie();
}
})
);
this.subscription.add(
this.loadingService.config$.subscribe((cfg: LoadingConfig) => {
if (cfg.title !== undefined) this.title = cfg.title;
if (cfg.message !== undefined) this.message = cfg.message;
if (cfg.mode !== undefined) this.currentMode = cfg.mode;
this.cdr.markForCheck();
})
);
}
}
ngAfterViewInit(): void {
if (this.active) {
this.initLottie();
}
}
ngOnDestroy(): void {
this.subscription?.unsubscribe();
this.destroyLottie();
}
preventDismiss(event: MouseEvent): void {
event.stopPropagation();
}
private initLottie(): void {
if (!this.lottieContainerRef?.nativeElement) return;
this.destroyLottie();
const container = this.lottieContainerRef.nativeElement;
// Check if lottie-web global is available
if (typeof lottie !== 'undefined') {
try {
this.animInstance = lottie.loadAnimation({
container,
renderer: 'svg',
loop: true,
autoplay: true,
path: 'assets/animations/loading_animation.json'
});
this.lottieLoaded = true;
this.cdr.markForCheck();
return;
} catch (err) {
console.warn('Could not load Lottie animation, using vector fallback', err);
}
}
// Try dynamic script load of local asset if not loaded yet
this.loadLottieScript()
.then(() => {
if (typeof lottie !== 'undefined' && this.lottieContainerRef?.nativeElement) {
this.animInstance = lottie.loadAnimation({
container: this.lottieContainerRef.nativeElement,
renderer: 'svg',
loop: true,
autoplay: true,
path: 'assets/animations/loading_animation.json'
});
this.lottieLoaded = true;
this.cdr.markForCheck();
}
})
.catch(() => {
this.lottieLoaded = false;
this.cdr.markForCheck();
});
}
private loadLottieScript(): Promise<void> {
return new Promise((resolve, reject) => {
if (typeof lottie !== 'undefined') {
resolve();
return;
}
const existingScript = document.getElementById('cbs-lottie-script');
if (existingScript) {
existingScript.addEventListener('load', () => resolve());
return;
}
const script = document.createElement('script');
script.id = 'cbs-lottie-script';
script.src = 'assets/js/lottie.min.js';
script.onload = () => resolve();
script.onerror = () => reject();
document.head.appendChild(script);
});
}
private destroyLottie(): void {
if (this.animInstance) {
try {
this.animInstance.destroy();
} catch (_) {}
this.animInstance = null;
}
}
}
@@ -0,0 +1,61 @@
/**
* 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 } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs';
export interface LoadingConfig {
title?: string;
message?: string;
mode?: 'overlay' | 'inline' | 'fullscreen';
}
@Injectable({
providedIn: 'root'
})
export class LoadingScreenService {
private readonly visibleSubject = new BehaviorSubject<boolean>(false);
private readonly configSubject = new BehaviorSubject<LoadingConfig>({
title: 'CBS Nexora',
message: 'Processing transaction...',
mode: 'overlay'
});
readonly isVisible$: Observable<boolean> = this.visibleSubject.asObservable();
readonly config$: Observable<LoadingConfig> = this.configSubject.asObservable();
/**
* Show the loading screen/overlay
* @param message Optional message to display (e.g. "Processing Transfer...")
* @param title Optional title to display (defaults to "CBS Nexora")
* @param mode Optional display mode ('overlay' | 'fullscreen')
*/
show(message?: string, title?: string, mode: 'overlay' | 'fullscreen' = 'overlay'): void {
this.configSubject.next({
title: title || 'CBS Nexora',
message: message || 'Please wait...',
mode
});
this.visibleSubject.next(true);
}
/**
* Hide the loading screen/overlay
*/
hide(): void {
this.visibleSubject.next(false);
}
/**
* Update message while loading screen is active
*/
setMessage(message: string): void {
const current = this.configSubject.getValue();
this.configSubject.next({ ...current, message });
}
}
+3
View File
@@ -30,6 +30,7 @@ import { SearchToolComponent } from './search-tool/search-tool.component';
import { KeyboardShortcutsDialogComponent } from './keyboard-shortcuts-dialog/keyboard-shortcuts-dialog.component';
import { ServerSelectorComponent } from './server-selector/server-selector.component';
import { TenantSelectorComponent } from './tenant-selector/tenant-selector.component';
import { LoadingScreenComponent } from './loading-screen/loading-screen.component';
/** Custom Modules */
import { IconsModule } from './icons.module';
@@ -116,9 +117,11 @@ import { DatetimePickerStubComponent } from './datetime-picker-stub/datetime-pic
DropdownComponent,
InputAmountComponent,
InputPasswordComponent,
LoadingScreenComponent,
DatetimePickerStubComponent // temporarily added stub for datetime picker
],
exports: [
LoadingScreenComponent,
FileUploadComponent,
FooterComponent,
LanguageSelectorComponent,
@@ -130,103 +130,4 @@
<p class="empty-text">There are no accounts awaiting approval at this time.</p>
</div>
}
<!-- Share accounts awaiting approval render here too, rather than on their own tab. Own table,
own per-row approve/reject/view actions - unchanged from their previous standalone screen. -->
<h3 class="pa-section-heading">Pending Share Accounts</h3>
@if (showShareData) {
<div class="psa-action-bar">
<mat-form-field appearance="outline" class="psa-search-field">
<mat-icon matPrefix>search</mat-icon>
<input
#shareFilterInput
matInput
placeholder="Search by account number, member or product"
(keyup)="applyShareFilter(shareFilterInput.value)"
/>
</mat-form-field>
</div>
<div class="table-container">
<table mat-table [dataSource]="shareDataSource" class="modern-table">
<ng-container matColumnDef="accountNo">
<th mat-header-cell *matHeaderCellDef>Account No.</th>
<td mat-cell *matCellDef="let account" class="psa-mono">{{ account.accountNo }}</td>
</ng-container>
<ng-container matColumnDef="clientName">
<th mat-header-cell *matHeaderCellDef>Member</th>
<td mat-cell *matCellDef="let account">
{{ account.clientName }}
@if (account.officeName) {
<div class="psa-sub">{{ account.officeName }}</div>
}
</td>
</ng-container>
<ng-container matColumnDef="productName">
<th mat-header-cell *matHeaderCellDef>Product</th>
<td mat-cell *matCellDef="let account">{{ account.productName }}</td>
</ng-container>
<ng-container matColumnDef="requestedShares">
<th mat-header-cell *matHeaderCellDef class="ta-right">Shares</th>
<td mat-cell *matCellDef="let account" class="ta-right psa-num">{{ account.requestedShares }}</td>
</ng-container>
<ng-container matColumnDef="requestedAmount">
<th mat-header-cell *matHeaderCellDef class="ta-right">Amount</th>
<td mat-cell *matCellDef="let account" class="ta-right psa-num">
{{ account.requestedAmount | number: '1.2-2' }}
</td>
</ng-container>
<ng-container matColumnDef="resolution">
<th mat-header-cell *matHeaderCellDef>Resolution</th>
<td mat-cell *matCellDef="let account">
{{ account.resolutionNumber || '—' }}
@if (account.resolutionDate) {
<div class="psa-sub">{{ formatShareDate(account.resolutionDate) }}</div>
}
</td>
</ng-container>
<ng-container matColumnDef="submittedDate">
<th mat-header-cell *matHeaderCellDef>Submitted</th>
<td mat-cell *matCellDef="let account">
{{ formatShareDate(account.submittedDate) }}
@if (account.submittedByUsername) {
<div class="psa-sub">by {{ account.submittedByUsername }}</div>
}
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef>Actions</th>
<td mat-cell *matCellDef="let account">
<button mat-stroked-button class="psa-btn psa-btn-view" (click)="viewShareAccount(account)">
<fa-icon icon="eye" class="m-r-10"></fa-icon>View
</button>
<button mat-stroked-button class="psa-btn psa-btn-approve" (click)="approveShareAccount(account)">
<fa-icon icon="check" class="m-r-10"></fa-icon>Approve
</button>
<button mat-stroked-button class="psa-btn psa-btn-reject" (click)="rejectShareAccount(account)">
<fa-icon icon="ban" class="m-r-10"></fa-icon>Reject
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="shareDisplayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: shareDisplayedColumns" class="table-row"></tr>
</table>
</div>
}
@if (!showShareData) {
<div class="empty-state">
<fa-icon icon="hourglass-half" class="empty-icon"></fa-icon>
<p class="empty-title">No pending share accounts</p>
<p class="empty-text">There are no share accounts awaiting approval at this time.</p>
</div>
}
</div>
@@ -14,16 +14,6 @@ $text-muted: #64748b;
padding: 16px 0;
}
/* Marks where the Share Account section begins below the normal-account table on this same screen. */
.pa-section-heading {
margin: 28px 0 12px;
font-size: 15px;
font-weight: 700;
color: #0d1b2a;
padding-top: 16px;
border-top: 1px solid $border;
}
.cv-action-bar {
display: flex;
align-items: center;
@@ -29,24 +29,17 @@ import { MatCheckbox } from '@angular/material/checkbox';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { TasksService } from '../../tasks.service';
import { SharesService } from 'app/shares/shares.service';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
import { DecimalPipe } from '@angular/common';
import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/confirmation-dialog.component';
import { FormDialogComponent } from 'app/shared/form-dialog/form-dialog.component';
import { FormfieldBase } from 'app/shared/form-dialog/formfield/model/formfield-base';
import { DatepickerBase } from 'app/shared/form-dialog/formfield/model/datepicker-base';
import { InputBase } from 'app/shared/form-dialog/formfield/model/input-base';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@Component({
selector: 'mifosx-pending-account',
templateUrl: './pending-account.component.html',
styleUrls: [
'./pending-account.component.scss',
'../pending-share-accounts/pending-share-accounts.component.scss'
],
styleUrls: ['./pending-account.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FaIconComponent,
@@ -61,8 +54,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
MatHeaderRow,
MatRowDef,
MatRow,
MatIcon,
DecimalPipe
MatIcon
]
})
export class PendingAccountComponent {
@@ -72,7 +64,6 @@ export class PendingAccountComponent {
private dateUtils = inject(Dates);
private settingsService = inject(SettingsService);
private tasksService = inject(TasksService);
private sharesService = inject(SharesService);
dataSource: MatTableDataSource<any>;
selection: SelectionModel<any>;
@@ -90,26 +81,8 @@ export class PendingAccountComponent {
'status'
];
/** Share accounts awaiting approval - rendered as their own section on this same screen rather
* than a separate tab. Kept out of `dataSource`/`selection` (bulk-select + date-picker approve)
* since a share subscription is approved/rejected one at a time against today's business date,
* exactly as it always was on its own page - only where it's shown has changed. */
shareDataSource: MatTableDataSource<any>;
showShareData = false;
shareDisplayedColumns: string[] = [
'accountNo',
'clientName',
'productName',
'requestedShares',
'requestedAmount',
'resolution',
'submittedDate',
'actions'
];
constructor() {
this.route.data.subscribe((data: { pendingAccountsData: any; pendingShareAccountsData: any }) => {
this.route.data.subscribe((data: { pendingAccountsData: any }) => {
// status 100 = submitted and pending approval only; exclude terminated accounts
const items = (data.pendingAccountsData?.pageItems || []).filter(
(a: any) => a.status?.id === 100 && !a.terminateTrue
@@ -117,10 +90,6 @@ export class PendingAccountComponent {
this.showData = items.length > 0;
this.dataSource = new MatTableDataSource(items);
this.selection = new SelectionModel(true, []);
const shareItems = data.pendingShareAccountsData || [];
this.showShareData = shareItems.length > 0;
this.shareDataSource = new MatTableDataSource(shareItems);
});
}
@@ -314,92 +283,6 @@ export class PendingAccountComponent {
return date;
}
applyShareFilter(filterValue: string = '') {
this.shareDataSource.filter = filterValue.trim().toLowerCase();
}
/** A date from the share account API rendered readably, whether it arrives as a string or as
* Fineract's {@code [yyyy, m, d]} array. Kept separate from `formatDate()` above, which the
* normal-account table already relies on with its own date-array handling. */
formatShareDate(date: any): string {
if (!date) {
return '';
}
const d = Array.isArray(date) ? new Date(date[0], date[1] - 1, date[2]) : new Date(date);
return isNaN(d.getTime()) ? String(date) : d.toLocaleDateString('en-IN');
}
/** Opens the share account itself, for a checker who wants more than the row shows. */
viewShareAccount(account: any): void {
this.router.navigate([
'/clients',
account.clientId,
'shares-accounts',
account.shareAccountId
]);
}
approveShareAccount(account: any): void {
const dialogRef = this.dialog.open(ConfirmationDialogComponent, {
data: {
heading: 'Approve Share Account',
dialogContext:
`Approve share account "${account.accountNo}" for ${account.clientName} — ` +
`${account.requestedShares} shares at ${account.requestedAmount}?`,
type: 'basic'
}
});
dialogRef.afterClosed().subscribe((response: any) => {
if (response?.confirm) {
// The approval date is the business date rather than the checker's choice, same as this
// queue's original standalone screen: clearing a submission is a decision, not a backdated
// transaction, so a date field per row would be extra clicks for no benefit.
const data = {
approvedDate: this.dateUtils.formatDate(this.settingsService.businessDate, this.settingsService.dateFormat),
dateFormat: this.settingsService.dateFormat,
locale: this.settingsService.language.code
};
this.sharesService
.executeSharesAccountCommand(account.shareAccountId, 'approve', data)
.subscribe(() => this.reload());
}
});
}
rejectShareAccount(account: any): void {
const formfields: FormfieldBase[] = [
new InputBase({
controlName: 'rejectReason',
label: 'Reason for rejection',
value: '',
type: 'text',
required: false
})
];
const dialogRef = this.dialog.open(FormDialogComponent, {
data: {
title: `Reject Share Account ${account.accountNo}`,
formfields
}
});
dialogRef.afterClosed().subscribe((response: any) => {
if (response?.data) {
const data: any = {
rejectedDate: this.dateUtils.formatDate(this.settingsService.businessDate, this.settingsService.dateFormat),
dateFormat: this.settingsService.dateFormat,
locale: this.settingsService.language.code
};
const reason = response.data.value?.rejectReason?.trim();
if (reason) {
data.note = reason;
}
this.sharesService
.executeSharesAccountCommand(account.shareAccountId, 'reject', data)
.subscribe(() => this.reload());
}
});
}
reload() {
const url = this.router.url;
this.router
@@ -72,6 +72,15 @@
<fa-icon icon="hourglass-half" class="ac-tab__ic"></fa-icon>
<span>Pending Account</span>
</a>
<a
class="ac-tab"
routerLink="./pending-share-accounts"
routerLinkActive="ac-tab--active"
*mifosxHasPermission="'ALL_FUNCTIONS_READ'"
>
<fa-icon icon="hourglass-half" class="ac-tab__ic"></fa-icon>
<span>Pending Share Accounts</span>
</a>
<a
class="ac-tab"
routerLink="./product-approval"
+1 -4
View File
@@ -108,10 +108,7 @@ const routes: Routes = [
component: PendingAccountComponent,
data: { title: 'Pending Account' },
resolve: {
pendingAccountsData: GetPendingAccounts,
// Share accounts awaiting approval now render inside this same screen instead of their
// own tab - reuses the same resolver the standalone route below still uses.
pendingShareAccountsData: GetPendingShareAccounts
pendingAccountsData: GetPendingAccounts
}
},
{
@@ -13,15 +13,21 @@
<div>
<h2 class="tdsr__title">Deposit Interest &amp; TDS</h2>
<div class="tdsr__sub">
Quarter-wise and yearly interest on time deposits, with tax deductible under {{ statute.section }} ·
{{ scopeLabel }}
Interest each customer earned this year, and whether TDS has to be deducted on it ·
<strong>Showing: {{ scopeLabel }}</strong>
</div>
</div>
</div>
<button type="button" class="tdsr__btn" (click)="exportWorkbook()" [disabled]="exporting">
<fa-icon icon="download"></fa-icon>
{{ exporting ? 'Preparing…' : 'Export' }}
</button>
<div class="tdsr__head-r">
<button type="button" class="tdsr__btn" (click)="showHelp = !showHelp">
<fa-icon icon="question-circle"></fa-icon>
{{ showHelp ? 'Hide help' : 'How to read this page' }}
</button>
<button type="button" class="tdsr__btn" (click)="exportWorkbook()" [disabled]="exporting">
<fa-icon icon="download"></fa-icon>
{{ exporting ? 'Preparing…' : 'Export' }}
</button>
</div>
</div>
@if (loadError) {
@@ -31,57 +37,87 @@
</div>
}
<!-- Help -->
@if (showHelp) {
<div class="tdsr__help">
<div class="tdsr__help-title"><fa-icon icon="info-circle"></fa-icon> How to read this page</div>
<ol class="tdsr__help-list">
<li>
Every customer with an account is listed. Each account is either
<span class="tdsr__pill tdsr__pill--ok">Tracked</span> — its interest counts toward the TDS limit — or
<span class="tdsr__pill">Not tracked</span>. Open a row (<fa-icon icon="chevron-down"></fa-icon>) to see which
account is which, and why.
</li>
<li>
Only <strong>fixed and recurring deposits</strong> are tracked. Savings interest is shown for information but
never attracts TDS.
</li>
@if (rules) {
<li>
A customer's tracked interest for the whole year, across every branch, is checked against the limit:
<strong>₹{{ rules.general | number: '1.0-0' }}</strong
>, or <strong>₹{{ rules.senior | number: '1.0-0' }}</strong> for a senior citizen (60+).
</li>
<li>
Once over the limit, TDS is <strong>{{ rules.rate | number: '1.0-2' }}%</strong> of the
<em>whole</em> year's interest — <strong>{{ rules.rateNoPan | number: '1.0-2' }}%</strong> if no PAN is on
record.
</li>
}
<li>A customer who has submitted Form 15G/15H is not deducted, even over the limit.</li>
<li>
The TDS figures here are <strong>worked out by this report</strong> from the interest actually posted. They
are not debited from the customer's account automatically.
</li>
</ol>
</div>
}
<!-- Summary -->
<div class="tdsr__kpis">
<div class="tdsr__kpi">
<div class="tdsr__kpi-n">{{ summary.deductees | number }}</div>
<div class="tdsr__kpi-l">Deductees</div>
<div class="tdsr__kpi-l">Customers · {{ summary.trackedCustomers | number }} with an FD/RD being tracked</div>
</div>
<div class="tdsr__kpi">
<div class="tdsr__kpi-n">₹{{ summary.applicableInterest | number: '1.0-0' }}</div>
<div class="tdsr__kpi-l">Interest on FD / RD</div>
<div class="tdsr__kpi-l">FD / RD interest — counts toward the limit</div>
</div>
<div class="tdsr__kpi tdsr__kpi--muted">
<div class="tdsr__kpi-n">₹{{ summary.exemptInterest | number: '1.0-0' }}</div>
<div class="tdsr__kpi-l">Savings interest — outside the charge</div>
</div>
<div class="tdsr__kpi tdsr__kpi--warn">
<div class="tdsr__kpi-n">₹{{ summary.totalTds | number: '1.0-0' }}</div>
<div class="tdsr__kpi-l">TDS payable</div>
<div class="tdsr__kpi-l">Savings interest — shown only, no TDS</div>
</div>
<div class="tdsr__kpi tdsr__kpi--warn">
<div class="tdsr__kpi-n">{{ summary.crossed | number }}</div>
<div class="tdsr__kpi-l">Crossed the threshold</div>
<div class="tdsr__kpi-l">Customers over the limit</div>
</div>
<button type="button" class="tdsr__kpi tdsr__kpi--action" (click)="showNearThreshold()">
<div class="tdsr__kpi-n">{{ summary.nearThreshold | number }}</div>
<div class="tdsr__kpi-l">Within ₹{{ nearBand | number: '1.0-0' }} of it — chase a declaration</div>
</button>
<div class="tdsr__kpi tdsr__kpi--ok">
<div class="tdsr__kpi-n">₹{{ summary.tdsSaved | number: '1.0-0' }}</div>
<div class="tdsr__kpi-l">Saved by {{ summary.declarations | number }} declarations</div>
<div class="tdsr__kpi tdsr__kpi--warn">
<div class="tdsr__kpi-n">₹{{ summary.totalTds | number: '1.0-0' }}</div>
<div class="tdsr__kpi-l">TDS to be deducted</div>
</div>
@if (summary.noPan > 0) {
<div class="tdsr__kpi tdsr__kpi--warn">
<div class="tdsr__kpi-n">{{ summary.noPan | number }}</div>
<div class="tdsr__kpi-l">No PAN — deducted at 20%</div>
</div>
}
</div>
<!-- Quarter strip -->
<div class="tdsr__card">
<div class="tdsr__card-head"><fa-icon icon="calendar"></fa-icon> Quarter by quarter</div>
<div class="tdsr__qstrip">
@for (q of summary.byQuarter; track q.quarter) {
<div class="tdsr__qcell">
<div class="tdsr__qcell-l">{{ quarterLabel[q.quarter] }}</div>
<div class="tdsr__qcell-n">₹{{ q.interest | number: '1.0-0' }}</div>
<div class="tdsr__qcell-s">TDS ₹{{ q.tds | number: '1.0-0' }}</div>
</div>
<!-- Things worth acting on, only when there are any -->
@if (summary.nearThreshold || summary.declarations || summary.noPan) {
<div class="tdsr__chips">
@if (summary.nearThreshold) {
<button type="button" class="tdsr__chip tdsr__chip--near" (click)="showNearThreshold()">
{{ summary.nearThreshold | number }} near the limit (within ₹{{ nearBand | number: '1.0-0' }}) — ask for
15G/15H
</button>
}
@if (summary.declarations) {
<span class="tdsr__chip tdsr__chip--ok">
{{ summary.declarations | number }} with 15G/15H — ₹{{ summary.tdsSaved | number: '1.0-0' }} not deducted
</span>
}
@if (summary.noPan) {
<span class="tdsr__chip tdsr__chip--warn">
{{ summary.noPan | number }} FD/RD customers without PAN — higher rate applies
</span>
}
</div>
</div>
}
<!-- Filters -->
<div class="tdsr__filters">
@@ -99,8 +135,8 @@
<span>Branch</span>
<select [(ngModel)]="filter.branchCode" (ngModelChange)="onFilterChanged()">
<option value="">All branches</option>
@for (b of branches; track b.code) {
<option [value]="b.code">{{ b.name }}</option>
@for (b of branches; track b) {
<option [value]="b">{{ b }}</option>
}
</select>
</label>
@@ -117,32 +153,32 @@
</label>
<label class="tdsr__filter">
<span>Threshold band</span>
<span>Age group</span>
<select [(ngModel)]="filter.band" (ngModelChange)="onFilterChanged()">
<option value="">Both bands</option>
<option value="GENERAL">General (below 60)</option>
<option value="">Everyone</option>
<option value="GENERAL">Below 60</option>
<option value="SENIOR">Senior citizen (60+)</option>
</select>
</label>
<label class="tdsr__filter tdsr__filter--wide">
<span>Customer / CIF / PAN</span>
<span>Search customer</span>
<input
type="text"
[(ngModel)]="filter.search"
(ngModelChange)="onFilterChanged()"
placeholder="name, CIF or PAN"
placeholder="Name, customer ID or PAN"
/>
</label>
<label class="tdsr__check">
<input type="checkbox" [(ngModel)]="filter.crossedOnly" (ngModelChange)="onFilterChanged()" />
<span>Threshold crossed only</span>
<span>Over the limit only</span>
</label>
<label class="tdsr__check">
<input type="checkbox" [(ngModel)]="filter.nearThresholdOnly" (ngModelChange)="onFilterChanged()" />
<span>Near threshold only</span>
<span>Near the limit only</span>
</label>
@if (hasFilters) {
@@ -150,7 +186,7 @@
}
</div>
<!-- Tabs Selector -->
<!-- Tabs -->
<div class="tdsr__tabs">
<button
type="button"
@@ -158,7 +194,7 @@
[class.tdsr__tab--active]="selectedTab === 'paying'"
(click)="selectTab('paying')"
>
Paying TDS ({{ payingRows.length }})
TDS to deduct ({{ payingRows.length }})
</button>
<button
type="button"
@@ -166,7 +202,7 @@
[class.tdsr__tab--active]="selectedTab === 'not-paying'"
(click)="selectTab('not-paying')"
>
Not Paying TDS ({{ notPayingRows.length }})
No TDS ({{ notPayingRows.length }})
</button>
</div>
@@ -176,87 +212,76 @@
<table class="tdsr__table">
<thead>
<tr>
<th>Customer · PAN</th>
<th>Customer</th>
<th>Branch</th>
<th>Form · Band</th>
<th class="num">Q1</th>
<th class="num">Q2</th>
<th class="num">Q3</th>
<th class="num">Q4</th>
<th class="num">Year total</th>
<th class="num">Threshold</th>
<th>Crossed</th>
<th>Accounts</th>
<th class="num">FD / RD interest this year</th>
<th class="num">Limit</th>
<th>Status</th>
<th class="num">TDS</th>
<th class="num">Saved</th>
<th class="col-actions"></th>
</tr>
</thead>
<tbody>
@for (row of pagedRows; track trackRow($index, row)) {
<tr [class.tdsr__row--warn]="!row.panOnRecord" [class.tdsr__row--near]="row.nearThreshold">
<tr class="tdsr__row--click" [class.tdsr__row--near]="row.nearThreshold" (click)="toggleExpanded(row)">
<td>
<div class="tdsr__name">{{ row.deductee.name }}</div>
<div class="mono small muted">
{{ row.deductee.customerId }} ·
ID {{ row.deductee.customerId }} ·
@if (row.panOnRecord) {
{{ row.deductee.pan }}
PAN {{ row.deductee.pan }}
} @else {
<span class="tdsr__pill tdsr__pill--warn">no PAN</span>
}
@if (row.band === 'SENIOR') {
<span class="tdsr__pill">Senior 60+</span>
}
</div>
</td>
<td class="small">{{ branchName(row.branchCode) }}</td>
<td>
<span class="tdsr__pill" [class.tdsr__pill--ok]="row.hasDeclaration">
{{ row.form === '121' ? '121' : row.form }}
</span>
<div class="small muted">{{ row.band === 'SENIOR' ? 'Senior' : 'General' }}</div>
@if (row.hasDeclaration) {
<div class="mono small muted">{{ row.declarationUin }}</div>
<td class="small">{{ row.branchCode || '—' }}</td>
<td class="small">
@if (row.trackedAccounts) {
<span class="tdsr__pill tdsr__pill--ok">{{ row.trackedAccounts }} tracked</span>
}
@if (untrackedOf(row).length) {
<span class="tdsr__pill">{{ untrackedOf(row).length }} not tracked</span>
}
</td>
@for (q of quarters; track q) {
<td class="num small">
@if (row.quarters[q]) {
₹{{ row.quarters[q] | number: '1.0-0' }}
} @else {
<span class="muted">—</span>
}
</td>
}
<td class="num">
<strong>₹{{ row.tdsApplicableInterest | number: '1.0-0' }}</strong>
@if (row.exemptInterest) {
<div class="small muted">+₹{{ row.exemptInterest | number: '1.0-0' }} SB (exempt)</div>
<div class="small muted">+ ₹{{ row.exemptInterest | number: '1.0-0' }} savings (no TDS)</div>
}
</td>
<td class="num small muted">₹{{ row.threshold | number: '1.0-0' }}</td>
<td class="small">
@if (row.crossedInQuarter) {
<span class="tdsr__pill tdsr__pill--warn">Q{{ row.crossedInQuarter }}</span>
} @else if (row.nearThreshold) {
<span class="tdsr__pill tdsr__pill--near"> ₹{{ row.headroom | number: '1.0-0' }} left </span>
} @else {
<span class="muted">—</span>
}
<span
class="tdsr__pill"
[class.tdsr__pill--warn]="statusOf(row).tone === 'warn'"
[class.tdsr__pill--near]="statusOf(row).tone === 'near'"
[class.tdsr__pill--ok]="statusOf(row).tone === 'ok'"
>
{{ statusOf(row).label }}
</span>
</td>
<td class="num">
@if (row.schedule.totalTds) {
<strong>₹{{ row.schedule.totalTds | number: '1.0-0' }}</strong>
<div class="small muted">@ {{ row.rateApplied * 100 | number: '1.0-0' }}%</div>
} @else {
<span class="muted">—</span>
}
</td>
<td class="num">
@if (row.tdsSaved) {
<strong class="tdsr__ok">₹{{ row.tdsSaved | number: '1.0-0' }}</strong>
} @else if (row.tdsSaved) {
<span class="small tdsr__ok">₹{{ row.tdsSaved | number: '1.0-0' }} saved</span>
} @else {
<span class="muted">—</span>
}
</td>
<td class="col-actions">
<button type="button" class="tdsr__icon-btn" (click)="toggleExpanded(row)">
<button
type="button"
class="tdsr__icon-btn"
[attr.aria-label]="isExpanded(row) ? 'Hide details' : 'Show details'"
(click)="$event.stopPropagation(); toggleExpanded(row)"
>
<fa-icon [icon]="isExpanded(row) ? 'chevron-up' : 'chevron-down'"></fa-icon>
</button>
</td>
@@ -264,89 +289,138 @@
@if (isExpanded(row)) {
<tr class="tdsr__row--detail">
<td colspan="13">
@for (note of row.schedule.notes; track note) {
<div class="tdsr__note">
<fa-icon icon="info-circle"></fa-icon>
<div>{{ note }}</div>
</div>
}
@if (row.jointHeldInterest) {
<div class="tdsr__note">
<fa-icon icon="info-circle"></fa-icon>
<div>
₹{{ row.jointHeldInterest | number: '1.0-0' }} of interest sits on deposits where this customer
is a joint holder but not the first-named one. Tax there is deducted against the first-named
holder, so it is excluded from the figures above.
</div>
</div>
}
<td colspan="8">
<!-- 1. Which accounts are tracked -->
<div class="tdsr__detail-h">Accounts of {{ row.deductee.name }}</div>
<table class="tdsr__table tdsr__table--inner">
<thead>
<tr>
<th>Quarter</th>
<th class="num">Interest</th>
<th class="num">Cumulative</th>
<th class="num">Liability to date</th>
<th class="num">TDS this quarter</th>
<th>Return due</th>
<th>Account no.</th>
<th>Type</th>
<th>Tracked for TDS?</th>
<th class="num">Interest this year</th>
<th class="num">Postings</th>
<th>Last posted</th>
</tr>
</thead>
<tbody>
@for (line of row.schedule.lines; track line.quarter) {
<tr [class.tdsr__row--warn]="line.thresholdCrossedHere">
@for (a of row.accounts; track a.accountNo) {
<tr>
<td class="mono small">{{ a.accountNo }}</td>
<td class="small">{{ accountTypeLabel[a.type] }}</td>
<td class="small">
{{ line.label }}
@if (line.thresholdCrossedHere) {
<span class="tdsr__pill tdsr__pill--warn">crossed here</span>
}
@if (line.declarationActive) {
<span class="tdsr__pill tdsr__pill--ok">Form 121 active</span>
}
</td>
<td class="num">₹{{ line.interest | number: '1.0-0' }}</td>
<td class="num">₹{{ line.cumInterest | number: '1.0-0' }}</td>
<td class="num">₹{{ line.cumLiability | number: '1.0-0' }}</td>
<td class="num">
@if (line.tds !== 0) {
<strong>₹{{ line.tds | number: '1.0-0' }}</strong>
@if (a.tracked) {
<span class="tdsr__pill tdsr__pill--ok"><fa-icon icon="check"></fa-icon> Yes</span>
} @else {
<span class="muted">—</span>
<span class="tdsr__pill">No</span>
}
<span class="muted"> {{ a.reason }}</span>
</td>
<td class="num">₹{{ a.interest | number: '1.2-2' }}</td>
<td class="num small">{{ a.postings }}</td>
<td class="small muted">
{{ a.lastPostedOn ? (a.lastPostedOn | date: 'd MMM y') : 'No interest posted yet' }}
</td>
<td class="small muted">{{ line.dueDate }}</td>
</tr>
}
</tbody>
</table>
<div class="tdsr__meta">
<span
>Settlement: <strong>{{ row.mode === 'QUARTERLY' ? 'Quarterly' : 'Year end' }}</strong> (set by
the deposit product)</span
>
@if (row.declarationAcceptedOn) {
<span
>Declaration accepted: <strong>{{ row.declarationAcceptedOn | date: 'd MMM y' }}</strong></span
>
<!-- 2. How the TDS was worked out -->
@if (row.trackedAccounts) {
<div class="tdsr__detail-h">How the TDS is worked out, quarter by quarter</div>
@for (note of row.schedule.notes; track note) {
<div class="tdsr__note">
<fa-icon icon="info-circle"></fa-icon>
<div>{{ note }}</div>
</div>
}
@if (row.schedule.lines.length) {
<table class="tdsr__table tdsr__table--inner">
<thead>
<tr>
<th>Quarter</th>
<th class="num">Interest in quarter</th>
<th class="num">Total so far</th>
<th class="num">TDS this quarter</th>
<th>Return due</th>
</tr>
</thead>
<tbody>
@for (line of row.schedule.lines; track line.quarter) {
<tr [class.tdsr__row--warn]="line.thresholdCrossedHere">
<td class="small">
{{ line.label }}
@if (line.thresholdCrossedHere) {
<span class="tdsr__pill tdsr__pill--warn">limit crossed here</span>
}
@if (line.declarationActive) {
<span class="tdsr__pill tdsr__pill--ok">15G/15H active</span>
}
</td>
<td class="num">₹{{ line.interest | number: '1.0-0' }}</td>
<td class="num">
₹{{ line.cumInterest | number: '1.0-0' }}
<span class="small muted">of ₹{{ row.threshold | number: '1.0-0' }}</span>
</td>
<td class="num">
@if (line.tds !== 0) {
<strong>₹{{ line.tds | number: '1.0-0' }}</strong>
} @else {
<span class="muted">—</span>
}
</td>
<td class="small muted">{{ line.dueDate }}</td>
</tr>
}
</tbody>
</table>
} @else {
<div class="small muted">No interest has been posted on the tracked deposits this year yet.</div>
}
@if (row.paidWithoutDeduction) {
<span>
Paid without deduction:
<strong>₹{{ row.paidWithoutDeduction | number: '1.0-0' }}</strong> — reportable in
{{ statute.statement }}
</span>
<div class="tdsr__meta">
<span>
Paid without deduction because of 15G/15H:
<strong>₹{{ row.paidWithoutDeduction | number: '1.0-0' }}</strong> — still reported in
{{ statute.statement }}
</span>
</div>
}
</div>
} @else {
<div class="tdsr__note">
<fa-icon icon="info-circle"></fa-icon>
<div>
This customer has no fixed or recurring deposit, so nothing here counts toward TDS. Savings
interest is shown for information only.
</div>
</div>
}
@if (row.jointHeldInterest) {
<div class="tdsr__note">
<fa-icon icon="info-circle"></fa-icon>
<div>
₹{{ row.jointHeldInterest | number: '1.0-0' }} of interest is on joint deposits where this
customer is not the first-named holder, so it is counted under that holder instead.
</div>
</div>
}
</td>
</tr>
}
}
@if (visibleRows.length === 0) {
<tr>
<td colspan="13" class="tdsr__empty">
<fa-icon icon="info-circle"></fa-icon> No customers in this section match the filters.
<td colspan="8" class="tdsr__empty">
<fa-icon icon="info-circle"></fa-icon>
@if (loading) {
Loading…
} @else if (selectedTab === 'paying') {
No customer needs TDS deducted for these filters. Check the "No TDS" tab.
} @else {
No customers match these filters.
}
</td>
</tr>
}
@@ -359,8 +433,7 @@
<div class="tdsr__pager-l">
<span>
Showing <strong>{{ rangeStart | number }}–{{ rangeEnd | number }}</strong> of
<strong>{{ visibleRows.length | number }}</strong>
{{ selectedTab === 'paying' ? 'deductees paying TDS' : 'deductees not paying TDS' }}
<strong>{{ visibleRows.length | number }}</strong> customers
</span>
<label class="tdsr__pager-sizel">
Rows
@@ -13,12 +13,12 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import * as ExcelJS from 'exceljs';
import { BranchScopeService } from '../core/branch-scope.service';
import { GL_BRANCHES, glBranchName } from '../gl-reports/gl-reports-data';
import { FY_TAX_CONFIG, Quarter, QUARTER_LABEL } from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute';
import { setTdsDeductees, tdsFys } from './tds-reports-data';
import { TdsDeductee, setTdsDeductees, tdsFys } from './tds-reports-data';
import { TdsReportsService } from './tds-reports.service';
import {
NEAR_THRESHOLD_BAND,
TdsAccountLine,
TdsInterestRow,
TdsInterestSummary,
TdsReportFilter,
@@ -40,7 +40,12 @@ export class TdsInterestReportComponent implements OnInit {
private scope = inject(BranchScopeService);
private tdsService = inject(TdsReportsService);
readonly branches = GL_BRANCHES.filter((b) => b.code !== 'HO');
/**
* Office names the loaded customers belong to, for Head Office's branch selector. Read off the
* data rather than a fixed list, which named branches this bank does not have and left out the
* ones it does.
*/
branches: string[] = [];
/** Filled by the route resolver from Organization › TDS Configuration. */
readonly financialYears = tdsFys();
readonly quarterLabel = QUARTER_LABEL;
@@ -92,16 +97,26 @@ export class TdsInterestReportComponent implements OnInit {
loading = false;
loadError = '';
/** The "how to read this page" panel. */
showHelp = true;
readonly accountTypeLabel: Record<TdsAccountLine['type'], string> = {
FD: 'Fixed deposit',
RD: 'Recurring deposit',
SB: 'Savings',
OTHER: 'Other'
};
ngOnInit(): void {
if (!this.financialYears.length) {
this.loadError = 'No financial year is configured for TDS. Set one up under Organization › TDS Configuration.';
return;
}
// A branch login sees only its own book and cannot widen the scope.
const own = this.scope.ownBranchCode;
if (own) {
this.filter.branchCode = own;
// A branch login sees only its own book and cannot widen the scope. The platform labels each
// customer with their office's name, so the login's office name is the filter — the GL branch
// code list does not know every office and turned a branch login into an empty report.
if (!this.scope.isHeadOffice) {
this.filter.branchCode = this.scope.officeName;
}
this.loadDeductees();
}
@@ -121,6 +136,7 @@ export class TdsInterestReportComponent implements OnInit {
next: (deductees) => {
this.loading = false;
setTdsDeductees(deductees);
this.branches = this.branchesOf(deductees);
this.invalidateCaches();
},
error: (err: any) => {
@@ -138,6 +154,11 @@ export class TdsInterestReportComponent implements OnInit {
* only the summary left the KPIs counting the new data while the tables kept the empty list
* cached on the first render, before the load returned.
*/
private branchesOf(deductees: TdsDeductee[]): string[] {
const names = new Set(deductees.map((d) => d.branchCode).filter((n) => !!n));
return [...names].sort((a, b) => a.localeCompare(b));
}
private invalidateCaches(): void {
this.rowsCacheKey = '';
this.summaryCacheKey = '';
@@ -168,12 +189,54 @@ export class TdsInterestReportComponent implements OnInit {
};
}
/** The limits and rates for the selected year, for the help panel. */
get rules(): { general: number; senior: number; rate: number; rateNoPan: number } | null {
const cfg = FY_TAX_CONFIG[this.filter.fy];
if (!cfg) {
return null;
}
return {
general: cfg.thresholdGeneral,
senior: cfg.thresholdSenior,
rate: cfg.rate * 100,
rateNoPan: cfg.rateNoPan * 100
};
}
get canSelectBranch(): boolean {
return this.scope.canSelectBranch;
}
get scopeLabel(): string {
return this.filter.branchCode ? glBranchName(this.filter.branchCode) : 'All branches';
return this.filter.branchCode ? `${this.filter.branchCode} branch` : 'All branches';
}
/**
* One plain-language status per customer, so a reader does not have to work it out from the
* threshold, crossed and saved columns together.
*/
statusOf(row: TdsInterestRow): { label: string; tone: 'muted' | 'warn' | 'near' | 'ok' } {
if (!row.trackedAccounts) {
return { label: 'Not tracked — no FD/RD', tone: 'muted' };
}
if (row.schedule.totalTds > 0) {
return { label: 'Over the limit — TDS due', tone: 'warn' };
}
if (row.hasDeclaration && row.crossed) {
return { label: 'Over the limit — 15G/15H, no TDS', tone: 'ok' };
}
if (row.nearThreshold) {
return { label: `Near the limit — ₹${Math.round(row.headroom).toLocaleString('en-IN')} left`, tone: 'near' };
}
return { label: 'Below the limit', tone: 'ok' };
}
trackedOf(row: TdsInterestRow): TdsAccountLine[] {
return row.accounts.filter((a) => a.tracked);
}
untrackedOf(row: TdsInterestRow): TdsAccountLine[] {
return row.accounts.filter((a) => !a.tracked);
}
/** Recomputes the book only when the filter actually changed. Side-effect free. */
@@ -315,10 +378,6 @@ export class TdsInterestReportComponent implements OnInit {
return this.expandedId === row.deductee.customerId;
}
branchName(code: string): string {
return glBranchName(code);
}
trackRow(_: number, row: TdsInterestRow): string {
return row.deductee.customerId;
}
@@ -337,6 +396,9 @@ export class TdsInterestReportComponent implements OnInit {
'CIF',
'PAN',
'Branch',
'Accounts tracked for TDS',
'Accounts not tracked',
'Status',
'Form',
'Band',
'Age',
@@ -361,7 +423,14 @@ export class TdsInterestReportComponent implements OnInit {
r.deductee.name,
r.deductee.customerId,
r.deductee.pan || 'NOT ON RECORD',
this.branchName(r.branchCode),
r.branchCode,
this.trackedOf(r)
.map((a) => `${a.accountNo} (${a.type})`)
.join(', '),
this.untrackedOf(r)
.map((a) => `${a.accountNo} (${a.type})`)
.join(', '),
this.statusOf(r).label,
r.form === '121' ? 'Form 121' : `Form ${r.form}`,
r.band === 'SENIOR' ? 'Senior citizen' : 'General',
r.age,
@@ -389,6 +458,9 @@ export class TdsInterestReportComponent implements OnInit {
`${s.deductees} deductees`,
'',
'',
`${s.trackedCustomers} tracked`,
'',
'',
'',
'',
'',
+74 -4
View File
@@ -56,6 +56,25 @@ export interface TdsReportFilter {
nearThresholdOnly: boolean;
}
/**
* One of the customer's accounts as the report sees it: whether its interest is being counted
* toward the deduction, and if not, why not. A customer can hold savings, fixed and recurring
* accounts together, and the branch has to see which of them the deduction is actually watching.
*/
export interface TdsAccountLine {
accountNo: string;
type: 'FD' | 'RD' | 'SB' | 'OTHER';
/** True when the account's interest counts toward this customer's threshold. */
tracked: boolean;
/** Plain-language reason, shown beside the account. */
reason: string;
/** Interest credited in the year. */
interest: number;
/** How many interest postings made up that figure. */
postings: number;
lastPostedOn: string | null;
}
/** How close to the trigger counts as "worth chasing a declaration for". */
export const NEAR_THRESHOLD_BAND = 6_000;
@@ -101,6 +120,10 @@ export interface TdsInterestRow {
paidWithoutDeduction: number;
panOnRecord: boolean;
rateApplied: number;
/** Every account the customer holds, tracked or not. */
accounts: TdsAccountLine[];
/** How many of them count toward the deduction. */
trackedAccounts: number;
}
function emptyQuarters(): Record<Quarter, number> {
@@ -154,14 +177,35 @@ export function tdsInterestRowsForFy(fy: string): TdsInterestRow[] {
let exemptInterest = 0;
let jointHeldInterest = 0;
const accountLines: TdsAccountLine[] = [];
for (const account of deductee.accounts) {
const inYear = account.credits.filter((c) => fyOf(c.date) === fy).reduce((s, c) => s + c.amount, 0);
const yearCredits = account.credits.filter((c) => fyOf(c.date) === fy);
const inYear = yearCredits.reduce((s, c) => s + c.amount, 0);
const type = accountTypeOf(account.depositTypeCode);
let tracked = false;
let reason: string;
if (!isTdsApplicable(account)) {
exemptInterest += inYear;
reason = type === 'SB' ? 'Savings account — no TDS on savings interest' : 'Not a time deposit — no TDS';
} else if (tdsDeducteeOf(account) !== deductee.clientId) {
jointHeldInterest += inYear;
reason = 'Joint deposit — counted under the first-named holder';
} else {
tracked = true;
reason = 'Counted toward the TDS limit';
}
accountLines.push({
accountNo: account.accountNo || '',
type,
tracked,
reason,
interest: inYear,
postings: yearCredits.length,
lastPostedOn: yearCredits.reduce<string | null>((last, c) => (!last || c.date > last ? c.date : last), null)
});
}
// Tracked accounts first, so the deposits that drive the figure lead the list.
accountLines.sort((a, b) => Number(b.tracked) - Number(a.tracked));
const crossedLine = schedule.lines.find((l) => l.thresholdCrossedHere);
const headroom = Math.max(0, schedule.threshold - schedule.totalInterest);
@@ -190,7 +234,9 @@ export function tdsInterestRowsForFy(fy: string): TdsInterestRow[] {
tdsSaved: declaration ? Math.max(0, withoutDeclaration.totalTds - schedule.totalTds) : 0,
paidWithoutDeduction: schedule.shieldedInterest,
panOnRecord: isWellFormedPan(deductee.pan),
rateApplied: schedule.rateApplied
rateApplied: schedule.rateApplied,
accounts: accountLines,
trackedAccounts: accountLines.filter((a) => a.tracked).length
});
}
@@ -210,10 +256,29 @@ function overallModeFor(deductee: TdsDeductee): TdsMode {
return applicable.some((a) => tdsModeFor(a) === 'QUARTERLY') ? 'QUARTERLY' : 'ANNUAL';
}
function accountTypeOf(code: string | undefined): TdsAccountLine['type'] {
const c = (code || '').toLowerCase();
if (c.includes('fixeddeposit')) {
return 'FD';
}
if (c.includes('recurringdeposit')) {
return 'RD';
}
if (c.includes('savings')) {
return 'SB';
}
return 'OTHER';
}
/** Branches are compared by office name, case and spacing aside — the platform and the session both carry the name. */
function sameBranch(a: string, b: string): boolean {
return (a || '').trim().toLowerCase() === (b || '').trim().toLowerCase();
}
export function tdsInterestRows(filter: TdsReportFilter): TdsInterestRow[] {
const needle = filter.search.trim().toLowerCase();
return tdsInterestRowsForFy(filter.fy).filter((row) => {
if (filter.branchCode && row.branchCode !== filter.branchCode) {
if (filter.branchCode && !sameBranch(row.branchCode, filter.branchCode)) {
return false;
}
if (filter.band && row.band !== filter.band) {
@@ -254,6 +319,8 @@ export interface TdsInterestSummary {
tdsSaved: number;
paidWithoutDeduction: number;
noPan: number;
/** Customers holding at least one account whose interest counts toward the deduction. */
trackedCustomers: number;
byQuarter: { quarter: Quarter; interest: number; tds: number }[];
}
@@ -280,7 +347,10 @@ export function tdsInterestSummary(filter: TdsReportFilter): TdsInterestSummary
declarations: rows.filter((r) => r.hasDeclaration).length,
tdsSaved: rows.reduce((s, r) => s + r.tdsSaved, 0),
paidWithoutDeduction: rows.reduce((s, r) => s + r.paidWithoutDeduction, 0),
noPan: rows.filter((r) => !r.panOnRecord).length,
// Only customers with a deposit the deduction watches: a savings-only customer without a PAN
// is never deducted at the higher rate, so counting them overstated the problem.
noPan: rows.filter((r) => !r.panOnRecord && r.trackedAccounts > 0).length,
trackedCustomers: rows.filter((r) => r.trackedAccounts > 0).length,
byQuarter
};
}
@@ -647,3 +647,82 @@
color: var(--tdsr-ink);
white-space: nowrap;
}
/* ── Help panel (interest report) ── */
.tdsr__help {
margin-bottom: 16px;
padding: 14px 18px;
border: 1px solid color-mix(in srgb, var(--tdsr-blue) 25%, transparent);
border-radius: 10px;
background: color-mix(in srgb, var(--tdsr-blue) 5%, transparent);
line-height: 1.6;
}
.tdsr__help-title {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 6px;
font-weight: 700;
color: var(--tdsr-blue);
}
.tdsr__help-list {
margin: 0;
padding-left: 20px;
li + li {
margin-top: 2px;
}
}
/* ── Action chips under the summary ── */
.tdsr__chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: -6px 0 16px;
}
.tdsr__chip {
padding: 5px 12px;
border: 1px solid var(--tdsr-line);
border-radius: 999px;
background: var(--tdsr-bg);
font: inherit;
font-size: 12px;
font-weight: 600;
&--near {
border-color: color-mix(in srgb, var(--tdsr-blue) 30%, transparent);
color: var(--tdsr-blue);
cursor: pointer;
}
&--ok {
border-color: color-mix(in srgb, var(--tdsr-green) 35%, transparent);
color: var(--tdsr-green);
}
&--warn {
border-color: color-mix(in srgb, var(--tdsr-amber) 35%, transparent);
color: var(--tdsr-amber);
}
}
.tdsr__row--click {
cursor: pointer;
&:hover > td {
background: var(--tdsr-bg);
}
}
.tdsr__detail-h {
margin: 4px 0 8px;
font-weight: 700;
.tdsr__table--inner + & {
margin-top: 16px;
}
}
+1
View File
@@ -7,3 +7,4 @@
-->
<router-outlet></router-outlet>
<mifosx-loading-screen></mifosx-loading-screen>
+5
View File
@@ -296,6 +296,11 @@ export class WebAppComponent implements OnInit, OnDestroy {
});
});
}
// Dismiss initial application boot splash screen smoothly
if (typeof (window as any).hideAppSplash === 'function') {
(window as any).hideAppSplash();
}
}
ngOnDestroy() {
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -21,7 +21,7 @@
// in the tenants table of the tenant-store database, or the platform rejects the request as an
// unknown tenant.
window["env"]["fineractPlatformTenantId"] = 'utkarshbank';
window["env"]["fineractPlatformTenantIds"] = 'utkarshbank';
window["env"]["fineractPlatformTenantIds"] = 'utkarshbank,lincpay,utkarshnagari,testbank,humarwork,default,mybank';
window['env']['tenantLogoUrl'] = '';
window['env']['tenantLogoUrlDark'] = '';
Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

+72 -30
View File
@@ -6,53 +6,95 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/* ==========================================================================
Modernized Loader Wrapper (Inspired by openMF/mifos-mobile)
Supports full-screen and embedded containers with smooth vector physics.
========================================================================== */
.loader-wrapper {
height: 100vh;
width: 100vw;
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
min-height: 280px;
height: 100%;
background: transparent;
/* Modern orbital rings that replace legacy rigid triangles smoothly */
.triangle {
width: 0;
height: 0;
border-style: solid;
position: absolute;
animation: rotate 0.8s infinite 0.2s;
border-radius: 50%;
border-style: solid;
box-sizing: border-box;
transition: all 0.3s ease;
}
.bottom {
border-width: 0 90px 90px;
border-color: transparent transparent #1fb050;
bottom: calc(50% - 100px);
left: calc(50% - 90px);
width: 72px;
height: 72px;
border-width: 3px;
border-color: #2563eb transparent transparent;
animation: cbs-spin-ring 1.1s cubic-bezier(0.5, 0, 0.5, 1) infinite;
box-shadow: 0 0 16px rgb(37 99 235 / 35%);
}
.top {
border-width: 90px 90px 0;
border-color: #b4d575 transparent transparent;
top: calc(50% - 100px);
left: calc(50% - 90px);
width: 54px;
height: 54px;
border-width: 3px;
border-color: transparent transparent #0ea5e9;
animation: cbs-spin-ring 1.4s cubic-bezier(0.5, 0, 0.5, 1) infinite reverse;
box-shadow: 0 0 14px rgb(14 165 233 / 35%);
}
.left {
border-width: 90px 0 90px 90px;
border-color: transparent transparent transparent #1074b9;
top: calc(50% - 90px);
left: calc(50% - 100px);
width: 36px;
height: 36px;
border-width: 2.5px;
border-color: #10b981 transparent transparent;
animation: cbs-spin-ring 0.9s cubic-bezier(0.5, 0, 0.5, 1) infinite;
box-shadow: 0 0 10px rgb(16 185 129 / 35%);
}
.right {
border-width: 90px 90px 90px 0;
border-color: transparent #1daeec transparent transparent;
top: calc(50% - 90px);
right: calc(50% - 100px);
width: 18px;
height: 18px;
border-width: 2px;
border-color: transparent #38bdf8 transparent transparent;
animation: cbs-spin-ring 0.7s cubic-bezier(0.5, 0, 0.5, 1) infinite reverse;
}
@keyframes rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
/* Optional central glowing core */
&::after {
content: '';
width: 8px;
height: 8px;
border-radius: 50%;
background: #38bdf8;
box-shadow: 0 0 12px #38bdf8;
animation: cbs-pulse-core 1.5s ease-in-out infinite;
}
}
@keyframes cbs-spin-ring {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes cbs-pulse-core {
0%,
100% {
transform: scale(0.85);
opacity: 0.6;
}
50% {
transform: scale(1.3);
opacity: 1;
}
}
+1
View File
@@ -2971,6 +2971,7 @@
"Transfer Interest to Savings Account": "Transfer Interest to Savings Account",
"Transfer Type": "Transfer Type",
"Transfer in suspense": "Transfer in suspense",
"Transfer to GL Account": "Transfer to GL Account",
"Transfer to Savings": "Transfer to Savings",
"Transfer to savings account": "Transfer to savings account",
"Trigger Type": "Trigger Type",
+2 -2
View File
@@ -9,8 +9,8 @@
/* tslint:disable */
export default {
'mifos_x': {
'version': '261001',
'hash': '4f02f24a'
'version': '261008',
'hash': '4acee3e63'
},
'allow_switching_backend_instance': true
};
+2 -1
View File
@@ -20,7 +20,8 @@ export const environment = {
hash: env.mifos_x.hash,
// For connecting to server running elsewhere update the tenant identifier
fineractPlatformTenantId: loadedEnv['fineractPlatformTenantId'] || 'utkarshbank',
fineractPlatformTenantIds: loadedEnv['fineractPlatformTenantIds'] || 'utkarshbank',
fineractPlatformTenantIds:
loadedEnv['fineractPlatformTenantIds'] || 'utkarshbank,lincpay,utkarshnagari,testbank,humarwork,default,mybank',
// For connecting to others servers running elsewhere update the base API URL
baseApiUrls:
loadedEnv['fineractApiUrls'] || 'https://demo.mifos.community,https://localhost:8443,' + window.location.origin,
+2 -1
View File
@@ -23,7 +23,8 @@ export const environment = {
hash: env.mifos_x.hash,
// For connecting to server running elsewhere update the tenant identifier
fineractPlatformTenantId: loadedEnv.fineractPlatformTenantId || 'utkarshbank',
fineractPlatformTenantIds: loadedEnv.fineractPlatformTenantIds || 'utkarshbank',
fineractPlatformTenantIds:
loadedEnv.fineractPlatformTenantIds || 'utkarshbank,lincpay,utkarshnagari,testbank,humarwork,default,mybank',
// For connecting to others servers running elsewhere update the base API URL
baseApiUrls:
loadedEnv.fineractApiUrls || 'https://demo.mifos.community,https://localhost:8443,' + window.location.origin,
+290 -9
View File
@@ -11,7 +11,7 @@
<html lang="en" class="h-full bg-gray-100">
<head>
<meta charset="utf-8" />
<title>{{'APP_NAME' | translate}}</title>
<title>CBS Nexora</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
<meta name="apple-mobile-web-app-capable" content="yes" />
@@ -27,7 +27,10 @@
rel="stylesheet"
/>
<link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700|Roboto+Mono:300" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@500&display=swap" rel="stylesheet" />
<link
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Serif:wght@600&display=swap"
rel="stylesheet"
/>
<link
href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@400;500;600;700;800&family=Newsreader:ital,wght@0,400;0,500;1,400;1,500&display=swap"
rel="stylesheet"
@@ -108,13 +111,291 @@
});
</script>
<mifosx-web-app>
<div class="loader-wrapper">
<div class="bottom triangle"></div>
<div class="top triangle"></div>
<div class="left triangle"></div>
<div class="right triangle"></div>
<div id="cbs-app-splash-screen" class="cbs-init-screen">
<style>
.cbs-init-screen {
position: fixed;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: radial-gradient(circle at center, #0f172a 0%, #090d16 100%);
color: #f8fafc;
font-family:
'Plus Jakarta Sans',
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
sans-serif;
z-index: 999999;
overflow: hidden;
transition:
opacity 0.6s cubic-bezier(0.16, 1, 0.3, 1),
transform 0.6s cubic-bezier(0.16, 1, 0.3, 1);
}
.cbs-init-screen.cbs-splash-fade-out {
opacity: 0;
transform: scale(1.02);
pointer-events: none;
}
.cbs-init-brand {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
margin-bottom: 20px;
text-align: center;
}
.cbs-init-logo-wrap {
display: flex;
align-items: center;
justify-content: center;
}
.cbs-init-logo-img {
height: 52px;
width: auto;
max-width: 260px;
object-fit: contain;
filter: drop-shadow(0 6px 18px rgba(0, 0, 0, 0.5));
transition: transform 0.3s ease;
}
.cbs-init-icon {
width: 56px;
height: 56px;
border-radius: 14px;
background: linear-gradient(135deg, #1e3a8a 0%, #0284c7 100%);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 10px 24px rgba(14, 165, 233, 0.35);
border: 1px solid rgba(255, 255, 255, 0.2);
}
.cbs-init-title {
font-size: 1.5rem;
font-weight: 800;
letter-spacing: -0.02em;
margin: 0;
}
.cbs-init-sub {
font-size: 0.78rem;
color: #94a3b8;
font-weight: 500;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.cbs-init-lottie-box {
width: 130px;
height: 130px;
margin: 8px auto;
display: flex;
align-items: center;
justify-content: center;
filter: drop-shadow(0 8px 24px rgba(37, 99, 235, 0.25));
}
.cbs-init-spinner {
width: 64px;
height: 64px;
position: relative;
}
.cbs-init-ring {
position: absolute;
inset: 0;
border-radius: 50%;
border: 3px solid transparent;
}
.cbs-init-ring-1 {
border-top-color: #2563eb;
border-right-color: #2563eb;
animation: cbsInitSpin 1.1s cubic-bezier(0.5, 0, 0.5, 1) infinite;
}
.cbs-init-ring-2 {
inset: 6px;
border-bottom-color: #0ea5e9;
border-left-color: #0ea5e9;
animation: cbsInitSpin 1.5s cubic-bezier(0.5, 0, 0.5, 1) infinite reverse;
}
.cbs-init-ring-3 {
inset: 12px;
border-top-color: #10b981;
animation: cbsInitSpin 0.9s cubic-bezier(0.5, 0, 0.5, 1) infinite;
}
.cbs-init-track {
width: 260px;
height: 5px;
background: rgba(255, 255, 255, 0.1);
border-radius: 99px;
overflow: hidden;
margin: 18px 0 12px;
position: relative;
}
.cbs-init-fill {
height: 100%;
width: 0%;
background: linear-gradient(90deg, #2563eb, #38bdf8);
border-radius: 99px;
box-shadow: 0 0 12px rgba(56, 189, 248, 0.6);
transition: width 0.3s ease;
}
.cbs-init-status {
font-size: 0.82rem;
color: #94a3b8;
font-family: inherit;
margin: 0;
height: 20px;
}
@keyframes cbsInitSpin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>
<div class="cbs-init-brand">
<div class="cbs-init-logo-wrap">
<img
src="assets/images/cbsnexora-logo.png"
alt="CBS Nexora"
class="cbs-init-logo-img"
onerror="
this.style.display = 'none';
document.getElementById('cbs-init-fallback-brand').style.display = 'flex';
"
/>
<div id="cbs-init-fallback-brand" style="display: none; align-items: center; gap: 8px">
<div class="cbs-init-icon">
<svg width="32" height="32" viewBox="0 0 32 32">
<rect x="2" y="2" width="13" height="13" rx="2.5" fill="#ffffff" />
<rect x="17" y="2" width="13" height="13" rx="2.5" fill="#29abe2" />
<rect x="2" y="17" width="13" height="13" rx="2.5" fill="#00b388" />
<rect x="17" y="17" width="13" height="13" rx="2.5" fill="#6b7fa3" />
</svg>
</div>
<h2 class="cbs-init-title" style="margin: 0; font-size: 1.4rem">CBS Nexora</h2>
</div>
</div>
<span class="cbs-init-sub">Next-Generation Core Banking</span>
</div>
</mifosx-web-app>
<div id="cbs-init-lottie-target" class="cbs-init-lottie-box">
<div class="cbs-init-spinner">
<div class="cbs-init-ring cbs-init-ring-1"></div>
<div class="cbs-init-ring cbs-init-ring-2"></div>
<div class="cbs-init-ring cbs-init-ring-3"></div>
</div>
</div>
<div class="cbs-init-track">
<div class="cbs-init-fill" id="cbs-init-progress-bar"></div>
</div>
<p class="cbs-init-status" id="cbs-init-status-text">Connecting to Secure Banking Gateway...</p>
</div>
<script>
(function () {
var splash = document.getElementById('cbs-app-splash-screen');
var bar = document.getElementById('cbs-init-progress-bar');
var status = document.getElementById('cbs-init-status-text');
var progress = 0;
var minTimePassed = false;
var angularReady = false;
var dismissed = false;
// Load Lottie Vector Animation
var script = document.createElement('script');
script.src = 'assets/js/lottie.min.js';
script.onload = function () {
if (typeof lottie !== 'undefined') {
var target = document.getElementById('cbs-init-lottie-target');
if (target) {
target.innerHTML = '';
lottie.loadAnimation({
container: target,
renderer: 'svg',
loop: true,
autoplay: true,
path: 'assets/animations/loading_animation.json'
});
}
}
};
document.head.appendChild(script);
// Progress simulation stages (Guaranteed ~2 seconds duration on refresh)
var interval = setInterval(function () {
if (progress < 90) {
progress += 5;
} else if (angularReady && progress < 100) {
progress += 5;
}
if (bar) bar.style.width = Math.min(progress, 100) + '%';
if (status) {
if (progress >= 85) {
status.innerText = 'Launching CBS Nexora Dashboard...';
} else if (progress >= 60) {
status.innerText = 'Synchronizing core banking ledger schema...';
} else if (progress >= 30) {
status.innerText = 'Authenticating token & cryptographic keys...';
}
}
if (progress >= 100 && angularReady && minTimePassed) {
clearInterval(interval);
dismissSplash();
}
}, 90);
// Enforce minimum hold time (2000ms = 2 seconds) so refresh always shows the animation
setTimeout(function () {
minTimePassed = true;
if (angularReady) {
progress = 100;
if (bar) bar.style.width = '100%';
dismissSplash();
}
}, 2000);
function dismissSplash() {
if (dismissed) return;
dismissed = true;
clearInterval(interval);
if (bar) bar.style.width = '100%';
if (status) status.innerText = 'Ready! System loaded successfully.';
setTimeout(function () {
if (splash) {
splash.classList.add('cbs-splash-fade-out');
setTimeout(function () {
if (splash && splash.parentNode) {
splash.parentNode.removeChild(splash);
}
}, 600);
}
}, 250);
}
// Global hook for Angular to notify when ready
window.hideAppSplash = function () {
angularReady = true;
if (minTimePassed) {
dismissSplash();
}
};
// Safety fallback (Max 7s in case of backend network failure)
setTimeout(function () {
angularReady = true;
minTimePassed = true;
dismissSplash();
}, 7000);
})();
</script>
<mifosx-web-app></mifosx-web-app>
</body>
</html>
+343 -37
View File
@@ -81,66 +81,332 @@
// ===== Sub-menu Mega Panels (rendered in CDK overlay) =====
.deposits-mega-panel,
.hrm-mega-panel {
max-width: 700px !important;
.hrm-mega-panel,
.cashier-mega-panel,
.clerk-mega-panel,
.clearing-mega-panel,
.npa-mega-panel,
.advances-mega-panel,
.admin-mega-panel {
min-width: 680px !important;
max-width: 740px !important;
}
.branch-ops-mega-panel,
.insurance-mega-panel,
.digital-mega-panel,
.ckyc-mega-panel {
min-width: 440px !important;
max-width: 480px !important;
overflow-x: hidden !important;
}
.share-mega-panel,
.others-mega-panel {
max-width: 850px !important;
min-width: 780px !important;
max-width: 840px !important;
}
.share-grid,
.others-grid {
grid-template-columns: repeat(4, 1fr) !important;
}
.others-grid,
.npa-grid {
grid-template-columns: repeat(3, 1fr) !important;
}
.npa-mega-panel,
.advances-mega-panel {
.reports-mega-panel {
min-width: 740px !important;
max-width: 800px !important;
border-top: 2.5px solid #0369a1 !important;
}
.advances-grid {
grid-template-columns: repeat(3, 1fr) !important;
.letter-mega-panel,
.reconciliation-mega-panel {
min-width: 900px !important;
max-width: 960px !important;
}
/* ==========================================================================
Mega Menu Panel CDK Overlay Rules (Anchor naturally under triggers)
========================================================================== */
.cdk-overlay-pane:has(.mat-mdc-menu-panel) {
max-width: 96vw !important;
}
/* Individual Top Accent Border for Mega Panels */
.deposits-mega-panel,
.advances-mega-panel {
border-top: 2.5px solid #1074b9 !important;
}
.cashier-mega-panel {
border-top: 2.5px solid #0284c7 !important;
}
.clerk-mega-panel {
border-top: 2.5px solid #2563eb !important;
}
.branch-ops-mega-panel {
border-top: 2.5px solid #4f46e5 !important;
}
.clearing-mega-panel,
.reconciliation-mega-panel {
border-top: 2.5px solid #0d9488 !important;
}
.hrm-mega-panel {
border-top: 2.5px solid #0284c7 !important;
}
.admin-mega-panel,
.digital-mega-panel {
border-top: 2.5px solid #7c3aed !important;
}
.share-mega-panel {
border-top: 2.5px solid #d97706 !important;
}
.others-mega-panel {
border-top: 2.5px solid #0284c7 !important;
}
.npa-mega-panel {
border-top: 2.5px solid #dc2626 !important;
}
.letter-mega-panel {
max-width: 1100px !important;
border-top: 2.5px solid #15803d !important;
}
.letter-grid {
grid-template-columns: repeat(5, minmax(190px, 1fr)) !important;
min-width: 1040px;
.insurance-mega-panel {
border-top: 2.5px solid #c2410c !important;
}
.reconciliation-mega-panel {
max-width: 1100px !important;
.ckyc-mega-panel {
border-top: 2.5px solid #be185d !important;
}
/* Universal Mega Panel Grid styling inside Material Menu Overlays */
.deposit-menu-grid {
display: grid !important;
gap: 4px 12px !important;
padding: 6px 10px !important;
box-sizing: border-box !important;
min-width: 0 !important;
width: 100% !important;
max-width: 100% !important;
overflow-x: hidden !important;
}
/* Mega Panel Grids with exact column definitions */
.deposit-menu-grid.cashier-grid,
.deposit-menu-grid.clerk-grid,
.deposit-menu-grid.clearing-grid,
.deposit-menu-grid.advances-grid,
.deposit-menu-grid.deposits-grid,
.deposit-menu-grid.hrm-grid,
.deposit-menu-grid.admin-grid,
.deposit-menu-grid.npa-grid,
.cashier-grid,
.clerk-grid,
.clearing-grid,
.advances-grid,
.deposits-grid,
.hrm-grid,
.admin-grid,
.npa-grid {
grid-template-columns: repeat(3, minmax(210px, 1fr)) !important;
}
.deposit-menu-grid.reports-grid,
.reports-grid {
grid-template-columns: repeat(3, minmax(230px, 1fr)) !important;
}
.deposit-menu-grid.branch-ops-grid,
.deposit-menu-grid.insurance-grid,
.deposit-menu-grid.digital-grid,
.deposit-menu-grid.ckyc-grid,
.branch-ops-grid,
.insurance-grid,
.digital-grid,
.ckyc-grid {
grid-template-columns: repeat(2, minmax(200px, 1fr)) !important;
}
.deposit-menu-grid.share-grid,
.deposit-menu-grid.others-grid,
.share-grid,
.others-grid {
grid-template-columns: repeat(4, minmax(185px, 1fr)) !important;
}
.deposit-menu-grid.letter-grid,
.deposit-menu-grid.reconciliation-grid,
.letter-grid,
.reconciliation-grid {
grid-template-columns: repeat(5, minmax(190px, 1fr)) !important;
min-width: 1040px;
grid-template-columns: repeat(5, minmax(170px, 1fr)) !important;
}
/* Admin Mega Menu — 3-column rectangular panel (Setup · Catalog ·
Audit & Compliance). Reuses the deposit-mega-menu grid + group chrome
and retints the group-title accent to Admin purple. */
.admin-mega-panel {
max-width: 760px !important;
}
.admin-grid {
grid-template-columns: repeat(3, minmax(220px, 1fr)) !important;
min-width: 680px;
.deposit-menu-group {
display: flex !important;
flex-direction: column !important;
gap: 1px !important;
min-width: 0 !important;
.deposit-group-title {
color: #7c3aed !important;
border-bottom-color: #7c3aed !important;
font-weight: 700 !important;
font-size: 9.5px !important;
text-transform: uppercase !important;
letter-spacing: 0.6px !important;
color: #1074b9 !important;
padding: 3px 6px !important;
margin-bottom: 2px !important;
border-bottom: 1.5px solid #1074b9 !important;
display: flex !important;
align-items: center !important;
line-height: 1.2 !important;
white-space: nowrap !important;
}
}
/* Specific Accent Colors for Mega Panel Group Titles */
.cashier-grid .deposit-group-title {
color: #0284c7 !important;
border-bottom-color: #0284c7 !important;
}
.clerk-grid .deposit-group-title {
color: #2563eb !important;
border-bottom-color: #2563eb !important;
}
.clearing-grid .deposit-group-title {
color: #0d9488 !important;
border-bottom-color: #0d9488 !important;
}
.branch-ops-grid .deposit-group-title {
color: #4f46e5 !important;
border-bottom-color: #4f46e5 !important;
}
.reports-grid .deposit-group-title {
color: #0369a1 !important;
border-bottom-color: #0369a1 !important;
}
.insurance-grid .deposit-group-title {
color: #c2410c !important;
border-bottom-color: #c2410c !important;
}
.digital-grid .deposit-group-title {
color: #7c3aed !important;
border-bottom-color: #7c3aed !important;
}
.ckyc-grid .deposit-group-title {
color: #be185d !important;
border-bottom-color: #be185d !important;
}
.admin-grid .deposit-group-title {
color: #7c3aed !important;
border-bottom-color: #7c3aed !important;
}
.reconciliation-grid .deposit-group-title {
color: #0d9488 !important;
border-bottom-color: #0d9488 !important;
}
/* Compact, RBI-Style Menu Item Overrides in CDK Overlays */
/* Compact, RBI-Style Menu Item Overrides for Navbar Mega Panels ONLY */
.mat-mdc-menu-panel:has(.deposit-menu-grid) {
--mat-menu-item-label-text-size: 10.5px !important;
--mat-menu-item-label-text-line-height: 14px !important;
border-radius: 8px !important;
box-shadow:
0 10px 28px rgb(15 23 42 / 14%),
0 2px 6px rgb(15 23 42 / 8%) !important;
border: 1px solid #d1d5db !important;
background: #fff !important;
max-height: 75vh !important;
overflow: hidden auto !important;
.mat-mdc-menu-content {
padding: 4px 6px !important;
overflow-x: hidden !important;
}
.deposit-menu-grid .mat-mdc-menu-item {
font-size: 10.5px !important;
font-weight: 500 !important;
height: 25px !important;
min-height: 25px !important;
line-height: 25px !important;
padding: 0 6px !important;
border-radius: 4px !important;
color: #334155 !important;
font-family:
'IBM Plex Sans',
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
sans-serif !important;
transition:
background-color 0.12s ease,
color 0.12s ease !important;
white-space: nowrap !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
min-width: 0 !important;
width: 100% !important;
box-sizing: border-box !important;
.mat-mdc-menu-item-text,
.mdc-list-item__primary-text,
.mdc-list-item__content {
font-size: 10.5px !important;
font-weight: 500 !important;
line-height: 1.25 !important;
color: #334155 !important;
white-space: nowrap !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
display: inline-flex !important;
align-items: center !important;
width: 100% !important;
}
.menu-item-icon,
fa-icon,
i {
font-size: 10.5px !important;
width: 13px !important;
min-width: 13px !important;
text-align: center !important;
margin-right: 5px !important;
opacity: 0.8 !important;
flex-shrink: 0 !important;
}
&:hover {
background-color: #f0f7ff !important;
color: #0d47a1 !important;
.mat-mdc-menu-item-text,
.mdc-list-item__primary-text {
color: #0d47a1 !important;
}
.menu-item-icon,
fa-icon,
i {
opacity: 1 !important;
color: #0d47a1 !important;
}
}
}
}
@@ -2012,3 +2278,43 @@ mifosx-client-datatable-step * {
}
}
}
// ===== CBS Compact Enterprise Density Polish =====
// Reduces excessive whitespace and oversized elements across the banking UI
.mat-mdc-table {
th.mat-mdc-header-cell {
padding: 8px 12px !important;
font-size: 11px !important;
font-weight: 700 !important;
letter-spacing: 0.04em !important;
}
td.mat-mdc-cell {
padding: 6px 12px !important;
font-size: 12px !important;
line-height: 1.35 !important;
}
tr.mat-mdc-row,
tr.mat-mdc-header-row {
height: 36px !important;
min-height: 36px !important;
}
}
// Compact Material Cards
.mat-mdc-card {
padding: 12px 16px !important;
}
// Breadcrumb & Page Titles Compact Spacing
.breadcrumb-container {
margin-bottom: 8px !important;
padding: 4px 0 !important;
}
.mifosx-page-title,
.page-title {
font-size: 17px !important;
margin-bottom: 8px !important;
}
+1 -1
View File
@@ -53,7 +53,7 @@ $mifosx-typography: mat.m2-define-typography-config(
$button: $mifosx-typography-level
);
$mifosx-density: 0;
$mifosx-density: -1;
$mifosx-app-theme: mat.m2-define-light-theme(
(