Compare commits

...
Author SHA1 Message Date
niteshandClaude Opus 5.5 211027b9d8 fix(bank-calendar): follow Admin > Working Days for weekly offs
The Bank Calendar never read the Working Days setting. It hard-coded
Mon-Sat as working days and always marked only Sundays and the 2nd and
4th Saturday as off, so unticking Saturday (or any weekday) in
Admin > Working Days changed nothing on the calendar.

- Load the setting from /workingdays and parse its recurrence
  (BYDAY=MO,TU,...). Until it answers, or if it cannot be read, the old
  Mon-Sat default stays in place.
- A weekday left out of Working Days is off every week. When Saturday
  is left out, every Saturday is off; when it is a working day, the RBI
  rule still keeps the 2nd and 4th Saturday off. Sunday shows as a
  working day if Working Days ticks it.
- Month and year counts, the Saturday stat card label ("2nd & 4th Sat"
  or "Saturdays"), the legend, tooltips and the month summary tags
  ("N Sat Off", "N Weekly Off") follow the same rule.

Verified with prettier, eslint, tsc and ng build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 10:57:06 +05:30
niteshandClaude Opus 5.5 d88f2a6eb1 Merge latest feature/advanceLoanProduct into feature/merge-dds-advance-loan-into-dev-latest
Brings Sonali's four new commits (7-12 Oct):
- Loans A/C (EMI) stepper wired to the loan interest rate register
- Maker-checker UI for Delinquency Ranges and Buckets
- GL mapping, checker diff view and approval flow for the loan interest
  rate register
- Her merge of dev-latest (7 Oct)

Conflicts resolved:
- src/assets/env.js, src/environments/.env.ts: kept dev-latest (all
  seven tenants; .env.ts is regenerated by `npm start`).

Hidden issue handled (no Git conflict, but keshav's work would have
been overwritten):
Sonali's merge commit a5a64d4ef carried its own changes to 19 of
keshav's non-loan files (TDS reports, RBI reserve return / Form 1,
dashboard and CRR-SLR dialog, Form 121 tab, RBI reserve config,
risk review). keshav changed two of them again on 12 Oct
(2d2eac8d0). These 19 files were re-merged three-way against keshav's
7 Oct version (6b0f218b7), the version Sonali merged from:
- every line keshav added after 7 Oct is kept;
- every line Sonali added is kept;
- about 130 of keshav's 7 Oct lines that Sonali edited inside her merge
  commit now carry her edit. No conflicts.
Files: tds-interest-export(.spec).ts, tds-interest-report.component
(.html/.ts), tds-remittance-report.component(.html/.ts),
tds-reports.compute(.spec).ts, tds-reports.service.ts,
reserve-return.base.ts, reserve-return.component.html,
form1-statutory.component.scss, rbi-reserve-config.component.html,
dashboard.component(.html/.ts), crr-slr-dialog.component(.html/.ts),
form121-tab.component.ts, risk-review-report.component.scss.

Prettier formatting on rbi-reserve-config.component.html and
reserve-return.component.html after the merge (content unchanged).

Nothing from dev-latest, DDS, keshav or Sonali is removed. Verified
with tsc -p src/tsconfig.app.json --noEmit, prettier, stylelint,
eslint and the license header check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:29:09 +05:30
sonaliandClaude Sonnet 5 606d14b995 feat(loan-products): build out Loans A/C (EMI) stepper and wire it to the rate register
Adds the curated Loans A/C steps (security/collateral, amount & tenure,
repayment & EMI, interest, prepayment & closure) and a product-type
picker (loan-product-types.ts) shared by the create wizard, product
list and account picker. The interest step and a new register-rate-view
read the live Loan Interest Rate Register for a product's chart/CIBIL
rate instead of a hardcoded value, and a new loan-product-catalog
service + template card back the loan account stepper's product pick.

Also carries forward in-progress Loan Interest Rate Register UI work:
an inline Add Slab panel (replacing window.prompt) and a Submit Details
dialog for confirming name/effective dates before submitting for
approval.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-12 15:53:43 +05:30
sonaliandClaude Sonnet 5 afcd3af918 feat(delinquency): maker-checker UI for Delinquency Ranges and Buckets
Checker inbox now resolves DELINQUENCY_RANGE/DELINQUENCY_BUCKET entries
to readable diffs instead of raw JSON, and each bucket/range screen
shows its own pending-approval state via a new pending-requests panel.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-12 15:52:11 +05:30
niteshandClaude Opus 5.5 0eb087e3c3 Merge branch 'feature/advanceLoanProduct' into merge/dds-advance-loan-into-dev-latest
Adds the Advance Loan Product UI on top of the DDS merge of dev-latest:
- Advance loan product stepper steps (details, settings, terms) and
  create-loan-product changes
- Loan interest rate register UI
- Interest loan related UI

Conflicts resolved:
- src/assets/env.js: kept dev-latest. feature/advanceLoanProduct set
  the default tenant to 'default', cut the tenant list to 'default'
  only and forced 'default' into every browser's localStorage on load,
  which is a local testing setup and would hide the other tenants
  (utkarshbank, lincpay, utkarshnagari, testbank, humarwork, mybank).
- src/environments/.env.ts: kept dev-latest's generated version/hash;
  the file is regenerated by `npm start`.

Nothing from dev-latest or DDS is removed. Lines removed by this merge
are only in loan-product files that neither dev-latest nor DDS touched
(feature/advanceLoanProduct's own rewrite); products.service.ts only
gains HttpContext in its @angular/common/http import.

Verified with tsc -p src/tsconfig.app.json --noEmit, prettier,
stylelint, eslint and the license header check on all merged files.

Branch renamed from merge/dds-into-dev-latest, since it now carries
both DDS and Advance Loan Product.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:28:44 +05:30
niteshandClaude Opus 5.5 7c23bdd6af Merge branch 'feature/dds' into merge/dds-into-dev-latest
Brings the DDS (Daily Deposit Scheme) UI from feature/dds onto the
current dev-latest:
- Agent settlement (Agent Tran), Close Party account, DDS reports,
  Party-Savings link field
- Agent collection entry screen, per-agent GL account, maturity auto
  close
- Approvals tab, vouchers, payout & close, Sundry GL search, closure
  tab and fixes
- Menu: DDS Party Master, DDS Accounts, DDS Collection, DDS Reports and
  Locker links placed in the Deposits mega panel groups; Agent Master,
  Commission Slab and DDS Batch Processing under Organization; DDS
  Approvals tab in the checker inbox

No content conflicts. Nothing from dev-latest is removed.

Merge adjustments:
- package-lock.json: kept dev-latest's lock file. feature/dds does not
  add or change any dependency in package.json; its lock file was only
  regenerated (about 18k lines removed and 12k added), which would have
  changed resolved versions for packages already on dev-latest.
- Dropped src/app/deposits/party-master/CBS-Backend.code-workspace, a
  VS Code workspace file holding local folder paths, not app code.
- angular.json: bundle budget raised to 18mb warning / 20mb error, as on
  feature/dds.

Lint fixes required by the pre-commit hook (no behaviour change):
- Prettier formatting on 12 DDS files.
- create-party.component.scss: the dense data-entry section deliberately
  re-targets the base selectors to override them, so
  no-duplicate-selectors is disabled for that section instead of
  merging rules and changing the cascade.
- notice-dialog / create-dds-account-type scss: stylelint-disable-line
  for the "ch" width unit.
- commission-slab-master.component.scss: stylelint auto-fix.

Verified with tsc -p src/tsconfig.app.json --noEmit, stylelint, prettier
and eslint on all merged files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:10:37 +05:30
niteshandClaude Opus 5.5 767461faae Merge branch 'feature/fd-interest-posting' into dev-latest
Brings the FD interest posting approval UI and the latest UI work into
dev-latest:
- FD interest posting approval screens, posting period and
  GL-reconciled maturity interest
- FD maturity approval UI added and then reverted (maturity now runs
  directly through the scheduler, no approval step)
- "mybank" tenant registered in the UI tenant list
- Animated boot splash and reusable loading screen
- Compact navigation with grouped mega menus and branded loaders

Conflict resolved:
- src/environments/.env.ts: kept dev-latest's generated version/hash;
  the file is regenerated by `npm start` anyway.

Also: prettier formatting of toolbar.component.html (already
unformatted on dev-latest; formatting only, required by the
pre-commit hook).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 12:20:00 +05:30
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
keshav c91dcf4d99 Merge branch 'dev-latest' of https://git.lincpay.tech/CBSNexora/cbsnexora into dev-latest
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s
2026-10-12 10:23:26 +05:30
keshav 2d2eac8d03 feat: recurring deposit enhancements, dashboard, chart, and account view updates 2026-10-12 10:23:10 +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
sonaliandClaude Sonnet 5 d61584f291 feat(loan-products): wire GL mapping, checker diff view, and approval flow for loan interest rate register
- Add GL account picker (gl-picker.component.ts) for mapping each product row to a GL account
- Extract shared diff logic into rate-register-diff.util.ts + loan-rate-register-diff.util.ts
- Add rate-register-diff component to render before/after changes for checker approval
- Rewire checker-inbox and view-checker-inbox to show loan interest rate register diffs
- Refactor rate-register.service.ts (mock-to-real HTTP wiring, GL-aware payload mapping)
- Simplify preview-register component, moving diff rendering into the shared diff component
- Add glAccountId field to RateChartRow model

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-08 17:22:59 +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
sonali a5a64d4efe Merge branch 'dev-latest' into feature/advanceLoanProduct 2026-10-07 15:44:36 +05:30
sonali 0ea57b3f82 Add interest loan related UI 2026-10-07 15:30:15 +05:30
nitesh 0976bc230c Merge branch 'dev-latest' of https://git.lincpay.tech/CBSNexora/cbsnexora into dev-latest 2026-10-07 10:19:26 +05:30
keshav 6b0f218b70 Merge branch 'dev-latest' of https://git.lincpay.tech/CBSNexora/cbsnexora into dev-latest 2026-10-07 10:13:37 +05:30
keshav 6226682b36 feat: updates for cashier, tds reports, rbi reserve and dashboard 2026-10-07 10:13:28 +05:30
niteshandClaude Opus 5 38884610d2 feat(shares): preview the approval checklist before submit, fix settlement GL option
The Clerk Dashboard's standalone Share Withdrawal Request form never offered a
GL settlement destination -- it always submitted SAVINGS, silently, while the
Cashier's Transaction Entry screen already let a clerk pick any postable GL
account on its CR row. Added the same "Settlement Destination" choice (Savings
/ GL Account) with a searchable GL selector here too, so both entry points to
the same backend endpoint behave the same way.

Both share-withdrawal confirm dialogs (savings settlement and GL settlement, in
transaction-entry.component.ts) now call the backend's new preview-validate
endpoint before showing the confirm dialog, so a clerk sees the same checklist
a manager will see on approval -- a missing GL mapping, an unverified KYC, an
open AML alert -- before committing to the request, not after a manager has
already spent time reviewing it. A failing check is shown, not blocked: real
hard rules are still enforced server-side at approve(). Guarded with a 5s
timeout and a fallback message so a slow or unavailable check can never leave
the dialog looking stuck.

error-handler.interceptor.ts: a document/signature attachment whose row
survives but whose file is missing from the content store now resolves to a
silent 404 (matching the existing image-fetch handling) instead of surfacing a
raw error. Background GET failures (500/501/503) on calls a component already
recovers from (photo, attachment, notes, standing instructions, ...) now
surface as a corner toast instead of a blocking modal that has to be dismissed
-- a write action's failure (POST/PUT/DELETE) still gets the centred,
must-acknowledge dialog, since that is the one thing the user was waiting on.
Also surfaces the backend's real message on a 500 instead of a hardcoded
generic sentence.

Backend counterpart: ClerkShareWithdrawalRequestApiResource (preview-validate
endpoint, shared buildShareWithdrawalChecks()) and
DocumentReadPlatformServiceImpl (missing-file 404) land separately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-06 22:36:15 +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
Sonali LikhitkarandClaude Sonnet 5.5 7c868ae073 feat(loan-products): add loan interest rate register UI
- Add Loan Interest Rate Register screen with CIBIL adjustment and edit-register views, backed by rate-register service and model
- Wire the new screen into products routing and the products menu
- Update loan product details and advance limit/tenure steps

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 16:20:36 +05:30
tarunmalviya 885b39d970 DDS: approvals tab, vouchers, payout & close, Sundry GL search, closure tab, fixes 2026-10-01 13:08:52 +05:30
Sonali LikhitkarandClaude Sonnet 5.5 283ab2f733 feat(loan-products): add advance loan product stepper steps
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 18:06:08 +05:30
tarunmalviya d47e0860f6 Merge remote-tracking branch 'origin/dev-latest' into feature/dds-agent-settlement-reports
# Conflicts:
#	src/app/core/shell/shell.component.html
#	src/app/organization/organization-routing.module.ts
#	src/environments/.env.ts
2026-09-23 12:11:12 +05:30
tarunmalviyaandClaude Opus 5 e2eff9914f DDS: agent collection entry screen, per-agent GL account, maturity auto close
The Daily Transaction Entry screen no longer composes a Party Tran. An agent records their own round
elsewhere; this screen reads back what they wrote down, shows what it adds up to, and settles the
day once a clerk has counted the cash against it. Nothing is typed here and nothing is edited - what
settles is what the entries say, to the rupee.

Agent Master now asks which ledger account the agent's collections pass through, one agent to an
account, and offers only the accounts that can carry them. The DDS scheme product is kept out of the
agent's own savings list for the same reason the server refuses it: debits are blocked on those
accounts, so commission paid into one would go in and never come out.

The DDS product carries a Maturity Auto Close setting, asked as Yes/No to match the Auto Interest
Post question directly above it, and Batch Processing offers the run that applies it.

Also: the report viewer stops printing two money columns ungrouped and stops totalling a column that
holds a word, and an agent's birth date cannot be picked later than eighteen years ago.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 11:54:49 +05:30
tarunmalviya 57a61b3057 DDS: Agent settlement (Agent Tran), Close Party account UI, reports UI, Party-Savings link field 2026-08-25 10:56:03 +05:30
321 changed files with 48253 additions and 4585 deletions
+9 -2
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": [
@@ -69,8 +76,8 @@
"budgets": [
{
"type": "initial",
"maximumWarning": "15mb",
"maximumError": "17mb"
"maximumWarning": "18mb",
"maximumError": "20mb"
},
{
"type": "anyComponentStyle",
+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",
@@ -11,6 +11,14 @@ import { Routes, RouterModule } from '@angular/router';
import { Route } from '../core/route/route.service';
import { AccountsListComponent } from './accounts-list/accounts-list.component';
import { PartyMasterComponent } from '../deposits/party-master/party-master.component';
import { CreatePartyComponent } from '../deposits/party-master/create-party/create-party.component';
import { DueForClosureComponent } from '../deposits/party-master/due-for-closure/due-for-closure.component';
import { DailyTransactionEntryComponent } from '../deposits/daily-transaction-entry/daily-transaction-entry.component';
import { DdsReportsComponent } from '../deposits/dds-reports/dds-reports.component';
import { ReportViewerComponent } from '../deposits/dds-reports/report-viewer/report-viewer.component';
import { DdsAccountsComponent } from '../deposits/dds-accounts/dds-accounts.component';
import { DdsAccountViewComponent } from '../deposits/dds-accounts/dds-account-view/dds-account-view.component';
const routes: Routes = [
Route.withShell([
@@ -41,6 +49,59 @@ const routes: Routes = [
title: 'Recurring Deposit Accounts',
breadcrumb: 'Recurring Deposit Accounts'
}
},
{
path: 'dds',
data: { title: 'Daily Deposit Scheme — Party Master', breadcrumb: 'Party Master' },
children: [
{ path: '', component: PartyMasterComponent },
{
path: 'create',
component: CreatePartyComponent,
data: { title: 'Create Party', breadcrumb: 'Create' }
},
{
// Ahead of the :partyId routes, so "dueforclosure" is not read as a party id.
path: 'dueforclosure',
component: DueForClosureComponent,
data: { title: 'Accounts Due for Closure', breadcrumb: 'Due for Closure' }
},
{
path: ':partyId/edit',
component: CreatePartyComponent,
data: { title: 'Edit Party', breadcrumb: 'Edit' }
}
]
},
{
// The DDS accounts listed like Savings Accounts, each opening onto its transactions.
path: 'dds-accounts',
data: { title: 'DDS Accounts', breadcrumb: 'DDS Accounts' },
children: [
{ path: '', component: DdsAccountsComponent },
{
path: ':partyId',
component: DdsAccountViewComponent,
data: { title: 'DDS Account', breadcrumb: 'Account', routeParamBreadcrumb: false }
}
]
},
{
path: 'dds-collection',
component: DailyTransactionEntryComponent,
data: { title: 'Daily Transaction Entry', breadcrumb: 'Daily Transaction Entry' }
},
{
path: 'dds-reports',
data: { title: 'DDS Reports', breadcrumb: 'DDS Reports' },
children: [
{ path: '', component: DdsReportsComponent },
{
path: 'view/:reportName',
component: ReportViewerComponent,
data: { title: 'DDS Report', breadcrumb: 'reportName', routeParamBreadcrumb: 'reportName' }
}
]
}
]
}
+5
View File
@@ -85,6 +85,11 @@ export class AccountsService {
return this.http.get('/fixeddepositaccounts/maturity-dashboard');
}
/** RD Dashboard: running RDs with their installment position, closures and penalty income for the month. */
getRecurringDepositDashboard(): Observable<any> {
return this.http.get('/recurringdepositaccounts/dashboard');
}
getRecurringDepositAccounts(params: AccountListParams = {}): Observable<any> {
let httpParams = new HttpParams()
.set('paged', 'true')
+4
View File
@@ -64,6 +64,7 @@ import { BankCalendarModule } from './bank-calendar/bank-calendar.module';
import { DeafModule } from './deaf/deaf.module';
import { SavingsDailyInterestModule } from './savings-daily-interest/savings-daily-interest.module';
import { FdDashboardModule } from './fd-dashboard/fd-dashboard.module';
import { RdDashboardModule } from './rd-dashboard/rd-dashboard.module';
import { DicgcModule } from './dicgc/dicgc.module';
import { FiuModule } from './fiu/fiu.module';
import { GlReportsModule } from './gl-reports/gl-reports.module';
@@ -96,6 +97,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 {
@@ -178,6 +180,7 @@ export function HttpLoaderFactory(http: HttpClient) {
DeafModule,
SavingsDailyInterestModule,
FdDashboardModule,
RdDashboardModule,
DicgcModule,
FiuModule,
GlReportsModule,
@@ -195,6 +198,7 @@ export function HttpLoaderFactory(http: HttpClient) {
AppRoutingModule,
NotFoundComponent,
CallbackComponent,
LoadingScreenComponent,
OAuthModule.forRoot()
],
providers: [
@@ -44,8 +44,11 @@
<div class="cal-stat sat2nd4th">
<fa-icon icon="calendar-times" class="cal-stat__icon"></fa-icon>
<div class="cal-stat__info">
<div class="cal-stat__value">{{ overallStats.sat2nd4th }}</div>
<div class="cal-stat__label">2nd &amp; 4th Sat</div>
<div class="cal-stat__value">{{ overallStats.sat2nd4th + overallStats.weeklyOff }}</div>
<div class="cal-stat__label">
{{ saturdayIsWorkingDay ? '2nd & 4th Sat' : 'Saturdays'
}}{{ overallStats.weeklyOff > 0 ? ' / Weekly Off' : '' }}
</div>
</div>
</div>
<div class="cal-stat holidays">
@@ -69,7 +72,11 @@
<div class="cal-legend__items">
<div class="cal-legend__item"><span class="cal-legend__dot working"></span> Working Day</div>
<div class="cal-legend__item"><span class="cal-legend__dot sunday"></span> Sunday</div>
<div class="cal-legend__item"><span class="cal-legend__dot sat2nd4th"></span> 2nd / 4th Saturday</div>
<div class="cal-legend__item">
<span class="cal-legend__dot sat2nd4th"></span>
{{ saturdayIsWorkingDay ? '2nd / 4th Saturday' : 'Saturday'
}}{{ overallStats.weeklyOff > 0 ? ' / Weekly Off' : '' }}
</div>
<div class="cal-legend__item"><span class="cal-legend__dot holiday"></span> Holiday</div>
<div class="cal-legend__item"><span class="cal-legend__dot today"></span> Today</div>
</div>
@@ -319,7 +326,9 @@
[class.cal-grid__day--other]="!day.isCurrentMonth"
[class.cal-grid__day--today]="day.isToday"
[class.cal-grid__day--sunday]="day.isSunday && day.isCurrentMonth"
[class.cal-grid__day--sat2nd4th]="(day.is2ndSaturday || day.is4thSaturday) && day.isCurrentMonth"
[class.cal-grid__day--sat2nd4th]="
(day.is2ndSaturday || day.is4thSaturday || day.isWeeklyOff) && day.isCurrentMonth
"
[class.cal-grid__day--holiday]="day.isHoliday && day.isCurrentMonth"
[class.cal-grid__day--working]="day.isWorking && day.isCurrentMonth"
[matTooltip]="
@@ -328,9 +337,11 @@
? '2nd Saturday Off'
: day.is4thSaturday
? '4th Saturday Off'
: day.isSunday && day.isCurrentMonth
? 'Sunday'
: '')
: day.isWeeklyOff && day.isCurrentMonth
? weekDays[day.dayOfWeek] + ' Off (Working Days)'
: day.isSunday && day.isCurrentMonth
? 'Sunday'
: '')
"
>
{{ day.isCurrentMonth ? day.date : '' }}
@@ -343,10 +354,15 @@
@if (month.holidays > 0) {
<span class="summary-tag holiday">{{ month.holidays }} Holiday{{ month.holidays > 1 ? 's' : '' }}</span>
}
<span class="summary-tag sun">{{ month.sundays }} Sun</span>
@if (month.sundays > 0) {
<span class="summary-tag sun">{{ month.sundays }} Sun</span>
}
@if (month.secondFourthSat > 0) {
<span class="summary-tag sat">{{ month.secondFourthSat }} Sat Off</span>
}
@if (month.weeklyOff > 0) {
<span class="summary-tag sat">{{ month.weeklyOff }} Weekly Off</span>
}
</div>
</div>
}
@@ -63,6 +63,8 @@ interface CalendarDay {
isSunday: boolean;
is2ndSaturday: boolean;
is4thSaturday: boolean;
/** Off every week because Admin > Working Days leaves this weekday out (Sunday aside). */
isWeeklyOff: boolean;
holidayName?: string;
dayOfWeek: number; // 0=Sun, 6=Sat
}
@@ -76,9 +78,23 @@ interface MonthData {
nonWorkingDays: number;
holidays: number;
sundays: number;
/** Saturdays off: the 2nd and 4th when Saturday is a working day, every one when it is not. */
secondFourthSat: number;
/** Other weekdays off every week because Working Days leaves them out. */
weeklyOff: number;
}
/** Weekday codes in Date#getDay() order, as used by the /workingdays recurrence (BYDAY=MO,TU,...). */
const DAY_CODES = [
'SU',
'MO',
'TU',
'WE',
'TH',
'FR',
'SA'
];
@Component({
selector: 'mifosx-bank-calendar',
templateUrl: './bank-calendar.component.html',
@@ -118,7 +134,7 @@ export class BankCalendarComponent implements OnInit {
];
workingDaysConfig: string[] = [];
holidays: any[] = [];
overallStats = { working: 0, nonWorking: 0, holidays: 0, sundays: 0, sat2nd4th: 0, totalDays: 0 };
overallStats = { working: 0, nonWorking: 0, holidays: 0, sundays: 0, sat2nd4th: 0, weeklyOff: 0, totalDays: 0 };
// ───── Holiday list (office-wise) ─────
offices: OfficeLite[] = [];
@@ -202,7 +218,8 @@ export class BankCalendarComponent implements OnInit {
}
private loadWorkingDays() {
// Default Indian banking working days: Mon-Sat (Sun off, 2nd & 4th Sat off)
// Default Indian banking working days: Mon-Sat (Sun off, 2nd & 4th Sat off), used until
// Admin > Working Days answers, or if it cannot be read.
this.workingDaysConfig = [
'MO',
'TU',
@@ -215,6 +232,19 @@ export class BankCalendarComponent implements OnInit {
this.buildCalendar();
this.rebuildHolidayRows();
// The calendar has to agree with Admin > Working Days: a weekday unticked there is off every
// week here too.
this.organizationService.getWorkingDays().subscribe({
next: (data: any) => {
const configured = this.parseWorkingDays(data?.recurrence);
if (configured.length > 0) {
this.workingDaysConfig = configured;
this.buildCalendar();
}
},
error: () => {}
});
// Pull offices then fan-out a per-office holiday fetch so each office's
// calendar entries are surfaced in the list panel (calendar grid still
// unions everything for the year-at-a-glance view).
@@ -273,6 +303,23 @@ export class BankCalendarComponent implements OnInit {
});
}
/** Weekday codes out of a recurrence such as "FREQ=WEEKLY;INTERVAL=1;BYDAY=MO,TU,WE,TH,FR". */
private parseWorkingDays(recurrence: string | undefined): string[] {
const byDay = /BYDAY=([^;]*)/.exec(recurrence || '');
if (!byDay) {
return [];
}
return byDay[1]
.split(',')
.map((d) => d.trim().toUpperCase())
.filter((d) => DAY_CODES.includes(d));
}
/** True when Admin > Working Days counts Saturday as a working day (so only the 2nd and 4th are off). */
get saturdayIsWorkingDay(): boolean {
return this.workingDaysConfig.includes('SA');
}
/** Flatten the holidays array into a presentable list with office labels. */
private rebuildHolidayRows(): void {
this.holidayRows = this.holidays.map((h: any) => {
@@ -511,7 +558,15 @@ export class BankCalendarComponent implements OnInit {
buildCalendar() {
this.months = [];
this.overallStats = { working: 0, nonWorking: 0, holidays: 0, sundays: 0, sat2nd4th: 0, totalDays: 0 };
this.overallStats = {
working: 0,
nonWorking: 0,
holidays: 0,
sundays: 0,
sat2nd4th: 0,
weeklyOff: 0,
totalDays: 0
};
for (let m = 0; m < 12; m++) {
const monthData = this.buildMonth(m, this.currentYear);
@@ -521,6 +576,7 @@ export class BankCalendarComponent implements OnInit {
this.overallStats.holidays += monthData.holidays;
this.overallStats.sundays += monthData.sundays;
this.overallStats.sat2nd4th += monthData.secondFourthSat;
this.overallStats.weeklyOff += monthData.weeklyOff;
}
this.overallStats.totalDays = this.overallStats.working + this.overallStats.nonWorking;
}
@@ -552,6 +608,7 @@ export class BankCalendarComponent implements OnInit {
let holidayCount = 0;
let sundayCount = 0;
let sat2nd4thCount = 0;
let weeklyOffCount = 0;
// Previous month padding
const prevMonthDays = new Date(year, month, 0).getDate();
@@ -567,6 +624,7 @@ export class BankCalendarComponent implements OnInit {
isSunday: false,
is2ndSaturday: false,
is4thSaturday: false,
isWeeklyOff: false,
dayOfWeek: (startDayOfWeek - i - 1 + 7) % 7
});
}
@@ -576,24 +634,29 @@ export class BankCalendarComponent implements OnInit {
for (let d = 1; d <= daysInMonth; d++) {
const date = new Date(year, month, d);
const dow = date.getDay();
const isSunday = dow === 0;
const isSaturday = dow === 6;
const isWorkingWeekday = this.workingDaysConfig.includes(DAY_CODES[dow]);
const isSunday = dow === 0 && !isWorkingWeekday;
if (isSaturday) saturdayCount++;
const is2ndSat = isSaturday && saturdayCount === 2;
const is4thSat = isSaturday && saturdayCount === 4;
// RBI: when Saturday is a working day the 2nd and 4th are still off. When Working Days leaves
// Saturday out, every Saturday is a weekly off instead.
const is2ndSat = isSaturday && isWorkingWeekday && saturdayCount === 2;
const is4thSat = isSaturday && isWorkingWeekday && saturdayCount === 4;
const isWeeklyOff = dow !== 0 && !isWorkingWeekday;
const isToday = date.toDateString() === today.toDateString();
// Check if holiday
const holiday = this.getHolidayForDate(year, month, d);
const isHoliday = !!holiday;
// Non-working: Sunday, 2nd Sat, 4th Sat, or holiday
const isNonWorking = isSunday || is2ndSat || is4thSat || isHoliday;
// Non-working: Sunday, 2nd Sat, 4th Sat, a weekly off from Working Days, or holiday
const isNonWorking = isSunday || is2ndSat || is4thSat || isWeeklyOff || isHoliday;
if (isSunday) sundayCount++;
if (is2ndSat || is4thSat) sat2nd4thCount++;
if (is2ndSat || is4thSat || (isSaturday && isWeeklyOff)) sat2nd4thCount++;
if (isWeeklyOff && !isSaturday) weeklyOffCount++;
if (isHoliday) holidayCount++;
if (isNonWorking) {
@@ -613,6 +676,7 @@ export class BankCalendarComponent implements OnInit {
isSunday,
is2ndSaturday: is2ndSat,
is4thSaturday: is4thSat,
isWeeklyOff,
holidayName: holiday?.name,
dayOfWeek: dow
});
@@ -632,6 +696,7 @@ export class BankCalendarComponent implements OnInit {
isSunday: false,
is2ndSaturday: false,
is4thSaturday: false,
isWeeklyOff: false,
dayOfWeek: days.length % 7
});
}
@@ -645,7 +710,8 @@ export class BankCalendarComponent implements OnInit {
nonWorkingDays: nonWorkingCount,
holidays: holidayCount,
sundays: sundayCount,
secondFourthSat: sat2nd4thCount
secondFourthSat: sat2nd4thCount,
weeklyOff: weeklyOffCount
};
}
@@ -12,6 +12,7 @@ import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { catchError, of } from 'rxjs';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
interface RegisterRow {
accountId: number;
@@ -83,7 +84,7 @@ export class AccountRegisterComponent implements OnInit {
/** Local date — toISOString() would shift the day in any timezone behind UTC. */
private today(): string {
const d = new Date();
const d = businessToday();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
@@ -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 {
@@ -11,6 +11,7 @@ import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { AuthenticationService } from '../../core/authentication/authentication.service';
import { ExportSheetSpec, exportWorkbook } from '../cashier-export';
import { businessToday } from 'app/core/utils/business-today';
interface SummaryRow {
code: string;
@@ -1316,7 +1317,7 @@ export class CashClearingTransferSummaryComponent {
private dateTag = '2607';
constructor() {
const d = new Date();
const d = businessToday();
this.asOnDate = `${String(d.getDate()).padStart(2, '0')}-${String(d.getMonth() + 1).padStart(2, '0')}-${d.getFullYear()}`;
this.dateTag = `${String(d.getFullYear()).slice(-2)}${String(d.getMonth() + 1).padStart(2, '0')}`;
@@ -12,6 +12,7 @@ import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
interface Denom {
id: number;
@@ -63,7 +64,7 @@ export class CashExchangeEntryComponent implements OnInit {
}
ngOnInit(): void {
const d = new Date();
const d = businessToday();
this.exchangeDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.service.getActiveDenominations().subscribe({
next: (list) => {
@@ -18,6 +18,7 @@ import { MatInputModule } from '@angular/material/input';
import { forkJoin, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
type PageState = 'loading' | 'no-cashier' | 'needs-reconciliation' | 'already-settled' | 'form';
@@ -90,7 +91,7 @@ export class CashSettlementComponent implements OnInit {
saving = false;
ngOnInit(): void {
const d = new Date();
const d = businessToday();
this.settlementDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.service
.getTellers()
@@ -17,6 +17,7 @@ import { MatInputModule } from '@angular/material/input';
import { of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
interface Denom {
id: number;
@@ -82,7 +83,7 @@ export class CashTransferComponent implements OnInit {
loadingVault = false;
ngOnInit(): void {
const d = new Date();
const d = businessToday();
this.transferDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.service.getActiveDenominations().subscribe({
next: (list) => {
@@ -17,6 +17,7 @@ import { forkJoin, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
import { AuthenticationService } from '../../core/authentication/authentication.service';
import { businessToday } from 'app/core/utils/business-today';
interface DenomRow {
denominationId: number;
@@ -71,7 +72,7 @@ export class CashierPositionComponent implements OnInit {
isCashierMode = false;
ngOnInit(): void {
const d = new Date();
const d = businessToday();
this.asOfDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.isCashierMode = this.authService.isCashierUser();
@@ -51,6 +51,12 @@ export class ClerkShareWithdrawalService {
return this.http.post(`/clerk/share-withdrawal-requests/${id}/validate`, {});
}
/** Runs the exact same checklist approve() will run, against a draft that hasn't been submitted
* yet — so the clerk sees it in the confirm dialog, before committing to the request. */
previewValidate(data: any): Observable<any> {
return this.http.post('/clerk/share-withdrawal-requests/preview-validate', data);
}
approve(id: number): Observable<any> {
return this.http.post(`/clerk/share-withdrawal-requests/${id}/approve`, {});
}
@@ -12,6 +12,7 @@ import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
/** Day-wise denomination-wise closing cash collected from deposits. */
@Component({
@@ -204,7 +205,7 @@ export class ClosingCashReportComponent implements OnInit {
}
ngOnInit(): void {
const d = new Date();
const d = businessToday();
this.date = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.load();
this.service.getTellers().subscribe({ next: (t: any[]) => (this.tellers = t || []), error: () => {} });
@@ -13,6 +13,7 @@ import { MatIconModule } from '@angular/material/icon';
import { Router } from '@angular/router';
import { catchError, of } from 'rxjs';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
/**
* Which part of the day's work a row came from. Set by the server, one per source register.
@@ -95,7 +96,7 @@ export class DayBookComponent implements OnInit {
/** Local date as yyyy-MM-dd — toISOString() would shift the day in any timezone behind UTC. */
private today(): string {
const d = new Date();
const d = businessToday();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
@@ -17,6 +17,7 @@ import { MatInputModule } from '@angular/material/input';
import { of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
interface Denom {
id: number;
@@ -79,7 +80,7 @@ export class InterCashierTransferComponent implements OnInit {
}
ngOnInit(): void {
const d = new Date();
const d = businessToday();
this.transferDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.service.getActiveDenominations().subscribe({
next: (list) => {
+2 -1
View File
@@ -14,6 +14,7 @@ import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { catchError, finalize, of } from 'rxjs';
import { CashierService } from '../cashier.service';
import { VoucherDetailDialogComponent } from './voucher-detail-dialog.component';
import { businessToday } from 'app/core/utils/business-today';
/** What the signed-in user's part in a voucher was. */
export type MyRole = 'RAISED' | 'VERIFIED' | 'RAISED_AND_VERIFIED';
@@ -357,7 +358,7 @@ export class MyWorkComponent implements OnInit {
}
private todayIso(): string {
const d = new Date();
const d = businessToday();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
}
@@ -18,6 +18,7 @@ import { MatTabsModule } from '@angular/material/tabs';
import { forkJoin, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
interface DenomRow {
denominationId: number;
@@ -85,7 +86,7 @@ export class ReconciliationComponent implements OnInit {
loadingHistory = false;
ngOnInit(): void {
const d = new Date();
const d = businessToday();
this.reconDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.service
.getTellers()
@@ -11,7 +11,7 @@ import { CommonModule } from '@angular/common';
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
import { FormsModule } from '@angular/forms';
import { HttpClient, HttpParams } from '@angular/common/http';
import { catchError, debounceTime, distinctUntilChanged, map } from 'rxjs/operators';
import { catchError, debounceTime, distinctUntilChanged, map, timeout } from 'rxjs/operators';
import { forkJoin, of, Subject } from 'rxjs';
import { MatDialog, MatDialogModule, MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
@@ -33,6 +33,7 @@ import {
import { SponsorDDService } from '../../sponsor-dd/sponsor-dd.service';
import { OrganizationService } from '../../organization/organization.service';
import { CashierIssueDdDialogComponent } from './cashier-issue-dd-dialog.component';
import { businessToday, businessTodayIso } from 'app/core/utils/business-today';
interface TxnRow {
typ: string;
@@ -621,7 +622,7 @@ export class AccountStatementDialogComponent implements OnInit {
@ViewChild('statementContent') statementContent!: ElementRef;
today = new Date().toISOString().slice(0, 10);
today = businessTodayIso();
fromDate = '';
toDate = this.today;
@@ -1870,7 +1871,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
saving = false;
ngOnInit(): void {
const d = new Date();
const d = businessToday();
this.today = `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}/${d.getFullYear()}`;
this.addRow();
this.loadDenominations();
@@ -2103,7 +2104,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}
private loadOpeningPosition(): void {
const d = new Date();
const d = businessToday();
const asOfDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.cashierService
.getCashierPositionForMe(asOfDate)
@@ -2837,7 +2838,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
fundingAccountId: number,
fundingClientId: number | null
): void {
const d = new Date();
const d = businessToday();
const issuedDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
const isCurrent = row.acTyp === 'CA' || row.acTyp === 'CB';
const isGl = row.acTyp === 'GL';
@@ -2969,7 +2970,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
const d = new Date();
const d = businessToday();
const issuedDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
const isCurrent = row.acTyp === 'CA' || row.acTyp === 'CB';
@@ -3068,7 +3069,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
const d = new Date();
const d = businessToday();
const issuedDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
const payload = {
@@ -3391,7 +3392,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
const today = new Date();
const today = businessToday();
const requestedDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
// Full snapshot of the CR (settlement) row exactly as the clerk filled it in — saved verbatim
@@ -3425,6 +3426,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
meetingDate: shareRow.meetingDate,
description: shareRow.description || null,
settlementPaymentTypeId: savingsRow.paymentTypeId,
settlementMode: 'SAVINGS',
settlementRowSnapshot
};
@@ -3455,11 +3457,60 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
? 'Pending Manager approval — redemption, settlement and account closure all post together on approval.'
: 'Pending Manager approval — redemption and settlement both post together on approval.'
];
this.showConfirmAlert('info', 'Confirm Share Withdrawal Request', confirmLines, 'Submit for Approval', () =>
this.showShareWithdrawalConfirm(payload, confirmLines, () =>
this.doSubmitShareRedemptionTransfer(payload, shareRow, shares, linkedAcNo)
);
}
/**
* Shows the confirm dialog with the same checklist the Manager will see when approving this
* request — fetched live from the backend (`previewValidate`) rather than re-implemented here,
* so it can never show the clerk something different from what the approver actually sees. A
* failing check is shown, not blocked: approve() enforces its own hard rules separately (e.g.
* Share Advance Collection applied), this list is advisory so problems surface to whoever can
* still fix them, before a manager's time is spent on a request that was always going to fail.
*/
private showShareWithdrawalConfirm(
payload: Record<string, unknown>,
baseLines: string[],
onConfirm: () => void
): void {
this.clerkShareWithdrawalService
.previewValidate(payload)
.pipe(
// A request that never resolves (slow network, server busy) must not leave the Submit
// button looking dead — the dialog still has to open, just without the checklist.
timeout(5000),
catchError(() => of({ __previewFailed: true }))
)
.subscribe((res: any) => {
const checks: { label: string; pass: boolean; detail: string }[] = res?.checks ?? [];
// A failed call (most likely the backend not yet carrying the /preview-validate endpoint)
// says so explicitly rather than just omitting the section — a silently empty checklist
// looks identical to "everything passed, nothing to show", which is the one reading this
// must never give.
const checkLines = res?.__previewFailed ? [
'',
'⚠ Validation checklist unavailable right now — the request can still be submitted.'
] : checks.length ? [
'',
'Validation Checks (same as Manager approval):',
...checks.map((c) => `${c.pass ? '✓' : '⚠'} ${c.label}: ${c.detail}`)
] : [];
// baseLines ends with a blank line followed by the "Pending Manager approval…" footer
// sentence, which must stay last so the modal renders it as a footnote rather than a
// key/value row — so the checklist is spliced in just before that pair, not appended after.
const footerStart =
baseLines.length >= 2 && baseLines[baseLines.length - 2] === '' ? baseLines.length - 2 : baseLines.length;
const lines = [
...baseLines.slice(0, footerStart),
...checkLines,
...baseLines.slice(footerStart)
];
this.showConfirmAlert('info', 'Confirm Share Withdrawal Request', lines, 'Submit for Approval', onConfirm);
});
}
/**
* Share redemption settled to a GL account the clerk picks on the CR row, instead of the
* member's linked savings account — mirrors submitShareRedemptionTransfer() above, but the
@@ -3512,7 +3563,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
const today = new Date();
const today = businessToday();
const requestedDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
// Full snapshot of the CR (settlement) row exactly as the clerk filled it in — saved verbatim
@@ -3571,7 +3622,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
? 'Pending Manager approval — redemption, settlement and account closure all post together on approval.'
: 'Pending Manager approval — redemption and settlement both post together on approval.'
];
this.showConfirmAlert('info', 'Confirm Share Withdrawal Request', confirmLines, 'Submit for Approval', () =>
this.showShareWithdrawalConfirm(payload, confirmLines, () =>
this.doSubmitShareGlSettlementTransfer(payload, shareRow, shares, glRow.acNo)
);
}
@@ -4173,7 +4224,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
): void {
const accountUrl = `${cfg.detail}/${accountId}${cfg.detailParams ?? '?associations=all'}`;
const todayStr = (() => {
const d = new Date();
const d = businessToday();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
})();
@@ -4549,7 +4600,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}
get todayIso(): string {
const d = new Date();
const d = businessToday();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
@@ -5071,7 +5122,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
this.sameDayChequeClearing = 0;
return;
}
const today = new Date();
const today = businessToday();
const sameDay = (d: any) => {
if (!d) return false;
const t = Array.isArray(d) && d.length >= 3 ? new Date(d[0], d[1] - 1, d[2]) : new Date(d);
@@ -5195,7 +5246,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
this.showAlert('error', 'Invalid Account Type', [
`Account type(s) not allowed in Bank Transfer: ${invalidAccounts.map((r) => r.acTyp + ' (' + r.acNo + ')').join(', ')}`,
'Only Savings (SD) and Current (CA) accounts are permitted.',
'GL, FD, RD, SHR accounts are not allowed — use Disbursement/Collection instead.'
'GL, FD and SHR accounts are not allowed — use Disbursement/Collection instead.',
'RD installments are paid through a cashier Receipt or Auto Debit.'
]);
return;
}
@@ -5249,6 +5301,19 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
this.rows.length >= 2 &&
this.rows.every((r) => r.typ === 'Disbursement' || r.typ === 'Fund Transfer')
) {
// A Recurring Deposit is never paid this way: these requests post straight into the account, so the
// installment schedule never hears of the money -- installments stay unpaid, Auto Debit collects them
// again and the maturity amount comes out wrong. RD installments come in through the cashier's
// Receipt or the RD's own Auto Debit.
const rdRows = this.rows.filter((r) => r.acTyp === 'RD');
if (rdRows.length > 0) {
this.showAlert('error', 'Recurring Deposit Not Allowed', [
`RD account(s) ${rdRows.map((r) => r.acNo).join(', ')} cannot be used in a Disbursement, Collection or Fund Transfer.`,
'Pay RD installments through a cashier Receipt, or by Auto Debit from the linked savings account.'
]);
return;
}
const drRows = this.rows.filter((r) => r.dbCr === 'Dr');
const crRows = this.rows.filter((r) => r.dbCr === 'Cr');
@@ -5959,7 +6024,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
const today = new Date();
const today = businessToday();
const txnDate = `${String(today.getDate()).padStart(2, '0')}/${String(today.getMonth() + 1).padStart(2, '0')}/${today.getFullYear()}`;
const payload: any = {
@@ -6154,7 +6219,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
/** Build the withdrawal-request payload from the current row + loaded account. */
private buildWithdrawalPayload(row: TxnRow, amount: number): any {
const today = new Date();
const today = businessToday();
const txnDateIso = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
const selectedPt = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId);
@@ -6259,7 +6324,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
/** Build the deposit-request payload from the current row + loaded account. */
private buildDepositPayload(row: TxnRow, amount: number): any {
const today = new Date();
const today = businessToday();
const txnDateIso = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
const selectedPt = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId);
// Send denominations only for cash payments — stale counts on a cheque
@@ -6370,7 +6435,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
const today = new Date();
const today = businessToday();
const txnDate = `${String(today.getDate()).padStart(2, '0')}/${String(today.getMonth() + 1).padStart(2, '0')}/${today.getFullYear()}`;
const txnDateIso = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
@@ -16,6 +16,7 @@ import { MatInputModule } from '@angular/material/input';
import { of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
interface DenomRow {
denominationId: number;
@@ -67,7 +68,7 @@ export class VaultPositionComponent implements OnInit {
errorMsg = '';
ngOnInit(): void {
const d = new Date();
const d = businessToday();
this.asOfDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.service
@@ -13,6 +13,7 @@ import { ActivatedRoute } from '@angular/router';
import { MatIconModule } from '@angular/material/icon';
import { catchError, of } from 'rxjs';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
/**
* Which side of the register a screen shows.
@@ -227,7 +228,7 @@ export class VoucherReportComponent implements OnInit {
}
private todayIso(): string {
const now = new Date();
const now = businessToday();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
}
@@ -129,6 +129,31 @@
<span>* Date of Meeting</span>
<input type="date" [(ngModel)]="meetingDate" [disabled]="isReview" />
</label>
<label class="inv-form-field">
<span>* Settlement Destination</span>
@if (!isReview) {
<select [ngModel]="settlementMode" (ngModelChange)="onSettlementModeChange($event)">
<option value="SAVINGS">Member's Linked Savings Account</option>
<option value="GL">GL Account</option>
</select>
} @else {
<input
type="text"
[value]="reviewRequest?.settlement_mode === 'GL' ? 'GL Account' : 'Linked Savings Account'"
readonly
/>
}
</label>
@if (!isReview && settlementMode === 'GL') {
<label class="inv-form-field">
<span>* Settlement GL Account</span>
<mifosx-gl-account-selector
[inputFormControl]="toGlAccountControl"
[glAccountList]="glAccountsData"
[required]="true"
></mifosx-gl-account-selector>
</label>
}
<label class="inv-form-field">
<span>* Settlement Payment Type</span>
@if (!isReview) {
@@ -386,14 +411,25 @@
<td>Date of Meeting</td>
<td>{{ meetingDate }}</td>
</tr>
<tr>
<td>Settlement Destination</td>
<td>{{ settlementMode === 'GL' ? 'GL Account' : 'Linked Savings Account' }}</td>
</tr>
@if (settlementMode === 'GL') {
<tr>
<td>Settlement GL Account</td>
<td>{{ selectedSettlementGlName }}</td>
</tr>
} @else {
<tr>
<td>Linked Savings Account</td>
<td>{{ account?.savingsAccountNumber || 'None — settlement will not run automatically' }}</td>
</tr>
}
<tr>
<td>Settlement Payment Type</td>
<td>{{ settlementPaymentTypeName }}</td>
</tr>
<tr>
<td>Linked Savings Account</td>
<td>{{ account?.savingsAccountNumber || 'None — settlement will not run automatically' }}</td>
</tr>
</tbody>
</table>
@@ -8,7 +8,7 @@
import { Component, EventEmitter, Input, OnInit, Output, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { FormsModule, UntypedFormControl } from '@angular/forms';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
@@ -19,6 +19,9 @@ import { SharesService } from '../../shares/shares.service';
import { ClerkShareWithdrawalService } from '../../cashier/clerk-share-withdrawal.service';
import { CashierService } from '../../cashier/cashier.service';
import { SearchSelectComponent, SearchSelectOption } from '../../shared/search-select/search-select.component';
import { GlAccountSelectorComponent } from '../../shared/accounting/gl-account-selector/gl-account-selector.component';
import { AccountingService } from '../../accounting/accounting.service';
import { GLAccount } from '../../shared/models/general.model';
export type ShareWdMode = 'create' | 'review';
@@ -51,7 +54,8 @@ interface ActivityEntry {
RouterModule,
MatIconModule,
MatSnackBarModule,
SearchSelectComponent
SearchSelectComponent,
GlAccountSelectorComponent
]
})
export class ShareWithdrawalRequestComponent implements OnInit {
@@ -60,6 +64,7 @@ export class ShareWithdrawalRequestComponent implements OnInit {
private sharesService = inject(SharesService);
private wdService = inject(ClerkShareWithdrawalService);
private cashierService = inject(CashierService);
private accountingService = inject(AccountingService);
private snackBar = inject(MatSnackBar);
/** When embedded (e.g. opened as a popup from a list screen instead of routed to directly), mode
@@ -91,6 +96,17 @@ export class ShareWithdrawalRequestComponent implements OnInit {
paymentTypes: any[] = [];
settlementPaymentTypeId: number | null = null;
/** Where the redeemed value goes. 'SAVINGS' (the long-standing default) credits the member's
* linked savings account. 'GL' lets the clerk pick any postable GL account directly — same
* option the Cashier's Transaction Entry screen already offers on its CR row, now available
* here too so both entry points behave the same way. Naming the product's own Share Sundry GL
* here is still fine: the backend recognises it and books straight to Sundry with no further
* settlement leg (see ClerkShareWithdrawalRequestApiResource#submit). */
settlementMode: 'SAVINGS' | 'GL' = 'SAVINGS';
settlementGlAccountId: number | null = null;
glAccountsData: GLAccount[] = [];
toGlAccountControl = new UntypedFormControl('');
activityLog: ActivityEntry[] = [];
reviewRequest: any = null;
showRejectDialog = false;
@@ -132,6 +148,17 @@ export class ShareWithdrawalRequestComponent implements OnInit {
return this.selectedSettlementPaymentType?.name || '—';
}
/** The chosen GL account's display name, for the confirm dialog and the checklist. Falls back to
* the raw id while the GL account list is still loading. */
get selectedSettlementGlName(): string {
if (!this.settlementGlAccountId) {
return '—';
}
return (
this.glAccountsData.find((g) => g.id === this.settlementGlAccountId)?.name || `GL #${this.settlementGlAccountId}`
);
}
get isFormValid(): boolean {
if (!this.account) return false;
if (!this.requestedShares || this.requestedShares <= 0) return false;
@@ -141,6 +168,7 @@ export class ShareWithdrawalRequestComponent implements OnInit {
if (!this.resolutionNumber.trim()) return false;
if (!this.meetingDate) return false;
if (!this.settlementPaymentTypeId) return false;
if (this.settlementMode === 'GL' && !this.settlementGlAccountId) return false;
return true;
}
@@ -160,11 +188,18 @@ export class ShareWithdrawalRequestComponent implements OnInit {
pass: shares > 0 && shares <= held,
detail: `${shares} of ${held} shares held`
},
{
label: 'Linked savings account confirmed',
pass: !!this.account?.savingsAccountNumber,
detail: this.account?.savingsAccountNumber || 'No linked savings account — settlement cannot run automatically'
},
this.settlementMode === 'GL'
? {
label: 'Settlement GL Account selected',
pass: !!this.settlementGlAccountId,
detail: this.settlementGlAccountId ? this.selectedSettlementGlName : 'Missing'
}
: {
label: 'Linked savings account confirmed',
pass: !!this.account?.savingsAccountNumber,
detail:
this.account?.savingsAccountNumber || 'No linked savings account — settlement cannot run automatically'
},
{
label: 'Settlement Payment Type selected (non-cash)',
pass: this.settlementPaymentTypeName !== '—',
@@ -214,11 +249,37 @@ export class ShareWithdrawalRequestComponent implements OnInit {
.pipe(catchError(() => of([])))
.subscribe((types: any[]) => (this.paymentTypes = types ?? []));
this.toGlAccountControl.valueChanges.subscribe((id: any) => {
this.settlementGlAccountId = id ? Number(id) : null;
});
if (this.requestId && this.mode === 'review') {
this.loadReviewRequest(this.requestId);
}
}
/** Flips the settlement destination. Switching away from GL clears whatever was picked so a
* stale GL id can never ride along on a SAVINGS submission. */
onSettlementModeChange(mode: 'SAVINGS' | 'GL'): void {
this.settlementMode = mode;
if (mode === 'GL') {
this.loadGlAccounts();
} else {
this.settlementGlAccountId = null;
this.toGlAccountControl.setValue('');
}
}
/** Fetched on first use rather than with the page, since most withdrawals settle to savings. */
private loadGlAccounts(): void {
if (this.glAccountsData.length) {
return;
}
this.accountingService.getGlAccounts().subscribe((accounts: GLAccount[]) => {
this.glAccountsData = accounts ?? [];
});
}
onAccountSelected(accountId: string): void {
this.selectedAccountId = accountId;
if (!accountId) {
@@ -246,6 +307,11 @@ export class ShareWithdrawalRequestComponent implements OnInit {
this.resolutionNumber = req.resolution_number ?? '';
this.meetingDate = req.meeting_date ? String(req.meeting_date).split('T')[0].split(' ')[0] : this.meetingDate;
this.description = req.description ?? '';
this.settlementMode = req.settlement_mode === 'GL' ? 'GL' : 'SAVINGS';
this.settlementGlAccountId = req.settlement_gl_account_id ? Number(req.settlement_gl_account_id) : null;
if (this.settlementMode === 'GL') {
this.loadGlAccounts();
}
if (req.activity_log) {
try {
@@ -289,7 +355,9 @@ export class ShareWithdrawalRequestComponent implements OnInit {
resolutionNumber: this.resolutionNumber.trim(),
meetingDate: this.meetingDate,
description: this.description,
settlementPaymentTypeId: this.settlementPaymentTypeId
settlementPaymentTypeId: this.settlementPaymentTypeId,
settlementMode: this.settlementMode,
...(this.settlementMode === 'GL' ? { settlementGlAccountId: this.settlementGlAccountId } : {})
};
this.wdService.submit(payload).subscribe({
next: () => {
@@ -116,8 +116,8 @@
<div class="f121__note f121__note--warn">
<mat-icon>warning_amber</mat-icon>
<div>
A Form 121 declaration is on record for this year, but withholding is still switched on for one or more
deposits. Turn it off on those accounts, or tax will keep being deducted.
A declaration is on record for this year, but one or more deposits still carry a tax group. Those accounts
deduct on their own and ignore the declaration — remove the tax group from them.
</div>
</div>
}
@@ -192,20 +192,90 @@
}
</span>
</div>
@if (declarationOnRecord && declarationFy) {
@if (declarationRecord; as d) {
<div class="f121__kv-row">
<span>Declared for</span>
<span>FY {{ declarationFy }}</span>
<span>FY {{ d.fy }} · Form {{ d.formType }}</span>
</div>
}
@if (declarationOnRecord && !acceptanceDateKnown) {
<div class="f121__kv-row f121__kv-row--muted">
<span>Acceptance date</span>
<div class="f121__kv-row">
<span>Accepted on</span>
<span>
Not captured — the declaration is treated as covering the whole year. A mid-year acceptance would change
the figures below.
{{ d.acceptedOn | date: 'd MMM y' }} — no TDS on interest credited from this day to 31 March. Interest
credited before it is taxed as usual.
</span>
</div>
@if (d.uin) {
<div class="f121__kv-row">
<span>UIN</span>
<span class="f121__mono">{{ d.uin }}</span>
</div>
}
<div class="f121__kv-row">
<span></span>
<span>
<button type="button" class="f121__btn f121__btn--warn" (click)="revokeDeclaration()">
Withdraw declaration
</button>
</span>
</div>
} @else if (fy && !configMissing) {
<div class="f121__kv-row">
<span></span>
<span>
@if (!showDeclarationForm) {
<button type="button" class="f121__btn" (click)="openDeclarationForm()" [disabled]="!panWellFormed">
Record {{ formLabel }} for FY {{ fy }}
</button>
@if (!panWellFormed) {
<div class="muted">A valid PAN must be on the KYC first — a declaration without one is void.</div>
}
}
</span>
</div>
@if (showDeclarationForm) {
<div class="f121__decl-form">
<label>
<span>Accepted on *</span>
<input type="date" [(ngModel)]="newDeclaration.acceptedOn" />
</label>
<label>
<span>UIN</span>
<input type="text" [(ngModel)]="newDeclaration.uin" placeholder="Allotted on acceptance" />
</label>
<label>
<span>Estimated income for the year (₹)</span>
<input type="number" min="0" [(ngModel)]="newDeclaration.estimatedIncome" />
</label>
@if (declarationError) {
<div class="f121__note f121__note--warn">
<mat-icon>error_outline</mat-icon>
<div>{{ declarationError }}</div>
</div>
}
<div class="f121__decl-actions">
<button type="button" class="f121__btn" (click)="showDeclarationForm = false">Cancel</button>
<button
type="button"
class="f121__btn f121__btn--primary"
(click)="saveDeclaration()"
[disabled]="savingDeclaration"
>
{{ savingDeclaration ? 'Saving…' : 'Save declaration' }}
</button>
</div>
</div>
}
}
@for (old of declarations; track old.id) {
@if (old.revoked && old.fy === fy) {
<div class="f121__kv-row f121__kv-row--muted">
<span>Withdrawn</span>
<span>
Form {{ old.formType }} accepted {{ old.acceptedOn | date: 'd MMM y' }}, withdrawn
{{ old.revokedOn | date: 'd MMM y' }} — {{ old.revokeReason }}
</span>
</div>
}
}
@if (eligibility) {
@for (reason of eligibility.reasons; track reason) {
@@ -332,3 +332,61 @@
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-weight: 600;
}
/* ── Recording a declaration ── */
.f121__btn {
padding: 6px 14px;
border: 1px solid #d1d5db;
border-radius: 6px;
background: #fff;
font: inherit;
font-size: 12.5px;
font-weight: 600;
cursor: pointer;
&:disabled {
opacity: 0.55;
cursor: not-allowed;
}
&--primary {
border-color: #1d4ed8;
background: #1d4ed8;
color: #fff;
}
&--warn {
border-color: #b45309;
color: #b45309;
}
}
.f121__decl-form {
display: grid;
gap: 10px;
padding: 12px 16px;
label {
display: grid;
gap: 4px;
font-size: 12px;
span {
color: #6b7280;
font-weight: 600;
}
input {
max-width: 280px;
padding: 6px 8px;
border: 1px solid #d1d5db;
border-radius: 6px;
font: inherit;
}
}
}
.f121__decl-actions {
display: flex;
gap: 8px;
}
@@ -23,6 +23,8 @@ import { SavingsService } from 'app/savings/savings.service';
import { FixedDepositsService } from 'app/deposits/fixed-deposits/fixed-deposits.service';
import { RecurringDepositsService } from 'app/deposits/recurring-deposits/recurring-deposits.service';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { TdsDeclarationRecord, TdsReportsService } from 'app/tds-reports/tds-reports.service';
import { TdsDeductee } from 'app/tds-reports/tds-reports-data';
/** Domain logic */
import {
@@ -111,6 +113,7 @@ export class Form121TabComponent implements OnInit, OnDestroy {
private savingsService = inject(SavingsService);
private fixedDepositsService = inject(FixedDepositsService);
private recurringDepositsService = inject(RecurringDepositsService);
private tdsService = inject(TdsReportsService);
private destroy$ = new Subject<void>();
@@ -124,23 +127,25 @@ export class Form121TabComponent implements OnInit, OnDestroy {
pan = '';
panWellFormed = false;
// ── Declaration on record ──
declarationOnRecord = false;
// ── Declarations on record ──
/**
* Every declaration the customer has given, each for one year and from the day the branch
* accepted it. The KYC record only ever held a type and a year, which made a September
* declaration shield interest back to April and let last year's form run on.
*/
declarations: TdsDeclarationRecord[] = [];
/**
* The form actually in force for the selected year. From FY 2026-27 a single
* Form 121 replaced the 15G/15H split, so this is derived from the year rather
* than from the payee's age.
*/
declarationForm: DeclarationForm = '121';
/** Kind recorded at KYC, meaningful only for a pre-2026 year. */
declarationKind: '15G' | '15H' = '15G';
declarationFy = '';
/**
* The KYC step records only the year, not the date the branch accepted the
* declaration, so it has to be treated as covering the whole year. Surfaced on
* screen rather than hidden — a mid-year acceptance would change the figures.
*/
acceptanceDateKnown = false;
// ── Recording a declaration ──
showDeclarationForm = false;
savingDeclaration = false;
declarationError = '';
newDeclaration = { acceptedOn: '', uin: '', estimatedIncome: null as number | null };
eligibility: EligibilityResult | null = null;
@@ -157,9 +162,9 @@ export class Form121TabComponent implements OnInit, OnDestroy {
configMissing = false;
/** Raw account inputs, kept so the year selector can recompute cheaply. */
private accountInputs: (TdsAccountInput & { meta: Form121AccountRow })[] = [];
/** Withheld-tax postings by FY, from the ledger. */
private actualTdsByFy = new Map<string, number>();
private accountInputs: (TdsAccountInput & { meta: Form121AccountRow; taxLines: InterestCredit[] })[] = [];
/** This customer's FD/RD interest received in savings, and TDS taken there, per year. */
private platformByFy = new Map<string, TdsDeductee | null>();
ngOnInit(): void {
this.route.parent.params.pipe(takeUntil(this.destroy$)).subscribe((params: any) => {
@@ -182,13 +187,15 @@ export class Form121TabComponent implements OnInit, OnDestroy {
forkJoin({
client: this.clientsService.getClientData(this.clientId, true).pipe(catchError(() => of(null))),
kyc: this.clientsService.getClientKyc(this.clientId, true).pipe(catchError(() => of(null))),
accounts: this.clientsService.getClientAccountData(this.clientId).pipe(catchError(() => of(null)))
accounts: this.clientsService.getClientAccountData(this.clientId).pipe(catchError(() => of(null))),
declarations: this.tdsService.getDeclarations(Number(this.clientId)).pipe(catchError(() => of([])))
})
.pipe(
takeUntil(this.destroy$),
switchMap(({ client, kyc, accounts }) => {
switchMap(({ client, kyc, accounts, declarations }) => {
this.applyClient(client);
this.applyKyc(kyc);
this.declarations = declarations || [];
const deposits = this.depositAccountsOf(accounts);
return deposits.length ? forkJoin(deposits.map((a) => this.fetchAccount(a))) : of([] as any[]);
}),
@@ -212,12 +219,8 @@ export class Form121TabComponent implements OnInit, OnDestroy {
private applyKyc(kyc: any): void {
this.pan = (kyc?.panNumber ?? '').toUpperCase();
this.panWellFormed = isWellFormedPan(this.pan);
this.declarationOnRecord = !!kyc?.form15Applicable;
this.declarationKind = kyc?.form15Type === '15H' ? '15H' : '15G';
this.declarationForm =
this.fy && FY_TAX_CONFIG[normaliseFy(this.fy)] ? declarationFormFor(this.fy, this.dateOfBirth) : '121';
this.declarationFy = kyc?.form15FY ?? '';
this.acceptanceDateKnown = false;
}
/** Savings, FD and RD accounts — everything that can credit interest. */
@@ -251,7 +254,7 @@ export class Form121TabComponent implements OnInit, OnDestroy {
*/
private ingest(details: any[]): void {
this.accountInputs = [];
this.actualTdsByFy = new Map<string, number>();
this.platformByFy = new Map<string, TdsDeductee | null>();
const fys = new Set<string>();
const clientIdNum = Number(this.clientId);
@@ -272,6 +275,7 @@ export class Form121TabComponent implements OnInit, OnDestroy {
};
const credits: InterestCredit[] = [];
const taxLines: InterestCredit[] = [];
let tdsDeducted = 0;
for (const t of d.transactions ?? []) {
@@ -289,8 +293,7 @@ export class Form121TabComponent implements OnInit, OnDestroy {
} else if (t.transactionType?.withholdTax) {
const amount = t.amount ?? 0;
tdsDeducted += amount;
const txnFy = fyOf(iso);
this.actualTdsByFy.set(txnFy, (this.actualTdsByFy.get(txnFy) ?? 0) + amount);
taxLines.push({ date: iso, amount, accountNo: ref.accountNo });
}
}
@@ -313,12 +316,13 @@ export class Form121TabComponent implements OnInit, OnDestroy {
deducteeClientId: deductee
};
this.accountInputs.push({ ...ref, credits, meta });
this.accountInputs.push({ ...ref, credits, taxLines, meta });
}
this.availableFys = [...fys].sort().reverse();
if (!this.availableFys.includes(this.fy)) {
this.fy = this.availableFys[0] ?? (this.declarationFy ? normaliseFy(this.declarationFy) : '');
this.fy =
this.availableFys[0] ?? this.declarations[0]?.fy ?? Object.keys(FY_TAX_CONFIG).sort().reverse()[0] ?? '';
}
}
@@ -338,11 +342,33 @@ export class Form121TabComponent implements OnInit, OnDestroy {
const year = normaliseFy(this.fy);
this.configMissing = !FY_TAX_CONFIG[year];
// TDS is tracked on FD/RD interest once it reaches savings, and deducted there. Those figures live
// on the platform, not in the deposit's own transactions, so they are fetched once per year and
// the computation waits for them.
if (!this.platformByFy.has(year)) {
this.loadPlatformYear(year);
return;
}
const platform = this.platformByFy.get(year);
for (const a of this.accountInputs) {
if (!a.meta.tdsApplicable) {
continue;
}
const match = platform?.accounts.find((p) => p.accountNo === a.accountNo);
a.credits = match?.credits ?? [];
a.taxLines = match?.deductions ?? [];
}
// Per-account interest for the selected year, for the three lists.
for (const a of this.accountInputs) {
a.meta.interest = a.credits.filter((c) => fyOf(c.date) === year).reduce((s, c) => s + c.amount, 0);
// The selected year only - this used to total every year the account had ever been taxed in.
a.meta.tdsDeducted = a.meta.tdsApplicable
? a.taxLines.filter((c) => fyOf(c.date) === year).reduce((s, c) => s + c.amount, 0)
: 0;
}
this.actualTds = this.actualTdsByFy.get(year) ?? 0;
// What was actually debited for this customer as deductee, wherever the savings account sits.
this.actualTds = this.accountInputs.filter((a) => a.meta.tdsApplicable).reduce((s, a) => s + a.meta.tdsDeducted, 0);
if (!this.dateOfBirth || this.configMissing) {
return;
@@ -382,21 +408,95 @@ export class Form121TabComponent implements OnInit, OnDestroy {
return applicable.some((a) => a.meta.mode === 'QUARTERLY') ? 'QUARTERLY' : 'ANNUAL';
}
/** The declaration, but only when it was filed for the year being shown. */
private declarationForSelectedFy(): Declaration | null {
if (!this.declarationOnRecord || !this.fy) {
private loadPlatformYear(year: string): void {
this.tdsService
.getDepositInterest(Number(year.slice(0, 4)), Number(this.clientId))
.pipe(
catchError(() => of([])),
takeUntil(this.destroy$)
)
.subscribe((rows) => {
this.platformByFy.set(year, rows.find((r) => `${r.clientId}` === `${this.clientId}`) ?? null);
this.recompute();
});
}
/** The live declaration recorded for the year being shown, if any. */
get declarationRecord(): TdsDeclarationRecord | null {
if (!this.fy) {
return null;
}
const year = normaliseFy(this.fy);
if (this.declarationFy && normaliseFy(this.declarationFy) !== year) {
return this.declarations.find((d) => d.fy === year && !d.revoked) ?? null;
}
get declarationOnRecord(): boolean {
return !!this.declarationRecord;
}
/** The declaration as the engine takes it — operative from the day it was accepted. */
private declarationForSelectedFy(): Declaration | null {
const d = this.declarationRecord;
if (!d) {
return null;
}
return {
kind: this.declarationKind,
// No acceptance date on record — treated as covering the whole year.
acceptedOn: `${year.slice(0, 4)}-04-01`,
fy: year
};
const kind = d.formType === '15H' ? '15H' : d.formType === '15G' ? '15G' : formKindFor(this.dateOfBirth, d.fy);
return { kind, acceptedOn: d.acceptedOn, fy: d.fy, uin: d.uin || undefined };
}
openDeclarationForm(): void {
this.newDeclaration = { acceptedOn: new Date().toISOString().slice(0, 10), uin: '', estimatedIncome: null };
this.declarationError = '';
this.showDeclarationForm = true;
}
saveDeclaration(): void {
if (!this.newDeclaration.acceptedOn) {
this.declarationError = 'Enter the date the branch accepted the form.';
return;
}
this.savingDeclaration = true;
this.declarationError = '';
this.tdsService
.createDeclaration({
clientId: Number(this.clientId),
fy: normaliseFy(this.fy),
formType: declarationFormFor(this.fy, this.dateOfBirth),
acceptedOn: this.newDeclaration.acceptedOn,
uin: this.newDeclaration.uin || undefined,
estimatedIncome: this.newDeclaration.estimatedIncome
})
.subscribe({
next: (rows) => {
this.savingDeclaration = false;
this.showDeclarationForm = false;
this.declarations = rows || [];
this.recompute();
},
error: (err: any) => {
this.savingDeclaration = false;
this.declarationError =
err?.error?.errors?.[0]?.defaultUserMessage || err?.error?.defaultUserMessage || 'Could not save.';
}
});
}
revokeDeclaration(): void {
const d = this.declarationRecord;
if (!d) {
return;
}
const reason = prompt('Why is this declaration being withdrawn? From today, TDS will be deducted again.');
if (!reason) {
return;
}
this.tdsService.revokeDeclaration(d.id, reason).subscribe({
next: (rows) => {
this.declarations = rows || [];
this.recompute();
},
error: (err: any) => alert(err?.error?.defaultUserMessage || 'Could not revoke the declaration.')
});
}
// ───────────────────────── Template helpers ─────────────────────────
@@ -474,11 +574,12 @@ export class Form121TabComponent implements OnInit, OnDestroy {
}
/**
* A declaration is on record but deposit accounts are still set to withhold —
* the branch has not switched them off.
* A declaration is on record but a deposit still carries a stock Fineract tax group. Those
* accounts withhold on their own rules and never consult the declaration record, so the form
* does not stop their deduction until the tax group is removed.
*/
get declarationNotAppliedToAccounts(): boolean {
return !!this.declarationForSelectedFy() && this.liableAccounts.some((a) => a.withHoldTax);
return !!this.declarationForSelectedFy() && this.liableAccounts.some((a) => !!a.taxGroup);
}
/**
@@ -591,16 +591,13 @@
<i class="fa fa-calendar section-header__icon"></i>
<h3 class="section-header__title">{{ 'labels.heading.Recurring Deposit Accounts' | translate }}</h3>
</div>
<div class="section-header__right">
<button mat-raised-button color="primary" (click)="toggleRecurringAccountsOverview()">
{{ viewAccountsLabel(showClosedRecurringAccounts) | translate }}
</button>
</div>
</div>
@if (!showClosedRecurringAccounts) {
<!-- Active and ended recurring deposits share one table; the Status column says how each one ended (matured or
closed early), so the client's RD history stays visible without switching views. -->
@if (savingAccounts) {
<div class="table-wrapper">
<table mat-table [dataSource]="savingAccounts | accountsFilter: 'saving' : 'open' : 'isRecurring'">
<table mat-table [dataSource]="savingAccounts | accountsFilter: 'saving' : 'all' : 'isRecurring'">
<ng-container matColumnDef="Account No">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Account No' | translate }}</th>
<td mat-cell *matCellDef="let element">
@@ -614,6 +611,15 @@
<mifosx-long-text textValue="{{ element.productName }}" chars="35"></mifosx-long-text>
</td>
</ng-container>
<ng-container matColumnDef="Status">
<th mat-header-cell *matHeaderCellDef>Status</th>
<td mat-cell *matCellDef="let element">
<span class="rd-status" [class.rd-status--ended]="element.timeline?.closedOnDate">{{ rdStatusLabel(element) }}</span>
@if (element.timeline?.closedOnDate) {
<div class="rd-status__date">on {{ element.timeline.closedOnDate | dateFormat }}</div>
}
</td>
</ng-container>
<ng-container matColumnDef="Last Active">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Last Active' | translate }}</th>
<td mat-cell *matCellDef="let element">{{ element.lastActiveTransactionDate | dateFormat }}</td>
@@ -638,7 +644,7 @@
<i class="fa fa-check"></i>
</button>
}
@if (!element.status.submittedAndPendingApproval && !element.status.active) {
@if (element.status.approved) {
<button
class="account-action-button"
mat-raised-button
@@ -664,7 +670,7 @@
</ng-container>
<ng-container matColumnDef="no-data">
<td mat-footer-cell *matFooterCellDef [attr.colspan]="openSavingsColumns.length">
<td mat-footer-cell *matFooterCellDef [attr.colspan]="recurringColumns.length">
<div class="table-empty-state">
<i class="fa fa-folder-open-o table-empty-state__icon"></i>
<p class="table-empty-state__text">
@@ -674,67 +680,17 @@
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="openSavingsColumns"></tr>
<tr mat-header-row *matHeaderRowDef="recurringColumns"></tr>
<tr
mat-row
*matRowDef="let row; columns: openSavingsColumns"
*matRowDef="let row; columns: recurringColumns"
[routerLink]="['../', 'recurring-deposits-accounts', row.id, 'general']"
class="select-row"
></tr>
<tr
mat-footer-row
*matFooterRowDef="
(savingAccounts | accountsFilter: 'saving' : 'open' : 'isRecurring')?.length === 0 ? ['no-data'] : []
"
></tr>
</table>
</div>
}
<!-- Closed Recurring Deposit Accounts -->
@if (showClosedRecurringAccounts) {
<div class="table-wrapper">
<table mat-table [dataSource]="savingAccounts | accountsFilter: 'saving' : 'closed' : 'isRecurring'">
<ng-container matColumnDef="Account No">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Account No' | translate }}</th>
<td mat-cell *matCellDef="let element">
<i class="fa fa-stop" [ngClass]="element.status.code | statusLookup"></i>
<mifosx-account-number accountNo="{{ element.accountNo }}"></mifosx-account-number>
</td>
</ng-container>
<ng-container matColumnDef="Saving Account">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Recurring Deposit Product' | translate }}</th>
<td mat-cell *matCellDef="let element">
<mifosx-long-text textValue="{{ element.productName }}" chars="35"></mifosx-long-text>
</td>
</ng-container>
<ng-container matColumnDef="Closed Date">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Closed Date' | translate }}</th>
<td mat-cell *matCellDef="let element">{{ element.timeline.closedOnDate | dateFormat }}</td>
</ng-container>
<ng-container matColumnDef="no-data">
<td mat-footer-cell *matFooterCellDef [attr.colspan]="closedSavingsColumns.length">
<div class="table-empty-state">
<i class="fa fa-folder-open-o table-empty-state__icon"></i>
<p class="table-empty-state__text">
{{ 'labels.messages.No Closed Recurring Deposit Accounts Available' | translate }}
</p>
</div>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="closedSavingsColumns"></tr>
<tr
mat-row
*matRowDef="let row; columns: closedSavingsColumns"
[routerLink]="['../', 'recurring-deposits-accounts', row.id, 'general']"
class="select-row"
></tr>
<tr
mat-footer-row
*matFooterRowDef="
(savingAccounts | accountsFilter: 'saving' : 'closed' : 'isRecurring')?.length === 0 ? ['no-data'] : []
(savingAccounts | accountsFilter: 'saving' : 'all' : 'isRecurring')?.length === 0 ? ['no-data'] : []
"
></tr>
</table>
@@ -419,3 +419,24 @@ table[mat-table] {
font-size: 24px;
z-index: 1;
}
.rd-status {
display: inline-block;
padding: 2px 8px;
border-radius: 10px;
font-size: 12px;
font-weight: 600;
background: #dcfce7;
color: #166534;
&--ended {
background: #e2e8f0;
color: #475569;
}
}
.rd-status__date {
margin-top: 2px;
font-size: 11px;
color: #64748b;
}
@@ -187,6 +187,15 @@ export class GeneralTabComponent implements OnDestroy {
'Saving Account',
'Closed Date'
];
/** Closed Recurring Deposit Accounts Columns - with how each one ended, for tracking. */
recurringColumns: string[] = [
'Account No',
'Saving Account',
'Status',
'Last Active',
'Balance',
'Actions'
];
/** Open Shares Accounts Columns */
openSharesColumns: string[] = [
'Account No',
@@ -475,6 +484,30 @@ export class GeneralTabComponent implements OnDestroy {
);
}
/** Where a recurring deposit stands - for one that has ended, how it ended. */
rdStatusLabel(account: any): string {
switch (account?.status?.code) {
case 'savingsAccountStatusType.active':
return 'Active';
case 'savingsAccountStatusType.matured':
return 'Matured (not closed)';
case 'savingsAccountStatusType.submitted.and.pending.approval':
return 'Pending Approval';
case 'savingsAccountStatusType.approved':
return 'Approved';
case 'savingsAccountStatusType.closed':
return 'Matured & Closed';
case 'savingsAccountStatusType.pre.mature.closure':
return 'Premature Closed';
case 'savingsAccountStatusType.rejected':
return 'Rejected';
case 'savingsAccountStatusType.withdrawn.by.applicant':
return 'Withdrawn by Client';
default:
return account?.status?.value ?? '';
}
}
viewAccountsLabel(closed: boolean): string {
if (closed) {
return 'labels.buttons.View Active Accounts';
@@ -377,6 +377,8 @@
</button>
}
@if (selectedTabIndex < totalTabs - 1) {
<!-- Tooltip sits on a wrapper: a disabled button fires no mouse events, so it can't host it. -->
<span [matTooltip]="currentStepBlockerMessage" [matTooltipDisabled]="!currentStepBlockerMessage">
<button mat-raised-button color="primary" (click)="onNext()" [disabled]="savingClient || !currentStepValid">
@if (savingClient) {
<mat-progress-spinner diameter="18" mode="indeterminate" class="m-r-10"></mat-progress-spinner>
@@ -390,6 +392,7 @@
<fa-icon icon="arrow-right" class="m-l-10"></fa-icon>
}
</button>
</span>
}
@if (selectedTabIndex === totalTabs - 1) {
<button mat-raised-button color="primary" [routerLink]="['../']">
@@ -393,6 +393,12 @@ export class EditClientComponent {
if (this.selectedTabIndex === 0 && this.clientGeneralForm?.valid && !this.contactVerificationComplete) {
return 'Verify the OTP sent to both the old and new mobile number/email before saving.';
}
if (this.selectedTabIndex === 0 && this.clientGeneralForm && !this.clientGeneralForm.valid) {
const labels = this.invalidGeneralFieldLabels();
if (labels.length) {
return `Please fill/correct: ${labels.join(', ')}.`;
}
}
if (this.selectedTabIndex === 1) {
const labels = this.clientKycStep?.invalidFieldLabels || [];
if (labels.length) {
@@ -408,6 +414,42 @@ export class EditClientComponent {
return 'Please fill all mandatory fields on this tab to continue.';
}
/**
* Readable names of the General-tab controls that are invalid. Older clients can be missing
* fields that are now mandatory (e.g. mobile/email), which leaves Save disabled with nothing
* on screen saying why — this feeds the button tooltip.
*/
private invalidGeneralFieldLabels(): string[] {
const labels: Record<string, string> = {
officeId: 'Office',
legalFormId: 'Legal Form',
genderId: 'Gender',
mobileNo: 'Mobile No',
alternateMobileNo: 'Alternate Mobile No',
emailAddress: 'Email',
dateOfBirth: 'Date of Birth',
submittedOnDate: 'Submitted On',
activationDate: 'Activation Date',
savingsProductId: 'Savings Product',
firstname: 'First Name',
middlename: 'Middle Name',
lastname: 'Last Name',
fullname: 'Name',
monthlyIncome: 'Monthly Income',
annualIncome: 'Annual Income',
monthlyTurnover: 'Monthly Turnover',
annualTurnover: 'Yearly Turnover',
agricultureIncome: 'Agriculture Income',
nonAgricultureIncome: 'Non-Agriculture Income',
guardianDetails: 'Guardian',
clientNonPersonDetails: 'Entity Details'
};
const controls = this.clientGeneralForm?.controls || {};
return Object.keys(controls)
.filter((name) => controls[name].invalid)
.map((name) => labels[name] || name);
}
/** Whether the current tab has unsaved edits */
isCurrentTabEdited(): boolean {
return this.tabEditMode[this.selectedTabIndex] === true;
+10 -1
View File
@@ -36,6 +36,10 @@ export class CacheInterceptor implements HttpInterceptor {
'/rbireserveconfig',
'/tdsconfig',
'/tds/deposit-interest',
// Declarations and challans are entered on the very screens that read them back.
'/tds/declarations',
'/tds/challans',
'/tds/deducted',
'/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.
@@ -44,7 +48,12 @@ export class CacheInterceptor implements HttpInterceptor {
'/cashreconciliation',
'/cashsettlement',
'/cashtransfer',
'/branchday'
'/branchday',
// A deposit's lien moves with every repayment of the loan it secures, and decides whether the
// deposit may be closed at all - a stale copy would show a released lien as still blocking.
'/liens',
// The RD Dashboard counts late installments as of today; a cached copy would hide a payment just taken.
'/recurringdepositaccounts/dashboard'
];
private isCacheable(url: string): boolean {
+27 -5
View File
@@ -67,12 +67,18 @@ export class ErrorHandlerInterceptor implements HttpInterceptor {
// endpoint isn't available on a given backend deployment, the client-general-step
// component already falls back to riskRules = [] silently; no toast needed here either.
const isKycRiskRulesRequest = request.url.includes('/kyc/risk-rules');
// A document's own file can be missing from the content store (a database copied between
// environments without its file store is the usual cause) even though its m_document row
// survives — the backend reports that as a 404, same as a client with no photo, and every
// caller that fetches a signature/attachment image already recovers with its own catchError.
const isDocumentAttachmentRequest = request.url.match(/\/documents\/\d+\/attachment$/);
const isSilentRequest =
isClientImageRequest ||
isClientKycRequest ||
isDirectorRequest ||
isClientPendingEditRequest ||
isKycRiskRulesRequest;
isKycRiskRulesRequest ||
isDocumentAttachmentRequest;
// Explicit per-request opt-out (e.g. per-row enrichment fetches on a list page whose
// component already recovers via its own catchError) — silenced for every status, since
@@ -93,8 +99,8 @@ export class ErrorHandlerInterceptor implements HttpInterceptor {
throw response;
}
// Resolved once and emitted in one place below, so every failure here is consistently tagged
// as an error and therefore surfaces as a centred modal rather than a corner toast.
// Resolved once and emitted in one place below — see the severity decision there for which of
// these surfaces as a centred modal versus a corner toast.
let alert: { type: string; message: string } | null = null;
if (status === 401 || (environment.oauth.enabled && status === 400)) {
alert = { type: 'Authentication Error', message: 'Invalid User Details. Please try again!' };
@@ -119,7 +125,13 @@ export class ErrorHandlerInterceptor implements HttpInterceptor {
message: errorMessage || 'This action is not allowed in the current state.'
};
} else if (status === 500) {
alert = { type: 'Internal Server Error', message: 'Internal Server Error. Please try again later.' };
// errorMessage carries the backend's actual explanation (defaultUserMessage/developerMessage)
// when one is present; the generic sentence is only a last resort for a response with no body
// at all (a proxy timeout, a stack trace the server never wrapped into JSON).
alert = {
type: 'Internal Server Error',
message: errorMessage || 'Internal Server Error. Please try again later.'
};
} else if (status === 501) {
alert = {
type: this.translate.instant('error.resource.notImplemented.type'),
@@ -133,7 +145,17 @@ export class ErrorHandlerInterceptor implements HttpInterceptor {
}
if (alert) {
this.alertService.alert({ ...alert, severity: 'error' });
// A GET is the page reading data it did not ask the user for, in the background — a photo,
// an attachment, a notes list, a standing-instructions panel. Every one of those already has
// its own catchError fallback at the call site (see transaction-entry.component.ts and
// similar account-overview loaders), so the page keeps working regardless. Blocking the whole
// screen on a dialog the user has to click "OK" on, for data that was never more than a
// secondary panel, is the "irritate na ho" complaint this guards against — it goes to the
// corner toast instead, same place a non-error alert already goes. A write (POST/PUT/PATCH/
// DELETE) is different: that is the one action the user was waiting on, so its failure still
// needs the centred, must-acknowledge dialog.
const isBackgroundRead = request.method === 'GET' && (status === 500 || status === 501 || status === 503);
this.alertService.alert({ ...alert, severity: isBackgroundRead ? undefined : 'error' });
}
throw response;
+405 -279
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,
@@ -897,7 +914,11 @@
(click)="subLedgerOpen = false"
><fa-icon icon="balance-scale" class="gl-icon"></fa-icon> Suit File Entry</a
>
<a class="gl-link" [class.hidden]="!matchesSubLedgerSearch('Locker Entry')" (click)="subLedgerOpen = false"
<a
class="gl-link"
[class.hidden]="!matchesSubLedgerSearch('Locker Entry')"
routerLink="/locker"
(click)="subLedgerOpen = false"
><fa-icon icon="lock" class="gl-icon"></fa-icon> Locker Entry</a
>
<a
@@ -1060,121 +1081,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']">
@@ -1189,14 +1235,29 @@
<button mat-menu-item tabindex="0" [routerLink]="['/accounts/recurring-deposits']">
<fa-icon icon="sync" class="menu-item-icon"></fa-icon> Recurring
</button>
<button mat-menu-item tabindex="0"><fa-icon icon="coins" class="menu-item-icon"></fa-icon> DDS</button>
<button mat-menu-item tabindex="0" [routerLink]="['/accounts/dds']" (click)="depositsMenu.closed.emit()">
<fa-icon icon="coins" class="menu-item-icon"></fa-icon> DDS Party Master
</button>
<button
mat-menu-item
tabindex="0"
[routerLink]="['/accounts/dds-accounts']"
(click)="depositsMenu.closed.emit()"
>
<fa-icon icon="list-alt" class="menu-item-icon"></fa-icon> DDS Accounts
</button>
</div>
<div class="deposit-menu-group">
<div class="deposit-group-title">Agent</div>
<button mat-menu-item tabindex="0">
<fa-icon icon="hand-holding-usd" class="menu-item-icon"></fa-icon> Agent Commission
</button>
<button mat-menu-item tabindex="0">
<button
mat-menu-item
tabindex="0"
[routerLink]="['/accounts/dds-collection']"
(click)="depositsMenu.closed.emit()"
>
<fa-icon icon="calendar-check" class="menu-item-icon"></fa-icon> Today Collection
</button>
<button mat-menu-item tabindex="0">
@@ -1211,6 +1272,14 @@
</div>
<div class="deposit-menu-group">
<div class="deposit-group-title">Reports</div>
<button
mat-menu-item
tabindex="0"
[routerLink]="['/accounts/dds-reports']"
(click)="depositsMenu.closed.emit()"
>
<fa-icon icon="coins" class="menu-item-icon"></fa-icon> DDS Reports
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/savings-daily-interest']">
<fa-icon icon="chart-line" class="menu-item-icon"></fa-icon> Savings Daily Interest
</button>
@@ -1220,6 +1289,9 @@
<button mat-menu-item tabindex="0" [routerLink]="['/fd-dashboard']">
<fa-icon icon="calendar-check" class="menu-item-icon"></fa-icon> FD Dashboard
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/rd-dashboard']">
<fa-icon icon="piggy-bank" class="menu-item-icon"></fa-icon> RD Dashboard
</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="chart-line" class="menu-item-icon"></fa-icon> FD Interest Report
</button>
@@ -1236,34 +1308,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>
@@ -1408,7 +1500,9 @@
<button mat-menu-item tabindex="0">
<fa-icon icon="user-slash" class="menu-item-icon" style="color: #c2410c"></fa-icon> Non-Customer Entry
</button>
<button mat-menu-item tabindex="0"><fa-icon icon="lock" class="menu-item-icon"></fa-icon> Locker</button>
<button mat-menu-item routerLink="/locker" tabindex="0">
<fa-icon icon="lock" class="menu-item-icon"></fa-icon> Locker
</button>
<button mat-menu-item routerLink="/investment/bonds" tabindex="0">
<fa-icon icon="chart-line" class="menu-item-icon"></fa-icon> SLR and Non SLR Investment Report
</button>
@@ -1570,23 +1664,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 +1708,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 +1720,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 +1737,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 +1758,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 +1771,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 +1951,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 {
@@ -20,7 +20,9 @@
<div class="t-live"></div>
&nbsp;LIVE
</div>
<div class="t-chip">Branch:&nbsp;<strong>HO</strong></div>
<div class="t-chip">
Branch:&nbsp;<strong>{{ officeLabel }}</strong>
</div>
<div class="t-chip">
Date:&nbsp;<strong>{{ today | date: 'dd MMM yyyy' }}</strong>
</div>
@@ -522,7 +524,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;
}
}
}
}
}
@@ -49,6 +49,7 @@ import { MatIcon } from '@angular/material/icon';
import { NotificationsTrayComponent as NotificationsTrayComponent_1 } from '../../../shared/notifications-tray/notifications-tray.component';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { DocumentationLinksService } from 'app/shared/services/documentation-links.service';
import { businessToday } from 'app/core/utils/business-today';
/**
* Toolbar component.
@@ -92,6 +93,14 @@ export class ToolbarComponent implements OnInit, AfterViewInit, AfterContentChec
@ViewChild('templateAppMenu') templateAppMenu: TemplateRef<any>;
@ViewChild('notificationsTray') notificationsTray: NotificationsTrayComponent;
/** The signed-in user's office — this chip read "HO" for every login, branch staff included. */
get officeLabel(): string {
if (this.authenticationService.isHeadOfficeUser()) {
return 'HO';
}
return this.authenticationService.getCredentials()?.officeName || '—';
}
/** Subscription to breakpoint observer for handset. */
isHandset$: Observable<boolean> = this.breakpointObserver
.observe(Breakpoints.Handset)
@@ -100,8 +109,10 @@ export class ToolbarComponent implements OnInit, AfterViewInit, AfterContentChec
/** Sets the initial state of sidenav as collapsed. Not collapsed if false. */
sidenavCollapsed = true;
/** Current date for top bar display */
today = new Date();
/** The bank's "today" for the top bar — the business date when enabled, else the server date. */
get today(): Date {
return businessToday();
}
/** Last login time display */
lastLoginTime = this.formatLastLogin();
+30
View File
@@ -0,0 +1,30 @@
/**
* 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/.
*/
/**
* The bank's "today": the business date while `enable-business-date` is on, the server's own date
* while it is off. The server decides which (GET /branchday/today); the web app stores the answer
* in `mifosXServerDate` at login and whenever the business date changes. Before that has happened
* the browser clock is the only date there is, so it is the fallback.
*/
export function businessToday(): Date {
const stored = localStorage.getItem('mifosXServerDate');
const match = stored ? /^(\d{4})-(\d{2})-(\d{2})$/.exec(stored) : null;
if (!match) {
return new Date();
}
// Built from parts so it is local midnight — new Date('yyyy-MM-dd') would be UTC midnight and
// land on the previous day in any timezone behind UTC.
return new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
}
/** {@link businessToday} as yyyy-MM-dd. */
export function businessTodayIso(): string {
const d = businessToday();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
@@ -0,0 +1,381 @@
<!--
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="dialog-wrapper">
<div class="dialog-header">
<div class="header-icon">
<mat-icon>account_balance_wallet</mat-icon>
</div>
<div class="header-text">
<h2>Apply DDS Receipt</h2>
<p>
Agent <strong class="mono">{{ data.agentCode }}</strong> · {{ data.agentName }}
@if (data.glCode) {
· GL <strong class="mono">{{ data.glCode }}</strong>
}
</p>
</div>
<button type="button" mat-icon-button class="header-close" [mat-dialog-close]="false" aria-label="Close">
<mat-icon>close</mat-icon>
</button>
</div>
<mat-dialog-content class="dialog-body">
@if (loading) {
<div class="state-block">
<mat-spinner diameter="30"></mat-spinner>
<span>Loading entries and receipts…</span>
</div>
} @else {
<!-- What the picked vouchers have to cover, and whether they do. -->
<div class="sc-card">
<div class="sc-row" [class.sc-ok]="canApply" [class.sc-bad]="pickedReceipts.size > 0 && difference !== 0">
<div class="sc-left">
<span class="sc-label">Collections up to {{ data.entryDate | date: 'dd/MM/yyyy' }}</span>
<span class="sc-sub"
>{{ pickedEntryCount }} {{ pickedEntryCount === 1 ? 'entry' : 'entries' }} to settle</span
>
</div>
<div class="sc-right">
<span class="sc-val">{{ required | number: '1.2-2' }}</span>
</div>
</div>
<div
class="sc-row sc-sep"
[class.sc-ok]="canApply"
[class.sc-bad]="pickedReceipts.size > 0 && difference !== 0"
>
<div class="sc-left">
<span class="sc-label">Vouchers picked</span>
<span class="sc-sub"
>{{ pickedReceipts.size }} {{ pickedReceipts.size === 1 ? 'voucher' : 'vouchers' }}</span
>
</div>
<div class="sc-right">
<span
class="sc-val"
[class.sc-val-ok]="canApply"
[class.sc-val-bad]="pickedReceipts.size > 0 && difference !== 0"
>
{{ received | number: '1.2-2' }}
</span>
@if (pickedReceipts.size > 0 && pickedDays.size > 0 && difference < 0) {
<span class="sc-tag sc-tag-bad">Short {{ -difference | number: '1.2-2' }}</span>
} @else if (pickedReceipts.size > 0 && pickedDays.size > 0 && difference > 0) {
<span class="sc-tag sc-tag-bad">Over {{ difference | number: '1.2-2' }}</span>
} @else if (canApply) {
<span class="sc-tag sc-tag-ok">✓ Matches</span>
}
</div>
</div>
<div class="sc-total-row" [class.sc-total-ok]="canApply">
<span class="sc-total-lbl">Total to Apply</span>
<div class="sc-total-nums">
<span class="sc-total-req mono">{{ required | number: '1.2-2' }}</span>
<span class="sc-arrow">→</span>
<span class="sc-total-got mono" [class.sc-total-got-ok]="canApply">{{ received | number: '1.2-2' }}</span>
<span class="sc-status-pill" [class.sc-pill-ok]="canApply" [class.sc-pill-pending]="!canApply">
{{ canApply ? '✓ Ready' : 'Pending' }}
</span>
</div>
</div>
</div>
<!-- ── 1: the days the agent wrote down ──────────────────────────────────── -->
<section class="slot">
<header class="slot-head">
<span class="slot-step">1</span>
<div class="slot-title">
<h3>Outstanding up to {{ data.entryDate | date: 'dd/MM/yyyy' }}</h3>
<p>Everything the agent wrote down and has not handed in, day by day. Open a day to see its lines.</p>
</div>
</header>
@if (days.length) {
<ul class="options">
@for (day of days; track day.date) {
<li class="option" (click)="toggleOpen(day, $event)">
<div class="option-main">
<span class="option-voucher mono">{{ day.date | date: 'dd/MM/yyyy' }}</span>
<span class="option-meta"
>{{ day.entries.length }} {{ day.entries.length === 1 ? 'entry' : 'entries' }}</span
>
</div>
<span class="option-amount mono">{{ day.total | number: '1.2-2' }}</span>
<button
type="button"
class="option-info"
(click)="toggleOpen(day, $event)"
aria-label="Show the day's lines"
>
<mat-icon>{{ openDay === day.date ? 'expand_less' : 'expand_more' }}</mat-icon>
</button>
</li>
@if (openDay === day.date) {
<li class="day-lines">
@if (day.entries.length > 8) {
<input
class="day-search"
type="text"
[(ngModel)]="daySearch"
placeholder="Search party code or name"
autocomplete="off"
/>
}
<div class="day-scroll">
<table class="entries">
<tbody>
@for (entry of visibleEntries(day); track entry.id) {
<tr>
<td class="entry-code">{{ entry.partyCode }}</td>
<td class="entry-name">{{ entry.partyName }}</td>
<td>
{{ entry.transactionMode }}
@if (entry.advanceDays) {
· {{ entry.advanceDays }}d advance
}
@if (entry.arrearsDays) {
· {{ entry.arrearsDays }}d previous
}
</td>
<td class="num mono">{{ entry.amount | number: '1.2-2' }}</td>
</tr>
} @empty {
<tr>
<td colspan="4" class="entry-name">No line matches.</td>
</tr>
}
</tbody>
</table>
</div>
</li>
}
}
</ul>
} @else {
<p class="slot-empty">
{{ data.agentName }} has nothing outstanding for {{ data.entryDate | date: 'dd/MM/yyyy' }}. Change the date
on the screen to the day the entries were written.
</p>
}
</section>
<!-- ── 2: the cashier's receipts ─────────────────────────────────────────── -->
<section class="slot">
<header class="slot-head">
<span class="slot-step">2</span>
<div class="slot-title">
<h3>DDS Cash Receipts</h3>
<p>Tick the vouchers. Posts Dr Agent GL / Cr DDS Customer Deposits.</p>
</div>
<button type="button" class="slot-search" (click)="openSearch()">
<mat-icon>search</mat-icon>
<span>Find by voucher</span>
</button>
</header>
@if (available.length) {
<ul class="options">
@for (option of available; track option.id) {
<li class="option" [class.option-picked]="pickedReceipts.has(option.id)" (click)="toggleReceipt(option)">
<mat-icon class="option-check">{{
pickedReceipts.has(option.id) ? 'check_box' : 'check_box_outline_blank'
}}</mat-icon>
<div class="option-main">
<span class="option-voucher mono">{{ option.voucherNo }}</span>
<span class="option-meta">{{ option.txnDate }} · {{ glLabel(option) }}</span>
@if (option.description) {
<span class="option-desc">{{ option.description }}</span>
}
</div>
<span class="option-amount mono">{{ option.amount | number: '1.2-2' }}</span>
<button type="button" class="option-info" (click)="openDetail(option, $event)" aria-label="GL entries">
<mat-icon>receipt_long</mat-icon>
</button>
</li>
}
</ul>
} @else {
<p class="slot-empty">
No DDS receipt is waiting on this agent's GL account. Collect the cash at
<em>Cashier › Transaction Entry</em> with entry type <strong>DDS</strong> and have the manager approve it,
or find an earlier voucher by number.
</p>
}
@if (pickedReceipts.size > 0 && pickedDays.size > 0 && difference !== 0) {
<p class="slot-note note-bad">
The vouchers ({{ received | number: '1.2-2' }}) have to match the day's entries ({{
required | number: '1.2-2'
}}) exactly, so every customer is credited what the agent wrote down.
</p>
}
</section>
@if (errorMsg && !searchOpen) {
<div class="alert">
<mat-icon>error_outline</mat-icon>
<span>{{ errorMsg }}</span>
</div>
}
}
</mat-dialog-content>
<mat-dialog-actions class="dialog-actions">
<button type="button" mat-stroked-button [mat-dialog-close]="false" [disabled]="submitting">Cancel</button>
<button type="button" mat-flat-button color="primary" [disabled]="!canApply" (click)="apply()">
@if (submitting) {
<mat-spinner diameter="16"></mat-spinner>
<span>Applying…</span>
} @else {
<mat-icon>check</mat-icon>
<span>Apply Receipt</span>
}
</button>
</mat-dialog-actions>
</div>
<!-- ── Find a voucher the list does not carry ───────────────────────────────── -->
@if (searchOpen) {
<div class="overlay" (click)="closeSearch()">
<div class="popup" (click)="$event.stopPropagation()">
<header class="popup-head">
<h3>Find a DDS receipt</h3>
<button type="button" mat-icon-button (click)="closeSearch()" aria-label="Close">
<mat-icon>close</mat-icon>
</button>
</header>
<div class="popup-body">
<label class="field">
<span>GL Account</span>
<div class="field-readonly mono">{{ data.glCode || '—' }} · {{ data.agentCode }}</div>
</label>
<label class="field">
<span>Voucher No.</span>
<input
type="text"
[(ngModel)]="searchVoucherNo"
placeholder="CR/24092026/0001"
(keyup.enter)="runSearch()"
autocomplete="off"
/>
</label>
@if (errorMsg) {
<div class="alert">
<mat-icon>error_outline</mat-icon>
<span>{{ errorMsg }}</span>
</div>
}
</div>
<footer class="popup-foot">
<button type="button" mat-stroked-button (click)="closeSearch()">Cancel</button>
<button
type="button"
mat-flat-button
color="primary"
[disabled]="!searchVoucherNo.trim() || searching"
(click)="runSearch()"
>
@if (searching) {
<mat-spinner diameter="16"></mat-spinner>
} @else {
<span>Find &amp; Use</span>
}
</button>
</footer>
</div>
</div>
}
<!-- ── A receipt's own GL entries ───────────────────────────────────────────── -->
@if (viewing) {
<div class="overlay" (click)="closeDetail()">
<div class="popup" (click)="$event.stopPropagation()">
<header class="popup-head">
<h3>
Voucher <span class="mono">{{ viewing.voucherNo }}</span>
</h3>
<button type="button" mat-icon-button (click)="closeDetail()" aria-label="Close">
<mat-icon>close</mat-icon>
</button>
</header>
<div class="popup-body">
<dl class="detail-list">
<div>
<dt>Date</dt>
<dd class="mono">{{ viewing.txnDate }}</dd>
</div>
<div>
<dt>Amount</dt>
<dd class="mono">{{ viewing.amount | number: '1.2-2' }}</dd>
</div>
<div>
<dt>GL Account</dt>
<dd>{{ glLabel(viewing) }}</dd>
</div>
@if (viewing.cashierName) {
<div>
<dt>Collected By</dt>
<dd>{{ viewing.cashierName }}</dd>
</div>
}
@if (viewing.managerName) {
<div>
<dt>Approved By</dt>
<dd>{{ viewing.managerName }}</dd>
</div>
}
@if (viewing.description) {
<div>
<dt>Description</dt>
<dd>{{ viewing.description }}</dd>
</div>
}
</dl>
<h4 class="detail-heading">Journal Entries</h4>
@if (loadingGlEntries) {
<div class="state-block"><mat-spinner diameter="22"></mat-spinner></div>
} @else if (viewingGlEntries?.length) {
<table class="gl-table">
<thead>
<tr>
<th>Account</th>
<th class="num">Debit</th>
<th class="num">Credit</th>
</tr>
</thead>
<tbody>
@for (entry of viewingGlEntries; track entry.id) {
<tr>
<td>
<span class="mono">{{ entry.glCode }}</span> {{ entry.glName }}
</td>
<td class="num mono">{{ entry.drCr === 'DR' ? (entry.amount | number: '1.2-2') : '' }}</td>
<td class="num mono">{{ entry.drCr === 'CR' ? (entry.amount | number: '1.2-2') : '' }}</td>
</tr>
}
</tbody>
</table>
} @else {
<p class="slot-empty">No journal entries are recorded against this receipt.</p>
}
</div>
<footer class="popup-foot">
<button type="button" mat-stroked-button (click)="closeDetail()">Close</button>
</footer>
</div>
</div>
}
@@ -0,0 +1,95 @@
/**
* 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/.
*/
// The same look as Apply Additional Shares, on purpose: both apply a cashier's receipt to what it was
// collected for, and the counter should meet one screen, not two. Used as it is, never changed here.
@use '../../../shares/share-dialog.shared' as *;
:host {
display: block;
width: 100%;
}
// The agent's outstanding lines - read only, what the receipt has to cover.
.entries {
width: 100%;
border-collapse: collapse;
font-size: 13px;
td {
padding: 7px 4px;
border-bottom: 1px solid #eef2f7;
}
tr:last-child td {
border-bottom: none;
}
.entry-date {
color: #64748b;
white-space: nowrap;
}
.entry-code {
font-weight: 600;
white-space: nowrap;
}
.entry-name {
color: #475569;
}
.num {
text-align: right;
white-space: nowrap;
}
}
// Days and vouchers are ticked, any number of each - a check box where the share dialog has a radio.
.option-check {
color: #94a3b8;
flex-shrink: 0;
}
.option-picked {
border-color: #2563eb !important;
background: #eff6ff !important;
.option-check {
color: #2563eb;
}
}
// A day's own lines, opened under it. Scrolls inside itself, so a thousand customers do not make the
// dialog a thousand lines tall.
.day-lines {
list-style: none;
padding: 6px 10px 10px 42px;
}
.day-search {
width: 100%;
margin-bottom: 6px;
padding: 6px 10px;
border: 1px solid #e2e8f0;
border-radius: 6px;
font: inherit;
font-size: 13px;
}
.day-scroll {
max-height: 240px;
overflow-y: auto;
}
.field-readonly {
padding: 8px 10px;
border: 1px solid #e2e8f0;
border-radius: 6px;
background: #f8fafc;
}
@@ -0,0 +1,351 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MAT_DIALOG_DATA, MatDialogRef, MatDialogModule } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { catchError } from 'rxjs/operators';
import { of } from 'rxjs';
import { OrganizationService } from 'app/organization/organization.service';
import { SettingsService } from 'app/settings/settings.service';
/** One day of an agent's outstanding entries, as the dialog lists them. */
export interface EntryDay {
date: string;
entries: any[];
total: number;
}
/**
* Applies the cashier's DDS receipts to an agent's outstanding collections.
*
* The agent's cash is already in the bank: the cashier took it at the counter into the agent's own GL
* account, tagged DDS, and the manager approved it. Applying it here hands the money on to the
* customers the agent wrote down - Dr the agent's GL, Cr DDS Customer Deposits - and each entry is
* settled against the application. The same screen as Apply Additional Shares.
*
* Everything outstanding up to the day chosen on the screen, shown a day at a time - nothing to tick,
* because what the agent wrote down is settled whole, and an agent who comes in after several days
* brings several days' cash. The vouchers are ticked, since a day's
* cash can come in more than one visit. They have to agree exactly: what the agent wrote down is what
* each customer is owed, so vouchers short of it leave somebody uncredited and vouchers over it leave
* money nobody can place.
*
* The day is shown totalled and its lines opened only when asked for - an agent with a thousand
* customers is a thousand lines, and the day's total is what the counter is checking.
*/
@Component({
selector: 'mifosx-apply-dds-receipt-dialog',
templateUrl: './apply-dds-receipt-dialog.component.html',
styleUrls: ['./apply-dds-receipt-dialog.component.scss'],
standalone: true,
imports: [
CommonModule,
FormsModule,
MatDialogModule,
MatButtonModule,
MatIconModule,
MatProgressSpinnerModule
]
})
export class ApplyDdsReceiptDialogComponent implements OnInit {
private organizationService = inject(OrganizationService);
private settingsService = inject(SettingsService);
public dialogRef = inject(MatDialogRef<ApplyDdsReceiptDialogComponent>);
public data = inject<{
agentId: number;
agentCode: string;
agentName: string;
glCode: string | null;
entryDate: string;
}>(MAT_DIALOG_DATA);
loadingEntries = true;
loadingReceipts = true;
submitting = false;
errorMsg = '';
/** The chosen day's outstanding entries - one day, or none when nothing is outstanding on it. */
days: EntryDay[] = [];
/** DDS receipts on the agent's GL account, approved and not yet applied. */
available: any[] = [];
/**
* The day is settled whole, so it is always included. The vouchers are the clerk's choice, and none
* is picked for them even when only one is listed.
*/
pickedDays = new Set<string>();
pickedReceipts = new Set<number>();
/** The day whose lines are open, and the search within it. */
openDay: string | null = null;
daySearch = '';
get loading(): boolean {
return this.loadingEntries || this.loadingReceipts;
}
ngOnInit(): void {
// Everything outstanding up to the chosen date, whatever day it was written - an agent's cash for
// several days arrives in one visit, and all of it is settled together.
this.organizationService
.getAgentPendingCollections(this.data.agentId)
.pipe(
catchError((err) => {
this.errorMsg = this.readError(err, "Could not load the agent's entries.");
return of(null);
})
)
.subscribe((res: any) => {
this.days = groupByDay(
(res?.entries || []).filter(
(entry: any) => entry.status === 'PENDING' && String(entry.entryDate) <= this.data.entryDate
)
);
this.pickedDays = new Set(this.days.map((day) => day.date));
this.loadingEntries = false;
});
this.organizationService
.getAgentCashReceipts(this.data.agentId)
.pipe(
catchError((err) => {
this.errorMsg = this.readError(err, 'Could not load the available receipts.');
return of([]);
})
)
.subscribe((rows: any) => {
this.available = Array.isArray(rows) ? rows : [];
this.loadingReceipts = false;
});
}
// ── Picking ─────────────────────────────────────────────────────────────────────────────────
toggleReceipt(receipt: any): void {
if (this.submitting) {
return;
}
this.pickedReceipts.has(receipt.id) ? this.pickedReceipts.delete(receipt.id) : this.pickedReceipts.add(receipt.id);
}
toggleOpen(day: EntryDay, event: Event): void {
event.stopPropagation();
this.openDay = this.openDay === day.date ? null : day.date;
this.daySearch = '';
}
/** The open day's lines, narrowed by party code or name. */
visibleEntries(day: EntryDay): any[] {
const term = this.daySearch.trim().toLowerCase();
if (!term) {
return day.entries;
}
return day.entries.filter(
(entry: any) =>
String(entry.partyCode || '')
.toLowerCase()
.includes(term) ||
String(entry.partyName || '')
.toLowerCase()
.includes(term)
);
}
// ── What has to be covered ──────────────────────────────────────────────────────────────────
get entryCount(): number {
return this.days.reduce((sum, day) => sum + day.entries.length, 0);
}
get outstandingTotal(): number {
return this.days.reduce((sum, day) => sum + day.total, 0);
}
get pickedEntryCount(): number {
return this.days.filter((day) => this.pickedDays.has(day.date)).reduce((sum, day) => sum + day.entries.length, 0);
}
get required(): number {
return round2(this.days.filter((day) => this.pickedDays.has(day.date)).reduce((sum, day) => sum + day.total, 0));
}
get received(): number {
return round2(
this.available.filter((r) => this.pickedReceipts.has(r.id)).reduce((sum, r) => sum + Number(r.amount || 0), 0)
);
}
/** Vouchers less entries: below zero they are short, above zero they are over. */
get difference(): number {
return round2(this.received - this.required);
}
get canApply(): boolean {
return this.pickedDays.size > 0 && this.pickedReceipts.size > 0 && this.difference === 0 && !this.submitting;
}
glLabel(option: any): string {
return [
option?.glCode,
option?.glName
]
.filter(Boolean)
.join(' — ');
}
// ── Voucher search, for a receipt the list does not carry ───────────────────────────────────
searching = false;
searchOpen = false;
searchVoucherNo = '';
openSearch(): void {
this.searchOpen = true;
this.searchVoucherNo = '';
this.errorMsg = '';
}
closeSearch(): void {
this.searchOpen = false;
this.errorMsg = '';
}
/**
* Finds one voucher on the agent's GL account and, when it cannot be used, says why - not taken as
* DDS, not approved yet, or already applied - rather than simply not finding it.
*/
runSearch(): void {
const voucher = this.searchVoucherNo.trim();
if (!voucher || this.searching) {
return;
}
this.searching = true;
this.errorMsg = '';
this.organizationService
.getAgentCashReceipts(this.data.agentId, voucher)
.pipe(
catchError((err) => {
this.errorMsg = this.readError(err, `Voucher ${voucher} could not be looked up.`);
return of(null);
})
)
.subscribe((rows: any) => {
this.searching = false;
if (rows === null) {
return;
}
const found = (Array.isArray(rows) ? rows : [])[0];
if (!found) {
this.errorMsg = `Voucher ${voucher} is not on ${this.data.agentCode}'s GL account ${this.data.glCode || ''}.`;
} else if (found.entryType !== 'DDS') {
this.errorMsg = `Voucher ${voucher} was taken as "${found.entryType || 'untagged'}", not DDS. Ask the cashier to correct the entry type.`;
} else if (found.status !== 'APPROVED') {
this.errorMsg = `Voucher ${voucher} is ${found.status}. It can be applied once the manager approves it.`;
} else if (found.applied) {
this.errorMsg = `Voucher ${voucher} has already been applied.`;
} else {
this.available = [
found,
...this.available.filter((entry) => entry.id !== found.id)
];
this.pickedReceipts.add(found.id);
this.searchOpen = false;
}
});
}
// ── A receipt's own GL entries ──────────────────────────────────────────────────────────────
viewing: any = null;
viewingGlEntries: any[] | null = null;
loadingGlEntries = false;
openDetail(option: any, event: Event): void {
event.stopPropagation();
this.viewing = option;
this.viewingGlEntries = null;
this.loadingGlEntries = true;
this.organizationService
.getCashReceiptGlEntries(option.id)
.pipe(catchError(() => of([])))
.subscribe((rows: any) => {
this.viewingGlEntries = Array.isArray(rows) ? rows : [];
this.loadingGlEntries = false;
});
}
closeDetail(): void {
this.viewing = null;
this.viewingGlEntries = null;
}
// ── Submission ──────────────────────────────────────────────────────────────────────────────
apply(): void {
if (!this.canApply) {
return;
}
this.submitting = true;
this.errorMsg = '';
const vouchers = this.available.filter((r) => this.pickedReceipts.has(r.id)).map((r) => r.voucherNo);
const entries = this.pickedEntryCount;
const amount = this.required;
this.organizationService
.settleAgentCollections({
agentId: Number(this.data.agentId),
cashierDepositRequestIds: Array.from(this.pickedReceipts),
entryDates: Array.from(this.pickedDays),
locale: this.settingsService.language.code
})
.pipe(
catchError((err) => {
this.errorMsg = this.readError(err, 'The receipts could not be applied.');
this.submitting = false;
return of(null);
})
)
.subscribe((res: any) => {
if (res) {
this.dialogRef.close({ vouchers, entries, amount, response: res });
}
});
}
private readError(err: any, fallback: string): string {
return (
err?.error?.errors?.[0]?.defaultUserMessage ??
err?.error?.defaultUserMessage ??
err?.error?.developerMessage ??
fallback
);
}
}
/** Entries grouped by the day they were collected, oldest first, each day totalled. */
export function groupByDay(entries: any[]): EntryDay[] {
const byDay = new Map<string, EntryDay>();
for (const entry of entries) {
const date = String(entry.entryDate);
const day = byDay.get(date) ?? { date, entries: [], total: 0 };
day.entries.push(entry);
day.total = round2(day.total + Number(entry.amount || 0));
byDay.set(date, day);
}
return Array.from(byDay.values()).sort((a, b) => a.date.localeCompare(b.date));
}
function round2(value: number): number {
return Math.round(value * 100) / 100;
}
@@ -0,0 +1,468 @@
<!--
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="dte-container">
<div class="dte-header">
<button mat-stroked-button [routerLink]="['../']" class="dte-back">
<i class="fas fa-arrow-left"></i> Deposits
</button>
<h2 class="dte-header__title">Daily Transaction Detail</h2>
</div>
<div class="dte-card">
<form [formGroup]="entryForm">
<div class="dte-info-panels">
<div class="dte-info-panel">
<div class="dte-info-panel__title">Agent Information</div>
<dl>
<dt>Name</dt>
<dd>{{ agentInfo.name || '—' }}</dd>
<dt>Address</dt>
<dd>{{ agentInfo.address || '—' }}</dd>
<dt>City</dt>
<dd>{{ agentInfo.city || '—' }}</dd>
<dt>A/C</dt>
<dd>{{ agentInfo.accountNo || '—' }}</dd>
<dt>Guarantor</dt>
<dd>{{ agentInfo.guarantor1 || '—' }}</dd>
<dt>Guarantor</dt>
<dd>{{ agentInfo.guarantor2 || '—' }}</dd>
<dt>Curr Bal</dt>
<dd>{{ agentInfo.currentBalance | number: '1.2-2' }}</dd>
</dl>
</div>
<div class="dte-info-panel">
<div class="dte-info-panel__title">Party Information</div>
@if (partyInfo) {
<dl>
<dt>Client Id</dt>
<dd>{{ partyInfo.clientId }}</dd>
<dt>Name</dt>
<dd>{{ partyInfo.partyName }}</dd>
<dt>Address</dt>
<dd>{{ partyInfo.address1 || '—' }}</dd>
<dt>City</dt>
<dd>{{ partyInfo.city || '—' }}</dd>
<dt>Op. Date</dt>
<dd>{{ partyInfo.openingDate ? (partyInfo.openingDate | dateFormat) : '—' }}</dd>
<dt>Maturity</dt>
<dd>{{ partyInfo.maturityDate ? (partyInfo.maturityDate | dateFormat) : '—' }}</dd>
<dt>Daily Depo</dt>
<dd>{{ partyInfo.dailyDepositAmount | number: '1.2-2' }}</dd>
<dt>Ledger</dt>
<dd>{{ partyInfo.ledgerNo || '—' }}</dd>
<dt>Aadhar No</dt>
<dd>{{ partyInfo.aadharNo || '—' }}</dd>
<dt>Curr Bal</dt>
<dd>{{ partyInfo.currentBalance | number: '1.2-2' }}</dd>
<!--
What should have been collected by today against what has been. Beside the balance
rather than instead of it: the balance is what the customer holds, this is whether
they have kept to the scheme.
-->
<dt>Due So Far</dt>
<dd>{{ partyInfo.expectedToDate | number: '1.2-2' }}</dd>
<dt>Collected</dt>
<dd>{{ partyInfo.collectedToDate | number: '1.2-2' }}</dd>
@if (schedule(partyInfo); as sched) {
<dt [class]="'dte-sched dte-sched--' + sched.state">Schedule</dt>
<dd [class]="'dte-sched dte-sched--' + sched.state">{{ sched.text }}</dd>
}
<dt class="dte-after">Bal. After This Tr</dt>
<dd class="dte-after">{{ partyBalanceAfter | number: '1.2-2' }}</dd>
</dl>
} @else {
<p class="dte-info-panel__hint">
Pick a party in a collection row below — this panel shows whose account it is and what the balance
becomes.
</p>
}
</div>
</div>
<div class="dte-controls">
<mat-form-field appearance="outline" class="dte-field dte-field--agent">
<mat-label>Agent</mat-label>
<mat-select required formControlName="agentId" (selectionChange)="loadAgent()">
@for (agent of selectableAgents; track agent.id) {
<mat-option [value]="agent.id">
{{ agent.agentCode }} · {{ agent.agentNameId }}
@if (!agent.active) {
<span class="dte-agent-off">— not collecting</span>
}
</mat-option>
}
</mat-select>
@if (selectableAgents.length === 0) {
<mat-hint class="dte-hint--missing">No active agent on file</mat-hint>
} @else if (pickedAgentIsInactive) {
<!-- Only reachable on a settlement: this agent no longer collects, but the cash they
already hold still has to be able to reach a branch. -->
<mat-hint class="dte-hint--missing">This agent no longer collects — settling held cash only</mat-hint>
} @else if (agentInfo.name) {
<mat-hint class="dte-hint--found">
{{ agentParties.length }} {{ agentParties.length === 1 ? 'party' : 'parties' }} to collect from
</mat-hint>
}
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Date</mat-label>
<!-- The date picks out a round, not just a posting date: a Party Tran settles whatever
the agent wrote down for that day, so changing it has to fetch that day's list. -->
<input
matInput
required
[matDatepicker]="txnDatePicker"
formControlName="transactionDate"
(dateChange)="loadPendingEntries(); loadAgentMoney()"
/>
<mat-datepicker-toggle matIconSuffix [for]="txnDatePicker"></mat-datepicker-toggle>
<mat-datepicker #txnDatePicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Tran. Type</mat-label>
<mat-select required formControlName="transactionType">
@for (type of transactionTypes; track type.label) {
<mat-option [value]="type.label" [disabled]="!type.supported">
{{ type.label }}
@if (!type.supported) {
<span class="dte-soon"> — not available yet</span>
}
</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<!--
What the agent wrote down on their round, waiting to be counted.
This used to be an empty grid the clerk typed the round into, and typing it posted it: a
customer was credited the moment a line was keyed, whether or not the cash behind it ever
reached the branch. The agent records their own collections now, and what the clerk does here
is agree with them - so there is nothing to type, only a list to check against a bag of cash.
-->
@if (isPartyTran && agentInfo.name) {
<div class="dte-section">
<div class="dte-section__header">
<i class="fas fa-list"></i>
<h3>Collections up to {{ entryForm.get('transactionDate')?.value | dateFormat }}</h3>
<button mat-stroked-button type="button" class="dte-add-row" (click)="loadPendingEntries()">
<i class="fas fa-sync"></i> Refresh
</button>
</div>
@if (pendingLoading) {
<p class="dte-empty">Reading this agent's entries...</p>
} @else if (pendingEntries.length === 0) {
<p class="dte-empty">{{ agentInfo.name }} has nothing outstanding up to this date.</p>
} @else {
@for (entry of pendingEntries; track entry.id) {
<div class="dte-pending-row">
<!-- The day the agent took it, on the line itself. All the lines carry the date
chosen above, but a row that says so can be read, printed or shown to the agent
on its own without the header to explain it. -->
<span class="dte-pending-row__date">{{ entry.entryDate | dateFormat }}</span>
<span class="dte-pending-row__code">{{ entry.partyCode }}</span>
<span class="dte-pending-row__name">{{ entry.partyName }}</span>
<span class="dte-pending-row__mode"
>{{ entry.transactionMode }}
@if (entry.advanceDays) {
· {{ entry.advanceDays }}d advance
}
@if (entry.arrearsDays) {
· {{ entry.arrearsDays }}d previous
}
</span>
<span class="dte-pending-row__amount">{{ entry.amount | number: '1.2-2' }}</span>
</div>
}
<div class="dte-total">
<span>{{ pendingEntries.length }} {{ pendingEntries.length === 1 ? 'entry' : 'entries' }}</span>
<strong>{{ pendingTotal | number: '1.2-2' }}</strong>
</div>
<!--
The figure the clerk counts against, said in words rather than left as a column total -
it is the one number they are acting on, and the rest of the panel is working out.
It says what the agent wrote down and stops there. The money side - what is owed, what the
cashier has taken in, and applying it - is Agent Tran, so each type has one job.
-->
<p class="dte-expected dte-expected--note">
Everything {{ agentInfo.name }} wrote down and has not handed in yet, up to this date, customer by
customer. The cash and its receipts are under Agent Tran.
</p>
}
</div>
}
<!--
The agent's money: what they owe on everything written down, what the cashier has taken in
against it, and what is left.
This used to be a form the clerk typed an amount into to take the agent's cash in here. The
cash is taken at the cashier's counter now, into the agent's own GL account, and one way to take
the same cash in is the only safe number of ways - so there is nothing to type. What is done here
is applying the cashier's receipts to the customers, in Apply DDS Receipt.
-->
@if (isAgentTran && agentInfo.name) {
<div class="dte-section">
<div class="dte-section__header">
<i class="fas fa-hand-holding-usd"></i>
<h3>Agent's cash</h3>
<button mat-stroked-button type="button" class="dte-add-row" (click)="loadAgentMoney()">
<i class="fas fa-sync"></i> Refresh
</button>
<button
mat-raised-button
color="primary"
type="button"
class="dte-apply-receipt"
(click)="openApplyReceipt()"
>
<i class="fas fa-receipt"></i> Apply DDS Receipt
</button>
</div>
@if (!agentGlCode) {
<p class="dte-expected dte-expected--note">
{{ agentInfo.name }} has no GL account, so the cashier has nowhere to take their cash. Set one on Agent
Master.
</p>
} @else {
<!-- The GL the cashier takes this agent's cash into - the code to key at Transaction Entry. -->
<p class="dte-expected">
Agent GL: <strong>{{ agentGlCode }}{{ agentGlName ? ' · ' + agentGlName : '' }}</strong> — the cashier
takes {{ agentInfo.name }}'s cash into this GL (Entry Type DDS).
</p>
}
<div class="dte-money">
<div class="dte-money__box">
<span class="dte-money__label">To take</span>
<strong>{{ agentOwed | number: '1.2-2' }}</strong>
</div>
<div class="dte-money__box">
<span class="dte-money__label">Received (approved)</span>
<strong>{{ agentReceived | number: '1.2-2' }}</strong>
</div>
<div class="dte-money__box" [class.dte-money__box--due]="agentBalance > 0">
<span class="dte-money__label">Still to take</span>
<strong>{{ agentBalance | number: '1.2-2' }}</strong>
</div>
</div>
@if (agentReceivedBeyondDay > 0) {
<p class="dte-expected dte-expected--note">
{{ agentReceivedBeyondDay | number: '1.2-2' }} received at the counter is more than everything outstanding
up to {{ entryForm.get('transactionDate')?.value | dateFormat }}. Check the voucher was taken for the
right agent.
</p>
}
@if (agentWaiting > 0) {
<p class="dte-expected dte-expected--note">
{{ agentWaiting | number: '1.2-2' }} more is at the counter awaiting the manager's approval.
</p>
}
<h4 class="dte-subhead">
Outstanding entries up to {{ entryForm.get('transactionDate')?.value | dateFormat }}
</h4>
@if (agentMoneyLoading) {
<p class="dte-empty">Reading this agent's entries...</p>
} @else if (agentDays.length === 0) {
<p class="dte-empty">{{ agentInfo.name }} has nothing outstanding.</p>
} @else {
@for (day of agentDays; track day.date) {
<div class="dte-pending-row dte-pending-row--day" (click)="toggleAgentDay(day.date)">
<span class="dte-pending-row__date">{{ day.date | dateFormat }}</span>
<span class="dte-pending-row__name">
{{ day.entries.length }} {{ day.entries.length === 1 ? 'entry' : 'entries' }}
</span>
<span class="dte-pending-row__amount">{{ day.total | number: '1.2-2' }}</span>
<i [class]="agentOpenDay === day.date ? 'fas fa-chevron-up' : 'fas fa-chevron-down'"></i>
</div>
@if (agentOpenDay === day.date) {
<div class="dte-day-lines">
@for (entry of day.entries; track entry.id) {
<div class="dte-pending-row">
<span class="dte-pending-row__code">{{ entry.partyCode }}</span>
<span class="dte-pending-row__name">{{ entry.partyName }}</span>
<span class="dte-pending-row__mode"
>{{ entry.transactionMode }}
@if (entry.advanceDays) {
· {{ entry.advanceDays }}d advance
}
@if (entry.arrearsDays) {
· {{ entry.arrearsDays }}d previous
}
</span>
<span class="dte-pending-row__amount">{{ entry.amount | number: '1.2-2' }}</span>
</div>
}
</div>
}
}
}
<h4 class="dte-subhead">Cashier receipts</h4>
@if (agentReceipts.length === 0) {
<p class="dte-empty">
No DDS receipt on {{ agentInfo.name }}'s GL account{{ agentGlCode ? ' ' + agentGlCode : '' }} waiting to
be applied.
</p>
} @else {
@for (receipt of agentReceipts; track receipt.id) {
<div class="dte-pending-row">
<span class="dte-pending-row__code">{{ receipt.voucherNo }}</span>
<span class="dte-pending-row__date">{{ receipt.txnDate | dateFormat }}</span>
<span class="dte-pending-row__name">{{ receipt.cashierName || '—' }}</span>
<span class="dte-status" [class.dte-status--ok]="receipt.status === 'APPROVED'">
{{ receipt.status === 'APPROVED' ? 'Approved' : 'Awaiting manager' }}
</span>
<span class="dte-pending-row__amount">{{ receipt.amount | number: '1.2-2' }}</span>
</div>
}
}
</div>
}
@if (isWithdrawDeposite) {
<div class="dte-section">
<div class="dte-section__header">
<i class="fas fa-exchange-alt"></i>
<h3>Withdraw Deposite</h3>
</div>
<div class="dte-grid">
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Creditor Party Code</mat-label>
<input
matInput
formControlName="creditorPartyCode"
[matAutocomplete]="creditorAuto"
#creditorTrigger="matAutocompleteTrigger"
(click)="creditorTrigger.openPanel()"
(focus)="creditorTrigger.openPanel()"
(blur)="settleTransferParty('creditor')"
placeholder="Search by code or name…"
autocomplete="off"
/>
<mat-icon matSuffix class="dte-list-arrow" (click)="$event.stopPropagation(); creditorTrigger.openPanel()"
>arrow_drop_down</mat-icon
>
<mat-autocomplete #creditorAuto="matAutocomplete" panelWidth="min(30rem, 92vw)">
@for (party of transferPartyOptions('creditor'); track party.id) {
<mat-option [value]="party.partyCode">
{{ party.partyCode }} · {{ party.partyName }} · ₹{{ party.currentBalance | number: '1.2-2' }}
</mat-option>
}
@if (transferPartyOptions('creditor').length === 0) {
<mat-option disabled>No open DDS account matches</mat-option>
}
</mat-autocomplete>
<mat-hint>Account the money goes into</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Creditor Party Name</mat-label>
<input matInput formControlName="creditorPartyName" readonly />
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Creditor Curr Bal</mat-label>
<input matInput formControlName="creditorPartyBalance" readonly />
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Debit Party Code</mat-label>
<input
matInput
formControlName="debitPartyCode"
[matAutocomplete]="debitAuto"
#debitTrigger="matAutocompleteTrigger"
(click)="debitTrigger.openPanel()"
(focus)="debitTrigger.openPanel()"
(blur)="settleTransferParty('debit')"
placeholder="Search by code or name…"
autocomplete="off"
/>
<mat-icon matSuffix class="dte-list-arrow" (click)="$event.stopPropagation(); debitTrigger.openPanel()"
>arrow_drop_down</mat-icon
>
<mat-autocomplete #debitAuto="matAutocomplete" panelWidth="min(30rem, 92vw)">
@for (party of transferPartyOptions('debit'); track party.id) {
<mat-option [value]="party.partyCode">
{{ party.partyCode }} · {{ party.partyName }} · ₹{{ party.currentBalance | number: '1.2-2' }}
</mat-option>
}
@if (transferPartyOptions('debit').length === 0) {
<mat-option disabled>No open DDS account matches</mat-option>
}
</mat-autocomplete>
@if (transferToItself) {
<mat-hint class="dte-same-party">Pick a different account - it cannot transfer to itself</mat-hint>
} @else {
<mat-hint>Account the money is taken from</mat-hint>
}
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Debit Party Name</mat-label>
<input matInput formControlName="debitPartyName" readonly />
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Debit Curr Bal</mat-label>
<input matInput formControlName="debitPartyBalance" readonly />
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Amount</mat-label>
<input matInput type="number" formControlName="withdrawAmount" />
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field dte-field--wide">
<mat-label>Description</mat-label>
<input matInput formControlName="withdrawDescription" />
</mat-form-field>
</div>
</div>
}
@if (!isPartyTran && !isWithdrawDeposite && !isAgentTran) {
<div class="dte-section">
<p class="dte-empty">
"{{ entryForm.get('transactionType').value }}" entry screen — same shape as Party Tran, to be filled in once
the backend distinguishes these transaction types.
</p>
</div>
}
<div class="dte-action-bar">
<button mat-raised-button type="button" (click)="exit()">Exit</button>
<button mat-stroked-button type="button" (click)="cancel()">Cancel</button>
<button mat-stroked-button type="button" (click)="retrieve()">Retrieve</button>
<button mat-stroked-button type="button" (click)="viewLedger()">View Ledger</button>
<button mat-stroked-button type="button" (click)="signature()">Signature</button>
<!-- Party Tran and Agent Tran are read here, never saved: the agent writes the round down on
their own screen and the cash is taken in at the cashier's counter. Agent Tran applies the
cashier's receipts from its own Apply DDS Receipt. Only Withdraw Deposite still saves. -->
@if (!isPartyTran && !isAgentTran) {
<button
mat-raised-button
color="primary"
type="button"
[disabled]="entryForm.invalid || transferToItself"
(click)="save()"
>
Save
</button>
}
</div>
</form>
</div>
</div>
@@ -0,0 +1,488 @@
/**
* 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/.
*/
$primary: #0f766e;
.dte-container {
// Wider than a normal form page: a collection row carries six fields side by side, and a clerk
// keying a round should see the whole of it.
max-width: 1280px;
margin: 0 auto;
padding: 20px 16px;
}
.dte-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 20px;
&__title {
margin: 0;
font-size: 22px;
font-weight: 500;
}
}
.dte-back {
color: $primary;
}
.dte-card {
background: var(--md-sys-color-surface, #fff);
border-radius: 8px;
box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
padding: 20px 24px 24px;
}
.dte-info-panels {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 20px;
}
.dte-info-panel {
border: 1px solid rgb(0 0 0 / 10%);
border-radius: 8px;
padding: 12px 16px;
background: rgb(15 118 110 / 4%);
&__title {
font-weight: 600;
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: $primary;
margin-bottom: 8px;
}
&__hint {
margin: 0;
color: rgb(0 0 0 / 50%);
font-size: 13px;
}
dl {
display: grid;
grid-template-columns: auto 1fr;
gap: 4px 12px;
margin: 0;
}
dt {
font-weight: 500;
color: rgb(0 0 0 / 60%);
font-size: 13px;
}
dd {
margin: 0;
font-size: 13px;
}
}
.dte-controls {
display: flex;
align-items: flex-start;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 12px;
}
// A sensible floor for the fields laid out with flex in .dte-controls, where there is room to spare.
.dte-field {
min-width: 180px;
&--wide {
grid-column: span 2;
}
}
// Inside the collection grid the track decides the width, not the field. Left to its own 180px
// floor a field refuses to shrink into a narrower track and overruns the one beside it.
.dte-collection-row .dte-field {
width: 100%;
min-width: 0;
}
.dte-section {
padding: 16px 0;
border-top: 1px solid rgb(0 0 0 / 8%);
// A very narrow window scrolls the grid sideways rather than the whole page.
overflow-x: auto;
&__header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
i {
color: $primary;
}
h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
flex: 1;
}
}
}
.dte-add-row {
color: $primary;
}
.dte-empty {
color: rgb(0 0 0 / 50%);
font-size: 14px;
margin: 0;
}
.dte-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px 16px;
}
/*
* One collection per row. Fixed columns rather than an even split, so the row holds its shape as
* fields come and go - Cheque No and Token No only appear for a non-cash entry - and the party
* picker gets the width its "code · name · daily amount" needs.
*/
.dte-collection-row {
display: grid;
grid-template-columns:
minmax(0, 2.2fr) /* Party */
minmax(0, 1fr) /* TRTP */
minmax(0, 1fr) /* AC Type */
minmax(0, 1.4fr) /* A/C No. */
minmax(0, 1fr) /* Amount */
minmax(0, 1.6fr) /* Description */
auto; /* remove */
gap: 8px 12px;
align-items: start;
padding: 10px 0;
border-top: 1px dashed rgb(0 0 0 / 10%);
&:first-of-type {
border-top: none;
}
// Cheque No and Token No push Description and the remove button onto a second line, which is
// what a non-cash entry should look like rather than a row running off the screen.
&:has(.dte-field--instrument) {
grid-template-columns:
minmax(0, 2.2fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1.4fr) minmax(0, 1fr)
minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1.6fr) auto;
}
}
.dte-collection-row .dte-field--wide {
grid-column: span 2;
}
@media (width <= 1200px) {
.dte-collection-row,
.dte-collection-row:has(.dte-field--instrument) {
grid-template-columns: repeat(3, minmax(0, 1fr)) auto;
}
}
.dte-remove-row {
color: #b91c1c;
margin-top: 8px;
}
.dte-action-bar {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid rgb(0 0 0 / 8%);
flex-wrap: wrap;
}
@media (width <= 1000px) {
.dte-info-panels {
grid-template-columns: 1fr;
}
.dte-grid {
grid-template-columns: repeat(2, 1fr);
}
.dte-collection-row {
grid-template-columns: repeat(2, 1fr);
}
}
/* Resolved name under an Agent / Party Code field — the check that the right record was found. */
.dte-hint--found {
color: #15803d;
font-weight: 500;
}
.dte-hint--missing {
color: #b91c1c;
font-weight: 500;
}
/* Marks an agent in the list who is no longer collecting - only offered for settlement. */
.dte-agent-off {
color: #b45309;
font-size: 12px;
font-style: italic;
margin-left: 6px;
}
/* The figure the clerk actually verifies against the passbook before saving. */
.dte-after {
font-weight: 600;
color: #0f766e;
}
/* What the agent should be handing over for this round. */
.dte-total {
display: flex;
justify-content: flex-end;
align-items: baseline;
gap: 14px;
padding: 10px 4px 2px;
border-top: 1px solid rgb(0 0 0 / 10%);
font-size: 0.88rem;
color: rgb(0 0 0 / 60%);
strong {
font-size: 1.1rem;
color: rgb(0 0 0 / 85%);
font-variant-numeric: tabular-nums;
}
}
/* Marks a transaction type the system does not handle yet, so it reads as pending rather than broken. */
.dte-soon {
font-size: 0.78rem;
font-style: italic;
color: rgb(0 0 0 / 45%);
}
/*
* Marks a party this agent has already collected from today, on the row where it is picked.
*
* Amber, not red: a customer paying twice in a day is legitimate - arrears, or two days at once -
* so this is a second look, not an error. Blocking it would stop real work; saying nothing lets a
* whole round be credited twice.
*/
.dte-hint--again {
color: #b45309;
font-weight: 500;
}
/*
* Where a party stands against its instalments. Three states rather than one colour, because they
* call for three different things from the clerk: ahead means take nothing, behind means take more
* than the daily amount, level means take the daily amount as usual.
*
* Behind is amber, not red - falling a few days behind is ordinary in daily collection and is the
* agent's job to recover, not an error. Red here would cry wolf on most rows.
*/
.dte-hint--ahead,
.dte-sched--ahead {
color: #15803d;
font-weight: 500;
}
.dte-hint--behind,
.dte-sched--behind {
color: #b45309;
font-weight: 500;
}
.dte-hint--level,
.dte-sched--level {
color: rgb(0 0 0 / 55%);
}
/* In the party panel the schedule line carries more weight than the fields around it. */
.dte-sched {
font-weight: 500;
}
/* Says why a party in the list cannot be picked, beside the party rather than in a tooltip. */
.dte-covered {
color: #15803d;
font-size: 0.8rem;
}
/*
* An amount that looks like a slip. Red rather than the amber used for arrears: arrears are ordinary
* and the agent's job to recover, while this is a figure that probably was not handed over at all,
* and once saved it cannot be taken back. It still does not block - the clerk decides.
*/
.dte-hint--overpay {
color: #b91c1c;
font-weight: 500;
}
/*
The agent's written-down round, as the counter reads it.
Laid out as lines rather than as the input grid this replaced: there is nothing to fill in here,
only a list to check a bag of cash against, and controls would invite editing something the clerk
has no business editing.
*/
.dte-pending-row {
display: flex;
align-items: baseline;
gap: 12px;
padding: 10px 2px;
border-bottom: 1px solid rgb(0 0 0 / 8%);
&:last-of-type {
border-bottom: none;
}
/* First, and in the quieter weight: the date is context for the line, not what is being read. */
&__date {
min-width: 96px;
opacity: 0.7;
font-variant-numeric: tabular-nums;
}
&__code {
font-weight: 600;
min-width: 84px;
}
&__name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
opacity: 0.75;
}
&__mode {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
opacity: 0.6;
}
/* Amounts are read down the column and compared, so the digits have to line up. */
&__amount {
font-variant-numeric: tabular-nums;
font-weight: 600;
min-width: 96px;
text-align: right;
}
}
/* The one figure the clerk acts on, said as a sentence so it is not read as another column total. */
.dte-expected {
margin: 14px 2px 0;
font-size: 14px;
line-height: 1.5;
strong {
font-variant-numeric: tabular-nums;
}
}
/* Quieter than the figure above it: a note about how the screen behaves, not part of the count. */
.dte-expected--note {
font-size: 13px;
opacity: 0.65;
margin-top: 4px;
}
.dte-apply-receipt {
margin-left: 8px;
}
/* Agent Tran: what the agent owes, what the cashier has taken in, and what is left. */
.dte-money {
display: flex;
gap: 12px;
margin: 8px 0 12px;
flex-wrap: wrap;
&__box {
flex: 1;
min-width: 160px;
padding: 10px 14px;
border: 1px solid rgb(0 0 0 / 10%);
border-radius: 8px;
display: flex;
flex-direction: column;
gap: 4px;
font-variant-numeric: tabular-nums;
strong {
font-size: 18px;
}
&--due {
border-color: rgb(198 40 40 / 40%);
background: rgb(198 40 40 / 4%);
}
}
&__label {
font-size: 12px;
opacity: 0.7;
text-transform: uppercase;
letter-spacing: 0.04em;
}
}
.dte-subhead {
margin: 16px 0 4px;
font-size: 14px;
font-weight: 600;
}
.dte-pending-row--day {
cursor: pointer;
}
.dte-day-lines {
margin-left: 24px;
max-height: 280px;
overflow-y: auto;
border-left: 3px solid rgb(25 118 210 / 30%);
padding-left: 10px;
}
.dte-status {
font-size: 12px;
padding: 2px 8px;
border-radius: 10px;
background: rgb(237 108 2 / 12%);
color: #b45309;
&--ok {
background: rgb(46 125 50 / 12%);
color: #2e7d32;
}
}
.dte-list-arrow {
cursor: pointer;
}
.dte-same-party {
color: #c62828;
font-weight: 600;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,56 @@
<!--
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/.
-->
<h2 mat-dialog-title>{{ title }}</h2>
<mat-dialog-content>
<mat-form-field appearance="outline" class="dcd-field">
<mat-label>Party</mat-label>
<mat-select [(ngModel)]="partyCode" (selectionChange)="partyChanged()">
@for (p of eligibleParties; track p.id) {
<mat-option [value]="p.partyCode">
{{ p.partyCode }} · {{ p.partyName }} · Daily {{ p.dailyDepositAmount | number: '1.0-0' }}
</mat-option>
}
</mat-select>
@if (eligibleParties.length === 0) {
<!-- Only reachable on the arrears side: nobody is behind within the window. -->
<mat-hint class="dcd-none">No party has recoverable days</mat-hint>
}
</mat-form-field>
<mat-form-field appearance="outline" class="dcd-field">
<mat-label>Days</mat-label>
<mat-select [(ngModel)]="days" [disabled]="!partyCode">
@for (d of dayOptions; track d) {
<mat-option [value]="d">{{ d }} {{ d === 1 ? 'day' : 'days' }}</mat-option>
}
</mat-select>
@if (isAdvance) {
<mat-hint>Up to 5 days</mat-hint>
} @else if (partyCode) {
<mat-hint>Only days still within the catch-up window</mat-hint>
}
</mat-form-field>
<!--
What it comes to, shown before it is added rather than after. The clerk quotes this to the
customer, so it has to be the scheme's own figure and has to be visible while deciding.
-->
@if (amount > 0) {
<div class="dcd-amount">
<span class="dcd-amount__figure">{{ amount | number: '1.2-2' }}</span>
<span class="dcd-amount__basis"> {{ days }} × {{ party?.dailyDepositAmount | number: '1.2-2' }} daily </span>
</div>
}
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button mat-dialog-close type="button">Cancel</button>
<button mat-raised-button color="primary" type="button" [disabled]="!canAdd" (click)="add()">Add</button>
</mat-dialog-actions>
@@ -0,0 +1,42 @@
/**
* 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/.
*/
.dcd-field {
width: 100%;
}
.dcd-none {
color: #b45309;
}
/*
* The figure the clerk quotes at the counter. Larger than the fields above it because it is the one
* thing the customer is told, and it is shown with its basis so the clerk can say where it came from
* rather than just naming a number.
*/
.dcd-amount {
display: flex;
align-items: baseline;
gap: 10px;
margin-top: 6px;
padding: 12px 14px;
border-radius: 6px;
background: rgb(15 118 110 / 8%);
&__figure {
font-size: 1.3rem;
font-weight: 600;
color: #0f766e;
font-variant-numeric: tabular-nums;
}
&__basis {
font-size: 0.8rem;
color: rgb(0 0 0 / 60%);
}
}
@@ -0,0 +1,115 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/** Angular Imports */
import { Component, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { MatSelectModule } from '@angular/material/select';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
/**
* Collecting for days other than today - either paid ahead, or owed from days gone by.
*
* Both are the same act from the counter's side: the customer is paying for a number of days, and
* what that comes to is the scheme's business, not the clerk's. So the clerk picks days and the
* amount follows. Typing an amount instead would let the daily figure be worked around a little at a
* time, and would quote the customer something the scheme does not charge.
*
* The two differ only in how many days are on offer. Paying ahead is capped by the bank's allowance.
* Paying arrears is capped by what is still inside the catch-up window - older instalments are gone,
* and offering them would have the clerk asking for money the bank has already written off.
*/
@Component({
selector: 'mifosx-day-collection-dialog',
standalone: true,
imports: [
CommonModule,
FormsModule,
MatDialogModule,
MatButtonModule,
MatSelectModule,
MatFormFieldModule,
MatInputModule
],
templateUrl: './day-collection-dialog.component.html',
styleUrls: ['./day-collection-dialog.component.scss']
})
export class DayCollectionDialogComponent {
private dialogRef = inject(MatDialogRef<DayCollectionDialogComponent>);
/** { mode: 'advance' | 'previous', parties: [] } - the agent's parties, as the screen already has them. */
data = inject<any>(MAT_DIALOG_DATA);
partyCode = '';
days: number | '' = '';
get isAdvance(): boolean {
return this.data?.mode === 'advance';
}
get title(): string {
return this.isAdvance ? 'Collect in advance' : 'Collect previous days';
}
get party(): any {
return (this.data?.parties || []).find((p: any) => String(p.partyCode) === this.partyCode) || null;
}
/**
* The parties this can be used on.
*
* Paying ahead is offered to anyone. Paying arrears is offered only where there are arrears still
* inside the window - on an account that is up to date there is nothing to collect for past days,
* and listing it would invite an entry that is really an advance in disguise.
*/
get eligibleParties(): any[] {
const parties = this.data?.parties || [];
return this.isAdvance ? parties : parties.filter((p: any) => Number(p.recoverableAmount || 0) > 0);
}
/**
* How many days may be picked. Five ahead, matching the bank's allowance and the server's own
* check; for arrears, only what has not yet fallen out of the catch-up window.
*/
get dayOptions(): number[] {
const daily = Number(this.party?.dailyDepositAmount || 0);
if (!this.party || daily <= 0) {
return [];
}
const limit = this.isAdvance ? 5 : Math.floor(Number(this.party.recoverableAmount || 0) / daily);
return Array.from({ length: Math.max(0, limit) }, (_, index) => index + 1);
}
get amount(): number {
return Number(this.party?.dailyDepositAmount || 0) * Number(this.days || 0);
}
/** Clearing the days when the party changes - a count valid for one account is not for another. */
partyChanged(): void {
this.days = '';
}
get canAdd(): boolean {
return !!this.partyCode && Number(this.days) > 0 && this.amount > 0;
}
add(): void {
this.dialogRef.close({
partyCode: this.partyCode,
days: Number(this.days),
amount: this.amount,
// Sent as days, not just an amount: the server recomputes what they come to and checks it
// against this account, so the figure quoted here is the one that gets posted.
advanceDays: this.isAdvance ? Number(this.days) : undefined,
arrearsDays: this.isAdvance ? undefined : Number(this.days)
});
}
}
@@ -0,0 +1,21 @@
<!--
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="nd-head">
<span class="nd-head__ic"><fa-icon icon="info-circle"></fa-icon></span>
<h2 class="nd-head__title">{{ data.heading }}</h2>
</div>
<mat-dialog-content>
<p class="nd-message">{{ data.message }}</p>
</mat-dialog-content>
<!-- One button, because there is nothing here to agree to or call off. -->
<mat-dialog-actions align="end">
<button mat-flat-button color="primary" mat-dialog-close>OK</button>
</mat-dialog-actions>
@@ -0,0 +1,44 @@
/**
* 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/.
*/
.nd-head {
display: flex;
align-items: center;
gap: 12px;
padding: 18px 24px 4px;
/*
* Teal rather than the amber or red the platform's other dialogs use. Nothing has gone wrong here
* and nothing is at risk - the clerk is being told a fact about the agent they picked, and a
* warning colour would send them looking for a fault that is not there.
*/
&__ic {
display: grid;
place-items: center;
width: 34px;
height: 34px;
border-radius: 8px;
background: rgb(15 118 110 / 10%);
color: #0f766e;
flex: none;
}
&__title {
margin: 0;
font-size: 1.05rem;
font-weight: 600;
}
}
.nd-message {
margin: 0;
max-width: 44ch; /* stylelint-disable-line unit-allowed-list */
font-size: 0.9rem;
line-height: 1.55;
color: rgb(0 0 0 / 70%);
}
@@ -0,0 +1,39 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/** Angular Imports */
import { Component, inject } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
/**
* Something the clerk needs told, with nothing to decide.
*
* The platform's confirmation dialog asks a question - Cancel or Confirm - and its buttons imply a
* choice. Used for a plain statement it leaves the clerk hunting for what they are agreeing to, and
* "Cancel" reads as though the thing being described could be undone. The error dialog has the right
* single button but dresses the message in red, which turns "this agent has no parties" into a fault.
*
* So: one button, and the message stands on its own.
*/
@Component({
selector: 'mifosx-notice-dialog',
standalone: true,
imports: [
MatDialogModule,
MatButtonModule,
FaIconComponent
],
templateUrl: './notice-dialog.component.html',
styleUrls: ['./notice-dialog.component.scss']
})
export class NoticeDialogComponent {
/** { heading, message } */
data = inject<any>(MAT_DIALOG_DATA);
}
@@ -0,0 +1,557 @@
<!--
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="dav-page">
@if (isLoading) {
<div class="dav-card dav-state">
<mat-spinner diameter="32"></mat-spinner>
<span>Loading account…</span>
</div>
} @else if (!account) {
<div class="dav-card dav-state">
<fa-icon icon="inbox" size="2x"></fa-icon>
<span>DDS account not found</span>
<button class="dav-btn" (click)="back()"><fa-icon icon="arrow-left"></fa-icon> DDS Accounts</button>
</div>
} @else {
<!-- Banner, as the savings account page opens -->
<div class="dav-banner">
<div class="dav-banner__icon"><fa-icon icon="coins"></fa-icon></div>
<div class="dav-banner__text">
<span class="dav-pill" [ngClass]="'dav-pill--' + (account.status || '').toLowerCase()"
>● {{ account.status }}</span
>
<h2 class="dav-banner__title">{{ account.subType }}</h2>
<div class="dav-banner__meta">
<span class="dav-banner__label">Account Number</span>
<span class="dav-banner__value dav-mono">{{ account.accountNo }}</span>
<span class="dav-banner__label">Client Name</span>
<span class="dav-banner__value">{{ account.clientName || account.partyName }}</span>
</div>
</div>
<div class="dav-banner__boxes">
<div class="dav-box">
<span class="dav-box__label">Current Balance</span>
<span class="dav-box__value">₹{{ account.currentBalance || 0 | formatNumber }}</span>
</div>
<div class="dav-box">
<span class="dav-box__label">Maturity Amount</span>
<span class="dav-box__value">₹{{ account.maturityAmount || 0 | formatNumber }}</span>
</div>
</div>
<div class="dav-banner__actions">
<button class="dav-icon-btn" (click)="back()" matTooltip="Back to DDS Accounts">
<fa-icon icon="arrow-left"></fa-icon>
</button>
<button class="dav-icon-btn" (click)="openInPartyMaster()" matTooltip="Open in DDS Party Master">
<fa-icon icon="pen"></fa-icon>
</button>
</div>
</div>
<!-- Tabs -->
<div class="dav-tabs">
<button class="dav-tab" [class.dav-tab--on]="tab === 'general'" (click)="tab = 'general'">
<fa-icon icon="info-circle"></fa-icon> General
</button>
<button class="dav-tab" [class.dav-tab--on]="tab === 'transactions'" (click)="tab = 'transactions'">
<fa-icon icon="exchange-alt"></fa-icon> Transactions
</button>
<button class="dav-tab" [class.dav-tab--on]="tab === 'passbook'" (click)="tab = 'passbook'">
<fa-icon icon="book"></fa-icon> Passbook
</button>
<button class="dav-tab" [class.dav-tab--on]="tab === 'nominees'" (click)="tab = 'nominees'">
<fa-icon icon="user"></fa-icon> Nominees
</button>
@if (account.status === 'CLOSED') {
<button class="dav-tab" [class.dav-tab--on]="tab === 'closure'" (click)="tab = 'closure'">
<fa-icon icon="lock"></fa-icon> Closure
</button>
}
</div>
<!-- Closure: how a closed account was closed and where its money went -->
@if (tab === 'closure') {
@if (!closure) {
<div class="dav-card">
<div class="dav-row"><span>Loading closure details…</span></div>
</div>
} @else {
<div class="dav-columns">
<div class="dav-column">
<div class="dav-card">
<div class="dav-card__head"><span class="dav-card__dot"></span> How It Closed</div>
<div class="dav-row">
<span>Type</span>
<strong>
{{ closureLabels[closure.closureType] || closure.closureType }}
@if (closure.withinLockin) {
<em class="dav-closure-tag">within lock-in</em>
}
</strong>
</div>
<div class="dav-row">
<span>Closed On</span><strong>{{ closure.closeDate | date: 'dd MMMM yyyy' }}</strong>
</div>
@if (closure.paidOutBy) {
<div class="dav-row">
<span>Paid Out By</span><strong>{{ closure.paidOutBy }}</strong>
</div>
}
<div class="dav-row">
<span>Closed By</span><strong>{{ closure.closedBy || '—' }}</strong>
</div>
<div class="dav-row">
<span>Approved By</span><strong>{{ closure.approvedBy || '—' }}</strong>
</div>
@if (closure.lockinOverrideReason) {
<div class="dav-row">
<span>Reason</span><strong>{{ closure.lockinOverrideReason }}</strong>
</div>
}
@if (closure.shortfallBorneBy) {
<div class="dav-row">
<span>Cost Borne By</span>
<strong>{{
closure.shortfallBorneBy === 'AGENT' ? 'Agent — ' + closure.agentCode : 'Customer'
}}</strong>
</div>
}
</div>
<div class="dav-card">
<div class="dav-card__head"><span class="dav-card__dot"></span> The Money</div>
<div class="dav-row">
<span>Balance at Close</span><strong>₹{{ closure.balanceAtClose || 0 | formatNumber }}</strong>
</div>
@if (closure.interestRecovered > 0) {
<div class="dav-row">
<span>Interest Recovered</span
><strong class="dav-dr">− ₹{{ closure.interestRecovered | formatNumber }}</strong>
</div>
}
@if (closure.interestToppedUpByAgent > 0) {
<div class="dav-row">
<span>Interest Funded by Agent</span
><strong class="dav-cr">+ ₹{{ closure.interestToppedUpByAgent | formatNumber }}</strong>
</div>
}
<div class="dav-row dav-closure-total">
<span>Paid Out</span><strong>₹{{ closure.paidOut || 0 | formatNumber }}</strong>
</div>
@if (closure.closureType === 'PREMATURE') {
<div class="dav-row">
<span>Agent Penalty ({{ closure.agentCode }})</span
><strong>₹{{ closure.agentCommissionPenalty || 0 | formatNumber }}</strong>
</div>
}
</div>
</div>
<div class="dav-column">
<div class="dav-card">
<div class="dav-card__head"><span class="dav-card__dot"></span> Where It Went</div>
@if (closure.destination === 'OWN_ACCOUNT') {
<div class="dav-row"><span>Paid Into</span><strong>Customer's own account</strong></div>
<div class="dav-row">
<span>Account</span><strong class="dav-mono">{{ closure.creditedAccount?.accountNo }}</strong>
</div>
@if (closure.creditedAccount?.productName) {
<div class="dav-row">
<span>Product</span><strong>{{ closure.creditedAccount.productName }}</strong>
</div>
}
@if (closure.creditedAccount?.owner) {
<div class="dav-row">
<span>Holder</span><strong>{{ closure.creditedAccount.owner }}</strong>
</div>
}
} @else if (closure.destination === 'SUNDRY') {
<div class="dav-row"><span>Parked In</span><strong>Sundry (customer has no account here)</strong></div>
<div class="dav-row">
<span>GL Account</span
><strong class="dav-mono">{{ closure.sundryGl?.glCode }} · {{ closure.sundryGl?.glName }}</strong>
</div>
} @else {
<div class="dav-row"><span>Paid Out</span><strong>Nothing - the balance was nil</strong></div>
}
@if (closure.voucherNo) {
<div class="dav-row">
<span>Voucher</span><strong class="dav-mono">{{ closure.voucherNo }}</strong>
</div>
}
@if (closure.payoutDate) {
<div class="dav-row">
<span>Paid On</span><strong>{{ closure.payoutDate | date: 'dd MMMM yyyy' }}</strong>
</div>
}
@if (closure.passbookNote) {
<div class="dav-row">
<span>Passbook Note</span><strong>{{ closure.passbookNote }}</strong>
</div>
}
</div>
@if (closure.ledgerEntries?.length) {
<div class="dav-card">
<div class="dav-card__head"><span class="dav-card__dot"></span> Ledger Entry</div>
<div class="dav-row">
<span>Transaction ID</span><strong class="dav-mono">{{ closure.ledgerTransactionId }}</strong>
</div>
@for (leg of closure.ledgerEntries; track $index) {
<div class="dav-row">
<span>{{ leg.glCode }} · {{ leg.glName }}</span>
<strong [class.dav-dr]="leg.side === 'DR'" [class.dav-cr]="leg.side === 'CR'">
{{ leg.side }} ₹{{ leg.amount | formatNumber }}
</strong>
</div>
}
</div>
}
</div>
</div>
}
}
<!-- General -->
@if (tab === 'general') {
<div class="dav-columns">
<div class="dav-column">
<div class="dav-card">
<div class="dav-card__head"><span class="dav-card__dot"></span> DDS Details</div>
<div class="dav-row">
<span>Account Number</span><strong class="dav-mono">{{ account.accountNo }}</strong>
</div>
<div class="dav-row">
<span>Party Code</span><strong>{{ account.partyCode }}</strong>
</div>
<div class="dav-row">
<span>Client Name</span><strong>{{ account.clientName }} ({{ account.clientId }})</strong>
</div>
<div class="dav-row">
<span>Agent</span><strong>{{ account.agentCode }} · {{ account.agentName }}</strong>
</div>
<div class="dav-row">
<span>Branch</span><strong>{{ account.officeName }}</strong>
</div>
<div class="dav-row">
<span>Product</span><strong>{{ account.subType }}</strong>
</div>
<div class="dav-row">
<span>Opened On</span><strong>{{ account.openingDate | date: 'dd MMMM yyyy' }}</strong>
</div>
<div class="dav-row">
<span>Matures On</span><strong>{{ account.maturityDate | date: 'dd MMMM yyyy' }}</strong>
</div>
<div class="dav-row">
<span>Daily Deposit</span><strong>₹{{ account.dailyDepositAmount || 0 | formatNumber }}</strong>
</div>
<div class="dav-row">
<span>Term</span><strong>{{ account.month }} months · {{ account.days }} days</strong>
</div>
<div class="dav-row">
<span>Interest Rate</span><strong>{{ account.interestRate }} %</strong>
</div>
<div class="dav-row">
<span>Currency</span><strong>Indian Rupee <em class="dav-mono">[INR]</em></strong>
</div>
</div>
<div class="dav-card">
<div class="dav-card__head"><span class="dav-card__dot"></span> Performance History</div>
<div class="dav-row">
<span>Total Collected</span><strong class="dav-cr">₹{{ totalOf('PARTY_TRAN') | formatNumber }}</strong>
</div>
<div class="dav-row">
<span>Total Interest Earned</span><strong>₹{{ interestEarned | formatNumber }}</strong>
</div>
<div class="dav-row">
<span>Total Paid Out</span><strong class="dav-dr">₹{{ totalDebitsAll | formatNumber }}</strong>
</div>
</div>
</div>
<div class="dav-column">
<div class="dav-card">
<div class="dav-card__head"><span class="dav-card__dot"></span> Account Summary</div>
<div class="dav-row">
<span>Collected to Date</span><strong>₹{{ account.collectedToDate || 0 | formatNumber }}</strong>
</div>
<div class="dav-row">
<span>Expected to Date</span><strong>₹{{ account.expectedToDate || 0 | formatNumber }}</strong>
</div>
<div class="dav-row">
<span>Standing</span>
<strong>
@if (account.advanceDays > 0) {
{{ account.advanceDays }} days ahead
} @else if (account.arrearsDays > 0) {
<span class="dav-dr">{{ account.arrearsDays }} days behind</span>
} @else {
Up to date
}
</strong>
</div>
<div class="dav-row">
<span>Instalments Due</span><strong>{{ account.instalmentsDue ?? 0 }}</strong>
</div>
<div class="dav-row">
<span>Interest Earned</span><strong>₹{{ interestEarned | formatNumber }}</strong>
</div>
<div class="dav-row">
<span>Interest Calculated, Not Posted</span
><strong>₹{{ account.interestCalc || 0 | formatNumber }}</strong>
</div>
<div class="dav-row"><span>Interest Calculated Using</span><strong>Daily Balance</strong></div>
<div class="dav-row"><span>Days in Year</span><strong>365 Days</strong></div>
<div class="dav-row">
<span>Last Interest Date</span
><strong>{{ account.interestDate ? (account.interestDate | date: 'dd MMMM yyyy') : '—' }}</strong>
</div>
<div class="dav-row">
<span>Last Transaction Date</span
><strong>{{ lastTransactionDate ? (lastTransactionDate | date: 'dd MMMM yyyy') : '—' }}</strong>
</div>
<div class="dav-row">
<span>Maturity Amount</span><strong>₹{{ account.maturityAmount || 0 | formatNumber }}</strong>
</div>
<div class="dav-row">
<span>Lien</span>
<strong>{{ account.lien ? 'Yes — ₹' + (account.amountHeldUnderLien || 0) : 'No' }}</strong>
</div>
<div class="dav-row">
<span>Freeze</span><strong>{{ account.freezeCode || 'Normal' }}</strong>
</div>
@if (account.closeDate) {
<div class="dav-row">
<span>Closed On</span><strong>{{ account.closeDate | date: 'dd MMMM yyyy' }}</strong>
</div>
}
</div>
</div>
</div>
}
<!-- Transactions -->
@if (tab === 'transactions') {
<div class="dav-card">
<div class="dav-toolbar">
<div class="dav-datepick">
<span>From</span>
<input type="date" [formControl]="fromDateControl" [max]="today" />
</div>
<div class="dav-datepick">
<span>To</span>
<input type="date" [formControl]="toDateControl" [max]="today" />
</div>
<button class="dav-btn" (click)="showToday()"><fa-icon icon="clock"></fa-icon> Today</button>
@if (hasDateFilters) {
<button class="dav-btn" (click)="clearDateFilters()"><fa-icon icon="times"></fa-icon> Clear</button>
}
<div class="dav-totals">
<span class="dav-cr">Credits ₹{{ totalCredits | formatNumber }}</span>
<span class="dav-dr">Debits ₹{{ totalDebits | formatNumber }}</span>
</div>
<button class="dav-btn dav-btn--primary" (click)="exportCsv()" [disabled]="shownTransactions.length === 0">
Export
</button>
</div>
@if (shownTransactions.length === 0) {
<div class="dav-state">
<fa-icon icon="inbox" size="2x"></fa-icon>
<span>{{
hasDateFilters
? 'Nothing moved on this account in these dates'
: 'Nothing has been credited to this account yet'
}}</span>
</div>
} @else {
<div class="dav-table-wrap">
<table class="dav-table">
<thead>
<tr>
<th>#</th>
<th>ID</th>
<th>Transaction Date</th>
<th>Transaction Type</th>
<th>Voucher</th>
<th>Description</th>
<th class="dav-right">Debit</th>
<th class="dav-right">Credit</th>
<th class="dav-right">Balance</th>
</tr>
</thead>
<tbody>
@for (txn of shownTransactions; track txn.id; let i = $index) {
<tr>
<td>{{ i + 1 }}</td>
<td>{{ txn.id }}</td>
<td>{{ txn.transactionDate | date: 'dd MMMM yyyy' }}</td>
<td>{{ label(txn.transactionType) }}</td>
<td class="dav-mono">{{ txn.voucherNo || '—' }}</td>
<td class="dav-desc">{{ txn.description }}</td>
<td class="dav-right">
{{ txn.direction === 'DEBIT' ? '₹' + (txn.amount | formatNumber) : 'N/A' }}
</td>
<td class="dav-right">
{{ txn.direction === 'CREDIT' ? '₹' + (txn.amount | formatNumber) : 'N/A' }}
</td>
<td class="dav-right">
<strong>₹{{ txn.balanceAfterTransaction || 0 | formatNumber }}</strong>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
}
<!-- Passbook: the same entries oldest first, the way a passbook is written up, and printable -->
@if (tab === 'passbook') {
<div class="dav-card">
<div class="dav-toolbar dav-noprint">
<div class="dav-datepick">
<span>From</span>
<input type="date" [formControl]="fromDateControl" [max]="today" />
</div>
<div class="dav-datepick">
<span>To</span>
<input type="date" [formControl]="toDateControl" [max]="today" />
</div>
@if (hasDateFilters) {
<button class="dav-btn" (click)="clearDateFilters()"><fa-icon icon="times"></fa-icon> Clear</button>
}
<button class="dav-btn dav-btn--primary dav-push" (click)="printPassbook()">
<fa-icon icon="print"></fa-icon> Print
</button>
</div>
<div class="dav-passbook" id="dds-passbook">
<div class="dav-pb-head">
<div>
<div class="dav-pb-title">DDS Passbook</div>
<div class="dav-pb-sub">{{ account.officeName }}</div>
</div>
<div class="dav-pb-grid">
<span>Account No</span><strong class="dav-mono">{{ account.accountNo }}</strong> <span>Party Code</span
><strong>{{ account.partyCode }}</strong> <span>Name</span
><strong>{{ account.clientName || account.partyName }}</strong> <span>Product</span
><strong>{{ account.subType }} · {{ account.interestRate }} %</strong> <span>Opened On</span
><strong>{{ account.openingDate | date: 'dd/MM/yyyy' }}</strong> <span>Agent</span
><strong>{{ account.agentCode }}</strong>
</div>
</div>
@if (passbookEntries.length === 0) {
<div class="dav-state">
<span>{{ hasDateFilters ? 'No entries in these dates' : 'No entries yet' }}</span>
</div>
} @else {
<table class="dav-table dav-pb-table">
<thead>
<tr>
<th>Date</th>
<th>Voucher</th>
<th>Particulars</th>
<th class="dav-right">Withdrawals</th>
<th class="dav-right">Deposits</th>
<th class="dav-right">Balance</th>
</tr>
</thead>
<tbody>
@for (txn of passbookEntries; track txn.id) {
<tr>
<td>{{ txn.transactionDate | date: 'dd/MM/yyyy' }}</td>
<td class="dav-mono">{{ txn.voucherNo || '' }}</td>
<td>
{{ label(txn.transactionType) }}
@if (txn.description) {
<span class="dav-pb-note">{{ txn.description }}</span>
}
</td>
<td class="dav-right">{{ txn.direction === 'DEBIT' ? (txn.amount | formatNumber) : '' }}</td>
<td class="dav-right">{{ txn.direction === 'CREDIT' ? (txn.amount | formatNumber) : '' }}</td>
<td class="dav-right">
<strong>{{ txn.balanceAfterTransaction || 0 | formatNumber }}</strong>
</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="3"><strong>Total</strong></td>
<td class="dav-right">
<strong>{{ totalDebits | formatNumber }}</strong>
</td>
<td class="dav-right">
<strong>{{ totalCredits | formatNumber }}</strong>
</td>
<td class="dav-right">
<strong>{{ passbookClosingBalance | formatNumber }}</strong>
</td>
</tr>
</tfoot>
</table>
}
</div>
</div>
}
<!-- Nominees -->
@if (tab === 'nominees') {
<div class="dav-card">
@if (!account.nominees || account.nominees.length === 0) {
<div class="dav-state">
<fa-icon icon="user" size="2x"></fa-icon>
<span>No nominee on this account</span>
</div>
} @else {
<div class="dav-table-wrap">
<table class="dav-table">
<thead>
<tr>
<th>#</th>
<th>Name</th>
<th>Relation</th>
<th>Gender</th>
<th>Mobile</th>
<th>Birth Date</th>
<th>Nominated On</th>
<th>Guardian</th>
<th class="dav-right">{{ account.nomineeSplitMode === 'Sequence' ? 'Order' : 'Share %' }}</th>
</tr>
</thead>
<tbody>
@for (nominee of account.nominees; track nominee.id; let i = $index) {
<tr>
<td>{{ i + 1 }}</td>
<td>
<strong>{{ nominee.name }}</strong>
</td>
<td>{{ nominee.relation }}</td>
<td>{{ nominee.gender }}</td>
<td>{{ nominee.mobileNo || '—' }}</td>
<td>{{ nominee.birthDate ? (nominee.birthDate | date: 'dd MMM yyyy') : '—' }}</td>
<td>{{ nominee.nominationDate ? (nominee.nominationDate | date: 'dd MMM yyyy') : '—' }}</td>
<td>{{ nominee.guardianName || '—' }}</td>
<td class="dav-right">
{{ account.nomineeSplitMode === 'Sequence' ? nominee.sequenceNo : (nominee.sharePercent ?? '—') }}
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
}
}
</div>
@@ -0,0 +1,459 @@
/**
* 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/.
*/
/* One DDS account's page, laid out the way the savings account page is: a banner, tabs, cards. */
.dav-page {
display: flex;
flex-direction: column;
gap: 16px;
padding: 24px 32px;
}
.dav-mono {
font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', monospace;
font-style: normal;
}
.dav-cr {
color: #047857;
}
.dav-dr {
color: #b91c1c;
}
.dav-right {
text-align: right !important;
}
/* ── Banner ───────────────────────────────────────────────────────────── */
.dav-banner {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 16px;
padding: 16px 24px;
border-radius: 16px;
color: #fff;
background: linear-gradient(90deg, #1e3a8a 0%, #2d3fb4 55%, #5b47d6 100%);
box-shadow: 0 8px 24px rgb(30 58 138 / 25%);
}
.dav-banner__icon {
width: 48px;
height: 48px;
border-radius: 12px;
display: grid;
place-items: center;
font-size: 1.3rem;
background: rgb(255 255 255 / 16%);
border: 1px solid rgb(255 255 255 / 25%);
}
.dav-banner__text {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.dav-pill {
align-self: flex-start;
padding: 2px 10px;
border-radius: 999px;
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.04em;
background: rgb(16 185 129 / 25%);
color: #a7f3d0;
border: 1px solid rgb(167 243 208 / 40%);
}
.dav-pill--closed {
background: rgb(148 163 184 / 25%);
color: #e2e8f0;
border-color: rgb(226 232 240 / 40%);
}
.dav-banner__title {
margin: 0;
font-size: 1.25rem;
font-weight: 700;
}
.dav-banner__meta {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 6px 12px;
}
.dav-banner__label {
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: rgb(255 255 255 / 60%);
}
.dav-banner__value {
font-size: 0.9rem;
font-weight: 600;
}
.dav-banner__boxes {
margin-left: auto;
display: flex;
gap: 12px;
}
.dav-box {
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px 18px;
border-radius: 12px;
background: rgb(255 255 255 / 12%);
border: 1px solid rgb(255 255 255 / 25%);
}
.dav-box__label {
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: rgb(255 255 255 / 70%);
}
.dav-box__value {
font-size: 1.15rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.dav-banner__actions {
display: flex;
gap: 8px;
}
.dav-icon-btn {
width: 38px;
height: 38px;
border-radius: 50%;
border: 1px solid rgb(255 255 255 / 35%);
background: rgb(255 255 255 / 12%);
color: #fff;
cursor: pointer;
&:hover {
background: rgb(255 255 255 / 22%);
}
}
/* ── Tabs ─────────────────────────────────────────────────────────────── */
.dav-tabs {
display: flex;
gap: 8px;
padding: 8px;
border-radius: 14px;
background: #fff;
border: 1px solid rgb(0 0 0 / 7%);
}
.dav-tab {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 10px 16px;
border: 0;
border-radius: 10px;
background: transparent;
font-size: 0.95rem;
font-weight: 600;
color: #475569;
cursor: pointer;
&:hover {
background: #f1f5f9;
}
}
.dav-tab--on {
color: #fff;
background: linear-gradient(90deg, #1d4ed8, #2563eb);
box-shadow: 0 4px 12px rgb(37 99 235 / 30%);
&:hover {
background: linear-gradient(90deg, #1d4ed8, #2563eb);
}
}
/* ── Cards ────────────────────────────────────────────────────────────── */
.dav-columns {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(380px, 1fr));
gap: 16px;
align-items: start;
}
.dav-column {
display: flex;
flex-direction: column;
gap: 16px;
}
.dav-card {
border-radius: 14px;
background: #fff;
border: 1px solid rgb(0 0 0 / 7%);
overflow: hidden;
}
.dav-card__head {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 20px;
font-size: 1rem;
font-weight: 700;
background: #f8fafc;
border-bottom: 1px solid rgb(0 0 0 / 7%);
}
.dav-card__dot {
width: 30px;
height: 30px;
border-radius: 8px;
background: linear-gradient(135deg, #2563eb, #1e40af);
}
.dav-row {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 12px 20px;
border-bottom: 1px dashed rgb(0 0 0 / 7%);
font-size: 0.92rem;
&:last-child {
border-bottom: 0;
}
span {
color: #334155;
}
strong {
text-align: right;
font-weight: 600;
}
}
.dav-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 48px 16px;
color: rgb(0 0 0 / 50%);
}
/* ── Transactions ─────────────────────────────────────────────────────── */
.dav-toolbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
padding: 14px 20px;
background: #f8fafc;
border-bottom: 1px solid rgb(0 0 0 / 7%);
}
.dav-datepick {
display: flex;
flex-direction: column;
gap: 2px;
span {
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: rgb(0 0 0 / 45%);
}
input {
padding: 6px 10px;
border-radius: 8px;
border: 1px solid rgb(0 0 0 / 15%);
font: inherit;
}
}
.dav-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
border-radius: 8px;
border: 1px solid rgb(0 0 0 / 15%);
background: #fff;
font-weight: 600;
cursor: pointer;
&:disabled {
opacity: 0.5;
cursor: default;
}
}
.dav-btn--primary {
color: #fff;
border-color: transparent;
background: linear-gradient(90deg, #1d4ed8, #2563eb);
}
.dav-totals {
margin-left: auto;
display: flex;
gap: 16px;
font-size: 0.88rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.dav-table-wrap {
overflow-x: auto;
}
.dav-table {
width: 100%;
border-collapse: collapse;
font-size: 0.92rem;
th {
padding: 14px 16px;
text-align: left;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #475569;
border-bottom: 1px solid rgb(0 0 0 / 8%);
white-space: nowrap;
}
td {
padding: 16px;
border-bottom: 1px solid rgb(0 0 0 / 5%);
font-variant-numeric: tabular-nums;
}
tbody tr:nth-child(odd) {
background: #f8fafc;
}
}
.dav-desc {
max-width: 320px;
color: rgb(0 0 0 / 65%);
}
@media (width <= 720px) {
.dav-page {
padding: 16px;
}
.dav-banner__boxes {
margin-left: 0;
width: 100%;
}
.dav-tab {
font-size: 0.85rem;
padding: 8px;
}
}
/* ── Passbook ─────────────────────────────────────────────────────────── */
.dav-push {
margin-left: auto;
}
.dav-passbook {
padding: 20px;
}
.dav-pb-head {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
gap: 16px 32px;
margin-bottom: 16px;
padding-bottom: 16px;
border-bottom: 2px solid #1e3a8a;
}
.dav-pb-title {
font-size: 1.2rem;
font-weight: 700;
color: #1e3a8a;
}
.dav-pb-sub {
font-size: 0.85rem;
color: rgb(0 0 0 / 55%);
}
.dav-pb-grid {
display: grid;
grid-template-columns: auto auto;
gap: 4px 16px;
font-size: 0.88rem;
span {
color: rgb(0 0 0 / 55%);
}
}
.dav-pb-table {
tfoot td {
padding: 14px 16px;
border-top: 2px solid rgb(0 0 0 / 15%);
}
}
.dav-pb-note {
display: block;
font-size: 0.78rem;
color: rgb(0 0 0 / 50%);
}
/* Closure tab */
.dav-closure-tag {
margin-left: 8px;
padding: 1px 8px;
border-radius: 999px;
background: #fff3e0;
color: #e65100;
font-size: 0.75rem;
font-style: normal;
}
.dav-closure-total {
border-top: 1px solid rgb(0 0 0 / 10%);
font-weight: 600;
}
@@ -0,0 +1,297 @@
/**
* 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 { ActivatedRoute, Router } from '@angular/router';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { NgClass } from '@angular/common';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { forkJoin, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FormatNumberPipe } from 'app/pipes/format-number.pipe';
import { OrganizationService } from 'app/organization/organization.service';
/** What each DDS transaction type is called on screen. */
const TRANSACTION_LABELS: Record<string, string> = {
PARTY_TRAN: 'Collection',
INTEREST_POST: 'Interest',
OD_INTEREST_POST: 'OD Interest',
MATURITY_PAYOUT: 'Maturity Payout',
PREMATURE_PAYOUT: 'Premature Payout',
PREMATURE_INT_RECOVERY: 'Interest Recovered (Premature)',
PREMATURE_AGENT_TOP_UP: 'Interest Funded by Agent',
WITHDRAW_DEPOSITE: 'Transfer (Withdraw Deposite)',
TRANSFER: 'Transfer'
};
/**
* One DDS account and everything that has moved on it, as the savings account page shows a savings
* account: the account's terms and where it stands, then its transactions with the balance after
* each, narrowed to a day or a range.
*
* Read-only. The account is changed from DDS Party Master; its money moves through collections, the
* cashier and the batch runs.
*/
@Component({
selector: 'mifosx-dds-account-view',
templateUrl: './dds-account-view.component.html',
styleUrls: ['./dds-account-view.component.scss'],
standalone: true,
imports: [
...STANDALONE_SHARED_IMPORTS,
ReactiveFormsModule,
NgClass,
FaIconComponent,
MatTooltipModule,
MatProgressSpinnerModule,
FormatNumberPipe
]
})
export class DdsAccountViewComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private organizationService = inject(OrganizationService);
isLoading = true;
account: any = null;
private transactions: any[] = [];
/** The page's tabs, as the savings account page has them - only those that apply to DDS. */
tab: 'general' | 'transactions' | 'passbook' | 'nominees' | 'closure' = 'general';
/** How a closed account was closed - read only for closed accounts, for the Closure tab. */
closure: any = null;
/** What each kind of closure is called on screen. */
readonly closureLabels: Record<string, string> = {
PREMATURE: 'Premature Close',
MATURITY_PAYOUT_AND_CLOSE: 'Maturity Payout & Close',
MATURITY_PAYOUT_THEN_CLOSE: 'Maturity Payout, then Closed',
MATURITY_AUTO_CLOSE: 'Maturity Auto Close (night job)',
CLOSED_NIL_BALANCE: 'Closed with nil balance'
};
/** The passbook reads oldest first, as it is written up. */
get passbookEntries(): any[] {
return [...this.shownTransactions].reverse();
}
/** The balance after the last entry shown. */
get passbookClosingBalance(): number {
const entries = this.passbookEntries;
return entries.length > 0 ? Number(entries[entries.length - 1].balanceAfterTransaction || 0) : 0;
}
/**
* Prints the passbook alone. It is copied into a window of its own with only its own styles, so the
* page's menus, banner and tabs do not come out on the paper.
*/
printPassbook(): void {
const passbook = document.getElementById('dds-passbook');
const popup = window.open('', '_blank', 'width=900,height=700');
if (!passbook || !popup) {
return;
}
popup.document.write(`<html><head><title>${this.account?.accountNo || 'DDS'} Passbook</title><style>
body { font-family: Arial, sans-serif; margin: 24px; color: #111; }
.dav-pb-head { display: flex; justify-content: space-between; gap: 24px; margin-bottom: 16px; }
.dav-pb-title { font-size: 20px; font-weight: 700; }
.dav-pb-sub { color: #555; }
.dav-pb-grid { display: grid; grid-template-columns: auto auto; gap: 2px 12px; font-size: 13px; }
.dav-pb-grid span { color: #555; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th, td { border: 1px solid #999; padding: 6px 8px; text-align: left; }
.dav-right { text-align: right; }
.dav-pb-note { display: block; font-size: 11px; color: #555; }
</style></head><body>${passbook.innerHTML}</body></html>`);
popup.document.close();
popup.focus();
popup.print();
}
fromDateControl = new FormControl('');
toDateControl = new FormControl('');
readonly today = new Date().toISOString().split('T')[0];
ngOnInit(): void {
const partyId = this.route.snapshot.paramMap.get('partyId');
if (!partyId) {
this.isLoading = false;
return;
}
forkJoin({
account: this.organizationService.getParty(partyId).pipe(catchError(() => of(null))),
transactions: this.organizationService.getPartyTransactionsOfParty(partyId).pipe(catchError(() => of([])))
}).subscribe(({ account, transactions }) => {
this.account = account;
if (account?.status === 'CLOSED') {
this.organizationService.getPartyClosure(partyId).subscribe({
next: (closure: any) => (this.closure = closure),
error: () => (this.closure = null)
});
}
// Newest first, and within a day the later entry first - the order a passbook is read back in.
this.transactions = (Array.isArray(transactions) ? transactions : []).sort(
(a: any, b: any) =>
String(b.transactionDate).localeCompare(String(a.transactionDate)) || Number(b.id) - Number(a.id)
);
this.isLoading = false;
});
}
/** The transactions in the chosen range - all of them when no range is set. */
get shownTransactions(): any[] {
const from = this.fromDateControl.value;
const to = this.toDateControl.value;
return this.transactions.filter((txn: any) => {
const day = String(txn.transactionDate || '');
if (from && day < String(from)) {
return false;
}
if (to && day > String(to)) {
return false;
}
return true;
});
}
get totalCredits(): number {
return this.sumOf('CREDIT');
}
get totalDebits(): number {
return this.sumOf('DEBIT');
}
private sumOf(direction: string): number {
return this.shownTransactions
.filter((txn: any) => txn.direction === direction)
.reduce((sum: number, txn: any) => sum + Number(txn.amount || 0), 0);
}
/** Everything of one type ever credited, whatever the date filter says - for the summary cards. */
totalOf(type: string): number {
return this.transactions
.filter((txn: any) => txn.transactionType === type && txn.direction === 'CREDIT')
.reduce((sum: number, txn: any) => sum + Number(txn.amount || 0), 0);
}
/** Interest credited, less any taken back on an early closure. */
get interestEarned(): number {
const recovered = this.transactions
.filter((txn: any) => txn.transactionType === 'PREMATURE_INT_RECOVERY')
.reduce((sum: number, txn: any) => sum + Number(txn.amount || 0), 0);
return (
this.totalOf('INTEREST_POST') +
this.totalOf('OD_INTEREST_POST') +
this.totalOf('PREMATURE_AGENT_TOP_UP') -
recovered
);
}
/** Money that has left the account - payouts, withdrawals, transfers out. */
get totalDebitsAll(): number {
return this.transactions
.filter((txn: any) => txn.direction === 'DEBIT' && txn.transactionType !== 'PREMATURE_INT_RECOVERY')
.reduce((sum: number, txn: any) => sum + Number(txn.amount || 0), 0);
}
get lastTransactionDate(): string | null {
return this.transactions.length > 0 ? this.transactions[0].transactionDate : null;
}
/** The rows on screen, as a spreadsheet the branch can keep or send on. */
exportCsv(): void {
const header = [
'#',
'ID',
'Transaction Date',
'Transaction Type',
'Voucher',
'Description',
'Account',
'Debit',
'Credit',
'Balance'
];
const quote = (value: any) => `"${String(value ?? '').replace(/"/g, '""')}"`;
const lines = this.shownTransactions.map((txn: any, index: number) =>
[
index + 1,
txn.id,
txn.transactionDate,
this.label(txn.transactionType),
txn.voucherNo,
txn.description,
txn.accountNo,
txn.direction === 'DEBIT' ? txn.amount : '',
txn.direction === 'CREDIT' ? txn.amount : '',
txn.balanceAfterTransaction
]
.map(quote)
.join(',')
);
const blob = new Blob(
[[
header.map(quote).join(','),
...lines
].join('\r\n')],
{ type: 'text/csv;charset=utf-8' }
);
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `${this.account?.accountNo || 'dds-account'}-transactions.csv`;
link.click();
URL.revokeObjectURL(link.href);
}
get hasDateFilters(): boolean {
return !!(this.fromDateControl.value || this.toDateControl.value);
}
showToday(): void {
this.fromDateControl.setValue(this.today);
this.toDateControl.setValue(this.today);
}
clearDateFilters(): void {
this.fromDateControl.setValue('');
this.toDateControl.setValue('');
}
label(type: string): string {
return TRANSACTION_LABELS[type] || String(type || '').replace(/_/g, ' ');
}
statusClass(): string {
switch (this.account?.status) {
case 'ACTIVE':
return 'status--active';
case 'CLOSED':
return 'status--closed';
default:
return 'status--default';
}
}
back(): void {
this.router.navigate(['/accounts/dds-accounts']);
}
openInPartyMaster(): void {
this.router.navigate([
'/accounts/dds',
this.account.id,
'edit'
]);
}
}
@@ -0,0 +1,211 @@
<!--
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="acl-page">
<!-- Header -->
<div class="acl-header accent--rd">
<div class="acl-header__icon">
<fa-icon icon="coins"></fa-icon>
</div>
<div class="acl-header__text">
<h2 class="acl-header__title">DDS Accounts</h2>
<span class="acl-header__count">
@if (isLoading) {
Loading...
} @else {
{{ allCount }} accounts · {{ activeCount }} active · {{ closedCount }} closed
}
</span>
</div>
</div>
<!-- Search Bar -->
<div class="acl-search-wrap">
<div class="acl-search">
<fa-icon icon="search" class="acl-search__icon"></fa-icon>
<input
[formControl]="searchControl"
class="acl-search__input"
[placeholder]="
searchType === 'accountNo' ? 'Search by account number or party code…' : 'Search by client name…'
"
/>
@if (searchControl.value) {
<button class="acl-search__clear" (click)="clearSearch()">
<fa-icon icon="times"></fa-icon>
</button>
}
</div>
<button
class="acl-btn acl-btn--toggle"
(click)="toggleSearchType()"
[matTooltip]="'Switch to ' + (searchType === 'accountNo' ? 'client name' : 'account number') + ' search'"
>
<fa-icon icon="exchange-alt"></fa-icon>
{{ searchType === 'accountNo' ? 'Acct No' : 'Client' }}
</button>
<button class="acl-btn acl-btn--icon" (click)="loadAccounts()" matTooltip="Refresh">
<fa-icon icon="sync-alt"></fa-icon>
</button>
</div>
<!-- Status: all accounts, only the open ones, or only the closed -->
<div class="dds-status-filter" role="tablist">
<button
type="button"
class="dds-status-filter__btn"
[class.is-on]="statusFilter === 'ALL'"
(click)="setStatusFilter('ALL')"
>
All <span class="dds-status-filter__count">{{ allCount }}</span>
</button>
<button
type="button"
class="dds-status-filter__btn dds-status-filter__btn--active"
[class.is-on]="statusFilter === 'ACTIVE'"
(click)="setStatusFilter('ACTIVE')"
>
Active <span class="dds-status-filter__count">{{ activeCount }}</span>
</button>
<button
type="button"
class="dds-status-filter__btn dds-status-filter__btn--closed"
[class.is-on]="statusFilter === 'CLOSED'"
(click)="setStatusFilter('CLOSED')"
>
Closed <span class="dds-status-filter__count">{{ closedCount }}</span>
</button>
</div>
<!-- Date Filters: by the day the account was opened -->
<div class="acl-filters">
<div class="acl-filters__group">
<div class="acl-datepick">
<span class="acl-datepick__label">Opened From</span>
<input type="date" class="acl-datepick__input" [formControl]="fromDateControl" [max]="today" />
</div>
<div class="acl-filters__arrow">
<fa-icon icon="arrow-right"></fa-icon>
</div>
<div class="acl-datepick">
<span class="acl-datepick__label">To</span>
<input type="date" class="acl-datepick__input" [formControl]="toDateControl" [max]="today" />
</div>
</div>
<div class="acl-filters__sep"></div>
<div class="acl-datepick acl-datepick--ason">
<span class="acl-datepick__label">
<fa-icon icon="clock"></fa-icon>
As On Date
</span>
<input type="date" class="acl-datepick__input" [formControl]="asOnDateControl" [max]="today" />
</div>
@if (hasDateFilters) {
<button class="acl-filters__clear" (click)="clearDateFilters()">
<fa-icon icon="times"></fa-icon>
Clear
</button>
}
</div>
<!-- Table -->
<div class="acl-card">
@if (isLoading) {
<div class="acl-state acl-state--loading">
<mat-spinner diameter="32"></mat-spinner>
<span>Loading accounts…</span>
</div>
}
@if (!isLoading && rows.length === 0) {
<div class="acl-state acl-state--empty">
<div class="acl-state__icon">
<fa-icon icon="inbox"></fa-icon>
</div>
<p class="acl-state__title">No DDS accounts found</p>
<p class="acl-state__sub">Try adjusting your search</p>
</div>
}
@if (!isLoading && rows.length > 0) {
<div class="acl-table-wrap">
<table mat-table [dataSource]="rows" class="acl-table">
<ng-container matColumnDef="accountNo">
<th mat-header-cell *matHeaderCellDef class="acl-th">Account No</th>
<td mat-cell *matCellDef="let row" class="acl-td">
<span class="acl-acct-no">{{ row.accountNo }}</span>
</td>
</ng-container>
<ng-container matColumnDef="depositType">
<th mat-header-cell *matHeaderCellDef class="acl-th acl-th--center">Type</th>
<td mat-cell *matCellDef="let row" class="acl-td acl-td--center">
<span class="acl-type-badge dtype--rd">DDS</span>
</td>
</ng-container>
<ng-container matColumnDef="clientName">
<th mat-header-cell *matHeaderCellDef class="acl-th">Client</th>
<td mat-cell *matCellDef="let row" class="acl-td">
<div class="acl-client">
<div class="acl-client__avatar">{{ (row.clientName || row.partyName || '?')[0] }}</div>
<span class="acl-client__name">{{ row.clientName || row.partyName }}</span>
</div>
</td>
</ng-container>
<ng-container matColumnDef="productName">
<th mat-header-cell *matHeaderCellDef class="acl-th">Product</th>
<td mat-cell *matCellDef="let row" class="acl-td">
<span class="acl-product">{{ row.subType }}</span>
</td>
</ng-container>
<ng-container matColumnDef="balance">
<th mat-header-cell *matHeaderCellDef class="acl-th acl-th--right">Balance</th>
<td mat-cell *matCellDef="let row" class="acl-td acl-td--right">
<span class="acl-balance">₹ {{ row.currentBalance || 0 | formatNumber }}</span>
</td>
</ng-container>
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef class="acl-th acl-th--center">Status</th>
<td mat-cell *matCellDef="let row" class="acl-td acl-td--center">
<span class="acl-status" [ngClass]="getStatusClass(row)">{{ row.status }}</span>
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef class="acl-th acl-th--center">Actions</th>
<td mat-cell *matCellDef="let row" class="acl-td acl-td--center">
<button class="acl-view-btn" (click)="$event.stopPropagation(); viewAccount(row)" matTooltip="View">
<fa-icon icon="eye"></fa-icon>
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns" class="acl-header-row"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns" class="acl-row" (click)="viewAccount(row)"></tr>
</table>
</div>
<mat-paginator
[length]="totalRecords"
[pageSize]="pageSize"
[pageIndex]="pageIndex"
[pageSizeOptions]="[10, 20, 50, 100]"
(page)="onPageChange($event)"
class="acl-paginator"
showFirstLastButtons
></mat-paginator>
}
</div>
</div>
@@ -0,0 +1,56 @@
/**
* 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/.
*/
/* All / Active / Closed - which DDS accounts the list shows. */
.dds-status-filter {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 0 0 12px;
&__btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 14px;
border: 1px solid rgb(0 0 0 / 12%);
border-radius: 999px;
background: #fff;
color: rgb(0 0 0 / 70%);
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
&.is-on {
border-color: #1565c0;
background: #e3f2fd;
color: #0d47a1;
}
&--active.is-on {
border-color: #2e7d32;
background: #e8f5e9;
color: #1b5e20;
}
&--closed.is-on {
border-color: #c62828;
background: #ffebee;
color: #b71c1c;
}
}
&__count {
min-width: 22px;
padding: 0 6px;
border-radius: 999px;
background: rgb(0 0 0 / 8%);
font-size: 0.75rem;
text-align: center;
}
}
@@ -0,0 +1,265 @@
/**
* 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, OnDestroy, OnInit, inject } from '@angular/core';
import { Router } from '@angular/router';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { NgClass } from '@angular/common';
import { MatPaginator, PageEvent } from '@angular/material/paginator';
import {
MatCell,
MatCellDef,
MatColumnDef,
MatHeaderCell,
MatHeaderCellDef,
MatHeaderRow,
MatHeaderRowDef,
MatRow,
MatRowDef,
MatTable
} from '@angular/material/table';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { Subject, merge } from 'rxjs';
import { debounceTime, distinctUntilChanged, takeUntil } from 'rxjs/operators';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FormatNumberPipe } from 'app/pipes/format-number.pipe';
import { OrganizationService } from 'app/organization/organization.service';
/**
* The DDS accounts, listed the way Savings Accounts lists savings: account number, client, product,
* balance and status, searched by account number or client and narrowed by opening date. Each opens
* onto the account's own page and its transactions.
*
* Read-only. Accounts are opened and changed from DDS Party Master; money moves through collections,
* the cashier and the batch runs.
*/
@Component({
selector: 'mifosx-dds-accounts',
templateUrl: './dds-accounts.component.html',
// The Savings Accounts list's own look, so the two screens read as one family.
styleUrls: [
'../../accounts/accounts-list/accounts-list.component.scss',
'./dds-accounts.component.scss'
],
standalone: true,
imports: [
...STANDALONE_SHARED_IMPORTS,
ReactiveFormsModule,
NgClass,
FaIconComponent,
MatTable,
MatColumnDef,
MatHeaderCellDef,
MatHeaderCell,
MatCellDef,
MatCell,
MatHeaderRowDef,
MatHeaderRow,
MatRowDef,
MatRow,
MatPaginator,
MatTooltipModule,
MatProgressSpinnerModule,
FormatNumberPipe
]
})
export class DdsAccountsComponent implements OnInit, OnDestroy {
private organizationService = inject(OrganizationService);
private router = inject(Router);
private destroy$ = new Subject<void>();
displayedColumns = [
'accountNo',
'depositType',
'clientName',
'productName',
'balance',
'status',
'actions'
];
/** Every DDS account, fetched once; search, dates and paging work on this. */
private allAccounts: any[] = [];
/** The accounts matching the search and dates, whatever their status - what the status counts read. */
private beforeStatus: any[] = [];
/** The accounts matching the search, dates and status, before paging. */
private matching: any[] = [];
/** Which accounts are shown: all of them, only the open ones, or only the closed. */
statusFilter: 'ALL' | 'ACTIVE' | 'CLOSED' = 'ALL';
/** The page on screen. */
rows: any[] = [];
pageSize = 20;
pageIndex = 0;
isLoading = false;
searchControl = new FormControl('');
searchType: 'accountNo' | 'clientName' = 'accountNo';
fromDateControl = new FormControl('');
toDateControl = new FormControl('');
asOnDateControl = new FormControl('');
readonly today = new Date().toISOString().split('T')[0];
get totalRecords(): number {
return this.matching.length;
}
get activeCount(): number {
return this.beforeStatus.filter((account) => account.status === 'ACTIVE').length;
}
get closedCount(): number {
return this.beforeStatus.filter((account) => account.status === 'CLOSED').length;
}
get allCount(): number {
return this.beforeStatus.length;
}
setStatusFilter(status: 'ALL' | 'ACTIVE' | 'CLOSED'): void {
this.statusFilter = status;
this.pageIndex = 0;
this.applyFilters();
}
get hasDateFilters(): boolean {
return !!((this.fromDateControl.value && this.toDateControl.value) || this.asOnDateControl.value);
}
ngOnInit(): void {
this.loadAccounts();
this.searchControl.valueChanges
.pipe(debounceTime(300), distinctUntilChanged(), takeUntil(this.destroy$))
.subscribe(() => {
this.pageIndex = 0;
this.applyFilters();
});
merge(this.fromDateControl.valueChanges, this.toDateControl.valueChanges, this.asOnDateControl.valueChanges)
.pipe(debounceTime(300), takeUntil(this.destroy$))
.subscribe(() => {
this.pageIndex = 0;
this.applyFilters();
});
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
loadAccounts(): void {
this.isLoading = true;
this.organizationService
.getParties()
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (parties: any) => {
// Newest first, as the savings list shows them.
this.allAccounts = (Array.isArray(parties) ? parties : parties?.pageItems || []).sort(
(a: any, b: any) => Number(b.id) - Number(a.id)
);
this.isLoading = false;
this.applyFilters();
},
error: () => {
this.allAccounts = [];
this.isLoading = false;
this.applyFilters();
}
});
}
/** Search by account number or client, and by the day the account was opened. */
private applyFilters(): void {
const term = String(this.searchControl.value || '')
.trim()
.toLowerCase();
const fromVal = this.fromDateControl.value;
const toVal = this.toDateControl.value;
const bothDates = !!(fromVal && toVal);
const asOn = this.asOnDateControl.value;
this.beforeStatus = this.allAccounts.filter((account: any) => {
if (term) {
const haystack =
this.searchType === 'accountNo'
? `${account.accountNo || ''} ${account.partyCode || ''}`
: `${account.clientName || ''} ${account.partyName || ''}`;
if (!haystack.toLowerCase().includes(term)) {
return false;
}
}
const opened = String(account.openingDate || '');
if (bothDates && opened && (opened < String(fromVal) || opened > String(toVal))) {
return false;
}
// As on a date: the accounts that had been opened by then.
if (asOn && opened && opened > String(asOn)) {
return false;
}
return true;
});
this.matching =
this.statusFilter === 'ALL'
? this.beforeStatus
: this.beforeStatus.filter((account: any) => account.status === this.statusFilter);
this.showPage();
}
private showPage(): void {
const start = this.pageIndex * this.pageSize;
this.rows = this.matching.slice(start, start + this.pageSize);
}
onPageChange(event: PageEvent): void {
this.pageIndex = event.pageIndex;
this.pageSize = event.pageSize;
this.showPage();
}
toggleSearchType(): void {
this.searchType = this.searchType === 'accountNo' ? 'clientName' : 'accountNo';
this.pageIndex = 0;
this.applyFilters();
}
clearSearch(): void {
this.searchControl.setValue('');
}
clearDateFilters(): void {
this.fromDateControl.setValue('', { emitEvent: false });
this.toDateControl.setValue('', { emitEvent: false });
this.asOnDateControl.setValue('', { emitEvent: false });
this.pageIndex = 0;
this.applyFilters();
}
viewAccount(account: any): void {
this.router.navigate([
'/accounts/dds-accounts',
account.id
]);
}
getStatusClass(account: any): string {
switch (account?.status) {
case 'ACTIVE':
return 'status--active';
case 'CLOSED':
return 'status--closed';
default:
return 'status--default';
}
}
}
@@ -0,0 +1,46 @@
/**
* 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 { OrganizationService } from 'app/organization/organization.service';
import { AlertService } from 'app/core/alert/alert.service';
/**
* Whether the server held a DDS entry for a checker rather than carrying it out. The reply then
* names the entry (commandId) and asks for the work to be rolled back, and carries no result of its own.
*/
export function heldForApproval(response: any): boolean {
return !!response?.rollbackTransaction || (!!response?.commandId && response?.resourceId == null);
}
/**
* Tells the clerk their entry went to a checker - with the voucher it will move under - instead of
* saying it was done. Nothing has moved yet, and a message that read as though it had would send them
* looking for money that is still waiting on a manager.
*/
export function announceHeldForApproval(
response: any,
what: string,
organizationService: OrganizationService,
alertService: AlertService
): void {
const say = (voucherNo?: string) =>
alertService.alert({
type: 'Sent for Approval',
message:
`${what} sent for approval${voucherNo ? ' under voucher ' + voucherNo : ''}. ` +
'It takes effect once a checker approves it in Approval Center › DDS Approvals.'
});
if (!response?.commandId) {
say();
return;
}
organizationService.getDdsApproval(response.commandId).subscribe({
next: (entry: any) => say(entry?.voucherNo),
error: () => say()
});
}
@@ -0,0 +1,265 @@
<!--
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/.
-->
<!-- Laid out as Disbursement & Transfers reviews its requests, and styled by the same sheet. -->
<div class="dri dri--dialog">
<nav class="cashier-nav cashier-nav--clerk">
<span class="dri-header-title">DDS {{ entry.type }} Review</span>
<span class="cashier-nav__esc-hint" (click)="close()">✕ Close</span>
</nav>
@if (loading) {
<div class="dri-loading">
<mat-icon class="dri-loading__icon">hourglass_empty</mat-icon>
<span>Loading entry…</span>
</div>
} @else {
<div class="dri-body">
<!-- Header card: the voucher it moves under, its status, who made it and when -->
<div class="dri-card dri-card--header">
<div class="dri-card-header">
<div class="dri-card-header__left">
<span class="dri-voucher">{{ voucherNo || '—' }}</span>
<span class="dri-status" [ngClass]="statusClass(entry.status)">
{{ entry.status }}{{ entry.voucherCancelled ? ' · VOUCHER CANCELLED' : '' }}
</span>
</div>
<div class="dri-card-header__meta">
<span class="dri-meta-item">
<mat-icon>person</mat-icon>
{{ entry.makerName || entry.maker }} ({{ entry.maker }})
</span>
<span class="dri-meta-item">
<mat-icon>schedule</mat-icon>
{{ formatDateTime(entry.madeOn) }}
</span>
@if (entry.status !== 'PENDING' && entry.checker) {
<span class="dri-meta-item">
<mat-icon>check_circle</mat-icon>
Actioned by: {{ entry.checkerName || entry.checker }}
</span>
}
</div>
</div>
</div>
<!-- Summary -->
<div class="dri-card">
<div class="dri-section-title">
<mat-icon>swap_horiz</mat-icon>
{{ entry.type }} Summary
</div>
<div class="dri-detail-grid">
<div class="dri-detail-item">
<span class="dri-detail-label">Total Amount</span>
<span class="dri-detail-value dri-amount">
{{ entry.amount !== null && entry.amount !== undefined ? '₹' + (entry.amount | number: '1.2-2') : '—' }}
</span>
</div>
<div class="dri-detail-item">
<span class="dri-detail-label">DR Accounts</span>
<span class="dri-detail-value"
>{{ debitLines.length }} account{{ debitLines.length !== 1 ? 's' : '' }}</span
>
</div>
<div class="dri-detail-item">
<span class="dri-detail-label">CR Accounts</span>
<span class="dri-detail-value"
>{{ creditLines.length }} account{{ creditLines.length !== 1 ? 's' : '' }}</span
>
</div>
<div class="dri-detail-item dri-detail-item--full">
<span class="dri-detail-label">Details</span>
<span class="dri-detail-value">{{ entry.details || '—' }}</span>
</div>
@if (detail?.description) {
<div class="dri-detail-item dri-detail-item--full">
<span class="dri-detail-label">Description</span>
<span class="dri-detail-value">{{ detail.description }}</span>
</div>
}
</div>
</div>
<!-- Debit entries -->
@if (debitLines.length) {
<div class="dri-card">
<div class="dri-section-title">
<mat-icon>arrow_upward</mat-icon>
Debit Entries ({{ debitLines.length }} account{{ debitLines.length !== 1 ? 's' : '' }} — funds out)
</div>
<table class="dri-cr-table">
<thead>
<tr>
<th>#</th>
<th>Account No</th>
<th>Account Name</th>
<th>Type</th>
<th class="num">Amount (DR)</th>
</tr>
</thead>
<tbody>
@for (line of debitLines; track $index; let i = $index) {
<tr>
<td class="dri-cr-idx">{{ i + 1 }}</td>
<td class="cell-mono">{{ line.accountNo || '—' }}</td>
<td>{{ line.accountName || '—' }}</td>
<td>
<span class="dri-type-badge">{{ line.type || '—' }}</span>
</td>
<td class="num dri-cr-amount">₹{{ line.amount | number: '1.2-2' }}</td>
</tr>
}
<tr class="dri-cr-total-row">
<td colspan="4" class="dri-cr-total-label">Total Debit</td>
<td class="num dri-cr-total-amount">₹{{ total(debitLines) | number: '1.2-2' }}</td>
</tr>
</tbody>
</table>
</div>
}
<!-- Credit entries -->
@if (creditLines.length) {
<div class="dri-card">
<div class="dri-section-title">
<mat-icon>arrow_downward</mat-icon>
Credit Entries ({{ creditLines.length }} account{{ creditLines.length !== 1 ? 's' : '' }} — funds in)
</div>
<table class="dri-cr-table">
<thead>
<tr>
<th>#</th>
<th>Account No</th>
<th>Account Name</th>
<th>Type</th>
<th class="num">Amount (CR)</th>
</tr>
</thead>
<tbody>
@for (line of creditLines; track $index; let i = $index) {
<tr>
<td class="dri-cr-idx">{{ i + 1 }}</td>
<td class="cell-mono">{{ line.accountNo || '—' }}</td>
<td>{{ line.accountName || '—' }}</td>
<td>
<span class="dri-type-badge">{{ line.type || '—' }}</span>
</td>
<td class="num dri-cr-amount">₹{{ line.amount | number: '1.2-2' }}</td>
</tr>
}
<tr class="dri-cr-total-row">
<td colspan="4" class="dri-cr-total-label">Total Credit</td>
<td class="num dri-cr-total-amount">₹{{ total(creditLines) | number: '1.2-2' }}</td>
</tr>
</tbody>
</table>
</div>
}
@if (entry.status === 'PENDING' && isMine && !done) {
<div class="dri-card dri-card--processing">
<div class="dri-section-title">
<mat-icon>info</mat-icon>
Your own entry
</div>
<p class="dri-processing-text">
You made this entry, so another user with checker rights has to approve or reject it.
</p>
</div>
}
@if (entry.status === 'REJECTED') {
<div class="dri-card dri-card--rejected">
<div class="dri-section-title">
<mat-icon>cancel</mat-icon>
Rejected
</div>
<p class="dri-rejection-text">
Nothing was done{{ voucherNo ? '. Voucher ' + voucherNo + ' is cancelled and will not be used' : '' }}.
</p>
@if (entry.checker) {
<div class="dri-meta-item" style="margin-top: 8px">
<mat-icon>manage_accounts</mat-icon>
{{ entry.checker }}
</div>
}
</div>
}
@if (entry.status === 'APPROVED') {
<div class="dri-card dri-card--approved">
<div class="dri-section-title">
<mat-icon>check_circle</mat-icon>
Approved and carried out
</div>
<p class="dri-txn-refs cell-mono">
{{
voucherNo
? 'Voucher ' + voucherNo
: entry.entity === 'DDSBATCH'
? 'Posting is running - refresh for its voucher'
: '—'
}}
</p>
<div class="dri-meta-item" style="margin-top: 8px">
<mat-icon>manage_accounts</mat-icon>
Approved by: {{ entry.checker || '—' }}
</div>
</div>
}
@if (error) {
<div class="dri-card dri-card--failed">
<div class="dri-section-title">
<mat-icon>error</mat-icon>
Could not complete
</div>
<p class="dri-rejection-text">{{ error }}</p>
</div>
}
@if (entry.status === 'PENDING' && !done) {
<div class="dri-actions">
@if (!confirmReject) {
<button type="button" class="dri-btn dri-btn--approve" [disabled]="!canAct" (click)="act('approve')">
<mat-icon>check_circle</mat-icon>
{{ working ? 'Processing…' : 'Approve ' + entry.type }}
</button>
<button type="button" class="dri-btn dri-btn--reject" [disabled]="!canAct" (click)="confirmReject = true">
<mat-icon>cancel</mat-icon>
Reject
</button>
} @else {
<div class="dri-reject-form">
<label class="dri-reject-label">
Reject this entry? Nothing will be done{{
voucherNo ? ' and voucher ' + voucherNo + ' will be cancelled' : ''
}}.
</label>
<div class="dri-reject-actions">
<button type="button" class="dri-btn dri-btn--reject" [disabled]="!canAct" (click)="act('reject')">
<mat-icon>cancel</mat-icon>
{{ working ? 'Processing…' : 'Confirm Rejection' }}
</button>
<button
type="button"
class="dri-btn dri-btn--ghost"
[disabled]="working"
(click)="confirmReject = false"
>
Cancel
</button>
</div>
</div>
}
</div>
}
</div>
}
</div>
@@ -0,0 +1,148 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { OrganizationService } from 'app/organization/organization.service';
/**
* One DDS entry, laid out the way Disbursement & Transfers reviews a request - voucher and status on
* top, who made it and when, then the debit and credit entries it moves - and styled by the same
* sheet, so a checker reads both queues the same way.
*
* The checker approves it, which carries it out under the voucher numbered when it was sent, or
* rejects it, which leaves nothing done and cancels that number. Neither is offered to the user who
* made the entry, and the server refuses it as well.
*/
@Component({
selector: 'mifosx-dds-approval-review',
templateUrl: './dds-approval-review.component.html',
styleUrls: ['../../../clerk/disbursement-review/disbursement-review.component.scss'],
standalone: true,
imports: [
CommonModule,
MatDialogModule,
MatIconModule
]
})
export class DdsApprovalReviewComponent implements OnInit {
private organizationService = inject(OrganizationService);
private dialogRef = inject(MatDialogRef<DdsApprovalReviewComponent>);
data = inject<any>(MAT_DIALOG_DATA);
entry: any = this.data.entry;
/** The entry in full, with the debit and credit sides it moves. */
detail: any = null;
loading = true;
working = false;
confirmReject = false;
done = false;
error = '';
/** Set by an approval, in case the list row did not carry the number yet. */
approvedVoucher = '';
ngOnInit(): void {
this.load();
}
private load(): void {
this.organizationService.getDdsApproval(this.entry.id).subscribe({
next: (detail: any) => {
this.detail = detail;
this.entry = { ...this.entry, ...detail };
this.loading = false;
},
error: () => (this.loading = false)
});
}
get voucherNo(): string {
return this.approvedVoucher || this.entry?.voucherNo || '';
}
get debitLines(): any[] {
return (this.detail?.lines || []).filter((line: any) => line.side === 'DR');
}
get creditLines(): any[] {
return (this.detail?.lines || []).filter((line: any) => line.side === 'CR');
}
total(lines: any[]): number {
return lines.reduce((sum: number, line: any) => sum + Number(line.amount || 0), 0);
}
get isMine(): boolean {
return !!this.data.currentUserId && Number(this.entry.makerId) === Number(this.data.currentUserId);
}
get canAct(): boolean {
return this.entry.status === 'PENDING' && !this.isMine && !this.done && !this.working;
}
statusClass(status: string): string {
const map: Record<string, string> = {
PENDING: 'dri-status--pending',
APPROVED: 'dri-status--approved',
REJECTED: 'dri-status--rejected'
};
return map[status] ?? '';
}
act(command: 'approve' | 'reject'): void {
this.working = true;
this.error = '';
this.organizationService.actOnDdsApproval(this.entry.id, command).subscribe({
next: (result: any) => {
this.working = false;
this.done = true;
this.approvedVoucher = result?.voucherNo || '';
this.entry = {
...this.entry,
status: command === 'approve' ? 'APPROVED' : 'REJECTED',
voucherCancelled: command === 'reject' && !!this.voucherNo
};
// The approved entry's sides are now the voucher's own lines.
this.load();
},
error: (err: any) => {
this.working = false;
const body = err?.error;
this.error =
body?.errors?.[0]?.defaultUserMessage ||
body?.defaultUserMessage ||
body?.developerMessage ||
'Could not complete this.';
}
});
}
/** The same form Disbursement & Transfers prints its times in: 29 Sept 2026, 01:25 pm. */
formatDateTime(val: any): string {
if (!val) {
return '—';
}
const d = new Date(String(val).replace(' ', 'T'));
return isNaN(d.getTime())
? String(val)
: d.toLocaleString('en-IN', {
day: '2-digit',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
close(): void {
this.dialogRef.close(this.done);
}
}
@@ -0,0 +1,176 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="cv-tab-content wri-tab">
<div class="wri-action-bar">
<div class="wri-action-bar__left">
<mat-icon class="wri-action-bar__icon">savings</mat-icon>
<div>
<div class="wri-action-bar__title">DDS Approvals</div>
<div class="wri-action-bar__sub">
Review, approve or reject DDS entries - transfers, payouts, early closures, receipts applied and postings.
</div>
</div>
</div>
</div>
<div class="wri-filter-bar">
<select class="wri-filter-select" [(ngModel)]="filterStatus" (change)="onFilterChange()">
@for (opt of statusOptions; track opt.value) {
<option [value]="opt.value">{{ opt.label }}</option>
}
</select>
<span class="wri-fb-sep"></span>
<div class="wri-presets">
@for (p of datePresets; track p.key) {
<button
type="button"
class="wri-preset-chip"
[class.wri-preset-chip--active]="activePreset === p.key"
(click)="applyPreset(p.key)"
>
{{ p.label }}
</button>
}
</div>
<span class="wri-fb-sep"></span>
<div class="wri-date-range">
<div class="wri-date-box">
<span class="wri-date-box__lbl">From</span>
<input
class="wri-date-box__input"
[matDatepicker]="fromPicker"
[(ngModel)]="fromDate"
[max]="maxDate"
(dateChange)="onFromDateChange()"
readonly
/>
<button type="button" class="wri-date-box__btn" (click)="fromPicker.open()">
<mat-icon>event</mat-icon>
</button>
<mat-datepicker #fromPicker></mat-datepicker>
</div>
<span class="wri-date-range__sep">—</span>
<div class="wri-date-box">
<span class="wri-date-box__lbl">To</span>
<input
class="wri-date-box__input"
[matDatepicker]="toPicker"
[(ngModel)]="toDate"
[min]="fromDate"
[max]="maxDate"
(dateChange)="onToDateChange()"
readonly
/>
<button type="button" class="wri-date-box__btn" (click)="toPicker.open()">
<mat-icon>event</mat-icon>
</button>
<mat-datepicker #toPicker></mat-datepicker>
</div>
</div>
<button type="button" class="wri-btn wri-btn--refresh" (click)="load()" [disabled]="loading">
<mat-icon>refresh</mat-icon>
</button>
</div>
@if (loading) {
<div class="empty-state">
<p class="empty-title">Loading…</p>
</div>
} @else if (loaded && entries.length === 0) {
<div class="empty-state">
<i class="fas fa-inbox empty-icon"></i>
<p class="empty-title">No DDS entries</p>
<p class="empty-text">
@if (filterStatus === 'PENDING') {
There are no DDS entries waiting for approval.
} @else {
No {{ filterStatus | lowercase }} DDS entries found.
}
</p>
</div>
} @else {
<div class="wri-table-wrap">
<table class="wri-table">
<thead>
<tr>
<th class="wri-th-id">Voucher No</th>
<th>Type</th>
<th>Account / Details</th>
<th class="num">Amount</th>
<th>Entries</th>
<th>Clerk</th>
<th>Reviewed By</th>
<th>Submitted At</th>
<th>Status</th>
<th class="wri-th-action">Action</th>
</tr>
</thead>
<tbody>
@for (e of pagedEntries; track e.id) {
<tr class="wri-row" (click)="review(e)">
<td class="cell-mono wri-voucher">
{{ e.voucherNo || '—' }}
@if (e.voucherCancelled) {
<span class="wri-gl-name">(cancelled)</span>
}
</td>
<td>
<span class="wri-credit-badge" [style.background]="typeColor(e)">{{ e.type }}</span>
</td>
<td>
<span class="wri-gl-name">{{ e.details || '—' }}</span>
</td>
<td class="num wri-amount">
{{ e.amount !== null && e.amount !== undefined ? '₹' + (e.amount | number: '1.2-2') : '—' }}
</td>
<td class="wri-credit-count">
@if (e.entries) {
<span class="wri-credit-badge">{{ e.entries }} account{{ e.entries !== 1 ? 's' : '' }}</span>
} @else {
—
}
</td>
<td>
{{ e.makerName || e.maker || '—' }}
@if (isMine(e) && e.status === 'PENDING') {
<span class="wri-gl-name">(you)</span>
}
</td>
<td>{{ e.status === 'PENDING' ? '—' : e.checkerName || e.checker || '—' }}</td>
<td class="wri-dt">{{ formatDateTime(e.madeOn) }}</td>
<td>
<span class="wri-status" [ngClass]="statusClass(e.status)">{{ e.status | titlecase }}</span>
</td>
<td class="wri-action-cell" (click)="$event.stopPropagation()">
<button type="button" class="wri-btn wri-btn--review" (click)="review(e)">
<mat-icon>rate_review</mat-icon>
Review
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
<mat-paginator
[length]="entries.length"
[pageSize]="pageSize"
[pageSizeOptions]="pageSizeOptions"
[pageIndex]="pageIndex"
showFirstLastButtons
(page)="onPageChange($event)"
></mat-paginator>
}
</div>
@@ -0,0 +1,215 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { MatDialog } from '@angular/material/dialog';
import { MatNativeDateModule } from '@angular/material/core';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { catchError } from 'rxjs/operators';
import { of } from 'rxjs';
import { OrganizationService } from 'app/organization/organization.service';
import { SettingsService } from 'app/settings/settings.service';
import { AuthenticationService } from 'app/core/authentication/authentication.service';
import { DdsApprovalReviewComponent } from './dds-approval-review/dds-approval-review.component';
/**
* Approval Center › DDS Approvals: every DDS entry waiting on a checker, in the clerk's terms - what
* it does, on which accounts, for how much - and, once approved, the voucher it moved under.
*
* Laid out like Disbursement & Transfers beside it, and sharing its styles, so the two queues read the
* same way. The entries are the platform's own maker-checker entries; approving one here carries it out.
*/
@Component({
selector: 'mifosx-dds-approvals',
templateUrl: './dds-approvals.component.html',
styleUrls: ['../../tasks/checker-inbox-and-tasks-tabs/disbursement-requests/disbursement-requests.component.scss'],
standalone: true,
imports: [
CommonModule,
FormsModule,
MatIconModule,
MatPaginatorModule,
MatNativeDateModule,
MatDatepickerModule
]
})
export class DdsApprovalsComponent implements OnInit {
private organizationService = inject(OrganizationService);
private settingsService = inject(SettingsService);
private authenticationService = inject(AuthenticationService);
private dialog = inject(MatDialog);
entries: any[] = [];
pageSize = 20;
pageIndex = 0;
readonly pageSizeOptions = [
10,
20,
50
];
/** The page of entries on screen. */
get pagedEntries(): any[] {
const start = this.pageIndex * this.pageSize;
return this.entries.slice(start, start + this.pageSize);
}
onPageChange(event: PageEvent): void {
this.pageSize = event.pageSize;
this.pageIndex = event.pageIndex;
}
filterStatus = 'PENDING';
fromDate: Date | null = null;
toDate: Date | null = null;
maxDate!: Date;
activePreset = 'today';
loading = false;
loaded = false;
readonly currentUserId = this.authenticationService.getCredentials()?.userId;
readonly statusOptions = [
{ value: 'PENDING', label: 'Pending' },
{ value: 'APPROVED', label: 'Approved' },
{ value: 'REJECTED', label: 'Rejected' }
];
readonly datePresets = [
{ key: 'today', label: 'Today' },
{ key: 'week', label: 'This Week' },
{ key: 'month', label: 'This Month' },
{ key: 'all', label: 'All' }
];
ngOnInit(): void {
const today = new Date(this.settingsService.businessDate || new Date());
today.setHours(0, 0, 0, 0);
this.maxDate = today;
this.applyPreset('today');
}
load(): void {
this.loading = true;
this.organizationService
.getDdsApprovals(this.filterStatus, this.isoDate(this.fromDate), this.isoDate(this.toDate))
.pipe(catchError(() => of([])))
.subscribe((rows: any) => {
this.entries = Array.isArray(rows) ? rows : [];
this.pageIndex = 0;
this.loading = false;
this.loaded = true;
});
}
onFilterChange(): void {
this.load();
}
onFromDateChange(): void {
this.activePreset = '';
if (this.fromDate && this.toDate && this.fromDate > this.toDate) {
this.toDate = new Date(this.fromDate);
}
this.load();
}
onToDateChange(): void {
this.activePreset = '';
this.load();
}
applyPreset(key: string): void {
this.activePreset = key;
const today = new Date(this.settingsService.businessDate || new Date());
today.setHours(0, 0, 0, 0);
if (key === 'today') {
this.fromDate = new Date(today);
this.toDate = new Date(today);
} else if (key === 'week') {
const start = new Date(today);
start.setDate(today.getDate() - today.getDay());
this.fromDate = start;
this.toDate = new Date(today);
} else if (key === 'month') {
this.fromDate = new Date(today.getFullYear(), today.getMonth(), 1);
this.toDate = new Date(today);
} else {
this.fromDate = null;
this.toDate = null;
}
this.load();
}
review(entry: any): void {
this.dialog
.open(DdsApprovalReviewComponent, { data: { entry, currentUserId: this.currentUserId }, autoFocus: false })
.afterClosed()
// However the dialog was closed - its Close button, a click outside, Esc - the list is read
// again, so an entry just approved or rejected leaves the Pending list at once.
.subscribe(() => this.load());
}
/** Whose entry it is - a checker cannot pass their own. */
isMine(entry: any): boolean {
return !!this.currentUserId && Number(entry.makerId) === Number(this.currentUserId);
}
statusClass(status: string): string {
const map: Record<string, string> = {
PENDING: 'wri-status--pending',
APPROVED: 'wri-status--approved',
REJECTED: 'wri-status--rejected'
};
return map[status] ?? '';
}
typeColor(entry: any): string {
const map: Record<string, string> = {
Transfer: '#e3f2fd',
'Apply Receipt': '#fce4ec'
};
if (String(entry.type).startsWith('Premature')) {
return '#fff3e0';
}
if (String(entry.type).startsWith('Maturity')) {
return '#e8f5e9';
}
if (String(entry.type).startsWith('Post')) {
return '#ede7f6';
}
return map[entry.type] ?? '#f5f5f5';
}
formatDateTime(val: any): string {
if (!val) {
return '—';
}
const d = new Date(String(val).replace(' ', 'T'));
return isNaN(d.getTime())
? String(val)
: d.toLocaleString('en-IN', {
day: '2-digit',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
private isoDate(d: Date | null): string | undefined {
if (!d) {
return undefined;
}
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
}
@@ -0,0 +1,33 @@
<!--
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="dr-container">
<div class="dr-header">
<button mat-stroked-button [routerLink]="['../']" class="dr-back">
<i class="fas fa-arrow-left"></i> Deposits
</button>
<h2 class="dr-header__title">DDS Reports</h2>
</div>
@for (group of reportGroups; track group.title) {
<div class="dr-group">
<div class="dr-group__title">{{ group.title }}</div>
<div class="dr-grid">
@for (report of group.reports; track report) {
<a class="dr-card" [routerLink]="['view', report]">
<div class="dr-card__icon">
<i class="fas fa-file-alt"></i>
</div>
<div class="dr-card__title">{{ report }}</div>
<i class="fas fa-chevron-right dr-card__arrow"></i>
</a>
}
</div>
</div>
}
</div>
@@ -0,0 +1,97 @@
/**
* 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/.
*/
$primary: #0f766e;
.dr-container {
max-width: 1080px;
margin: 0 auto;
padding: 24px 16px;
}
.dr-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 24px;
&__title {
margin: 0;
font-size: 22px;
font-weight: 500;
}
}
.dr-back {
color: $primary;
}
.dr-group {
margin-bottom: 24px;
&__title {
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: $primary;
font-weight: 700;
margin-bottom: 10px;
}
}
.dr-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
.dr-card {
display: flex;
align-items: center;
gap: 12px;
background: var(--md-sys-color-surface, #fff);
border: 1px solid rgb(0 0 0 / 8%);
border-radius: 8px;
padding: 12px 14px;
text-decoration: none;
color: inherit;
transition: box-shadow 0.15s ease;
&:hover {
box-shadow: 0 2px 8px rgb(0 0 0 / 10%);
}
&__icon {
color: $primary;
font-size: 16px;
flex: none;
}
&__title {
flex: 1;
font-size: 13px;
font-weight: 500;
}
&__arrow {
color: rgb(0 0 0 / 30%);
font-size: 12px;
}
}
@media (width <= 900px) {
.dr-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (width <= 600px) {
.dr-grid {
grid-template-columns: 1fr;
}
}
@@ -0,0 +1,73 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/** Angular Imports */
import { Component } from '@angular/core';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* DDS Reports hub component.
*
* Lists every DDS report from the legacy Reports menu, grouped the same
* way (As-On-Date, Interest, Daily/Balance/Master, Transaction). Each
* links to the shared report viewer, parameterised by report name.
*
* UI only for now: no report actually queries the backend yet — see
* ReportViewerComponent.
*/
@Component({
selector: 'mifosx-dds-reports',
templateUrl: './dds-reports.component.html',
styleUrls: ['./dds-reports.component.scss'],
imports: [...STANDALONE_SHARED_IMPORTS]
})
export class DdsReportsComponent {
reportGroups = [
{
title: 'As On Date',
reports: [
'Supplementary-DDS (As On)',
'Agent Check List',
'Party Check List-As On',
'Party Balance',
'Agent Wise Balance',
'Agent Wise Monthly Collection',
'Commission Register',
'Collection Summary Agent Wise',
'Close Party Ac Register',
'Agent Partywise Daily Collection',
'Daily Collection Balance',
'Interest Certificate',
'Mature Date Wise Report'
]
},
{
title: 'Interest',
reports: [
'Party Int. Register',
'Commission Register'
]
},
{
title: 'Daily / Balance / Master',
reports: [
'Daily Collection Report',
'Balance Summary',
'Master Data Export'
]
},
{
title: 'Transaction',
reports: ['Transaction Register']
},
{
title: 'Statements',
reports: ['Agent Commission Statement']
}
];
}
@@ -0,0 +1,146 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="rv-container">
<div class="rv-header">
<button mat-stroked-button [routerLink]="['../../']" class="rv-back">
<i class="fas fa-arrow-left"></i> DDS Reports
</button>
<h2 class="rv-header__title">{{ reportName }}</h2>
</div>
<div class="rv-card">
<form [formGroup]="filterForm">
<!-- Only the filters this report's query actually reads are offered. One that changed nothing
would read as a report ignoring what it was asked. -->
@if (!hasNoFilters) {
<div class="rv-grid">
@if (usesDate) {
<mat-form-field appearance="outline" class="rv-field">
<mat-label>{{ dateLabel }} — from</mat-label>
<input matInput required [matDatepicker]="fromPicker" formControlName="fromDate" />
<mat-datepicker-toggle matIconSuffix [for]="fromPicker"></mat-datepicker-toggle>
<mat-datepicker #fromPicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="rv-field">
<mat-label>{{ dateLabel }} — to</mat-label>
<input matInput required [matDatepicker]="toPicker" formControlName="toDate" />
<mat-datepicker-toggle matIconSuffix [for]="toPicker"></mat-datepicker-toggle>
<mat-datepicker #toPicker></mat-datepicker>
</mat-form-field>
}
@if (usesAgent) {
<mat-form-field appearance="outline" class="rv-field">
<mat-label>Agent From</mat-label>
<input matInput type="number" formControlName="agentFrom" />
</mat-form-field>
<mat-form-field appearance="outline" class="rv-field">
<mat-label>Agent To</mat-label>
<input matInput type="number" formControlName="agentTo" />
</mat-form-field>
}
@if (usesParty) {
<mat-form-field appearance="outline" class="rv-field">
<mat-label>Party From</mat-label>
<input matInput type="number" formControlName="partyFrom" />
</mat-form-field>
<mat-form-field appearance="outline" class="rv-field">
<mat-label>Party To</mat-label>
<input matInput type="number" formControlName="partyTo" />
</mat-form-field>
}
</div>
}
<div class="rv-action-bar">
<button mat-raised-button type="button" (click)="exit()">Exit</button>
<!-- A report with nothing to fill in has already run itself; the button then only offers to
fetch the figures again, which is what it is called. -->
<button mat-raised-button color="primary" type="button" [disabled]="filterForm.invalid" (click)="runReport()">
<i class="fas" [class.fa-play]="!hasNoFilters" [class.fa-rotate-right]="hasNoFilters"></i>
{{ hasNoFilters ? 'Refresh' : 'Run Report' }}
</button>
</div>
@if (hasRun) {
<div class="rv-results">
@if (!available) {
<p class="rv-empty">{{ unavailableMessage }}</p>
} @else if (rows.length === 0) {
<p class="rv-empty">No rows matched these filters.</p>
} @else {
<!-- Laid out as the legacy statement printed it: a heading naming the report and the
period, numbered rows, and a total under every money column. -->
<div class="rv-statement">
<div class="rv-statement__head">
<h3 class="rv-statement__title">{{ reportName }}</h3>
@if (usesDate) {
<p class="rv-statement__period">
{{ dateLabel | lowercase }} {{ ranFromDate | dateFormat }} and
{{ ranToDate | dateFormat }}
</p>
} @else {
<p class="rv-statement__period">as on {{ ranOnDate | dateFormat }}</p>
}
<button mat-stroked-button type="button" class="rv-print" (click)="print()">
<i class="fas fa-print"></i> Print
</button>
</div>
<div class="rv-table-scroll">
<table class="rv-table">
<thead>
<tr>
<th class="rv-sr">#</th>
@for (column of columns; track column) {
<th [class.rv-num]="isAmountColumn(column)">{{ column }}</th>
}
</tr>
</thead>
<tbody>
@for (row of rows; track $index) {
<tr>
<td class="rv-sr">{{ $index + 1 }}</td>
@for (column of columns; track column) {
<td [class.rv-num]="isAmountColumn(column)">{{ cell(row, column) }}</td>
}
</tr>
}
</tbody>
@if (hasTotalledColumn) {
<tfoot>
<tr>
<td class="rv-sr"></td>
@for (column of columns; track column; let first = $first) {
<td [class.rv-num]="isAmountColumn(column)">
@if (first) {
Total :
} @else if (isTotalledColumn(column)) {
{{ columnTotal(column) }}
}
</td>
}
</tr>
</tfoot>
}
</table>
</div>
<p class="rv-statement__count">{{ rows.length }} {{ rows.length === 1 ? 'row' : 'rows' }}</p>
</div>
}
</div>
}
</form>
</div>
</div>
@@ -0,0 +1,190 @@
/**
* 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/.
*/
$primary: #0f766e;
.rv-container {
max-width: 1100px;
margin: 0 auto;
padding: 24px 16px;
}
.rv-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 20px;
&__title {
margin: 0;
font-size: 22px;
font-weight: 500;
}
}
.rv-back {
color: $primary;
}
.rv-card {
background: var(--md-sys-color-surface, #fff);
border-radius: 8px;
box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
padding: 20px 24px 24px;
}
.rv-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px 16px;
}
.rv-field {
width: 100%;
}
.rv-action-bar {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid rgb(0 0 0 / 8%);
}
.rv-results {
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid rgb(0 0 0 / 8%);
}
.rv-empty {
color: rgb(0 0 0 / 50%);
font-size: 14px;
margin: 0;
text-align: center;
padding: 24px 0;
}
.rv-table-scroll {
overflow-x: auto;
}
.rv-table {
width: 100%;
border-collapse: collapse;
white-space: nowrap;
th {
text-align: left;
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: rgb(0 0 0 / 55%);
padding: 8px 10px;
border-bottom: 2px solid rgb(0 0 0 / 10%);
}
td {
padding: 8px 10px;
border-bottom: 1px solid rgb(0 0 0 / 6%);
}
/* Figures line up column-wise so a wrong order of magnitude is visible at a glance. */
.rv-num {
text-align: right;
font-variant-numeric: tabular-nums;
}
/* The row number down the left, as the printed statement carried it. */
.rv-sr {
width: 1%;
color: rgb(0 0 0 / 45%);
font-style: italic;
font-variant-numeric: tabular-nums;
}
tfoot td {
border-bottom: none;
border-top: 2px solid rgb(0 0 0 / 20%);
font-weight: 600;
padding-top: 10px;
}
}
/* ---- the statement block: heading, table, footer count ---- */
.rv-action-bar i {
margin-right: 6px;
}
.rv-statement__head {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 4px 12px;
margin-bottom: 14px;
}
.rv-statement__title {
margin: 0;
font-size: 17px;
font-weight: 600;
flex: 0 0 auto;
}
.rv-statement__period {
margin: 0;
font-size: 13px;
color: rgb(0 0 0 / 55%);
}
.rv-print {
margin-left: auto;
i {
margin-right: 6px;
}
}
.rv-statement__count {
margin: 10px 0 0;
font-size: 12px;
color: rgb(0 0 0 / 45%);
text-align: right;
}
/* Printing a statement should give the statement, not the screen around it. */
@media print {
.rv-header,
.rv-grid,
.rv-action-bar,
.rv-print {
display: none !important;
}
.rv-card {
box-shadow: none;
padding: 0;
}
.rv-table-scroll {
overflow-x: visible;
}
.rv-table {
white-space: normal;
font-size: 11px;
}
}
@media (width <= 900px) {
.rv-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@@ -0,0 +1,344 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/** Angular Imports */
import { Component, OnInit, inject } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Custom Services */
import { OrganizationService } from 'app/organization/organization.service';
import { Dates } from 'app/core/utils/dates';
/**
* Generic DDS report viewer.
*
* One shared screen for all 13+ DDS reports — the report name comes from
* the route and is mapped to a backend report key. Every report in the
* legacy system shared the same basic filter shape (date, agent range,
* party range), so this avoids building a bespoke screen per report - the
* backend returns a generic {columns, rows} shape this renders as one
* table regardless of which report it is.
*
* One legacy report (Close Party Ac Register) has no corresponding data in
* the backend yet - Party accounts have no closed/open status at all. Run
* Report still calls through, and the backend's `available: false` +
* message is shown instead of a table.
*/
@Component({
selector: 'mifosx-dds-report-viewer',
templateUrl: './report-viewer.component.html',
styleUrls: ['./report-viewer.component.scss'],
imports: [...STANDALONE_SHARED_IMPORTS]
})
export class ReportViewerComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private formBuilder = inject(UntypedFormBuilder);
private organizationService = inject(OrganizationService);
private dateUtils = inject(Dates);
private static readonly REPORT_KEYS: Record<string, string> = {
'Supplementary-DDS (As On)': 'SUPPLEMENTARY_DDS',
'Agent Check List': 'AGENT_CHECK_LIST',
'Party Check List-As On': 'PARTY_CHECK_LIST',
'Party Balance': 'PARTY_BALANCE',
'Agent Wise Balance': 'AGENT_WISE_BALANCE',
'Agent Wise Monthly Collection': 'AGENT_WISE_MONTHLY_COLLECTION',
'Commission Register': 'COMMISSION_REGISTER',
'Collection Summary Agent Wise': 'COLLECTION_SUMMARY_AGENT_WISE',
'Close Party Ac Register': 'CLOSE_PARTY_AC_REGISTER',
'Agent Partywise Daily Collection': 'AGENT_PARTYWISE_DAILY_COLLECTION',
'Daily Collection Balance': 'DAILY_COLLECTION_REPORT',
'Interest Certificate': 'INTEREST_CERTIFICATE',
'Mature Date Wise Report': 'MATURE_DATE_WISE',
'Party Int. Register': 'PARTY_INTEREST_REGISTER',
'Daily Collection Report': 'DAILY_COLLECTION_REPORT',
'Balance Summary': 'BALANCE_SUMMARY',
'Master Data Export': 'MASTER_DATA_EXPORT',
'Transaction Register': 'TRANSACTION_REGISTER',
'Agent Commission Statement': 'AGENT_COMMISSION_STATEMENT'
};
/**
* Which filters each report actually uses, and what its date range means.
*
* Every report used to be offered the same six filters whether it read them or not — so a date
* range could be typed on the Agent Check List, which has no date in its query at all, and the
* same rows would come back however it was set. A filter that changes nothing is worse than no
* filter: it reads as a report that ignores what it was asked.
*
* Kept in step with the backend by hand; the source of truth is the switch in
* `DdsReportReadPlatformServiceImpl.runReport`.
*/
private static readonly REPORT_FILTERS: Record<string, { date?: string; agent?: boolean; party?: boolean }> = {
AGENT_CHECK_LIST: {},
PARTY_CHECK_LIST: { agent: true, party: true },
PARTY_BALANCE: { agent: true, party: true },
AGENT_WISE_BALANCE: { agent: true },
BALANCE_SUMMARY: { agent: true, party: true },
MASTER_DATA_EXPORT: { agent: true, party: true },
AGENT_WISE_MONTHLY_COLLECTION: { date: 'Collected between', agent: true },
COLLECTION_SUMMARY_AGENT_WISE: { date: 'Collected between', agent: true },
DAILY_COLLECTION_REPORT: { date: 'Collected between', agent: true },
AGENT_PARTYWISE_DAILY_COLLECTION: { date: 'Collected between', agent: true, party: true },
SUPPLEMENTARY_DDS: { date: 'Collected between', agent: true, party: true },
COMMISSION_REGISTER: { date: 'Posted between', agent: true },
AGENT_COMMISSION_STATEMENT: { date: 'Posted between' },
PARTY_INTEREST_REGISTER: { date: 'Posted between', party: true },
INTEREST_CERTIFICATE: { date: 'Interest earned between', agent: true, party: true },
TRANSACTION_REGISTER: { date: 'Transacted between', agent: true, party: true },
CLOSE_PARTY_AC_REGISTER: { date: 'Closed between', agent: true, party: true },
MATURE_DATE_WISE: { date: 'Maturing between', agent: true, party: true }
};
reportName: string = this.route.snapshot.paramMap.get('reportName') || 'Report';
hasRun = false;
available = true;
unavailableMessage = '';
columns: string[] = [];
rows: Array<Record<string, any>> = [];
/** The range the rows on screen were actually fetched for — not whatever the filter says now. */
ranFromDate: Date | null = null;
ranToDate: Date | null = null;
/** When a report carries no date range, the moment it was run is what dates its figures. */
ranOnDate: Date | null = null;
private static readonly MONEY = new Intl.NumberFormat('en-IN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
});
private static readonly DATE = new Intl.DateTimeFormat('en-GB', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
});
/**
* One cell, rendered the way a printed statement shows it.
*
* Dates arrive from the platform as `[yyyy, m, d]` and numbers as raw decimals, so printing them
* as they come gives "2026,9,2" and "10052.6" — unreadable on a statement someone checks figures
* against. Amounts get Indian grouping and two decimals; identifiers that merely look numeric
* (account numbers, codes) are left alone, since grouping them would corrupt them.
*/
cell(row: Record<string, any>, column: string): string {
const value = row[column];
if (value === null || value === undefined || value === '') {
return '—';
}
if (Array.isArray(value) && value.length >= 3) {
return ReportViewerComponent.DATE.format(new Date(value[0], value[1] - 1, value[2]));
}
if (this.isAmountColumn(column) && typeof value === 'number') {
return ReportViewerComponent.MONEY.format(value);
}
return String(value);
}
/**
* Whether a column holds money, and so should be right-aligned with two decimals.
*
* Decided from the column's name rather than its values: a count column and an account-number
* column are both numbers, and neither reads as an amount.
*/
isAmountColumn(column: string): boolean {
const name = column.toLowerCase();
// Ruled out first, because several of these also contain a money word: a rate is a percentage,
// "Total Accounts" is a count that happens to say total, and an account number is an identifier
// that would be corrupted by grouping.
//
// "agents" is matched exactly rather than as a substring: the two count columns are named
// "Total Agents" and "Active Agents", but "Cash With Agents" and "Commission Due To Agents"
// are money, and a substring test swept those two up and printed them ungrouped.
//
// A "type" column names a kind, never a quantity — "Interest Type" holds the word "Interest",
// which is enough for the money test below to claim it and put a meaningless total under it.
if (
name.includes('a/c no') ||
name.includes('code') ||
name.includes('rate') ||
name.includes('%') ||
name.includes('count') ||
name.includes('entries') ||
name.includes('accounts') ||
name === 'total agents' ||
name === 'active agents' ||
name.includes('type') ||
name.includes('pan')
) {
return false;
}
return (
name.includes('amount') ||
name.includes('balance') ||
name.includes('collect') ||
name.includes('commission') ||
name.includes('deduction') ||
name.includes('deposit') ||
name.includes('advance') ||
name.includes('interest') ||
name.includes('tds') ||
name.includes('total') ||
name.includes('diff') ||
name.includes('cash') ||
name.includes('debit') ||
name.includes('credit') ||
name.includes('settled') ||
name.includes('shortfall') ||
name.includes('due')
);
}
/**
* Whether a total under this column would mean anything.
*
* Not every amount is worth adding up. A "Balance After" on a transaction register is the running
* balance *at that moment*, so its column holds several snapshots of the same account at different
* times — summing them produces a figure that corresponds to nothing, and a reader would sooner or
* later try to reconcile it against something.
*
* A plain balance on a listing report is different: there each row is one account's position at
* one moment, and the sum is the real "total held", which is exactly what a branch wants to see.
*/
isTotalledColumn(column: string): boolean {
if (!this.isAmountColumn(column)) {
return false;
}
return !column.toLowerCase().includes('balance after');
}
/** The figure printed under a column, as the legacy statement's "Total :" row does. */
columnTotal(column: string): string {
const sum = this.rows.reduce((running, row) => {
const value = row[column];
return running + (typeof value === 'number' ? value : 0);
}, 0);
return ReportViewerComponent.MONEY.format(sum);
}
/**
* Whether to print a Total row at all.
*
* A single-row report is already its own total - Balance Summary is one line of figures for the
* whole scheme, and repeating it underneath itself only invites the reader to look for a difference
* between the two.
*/
get hasTotalledColumn(): boolean {
return this.rows.length > 1 && this.columns.some((column) => this.isTotalledColumn(column));
}
print(): void {
window.print();
}
filterForm: UntypedFormGroup = this.formBuilder.group({
fromDate: [''],
toDate: [''],
agentFrom: [1],
agentTo: [99999],
partyFrom: [1],
partyTo: [99999999]
});
private get filters(): { date?: string; agent?: boolean; party?: boolean } {
return ReportViewerComponent.REPORT_FILTERS[ReportViewerComponent.REPORT_KEYS[this.reportName]] ?? {};
}
/** True when this report's query actually reads a date range - only then is one asked for. */
get usesDate(): boolean {
return !!this.filters.date;
}
/** What this report's date range means, e.g. "Posted between" rather than a bare "From Date". */
get dateLabel(): string {
return this.filters.date ?? '';
}
get usesAgent(): boolean {
return !!this.filters.agent;
}
get usesParty(): boolean {
return !!this.filters.party;
}
/** True when this report takes no filters at all, so there is nothing for anyone to fill in. */
get hasNoFilters(): boolean {
return !this.usesDate && !this.usesAgent && !this.usesParty;
}
ngOnInit(): void {
// A date is required exactly for the reports that read one, so a report without dates can be run
// straight away instead of demanding two values it will ignore.
if (this.usesDate) {
this.filterForm.get('fromDate')?.setValidators([Validators.required]);
this.filterForm.get('toDate')?.setValidators([Validators.required]);
this.filterForm.get('fromDate')?.updateValueAndValidity();
this.filterForm.get('toDate')?.updateValueAndValidity();
}
// Nothing to narrow it by means nothing to decide, so the report simply opens showing its
// figures. Making someone press Run on an empty form asks a question with only one answer.
if (this.hasNoFilters) {
this.runReport();
}
}
runReport(): void {
const reportKey = ReportViewerComponent.REPORT_KEYS[this.reportName];
if (!reportKey) {
return;
}
const raw = this.filterForm.getRawValue();
// Only what this report reads is sent. The backend defaults the rest to the full range, so a
// report that ignores a filter is never handed a value that looks like it was applied.
const params: Record<string, any> = {};
if (this.usesDate) {
params['fromDate'] = this.dateUtils.getDate(raw.fromDate);
params['toDate'] = this.dateUtils.getDate(raw.toDate);
}
if (this.usesAgent) {
params['agentFrom'] = raw.agentFrom;
params['agentTo'] = raw.agentTo;
}
if (this.usesParty) {
params['partyFrom'] = raw.partyFrom;
params['partyTo'] = raw.partyTo;
}
this.organizationService.runDdsReport(reportKey, params).subscribe((result: any) => {
this.available = result.available;
this.unavailableMessage = result.message || '';
this.columns = result.columns || [];
this.rows = result.rows || [];
// Captured here so the printed heading names the range these rows came from, and does not
// silently change if the filter is edited without re-running.
this.ranFromDate = raw.fromDate;
this.ranToDate = raw.toDate;
this.ranOnDate = new Date();
this.hasRun = true;
});
}
exit(): void {
this.router.navigate(['../../'], { relativeTo: this.route });
}
}
@@ -0,0 +1,49 @@
/**
* 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 { Observable, forkJoin, of } from 'rxjs';
import { catchError, map } from 'rxjs/operators';
import { ClientsService } from 'app/clients/clients.service';
/**
* Finds clients by name whatever case the officer types it in.
*
* The platform's name filter matches anywhere in the name but only in the exact case typed, so
* "sneha" finds nobody while "Sneha" finds Ms. Sneha Iyer. Names are stored capitalised, so the term
* is tried as typed, capitalised word by word, and in upper and lower case, and the results merged.
* Done here rather than by changing the platform's search, which every other screen relies on.
*/
export function searchClientsAnyCase(clientsService: ClientsService, term: string, officeId?: any): Observable<any[]> {
const variants = Array.from(
new Set([
term,
term.toLowerCase().replace(/(^|[\s.])(\p{L})/gu, (_match, lead, letter) => lead + letter.toUpperCase()),
term.toUpperCase(),
term.toLowerCase()
])
);
return forkJoin(
variants.map((variant) =>
clientsService.getFilteredClients('displayName', 'ASC', false, variant, officeId).pipe(
map((response: any) => {
const found = response?.pageItems || response || [];
return Array.isArray(found) ? found : [];
}),
catchError(() => of([]))
)
)
).pipe(
map((lists: any[][]) => {
const byId = new Map<number, any>();
for (const client of lists.flat()) {
byId.set(client.id, client);
}
return Array.from(byId.values()).sort((a, b) => String(a.displayName).localeCompare(String(b.displayName)));
})
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,528 @@
/**
* 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/.
*/
$primary: #0f766e;
.pm-container {
// Wider than the usual form page: the extra width is what keeps this many fields off a
// second screen.
max-width: 1280px;
margin: 0 auto;
padding: 12px 16px 20px;
}
.pm-header {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 10px;
&__title {
margin: 0;
font-size: 18px;
font-weight: 500;
}
}
.pm-back {
color: $primary;
}
.pm-card {
background: var(--md-sys-color-surface, #fff);
border-radius: 8px;
box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
}
.pm-tabs {
::ng-deep .mat-mdc-tab-body-wrapper {
min-height: 300px;
}
}
.pm-tab-content {
padding: 20px 24px;
}
.pm-section {
padding: 16px 0;
border-bottom: 1px solid rgb(0 0 0 / 8%);
&:last-of-type {
border-bottom: none;
}
&__header {
display: flex;
align-items: center;
// The subtitle sits beside the title where there is room and drops below it where there is not.
flex-wrap: wrap;
gap: 2px 10px;
margin-bottom: 16px;
i {
color: $primary;
}
h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
// Held at its natural width: letting it shrink breaks a two-word heading onto three lines.
flex: 0 0 auto;
white-space: nowrap;
}
}
}
.pm-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 2px 18px;
align-items: start;
}
/*
* Label to the left of its field, the way the screen this replaces laid them out. A stacked label
* costs a whole row of height per field, and with 60-odd fields that is the difference between an
* operator seeing the form at once and scrolling through three screens of it.
*/
.pm-f {
display: grid;
grid-template-columns: 104px minmax(0, 1fr);
align-items: start;
column-gap: 7px;
&--wide {
grid-column: span 2;
}
}
.pm-f-lbl {
font-size: 11.5px;
line-height: 1.25;
text-align: right;
color: rgb(0 0 0 / 68%);
padding-top: 7px;
// Required marker, in the same red the legacy screen used.
b {
color: #c1121f;
font-weight: 400;
margin-left: 2px;
}
}
.pm-field {
width: 100%;
}
.pm-checkbox-field {
display: flex;
align-items: center;
height: 56px;
}
.pm-radio-group {
display: flex;
align-items: center;
gap: 16px;
height: 56px;
}
.pm-add-nominee {
color: $primary;
height: fit-content;
align-self: center;
}
/*
* The running total of the shares entered, beside the button that adds another. Reads as ordinary
* text while the split still adds up and turns to the warning colour when it does not, so the clerk
* sees the state of the whole nomination without having to try to save it.
*/
.pm-share-total {
align-self: center;
margin-left: 12px;
font-size: 13px;
color: rgb(0 0 0 / 60%);
}
.pm-share-total-bad {
color: #b3261e;
font-weight: 500;
}
.pm-empty {
color: rgb(0 0 0 / 50%);
font-size: 14px;
margin: 0 0 8px;
}
/*
* One nominee, laid out as fields that wrap rather than columns that must all fit.
*
* A fixed column per field only works while the count is fixed, and it is not - the Share % field
* appears and disappears with the split mode, and a tenth column pushed the remove button onto a
* line of its own. Each field instead states the width it needs to stay readable and the row breaks
* where it runs out of space, so nothing is squeezed and nothing is orphaned.
*/
.pm-nominee-row {
display: flex;
flex-wrap: wrap;
gap: 4px 12px;
align-items: start;
padding: 12px 0;
border-top: 1px dashed rgb(0 0 0 / 10%);
&:first-of-type {
border-top: none;
}
/* Names and addresses need room; a date, Yes/No or a percentage does not. */
.pm-f {
flex: 1 1 170px;
min-width: 0;
}
.pm-f--date {
flex: 0 1 140px;
}
.pm-f--tight {
flex: 0 1 100px;
}
}
.pm-remove-nominee {
color: #b91c1c;
margin-top: 8px;
}
.pm-action-bar {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 16px 24px;
border-top: 1px solid rgb(0 0 0 / 8%);
}
.pm-callout {
display: flex;
align-items: center;
gap: 10px;
margin: 0 24px;
padding: 12px 16px;
background: rgb(185 28 28 / 8%);
border: 1px solid rgb(185 28 28 / 25%);
border-radius: 6px;
color: #b91c1c;
font-size: 0.9rem;
}
@media (width <= 1000px) {
.pm-grid {
grid-template-columns: repeat(2, 1fr);
}
}
/* Resolved name shown under a Client / Agent / Guardian id, as the legacy screen did in red. */
.pm-hint--found {
color: #15803d;
font-weight: 500;
}
.pm-hint--missing {
color: #b91c1c;
font-weight: 500;
}
/* Explains why a block of fields is absent, so the screen does not just look unfinished. */
.pm-note {
margin: 4px 0 0;
font-size: 0.8rem;
color: rgb(0 0 0 / 55%);
font-style: italic;
}
.pm-closed-badge {
margin-left: 12px;
padding: 3px 11px;
border-radius: 11px;
font-size: 0.72rem;
font-weight: 500;
background: rgb(0 0 0 / 7%);
color: rgb(0 0 0 / 60%);
}
/* Says whose details a block of fields is for, so the screen explains itself. */
.pm-section__sub {
flex: 1 1 auto;
min-width: 0;
font-size: 0.78rem;
font-weight: 400;
line-height: 1.45;
color: rgb(0 0 0 / 55%);
}
.pm-search-ic {
color: rgb(0 0 0 / 38%);
font-size: 0.85rem;
}
/*
* Money reads right to left, so a column of figures only lines up when it is right-aligned; the
* tabular figures keep every digit the same width so the decimal points stack.
*/
input.pm-money {
text-align: right;
font-variant-numeric: tabular-nums;
}
/* One row of a Client / Sub Type search list: the id, the name, and where it belongs. */
.pm-opt {
display: flex;
align-items: baseline;
gap: 10px;
overflow: hidden;
}
/* The "there are more" footer row. Quieter than a real option, since it is not one. */
.pm-opt-more {
font-size: 0.75rem;
font-style: italic;
opacity: 0.75;
}
.pm-opt-id {
flex: 0 0 auto;
min-width: 52px;
font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', monospace;
font-size: 0.78rem;
color: var(--pl-accent, #b45309);
}
/*
The name is the reason the row exists, so it gets whatever width is going and is the last thing
to be cut. Before this the branch was fixed-width and the name carried the ellipsis, so in the
narrow panel every customer read as a single letter.
*/
.pm-opt-main {
flex: 1 1 auto;
min-width: 8rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pm-opt-sub {
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.75rem;
color: rgb(0 0 0 / 50%);
}
/* stylelint-disable no-duplicate-selectors -- the dense layout below deliberately re-targets the base selectors above to override them. */
/*
* Dense data-entry layout.
*
* The screen this replaces fitted the whole form on one page - an officer opening accounts all day
* reads it at a glance and never scrolls. Material's default field height and spacing turn the same
* 56 fields into three screens' worth, so the whole card is tightened: shorter fields, smaller type,
* and just enough room under each field for the hint that names the client or agent it resolved.
*/
/*
* With the label moved outside, the box only has to hold the value - so it loses the height Material
* reserves for a floating label, and the form fits a screen instead of three.
*/
.pm-card {
::ng-deep {
.mat-mdc-form-field {
font-size: 12.5px;
}
.mat-mdc-form-field-infix {
min-height: 26px;
padding-top: 3px !important;
padding-bottom: 3px !important;
border-top: 0;
}
.mat-mdc-text-field-wrapper {
padding-left: 8px;
padding-right: 8px;
}
.mat-mdc-input-element,
.mat-mdc-select-value-text {
font-size: 12.5px;
}
// Room for one line of hint - the resolved client/agent name that confirms the right record.
.mat-mdc-form-field-subscript-wrapper {
height: 14px;
min-height: 14px;
}
.mat-mdc-form-field-hint {
font-size: 10.5px;
line-height: 14px;
}
// Suffix icons and the datepicker toggle default to a 48px touch target, which reopens all the
// height just saved.
.mat-mdc-icon-button.mat-mdc-button-base {
width: 26px;
height: 26px;
padding: 3px;
}
.mat-mdc-form-field-icon-suffix {
padding-left: 2px;
.mat-datepicker-toggle svg {
width: 15px;
height: 15px;
}
}
.mat-mdc-tab-body-wrapper {
min-height: 0;
}
}
}
.pm-tab-content {
padding: 8px 16px;
}
/*
* Blocks drawn the way the screen this replaces drew them: a hairline between groups of fields,
* with the group's name as a quiet caption above it rather than a heading bar of its own. An
* operator who has filled this form a hundred times navigates by field position; one filling their
* first still gets told which block is which.
*/
.pm-section {
padding: 8px 0 2px;
border-bottom: 1px solid rgb(0 0 0 / 11%);
&__header {
margin-bottom: 6px;
gap: 0 10px;
// Decoration the legacy screen did without, and the row is tighter for losing it.
i {
display: none;
}
h3 {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.07em;
text-transform: uppercase;
color: $primary;
}
}
}
.pm-section__sub {
font-size: 0.72rem;
color: rgb(0 0 0 / 45%);
}
.pm-grid {
gap: 0 12px;
}
.pm-checkbox-field,
.pm-radio-group {
height: 28px;
::ng-deep label {
font-size: 12.5px;
}
}
// The nominee grid is its own row of fields, so its labels sit above rather than beside - there is
// no room for a label column eight fields across.
.pm-nominee-row {
gap: 0 10px;
padding: 3px 0;
.pm-f {
grid-template-columns: minmax(0, 1fr);
row-gap: 1px;
}
.pm-f-lbl {
text-align: left;
padding-top: 0;
font-size: 10.5px;
}
}
.pm-note {
margin: 0;
font-size: 0.74rem;
}
.pm-action-bar {
padding: 10px 18px;
}
/*
The customer's real deposit account. Not a form field - nothing here is typed, it is what the
bank's books say - so it reads as a fact rather than sitting in an input box pretending to be one.
*/
.pm-account {
display: flex;
flex-direction: column;
gap: 2px;
padding: 3px 0;
min-height: 26px;
justify-content: center;
}
.pm-account-no {
font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', monospace;
font-size: 0.85rem;
letter-spacing: 0.02em;
}
.pm-account-meta {
font-size: 0.75rem;
color: rgb(0 0 0 / 55%);
}
.pm-account-live {
color: #15803d;
font-weight: 500;
}
/* Said plainly: this party has no account behind it, which is a real gap, not a blank field. */
.pm-account-missing {
font-size: 0.75rem;
font-style: italic;
color: #9b2c2c;
}
/*
Something the branch has to do, rather than something that has gone wrong - so it reads as an
instruction in the warning colour, not an error in red like the closed-account notice.
*/
.pm-callout--action {
background: rgb(180 83 9 / 8%);
border-color: rgb(180 83 9 / 25%);
color: #b45309;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,166 @@
<!--
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="pl-root">
<div class="pl-header">
<div class="pl-header-left">
<div class="pl-header-ic"><fa-icon icon="clipboard-check"></fa-icon></div>
<div class="pl-header-text">
<h2>Accounts Due for Closure</h2>
<p>Matured DDS accounts still open — pay the customer, then close the account.</p>
</div>
</div>
<div class="pl-header-right">
<button class="pl-create-btn" type="button" [routerLink]="['/accounts/dds']">
<fa-icon icon="arrow-left"></fa-icon>
<span>Party Master</span>
</button>
</div>
</div>
@if (!loading && parties.length > 0) {
<div class="pl-kpis">
<div class="pl-kpi pl-kpi--purple" matTooltip="Matured accounts still open, at either step">
<div class="pl-kpi-ic"><fa-icon icon="clipboard-check"></fa-icon></div>
<div>
<div class="pl-kpi-n">{{ parties.length | number }}</div>
<div class="pl-kpi-l">Outstanding</div>
</div>
</div>
<div
class="pl-kpi pl-kpi--teal"
matTooltip="Money the bank is holding on matured accounts it has not yet paid out"
>
<div class="pl-kpi-ic"><fa-icon icon="wallet"></fa-icon></div>
<div>
<div class="pl-kpi-n">{{ totalPayable | number: '1.2-2' }}</div>
<div class="pl-kpi-l">Still to Pay</div>
</div>
</div>
<!-- Already paid, waiting only on the close. Separated because it is the quicker half of the
list and needs no customer to be reached. -->
@if (awaitingCloseCount > 0) {
<div class="pl-kpi pl-kpi--blue" matTooltip="Paid out in full — these only need closing">
<div class="pl-kpi-ic"><fa-icon icon="check"></fa-icon></div>
<div>
<div class="pl-kpi-n">{{ awaitingCloseCount | number }}</div>
<div class="pl-kpi-l">Ready to Close</div>
</div>
</div>
}
</div>
}
<div class="pl-card">
@if (loading) {
<div class="pl-empty"><p>Loading…</p></div>
} @else if (parties.length === 0) {
<!-- An empty list here is good news, so it says so rather than looking like a failed load. -->
<div class="pl-empty">
<div class="pl-empty-ic dfc-empty-ic"><fa-icon icon="check"></fa-icon></div>
<h3>Nothing to close</h3>
<p>No DDS account has matured and been left open. Every matured account has been paid out and closed.</p>
</div>
} @else {
<div class="pl-table-wrap">
<table class="pl-table dfc-table">
<thead>
<tr>
<th>Party</th>
<th>Customer</th>
<th>Collected By</th>
<th>Matured On</th>
<th class="num">Payable</th>
<th class="num">Action</th>
</tr>
</thead>
<tbody>
@for (party of parties; track party.id) {
<tr class="pl-row">
<td>
<div class="pl-product-meta">
<div class="pl-product-name">{{ party.partyCode }}</div>
<div class="pl-product-sub dfc-account">{{ party.accountNo || party.savingsAccountNo || '—' }}</div>
</div>
</td>
<td>{{ party.partyName }}</td>
<td>
<div class="pl-product-meta">
<div class="pl-product-name">{{ party.agentName || '—' }}</div>
<div class="pl-product-sub">{{ party.agentCode }}</div>
</div>
</td>
<td>
<div class="pl-product-meta">
<div class="pl-product-name">{{ party.maturityDate | dateFormat }}</div>
<!-- How long the bank has been holding it. A maturity from months back is a
different problem from yesterday's, and the date alone does not say so. -->
<div class="pl-product-sub" [class.dfc-stale]="daysOverdue(party) > 30">
{{ daysOverdue(party) }} {{ daysOverdue(party) === 1 ? 'day' : 'days' }} ago
</div>
</div>
</td>
<td class="num">
@if (awaitingPayout(party)) {
<strong>{{ party.currentBalance | number: '1.2-2' }}</strong>
} @else {
<!-- Paid. Shown as words rather than 0.00 so a settled account is not mistaken for
one that was never collected on. -->
<span class="dfc-paid">Paid out</span>
}
</td>
<td class="num">
<!--
Two steps, and an account stays on this list through both: pay the customer, then
close. The officer sees the balance fall to nil before closing, so nothing is
closed on a payment nobody checked.
-->
@if (heldUnderLien(party)) {
<!--
Pledged against a loan. Offering Pay Out here would only be refused by the
server, so the row says why instead — the deposit is the bank's security until
the borrowing against it is cleared.
-->
<span
class="dfc-lien"
[matTooltip]="
(party.amountHeldUnderLien | number: '1.2-2') +
' is pledged against a loan. It is paid out once the loan is repaid and the lien released.'
"
>
<fa-icon icon="lock"></fa-icon>
<span>Held under lien</span>
</span>
} @else if (awaitingPayout(party)) {
<button
type="button"
class="pl-action-btn pl-action-btn--edit"
(click)="payOut(party)"
matTooltip="Pay this money out to the customer"
>
<fa-icon icon="wallet"></fa-icon><span>Pay Out</span>
</button>
} @else {
<button
type="button"
class="pl-action-btn dfc-close-btn"
(click)="closeAccount(party)"
matTooltip="The money has gone out — close this account"
>
<fa-icon icon="check"></fa-icon><span>Close Account</span>
</button>
}
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
</div>
@@ -0,0 +1,97 @@
/**
* 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/.
*/
@use 'assets/styles/product-list' as pl;
@include pl.product-list-styles;
/* An empty list here means the work is done, so the mark is the reassuring colour, not the grey one. */
.dfc-empty-ic {
color: #15803d;
}
.dfc-account {
font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', monospace;
font-size: 0.75rem;
}
/*
* A maturity the bank has been sitting on for over a month. Called out because the date alone reads
* the same whether it passed yesterday or in the spring, and the two are not the same problem.
*/
.dfc-stale {
color: #b45309;
font-weight: 500;
}
/*
* An account whose money has already gone out. Words rather than 0.00, so a settled account is not
* read as one that was never collected on - both would show the same figure.
*/
.dfc-paid {
color: #15803d;
font-weight: 600;
font-size: 0.78rem;
}
/*
* The close action. Green rather than the list's usual blue because it is the step that finishes the
* account, and it only ever appears on a row already paid.
*/
.dfc-close-btn {
color: #15803d;
border-color: rgb(21 128 61 / 30%);
background: rgb(21 128 61 / 6%);
&:hover {
background: rgb(21 128 61 / 12%);
border-color: rgb(21 128 61 / 45%);
}
}
/*
* A deposit pledged against a loan. Not a button, because there is nothing for the clerk to do here
* until the loan is cleared — showing one would only lead to a refusal.
*/
.dfc-lien {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border: 1px solid rgb(90 74 156 / 30%);
border-radius: 4px;
background: rgb(90 74 156 / 8%);
color: #5a4a9c;
font-size: 0.78rem;
font-weight: 600;
cursor: help;
}
/* Plain table rather than mat-table: this list is short by design and needs no sort or paging. */
.dfc-table {
th,
td {
padding: 10px 14px;
text-align: left;
border-bottom: 1px solid rgb(0 0 0 / 6%);
}
th {
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: rgb(0 0 0 / 55%);
background: rgb(0 0 0 / 2%);
}
.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
}
@@ -0,0 +1,214 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/** Angular Imports */
import { Component, OnInit, inject } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Custom Services */
import { OrganizationService } from 'app/organization/organization.service';
import { AlertService } from 'app/core/alert/alert.service';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
/** Custom Components */
import { PayoutDialogComponent } from '../payout-dialog/payout-dialog.component';
import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/confirmation-dialog.component';
/**
* The DDS accounts whose term has run out and which are still open - the branch's list of closures
* waiting to be done.
*
* A list of work, not a report. Nothing closes itself: a matured account is money the bank now owes
* the customer, and where it goes has to be agreed with them, so each one waits here until an
* officer deals with it. Before this existed the maturity date simply passed and nobody was told -
* the bank held the money and the customer had to think to ask for it.
*
* Two steps, not one, and an account stays on this list through both:
*
* balance owing pay it out - to another account of theirs, or into matured-deposits payable
* balance nil paid; close it
*
* Splitting them is what a branch actually does. The officer pays, sees the balance fall to nil and
* the money land where it was meant to, and only then closes. One click doing both would close
* accounts whose payment nobody had checked, and a closed account is far harder to put right.
*
* Oldest maturity first, because that is the one the bank has been holding longest.
*/
@Component({
selector: 'mifosx-due-for-closure',
templateUrl: './due-for-closure.component.html',
styleUrls: ['./due-for-closure.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FaIconComponent
]
})
export class DueForClosureComponent implements OnInit {
private organizationService = inject(OrganizationService);
private dialog = inject(MatDialog);
private alertService = inject(AlertService);
private settingsService = inject(SettingsService);
private dateUtils = inject(Dates);
parties: any[] = [];
loading = true;
ngOnInit(): void {
this.load();
}
private load(): void {
this.loading = true;
this.organizationService.getPartiesDueForClosure().subscribe({
next: (parties: any) => {
this.parties = Array.isArray(parties) ? parties : parties?.pageItems || [];
this.loading = false;
},
error: () => {
this.parties = [];
this.loading = false;
}
});
}
/** What the bank is holding in total across every matured account still open. */
get totalPayable(): number {
return this.parties.reduce((sum, party) => sum + Number(party.currentBalance || 0), 0);
}
/**
* Whether this one is still waiting on its payment. Anything left on the account is the customer's
* money, so it is paid before the account can be closed.
*/
awaitingPayout(party: any): boolean {
return Number(party.currentBalance || 0) > 0;
}
/**
* Whether this deposit is standing as security for a loan.
*
* The term has ended and the money is owed, but it cannot leave while it is pledged - so the row
* says so rather than offering a Pay Out the server would refuse. A customer who needed cash before
* their term ended borrowed against the deposit instead of breaking it; the deposit is paid when
* that loan is cleared.
*/
heldUnderLien(party: any): boolean {
return Number(party.amountHeldUnderLien || 0) > 0;
}
/** How many are already paid and now only need closing - the quicker half of the list. */
get awaitingCloseCount(): number {
return this.parties.filter((party) => !this.awaitingPayout(party)).length;
}
/**
* How long the bank has been sitting on this one. Shown because a maturity from three months ago
* is a different problem from one from yesterday, and the list alone would not say so.
*/
daysOverdue(party: any): number {
const matured = this.toDate(party.maturityDate);
if (!matured) {
return 0;
}
return Math.max(0, Math.floor((Date.now() - matured.getTime()) / 86400000));
}
/** The API sends dates as [year, month, day]; month is 1-based there and 0-based in Date. */
private toDate(value: any): Date | null {
if (Array.isArray(value) && value.length >= 3) {
return new Date(value[0], value[1] - 1, value[2]);
}
return value ? new Date(value) : null;
}
payOut(party: any): void {
const dialogRef = this.dialog.open(PayoutDialogComponent, {
data: party,
width: '520px',
autoFocus: false
});
dialogRef.afterClosed().subscribe((payoutCommand: any) => {
if (!payoutCommand) {
return;
}
this.organizationService.payOutParty(party.id, payoutCommand).subscribe({
next: (result: any) => {
if (awaitingApproval(result)) {
this.alertService.alert({
type: 'Sent for Approval',
message: `Payout of ${party.partyCode} is waiting for a manager's approval. Nothing has been paid yet.`
});
this.load();
return;
}
this.alertService.alert({
type: 'Party Paid Out',
message: payoutCommand.toSavingsAccountNo
? `${party.partyCode} paid into ${payoutCommand.toSavingsAccountNo} and the account is closed.`
: `${party.partyCode} parked in Sundry in the customer's name and the account is closed.`
});
this.load();
}
// A refusal is shown by the platform's own error handler, which names the reason - repeating
// it here would say the same thing twice.
});
});
}
/**
* Closes an account whose money has already gone out. The server refuses one still carrying a
* balance, so this cannot close an account out from under an unpaid customer.
*
* Asked for first, because closing is the one step here with no way back.
*/
closeAccount(party: any): void {
const confirmation = this.dialog.open(ConfirmationDialogComponent, {
data: {
heading: `Close ${party.partyCode}?`,
dialogContext: `${party.partyName}'s account has been paid out in full and can be closed. It cannot be reopened.`,
type: 'warn'
}
});
confirmation.afterClosed().subscribe((response: any) => {
if (!response?.confirm) {
return;
}
const dateFormat = this.settingsService.dateFormat;
const locale = this.settingsService.language.code;
this.organizationService
.closeParty(party.id, {
closeDate: this.dateUtils.formatDate(this.settingsService.businessDate || new Date(), dateFormat),
dateFormat,
locale
})
.subscribe({
next: (result: any) => {
this.alertService.alert(
awaitingApproval(result)
? {
type: 'Sent for Approval',
message: `Closing ${party.partyCode} is waiting for a manager's approval. It stays open until then.`
}
: { type: 'Party Closed', message: `${party.partyCode} is closed.` }
);
this.load();
}
});
});
}
}
/** Under maker-checker the command is only recorded - it has a command id but no resource yet. */
function awaitingApproval(result: any): boolean {
return !!result?.commandId && result?.resourceId == null;
}
@@ -0,0 +1,79 @@
<!--
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/.
-->
<h2 mat-dialog-title class="nd-title">
Nominees
<span class="nd-party">{{ data.partyCode }} · {{ data.partyName }}</span>
</h2>
<mat-dialog-content class="nd-body">
@if (nominees.length === 0) {
<p class="nd-empty">Nobody is nominated on this account.</p>
} @else {
<p class="nd-mode">
@if (bySequence) {
Paid in the order listed — the first named who can be found receives the deposit.
} @else {
Split by share of the deposit.
}
</p>
<table class="nd-table">
<thead>
<tr>
<th>
@if (bySequence) {
Order
} @else {
#
}
</th>
<th>Name</th>
<th>Relation</th>
<th>Nominated</th>
@if (!bySequence) {
<th class="nd-num">Share</th>
}
</tr>
</thead>
<tbody>
@for (nominee of nominees; track nominee.id; let i = $index) {
<tr>
<!-- The stated order where there is one; otherwise just the position in the list. -->
<td class="nd-seq">{{ nominee.sequenceNo ?? i + 1 }}</td>
<td>
{{ nominee.name }}
@if (nominee.minor) {
<span class="nd-minor">minor · guardian {{ nominee.guardianName || '—' }}</span>
}
</td>
<td>{{ nominee.relation }}</td>
<td>{{ nominee.nominationDate | dateFormat }}</td>
@if (!bySequence) {
<td class="nd-num">
@if (nominee.sharePercent !== null && nominee.sharePercent !== undefined) {
{{ nominee.sharePercent }}%
} @else {
—
}
</td>
}
</tr>
}
</tbody>
</table>
@if (shareWarning) {
<p class="nd-warn">{{ shareWarning }}</p>
}
}
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-raised-button color="primary" mat-dialog-close>Close</button>
</mat-dialog-actions>
@@ -0,0 +1,84 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
.nd-title {
display: flex;
flex-direction: column;
gap: 2px;
margin-bottom: 0;
}
.nd-party {
font-size: 13px;
font-weight: 400;
color: rgb(0 0 0 / 55%);
}
.nd-body {
min-width: 420px;
padding-top: 8px;
}
.nd-mode {
margin: 0 0 12px;
font-size: 13px;
color: rgb(0 0 0 / 60%);
}
.nd-empty {
margin: 8px 0;
color: rgb(0 0 0 / 55%);
}
.nd-table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
th {
text-align: left;
font-weight: 500;
font-size: 12px;
letter-spacing: 0.04em;
text-transform: uppercase;
color: rgb(0 0 0 / 50%);
padding: 0 8px 6px 0;
border-bottom: 1px solid rgb(0 0 0 / 10%);
}
td {
padding: 8px 8px 8px 0;
border-bottom: 1px solid rgb(0 0 0 / 6%);
vertical-align: top;
}
}
/* Shares line up to be read down the column, not across the row. */
.nd-num {
text-align: right;
font-variant-numeric: tabular-nums;
padding-right: 0;
}
.nd-seq {
width: 32px;
color: rgb(0 0 0 / 45%);
font-variant-numeric: tabular-nums;
}
.nd-minor {
display: block;
font-size: 12px;
color: rgb(0 0 0 / 50%);
}
.nd-warn {
margin: 12px 0 0;
font-size: 13px;
color: #b3261e;
}
@@ -0,0 +1,82 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/** Angular Imports */
import { Component, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
/** Custom Pipes */
import { DateFormatPipe } from 'app/pipes/date-format.pipe';
/**
* Who this account is nominated to, read without opening it for editing.
*
* A clerk asked "who is on this account" has one question, not an intention to change anything, and
* the edit form answers it only after a load, a tab and a scroll - with every field live the whole
* time. This states it and closes.
*
* What is shown follows the split the account was nominated under: a share per nominee under
* Percentage, and the order they are paid in under Sequence, because that ordering is the whole of
* what Sequence records.
*/
@Component({
selector: 'mifosx-nominee-list-dialog',
standalone: true,
imports: [
CommonModule,
MatDialogModule,
MatButtonModule,
DateFormatPipe
],
templateUrl: './nominee-list-dialog.component.html',
styleUrls: ['./nominee-list-dialog.component.scss']
})
export class NomineeListDialogComponent {
/** { partyCode, partyName, splitMode, nominees } */
data = inject<any>(MAT_DIALOG_DATA);
get bySequence(): boolean {
return this.data?.splitMode === 'Sequence';
}
get nominees(): any[] {
return this.data?.nominees || [];
}
/**
* What the shares come to. Shown only when it is not the whole deposit - a nomination recorded
* before shares were captured reads as complete otherwise, and the remainder has nobody named
* to receive it.
*/
get shareTotal(): number | null {
if (this.bySequence) {
return null;
}
const stated = this.nominees.filter((n) => n?.sharePercent !== null && n?.sharePercent !== undefined);
if (stated.length === 0) {
return null;
}
return stated.reduce((sum, n) => sum + Number(n.sharePercent || 0), 0);
}
get shareWarning(): string | null {
if (this.bySequence) {
// Under Sequence the order is the whole of the nomination, so an order nobody stated is the
// same gap a missing share is under Percentage.
const stated = this.nominees.filter((n) => n?.sequenceNo !== null && n?.sequenceNo !== undefined);
return stated.length === this.nominees.length ? null : 'No order was recorded against these nominees.';
}
const total = this.shareTotal;
if (total === null) {
return 'No shares were recorded against these nominees.';
}
return total === 100 ? null : `The shares come to ${total}%, not 100%.`;
}
}
@@ -0,0 +1,240 @@
<!--
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="pl-root">
<div class="pl-header">
<div class="pl-header-left">
<div class="pl-header-ic"><fa-icon icon="users"></fa-icon></div>
<div class="pl-header-text">
<h2>Party Master</h2>
<p>The DDS accounts on file — each one a customer collected from daily.</p>
</div>
</div>
<div class="pl-header-right">
<div class="pl-count-chip" matTooltip="Total parties">
<fa-icon icon="users"></fa-icon>
<strong>{{ totalCount }}</strong>
<span>{{ totalCount === 1 ? 'party' : 'parties' }}</span>
</div>
<button class="pl-create-btn" type="button" [routerLink]="['create']">
<fa-icon icon="plus"></fa-icon>
<span>Create Party</span>
</button>
</div>
</div>
<!--
Matured accounts still open. Money the bank owes a customer that nobody has been told about, so
it is called out on the screen an officer already opens rather than left on a report they would
have to think to run.
-->
@if (dueForClosureCount > 0) {
<a class="pm-due-banner" [routerLink]="['dueforclosure']">
<fa-icon icon="clipboard-check" class="pm-due-banner__ic"></fa-icon>
<span class="pm-due-banner__text">
<strong>{{ dueForClosureCount }}</strong>
{{ dueForClosureCount === 1 ? 'account has' : 'accounts have' }} matured and
{{ dueForClosureCount === 1 ? 'is' : 'are' }} still open —
<!-- Nil once every one of them has been paid: what is left then is the closing, not the money. -->
@if (dueForClosurePayable > 0) {
<strong>{{ dueForClosurePayable | number: '1.2-2' }}</strong> still owed to customers.
} @else {
paid in full, waiting to be closed.
}
</span>
<span class="pm-due-banner__cta"
>Settle {{ dueForClosureCount === 1 ? 'it' : 'them' }} <fa-icon icon="arrow-right"></fa-icon
></span>
</a>
}
<div class="pl-kpis">
<div class="pl-kpi pl-kpi--blue">
<div class="pl-kpi-ic"><fa-icon icon="users"></fa-icon></div>
<div>
<div class="pl-kpi-n">{{ activeCount | number }}</div>
<div class="pl-kpi-l">Open Accounts</div>
</div>
</div>
<div
class="pl-kpi pl-kpi--purple"
matTooltip="What the agents should collect between them on a full collection day"
>
<div class="pl-kpi-ic"><fa-icon icon="calendar-day"></fa-icon></div>
<div>
<div class="pl-kpi-n">{{ totalDailyDue | number: '1.2-2' }}</div>
<div class="pl-kpi-l">Daily Collection Due</div>
</div>
</div>
<div class="pl-kpi pl-kpi--teal" matTooltip="Every open account's running balance added up">
<div class="pl-kpi-ic"><fa-icon icon="wallet"></fa-icon></div>
<div>
<div class="pl-kpi-n">{{ totalCurrentBalance | number: '1.2-2' }}</div>
<div class="pl-kpi-l">Total Deposits Held</div>
</div>
</div>
</div>
<div class="pl-card">
<div class="pl-toolbar">
<div class="pl-search">
<fa-icon icon="search" class="pl-search-ic"></fa-icon>
<input
type="text"
(input)="applyFilter($any($event.target).value)"
placeholder="Search by code, name or agent…"
/>
</div>
</div>
@if (totalCount === 0) {
<div class="pl-empty">
<div class="pl-empty-ic"><fa-icon icon="users"></fa-icon></div>
<h3>No parties yet</h3>
<p>Open one so an agent can start collecting daily deposits against it.</p>
<button class="pl-create-btn" type="button" [routerLink]="['create']">
<fa-icon icon="plus"></fa-icon>
<span>Create Party</span>
</button>
</div>
} @else {
<div class="pl-table-wrap">
<table mat-table [dataSource]="dataSource" matSort class="pl-table">
<ng-container matColumnDef="partyCode">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Party Code</th>
<td mat-cell *matCellDef="let p">
<div class="pl-product-cell">
<div class="pl-product-icon"><fa-icon icon="user"></fa-icon></div>
<div class="pl-product-meta">
<div class="pl-product-name">{{ p.partyCode }}</div>
<div class="pl-product-sub">{{ p.city || '—' }}</div>
</div>
</div>
</td>
</ng-container>
<ng-container matColumnDef="partyName">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Party Name</th>
<td mat-cell *matCellDef="let p">{{ p.partyName }}</td>
</ng-container>
<ng-container matColumnDef="subType">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Sub Type</th>
<td mat-cell *matCellDef="let p">
<span class="pl-tag" [matTooltip]="p.subType">{{ p.subTypeDescription || p.subType }}</span>
</td>
</ng-container>
<ng-container matColumnDef="agentName">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Collected By</th>
<td mat-cell *matCellDef="let p">
<div class="pl-product-meta">
<div class="pl-product-name">{{ p.agentName || '—' }}</div>
<div class="pl-product-sub">{{ p.agentCode }}</div>
</div>
</td>
</ng-container>
<!--
The customer's real deposit account. A party without one is money the bank's books do not
know about, so it is called out rather than shown as a blank cell.
-->
<ng-container matColumnDef="savingsAccountNo">
<th mat-header-cell *matHeaderCellDef>DDS Account</th>
<td mat-cell *matCellDef="let p">
@if (p.accountNo) {
<div class="pl-product-meta">
<div class="pm-list-account">{{ p.accountNo }}</div>
<div class="pl-product-sub">{{ p.subTypeDescription || p.subType }}</div>
</div>
} @else if (p.savingsAccountNo) {
<div class="pl-product-meta">
<div class="pm-list-account">{{ p.savingsAccountNo }}</div>
<div class="pl-product-sub">{{ p.savingsAccountStatus }}</div>
</div>
} @else {
<span class="pm-list-no-account">Not opened</span>
}
</td>
</ng-container>
<ng-container matColumnDef="dailyDepositAmount">
<th mat-header-cell *matHeaderCellDef mat-sort-header class="num">Daily Deposit</th>
<td mat-cell *matCellDef="let p" class="num">{{ p.dailyDepositAmount | number: '1.2-2' }}</td>
</ng-container>
<ng-container matColumnDef="currentBalance">
<th mat-header-cell *matHeaderCellDef mat-sort-header class="num">Current Balance</th>
<td mat-cell *matCellDef="let p" class="num">
<strong>{{ p.currentBalance | number: '1.2-2' }}</strong>
</td>
</ng-container>
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef>Status</th>
<td mat-cell *matCellDef="let p">
<span class="pm-pill" [class.pm-pill--off]="p.status === 'CLOSED'">
{{ p.status === 'CLOSED' ? 'Closed' : 'Active' }}
</span>
</td>
</ng-container>
<ng-container matColumnDef="nominees">
<th mat-header-cell *matHeaderCellDef>Nominees</th>
<td mat-cell *matCellDef="let p">
@if (nomineeCount(p) > 0) {
<button
type="button"
class="pl-nominee-btn"
(click)="showNominees(p)"
matTooltip="See who this account is nominated to"
>
{{ nomineeCount(p) }} nominated
</button>
} @else {
<span class="pl-muted">None</span>
}
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef class="num">Actions</th>
<td mat-cell *matCellDef="let p" class="num">
<button
type="button"
class="pl-action-btn pl-action-btn--edit"
[routerLink]="[p.id, 'edit']"
[matTooltip]="p.status === 'CLOSED' ? 'View this closed party' : 'Edit this party'"
>
<fa-icon [icon]="p.status === 'CLOSED' ? 'eye' : 'edit'"></fa-icon>
<span>{{ p.status === 'CLOSED' ? 'View' : 'Edit' }}</span>
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr
mat-row
*matRowDef="let row; columns: displayedColumns"
class="pl-row"
[class.pm-row--closed]="row.status === 'CLOSED'"
></tr>
</table>
@if (filteredCount === 0 && totalCount > 0) {
<div class="pl-no-match">
<fa-icon icon="search"></fa-icon>
<span>No parties match your filter.</span>
</div>
}
<mat-paginator [pageSizeOptions]="[10, 25, 50, 100]" [pageSize]="10" showFirstLastButtons></mat-paginator>
</div>
}
</div>
</div>
@@ -0,0 +1,133 @@
/**
* 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/.
*/
@use 'assets/styles/product-list' as pl;
@include pl.product-list-styles;
/* Party-specific amber accent */
:host {
--pl-accent: #b45309;
--pl-accent-l: #fef3c7;
}
/* A closed party stays listed - its collections and interest are still on the books. */
.pm-row--closed td:not(.num) {
opacity: 0.55;
}
.pm-pill {
display: inline-block;
padding: 3px 11px;
border-radius: 11px;
font-size: 0.72rem;
font-weight: 500;
line-height: 1.7;
background: #dcfce7;
color: #15803d;
white-space: nowrap;
}
.pm-pill--off {
background: rgb(0 0 0 / 7%);
color: rgb(0 0 0 / 52%);
}
/* The customer's real deposit account number, in the list. */
.pm-list-account {
font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', monospace;
font-size: 0.8rem;
}
/*
A party with no account behind it. Called out rather than left blank: the balance beside it is
money the bank's own books have no record of, which is the thing worth noticing here.
*/
.pm-list-no-account {
font-size: 0.75rem;
font-style: italic;
color: #9b2c2c;
}
/*
Matured accounts still open. Deliberately loud: it is money the bank owes a customer who has not
been told, and every day it sits there is a day the bank is holding somebody else's money. Styled
as a link to the work rather than a notice about it - reading it should be the same click as
starting on it.
*/
.pm-due-banner {
display: flex;
align-items: center;
gap: 12px;
margin: 0 0 14px;
padding: 12px 16px;
background: rgb(180 83 9 / 8%);
border: 1px solid rgb(180 83 9 / 28%);
border-radius: 8px;
color: #92400e;
text-decoration: none;
transition: background 0.15s ease;
&:hover {
background: rgb(180 83 9 / 13%);
}
&__ic {
font-size: 1.05rem;
flex: 0 0 auto;
}
&__text {
flex: 1 1 auto;
font-size: 0.88rem;
line-height: 1.45;
}
&__cta {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.82rem;
font-weight: 600;
white-space: nowrap;
}
}
@media (width <= 700px) {
.pm-due-banner {
flex-wrap: wrap;
}
}
/*
* The nominee count, which opens the list of names rather than the account itself.
*
* Styled as text that can be clicked rather than as a second action button: the row already has one
* action, and a pair of equal-looking buttons would put "look at this" beside "change this" as
* though the two were the same kind of thing.
*/
.pl-nominee-btn {
border: 0;
background: none;
padding: 0;
font: inherit;
color: var(--pl-accent);
font-weight: 500;
cursor: pointer;
text-decoration: underline;
text-underline-offset: 3px;
&:hover {
text-decoration-thickness: 2px;
}
}
.pl-muted {
color: rgb(0 0 0 / 40%);
}
@@ -0,0 +1,198 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/** Angular Imports */
import { Component, OnInit, ViewChild, inject } from '@angular/core';
import { MatPaginator } from '@angular/material/paginator';
import { MatSort, MatSortHeader } from '@angular/material/sort';
import {
MatTable,
MatTableDataSource,
MatColumnDef,
MatHeaderCellDef,
MatHeaderCell,
MatCellDef,
MatCell,
MatHeaderRowDef,
MatHeaderRow,
MatRowDef,
MatRow
} from '@angular/material/table';
import { MatTooltip } from '@angular/material/tooltip';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { MatDialog } from '@angular/material/dialog';
/** Custom Services */
import { OrganizationService } from 'app/organization/organization.service';
/** Custom Components */
import { NomineeListDialogComponent } from './nominee-list-dialog/nominee-list-dialog.component';
/**
* Party Master list.
*
* The DDS accounts on file - each one a customer's daily deposit account, opened under a scheme
* (Sub Type) and assigned to the agent who collects for it. Follows the same list -> create/edit
* shape as Agent Master and the product lists, and shares their stylesheet.
*/
@Component({
selector: 'mifosx-party-master',
templateUrl: './party-master.component.html',
styleUrls: ['./party-master.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FaIconComponent,
MatTable,
MatSort,
MatColumnDef,
MatHeaderCellDef,
MatHeaderCell,
MatSortHeader,
MatCellDef,
MatCell,
MatHeaderRowDef,
MatHeaderRow,
MatRowDef,
MatRow,
MatPaginator,
MatTooltip
]
})
export class PartyMasterComponent implements OnInit {
private organizationService = inject(OrganizationService);
private dialog = inject(MatDialog);
displayedColumns: string[] = [
'partyCode',
'partyName',
'subType',
'agentName',
'savingsAccountNo',
'dailyDepositAmount',
'currentBalance',
'nominees',
'status',
'actions'
];
/**
* Who a party is nominated to, without opening it for editing.
*
* Asked often enough to belong on the list, and answered from what the list already holds - the
* parties come back with their nominees on them, so this opens rather than fetches.
*/
showNominees(party: any): void {
this.dialog.open(NomineeListDialogComponent, {
data: {
partyCode: party.partyCode,
partyName: party.partyName,
splitMode: party.nomineeSplitMode,
nominees: party.nominees || []
},
autoFocus: false
});
}
/** How many people are named on an account, which is what the list column shows. */
nomineeCount(party: any): number {
return (party?.nominees || []).length;
}
/**
* Matured accounts still open, and what they add up to. Shown here because this is the screen an
* officer already opens - money the bank owes a customer should not wait on somebody thinking to
* run a report.
*/
dueForClosureCount = 0;
dueForClosurePayable = 0;
dataSource: MatTableDataSource<any> = new MatTableDataSource<any>([]);
parties: any[] = [];
filteredCount = 0;
paginator: MatPaginator;
sort: MatSort;
// The table sits inside an @if, so these only exist once the list has loaded. Read statically they
// were never there, the table was never paged, and the paginator read "0 of 0" over a full list.
@ViewChild(MatPaginator) set matPaginator(paginator: MatPaginator) {
this.paginator = paginator;
if (paginator) {
this.dataSource.paginator = paginator;
}
}
@ViewChild(MatSort) set matSort(sort: MatSort) {
this.sort = sort;
if (sort) {
this.dataSource.sort = sort;
}
}
get totalCount(): number {
return this.parties.length;
}
get activeCount(): number {
return this.parties.filter((party) => party.status !== 'CLOSED').length;
}
/** What the whole book is worth today - every open account's running balance added up. */
get totalCurrentBalance(): number {
return this.parties.reduce((sum, party) => sum + Number(party.currentBalance || 0), 0);
}
/** What the agents should be collecting between them on a full collection day. */
get totalDailyDue(): number {
return this.parties
.filter((party) => party.status !== 'CLOSED')
.reduce((sum, party) => sum + Number(party.dailyDepositAmount || 0), 0);
}
ngOnInit(): void {
this.organizationService.getParties().subscribe((parties: any[]) => {
this.parties = parties || [];
this.dataSource = new MatTableDataSource(this.parties);
this.dataSource.paginator = this.paginator;
this.dataSource.sort = this.sort;
this.filteredCount = this.parties.length;
this.dataSource.connect().subscribe((rows) => (this.filteredCount = rows.length));
});
this.loadDueForClosure();
}
/**
* Matured accounts still open. A separate call rather than filtered out of the list above, because
* the server decides what counts as matured - comparing dates here would drift from it the first
* time either changed.
*/
private loadDueForClosure(): void {
this.organizationService.getPartiesDueForClosure().subscribe({
next: (due: any) => {
const rows = Array.isArray(due) ? due : due?.pageItems || [];
this.dueForClosureCount = rows.length;
this.dueForClosurePayable = rows.reduce(
(sum: number, party: any) => sum + Number(party.currentBalance || 0),
0
);
},
// The party list is the point of this screen; a failed banner must not take it down with it.
error: () => {
this.dueForClosureCount = 0;
this.dueForClosurePayable = 0;
}
});
}
applyFilter(filterValue: string): void {
this.dataSource.filter = filterValue.trim().toLowerCase();
}
}
@@ -0,0 +1,164 @@
<!--
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/.
-->
<h2 mat-dialog-title>Pay out {{ data.partyCode }}</h2>
<mat-dialog-content>
<!-- What is being paid and to whom, stated once at the top so the officer confirms against the
customer in front of them rather than against a row they clicked a moment ago. -->
<div class="po-summary">
<div class="po-summary__row">
<span class="po-summary__label">Customer</span>
<span class="po-summary__value">{{ data.partyName }}</span>
</div>
<div class="po-summary__row">
<span class="po-summary__label">Matured on</span>
<span class="po-summary__value">{{ data.maturityDate | dateFormat }}</span>
</div>
<div class="po-summary__row">
<span class="po-summary__label">Opened on</span>
<span class="po-summary__value">{{ data.openingDate | dateFormat }}</span>
</div>
@if (breakdown) {
<div class="po-breakdown">
<div class="po-summary__row">
<span class="po-summary__label">Deposits collected</span>
<span class="po-summary__value">{{ breakdown.deposits | number: '1.2-2' }}</span>
</div>
@if (breakdown.transfersIn > 0) {
<div class="po-summary__row">
<span class="po-summary__label">Transferred in</span>
<span class="po-summary__value">+ {{ breakdown.transfersIn | number: '1.2-2' }}</span>
</div>
}
@if (breakdown.transfersOut > 0) {
<div class="po-summary__row">
<span class="po-summary__label">Transferred out</span>
<span class="po-summary__value">− {{ breakdown.transfersOut | number: '1.2-2' }}</span>
</div>
}
@if (breakdown.other !== 0) {
<div class="po-summary__row">
<span class="po-summary__label">Other (opening balance / adjustments)</span>
<span class="po-summary__value">{{ breakdown.other | number: '1.2-2' }}</span>
</div>
}
<div class="po-summary__row">
<span class="po-summary__label">
Interest @ {{ data.interestRate | number: '1.2-2' }}%
<span class="po-summary__note"
>{{ data.openingDate | dateFormat }} to {{ data.interestDate | dateFormat }}</span
>
</span>
<span class="po-summary__value">+ {{ breakdown.interest | number: '1.2-2' }}</span>
</div>
@if (breakdown.interestToPost > 0) {
<div class="po-summary__row">
<span class="po-summary__label"> Interest calculated, posted with this payout </span>
<span class="po-summary__value">+ {{ breakdown.interestToPost | number: '1.2-2' }}</span>
</div>
}
</div>
}
<div class="po-summary__row po-summary__row--amount">
<span class="po-summary__label">Total payable</span>
<span class="po-summary__value">{{ (breakdown ? breakdown.total : data.currentBalance) | number: '1.2-2' }}</span>
</div>
@if (interestStillToCome) {
<p class="po-summary__note">
Interest from {{ data.interestDate | dateFormat }} to {{ data.maturityDate | dateFormat }} is not worked out yet
- it is added when this is paid out, so the amount paid will be a little more.
</p>
}
</div>
<p class="po-summary__note">
Paying out closes {{ data.partyCode }} - the balance is nil afterwards and it takes nothing more.
</p>
@if (loading) {
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
} @else {
<p class="po-question">Where should this go?</p>
<mat-radio-group [(ngModel)]="destination" class="po-choices">
<mat-radio-button value="own" [disabled]="ownAccounts.length === 0" class="po-choice">
<span class="po-choice__title">Into another account of theirs here</span>
@if (ownAccounts.length === 0) {
<span class="po-choice__note">{{ data.partyName }} has no other savings account at this bank</span>
} @else {
<span class="po-choice__note">Moved across today</span>
}
</mat-radio-button>
@if (destination === 'own' && ownAccounts.length > 0) {
<mat-form-field appearance="outline" class="po-account">
<mat-label>Account</mat-label>
<mat-select [(ngModel)]="selectedAccountNo" panelWidth="min(28rem, 90vw)">
@for (account of ownAccounts; track account.id) {
<mat-option [value]="account.accountNo"> {{ account.accountNo }} · {{ account.productName }} </mat-option>
}
</mat-select>
</mat-form-field>
}
<mat-radio-button value="other" class="po-choice">
<span class="po-choice__title">Park in Sundry</span>
<span class="po-choice__note"
>When the customer has no account here - held in the bank's Sundry account in their name</span
>
</mat-radio-button>
</mat-radio-group>
@if (destination === 'other') {
<mat-form-field appearance="outline" class="po-account">
<mat-label>Park in GL account</mat-label>
<input
matInput
[ngModel]="sundrySearch"
(ngModelChange)="onSundryTyped($event)"
[matAutocomplete]="sundryAuto"
placeholder="Type GL code or name…"
autocomplete="off"
/>
<mat-autocomplete
#sundryAuto="matAutocomplete"
panelWidth="min(28rem, 90vw)"
[displayWith]="sundryDisplay"
(optionSelected)="pickSundry($event.option.value)"
>
@for (account of sundryOptions; track account.id) {
<mat-option [value]="account">{{ account.glCode }} · {{ account.name }}</mat-option>
}
@if (sundryOptions.length === 0) {
<mat-option disabled>No liability GL matches</mat-option>
}
</mat-autocomplete>
@if (!sundryGlAccountId) {
<mat-hint class="po-hint-warn">Search and pick your bank's Sundry GL account</mat-hint>
} @else {
<mat-hint>The balance is held here in the customer's name until it is paid to them</mat-hint>
}
</mat-form-field>
}
@if (destination === 'other') {
<!-- Said plainly, because the officer will be asked by the customer when the money arrives. -->
<div class="po-callout">
The money comes off {{ data.partyCode }} today and is held in the Sundry account chosen above against
{{ data.partyName }} until it is paid to them - so it is accounted for the whole time and nothing is lost.
</div>
}
}
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-stroked-button mat-dialog-close>Cancel</button>
<button mat-raised-button color="primary" [disabled]="!canPayOut" (click)="payOut()">Pay out &amp; Close</button>
</mat-dialog-actions>
@@ -0,0 +1,112 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/* What is being paid and to whom. Read against the customer standing there, so it comes first. */
.po-summary {
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px 14px;
margin-bottom: 18px;
background: rgb(0 0 0 / 3%);
border-radius: 6px;
&__row {
display: flex;
justify-content: space-between;
gap: 16px;
font-size: 0.85rem;
/* The figure the officer is about to hand over - given the weight that deserves. */
&--amount {
padding-top: 6px;
border-top: 1px solid rgb(0 0 0 / 8%);
font-size: 1rem;
}
}
&__label {
color: rgb(0 0 0 / 55%);
}
&__value {
font-weight: 500;
font-variant-numeric: tabular-nums;
}
&__note {
display: block;
margin: 0;
font-size: 0.75rem;
color: rgb(0 0 0 / 45%);
}
}
/* How the amount payable is made up - one line per source, adding up to the total below. */
.po-breakdown {
display: flex;
flex-direction: column;
gap: 6px;
padding-top: 6px;
border-top: 1px dashed rgb(0 0 0 / 10%);
}
.po-question {
margin: 0 0 10px;
font-size: 0.9rem;
font-weight: 500;
}
.po-choices {
display: flex;
flex-direction: column;
gap: 4px;
}
/*
* Each choice carries its own consequence underneath it. Two radio labels alone would not tell an
* officer that one keeps the money inside the bank and the other starts it on its way out.
*/
.po-choice {
display: block;
padding: 4px 0;
&__title {
display: block;
font-size: 0.88rem;
}
&__note {
display: block;
font-size: 0.75rem;
color: rgb(0 0 0 / 55%);
line-height: 1.4;
}
}
.po-account {
width: 100%;
margin: 8px 0 4px 32px;
max-width: calc(100% - 32px);
}
/* What will actually happen, in the words the customer will ask about. */
.po-callout {
margin-top: 12px;
padding: 10px 12px;
background: rgb(180 83 9 / 8%);
border: 1px solid rgb(180 83 9 / 22%);
border-radius: 6px;
color: #92400e;
font-size: 0.78rem;
line-height: 1.5;
}
.po-hint-warn {
color: #c62828;
}
@@ -0,0 +1,230 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/** Angular Imports */
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { MatRadioModule } from '@angular/material/radio';
import { MatSelectModule } from '@angular/material/select';
import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatProgressBarModule } from '@angular/material/progress-bar';
/** Custom Services */
import { OrganizationService } from 'app/organization/organization.service';
/** Custom Pipes */
import { DateFormatPipe } from 'app/pipes/date-format.pipe';
/**
* Where a matured DDS account's money should go.
*
* Two destinations, and the difference is whether the money leaves the bank. Another account of the
* customer's here is a move across the books. Anywhere else means a transfer out, which clears later
* or fails - so the money is parked in the matured-deposits payable account first and the branch
* sends it on from there, rather than being launched into a gap where it has left one account and
* arrived at none.
*
* The amount is not asked for. It is whatever the account holds, worked out by the server: a figure
* typed here could pay out more than the customer has, or quietly leave some behind.
*/
@Component({
selector: 'mifosx-payout-dialog',
standalone: true,
imports: [
CommonModule,
FormsModule,
MatDialogModule,
MatButtonModule,
MatRadioModule,
MatSelectModule,
MatAutocompleteModule,
MatFormFieldModule,
MatInputModule,
MatProgressBarModule,
DateFormatPipe
],
templateUrl: './payout-dialog.component.html',
styleUrls: ['./payout-dialog.component.scss']
})
export class PayoutDialogComponent implements OnInit {
private organizationService = inject(OrganizationService);
private dialogRef = inject(MatDialogRef<PayoutDialogComponent>);
data = inject<any>(MAT_DIALOG_DATA);
/** 'own' moves it to another account here; 'other' parks it for a transfer to another bank. */
destination: 'own' | 'other' = 'own';
/**
* The customer's accounts this maturity can be paid into.
*
* Worked out by the server, which applies the same conditions the payout enforces: an active
* savings account of theirs, not a fixed or recurring deposit, and not one of their DDS scheme
* accounts. Filtering here instead let the two drift - the list once offered a scheme account
* belonging to a party that had already closed, which would have stranded the money.
*/
ownAccounts: any[] = [];
selectedAccountNo = '';
/** Where the balance is parked when the customer has no account here. */
sundryAccounts: any[] = [];
sundryGlAccountId: number | null = null;
/** What is typed in the GL box - a code or part of a name. */
sundrySearch = '';
/** The accounts matching what is typed, by GL code or name, in any case. */
get sundryOptions(): any[] {
const typed = this.sundrySearch.trim().toLowerCase();
if (!typed || typed === this.sundryLabel(this.chosenSundry).toLowerCase()) {
return this.sundryAccounts;
}
return this.sundryAccounts.filter(
(account: any) =>
String(account.glCode).toLowerCase().includes(typed) ||
String(account.name || '')
.toLowerCase()
.includes(typed)
);
}
get chosenSundry(): any {
return this.sundryAccounts.find((account: any) => account.id === this.sundryGlAccountId);
}
/** Shows a picked account in the box as its code and name. */
readonly sundryDisplay = (value: any): string =>
value && typeof value === 'object' ? this.sundryLabel(value) : value || '';
sundryLabel(account: any): string {
return account ? `${account.glCode} · ${account.name}` : '';
}
pickSundry(account: any): void {
this.sundryGlAccountId = account ? account.id : null;
this.sundrySearch = this.sundryLabel(account);
}
/** Typing again clears the choice until an account is picked, so a half-typed code is never sent. */
onSundryTyped(value: any): void {
// Picking from the list hands over the account itself rather than text.
if (value && typeof value === 'object') {
this.pickSundry(value);
return;
}
value = String(value ?? '');
this.sundrySearch = value;
if (value !== this.sundryLabel(this.chosenSundry)) {
const exact = this.sundryAccounts.find(
(account: any) => String(account.glCode).toLowerCase() === value.trim().toLowerCase()
);
this.sundryGlAccountId = exact ? exact.id : null;
}
}
loading = true;
/**
* How the amount payable was made up - what was deposited, what came in or went out by transfer,
* and the interest - read from the account's own transactions so the officer can explain the
* figure to the customer line by line. Null until the transactions have arrived.
*/
breakdown: {
deposits: number;
transfersIn: number;
transfersOut: number;
interest: number;
other: number;
interestToPost: number;
total: number;
} | null = null;
ngOnInit(): void {
// The accounts the balance can be parked in. None is chosen: each bank has its own Sundry account,
// and the clerk searches for it and picks it.
this.organizationService.getDdsSundryAccounts().subscribe({
next: (accounts: any) => (this.sundryAccounts = accounts || []),
error: () => (this.sundryAccounts = [])
});
this.organizationService.getPartyTransactionsOfParty(this.data.id).subscribe({
next: (transactions: any) => (this.breakdown = this.breakdownOf(Array.isArray(transactions) ? transactions : [])),
error: () => (this.breakdown = null)
});
this.organizationService.getCreditableAccounts(this.data.clientId).subscribe({
next: (accounts: any) => {
this.ownAccounts = accounts || [];
// Nothing here to pay into, so the only real choice is a transfer out - offered as the
// default rather than leaving the officer on an option that cannot be completed.
if (this.ownAccounts.length === 0) {
this.destination = 'other';
} else if (this.ownAccounts.length === 1) {
this.selectedAccountNo = this.ownAccounts[0].accountNo;
}
this.loading = false;
},
error: () => {
this.ownAccounts = [];
this.destination = 'other';
this.loading = false;
}
});
}
/** Interest is still to be worked out for part of the term, and the payout will add it. */
get interestStillToCome(): boolean {
return (
!!this.data.interestDate &&
!!this.data.maturityDate &&
String(this.data.interestDate) < String(this.data.maturityDate)
);
}
private breakdownOf(transactions: any[]) {
const sum = (test: (txn: any) => boolean) =>
transactions.filter(test).reduce((total: number, txn: any) => total + Number(txn.amount || 0), 0);
const signed = (type: string) =>
sum((txn) => txn.transactionType === type && txn.direction === 'CREDIT') -
sum((txn) => txn.transactionType === type && txn.direction === 'DEBIT');
const isTransfer = (txn: any) => txn.transactionType === 'WITHDRAW_DEPOSITE' || txn.transactionType === 'TRANSFER';
const deposits = signed('PARTY_TRAN');
const transfersIn = sum((txn) => isTransfer(txn) && txn.direction === 'CREDIT');
const transfersOut = sum((txn) => isTransfer(txn) && txn.direction === 'DEBIT');
const interest =
signed('INTEREST_POST') +
signed('OD_INTEREST_POST') +
signed('PREMATURE_AGENT_TOP_UP') +
signed('PREMATURE_INT_RECOVERY');
const balance = Number(this.data.currentBalance || 0);
// Anything the four lines above do not cover - an opening balance brought over, say - so the lines
// still add up to the balance rather than leaving the officer a gap to explain.
const other = Math.round((balance - (deposits + transfersIn - transfersOut + interest)) * 100) / 100;
// Interest already worked out but not yet credited is posted by the payout itself, first.
const interestToPost = Number(this.data.interestCalc || 0);
return { deposits, transfersIn, transfersOut, interest, other, interestToPost, total: balance + interestToPost };
}
get canPayOut(): boolean {
if (this.loading) {
return false;
}
return this.destination === 'other' ? !!this.sundryGlAccountId : !!this.selectedAccountNo;
}
payOut(): void {
this.dialogRef.close(
this.destination === 'own'
? { toSavingsAccountNo: this.selectedAccountNo }
: { sundryGlAccountId: this.sundryGlAccountId }
);
}
}
@@ -0,0 +1,175 @@
<!--
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/.
-->
<h2 mat-dialog-title>Close {{ data.partyCode }} early</h2>
<mat-dialog-content>
@if (loading) {
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
}
<!--
The account as it stands, so the clerk is choosing against real figures rather than from memory.
The term still to run is the whole reason there is a charge at all.
-->
<div class="pcd-summary">
<div class="pcd-row">
<span>Customer</span>
<strong>{{ data.partyName }}</strong>
</div>
<div class="pcd-row">
<span>Balance</span>
<strong class="pcd-amount">{{ data.currentBalance | number: '1.2-2' }}</strong>
</div>
<div class="pcd-row">
<span>Matures on</span>
<strong>{{ data.maturityDate | dateFormat }}</strong>
</div>
<div class="pcd-row">
<span>Scheme rate</span>
<strong>{{ data.interestRate | number: '1.0-2' }}%</strong>
</div>
</div>
<h4 class="pcd-question">Who bears the cost of closing early?</h4>
<mat-radio-group class="pcd-choices" [(ngModel)]="borneBy">
<label class="pcd-choice" [class.pcd-choice--on]="borneBy === 'CUSTOMER'">
<mat-radio-button value="CUSTOMER"></mat-radio-button>
<div class="pcd-choice-body">
<div class="pcd-choice-title">Customer</div>
@if (scheme) {
<div class="pcd-choice-detail">
Interest recalculated at
<strong>{{ reducedRate | number: '1.0-2' }}%</strong>
instead of {{ data.interestRate | number: '1.0-2' }}% for the whole period, and the difference taken back
before the balance is paid out.
</div>
} @else {
<div class="pcd-choice-detail">Their interest is recalculated at the scheme's reduced rate.</div>
}
</div>
</label>
<label class="pcd-choice" [class.pcd-choice--on]="borneBy === 'AGENT'">
<mat-radio-button value="AGENT"></mat-radio-button>
<div class="pcd-choice-body">
<div class="pcd-choice-title">Agent — {{ data.agentCode }}</div>
<div class="pcd-choice-detail">
The customer keeps a full month's interest, and the agent funds the days left in this month. The balance is
paid out in full.
</div>
</div>
</label>
</mat-radio-group>
<!--
Stated rather than offered as a third option: the agent is charged whichever answer is picked, and
a clerk who did not know that would be surprised by it afterwards.
-->
@if (scheme && scheme.prematureAgentCommissionPenalty > 0) {
<p class="pcd-note">
Either way, {{ data.agentCode }} is charged
<strong>{{ scheme.prematureAgentCommissionPenalty | number: '1.0-2' }}%</strong>
of the commission owed to them — the round was not kept going.
</p>
}
<h4 class="pcd-question">Where should the balance go?</h4>
<mat-radio-group class="pcd-destinations" [(ngModel)]="destination">
<mat-radio-button value="own" [disabled]="ownAccounts.length === 0">
Into another account of theirs at this bank
</mat-radio-button>
<mat-radio-button value="other">Park in Sundry (no account here)</mat-radio-button>
</mat-radio-group>
@if (destination === 'own' && ownAccounts.length > 0) {
<mat-form-field appearance="outline" class="pcd-field">
<mat-label>Credit to account</mat-label>
<mat-select [(ngModel)]="selectedAccountNo">
@for (account of ownAccounts; track account.accountNo) {
<mat-option [value]="account.accountNo"> {{ account.accountNo }} — {{ account.productName }} </mat-option>
}
</mat-select>
</mat-form-field>
}
@if (destination === 'other') {
<mat-form-field appearance="outline" class="pcd-field">
<mat-label>Park in GL account</mat-label>
<input
matInput
[ngModel]="sundrySearch"
(ngModelChange)="onSundryTyped($event)"
[matAutocomplete]="sundryAuto"
placeholder="Type GL code or name…"
autocomplete="off"
/>
<mat-autocomplete
#sundryAuto="matAutocomplete"
panelWidth="min(28rem, 90vw)"
[displayWith]="sundryDisplay"
(optionSelected)="pickSundry($event.option.value)"
>
@for (account of sundryOptions; track account.id) {
<mat-option [value]="account">{{ account.glCode }} · {{ account.name }}</mat-option>
}
@if (sundryOptions.length === 0) {
<mat-option disabled>No liability GL matches</mat-option>
}
</mat-autocomplete>
@if (!sundryGlAccountId) {
<mat-hint class="po-hint-warn">Search and pick your bank's Sundry GL account</mat-hint>
} @else {
<mat-hint>The balance is held here in the customer's name until it is paid to them</mat-hint>
}
</mat-form-field>
}
@if (ownAccounts.length === 0 && !loading) {
<p class="pcd-note">
This customer has no other account here, so the balance is parked in the Sundry account chosen above, in their
name until it is paid to them.
</p>
}
<!--
Inside the lock-in the scheme does not allow closing early, so this is asked for rather than done:
the reason goes to a manager, who approves this one account. The scheme's lock-in stays as it is.
-->
@if (withinLockin) {
<div class="pcd-lockin">
<p class="pcd-lockin-title">Within lock-in until {{ closableFrom | dateFormat }}</p>
<p class="pcd-note">
{{ data.partyCode }} opened on {{ data.openingDate | dateFormat }} and the scheme locks it in for
{{ scheme?.prematureLockinMonths }} months. It can be closed early only if a manager approves it.
</p>
<mat-form-field appearance="outline" class="pcd-field">
<mat-label>Reason for closing within lock-in</mat-label>
<textarea matInput rows="2" maxlength="250" required [(ngModel)]="lockinReason"></textarea>
<mat-hint align="end">{{ lockinReason.length }}/250</mat-hint>
</mat-form-field>
</div>
}
<p class="pcd-warn">
@if (withinLockin) {
Once approved, this closes the account. It cannot be reopened.
} @else {
This closes the account. It cannot be reopened.
}
</p>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button mat-dialog-close type="button">Cancel</button>
<button mat-raised-button color="warn" type="button" [disabled]="!canClose" (click)="close()">
{{ withinLockin ? 'Send for Approval' : 'Close Account' }}
</button>
</mat-dialog-actions>
@@ -0,0 +1,132 @@
/**
* 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/.
*/
/* The account as it stands, so the choice below is made against figures rather than from memory. */
.pcd-summary {
margin: 4px 0 20px;
padding: 12px 14px;
border-radius: 6px;
background: rgb(0 0 0 / 3%);
}
.pcd-row {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 3px 0;
font-size: 0.85rem;
span {
color: rgb(0 0 0 / 60%);
}
}
.pcd-amount {
font-variant-numeric: tabular-nums;
}
.pcd-question {
margin: 20px 0 10px;
font-size: 0.9rem;
font-weight: 600;
}
.pcd-choices {
display: flex;
flex-direction: column;
gap: 10px;
}
/*
* Each answer is a card rather than a bare radio, because the two are not interchangeable - they take
* money from different people, and the consequence has to be readable before the choice is made.
*/
.pcd-choice {
display: flex;
align-items: flex-start;
gap: 4px;
padding: 10px 12px 12px;
border: 1px solid rgb(0 0 0 / 12%);
border-radius: 6px;
cursor: pointer;
&:hover {
background: rgb(0 0 0 / 2%);
}
}
.pcd-choice--on {
border-color: #b45309;
background: rgb(180 83 9 / 5%);
}
.pcd-choice-body {
padding-top: 2px;
}
.pcd-choice-title {
font-weight: 600;
font-size: 0.88rem;
}
.pcd-choice-detail {
margin-top: 3px;
font-size: 0.8rem;
line-height: 1.5;
color: rgb(0 0 0 / 65%);
}
.pcd-destinations {
display: flex;
flex-direction: column;
gap: 6px;
}
.pcd-field {
width: 100%;
margin-top: 12px;
}
.pcd-note {
margin: 12px 0 0;
font-size: 0.8rem;
line-height: 1.5;
color: rgb(0 0 0 / 65%);
}
/* The one step here with no way back, so it is said plainly rather than left to the button colour. */
.pcd-warn {
margin: 18px 0 0;
padding: 9px 12px;
border-radius: 6px;
border: 1px solid rgb(185 28 28 / 25%);
background: rgb(185 28 28 / 6%);
color: #b91c1c;
font-size: 0.8rem;
font-weight: 500;
}
/* Inside the lock-in: a request for a manager, not a closure, so it is set apart from the terms above. */
.pcd-lockin {
margin-top: 18px;
padding: 12px 14px 4px;
border-radius: 8px;
border: 1px solid rgb(180 83 9 / 30%);
background: rgb(245 158 11 / 8%);
}
.pcd-lockin-title {
margin: 0;
font-size: 0.85rem;
font-weight: 600;
color: #b45309;
}
.po-hint-warn {
color: #c62828;
}
@@ -0,0 +1,228 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/** Angular Imports */
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { MatRadioModule } from '@angular/material/radio';
import { MatSelectModule } from '@angular/material/select';
import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatProgressBarModule } from '@angular/material/progress-bar';
/** Custom Services */
import { OrganizationService } from 'app/organization/organization.service';
import { ProductsService } from 'app/products/products.service';
/** Custom Pipes */
import { DateFormatPipe } from 'app/pipes/date-format.pipe';
/**
* Closing a DDS account before its term is up.
*
* A customer who stops saving early costs the bank the interest it had promised on money it no longer
* has for as long as it expected. Somebody has to carry that, and the bank allows two answers - so
* this asks which, and it is the only thing it asks.
*
* What each answer costs is the scheme's, not the clerk's: the reduced rate and the agent's share are
* set once when the scheme is created and shown here as fact. A figure typed at the counter is a
* figure that can be typed differently for the next customer, which is exactly what a published term
* is meant to prevent.
*
* The destination works as it does on a maturity payout - another account of theirs here, or parked
* in matured-deposits payable for a transfer out - because what is left over is still their money and
* has to reach them the same way.
*/
@Component({
selector: 'mifosx-premature-close-dialog',
standalone: true,
imports: [
CommonModule,
FormsModule,
MatDialogModule,
MatButtonModule,
MatRadioModule,
MatSelectModule,
MatAutocompleteModule,
MatFormFieldModule,
MatInputModule,
MatProgressBarModule,
DateFormatPipe
],
templateUrl: './premature-close-dialog.component.html',
styleUrls: ['./premature-close-dialog.component.scss']
})
export class PrematureCloseDialogComponent implements OnInit {
private organizationService = inject(OrganizationService);
private productsService = inject(ProductsService);
private dialogRef = inject(MatDialogRef<PrematureCloseDialogComponent>);
data = inject<any>(MAT_DIALOG_DATA);
/** Who carries the shortfall. No default - the two answers take money from different people. */
borneBy: 'CUSTOMER' | 'AGENT' | '' = '';
/** 'own' moves what is left to another account here; 'other' parks it for a transfer out. */
destination: 'own' | 'other' = 'own';
ownAccounts: any[] = [];
selectedAccountNo = '';
/** Where the balance is parked when the customer has no account here. */
sundryAccounts: any[] = [];
sundryGlAccountId: number | null = null;
/** What is typed in the GL box - a code or part of a name. */
sundrySearch = '';
/** The accounts matching what is typed, by GL code or name, in any case. */
get sundryOptions(): any[] {
const typed = this.sundrySearch.trim().toLowerCase();
if (!typed || typed === this.sundryLabel(this.chosenSundry).toLowerCase()) {
return this.sundryAccounts;
}
return this.sundryAccounts.filter(
(account: any) =>
String(account.glCode).toLowerCase().includes(typed) ||
String(account.name || '')
.toLowerCase()
.includes(typed)
);
}
get chosenSundry(): any {
return this.sundryAccounts.find((account: any) => account.id === this.sundryGlAccountId);
}
/** Shows a picked account in the box as its code and name. */
readonly sundryDisplay = (value: any): string =>
value && typeof value === 'object' ? this.sundryLabel(value) : value || '';
sundryLabel(account: any): string {
return account ? `${account.glCode} · ${account.name}` : '';
}
pickSundry(account: any): void {
this.sundryGlAccountId = account ? account.id : null;
this.sundrySearch = this.sundryLabel(account);
}
/** Typing again clears the choice until an account is picked, so a half-typed code is never sent. */
onSundryTyped(value: any): void {
// Picking from the list hands over the account itself rather than text.
if (value && typeof value === 'object') {
this.pickSundry(value);
return;
}
value = String(value ?? '');
this.sundrySearch = value;
if (value !== this.sundryLabel(this.chosenSundry)) {
const exact = this.sundryAccounts.find(
(account: any) => String(account.glCode).toLowerCase() === value.trim().toLowerCase()
);
this.sundryGlAccountId = exact ? exact.id : null;
}
}
loading = true;
/**
* The scheme's terms, shown so the clerk can tell the customer what each answer costs before
* choosing. Null while loading, and left null if the scheme cannot be read - the choice is then
* still offered, because the server applies the terms either way and a screen that could not
* display them should not block a closure the customer has asked for.
*/
scheme: any = null;
ngOnInit(): void {
// The accounts the balance can be parked in. None is chosen: each bank has its own Sundry account,
// and the clerk searches for it and picks it.
this.organizationService.getDdsSundryAccounts().subscribe({
next: (accounts: any) => (this.sundryAccounts = accounts || []),
error: () => (this.sundryAccounts = [])
});
this.organizationService.getCreditableAccounts(this.data.clientId).subscribe({
next: (accounts: any) => {
this.ownAccounts = accounts || [];
if (this.ownAccounts.length === 0) {
this.destination = 'other';
} else if (this.ownAccounts.length === 1) {
this.selectedAccountNo = this.ownAccounts[0].accountNo;
}
this.loading = false;
},
error: () => {
this.ownAccounts = [];
this.destination = 'other';
this.loading = false;
}
});
// The terms are read from the scheme the account runs on, so what is shown here is what the
// server will actually charge rather than a second copy that can drift from it. Matched on the
// code the party carries, which is the only handle it has on its scheme.
this.productsService.getDdsAccountTypes().subscribe({
next: (schemes: any) => {
const all = Array.isArray(schemes) ? schemes : schemes?.pageItems || [];
this.scheme = all.find((s: any) => s.accountTypeCode === this.data.subType) || null;
},
error: () => (this.scheme = null)
});
}
/** The rate the customer would be left on if they carry it. Never below nothing. */
get reducedRate(): number {
const rate = Number(this.data.interestRate || 0);
const reduction = Number(this.scheme?.prematureInterestReduction || 0);
return Math.max(0, rate - reduction);
}
/** Why the lock-in should be waived for this account - required when it is inside it. */
lockinReason = '';
/**
* The day the scheme's lock-in ends for this account, or null when the scheme has none. Inside it
* the account cannot be closed early on the scheme's terms; it can only be asked for, with a reason,
* for a manager to approve - the scheme's lock-in itself is never changed to let one account through.
*/
get closableFrom(): Date | null {
const months = Number(this.scheme?.prematureLockinMonths || 0);
const opened = this.data.openingDate ? new Date(this.data.openingDate) : null;
if (months <= 0 || !opened || isNaN(opened.getTime())) {
return null;
}
const closable = new Date(opened);
closable.setMonth(closable.getMonth() + months);
return closable;
}
get withinLockin(): boolean {
const closable = this.closableFrom;
return !!closable && closable.getTime() > Date.now();
}
get canClose(): boolean {
if (this.loading || !this.borneBy) {
return false;
}
if (this.withinLockin && !this.lockinReason.trim()) {
return false;
}
return this.destination === 'other' ? !!this.sundryGlAccountId : !!this.selectedAccountNo;
}
close(): void {
this.dialogRef.close({
shortfallBorneBy: this.borneBy,
...(this.destination === 'own' && this.selectedAccountNo ? { toSavingsAccountNo: this.selectedAccountNo } : {}),
...(this.destination === 'other' && this.sundryGlAccountId ? { sundryGlAccountId: this.sundryGlAccountId } : {}),
...(this.withinLockin ? { lockinOverrideReason: this.lockinReason.trim() } : {})
});
}
}
@@ -195,6 +195,13 @@ export class CreateRecurringDepositsAccountComponent {
});
return;
}
if (this.recurringDepositsAccountProductTemplate?.introducerRequired && !detailsStep?.introducerId) {
this.alertService.alert({
type: 'Error',
message: 'This product requires an introducer. Please choose an existing customer as introducer before submitting.'
});
return;
}
if (!this.selectedTenureValue || this.selectedTenureFrequencyId === null) {
this.alertService.alert({
type: 'Error',
@@ -209,6 +216,39 @@ export class CreateRecurringDepositsAccountComponent {
});
return;
}
// Same limits the server enforces - checked here so the message names the numbers before anything is sent.
const minInstallment = Number(this.recurringDepositsAccountProductTemplate?.minDepositAmount) || 0;
const maxInstallment = Number(this.recurringDepositsAccountProductTemplate?.maxDepositAmount) || 0;
if (minInstallment > 0 && this.selectedDepositAmount < minInstallment) {
this.alertService.alert({
type: 'Error',
message: `Installment ${this.selectedDepositAmount} is below this product's minimum of ${minInstallment}.`
});
return;
}
if (maxInstallment > 0 && this.selectedDepositAmount > maxInstallment) {
this.alertService.alert({
type: 'Error',
message: `Installment ${this.selectedDepositAmount} is above this product's maximum of ${maxInstallment}.`
});
return;
}
if (
this.recurringDepositsAccountCurrencyStep?.autoDebitFacility &&
detailsStep?.selectedLinkedAccount?.accountType !== 'SAVINGS'
) {
this.alertService.alert({
type: 'Error',
message:
'Auto Debit collects each installment from the linked account. Link a Savings account under Deposit Account Link, or turn Auto Debit off.'
});
return;
}
const lienError = this.recurringDepositsAccountCurrencyStep?.lienPercentageError;
if (lienError) {
this.alertService.alert({ type: 'Error', message: `Loan against this RD: ${lienError}` });
return;
}
if (!detailsStep?.selectedCustomerSegment) {
this.alertService.alert({ type: 'Error', message: 'Please select a Customer Segment before submitting.' });
return;
@@ -255,8 +295,16 @@ export class CreateRecurringDepositsAccountComponent {
isCalendarInherited: pt?.isCalendarInherited ?? false,
isMandatoryDeposit: pt?.isMandatoryDeposit ?? false,
allowWithdrawal: pt?.allowWithdrawal ?? false,
adjustAdvanceTowardsFuturePayments: pt?.adjustAdvanceTowardsFuturePayments ?? false,
autoDebitFacility: currencyStep?.autoDebitFacility ?? pt?.autoDebitFacility ?? false
// Money paid ahead of a due date counts towards the next installments. Without this an advance cash
// payment stayed unallocated: the schedule kept showing the installment unpaid, so Auto Debit collected it
// a second time and the missed-installment penalty charged it. The RD product form has no such option, so
// the product's value was always false.
adjustAdvanceTowardsFuturePayments: true,
autoDebitFacility: currencyStep?.autoDebitFacility ?? pt?.autoDebitFacility ?? false,
// Lien Marked products only - the server ignores it otherwise and caps it at the product's limit.
...(currencyStep?.lienAllowed && currencyStep?.lienPercentage != null
? { lienPercentage: Number(currencyStep.lienPercentage) }
: {})
};
// Charges from product template
@@ -233,6 +233,11 @@ export class EditRecurringDepositAccountComponent {
});
return;
}
const lienError = this.recurringDepositsAccountCurrencyStep?.lienPercentageError;
if (lienError) {
this.alertService.alert({ type: 'Error', message: `Loan against this RD: ${lienError}` });
return;
}
if (!detailsStep?.selectedCustomerSegment) {
this.alertService.alert({ type: 'Error', message: 'Please select a Customer Segment before submitting.' });
return;
@@ -275,8 +280,13 @@ export class EditRecurringDepositAccountComponent {
isCalendarInherited: pt?.isCalendarInherited ?? false,
isMandatoryDeposit: pt?.isMandatoryDeposit ?? false,
allowWithdrawal: pt?.allowWithdrawal ?? false,
adjustAdvanceTowardsFuturePayments: pt?.adjustAdvanceTowardsFuturePayments ?? false,
autoDebitFacility: currencyStep?.autoDebitFacility ?? pt?.autoDebitFacility ?? false
// See create-recurring-deposits-account: advance payments must count towards the next installments.
adjustAdvanceTowardsFuturePayments: true,
autoDebitFacility: currencyStep?.autoDebitFacility ?? pt?.autoDebitFacility ?? false,
// Lien Marked products only - the server ignores it otherwise and caps it at the product's limit.
...(currencyStep?.lienAllowed && currencyStep?.lienPercentage != null
? { lienPercentage: Number(currencyStep.lienPercentage) }
: {})
};
const rawCharges: any[] = pt?.charges ?? [];
@@ -70,8 +70,9 @@ export class ActivateRecurringDepositsAccountComponent implements OnInit {
*/
createActivateRecurringDepositsAccountForm() {
this.activateRecurringDepositsAccountForm = this.formBuilder.group({
// Defaults to the business date (the branch's working day), which is what the account is normally activated on.
activatedOnDate: [
'',
this.settingsService.businessDate,
Validators.required
]
});
@@ -72,8 +72,9 @@ export class ApproveRecurringDepositsAccountComponent implements OnInit {
*/
createApproveRecurringDepositsAccountForm() {
this.approveRecurringDepositsAccountForm = this.formBuilder.group({
// Defaults to the business date (the branch's working day), which is what the account is normally approved on.
approvedOnDate: [
'',
this.settingsService.businessDate,
Validators.required
],
note: ['']
@@ -13,6 +13,16 @@
<div class="layout-column">
<h3 class="mat-h3 flex-fill">{{ title }}</h3>
@if (totalLienAmount > 0) {
<p class="lien-warn">
This deposit is pledged as loan security (lien {{ totalLienAmount | number: '1.2-2' }} -
@for (lien of liens; track lien.loanId; let last = $last) {
loan {{ lien.loanAccountNo }}{{ last ? '' : ', ' }}
}
). It cannot be closed until the loan is repaid or the guarantee is released.
</p>
}
<mat-form-field (click)="closedOnDatePicker.open()">
<mat-label>{{ 'labels.inputs.Closed On' | translate }}</mat-label>
<input
@@ -154,7 +164,7 @@
<button type="button" mat-raised-button [routerLink]="['../general']">
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button mat-raised-button color="primary" [disabled]="!closeRecurringDepositForm.valid">
<button mat-raised-button color="primary" [disabled]="!closeRecurringDepositForm.valid || totalLienAmount > 0">
{{ 'labels.buttons.Submit' | translate }}
</button>
</mat-card-actions>
@@ -10,6 +10,12 @@
max-width: 37rem;
}
.lien-warn {
margin: 0 0 16px;
font-size: 13px;
color: #c62828;
}
.expandcollapsebutton {
margin-top: -7px;
margin-left: 2%;
@@ -64,6 +64,9 @@ export class CloseRecurringDepositsAccountComponent implements OnInit {
maxDate = new Date();
/** Close Recurring Deposit Account form. */
closeRecurringDepositForm: UntypedFormGroup;
/** Loans this deposit secures. While any remain, the server refuses to close it. */
liens: any[] = [];
totalLienAmount = 0;
/**
* Retrieves action details template data from `resolve`
@@ -79,6 +82,11 @@ export class CloseRecurringDepositsAccountComponent implements OnInit {
this.maturityAmount = data.recurringDepositsAccountActionData.maturityAmount;
this.onAccountClosureOptions = data.recurringDepositsAccountActionData.onAccountClosureOptions;
this.paymentTypes = data.recurringDepositsAccountActionData.paymentTypeOptions;
// A matured deposit closes on or after its maturity date - the server refuses anything earlier.
const maturityDate = data.recurringDepositsAccountActionData.maturityDate;
if (Array.isArray(maturityDate) && maturityDate.length >= 3) {
this.minDate = new Date(maturityDate[0], maturityDate[1] - 1, maturityDate[2]);
}
if (data.recurringDepositsAccountActionData.maturityAmount) {
this.title = 'Recurring Deposit Closure on Maturity';
} else {
@@ -92,6 +100,10 @@ export class CloseRecurringDepositsAccountComponent implements OnInit {
this.maxDate = this.settingsService.businessDate;
this.createcloseRecurringDepositForm();
this.addTransferDetails();
this.recurringDepositsService.getRecurringDepositLiens(this.accountId).subscribe((response: any) => {
this.liens = response?.liens ?? [];
this.totalLienAmount = Number(response?.totalLienAmount) || 0;
});
}
/**
@@ -99,8 +111,10 @@ export class CloseRecurringDepositsAccountComponent implements OnInit {
*/
createcloseRecurringDepositForm() {
this.closeRecurringDepositForm = this.formBuilder.group({
// The business date, not the computer's clock: the branch's working day can differ from the calendar day,
// and closing on the clock's date (before maturity, or before the deposit's last installment) fails.
closedOnDate: [
new Date(),
this.settingsService.businessDate,
Validators.required
],
maturityAmount: [{ value: this.maturityAmount, disabled: true }],
@@ -11,6 +11,51 @@
<form [formGroup]="prematureCloseRecurringDepositsAccountForm" (ngSubmit)="submit()">
<mat-card-content>
<div class="layout-column">
@if (account) {
<div class="pc-context">
<div class="pc-row">
<span>Account</span><b>{{ account.accountNo }}</b>
</div>
<div class="pc-row">
<span>Current balance</span><b>{{ currencySymbol }} {{ balance | number: '1.2-2' }}</b>
</div>
@if (account.maturityDate) {
<div class="pc-row">
<span>Maturity date</span><b>{{ account.maturityDate | dateFormat }}</b>
</div>
}
@if (account.maturityAmount != null) {
<div class="pc-row">
<span>Amount at maturity</span><b>{{ currencySymbol }} {{ account.maturityAmount | number: '1.2-2' }}</b>
</div>
}
<div class="pc-row">
<span>Pre-closure penalty</span>
<b>
@if (account.preClosurePenalApplicable) {
{{ account.preClosurePenalInterest }}% less interest
@if (account.preClosurePenalInterestOnType?.value) {
({{ account.preClosurePenalInterestOnType.value }})
}
} @else {
None
}
</b>
</div>
</div>
}
@if (totalLienAmount > 0) {
<p class="pc-warn">
This deposit is pledged as loan security (lien {{ currencySymbol }}
{{ totalLienAmount | number: '1.2-2' }} -
@for (lien of liens; track lien.loanId; let last = $last) {
loan {{ lien.loanAccountNo }}{{ last ? '' : ', ' }}
}
). It cannot be closed until the loan is repaid or the guarantee is released.
</p>
}
<mat-form-field (click)="closedOnDatePicker.open()">
<mat-label>{{ 'labels.inputs.Closed On' | translate }}</mat-label>
<input
@@ -23,6 +68,7 @@
/>
<mat-datepicker-toggle matSuffix [for]="closedOnDatePicker"></mat-datepicker-toggle>
<mat-datepicker #closedOnDatePicker></mat-datepicker>
<mat-hint>Must be before the maturity date - on or after it, use Close instead.</mat-hint>
@if (prematureCloseRecurringDepositsAccountForm.controls.closedOnDate.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Closed On Date' | translate }} {{ 'labels.commons.is' | translate }}
@@ -30,6 +76,89 @@
</mat-error>
}
</mat-form-field>
@if (reasonOptions.length > 1) {
<mat-form-field subscriptSizing="dynamic">
<mat-label>Closure reason</mat-label>
<mat-select formControlName="prematureClosureReasonId">
@for (reason of reasonOptions; track reason.id) {
<mat-option [value]="reason.id">{{ reason.label }}</mat-option>
}
</mat-select>
<mat-hint>{{ selectedReasonHint }}</mat-hint>
</mat-form-field>
}
<div class="pc-calc">
<button
type="button"
mat-stroked-button
color="primary"
(click)="calculate()"
[disabled]="
calculating || totalLienAmount > 0 || !prematureCloseRecurringDepositsAccountForm.value.closedOnDate
"
>
{{ calculating ? 'Calculating…' : 'Calculate Payout' }}
</button>
</div>
@if (preview) {
<div class="pc-result">
<div class="pc-row pc-row--total">
<span>Payable on premature closure</span>
<b>{{ currencySymbol }} {{ preview.payoutAmount | number: '1.2-2' }}</b>
</div>
@if (shortfallVsMaturity != null && shortfallVsMaturity > 0) {
<div class="pc-row pc-row--muted">
<span>Less than at maturity by</span>
<b>{{ currencySymbol }} {{ shortfallVsMaturity | number: '1.2-2' }}</b>
</div>
}
<p class="pc-note">
Paid by transfer to the client's savings account - a recurring deposit cannot be broken for cash.
</p>
</div>
@if (destinationAccounts.length === 0) {
<p class="pc-warn">
This client has no active savings account to receive the proceeds. Open one first, then close this
deposit.
</p>
} @else {
<mat-form-field>
<mat-label>{{ 'labels.inputs.Transfer to Savings' | translate }}</mat-label>
<mat-select required formControlName="toSavingsAccountId">
@for (savings of destinationAccounts; track savings.id) {
<mat-option [value]="savings.id">
{{ savings.accountNo }} - {{ savings.productName }}
@if (savings.owner) {
(nominee: {{ savings.owner }})
} @else if (savings.id === account?.linkedAccountId) {
(linked)
}
</mat-option>
}
</mat-select>
@if (prematureCloseRecurringDepositsAccountForm.controls.toSavingsAccountId.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Transfer to savings account' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
<mat-form-field>
<mat-label>{{ 'labels.inputs.Transfer Description' | translate }}</mat-label>
<input matInput formControlName="transferDescription" />
</mat-form-field>
<mat-form-field>
<mat-label>{{ 'labels.inputs.Note' | translate }}</mat-label>
<textarea matInput cdkTextareaAutosize formControlName="note"></textarea>
</mat-form-field>
}
}
</div>
</mat-card-content>
@@ -37,8 +166,8 @@
<button type="button" mat-raised-button [routerLink]="['../../']">
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button mat-raised-button color="primary" [disabled]="!prematureCloseRecurringDepositsAccountForm">
{{ 'labels.buttons.Confirm' | translate }}
<button mat-raised-button color="primary" [disabled]="!canConfirm">
{{ submitting ? 'Closing…' : ('labels.buttons.Confirm' | translate) }}
</button>
</mat-card-actions>
</form>
@@ -9,3 +9,45 @@
.container {
max-width: 37rem;
}
.pc-context,
.pc-result {
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px 14px;
margin-bottom: 16px;
border: 1px solid rgb(128 128 128 / 30%);
border-radius: 6px;
}
.pc-row {
display: flex;
justify-content: space-between;
gap: 12px;
font-size: 13px;
&--total {
font-size: 15px;
}
&--muted {
opacity: 0.75;
}
}
.pc-calc {
margin: 4px 0 16px;
}
.pc-note {
margin: 4px 0 0;
font-size: 12px;
opacity: 0.75;
}
.pc-warn {
margin: 0 0 16px;
font-size: 13px;
color: #c62828;
}

Some files were not shown because too many files have changed in this diff Show More