Compare commits

...
77 Commits
Author SHA1 Message Date
nitesh 4281a596af fix: restore remaining content dropped by the dev merge (comprehensive audit)
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
Systematic file-by-file audit of demo-backup against dev to find any
remaining instances of the same silent-drop pattern from the earlier
merge (git's 3-way auto-resolve keeping demo-backup's side wherever it
had independently touched nearby lines, even when dev's side was a
genuine, unrelated addition). Restored:

- clients.module.ts: missing ClientFamilyMemberChildDialogComponent
  registration (component existed but was never wired in)
- client-family-member-dialog: full children-management flow (checkbox,
  form array, child dialog integration, view/edit summary blocks)
- edit-client: FIR-exception flow for inaccessible old mobile numbers,
  and a hardened detectSensitiveFieldChanges() (null-guard, phone-digit
  normalization, dirty-checked comparisons to avoid false-positive
  Verify-Contact-Change prompts)
- shell.component.ts: withdrawal-review URL matcher extended to cover
  the Share Withdrawal Request review route
- fiu.component.html: AML alert status filter and monthly CTR reports
  table UI (backing logic already existed unused in the .ts)
- Shares module: Passbook tab (component existed but had no route or
  tab link), running-balance/resolution columns on the transactions
  tab, subscript-sizing fixes and a pill-style filter-bar redesign on
  Share Holders
- Savings/Share product accounting steps: Payment-Channel-to-Fund-
  Source mapping section, and Share Reference account restructuring
- pending-client-edits: email status badges + "View Email" button
  (dialog component existed but nothing on this list triggered it)

Everything else that still differs from dev was individually verified
as either demo-backup's own independent work (Investment/Loan
Origination/NPA/RBI-OSS modules, the Approval Center tab-nav redesign,
AML/compliance review features, bank-name rebrand in demo seed data)
or purely cosmetic formatting differences — left untouched.
2026-08-06 16:35:52 +05:30
nitesh 9f9c9bfab6 feat(cashier): restore Share Redemption Transfer support in Transaction Entry
This entire feature — submitting a governed Share Withdrawal Request
directly from a Bank Transfer's DR(Share)/CR(linked savings) row pair
— was silently dropped from demo-backup during the dev merge's auto-
resolve, even though the feature files it depends on (clerk-share-
withdrawal.service.ts) were present. Restores: paymentTypesForRow()
(hides cash payment types for Share rows and their settlement CR
row), the SHARE NO./RESOLUTION NO./MEETING DATE fields (replacing
AMOUNT/CHEQUE NO./CHEQ. DATE for Share rows), rowSharePrices/
rowClientIds/rowLinkedSavingsAcNo tracking, submitShareRedemptionTransfer()
+ its confirm/submit flow, syncShareLinkedAmounts() auto-fill, the
Share-aware ecsAcNo/currentBalance logic, the Other Information panel's
Share-specific fields (No. of Shares Held, Market Price/Share,
Resolution No., Linked Savings A/C No.), and the SHR guard blocking
the generic Deposit/Withdrawal Slip flow for Share accounts.
2026-08-06 16:03:31 +05:30
nitesh 513e863bba fix(cashier): skip today's-transactions/standing-instructions calls for Share accounts
Share accounts have no /accounts/share/{id}/transactions REST
sub-resource (transactions come embedded via ?associations=all) and
Share isn't a valid standing-instruction source account type on the
backend — hitting either endpoint 404s / errors. Restores the
txnFetchUnsupported/siFetchUnsupported config flags (present on dev,
lost in the earlier dev-merge's silent auto-resolve) that skip both
calls for Share, fixing the 404 seen when opening a Share account in
Transaction Entry.
2026-08-06 15:30:30 +05:30
nitesh 13333088d7 fix(cashier): fix Share account lookup false-negative in Transaction Entry
The partial/suffix match fallback was gated on "exactly one row came
back from the list endpoint", but Share's /accounts/share list has no
accountNo server-side filter at all — it always returns the full
unfiltered list, so that guard could never pass and any non-exact
account number search (e.g. shortened digits) reported "Account not
found" even when the account existed. This fix (present on dev, lost
in the earlier dev-merge's silent auto-resolve) drops the count gate
and tries suffix-match then substring-match unconditionally.
2026-08-06 15:23:28 +05:30
nitesh 89bb76455e fix: restore content silently dropped from the dev merge (KYC exception, TDS reports, Form 121)
The demo-backup merge's git 3-way auto-resolve dropped several hunks
without flagging conflicts, wherever demo had independently touched
adjacent lines: the KYC Exception feature (client-kyc-step, kyc-tab,
create-client, clients.service — checkbox, derivation logic, banners,
contact-log endpoints), TDS report routes/menu links (reports-routing,
shell), the Form 121 client tab (clients-routing, clients-view), the
kyc-management exceptions dashboard, and the Form 121/15G-H register's
real-data view integration. Restored by diffing demo-backup against
dev per file and re-applying each genuine gap while preserving
demo-backup's own independent work (Approval Center tab style, Loan
Origination/Investment menus, form-97-98/risk-review report routes).
2026-08-06 15:12:06 +05:30
nitesh 7e69468995 fix: remove duplicate declarations introduced by dev merge
Both dev and demo-backup independently added suppressMonthlyAnnualLink
and isHeadOfficeUser() at different points in their respective history,
so the merge's auto-resolve kept both copies instead of deduping,
causing TS2300/TS2393 build failures.
2026-08-06 14:31:13 +05:30
nitesh 63ab9643ce fix(headers): add missing MPL-2.0 license headers on merged files
Files restored from dev during the demo-backup merge were missing the
standard license header (dev's copies predate that fix, which had only
been applied on the demo line previously). Also fixed pre-existing lint
debt surfaced by the edit: wrong component selector prefix and
constructor-injection pattern in client-family-member-child-dialog.
2026-08-06 14:21:06 +05:30
nitesh 8255c8398d Merge dev into demo-backup
Brings in all of dev's latest content not present on demo-backup
(Share Withdrawal/Closure feature, TDS reports, KYC exception tracking,
client-edit-email-dialog, form121 tab, search-select) while preserving
demo-backup's own unique changes (wrapping Approval Center tab nav,
Loan Origination/Investment mega-menus). 36 files that a prior "ours
wins" conflict resolution had silently dropped were restored explicitly
from dev's tip.
2026-08-06 14:16:22 +05:30
nitesh 14c5f5070d Revert demo to pre-2026-08-06 state (rollback today's push)
Reverts all commits pushed to demo today (a5124ca3, 858c277a, a1a50ab8,
f72abb09, fcf66657) back to f8c532f7 as a single forward commit, without
rewriting history. All reverted content (Share Withdrawal/Closure feature,
TDS reports, KYC exception tracking, client-edit-email-dialog, form121 tab,
search-select) remains intact on dev and was verified identical before
this revert.
2026-08-06 13:39:40 +05:30
niteshandClaude Sonnet 5 fcf6665772 fix(nav): restore Loan Origination and Investment menus dropped by merge
The "Merge dev into demo" commit silently dropped both top-level nav
entries (desktop and mobile variants) along with their entire menu
panels — Loan Origination's mat-menu and the whole Investment mega-menu
(~150 lines) — because dev never had these menus, so the merge picked
dev's shell.component.html wholesale for this file instead of combining
both sides' additions. The underlying feature modules and routes were
untouched, so this restores only the shell wiring, copied verbatim from
the pre-merge demo revision.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 13:19:24 +05:30
nitesh f72abb0921 Merge remote-tracking branch 'origin/demo' into demo 2026-08-06 13:14:26 +05:30
niteshandClaude Sonnet 5 a1a50ab8a3 chore: update version stamp
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 13:10:52 +05:30
niteshandClaude Sonnet 5 858c277ae3 chore: update version stamp
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 12:17:49 +05:30
nitesh b609f19e9f Merge branch 'dev' of https://git.lincpay.tech/CBSNexora/cbsnexora into dev
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-08-06 11:45:46 +05:30
niteshandClaude Sonnet 5 7f836f6f71 feat(shares): add Share Account Closure maker-checker workflow
Clerk submits a closure request only once a share account holds zero
shares; manager validates (shares still zero, account active, no active
loan/guarantee, KYC/AML standing, maker!=checker) and approves, which
executes the real Fineract "close" command. The direct native "Close"
action is removed from the account menu — it silently force-redeemed any
remaining shares, bypassing the withdrawal approval flow entirely — and
replaced with an "Eligible for Closure" banner. When a withdrawal approval
brings a share account's holding to zero, a closure request is now
auto-submitted (still requires manager approval) so the clerk doesn't have
to separately open "New Share Closure Request".

Also fixes an unrelated build break introduced by a merged commit: a
missing `suppressMonthlyAnnualLink` property declaration, and two files
(branch-scope.service.ts, gl-reports-data.ts) that existed on `demo` but
were never brought into `dev` alongside the new TDS report components
that depend on them.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 11:43:56 +05:30
Anshul Rathore a5124ca3e8 Merge dev into demo 2026-08-06 10:46:08 +05:30
Anshul Rathore 1a33f9d6ce Merge remote-tracking branch 'origin/dev' into dev
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-08-06 10:45:10 +05:30
Anshul Rathore 983c613b8e feat: update changes 2026-08-06 10:41:28 +05:30
nitesh ddb215decf Merge branch 'dev' of https://git.lincpay.tech/CBSNexora/cbsnexora into dev
Publish Image in Docker Hub / build (push) Canceled after 0s
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s
2026-08-05 18:33:57 +05:30
niteshandClaude Sonnet 5 2cc8d979ba fix(shares): fix popup fixed-position bug, add Client Compliance card
backdrop-filter on the list-embedded review popup was making it a new
containing block for its own nested position:fixed dialogs (Validate,
Reject, Confirm-Submit), confining them to the popup's box instead of the
viewport — replaced the blur with a plain darker overlay. Also adds a
permanent Client Compliance card (KYC status, risk category, open AML
alerts) on the review screen so a manager can see it without opening the
Validate popup.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 18:32:48 +05:30
keshav 569f17ef46 kyc exception at client creation time and some tds work
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-08-05 17:23:56 +05:30
niteshandClaude Sonnet 5 cc5cd4c6ab feat(shares): add Share Withdrawal maker-checker workflow with GL settlement
Tests / testRigor (push) Canceled after 0s
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
Clerk submits a governed share redemption request (shares, Board Resolution
No., Meeting Date, settlement Payment Type); Bank Manager reviews with a
live backend-validated checklist (account/GL state, not just submit-time
snapshot) before approving, which redeems the shares and settles the
linked savings account in one transaction. Adds a Passbook tab to the
Share Account view, a reusable search-select control, and a Savings
Product "Map Payment Types to Specific Funds" fund-source override (only
Loan Products had this before) so settlement can be GL-mapped correctly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 16:55:21 +05:30
nitesh 8c7d8287f9 Merge branch 'uate' into dev — sync branches to same level
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-08-04 11:35:08 +05:30
keshav 504d7ee7f6 Merge branch 'demo' of https://git.lincpay.tech/CBSNexora/cbsnexora into demo 2026-08-03 10:51:53 +05:30
keshav 26091c68b0 fix(accounting): prioritize specific Fineract error messages when AML marking fails 2026-08-03 10:51:07 +05:30
niteshandClaude Sonnet 5 f8c532f74a fix(tasks): wrap Approval Center tabs instead of scrolling behind pagination arrows
Replace mat-tab-nav-bar with a plain wrapping pill nav so all 16 approval sections stay
visible and one click away, instead of being hidden behind Material's prev/next scroll
arrows on a single-row strip.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 10:36:13 +05:30
keshav 44b41e504a Merge branch 'demo' of https://git.lincpay.tech/CBSNexora/cbsnexora into demo 2026-08-03 10:32:31 +05:30
keshav e8d91c8f60 feat(accounting): add AML manual referral and marking to general ledger entries 2026-08-03 10:31:32 +05:30
nitesh 8dd9767bdf Merge branch 'demo' of https://git.lincpay.tech/CBSNexora/cbsnexora into demo 2026-08-02 21:48:25 +05:30
niteshandClaude Sonnet 5 c868beb344 chore(npa): rename NPA Interest report/menu item to NPA Report
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 21:44:46 +05:30
keshav b43ed87771 risk review report with on which aml rule 2026-08-02 21:40:34 +05:30
niteshandClaude Sonnet 5 ebe67cc410 feat(investment,npa): RBI-grounded targets, GL-tracked interest posting, date/search pickers
- Correct Investment portfolio allocation and Loan Origination product-mix targets against
  RBI's UCB SLR/Non-SLR framework and PSL circulars, with a Target Basis column distinguishing
  RBI-mandated vs board-policy rows (Excel export included).
- Expand Investment holdings dataset with more near-term maturities so the Due-Date Diary
  reflects a realistic spread across FD/Govt Security/Non-SLR/Bond.
- Add From/To period-based Interest Calculation & Post (with GL selection for both posting and
  receiving) across the Investment Interest module, FD Entry and Bond Entry screens; fix a bug
  where a posted period kept re-appearing as due since accrual wasn't tracked per-holding.
- Add a reusable DD-MM-YYYY date-picker component and roll it out across all Investment date
  fields.
- NPA Summary Report: drop SMA-0/1/2 from the stage-wise table (kept in Day-wise Overdue only).
- NPA Recovery Report: add Account Type / NPA Category / Target-vs-Actual columns, a shared
  Account Type filter driving both the account picker and the register, and a searchable
  (fixed-position, not clipped by card overflow) account select for long lists.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 19:26:19 +05:30
nitesh d7f5bfe5f5 feat(fiu): add Head Office / Branch-wise scoping; remove reconciliation nav dots
FIU: adds a branch selector (Head Office sees all/drills into one branch,
a branch-level login is locked to its own branch) scoping the CTR/STR tabs,
dashboard KPIs and header. A FIU_BRANCH_CODE_TO_CANONICAL map bridges the
module's internal FINnet branch codes to the app-wide BranchMaster codes.
Live backend-driven AML alerts/cases are unchanged (no branch field to scope by).

Shell: removes the "working nav" green-dot indicator from the Reconciliation
mega-menu's 17 report links per request.
2026-08-02 16:06:03 +05:30
nitesh be6103e0b3 Merge branch 'demo' of https://git.lincpay.tech/CBSNexora/cbsnexora into demo 2026-08-02 14:23:46 +05:30
nitesh 5cd9998cfc feat(loan-origination): add full Loan Origination Sanctioning workflow module
New standalone module modelling the pre-disbursement pipeline: Channel intake
(Branch/BC/Self-Apply) -> KYC/OVD & e-KYC fingerprint -> CIC/Credit Bureau
check -> Field Investigation -> Personal Discussion & declaration -> Loan
Committee -> Risk Approval -> Legal Approval -> Customer Signing -> Cheque/ACH
-> Post-Disbursement Check -> Disbursement, per RBI KYC Master Direction, the
mandatory CIC check, and co-operative-bank share-membership rules.

Includes a 12-stage stepper with real approve/reject actions and a date-wise
audit trail, CIC upload + credit-score check, Field Inquiry push + Agent
Report upload, Committee "Reports Required" / Risk "Verification Checklist",
Head-Office-vs-branch scoping, a Pipeline screen (filters, New Application
intake, Channel-wise/BC-wise/Share-Member reports, Disbursement Target vs
Actual, Export to Excel), and a full EMI repayment schedule once disbursed.
2026-08-02 14:22:32 +05:30
keshav 0ca6842bde Merge branch 'demo' of https://git.lincpay.tech/CBSNexora/cbsnexora into demo 2026-08-02 11:49:32 +05:30
keshav 3030e2f34e form 91/98 report, min max value update in AML rule and whihc rule apply day, week, month and some more updates 2026-08-02 11:46:40 +05:30
nitesh b4608cbbe4 feat(npa): add Recovery Management System reports + Recovery Journey popup
Adds loan-type-aware recovery tracking (EMI/Overdrawn/Bullet) with a Recovery
Management Dashboard, Day-wise Overdue/Overdrawn reports, an Agriculture/Gold
market-value report, and a security-value exception check — all derived from
new repaymentType/emiAmount/drawingPower/maturityDate/collateralMarketValue
fields on LoanAccount, wired into Export to Excel and the NPA mega-menu.

Also replaces the always-visible Recovery/Collection Journey card on the
Account Detail Report with a "Recovery Journey" popup: a date-led, icon-coded
timeline of every SMS/call/visit/notice and payment run against an account.
2026-08-02 11:33:01 +05:30
nitesh 27cdaf7fc2 feat(npa): add Undebited Interest Recovery Report, fix note text width
Adds a 10th NPA report tracking memorandum "undebited interest" per IRAC
norms (accrued vs cash-recovered vs balance), wired into Export to Excel
and the NPA mega-menu. Also fixes a narrow-width text wrap on the report's
description note (new .npa-report-note class overriding pg-sub's 560px cap).
2026-08-02 08:34:03 +05:30
nitesh 90beea64cb feat(npa): add Export to Excel across all 9 NPA reports
Every report screen (Interest, Status, Recovery, Summary, Account Detail,
Movement, Provision, Probable NPA, SMA) now has an Export to Excel button
that builds a workbook from the same derived getters the on-screen tables
use, reusing the shared cashier-export.ts builder.
2026-08-01 20:58:42 +05:30
nitesh 1317a93838 feat(cashier): full double-entry journal view + Excel export for Cash/Clearing/Transfer Summary
Transaction Details now renders each voucher as a paired Dr/Cr journal entry with a
derived contra account and a Balanced status chip, so both sides of a transaction are
visible at a glance for reconciliation. Adds a shared cashier-export.ts (mirrors the
Investment module's exporter) and an Export to Excel button producing a Summary +
Transaction Details workbook, plus a Branch Breakup sheet for Head Office consolidated view.
2026-08-01 20:32:31 +05:30
nitesh 0832017508 feat(investment,reconciliation): add Export to Excel across Investment, fix export popup
Adds a shared Export to Excel utility (investment-export.ts) and wires it
into every listing/report screen in the Investment module — Dashboard
(break-up + allocation), Reports (all ~20 report shapes), Interest (per
active tab), the FD/Bond/Share Holdings registers, and Masters (per active
master tab). Entry/transaction forms intentionally have no export button.
No sign-off is included here, matching the request to keep that feature
reconciliation-only.

Also fixes the Reconciliation export flow: the Prepared/Checked/Approved
sign-off is now an optional one-off popup shown when "Export to Excel" is
clicked (fields are blank by default and downloading works whether or not
they're filled), replacing the earlier always-visible sign-off card. Also
fixes three reconciliation reports (Sundry Dr/Cr, NEFT/RTGS, UDIR) whose
labels contain "/" — an Excel-forbidden worksheet-name character — which
was silently aborting their export.
2026-08-01 19:20:53 +05:30
nitesh 8ac4010967 feat(investment): add portfolio allocation targets and interest receivable/post/receive lifecycle
Adds a Portfolio Allocation (Target vs Actual %) section to the Investment
Dashboard, and reworks Investment Interest into the full accrual lifecycle:
Interest Receivable, FD/Govt./Bond Interest Calculation & Post, and Interest
Receive, plus the existing registers.

Interest Calculation & Post now tracks cumulative posted/received amounts
per holding instead of recomputing full accrued interest on every click
(which previously double-counted on repeat posting). Holdings classified
Non-Performing (Sub-Standard/Doubtful/Loss) are excluded from accrual
posting per RBI norms — their interest accrues in memo/suspense only and is
recognised solely on actual cash receipt via Interest Receive.

Also bumps the production initial-bundle budget in angular.json (13/14mb ->
15/17mb) since the growing set of eagerly-loaded feature modules (Investment,
NPA, Reconciliation, GL Reports, OSS Returns) pushed the build past the old
limit.
2026-08-01 17:56:44 +05:30
nitesh 1c125d97c5 Merge branch 'demo' of https://git.lincpay.tech/CBSNexora/cbsnexora into demo 2026-08-01 13:15:57 +05:30
nitesh c935e2a62a feat(reconciliation): build full 16-screen Reconciliation module
Wires every item in the Reconciliation mega-menu (Core & Books, Bank &
Clearing, Digital & Payments, Instruments & Members, Compliance &
Financials) to a working screen, mirroring the NPA module's pattern: one
route-param-driven component over a shared two-sided (Side A vs Side B)
matching model, with pagination and a "Mark as Reconciled" action on every
screen.

Access is split by real-world scope: activity that happens at a branch
(vouchers, cheques, digital transactions, share members, fixed assets) is
branch-wise with the same Head-Office/branch login behavior as NPA; activity
with no branch dimension in real banking (GL/P&L consolidation, treasury's
own bank accounts, statutory tax filing, vendor procurement, inter-branch
netting) is Head-Office-only, with branch logins shown a restricted notice
instead of the data.

Also adds the .menu-item-live "working" indicator to mat-menu-item buttons
(same convention as .gl-link--live) and applies it across the reconciliation
menu and the pre-existing DD/Pay Order Reconciliation link.
2026-08-01 13:15:17 +05:30
keshav 2a9df828ca fix: update General Ledger toolbar menu links with routerLink 2026-08-01 12:01:55 +05:30
keshav bd753f4983 oss file updated, profitl loss as per the day month and also with date select 2026-08-01 11:46:11 +05:30
nitesh 4261f9659e feat(npa): add branch-wise NPA module with realistic data, pagination and recovery actions
Adds a full NPA (Non-Performing Assets) module mirroring the legacy CBS NPA
mega-menu: NPA Reports, NPA Analysis, and NPA Classification screens over a
640-account loan book classified per RBI IRAC norms (Standard through
Loss). Same Head-Office/branch-wise access pattern as the Cash/Clearing/
Transfer summary, 20-row pagination on every large table, and a Recovery
Action workflow (demand notice, SARFAESI, suit filing, OTS) on the Account
Detail screen.

Fixes several data-generation bugs so every account stays internally
consistent regardless of when it's viewed: disbursement date can no longer
predate the account's own overdue history, last-payment date now reflects
the most recent recovery entry, the quarter-ago snapshot can't imply more
overdue days than the loan's age allowed, and provision held is capped at
the account's outstanding balance.

Also fixes a text-overflow/dropdown-arrow overlap on long select options
(Investment + NPA shared form styles), and marks the GL "Financial
Statements" menu items that are already wired to a working dialog.
2026-08-01 11:18:02 +05:30
nitesh 63ac900e42 feat(investment): add Treasury Investment module (FD, Govt Sec/Non-SLR/Bond/MF, Share)
Adds full frontend-only Investment module mirroring the legacy AutoBank CBS
Investment menu: Dashboard, FD/Bond/Share entry screens (purchase, interest,
sell/redeem, write-off, amortize and more), 10 master screens, an Interest
calculation/register screen, and ~21 reports. All figures are derived from a
single holdings/transactions dataset so entry, interest, and reports can
never drift out of sync. Wires the new "Investment" mega-menu into the shell
and routes the previously inert Investment stub buttons.
2026-08-01 09:17:06 +05:30
niteshandClaude Sonnet 5 fd28788d23 fix(cashier): realistic branch-aware account numbers, dynamic date tags in summary report
Account No now always holds a genuine reference for its transaction type
(customer account, GL/facility code, IFSC, DD/PO/TDS/GST/cheque ref, or
UTR/RRN/UMRN for RTGS/NEFT/UPI/ATM/NACH) instead of a plain description.
Branch 2's view derives its own valid-format account numbers and IFSC
codes by shifting the numeric segment, rather than tagging a "BR2-" prefix
onto Branch 1's values, which looked fake for standardized formats.

Voucher/reference numbers' baked-in "2607" (Jul 2026) tag is now derived
from the real current date at render time, so it won't go stale next month.

feat(clients): auto-calculate Monthly/Annual Income and Turnover

Typing either the monthly or annual figure now fills in the other at a 12x
ratio (rounded to 2 decimals, no trailing ".00" on whole numbers). Guarded
against firing during prefillForEdit()'s initial load of an existing
client's independently-saved values, so editing doesn't silently overwrite
historical data that wasn't an exact 12x match.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-31 17:24:57 +05:30
keshav b9b37397fe Merge remote-tracking branch 'origin/demo' into demo 2026-07-31 15:10:42 +05:30
keshav f2525080c8 static update 2026-07-31 15:09:18 +05:30
Anshul Rathore a982083b14 chore: stop tracking auto-generated .env.ts file 2026-07-31 15:08:35 +05:30
nitesh 43e3c546a7 Merge remote-tracking branch 'origin/dev' into demo 2026-07-31 14:47:03 +05:30
nitesh b68b25f789 Merge remote-tracking branch 'origin/uate' into demo 2026-07-31 14:46:38 +05:30
Anshul Rathore a560cd6533 feat: add Welcome Kit dispatch dialog and Share Holders view
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-07-31 14:06:27 +05:30
niteshandClaude Sonnet 5 1dc378c2dd feat(cashier): add Cash/Clearing/Transfer summary report with branch-wise drill-down
Adds a dummy-data summary report (Opening/Credit/Debit/Closing) under Sub
Ledger > Cash / Clearing / Transfer, with Cash, Transfer (GL-wise), Clearing
and Digital (RTGS/NEFT/UPI/ATM/NACH) tabs plus a transaction-details drill
down. Credit/Debit/Closing are always derived from the transaction lines so
the summary and detail views can never drift apart.

Head Office logins (officeId 1 or office name containing "head office") get
a branch switcher across two demo branches plus a consolidated view with a
branch-wise break-up; branch-level logins see only their own office's data.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-31 13:39:13 +05:30
keshav 0bf337b962 Merge branch 'uate' of https://git.lincpay.tech/CBSNexora/cbsnexora into uate 2026-07-31 11:00:14 +05:30
keshav 1daa12a117 kyc risk and AML & Compliance Checks automate on client creation 2026-07-31 10:58:58 +05:30
nitesh f687e1808d fix(http): stop surfacing background/enrichment-fetch failures as error toasts
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Two related cases where ErrorHandlerInterceptor's global toast fired even
though the calling component already recovers gracefully on its own:

- GET /kyc/risk-rules 404ing (e.g. not yet deployed on a given backend)
  popped a raw "Http failure response ... 404" toast on the Create Client
  page, even though client-general-step already falls back to an empty
  rule set and a default risk category when this call fails.

- The Clients list page fires several per-row background calls
  (client data/KYC/address, for every visible row) plus the main search —
  each already had its own catchError/error handling, but the
  interceptor's 500 branch had no silencing path at all (only 403/404 did,
  and only for a fixed set of known-optional URLs), so any backend
  hiccup there spammed "Internal Server Error" toasts on top of an
  otherwise-working page.

Added a SILENT_HTTP_ERROR HttpContextToken so a caller can opt a specific
request out of the toast for every status code (not just 403/404) when it
already handles failure itself — threaded as an optional `silent` param
through ClientsService's searchByText/getClientData/getClientKyc/
getClientAddressData (default false, so no other of their ~50+ call sites
change behavior) and set from ClientsComponent's list-page calls. The
risk-rules endpoint was simpler: just added to the interceptor's existing
URL-based silent-request list alongside client images/KYC/pending-edits.
2026-07-30 13:41:28 +05:30
nitesh 186a9057a1 feat(clients): confirm linked Client ID + details after adding a family member
The "Add Member" dialog closed silently on submit, giving no
acknowledgement of what got recorded — most notably the linked client's
own CBS Client ID when the member was linked to an existing client rather
than entered manually. Added a confirmation popup showing Client ID (when
linked), name, relationship, DOB and dependent status right after submit.
2026-07-30 13:38:10 +05:30
nitesh 387bd060e1 feat(clients): draw-signature option for Specimen Signature, drop its webcam capture
DrawSignatureDialogComponent (a mouse/touchpad signature pad) already
existed and was used from the client profile view, but wasn't wired into
the KYC step used during client creation/edit — a stale comment there
even referred to a "draw-pad" option as if it were already present.
Wired it in as a third capture method alongside Choose & Crop, then
removed Use Webcam from the signature box specifically (kept for Client
Photograph) per follow-up feedback — a signature is drawn or scanned, not
photographed.

Refactored the file-picker/webcam/draw-pad paths' duplicated stage+upload
logic into shared applySignatureFile()/uploadSignatureFile() helpers.
2026-07-30 12:28:08 +05:30
nitesh 29f1cdc646 fix(clients): Aadhaar show/hide icon spilling past the field border in KYC step
.toggle-vis-btn shrinks the visible mat-icon-button to 28px, but Material
still reserves its default ~48px invisible touch-target/ripple centered
on the button. Since the suffix container doesn't clip overflow, that
ripple rendered past the input's own border on hover/focus, making the
eye icon look like it was sitting outside/on top of the field edge.
Constrained the touch-target and ripple to match the shrunk button.
2026-07-30 10:32:33 +05:30
nitesh 1efb520009 feat(tasks): backend-driven pagination, search and date filters for Client Approval
Client Approval previously fetched up to 1000 pending clients via a route
resolver and did name-search/sorting client-side (the search box was
actually dead — it set a MatTableDataSource filter that the grouped-table
view never read). Reworked to mirror PendingClientEditsComponent: real
mat-paginator against /clients (offset/limit), server-side displayName
search, orderBy=submittedOnDate for latest-first, and All/Today/Yesterday
presets backed by the new fromDate/toDate params. Dropped the now-unused
GetGroupedClientsData resolver.

Also: Account Number column -> Client ID (element.id), disabled the
Approve button with a "N selected" indicator when nothing's checked, and
a redesigned action bar / empty-state (context-aware message for
zero-results-from-search vs. zero-results-from-date-filter vs. truly
nothing pending) — plus the plain-div search box and matched heights
across chips/search/button, replacing an earlier mat-form-field version
that Material's own outlined-field box model kept misaligning against the
pill buttons.
2026-07-30 10:31:55 +05:30
nitesh 4ea833ff49 feat(clients): cite UAPA Sec. 51-A / RBI KYC Direction and list-currency date in sanctions dialog
The sanctions verification dialog said which lists a client was screened
against but not the regulatory basis for doing so, nor when that list
data was last refreshed. Added a regulatory-basis note and a "List Data
As Of" row (sourced from the backend's newly-exposed listDataAsOf) to
both the cleared and match-found panels.
2026-07-30 10:30:45 +05:30
nitesh 5dc284bd1c fix(clients): address-proof date fields use calendar picker, Pension Payment Order gets one too
Both the address-proof upload dialog and the permanent-address step used a
native `type="date"` input for the issue/statement date, so no calendar
popup was available. Switched both to Angular Material's datepicker, kept
the client-india-address-step round-trip (payload -> saved description ->
re-parsed on edit) working with the Dates util now that the control holds
a Date object instead of a raw string.

Pension Payment Orders was also the only "Financial & Other" address-proof
type missing requiresDate, so selecting it never showed a date field at
all — added the flag and moved Monthly/Yearly Turnover under the "Other
Income" heading alongside Agriculture/Non-Agriculture Income.
2026-07-30 10:30:00 +05:30
niteshandClaude Sonnet 5 56f13ade67 fix(clients): latest-identifier ordering for KYC docs, show client ID in list
Repeat KYC document uploads create a new client_identifier row rather than
updating in place, so loadExistingKycIdentifier() must prefer the newest
matching row instead of the first one returned by the API. Also swap the
Clients list "Account No." column to show the client's numeric ID instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 11:32:41 +05:30
nitesh dbd160e7e7 Merge remote-tracking branch 'origin/uate' into uate 2026-07-29 10:29:55 +05:30
niteshandClaude Sonnet 5 468eaa96ab feat(clients): maker-checker for client edits (frontend)
- New "Pending Client Edits" screen under Checker Inbox & Tasks: server-driven pagination,
  date range + Today/Yesterday/All presets, status filter (Pending/Approved/Rejected), search —
  mirrors ChequebookRequestsComponent's architecture.
- Compare dialog diffs the maker's proposed values against a point-in-time "before" snapshot
  (client-edit-diff.util.ts) captured when the edit page was opened, so only genuinely changed
  fields show — works correctly for Pending, Approved, and Rejected edits alike, since it no
  longer depends on comparing against the live (possibly already-updated) client record.
- edit-client.component.ts: General tab save now submits for approval instead of applying
  immediately, shows a pending-approval banner, and blocks a second submission while one's
  outstanding.
- error-handler.interceptor.ts: added the new /clients/{id}/edit/pending check to the existing
  "expected 403/404, don't alert" allowlist (same pattern already used for client images/KYC).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 10:27:45 +05:30
keshav f682cb17f6 CTR/STR report and Risk Category auto mark on bases of person occupation and anual salary enter 2026-07-29 10:21:02 +05:30
niteshandClaude Sonnet 5 79fedb66e5 fix(clients): minor-age threshold to 18, senior/PWD doc reload, in-app document preview dialog
- Raise isMinor threshold from age <10 to <18 (legal definition), cascading correctly to
  guardian requirement, KYC document filtering, and Education Level/Degree Qualification
  visibility (now shown for all ages, not hidden for minors).
- Load and display previously-uploaded Senior Citizen proof / PWD certificate documents on
  edit (view/replace/remove), instead of always showing an empty upload prompt.
- Fix seniorPwdDocsComplete to accept an existing certificate, not just a freshly-picked file.
- Add DocumentPreviewDialogComponent: a reusable in-app modal (image/PDF/download) used in
  place of window.open() for saved KYC/address/senior-citizen/PWD documents — avoids popup
  blockers (fired only after the async download resolved) and Chrome/Edge's failure to paint
  large base64 data: URLs on first open.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 15:49:53 +05:30
niteshandClaude Sonnet 5 c1fc95d9b7 fix(clients): minor-specific KYC document types (Birth Certificate/School ID) never appeared
filterDocTypesForMinor() only ever removes entries. The initial (pre-DOB, isMinor=false) seed
already stripped Birth Certificate/School ID Card out of kycDocumentTypes, so the isMinor
ngOnChanges handler re-filtering that same shortened list could never bring them back once the
client turned out to be a minor. Rebuild from the full defaultDocumentTypes source instead
(matching how the initial seed itself is built) whenever isMinor changes and the backend KYC
template hasn't already taken over. Also gives School ID Card the "Government-issued ID"
category it was missing, so it groups with Aadhaar/Passport/Birth Certificate instead of "Other".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 12:56:30 +05:30
nitesh b422ae6600 Merge remote-tracking branch 'origin/uate' into uate 2026-07-28 12:37:59 +05:30
niteshandClaude Sonnet 5 5fd7e1890e feat(clients): require Mother + Father/Caretaker as family members before activation
- Add hasMother/hasFather/hasCaretaker/familyRelationshipRulesMet to
  ClientFamilyMembersStepComponent, matched against the RELATIONSHIP code value label.
- Show two independent inline error banners (never merged into one message) when Mother, or
  both Father and Caretaker, are missing.
- Gate "Save & Next" and final Submit on this rule in both the create and edit client flows —
  the edit flow previously treated the Family tab as unconditionally optional.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 12:36:30 +05:30
keshav 0389b1f7c3 aadher edit fild update 2026-07-28 12:32:51 +05:30
nitesh 2e4187c3d2 Merge remote-tracking branch 'origin/uate' into uate 2026-07-28 10:53:26 +05:30
niteshandClaude Sonnet 5 cb4260b7a3 fix(clients): stale signature bug, monthly turnover/annual income fields, senior citizen/PWD classification
- Fix signature-document reads picking the oldest upload instead of the latest across 7 call
  sites (CKYC tab, view/delete-signature dialogs, edit-wizard preview, cashier transaction entry,
  signatory service, record-master) by sorting on document id instead of an unsorted find().
- Add Monthly Turnover and Annual Income fields alongside the renamed Yearly Turnover field.
- Add Senior Citizen (auto-detected from DOB) and Person with Disability classification, with a
  required disability-certificate upload gate before client creation can proceed.
- Add Graduate and Diploma as distinct Education Levels, with diploma-specific degree options.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 10:49:28 +05:30
254 changed files with 70641 additions and 1881 deletions
+2 -2
View File
@@ -69,8 +69,8 @@
"budgets": [
{
"type": "initial",
"maximumWarning": "13mb",
"maximumError": "14mb"
"maximumWarning": "15mb",
"maximumError": "17mb"
},
{
"type": "anyComponentStyle",
@@ -0,0 +1,70 @@
<!--
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 class="mk-title" [class.mk-title--str]="isEscalation">
<fa-icon [icon]="isEscalation ? 'exclamation-triangle' : 'check-circle'"></fa-icon>
{{ isEscalation ? 'Mark as Suspicious' : 'Mark as Whitelisted' }}
</h2>
<mat-dialog-content>
<div class="mk-entry">
<div>
<label>Journal entry</label><span class="mono">#{{ data.journalEntryId }}</span>
</div>
<div>
<label>Transaction</label><span class="mono">{{ data.transactionId }}</span>
</div>
<div>
<label>Date</label><span>{{ data.transactionDate }}</span>
</div>
<div>
<label>Amount</label><span class="mono">&#8377;{{ data.amount | number: '1.2-2' }}</span>
</div>
<div class="wide">
<label>GL account</label><span>{{ data.glAccountName }}</span>
</div>
</div>
@if (isEscalation) {
<p class="mk-warn">
<fa-icon icon="exclamation-triangle"></fa-icon>
This raises an AML alert against the customer behind this entry and escalates it. An STR case opens for the
Principal Officer with the seven working day FIU-IND clock running. It cannot be undone from this screen.
</p>
} @else {
<p class="mk-note">
Records the entry as reviewed and cleared. No report is filed and the customer's risk rating is untouched.
</p>
}
<label class="mk-field">
<span>Reviewer notes {{ isEscalation ? '(required)' : '(optional)' }}</span>
<textarea
rows="4"
[(ngModel)]="notes"
[placeholder]="
isEscalation
? 'Why is this entry suspicious? What did you check?'
: 'What satisfied you that this entry is in order?'
"
></textarea>
</label>
</mat-dialog-content>
<mat-dialog-actions class="mk-actions">
<button class="mk-btn" (click)="dialogRef.close()">Cancel</button>
<button
class="mk-btn"
[class.mk-btn--str]="isEscalation"
[class.mk-btn--ok]="!isEscalation"
[disabled]="!canSubmit"
(click)="confirm()"
>
{{ isEscalation ? 'Escalate to STR' : 'Whitelist entry' }}
</button>
</mat-dialog-actions>
@@ -0,0 +1,165 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
:host {
--mk-g100: #f1f5f9;
--mk-g200: #e2e8f0;
--mk-g300: #cbd5e1;
--mk-g500: #64748b;
--mk-g700: #334155;
--mk-g900: #0f172a;
--mk-green: #15803d;
--mk-red: #c0392b;
--mk-amber-l: #fef3c7;
display: block;
font-family: 'DM Sans', system-ui, sans-serif;
color: var(--mk-g900);
min-width: 460px;
}
.mk-title {
display: flex;
align-items: center;
gap: 9px;
margin: 0;
padding: 18px 22px 10px;
font-size: 16px;
font-weight: 700;
color: var(--mk-green);
&--str {
color: var(--mk-red);
}
}
.mk-entry {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px 18px;
padding: 11px 14px;
background: #fff;
border: 1px solid var(--mk-g200);
border-radius: 8px;
margin-bottom: 12px;
> div {
display: flex;
flex-direction: column;
}
.wide {
grid-column: span 2;
}
label {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--mk-g500);
}
span {
font-size: 12.5px;
}
}
.mk-warn {
display: flex;
align-items: baseline;
gap: 8px;
font-size: 12px;
color: #78350f;
background: var(--mk-amber-l);
border-radius: 7px;
padding: 9px 12px;
margin: 0 0 12px;
}
.mk-note {
font-size: 12px;
color: var(--mk-g500);
margin: 0 0 12px;
}
.mk-field {
display: flex;
flex-direction: column;
gap: 4px;
span {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--mk-g500);
}
textarea {
padding: 9px 11px;
border: 1px solid var(--mk-g300);
border-radius: 7px;
font-size: 13px;
font-family: inherit;
resize: vertical;
&:focus {
outline: none;
border-color: var(--mk-g700);
}
}
}
.mk-actions {
display: flex;
justify-content: flex-end;
gap: 9px;
padding: 8px 22px 18px;
}
.mk-btn {
padding: 8px 16px;
border: 1px solid var(--mk-g300);
background: #fff;
border-radius: 8px;
font-size: 12.5px;
font-weight: 600;
color: var(--mk-g700);
cursor: pointer;
font-family: inherit;
&:hover:not(:disabled) {
background: var(--mk-g100);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
&--ok {
background: var(--mk-green);
border-color: var(--mk-green);
color: #fff;
&:hover:not(:disabled) {
background: #12692f;
}
}
&--str {
background: var(--mk-red);
border-color: var(--mk-red);
color: #fff;
&:hover:not(:disabled) {
background: #a5301f;
}
}
}
@@ -0,0 +1,63 @@
/**
* 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 { FormsModule } from '@angular/forms';
import { MatDialogRef, MAT_DIALOG_DATA, MatDialogContent, MatDialogActions } from '@angular/material/dialog';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
export interface MarkEntryDialogData {
disposition: 'WHITELIST' | 'ESCALATE';
journalEntryId: number;
transactionId: string;
amount: number;
glAccountName: string;
transactionDate: string;
}
/**
* Captures the reviewer's reason before a ledger entry is marked.
*
* Notes are not optional on an escalation: the Principal Officer picks the case up
* with nothing else to go on, and an STR filed on an unexplained referral is not
* defensible. A whitelist may be recorded without them.
*/
@Component({
selector: 'mifosx-mark-entry-dialog',
templateUrl: './mark-entry-dialog.component.html',
styleUrls: ['./mark-entry-dialog.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule,
FaIconComponent,
MatDialogContent,
MatDialogActions
]
})
export class MarkEntryDialogComponent {
dialogRef = inject<MatDialogRef<MarkEntryDialogComponent>>(MatDialogRef);
data = inject<MarkEntryDialogData>(MAT_DIALOG_DATA);
notes = '';
get isEscalation(): boolean {
return this.data.disposition === 'ESCALATE';
}
get canSubmit(): boolean {
return !this.isEscalation || this.notes.trim().length > 0;
}
confirm(): void {
if (this.canSubmit) {
this.dialogRef.close(this.notes.trim());
}
}
}
@@ -535,6 +535,14 @@
</div>
</div>
@if (markError) {
<div class="gl-mark-error">
<fa-icon icon="exclamation-triangle"></fa-icon>
<span>{{ markError }}</span>
<button class="gl-mark-error__close" (click)="markError = ''">&times;</button>
</div>
}
<div class="gl-table-shell">
<mat-table [dataSource]="dataSource" matSort class="gl-mat-table">
<!-- #ID -->
@@ -692,6 +700,50 @@
</mat-cell>
</ng-container>
<!-- AML review — whitelist or refer the entry to STR -->
<ng-container matColumnDef="amlMark">
<mat-header-cell *matHeaderCellDef class="gl-th">AML Review</mat-header-cell>
<mat-cell *matCellDef="let e" class="gl-td">
@if (markOf(e); as mark) {
<span
class="gl-mark"
[class.gl-mark--str]="mark.disposition === 'ESCALATE'"
[matTooltip]="
(mark.disposition === 'ESCALATE'
? 'Escalated to STR' + (mark.strCaseId ? ' — case #' + mark.strCaseId : '')
: 'Whitelisted') +
(mark.reviewedBy ? ' by ' + mark.reviewedBy : '') +
(mark.notes ? ' — ' + mark.notes : '')
"
>
<fa-icon [icon]="mark.disposition === 'ESCALATE' ? 'exclamation-triangle' : 'check-circle'"></fa-icon>
{{ mark.disposition === 'ESCALATE' ? 'Suspicious' : 'Whitelisted' }}
</span>
} @else if (isMarking(e)) {
<span class="gl-mark gl-mark--busy">Saving…</span>
} @else if (canMark(e)) {
<span class="gl-mark-actions">
<button
class="gl-mark-btn gl-mark-btn--ok"
matTooltip="Reviewed and in order — clear this entry"
(click)="openMarkDialog(e, 'WHITELIST')"
>
<fa-icon icon="check-circle"></fa-icon> Whitelist
</button>
<button
class="gl-mark-btn gl-mark-btn--str"
matTooltip="Refer to the Principal Officer as an STR case"
(click)="openMarkDialog(e, 'ESCALATE')"
>
<fa-icon icon="exclamation-triangle"></fa-icon> Suspicious
</button>
</span>
} @else {
<span class="gl-mark gl-mark--na" [matTooltip]="markBlockedReason(e)">—</span>
}
</mat-cell>
</ng-container>
<!-- Actions -->
<ng-container matColumnDef="actions">
<mat-header-cell *matHeaderCellDef class="gl-th"></mat-header-cell>
@@ -1328,6 +1328,17 @@
flex: 0 0 130px;
}
// Wide enough to hold both action buttons on one line. Every other column
// declares a width; without one this collapsed and clipped its contents.
::ng-deep .mat-column-amlMark {
width: 184px;
min-width: 184px;
flex: 0 0 184px;
// Keeps the "AML REVIEW" heading on one line instead of clipping it.
white-space: nowrap;
overflow: visible;
}
::ng-deep .mat-column-actions {
width: 50px;
min-width: 50px;
@@ -1556,6 +1567,112 @@
}
}
// ── AML review column ─────────────────────────────────────────────────────────
// Never wrap — a wrapped second button used to be clipped by the row height.
.gl-mark-actions {
display: flex;
align-items: center;
gap: 5px;
flex-wrap: nowrap;
width: 100%;
}
.gl-mark-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 4px 8px;
border: 1px solid var(--c-border);
background: var(--c-surface);
border-radius: 6px;
font-size: 0.62rem;
font-weight: 700;
line-height: 1.2;
color: var(--c-text-2);
cursor: pointer;
font-family: inherit;
white-space: nowrap;
flex: 1 1 auto;
transition: all var(--transition);
// Tinted at rest, not only on hover — these have to be findable in a dense
// table without the reviewer hunting for them.
&--ok {
border-color: rgb(16 185 129 / 45%);
background: var(--c-cr-bg);
color: #047857;
&:hover {
border-color: var(--c-cr);
background: #d1fae5;
}
}
&--str {
border-color: rgb(239 68 68 / 45%);
background: var(--c-dr-bg);
color: #b91c1c;
&:hover {
border-color: var(--c-dr);
background: #fee2e2;
}
}
}
.gl-mark {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 2px 9px;
border-radius: 999px;
font-size: 0.62rem;
font-weight: 700;
white-space: nowrap;
background: var(--c-cr-bg);
color: #047857;
&--str {
background: var(--c-dr-bg);
color: #b91c1c;
}
&--busy {
background: #f1f5f9;
color: var(--c-text-3);
}
&--na {
background: transparent;
color: var(--c-text-3);
cursor: help;
}
}
.gl-mark-error {
display: flex;
align-items: center;
gap: 9px;
margin: 0 0 10px;
padding: 9px 14px;
border: 1px solid #fecaca;
background: var(--c-dr-bg);
border-radius: 8px;
font-size: 0.75rem;
color: #b91c1c;
&__close {
margin-left: auto;
border: 0;
background: transparent;
font-size: 1.1rem;
line-height: 1;
color: inherit;
cursor: pointer;
}
}
// ── Empty state ───────────────────────────────────────────────────────────────
.gl-empty {
display: flex;
@@ -41,9 +41,20 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { MatCardModule } from '@angular/material/card';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatIconModule } from '@angular/material/icon';
import { MatDialog } from '@angular/material/dialog';
import { AmlService } from 'app/aml/aml.service';
import { MarkEntryDialogComponent, MarkEntryDialogData } from './mark-entry-dialog.component';
const STATE_KEY = 'gl_ledger_filter_state';
/** How a ledger entry has been marked by a reviewer, keyed on journal entry id. */
interface EntryMark {
disposition: 'WHITELIST' | 'ESCALATE';
strCaseId: number | null;
reviewedBy: string;
notes: string;
}
@Component({
selector: 'mifosx-search-journal-entry',
standalone: true,
@@ -87,6 +98,8 @@ export class SearchJournalEntryComponent implements OnInit, OnDestroy, AfterView
private settingsService = inject(SettingsService);
private dateUtils = inject(Dates);
private route = inject(ActivatedRoute);
private amlService = inject(AmlService);
private dialog = inject(MatDialog);
// ── Misc ──────────────────────────────────────────────────────────────────
minDate = new Date(2000, 0, 1);
@@ -129,6 +142,7 @@ export class SearchJournalEntryComponent implements OnInit, OnDestroy, AfterView
'manualEntry',
'createdByUserName',
'submittedOnDate',
'amlMark',
'actions'
];
dayWiseColumns = [
@@ -157,6 +171,14 @@ export class SearchJournalEntryComponent implements OnInit, OnDestroy, AfterView
dayWiseData: any[] = [];
glAccountInfo: any = null;
// ── AML marking ───────────────────────────────────────────────────────────
/** Marks already recorded, by journal entry id. */
private marks = new Map<number, EntryMark>();
/** Entry ids with a request in flight, so a row cannot be double-submitted. */
private marking = new Set<number>();
markError = '';
// ── UI state ──────────────────────────────────────────────────────────────
activeQuickFilter = '';
activeFiltersCount = 0;
@@ -212,6 +234,103 @@ export class SearchJournalEntryComponent implements OnInit, OnDestroy, AfterView
this.setFilteredOffices();
this.getJournalEntries();
this.loadLedgerSummary();
this.loadMarks();
}
// ── AML marking ───────────────────────────────────────────────────────────
/**
* Marks are loaded once for the whole ledger rather than per page, because the
* user pages and re-filters constantly and the set is small — only entries a
* reviewer has actually touched.
*/
private loadMarks(): void {
this.amlService.getManualReferrals().subscribe({
next: (rows) => {
this.marks = new Map(
(rows || []).map((r: any) => [
r.journalEntryId,
{
disposition: r.disposition === 'ESCALATE' ? 'ESCALATE' : 'WHITELIST',
strCaseId: r.strCaseId ?? null,
reviewedBy: r.reviewedByUsername || '',
notes: r.reviewNotes || ''
} as EntryMark
])
);
},
// A ledger that cannot reach the AML service still has to render.
error: () => (this.marks = new Map())
});
}
markOf(entry: any): EntryMark | null {
return this.marks.get(entry?.id) ?? null;
}
isMarking(entry: any): boolean {
return this.marking.has(entry?.id);
}
/**
* An alert needs a customer, and a journal entry only reaches one through the
* savings, loan or client transaction that produced it. A purely manual GL
* posting reaches none, so it cannot be referred — the server enforces this too.
*/
canMark(entry: any): boolean {
return !!entry && !this.markOf(entry) && !!(entry.savingsAccountNo || entry.entityId);
}
markBlockedReason(entry: any): string {
if (this.markOf(entry)) {
return 'Already reviewed — a ledger entry is marked once';
}
return 'No customer behind this entry, so it cannot be referred';
}
openMarkDialog(entry: any, disposition: 'WHITELIST' | 'ESCALATE'): void {
if (!this.canMark(entry) || this.isMarking(entry)) {
return;
}
const data: MarkEntryDialogData = {
disposition,
journalEntryId: entry.id,
transactionId: entry.transactionId,
amount: entry.amount,
glAccountName: `${entry.glAccountCode} — ${entry.glAccountName}`,
transactionDate: this.dateUtils.formatDate(entry.transactionDate, this.settingsService.dateFormat)
};
this.dialog
.open(MarkEntryDialogComponent, { data, width: '520px', autoFocus: false })
.afterClosed()
.subscribe((notes: string | undefined) => {
if (notes !== undefined) {
this.submitMark(entry, disposition, notes);
}
});
}
private submitMark(entry: any, disposition: 'WHITELIST' | 'ESCALATE', notes: string): void {
this.markError = '';
this.marking.add(entry.id);
this.amlService.referJournalEntry(entry.id, disposition, notes).subscribe({
next: (res: any) => {
this.marking.delete(entry.id);
this.marks.set(entry.id, {
disposition,
strCaseId: res?.strCaseId ?? null,
reviewedBy: res?.reviewedByUsername || '',
notes
});
},
error: (err: any) => {
this.marking.delete(entry.id);
this.markError =
err?.error?.defaultUserMessage || err?.error?.errors?.[0]?.defaultUserMessage || 'Could not mark this entry.';
}
});
}
ngOnDestroy() {
+111
View File
@@ -0,0 +1,111 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
/**
* AML transaction-monitoring API: live alerts raised by the rule engine, the
* two-tier AML Officer / Principal Officer review workflow, STR cases (with
* their 7-working-day FIU filing deadline), monthly CTR reports, and a
* client's risk-category change history.
*/
@Injectable({
providedIn: 'root'
})
export class AmlService {
private http = inject(HttpClient);
// ═══ Alerts (AML Officer) ═══
getAlerts(status?: string): Observable<any[]> {
const params = status ? new HttpParams().set('status', status) : undefined;
return this.http.get<any[]>('/aml/alerts', { params });
}
/** disposition: 'WHITELIST' | 'ESCALATE' */
reviewAlert(alertId: number, disposition: 'WHITELIST' | 'ESCALATE', notes: string): Observable<any> {
return this.http.post(`/aml/alerts/${alertId}/review`, { disposition, notes });
}
// ═══ Manual referrals from the general ledger ═══
/**
* Ledger entries a user has already marked, keyed on `journalEntryId`.
*
* Separate from `getAlerts()` because these are not engine alerts — they carry the
* journal entry id rather than a savings or loan transaction id, so the ledger
* screen can match them to the rows it is showing.
*/
getManualReferrals(): Observable<any[]> {
return this.http.get<any[]>('/aml/alerts/manual-referrals');
}
/**
* Marks a ledger entry. WHITELIST clears it with no report; ESCALATE opens an STR
* case for the Principal Officer, the same road an escalated engine alert takes.
* The entry must have a customer behind it or the server rejects it.
*/
referJournalEntry(journalEntryId: number, disposition: 'WHITELIST' | 'ESCALATE', notes: string): Observable<any> {
return this.http.post('/aml/alerts/manual-referral', { journalEntryId, disposition, notes });
}
// ═══ STR cases (Principal Officer) ═══
getStrCases(): Observable<any[]> {
return this.http.get<any[]>('/aml/str-cases');
}
getStrCase(strCaseId: number): Observable<any> {
return this.http.get(`/aml/str-cases/${strCaseId}`);
}
getWeeklyDigest(): Observable<any[]> {
return this.http.get<any[]>('/aml/str-cases/weekly-digest');
}
assignPrincipalOfficer(strCaseId: number, principalOfficerUserId: number): Observable<any> {
return this.http.post(`/aml/str-cases/${strCaseId}/assign`, { principalOfficerUserId });
}
/** conclusion: 'NOT_SUSPICIOUS' | 'SUSPICIOUS' */
concludeStrCase(
strCaseId: number,
conclusion: 'NOT_SUSPICIOUS' | 'SUSPICIOUS',
investigationNotes: string,
sourceOfFundsNotes: string
): Observable<any> {
return this.http.post(`/aml/str-cases/${strCaseId}/conclude`, {
conclusion,
investigationNotes,
sourceOfFundsNotes
});
}
fileStr(strCaseId: number, referenceNo: string): Observable<any> {
return this.http.post(`/aml/str-cases/${strCaseId}/file`, { referenceNo });
}
// ═══ CTR reports (mechanical, threshold-based) ═══
getCtrReports(): Observable<any[]> {
return this.http.get<any[]>('/aml/ctr-reports');
}
/** month format: yyyy-MM */
generateCtrReport(month: string): Observable<any> {
return this.http.post('/aml/ctr-reports/generate', { month });
}
// ═══ Client risk-category history ═══
getRiskCategoryHistory(clientId: number): Observable<any[]> {
return this.http.get<any[]>(`/clients/${clientId}/risk-category-history`);
}
}
+10
View File
@@ -64,7 +64,12 @@ import { BankCalendarModule } from './bank-calendar/bank-calendar.module';
import { DeafModule } from './deaf/deaf.module';
import { DicgcModule } from './dicgc/dicgc.module';
import { FiuModule } from './fiu/fiu.module';
import { GlReportsModule } from './gl-reports/gl-reports.module';
import { SftModule } from './sft/sft.module';
import { InvestmentModule } from './investment/investment.module';
import { NpaModule } from './npa/npa.module';
import { ReconciliationModule } from './reconciliation/reconciliation.module';
import { LoanOriginationModule } from './loan-origination/loan-origination.module';
import { DigitalModule } from './digital/digital.module';
import { LetterMgmtModule } from './letter-mgmt/letter-mgmt.module';
import { AccountsModule } from './accounts/accounts.module';
@@ -171,7 +176,12 @@ export function HttpLoaderFactory(http: HttpClient) {
DeafModule,
DicgcModule,
FiuModule,
GlReportsModule,
SftModule,
InvestmentModule,
NpaModule,
ReconciliationModule,
LoanOriginationModule,
DigitalModule,
LetterMgmtModule,
AccountsModule,
@@ -0,0 +1,292 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="pg">
<header class="pg-head">
<div>
<div class="pg-eyebrow">SUB LEDGER · TRANSACTIONS · {{ scopeName | uppercase }}</div>
<h1 class="pg-title">Cash / Clearing / Transfer - Summary Report</h1>
<p class="pg-sub">
Opening, credit, debit and closing position across Cash, Transfer, Clearing and Digital channels as on
{{ asOnDate }}.
</p>
</div>
<div class="pg-toolbar">
@if (isHeadOffice) {
<label class="pg-field">
<span>Branch</span>
<select class="pg-select" [ngModel]="selectedBranchCode" (ngModelChange)="setBranch($event)">
<option value="ALL">All Branches (Consolidated)</option>
@for (b of branches; track b.code) {
<option [value]="b.code">{{ b.name }}</option>
}
</select>
</label>
}
<button type="button" class="pg-btn" [class.pg-btn--active]="showDetails" (click)="toggleDetails()">
{{ showDetails ? 'Summary View' : 'Transaction Details' }}
</button>
<button type="button" class="pg-btn pg-btn--ghost" [disabled]="generating" (click)="exportExcel()">
{{ generating ? 'Exporting…' : 'Export to Excel' }}
</button>
</div>
</header>
<div class="cctr-grand-summary">
<div class="cctr-grand-summary__item">
<span class="cctr-grand-summary__label">Opening</span>
<span class="cctr-grand-summary__value">₹{{ grandOpening | number: '1.2-2' }}</span>
</div>
<div class="cctr-grand-summary__item">
<span class="cctr-grand-summary__label">Credit</span>
<span class="cctr-grand-summary__value col-in">₹{{ grandCredit | number: '1.2-2' }}</span>
</div>
<div class="cctr-grand-summary__item">
<span class="cctr-grand-summary__label">Debit</span>
<span class="cctr-grand-summary__value col-out">₹{{ grandDebit | number: '1.2-2' }}</span>
</div>
<div class="cctr-grand-summary__item">
<span class="cctr-grand-summary__label">Closing</span>
<span class="cctr-grand-summary__value">₹{{ grandClosing | number: '1.2-2' }}</span>
</div>
</div>
@if (isHeadOffice && selectedBranchCode === 'ALL') {
<div class="pg-card">
<div class="pos-section-label">Branch-wise Break-up — click a branch to drill in</div>
<table class="pg-table">
<thead>
<tr>
<th>Branch</th>
<th class="num">Opening</th>
<th class="num col-in">Credit</th>
<th class="num col-out">Debit</th>
<th class="num col-net">Closing</th>
</tr>
</thead>
<tbody>
@for (b of branchBreakup; track b.code) {
<tr class="cctr-branch-row" (click)="setBranch(b.code)">
<td>{{ b.name }}</td>
<td class="num">₹{{ b.opening | number: '1.2-2' }}</td>
<td class="num col-in">₹{{ b.credit | number: '1.2-2' }}</td>
<td class="num col-out">₹{{ b.debit | number: '1.2-2' }}</td>
<td class="num col-net">₹{{ b.closing | number: '1.2-2' }}</td>
</tr>
}
</tbody>
<tfoot>
<tr class="cctr-total-row">
<td>All Branches</td>
<td class="num">₹{{ grandOpening | number: '1.2-2' }}</td>
<td class="num">₹{{ grandCredit | number: '1.2-2' }}</td>
<td class="num">₹{{ grandDebit | number: '1.2-2' }}</td>
<td class="num">₹{{ grandClosing | number: '1.2-2' }}</td>
</tr>
</tfoot>
</table>
</div>
}
<div class="cctr-tabs">
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeTab === 'cash'"
(click)="setTab('cash')"
>
Cash
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeTab === 'transfer'"
(click)="setTab('transfer')"
>
Transfer
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeTab === 'clearing'"
(click)="setTab('clearing')"
>
Clearing
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="activeTab === 'digital'"
(click)="setTab('digital')"
>
Digital
</button>
</div>
<!-- Summary (Opening | Credit | Debit | Closing) -->
@if (!showDetails) {
<div class="pg-card">
@if (activeTab === 'digital') {
<div class="pos-section-label">RTGS · NEFT · UPI · ATM · NACH — Overall Position</div>
}
<table class="pg-table">
<thead>
<tr>
@if (activeTab !== 'digital') {
<th>GL Code</th>
}
<th>Particulars</th>
<th class="num">Opening</th>
<th class="num col-in">Credit</th>
<th class="num col-out">Debit</th>
<th class="num col-net">Closing</th>
</tr>
</thead>
<tbody>
@for (r of rowsForActiveTab; track r.code) {
<tr>
@if (activeTab !== 'digital') {
<td>
<span class="pg-chip">{{ r.code }}</span>
</td>
}
<td>{{ r.name }}</td>
<td class="num">₹{{ r.opening | number: '1.2-2' }}</td>
<td class="num col-in">₹{{ rowCredit(r, txnsForActiveTab) | number: '1.2-2' }}</td>
<td class="num col-out">₹{{ rowDebit(r, txnsForActiveTab) | number: '1.2-2' }}</td>
<td class="num col-net">₹{{ closing(r, txnsForActiveTab) | number: '1.2-2' }}</td>
</tr>
}
</tbody>
<tfoot>
<tr class="cctr-total-row">
<td [attr.colspan]="activeTab !== 'digital' ? 2 : 1">Total</td>
<td class="num">₹{{ total(rowsForActiveTab, txnsForActiveTab, 'opening') | number: '1.2-2' }}</td>
<td class="num">₹{{ total(rowsForActiveTab, txnsForActiveTab, 'credit') | number: '1.2-2' }}</td>
<td class="num">₹{{ total(rowsForActiveTab, txnsForActiveTab, 'debit') | number: '1.2-2' }}</td>
<td class="num">₹{{ totalClosing(rowsForActiveTab, txnsForActiveTab) | number: '1.2-2' }}</td>
</tr>
</tfoot>
</table>
</div>
}
<!-- Transaction Details (all txns for the selected category, optionally filtered by GL/channel) -->
@if (showDetails) {
@if (rowsForActiveTab.length > 1) {
<div class="cctr-tabs">
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="glFilter === null"
(click)="setGlFilter(null)"
>
All
</button>
@for (r of rowsForActiveTab; track r.code) {
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="glFilter === r.code"
(click)="setGlFilter(r.code)"
>
{{ filterLabel(r) }}
</button>
}
</div>
}
<div class="pg-card">
<div class="pos-section-label">
{{
activeTab === 'cash'
? 'Cash'
: activeTab === 'transfer'
? 'Transfer'
: activeTab === 'clearing'
? 'Clearing'
: 'Digital'
}}
— Transaction Details (Full Double Entry)
@if (glFilter !== null) {
· {{ glFilter }}
}
</div>
<table class="pg-table cctr-journal">
<thead>
<tr>
<th>Date</th>
<th>Voucher / Ref No</th>
<th>Dr / Cr</th>
<th>GL / Account</th>
<th class="num col-out">Debit</th>
<th class="num col-in">Credit</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@for (t of filteredTxns; track t.voucherNo) {
<tr class="cctr-journal-row cctr-journal-row--first">
<td>{{ asOnDate }}</td>
<td>
<span class="pg-chip">{{ display(t.voucherNo) }}</span>
</td>
<td><span class="cctr-drcr cctr-drcr--dr">Dr</span></td>
<td>
@if (t.debit > 0) {
<span class="pg-chip">{{ t.glCode }}</span> {{ glName(t.glCode) }} — {{ display(t.accountNo) }} ({{
t.accountName
}})
} @else {
<span class="cctr-contra">{{ contraLabel(t) }}</span>
}
</td>
<td class="num col-out">₹{{ legAmount(t) | number: '1.2-2' }}</td>
<td class="num"></td>
<td rowspan="2">
<span class="cctr-balanced-chip">✓ Balanced</span>
</td>
</tr>
<tr class="cctr-journal-row cctr-journal-row--second">
<td></td>
<td></td>
<td><span class="cctr-drcr cctr-drcr--cr">Cr</span></td>
<td>
@if (t.credit > 0) {
<span class="pg-chip">{{ t.glCode }}</span> {{ glName(t.glCode) }} — {{ display(t.accountNo) }} ({{
t.accountName
}})
} @else {
<span class="cctr-contra">{{ contraLabel(t) }}</span>
}
</td>
<td class="num"></td>
<td class="num col-in">₹{{ legAmount(t) | number: '1.2-2' }}</td>
</tr>
} @empty {
<tr>
<td colspan="7" class="pg-empty">No transactions for this filter.</td>
</tr>
}
</tbody>
<tfoot>
<tr class="cctr-total-row">
<td colspan="4">Total ({{ filteredTxns.length }} vouchers)</td>
<td class="num">
₹{{ txnTotal(filteredTxns, 'debit') + txnTotal(filteredTxns, 'credit') | number: '1.2-2' }}
</td>
<td class="num">
₹{{ txnTotal(filteredTxns, 'debit') + txnTotal(filteredTxns, 'credit') | number: '1.2-2' }}
</td>
<td></td>
</tr>
</tfoot>
</table>
</div>
}
</div>
@@ -0,0 +1,138 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
.cctr-tabs {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.cctr-total-row {
td {
background: #f1f5f9;
font-weight: 800;
border-top: 2px solid #cbd5e1;
border-bottom: none;
color: #0f172a;
}
}
.cctr-grand-summary {
display: flex;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
overflow: hidden;
&__item {
flex: 1;
padding: 14px 20px;
display: flex;
flex-direction: column;
gap: 4px;
border-right: 1px solid #e2e8f0;
&:last-child {
border-right: none;
}
}
&__label {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.07em;
color: #64748b;
}
&__value {
font-size: 20px;
font-weight: 800;
font-variant-numeric: tabular-nums;
color: #0f172a;
}
}
.cctr-subtext {
font-size: 11px;
color: #94a3b8;
margin-top: 2px;
}
.cctr-branch-row {
cursor: pointer;
&:hover td {
background: #eff6ff;
}
}
// Both journal lines of one voucher share a tint and sit flush against each other (no dotted
// divider between them) so the pair reads as a single entry; a hairline separates one voucher's
// pair from the next.
.cctr-journal-row {
&--first td {
border-bottom: none;
padding-bottom: 4px;
}
&--second td {
border-top: none;
padding-top: 4px;
border-bottom: 1px solid #e2e8f0;
}
&--first,
&--second {
&:hover td {
background: #f8fafc;
}
}
}
.cctr-drcr {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 28px;
padding: 1px 8px;
border-radius: 6px;
font-size: 11px;
font-weight: 800;
letter-spacing: 0.03em;
&--dr {
background: #eff6ff;
color: #1d4ed8;
border: 1px solid #bfdbfe;
}
&--cr {
background: #f0fdf4;
color: #15803d;
border: 1px solid #bbf7d0;
}
}
.cctr-contra {
color: #64748b;
font-style: italic;
}
.cctr-balanced-chip {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
background: #f0fdf4;
color: #15803d;
border: 1px solid #bbf7d0;
white-space: nowrap;
}
+88
View File
@@ -0,0 +1,88 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
// Shared "Export to Excel" builder for Cashier reports (mirrors ../investment/investment-export.ts)
// — centralised so the ExcelJS boilerplate (title/meta/header rows, number formatting, safe sheet
// names, Blob download) is written once and every cashier report's export behaves the same way.
import * as ExcelJS from 'exceljs';
export interface ExportSheetSpec {
/** Worksheet tab name — sanitised for Excel's forbidden characters (\ / ? * [ ]) and length. */
sheetName: string;
/** Bold title row, merged across all columns. */
title: string;
/** Optional italic subtitle row (e.g. scope/date), merged across all columns. */
subtitle?: string;
headers: string[];
rows: (string | number | null)[][];
widths?: number[];
}
/** Excel worksheet names can't contain \ / ? * [ ] or exceed 31 chars. */
function safeSheetName(name: string): string {
return name.replace(/[\\/?*[\]]/g, '-').slice(0, 31);
}
function buildSheet(wb: ExcelJS.Workbook, spec: ExportSheetSpec): void {
const sheet = wb.addWorksheet(safeSheetName(spec.sheetName));
sheet.columns = (spec.widths || spec.headers.map(() => 18)).map((w) => ({ width: w }));
const title = sheet.addRow([spec.title]);
sheet.mergeCells(title.number, 1, title.number, spec.headers.length);
title.font = { bold: true, size: 14, color: { argb: 'FFFFFFFF' } };
title.alignment = { horizontal: 'center', vertical: 'middle' };
title.height = 24;
title.getCell(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF0F4F3C' } };
if (spec.subtitle) {
const meta = sheet.addRow([spec.subtitle]);
sheet.mergeCells(meta.number, 1, meta.number, spec.headers.length);
meta.font = { italic: true, size: 10 };
}
sheet.addRow([]);
const head = sheet.addRow(spec.headers);
head.font = { bold: true };
head.alignment = { wrapText: true, horizontal: 'center' };
head.eachCell((c) => {
c.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE2E8F0' } };
c.border = { bottom: { style: 'thin' } };
});
for (const r of spec.rows) {
const row = sheet.addRow(r);
r.forEach((v, idx) => {
if (typeof v === 'number') {
row.getCell(idx + 1).numFmt = '#,##0.00';
}
});
}
sheet.views = [{ state: 'frozen', ySplit: spec.subtitle ? 4 : 3 }];
}
/** Builds a (possibly multi-sheet) workbook and triggers a browser download. */
export async function exportWorkbook(sheets: ExportSheetSpec[], fileName: string): Promise<void> {
const wb = new ExcelJS.Workbook();
wb.creator = 'CBSNexora';
wb.created = new Date();
sheets.forEach((s) => buildSheet(wb, s));
const buffer = await wb.xlsx.writeBuffer();
const blob = new Blob([buffer], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${fileName.replace(/[^\w]+/g, '-')}.xlsx`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
+43
View File
@@ -62,6 +62,38 @@ const routes: Routes = [
),
data: { title: 'My Disbursement Requests', breadcrumb: 'My Disbursement Requests' }
},
{
path: 'share-withdrawal-request',
loadComponent: () =>
import('../clerk/share-withdrawal-request/share-withdrawal-request.component').then(
(m) => m.ShareWithdrawalRequestComponent
),
data: { title: 'New Share Withdrawal Request', breadcrumb: 'New Share Withdrawal Request', mode: 'create' }
},
{
path: 'share-withdrawal-requests',
loadComponent: () =>
import('../clerk/my-share-withdrawal-requests/my-share-withdrawal-requests.component').then(
(m) => m.MyShareWithdrawalRequestsComponent
),
data: { title: 'My Share Requests', breadcrumb: 'My Share Requests' }
},
{
path: 'share-closure-request',
loadComponent: () =>
import('../clerk/share-closure-request/share-closure-request.component').then(
(m) => m.ShareClosureRequestComponent
),
data: { title: 'New Share Closure Request', breadcrumb: 'New Share Closure Request', mode: 'create' }
},
{
path: 'share-closure-requests',
loadComponent: () =>
import('../clerk/my-share-closure-requests/my-share-closure-requests.component').then(
(m) => m.MyShareClosureRequestsComponent
),
data: { title: 'My Share Closure Requests', breadcrumb: 'My Share Closure Requests' }
},
{
path: 'account-facilities',
loadComponent: () =>
@@ -179,6 +211,17 @@ const routes: Routes = [
loadComponent: () =>
import('./my-deposit-requests/my-deposit-requests.component').then((m) => m.MyDepositRequestsComponent),
data: { title: 'My Deposit Requests', breadcrumb: 'My Deposit Requests' }
},
{
path: 'cash-clearing-transfer-summary',
loadComponent: () =>
import('./cash-clearing-transfer-summary/cash-clearing-transfer-summary.component').then(
(m) => m.CashClearingTransferSummaryComponent
),
data: {
title: 'Cash / Clearing / Transfer - Summary Report',
breadcrumb: 'Cash / Clearing / Transfer - Summary Report'
}
}
]
}
@@ -0,0 +1,53 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
/** HTTP service for the Share Closure maker-checker workflow — mirrors
* ClerkShareWithdrawalService, adapted for closing an already-zero-share account. */
@Injectable({ providedIn: 'root' })
export class ClerkShareClosureService {
private http = inject(HttpClient);
submit(data: any): Observable<any> {
return this.http.post('/clerk/share-closure-requests', data);
}
getMyRequests(status?: string, offset = 0, limit = 20, fromDate?: string, toDate?: string): Observable<any> {
let params = new HttpParams().set('offset', String(offset)).set('limit', String(limit));
if (status) params = params.set('status', status);
if (fromDate) params = params.set('fromDate', fromDate);
if (toDate) params = params.set('toDate', toDate);
return this.http.get('/clerk/share-closure-requests/my', { params });
}
getPendingRequests(status = 'PENDING', offset = 0, limit = 20, fromDate?: string, toDate?: string): Observable<any> {
let params = new HttpParams().set('status', status).set('offset', String(offset)).set('limit', String(limit));
if (fromDate) params = params.set('fromDate', fromDate);
if (toDate) params = params.set('toDate', toDate);
return this.http.get('/clerk/share-closure-requests', { params });
}
getById(id: number): Observable<any> {
return this.http.get(`/clerk/share-closure-requests/${id}`);
}
validate(id: number): Observable<any> {
return this.http.post(`/clerk/share-closure-requests/${id}/validate`, {});
}
approve(id: number): Observable<any> {
return this.http.post(`/clerk/share-closure-requests/${id}/approve`, {});
}
reject(id: number, reason: string): Observable<any> {
return this.http.post(`/clerk/share-closure-requests/${id}/reject`, { reason });
}
}
@@ -0,0 +1,53 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
/** HTTP service for the Share Withdrawal (redeem shares) maker-checker workflow — mirrors
* ClerkWithdrawalService (cash withdrawal), adapted for shares. */
@Injectable({ providedIn: 'root' })
export class ClerkShareWithdrawalService {
private http = inject(HttpClient);
submit(data: any): Observable<any> {
return this.http.post('/clerk/share-withdrawal-requests', data);
}
getMyRequests(status?: string, offset = 0, limit = 20, fromDate?: string, toDate?: string): Observable<any> {
let params = new HttpParams().set('offset', String(offset)).set('limit', String(limit));
if (status) params = params.set('status', status);
if (fromDate) params = params.set('fromDate', fromDate);
if (toDate) params = params.set('toDate', toDate);
return this.http.get('/clerk/share-withdrawal-requests/my', { params });
}
getPendingRequests(status = 'PENDING', offset = 0, limit = 20, fromDate?: string, toDate?: string): Observable<any> {
let params = new HttpParams().set('status', status).set('offset', String(offset)).set('limit', String(limit));
if (fromDate) params = params.set('fromDate', fromDate);
if (toDate) params = params.set('toDate', toDate);
return this.http.get('/clerk/share-withdrawal-requests', { params });
}
getById(id: number): Observable<any> {
return this.http.get(`/clerk/share-withdrawal-requests/${id}`);
}
validate(id: number): Observable<any> {
return this.http.post(`/clerk/share-withdrawal-requests/${id}/validate`, {});
}
approve(id: number): Observable<any> {
return this.http.post(`/clerk/share-withdrawal-requests/${id}/approve`, {});
}
reject(id: number, reason: string): Observable<any> {
return this.http.post(`/clerk/share-withdrawal-requests/${id}/reject`, { reason });
}
}
@@ -146,7 +146,7 @@
[class.ws-f__ctl--req]="!row.paymentTypeId"
>
<option [ngValue]="null">— Select —</option>
@for (pt of paymentTypes; track pt.id) {
@for (pt of paymentTypesForRow(row); track pt.id) {
<option [ngValue]="pt.id">{{ pt.name }}</option>
}
</select>
@@ -231,8 +231,11 @@
<span class="ws-caption">Payment instrument</span>
<div class="ws-group__fields">
<div class="ws-f ws-f--amt">
<label class="ws-f__lbl" title="Transaction Amount">
AMOUNT
<label
class="ws-f__lbl"
[title]="row.acTyp === 'SHR' ? 'Number of Shares' : 'Transaction Amount'"
>
{{ row.acTyp === 'SHR' ? 'SHARE NO.' : 'AMOUNT' }}
@if (!isClerk && activeWithdrawalRequest) {
<span class="ws-amt-locked-badge" title="Amount locked — approved withdrawal">🔒</span>
}
@@ -242,20 +245,48 @@
[class.ws-amt-wrap--cash]="isCashPayment && selectedRow === row"
[class.ws-amt-wrap--locked]="!isClerk && !!activeWithdrawalRequest"
>
<span class="ws-amt-prefix">₹</span>
@if (row.acTyp !== 'SHR') {
<span class="ws-amt-prefix">₹</span>
}
<input
class="ws-f__ctl ws-amt-input"
type="number"
[value]="row.amount ?? ''"
(input)="row.amount = +$any($event.target).value"
(input)="row.amount = +$any($event.target).value; syncShareLinkedAmounts()"
(click)="$event.stopPropagation()"
placeholder="0.00"
[placeholder]="row.acTyp === 'SHR' ? '0' : '0.00'"
[readonly]="!isClerk && !!activeWithdrawalRequest"
[class.ws-amt-input--locked]="!isClerk && !!activeWithdrawalRequest"
/>
</div>
@if (row.acTyp === 'SHR' && rowSharePrices[row.acNo]) {
<span class="ws-f__hint"
>= ₹{{ (row.amount ?? 0) * rowSharePrices[row.acNo] | number: '1.2-2' }} @ ₹{{
rowSharePrices[row.acNo] | number: '1.2-2'
}}/share</span
>
}
</div>
@if (row.typ !== 'Withdrawal Slip') {
@if (row.acTyp === 'SHR' && row.typ === 'Enter Bank Transfer') {
<div class="ws-f ws-f--cheqno">
<label class="ws-f__lbl" title="Board Resolution Number">RESOLUTION NO.</label>
<input
class="ws-f__ctl"
[(ngModel)]="row.resolutionNumber"
(click)="$event.stopPropagation()"
placeholder="e.g. BR-2026-014"
/>
</div>
<div class="ws-f ws-f--cheqdt">
<label class="ws-f__lbl" title="Date of Meeting">MEETING DATE</label>
<input
class="ws-f__ctl"
type="date"
[(ngModel)]="row.meetingDate"
(click)="$event.stopPropagation()"
/>
</div>
} @else if (row.typ !== 'Withdrawal Slip') {
<div class="ws-f ws-f--cheqno">
<label class="ws-f__lbl" title="Cheque Number">CHEQUE NO.</label>
<input
@@ -712,6 +743,32 @@
'—'
}}</span>
</div>
@if (selectedRow?.acTyp === 'SHR') {
<div class="te-field">
<span class="te-label">No. of Shares Held</span
><span class="te-value te-mono">{{ accountDetails.summary?.totalApprovedShares ?? 0 }}</span>
</div>
<div class="te-field">
<span class="te-label">Market Price / Share</span
><span class="te-value te-mono"
>{{ currencySymbol }}{{ accountDetails.currentMarketPrice | number: '1.2-2' }}</span
>
</div>
<div class="te-field">
<span class="te-label">Resolution No.</span
><span class="te-value te-mono">{{ accountDetails.resolutionNumber || '—' }}</span>
</div>
<div class="te-field">
<span class="te-label">Linked Savings A/C No.</span
><span class="te-value te-mono">{{ accountDetails.savingsAccountNumber || '—' }}</span>
</div>
@if (accountDetails.linkedLoanAccountNo) {
<div class="te-field">
<span class="te-label">Linked Loan A/C No.</span
><span class="te-value te-mono">{{ accountDetails.linkedLoanAccountNo }}</span>
</div>
}
}
</div>
<div class="te-panel__header te-panel__header--mt">
@@ -724,7 +781,13 @@
<div class="te-panel__body">
<div class="te-balance-row">
<span class="te-bal-num">1.</span>
<span class="te-bal-label">{{ accountDetails?.glCode ? 'GL Balance' : 'Current Balance' }}</span>
<span class="te-bal-label">{{
selectedRow?.acTyp === 'SHR'
? 'Share Balance'
: accountDetails?.glCode
? 'GL Balance'
: 'Current Balance'
}}</span>
<span class="te-bal-value te-bal-value--cr"
>{{ currencySymbol }}{{ currentBalance | number: '1.2-2' }}</span
>
@@ -1281,7 +1344,7 @@
[class.ws-f__ctl--req]="!row.paymentTypeId"
>
<option [ngValue]="null">— Select —</option>
@for (pt of paymentTypes; track pt.id) {
@for (pt of paymentTypesForRow(row); track pt.id) {
<option [ngValue]="pt.id">{{ pt.name }}</option>
}
</select>
@@ -1360,7 +1423,7 @@
<div class="ws-group__fields">
<div class="ws-f ws-f--amt">
<label class="ws-f__lbl">
AMOUNT
{{ row.acTyp === 'SHR' ? 'SHARE NO.' : 'AMOUNT' }}
@if (!isClerk && activeWithdrawalRequest) {
<span class="ws-amt-locked-badge" title="Amount locked — approved withdrawal">🔒</span>
}
@@ -1370,27 +1433,51 @@
[class.ws-amt-wrap--cash]="isCashPayment && selectedRow === row"
[class.ws-amt-wrap--locked]="!isClerk && !!activeWithdrawalRequest"
>
<span class="ws-amt-prefix">₹</span>
@if (row.acTyp !== 'SHR') {
<span class="ws-amt-prefix">₹</span>
}
<input
class="ws-f__ctl ws-amt-input"
type="number"
[value]="row.amount ?? ''"
(input)="row.amount = +$any($event.target).value"
(input)="row.amount = +$any($event.target).value; syncShareLinkedAmounts()"
(click)="$event.stopPropagation()"
placeholder="0.00"
[placeholder]="row.acTyp === 'SHR' ? '0' : '0.00'"
[readonly]="!isClerk && !!activeWithdrawalRequest"
[class.ws-amt-input--locked]="!isClerk && !!activeWithdrawalRequest"
/>
</div>
@if (row.acTyp === 'SHR' && rowSharePrices[row.acNo]) {
<span class="ws-f__hint"
>= ₹{{ (row.amount ?? 0) * rowSharePrices[row.acNo] | number: '1.2-2' }} @ ₹{{
rowSharePrices[row.acNo] | number: '1.2-2'
}}/share</span
>
}
</div>
<div class="ws-f ws-f--cheqno">
<label class="ws-f__lbl">CHEQUE NO.</label>
<input class="ws-f__ctl ws-f__ctl--mono" [(ngModel)]="row.chequeNo" placeholder="—" />
</div>
<div class="ws-f ws-f--cheqdt">
<label class="ws-f__lbl">CHEQ. DATE</label>
<input class="ws-f__ctl" [(ngModel)]="row.cheqDate" placeholder="dd-mm-yyyy" />
</div>
@if (row.acTyp === 'SHR' && row.typ === 'Enter Bank Transfer') {
<div class="ws-f ws-f--cheqno">
<label class="ws-f__lbl">RESOLUTION NO.</label>
<input
class="ws-f__ctl"
[(ngModel)]="row.resolutionNumber"
placeholder="e.g. BR-2026-014"
/>
</div>
<div class="ws-f ws-f--cheqdt">
<label class="ws-f__lbl">MEETING DATE</label>
<input class="ws-f__ctl" type="date" [(ngModel)]="row.meetingDate" />
</div>
} @else {
<div class="ws-f ws-f--cheqno">
<label class="ws-f__lbl">CHEQUE NO.</label>
<input class="ws-f__ctl ws-f__ctl--mono" [(ngModel)]="row.chequeNo" placeholder="—" />
</div>
<div class="ws-f ws-f--cheqdt">
<label class="ws-f__lbl">CHEQ. DATE</label>
<input class="ws-f__ctl" [(ngModel)]="row.cheqDate" placeholder="dd-mm-yyyy" />
</div>
}
<div class="ws-f ws-f--tok">
<label class="ws-f__lbl">TOK. NO.</label>
<input
@@ -961,6 +961,14 @@ input.ws-amt-input--locked {
flex-shrink: 0;
}
.ws-f__hint {
display: block;
margin-top: 2px;
font-size: 10px;
color: $ws-muted;
font-weight: 500;
}
.ws-amt-input {
flex: 1;
height: 100%;
@@ -21,6 +21,7 @@ import { AuthenticationService } from '../../core/authentication/authentication.
import { ClerkUiStateService, ClerkSavedAccount } from '../clerk-ui-state.service';
import { ClerkWithdrawalService } from '../clerk-withdrawal.service';
import { ClerkDisbursementService } from '../clerk-disbursement.service';
import { ClerkShareWithdrawalService } from '../clerk-share-withdrawal.service';
import { CashierDepositService } from '../cashier-deposit.service';
import { SettingsService } from '../../settings/settings.service';
import {
@@ -47,6 +48,10 @@ interface TxnRow {
routingCode: string;
/** DD issuance charges/commission — only used when TYPE=DD, TXN TP=Cash, AC.TP=GL */
ddCharges: number | null;
/** Only used when this row is AC.TP=SHR inside an Enter Bank Transfer to its linked savings
* account — governs the share redemption (see submitShareRedemptionTransfer()). */
resolutionNumber?: string;
meetingDate?: string;
}
interface Denomination {
@@ -394,7 +399,12 @@ export class JointHolderDialogComponent implements OnInit {
this.kyc = kycArr[0] ?? null;
this.photo = photo ?? null;
const docs: any[] = Array.isArray(documents) ? documents : [];
const sigDoc = docs.find((d: any) => d.name === 'clientSignature' || d.name?.toLowerCase().includes('signature'));
// Multiple 'clientSignature' documents can exist for clients who re-cropped/replaced their
// signature before uploads became single-slot, so pick the highest id (latest) rather than
// the first unordered match.
const sigDoc = docs
.filter((d: any) => d.name === 'clientSignature' || d.name?.toLowerCase().includes('signature'))
.sort((a: any, b: any) => b.id - a.id)[0];
this.loading = false;
if (sigDoc) {
this.http
@@ -748,6 +758,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
private clerkUiState = inject(ClerkUiStateService);
private clerkWithdrawalService = inject(ClerkWithdrawalService);
private clerkDisbursementService = inject(ClerkDisbursementService);
private clerkShareWithdrawalService = inject(ClerkShareWithdrawalService);
private cashierDepositService = inject(CashierDepositService);
readonly router = inject(Router);
private settingsService = inject(SettingsService);
@@ -764,9 +775,38 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
loadingAccount = false;
accountError = '';
/** Share redemption settlement is always a book-transfer to the linked savings account — never a
* cash handover. A Share (SHR) row's own TXN TP. never offers a cash payment type, and neither
* does its paired CR (savings) row in "Enter Bank Transfer" mode — that CR row's payment type is
* the one that actually governs how the settlement credit posts (see submitShareRedemptionTransfer()).
* Every other account type/mode keeps the full, unfiltered list. */
paymentTypesForRow(row: TxnRow): any[] {
if (row.acTyp === 'SHR') {
return this.paymentTypes.filter((pt: any) => !pt.isCashPayment);
}
const isShareSettlementCredit =
row.typ === 'Enter Bank Transfer' &&
row.dbCr === 'Cr' &&
this.rows.some((r) => r.typ === 'Enter Bank Transfer' && r.dbCr === 'Dr' && r.acTyp === 'SHR');
if (isShareSettlementCredit) {
return this.paymentTypes.filter((pt: any) => !pt.isCashPayment);
}
return this.paymentTypes;
}
// Per-row account id/name map: acNo → Fineract id/name (used for multi-row disbursement)
rowAccountIds: Record<string, number> = {};
rowAccountNames: Record<string, string> = {};
// Per-row Share account market price, keyed by acNo — lets a Share row's "SHARE NO." field
// auto-fill the settlement amount on any other row added alongside it (calculator convenience
// only; a Share row still can never itself be saved from this screen — see accountTypeConfig).
rowSharePrices: Record<string, number> = {};
// Per-row client id, keyed by acNo — needed to submit a Share Withdrawal Request from a row
// that isn't necessarily the currently "selected" one.
rowClientIds: Record<string, number> = {};
// Per-row linked savings account number, keyed by the Share account's own acNo — used to make
// sure a Share-to-linked-account Bank Transfer actually credits the right account.
rowLinkedSavingsAcNo: Record<string, string> = {};
// Full account + client data
accountDetails: any = null;
@@ -1200,6 +1240,9 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
};
this.rows.push(mirror);
this.selectedRow = mirror;
if (active.acTyp === 'SHR') {
this.syncShareLinkedAmounts();
}
return;
}
const row: TxnRow = {
@@ -1494,6 +1537,182 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
});
}
/** One-step Share redemption from Bank Transfer mode: DR row is the Share account (its
* "SHARE NO." field), CR row must be that account's own linked savings account. Submits a
* governed Share Withdrawal Request (same maker-checker table/flow as the dedicated screen) —
* approval both redeems the shares and settles the linked account automatically. */
private submitShareRedemptionTransfer(shareRow: TxnRow, savingsRow: TxnRow): void {
// Belt-and-braces: the TXN TP. dropdown already hides cash payment types for the CR (savings)
// row (see paymentTypesForRow()), but re-check here too in case its payment type was set before
// that filter applied — settlement is always a book-transfer to the linked savings account, and
// it's the CR row's payment type that actually governs how that credit posts, never the DR
// (Share) row's, which has no real payment-type concept.
const settlementPaymentType = this.paymentTypes.find((p: any) => p.id === savingsRow.paymentTypeId);
if (!savingsRow.paymentTypeId) {
this.showAlert('error', 'Settlement Payment Type Required', [
'Select a Payment Type on the CR (savings account) row — it governs how the settlement credit posts.'
]);
return;
}
if (settlementPaymentType?.isCashPayment) {
this.showAlert('error', 'Cash Not Allowed for Share Transactions', [
'Share redemption settlement is always a book-transfer to the linked savings account, never cash.',
'Select a non-cash Payment Type on the CR row (e.g. Transfer or Share Redemption Transfer).'
]);
return;
}
const shareAccountId = this.rowAccountIds[shareRow.acNo];
if (!shareAccountId) {
this.showAlert('error', 'Share Account Not Loaded', [`Please load account details for: ${shareRow.acNo}`]);
return;
}
if (!this.rowAccountIds[savingsRow.acNo]) {
this.showAlert('error', 'Savings Account Not Loaded', [`Please load account details for: ${savingsRow.acNo}`]);
return;
}
const linkedAcNo = this.rowLinkedSavingsAcNo[shareRow.acNo];
const enteredAcNo = savingsRow.acNo?.trim();
const matchesLinked =
!!linkedAcNo &&
(linkedAcNo === enteredAcNo || linkedAcNo.endsWith(enteredAcNo) || savingsRow.ecsAcNo === linkedAcNo);
if (linkedAcNo && !matchesLinked) {
this.showAlert('error', 'Wrong Credit Account', [
`The CR account must be this Share account's own linked savings account: ${linkedAcNo}`,
`You entered: ${enteredAcNo}`
]);
return;
}
if (!linkedAcNo) {
this.showAlert('error', 'No Linked Savings Account', [
'This Share account has no linked savings account configured — settlement has nowhere to go.'
]);
return;
}
const shares = Number(shareRow.amount) || 0;
if (shares <= 0) {
this.showAlert('error', 'Share No. Required', ['Enter the number of shares to withdraw (greater than zero).']);
return;
}
if (!shareRow.resolutionNumber?.trim()) {
this.showAlert('error', 'Board Resolution Number Required', [
'A share withdrawal must be authorised by a Board Resolution.'
]);
return;
}
if (!shareRow.meetingDate) {
this.showAlert('error', 'Date of Meeting Required', ['Enter the Board meeting date that passed the resolution.']);
return;
}
const today = new Date();
const requestedDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
// Full snapshot of the CR (settlement) row exactly as the clerk filled it in — saved verbatim
// for audit purposes. Business logic still only uses settlementPaymentTypeId above; the rest of
// these fields are not re-read by the backend, they are the audit record of what was on screen.
const settlementRowSnapshot = {
branch: savingsRow.br,
accountType: savingsRow.acTyp,
accountNo: savingsRow.acNo,
ecsAccountNo: savingsRow.ecsAcNo || null,
dbCr: savingsRow.dbCr,
amount: savingsRow.amount ?? null,
chequeNo: savingsRow.chequeNo || null,
chequeDate: savingsRow.cheqDate || null,
tokenNo: savingsRow.tokNo || null,
description: savingsRow.description || null,
bankName: savingsRow.bankName || null,
routingCode: savingsRow.routingCode || null,
paymentTypeId: savingsRow.paymentTypeId,
paymentTypeName: settlementPaymentType?.name ?? null
};
const payload = {
shareAccountId,
accountNo: shareRow.acNo,
clientId: this.rowClientIds[shareRow.acNo] ?? null,
clientName: this.rowAccountNames[shareRow.acNo] ?? null,
requestedShares: shares,
requestedDate,
resolutionNumber: shareRow.resolutionNumber.trim(),
meetingDate: shareRow.meetingDate,
description: shareRow.description || null,
settlementPaymentTypeId: savingsRow.paymentTypeId,
settlementRowSnapshot
};
const sharePrice = this.rowSharePrices[shareRow.acNo] ?? 0;
const confirmLines = [
`Share Account: ${shareRow.acNo} · Client: ${this.rowAccountNames[shareRow.acNo] ?? '—'}`,
`Shares to Withdraw: ${shares} @ ₹${sharePrice.toLocaleString('en-IN', { minimumFractionDigits: 2 })} = ₹${(shares * sharePrice).toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
`Board Resolution No.: ${payload.resolutionNumber}`,
`Date of Meeting: ${payload.meetingDate}`,
`Settlement Credit To: ${linkedAcNo}`,
`Settlement Payment Type: ${settlementPaymentType?.name ?? '—'}`,
...(settlementRowSnapshot.ecsAccountNo ? [`ECS A/C No.: ${settlementRowSnapshot.ecsAccountNo}`] : []),
...(settlementRowSnapshot.chequeNo ? [`Cheque No.: ${settlementRowSnapshot.chequeNo}`] : []),
...(settlementRowSnapshot.chequeDate ? [`Cheque Date: ${settlementRowSnapshot.chequeDate}`] : []),
...(settlementRowSnapshot.tokenNo ? [`Token No.: ${settlementRowSnapshot.tokenNo}`] : []),
...(settlementRowSnapshot.description ? [`CR Row Remarks: ${settlementRowSnapshot.description}`] : []),
'',
'Validation checks:',
` ✓ Share account and linked savings account both loaded`,
` ✓ CR account matches this Share account's linked savings account`,
` ✓ Settlement Payment Type is non-cash`,
` ✓ Board Resolution Number and Date of Meeting provided`,
'',
'Pending Manager approval — redemption and settlement both post together on approval.'
];
this.showConfirmAlert('info', 'Confirm Share Withdrawal Request', confirmLines, 'Submit for Approval', () =>
this.doSubmitShareRedemptionTransfer(payload, shareRow, shares, linkedAcNo)
);
}
private doSubmitShareRedemptionTransfer(
payload: Record<string, unknown>,
shareRow: TxnRow,
shares: number,
linkedAcNo: string
): void {
this.saving = true;
this.clerkShareWithdrawalService
.submit(payload)
.pipe(
catchError((err: any) => {
const msg =
err?.error?.errors?.[0]?.defaultUserMessage ??
err?.error?.developerMessage ??
err?.error?.defaultUserMessage ??
'Share withdrawal submission failed. Please try again.';
this.showAlert('error', 'Submission Failed', [msg]);
this.saving = false;
return of(null);
})
)
.subscribe((res: any) => {
this.saving = false;
if (res) {
this.showAlert('success', 'Share Withdrawal Submitted', [
`${shares} share(s) from ${shareRow.acNo} → settlement to ${linkedAcNo} on approval.`,
'Pending Manager approval. Track status in "My Share Requests".'
]);
this.rows = [];
this.rowAccountIds = {};
this.rowAccountNames = {};
this.rowSharePrices = {};
this.rowClientIds = {};
this.rowLinkedSavingsAcNo = {};
this.addRow();
this.selectedRow = this.rows[0];
this.accountDetails = null;
this.clientDetails = null;
}
});
}
/** Cashier enters a token number → look up the APPROVED withdrawal request for auto-fill. */
onTokNoBlur(row: TxnRow): void {
if (this.isClerk || !row.tokNo?.trim()) return;
@@ -1530,6 +1749,25 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
});
}
/** When exactly one row on screen is a Share account with a known market price, auto-fills every
* OTHER row's amount as (shares entered on that row) × (its market price) — a calculator
* convenience for filling the paired settlement entry. The Share row itself still can never be
* saved from this screen (see the SHR guard in confirmSubmit()) — this only helps compute the
* correct amount to carry over into the separate manual Deposit Slip entry. */
syncShareLinkedAmounts(): void {
const shareRows = this.rows.filter((r) => r.acTyp?.toUpperCase() === 'SHR' && this.rowSharePrices[r.acNo]);
if (shareRows.length !== 1) return;
const shareRow = shareRows[0];
const price = this.rowSharePrices[shareRow.acNo] ?? 0;
const shares = Number(shareRow.amount) || 0;
const total = Math.round(shares * price * 100) / 100;
this.rows.forEach((r) => {
if (r !== shareRow) {
r.amount = total;
}
});
}
onAcNoBlur(row: TxnRow, index: number): void {
if (!row.acNo.trim()) return;
this.selectedRow = row;
@@ -1628,12 +1866,14 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
if (token !== this.searchToken) return; // stale result — newer search started
const allItems: any[] = res?.pageItems ?? (Array.isArray(res) ? res : []);
const f = cfg.matchField;
// Exact match first, then suffix/partial match (backend may return by last-N-digits)
// Exact match first, then suffix/partial match. Some account types (e.g. Share — its
// list endpoint only accepts offset/limit, no accountNo filter at all) always return the
// FULL unfiltered list rather than a server-side-narrowed one, so a partial match can't be
// gated on "exactly one row came back" — it never would be for those types.
const exactMatch = allItems.find((x: any) => x[f] === acNo);
const partialMatch =
!exactMatch && allItems.length === 1
? allItems.find((x: any) => x[f]?.endsWith(acNo) || x[f]?.includes(acNo))
: null;
const partialMatch = !exactMatch
? (allItems.find((x: any) => x[f]?.endsWith(acNo)) ?? allItems.find((x: any) => x[f]?.includes(acNo)))
: null;
const match = exactMatch ?? partialMatch ?? null;
if (match?.id) {
const clientIdFromSearch = acTyp !== 'GL' && match.clientId != null ? String(match.clientId) : null;
@@ -1654,6 +1894,17 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
siAccountType: number;
txnBase: string;
txnSearchSupported: boolean;
/** True when this account type has no separate REST transactions sub-resource at all (e.g.
* Share accounts — Fineract returns transactions embedded on the account detail response
* itself via ?associations=all, never at /accounts/share/{id}/transactions). Skips the
* "today's transactions" HTTP call entirely instead of hitting a 404. */
txnFetchUnsupported?: boolean;
/** True when this account type has no standing-instruction "from account type" mapping on
* the backend at all (PortfolioAccountType only defines LOAN/SAVINGS/CURRENT — Share isn't
* a valid standing-instruction source account). Skips that HTTP call entirely instead of
* sending an fromAccountType value the backend's query-builder silently can't match,
* which otherwise produces a malformed "WHERE <empty>" SQL statement server-side. */
siFetchUnsupported?: boolean;
maxLimit: number;
skipGlobalSearch: boolean;
listQueryParam: string;
@@ -1688,6 +1939,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
siAccountType: 4,
txnBase: '/accounts/share',
txnSearchSupported: false,
txnFetchUnsupported: true,
siFetchUnsupported: true,
maxLimit: 500,
skipGlobalSearch: true,
listQueryParam: 'accountNo',
@@ -1800,6 +2053,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
siAccountType: number;
txnBase: string;
txnSearchSupported: boolean;
txnFetchUnsupported?: boolean;
siFetchUnsupported?: boolean;
detailParams?: string;
},
token: number,
@@ -1832,13 +2087,29 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
this.rowAccountIds[this.selectedRow.acNo] = acc.id;
this.rowAccountNames[this.selectedRow.acNo] =
acc.name ?? acc.displayName ?? acc.glAccountName ?? acc.accountNo ?? '';
this.rowClientIds[this.selectedRow.acNo] = acc.clientId ?? null;
}
if (this.selectedRow) {
this.selectedRow.ecsAcNo = acc.glCode ?? acc.savingsAccountNumber ?? acc.accountNo ?? '';
// Share accounts: ECS field is the share account's own number — acc.savingsAccountNumber
// is a different, unrelated account (the linked settlement savings account) and must
// never be shown here.
this.selectedRow.ecsAcNo =
acTyp?.toUpperCase() === 'SHR'
? (acc.accountNo ?? '')
: (acc.glCode ?? acc.savingsAccountNumber ?? acc.accountNo ?? '');
}
if (this.isClerk) this.clerkUiState.setFacilities(this.facilities, acNo, acTyp);
this.computeSameDayNeftRtgs(acc.transactions);
this.computeSameDayChequeClearing(acc.transactions);
if (acTyp?.toUpperCase() === 'SHR' && this.selectedRow?.acNo) {
// Keyed by the row's own typed acNo (e.g. "0011"), not the resolved full account number
// (e.g. "000000011") — rowAccountIds/rowAccountNames above use the same key, and every
// lookup elsewhere (syncShareLinkedAmounts, submitShareRedemptionTransfer, the template's
// rowSharePrices[row.acNo]) reads via row.acNo, so this must match or every lookup misses.
this.rowSharePrices[this.selectedRow.acNo] = Number(acc.currentMarketPrice) || 0;
this.rowLinkedSavingsAcNo[this.selectedRow.acNo] = acc.savingsAccountNumber ?? '';
this.syncShareLinkedAmounts();
}
return true;
};
@@ -1868,7 +2139,12 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
this.clientAddresses = Array.isArray(r.addresses) ? r.addresses : [];
this.clientPhotoUrl = r.photo ?? null;
const docs: any[] = Array.isArray(r.documents) ? r.documents : [];
const sigDoc = docs.find((d: any) => d.name === 'clientSignature' || d.name?.toLowerCase().includes('signature'));
// Multiple 'clientSignature' documents can exist for clients who re-cropped/replaced their
// signature before uploads became single-slot, so pick the highest id (latest) rather than
// the first unordered match.
const sigDoc = docs
.filter((d: any) => d.name === 'clientSignature' || d.name?.toLowerCase().includes('signature'))
.sort((a: any, b: any) => b.id - a.id)[0];
if (sigDoc) {
this.http
.get(`/clients/${clientId}/documents/${sigDoc.id}/attachment`, { responseType: 'blob' })
@@ -1903,18 +2179,22 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
documents: this.http.get<any[]>(`/clients/${clientId}/documents`).pipe(catchError(() => of([]))),
notes: this.http.get<any>(`/savings/${accountId}/notes`).pipe(catchError(() => of([]))),
linkedAcc: this.http.get<any>(`/clients/${clientId}/accounts`).pipe(catchError(() => of(null))),
si: this.http
.get<any>(`/standinginstructions?fromAccountType=${cfg.siAccountType}&fromAccountId=${accountId}&limit=10`)
.pipe(catchError(() => of({ pageItems: [] }))),
todayTxns: cfg.txnSearchSupported
? this.http
.get<any>(
`${cfg.txnBase}/${accountId}/transactions/search?fromDate=${todayStr}&toDate=${todayStr}&limit=20`
)
.pipe(catchError(() => of({ pageItems: [] })))
si: cfg.siFetchUnsupported
? of({ pageItems: [] })
: this.http
.get<any>(`${cfg.txnBase}/${accountId}/transactions?limit=50`)
.pipe(catchError(() => of({ pageItems: [] })))
.get<any>(`/standinginstructions?fromAccountType=${cfg.siAccountType}&fromAccountId=${accountId}&limit=10`)
.pipe(catchError(() => of({ pageItems: [] }))),
todayTxns: cfg.txnFetchUnsupported
? of({ pageItems: [] })
: cfg.txnSearchSupported
? this.http
.get<any>(
`${cfg.txnBase}/${accountId}/transactions/search?fromDate=${todayStr}&toDate=${todayStr}&limit=20`
)
.pipe(catchError(() => of({ pageItems: [] })))
: this.http
.get<any>(`${cfg.txnBase}/${accountId}/transactions?limit=50`)
.pipe(catchError(() => of({ pageItems: [] })))
});
if (clientIdHint) {
@@ -2079,6 +2359,11 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
// ── Balance getters ────────────────────────────────────────────────────────
get currentBalance(): number {
if (this.selectedRow?.acTyp === 'SHR') {
const shares = Number(this.accountDetails?.summary?.totalApprovedShares ?? 0);
const unitPrice = Number(this.accountDetails?.currentMarketPrice ?? 0);
return shares * unitPrice;
}
return this.accountDetails?.summary?.accountBalance ?? this.accountDetails?.organizationRunningBalance ?? 0;
}
@@ -2492,6 +2777,23 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
// Share redemption via linked-account transfer: DR=Share account, CR=its linked savings
// account. A Share account can never be debited by a plain ledger transfer (no such Fineract
// command exists) — so this exact one-DR/one-CR shape is routed to submitShareRedemptionTransfer()
// instead, which submits a governed Share Withdrawal Request through the same maker-checker
// table as the dedicated screen; approval both redeems the shares and settles the linked account.
const shareDrRows = drRows.filter((r) => r.acTyp === 'SHR');
if (shareDrRows.length > 0) {
if (drRows.length !== 1 || crRows.length !== 1) {
this.showAlert('error', 'Unsupported Share Transfer', [
'A Share redemption transfer supports exactly one DR row (the Share account) and one CR row (its linked savings account).'
]);
return;
}
this.submitShareRedemptionTransfer(shareDrRows[0], crRows[0]);
return;
}
// Bank transfer: only savings (SD) or current (CA/CB) accounts allowed
const allowedTypes = new Set([
'SD',
@@ -2994,6 +3296,24 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
const accountId = this.accountDetails.id;
const cfg = this.accountTypeConfig(row.acTyp);
// Share accounts must never go through this generic Deposit/Withdrawal Slip flow — both
// buildDepositPayload() and buildWithdrawalPayload() hardcode accountType: 'SAVINGS' and send
// savingsAccountId: this.accountDetails.id, which for a Share account is the SHARE account's
// own numeric id, not a real savings account id. Approving that request would call
// savingsAccountWithdrawal/Deposit against whatever savings account happens to share that same
// numeric id — a completely unrelated account. Share purchase/redemption must go through the
// dedicated Buy Shares / Share Withdrawal Request screens, which carry the correct governance
// (resolution number, meeting date, minimum-tenure and linked-loan checks).
if (row.acTyp === 'SHR') {
this.showAlert('error', 'Not Supported for Share Accounts', [
isDeposit
? 'Share purchases must be made from the Buy Shares screen on the Share Account itself.'
: 'Share withdrawal (redeem shares) must be submitted from "New Share Withdrawal Request" in the Clerk Dashboard.',
'This generic Deposit/Withdrawal Slip does not carry the Board Resolution Number or the governance checks required for shares.'
]);
return;
}
// GL withdrawal → direct journal entry (no maker-checker)
// GL deposit → falls through to maker-checker block below
if (row.acTyp === 'GL' && !isDeposit) {
@@ -12,16 +12,16 @@
* have meaningful variety out of the box.
*/
export const SAMPLE_CKYC_FILE = `10|24401|IN4711|MH|30|31-08-2020|V1.1|||||
20|1|01|01|||||||||||01|||01|10078|MR|RAVI|KUMAR|SHARMA|||||||01|MR|RAJESH|KUMAR|SHARMA||MRS|SUNITA|DEVI|||M|02|IN|B-01|10-08-1985|||||||||01|02|||||02|FLAT 402, SUNRISE TOWERS|MAROL ANDHERI EAST|MUMBAI|MUMBAI|MUMBAI|MH|IN|400069|01||Y|02|FLAT 402, SUNRISE TOWERS|MAROL ANDHERI EAST|MUMBAI|MUMBAI|MUMBAI|MH|IN|400069|01||||||||||||||||9892011001|||||31-08-2020|MUMBAI MAIN|31-08-2020|01|ANJALI MEHTA|ASSISTANT MANAGER|HEAD OFFICE|4|BHARAT URBAN BANK|IN4711|1|0||0|4||||||
20|1|01|01|||||||||||01|||01|10078|MR|RAVI|KUMAR|SHARMA|||||||01|MR|RAJESH|KUMAR|SHARMA||MRS|SUNITA|DEVI|||M|02|IN|B-01|10-08-1985|||||||||01|02|||||02|FLAT 402, SUNRISE TOWERS|MAROL ANDHERI EAST|MUMBAI|MUMBAI|MUMBAI|MH|IN|400069|01||Y|02|FLAT 402, SUNRISE TOWERS|MAROL ANDHERI EAST|MUMBAI|MUMBAI|MUMBAI|MH|IN|400069|01||||||||||||||||9892011001|||||31-08-2020|MUMBAI MAIN|31-08-2020|01|ANJALI MEHTA|ASSISTANT MANAGER|HEAD OFFICE|4|CBSNEXORA BANK|IN4711|1|0||0|4||||||
30|1|C|AAQPS8560J||01|02|||||
70|1|10078_Sign.jpeg|09|01|01||||
70|1|10078_Photo.jpeg|02|01|01||||
70|1|10078_AP.jpg|04|01|01||||
20|2|01|01|||||||||||01|||01|10079|MRS|PRIYA||PATEL|||||||01|MR|HARISH|PATEL|||MRS|MEENA|PATEL|||F|01|IN|O-04|22-03-1990|||||||||01|02|||||02|B-12, SHIVAJI NAGAR|FC ROAD|PUNE|PUNE|PUNE|MH|IN|411005|01||Y|02|B-12, SHIVAJI NAGAR|FC ROAD|PUNE|PUNE|PUNE|MH|IN|411005|01||||||||||||||||9822055002|||||31-08-2020|PUNE FC ROAD|31-08-2020|01|VIKRAM SINGH|BRANCH MANAGER|HEAD OFFICE|4|BHARAT URBAN BANK|IN4711|1|0||0|3||||||
20|2|01|01|||||||||||01|||01|10079|MRS|PRIYA||PATEL|||||||01|MR|HARISH|PATEL|||MRS|MEENA|PATEL|||F|01|IN|O-04|22-03-1990|||||||||01|02|||||02|B-12, SHIVAJI NAGAR|FC ROAD|PUNE|PUNE|PUNE|MH|IN|411005|01||Y|02|B-12, SHIVAJI NAGAR|FC ROAD|PUNE|PUNE|PUNE|MH|IN|411005|01||||||||||||||||9822055002|||||31-08-2020|PUNE FC ROAD|31-08-2020|01|VIKRAM SINGH|BRANCH MANAGER|HEAD OFFICE|4|CBSNEXORA BANK|IN4711|1|0||0|3||||||
30|2|E|XXXXXXXX2210||01|02|||||
70|2|10079_Sign.jpeg|09|01|01||||
70|2|10079_Photo.jpeg|02|01|01||||
20|3|01|01|||||||||||01|||01|10080|MR|ARJUN||DESHMUKH|||||||01|MR|VIJAY|DESHMUKH|||MRS|REKHA|DESHMUKH|||M|01|IN|B-01|15-01-1978|||||||||01|02|||||02|HOUSE NO 7, DHARAMPETH|RAMDASPETH|NAGPUR|NAGPUR|NAGPUR|MH|IN|440010|01||Y|02|HOUSE NO 7, DHARAMPETH|RAMDASPETH|NAGPUR|NAGPUR|NAGPUR|MH|IN|440010|01||||||||||||||||9823033003|||||30-08-2020|NAGPUR DHARAMPETH|30-08-2020|01|RAHUL JOSHI|ASSISTANT MANAGER|HEAD OFFICE|4|BHARAT URBAN BANK|IN4711|1|0||0|3||||||
20|3|01|01|||||||||||01|||01|10080|MR|ARJUN||DESHMUKH|||||||01|MR|VIJAY|DESHMUKH|||MRS|REKHA|DESHMUKH|||M|01|IN|B-01|15-01-1978|||||||||01|02|||||02|HOUSE NO 7, DHARAMPETH|RAMDASPETH|NAGPUR|NAGPUR|NAGPUR|MH|IN|440010|01||Y|02|HOUSE NO 7, DHARAMPETH|RAMDASPETH|NAGPUR|NAGPUR|NAGPUR|MH|IN|440010|01||||||||||||||||9823033003|||||30-08-2020|NAGPUR DHARAMPETH|30-08-2020|01|RAHUL JOSHI|ASSISTANT MANAGER|HEAD OFFICE|4|CBSNEXORA BANK|IN4711|1|0||0|3||||||
30|3|C|AGTPD1144K||01|02|||||
70|3|10080_Sign.jpeg|09|01|01||||
70|3|10080_AP.jpg|04|01|01||||
@@ -446,8 +446,12 @@ export class RecordMasterComponent implements OnInit {
};
}
// 2. Signature — client document named 'clientSignature'
const signatureDoc = (documents as any[])?.find((d: any) => d?.name === 'clientSignature');
// 2. Signature — client document named 'clientSignature'. Multiple rows can exist for
// clients who re-cropped/replaced their signature before uploads became single-slot, so
// pick the highest id (latest) rather than the first unordered match.
const signatureDoc = (documents as any[])
?.filter((d: any) => d?.name === 'clientSignature')
?.sort((a: any, b: any) => b.id - a.id)?.[0];
if (signatureDoc) {
this.viewDetails.docs.signature = {
present: true,
@@ -66,9 +66,12 @@ export class SignatoryService {
fetchClientSignature(clientId: number | string): Observable<string | null> {
return this.http.get<any[]>(`/clients/${clientId}/documents`).pipe(
switchMap((docs: any[]) => {
const sigDoc = (docs || []).find(
(d: any) => d.name === 'clientSignature' || d.name?.toLowerCase().includes('signature')
);
// Multiple 'clientSignature' documents can exist for clients who re-cropped/replaced
// their signature before uploads became single-slot, so pick the highest id (latest)
// rather than the first unordered match.
const sigDoc = (docs || [])
.filter((d: any) => d.name === 'clientSignature' || d.name?.toLowerCase().includes('signature'))
.sort((a: any, b: any) => b.id - a.id)[0];
if (!sigDoc) {
return of(null);
}
+77
View File
@@ -0,0 +1,77 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/* Small shared layout/status-pill classes for the Share Withdrawal maker-checker screens —
* self-contained (this branch has no separate "investment" module to borrow them from). */
.inv-form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
}
.inv-form-field {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 11px;
font-weight: 600;
color: #64748b;
input,
select,
textarea {
height: 36px;
padding: 0 10px;
border: 1px solid #e2e8f0;
border-radius: 8px;
font-size: 13px;
color: #0d1b2a;
font-family: inherit;
background: #fff;
box-sizing: border-box;
}
textarea {
height: auto;
padding: 8px 10px;
resize: vertical;
}
}
.inv-form-actions {
display: flex;
gap: 10px;
padding: 12px 16px 16px;
}
.inv-badge {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
background: #f1f5f9;
color: #475569;
&--active {
background: #fef3c7;
color: #92400e;
}
&--matured {
background: #dcfce7;
color: #166534;
}
&--written_off {
background: #fee2e2;
color: #991b1b;
}
}
@@ -0,0 +1,69 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="pg">
<header class="pg-head">
<div>
<div class="pg-eyebrow">CLERK · SHARE CLOSURE</div>
<h1 class="pg-title">My Share Closure Requests</h1>
<p class="pg-sub">Share account closure requests you have submitted, and their approval status.</p>
</div>
<div class="pg-toolbar">
<button type="button" class="pg-btn pg-btn--primary" (click)="newRequest()">New Share Closure Request</button>
</div>
</header>
<div class="pg-card">
<div class="inv-form-grid" style="padding: 12px 16px">
<label class="inv-form-field">
<span>Status</span>
<select [(ngModel)]="filterStatus" (change)="onFilterChange()">
@for (opt of statusOptions; track opt.value) {
<option [value]="opt.value">{{ opt.label }}</option>
}
</select>
</label>
</div>
<table class="pg-table">
<thead>
<tr>
<th>Account No</th>
<th>Client</th>
<th>Closed Date</th>
<th>Submitted At</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@if (loading) {
<tr>
<td colspan="5" class="pg-empty">Loading…</td>
</tr>
} @else if (loaded && requests.length === 0) {
<tr>
<td colspan="5" class="pg-empty">No share closure requests found.</td>
</tr>
} @else {
@for (r of requests; track r.id) {
<tr>
<td>
<span class="pg-chip">{{ r.account_no }}</span>
</td>
<td>{{ r.client_name }}</td>
<td>{{ formatDateTime(r.closed_date) }}</td>
<td>{{ formatDateTime(r.submitted_at) }}</td>
<td>
<span [class]="statusClass(r.status)">{{ statusLabel(r.status) }}</span>
</td>
</tr>
}
}
</tbody>
</table>
</div>
</div>
@@ -0,0 +1,112 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router, RouterModule } from '@angular/router';
import { catchError } from 'rxjs/operators';
import { of } from 'rxjs';
import { ClerkShareClosureService } from '../../cashier/clerk-share-closure.service';
/** Clerk Dashboard "My Share Closure Requests" — lists the share account closure requests this
* clerk has submitted and their approval status. Mirrors MyShareWithdrawalRequestsComponent. */
@Component({
selector: 'mifosx-my-share-closure-requests',
templateUrl: './my-share-closure-requests.component.html',
styleUrls: [
'../../cashier/cashier-shared.scss',
'../clerk-shared.scss'
],
standalone: true,
imports: [
CommonModule,
FormsModule,
RouterModule
]
})
export class MyShareClosureRequestsComponent implements OnInit {
private service = inject(ClerkShareClosureService);
private router = inject(Router);
requests: any[] = [];
totalRecords = 0;
loading = false;
loaded = false;
filterStatus = '';
get statusOptions(): { value: string; label: string }[] {
return [
{ value: '', label: 'All' },
{ value: 'PENDING', label: 'Pending' },
{ value: 'APPROVED', label: 'Approved' },
{ value: 'REJECTED', label: 'Rejected' }
];
}
ngOnInit(): void {
this.load();
}
load(): void {
this.loading = true;
this.service
.getMyRequests(this.filterStatus || undefined, 0, 50)
.pipe(catchError(() => of({ pageItems: [], totalFilteredRecords: 0 })))
.subscribe((res: any) => {
this.requests = res?.pageItems ?? [];
this.totalRecords = Number(res?.totalFilteredRecords ?? 0);
this.loading = false;
this.loaded = true;
});
}
onFilterChange(): void {
this.load();
}
newRequest(): void {
this.router.navigate(['/clerk/share-closure-request']);
}
statusClass(status: string): string {
const map: Record<string, string> = {
PENDING: 'inv-badge--active',
APPROVED: 'inv-badge--matured',
REJECTED: 'inv-badge--written_off'
};
return `inv-badge ${map[status] ?? ''}`;
}
statusLabel(status: string): string {
const map: Record<string, string> = {
PENDING: 'Pending Approval',
APPROVED: 'Approved',
REJECTED: 'Rejected'
};
return map[status] ?? status;
}
formatDateTime(val: any): string {
if (!val) return '—';
try {
const d = new Date(val);
return d.toLocaleString('en-IN', {
day: '2-digit',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
} catch {
return String(val);
}
}
}
@@ -0,0 +1,73 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="pg">
<header class="pg-head">
<div>
<div class="pg-eyebrow">CLERK · SHARE WITHDRAWAL</div>
<h1 class="pg-title">My Share Requests</h1>
<p class="pg-sub">Share withdrawal requests you have submitted, and their approval status.</p>
</div>
<div class="pg-toolbar">
<button type="button" class="pg-btn pg-btn--primary" (click)="newRequest()">New Share Withdrawal</button>
</div>
</header>
<div class="pg-card">
<div class="inv-form-grid" style="padding: 12px 16px">
<label class="inv-form-field">
<span>Status</span>
<select [(ngModel)]="filterStatus" (change)="onFilterChange()">
@for (opt of statusOptions; track opt.value) {
<option [value]="opt.value">{{ opt.label }}</option>
}
</select>
</label>
</div>
<table class="pg-table">
<thead>
<tr>
<th>Account No</th>
<th>Client</th>
<th class="num">Shares</th>
<th>Resolution No.</th>
<th>Meeting Date</th>
<th>Submitted At</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@if (loading) {
<tr>
<td colspan="7" class="pg-empty">Loading…</td>
</tr>
} @else if (loaded && requests.length === 0) {
<tr>
<td colspan="7" class="pg-empty">No share withdrawal requests found.</td>
</tr>
} @else {
@for (r of requests; track r.id) {
<tr>
<td>
<span class="pg-chip">{{ r.account_no }}</span>
</td>
<td>{{ r.client_name }}</td>
<td class="num">{{ r.requested_shares }}</td>
<td>{{ r.resolution_number }}</td>
<td>{{ formatDateTime(r.meeting_date) }}</td>
<td>{{ formatDateTime(r.submitted_at) }}</td>
<td>
<span [class]="statusClass(r.status)">{{ statusLabel(r.status) }}</span>
</td>
</tr>
}
}
</tbody>
</table>
</div>
</div>
@@ -0,0 +1,112 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router, RouterModule } from '@angular/router';
import { catchError } from 'rxjs/operators';
import { of } from 'rxjs';
import { ClerkShareWithdrawalService } from '../../cashier/clerk-share-withdrawal.service';
/** Clerk Dashboard "My Share Requests" — lists the share withdrawal requests this clerk has
* submitted and their approval status. Mirrors MyRequestsComponent/MyDisbursementRequestsComponent. */
@Component({
selector: 'mifosx-my-share-withdrawal-requests',
templateUrl: './my-share-withdrawal-requests.component.html',
styleUrls: [
'../../cashier/cashier-shared.scss',
'../clerk-shared.scss'
],
standalone: true,
imports: [
CommonModule,
FormsModule,
RouterModule
]
})
export class MyShareWithdrawalRequestsComponent implements OnInit {
private service = inject(ClerkShareWithdrawalService);
private router = inject(Router);
requests: any[] = [];
totalRecords = 0;
loading = false;
loaded = false;
filterStatus = '';
get statusOptions(): { value: string; label: string }[] {
return [
{ value: '', label: 'All' },
{ value: 'PENDING', label: 'Pending' },
{ value: 'APPROVED', label: 'Approved' },
{ value: 'REJECTED', label: 'Rejected' }
];
}
ngOnInit(): void {
this.load();
}
load(): void {
this.loading = true;
this.service
.getMyRequests(this.filterStatus || undefined, 0, 50)
.pipe(catchError(() => of({ pageItems: [], totalFilteredRecords: 0 })))
.subscribe((res: any) => {
this.requests = res?.pageItems ?? [];
this.totalRecords = Number(res?.totalFilteredRecords ?? 0);
this.loading = false;
this.loaded = true;
});
}
onFilterChange(): void {
this.load();
}
newRequest(): void {
this.router.navigate(['/clerk/share-withdrawal-request']);
}
statusClass(status: string): string {
const map: Record<string, string> = {
PENDING: 'inv-badge--active',
APPROVED: 'inv-badge--matured',
REJECTED: 'inv-badge--written_off'
};
return `inv-badge ${map[status] ?? ''}`;
}
statusLabel(status: string): string {
const map: Record<string, string> = {
PENDING: 'Pending Approval',
APPROVED: 'Approved',
REJECTED: 'Rejected'
};
return map[status] ?? status;
}
formatDateTime(val: any): string {
if (!val) return '—';
try {
const d = new Date(val);
return d.toLocaleString('en-IN', {
day: '2-digit',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
} catch {
return String(val);
}
}
}
@@ -0,0 +1,355 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="pg">
<header class="pg-head">
<div>
<div class="pg-eyebrow">{{ isReview ? 'CHECKER · APPROVAL' : 'CLERK · SHARE CLOSURE' }}</div>
<h1 class="pg-title">{{ pageTitle }}</h1>
<p class="pg-sub">{{ pageSubtitle }}</p>
</div>
<div class="pg-toolbar">
<button type="button" class="pg-btn" (click)="goBack()">
<mat-icon>arrow_back</mat-icon>
{{ isReview ? 'Back to Requests' : 'My Share Closure Requests' }}
</button>
</div>
</header>
<div class="pg-card">
<div class="pos-section-label">Share Account</div>
<div class="inv-form-grid" style="padding: 16px">
@if (!isReview) {
<label class="inv-form-field" style="grid-column: span 2">
<span>* Search Account No. / Client Name (only zero-share accounts are eligible)</span>
<mifosx-search-select
[options]="accountOptions"
[(ngModel)]="selectedAccountId"
(ngModelChange)="onAccountSelected($event)"
placeholder="Search share account no. or client name…"
></mifosx-search-select>
</label>
}
@if (account) {
<label class="inv-form-field">
<span>Account No</span>
<input type="text" [value]="account.accountNo" readonly />
</label>
<label class="inv-form-field">
<span>Client</span>
<input type="text" [value]="account.clientName" readonly />
</label>
<label class="inv-form-field">
<span>Shares Held</span>
<input type="text" [value]="heldShares" readonly />
</label>
}
@if (loadingAccount) {
<div class="pg-empty">Loading account…</div>
}
@if (account && heldShares !== 0 && !isReview) {
<div class="pg-empty" style="grid-column: span 2; color: #b42318">
This account still holds {{ heldShares }} share(s) — all shares must be withdrawn first via the Share
Withdrawal workflow before it becomes eligible for closure.
</div>
}
</div>
</div>
@if (isReview && reviewRequest) {
<div class="pg-card">
<div class="pos-section-label">Client Compliance</div>
<div class="inv-form-grid" style="padding: 16px">
<div class="inv-form-field">
<span>KYC Status</span>
<span
class="swr-chip"
[class.swr-chip--ok]="reviewRequest.client_kyc_verified"
[class.swr-chip--warn]="!reviewRequest.client_kyc_verified"
>
{{ reviewRequest.client_kyc_status_label }}
</span>
</div>
<div class="inv-form-field">
<span>Risk Category</span>
<span
class="swr-chip"
[class.swr-chip--ok]="!reviewRequest.client_risk_high"
[class.swr-chip--warn]="reviewRequest.client_risk_high"
>
{{ reviewRequest.client_risk_category_label }}
</span>
</div>
<div class="inv-form-field">
<span>Open AML Alerts</span>
<span
class="swr-chip"
[class.swr-chip--ok]="!reviewRequest.client_open_aml_alerts"
[class.swr-chip--warn]="reviewRequest.client_open_aml_alerts > 0"
>
{{ reviewRequest.client_open_aml_alerts || 0 }}
</span>
</div>
</div>
</div>
}
@if (account) {
<div class="pg-card">
<div class="pos-section-label">Closure Details</div>
<div class="inv-form-grid" style="padding: 16px">
<label class="inv-form-field">
<span>* Closed Date</span>
<input type="date" [(ngModel)]="closedDate" [disabled]="isReview" />
</label>
<label class="inv-form-field" style="grid-column: span 2">
<span>Note (optional)</span>
<input
type="text"
[(ngModel)]="note"
[disabled]="isReview"
placeholder="e.g. Member exit — all shares redeemed"
/>
</label>
</div>
@if (!isReview) {
<div class="inv-form-actions">
<button
type="button"
class="pg-btn pg-btn--primary"
[disabled]="!isFormValid || submitting"
(click)="openConfirmDialog()"
>
{{ submitting ? 'Submitting…' : 'Review &amp; Submit' }}
</button>
</div>
}
</div>
}
@if (isReview && reviewRequest?.status === 'PENDING') {
<div class="pg-card">
<div class="pos-section-label">Verification</div>
<div class="swrv-trigger">
<div class="swrv-trigger__icon">
<mat-icon>fact_check</mat-icon>
</div>
<div class="swrv-trigger__text">
<div class="swrv-trigger__title">Run a live backend validation</div>
<div class="swrv-trigger__sub">
Re-checks shares held, account status, active loan/guarantee and client compliance before you approve.
</div>
</div>
<button type="button" class="pg-btn pg-btn--primary" (click)="openValidateModal()">
<mat-icon>play_circle</mat-icon>
Validate Request
</button>
</div>
<div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="approveReview()">
<mat-icon>check_circle</mat-icon>
Approve &amp; Close Account
</button>
<button type="button" class="pg-btn pg-btn--danger" (click)="openRejectDialog()">
<mat-icon>cancel</mat-icon>
Reject
</button>
</div>
</div>
}
@if (showValidateModal) {
<div class="swrv-modal">
<div class="swrv-modal__card">
<div class="swrv-modal__header">
<div class="swrv-modal__title">
<span class="swrv-modal__title-icon"><mat-icon>fact_check</mat-icon></span>
Validate Share Closure Request
</div>
<button type="button" class="swrv-modal__close" [disabled]="validating" (click)="closeValidateModal()">
<mat-icon>close</mat-icon>
</button>
</div>
<div class="swrv-modal__body">
<p class="swrv-modal__intro">
Re-checking closure of <strong>{{ account?.accountNo }}</strong> ({{ account?.clientName }}) against the
live account, loan/guarantee and compliance state — not just what was captured at submission time.
</p>
@if (backendChecks && !validating && !validateAllPassed) {
<div class="swrv-modal__warn">
<mat-icon>warning</mat-icon>
<div>
<strong>Some checks need review before approving.</strong>
Scroll down to see which ones failed and why.
</div>
</div>
}
<ol class="swrv-steps">
@for (step of validateSteps; track step.label; let i = $index) {
<li
class="swrv-step"
[class.swrv-step--active]="step.status === 'active'"
[class.swrv-step--done]="step.status === 'done'"
[class.swrv-step--failed]="step.status === 'failed'"
>
<div class="swrv-step__indicator">
@if (step.status === 'done') {
<mat-icon>check</mat-icon>
} @else if (step.status === 'active') {
<mat-icon class="swrv-spin">autorenew</mat-icon>
} @else if (step.status === 'failed') {
<mat-icon>close</mat-icon>
} @else {
<span>{{ i + 1 }}</span>
}
</div>
<div class="swrv-step__content">
<div class="swrv-step__label">{{ step.label }}</div>
<div class="swrv-step__msg">{{ step.detail }}</div>
</div>
</li>
}
</ol>
@if (validating && validateSteps.length === 0) {
<div class="pg-empty">Connecting to server…</div>
}
@if (backendChecks && !validating && validateAllPassed) {
<div class="swrv-modal__result">
<mat-icon>verified</mat-icon>
<span>All checks passed — safe to approve.</span>
</div>
}
</div>
<div class="swrv-modal__footer">
@if (!validating && backendChecks) {
<button type="button" class="pg-btn" (click)="runBackendValidation()">
<mat-icon>refresh</mat-icon>
Re-run
</button>
}
@if (!validating && backendChecks && validateAllPassed) {
<button type="button" class="pg-btn pg-btn--primary" (click)="closeValidateModal(); approveReview()">
<mat-icon>check_circle</mat-icon>
Approve &amp; Close Account
</button>
}
<button type="button" class="pg-btn" [disabled]="validating" (click)="closeValidateModal()">
{{ validating ? 'Validating…' : 'Close' }}
</button>
</div>
</div>
</div>
}
@if (isReview && activityLog.length > 0) {
<div class="pg-card">
<div class="pos-section-label">Activity Log</div>
<table class="pg-table">
<thead>
<tr>
<th>Timestamp</th>
<th>Action</th>
<th>User</th>
<th>Note</th>
</tr>
</thead>
<tbody>
@for (entry of activityLog; track entry.timestamp) {
<tr>
<td>{{ entry.timestamp | date: 'medium' }}</td>
<td>{{ entry.action }}</td>
<td>{{ entry.user }}</td>
<td>{{ entry.note || '—' }}</td>
</tr>
}
</tbody>
</table>
</div>
}
@if (showConfirmDialog) {
<div class="swr-modal-backdrop">
<div class="swr-modal">
<h3>Confirm Share Closure Request</h3>
<p class="pg-sub">Review the details below before submitting for Bank Manager approval.</p>
<table class="pg-table">
<tbody>
<tr>
<td>Account No / Client</td>
<td>{{ account?.accountNo }} — {{ account?.clientName }}</td>
</tr>
<tr>
<td>Shares Held</td>
<td>{{ heldShares }}</td>
</tr>
<tr>
<td>Closed Date</td>
<td>{{ closedDate }}</td>
</tr>
<tr>
<td>Note</td>
<td>{{ note || '—' }}</td>
</tr>
</tbody>
</table>
<div class="pos-section-label" style="margin-top: 12px">Validation Checks</div>
<table class="pg-table">
<tbody>
@for (check of verificationChecks; track check.label) {
<tr>
<td>{{ check.label }}</td>
<td [class.swr-check-pass]="check.pass" [class.swr-check-fail]="!check.pass">
{{ check.pass ? 'Pass — ' : 'Review — ' }}{{ check.detail }}
</td>
</tr>
}
</tbody>
</table>
<div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--primary" [disabled]="submitting" (click)="confirmSubmit()">
{{ submitting ? 'Submitting…' : 'Confirm & Submit' }}
</button>
<button type="button" class="pg-btn" (click)="cancelConfirmDialog()">Back to Edit</button>
</div>
</div>
</div>
}
@if (showRejectDialog) {
<div class="swr-modal-backdrop">
<div class="swr-modal">
<h3>Reject Share Closure Request</h3>
<label class="inv-form-field">
<span>* Rejection Reason</span>
<textarea rows="3" [(ngModel)]="rejectionReason"></textarea>
</label>
<div class="inv-form-actions">
<button
type="button"
class="pg-btn pg-btn--danger"
[disabled]="!rejectionReason.trim()"
(click)="confirmRejectReview()"
>
Confirm Reject
</button>
<button type="button" class="pg-btn" (click)="showRejectDialog = false">Cancel</button>
</div>
</div>
</div>
}
</div>
@@ -0,0 +1,336 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, EventEmitter, Input, OnInit, Output, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { catchError } from 'rxjs/operators';
import { of } from 'rxjs';
import { SharesService } from '../../shares/shares.service';
import { ClerkShareClosureService } from '../../cashier/clerk-share-closure.service';
import { SearchSelectComponent, SearchSelectOption } from '../../shared/search-select/search-select.component';
export type ShareClosureMode = 'create' | 'review';
interface ActivityEntry {
timestamp: string;
action: string;
user: string;
note?: string;
}
/**
* Share Account Closure request — maker screen in the Clerk Dashboard. A clerk finds a member's
* Share Account that already holds zero shares (all shares already withdrawn), enters the closed
* date plus an optional note, and submits for Bank Manager approval. This is the ONLY entry point
* a share account closure should go through — approval server-side executes the real Fineract
* "close" command (see ClerkShareClosureRequestApiResource on the backend).
*/
@Component({
selector: 'mifosx-share-closure-request',
templateUrl: './share-closure-request.component.html',
styleUrls: [
'../../cashier/cashier-shared.scss',
'../clerk-shared.scss',
'../share-withdrawal-request/share-withdrawal-request.component.scss'
],
standalone: true,
imports: [
CommonModule,
FormsModule,
RouterModule,
MatIconModule,
MatSnackBarModule,
SearchSelectComponent
]
})
export class ShareClosureRequestComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private sharesService = inject(SharesService);
private closureService = inject(ClerkShareClosureService);
private snackBar = inject(MatSnackBar);
/** When embedded (e.g. opened as a popup from a list screen instead of routed to directly), mode
* and requestId come from these inputs rather than the route, and success/close actions emit
* events instead of navigating away. */
@Input() embedded = false;
@Input() overrideMode: ShareClosureMode | null = null;
@Input() overrideRequestId: number | null = null;
@Output() closeRequested = new EventEmitter<void>();
@Output() actionCompleted = new EventEmitter<void>();
mode: ShareClosureMode = 'create';
requestId: number | null = null;
submitting = false;
loadingAccount = false;
selectedAccountId = '';
accountOptions: SearchSelectOption[] = [];
account: any = null;
closedDate = '';
note = '';
activityLog: ActivityEntry[] = [];
reviewRequest: any = null;
showRejectDialog = false;
rejectionReason = '';
showConfirmDialog = false;
showValidateModal = false;
validating = false;
validateAllPassed = false;
backendChecks: { label: string; pass: boolean; detail: string }[] | null = null;
validateSteps: { label: string; detail: string; pass: boolean; status: 'pending' | 'active' | 'done' | 'failed' }[] =
[];
get heldShares(): number {
return Number(this.account?.summary?.totalApprovedShares ?? this.account?.totalApprovedShares ?? 0);
}
get isFormValid(): boolean {
if (!this.account) return false;
if (this.heldShares !== 0) return false;
if (!this.closedDate) return false;
return true;
}
/** Shown to the clerk before submit and to the manager before approval, so both can verify the same checks. */
get verificationChecks(): { label: string; pass: boolean; detail: string }[] {
return [
{ label: 'No shares currently held', pass: this.heldShares === 0, detail: `${this.heldShares} share(s) held` },
{ label: 'Closed Date provided', pass: !!this.closedDate, detail: this.closedDate || 'Missing' }
];
}
get isReview(): boolean {
return this.mode === 'review';
}
get pageTitle(): string {
return this.isReview ? 'Review Share Closure Request' : 'New Share Closure Request';
}
get pageSubtitle(): string {
return this.isReview
? "Approve or reject the clerk's share account closure request."
: 'Close a Share Account whose shares have already been fully withdrawn, for Bank Manager approval.';
}
ngOnInit(): void {
this.mode = this.overrideMode ?? (this.route.snapshot.data?.['mode'] as ShareClosureMode) ?? 'create';
const idParam = this.overrideRequestId ?? this.route.snapshot.params?.['id'];
this.requestId = idParam ? Number(idParam) : null;
const today = new Date();
this.closedDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
this.sharesService
.getShareAccountSummaries()
.pipe(catchError(() => of([])))
.subscribe((rows: any[]) => {
this.accountOptions = (rows || [])
.filter((r) => (r.status?.value || '').toLowerCase() === 'active' && Number(r.totalApprovedShares ?? 0) === 0)
.map((r) => ({
value: String(r.id),
label: `${r.accountNo} — ${r.clientName} (0 shares held)`,
searchText: `${r.accountNo} ${r.clientName}`
}));
});
if (this.requestId && this.mode === 'review') {
this.loadReviewRequest(this.requestId);
}
}
onAccountSelected(accountId: string): void {
this.selectedAccountId = accountId;
if (!accountId) {
this.account = null;
return;
}
this.loadingAccount = true;
this.sharesService
.getSharesAccountData(accountId, false)
.pipe(catchError(() => of(null)))
.subscribe((acc: any) => {
this.account = acc;
this.loadingAccount = false;
});
}
private loadReviewRequest(id: number): void {
this.closureService.getById(id).subscribe({
next: (req: any) => {
this.reviewRequest = req;
this.closedDate = req.closed_date ? String(req.closed_date).split('T')[0].split(' ')[0] : this.closedDate;
this.note = req.note ?? '';
if (req.activity_log) {
try {
this.activityLog = JSON.parse(req.activity_log);
} catch {
this.activityLog = [];
}
}
this.sharesService
.getSharesAccountData(String(req.share_account_id), false)
.pipe(catchError(() => of(null)))
.subscribe((acc: any) => (this.account = acc));
},
error: () => this.snackBar.open('Failed to load share closure request.', 'Close', { duration: 5000 })
});
}
/** "Review before Save" per requirement — opens the confirm dialog instead of submitting directly. */
openConfirmDialog(): void {
if (!this.isFormValid || this.submitting) {
return;
}
this.showConfirmDialog = true;
}
cancelConfirmDialog(): void {
this.showConfirmDialog = false;
}
confirmSubmit(): void {
this.showConfirmDialog = false;
this.submitting = true;
const payload = {
shareAccountId: Number(this.selectedAccountId),
accountNo: this.account?.accountNo,
clientId: this.account?.clientId,
clientName: this.account?.clientName,
closedDate: this.closedDate,
note: this.note
};
this.closureService.submit(payload).subscribe({
next: () => {
this.snackBar.open('Share closure request submitted for approval.', 'Close', { duration: 4000 });
if (this.embedded) {
this.actionCompleted.emit();
} else {
this.router.navigate(['/clerk/dashboard']);
}
},
error: (err) => {
this.submitting = false;
this.snackBar.open(err?.error?.defaultUserMessage || 'Could not submit the request.', 'Close', {
duration: 6000
});
}
});
}
openValidateModal(): void {
if (!this.reviewRequest) return;
this.showValidateModal = true;
this.runBackendValidation();
}
closeValidateModal(): void {
if (this.validating) return;
this.showValidateModal = false;
}
/** Re-checks live backend state (shares still zero, account still active, no active loan/guarantee,
* compliance standing) rather than relying on data already loaded in the browser — catches drift
* since submission. Results are revealed one step at a time for the pipeline-style animation. */
runBackendValidation(): void {
if (!this.reviewRequest || this.validating) return;
this.validating = true;
this.backendChecks = null;
this.validateSteps = [];
this.closureService.validate(this.reviewRequest.id).subscribe({
next: (res: any) => {
const checks = (res?.checks ?? []) as { label: string; pass: boolean; detail: string }[];
this.backendChecks = checks;
this.validateAllPassed = !!res?.allPassed;
this.validateSteps = checks.map((c) => ({ ...c, status: 'pending' as const }));
this.revealValidateSteps(0);
},
error: (err) => {
this.validating = false;
this.snackBar.open(err?.error?.defaultUserMessage || 'Could not run backend validation.', 'Close', {
duration: 6000
});
}
});
}
private revealValidateSteps(index: number): void {
if (index >= this.validateSteps.length) {
this.validating = false;
return;
}
this.validateSteps[index].status = 'active';
setTimeout(() => {
this.validateSteps[index].status = this.validateSteps[index].pass ? 'done' : 'failed';
this.revealValidateSteps(index + 1);
}, 320);
}
approveReview(): void {
if (!this.reviewRequest) return;
this.closureService.approve(this.reviewRequest.id).subscribe({
next: () => {
this.snackBar.open('Share closure approved — account closed.', 'Close', { duration: 6000 });
if (this.embedded) {
this.actionCompleted.emit();
} else {
this.router.navigate(['/checker-inbox-and-tasks/share-closure-requests']);
}
},
error: (err) =>
this.snackBar.open(err?.error?.defaultUserMessage || 'Could not approve the request.', 'Close', {
duration: 6000
})
});
}
openRejectDialog(): void {
this.showRejectDialog = true;
this.rejectionReason = '';
}
confirmRejectReview(): void {
if (!this.reviewRequest || !this.rejectionReason.trim()) return;
this.closureService.reject(this.reviewRequest.id, this.rejectionReason.trim()).subscribe({
next: () => {
this.showRejectDialog = false;
this.snackBar.open('Share closure request rejected.', 'Close', { duration: 4000 });
if (this.embedded) {
this.actionCompleted.emit();
} else {
this.router.navigate(['/checker-inbox-and-tasks/share-closure-requests']);
}
},
error: (err) =>
this.snackBar.open(err?.error?.defaultUserMessage || 'Could not reject the request.', 'Close', {
duration: 6000
})
});
}
goBack(): void {
if (this.embedded) {
this.closeRequested.emit();
return;
}
this.router.navigate(this.isReview ? ['/checker-inbox-and-tasks/share-closure-requests'] : ['/clerk/dashboard']);
}
}
@@ -0,0 +1,431 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="pg">
<header class="pg-head">
<div>
<div class="pg-eyebrow">{{ isReview ? 'CHECKER · APPROVAL' : 'CLERK · SHARE WITHDRAWAL' }}</div>
<h1 class="pg-title">{{ pageTitle }}</h1>
<p class="pg-sub">{{ pageSubtitle }}</p>
</div>
<div class="pg-toolbar">
<button type="button" class="pg-btn" (click)="goBack()">
<mat-icon>arrow_back</mat-icon>
{{ isReview ? 'Back to Requests' : 'My Share Requests' }}
</button>
</div>
</header>
<div class="pg-card">
<div class="pos-section-label">Share Account</div>
<div class="inv-form-grid" style="padding: 16px">
@if (!isReview) {
<label class="inv-form-field" style="grid-column: span 2">
<span>* Search Account No. / Client Name</span>
<mifosx-search-select
[options]="accountOptions"
[(ngModel)]="selectedAccountId"
(ngModelChange)="onAccountSelected($event)"
placeholder="Search share account no. or client name…"
></mifosx-search-select>
</label>
}
@if (account) {
<label class="inv-form-field">
<span>Account No</span>
<input type="text" [value]="account.accountNo" readonly />
</label>
<label class="inv-form-field">
<span>Client</span>
<input type="text" [value]="account.clientName" readonly />
</label>
<label class="inv-form-field">
<span>Shares Held</span>
<input
type="text"
[value]="account.summary?.totalApprovedShares ?? account.totalApprovedShares ?? 0"
readonly
/>
</label>
<label class="inv-form-field">
<span>Unit Price</span>
<input type="text" [value]="'₹' + (unitPrice | number: '1.2-2')" readonly />
</label>
}
@if (loadingAccount) {
<div class="pg-empty">Loading account…</div>
}
</div>
</div>
@if (isReview && reviewRequest) {
<div class="pg-card">
<div class="pos-section-label">Client Compliance</div>
<div class="inv-form-grid" style="padding: 16px">
<div class="inv-form-field">
<span>KYC Status</span>
<span
class="swr-chip"
[class.swr-chip--ok]="reviewRequest.client_kyc_verified"
[class.swr-chip--warn]="!reviewRequest.client_kyc_verified"
>
{{ reviewRequest.client_kyc_status_label }}
</span>
</div>
<div class="inv-form-field">
<span>Risk Category</span>
<span
class="swr-chip"
[class.swr-chip--ok]="!reviewRequest.client_risk_high"
[class.swr-chip--warn]="reviewRequest.client_risk_high"
>
{{ reviewRequest.client_risk_category_label }}
</span>
</div>
<div class="inv-form-field">
<span>Open AML Alerts</span>
<span
class="swr-chip"
[class.swr-chip--ok]="!reviewRequest.client_open_aml_alerts"
[class.swr-chip--warn]="reviewRequest.client_open_aml_alerts > 0"
>
{{ reviewRequest.client_open_aml_alerts || 0 }}
</span>
</div>
</div>
</div>
}
@if (account) {
<div class="pg-card">
<div class="pos-section-label">Withdrawal Details</div>
<div class="inv-form-grid" style="padding: 16px">
<label class="inv-form-field">
<span>* Shares to Withdraw</span>
<input type="number" [(ngModel)]="requestedShares" min="1" [disabled]="isReview" />
</label>
<label class="inv-form-field">
<span>* Requested Date</span>
<input type="date" [(ngModel)]="requestedDate" [disabled]="isReview" />
</label>
<label class="inv-form-field">
<span>Withdrawal Value</span>
<input type="text" [value]="'₹' + (withdrawalValue | number: '1.2-2')" readonly />
</label>
<label class="inv-form-field">
<span>Remaining Shares After Withdrawal</span>
<input type="text" [value]="remainingShares" readonly />
</label>
<label class="inv-form-field">
<span>* Board Resolution Number</span>
<input type="text" [(ngModel)]="resolutionNumber" [disabled]="isReview" placeholder="e.g. BR-2026-014" />
</label>
<label class="inv-form-field">
<span>* Date of Meeting</span>
<input type="date" [(ngModel)]="meetingDate" [disabled]="isReview" />
</label>
<label class="inv-form-field">
<span>* Settlement Payment Type</span>
@if (!isReview) {
<select [(ngModel)]="settlementPaymentTypeId">
<option [ngValue]="null">Select…</option>
@for (pt of paymentTypeOptions; track pt.id) {
<option [ngValue]="pt.id">{{ pt.name }}</option>
}
</select>
} @else {
<input type="text" [value]="settlementPaymentTypeName" readonly />
}
</label>
<label class="inv-form-field" style="grid-column: span 2">
<span>Description / Remarks</span>
<input type="text" [(ngModel)]="description" [disabled]="isReview" />
</label>
</div>
@if (!isReview) {
<div class="inv-form-actions">
<button
type="button"
class="pg-btn pg-btn--primary"
[disabled]="!isFormValid || submitting"
(click)="openConfirmDialog()"
>
{{ submitting ? 'Submitting…' : 'Review &amp; Submit' }}
</button>
</div>
}
</div>
}
@if (isReview && reviewRequest?.status === 'PENDING') {
<div class="pg-card">
<div class="pos-section-label">Verification</div>
<div class="swrv-trigger">
<div class="swrv-trigger__icon">
<mat-icon>fact_check</mat-icon>
</div>
<div class="swrv-trigger__text">
<div class="swrv-trigger__title">Run a live backend validation</div>
<div class="swrv-trigger__sub">
Re-checks account status, share balance, linked savings account and GL mapping before you approve.
</div>
</div>
<button type="button" class="pg-btn pg-btn--primary" (click)="openValidateModal()">
<mat-icon>play_circle</mat-icon>
Validate Request
</button>
</div>
<div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="approveReview()">
<mat-icon>check_circle</mat-icon>
Approve &amp; Process Withdrawal
</button>
<button type="button" class="pg-btn pg-btn--danger" (click)="openRejectDialog()">
<mat-icon>cancel</mat-icon>
Reject
</button>
</div>
</div>
}
@if (showValidateModal) {
<div class="swrv-modal">
<div class="swrv-modal__card">
<div class="swrv-modal__header">
<div class="swrv-modal__title">
<span class="swrv-modal__title-icon"><mat-icon>fact_check</mat-icon></span>
Validate Share Withdrawal Request
</div>
<button type="button" class="swrv-modal__close" [disabled]="validating" (click)="closeValidateModal()">
<mat-icon>close</mat-icon>
</button>
</div>
<div class="swrv-modal__body">
<p class="swrv-modal__intro">
Re-checking <strong>{{ requestedShares }} share(s)</strong> withdrawal from
<strong>{{ account?.accountNo }}</strong> ({{ account?.clientName }}) against the live account, savings link
and GL configuration — not just what was captured at submission time.
</p>
@if (backendChecks && !validating && !validateAllPassed) {
<div class="swrv-modal__warn">
<mat-icon>warning</mat-icon>
<div>
<strong>Some checks need review before approving.</strong>
Scroll down to see which ones failed and why.
</div>
</div>
}
<ol class="swrv-steps">
@for (step of validateSteps; track step.label; let i = $index) {
<li
class="swrv-step"
[class.swrv-step--active]="step.status === 'active'"
[class.swrv-step--done]="step.status === 'done'"
[class.swrv-step--failed]="step.status === 'failed'"
>
<div class="swrv-step__indicator">
@if (step.status === 'done') {
<mat-icon>check</mat-icon>
} @else if (step.status === 'active') {
<mat-icon class="swrv-spin">autorenew</mat-icon>
} @else if (step.status === 'failed') {
<mat-icon>close</mat-icon>
} @else {
<span>{{ i + 1 }}</span>
}
</div>
<div class="swrv-step__content">
<div class="swrv-step__label">{{ step.label }}</div>
<div class="swrv-step__msg">{{ step.detail }}</div>
</div>
</li>
}
</ol>
@if (validating && validateSteps.length === 0) {
<div class="pg-empty">Connecting to server…</div>
}
@if (backendChecks && !validating && validateAllPassed) {
<div class="swrv-modal__result">
<mat-icon>verified</mat-icon>
<span>All checks passed — safe to approve.</span>
</div>
}
</div>
<div class="swrv-modal__footer">
@if (!validating && backendChecks) {
<button type="button" class="pg-btn" (click)="runBackendValidation()">
<mat-icon>refresh</mat-icon>
Re-run
</button>
}
@if (!validating && backendChecks && validateAllPassed) {
<button type="button" class="pg-btn pg-btn--primary" (click)="closeValidateModal(); approveReview()">
<mat-icon>check_circle</mat-icon>
Approve &amp; Process Withdrawal
</button>
}
<button type="button" class="pg-btn" [disabled]="validating" (click)="closeValidateModal()">
{{ validating ? 'Validating…' : 'Close' }}
</button>
</div>
</div>
</div>
}
@if (isReview && reviewRequest?.status === 'APPROVED') {
<div class="pg-card">
<div class="pos-section-label">Settlement</div>
<div class="inv-form-grid" style="padding: 16px">
@if (reviewRequest?.settlement_amount) {
<label class="inv-form-field">
<span>Amount Credited</span>
<input type="text" [value]="'₹' + (reviewRequest.settlement_amount | number: '1.2-2')" readonly />
</label>
<label class="inv-form-field">
<span>Credited To (Linked Savings A/C)</span>
<input type="text" [value]="account?.savingsAccountNumber || '—'" readonly />
</label>
<label class="inv-form-field">
<span>Savings Transaction Ref.</span>
<input type="text" [value]="reviewRequest.settlement_savings_transaction_id" readonly />
</label>
} @else {
<div class="pg-empty" style="grid-column: span 2">
Settlement is processed automatically on approval. Details aren't available when reopening an
already-approved request — check the linked savings account's transaction history to confirm.
</div>
}
</div>
</div>
}
@if (isReview && activityLog.length > 0) {
<div class="pg-card">
<div class="pos-section-label">Activity Log</div>
<table class="pg-table">
<thead>
<tr>
<th>Timestamp</th>
<th>Action</th>
<th>User</th>
<th>Note</th>
</tr>
</thead>
<tbody>
@for (entry of activityLog; track entry.timestamp) {
<tr>
<td>{{ entry.timestamp | date: 'medium' }}</td>
<td>{{ entry.action }}</td>
<td>{{ entry.user }}</td>
<td>{{ entry.note || '—' }}</td>
</tr>
}
</tbody>
</table>
</div>
}
@if (showConfirmDialog) {
<div class="swr-modal-backdrop">
<div class="swr-modal">
<h3>Confirm Share Withdrawal Request</h3>
<p class="pg-sub">Review the details below before submitting for Bank Manager approval.</p>
<table class="pg-table">
<tbody>
<tr>
<td>Account No / Client</td>
<td>{{ account?.accountNo }} — {{ account?.clientName }}</td>
</tr>
<tr>
<td>Shares to Withdraw</td>
<td>
{{ requestedShares }} @ ₹{{ unitPrice | number: '1.2-2' }} = ₹{{ withdrawalValue | number: '1.2-2' }}
</td>
</tr>
<tr>
<td>Remaining Shares After Withdrawal</td>
<td>{{ remainingShares }}</td>
</tr>
<tr>
<td>Requested Date</td>
<td>{{ requestedDate }}</td>
</tr>
<tr>
<td>Board Resolution Number</td>
<td>{{ resolutionNumber }}</td>
</tr>
<tr>
<td>Date of Meeting</td>
<td>{{ meetingDate }}</td>
</tr>
<tr>
<td>Settlement Payment Type</td>
<td>{{ settlementPaymentTypeName }}</td>
</tr>
<tr>
<td>Linked Savings Account</td>
<td>{{ account?.savingsAccountNumber || 'None — settlement will not run automatically' }}</td>
</tr>
</tbody>
</table>
<div class="pos-section-label" style="margin-top: 12px">Validation Checks</div>
<table class="pg-table">
<tbody>
@for (check of verificationChecks; track check.label) {
<tr>
<td>{{ check.label }}</td>
<td [class.swr-check-pass]="check.pass" [class.swr-check-fail]="!check.pass">
{{ check.pass ? 'Pass — ' : 'Review — ' }}{{ check.detail }}
</td>
</tr>
}
</tbody>
</table>
<div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--primary" [disabled]="submitting" (click)="confirmSubmit()">
{{ submitting ? 'Submitting…' : 'Confirm & Submit' }}
</button>
<button type="button" class="pg-btn" (click)="cancelConfirmDialog()">Back to Edit</button>
</div>
</div>
</div>
}
@if (showRejectDialog) {
<div class="swr-modal-backdrop">
<div class="swr-modal">
<h3>Reject Share Withdrawal Request</h3>
<label class="inv-form-field">
<span>* Rejection Reason</span>
<textarea rows="3" [(ngModel)]="rejectionReason"></textarea>
</label>
<div class="inv-form-actions">
<button
type="button"
class="pg-btn pg-btn--danger"
[disabled]="!rejectionReason.trim()"
(click)="confirmRejectReview()"
>
Confirm Reject
</button>
<button type="button" class="pg-btn" (click)="showRejectDialog = false">Cancel</button>
</div>
</div>
</div>
}
</div>
@@ -0,0 +1,433 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
.swr-modal-backdrop {
position: fixed;
inset: 0;
background: rgb(13 27 42 / 55%);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.swr-modal {
background: #fff;
border-radius: 12px;
padding: 20px;
width: 420px;
max-width: 90vw;
max-height: 85vh;
overflow-y: auto;
box-shadow: 0 12px 32px rgb(15 23 42 / 20%);
h3 {
margin: 0 0 12px;
font-size: 16px;
color: #0d1b2a;
}
textarea {
width: 100%;
padding: 8px 10px;
border: 1px solid #e2e8f0;
border-radius: 8px;
font-family: inherit;
font-size: 13px;
resize: vertical;
box-sizing: border-box;
}
&:has(table) {
width: 560px;
}
}
.swr-check-pass {
color: #1a7f37;
font-weight: 600;
}
.swr-check-fail {
color: #b42318;
font-weight: 600;
}
.swr-chip {
display: inline-flex;
align-items: center;
width: fit-content;
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
&--ok {
background: #eafaf0;
color: #1a7f37;
}
&--warn {
background: #fdecea;
color: #b42318;
}
}
/* ---------- Verification trigger card ---------- */
.swrv-trigger {
display: flex;
align-items: center;
gap: 14px;
padding: 16px;
flex-wrap: wrap;
&__icon {
width: 42px;
height: 42px;
border-radius: 12px;
background: linear-gradient(135deg, #3457d5, #6c8cff);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
box-shadow: 0 4px 12px rgb(52 87 213 / 30%);
}
&__text {
flex: 1;
min-width: 200px;
}
&__title {
font-size: 13px;
font-weight: 700;
color: #0d1b2a;
}
&__sub {
font-size: 12px;
color: #64748b;
margin-top: 2px;
line-height: 1.4;
}
}
/* ---------- Validate pipeline modal ---------- */
@keyframes swrv-fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes swrv-zoom {
from {
opacity: 0;
transform: scale(0.95) translateY(8px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
@keyframes swrv-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.swrv-spin {
animation: swrv-spin 0.9s linear infinite;
}
.swrv-modal {
position: fixed;
inset: 0;
// No backdrop-filter: it would make this element a containing block for any position:fixed
// descendant (e.g. if this modal is ever nested inside another fixed popup, or gains one of its
// own later) — see the identical fix in share-withdrawal-requests.component.scss for the bug this
// caused. Extra opacity compensates for the loss of the blur look.
background: rgb(15 25 45 / 65%);
z-index: 1200;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
animation: swrv-fade 0.2s ease-out;
&__card {
background: #fff;
border-radius: 16px;
width: 100%;
max-width: 520px;
max-height: 90vh;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 24px 60px rgb(0 0 0 / 25%);
animation: swrv-zoom 0.25s ease-out;
}
&__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 22px;
border-bottom: 1px solid #eef1f5;
}
&__title {
display: flex;
align-items: center;
gap: 10px;
font-size: 15px;
font-weight: 700;
color: #0d1b2a;
}
&__title-icon {
width: 34px;
height: 34px;
border-radius: 10px;
background: linear-gradient(135deg, #3457d5, #6c8cff);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
&__close {
width: 32px;
height: 32px;
border-radius: 8px;
border: 1px solid #e2e8f0;
background: #fff;
color: #64748b;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.12s;
&:not(:disabled):hover {
color: #b42318;
border-color: #b42318;
background: #fdecea;
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
}
&__body {
padding: 20px 22px;
overflow-y: auto;
flex: 1;
}
&__intro {
font-size: 13px;
color: #475569;
line-height: 1.55;
margin: 0 0 16px;
strong {
color: #0d1b2a;
font-weight: 700;
}
}
&__warn {
display: flex;
gap: 10px;
padding: 12px 14px;
background: #fef3e2;
border: 1px solid rgb(217 119 6 / 30%);
border-left: 3px solid #d97706;
border-radius: 8px;
margin-bottom: 16px;
font-size: 12px;
color: #633806;
line-height: 1.5;
mat-icon {
color: #d97706;
font-size: 18px;
width: 18px;
height: 18px;
flex-shrink: 0;
}
strong {
display: block;
font-weight: 700;
color: #4d2a05;
}
}
&__result {
display: flex;
align-items: center;
gap: 8px;
margin-top: 6px;
padding: 12px 14px;
background: #eafaf0;
border: 1px solid rgb(26 127 55 / 25%);
border-radius: 8px;
font-size: 13px;
font-weight: 600;
color: #1a7f37;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
}
&__footer {
padding: 16px 22px;
border-top: 1px solid #eef1f5;
display: flex;
justify-content: flex-end;
gap: 10px;
background: #f8fafc;
}
}
.swrv-steps {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
}
.swrv-step {
display: flex;
gap: 14px;
padding: 10px 0;
position: relative;
&:not(:last-child)::after {
content: '';
position: absolute;
left: 15px;
top: 38px;
bottom: -4px;
width: 2px;
background: #e2e8f0;
}
&__indicator {
width: 32px;
height: 32px;
border-radius: 50%;
background: #fff;
border: 2px solid #e2e8f0;
color: #64748b;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
flex-shrink: 0;
position: relative;
z-index: 1;
transition: all 0.25s;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
}
&__content {
padding-top: 5px;
flex: 1;
min-width: 0;
}
&__label {
font-size: 13px;
font-weight: 600;
color: #475569;
}
&__msg {
font-size: 11px;
color: #94a3b8;
margin-top: 2px;
}
&--active {
.swrv-step__indicator {
background: #3457d5;
border-color: #3457d5;
color: #fff;
box-shadow: 0 0 0 4px rgb(52 87 213 / 15%);
}
.swrv-step__label {
color: #3457d5;
}
}
&--done {
.swrv-step__indicator {
background: #1a7f37;
border-color: #1a7f37;
color: #fff;
}
&::after {
background: #1a7f37;
}
.swrv-step__label {
color: #0d1b2a;
}
}
&--failed {
.swrv-step__indicator {
background: #b42318;
border-color: #b42318;
color: #fff;
}
.swrv-step__label {
color: #b42318;
}
}
}
@media (prefers-reduced-motion: reduce) {
.swrv-spin {
animation: none;
}
.swrv-modal,
.swrv-modal__card {
animation: none;
}
}
@@ -0,0 +1,416 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, EventEmitter, Input, OnInit, Output, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { catchError } from 'rxjs/operators';
import { of } from 'rxjs';
import { SharesService } from '../../shares/shares.service';
import { ClerkShareWithdrawalService } from '../../cashier/clerk-share-withdrawal.service';
import { CashierService } from '../../cashier/cashier.service';
import { SearchSelectComponent, SearchSelectOption } from '../../shared/search-select/search-select.component';
export type ShareWdMode = 'create' | 'review';
interface ActivityEntry {
timestamp: string;
action: string;
user: string;
note?: string;
}
/**
* Share Withdrawal (redeem shares) request — maker screen in the Clerk Dashboard. A clerk finds
* the member's Share Account, enters how many shares to withdraw plus the governing Board
* Resolution Number / Date of Meeting, and submits for Bank Manager approval. This is the ONLY
* entry point a share withdrawal should go through — approval server-side executes the real
* "redeemshares" command (see ClerkShareWithdrawalRequestApiResource on the backend).
*/
@Component({
selector: 'mifosx-share-withdrawal-request',
templateUrl: './share-withdrawal-request.component.html',
styleUrls: [
'../../cashier/cashier-shared.scss',
'../clerk-shared.scss',
'./share-withdrawal-request.component.scss'
],
standalone: true,
imports: [
CommonModule,
FormsModule,
RouterModule,
MatIconModule,
MatSnackBarModule,
SearchSelectComponent
]
})
export class ShareWithdrawalRequestComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private sharesService = inject(SharesService);
private wdService = inject(ClerkShareWithdrawalService);
private cashierService = inject(CashierService);
private snackBar = inject(MatSnackBar);
/** When embedded (e.g. opened as a popup from a list screen instead of routed to directly), mode
* and requestId come from these inputs rather than the route, and success/close actions emit
* events instead of navigating away. */
@Input() embedded = false;
@Input() overrideMode: ShareWdMode | null = null;
@Input() overrideRequestId: number | null = null;
@Output() closeRequested = new EventEmitter<void>();
@Output() actionCompleted = new EventEmitter<void>();
mode: ShareWdMode = 'create';
requestId: number | null = null;
submitting = false;
loadingAccount = false;
selectedAccountId = '';
accountOptions: SearchSelectOption[] = [];
account: any = null;
requestedShares: number | null = null;
requestedDate = '';
resolutionNumber = '';
meetingDate = '';
description = '';
paymentTypes: any[] = [];
settlementPaymentTypeId: number | null = null;
activityLog: ActivityEntry[] = [];
reviewRequest: any = null;
showRejectDialog = false;
rejectionReason = '';
showConfirmDialog = false;
showValidateModal = false;
validating = false;
validateAllPassed = false;
backendChecks: { label: string; pass: boolean; detail: string }[] | null = null;
validateSteps: { label: string; detail: string; pass: boolean; status: 'pending' | 'active' | 'done' | 'failed' }[] =
[];
get remainingShares(): number {
const held = Number(this.account?.summary?.totalApprovedShares ?? this.account?.totalApprovedShares ?? 0);
return held - (this.requestedShares || 0);
}
get unitPrice(): number {
return Number(this.account?.currentMarketPrice ?? 0);
}
get withdrawalValue(): number {
return (this.requestedShares || 0) * this.unitPrice;
}
get paymentTypeOptions(): any[] {
return this.paymentTypes.filter((pt: any) => !pt.isCashPayment);
}
get selectedSettlementPaymentType(): any {
return this.paymentTypes.find((pt: any) => pt.id === this.settlementPaymentTypeId);
}
get settlementPaymentTypeName(): string {
if (this.isReview) {
return this.reviewRequest?.settlement_payment_type_name || '—';
}
return this.selectedSettlementPaymentType?.name || '—';
}
get isFormValid(): boolean {
if (!this.account) return false;
if (!this.requestedShares || this.requestedShares <= 0) return false;
const held = Number(this.account?.summary?.totalApprovedShares ?? this.account?.totalApprovedShares ?? 0);
if (this.requestedShares > held) return false;
if (!this.requestedDate) return false;
if (!this.resolutionNumber.trim()) return false;
if (!this.meetingDate) return false;
if (!this.settlementPaymentTypeId) return false;
return true;
}
/** Shown to the clerk before submit and to the manager before approval, so both can verify the same checks. */
get verificationChecks(): { label: string; pass: boolean; detail: string }[] {
const held = Number(this.account?.summary?.totalApprovedShares ?? this.account?.totalApprovedShares ?? 0);
const shares = this.requestedShares || 0;
return [
{
label: 'Board Resolution Number provided',
pass: !!this.resolutionNumber?.trim(),
detail: this.resolutionNumber?.trim() || 'Missing'
},
{ label: 'Date of Meeting provided', pass: !!this.meetingDate, detail: this.meetingDate || 'Missing' },
{
label: 'Shares requested within shares held',
pass: shares > 0 && shares <= held,
detail: `${shares} of ${held} shares held`
},
{
label: 'Linked savings account confirmed',
pass: !!this.account?.savingsAccountNumber,
detail: this.account?.savingsAccountNumber || 'No linked savings account — settlement cannot run automatically'
},
{
label: 'Settlement Payment Type selected (non-cash)',
pass: this.settlementPaymentTypeName !== '—',
detail: this.settlementPaymentTypeName
}
];
}
get isReview(): boolean {
return this.mode === 'review';
}
get pageTitle(): string {
return this.isReview ? 'Review Share Withdrawal Request' : 'New Share Withdrawal Request';
}
get pageSubtitle(): string {
return this.isReview
? "Approve or reject the clerk's share withdrawal request."
: 'Submit a share withdrawal (redeem shares) request for Bank Manager approval.';
}
ngOnInit(): void {
this.mode = this.overrideMode ?? (this.route.snapshot.data?.['mode'] as ShareWdMode) ?? 'create';
const idParam = this.overrideRequestId ?? this.route.snapshot.params?.['id'];
this.requestId = idParam ? Number(idParam) : null;
const today = new Date();
this.requestedDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
this.meetingDate = this.requestedDate;
this.sharesService
.getShareAccountSummaries()
.pipe(catchError(() => of([])))
.subscribe((rows: any[]) => {
this.accountOptions = (rows || [])
.filter((r) => (r.status?.value || '').toLowerCase() === 'active')
.map((r) => ({
value: String(r.id),
label: `${r.accountNo} — ${r.clientName} (${r.totalApprovedShares} shares)`,
searchText: `${r.accountNo} ${r.clientName}`
}));
});
this.cashierService
.getPaymentTypes()
.pipe(catchError(() => of([])))
.subscribe((types: any[]) => (this.paymentTypes = types ?? []));
if (this.requestId && this.mode === 'review') {
this.loadReviewRequest(this.requestId);
}
}
onAccountSelected(accountId: string): void {
this.selectedAccountId = accountId;
if (!accountId) {
this.account = null;
return;
}
this.loadingAccount = true;
this.sharesService
.getSharesAccountData(accountId, false)
.pipe(catchError(() => of(null)))
.subscribe((acc: any) => {
this.account = acc;
this.loadingAccount = false;
});
}
private loadReviewRequest(id: number): void {
this.wdService.getById(id).subscribe({
next: (req: any) => {
this.reviewRequest = req;
this.requestedShares = Number(req.requested_shares);
this.requestedDate = req.requested_date
? String(req.requested_date).split('T')[0].split(' ')[0]
: this.requestedDate;
this.resolutionNumber = req.resolution_number ?? '';
this.meetingDate = req.meeting_date ? String(req.meeting_date).split('T')[0].split(' ')[0] : this.meetingDate;
this.description = req.description ?? '';
if (req.activity_log) {
try {
this.activityLog = JSON.parse(req.activity_log);
} catch {
this.activityLog = [];
}
}
this.sharesService
.getSharesAccountData(String(req.share_account_id), false)
.pipe(catchError(() => of(null)))
.subscribe((acc: any) => (this.account = acc));
},
error: () => this.snackBar.open('Failed to load share withdrawal request.', 'Close', { duration: 5000 })
});
}
/** "Review before Save" per requirement — opens the confirm dialog instead of submitting directly. */
openConfirmDialog(): void {
if (!this.isFormValid || this.submitting) {
return;
}
this.showConfirmDialog = true;
}
cancelConfirmDialog(): void {
this.showConfirmDialog = false;
}
confirmSubmit(): void {
this.showConfirmDialog = false;
this.submitting = true;
const payload = {
shareAccountId: Number(this.selectedAccountId),
accountNo: this.account?.accountNo,
clientId: this.account?.clientId,
clientName: this.account?.clientName,
requestedShares: this.requestedShares,
requestedDate: this.requestedDate,
resolutionNumber: this.resolutionNumber.trim(),
meetingDate: this.meetingDate,
description: this.description,
settlementPaymentTypeId: this.settlementPaymentTypeId
};
this.wdService.submit(payload).subscribe({
next: () => {
this.snackBar.open('Share withdrawal request submitted for approval.', 'Close', { duration: 4000 });
if (this.embedded) {
this.actionCompleted.emit();
} else {
this.router.navigate(['/clerk/dashboard']);
}
},
error: (err) => {
this.submitting = false;
this.snackBar.open(err?.error?.defaultUserMessage || 'Could not submit the request.', 'Close', {
duration: 6000
});
}
});
}
openValidateModal(): void {
if (!this.reviewRequest) return;
this.showValidateModal = true;
this.runBackendValidation();
}
closeValidateModal(): void {
if (this.validating) return;
this.showValidateModal = false;
}
/** Re-checks live backend state (account still active, GL mapping still configured, etc.) rather
* than relying on data already loaded in the browser — catches drift since submission. Results
* are revealed one step at a time (see revealValidateSteps()) for the pipeline-style animation. */
runBackendValidation(): void {
if (!this.reviewRequest || this.validating) return;
this.validating = true;
this.backendChecks = null;
this.validateSteps = [];
this.wdService.validate(this.reviewRequest.id).subscribe({
next: (res: any) => {
const checks = (res?.checks ?? []) as { label: string; pass: boolean; detail: string }[];
this.backendChecks = checks;
this.validateAllPassed = !!res?.allPassed;
this.validateSteps = checks.map((c) => ({ ...c, status: 'pending' as const }));
this.revealValidateSteps(0);
},
error: (err) => {
this.validating = false;
this.snackBar.open(err?.error?.defaultUserMessage || 'Could not run backend validation.', 'Close', {
duration: 6000
});
}
});
}
private revealValidateSteps(index: number): void {
if (index >= this.validateSteps.length) {
this.validating = false;
return;
}
this.validateSteps[index].status = 'active';
setTimeout(() => {
this.validateSteps[index].status = this.validateSteps[index].pass ? 'done' : 'failed';
this.revealValidateSteps(index + 1);
}, 320);
}
approveReview(): void {
if (!this.reviewRequest) return;
this.wdService.approve(this.reviewRequest.id).subscribe({
next: (res: any) => {
let message =
res?.settlementAmount != null
? `Share withdrawal approved. ₹${Number(res.settlementAmount).toLocaleString('en-IN', { minimumFractionDigits: 2 })} credited to the linked savings account.`
: (res?.settlementWarning ?? 'Share withdrawal approved and processed.');
if (res?.autoClosureRequestId) {
message += ' All shares now withdrawn — a Share Closure Request was auto-submitted for manager review.';
}
this.snackBar.open(message, 'Close', { duration: 8000 });
if (this.embedded) {
this.actionCompleted.emit();
} else {
this.router.navigate(['/checker-inbox-and-tasks/share-withdrawal-requests']);
}
},
error: (err) =>
this.snackBar.open(err?.error?.defaultUserMessage || 'Could not approve the request.', 'Close', {
duration: 6000
})
});
}
openRejectDialog(): void {
this.showRejectDialog = true;
this.rejectionReason = '';
}
confirmRejectReview(): void {
if (!this.reviewRequest || !this.rejectionReason.trim()) return;
this.wdService.reject(this.reviewRequest.id, this.rejectionReason.trim()).subscribe({
next: () => {
this.showRejectDialog = false;
this.snackBar.open('Share withdrawal request rejected.', 'Close', { duration: 4000 });
if (this.embedded) {
this.actionCompleted.emit();
} else {
this.router.navigate(['/checker-inbox-and-tasks/share-withdrawal-requests']);
}
},
error: (err) =>
this.snackBar.open(err?.error?.defaultUserMessage || 'Could not reject the request.', 'Close', {
duration: 6000
})
});
}
goBack(): void {
if (this.embedded) {
this.closeRequested.emit();
return;
}
this.router.navigate(this.isReview ? ['/checker-inbox-and-tasks/share-withdrawal-requests'] : ['/clerk/dashboard']);
}
}
@@ -0,0 +1,110 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="fm-dialog">
<div class="fm-dialog__header" style="display: flex; align-items: center; gap: 8px">
<mat-icon class="fm-dialog__icon">people</mat-icon>
<span style="font-weight: 500; font-size: 18px">CHILDREN</span>
</div>
<mat-dialog-content>
<form [formGroup]="childrenForm">
<div formArrayName="children" style="margin-top: 12px; background: #fff; padding: 12px">
@for (child of children.controls; track $index) {
<div
[formGroupName]="$index"
style="
position: relative;
padding: 24px 16px 16px;
margin-top: 16px;
margin-bottom: 24px;
border: 1px solid #e0e0e0;
border-radius: 8px;
background: #fff;
"
>
<div class="fm-grid">
<mat-form-field appearance="outline">
<mat-label>Child Full Name*</mat-label>
<input matInput formControlName="fullName" required placeholder="Child Full Name*" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Gender*</mat-label>
<mat-select formControlName="genderId" required>
@for (g of genderIdOptions; track g.id) {
<mat-option [value]="g.id">{{ g.name }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" (click)="childDobPicker.open()">
<mat-label>Date of Birth</mat-label>
<input matInput [max]="maxDate" [matDatepicker]="childDobPicker" formControlName="dateOfBirth" />
<mat-datepicker-toggle matSuffix [for]="childDobPicker"></mat-datepicker-toggle>
<mat-datepicker #childDobPicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Nationality</mat-label>
<mat-select formControlName="nationality">
<mat-option value="INDIAN">Indian</mat-option>
<mat-option value="NRI">NRI</mat-option>
<mat-option value="FOREIGN_NATIONAL">Foreign National</mat-option>
<mat-option value="OTHER">Other</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Aadhaar Number (Optional)</mat-label>
<input
matInput
formControlName="aadhaarNumber"
maxlength="12"
placeholder="12-digit Aadhaar"
(keypress)="$event.charCode >= 48 && $event.charCode <= 57 ? true : $event.preventDefault()"
/>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>PAN Number (Optional)</mat-label>
<input
matInput
formControlName="panNumber"
maxlength="10"
placeholder="ABCDE1234F"
style="text-transform: uppercase"
(input)="
$any(children.at($index))
.get('panNumber')
?.setValue($any($event.target).value.toUpperCase(), { emitEvent: false })
"
/>
@if (
$any(children.at($index)).get('panNumber')?.hasError('pattern') &&
$any(children.at($index)).get('panNumber')?.touched
) {
<mat-error>Invalid PAN format</mat-error>
}
</mat-form-field>
</div>
</div>
@if ($index < children.controls.length - 1) {
<div style="border-bottom: 1px dashed #ccc; margin-bottom: 16px"></div>
}
}
<div style="border-bottom: 1px dashed #ccc; margin-bottom: 16px"></div>
<button mat-stroked-button type="button" color="primary" (click)="addChild()">
<mat-icon>add</mat-icon> Add Another Child
</button>
</div>
</form>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-stroked-button mat-dialog-close type="button">Cancel</button>
<button mat-flat-button color="primary" type="button" [disabled]="childrenForm.invalid" (click)="submit()">
Add Member
</button>
</mat-dialog-actions>
</div>
@@ -0,0 +1,124 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { UntypedFormBuilder, UntypedFormGroup, UntypedFormArray, Validators } from '@angular/forms';
import { MatIcon } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { MatDialogModule } from '@angular/material/dialog';
import { MatTooltipModule } from '@angular/material/tooltip';
import { ReactiveFormsModule } from '@angular/forms';
import { CommonModule } from '@angular/common';
@Component({
selector: 'mifosx-client-family-member-child-dialog',
templateUrl: './client-family-member-child-dialog.component.html',
styleUrls: ['../client-family-member-dialog/client-family-member-dialog.component.scss'],
standalone: true,
imports: [
CommonModule,
ReactiveFormsModule,
MatDialogModule,
MatIcon,
MatButtonModule,
MatInputModule,
MatSelectModule,
MatDatepickerModule,
MatTooltipModule
]
})
export class ClientFamilyMemberChildDialogComponent implements OnInit {
dialogRef = inject(MatDialogRef<ClientFamilyMemberChildDialogComponent>);
data = inject(MAT_DIALOG_DATA);
private formBuilder = inject(UntypedFormBuilder);
childrenForm: UntypedFormGroup;
genderIdOptions: any[];
maxDate: Date = new Date();
constructor() {
this.genderIdOptions = this.data.genderIdOptions;
this.childrenForm = this.formBuilder.group({
children: this.formBuilder.array([])
});
}
ngOnInit() {
if (this.data.children && this.data.children.length > 0) {
this.data.children.forEach((child: any) => {
this.children.push(
this.formBuilder.group({
id: [child.id],
fullName: [
child.fullName,
Validators.required
],
genderId: [
child.genderId,
Validators.required
],
dateOfBirth: [child.dateOfBirth ? new Date(child.dateOfBirth) : ''],
nationality: [child.nationality || 'INDIAN'],
aadhaarNumber: [child.aadhaarNumber || ''],
panNumber: [
child.panNumber || '',
Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)
]
})
);
});
} else {
this.addChild();
}
}
get children(): UntypedFormArray {
return this.childrenForm.get('children') as UntypedFormArray;
}
createChildFormGroup(): UntypedFormGroup {
return this.formBuilder.group({
id: [''],
fullName: [
'',
Validators.required
],
genderId: [
'',
Validators.required
],
dateOfBirth: [''],
nationality: ['INDIAN'],
aadhaarNumber: [''],
panNumber: [
'',
Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)
]
});
}
addChild(): void {
this.children.push(this.createChildFormGroup());
}
removeChild(index: number): void {
this.children.removeAt(index);
}
submit(): void {
if (this.childrenForm.valid) {
this.dialogRef.close(this.children.value);
}
}
}
@@ -139,6 +139,21 @@
<span class="fm-readonly-value">{{ familyMemberForm.get('remarks')?.value }}</span>
</div>
}
@if (isSpouse && familyMemberForm.get('children')?.value?.length > 0) {
<div class="fm-readonly-remarks">
<span class="fm-readonly-label">Children</span>
<div class="fm-children-list">
@for (child of familyMemberForm.get('children')?.value; track $index) {
<div class="fm-child-item" style="font-size: 13px; color: #555; margin-top: 4px">
<span>{{ child.fullName }}</span>
@if (child.dateOfBirth) {
<span> · DOB: {{ child.dateOfBirth | date: 'dd MMM yyyy' }}</span>
}
</div>
}
</div>
</div>
}
}
<!-- ═══ ADD / EDIT MODE ═══ -->
@@ -447,6 +462,25 @@
<mat-label>Remarks</mat-label>
<textarea matInput formControlName="remarks" rows="2"></textarea>
</mat-form-field>
<!-- Children Section -->
@if (isSpouse) {
<div class="fm-section-label" style="margin-top: 16px">Children</div>
<div class="fm-checkbox-row" style="display: flex; align-items: center; justify-content: space-between">
<mat-checkbox formControlName="hasChildren">Does this family member have a child/children?</mat-checkbox>
@if (familyMemberForm.get('hasChildren')?.value) {
<button
mat-stroked-button
type="button"
color="primary"
(click)="openChildrenDialog()"
style="margin-left: 16px"
>
<mat-icon>edit</mat-icon> Edit Children Details ({{ children.length }})
</button>
}
</div>
}
}
</form>
</mat-dialog-content>
@@ -17,7 +17,7 @@ import {
MatDialogClose,
MatDialogContent
} from '@angular/material/dialog';
import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms';
import { UntypedFormGroup, UntypedFormBuilder, UntypedFormArray, Validators } from '@angular/forms';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
import { ClientsService } from 'app/clients/clients.service';
@@ -58,6 +58,8 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
private dialog = inject(MatDialog);
maxDate = new Date();
hasChildrenChecked = false;
familyMemberForm: UntypedFormGroup;
/** Client search */
@@ -197,6 +199,32 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
// Re-fetch linked client details (profile, signature, KYC status)
this.fetchLinkedClientData(m.linkedClientId.toString());
}
if (m.children && m.children.length > 0) {
this.hasChildrenChecked = true;
this.familyMemberForm.patchValue({ hasChildren: true });
m.children.forEach((child: any) => {
this.children.push(
this.formBuilder.group({
id: [child.id],
fullName: [
child.fullName,
Validators.required
],
genderId: [
child.genderId,
Validators.required
],
dateOfBirth: [child.dateOfBirth ? new Date(child.dateOfBirth) : ''],
nationality: [child.nationality || 'INDIAN'],
aadhaarNumber: [child.aadhaarNumber || ''],
panNumber: [
child.panNumber || '',
Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)
]
})
);
});
}
if (this.isViewMode) {
this.familyMemberForm.disable();
}
@@ -225,8 +253,102 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
isAlive: [true],
marriageDate: [''],
marriageAuthority: [''],
linkedClientId: ['']
linkedClientId: [''],
hasChildren: [false],
children: this.formBuilder.array([])
});
this.familyMemberForm.get('hasChildren')?.valueChanges.subscribe((value) => {
if (value && !this.hasChildrenChecked) {
this.openChildrenDialog();
} else if (!value) {
this.hasChildrenChecked = false;
this.children.clear();
}
});
}
get children(): UntypedFormArray {
return this.familyMemberForm.get('children') as UntypedFormArray;
}
async openChildrenDialog() {
// Dynamically import the dialog to avoid a circular dependency between the two dialogs.
const { ClientFamilyMemberChildDialogComponent } =
await import('../client-family-member-child-dialog/client-family-member-child-dialog.component');
const dialogRef = this.dialog.open(ClientFamilyMemberChildDialogComponent, {
data: {
children: this.children.value,
genderIdOptions: this.data.options.genderIdOptions
},
width: '500px'
});
dialogRef.afterClosed().subscribe((result: any[]) => {
if (result) {
this.children.clear();
result.forEach((child) => {
this.children.push(
this.formBuilder.group({
id: [child.id],
fullName: [
child.fullName,
Validators.required
],
genderId: [
child.genderId,
Validators.required
],
dateOfBirth: [child.dateOfBirth],
nationality: [child.nationality || 'INDIAN'],
aadhaarNumber: [child.aadhaarNumber || ''],
panNumber: [
child.panNumber || '',
Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)
]
})
);
});
this.hasChildrenChecked = true;
this.familyMemberForm.patchValue({ hasChildren: true }, { emitEvent: false });
} else if (this.children.length === 0) {
this.hasChildrenChecked = false;
this.familyMemberForm.patchValue({ hasChildren: false }, { emitEvent: false });
}
});
}
createChildFormGroup(): UntypedFormGroup {
return this.formBuilder.group({
id: [''],
fullName: [
'',
Validators.required
],
genderId: [
'',
Validators.required
],
dateOfBirth: [''],
nationality: ['INDIAN'],
aadhaarNumber: [''],
panNumber: [
'',
Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)
]
});
}
addChild(): void {
this.children.push(this.createChildFormGroup());
}
removeChild(index: number): void {
this.children.removeAt(index);
if (this.children.length === 0) {
this.familyMemberForm.patchValue({ hasChildren: false });
}
}
/** Handle marriage certificate file selection */
@@ -481,6 +603,25 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
member._linkedClient = this.selectedClient;
}
// Format children dates
if (this.isSpouse && member.children && member.children.length > 0) {
member.children = member.children.map((child: any) => {
const c = { ...child };
if (c.dateOfBirth instanceof Date) {
c.dateOfBirth = this.dateUtils.formatDate(c.dateOfBirth, dateFormat);
} else if (!c.dateOfBirth) {
delete c.dateOfBirth;
}
if (c.panNumber) {
c.panNumber = c.panNumber.toUpperCase();
}
return c;
});
} else {
delete member.children;
}
delete member.hasChildren;
// Attach marriage certificate file if present
if (this.marriageCertFile) {
member._marriageCertFile = this.marriageCertFile;
@@ -62,6 +62,22 @@
</div>
}
<!-- Mother requirement — shown independently of the Father/Caretaker check below -->
@if (!nomineeOnly && clientFamilyMembers.length > 0 && !hasMother) {
<div class="fm-step__empty fm-step__empty--required">
<mat-icon class="fm-step__empty-icon">error_outline</mat-icon>
<span class="fm-step__empty-required">Mother is required.</span>
</div>
}
<!-- Father-or-Caretaker requirement — shown independently of the Mother check above -->
@if (!nomineeOnly && clientFamilyMembers.length > 0 && !hasFather && !hasCaretaker) {
<div class="fm-step__empty fm-step__empty--required">
<mat-icon class="fm-step__empty-icon">error_outline</mat-icon>
<span class="fm-step__empty-required">Either Father or a Caretaker is required.</span>
</div>
}
<!-- Spouse hint for married clients -->
@if (!nomineeOnly && isMarried && !hasSpouse) {
<div class="fm-spouse-hint">
@@ -12,6 +12,7 @@ import { TranslateService } from '@ngx-translate/core';
import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component';
import { ClientFamilyMemberDialogComponent } from './client-family-member-dialog/client-family-member-dialog.component';
import { ImageZoomDialogComponent } from './client-family-member-dialog/client-family-member-dialog.component';
import { FamilyMemberAddedDialogComponent } from './family-member-added-dialog.component';
import { ClientsService } from '../../clients.service';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { MatIcon } from '@angular/material/icon';
@@ -152,6 +153,22 @@ export class ClientFamilyMembersStepComponent implements OnInit {
ref.afterClosed().subscribe((response: any) => {
if (response?.member) {
this.clientFamilyMembers.push(response.member);
this.showFamilyMemberAddedConfirmation(response.member);
}
});
}
/** Confirms what was just recorded — in particular the linked client's own CBS Client ID,
* since the "Add Member" dialog closes silently otherwise and gives no acknowledgement. */
private showFamilyMemberAddedConfirmation(member: any): void {
this.dialog.open(FamilyMemberAddedDialogComponent, {
width: '360px',
data: {
clientId: member._linkedClient?.id ?? member.linkedClientId ?? null,
name: member._linkedClient?.displayName || member.firstName,
relationship: this.getRelName(member.relationshipId),
dateOfBirth: member.dateOfBirth,
isDependent: member.isDependent
}
});
}
@@ -223,6 +240,33 @@ export class ClientFamilyMembersStepComponent implements OnInit {
return this.clientFamilyMembers.length > 0;
}
/** True if a family member with the given relationship (case-insensitive) has been added */
private hasRelationship(name: string): boolean {
const target = name.toLowerCase();
return this.clientFamilyMembers.some((m: any) => {
const resolved = this.getRelName(m.relationshipId ?? m.relationship).toLowerCase();
if (resolved === target) return true;
return typeof m.relationship === 'string' && m.relationship.toLowerCase() === target;
});
}
get hasMother(): boolean {
return this.hasRelationship('mother');
}
get hasFather(): boolean {
return this.hasRelationship('father');
}
get hasCaretaker(): boolean {
return this.hasRelationship('caretaker');
}
/** Mother is always required; Father is required unless a Caretaker has been added instead. */
get familyRelationshipRulesMet(): boolean {
return this.hasMother && (this.hasFather || this.hasCaretaker);
}
/** Check if client's marital status is "Married" */
get isMarried(): boolean {
if (!this.maritalStatusId || !this.maritalStatusOptions?.length) return false;
@@ -0,0 +1,145 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, inject } from '@angular/core';
import { MatDialogRef, MAT_DIALOG_DATA, MatDialogTitle, MatDialogActions } from '@angular/material/dialog';
import { MatIcon } from '@angular/material/icon';
import { MatButton } from '@angular/material/button';
/**
* Confirmation popup shown right after a family member is added — surfaces the linked
* client's own CBS Client ID (when the member was linked to an existing client rather than
* entered manually) alongside name/relationship, since the "Add Member" dialog itself just
* closed silently before this with no acknowledgement of what got recorded.
*/
@Component({
selector: 'mifosx-family-member-added-dialog',
imports: [
MatDialogTitle,
MatDialogActions,
MatIcon,
MatButton
],
template: `
<h2 mat-dialog-title class="fma-title">
<mat-icon class="fma-title__icon">check_circle</mat-icon>
Family Member Added
</h2>
<div class="fma-body">
@if (data.clientId) {
<div class="fma-row fma-row--id">
<span class="fma-row__label">Client ID</span>
<span class="fma-row__value fma-row__value--id">{{ data.clientId }}</span>
</div>
}
<div class="fma-row">
<span class="fma-row__label">Name</span>
<span class="fma-row__value">{{ data.name }}</span>
</div>
@if (data.relationship) {
<div class="fma-row">
<span class="fma-row__label">Relationship</span>
<span class="fma-row__value">{{ data.relationship }}</span>
</div>
}
@if (data.dateOfBirth) {
<div class="fma-row">
<span class="fma-row__label">Date of Birth</span>
<span class="fma-row__value">{{ data.dateOfBirth }}</span>
</div>
}
@if (data.isDependent) {
<div class="fma-row">
<span class="fma-row__label">Dependent</span>
<span class="fma-row__value">Yes</span>
</div>
}
</div>
<mat-dialog-actions align="end">
<button mat-flat-button color="primary" (click)="dialogRef.close()">Done</button>
</mat-dialog-actions>
`,
styles: [
`
.fma-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 17px;
font-weight: 700;
color: #0f172a;
margin: 0;
padding: 20px 24px 4px;
}
.fma-title__icon {
color: #059669;
}
.fma-body {
display: flex;
flex-direction: column;
gap: 2px;
padding: 12px 24px 8px;
min-width: 280px;
}
.fma-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
padding: 8px 0;
border-bottom: 1px solid #f1f5f9;
&:last-child {
border-bottom: none;
}
}
.fma-row__label {
font-size: 12px;
color: #64748b;
font-weight: 500;
}
.fma-row__value {
font-size: 13px;
color: #0f172a;
font-weight: 600;
text-align: right;
}
.fma-row--id {
background: #eff6ff;
margin: 0 -24px;
padding: 10px 24px;
}
.fma-row__value--id {
font-family: 'Roboto Mono', monospace;
color: #1353a0;
font-size: 14px;
}
mat-dialog-actions {
padding: 8px 24px 16px;
}
`
]
})
export class FamilyMemberAddedDialogComponent {
dialogRef = inject<MatDialogRef<FamilyMemberAddedDialogComponent>>(MatDialogRef);
data: {
clientId?: string | number | null;
name: string;
relationship?: string;
dateOfBirth?: string;
isDependent?: boolean;
} = inject(MAT_DIALOG_DATA);
}
@@ -136,7 +136,7 @@
| translate
}}
</mat-label>
<input matInput required formControlName="fullname" />
<input matInput required formControlName="fullname" autocomplete="off" />
@if (createClientForm.controls.fullname.hasError('required')) {
<mat-error>Client name is <strong>required</strong></mat-error>
} @else if (createClientForm.controls.fullname.hasError('pattern')) {
@@ -214,7 +214,7 @@
<mat-form-field appearance="outline" class="name-field first-name">
<mat-label>{{ 'labels.inputs.First Name' | translate }}</mat-label>
<span matPrefix class="field-prefix-icon"><mat-icon>badge</mat-icon></span>
<input matInput required formControlName="firstname" />
<input matInput required formControlName="firstname" autocomplete="off" />
@if (createClientForm.controls.firstname.hasError('required')) {
<mat-error>First name is <strong>required</strong></mat-error>
} @else if (createClientForm.controls.firstname.hasError('pattern')) {
@@ -228,7 +228,7 @@
<mat-form-field appearance="outline" class="name-field middle-name">
<mat-label>{{ 'labels.inputs.Middle Name' | translate }}</mat-label>
<span matPrefix class="field-prefix-icon"><mat-icon>person_outline</mat-icon></span>
<input matInput formControlName="middlename" />
<input matInput formControlName="middlename" autocomplete="off" />
@if (
createClientForm.controls.middlename?.hasError('pattern') &&
createClientForm.controls.middlename?.touched
@@ -241,7 +241,7 @@
<mat-form-field appearance="outline" class="name-field last-name">
<mat-label>{{ 'labels.inputs.Last Name' | translate }}</mat-label>
<span matPrefix class="field-prefix-icon"><mat-icon>person</mat-icon></span>
<input matInput required formControlName="lastname" />
<input matInput required formControlName="lastname" autocomplete="off" />
@if (createClientForm.controls.lastname.hasError('required')) {
<mat-error>Last name is <strong>required</strong></mat-error>
} @else if (createClientForm.controls.lastname.hasError('pattern')) {
@@ -358,11 +358,14 @@
@if (isMinor) {
<span class="minor-badge">Minor</span>
}
@if (isSeniorCitizen) {
<span class="senior-citizen-badge">Senior Citizen</span>
}
</div>
}
</div>
<!-- Minor / Guardian Mapping (mandatory if age < 10) -->
<!-- Minor / Guardian Mapping (mandatory if age < 18) -->
@if (isMinor && createClientForm.get('legalFormId')?.value === LegalFormId.PERSON) {
<div class="guardian-section">
<!-- Header bar -->
@@ -1032,69 +1035,67 @@
Education &amp; Social Details
</h4>
<div class="form-grid">
<!-- Education Level (non-minor) -->
@if (!isMinor) {
<mat-form-field appearance="outline" class="form-field">
<mat-label>Education Level</mat-label>
<mat-select formControlName="educationLevelId">
<mat-select-trigger>
@if (getSelectedEducationLevel()) {
<span class="icon-option">
<mat-icon class="icon-option-icon">{{
getEducationLevelIcon(getSelectedEducationLevel().name)
}}</mat-icon>
{{ getSelectedEducationLevel().name }}
</span>
}
</mat-select-trigger>
@for (el of educationLevelOptions; track el) {
<mat-option [value]="el.id">
<span class="icon-option">
<mat-icon class="icon-option-icon">{{ getEducationLevelIcon(el.name) }}</mat-icon>
{{ el.name }}
</span>
</mat-option>
<!-- Education Level -->
<mat-form-field appearance="outline" class="form-field">
<mat-label>Education Level</mat-label>
<mat-select formControlName="educationLevelId">
<mat-select-trigger>
@if (getSelectedEducationLevel()) {
<span class="icon-option">
<mat-icon class="icon-option-icon">{{
getEducationLevelIcon(getSelectedEducationLevel().name)
}}</mat-icon>
{{ getSelectedEducationLevel().name }}
</span>
}
</mat-select>
</mat-form-field>
</mat-select-trigger>
@for (el of educationLevelOptions; track el) {
<mat-option [value]="el.id">
<span class="icon-option">
<mat-icon class="icon-option-icon">{{ getEducationLevelIcon(el.name) }}</mat-icon>
{{ el.name }}
</span>
</mat-option>
}
</mat-select>
</mat-form-field>
<!-- Degree / Qualification (depends on Education Level) -->
<mat-form-field appearance="outline" class="form-field">
<mat-label>Degree / Qualification</mat-label>
<mat-select
formControlName="degreeQualificationId"
[placeholder]="getSelectedEducationLevel() ? 'Select Degree' : 'Select Education Level first'"
>
<mat-select-trigger>
@if (getSelectedDegreeQualification()) {
<span class="icon-option">
<mat-icon class="icon-option-icon">{{
getDegreeQualificationIcon(getSelectedDegreeQualification().name)
}}</mat-icon>
{{ getSelectedDegreeQualification().name }}
</span>
}
</mat-select-trigger>
@for (dq of filteredDegreeQualificationOptions; track dq) {
<mat-option [value]="dq.id">
<span class="icon-option">
<mat-icon class="icon-option-icon">{{ getDegreeQualificationIcon(dq.name) }}</mat-icon>
{{ dq.name }}
</span>
</mat-option>
<!-- Degree / Qualification (depends on Education Level) -->
<mat-form-field appearance="outline" class="form-field">
<mat-label>Degree / Qualification</mat-label>
<mat-select
formControlName="degreeQualificationId"
[placeholder]="getSelectedEducationLevel() ? 'Select Degree' : 'Select Education Level first'"
>
<mat-select-trigger>
@if (getSelectedDegreeQualification()) {
<span class="icon-option">
<mat-icon class="icon-option-icon">{{
getDegreeQualificationIcon(getSelectedDegreeQualification().name)
}}</mat-icon>
{{ getSelectedDegreeQualification().name }}
</span>
}
@if (!getSelectedEducationLevel()) {
<mat-option disabled>
<em>Please select Education Level first</em>
</mat-option>
} @else if (filteredDegreeQualificationOptions.length === 0) {
<mat-option disabled>
<em>No options available</em>
</mat-option>
}
</mat-select>
</mat-form-field>
}
</mat-select-trigger>
@for (dq of filteredDegreeQualificationOptions; track dq) {
<mat-option [value]="dq.id">
<span class="icon-option">
<mat-icon class="icon-option-icon">{{ getDegreeQualificationIcon(dq.name) }}</mat-icon>
{{ dq.name }}
</span>
</mat-option>
}
@if (!getSelectedEducationLevel()) {
<mat-option disabled>
<em>Please select Education Level first</em>
</mat-option>
} @else if (filteredDegreeQualificationOptions.length === 0) {
<mat-option disabled>
<em>No options available</em>
</mat-option>
}
</mat-select>
</mat-form-field>
<!-- Religion -->
<mat-form-field appearance="outline" class="form-field">
@@ -1217,21 +1218,25 @@
</mat-form-field>
<mat-form-field appearance="outline" class="form-field">
<mat-label>Annual Turnover (Last FY)</mat-label>
<mat-label>Annual Income</mat-label>
<span matPrefix class="rupee-prefix">&#8377;</span>
<input matInput type="number" formControlName="annualTurnover" min="0" />
<input matInput type="number" formControlName="annualIncome" min="0" />
@if (
createClientForm.get('annualTurnover')?.hasError('pattern') &&
createClientForm.get('annualTurnover')?.touched
createClientForm.get('annualIncome')?.hasError('pattern') && createClientForm.get('annualIncome')?.touched
) {
<mat-error>Enter a valid amount</mat-error>
} @else if (
createClientForm.get('annualTurnover')?.hasError('min') && createClientForm.get('annualTurnover')?.touched
createClientForm.get('annualIncome')?.hasError('min') && createClientForm.get('annualIncome')?.touched
) {
<mat-error>Amount cannot be negative</mat-error>
}
</mat-form-field>
<div class="full-width subsection-title">
<fa-icon icon="coins" class="subsection-icon"></fa-icon>
Other Income
</div>
<mat-form-field appearance="outline" class="form-field">
<mat-label>Agriculture Income</mat-label>
<span matPrefix class="rupee-prefix">&#8377;</span>
@@ -1265,6 +1270,128 @@
<mat-error>Amount cannot be negative</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline" class="form-field">
<mat-label>Monthly Turnover</mat-label>
<span matPrefix class="rupee-prefix">&#8377;</span>
<input matInput type="number" formControlName="monthlyTurnover" min="0" />
@if (
createClientForm.get('monthlyTurnover')?.hasError('pattern') &&
createClientForm.get('monthlyTurnover')?.touched
) {
<mat-error>Enter a valid amount</mat-error>
} @else if (
createClientForm.get('monthlyTurnover')?.hasError('min') && createClientForm.get('monthlyTurnover')?.touched
) {
<mat-error>Amount cannot be negative</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline" class="form-field">
<mat-label>Yearly Turnover</mat-label>
<span matPrefix class="rupee-prefix">&#8377;</span>
<input matInput type="number" formControlName="annualTurnover" min="0" />
@if (
createClientForm.get('annualTurnover')?.hasError('pattern') &&
createClientForm.get('annualTurnover')?.touched
) {
<mat-error>Enter a valid amount</mat-error>
} @else if (
createClientForm.get('annualTurnover')?.hasError('min') && createClientForm.get('annualTurnover')?.touched
) {
<mat-error>Amount cannot be negative</mat-error>
}
</mat-form-field>
<div class="full-width subsection-title">
<fa-icon icon="user-shield" class="subsection-icon"></fa-icon>
Senior Citizen / Person with Disability
</div>
@if (isSeniorCitizen) {
<div class="full-width senior-citizen-auto-note">
<fa-icon icon="check-circle"></fa-icon>
Senior Citizen (60+ years) — detected automatically from Date of Birth
</div>
<div class="full-width doc-upload-field">
<label class="doc-upload-label">
<input type="file" accept=".jpg,.jpeg,.png,.pdf" hidden (change)="onSeniorCitizenProofSelected($event)" />
<fa-icon icon="file-upload"></fa-icon>
@if (seniorCitizenProofFile) {
<span>{{ seniorCitizenProofFile.name }}</span>
} @else if (existingSeniorCitizenProofDoc) {
<span>{{ existingSeniorCitizenProofDoc.name }} (uploaded)</span>
} @else {
<span>Upload Senior Citizen Proof (Age / ID Proof) &mdash; optional</span>
}
</label>
@if (seniorCitizenProofFile) {
<button type="button" mat-icon-button (click)="removeSeniorCitizenProof()" matTooltip="Remove">
<mat-icon>close</mat-icon>
</button>
} @else if (existingSeniorCitizenProofDoc) {
<button type="button" mat-icon-button (click)="viewExistingSeniorCitizenProof()" matTooltip="View">
<mat-icon>visibility</mat-icon>
</button>
<button type="button" mat-icon-button (click)="removeExistingSeniorCitizenProof()" matTooltip="Remove">
<mat-icon>close</mat-icon>
</button>
}
</div>
}
<div class="checkbox-field full-width">
<mat-checkbox formControlName="isPwd">Person with Disability (PWD)</mat-checkbox>
</div>
@if (createClientForm.get('isPwd')?.value) {
<mat-form-field appearance="outline" class="form-field">
<mat-label>Disability Type</mat-label>
<mat-select formControlName="pwdCategoryId">
@for (category of pwdCategoryOptions; track category.id) {
<mat-option [value]="category.id">{{ category.name }}</mat-option>
}
</mat-select>
@if (seniorPwdDocsTouched && !createClientForm.get('pwdCategoryId')?.value) {
<mat-error>Disability type is required</mat-error>
}
</mat-form-field>
<div class="full-width doc-upload-field">
<label
class="doc-upload-label"
[class.doc-upload-label--missing]="
seniorPwdDocsTouched && !pwdCertificateFile && !existingPwdCertificateDoc
"
>
<input type="file" accept=".jpg,.jpeg,.png,.pdf" hidden (change)="onPwdCertificateSelected($event)" />
<fa-icon icon="file-upload"></fa-icon>
@if (pwdCertificateFile) {
<span>{{ pwdCertificateFile.name }}</span>
} @else if (existingPwdCertificateDoc) {
<span>{{ existingPwdCertificateDoc.name }} (uploaded)</span>
} @else {
<span>Upload Disability Certificate <span class="doc-upload-req">*</span></span>
}
</label>
@if (pwdCertificateFile) {
<button type="button" mat-icon-button (click)="removePwdCertificate()" matTooltip="Remove">
<mat-icon>close</mat-icon>
</button>
} @else if (existingPwdCertificateDoc) {
<button type="button" mat-icon-button (click)="viewExistingPwdCertificate()" matTooltip="View">
<mat-icon>visibility</mat-icon>
</button>
<button type="button" mat-icon-button (click)="removeExistingPwdCertificate()" matTooltip="Remove">
<mat-icon>close</mat-icon>
</button>
}
@if (seniorPwdDocsTouched && !pwdCertificateFile && !existingPwdCertificateDoc) {
<span class="doc-upload-error">Required when PWD is checked</span>
}
</div>
}
</div>
</div>
}
@@ -56,6 +56,78 @@ form {
grid-column: 1 / -1;
}
.senior-citizen-auto-note {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: #1074b9;
background: #eff6ff;
border: 1px solid #bfdbfe;
border-radius: 8px;
padding: 8px 12px;
margin-bottom: 8px;
}
.doc-upload-field {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.doc-upload-label {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
padding: 10px 14px;
border: 1px dashed #cbd5e1;
border-radius: 8px;
font-size: 13px;
color: #475569;
cursor: pointer;
transition: border-color 0.15s;
&:hover {
border-color: #1074b9;
}
&.doc-upload-label--missing {
border-color: #dc2626;
color: #dc2626;
}
}
.doc-upload-req {
color: #dc2626;
font-weight: 700;
}
.doc-upload-error {
font-size: 11px;
color: #dc2626;
}
.subsection-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #6b7280;
margin: 20px 0 4px;
padding-top: 16px;
border-top: 1px solid #e5e7eb;
.subsection-icon {
color: var(--md-sys-color-primary, #1074b9);
font-size: 12px;
}
}
.checkbox-field {
display: flex;
align-items: center;
@@ -234,6 +306,18 @@ form {
letter-spacing: 0.5px;
}
.senior-citizen-badge {
font-size: 10px;
font-weight: 700;
padding: 2px 8px;
border-radius: 10px;
background: #eff6ff;
color: #1074b9;
margin-left: 6px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* ═══ Guardian Section (Redesigned) ═══ */
.guardian-section {
margin-top: 12px;
@@ -46,6 +46,7 @@ import { MatSelectTrigger } from '@angular/material/select';
import { MatTooltip } from '@angular/material/tooltip';
import { MatDialog, MAT_DIALOG_DATA, MatDialogContent, MatDialogClose } from '@angular/material/dialog';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { DocumentPreviewDialogComponent } from '../../shared/document-preview-dialog/document-preview-dialog.component';
import { DateFormatPipe } from '../../../pipes/date-format.pipe';
import { SanctionsVerifyDialogComponent } from './sanctions-verify-dialog.component';
import { DipVerifyDialogComponent } from './dip-verify-dialog.component';
@@ -93,6 +94,148 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
/** Expose enum to template */
readonly LegalFormId = LegalFormId;
/** Senior Citizen proof is optional; the PWD certificate is mandatory (see seniorPwdDocsComplete). Both, when
* attached, are uploaded after client creation. */
seniorCitizenProofFile: File | null = null;
pwdCertificateFile: File | null = null;
/** Previously-uploaded documents, loaded in edit mode so the certificate doesn't appear to
* "disappear" on reopening — the raw File properties above only ever hold a fresh selection. */
existingSeniorCitizenProofDoc: { id: string; name: string } | null = null;
existingPwdCertificateDoc: { id: string; name: string } | null = null;
/** Disability categories (per the Rights of Persons with Disabilities Act, 2016) — fetched from
* m_code_value (code "PwdCategory") via the client template, same as occupationCategoryOptions.
* Admins can add/reorder/deactivate entries from System -> Manage Codes without a code change. */
pwdCategoryOptions: any[] = [];
riskRules: any[] = [];
/** Set true after a save attempt so the UI can highlight a missing required document */
seniorPwdDocsTouched = false;
/** Senior Citizen proof is optional. The PWD certificate + disability type are mandatory
* whenever the PWD checkbox is set. */
get seniorPwdDocsComplete(): boolean {
const pwdOk =
!this.createClientForm.get('isPwd')?.value ||
((!!this.pwdCertificateFile || !!this.existingPwdCertificateDoc) &&
!!this.createClientForm.get('pwdCategoryId')?.value);
return pwdOk;
}
markSeniorPwdDocsTouched(): void {
this.seniorPwdDocsTouched = true;
}
onSeniorCitizenProofSelected(event: any): void {
const file = event.target?.files?.[0];
if (file) {
this.seniorCitizenProofFile = file;
}
}
removeSeniorCitizenProof(): void {
this.seniorCitizenProofFile = null;
}
onPwdCertificateSelected(event: any): void {
const file = event.target?.files?.[0];
if (file) {
this.pwdCertificateFile = file;
}
}
removePwdCertificate(): void {
this.pwdCertificateFile = null;
}
/** Loads previously-uploaded senior-citizen-proof / PWD-certificate documents in edit mode —
* mirrors the KYC step's loadExistingSignature() pattern so re-opening edit shows what's
* already on file instead of an empty upload prompt. */
private loadExistingSeniorPwdDocuments(): void {
const clientId = this.clientData?.id;
if (!clientId) {
return;
}
this.clientService.getClientDocuments(clientId.toString()).subscribe({
next: (documents: any[]) => {
const seniorDoc = documents
?.filter((d: any) => d.name === 'seniorCitizenProof')
?.sort((a: any, b: any) => b.id - a.id)?.[0];
if (seniorDoc?.id) {
this.existingSeniorCitizenProofDoc = {
id: seniorDoc.id.toString(),
name: seniorDoc.fileName || 'Senior Citizen Proof'
};
}
const pwdDoc = documents
?.filter((d: any) => d.name === 'pwdCertificate')
?.sort((a: any, b: any) => b.id - a.id)?.[0];
if (pwdDoc?.id) {
this.existingPwdCertificateDoc = {
id: pwdDoc.id.toString(),
name: pwdDoc.fileName || 'Disability Certificate'
};
}
},
error: () => {}
});
}
viewExistingSeniorCitizenProof(): void {
const clientId = this.clientData?.id;
if (!clientId || !this.existingSeniorCitizenProofDoc) {
return;
}
this.clientService.downloadClientDocument(clientId.toString(), this.existingSeniorCitizenProofDoc.id).subscribe({
next: (blob: any) =>
this.dialog.open(DocumentPreviewDialogComponent, {
data: {
blob,
title: this.existingSeniorCitizenProofDoc?.name,
fileName: this.existingSeniorCitizenProofDoc?.name
}
}),
error: () => {}
});
}
removeExistingSeniorCitizenProof(): void {
const clientId = this.clientData?.id;
if (!clientId || !this.existingSeniorCitizenProofDoc) {
return;
}
this.clientService.deleteClientDocument(clientId.toString(), this.existingSeniorCitizenProofDoc.id).subscribe({
next: () => (this.existingSeniorCitizenProofDoc = null),
error: () => {}
});
}
viewExistingPwdCertificate(): void {
const clientId = this.clientData?.id;
if (!clientId || !this.existingPwdCertificateDoc) {
return;
}
this.clientService.downloadClientDocument(clientId.toString(), this.existingPwdCertificateDoc.id).subscribe({
next: (blob: any) =>
this.dialog.open(DocumentPreviewDialogComponent, {
data: { blob, title: this.existingPwdCertificateDoc?.name, fileName: this.existingPwdCertificateDoc?.name }
}),
error: () => {}
});
}
removeExistingPwdCertificate(): void {
const clientId = this.clientData?.id;
if (!clientId || !this.existingPwdCertificateDoc) {
return;
}
this.clientService.deleteClientDocument(clientId.toString(), this.existingPwdCertificateDoc.id).subscribe({
next: () => (this.existingPwdCertificateDoc = null),
error: () => {}
});
}
/** Minimum date allowed. */
minDate = new Date(2000, 0, 1);
/** Maximum date allowed. */
@@ -155,8 +298,10 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
];
/** Calculated age detail string (Y/M/D) */
calculatedAgeDetail: string | null = null;
/** Whether client is a minor (age < 10) */
/** Whether client is a minor (age < 18) */
isMinor = false;
/** Whether client is a senior citizen (age >= 60) — auto-detected from Date of Birth */
isSeniorCitizen = false;
/** Age breakdown for DB storage */
ageBreakdown = { years: 0, months: 0, days: 0 };
@@ -388,6 +533,8 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
screeningLogId: number | null = null;
/** Snapshot of the name that was last verified — used to invalidate verification when name changes */
private verifiedName: string = '';
/** True while prefillForEdit() is patching historical monthly/annual values — see linkMonthlyAnnual(). */
private suppressMonthlyAnnualLink = false;
/**
* @param {FormBuilder} formBuilder Form Builder
@@ -402,6 +549,18 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
ngOnInit() {
this.maxDate = this.settingsService.businessDate;
this.setOptions();
// Risk rules are an enhancement on top of the default low/medium-risk fallback in
// calculatedRisk — if the endpoint isn't available (e.g. not yet deployed on this
// backend), fail silently rather than surfacing a raw HTTP-error toast to the user.
this.clientService.getClientRiskRules().subscribe({
next: (rules) => {
this.riskRules = rules || [];
},
error: () => {
this.riskRules = [];
}
});
// Fetch marital status options from code values
this.clientService.getCodeValuesByName('MaritalStatus').subscribe((options) => {
this.maritalStatusOptions = options || [];
@@ -550,6 +709,13 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
*/
private prefillForEdit() {
const data = this.clientData;
this.loadExistingSeniorPwdDocuments();
// Loading an existing client's independently-saved monthly/annual values must not run
// through the auto-calc in linkMonthlyAnnual() — patching monthlyIncome then annualIncome
// (or vice versa) would otherwise have each overwrite the other via its valueChanges,
// instead of keeping the exact historical values as saved.
this.suppressMonthlyAnnualLink = true;
// Patch all base controls that exist on the form
this.createClientForm.patchValue({
@@ -574,13 +740,18 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
occupationCategoryId: data.occupationCategory?.id || data.occupationCategoryId || '',
occupationId: data.occupation?.id || data.occupationId || '',
monthlyIncome: data.monthlyIncome || '',
annualIncome: data.annualIncome || '',
annualTurnover: data.annualTurnover || '',
monthlyTurnover: data.monthlyTurnover || '',
isPwd: data.isPwd || false,
pwdCategoryId: data.pwdCategory?.id || '',
agricultureIncome: data.agricultureIncome || '',
nonAgricultureIncome: data.nonAgricultureIncome || '',
submittedOnDate: data.timeline?.submittedOnDate
? new Date(data.timeline.submittedOnDate)
: this.settingsService.businessDate
});
this.suppressMonthlyAnnualLink = false;
// Patch name fields (already created by buildDependencies via legalFormId subscription)
const legalFormId = data.legalForm?.id || LegalFormId.PERSON;
@@ -750,6 +921,13 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
Validators.min(0)
]
],
annualIncome: [
'',
[
Validators.pattern(/^\d+(\.\d{1,2})?$/),
Validators.min(0)
]
],
annualTurnover: [
'',
[
@@ -757,6 +935,15 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
Validators.min(0)
]
],
monthlyTurnover: [
'',
[
Validators.pattern(/^\d+(\.\d{1,2})?$/),
Validators.min(0)
]
],
isPwd: [false],
pwdCategoryId: [''],
agricultureIncome: [
'',
[
@@ -776,6 +963,75 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
Validators.required
]
});
this.linkMonthlyAnnual('monthlyIncome', 'annualIncome');
this.linkMonthlyAnnual('monthlyTurnover', 'annualTurnover');
this.linkIncomeToTurnover();
}
/**
* Auto-calculates turnover based on income (doubling monthly income for monthly turnover,
* and annual income for annual/yearly turnover).
*/
private linkIncomeToTurnover(): void {
const monthlyIncome = this.createClientForm.get('monthlyIncome');
const monthlyTurnover = this.createClientForm.get('monthlyTurnover');
const annualIncome = this.createClientForm.get('annualIncome');
const annualTurnover = this.createClientForm.get('annualTurnover');
if (monthlyIncome && monthlyTurnover) {
monthlyIncome.valueChanges.subscribe((value) => {
if (this.suppressMonthlyAnnualLink) {
return;
}
const num = parseFloat(value);
monthlyTurnover.setValue(!isNaN(num) && num >= 0 ? this.round2(num * 2) : '', { emitEvent: true });
});
}
if (annualIncome && annualTurnover) {
annualIncome.valueChanges.subscribe((value) => {
if (this.suppressMonthlyAnnualLink) {
return;
}
const num = parseFloat(value);
annualTurnover.setValue(!isNaN(num) && num >= 0 ? this.round2(num * 2) : '', { emitEvent: true });
});
}
}
/**
* Keeps a Monthly/Annual pair of amount fields in sync at a 12x ratio — typing into either
* one auto-fills the other. Uses { emitEvent: false } on the write side so the two
* subscriptions don't trigger each other back and forth.
*/
/** Rounds to at most 2 decimals without padding whole numbers with a trailing ".00". */
private round2(n: number): string {
return (Math.round(n * 100) / 100).toString();
}
private linkMonthlyAnnual(monthlyControlName: string, annualControlName: string): void {
const monthly = this.createClientForm.get(monthlyControlName);
const annual = this.createClientForm.get(annualControlName);
if (!monthly || !annual) {
return;
}
monthly.valueChanges.subscribe((value) => {
if (this.suppressMonthlyAnnualLink) {
return;
}
const num = parseFloat(value);
annual.setValue(!isNaN(num) && num >= 0 ? this.round2(num * 12) : '', { emitEvent: false });
});
annual.valueChanges.subscribe((value) => {
if (this.suppressMonthlyAnnualLink) {
return;
}
const num = parseFloat(value);
monthly.setValue(!isNaN(num) && num >= 0 ? this.round2(num / 12) : '', { emitEvent: false });
});
}
/**
@@ -794,6 +1050,7 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
this.occupationCategoryOptions = this.clientTemplate.occupationCategoryOptions || [];
this.occupationOptions = this.clientTemplate.occupationOptions || [];
this.salutationOptions = this.clientTemplate.salutationOptions || [];
this.pwdCategoryOptions = this.clientTemplate.pwdCategoryOptions || [];
}
/**
@@ -909,7 +1166,8 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
this.calculatedAgeDetail = `${years} yr${years !== 1 ? 's' : ''} ${months} mo${months !== 1 ? 's' : ''} ${days} day${days !== 1 ? 's' : ''}`;
this.ageBreakdown = { years, months, days };
const wasMinor = this.isMinor;
this.isMinor = years < 10;
this.isMinor = years < 18;
this.isSeniorCitizen = years >= 60;
// Add/remove guardian form group based on minor status
if (this.isMinor && !wasMinor) {
this.createClientForm.addControl(
@@ -935,12 +1193,14 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
} else {
this.calculatedAgeDetail = null;
this.isMinor = false;
this.isSeniorCitizen = false;
this.ageBreakdown = { years: 0, months: 0, days: 0 };
this.createClientForm.removeControl('guardianDetails');
}
} else {
this.calculatedAgeDetail = null;
this.isMinor = false;
this.isSeniorCitizen = false;
this.ageBreakdown = { years: 0, months: 0, days: 0 };
this.createClientForm.removeControl('guardianDetails');
}
@@ -1633,6 +1893,9 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
if (n.includes('postgrad') || n === 'pg' || n.includes('master')) return 'postgraduate';
if (n.includes('undergrad') || n === 'ug' || n.includes('bachelor') || n.includes('graduate'))
return 'undergraduate';
// Diploma is its own Education Level, distinct from School — checked before the school
// keywords below so it doesn't get swept into that bucket.
if (n.includes('diploma') || n.includes('polytechnic')) return 'diploma';
if (
n.includes('school') ||
n.includes('10th') ||
@@ -1641,7 +1904,6 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
n.includes('hsc') ||
n.includes('matric') ||
n.includes('intermediate') ||
n.includes('diploma') ||
n.includes('iti') ||
n.includes('below')
)
@@ -1719,8 +1981,12 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
}
/**
* Degrees filtered by the currently selected Education Level.
* Returns all degrees if no level is selected, or category can't be determined.
* Degrees filtered by the currently selected Education Level. Each DegreeQualification code
* value is tagged with its owning level in `description` (e.g. "Undergraduate") when seeded —
* match on that authoritative field first. Only fall back to the name-keyword heuristic
* (getDegreeQualificationCategory) for older/untagged rows that predate this tagging, so
* degrees never silently disappear because an admin added one without a description.
* Returns all degrees if no level is selected.
*/
get filteredDegreeQualificationOptions(): any[] {
const level = this.getSelectedEducationLevel();
@@ -1728,6 +1994,9 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
const category = this.getEducationLevelCategory(level.name);
if (!category) return this.degreeQualificationOptions;
const filtered = this.degreeQualificationOptions.filter((d: any) => {
if (d.description) {
return this.getEducationLevelCategory(d.description) === category;
}
const c = this.getDegreeQualificationCategory(d.name);
return c === category || c === '';
});
@@ -1890,6 +2159,8 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
// Store extended fields separately (not yet supported by Fineract API)
// Add isMinor flag
generalDetails.isMinor = this.isMinor;
// isSeniorCitizen is auto-detected from DOB (not a form control) — add it explicitly
generalDetails.isSeniorCitizen = this.isSeniorCitizen;
// Add guardian clientId for minors
if (this.isMinor && generalDetails.guardianDetails) {
@@ -1940,6 +2211,121 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
return generalDetails;
}
get calculatedRisk(): {
category: number;
reason: string;
amlCheckStatus?: string;
bannedEntityMatch?: string;
willfulDefaulter?: string;
ecgcSalFlag?: string;
dormantFlag?: string;
} {
const legalFormId = this.createClientForm.get('legalFormId')?.value || 1; // 1 = Person, 2 = Entity
const occCatId = this.createClientForm.get('occupationCategoryCvId')?.value;
const occId = this.createClientForm.get('occupationCvId')?.value;
const monthlyIncome = parseFloat(this.createClientForm.get('monthlyIncome')?.value) || 0;
const annualIncome = parseFloat(this.createClientForm.get('annualIncome')?.value) || 0;
const monthlyTurnover = parseFloat(this.createClientForm.get('monthlyTurnover')?.value) || 0;
const annualTurnover = parseFloat(this.createClientForm.get('annualTurnover')?.value) || 0;
const agricultureIncome = parseFloat(this.createClientForm.get('agricultureIncome')?.value) || 0;
const nonAgricultureIncome = parseFloat(this.createClientForm.get('nonAgricultureIncome')?.value) || 0;
const totalAnnualIncome = annualIncome + agricultureIncome + nonAgricultureIncome;
// Evaluate rules from DB (ordered by orderPosition ASC)
if (this.riskRules && this.riskRules.length > 0) {
for (const rule of this.riskRules) {
// Skip transaction/AML rules that have no KYC criteria
if (
rule.legalFormId == null &&
rule.occupationCategoryCvId == null &&
rule.occupationCvId == null &&
rule.minMonthlyIncome == null &&
rule.maxMonthlyIncome == null &&
rule.minAnnualIncome == null &&
rule.maxAnnualIncome == null &&
rule.minAnnualTurnover == null &&
rule.maxAnnualTurnover == null
) {
continue;
}
// Match legal form
if (rule.legalFormId != null && rule.legalFormId !== legalFormId) {
continue;
}
// Match occupation category
if (rule.occupationCategoryCvId != null && rule.occupationCategoryCvId !== occCatId) {
continue;
}
// Match occupation
if (rule.occupationCvId != null && rule.occupationCvId !== occId) {
continue;
}
// Match monthly income ranges
if (rule.minMonthlyIncome != null && rule.minMonthlyIncome > 0 && monthlyIncome < rule.minMonthlyIncome) {
continue;
}
if (rule.maxMonthlyIncome != null && rule.maxMonthlyIncome > 0 && monthlyIncome > rule.maxMonthlyIncome) {
continue;
}
// Match annual income ranges (evaluated against totalAnnualIncome)
if (rule.minAnnualIncome != null && rule.minAnnualIncome > 0 && totalAnnualIncome < rule.minAnnualIncome) {
continue;
}
if (rule.maxAnnualIncome != null && rule.maxAnnualIncome > 0 && totalAnnualIncome > rule.maxAnnualIncome) {
continue;
}
// Match turnover ranges
if (rule.minAnnualTurnover != null && rule.minAnnualTurnover > 0 && annualTurnover < rule.minAnnualTurnover) {
continue;
}
if (rule.maxAnnualTurnover != null && rule.maxAnnualTurnover > 0 && annualTurnover > rule.maxAnnualTurnover) {
continue;
}
// All matched!
return {
category: rule.riskCategory,
reason: rule.reason,
amlCheckStatus: rule.amlCheckStatus || (rule.riskCategory === 3 ? 'FLAGGED' : 'CLEAR'),
bannedEntityMatch: rule.bannedEntityMatch || 'NO',
willfulDefaulter: rule.willfulDefaulter || 'NO',
ecgcSalFlag: rule.ecgcSalFlag || 'NO',
dormantFlag: rule.dormantFlag || 'ACTIVE'
};
}
}
// Default Fallbacks
if (legalFormId !== LegalFormId.PERSON) {
return {
category: 2,
reason: 'Non-Individual / Entity profile (Default Medium Risk)',
amlCheckStatus: 'CLEAR',
bannedEntityMatch: 'NO',
willfulDefaulter: 'NO',
ecgcSalFlag: 'NO',
dormantFlag: 'ACTIVE'
};
}
return {
category: 1,
reason: 'Default Low Risk profile (Salary / Service / Agriculture / Others)',
amlCheckStatus: 'CLEAR',
bannedEntityMatch: 'NO',
willfulDefaulter: 'NO',
ecgcSalFlag: 'NO',
dormantFlag: 'ACTIVE'
};
}
}
/**
@@ -42,6 +42,7 @@ import { ClientsService } from 'app/clients/clients.service';
<strong>UN Security Council</strong> &amp;
<strong>Financial Intelligence Unit - India (FIU-IND under UNSC Resolution 2231)</strong>
</p>
<p class="regulatory-note">{{ regulatoryBasis }}</p>
</div>
}
@if (!verifying && !matchFound && !errorOccurred) {
@@ -69,7 +70,14 @@ import { ClientsService } from 'app/clients/clients.service';
<span class="meta-label">Database</span>
<span class="meta-value">UN SC Consolidated List</span>
</div>
@if (listDataAsOf) {
<div class="meta-row">
<span class="meta-label">List Data As Of</span>
<span class="meta-value">{{ listDataAsOf }}</span>
</div>
}
</div>
<p class="regulatory-note">{{ regulatoryBasis }}</p>
</div>
}
@if (!verifying && matchFound) {
@@ -96,11 +104,18 @@ import { ClientsService } from 'app/clients/clients.service';
<span class="meta-label">Database</span>
<span class="meta-value">UN SC Consolidated List</span>
</div>
@if (listDataAsOf) {
<div class="meta-row">
<span class="meta-label">List Data As Of</span>
<span class="meta-value">{{ listDataAsOf }}</span>
</div>
}
</div>
<div class="escalate-banner">
<fa-icon icon="exclamation-circle" class="escalate-icon"></fa-icon>
<span><strong>Escalate to Principal Officer immediately.</strong> Account opening blocked.</span>
</div>
<p class="regulatory-note">{{ regulatoryBasis }}</p>
</div>
}
@if (!verifying && errorOccurred) {
@@ -207,6 +222,15 @@ import { ClientsService } from 'app/clients/clients.service';
text-align: center;
}
.regulatory-note {
font-size: 11px;
color: #9ca3af;
line-height: 1.5;
text-align: center;
margin: 8px 0 0;
max-width: 420px;
}
.approve-card {
display: flex;
flex-direction: column;
@@ -524,6 +548,10 @@ export class SanctionsVerifyDialogComponent implements OnInit, OnDestroy {
errorOccurred = false;
errorMessage = '';
screeningLogId: number | null = null;
listDataAsOf: string | null = null;
regulatoryBasis =
'Screened as per Section 51-A of the Unlawful Activities (Prevention) Act, 1967 and the RBI Master Direction on KYC.';
screenedDate = new Date().toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' });
progressValue = 0;
@@ -624,6 +652,13 @@ export class SanctionsVerifyDialogComponent implements OnInit, OnDestroy {
'Unable to connect to sanctions screening service.';
} else {
this.screeningLogId = this.apiResult?.screeningLogId || null;
this.listDataAsOf = this.apiResult?.listDataAsOf
? new Date(this.apiResult.listDataAsOf).toLocaleDateString('en-IN', {
day: '2-digit',
month: 'short',
year: 'numeric'
})
: null;
if (this.apiResult?.matchFound) {
this.matchFound = true;
this.matchCount = this.apiResult.matchCount || 0;
@@ -173,8 +173,9 @@
@if (selectedAddrProof.requiresDate) {
<mat-form-field appearance="outline" class="addr-ff">
<mat-label>Issue / bill date</mat-label>
<span matPrefix class="addr-prefix"><mat-icon>event</mat-icon></span>
<input matInput type="date" formControlName="issueDate" />
<input matInput [matDatepicker]="addrIssueDatePicker" formControlName="issueDate" />
<mat-datepicker-toggle matSuffix [for]="addrIssueDatePicker"></mat-datepicker-toggle>
<mat-datepicker #addrIssueDatePicker></mat-datepicker>
</mat-form-field>
}
</div>
@@ -15,9 +15,12 @@ import { MatCheckbox } from '@angular/material/checkbox';
import { MatTabsModule } from '@angular/material/tabs';
import { MatIcon } from '@angular/material/icon';
import { MatIconButton, MatButton } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { ClientsService } from '../../clients.service';
import { ProofType, normalizeProofType, groupProofsByCategory } from '../../shared/kyc-proof-types';
import { KycUploadHelperService } from '../../shared/kyc-upload-helper.service';
import { DocumentPreviewDialogComponent } from '../../shared/document-preview-dialog/document-preview-dialog.component';
import { Dates } from 'app/core/utils/dates';
@Component({
selector: 'mifosx-client-india-address-step',
@@ -38,6 +41,8 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
private http = inject(HttpClient);
private clientsService = inject(ClientsService);
private kycUpload = inject(KycUploadHelperService);
private dialog = inject(MatDialog);
private dates = inject(Dates);
/** Client ID — when set, loads existing addresses for edit mode */
@Input() clientId: string = '';
@@ -46,6 +51,10 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
/** Minor clients can't hold utility bills, bank statements or an adult-only OVD in their own
* name — restricts the address-proof picker to Aadhaar/Passport when true. */
@Input() isMinor = false;
/** Non-individual (entity) clients — hides personal OVDs (Aadhaar/Passport/DL/Voter ID/NREGA/
* NPR/Birth Certificate/School ID) from the proof-of-address picker; a company's registered
* office address is evidenced by utility bills, GST/rent documents etc., not a person's ID. */
@Input() isNonIndividual = false;
activeTab = 0;
sameAsPermanent = false;
@@ -238,13 +247,49 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
}
}
/**
* Resets transient address-tab state for a brand-new client.
*
* This app's RouteReusableStrategy (core/route/route-reusable-strategy.ts) keeps this SAME
* component instance alive across repeat visits to /clients/create, so `ngOnInit` never
* re-runs for a second "create new client" session — the previous client's address forms,
* selected proof-of-address chip and uploaded files would otherwise leak into the new one.
* Called by CreateClientComponent.resetForNewClient() whenever a fresh create session begins.
*/
resetForNewClient(): void {
this.permanentForm.reset({ country: 'India' });
this.currentForm.reset({ country: 'India' });
this.officeForm.reset({ country: 'India' });
this.correspondenceForm.reset({ country: 'India' });
this.activeTab = 0;
this.sameAsPermanent = false;
this.pinStatus = {
permanent: { loading: false, found: false, error: false, message: '' },
current: { loading: false, found: false, error: false, message: '' },
office: { loading: false, found: false, error: false, message: '' },
correspondence: { loading: false, found: false, error: false, message: '' }
};
this.localityOptions = { permanent: [], current: [], office: [], correspondence: [] };
this.selectedAddrProof = null;
this.addrFiles = { front: null, back: null };
this.addrMergedFile = null;
this.addrMergedPreview = null;
this.existingAddressProofIdentifierId = null;
this.existingAddressProofDocuments = [];
this.addressLoaded = false;
// Re-fetch rather than re-filter `addressProofs` in place — if the previous session's
// isMinor filtering already reduced it (e.g. down to Aadhaar/Passport only), filtering
// that reduced list again can never recover the items it already removed.
this.loadProofTypes();
}
private loadProofTypes(): void {
this.loadingProofs = true;
this.clientsService.getKycProofTypes().subscribe({
next: (res: any) => {
const activeFilter = (p: any) => p?.active !== false;
this.addressProofs = this.filterAddressProofsForMinor(
(res?.addressProofs || []).filter(activeFilter).map(normalizeProofType)
this.addressProofs = this.filterAddressProofsForNonIndividual(
this.filterAddressProofsForMinor((res?.addressProofs || []).filter(activeFilter).map(normalizeProofType))
);
this.addressGroups = groupProofsByCategory(this.addressProofs);
this.loadingProofs = false;
@@ -270,6 +315,26 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
);
}
/** Personal OVDs — not valid proof of a registered office/entity address. */
private static readonly NON_INDIVIDUAL_EXCLUDED_ADDRESS_PROOF_CODES = new Set([
'AADHAAR',
'PASSPORT',
'VOTER_ID',
'DRIVING_LICENSE',
'NREGA_JOB_CARD',
'NPR_LETTER',
'BIRTH_CERTIFICATE',
'SCHOOL_ID_CARD'
]);
private filterAddressProofsForNonIndividual(proofs: ProofType[]): ProofType[] {
if (!this.isNonIndividual) return proofs;
return proofs.filter(
(p) =>
!ClientIndiaAddressStepComponent.NON_INDIVIDUAL_EXCLUDED_ADDRESS_PROOF_CODES.has((p.code || '').toUpperCase())
);
}
selectAddrProof(proof: ProofType): void {
this.selectedAddrProof = proof;
this.permanentForm.get('proofId')?.setValue(proof.id);
@@ -350,11 +415,12 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
files.push({ slot: 'Back', file: this.addrFiles.back });
}
}
const rawIssueDate = this.permanentForm.get('issueDate')?.value;
return {
proof: this.selectedAddrProof,
proofId: this.permanentForm.get('proofId')?.value || '',
documentKey: this.permanentForm.get('documentKey')?.value || '',
issueDate: this.permanentForm.get('issueDate')?.value || '',
issueDate: rawIssueDate ? this.dates.getDate(rawIssueDate) : '',
files
};
}
@@ -386,6 +452,14 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
}
}
}
// Legal form toggled on an already-initialized instance — re-fetch rather than filter
// `addressProofs` in place, since the non-individual filter only ever removes entries and
// toggling back to individual could never recover what a prior pass had stripped out.
if (changes['isNonIndividual'] && !changes['isNonIndividual'].firstChange) {
this.selectedAddrProof = null;
this.permanentForm.get('proofId')?.setValue('');
this.loadProofTypes();
}
}
/**
@@ -489,13 +563,13 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
proofId: matchedProof.id,
documentKey: identifier.documentKey || ''
});
// The description carries `Issue date: dd/mm/yyyy` when set on create (see
// The description carries `Issue date: yyyy-MM-dd` when set on create (see
// create-client.saveAddressProof) — pull it out so the form's issueDate field
// shows what was previously entered.
const desc: string = identifier.description || '';
const issueMatch = desc.match(/Issue date:\s*(.+)$/i);
if (issueMatch) {
this.permanentForm.patchValue({ issueDate: issueMatch[1].trim() });
this.permanentForm.patchValue({ issueDate: this.dates.parseDate(issueMatch[1].trim()) });
}
}
@@ -597,33 +671,17 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
});
}
/**
* Open the saved address proof in a new browser tab.
*
* For images we already hold a base64 data URL in `previewUrl` from
* `loadExistingAddressProof`, so we can hand it straight to `window.open`.
* For PDFs / other types we need to fetch the binary blob and create a
* temporary object URL so the browser's PDF viewer can render it.
*/
/** Open the saved address proof in an in-app preview dialog. */
viewExistingAddressProof(): void {
if (!this.existingAddressProofIdentifierId || this.existingAddressProofDocuments.length === 0) {
return;
}
const doc = this.existingAddressProofDocuments[0];
if (doc.previewUrl) {
window.open(doc.previewUrl, '_blank', 'noopener');
return;
}
this.clientsService.downloadClientIdentificationDocument(this.existingAddressProofIdentifierId, doc.id).subscribe({
next: (blob: Blob) => {
const url = URL.createObjectURL(blob);
const w = window.open(url, '_blank', 'noopener');
// Revoke after the tab has had time to load — browsers vary, so
// err on the side of a generous wait.
if (w) {
setTimeout(() => URL.revokeObjectURL(url), 60_000);
}
},
next: (blob: Blob) =>
this.dialog.open(DocumentPreviewDialogComponent, {
data: { blob, title: doc.fileName || doc.name, fileName: doc.fileName || doc.name }
}),
error: () => {
// Silent fail — UI shows the saved name so user knows it exists.
}
@@ -161,6 +161,17 @@
}
</button>
}
@if (aadhaarOtpSent && aadhaarVerifyStatus !== 'VERIFIED') {
<button
mat-stroked-button
type="button"
class="change-number-btn"
[disabled]="readOnly"
(click)="changeAadhaarNumber()"
>
<mat-icon>edit</mat-icon> Change Number
</button>
}
@if (aadhaarVerifyStatus === 'VERIFIED') {
<span class="verify-chip verified"><fa-icon icon="check-circle"></fa-icon> Verified</span>
}
@@ -712,6 +723,53 @@
<span>PAN verification requires client to be created first. Complete General tab and click Next to save.</span>
</div>
}
<!-- KYC Exception Flag -->
<div
class="exception-flag-box"
style="
margin-top: 20px;
padding: 16px;
border: 1px solid #f59e0b;
background: #fffbeb;
border-radius: 8px;
clear: both;
"
>
<h5
style="
margin: 0 0 12px 0;
font-size: 14px;
font-weight: 600;
color: #b45309;
display: flex;
align-items: center;
gap: 8px;
"
>
<fa-icon icon="exclamation-triangle"></fa-icon>
KYC Exception Flag
</h5>
<div style="display: flex; align-items: center; gap: 20px; flex-wrap: wrap">
<mat-checkbox formControlName="isException">Mark as KYC Exception</mat-checkbox>
@if (kycForm.get('isException')?.value) {
<mat-form-field appearance="outline" style="flex: 1; min-width: 280px; margin-bottom: -1.25em">
<mat-label>Exception Reasons</mat-label>
<mat-select formControlName="exceptionReason" multiple>
@if (!isNonIndividual) {
<mat-option value="PAN Card Verification Failed">PAN Card Verification Failed</mat-option>
<mat-option value="Aadhaar Verification Failed">Aadhaar Verification Failed</mat-option>
} @else {
<mat-option value="Enterprise PAN Verification Failed">Enterprise PAN Verification Failed</mat-option>
<mat-option value="GSTIN Verification Failed">GSTIN Verification Failed</mat-option>
<mat-option value="CIN Verification Failed">CIN Verification Failed</mat-option>
}
</mat-select>
</mat-form-field>
}
</div>
</div>
</div>
<!-- ═══ SECTION: KYC Document Management ═══ -->
@@ -1038,6 +1096,12 @@
</h4>
<div class="form-grid">
<div class="checkbox-field override-checkbox-wrapper full-width">
<mat-checkbox formControlName="allowRiskOverride" [disabled]="readOnly">
Override Auto-calculated Risk Category
</mat-checkbox>
</div>
<mat-form-field appearance="outline" class="form-field">
<mat-label>Risk Category</mat-label>
<mat-select formControlName="riskCategory">
@@ -1075,6 +1139,64 @@
<mat-hint>Auto: Low 10yr, Medium 8yr, High 2yr</mat-hint>
</mat-form-field>
@if (riskCalculationReason && !kycForm.get('allowRiskOverride')?.value && !readOnly) {
<div
class="risk-auto-calc-info full-width"
[class.risk-auto-calc-info--low]="kycForm.get('riskCategory')?.value === 1"
[class.risk-auto-calc-info--medium]="kycForm.get('riskCategory')?.value === 2"
[class.risk-auto-calc-info--high]="kycForm.get('riskCategory')?.value === 3"
>
<mat-icon class="info-icon">info</mat-icon>
<span class="info-text"
>Auto-determined: <strong>{{ riskCalculationReason }}</strong></span
>
</div>
}
<div class="risk-rules-toggle-wrapper full-width">
<button mat-button type="button" class="rules-toggle-btn" (click)="showRiskRulesTable = !showRiskRulesTable">
<mat-icon class="rules-toggle-icon">{{ showRiskRulesTable ? 'expand_less' : 'expand_more' }}</mat-icon>
{{
showRiskRulesTable ? 'Hide Risk Classification Criteria Table' : 'View Risk Classification Criteria Table'
}}
</button>
</div>
@if (showRiskRulesTable) {
<div class="risk-rules-card mat-elevation-z1 full-width">
<table class="risk-rules-table">
<thead>
<tr>
<th>Risk Category</th>
<th>Primary Criteria / Basis</th>
<th>Examples / Thresholds</th>
</tr>
</thead>
<tbody>
<tr class="rule-row rule-row--high">
<td><span class="risk-badge risk-badge--high">High Risk</span></td>
<td>PEPs, High-Risk Industries, High Income / Turnovers</td>
<td>
Politicians; Real Estate, Import/Export; Commission Agents; Income > ₹20L/yr; Turnover > ₹1 Crore
</td>
</tr>
<tr class="rule-row rule-row--medium">
<td><span class="risk-badge risk-badge--medium">Medium Risk</span></td>
<td>Self-Employed, Professionals, Cross-Border, Moderate Income</td>
<td>
Business Owners, Traders; Doctors, Lawyers, CAs; NRI, PIO, OCI; Income > ₹6L/yr; Turnover > ₹20L
</td>
</tr>
<tr class="rule-row rule-row--low">
<td><span class="risk-badge risk-badge--low">Low Risk</span></td>
<td>Non-Earning, Government Employment, Agriculture / Manual</td>
<td>Students, Minors, Homemakers; Govt/PSU Employees; Farmers, Daily Wage; Income <= ₹6L/yr</td>
</tr>
</tbody>
</table>
</div>
}
<mat-form-field appearance="outline" class="form-field full-width">
<mat-label>{{ isKycVerified ? 'Reason for Re-edit *' : 'Remarks' }}</mat-label>
<textarea
@@ -1176,6 +1298,12 @@
</h4>
<div class="form-grid">
<div class="checkbox-field override-checkbox-wrapper full-width">
<mat-checkbox formControlName="allowAmlOverride" [disabled]="readOnly">
Override Auto-calculated AML &amp; Compliance Checks
</mat-checkbox>
</div>
<mat-form-field appearance="outline" class="form-field">
<mat-label>AML Check Status</mat-label>
<mat-select required formControlName="amlCheckStatus">
@@ -1406,8 +1534,8 @@
<input type="file" accept="image/*" hidden (change)="onSignatureSelected($event)" />
<fa-icon icon="crop"></fa-icon> Choose &amp; Crop
</label>
<button type="button" class="upload-box__btn upload-box__btn--secondary" (click)="captureWebcamSignature()">
<fa-icon icon="video"></fa-icon> Use Webcam
<button type="button" class="upload-box__btn upload-box__btn--secondary" (click)="drawSignature()">
<fa-icon icon="pen"></fa-icon> Draw Signature
</button>
</div>
@if (photoSigTouched) {
@@ -266,6 +266,23 @@
gap: 6px;
}
.change-number-btn {
font-size: 12px !important;
font-weight: 600 !important;
padding: 0 14px !important;
height: 36px !important;
border-radius: 6px !important;
display: flex;
align-items: center;
gap: 6px;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
}
.verify-chip {
display: inline-flex;
align-items: center;
@@ -297,8 +314,24 @@
.toggle-vis-btn {
width: 28px !important;
height: 28px !important;
padding: 0 !important;
font-size: 13px !important;
color: #64748b;
/* Material reserves a 48x48 invisible touch-target + ripple around every
mat-icon-button by default. Shrinking the button to 28px above doesn't
shrink that touch-target/ripple with it, so — since the suffix
container doesn't clip overflow — it visually spills out past the
field's own border, making the icon look misplaced/half-outside the box. */
::ng-deep .mat-mdc-button-touch-target {
width: 28px !important;
height: 28px !important;
}
::ng-deep .mat-mdc-button-persistent-ripple,
::ng-deep .mat-ripple {
border-radius: 50%;
}
}
/* Verification Info Banner */
@@ -2240,3 +2273,161 @@
width: 95px;
}
}
.override-checkbox-wrapper {
margin-bottom: 8px;
}
.risk-auto-calc-info {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
margin-top: -8px;
margin-bottom: 12px;
border: 1px solid transparent;
.info-icon {
font-size: 18px !important;
width: 18px;
height: 18px;
flex-shrink: 0;
}
.info-text strong {
font-weight: 600;
}
&--low {
background: #ecfdf5;
color: #065f46;
border-color: #a7f3d0;
.info-icon {
color: #10b981;
}
}
&--medium {
background: #fffbeb;
color: #92400e;
border-color: #fde68a;
.info-icon {
color: #f59e0b;
}
}
&--high {
background: #fef2f2;
color: #991b1b;
border-color: #fecaca;
.info-icon {
color: #ef4444;
}
}
}
.risk-rules-toggle-wrapper {
margin-top: 4px;
margin-bottom: 12px;
.rules-toggle-btn {
font-size: 13px;
font-weight: 600;
color: #1e6bb8;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 20px;
transition: all 0.2s ease-in-out;
background: #f1f5f9;
&:hover {
background: #e2e8f0;
color: #0f172a;
}
}
}
.risk-rules-card {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
box-shadow: 0 1px 3px rgb(0 0 0 / 5%);
}
.risk-rules-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
text-align: left;
th {
padding: 10px 12px;
font-weight: 600;
color: #475569;
border-bottom: 2px solid #cbd5e1;
background: #f8fafc;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
td {
padding: 12px;
border-bottom: 1px solid #e2e8f0;
color: #334155;
vertical-align: top;
line-height: 1.5;
}
tr:last-child td {
border-bottom: none;
}
.rule-row {
transition: background 0.15s ease-in-out;
&:hover {
background: #f8fafc;
}
}
.risk-badge {
display: inline-block;
padding: 4px 10px;
border-radius: 12px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.3px;
text-align: center;
width: 80px;
&--high {
background: #fef2f2;
color: #dc2626;
border: 1px solid #fecaca;
}
&--medium {
background: #fffbeb;
color: #d97706;
border: 1px solid #fde68a;
}
&--low {
background: #ecfdf5;
color: #16a34a;
border: 1px solid #a7f3d0;
}
}
}
@@ -29,9 +29,12 @@ import { ClientsService } from '../../clients.service';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
import { ProofType, normalizeProofType, groupProofsByCategory } from '../../shared/kyc-proof-types';
import { deriveKycExceptionFields } from '../../kyc-exception.util';
import { KycUploadHelperService } from '../../shared/kyc-upload-helper.service';
import { CaptureImageDialogComponent } from '../../clients-view/custom-dialogs/capture-image-dialog/capture-image-dialog.component';
import { DrawSignatureDialogComponent } from '../../clients-view/custom-dialogs/draw-signature-dialog/draw-signature-dialog.component';
import { dataUrlToFile } from 'app/shared/utils/data-url.util';
import { DocumentPreviewDialogComponent } from '../../shared/document-preview-dialog/document-preview-dialog.component';
@Component({
selector: 'mifosx-client-kyc-step',
@@ -69,6 +72,15 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
@Input() entityName: string = '';
/** Read-only mode — disables form editing until user clicks edit icon */
@Input() readOnly = false;
@Input() calculatedRisk: {
category: number;
reason: string;
amlCheckStatus?: string;
bannedEntityMatch?: string;
willfulDefaulter?: string;
ecgcSalFlag?: string;
dormantFlag?: string;
} | null = null;
today = new Date();
kycId: string = '';
@@ -141,7 +153,8 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
kycDocumentName: 'KYC Document Type',
kycDocumentNumber: 'KYC Document Number',
amlCheckStatus: 'AML Check Status',
bannedEntityMatch: 'Banned Entity List Match'
bannedEntityMatch: 'Banned Entity List Match',
remarks: 'Reason for Re-edit'
};
/** Names every currently-invalid `kycForm` control (required-but-empty, or an entered value
@@ -169,6 +182,17 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
{ name: 'Birth Certificate', code: 'BIRTH_CERTIFICATE' },
{ name: 'School ID Card', code: 'SCHOOL_ID_CARD' }
];
/** Generic RBI-prescribed KYC documents for legal entities (Master Direction on KYC,
* Annexure I) — shown for non-individual clients before a specific constitution type is
* picked. Individual OVDs (Aadhaar/Passport/DL/Voter ID/NREGA/NPR) don't apply to entities. */
private nonIndividualDefaultDocTypes = [
{ name: 'Certificate of Incorporation / Registration Certificate', code: 'CERT_INCORPORATION' },
{ name: 'PAN Card of Entity', code: 'ENTITY_PAN' },
{ name: 'Memorandum & Articles of Association / Partnership Deed / Trust Deed', code: 'CONSTITUTIVE_DOC' },
{ name: 'Board Resolution / Authorization Letter', code: 'BOARD_RESOLUTION' },
{ name: 'KYC of Authorized Signatory / Power of Attorney Holder', code: 'AUTH_SIGNATORY_KYC' },
{ name: 'GST Registration Certificate', code: 'GST_CERTIFICATE' }
];
/** Constitution-based KYC document types for non-individual clients */
private constitutionDocTypes: Record<string, { name: string; mandatory: boolean }[]> = {
HUF: [
@@ -249,6 +273,17 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
{ value: 3, label: 'High Risk', years: 2, color: 'red' }
];
riskCalculationReason = '';
showRiskRulesTable = false;
/**
* Exception state as last read back from the server. Needed to tell "this client never had an
* exception" from "this client had one and has now cleared it" — only the latter should be
* stamped resolved and surface in the Resolved tab.
*/
persistedIsException = false;
persistedExceptionReason: string | null = null;
/** PAN verification */
panVerifyStatus: 'NONE' | 'VERIFIED' | 'FAILED' = 'NONE';
panVerifying = false;
@@ -398,8 +433,8 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
aadhaarNumber: [
'',
[
Validators.pattern(/^\d{12}$/),
Validators.maxLength(12)
Validators.pattern(/^(\d{12}|[X•*\s\d]{12,14})$/),
Validators.maxLength(14)
]
],
panAadhaarSeedingStatus: [''],
@@ -440,7 +475,11 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
kycDocIssueDate: [''],
kycDocExpiryDate: [''],
riskCategory: [1],
allowRiskOverride: [false],
allowAmlOverride: [false],
remarks: [''],
isException: [false],
exceptionReason: [[]],
amlCheckStatus: [
'CLEAR',
Validators.required
@@ -462,15 +501,16 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
form15FY: ['']
});
/** RBI OVDs a minor cannot hold: Voter ID and Driving Licence require adult age,
* PAN and NREGA job cards are issued to adults. Aadhaar, Passport and NPR letters
* are unaffected by age and stay available. */
private static readonly MINOR_INELIGIBLE_DOC_KEYWORDS = [
'pan',
'driving',
'voter',
'nrega'
];
/** RBI-accepted OVDs for a minor client — mirrors the backend's minor branch in
* ClientKycApiResource.getRequiredDocuments(). Voter ID, Driving Licence, PAN and NREGA
* all require adult eligibility; National Population Register Letter is deliberately
* excluded too (not offered as a minor OVD in this system). */
private static readonly MINOR_ELIGIBLE_DOC_CODES = new Set([
'AADHAAR',
'PASSPORT',
'BIRTH_CERTIFICATE',
'SCHOOL_ID_CARD'
]);
/** Birth Certificate / School ID Card are minor-specific OVDs — not relevant once the
* client is an adult, so they're hidden the other direction. */
@@ -481,32 +521,161 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
private filterDocTypesForMinor<T extends { name: string; code?: string }>(docs: T[]): T[] {
if (this.isMinor) {
return docs.filter((d) => {
const text = `${d.name || ''} ${d.code || ''}`.toLowerCase();
return !ClientKycStepComponent.MINOR_INELIGIBLE_DOC_KEYWORDS.some((k) => text.includes(k));
});
return docs.filter((d) => ClientKycStepComponent.MINOR_ELIGIBLE_DOC_CODES.has((d.code || '').toUpperCase()));
}
return docs.filter((d) => !ClientKycStepComponent.MINOR_ONLY_DOC_CODES.has((d.code || '').toUpperCase()));
}
/** Individual-only OVDs vs. entity-only RBI docs — never both, and minor filtering only
* ever applies to individuals. */
private getDefaultDocTypes(): { name: string; code: string }[] {
return this.isNonIndividual
? this.nonIndividualDefaultDocTypes
: this.filterDocTypesForMinor(this.defaultDocumentTypes);
}
ngOnInit(): void {
// Seed defaults immediately so the picker has options before clientId is set
// (template API requires clientId; user can still pick a type pre-save).
if (!this.kycDocumentTypes.length) {
this.kycDocumentTypes = this.filterDocTypesForMinor(
this.defaultDocumentTypes.map((d) => ({
name: d.name,
code: d.code,
mandatory: false
}))
);
this.rebuildKycDocPicker();
}
this.seedDefaultDocTypes();
this.loadExistingKycIfNeeded();
this.loadKycTemplate();
if (this.isNonIndividual && this.constitutionMeta) {
this.updateDocTypesForConstitution();
}
this.kycForm.get('allowRiskOverride')?.valueChanges.subscribe((allowed: boolean) => {
const riskControl = this.kycForm.get('riskCategory');
if (allowed) {
if (!this.readOnly) {
riskControl?.enable();
}
} else {
riskControl?.disable();
if (this.calculatedRisk) {
riskControl?.setValue(this.calculatedRisk.category);
}
}
});
this.kycForm.get('allowAmlOverride')?.valueChanges.subscribe((allowed: boolean) => {
const amlControls = [
this.kycForm.get('amlCheckStatus'),
this.kycForm.get('bannedEntityMatch'),
this.kycForm.get('willfulDefaulter'),
this.kycForm.get('ecgcSalFlag'),
this.kycForm.get('dormantFlag')
];
if (allowed) {
if (!this.readOnly) {
amlControls.forEach((ctrl) => ctrl?.enable());
}
} else {
amlControls.forEach((ctrl) => ctrl?.disable());
this.applyAutoCalculatedAmlFlags();
}
});
if (!this.kycForm.get('allowRiskOverride')?.value) {
this.kycForm.get('riskCategory')?.disable();
}
if (!this.kycForm.get('allowAmlOverride')?.value) {
this.kycForm.get('amlCheckStatus')?.disable();
this.kycForm.get('bannedEntityMatch')?.disable();
this.kycForm.get('willfulDefaulter')?.disable();
this.kycForm.get('ecgcSalFlag')?.disable();
this.kycForm.get('dormantFlag')?.disable();
}
}
/** Seed the document-type picker from defaults (entity RBI docs vs. individual OVDs,
* filtered by the current isMinor input for individuals). Only seeds when the list is
* empty — the template API (loadKycTemplate) takes over once a clientId is available. */
private seedDefaultDocTypes(): void {
if (this.kycDocumentTypes.length) return;
this.kycDocumentTypes = this.getDefaultDocTypes().map((d) => ({
name: d.name,
code: d.code,
mandatory: false
}));
this.rebuildKycDocPicker();
}
/**
* Resets transient KYC-tab state for a brand-new client.
*
* This app's RouteReusableStrategy (core/route/route-reusable-strategy.ts) keeps this SAME
* component instance alive across repeat visits to /clients/create, so `ngOnInit` never
* re-runs for a second "create new client" session. Without this, the document-type picker
* (and any PAN/Aadhaar verification state, uploaded photo/signature) would otherwise leak
* from whatever client was created in the previous session into the new one. Called by
* CreateClientComponent.resetForNewClient() whenever a fresh create session begins.
*/
resetForNewClient(): void {
this.kycForm.reset({
hasPan: true,
riskCategory: 1,
allowRiskOverride: false,
amlCheckStatus: 'CLEAR',
bannedEntityMatch: 'NO',
willfulDefaulter: 'NO',
ecgcSalFlag: 'NO',
dormantFlag: 'ACTIVE',
accountStatus: 'ACTIVE',
form60Type: 'FORM_60',
form15Type: '15G',
isException: false,
exceptionReason: []
});
this.persistedIsException = false;
this.persistedExceptionReason = null;
this.kycId = '';
this.kycLoaded = false;
this.kycStatus = 0;
this.kycTemplate = null;
this.kycTemplateLoaded = false;
this.kycDocumentTypes = [];
this.selectedKycDoc = null;
this.kycDocFiles = { front: null, back: null };
this.kycDocMergedFile = null;
this.kycDocMergedPreview = null;
this.existingKycIdentifierId = null;
this.riskCalculationReason = '';
this.showRiskRulesTable = false;
this.existingKycDocuments = [];
this.photoSigTouched = false;
this.panVerifyStatus = 'NONE';
this.panVerifying = false;
this.panVerifyError = '';
this.panVerifyName = '';
this.panVerifiedOn = '';
this.aadhaarVerifyStatus = 'NONE';
this.aadhaarVerifying = false;
this.aadhaarVerifyError = '';
this.aadhaarVerifyName = '';
this.aadhaarVisible = false;
this.aadhaarOtpSent = false;
this.resetOtpDigits();
this.aadhaarOtpReferenceId = '';
this.panAadhaarSeedingStatus = '';
this.entPanVerifyStatus = 'NONE';
this.entPanVerifying = false;
this.entPanVerifyName = '';
this.entPanVerifiedOn = '';
this.gstinVerifyStatus = 'NONE';
this.gstinVerifying = false;
this.gstinVerifyName = '';
this.gstinVerifiedOn = '';
this.gstinVerifyState = '';
this.cinVerifyStatus = 'NONE';
this.cinVerifying = false;
this.cinVerifiedOn = '';
this.cinCompanyName = '';
this.photographFile = null;
this.photographPreview = null;
this.signatureFile = null;
this.signaturePreview = null;
this.existingSignatureDocId = '';
this.seedDefaultDocTypes();
}
ngOnDestroy(): void {
@@ -524,21 +693,70 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
this.kycForm.disable({ emitEvent: false });
} else {
this.kycForm.enable({ emitEvent: false });
if (!this.kycForm.get('allowRiskOverride')?.value) {
this.kycForm.get('riskCategory')?.disable({ emitEvent: false });
}
if (!this.kycForm.get('allowAmlOverride')?.value) {
this.kycForm.get('amlCheckStatus')?.disable({ emitEvent: false });
this.kycForm.get('bannedEntityMatch')?.disable({ emitEvent: false });
this.kycForm.get('willfulDefaulter')?.disable({ emitEvent: false });
this.kycForm.get('ecgcSalFlag')?.disable({ emitEvent: false });
this.kycForm.get('dormantFlag')?.disable({ emitEvent: false });
}
}
}
if (changes['calculatedRisk']) {
const newRisk = changes['calculatedRisk'].currentValue;
if (newRisk) {
this.riskCalculationReason = newRisk.reason;
const allowOverride = this.kycForm.get('allowRiskOverride')?.value;
const riskControl = this.kycForm.get('riskCategory');
if (!allowOverride && riskControl && riskControl.value !== newRisk.category) {
riskControl.setValue(newRisk.category);
riskControl.markAsDirty();
}
const allowAmlOverride = this.kycForm.get('allowAmlOverride')?.value;
if (!allowAmlOverride) {
this.applyAutoCalculatedAmlFlags(newRisk);
}
} else {
this.riskCalculationReason = '';
}
}
if (changes['constitutionMeta'] && this.isNonIndividual) {
this.updateDocTypesForConstitution();
}
if (changes['isMinor']) {
// Legal form toggled on an already-initialized instance (route-reuse keeps this
// component alive across create sessions) — the individual OVD list and the entity RBI
// doc list are mutually exclusive, so re-seed from scratch rather than filtering in place.
if (changes['isNonIndividual'] && !changes['isNonIndividual'].firstChange && !this.kycTemplateLoaded) {
this.kycDocumentTypes = [];
this.selectedKycDoc = null;
this.kycForm.get('kycDocumentName')?.setValue('');
this.seedDefaultDocTypes();
if (this.isNonIndividual && this.constitutionMeta) {
this.updateDocTypesForConstitution();
}
}
if (changes['isMinor'] && !this.isNonIndividual) {
if (this.isMinor) {
this.kycForm.get('hasPan')?.setValue(false);
this.kycForm.get('panNumber')?.setValue('');
}
const filtered = this.filterDocTypesForMinor(this.kycDocumentTypes);
if (filtered.length !== this.kycDocumentTypes.length) {
this.kycDocumentTypes = filtered;
// Re-derive from the full default list, not by re-filtering the already-narrowed
// `kycDocumentTypes` — filterDocTypesForMinor() only ever REMOVES entries, so once the
// initial (pre-DOB, isMinor=false) seed has already stripped out Birth Certificate /
// School ID Card, re-filtering that same shortened list can never bring them back once
// the client turns out to be a minor. Once the backend KYC template has loaded
// (kycTemplateLoaded), that list is already minor-aware and authoritative — leave it alone.
if (!this.kycTemplateLoaded) {
const rebuilt = this.filterDocTypesForMinor(
this.defaultDocumentTypes.map((d) => ({ name: d.name, code: d.code, mandatory: false }))
);
this.kycDocumentTypes = rebuilt;
const currentName = this.kycForm.get('kycDocumentName')?.value;
if (currentName && !filtered.some((d) => d.name === currentName)) {
if (currentName && !rebuilt.some((d) => d.name === currentName)) {
this.kycForm.get('kycDocumentName')?.setValue('');
}
this.rebuildKycDocPicker();
@@ -614,30 +832,17 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
this.kycDocMergedPreview = null;
}
/**
* Open the saved KYC ID-proof document in a new browser tab.
*
* Same flow as the address-step variant: images use the already-loaded
* base64 data URL; PDFs (or anything without a cached preview) are fetched
* fresh and rendered via a temporary blob URL.
*/
/** Open the saved KYC ID-proof document in an in-app preview dialog. */
viewExistingKycDoc(): void {
if (!this.existingKycIdentifierId || this.existingKycDocuments.length === 0) {
return;
}
const doc = this.existingKycDocuments[0];
if (doc.previewUrl) {
window.open(doc.previewUrl, '_blank', 'noopener');
return;
}
this.clientsService.downloadClientIdentificationDocument(this.existingKycIdentifierId, doc.id).subscribe({
next: (blob: Blob) => {
const url = URL.createObjectURL(blob);
const w = window.open(url, '_blank', 'noopener');
if (w) {
setTimeout(() => URL.revokeObjectURL(url), 60_000);
}
},
next: (blob: Blob) =>
this.dialog.open(DocumentPreviewDialogComponent, {
data: { blob, title: doc.fileName || doc.name, fileName: doc.fileName || doc.name }
}),
error: () => {
/* silent — UI still shows the saved filename */
}
@@ -728,6 +933,14 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
kycDocExpiryDate: parseDate(data.kycDocExpiryDate),
// Risk & Remarks
riskCategory: data.riskCategory || 1,
allowRiskOverride: this.calculatedRisk ? this.calculatedRisk.category !== (data.riskCategory || 1) : false,
allowAmlOverride: this.calculatedRisk
? this.calculatedRisk.amlCheckStatus !== (data.amlCheckStatus || 'CLEAR') ||
this.calculatedRisk.bannedEntityMatch !== (data.bannedEntityMatch || 'NO') ||
this.calculatedRisk.willfulDefaulter !== (data.willfulDefaulter || 'NO') ||
this.calculatedRisk.ecgcSalFlag !== (data.ecgcSalFlag || 'NO') ||
this.calculatedRisk.dormantFlag !== (data.dormantFlag || 'ACTIVE')
: false,
remarks: data.remarks || '',
hasPan: !!data.panNumber,
// AML & Compliance
@@ -745,8 +958,18 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
form60Date: parseDate(data.form60Date),
form15Applicable: data.form15Applicable || false,
form15Type: data.form15Type || '15G',
form15FY: data.form15FY || ''
form15FY: data.form15FY || '',
isException: data.isException || false,
exceptionReason: data.exceptionReason ? data.exceptionReason.split(', ') : []
});
// Set remarks as required if KYC is verified
if (this.isKycVerified) {
this.kycForm.get('remarks')?.setValidators(Validators.required);
this.kycForm.get('remarks')?.updateValueAndValidity();
this.kycForm.get('remarks')?.markAsTouched(); // Force red underline so user knows it's blocking
}
this.persistedIsException = data.isException === true;
this.persistedExceptionReason = data.exceptionReason || null;
// Sync chip-picker selection with the prefilled document name
if (data.kycDocumentName) {
this.selectedKycDoc = this.kycDocProofs.find((p) => p.name === data.kycDocumentName) || null;
@@ -813,12 +1036,17 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
const n = i?.documentType?.name?.toLowerCase();
return !!n && this.kycDocProofs.some((p) => p.name?.toLowerCase() === n);
};
// Re-uploading a KYC document creates a new identifier row rather than updating the
// old one in place (same pattern as OVD identifiers), so sort newest-first before
// picking — otherwise a repeat upload of the same document type would keep showing
// the ORIGINAL file forever, since .find() would always hit the oldest matching row.
const sorted = [...identifiers].sort((a: any, b: any) => (b?.id ?? 0) - (a?.id ?? 0));
// Pick by document-type name match first; otherwise the first identifier whose
// type lives in the KYC proof list (avoid bleeding the address proof in here).
const match = kycDocumentName
? identifiers.find((i: any) => i?.documentType?.name?.toLowerCase() === kycDocumentName.toLowerCase())
? sorted.find((i: any) => i?.documentType?.name?.toLowerCase() === kycDocumentName.toLowerCase())
: null;
const identifier = match || identifiers.find(isKycType);
const identifier = match || sorted.find(isKycType);
if (!identifier?.id) return;
this.existingKycIdentifierId = identifier.id.toString();
@@ -893,7 +1121,12 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
if (!this.clientId) return;
this.clientsService.getClientDocuments(this.clientId).subscribe({
next: (documents: any[]) => {
const sigDoc = documents?.find((d: any) => d.name === 'clientSignature');
// Multiple 'clientSignature' documents can exist for clients who re-cropped/replaced
// their signature before uploads became single-slot, so pick the highest id (latest)
// rather than the first unordered match.
const sigDoc = documents
?.filter((d: any) => d.name === 'clientSignature')
?.sort((a: any, b: any) => b.id - a.id)?.[0];
if (sigDoc?.id) {
this.existingSignatureDocId = sigDoc.id.toString();
this.clientsService.getClientSignatureImage(this.clientId, this.existingSignatureDocId).subscribe({
@@ -941,13 +1174,11 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
error: () => {
// Fallback to defaults if template API unavailable
this.kycTemplateLoaded = true;
this.kycDocumentTypes = this.filterDocTypesForMinor(
this.defaultDocumentTypes.map((d) => ({
name: d.name,
code: d.code,
mandatory: false
}))
);
this.kycDocumentTypes = this.getDefaultDocTypes().map((d) => ({
name: d.name,
code: d.code,
mandatory: false
}));
this.rebuildKycDocPicker();
}
});
@@ -1370,6 +1601,21 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
);
}
/** Lets the user go back and fix a wrong Aadhaar number after an OTP was already sent —
* unlocks the (readonly-while-OTP-pending) input and discards the in-flight OTP session,
* since it was requested for the now-being-corrected number. */
changeAadhaarNumber(): void {
this.aadhaarOtpSent = false;
this.aadhaarVerifyError = '';
this.aadhaarOtpReferenceId = '';
this.resetOtpDigits();
this.resendCountdown = 0;
if (this.resendTimer) {
clearInterval(this.resendTimer);
this.resendTimer = null;
}
}
/** Verify Aadhaar OTP */
verifyAadhaarOtp() {
if (!this.aadhaarOtp || !this.clientId || !this.kycId) return;
@@ -1492,20 +1738,6 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
await this.openSignatureCropDialog(file);
}
/** Webcam capture entry point for the specimen signature — photographs a paper
* signature, then reuses the same crop dialog + staging/upload path as the file-picker
* flow above (additive to the existing draw-pad / upload options, not a replacement). */
async captureWebcamSignature(): Promise<void> {
const dialogRef = this.dialog.open(CaptureImageDialogComponent, {
data: { captureTarget: 'signature' }
});
dialogRef.afterClosed().subscribe(async (imageDataUrl: string) => {
if (!imageDataUrl) return;
const file = dataUrlToFile(imageDataUrl, 'webcam-signature.jpg');
await this.openSignatureCropDialog(file);
});
}
private async openSignatureCropDialog(file: File): Promise<void> {
const { ImageCropDialogComponent } = await import('./image-crop-dialog/image-crop-dialog.component');
const dialogRef = this.dialog.open(ImageCropDialogComponent, {
@@ -1515,42 +1747,51 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
});
dialogRef.afterClosed().subscribe((result: any) => {
if (result) {
this.signatureFile = result.croppedFile;
this.signaturePreview = result.previewUrl;
// In edit mode, delete old signature doc then upload new one
if (this.clientId) {
if (this.existingSignatureDocId) {
this.clientsService.deleteClientDocument(this.clientId, this.existingSignatureDocId).subscribe({
next: () => {
this.clientsService.uploadClientSignatureImage(this.clientId, result.croppedFile).subscribe({
next: (res: any) => {
if (res?.resourceId) this.existingSignatureDocId = res.resourceId.toString();
},
error: () => {}
});
},
error: () => {
this.clientsService.uploadClientSignatureImage(this.clientId, result.croppedFile).subscribe({
next: (res: any) => {
if (res?.resourceId) this.existingSignatureDocId = res.resourceId.toString();
},
error: () => {}
});
}
});
} else {
this.clientsService.uploadClientSignatureImage(this.clientId, result.croppedFile).subscribe({
next: (res: any) => {
if (res?.resourceId) this.existingSignatureDocId = res.resourceId.toString();
},
error: () => {}
});
}
}
this.applySignatureFile(result.croppedFile, result.previewUrl);
}
});
}
/** Draw-with-mouse/touchpad entry point for the specimen signature — same staging/upload
* path as the file-picker and webcam flows, minus the crop step (the pad's canvas is
* already the right shape/size). */
drawSignature(): void {
const dialogRef = this.dialog.open(DrawSignatureDialogComponent);
dialogRef.afterClosed().subscribe((file: File) => {
if (file) {
this.applySignatureFile(file, URL.createObjectURL(file));
}
});
}
/** Stages the signature locally and, in edit mode, persists it — replacing any existing
* signature doc first so a client never ends up with two. Shared by the file-picker, webcam
* and draw-pad entry points. */
private applySignatureFile(file: File, previewUrl: string): void {
this.signatureFile = file;
this.signaturePreview = previewUrl;
if (!this.clientId) {
return;
}
if (this.existingSignatureDocId) {
this.clientsService.deleteClientDocument(this.clientId, this.existingSignatureDocId).subscribe({
next: () => this.uploadSignatureFile(file),
error: () => this.uploadSignatureFile(file)
});
} else {
this.uploadSignatureFile(file);
}
}
private uploadSignatureFile(file: File): void {
this.clientsService.uploadClientSignatureImage(this.clientId, file).subscribe({
next: (res: any) => {
if (res?.resourceId) this.existingSignatureDocId = res.resourceId.toString();
},
error: () => {}
});
}
removePhoto() {
this.photographFile = null;
this.photographPreview = null;
@@ -1575,7 +1816,7 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
}
get kycData(): any {
const formVal = this.readOnly ? this.kycForm.getRawValue() : this.kycForm.value;
const formVal = this.kycForm.getRawValue();
const aadhaarRaw = formVal.aadhaarNumber || '';
const aadhaarMasked = aadhaarRaw.length === 12 ? 'XXXXXXXX' + aadhaarRaw.slice(-4) : null;
const dateFormat = this.settingsService.dateFormat;
@@ -1628,7 +1869,23 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
form15Applicable: v.form15Applicable || false,
form15Type: v.form15Type || null,
form15FY: v.form15FY || null,
remarks: v.remarks || null
remarks: v.remarks || null,
// Exception state is DERIVED from verification status, not from a checkbox — an unverified
// or blank PAN/Aadhaar is an exception by definition, so the officer cannot forget to flag
// it. See deriveKycExceptionFields() for the raise / narrow / close rules.
...deriveKycExceptionFields({
panVerified: this.isNonIndividual
? this.entPanVerifyStatus === 'VERIFIED'
: this.panVerifyStatus === 'VERIFIED',
aadhaarVerified: this.aadhaarVerifyStatus === 'VERIFIED',
// Non-individual clients submit a dummy masked Aadhaar, so checking it would saddle every
// company with a permanent exception.
aadhaarApplicable: !this.isNonIndividual,
hadException: this.persistedIsException,
previousReason: this.persistedExceptionReason,
manualReasons: Array.isArray(v.exceptionReason) ? v.exceptionReason : [],
todayFormatted: this.dateUtils.formatDate(new Date(), dateFormat)
})
};
}
@@ -1641,9 +1898,25 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
v.enterprisePan ||
v.cinNumber ||
v.kycDocumentName ||
v.form15Applicable
v.form15Applicable ||
v.isException
);
}
private applyAutoCalculatedAmlFlags(risk = this.calculatedRisk): void {
if (!risk) return;
this.kycForm.get('amlCheckStatus')?.setValue(risk.amlCheckStatus || 'CLEAR');
this.kycForm.get('bannedEntityMatch')?.setValue(risk.bannedEntityMatch || 'NO');
this.kycForm.get('willfulDefaulter')?.setValue(risk.willfulDefaulter || 'NO');
this.kycForm.get('ecgcSalFlag')?.setValue(risk.ecgcSalFlag || 'NO');
this.kycForm.get('dormantFlag')?.setValue(risk.dormantFlag || 'ACTIVE');
this.kycForm.get('amlCheckStatus')?.markAsDirty();
this.kycForm.get('bannedEntityMatch')?.markAsDirty();
this.kycForm.get('willfulDefaulter')?.markAsDirty();
this.kycForm.get('ecgcSalFlag')?.markAsDirty();
this.kycForm.get('dormantFlag')?.markAsDirty();
}
}
/**
@@ -195,7 +195,7 @@
client.isMinor ? 'preview-badge preview-badge--danger' : 'preview-badge preview-badge--verified'
"
>
{{ client.isMinor ? 'Yes (Age < 10)' : 'No' }}
{{ client.isMinor ? 'Yes (Age < 18)' : 'No' }}
</span>
</span>
</div>
@@ -60,6 +60,7 @@ import { ClientCollateralResolver } from './common-resolvers/client-collateral.r
import { KycTabComponent } from './clients-view/kyc-tab/kyc-tab.component';
import { CkycTabComponent } from './clients-view/ckyc-tab/ckyc-tab.component';
import { Form60TabComponent } from './clients-view/form60-tab/form60-tab.component';
import { Form121TabComponent } from './clients-view/form121-tab/form121-tab.component';
import { EntityClassificationTabComponent } from './clients-view/entity-classification-tab/entity-classification-tab.component';
import { familyAllowedGuard, nomineeAllowedGuard } from './utils/family-nominee.guard';
@@ -193,6 +194,11 @@ const routes: Routes = [
component: Form60TabComponent,
data: { title: 'Form 97', breadcrumb: 'Form 97', routeParamBreadcrumb: false }
},
{
path: 'form121',
component: Form121TabComponent,
data: { title: 'Form 121 / TDS', breadcrumb: 'Form 121 / TDS', routeParamBreadcrumb: false }
},
{
path: 'documents',
component: DocumentsTabComponent,
@@ -234,8 +234,12 @@ export class CkycTabComponent implements OnInit, OnDestroy {
};
}
// 2. Signature — client document with name='clientSignature'
const signatureDoc = (documents as any[])?.find((d: any) => d?.name === 'clientSignature');
// 2. Signature — client document with name='clientSignature'. Multiple rows can exist
// for clients who re-cropped/replaced their signature before uploads became single-slot,
// so pick the highest id (latest) rather than the first unordered match.
const signatureDoc = (documents as any[])
?.filter((d: any) => d?.name === 'clientSignature')
?.sort((a: any, b: any) => b.id - a.id)?.[0];
if (signatureDoc) {
this.existing.CLIENT_SIGNATURE = {
present: true,
@@ -410,6 +410,9 @@
<button mat-menu-item (click)="doAction('Add Charge')" *mifosxHasPermission="'CREATE_CLIENTCHARGE'">
{{ 'labels.buttons.Add Charge' | translate }}
</button>
<button mat-menu-item (click)="doAction('Welcome Kit')" *mifosxHasPermission="'READ_WELCOMEKIT'">
Welcome Kit
</button>
<button mat-menu-item (click)="doAction('Create Collateral')">
{{ 'labels.buttons.Create Collateral' | translate }}
</button>
@@ -542,6 +545,15 @@
>
<i class="fa fa-file-text cv-tab-icon"></i> {{ 'labels.inputs.Form 60' | translate }}
</a>
<a
mat-tab-link
[routerLink]="['./form121']"
routerLinkActive
#form121="routerLinkActive"
[active]="form121.isActive"
>
<i class="fa fa-percent cv-tab-icon"></i> Form 121 / TDS
</a>
<a
mat-tab-link
[routerLink]="['./documents']"
@@ -22,6 +22,7 @@ import { DrawSignatureDialogComponent } from './custom-dialogs/draw-signature-di
import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component';
import { UploadImageDialogComponent } from './custom-dialogs/upload-image-dialog/upload-image-dialog.component';
import { CaptureImageDialogComponent } from './custom-dialogs/capture-image-dialog/capture-image-dialog.component';
import { WelcomeKitDialogComponent } from './custom-dialogs/welcome-kit-dialog/welcome-kit-dialog.component';
/** Custom Services */
import { ClientsService } from '../clients.service';
@@ -255,6 +256,9 @@ export class ClientsViewComponent implements OnInit {
case 'Delete Image':
this.deleteProfileImage();
break;
case 'Welcome Kit':
this.dispatchWelcomeKit();
break;
case 'Create Standing Instructions':
const createStandingInstructionsQueryParams: any = {
officeId: this.clientViewData.officeId,
@@ -455,4 +459,20 @@ export class ClientsViewComponent implements OnInit {
}
});
}
/**
* Opens the Welcome Kit Dispatch dialog.
*/
private dispatchWelcomeKit() {
const welcomeKitDialogRef = this.dialog.open(WelcomeKitDialogComponent, {
data: { clientId: this.clientViewData.id },
width: '600px'
});
welcomeKitDialogRef.afterClosed().subscribe((response: any) => {
if (response) {
// the form was successfully submitted
this.reload();
}
});
}
}
@@ -47,7 +47,13 @@ export class DeleteSignatureDialogComponent {
* @param {any} data Documents data
*/
constructor() {
const signature = this.data.find((document: any) => document.name === 'clientSignature') || {};
// Multiple 'clientSignature' documents can exist for clients who re-cropped/replaced their
// signature before uploads became single-slot, so pick the highest id (latest) rather than
// the first unordered match.
const signature =
this.data
.filter((document: any) => document.name === 'clientSignature')
.sort((a: any, b: any) => b.id - a.id)[0] || {};
this.signatureId = signature.id;
}
}
@@ -347,9 +347,18 @@
<i class="fa fa-calendar"></i> Issue / statement date
<span class="kyc-opt">(not older than 3 months)</span>
</label>
<div class="kyc-input-wrap kyc-input-wrap--date">
<i class="fa fa-calendar-o kyc-input-wrap__icon"></i>
<input type="date" class="kyc-input kyc-input--date" formControlName="issueDate" />
<div class="kyc-input-wrap kyc-input-wrap--date" (click)="uploadIssueDatePicker.open()">
<input
class="kyc-input kyc-input--date"
[matDatepicker]="uploadIssueDatePicker"
formControlName="issueDate"
readonly
/>
<mat-datepicker-toggle
class="kyc-input-wrap__calendar-toggle"
[for]="uploadIssueDatePicker"
></mat-datepicker-toggle>
<mat-datepicker #uploadIssueDatePicker></mat-datepicker>
</div>
</div>
}
@@ -388,42 +388,32 @@ $red-900: #993c1d;
padding-left: 38px;
}
/* Date input — match styling of other kyc inputs */
/* Date input — opens the Material calendar picker; styled to match other kyc inputs */
.kyc-input--date {
font-family: inherit;
color: $gray-900;
cursor: pointer;
min-height: 40px;
padding-right: 14px !important;
padding-left: 12px !important;
padding-right: 40px !important;
}
&::-webkit-calendar-picker-indicator {
opacity: 0.55;
cursor: pointer;
filter: grayscale(1);
transition: opacity 0.15s;
.kyc-input-wrap--date .kyc-input-wrap__calendar-toggle {
position: absolute;
right: 2px;
top: 50%;
transform: translateY(-50%);
color: $gray-500;
::ng-deep .mat-mdc-icon-button {
width: 32px;
height: 32px;
padding: 4px;
}
&:hover::-webkit-calendar-picker-indicator {
opacity: 0.9;
}
&::-webkit-datetime-edit {
color: $gray-900;
padding-top: 1px;
}
&::-webkit-datetime-edit-fields-wrapper {
color: $gray-900;
}
&::-webkit-datetime-edit-text {
color: $gray-500;
padding: 0 2px;
}
&:invalid::-webkit-datetime-edit,
&:not(:focus):placeholder-shown::-webkit-datetime-edit {
color: $gray-500;
::ng-deep .mat-mdc-icon-button svg {
width: 18px;
height: 18px;
}
}
@@ -65,7 +65,13 @@ export class ViewSignatureDialogComponent implements OnInit {
* @param {any} data Documents data
*/
constructor() {
const signature = this.data.documents.find((document: any) => document.name === 'clientSignature') || {};
// Multiple 'clientSignature' documents can exist for clients who re-cropped/replaced their
// signature before uploads became single-slot, so pick the highest id (latest) rather than
// the first unordered match.
const signature =
this.data.documents
.filter((document: any) => document.name === 'clientSignature')
.sort((a: any, b: any) => b.id - a.id)[0] || {};
this.signatureId = signature.id;
this.clientId = this.data.id;
}
@@ -0,0 +1,94 @@
<!--
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>Welcome Kit Dispatch</h2>
<mat-dialog-content class="mat-typography">
<div fxLayout="row" fxLayoutAlign="center center" style="padding: 2rem" *ngIf="isLoading">
<mat-spinner diameter="40"></mat-spinner>
</div>
<ng-container *ngIf="!isLoading">
<!-- Tracking view if dispatches already exist -->
<div
class="dispatch-tracking-panel"
*ngFor="let dispatch of activeDispatches; let i = index"
style="margin-bottom: 16px; padding: 16px; border: 1px solid #e0e0e0; border-radius: 4px; background: #fafafa"
>
<div fxLayout="row" fxLayoutAlign="space-between start" style="margin-bottom: 8px">
<h3 style="margin: 0">{{ i === 0 ? 'Current' : 'Previous' }} Welcome Kit Status</h3>
<button mat-stroked-button color="primary" type="button" (click)="downloadPdf(dispatch)">
Download Details
</button>
</div>
<p style="margin: 4px 0"><strong>Delivery Address:</strong> {{ getDisplayAddress(dispatch) }}</p>
<p style="margin: 4px 0"><strong>Tracking ID:</strong> {{ dispatch.trackingId }}</p>
<p style="margin: 4px 0">
<strong>Delivery Mode:</strong> {{ dispatch.deliveryMode }}
<ng-container *ngIf="dispatch.deliveryMode === 'OTHER'">({{ dispatch.deliveryModeOtherText }})</ng-container>
</p>
<p style="margin: 4px 0"><strong>Dispatched On:</strong> {{ dispatch.dispatchedOnDate | date: 'mediumDate' }}</p>
<p style="margin: 4px 0">
<strong>Status:</strong>
<span class="status-badge status-{{ dispatch.status | lowercase }}">{{ dispatch.status }}</span>
</p>
</div>
<hr *ngIf="activeDispatches.length > 0 && welcomeKitsData?.canDispatchNew" />
<!-- Create form if they can dispatch a new one -->
<div class="dispatch-create-form" [formGroup]="dispatchForm" *ngIf="welcomeKitsData?.canDispatchNew">
<h3>Create New Dispatch</h3>
<div fxLayout="column" fxLayoutGap="16px">
<mat-form-field appearance="outline" class="full-width">
<mat-label>Delivery Address</mat-label>
<textarea matInput formControlName="deliveryAddress" rows="3" required></textarea>
<mat-error *ngIf="dispatchForm.get('deliveryAddress')?.hasError('required')">
Delivery Address is required
</mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="full-width">
<mat-label>Delivery Mode</mat-label>
<mat-select formControlName="deliveryMode" required>
<mat-option *ngFor="let mode of deliveryModes" [value]="mode">
{{ mode }}
</mat-option>
</mat-select>
<mat-error *ngIf="dispatchForm.get('deliveryMode')?.hasError('required')">
Delivery Mode is required
</mat-error>
</mat-form-field>
<mat-form-field
appearance="outline"
class="full-width"
*ngIf="dispatchForm.get('deliveryMode')?.value === 'OTHER'"
>
<mat-label>Specify Other Mode</mat-label>
<input matInput formControlName="deliveryModeOtherText" required />
<mat-error *ngIf="dispatchForm.get('deliveryModeOtherText')?.hasError('required')">
Please specify the delivery mode
</mat-error>
</mat-form-field>
</div>
</div>
</ng-container>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button mat-dialog-close>Close</button>
<button
mat-raised-button
color="primary"
*ngIf="welcomeKitsData?.canDispatchNew && !isLoading"
(click)="submit()"
[disabled]="dispatchForm.invalid || isLoading"
>
Dispatch
</button>
</mat-dialog-actions>
@@ -0,0 +1,59 @@
/**
* 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/.
*/
.dispatch-tracking-panel {
background-color: #f5f5f5;
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
h3 {
margin-top: 0;
margin-bottom: 12px;
font-size: 1.1rem;
}
p {
margin: 6px 0;
}
.status-badge {
padding: 4px 8px;
border-radius: 4px;
font-size: 0.85rem;
font-weight: 500;
color: white;
&.status-dispatched {
background-color: #1976d2;
}
&.status-in_transit {
background-color: #f57c00;
}
&.status-delivered {
background-color: #388e3c;
}
&.status-returned {
background-color: #d32f2f;
}
}
}
.dispatch-create-form {
h3 {
margin-top: 0;
margin-bottom: 12px;
font-size: 1.1rem;
}
}
.full-width {
width: 100%;
}
@@ -0,0 +1,347 @@
/**
* 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 { FormBuilder, FormGroup, Validators } from '@angular/forms';
import {
MAT_DIALOG_DATA,
MatDialogRef,
MatDialogTitle,
MatDialogContent,
MatDialogActions,
MatDialogClose
} from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { ClientsService } from '../../../clients.service';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { DatePipe } from '@angular/common';
import { jsPDF } from 'jspdf';
import autoTable from 'jspdf-autotable';
@Component({
selector: 'mifosx-welcome-kit-dialog',
templateUrl: './welcome-kit-dialog.component.html',
styleUrls: ['./welcome-kit-dialog.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatDialogTitle,
MatDialogContent,
MatDialogActions,
MatDialogClose,
MatProgressSpinnerModule
],
providers: [DatePipe]
})
export class WelcomeKitDialogComponent implements OnInit {
private dialogRef = inject(MatDialogRef<WelcomeKitDialogComponent>);
data: { clientId: string } = inject(MAT_DIALOG_DATA);
private clientsService = inject(ClientsService);
private formBuilder = inject(FormBuilder);
private snackBar = inject(MatSnackBar);
private datePipe = inject(DatePipe);
isLoading = true;
welcomeKitsData: any = null;
dispatchForm: FormGroup = this.formBuilder.group({
deliveryAddress: [
'',
Validators.required
],
deliveryMode: [
'POSTMAN',
Validators.required
],
deliveryModeOtherText: ['']
});
deliveryModes = [
'POSTMAN',
'COURIER',
'OTHER'
];
constructor() {
this.dispatchForm.get('deliveryMode')?.valueChanges.subscribe((mode) => {
const otherControl = this.dispatchForm.get('deliveryModeOtherText');
if (mode === 'OTHER') {
otherControl?.setValidators([Validators.required]);
} else {
otherControl?.clearValidators();
}
otherControl?.updateValueAndValidity();
});
}
ngOnInit(): void {
this.fetchData();
}
fetchData() {
this.isLoading = true;
this.clientsService.getWelcomeKits(this.data.clientId).subscribe({
next: (response: any) => {
this.welcomeKitsData = response;
if (this.welcomeKitsData && this.welcomeKitsData.canDispatchNew) {
this.fetchPrimaryAddress();
} else {
this.isLoading = false;
}
},
error: () => {
this.isLoading = false;
this.snackBar.open('Failed to load welcome kits data', 'Close', { duration: 3000 });
}
});
}
fetchPrimaryAddress() {
this.clientsService.getClientAddressData(this.data.clientId).subscribe({
next: (addresses: any) => {
this.isLoading = false;
if (addresses && addresses.length > 0) {
// Attempt to build a readable address from the first address returned
const primary = addresses[0];
const parts = [];
if (primary.addressLine1) parts.push(primary.addressLine1);
if (primary.addressLine2) parts.push(primary.addressLine2);
if (primary.city) parts.push(primary.city);
if (primary.stateName) parts.push(primary.stateName);
if (primary.postalCode) parts.push(primary.postalCode);
if (primary.countryName) parts.push(primary.countryName);
const clientNameStr = this.welcomeKitsData?.clientName ? this.welcomeKitsData.clientName + ' — ' : '';
this.dispatchForm.patchValue({
deliveryAddress: clientNameStr + parts.join(', ')
});
}
},
error: () => {
this.isLoading = false;
}
});
}
get activeDispatches() {
if (this.welcomeKitsData && this.welcomeKitsData.dispatches && this.welcomeKitsData.dispatches.length > 0) {
return this.welcomeKitsData.dispatches;
}
return [];
}
submit() {
if (this.dispatchForm.invalid) {
this.dispatchForm.markAllAsTouched();
return;
}
this.isLoading = true;
const formValue = this.dispatchForm.value;
const today = new Date();
const formattedDate = this.datePipe.transform(today, 'dd MMMM yyyy');
const payload = {
deliveryAddress: formValue.deliveryAddress,
deliveryMode: formValue.deliveryMode,
deliveryModeOtherText: formValue.deliveryMode === 'OTHER' ? formValue.deliveryModeOtherText : null,
dispatchedOnDate: formattedDate,
locale: 'en',
dateFormat: 'dd MMMM yyyy'
};
this.clientsService.dispatchWelcomeKit(this.data.clientId, payload).subscribe({
next: () => {
this.snackBar.open('Welcome Kit Dispatched successfully', 'Close', { duration: 3000 });
this.fetchData();
},
error: () => {
this.isLoading = false;
this.snackBar.open('Failed to dispatch welcome kit', 'Close', { duration: 3000 });
}
});
}
getDisplayAddress(dispatch: any): string {
const address = dispatch.deliveryAddress || '';
const name = this.welcomeKitsData?.clientName || '';
if (name && !address.startsWith(name)) {
return `${name} — ${address}`;
}
return address;
}
downloadPdf(dispatch: any) {
const doc = new jsPDF();
const clientName = this.welcomeKitsData?.clientName || dispatch.clientName || 'N/A';
const address = this.getDisplayAddress(dispatch);
const mode =
dispatch.deliveryMode === 'OTHER'
? `${dispatch.deliveryMode} (${dispatch.deliveryModeOtherText || ''})`
: dispatch.deliveryMode;
const trackingId = dispatch.trackingId || 'N/A';
const status = dispatch.status || 'N/A';
const dispatchedOn = this.datePipe.transform(dispatch.dispatchedOnDate, 'mediumDate') || 'N/A';
const primaryColor: [number, number, number] = [
31,
73,
125
]; // Navy Blue
const grayColor: [number, number, number] = [
100,
100,
100
];
const lightGray: [number, number, number] = [
230,
230,
230
];
const pageWidth = doc.internal.pageSize.getWidth();
const pageHeight = doc.internal.pageSize.getHeight();
// --- 1. Header Banner ---
doc.setFillColor(...primaryColor);
doc.rect(0, 0, pageWidth, 40, 'F');
// Decorative circles
doc.setFillColor(255, 255, 255);
doc.circle(pageWidth - 20, 15, 3, 'F');
doc.circle(pageWidth - 30, 25, 2, 'F');
doc.circle(pageWidth - 10, 30, 1.5, 'F');
doc.setFontSize(28);
doc.setFont('helvetica', 'bold');
doc.setTextColor(255, 255, 255);
doc.text('CBSNexora', 20, 22);
doc.setFontSize(11);
doc.setFont('helvetica', 'normal');
doc.text('Core Banking Solution', 22, 30);
// --- 2. Title ---
let currentY = 55;
doc.setFontSize(16);
doc.setFont('helvetica', 'bold');
doc.setTextColor(...primaryColor);
doc.text('Welcome Kit Dispatch Confirmation', pageWidth / 2, currentY, { align: 'center' });
currentY += 12;
// --- 3. Date Line ---
doc.setFontSize(11);
doc.setFont('helvetica', 'normal');
doc.setTextColor(0, 0, 0);
const today = this.datePipe.transform(new Date(), 'longDate');
doc.text(`Date: ${today}`, pageWidth - 20, currentY, { align: 'right' });
currentY += 15;
// --- 4. Greeting ---
doc.text(`Dear ${clientName},`, 20, currentY);
currentY += 10;
// --- 5. Opening welcome paragraph ---
const p1 =
'Welcome to the CBSNexora family! We are delighted to have you on board as a valued client. As part of our onboarding process, we have dispatched your Welcome Kit to your registered address, which includes your account documentation and essential banking materials to help you get started with us.';
const splitP1 = doc.splitTextToSize(p1, pageWidth - 40);
doc.text(splitP1, 20, currentY);
currentY += splitP1.length * 6 + 8;
// --- 6. Short paragraph about the company ---
const p2 =
'CBSNexora is committed to providing secure, reliable, and customer-first banking services. Our team is always available to assist you with any banking needs, from account management to digital services, ensuring a seamless banking experience.';
const splitP2 = doc.splitTextToSize(p2, pageWidth - 40);
doc.text(splitP2, 20, currentY);
currentY += splitP2.length * 6 + 8;
// --- 7. Transition line before details ---
doc.text('Please find your Welcome Kit dispatch details below for your reference:', 20, currentY);
currentY += 8;
// --- 8. Details Section ---
autoTable(doc, {
startY: currentY,
theme: 'plain',
margin: { left: 20, right: 20 },
styles: { fontSize: 11, cellPadding: 6, textColor: [
0,
0,
0
] },
columnStyles: {
0: { fontStyle: 'bold', cellWidth: 50 },
1: { cellWidth: 'auto' }
},
didDrawCell: (data) => {
if (data.row.index >= 0) {
doc.setDrawColor(...lightGray);
doc.setLineWidth(0.2);
doc.line(
data.cell.x,
data.cell.y + data.cell.height,
data.cell.x + data.cell.width,
data.cell.y + data.cell.height
);
}
},
body: [
[
'Delivery Address',
address
],
[
'Delivery Mode',
mode
],
[
'Tracking ID',
trackingId
],
[
'Status',
status
],
[
'Dispatched On',
dispatchedOn
]
]
});
// --- 9. Closing paragraph ---
currentY = (doc as any).lastAutoTable.finalY + 12;
const p3 =
'Should you have any questions about your Welcome Kit or need any assistance, please do not hesitate to reach out to our support team. We look forward to serving you and building a long-term relationship with you.';
const splitP3 = doc.splitTextToSize(p3, pageWidth - 40);
doc.text(splitP3, 20, currentY);
currentY += splitP3.length * 6 + 12;
// --- 10. Sign-off ---
doc.text('Warm regards,', 20, currentY);
currentY += 7;
doc.setFont('helvetica', 'bold');
doc.text('CBSNexora Operations Team', 20, currentY);
// --- 11. Footer ---
// Ensure footer doesn't float too high on short pages, but pushes down if content goes long
let footerY = currentY + 30;
if (footerY < pageHeight - 30) {
footerY = pageHeight - 30;
}
doc.setDrawColor(...primaryColor);
doc.setLineWidth(0.3);
doc.line(20, footerY - 5, pageWidth - 20, footerY - 5);
doc.setFontSize(9);
doc.setFont('helvetica', 'italic');
doc.setTextColor(...grayColor);
const timestamp = this.datePipe.transform(new Date(), 'medium');
doc.text(`This is a system-generated document. Generated on: ${timestamp}`, 20, footerY + 2);
doc.save(`WelcomeKit_Dispatch_${trackingId !== 'N/A' ? trackingId : new Date().getTime()}.pdf`);
}
}
@@ -0,0 +1,428 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="f121">
@if (loading) {
<div class="f121__loading">
<mat-spinner [diameter]="36"></mat-spinner>
<span>Reading the customer's deposit ledger…</span>
</div>
} @else if (loadError) {
<div class="f121__note f121__note--warn">
<mat-icon>error_outline</mat-icon>
<div>Could not load the customer's accounts. Try again, or check the deposit service.</div>
</div>
} @else {
<!-- ── Header ── -->
<div class="f121__head">
<div>
<h3 class="f121__title">Form 121 &amp; TDS on deposit interest</h3>
<div class="f121__sub">
Tax deducted at source under {{ statute.section }} on fixed and recurring deposit interest.
</div>
</div>
<div class="f121__controls">
@if (availableFys.length) {
<label class="f121__field">
<span>Financial year</span>
<select [(ngModel)]="fy" (ngModelChange)="recompute()">
@for (year of availableFys; track year) {
<option [value]="year">FY {{ year }}</option>
}
</select>
</label>
}
<div class="f121__field">
<span>Settlement</span>
<div
class="f121__mode"
[matTooltip]="'Set by the deposit product\'s interest-posting frequency: tax falls due when interest is credited, so the branch cannot defer it. Not a customer preference.'"
>
<mat-icon>schedule</mat-icon>
{{ mode === 'QUARTERLY' ? 'Quarterly' : 'At year end' }}
</div>
</div>
</div>
</div>
@if (!schedule) {
<div class="f121__note">
<mat-icon>info_outline</mat-icon>
<div>
@if (!dateOfBirth) {
Date of birth is not on record, so the senior-citizen threshold cannot be determined. Add it on the Personal
Data tab.
} @else {
No interest has been credited to this customer's deposits yet.
}
</div>
</div>
} @else {
<!-- ── KPI strip ── -->
<div class="f121__kpis">
<div class="f121__kpi">
<div class="f121__kpi-n">₹{{ schedule.totalInterest | number: '1.0-0' }}</div>
<div class="f121__kpi-l">Interest earned (FY {{ schedule.fy }})</div>
</div>
<div class="f121__kpi" [class.f121__kpi--warn]="actualTds > 0">
<div class="f121__kpi-n">₹{{ actualTds | number: '1.0-0' }}</div>
<div class="f121__kpi-l">TDS deducted</div>
</div>
<div class="f121__kpi">
<div class="f121__kpi-n">₹{{ schedule.threshold | number: '1.0-0' }}</div>
<div class="f121__kpi-l">Threshold ({{ schedule.senior ? 'senior citizen' : 'general' }})</div>
</div>
@if (thresholdCrossed) {
<div class="f121__kpi f121__kpi--warn">
<div class="f121__kpi-n">Crossed</div>
<div class="f121__kpi-l">TDS applies this year</div>
</div>
} @else {
<div class="f121__kpi f121__kpi--ok">
<div class="f121__kpi-n">₹{{ headroom | number: '1.0-0' }}</div>
<div class="f121__kpi-l">More interest before TDS starts</div>
</div>
}
@if (tdsSaved > 0) {
<div class="f121__kpi f121__kpi--ok">
<div class="f121__kpi-n">₹{{ tdsSaved | number: '1.0-0' }}</div>
<div class="f121__kpi-l">Saved by Form 121</div>
</div>
}
</div>
<!-- ── Alerts ── -->
@if (variance !== 0) {
<div class="f121__note f121__note--warn">
<mat-icon>warning_amber</mat-icon>
<div>
<strong>Ledger and computed tax differ by ₹{{ variance | number: '1.0-0' }}.</strong>
The ledger shows ₹{{ actualTds | number: '1.0-0' }} withheld; {{ statute.section }} on this year's interest
comes to ₹{{ schedule.totalTds | number: '1.0-0' }}. Investigate before the quarterly return is filed.
</div>
</div>
}
@if (declarationNotAppliedToAccounts) {
<div class="f121__note f121__note--warn">
<mat-icon>warning_amber</mat-icon>
<div>
A Form 121 declaration is on record for this year, but withholding is still switched on for one or more
deposits. Turn it off on those accounts, or tax will keep being deducted.
</div>
</div>
}
@if (declarationUselessOnJointAccounts) {
<div class="f121__note f121__note--warn">
<mat-icon>warning_amber</mat-icon>
<div>
This customer has filed Form 121, but tax on
{{ jointHolderAccounts.length }} joint deposit{{ jointHolderAccounts.length === 1 ? '' : 's' }} is deducted
against the first-named holder. Their declaration does not cover those — the primary holder must file their
own.
</div>
</div>
}
@if (shouldSuggestDeclaration) {
<div class="f121__note f121__note--tip">
<mat-icon>lightbulb_outline</mat-icon>
<div>
₹{{ schedule.totalTds | number: '1.0-0' }} of tax falls due this year. If the customer's total tax liability
for the year is nil, a {{ formLabel }} declaration would stop it being deducted at all — better than
reclaiming it later as a refund.
</div>
</div>
}
@if (!panWellFormed) {
<div class="f121__note f121__note--warn">
<mat-icon>error_outline</mat-icon>
<div>
No valid PAN on record, so tax is deducted at the higher section 206AA rate of
{{ schedule.rateApplied * 100 | number: '1.0-0' }}%. A Form 121 declaration is void without a valid PAN.
</div>
</div>
}
@for (note of schedule.notes; track note) {
<div class="f121__note">
<mat-icon>info_outline</mat-icon>
<div>{{ note }}</div>
</div>
}
<!-- ── Declaration panel ── -->
<div class="f121__card">
<div class="f121__card-head">
<mat-icon>assignment_turned_in</mat-icon>
<span>Declaration</span>
</div>
<div class="f121__kv">
<div class="f121__kv-row">
<span>Status</span>
<span>
@if (declarationOnRecord) {
<span class="f121__pill f121__pill--ok">On record — {{ formLabel }}</span>
} @else {
<span class="f121__pill">Not filed</span>
}
</span>
</div>
<div class="f121__kv-row">
<span>Form &amp; threshold band</span>
<span>{{ formLabel }} · {{ bandLabel }} band · {{ age }} yrs in this FY</span>
</div>
<div class="f121__kv-row">
<span>PAN</span>
<span class="f121__mono">
{{ pan || '—' }}
@if (pan && !panWellFormed) {
<span class="f121__pill f121__pill--warn">invalid</span>
}
</span>
</div>
@if (declarationOnRecord && declarationFy) {
<div class="f121__kv-row">
<span>Declared for</span>
<span>FY {{ declarationFy }}</span>
</div>
}
@if (declarationOnRecord && !acceptanceDateKnown) {
<div class="f121__kv-row f121__kv-row--muted">
<span>Acceptance date</span>
<span>
Not captured — the declaration is treated as covering the whole year. A mid-year acceptance would change
the figures below.
</span>
</div>
}
@if (eligibility) {
@for (reason of eligibility.reasons; track reason) {
<div class="f121__kv-row f121__kv-row--warn">
<span>Eligibility</span>
<span>{{ reason }}</span>
</div>
}
@for (note of eligibility.unassessed; track note) {
<div class="f121__kv-row f121__kv-row--muted">
<span>Not assessed</span>
<span>{{ note }}</span>
</div>
}
}
</div>
</div>
<!-- ── Quarter-wise ── -->
<div class="f121__card">
<div class="f121__card-head">
<mat-icon>table_chart</mat-icon>
<span>Quarter by quarter</span>
</div>
<div class="f121__table-wrap">
<table class="f121__table">
<thead>
<tr>
<th>Quarter</th>
<th class="num">Interest</th>
<th class="num">Cumulative</th>
<th class="num">Liability to date</th>
<th class="num">TDS this quarter</th>
<th>Return due</th>
</tr>
</thead>
<tbody>
@for (line of schedule.lines; track trackLine($index, line)) {
<tr [class.f121__row--warn]="line.thresholdCrossedHere">
<td>
{{ line.label }}
@if (line.thresholdCrossedHere) {
<span class="f121__pill f121__pill--warn">threshold crossed</span>
}
@if (line.declarationActive) {
<span class="f121__pill f121__pill--ok">Form 121 active</span>
}
</td>
<td class="num">₹{{ line.interest | number: '1.0-0' }}</td>
<td class="num">₹{{ line.cumInterest | number: '1.0-0' }}</td>
<td class="num">₹{{ line.cumLiability | number: '1.0-0' }}</td>
<td class="num">
@if (line.tds !== 0) {
<strong>₹{{ line.tds | number: '1.0-0' }}</strong>
} @else {
<span class="muted">—</span>
}
</td>
<td class="muted">{{ line.dueDate }}</td>
</tr>
}
<tr class="f121__row--total">
<td><strong>Total</strong></td>
<td class="num">
<strong>₹{{ schedule.totalInterest | number: '1.0-0' }}</strong>
</td>
<td></td>
<td></td>
<td class="num">
<strong>₹{{ schedule.totalTds | number: '1.0-0' }}</strong>
</td>
<td></td>
</tr>
</tbody>
</table>
</div>
@if (schedule.shieldedInterest > 0) {
<div class="f121__foot">
₹{{ schedule.shieldedInterest | number: '1.0-0' }} was paid without deduction on the strength of the
declaration. This amount is still reportable in the quarterly {{ statute.statement }} return.
</div>
}
</div>
<!-- ── Section 1: accounts that count ── -->
<div class="f121__card">
<div class="f121__card-head">
<mat-icon>account_balance</mat-icon>
<span>Deposits counted towards this customer's TDS</span>
</div>
@if (liableAccounts.length) {
<div class="f121__table-wrap">
<table class="f121__table">
<thead>
<tr>
<th>Account</th>
<th>Type</th>
<th class="num">Interest (FY)</th>
<th class="num">TDS deducted</th>
<th>Withholding</th>
<th>Settles</th>
</tr>
</thead>
<tbody>
@for (a of liableAccounts; track trackAccount($index, a)) {
<tr>
<td>
<div class="f121__mono">{{ a.accountNo }}</div>
<div class="muted">{{ a.productName }}</div>
</td>
<td>{{ a.kind }}</td>
<td class="num">₹{{ a.interest | number: '1.0-0' }}</td>
<td class="num">₹{{ a.tdsDeducted | number: '1.0-0' }}</td>
<td>
@if (a.withHoldTax) {
<span class="f121__pill f121__pill--warn">On</span>
@if (a.taxGroup) {
<div class="muted">{{ a.taxGroup }}</div>
}
} @else {
<span class="f121__pill">Off</span>
}
</td>
<td>{{ a.mode === 'QUARTERLY' ? 'Quarterly' : 'Year end' }}</td>
</tr>
}
</tbody>
</table>
</div>
<div class="f121__foot">
Aggregated across every branch — a bank on core banking computes the deduction threshold for the bank as a
whole, not branch by branch.
</div>
} @else {
<div class="f121__empty">No fixed or recurring deposits where this customer is the deductee.</div>
}
</div>
<!-- ── Section 2: joint holdings ── -->
@if (jointHolderAccounts.length) {
<div class="f121__card">
<div class="f121__card-head">
<mat-icon>group</mat-icon>
<span>Deposits where this customer is a joint holder</span>
</div>
<div class="f121__table-wrap">
<table class="f121__table">
<thead>
<tr>
<th>Account</th>
<th>Type</th>
<th class="num">Interest (FY)</th>
<th>Deducted against</th>
</tr>
</thead>
<tbody>
@for (a of jointHolderAccounts; track trackAccount($index, a)) {
<tr>
<td>
<div class="f121__mono">{{ a.accountNo }}</div>
<div class="muted">{{ a.productName }}</div>
</td>
<td>{{ a.kind }}</td>
<td class="num">₹{{ a.interest | number: '1.0-0' }}</td>
<td class="muted">
First-named holder
@if (a.deducteeClientId) {
(client {{ a.deducteeClientId }})
}
</td>
</tr>
}
</tbody>
</table>
</div>
<div class="f121__foot">
Tax on a joint deposit is deducted against the first-named holder's PAN, so this interest is
<strong>not</strong> counted in the figures above and does not use up this customer's threshold.
</div>
</div>
}
<!-- ── Section 3: exempt accounts ── -->
@if (exemptAccounts.length) {
<div class="f121__card">
<div class="f121__card-head">
<mat-icon>savings</mat-icon>
<span>Savings and current accounts</span>
</div>
<div class="f121__table-wrap">
<table class="f121__table">
<thead>
<tr>
<th>Account</th>
<th>Type</th>
<th class="num">Interest (FY)</th>
<th>TDS</th>
</tr>
</thead>
<tbody>
@for (a of exemptAccounts; track trackAccount($index, a)) {
<tr>
<td>
<div class="f121__mono">{{ a.accountNo }}</div>
<div class="muted">{{ a.productName }}</div>
</td>
<td>{{ a.kind }}</td>
<td class="num">₹{{ a.interest | number: '1.0-0' }}</td>
<td><span class="f121__pill">Not applicable</span></td>
</tr>
}
</tbody>
</table>
</div>
<div class="f121__foot">
The charge applies to time deposits. Savings bank interest is outside it and is excluded from the threshold
— the customer claims the 80TTA or 80TTB deduction on it in their own return instead.
</div>
</div>
}
}
}
</div>
@@ -0,0 +1,334 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
.f121 {
--f121-line: #e5e7eb;
--f121-bg: #f9fafb;
--f121-ink: #111827;
--f121-muted: #6b7280;
--f121-blue: #1d4ed8;
--f121-green: #15803d;
--f121-amber: #b45309;
padding: 16px;
font-size: 13px;
color: var(--f121-ink);
}
.f121__loading {
display: flex;
align-items: center;
gap: 12px;
padding: 40px 16px;
color: var(--f121-muted);
}
/* ── Header ── */
.f121__head {
display: flex;
flex-wrap: wrap;
gap: 16px;
align-items: flex-end;
justify-content: space-between;
margin-bottom: 16px;
}
.f121__title {
margin: 0;
font-size: 16px;
font-weight: 700;
}
.f121__sub {
margin-top: 4px;
color: var(--f121-muted);
font-size: 12px;
}
.f121__controls {
display: flex;
gap: 16px;
align-items: flex-end;
}
.f121__field {
display: flex;
flex-direction: column;
gap: 4px;
> span {
font-size: 10.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--f121-muted);
}
select {
padding: 7px 10px;
border: 1px solid var(--f121-line);
border-radius: 6px;
background: #fff;
font-size: 13px;
color: inherit;
}
}
.f121__mode {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 10px;
border: 1px solid var(--f121-line);
border-radius: 6px;
background: var(--f121-bg);
font-weight: 600;
cursor: help;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
color: var(--f121-muted);
}
}
/* ── KPI strip ── */
.f121__kpis {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 10px;
margin-bottom: 16px;
}
.f121__kpi {
padding: 12px 14px;
border: 1px solid var(--f121-line);
border-left: 3px solid var(--f121-blue);
border-radius: 8px;
background: #fff;
&--ok {
border-left-color: var(--f121-green);
}
&--warn {
border-left-color: var(--f121-amber);
}
}
.f121__kpi-n {
font-size: 19px;
font-weight: 800;
font-variant-numeric: tabular-nums;
}
.f121__kpi-l {
margin-top: 2px;
font-size: 11px;
color: var(--f121-muted);
line-height: 1.35;
}
/* ── Notes ── */
.f121__note {
display: flex;
gap: 10px;
align-items: flex-start;
margin-bottom: 12px;
padding: 11px 13px;
border-radius: 8px;
border-left: 3px solid var(--f121-blue);
background: color-mix(in srgb, var(--f121-blue) 6%, transparent);
line-height: 1.5;
mat-icon {
flex-shrink: 0;
font-size: 18px;
width: 18px;
height: 18px;
color: var(--f121-blue);
}
&--warn {
border-left-color: var(--f121-amber);
background: color-mix(in srgb, var(--f121-amber) 8%, transparent);
mat-icon {
color: var(--f121-amber);
}
}
&--tip {
border-left-color: var(--f121-green);
background: color-mix(in srgb, var(--f121-green) 7%, transparent);
mat-icon {
color: var(--f121-green);
}
}
}
/* ── Cards ── */
.f121__card {
margin-bottom: 16px;
border: 1px solid var(--f121-line);
border-radius: 10px;
background: #fff;
overflow: hidden;
}
.f121__card-head {
display: flex;
align-items: center;
gap: 8px;
padding: 11px 14px;
background: var(--f121-bg);
border-bottom: 1px solid var(--f121-line);
font-weight: 700;
font-size: 12.5px;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
color: var(--f121-muted);
}
}
.f121__foot {
padding: 10px 14px;
border-top: 1px solid var(--f121-line);
background: var(--f121-bg);
color: var(--f121-muted);
font-size: 11.5px;
line-height: 1.5;
}
.f121__empty {
padding: 20px 14px;
color: var(--f121-muted);
text-align: center;
}
/* ── Key/value list ── */
.f121__kv-row {
display: grid;
grid-template-columns: 190px 1fr;
gap: 12px;
padding: 10px 14px;
border-bottom: 1px solid #f3f4f6;
line-height: 1.5;
&:last-child {
border-bottom: 0;
}
> span:first-child {
color: var(--f121-muted);
font-weight: 600;
}
&--muted > span:last-child {
color: var(--f121-muted);
font-size: 12px;
}
&--warn > span:last-child {
color: var(--f121-amber);
}
}
/* ── Tables ── */
.f121__table-wrap {
overflow-x: auto;
}
.f121__table {
width: 100%;
border-collapse: collapse;
font-size: 12.5px;
th {
text-align: left;
padding: 10px 14px;
background: #fff;
border-bottom: 1px solid var(--f121-line);
color: var(--f121-muted);
font-weight: 700;
font-size: 10.5px;
text-transform: uppercase;
letter-spacing: 0.04em;
white-space: nowrap;
}
td {
padding: 10px 14px;
border-bottom: 1px solid #f3f4f6;
vertical-align: top;
}
tr:last-child td {
border-bottom: 0;
}
.num {
text-align: right;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.muted {
color: var(--f121-muted);
font-size: 11.5px;
}
}
.f121__row--warn > td {
background: color-mix(in srgb, var(--f121-amber) 7%, transparent);
}
.f121__row--total > td {
background: var(--f121-bg);
border-top: 1px solid var(--f121-line);
}
/* ── Bits ── */
.f121__pill {
display: inline-flex;
align-items: center;
padding: 2px 9px;
border-radius: 999px;
border: 1px solid var(--f121-line);
background: var(--f121-bg);
color: var(--f121-muted);
font-size: 10.5px;
font-weight: 700;
white-space: nowrap;
& + & {
margin-left: 5px;
}
&--ok {
border-color: color-mix(in srgb, var(--f121-green) 35%, transparent);
background: color-mix(in srgb, var(--f121-green) 10%, transparent);
color: var(--f121-green);
}
&--warn {
border-color: color-mix(in srgb, var(--f121-amber) 35%, transparent);
background: color-mix(in srgb, var(--f121-amber) 12%, transparent);
color: var(--f121-amber);
}
}
.f121__mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-weight: 600;
}
@@ -0,0 +1,500 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/** Angular Imports */
import { Component, OnDestroy, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute } from '@angular/router';
import { MatIcon } from '@angular/material/icon';
import { MatProgressSpinner } from '@angular/material/progress-spinner';
import { MatTooltip } from '@angular/material/tooltip';
import { Observable, Subject, forkJoin, of } from 'rxjs';
import { catchError, switchMap, takeUntil } from 'rxjs/operators';
/** Custom Services */
import { ClientsService } from '../../clients.service';
import { SavingsService } from 'app/savings/savings.service';
import { FixedDepositsService } from 'app/deposits/fixed-deposits/fixed-deposits.service';
import { RecurringDepositsService } from 'app/deposits/recurring-deposits/recurring-deposits.service';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Domain logic */
import {
Declaration,
DeclarationForm,
EligibilityResult,
FY_TAX_CONFIG,
InterestCredit,
TdsAccountInput,
TdsMode,
TdsQuarterLine,
TdsSchedule,
ageInFy,
buildSchedule,
checkEligibility,
collectTdsCredits,
declarationFormFor,
formKindFor,
fyOf,
isTdsApplicable,
isWellFormedPan,
normaliseFy,
tdsDeducteeOf,
tdsModeFor
} from 'app/letter-mgmt/certificates/form-15gh/tds-15gh.compute';
/** One of the customer's interest-bearing accounts, with its FY figures. */
export interface Form121AccountRow {
accountNo: string;
productName: string;
/** 'Fixed Deposit', 'Recurring Deposit' or 'Savings'. */
kind: string;
/** Interest credited to this account inside the selected FY. */
interest: number;
/** Tax Fineract has actually withheld on this account inside the FY. */
tdsDeducted: number;
/** Whether Fineract is currently withholding tax on this account. */
withHoldTax: boolean;
taxGroup: string | null;
/** When tax on this account settles — from the product, not a preference. */
mode: TdsMode;
/** Interest here attracts TDS at all (a time deposit, not a savings account). */
tdsApplicable: boolean;
/** This customer is the deductee — false when they are a joint holder only. */
isDeductee: boolean;
/** Who the tax is deducted against, when it is not this customer. */
deducteeClientId: number | null;
}
/** Fineract hands dates back as [yyyy, m, d]; normalise to ISO. */
function toIso(value: unknown): string | null {
if (Array.isArray(value) && value.length >= 3) {
const [
y,
m,
d
] = value as number[];
return `${y}-${`${m}`.padStart(2, '0')}-${`${d}`.padStart(2, '0')}`;
}
if (typeof value === 'string' && value.trim()) {
const parsed = new Date(value);
if (!isNaN(parsed.getTime())) {
return parsed.toISOString().slice(0, 10);
}
}
return null;
}
@Component({
selector: 'mifosx-form121-tab',
templateUrl: './form121-tab.component.html',
styleUrls: ['./form121-tab.component.scss'],
standalone: true,
imports: [
...STANDALONE_SHARED_IMPORTS,
CommonModule,
FormsModule,
MatIcon,
MatProgressSpinner,
MatTooltip
]
})
export class Form121TabComponent implements OnInit, OnDestroy {
private route = inject(ActivatedRoute);
private clientsService = inject(ClientsService);
private savingsService = inject(SavingsService);
private fixedDepositsService = inject(FixedDepositsService);
private recurringDepositsService = inject(RecurringDepositsService);
private destroy$ = new Subject<void>();
clientId = '';
loading = true;
loadError = false;
// ── Customer ──
clientName = '';
dateOfBirth = '';
pan = '';
panWellFormed = false;
// ── Declaration on record ──
declarationOnRecord = false;
/**
* The form actually in force for the selected year. From FY 2026-27 a single
* Form 121 replaced the 15G/15H split, so this is derived from the year rather
* than from the payee's age.
*/
declarationForm: DeclarationForm = '121';
/** Kind recorded at KYC, meaningful only for a pre-2026 year. */
declarationKind: '15G' | '15H' = '15G';
declarationFy = '';
/**
* The KYC step records only the year, not the date the branch accepted the
* declaration, so it has to be treated as covering the whole year. Surfaced on
* screen rather than hidden — a mid-year acceptance would change the figures.
*/
acceptanceDateKnown = false;
eligibility: EligibilityResult | null = null;
// ── Selection ──
availableFys: string[] = [];
fy = '';
// ── Computed ──
accounts: Form121AccountRow[] = [];
schedule: TdsSchedule | null = null;
actualTds = 0;
tdsSaved = 0;
/** Raw account inputs, kept so the year selector can recompute cheaply. */
private accountInputs: (TdsAccountInput & { meta: Form121AccountRow })[] = [];
/** Withheld-tax postings by FY, from the ledger. */
private actualTdsByFy = new Map<string, number>();
ngOnInit(): void {
this.route.parent.params.pipe(takeUntil(this.destroy$)).subscribe((params: any) => {
this.clientId = params['clientId'];
this.load();
});
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
// ────────────────────────────── Loading ──────────────────────────────
private load(): void {
this.loading = true;
this.loadError = false;
forkJoin({
client: this.clientsService.getClientData(this.clientId, true).pipe(catchError(() => of(null))),
kyc: this.clientsService.getClientKyc(this.clientId, true).pipe(catchError(() => of(null))),
accounts: this.clientsService.getClientAccountData(this.clientId).pipe(catchError(() => of(null)))
})
.pipe(
takeUntil(this.destroy$),
switchMap(({ client, kyc, accounts }) => {
this.applyClient(client);
this.applyKyc(kyc);
const deposits = this.depositAccountsOf(accounts);
return deposits.length ? forkJoin(deposits.map((a) => this.fetchAccount(a))) : of([] as any[]);
}),
catchError(() => {
this.loadError = true;
return of([] as any[]);
})
)
.subscribe((details: any[]) => {
this.loading = false;
this.ingest(details.filter(Boolean));
this.recompute();
});
}
private applyClient(client: any): void {
this.clientName = client?.displayName ?? '';
this.dateOfBirth = toIso(client?.dateOfBirth) ?? '';
}
private applyKyc(kyc: any): void {
this.pan = (kyc?.panNumber ?? '').toUpperCase();
this.panWellFormed = isWellFormedPan(this.pan);
this.declarationOnRecord = !!kyc?.form15Applicable;
this.declarationKind = kyc?.form15Type === '15H' ? '15H' : '15G';
this.declarationForm = this.fy ? declarationFormFor(this.fy, this.dateOfBirth) : '121';
this.declarationFy = kyc?.form15FY ?? '';
this.acceptanceDateKnown = false;
}
/** Savings, FD and RD accounts — everything that can credit interest. */
private depositAccountsOf(accounts: any): any[] {
return (accounts?.savingsAccounts ?? []).filter(
(a: any) => a.status?.active || a.status?.matured || a.status?.closed
);
}
private fetchAccount(a: any): Observable<any> {
const id = `${a.id}`;
const code = a.depositType?.code ?? '';
let call: Observable<any>;
if (code.includes('fixedDeposit')) {
call = this.fixedDepositsService.getFixedDepositsAccountData(id);
} else if (code.includes('recurringDeposit')) {
call = this.recurringDepositsService.getRecurringDepositsAccountData(id);
} else {
call = this.savingsService.getSavingsAccountData(id);
}
// One unreadable account must not blank the whole tab.
return call.pipe(catchError(() => of(null)));
}
/**
* Turns each account payload into an engine input.
*
* Joint and minor attributes are read defensively: they are captured on the
* account stepper but may not surface on every read endpoint, and an absent
* field must default to a single-name account rather than throw.
*/
private ingest(details: any[]): void {
this.accountInputs = [];
this.actualTdsByFy = new Map<string, number>();
const fys = new Set<string>();
const clientIdNum = Number(this.clientId);
for (const d of details) {
const code: string = d.depositType?.code ?? '';
const extra = d.extraAttributes ?? d.externalAttributes ?? d;
const ref = {
id: d.id,
accountNo: d.accountNo ?? `${d.id}`,
depositTypeCode: code,
accountStatus: extra.accountStatus ?? d.accountStatus ?? 'SINGLE',
clientId: d.clientId ?? clientIdNum,
primaryClientId: extra.primaryClientId ?? d.primaryClientId ?? null,
guardianClientId: extra.guardianClientId ?? d.guardianClientId ?? null,
jointHolders: extra.jointHolders ?? d.jointHolders ?? [],
interestPostingPeriodType: d.interestPostingPeriodType ?? null
};
const credits: InterestCredit[] = [];
let tdsDeducted = 0;
for (const t of d.transactions ?? []) {
if (t.reversed || t.isReversal) {
continue;
}
const iso = toIso(t.date);
if (!iso) {
continue;
}
fys.add(fyOf(iso));
if (t.transactionType?.interestPosting) {
credits.push({ date: iso, amount: t.amount ?? 0, accountNo: ref.accountNo });
} else if (t.transactionType?.withholdTax) {
const amount = t.amount ?? 0;
tdsDeducted += amount;
const txnFy = fyOf(iso);
this.actualTdsByFy.set(txnFy, (this.actualTdsByFy.get(txnFy) ?? 0) + amount);
}
}
const deductee = tdsDeducteeOf(ref);
const meta: Form121AccountRow = {
accountNo: ref.accountNo,
productName: d.savingsProductName ?? d.depositProductName ?? d.productName ?? '—',
kind: code.includes('fixedDeposit')
? 'Fixed Deposit'
: code.includes('recurringDeposit')
? 'Recurring Deposit'
: 'Savings',
interest: 0,
tdsDeducted,
withHoldTax: !!d.withHoldTax,
taxGroup: d.taxGroup?.name ?? null,
mode: tdsModeFor(ref),
tdsApplicable: isTdsApplicable(ref),
isDeductee: deductee === clientIdNum,
deducteeClientId: deductee
};
this.accountInputs.push({ ...ref, credits, meta });
}
this.availableFys = [...fys].sort().reverse();
if (!this.availableFys.includes(this.fy)) {
this.fy = this.availableFys[0] ?? (this.declarationFy ? normaliseFy(this.declarationFy) : '');
}
}
// ───────────────────────────── Computation ─────────────────────────────
/** Re-runs the engine for the selected year. */
recompute(): void {
this.schedule = null;
this.eligibility = null;
this.tdsSaved = 0;
this.accounts = this.accountInputs.map((a) => a.meta);
if (!this.fy) {
return;
}
const year = normaliseFy(this.fy);
// Per-account interest for the selected year, for the three lists.
for (const a of this.accountInputs) {
a.meta.interest = a.credits.filter((c) => fyOf(c.date) === year).reduce((s, c) => s + c.amount, 0);
}
this.actualTds = this.actualTdsByFy.get(year) ?? 0;
if (!this.dateOfBirth) {
return;
}
// The one place rules 8 to 12 are applied: savings interest dropped, joint
// deposits attributed to the primary holder, every branch aggregated.
const credits = collectTdsCredits(Number(this.clientId), this.accountInputs, year);
const ctx = { fy: year, dateOfBirth: this.dateOfBirth, pan: this.pan, resident: true };
const declaration = this.declarationForSelectedFy();
this.schedule = buildSchedule(this.overallMode, credits, { ...ctx, declaration });
if (declaration) {
const without = buildSchedule(this.overallMode, credits, { ...ctx, declaration: null });
this.tdsSaved = Math.max(0, without.totalTds - this.schedule.totalTds);
}
// The branch cannot compute income earned outside the bank, so the income
// limb is reported as unassessed rather than silently passed.
this.eligibility = checkEligibility(ctx, {
estimatedInterest: this.schedule.totalInterest,
estimatedIncome: 0,
incomeOnRecord: false
});
}
/**
* When tax settles for this customer overall.
*
* Timing is per account, but the customer needs one view. A quarterly account
* anywhere in the book means a deduction falls due at the first quarter end, so
* that is the honest headline; the per-account list carries the detail.
*/
private get overallMode(): TdsMode {
const applicable = this.accountInputs.filter((a) => a.meta.tdsApplicable && a.meta.isDeductee);
return applicable.some((a) => a.meta.mode === 'QUARTERLY') ? 'QUARTERLY' : 'ANNUAL';
}
/** The declaration, but only when it was filed for the year being shown. */
private declarationForSelectedFy(): Declaration | null {
if (!this.declarationOnRecord || !this.fy) {
return null;
}
const year = normaliseFy(this.fy);
if (this.declarationFy && normaliseFy(this.declarationFy) !== year) {
return null;
}
return {
kind: this.declarationKind,
// No acceptance date on record — treated as covering the whole year.
acceptedOn: `${year.slice(0, 4)}-04-01`,
fy: year
};
}
// ───────────────────────── Template helpers ─────────────────────────
get mode(): TdsMode {
return this.schedule?.mode ?? 'QUARTERLY';
}
get age(): number {
return this.dateOfBirth && this.fy ? ageInFy(this.dateOfBirth, this.fy) : 0;
}
get formKind(): string {
return this.dateOfBirth && this.fy ? formKindFor(this.dateOfBirth, this.fy) : '—';
}
/** The form the payee would file for the selected year. */
get formLabel(): string {
if (!this.fy) {
return '—';
}
const form = declarationFormFor(this.fy, this.dateOfBirth);
return form === '121' ? 'Form 121' : `Form ${form}`;
}
/** Which deduction trigger applies, now that the forms no longer split by age. */
get bandLabel(): string {
return this.schedule?.senior ? 'Senior citizen' : 'General';
}
/**
* Provision and return references for the selected year. Both changed on
* 01 April 2026, so the template reads them rather than hard-coding them.
*/
get statute(): { section: string; statement: string; rule: string; frequency: string } {
const cfg = this.fy ? FY_TAX_CONFIG[normaliseFy(this.fy)] : undefined;
return {
section: cfg?.interestTdsSection ?? '—',
statement: cfg?.tdsStatementForm ?? '—',
rule: cfg?.declarationRule ?? '—',
frequency: cfg?.declarationStatementFrequency === 'MONTHLY' ? 'monthly' : 'quarterly'
};
}
/** Accounts whose interest counts towards this customer's deduction aggregate. */
get liableAccounts(): Form121AccountRow[] {
return this.accounts.filter((a) => a.tdsApplicable && a.isDeductee);
}
/** Deposits where this customer is a joint holder but not the deductee. */
get jointHolderAccounts(): Form121AccountRow[] {
return this.accounts.filter((a) => a.tdsApplicable && !a.isDeductee);
}
/** Savings and current accounts — interest outside the charge on time deposits. */
get exemptAccounts(): Form121AccountRow[] {
return this.accounts.filter((a) => !a.tdsApplicable);
}
/** Engine figure less what the ledger shows was actually withheld. */
get variance(): number {
return (this.schedule?.totalTds ?? 0) - this.actualTds;
}
/** How much more interest before TDS starts. */
get headroom(): number {
if (!this.schedule) {
return 0;
}
return Math.max(0, this.schedule.threshold - this.schedule.totalInterest);
}
get thresholdCrossed(): boolean {
return !!this.schedule && this.schedule.totalInterest > this.schedule.threshold;
}
/**
* A declaration is on record but deposit accounts are still set to withhold —
* the branch has not switched them off.
*/
get declarationNotAppliedToAccounts(): boolean {
return !!this.declarationForSelectedFy() && this.liableAccounts.some((a) => a.withHoldTax);
}
/**
* The customer filed a declaration but holds joint deposits where someone else
* is the deductee — their form does nothing for those.
*/
get declarationUselessOnJointAccounts(): boolean {
return !!this.declarationForSelectedFy() && this.jointHolderAccounts.length > 0;
}
/** Worth prompting the branch to collect a declaration. */
get shouldSuggestDeclaration(): boolean {
return !this.declarationForSelectedFy() && (this.schedule?.totalTds ?? 0) > 0;
}
trackLine(_: number, line: TdsQuarterLine): number {
return line.quarter;
}
trackAccount(_: number, account: Form121AccountRow): string {
return account.accountNo;
}
}
@@ -42,6 +42,32 @@
<!-- KYC Summary (read-only when not in edit mode) -->
@if (!isEditMode && kycData) {
@if (kycData.isException && !kycData.exceptionResolved) {
<div
class="kyc-exception-banner"
style="
background: #fef3c7;
border: 1px solid #f59e0b;
border-radius: 8px;
padding: 16px;
margin-bottom: 20px;
display: flex;
align-items: flex-start;
gap: 12px;
"
>
<mat-icon style="color: #d97706; margin-top: 2px">warning</mat-icon>
<div>
<strong style="color: #92400e; font-size: 14px">KYC Exception Active</strong>
<p style="color: #b45309; font-size: 13px; margin: 4px 0 0 0">
Account is open under a temporary KYC Exception. Reason:
<strong>{{ kycData.exceptionReason || 'Other' }}</strong
>. Please contact the customer and complete verification as soon as possible.
</p>
</div>
</div>
}
<!-- Verification Table -->
<div class="kv-table">
<div class="kv-table__header">
@@ -740,6 +766,32 @@
</mat-select>
</mat-form-field>
<!-- KYC Exception Flag -->
<div
class="full-span exception-controls-row"
style="display: flex; align-items: center; gap: 20px; margin-bottom: 12px; flex-wrap: wrap"
>
<mat-checkbox formControlName="isException" style="margin-top: 8px">Mark as KYC Exception</mat-checkbox>
@if (kycForm.get('isException')?.value) {
<mat-form-field appearance="outline" style="flex: 1; min-width: 250px">
<mat-label>Exception Reasons</mat-label>
<mat-select formControlName="exceptionReason" multiple>
@if (!isNonIndividual) {
<mat-option value="PAN Card Verification Failed">PAN Card Verification Failed</mat-option>
<mat-option value="Aadhaar Verification Failed">Aadhaar Verification Failed</mat-option>
} @else {
<mat-option value="Enterprise PAN Verification Failed"
>Enterprise PAN Verification Failed</mat-option
>
<mat-option value="GSTIN Verification Failed">GSTIN Verification Failed</mat-option>
<mat-option value="CIN Verification Failed">CIN Verification Failed</mat-option>
}
</mat-select>
</mat-form-field>
}
</div>
<!-- Remarks -->
<mat-form-field appearance="outline" class="full-span">
<mat-label>{{ 'labels.inputs.Remarks' | translate }}</mat-label>
@@ -14,6 +14,7 @@ import { catchError, of, Subject, takeUntil } from 'rxjs';
/** Custom Services */
import { ClientsService } from '../../clients.service';
import { deriveKycExceptionFields } from '../../kyc-exception.util';
import { Dates } from 'app/core/utils/dates';
import { jsPDF } from 'jspdf';
import { MatIcon } from '@angular/material/icon';
@@ -121,7 +122,9 @@ export class KycTabComponent implements OnInit, OnDestroy {
'',
[Validators.pattern(/^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z]{1}[1-9A-Z]{1}Z[0-9A-Z]{1}$/)]
],
remarks: ['']
remarks: [''],
isException: [false],
exceptionReason: [[]]
});
ngOnInit(): void {
@@ -157,7 +160,9 @@ export class KycTabComponent implements OnInit, OnDestroy {
// the full 12-digit number to re-verify, so don't pre-fill.
aadhaarNumber: '',
gstinNumber: data.gstinNumber || '',
remarks: data.remarks || ''
remarks: data.remarks || '',
isException: data.isException || false,
exceptionReason: data.exceptionReason ? data.exceptionReason.split(', ') : []
});
}
});
@@ -195,6 +200,28 @@ export class KycTabComponent implements OnInit, OnDestroy {
}
}
/**
* Exception columns derived from verification status — see `deriveKycExceptionFields()`.
*
* A field counts as verified if it was verified in this session OR is already verified on the
* stored record; either way the customer has cleared that check. Verifying one of two
* outstanding items narrows the exception to the remaining one rather than closing it.
*/
private buildExceptionFields() {
const panVerified = this.verificationStatus.pan === 'VERIFIED' || this.kycData?.panVerified === true;
const aadhaarVerified = this.verificationStatus.aadhaar === 'VERIFIED' || this.kycData?.aadhaarVerified === true;
return deriveKycExceptionFields({
panVerified,
aadhaarVerified,
aadhaarApplicable: !this.isNonIndividual,
hadException: this.kycData?.isException === true,
previousReason: this.kycData?.exceptionReason || null,
manualReasons: Array.isArray(this.kycForm.value.exceptionReason) ? this.kycForm.value.exceptionReason : [],
todayFormatted: this.dateUtils.formatDate(new Date(), 'dd MMMM yyyy')
});
}
saveKyc(): void {
if (this.kycForm.invalid) {
return;
@@ -210,7 +237,10 @@ export class KycTabComponent implements OnInit, OnDestroy {
panNumber: this.kycForm.value.panNumber || null,
aadhaarNumberMasked: aadhaarMasked,
gstinNumber: this.kycForm.value.gstinNumber || null,
remarks: this.kycForm.value.remarks || null
remarks: this.kycForm.value.remarks || null,
// dateFormat is required so the backend can parse exceptionResolvedOn when an exception closes.
dateFormat: 'dd MMMM yyyy',
...this.buildExceptionFields()
};
if (!this.kycId) {
@@ -244,6 +274,38 @@ export class KycTabComponent implements OnInit, OnDestroy {
});
}
private saveKycBeforeVerify(callback: () => void, field: 'pan' | 'aadhaar' | 'gstin'): void {
if (this.isEditMode) {
const aadhaarRaw = this.kycForm.value.aadhaarNumber || '';
const aadhaarMasked = aadhaarRaw.length === 12 ? 'XXXXXXXX' + aadhaarRaw.slice(-4) : aadhaarRaw || null;
const payload: any = {
locale: 'en',
panNumber: this.kycForm.value.panNumber || null,
aadhaarNumberMasked: aadhaarMasked,
gstinNumber: this.kycForm.value.gstinNumber || null,
remarks: this.kycForm.value.remarks || null,
riskCategory: this.kycForm.value.riskCategory || 1,
dateFormat: 'dd MMMM yyyy',
...this.buildExceptionFields()
};
this.clientsService.updateClientKyc(this.clientId, this.kycId, payload).subscribe({
next: () => {
this.loadKycData();
callback();
},
error: (err: any) => {
this.verifying[field] = false;
this.verificationStatus[field] = 'FAILED';
this.verificationError[field] =
err?.error?.defaultUserMessage || `Failed to update ${field.toUpperCase()} number before verification.`;
}
});
} else {
callback();
}
}
verifyPAN(): void {
if (!this.kycId) {
this.verificationError.pan = 'KYC record not found. Save KYC details first.';
@@ -254,31 +316,33 @@ export class KycTabComponent implements OnInit, OnDestroy {
this.verificationError.pan = null;
this.verificationDetails.pan = null;
this.clientsService
.verifyPAN(this.clientId, this.kycId)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (response: any) => {
this.verifying.pan = false;
if (response?.changes?.panVerified) {
this.verificationStatus.pan = 'VERIFIED';
this.verificationDetails.pan = response.changes;
this.loadKycData();
} else {
this.saveKycBeforeVerify(() => {
this.clientsService
.verifyPAN(this.clientId, this.kycId)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (response: any) => {
this.verifying.pan = false;
if (response?.changes?.panVerified) {
this.verificationStatus.pan = 'VERIFIED';
this.verificationDetails.pan = response.changes;
this.loadKycData();
} else {
this.verificationStatus.pan = 'FAILED';
this.verificationError.pan = 'PAN verification failed.';
}
},
error: (err: any) => {
this.verifying.pan = false;
this.verificationStatus.pan = 'FAILED';
this.verificationError.pan = 'PAN verification failed.';
this.verificationError.pan =
err?.error?.developerMessage ||
err?.error?.defaultUserMessage ||
err?.message ||
'PAN verification failed. Please try again.';
}
},
error: (err: any) => {
this.verifying.pan = false;
this.verificationStatus.pan = 'FAILED';
this.verificationError.pan =
err?.error?.developerMessage ||
err?.error?.defaultUserMessage ||
err?.message ||
'PAN verification failed. Please try again.';
}
});
});
}, 'pan');
}
/** Show Aadhaar input in summary view for re-verification */
@@ -305,31 +369,33 @@ export class KycTabComponent implements OnInit, OnDestroy {
this.aadhaarOtpSent = false;
this.aadhaarOtp = '';
this.clientsService
.sendAadhaarOtp(this.clientId, this.kycId, aadhaar)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (response: any) => {
this.verifying.aadhaar = false;
const refNumber =
response?.changes?.aadhaarRefNumber ||
response?.aadhaarRefNumber ||
response?.changes?.referenceNumber ||
response?.resourceId;
this.aadhaarOtpSent = true;
this.aadhaarOtpReferenceId = refNumber || null;
this.verificationStatus.aadhaar = 'PENDING';
},
error: (err: any) => {
this.verifying.aadhaar = false;
this.verificationStatus.aadhaar = 'FAILED';
this.verificationError.aadhaar =
err?.error?.developerMessage ||
err?.error?.defaultUserMessage ||
err?.message ||
'Failed to send OTP. Please try again.';
}
});
this.saveKycBeforeVerify(() => {
this.clientsService
.sendAadhaarOtp(this.clientId, this.kycId, aadhaar)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (response: any) => {
this.verifying.aadhaar = false;
const refNumber =
response?.changes?.aadhaarRefNumber ||
response?.aadhaarRefNumber ||
response?.changes?.referenceNumber ||
response?.resourceId;
this.aadhaarOtpSent = true;
this.aadhaarOtpReferenceId = refNumber || null;
this.verificationStatus.aadhaar = 'PENDING';
},
error: (err: any) => {
this.verifying.aadhaar = false;
this.verificationStatus.aadhaar = 'FAILED';
this.verificationError.aadhaar =
err?.error?.developerMessage ||
err?.error?.defaultUserMessage ||
err?.message ||
'Failed to send OTP. Please try again.';
}
});
}, 'aadhaar');
}
/** Step 2: Verify OTP entered by user */
@@ -380,31 +446,33 @@ export class KycTabComponent implements OnInit, OnDestroy {
this.verificationError.gstin = null;
this.verificationDetails.gstin = null;
this.clientsService
.verifyGSTIN(this.clientId, this.kycId)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (response: any) => {
this.verifying.gstin = false;
if (response?.changes?.gstinVerified) {
this.verificationStatus.gstin = 'VERIFIED';
this.verificationDetails.gstin = response.changes;
this.loadKycData();
} else {
this.saveKycBeforeVerify(() => {
this.clientsService
.verifyGSTIN(this.clientId, this.kycId)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (response: any) => {
this.verifying.gstin = false;
if (response?.changes?.gstinVerified) {
this.verificationStatus.gstin = 'VERIFIED';
this.verificationDetails.gstin = response.changes;
this.loadKycData();
} else {
this.verificationStatus.gstin = 'FAILED';
this.verificationError.gstin = 'GSTIN verification failed.';
}
},
error: (err: any) => {
this.verifying.gstin = false;
this.verificationStatus.gstin = 'FAILED';
this.verificationError.gstin = 'GSTIN verification failed.';
this.verificationError.gstin =
err?.error?.developerMessage ||
err?.error?.defaultUserMessage ||
err?.message ||
'GSTIN verification failed. Please try again.';
}
},
error: (err: any) => {
this.verifying.gstin = false;
this.verificationStatus.gstin = 'FAILED';
this.verificationError.gstin =
err?.error?.developerMessage ||
err?.error?.defaultUserMessage ||
err?.message ||
'GSTIN verification failed. Please try again.';
}
});
});
}, 'gstin');
}
formatDate(date: any): string {
@@ -105,7 +105,7 @@
[class.pd-verify-badge--verified]="!clientViewData.isMinor"
[class.pd-verify-badge--pending]="clientViewData.isMinor"
>
{{ clientViewData.isMinor ? 'Yes (Age < 10)' : 'No' }}
{{ clientViewData.isMinor ? 'Yes (Age < 18)' : 'No' }}
</span>
</div>
</div>
+3 -5
View File
@@ -98,12 +98,10 @@
</td>
</ng-container>
<!-- Account no Column -->
<!-- Client ID Column -->
<ng-container matColumnDef="accountNumber">
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'labels.inputs.Account No' | translate }}</th>
<td mat-cell *matCellDef="let row" class="cell-mono">
<mifosx-account-number display="left" accountNo="{{ row.accountNumber }}"></mifosx-account-number>
</td>
<th mat-header-cell *matHeaderCellDef mat-sort-header>Client ID</th>
<td mat-cell *matCellDef="let row" class="cell-mono">{{ row.id }}</td>
</ng-container>
<!-- CIF Number Column -->
+13 -4
View File
@@ -137,7 +137,16 @@ export class ClientsComponent implements OnInit {
private getClients() {
this.isLoading = true;
this.clientService
.searchByText(this.filterText, this.currentPage, this.pageSize, this.sortAttribute, this.sortDirection)
.searchByText(
this.filterText,
this.currentPage,
this.pageSize,
this.sortAttribute,
this.sortDirection,
false,
'',
true
)
.subscribe(
(data: any) => {
this.allRows = data.content || [];
@@ -166,7 +175,7 @@ export class ClientsComponent implements OnInit {
// Use individual subscriptions instead of forkJoin to avoid EMPTY observable issues
clients.forEach((client, i) => {
this.clientService
.getClientData(client.id?.toString())
.getClientData(client.id?.toString(), true)
.pipe(catchError(() => of(null)))
.subscribe((data: any) => {
if (data?.legalForm) {
@@ -176,7 +185,7 @@ export class ClientsComponent implements OnInit {
});
this.clientService
.getClientKyc(client.id?.toString())
.getClientKyc(client.id?.toString(), true)
.pipe(catchError(() => of(null)))
.subscribe((kyc: any) => {
if (kyc) {
@@ -188,7 +197,7 @@ export class ClientsComponent implements OnInit {
});
this.clientService
.getClientAddressData(client.id?.toString())
.getClientAddressData(client.id?.toString(), true)
.pipe(catchError(() => of(null)))
.subscribe((addr: any) => {
if (addr && Array.isArray(addr) && addr.length > 0) {
+2
View File
@@ -62,6 +62,7 @@ import { ClientFamilyMembersStepComponent } from './client-stepper/client-family
import { ClientPreviewStepComponent } from './client-stepper/client-preview-step/client-preview-step.component';
import { ClientAddressStepComponent } from './client-stepper/client-address-step/client-address-step.component';
import { ClientFamilyMemberDialogComponent } from './client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component';
import { ClientFamilyMemberChildDialogComponent } from './client-stepper/client-family-members-step/client-family-member-child-dialog/client-family-member-child-dialog.component';
import { CaptureImageDialogComponent } from './clients-view/custom-dialogs/capture-image-dialog/capture-image-dialog.component';
import { AddClientCollateralComponent } from './clients-view/client-actions/add-client-collateral/add-client-collateral.component';
import { ClientDatatableStepComponent } from './client-stepper/client-datatable-step/client-datatable-step.component';
@@ -127,6 +128,7 @@ import { Form60TabComponent } from './clients-view/form60-tab/form60-tab.compone
ClientPreviewStepComponent,
ClientAddressStepComponent,
ClientFamilyMemberDialogComponent,
ClientFamilyMemberChildDialogComponent,
CaptureImageDialogComponent,
AddClientCollateralComponent,
ClientDatatableStepComponent,
+116 -9
View File
@@ -8,7 +8,8 @@
/** Angular Imports */
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams, HttpBackend, HttpHeaders } from '@angular/common/http';
import { HttpClient, HttpParams, HttpBackend, HttpHeaders, HttpContext } from '@angular/common/http';
import { SILENT_HTTP_ERROR } from 'app/core/http/silent-error.token';
/** rxjs Imports */
import { Observable, of, throwError } from 'rxjs';
@@ -66,8 +67,11 @@ export class ClientsService {
return this.http.get(`/clients/template?officeId=${officeId}&staffInSelectedOfficeOnly=true`);
}
getClientData(clientId: string) {
return this.http.get(`/clients/${clientId}`);
getClientData(clientId: string, silent = false) {
return this.http.get(
`/clients/${clientId}`,
silent ? { context: new HttpContext().set(SILENT_HTTP_ERROR, true) } : {}
);
}
createClient(client: any) {
@@ -82,6 +86,57 @@ export class ClientsService {
return this.http.delete(`/clients/${clientId}`);
}
/**
* Maker-checker for Client edits — stages the change in m_client_pending_edit instead of
* applying it immediately; a different user must call approveClientEdit() before it takes
* effect. See ClientEditApiResource on the backend.
*/
submitClientEdit(clientId: string, client: any) {
return this.http.post(`/clients/${clientId}/edit`, client);
}
getPendingClientEdit(clientId: string) {
return this.http.get(`/clients/${clientId}/edit/pending`);
}
getAllPendingClientEdits(
status?: string,
clientName?: string,
fromDate?: string,
toDate?: string,
offset = 0,
limit = 20
): Observable<any> {
let params = new HttpParams().set('offset', offset).set('limit', limit);
if (status) params = params.set('status', status);
if (clientName) params = params.set('clientName', clientName);
if (fromDate) params = params.set('fromDate', fromDate);
if (toDate) params = params.set('toDate', toDate);
return this.http.get(`/clients/edit/pending`, { params });
}
approveClientEdit(clientId: string) {
return this.http.post(`/clients/${clientId}/edit/approve`, {});
}
rejectClientEdit(clientId: string, rejectReason?: string) {
return this.http.post(`/clients/${clientId}/edit/reject`, { rejectReason: rejectReason || '' });
}
/** Fetches the exact email content (subject, body, sentAt, openedAt) for a given pending edit.
* Only populated after the Checker has approved and the send succeeded. */
getClientEditEmailPreview(clientId: string | number, pendingEditId: string | number) {
return this.http.get<{
emailSubject: string | null;
emailBody: string | null;
emailSent: boolean;
emailSentAt: string | null;
emailOpenedAt: string | null;
emailFrom: string | null;
emailTo: string | null;
}>(`/clients/${clientId}/edit/${pendingEditId}/email-preview`);
}
getClientDataAndTemplate(clientId: string) {
const httpParams = new HttpParams().set('template', 'true').set('staffInSelectedOfficeOnly', 'true');
return this.http.get(`/clients/${clientId}`, { params: httpParams });
@@ -116,8 +171,11 @@ export class ClientsService {
return this.http.get(`/clients/${clientId}/accounts`);
}
getClientAddressData(clientId: string) {
return this.http.get(`/client/${clientId}/addresses`);
getClientAddressData(clientId: string, silent = false) {
return this.http.get(
`/client/${clientId}/addresses`,
silent ? { context: new HttpContext().set(SILENT_HTTP_ERROR, true) } : {}
);
}
getClientChargesData(clientId: string) {
@@ -661,7 +719,8 @@ export class ClientsService {
sortAttribute: string = '',
sortDirection: string = '',
activeOnly: boolean = false,
legalFormType: string = ''
legalFormType: string = '',
silent: boolean = false
) {
const innerRequest: any = { text };
if (activeOnly) {
@@ -690,7 +749,11 @@ export class ClientsService {
]
};
}
return this.http.post(`/v2/clients/search`, request);
return this.http.post(
`/v2/clients/search`,
request,
silent ? { context: new HttpContext().set(SILENT_HTTP_ERROR, true) } : {}
);
}
/**
@@ -727,8 +790,11 @@ export class ClientsService {
* Gets KYC details for a client.
* @param clientId Client ID
*/
getClientKyc(clientId: string): Observable<any> {
return this.http.get(`/clients/${clientId}/kyc`);
getClientKyc(clientId: string, silent = false): Observable<any> {
return this.http.get(
`/clients/${clientId}/kyc`,
silent ? { context: new HttpContext().set(SILENT_HTTP_ERROR, true) } : {}
);
}
/**
@@ -739,6 +805,10 @@ export class ClientsService {
return this.http.get(`/clients/${clientId}/kyc/template`);
}
getClientRiskRules(): Observable<any[]> {
return this.http.get<any[]>('/kyc/risk-rules');
}
/** Fetch code values by code ID */
getCodeValuesById(codeId: number): Observable<any[]> {
return this.http.get<any[]>(`/codes/${codeId}/codevalues`).pipe(catchError(() => of([])));
@@ -894,4 +964,41 @@ export class ClientsService {
submitClientForm60(clientId: string, data: any): Observable<any> {
return this.http.post(`/clients/${clientId}/form60`, data);
}
getWelcomeKits(clientId: string | number): Observable<any> {
return this.http.get(`/clients/${clientId}/welcomekit`);
}
dispatchWelcomeKit(clientId: string | number, data: any): Observable<any> {
return this.http.post(`/clients/${clientId}/welcomekit`, data);
}
updateWelcomeKitStatus(welcomeKitId: number, status: string): Observable<any> {
return this.http.put(`/welcomekit/${welcomeKitId}`, { status });
}
getKycExceptions(resolved = false): Observable<any[]> {
return this.http.get<any[]>(`/kyc/exceptions?resolved=${resolved}`);
}
/**
* Records a KYC-exception outreach attempt against a customer, and — for the SMS, EMAIL and
* WHATSAPP channels — actually transmits the notice.
*
* The destination is resolved server-side from the client record; it is deliberately not part of
* this payload, so a tampered request cannot redirect a KYC notice elsewhere.
*
* Resolves with HTTP 200 even when delivery fails — inspect `deliveryStatus` / `delivered` on the
* response rather than assuming success, since the log row is written either way.
*/
saveKycContactLog(
clientId: string | number,
data: { channel: string; notes: string; officerName?: string }
): Observable<any> {
return this.http.post(`/kyc/exceptions/${clientId}/contact-log`, data);
}
getKycContactLog(clientId: string | number): Observable<any[]> {
return this.http.get<any[]>(`/kyc/exceptions/${clientId}/contact-log`);
}
}
@@ -76,6 +76,7 @@
[constitutionMeta]="clientGeneralStep?.selectedConstitution"
[entityName]="clientGeneralStep?.createClientForm?.get('fullname')?.value || ''"
[readOnly]="isTabReadOnly(1)"
[calculatedRisk]="clientGeneralStep?.calculatedRisk"
></mifosx-client-kyc-step>
</div>
@@ -148,6 +149,7 @@
<mifosx-client-india-address-step
#clientIndiaAddress
[isMinor]="clientGeneralStep?.isMinor || false"
[isNonIndividual]="legalFormType === 2"
[readOnly]="isTabReadOnly(addressTabIdx)"
></mifosx-client-india-address-step>
</div>
@@ -139,6 +139,8 @@ export class CreateClientComponent {
this.validationError = '';
this.prefillData = null;
this.clientGeneralStep?.resetForNewClient();
this.clientKycStep?.resetForNewClient();
this.clientIndiaAddressStep?.resetForNewClient();
}
/** Check if a tab is in read-only mode (completed tab navigated back to) */
@@ -238,8 +240,8 @@ export class CreateClientComponent {
return this.isAddressStepValid();
}
if (idx === 3) {
// Family tab — relations are optional, always allow.
return true;
// Family tab — Mother is required, and either Father or a Caretaker.
return !this.clientFamilyMembersStep || this.clientFamilyMembersStep.familyRelationshipRulesMet;
}
}
return true;
@@ -273,6 +275,11 @@ export class CreateClientComponent {
return `Please fill/correct: ${labels.join(', ')}.`;
}
}
if (idx === 3 && this.legalFormType === 1 && this.clientFamilyMembersStep) {
const step = this.clientFamilyMembersStep;
if (!step.hasMother) return 'Mother is required as a family member.';
if (!step.hasFather && !step.hasCaretaker) return 'Either Father or a Caretaker is required as a family member.';
}
return 'Please fill all mandatory fields on this tab to continue.';
}
@@ -351,6 +358,18 @@ export class CreateClientComponent {
return;
}
// PWD gate — disability type + certificate are mandatory when PWD is checked
// (Senior Citizen proof upload is optional, not gated here)
if (!this.clientGeneralStep?.seniorPwdDocsComplete) {
this.clientGeneralStep?.markSeniorPwdDocsTouched();
this.snackBar.open(
'Select the disability type and upload the Disability Certificate before continuing.',
'Close',
{ duration: 5000 }
);
return;
}
// Sanctions verification gate — Verify Client must be clicked before save
if (!this.clientGeneralStep?.sanctionsVerified) {
this.dialog
@@ -411,8 +430,22 @@ export class CreateClientComponent {
}
this.validationError = '';
// Save KYC
if (this.clientKycStep?.hasKycData && !this.clientKycStep.kycId) {
this.clientsService.saveClientKyc(clientId, this.clientKycStep.kycData).subscribe({ error: () => {} });
if (this.clientKycStep?.hasKycData) {
if (!this.clientKycStep.kycId) {
this.clientsService.saveClientKyc(clientId, this.clientKycStep.kycData).subscribe({
next: (res: any) => {
if (this.clientKycStep) {
this.clientKycStep.kycId = res?.resourceId?.toString() || res?.entityId?.toString() || '';
}
},
error: () => {}
});
} else {
const { kycType, ...updatePayload } = this.clientKycStep.kycData;
this.clientsService
.updateClientKyc(clientId, this.clientKycStep.kycId, updatePayload)
.subscribe({ error: () => {} });
}
}
// Upload photo & signature
if (this.clientKycStep?.photographFile) {
@@ -489,6 +522,29 @@ export class CreateClientComponent {
clearInterval(progressInterval);
this.saveProgress = 100;
this.createdClientId = response.resourceId;
// Senior Citizen / PWD supporting documents — uploaded after creation, same pattern as
// the KYC step's photo/signature uploads (the generic /clients/{id}/documents endpoint
// needs a real clientId, which only exists once createClient() has returned).
if (this.clientGeneralStep?.seniorCitizenProofFile) {
const seniorCitizenProofForm = new FormData();
seniorCitizenProofForm.append('file', this.clientGeneralStep.seniorCitizenProofFile);
seniorCitizenProofForm.append('name', 'seniorCitizenProof');
seniorCitizenProofForm.append('description', 'Senior Citizen Proof (Age/ID Proof)');
this.clientsService
.uploadClientDocument(this.createdClientId, seniorCitizenProofForm)
.subscribe({ error: () => {} });
}
if (this.clientGeneralStep?.pwdCertificateFile) {
const pwdCertificateForm = new FormData();
pwdCertificateForm.append('file', this.clientGeneralStep.pwdCertificateFile);
pwdCertificateForm.append('name', 'pwdCertificate');
pwdCertificateForm.append('description', 'Disability Certificate');
this.clientsService
.uploadClientDocument(this.createdClientId, pwdCertificateForm)
.subscribe({ error: () => {} });
}
// Brief delay to show 100% before transitioning
setTimeout(() => {
this.savingClient = false;
@@ -534,6 +590,21 @@ export class CreateClientComponent {
this.selectedTabIndex = 3;
return;
}
// Validate: Mother required, and either Father or a Caretaker (Individual clients)
if (
this.legalFormType === 1 &&
this.clientFamilyMembersStep &&
!this.clientFamilyMembersStep.familyRelationshipRulesMet
) {
const step = this.clientFamilyMembersStep;
if (!step.hasMother) {
this.validationError = 'Mother is required as a family member.';
} else {
this.validationError = 'Either Father or a Caretaker is required as a family member.';
}
this.selectedTabIndex = 3;
return;
}
this.validationError = '';
if (this.createdClientId) {
@@ -639,12 +710,20 @@ export class CreateClientComponent {
// Persist the KYC document picker selection + uploaded files as a client identifier
this.saveKycDocProof(clientId);
// Save KYC only if it wasn't already created during the KYC step
if (this.clientKycStep?.hasKycData && !this.clientKycStep.kycId) {
this.clientsService.saveClientKyc(clientId, this.clientKycStep.kycData).subscribe({
next: () => navigateToClient(),
error: () => navigateToClient()
});
// Save/update KYC to ensure latest exception fields are persisted
if (this.clientKycStep?.hasKycData) {
if (!this.clientKycStep.kycId) {
this.clientsService.saveClientKyc(clientId, this.clientKycStep.kycData).subscribe({
next: () => navigateToClient(),
error: () => navigateToClient()
});
} else {
const { kycType, ...updatePayload } = this.clientKycStep.kycData;
this.clientsService.updateClientKyc(clientId, this.clientKycStep.kycId, updatePayload).subscribe({
next: () => navigateToClient(),
error: () => navigateToClient()
});
}
} else {
navigateToClient();
}
@@ -25,9 +25,22 @@
{{ 'labels.inputs.GENERAL' | translate }}
</ng-template>
<div class="tab-content" [class.tab-content--readonly]="isTabReadOnly(0)">
@if (hasPendingGeneralEdit) {
<div class="pending-edit-banner">
<mat-icon>hourglass_top</mat-icon>
<span
>An edit is awaiting approval from another user. You can't submit another edit until it's resolved.</span
>
</div>
}
<div class="tab-mode-bar">
@if (isTabReadOnly(0)) {
<button mat-flat-button class="tab-mode-btn tab-mode-btn--edit" (click)="enableTabEdit(0)">
<button
mat-flat-button
class="tab-mode-btn tab-mode-btn--edit"
[disabled]="hasPendingGeneralEdit"
(click)="enableTabEdit(0)"
>
<mat-icon>edit</mat-icon> Edit
</button>
} @else {
@@ -71,14 +84,70 @@
<mat-icon>phone_iphone</mat-icon>
<span>Mobile Number</span>
</div>
<div class="contact-verify-panel__exception-row" style="margin-bottom: 12px">
<mat-checkbox
[checked]="oldMobileNotAccessible"
(change)="onOldMobileNotAccessibleToggle($event.checked)"
color="primary"
>
Old mobile number not accessible (lost / deactivated SIM)
</mat-checkbox>
</div>
<div class="contact-verify-panel__grid">
<mifosx-client-contact-otp-block
[clientId]="clientId"
channel="MOBILE"
[destination]="contactChanges.oldMobile"
label="Old Mobile Number"
(stateChange)="onOldMobileOtpState($event)"
></mifosx-client-contact-otp-block>
@if (!oldMobileNotAccessible) {
<mifosx-client-contact-otp-block
[clientId]="clientId"
channel="MOBILE"
[destination]="contactChanges.oldMobile"
label="Old Mobile Number"
(stateChange)="onOldMobileOtpState($event)"
></mifosx-client-contact-otp-block>
} @else {
<div
class="fir-upload-box"
style="padding: 12px; border: 1px dashed #cbd5e1; border-radius: 8px; background: #f8fafc"
>
<div style="font-weight: 500; font-size: 13px; margin-bottom: 6px; color: #475569">
Upload Police Complaint / FIR Copy for Lost Mobile
</div>
<div style="display: flex; align-items: center; gap: 12px">
<input
type="file"
(change)="onFirFileSelected($event)"
accept=".pdf,.jpg,.jpeg,.png"
#firFileInput
hidden
/>
<button
mat-stroked-button
type="button"
(click)="firFileInput.click()"
[disabled]="uploadingFir"
>
<mat-icon>upload_file</mat-icon>
{{ firFileName ? firFileName : 'Select FIR Document' }}
</button>
@if (uploadingFir) {
<mat-progress-spinner mode="indeterminate" diameter="20"></mat-progress-spinner>
}
@if (firDocumentId && !uploadingFir) {
<span
style="
color: #16a34a;
font-size: 12px;
font-weight: 500;
display: flex;
align-items: center;
gap: 4px;
"
>
<mat-icon style="font-size: 16px; width: 16px; height: 16px">check_circle</mat-icon> FIR
Uploaded Successfully
</span>
}
</div>
</div>
}
<mifosx-client-contact-otp-block
[clientId]="clientId"
channel="MOBILE"
@@ -152,6 +221,7 @@
[isMinor]="clientGeneralStep?.isMinor || false"
[clientId]="clientId"
[readOnly]="isTabReadOnly(1)"
[calculatedRisk]="clientGeneralStep?.calculatedRisk"
></mifosx-client-kyc-step>
</div>
</div>
@@ -208,6 +278,7 @@
#clientIndiaAddress
[clientId]="clientId"
[isMinor]="clientGeneralStep?.isMinor || false"
[isNonIndividual]="legalFormType === 2"
[readOnly]="isTabReadOnly(addressTabIdx)"
></mifosx-client-india-address-step>
</div>
@@ -217,6 +217,26 @@
}
}
/* ─── Pending Edit Approval Banner ─── */
.pending-edit-banner {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
margin: 0 0 16px;
background: #eff6ff;
border: 1px solid #93c5fd;
border-radius: 8px;
color: #1e40af;
font-size: 13px;
font-weight: 500;
mat-icon {
color: #2563eb;
flex-shrink: 0;
}
}
/* ─── Re-KYC Alert Banner ─── */
.rekyc-alert {
display: flex;
@@ -33,11 +33,13 @@ import { ContactOtpBlockComponent, ContactOtpState } from './contact-otp-block/c
import { MatTabsModule } from '@angular/material/tabs';
import { MatIconModule } from '@angular/material/icon';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatDivider } from '@angular/material/divider';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { MatProgressSpinner } from '@angular/material/progress-spinner';
import { MatTooltip } from '@angular/material/tooltip';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { ORIGINAL_VALUES_PAYLOAD_KEY, buildOriginalValuesSnapshot } from '../shared/client-edit-diff.util';
/**
* Edit Client Component — uses the same wizard/stepper layout as Create Client.
@@ -50,6 +52,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
...STANDALONE_SHARED_IMPORTS,
MatTabsModule,
MatIconModule,
MatCheckboxModule,
MatDivider,
FaIconComponent,
MatProgressSpinner,
@@ -107,6 +110,13 @@ export class EditClientComponent {
newMobileOtpId: number | null = null;
oldMobileVerified = false;
newMobileVerified = false;
// FIR exception flow properties for inaccessible old mobile number
oldMobileNotAccessible = false;
firFileName = '';
firDocumentId: number | null = null;
uploadingFir = false;
oldEmailOtpId: number | null = null;
newEmailOtpId: number | null = null;
oldEmailVerified = false;
@@ -116,6 +126,9 @@ export class EditClientComponent {
clientDataAndTemplate: any;
/** Client ID */
clientId: string;
/** True while a General-tab edit is staged awaiting checker approval — blocks submitting
* another one (the backend enforces this too; this just avoids a round-trip error toast). */
hasPendingGeneralEdit = false;
constructor() {
this.route.data.subscribe((data: { clientDataAndTemplate: any }) => {
@@ -124,6 +137,15 @@ export class EditClientComponent {
if (this.clientDataAndTemplate.legalForm) {
this.legalFormType = this.clientDataAndTemplate.legalForm.id;
}
this.loadPendingGeneralEdit();
});
}
private loadPendingGeneralEdit(): void {
if (!this.clientId) return;
this.clientsService.getPendingClientEdit(this.clientId).subscribe({
next: (edit: any) => (this.hasPendingGeneralEdit = !!edit && edit.status === 'PENDING'),
error: () => (this.hasPendingGeneralEdit = false)
});
}
@@ -202,11 +224,15 @@ export class EditClientComponent {
return this.detectSensitiveFieldChanges(this.clientDataAndTemplate);
}
/** Whether every OTP block required by the fields that actually changed has been verified. */
/** Whether every OTP block required by the fields that actually changed has been verified
* (or the FIR exception has been uploaded in place of old-mobile OTP verification). */
get contactVerificationComplete(): boolean {
const changes = this.contactChanges;
if (changes.mobileChanged && !(this.oldMobileVerified && this.newMobileVerified)) {
return false;
if (changes.mobileChanged) {
const isOldSatisfied = this.oldMobileNotAccessible ? this.firDocumentId != null : this.oldMobileVerified;
if (!isOldSatisfied || !this.newMobileVerified) {
return false;
}
}
if (changes.emailChanged && !(this.oldEmailVerified && this.newEmailVerified)) {
return false;
@@ -214,6 +240,43 @@ export class EditClientComponent {
return true;
}
/** Toggle the "old mobile not accessible" exception — clears any in-progress old-mobile OTP. */
onOldMobileNotAccessibleToggle(checked?: boolean): void {
if (checked !== undefined) {
this.oldMobileNotAccessible = checked;
}
if (this.oldMobileNotAccessible) {
this.oldMobileOtpId = null;
this.oldMobileVerified = false;
}
}
/** Upload the FIR / police-complaint copy that substitutes for old-mobile OTP verification. */
onFirFileSelected(event: any): void {
const file: File = event.target?.files?.[0];
if (!file) return;
this.firFileName = file.name;
this.uploadingFir = true;
const formData = new FormData();
formData.append('file', file);
formData.append('name', 'FIR - Lost Mobile Number');
formData.append('description', 'FIR police complaint copy for lost mobile number verification exception');
this.clientsService.uploadClientDocument(this.clientId, formData).subscribe({
next: (res: any) => {
this.uploadingFir = false;
this.firDocumentId = res?.resourceId || res?.id || null;
this.snackBar.open('FIR document uploaded successfully', 'Close', { duration: 3000 });
},
error: () => {
this.uploadingFir = false;
this.firDocumentId = null;
this.snackBar.open('Failed to upload FIR document', 'Close', { duration: 4000 });
}
});
}
onOldMobileOtpState(state: ContactOtpState): void {
this.oldMobileOtpId = state.otpId;
this.oldMobileVerified = state.verified;
@@ -241,6 +304,10 @@ export class EditClientComponent {
this.newMobileOtpId = null;
this.oldMobileVerified = false;
this.newMobileVerified = false;
this.oldMobileNotAccessible = false;
this.firFileName = '';
this.firDocumentId = null;
this.uploadingFir = false;
this.oldEmailOtpId = null;
this.newEmailOtpId = null;
this.oldEmailVerified = false;
@@ -287,14 +354,20 @@ export class EditClientComponent {
if (this.legalFormType === 2) {
if (idx === 2) return true; // Scheme & Sector — opt-in
if (idx === 3) return this.isAddressStepValid();
if (idx === 4) return true; // Family — optional
if (idx === 4) return true; // Family — not applicable to non-individual entities
} else {
if (idx === 2) return this.isAddressStepValid();
if (idx === 3) return true; // Family — optional
if (idx === 3) return this.isFamilyStepValid(); // Mother required, plus Father or a Caretaker
}
return true;
}
/** Mother is required, and either Father or a Caretaker, for individual clients. */
private isFamilyStepValid(): boolean {
const step = this.clientFamilyMembersStep;
return !step || step.familyRelationshipRulesMet;
}
private isAddressStepValid(): boolean {
const step = this.clientIndiaAddressStep;
if (!step) return true;
@@ -318,6 +391,12 @@ export class EditClientComponent {
return `Please fill/correct: ${labels.join(', ')}.`;
}
}
const familyTabIndex = this.legalFormType === 2 ? 4 : 3;
if (this.selectedTabIndex === familyTabIndex && this.legalFormType !== 2 && this.clientFamilyMembersStep) {
const step = this.clientFamilyMembersStep;
if (!step.hasMother) return 'Mother is required as a family member.';
if (!step.hasFather && !step.hasCaretaker) return 'Either Father or a Caretaker is required as a family member.';
}
return 'Please fill all mandatory fields on this tab to continue.';
}
@@ -414,7 +493,11 @@ export class EditClientComponent {
// email's when BOTH changed in the same save. See the class-level JSDoc above the OTP
// state fields for the full naming rationale.
if (changedFields.mobileChanged) {
clientData.mobileOldOtpId = this.oldMobileOtpId;
if (this.oldMobileNotAccessible && this.firDocumentId) {
(clientData as any).oldMobileVerificationExceptionDocumentId = this.firDocumentId;
} else {
clientData.mobileOldOtpId = this.oldMobileOtpId;
}
clientData.mobileNewOtpId = this.newMobileOtpId;
}
if (changedFields.emailChanged) {
@@ -422,46 +505,51 @@ export class EditClientComponent {
clientData.emailNewOtpId = this.newEmailOtpId;
}
// Point-in-time "before" snapshot for the checker's diff view — captured now, from the
// client record loaded when this edit page opened, rather than relying on comparing against
// the LIVE client later (which would show nothing changed once a checker approves the edit,
// since by then current == proposed).
clientData[ORIGINAL_VALUES_PAYLOAD_KEY] = buildOriginalValuesSnapshot(originalData);
this.savingClient = true;
this.clientsService.updateClient(this.clientId, clientData).subscribe({
// Maker-checker: stage the edit instead of applying it immediately. The customer-facing
// side effects that a direct update would have had immediately — SMS/Email contact-change
// notification, re-KYC flagging, PAN re-verification reset — are all deferred until a
// different user actually approves the edit (approval replays this exact payload through
// the same updateClient() the direct PUT used to call, so the backend-driven notification
// still fires correctly, just at approval time instead of now).
this.clientsService.submitClientEdit(this.clientId, clientData).subscribe({
next: () => {
this.savingClient = false;
this.tabEditMode[tabIndex] = false;
this.hasPendingGeneralEdit = true;
// Log mobile/email changes as client notes for risk audit
this.logFieldChangeNotes(changedFields);
// If name or DOB changed, flag re-KYC requirement and reset PAN verification
if (changedFields.nameChanged || changedFields.dobChanged) {
this.dobChanged = true;
this.tabEditMode[1] = true; // auto-enable KYC tab for re-verification
this.flagReKycRequired(changedFields);
// Senior Citizen / PWD supporting documents — separate upload endpoints, not part of
// the staged JSON edit, so these still apply immediately as before.
if (this.clientGeneralStep?.seniorCitizenProofFile) {
const seniorCitizenProofForm = new FormData();
seniorCitizenProofForm.append('file', this.clientGeneralStep.seniorCitizenProofFile);
seniorCitizenProofForm.append('name', 'seniorCitizenProof');
seniorCitizenProofForm.append('description', 'Senior Citizen Proof (Age/ID Proof)');
this.clientsService
.uploadClientDocument(this.clientId, seniorCitizenProofForm)
.subscribe({ error: () => {} });
}
if (this.clientGeneralStep?.pwdCertificateFile) {
const pwdCertificateForm = new FormData();
pwdCertificateForm.append('file', this.clientGeneralStep.pwdCertificateFile);
pwdCertificateForm.append('name', 'pwdCertificate');
pwdCertificateForm.append('description', 'Disability Certificate');
this.clientsService.uploadClientDocument(this.clientId, pwdCertificateForm).subscribe({ error: () => {} });
}
// Update original data snapshot so subsequent edits compare against latest
if (changedFields.mobileChanged) {
originalData.mobileNo = changedFields.newMobile;
}
if (changedFields.emailChanged) {
originalData.emailAddress = changedFields.newEmail;
}
if (changedFields.dobChanged) {
originalData.dateOfBirth = changedFields.newDob;
}
if (changedFields.nameChanged) {
const form = this.clientGeneralForm;
originalData.firstname = form?.get('firstname')?.value ?? originalData.firstname;
originalData.middlename = form?.get('middlename')?.value ?? originalData.middlename;
originalData.lastname = form?.get('lastname')?.value ?? originalData.lastname;
originalData.fullname = form?.get('fullname')?.value ?? originalData.fullname;
}
// Notification is backend-driven (SMS + Email) — just reassure staff it happened.
if (changedFields.mobileChanged || changedFields.emailChanged) {
this.snackBar.open('Customer notified via SMS/Email of the contact change.', 'Close', {
duration: 4000
});
}
this.snackBar.open(
'Edit submitted for approval — a different user must review it before it takes effect.',
'Close',
{
duration: 5000
}
);
this.resetContactVerificationState();
this.selectedTabIndex = this.selectedTabIndex + 1;
@@ -600,16 +688,36 @@ export class EditClientComponent {
private detectSensitiveFieldChanges(originalData: any) {
const form = this.clientGeneralForm;
if (!form || !originalData) {
return {
nameChanged: false,
mobileChanged: false,
emailChanged: false,
dobChanged: false,
oldName: '',
newName: '',
oldMobile: '',
newMobile: '',
oldEmail: '',
newEmail: '',
oldDob: '',
newDob: ''
};
}
const mobileControl = form.get('mobileNo');
const emailControl = form.get('emailAddress');
// Trimmed so browser autofill adding invisible leading/trailing whitespace never reads as
// a "change" — the masked preview shown in the Verify Contact Change panel only reveals the
// first couple of characters, so a whitespace-only difference is otherwise invisible to the
// user and just looks like the panel appeared for no reason. These trimmed-but-original-case
// values are what get returned/sent as the actual OTP destination and saved value — only the
// *comparison* (below) is further case-normalized for email, so we never silently rewrite the
// customer's email casing into the saved record or the OTP destination.
const newMobile = (form?.get('mobileNo')?.value || '').trim();
const newEmail = (form?.get('emailAddress')?.value || '').trim();
const newDobRaw = form?.get('dateOfBirth')?.value;
// *comparison* (below) is further normalized for mobile/email, so we never silently rewrite the
// customer's saved value or the OTP destination.
const newMobile = (mobileControl?.value || '').toString().trim();
const newEmail = (emailControl?.value || '').toString().trim();
const newDobRaw = form.get('dateOfBirth')?.value;
// Local calendar date, NOT `.toISOString()` — that converts to UTC first, which silently
// shifts the date by a day in any positive-UTC-offset timezone (e.g. India, UTC+5:30:
// midnight local on the 15th becomes 18:30 UTC on the 14th). That made this comparison
@@ -617,8 +725,8 @@ export class EditClientComponent {
// touched the date field, purely because the Date object round-tripped through the picker.
const newDob = newDobRaw instanceof Date ? this.toLocalDateString(newDobRaw) : newDobRaw || '';
const oldMobile = (originalData.mobileNo || '').trim();
const oldEmail = (originalData.emailAddress || '').trim();
const oldMobile = (originalData.mobileNo || '').toString().trim();
const oldEmail = (originalData.emailAddress || '').toString().trim();
const oldDobRaw = originalData.dateOfBirth;
const oldDob = Array.isArray(oldDobRaw)
? `${oldDobRaw[0]}-${String(oldDobRaw[1]).padStart(2, '0')}-${String(oldDobRaw[2]).padStart(2, '0')}`
@@ -651,12 +759,25 @@ export class EditClientComponent {
fullname: originalData.fullname || ''
});
// Normalize phone numbers by stripping non-digit characters (+91 9009009009 -> 9009009009)
const normPhone = (val: string) => val.replace(/\D/g, '');
const newMobileNorm = normPhone(newMobile);
const oldMobileNorm = normPhone(oldMobile);
// Mobile/Email are considered changed ONLY when the control has been edited (dirty) AND
// normalized values differ — otherwise a re-render/reformat of the control by the framework
// (never actually touched by the user) would falsely surface the dual-OTP verification panel.
const mobileChanged =
!!oldMobileNorm && !!newMobileNorm && !!mobileControl?.dirty && newMobileNorm !== oldMobileNorm;
// Case-insensitive: emails are not case-sensitive in practice, and comparing raw case
// would flag a change (surfacing the dual-OTP panel) even when nothing meaningful changed.
const emailChanged =
!!oldEmail && !!newEmail && !!emailControl?.dirty && newEmail.toLowerCase() !== oldEmail.toLowerCase();
return {
nameChanged: newName !== oldName,
mobileChanged: newMobile !== oldMobile,
// Case-insensitive: emails are not case-sensitive in practice, and comparing raw case
// would flag a change (surfacing the dual-OTP panel) even when nothing meaningful changed.
emailChanged: newEmail.toLowerCase() !== oldEmail.toLowerCase(),
mobileChanged,
emailChanged,
dobChanged: newDob !== oldDob,
oldName,
newName,
+175
View File
@@ -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/.
*/
import {
deriveKycExceptionFields,
KycExceptionInput,
KYC_EXCEPTION_AADHAAR,
KYC_EXCEPTION_PAN
} from './kyc-exception.util';
const TODAY = '05 August 2026';
/** Individual client, nothing previously recorded — the client-creation case. */
const newIndividual = (panVerified: boolean, aadhaarVerified: boolean): KycExceptionInput => ({
panVerified,
aadhaarVerified,
aadhaarApplicable: true,
hadException: false,
previousReason: null,
todayFormatted: TODAY
});
/** Individual client that already carries an open exception. */
const existingException = (
panVerified: boolean,
aadhaarVerified: boolean,
previousReason: string
): KycExceptionInput => ({
panVerified,
aadhaarVerified,
aadhaarApplicable: true,
hadException: true,
previousReason,
todayFormatted: TODAY
});
describe('deriveKycExceptionFields', () => {
describe('at client creation', () => {
it('marks both PAN and Aadhaar when neither is entered or verified', () => {
expect(deriveKycExceptionFields(newIndividual(false, false))).toEqual({
isException: true,
exceptionReason: `${KYC_EXCEPTION_PAN}, ${KYC_EXCEPTION_AADHAAR}`,
exceptionResolved: false,
exceptionResolvedOn: null
});
});
it('marks only PAN when Aadhaar verified but PAN did not', () => {
expect(deriveKycExceptionFields(newIndividual(false, true))).toEqual({
isException: true,
exceptionReason: KYC_EXCEPTION_PAN,
exceptionResolved: false,
exceptionResolvedOn: null
});
});
it('marks only Aadhaar when PAN verified but Aadhaar did not', () => {
expect(deriveKycExceptionFields(newIndividual(true, false))).toEqual({
isException: true,
exceptionReason: KYC_EXCEPTION_AADHAAR,
exceptionResolved: false,
exceptionResolvedOn: null
});
});
it('raises nothing when both verify, and does not mark the client resolved', () => {
// A clean client never had an exception, so it must not appear in the Resolved tab either.
expect(deriveKycExceptionFields(newIndividual(true, true))).toEqual({
isException: false,
exceptionReason: null,
exceptionResolved: false,
exceptionResolvedOn: null
});
});
it('does not raise an Aadhaar exception for a non-individual client', () => {
// Companies submit a dummy masked Aadhaar; checking it would mean a permanent exception.
expect(
deriveKycExceptionFields({
panVerified: true,
aadhaarVerified: false,
aadhaarApplicable: false,
hadException: false,
previousReason: null,
todayFormatted: TODAY
}).isException
).toBe(false);
});
});
describe('resolving', () => {
it('closes the exception once both checks pass, keeping the reason for the audit trail', () => {
const both = `${KYC_EXCEPTION_PAN}, ${KYC_EXCEPTION_AADHAAR}`;
expect(deriveKycExceptionFields(existingException(true, true, both))).toEqual({
isException: false,
exceptionReason: both,
exceptionResolved: true,
exceptionResolvedOn: TODAY
});
});
it('narrows to the outstanding item when only one of two verifies', () => {
const both = `${KYC_EXCEPTION_PAN}, ${KYC_EXCEPTION_AADHAAR}`;
// PAN verified, Aadhaar still outstanding — stays open, listing Aadhaar alone.
expect(deriveKycExceptionFields(existingException(true, false, both))).toEqual({
isException: true,
exceptionReason: KYC_EXCEPTION_AADHAAR,
exceptionResolved: false,
exceptionResolvedOn: null
});
});
it('narrows the other way round too', () => {
const both = `${KYC_EXCEPTION_PAN}, ${KYC_EXCEPTION_AADHAAR}`;
expect(deriveKycExceptionFields(existingException(false, true, both)).exceptionReason).toBe(KYC_EXCEPTION_PAN);
});
});
describe('re-opening', () => {
it('clears a prior resolution so a new exception returns to Pending', () => {
// Regression: without exceptionResolved=false the record still matched the Resolved query
// and a brand-new exception appeared as already closed.
const result = deriveKycExceptionFields({
panVerified: false,
aadhaarVerified: true,
aadhaarApplicable: true,
hadException: false,
previousReason: 'PAN Card Verification Failed',
todayFormatted: TODAY
});
expect(result.exceptionResolved).toBe(false);
expect(result.exceptionResolvedOn).toBeNull();
expect(result.isException).toBe(true);
});
});
describe('officer-entered reasons', () => {
it('keeps a manual reason alongside the automatic ones', () => {
const result = deriveKycExceptionFields({
...newIndividual(true, true),
manualReasons: ['Address proof pending']
});
expect(result).toEqual({
isException: true,
exceptionReason: 'Address proof pending',
exceptionResolved: false,
exceptionResolvedOn: null
});
});
it('drops a stale automatic reason left in the form once the check passes', () => {
// The form still holds 'PAN Card Verification Failed' from a previous load; PAN has since
// verified, so it must not survive into the payload.
const result = deriveKycExceptionFields({
...newIndividual(true, true),
manualReasons: [KYC_EXCEPTION_PAN]
});
expect(result.isException).toBe(false);
expect(result.exceptionReason).toBeNull();
});
it('does not duplicate a manual reason that matches an outstanding automatic one', () => {
const result = deriveKycExceptionFields({
...newIndividual(false, true),
manualReasons: [KYC_EXCEPTION_PAN]
});
expect(result.exceptionReason).toBe(KYC_EXCEPTION_PAN);
});
});
});
+112
View File
@@ -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/.
*/
/**
* Derives a client's KYC exception state from their verification status.
*
* A KYC exception is not something an officer should have to remember to tick — it is simply the
* fact that a mandatory identity check has not passed. Deriving it means the exception list can
* never drift out of step with reality: raising, narrowing and closing all fall out of whether PAN
* and Aadhaar are verified at the moment of the save.
*
* Shared by the client-creation stepper and the client-view KYC tab so both write identical state.
*/
export const KYC_EXCEPTION_PAN = 'PAN Card Verification Failed';
export const KYC_EXCEPTION_AADHAAR = 'Aadhaar Verification Failed';
/** The two reasons this module owns. Anything else on a record was added by an officer by hand. */
export const AUTO_MANAGED_KYC_EXCEPTION_REASONS: string[] = [
KYC_EXCEPTION_PAN,
KYC_EXCEPTION_AADHAAR
];
export interface KycExceptionInput {
/** PAN verified against the Income Tax database. Unverified OR simply not entered → exception. */
panVerified: boolean;
/** Aadhaar verified against UIDAI. Unverified OR simply not entered → exception. */
aadhaarVerified: boolean;
/**
* False for non-individual clients, where no Aadhaar is collected (the payload sends a dummy
* masked value). Without this guard every company would carry a permanent Aadhaar exception.
*/
aadhaarApplicable: boolean;
/** Whether an exception is already recorded — decides raise vs. resolve on a clean save. */
hadException: boolean;
/** Reason currently persisted, kept verbatim when closing so the audit trail survives. */
previousReason: string | null;
/** Officer-entered reasons beyond the two automatic ones; carried through untouched. */
manualReasons?: string[];
/** Today in the tenant's date format — stamped as exceptionResolvedOn when closing. */
todayFormatted: string;
}
export interface KycExceptionFields {
isException: boolean;
exceptionReason: string | null;
exceptionResolved: boolean;
exceptionResolvedOn: string | null;
}
/**
* Maps verification status to the four exception columns the API expects.
*
* Three outcomes:
* - **Something outstanding** → raise/keep the exception, listing only what is still failing. A
* record that had both PAN and Aadhaar outstanding and has since verified PAN comes back with
* Aadhaar alone, which is what narrows the entry rather than closing it.
* - **Nothing outstanding, exception existed** → close it. `exceptionReason` is retained on
* purpose: the Resolved tab shows what was resolved, and clearing it would erase the record of
* why the exception was ever raised.
* - **Nothing outstanding, no exception** → leave every flag off, so a clean client never appears
* in either tab.
*/
export function deriveKycExceptionFields(input: KycExceptionInput): KycExceptionFields {
const outstanding: string[] = [];
if (!input.panVerified) {
outstanding.push(KYC_EXCEPTION_PAN);
}
if (input.aadhaarApplicable && !input.aadhaarVerified) {
outstanding.push(KYC_EXCEPTION_AADHAAR);
}
// Officer-added reasons ride along, but the two automatic ones are filtered out first — a stale
// 'PAN Card Verification Failed' left in the form must not survive PAN actually being verified.
for (const reason of input.manualReasons || []) {
const trimmed = (reason || '').trim();
if (trimmed && !AUTO_MANAGED_KYC_EXCEPTION_REASONS.includes(trimmed) && !outstanding.includes(trimmed)) {
outstanding.push(trimmed);
}
}
if (outstanding.length > 0) {
return {
isException: true,
exceptionReason: outstanding.join(', '),
exceptionResolved: false,
exceptionResolvedOn: null
};
}
if (input.hadException) {
return {
isException: false,
exceptionReason: input.previousReason || null,
exceptionResolved: true,
exceptionResolvedOn: input.todayFormatted
};
}
return {
isException: false,
exceptionReason: null,
exceptionResolved: false,
exceptionResolvedOn: null
};
}
@@ -0,0 +1,149 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/**
* Shared between the maker side (edit-client, which captures a point-in-time snapshot of the
* client's current values right before staging an edit) and the checker side (pending-client-edits,
* which diffs the staged payload against that snapshot). Keeping both in one place means the two
* sides can never drift out of sync on which fields are captured/compared/labelled.
*
* Extracts a payload key's CURRENT value from a client record (as returned by GET /clients/{id}) —
* CodeValue fields come back as nested {id, name} objects, so most extractors pull the nested .id
* to compare against the payload's raw id.
*/
export const PENDING_EDIT_FIELD_EXTRACTORS: Record<string, (c: any) => any> = {
officeId: (c) => c.officeId,
legalFormId: (c) => c.legalForm?.id,
nationalityId: (c) => c.nationality,
isStaff: (c) => c.isStaff,
active: (c) => c.active,
accountNo: (c) => c.accountNo,
salutationId: (c) => c.salutation?.id,
firstname: (c) => c.firstname,
middlename: (c) => c.middlename,
lastname: (c) => c.lastname,
fullname: (c) => c.fullname,
genderId: (c) => c.gender?.id,
maritalStatusId: (c) => c.maritalStatus?.id,
educationLevelId: (c) => c.educationLevel?.id,
degreeQualificationId: (c) => c.degreeQualification?.id,
religionId: (c) => c.religion?.id,
socialCategoryId: (c) => c.socialCategory?.id,
mobileNo: (c) => c.mobileNo,
alternateMobileNo: (c) => c.alternateMobileNo,
emailAddress: (c) => c.emailAddress,
dateOfBirth: (c) => c.dateOfBirth,
occupationCategoryId: (c) => c.occupationCategory?.id,
occupationId: (c) => c.occupation?.id,
monthlyIncome: (c) => c.monthlyIncome,
annualIncome: (c) => c.annualIncome,
annualTurnover: (c) => c.annualTurnover,
monthlyTurnover: (c) => c.monthlyTurnover,
isPwd: (c) => c.isPwd,
pwdCategoryId: (c) => c.pwdCategory?.id,
isSeniorCitizen: (c) => c.isSeniorCitizen,
agricultureIncome: (c) => c.agricultureIncome,
nonAgricultureIncome: (c) => c.nonAgricultureIncome,
savingsProductId: (c) => c.savingsProductId,
staffId: (c) => c.staffId,
guardianClientId: (c) => c.guardianClientId,
guardianRelation: (c) => c.guardianRelation
};
/** Payload keys that are computed, internal, or otherwise never meaningfully "changed" from a
* checker's point of view — always sent by the form (age breakdown, OTP tracking ids, the
* business-date default on submittedOnDate) but with no comparable "current" value. */
export const NON_COMPARABLE_KEYS = new Set([
'dateFormat',
'locale',
'ageYears',
'ageMonths',
'ageDays',
'isMinor',
'addSavings',
'submittedOnDate',
'activationDate',
// Flattened onto top-level guardianClientId/guardianRelation right after this in the payload —
// that flattened pair already has real comparators above, so the nested group is redundant.
'guardianDetails',
// Mobile/email DIP verification + name-match + sanctions-screening results — carried along on
// every save to re-affirm the client's EXISTING verification state, not something the maker
// edited on this pass. dipTimestamp especially would almost never string-match exactly even
// when functionally unchanged, so these were never real "proposed changes" to review.
'dipVerified',
'dipLookupLogId',
'dipTimestamp',
'alternateDipVerified',
'alternateDipLookupLogId',
'alternateDipTimestamp',
'nameVerified',
'screeningLogId',
'mobileNewOtpId',
'mobileOldOtpId',
'emailNewOtpId',
'emailOldOtpId'
]);
export const PENDING_EDIT_FIELD_LABELS: Record<string, string> = {
officeId: 'Office',
legalFormId: 'Legal Form',
nationalityId: 'Nationality',
isStaff: 'Is Staff',
active: 'Active',
accountNo: 'Account Number',
salutationId: 'Salutation',
firstname: 'First Name',
middlename: 'Middle Name',
lastname: 'Last Name',
fullname: 'Full Name',
genderId: 'Gender',
maritalStatusId: 'Marital Status',
educationLevelId: 'Education Level',
degreeQualificationId: 'Degree / Qualification',
religionId: 'Religion',
socialCategoryId: 'Social Category',
mobileNo: 'Mobile Number',
alternateMobileNo: 'Alternate Mobile Number',
emailAddress: 'Email Address',
dateOfBirth: 'Date of Birth',
occupationCategoryId: 'Occupation Category',
occupationId: 'Occupation',
monthlyIncome: 'Monthly Income',
annualIncome: 'Annual Income',
annualTurnover: 'Yearly Turnover',
monthlyTurnover: 'Monthly Turnover',
isPwd: 'Person with Disability',
pwdCategoryId: 'Disability Type',
isSeniorCitizen: 'Senior Citizen',
agricultureIncome: 'Agriculture Income',
nonAgricultureIncome: 'Non-Agriculture Income',
submittedOnDate: 'Submitted On',
savingsProductId: 'Linked Savings Product',
staffId: 'Staff'
};
/** Key under which the maker's point-in-time "before" snapshot rides along in the submit payload —
* stripped out by the backend before replaying the rest through updateClient(), and stored
* separately as original_json for the checker's diff. Never a real Client field name, so it can't
* collide with anything ClientDataValidator actually expects. */
export const ORIGINAL_VALUES_PAYLOAD_KEY = '_originalValues';
/** Builds the maker-side "before" snapshot from the client record loaded when the edit page
* opened — flat key names matching the payload's own, so the checker side can diff two plain
* objects directly without needing to know Client's nested response shape at all. */
export function buildOriginalValuesSnapshot(clientData: any): Record<string, any> {
const snapshot: Record<string, any> = {};
if (!clientData) return snapshot;
for (const key of Object.keys(PENDING_EDIT_FIELD_EXTRACTORS)) {
const value = PENDING_EDIT_FIELD_EXTRACTORS[key](clientData);
if (value !== undefined) {
snapshot[key] = value;
}
}
return snapshot;
}
@@ -0,0 +1,24 @@
<!--
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/.
-->
<h1 mat-dialog-title>{{ data.title || data.fileName || 'Document Preview' }}</h1>
<div mat-dialog-content class="doc-preview-content">
@if (isImage) {
<img [src]="previewUrl" [alt]="data.fileName || 'Document'" class="doc-preview-image" />
} @else if (isPdf) {
<iframe [src]="safeUrl" class="doc-preview-pdf" title="Document preview"></iframe>
} @else {
<p>Preview isn't available for this file type — use Download to view it.</p>
}
</div>
<mat-dialog-actions align="end">
<button mat-raised-button (click)="download()">Download</button>
<button mat-raised-button mat-dialog-close color="primary">Close</button>
</mat-dialog-actions>
@@ -0,0 +1,27 @@
/**
* 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/.
*/
.doc-preview-content {
display: flex;
justify-content: center;
align-items: center;
min-width: 320px;
max-width: 80vw;
}
.doc-preview-image {
max-width: 100%;
max-height: 70vh;
object-fit: contain;
}
.doc-preview-pdf {
width: 70vw;
height: 70vh;
border: none;
}
@@ -0,0 +1,81 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/** Angular Imports */
import { Component, OnDestroy, inject } from '@angular/core';
import {
MatDialogRef,
MAT_DIALOG_DATA,
MatDialogTitle,
MatDialogContent,
MatDialogActions,
MatDialogClose
} from '@angular/material/dialog';
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
import { CdkScrollable } from '@angular/cdk/scrolling';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
export interface DocumentPreviewDialogData {
blob: Blob;
title?: string;
fileName?: string;
}
/**
* Generic in-app document preview dialog — shows an image inline or a PDF in an embedded
* viewer, with a Download fallback for anything else. Used in place of window.open() for
* saved KYC/address/senior-citizen/PWD documents: opening a new browser tab is subject to
* popup blockers (fires only after the async download resolves) and, for images, Chrome/Edge
* frequently fail to paint a huge base64 data: URL until the tab is manually refreshed.
*/
@Component({
selector: 'mifosx-document-preview-dialog',
templateUrl: './document-preview-dialog.component.html',
styleUrls: ['./document-preview-dialog.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatDialogTitle,
CdkScrollable,
MatDialogContent,
MatDialogActions,
MatDialogClose
]
})
export class DocumentPreviewDialogComponent implements OnDestroy {
dialogRef = inject<MatDialogRef<DocumentPreviewDialogComponent>>(MatDialogRef);
private sanitizer = inject(DomSanitizer);
data = inject<DocumentPreviewDialogData>(MAT_DIALOG_DATA);
private readonly objectUrl: string;
readonly safeUrl: SafeResourceUrl;
readonly isImage: boolean;
readonly isPdf: boolean;
constructor() {
this.objectUrl = URL.createObjectURL(this.data.blob);
this.safeUrl = this.sanitizer.bypassSecurityTrustResourceUrl(this.objectUrl);
const type = this.data.blob.type || '';
this.isImage = type.startsWith('image/');
this.isPdf = type === 'application/pdf';
}
ngOnDestroy(): void {
URL.revokeObjectURL(this.objectUrl);
}
get previewUrl(): string {
return this.objectUrl;
}
download(): void {
const a = document.createElement('a');
a.href = this.objectUrl;
a.download = this.data.fileName || 'document';
a.click();
}
}
+40
View File
@@ -81,6 +81,7 @@ export const PROOF_META: Record<string, Partial<ProofType>> = {
sides: 1,
documentKeyLabel: 'School ID / admission number',
documentKeyPlaceholder: 'Student ID number',
category: 'Government-issued ID',
info: 'School identity card bearing photograph — accepted proof of identity for minor clients as per RBI KYC norms.'
},
UTILITY_BILL: {
@@ -144,7 +145,46 @@ export const PROOF_META: Record<string, Partial<ProofType>> = {
PENSION_PAYMENT_ORDER: {
sides: 1,
category: 'Financial & other',
requiresDate: true,
info: 'Pension payment order — upload full document issued by pension authority.'
},
CERT_INCORPORATION: {
sides: 1,
category: 'Entity KYC documents',
documentKeyLabel: 'Registration / incorporation number',
documentKeyPlaceholder: 'CIN / registration number',
info: 'Certificate of Incorporation (companies/LLPs) or Registration Certificate (firms/trusts/societies) — as prescribed under the RBI Master Direction on KYC, Annexure I.'
},
ENTITY_PAN: {
sides: 1,
category: 'Entity KYC documents',
documentKeyLabel: 'PAN of entity',
documentKeyPlaceholder: 'e.g. ABCDE1234F',
info: 'PAN card issued in the name of the entity.'
},
CONSTITUTIVE_DOC: {
sides: 1,
category: 'Entity KYC documents',
documentKeyLabel: 'Document reference number',
documentKeyPlaceholder: 'Reference / registration number',
info: 'Constitutive document — Memorandum & Articles of Association (companies), Partnership Deed (firms), Trust Deed (trusts), or By-laws (societies) — as prescribed under the RBI Master Direction on KYC.'
},
BOARD_RESOLUTION: {
sides: 1,
category: 'Entity KYC documents',
info: 'Board Resolution / Authorization Letter naming the officers, managers or employees authorised to transact on the entity’s behalf.'
},
AUTH_SIGNATORY_KYC: {
sides: 1,
category: 'Entity KYC documents',
info: 'OVD-based KYC of the person(s) holding Power of Attorney / authorised to transact on the entity’s behalf.'
},
GST_CERTIFICATE: {
sides: 1,
category: 'Entity KYC documents',
documentKeyLabel: 'GSTIN',
documentKeyPlaceholder: 'e.g. 22AAAAA0000A1Z5',
info: 'GST Registration Certificate, where the entity is GST-registered.'
}
};
@@ -392,6 +392,22 @@ export class AuthenticationService {
return roleNames.some((n) => n === 'clerk');
}
/** Returns true when the logged-in user has the "Principal Officer" role (STR conclusion/filing authority). */
isPrincipalOfficerUser(): boolean {
const creds = this.getCredentials();
if (!creds || !creds.roles) return false;
const roleNames: string[] = (creds.roles as any[]).map((r) => (r.name || r.role || '').toLowerCase().trim());
return roleNames.some((n) => n === 'principal officer');
}
/** Returns true when the logged-in user has the "AML Officer" role (first-level alert review). */
isAmlOfficerUser(): boolean {
const creds = this.getCredentials();
if (!creds || !creds.roles) return false;
const roleNames: string[] = (creds.roles as any[]).map((r) => (r.name || r.role || '').toLowerCase().trim());
return roleNames.some((n) => n === 'aml officer');
}
/**
* Returns true ONLY for regular cashier role (not Head Cashier).
* Regular cashier → full-screen transaction entry, no nav.
@@ -424,6 +440,18 @@ export class AuthenticationService {
return roleNames.some((n) => n === 'cashier');
}
/**
* Returns true when the logged-in user belongs to the Head Office.
* Fineract seeds Head Office as officeId 1 with hierarchy "."; office name is
* checked too since some deployments rename/reseed the root office.
*/
isHeadOfficeUser(): boolean {
const creds = this.getCredentials();
if (!creds) return false;
if (creds.officeId === 1) return true;
return (creds.officeName || '').toLowerCase().includes('head office');
}
/**
* Sets the user credentials.
*
+98
View File
@@ -0,0 +1,98 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Injectable, inject } from '@angular/core';
import { AuthenticationService } from './authentication/authentication.service';
import { GL_BRANCHES, GlBranch } from 'app/gl-reports/gl-reports-data';
/**
* Resolves what branch data the logged-in user is allowed to see.
*
* Head Office sees every branch and can drill into any one of them; a branch login is
* locked to its own book. Previously this logic was copy-pasted into the Cash/Clearing
* Summary and the NPA report with slightly different branch code lists; this service is
* the single place it lives so new reports cannot drift.
*
* The logged-in office is matched to a branch by name, since session credentials carry
* only officeId and officeName. Office names are seeded to match the GL branch names
* exactly (see Liquibase 0442).
*/
@Injectable({ providedIn: 'root' })
export class BranchScopeService {
private auth = inject(AuthenticationService);
/** Sentinel used when a branch login cannot be matched to a known branch. */
static readonly UNRESOLVED = '__unresolved__';
get isHeadOffice(): boolean {
return this.auth.isHeadOfficeUser();
}
/** The office name on the session, for labelling the scope in report headers. */
get officeName(): string {
return this.auth.getCredentials()?.officeName || 'Branch Office';
}
/**
* Branch code the user is confined to, or null for Head Office.
*
* Deliberately returns the UNRESOLVED sentinel rather than falling back to the first
* branch when the office cannot be matched: showing an unrecognised branch someone
* else's figures would be worse than showing them nothing.
*/
get ownBranchCode(): string | null {
if (this.isHeadOffice) {
return null;
}
const name = this.officeName.toLowerCase().trim();
const match = GL_BRANCHES.find((b) => b.code !== 'HO' && name === b.name.toLowerCase());
// Fall back to a substring match for offices named e.g. "Andheri (E) Branch".
const loose = match ?? GL_BRANCHES.find((b) => b.code !== 'HO' && name.includes(b.name.toLowerCase()));
return loose?.code ?? BranchScopeService.UNRESOLVED;
}
/** True when the user may choose which branch to view. */
get canSelectBranch(): boolean {
return this.isHeadOffice;
}
/** Branches offered in a selector — everything for Head Office, nothing for a branch login. */
get selectableBranches(): GlBranch[] {
return this.isHeadOffice ? GL_BRANCHES : [];
}
/** Branch code a report should start on. '' means all branches consolidated. */
get defaultBranchCode(): string {
return this.isHeadOffice ? '' : (this.ownBranchCode ?? '');
}
/**
* Forces a requested branch code into what the user is actually allowed to see.
* Call this wherever a branch code reaches a data query, so a stale or hand-edited
* selection cannot widen the user's scope.
*/
enforce(requested: string): string {
return this.isHeadOffice ? requested : (this.ownBranchCode ?? BranchScopeService.UNRESOLVED);
}
/** True when a branch login's office matched no known branch, so it sees no data. */
get isUnresolved(): boolean {
return this.ownBranchCode === BranchScopeService.UNRESOLVED;
}
/** Human label for the current scope, for report headers. */
scopeLabel(selectedCode: string): string {
if (!this.isHeadOffice) {
return this.officeName;
}
if (!selectedCode) {
return 'All Branches (Consolidated)';
}
return GL_BRANCHES.find((b) => b.code === selectedCode)?.name ?? selectedCode;
}
}
+24 -2
View File
@@ -21,6 +21,7 @@ import { environment } from '../../../environments/environment';
import { Logger } from '../logger/logger.service';
import { AlertService } from '../alert/alert.service';
import { TranslateService } from '@ngx-translate/core'; // Added import for TranslateService
import { SILENT_HTTP_ERROR } from './silent-error.token';
/** Initialize Logger */
const log = new Logger('ErrorHandlerInterceptor');
@@ -54,12 +55,33 @@ export class ErrorHandlerInterceptor implements HttpInterceptor {
const isClientImageRequest = request.url.includes('/clients/') && request.url.includes('/images');
const isClientKycRequest = request.url.match(/\/clients\/\d+\/kyc$/);
const isDirectorRequest = request.url.includes('/election/directors');
const isSilentRequest = isClientImageRequest || isClientKycRequest || isDirectorRequest;
// "No pending edit for this client" is the normal/expected state — checked on every edit-page
// load, not a real error the user needs to see.
const isClientPendingEditRequest = request.url.match(/\/clients\/\d+\/edit\/pending$/);
// Risk rules are an enhancement on top of a default risk-category fallback — if this
// 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');
const isSilentRequest =
isClientImageRequest ||
isClientKycRequest ||
isDirectorRequest ||
isClientPendingEditRequest ||
isKycRiskRulesRequest;
if (!environment.production && !isSilentRequest) {
// 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
// the caller took responsibility for handling failure when it set this.
const isSilentOverride = request.context.get(SILENT_HTTP_ERROR);
if (!environment.production && !isSilentRequest && !isSilentOverride) {
log.error(`Request Error: ${errorMessage}`);
}
if (isSilentOverride) {
throw response;
}
// Silently handle expected 403/404 errors (no image, no KYC record)
// Throw instead of EMPTY so component-level catchError can handle it
if (isSilentRequest && (status === 403 || status === 404)) {
+17
View File
@@ -0,0 +1,17 @@
/**
* 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 { HttpContextToken } from '@angular/common/http';
/**
* Per-request opt-out of ErrorHandlerInterceptor's toast. Set via
* `{ context: new HttpContext().set(SILENT_HTTP_ERROR, true) }` on calls whose caller already
* handles failures itself (e.g. per-row enrichment fetches on a list page) — the interceptor
* still throws so the caller's own catchError/error callback runs, it just skips the alert.
*/
export const SILENT_HTTP_ERROR = new HttpContextToken<boolean>(() => false);
+421 -76
View File
@@ -99,6 +99,30 @@
</svg>
My Disbursements
</a>
<a
class="cashier-nav__item cashier-nav__item--sec"
[class.cashier-nav__item--active]="currentUrl.includes('/clerk/share-withdrawal-request')"
[routerLink]="['/clerk/share-withdrawal-requests']"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="9" />
<path
d="M9.5 9a2.5 2.5 0 0 1 2.5-1.5c1.4 0 2.5.8 2.5 2s-1 1.5-2.5 1.9c-1.5.4-2.5.9-2.5 2.1s1.1 2 2.5 2 2.5-.7 2.5-1.6"
/>
</svg>
My Share Requests
</a>
<a
class="cashier-nav__item cashier-nav__item--sec"
[class.cashier-nav__item--active]="currentUrl.includes('/clerk/share-closure-request')"
[routerLink]="['/clerk/share-closure-requests']"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="4" y="11" width="16" height="9" rx="2" />
<path d="M8 11V7a4 4 0 0 1 8 0v4" />
</svg>
My Share Closures
</a>
@if (clerkAccountLoaded) {
<div class="cashier-nav__item-wrap">
<a
@@ -350,6 +374,10 @@
<fa-icon icon="exclamation-triangle" class="ni-ico"></fa-icon>NPA
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a>
<a class="ni" tabindex="0" [matMenuTriggerFor]="loanOriginationMenu" *mifosxHasPermission="'MENU_ADVANCES'">
<fa-icon icon="clipboard-check" class="ni-ico"></fa-icon>Loan Origination
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a>
<a class="ni" tabindex="0" [matMenuTriggerFor]="advancesMenu" *mifosxHasPermission="'MENU_ADVANCES'">
<fa-icon icon="hand-holding-usd" class="ni-ico"></fa-icon>Advances
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
@@ -362,6 +390,10 @@
<fa-icon icon="university" class="ni-ico"></fa-icon>RBI
<fa-icon [icon]="rbiMenuOpen ? 'caret-up' : 'caret-down'" class="ni-caret"></fa-icon>
</a>
<a class="ni" tabindex="0" (click)="investmentMenuOpen = !investmentMenuOpen; rbiMenuOpen = false; glMenuOpen = false" *mifosxHasPermission="'MENU_RBI'">
<fa-icon icon="chart-line" class="ni-ico"></fa-icon>Investment
<fa-icon [icon]="investmentMenuOpen ? 'caret-up' : 'caret-down'" class="ni-caret"></fa-icon>
</a>
<a class="ni" tabindex="0" [matMenuTriggerFor]="digitalMenu" *mifosxHasPermission="'MENU_DIGITAL'">
<fa-icon icon="mobile-alt" class="ni-ico"></fa-icon>Digital
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
@@ -438,6 +470,10 @@
<fa-icon icon="exclamation-triangle" class="ni-ico"></fa-icon>NPA
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a>
<a class="ni" tabindex="0" [matMenuTriggerFor]="loanOriginationMenu" *mifosxHasPermission="'MENU_ADVANCES'">
<fa-icon icon="clipboard-check" class="ni-ico"></fa-icon>Loan Origination
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a>
<a
class="ni"
tabindex="0"
@@ -492,6 +528,15 @@
<fa-icon icon="university" class="ni-ico"></fa-icon>RBI
<fa-icon [icon]="rbiMenuOpen ? 'caret-up' : 'caret-down'" class="ni-caret"></fa-icon>
</a>
<a
class="ni"
tabindex="0"
(click)="investmentMenuOpen = !investmentMenuOpen; rbiMenuOpen = false; glMenuOpen = false"
*mifosxHasPermission="'MENU_RBI'"
>
<fa-icon icon="chart-line" class="ni-ico"></fa-icon>Investment
<fa-icon [icon]="investmentMenuOpen ? 'caret-up' : 'caret-down'" class="ni-caret"></fa-icon>
</a>
<a class="ni" tabindex="0" [matMenuTriggerFor]="reportsMenu" *mifosxHasPermission="'MENU_REPORTS'">
<fa-icon icon="chart-bar" class="ni-ico"></fa-icon>Reports
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
@@ -566,6 +611,19 @@
<button mat-menu-item [routerLink]="['/reports', 'Accounting']" tabindex="0">
<i class="fa fa-calculator menu-item-icon"></i> Accounting
</button>
<mat-divider></mat-divider>
<button mat-menu-item routerLink="/reports/form-97-98" tabindex="0">
<i class="fa fa-file-invoice menu-item-icon"></i> Form 97/98 Transaction Report
</button>
<button mat-menu-item routerLink="/reports/risk-review" tabindex="0">
<i class="fa fa-user-shield menu-item-icon"></i> Risk Review Report
</button>
<button mat-menu-item routerLink="/reports/tds-interest" tabindex="0">
<i class="fa fa-percent menu-item-icon"></i> Deposit Interest &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>
</mat-menu>
<!-- Admin Mega Menu — 3-column rectangular panel grouping Setup,
@@ -653,6 +711,7 @@
<a
class="gl-link"
[class.hidden]="!matchesSubLedgerSearch('Cash / Clearing / Transfer')"
routerLink="/cashier/cash-clearing-transfer-summary"
(click)="subLedgerOpen = false"
><fa-icon icon="money-bill" class="gl-icon"></fa-icon> Cash / Clearing / Transfer</a
>
@@ -701,8 +760,11 @@
</div>
<div class="gl-group">
<div class="gl-group-title">Deposits</div>
<a class="gl-link" [class.hidden]="!matchesSubLedgerSearch('FD / RD Entry')" (click)="subLedgerOpen = false"
><fa-icon icon="piggy-bank" class="gl-icon"></fa-icon> FD / RD Entry</a
<a
class="gl-link"
[class.hidden]="!matchesSubLedgerSearch('FD / RD Entry')"
(click)="subLedgerOpen = false; openFdRdEntryDialog()"
><fa-icon icon="piggy-bank" class="gl-icon" style="color: #0f766e"></fa-icon> FD / RD Entry</a
>
<a class="gl-link" [class.hidden]="!matchesSubLedgerSearch('DDS Entry')" (click)="subLedgerOpen = false"
><fa-icon icon="coins" class="gl-icon"></fa-icon> DDS Entry</a
@@ -808,50 +870,72 @@
<div class="gl-columns">
<div class="gl-group">
<div class="gl-group-title">Financial Statements</div>
<a class="gl-link" [class.hidden]="!matchesGlSearch('Trial Balance')" (click)="glMenuOpen = false"
><fa-icon icon="balance-scale" class="gl-icon"></fa-icon> Trial Balance</a
<a
class="gl-link"
[class.hidden]="!matchesGlSearch('Trial Balance')"
routerLink="/gl/reports/trial-balance"
(click)="glMenuOpen = false"
><fa-icon icon="balance-scale" class="gl-icon" style="color: #b45309"></fa-icon> Trial Balance</a
>
<a
class="gl-link"
[class.hidden]="!matchesGlSearch('Profit and Loss')"
(click)="glMenuOpen = false; openAccountingReportsDialog('PNL')"
routerLink="/gl/reports/profit-and-loss"
(click)="glMenuOpen = false"
><fa-icon icon="chart-line" class="gl-icon" style="color: #b45309"></fa-icon> Profit and Loss</a
>
<a
class="gl-link"
[class.hidden]="!matchesGlSearch('Consolidate Balance Sheet')"
(click)="glMenuOpen = false; openAccountingReportsDialog('BALANCE-SHEET')"
><fa-icon icon="file-alt" class="gl-icon" style="color: #b45309"></fa-icon> Consolidate Balance Sheet</a
routerLink="/gl/reports/balance-sheet"
(click)="glMenuOpen = false"
><fa-icon icon="file-alt" class="gl-icon" style="color: #b45309"></fa-icon> Consolidated Balance Sheet</a
>
<a
class="gl-link"
[class.hidden]="!matchesGlSearch('Comparative Balance Sheet')"
(click)="glMenuOpen = false; openAccountingReportsDialog('COMPARATIVE-BS')"
routerLink="/gl/reports/comparative-balance-sheet"
(click)="glMenuOpen = false"
><fa-icon icon="balance-scale" class="gl-icon" style="color: #b45309"></fa-icon> Comparative Balance
Sheet</a
>
</div>
<div class="gl-group">
<div class="gl-group-title">GL Reports</div>
<a class="gl-link" [class.hidden]="!matchesGlSearch('GL Average Balance')" (click)="glMenuOpen = false"
><fa-icon icon="calculator" class="gl-icon"></fa-icon> GL Average Balance</a
<a
class="gl-link"
[class.hidden]="!matchesGlSearch('GL Average Balance')"
routerLink="/gl/reports/gl-average-balance"
(click)="glMenuOpen = false"
><fa-icon icon="calculator" class="gl-icon" style="color: #b45309"></fa-icon> GL Average Balance</a
>
<a class="gl-link" [class.hidden]="!matchesGlSearch('GL Mapping for Report')" (click)="glMenuOpen = false"
><fa-icon icon="project-diagram" class="gl-icon"></fa-icon> GL Mapping for Report</a
<a
class="gl-link"
[class.hidden]="!matchesGlSearch('GL Mapping for Report')"
routerLink="/gl/reports/gl-mapping"
(click)="glMenuOpen = false"
><fa-icon icon="project-diagram" class="gl-icon" style="color: #b45309"></fa-icon> GL Mapping for
Report</a
>
<a
class="gl-link"
[class.hidden]="!matchesGlSearch('Account wise Balance List')"
routerLink="/gl/reports/account-wise-balance"
(click)="glMenuOpen = false"
><fa-icon icon="list" class="gl-icon"></fa-icon> Account wise Balance List</a
><fa-icon icon="list" class="gl-icon" style="color: #b45309"></fa-icon> Account wise Balance List</a
>
<a class="gl-link" [class.hidden]="!matchesGlSearch('Distribution of Balance')" (click)="glMenuOpen = false"
><fa-icon icon="chart-pie" class="gl-icon"></fa-icon> Distribution of Balance</a
<a
class="gl-link"
[class.hidden]="!matchesGlSearch('Distribution of Balance')"
routerLink="/gl/reports/distribution-of-balance"
(click)="glMenuOpen = false"
><fa-icon icon="chart-pie" class="gl-icon" style="color: #b45309"></fa-icon> Distribution of Balance</a
>
<a
class="gl-link"
[class.hidden]="!matchesGlSearch('GL Wise DR CR')"
(click)="glMenuOpen = false; openAccountingReportsDialog('GL-DR-CR')"
routerLink="/gl/reports/gl-wise-dr-cr"
(click)="glMenuOpen = false"
><fa-icon icon="project-diagram" class="gl-icon" style="color: #b45309"></fa-icon> GL Wise DR / CR
Report</a
>
@@ -861,28 +945,47 @@
<a
class="gl-link"
[class.hidden]="!matchesGlSearch('Client wise Balance Report')"
routerLink="/gl/reports/client-wise-balance"
(click)="glMenuOpen = false"
><fa-icon icon="users" class="gl-icon"></fa-icon> Client wise Balance Report</a
><fa-icon icon="users" class="gl-icon" style="color: #b45309"></fa-icon> Client wise Balance Report</a
>
<a class="gl-link" [class.hidden]="!matchesGlSearch('Contingent Expense')" (click)="glMenuOpen = false"
><fa-icon icon="exclamation-circle" class="gl-icon"></fa-icon> Contingent Expense</a
<a
class="gl-link"
[class.hidden]="!matchesGlSearch('Contingent Liabilities')"
routerLink="/gl/reports/contingent-liabilities"
(click)="glMenuOpen = false"
><fa-icon icon="exclamation-circle" class="gl-icon" style="color: #b45309"></fa-icon> Contingent
Liabilities</a
>
<a class="gl-link" [class.hidden]="!matchesGlSearch('Banker Head Report')" (click)="glMenuOpen = false"
><fa-icon icon="university" class="gl-icon"></fa-icon> Banker Head Report</a
<a
class="gl-link"
[class.hidden]="!matchesGlSearch('Banker Head Report')"
routerLink="/gl/reports/banker-head"
(click)="glMenuOpen = false"
><fa-icon icon="university" class="gl-icon" style="color: #b45309"></fa-icon> Banker Head Report</a
>
</div>
<div class="gl-group">
<div class="gl-group-title">Registers</div>
<a class="gl-link" [class.hidden]="!matchesGlSearch('Sundry Register')" (click)="glMenuOpen = false"
><fa-icon icon="clipboard-list" class="gl-icon"></fa-icon> Sundry Register</a
<a
class="gl-link"
[class.hidden]="!matchesGlSearch('Sundry Register')"
routerLink="/gl/reports/sundry-register"
(click)="glMenuOpen = false"
><fa-icon icon="clipboard-list" class="gl-icon" style="color: #b45309"></fa-icon> Sundry Register</a
>
<a class="gl-link" [class.hidden]="!matchesGlSearch('Voucher Register')" (click)="glMenuOpen = false"
><fa-icon icon="receipt" class="gl-icon"></fa-icon> Voucher Register</a
<a
class="gl-link"
[class.hidden]="!matchesGlSearch('Voucher Register')"
routerLink="/gl/reports/voucher-register"
(click)="glMenuOpen = false"
><fa-icon icon="receipt" class="gl-icon" style="color: #b45309"></fa-icon> Voucher Register</a
>
<a
class="gl-link"
[class.hidden]="!matchesGlSearch('Misc Expense Ledger')"
(click)="glMenuOpen = false; openAccountingReportsDialog('MISC-EXP')"
routerLink="/gl/reports/misc-expense-ledger"
(click)="glMenuOpen = false"
><fa-icon icon="receipt" class="gl-icon" style="color: #b45309"></fa-icon> Misc Expense Ledger</a
>
</div>
@@ -1121,7 +1224,7 @@
<button mat-menu-item tabindex="0">
<fa-icon icon="check-circle" class="menu-item-icon"></fa-icon> Share Issued
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" [routerLink]="['/shares/share-holders']">
<fa-icon icon="users" class="menu-item-icon"></fa-icon> Share Holder
</button>
<button mat-menu-item tabindex="0">
@@ -1155,7 +1258,7 @@
<fa-icon icon="user-slash" class="menu-item-icon" style="color: #c2410c"></fa-icon> Non-Customer Entry
</button>
<button mat-menu-item tabindex="0"><fa-icon icon="lock" class="menu-item-icon"></fa-icon> Locker</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item routerLink="/investment/bonds" tabindex="0">
<fa-icon icon="chart-line" class="menu-item-icon"></fa-icon> SLR and Non SLR Investment Report
</button>
<button mat-menu-item tabindex="0" (click)="openCicReportingDialog()">
@@ -1167,21 +1270,17 @@
<button mat-menu-item tabindex="0">
<fa-icon icon="chart-bar" class="menu-item-icon"></fa-icon> Depreciation Register
</button>
<button mat-menu-item routerLink="/fiu" tabindex="0">
<fa-icon icon="shield-alt" class="menu-item-icon" style="color: #dc2626"></fa-icon> Financial Intelligence
Unit (FIU-IND)
</button>
</div>
<!-- Investment -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Investment</div>
<button mat-menu-item tabindex="0">
<button mat-menu-item routerLink="/investment/dashboard" tabindex="0">
<fa-icon icon="calendar-alt" class="menu-item-icon"></fa-icon> Due Date Diary for All Investment
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item routerLink="/investment/reports" tabindex="0">
<fa-icon icon="dollar-sign" class="menu-item-icon"></fa-icon> Total Int Received &amp; Receivable Report
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item routerLink="/investment/reports" tabindex="0">
<fa-icon icon="piggy-bank" class="menu-item-icon"></fa-icon> Bucket Wise Investment
</button>
<button mat-menu-item routerLink="/deaf" tabindex="0">
@@ -1212,39 +1311,96 @@
<!-- NPA Reports -->
<div class="deposit-menu-group">
<div class="deposit-group-title">NPA Reports</div>
<button mat-menu-item tabindex="0">
<fa-icon icon="chart-line" class="menu-item-icon"></fa-icon> NPA Interest
<button mat-menu-item tabindex="0" routerLink="/npa/reports/interest">
<fa-icon icon="chart-line" class="menu-item-icon"></fa-icon> NPA Report
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" routerLink="/npa/reports/status">
<fa-icon icon="file-alt" class="menu-item-icon"></fa-icon> NPA Status Report
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" routerLink="/npa/reports/recovery">
<fa-icon icon="undo" class="menu-item-icon"></fa-icon> NPA Recovery Report
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" routerLink="/npa/reports/summary">
<fa-icon icon="clipboard-list" class="menu-item-icon"></fa-icon> NPA Summary Report
</button>
</div>
<!-- NPA Analysis -->
<div class="deposit-menu-group">
<div class="deposit-group-title">NPA Analysis</div>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" routerLink="/npa/reports/account-detail">
<fa-icon icon="list-alt" class="menu-item-icon"></fa-icon> NPA Account Detail Report
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" routerLink="/npa/reports/movement">
<fa-icon icon="exchange" class="menu-item-icon"></fa-icon> NPA Movement Report
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" routerLink="/npa/reports/provision">
<fa-icon icon="hand-holding-usd" class="menu-item-icon"></fa-icon> NPA with Provision and Required Provision
</button>
<button mat-menu-item tabindex="0" routerLink="/npa/reports/undebited-interest">
<fa-icon icon="coins" class="menu-item-icon"></fa-icon> Undebited Interest Recovery Report
</button>
</div>
<!-- NPA Classification -->
<div class="deposit-menu-group">
<div class="deposit-group-title">NPA Classification</div>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" routerLink="/npa/reports/probable">
<fa-icon icon="exclamation-circle" class="menu-item-icon"></fa-icon> Probable NPA
</button>
<button mat-menu-item tabindex="0"><fa-icon icon="flag" class="menu-item-icon"></fa-icon> SMA Report</button>
<button mat-menu-item tabindex="0" routerLink="/npa/reports/sma">
<fa-icon icon="flag" class="menu-item-icon"></fa-icon> SMA Report
</button>
</div>
<!-- Recovery Management -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Recovery Management</div>
<button mat-menu-item tabindex="0" routerLink="/npa/reports/recovery-dashboard">
<fa-icon icon="tachometer-alt" class="menu-item-icon"></fa-icon> Recovery Management Dashboard
</button>
<button mat-menu-item tabindex="0" routerLink="/npa/reports/daywise-overdue">
<fa-icon icon="calendar-alt" class="menu-item-icon"></fa-icon> Day-wise Overdue Report
</button>
<button mat-menu-item tabindex="0" routerLink="/npa/reports/daywise-overdrawn">
<fa-icon icon="credit-card" class="menu-item-icon"></fa-icon> Day-wise Overdrawn Report
</button>
<button mat-menu-item tabindex="0" routerLink="/npa/reports/agri-gold-value">
<fa-icon icon="crop" class="menu-item-icon"></fa-icon> Agriculture / Gold Market Value Report
</button>
<button mat-menu-item tabindex="0" routerLink="/npa/reports/exception-check">
<fa-icon icon="balance-scale" class="menu-item-icon"></fa-icon> Exception Check — Security Value vs Loan
</button>
</div>
</div>
</mat-menu>
<mat-menu #loanOriginationMenu="matMenu" class="npa-mega-panel" [overlapTrigger]="false">
<div class="deposit-menu-grid npa-grid" (click)="$event.stopPropagation()">
<!-- Loan Origination -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Loan Origination</div>
<button mat-menu-item tabindex="0" routerLink="/loan-origination/pipeline">
<fa-icon icon="stream" class="menu-item-icon"></fa-icon> Application Pipeline
</button>
</div>
<!-- Reports -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Reports</div>
<button
mat-menu-item
tabindex="0"
routerLink="/loan-origination/pipeline"
[queryParams]="{ view: 'channel' }"
>
<fa-icon icon="chart-pie" class="menu-item-icon"></fa-icon> Channel-wise Report
</button>
<button mat-menu-item tabindex="0" routerLink="/loan-origination/pipeline" [queryParams]="{ view: 'bc' }">
<fa-icon icon="mobile-alt" class="menu-item-icon"></fa-icon> BC-wise Report
</button>
<button mat-menu-item tabindex="0" routerLink="/loan-origination/pipeline" [queryParams]="{ view: 'share' }">
<fa-icon icon="coins" class="menu-item-icon"></fa-icon> Share Member Report
</button>
<button mat-menu-item tabindex="0" routerLink="/loan-origination/pipeline" [queryParams]="{ view: 'target' }">
<fa-icon icon="chart-column" class="menu-item-icon"></fa-icon> Target vs Actual
</button>
</div>
</div>
</mat-menu>
@@ -1277,27 +1433,27 @@
<!-- Core / Books -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Core &amp; Books</div>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" routerLink="/reconciliation/voucher">
<fa-icon icon="receipt" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Voucher Reconciliation
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" routerLink="/reconciliation/sundry-dr-cr">
<fa-icon icon="random" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Sundry Dr / Cr Reconciliation
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" routerLink="/reconciliation/pnl-impact">
<fa-icon icon="chart-line" class="menu-item-icon" style="color: #0d9488"></fa-icon>
P&amp;L Impact Reconciliation
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" routerLink="/reconciliation/gl-reconciliation">
<fa-icon icon="book-open" class="menu-item-icon" style="color: #0d9488"></fa-icon>
GL Reconciliation
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" routerLink="/reconciliation/fixed-assets">
<fa-icon icon="cube" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Fixed Assets Reconciliation
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" routerLink="/reconciliation/inter-company">
<fa-icon icon="sitemap" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Inter-Company Reconciliation
</button>
@@ -1306,19 +1462,19 @@
<!-- Bank & Clearing -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Bank &amp; Clearing</div>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" routerLink="/reconciliation/bank-reco">
<fa-icon icon="university" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Bank Reconciliation
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" routerLink="/reconciliation/api-bank-reco">
<fa-icon icon="code-branch" class="menu-item-icon" style="color: #0d9488"></fa-icon>
API Based Bank Reconciliation
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" routerLink="/reconciliation/neft-rtgs">
<fa-icon icon="exchange" class="menu-item-icon" style="color: #0d9488"></fa-icon>
NEFT / RTGS Reconciliation
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" routerLink="/reconciliation/clearing-reco">
<fa-icon icon="hand-holding-usd" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Clearing Reconciliation
</button>
@@ -1327,15 +1483,15 @@
<!-- Digital / Payments -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Digital &amp; Payments</div>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" routerLink="/reconciliation/udir">
<fa-icon icon="mobile-alt" class="menu-item-icon" style="color: #0d9488"></fa-icon>
UDIR (IMPS / UPI / ATM)
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" routerLink="/reconciliation/three-way-digital">
<fa-icon icon="project-diagram" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Three-way Digital Reconciliation
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" routerLink="/reconciliation/nach-reco">
<fa-icon icon="sync-alt" class="menu-item-icon" style="color: #0d9488"></fa-icon>
NACH Reconciliation
</button>
@@ -1348,7 +1504,7 @@
<fa-icon icon="money-check-alt" class="menu-item-icon" style="color: #0d9488"></fa-icon>
DD / Pay Order Reconciliation
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" routerLink="/reconciliation/share-member">
<fa-icon icon="coins" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Share Member Reconciliation
</button>
@@ -1357,11 +1513,11 @@
<!-- Compliance & Financials -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Compliance &amp; Financials</div>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" routerLink="/reconciliation/invoice-payment">
<fa-icon icon="file-invoice" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Invoice Payment
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" routerLink="/reconciliation/tax-reco">
<fa-icon icon="percentage" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Tax Reconciliation
</button>
@@ -1473,9 +1629,20 @@
<button mat-menu-item tabindex="0">
<fa-icon icon="file-signature" class="menu-item-icon" style="color: #15803d"></fa-icon> Form 98
</button>
<button mat-menu-item routerLink="/reports/form-97-98" tabindex="0">
<fa-icon icon="file-invoice" class="menu-item-icon" style="color: #15803d"></fa-icon> Form 97/98 Txn Report
</button>
<button mat-menu-item routerLink="/letter-mgmt/form-15gh" tabindex="0">
<fa-icon icon="file-signature" class="menu-item-icon" style="color: #15803d"></fa-icon> Form 121
</button>
<button mat-menu-item routerLink="/reports/tds-interest" tabindex="0">
<fa-icon icon="percentage" class="menu-item-icon" style="color: #15803d"></fa-icon> Deposit Interest &amp;
TDS
</button>
<button mat-menu-item routerLink="/reports/tds-remittance" tabindex="0">
<fa-icon icon="building-columns" class="menu-item-icon" style="color: #15803d"></fa-icon> TDS Remitted to
Govt
</button>
<button mat-menu-item routerLink="/letter-mgmt/hypothecation-certificate" tabindex="0">
<fa-icon icon="file-alt" class="menu-item-icon" style="color: #15803d"></fa-icon>
Hypothecation Certificate
@@ -1568,8 +1735,13 @@
<a class="gl-link" [class.hidden]="!matchesRbiSearch('Balance Sheet')" (click)="rbiMenuOpen = false">
<fa-icon icon="file-alt" class="gl-icon"></fa-icon> Balance Sheet (BR Act & Others)
</a>
<a class="gl-link" [class.hidden]="!matchesRbiSearch('CRR / SLR')" (click)="rbiMenuOpen = false">
<fa-icon icon="calculator" class="gl-icon"></fa-icon> CRR / SLR
<a
class="gl-link"
[class.hidden]="!matchesRbiSearch('CTR / STR')"
routerLink="/fiu"
(click)="rbiMenuOpen = false"
>
<fa-icon icon="calculator" class="gl-icon"></fa-icon> CTR / STR
</a>
<a class="gl-link" [class.hidden]="!matchesRbiSearch('Annexure')" (click)="rbiMenuOpen = false">
<fa-icon icon="paperclip" class="gl-icon"></fa-icon> Annexure
@@ -1664,31 +1836,49 @@
>
<fa-icon icon="bell" class="gl-icon"></fa-icon> Daily Position Notice
</a>
<a class="gl-link" [class.hidden]="!matchesRbiSearch('R/SLR')" (click)="rbiMenuOpen = false">
<fa-icon icon="lock" class="gl-icon"></fa-icon> R/SLR
<a
class="gl-link"
[class.hidden]="!matchesRbiSearch('AML')"
routerLink="/rbi/aml"
(click)="rbiMenuOpen = false"
>
<fa-icon icon="shield-alt" class="gl-icon"></fa-icon> AML
</a>
<a
class="gl-link"
[class.hidden]="!matchesRbiSearch('Risk Review Report')"
routerLink="/reports/risk-review"
(click)="rbiMenuOpen = false"
>
<fa-icon icon="user-shield" class="gl-icon"></fa-icon> Risk Review Report
</a>
</div>
<!-- OSS Reports -->
<div class="gl-group">
<div class="gl-group-title">OSS Reports</div>
<a class="gl-link" [class.hidden]="!matchesRbiSearch('OSS 1 XBRL/CIMS')" (click)="rbiMenuOpen = false">
<fa-icon icon="file-alt" class="gl-icon"></fa-icon> OSS 1 XBRL/CIMS
<a
class="gl-link"
[class.hidden]="!matchesRbiSearch('OSS 1 XBRL/CIMS')"
routerLink="/rbi/oss1"
(click)="rbiMenuOpen = false"
>
<fa-icon icon="file-alt" class="gl-icon" style="color: #0891b2"></fa-icon> OSS 1 XBRL/CIMS
</a>
<a
class="gl-link"
[class.hidden]="!matchesRbiSearch('OSS 2 XBRL')"
(click)="rbiMenuOpen = false; openCimsXbrlDialog('OSS-2')"
routerLink="/rbi/oss2"
(click)="rbiMenuOpen = false"
>
<fa-icon icon="file-alt" class="gl-icon" style="color: #0891b2"></fa-icon> OSS 2 XBRL
</a>
<a class="gl-link" [class.hidden]="!matchesRbiSearch('OSS 3 XBRL/CIMS')" (click)="rbiMenuOpen = false">
<fa-icon icon="file-alt" class="gl-icon"></fa-icon> OSS 3 XBRL/CIMS
</a>
<a class="gl-link" [class.hidden]="!matchesRbiSearch('OSS 4/OSS 5/OSS 6')" (click)="rbiMenuOpen = false">
<fa-icon icon="file-alt" class="gl-icon"></fa-icon> OSS 4 / OSS 5 / OSS 6
</a>
<a class="gl-link" [class.hidden]="!matchesRbiSearch('OSS 7')" (click)="rbiMenuOpen = false">
<fa-icon icon="file-alt" class="gl-icon"></fa-icon> OSS 7
<a
class="gl-link"
[class.hidden]="!matchesRbiSearch('OSS 3 XBRL/CIMS')"
routerLink="/rbi/oss3"
(click)="rbiMenuOpen = false"
>
<fa-icon icon="file-alt" class="gl-icon" style="color: #0891b2"></fa-icon> OSS 3 XBRL/CIMS
</a>
</div>
<!-- CIMS / XBRL Returns -->
@@ -1819,6 +2009,161 @@
</div>
}
<!-- Investment (Treasury) Mega Menu -->
@if (investmentMenuOpen) {
<div class="gl-backdrop" (click)="investmentMenuOpen = false"></div>
<div class="gl-mega-menu rbi-mega-menu">
<div class="gl-mega-menu-header">
<fa-icon icon="chart-line" class="gl-mega-menu-header-icon"></fa-icon>
<span>Investment</span>
</div>
<input
type="text"
class="gl-search"
placeholder="Search Investment..."
(input)="investmentSearch = $any($event.target).value"
/>
<div class="rbi-columns">
<!-- Dashboard -->
<div class="gl-group">
<div class="gl-group-title">Dashboard</div>
<a
class="gl-link"
[class.hidden]="!matchesInvestmentSearch('Investment Dashboard')"
routerLink="/investment/dashboard"
(click)="investmentMenuOpen = false"
>
<fa-icon icon="chart-pie" class="gl-icon"></fa-icon> Portfolio Dashboard
</a>
<a
class="gl-link"
[class.hidden]="!matchesInvestmentSearch('Due Date Diary for All Investment')"
routerLink="/investment/dashboard"
(click)="investmentMenuOpen = false"
>
<fa-icon icon="calendar-check" class="gl-icon"></fa-icon> Due Date Diary for All Investment
</a>
<a
class="gl-link"
[class.hidden]="!matchesInvestmentSearch('Bucket Wise Investment')"
routerLink="/investment/reports"
(click)="investmentMenuOpen = false"
>
<fa-icon icon="layer-group" class="gl-icon"></fa-icon> Bucket Wise Investment
</a>
</div>
<!-- Entry -->
<div class="gl-group">
<div class="gl-group-title">Entry</div>
<a
class="gl-link"
[class.hidden]="!matchesInvestmentSearch('FD Investment')"
routerLink="/investment/fd"
(click)="investmentMenuOpen = false"
>
<fa-icon icon="piggy-bank" class="gl-icon"></fa-icon> FD Investment
</a>
<a
class="gl-link"
[class.hidden]="!matchesInvestmentSearch('Govt Security / Non-SLR / Bond / Mutual Fund')"
routerLink="/investment/bonds"
(click)="investmentMenuOpen = false"
>
<fa-icon icon="university" class="gl-icon"></fa-icon> Govt Security / Non-SLR / Bond / Mutual Fund
</a>
<a
class="gl-link"
[class.hidden]="!matchesInvestmentSearch('SLR and Non SLR Investment Report')"
routerLink="/investment/bonds"
(click)="investmentMenuOpen = false"
>
<fa-icon icon="balance-scale" class="gl-icon"></fa-icon> SLR and Non SLR Investment Report
</a>
<a
class="gl-link"
[class.hidden]="!matchesInvestmentSearch('Share Entry')"
routerLink="/investment/share"
(click)="investmentMenuOpen = false"
>
<fa-icon icon="chart-line" class="gl-icon"></fa-icon> Share Entry
</a>
</div>
<!-- Masters -->
<div class="gl-group">
<div class="gl-group-title">Masters</div>
<a
class="gl-link"
[class.hidden]="
!matchesInvestmentSearch(
'Bank Party Script Broker SubType Control Parameter Daily Script LIEN Reason Mutual Fund Product SGL Bank Script Market Detail Masters'
)
"
routerLink="/investment/masters"
(click)="investmentMenuOpen = false"
>
<fa-icon icon="cogs" class="gl-icon"></fa-icon> Bank/Party, Script, Broker &amp; Other Masters
</a>
</div>
<!-- Interest -->
<div class="gl-group">
<div class="gl-group-title">Interest</div>
<a
class="gl-link"
[class.hidden]="!matchesInvestmentSearch('FD Interest Calculation')"
routerLink="/investment/interest"
(click)="investmentMenuOpen = false"
>
<fa-icon icon="calculator" class="gl-icon"></fa-icon> FD Interest Calculation
</a>
<a
class="gl-link"
[class.hidden]="!matchesInvestmentSearch('Govt Bond Interest Calculation')"
routerLink="/investment/interest"
(click)="investmentMenuOpen = false"
>
<fa-icon icon="calculator" class="gl-icon"></fa-icon> Govt./Bond Int. Calculation
</a>
<a
class="gl-link"
[class.hidden]="!matchesInvestmentSearch('Government Bond Int Register FD Interest Register FD Register')"
routerLink="/investment/interest"
(click)="investmentMenuOpen = false"
>
<fa-icon icon="list-alt" class="gl-icon"></fa-icon> Bond Int / FD Int / FD Registers
</a>
</div>
<!-- Reports -->
<div class="gl-group">
<div class="gl-group-title">Reports</div>
<a
class="gl-link"
[class.hidden]="!matchesInvestmentSearch('Balance Ledger Maturity Investment Reports')"
routerLink="/investment/reports"
(click)="investmentMenuOpen = false"
>
<fa-icon icon="file-invoice" class="gl-icon"></fa-icon> Balance / Ledger / Maturity Reports
</a>
<a
class="gl-link"
[class.hidden]="!matchesInvestmentSearch('Total Int Received & Receivable Report')"
routerLink="/investment/reports"
(click)="investmentMenuOpen = false"
>
<fa-icon icon="hand-holding-usd" class="gl-icon"></fa-icon> Total Int Received &amp; Receivable Report
</a>
<a
class="gl-link"
[class.hidden]="!matchesInvestmentSearch('Investment Portfolio')"
routerLink="/investment/reports"
(click)="investmentMenuOpen = false"
>
<fa-icon icon="briefcase" class="gl-icon"></fa-icon> Investment Portfolio
</a>
</div>
</div>
</div>
}
<!-- Progress Bar -->
@if (progressBarMode !== 'none') {
<div>
+41
View File
@@ -677,6 +677,26 @@ $gray-700: #334155;
opacity: 0.7;
}
// Marks a `mat-menu-item` as actually wired to a working screen/dialog (vs a still-unwired
// placeholder) — same "green dot" convention as .gl-link--live, positioned absolutely since
// mat-menu-item's internal layout isn't a plain flex row we control directly.
[mat-menu-item].menu-item-live {
position: relative;
&::after {
content: '';
position: absolute;
right: 14px;
top: 50%;
transform: translateY(-50%);
width: 6px;
height: 6px;
border-radius: 50%;
background: #16a34a;
box-shadow: 0 0 0 2px rgb(22 163 74 / 15%);
}
}
// ===== Nav Dropdown Menus =====
::ng-deep .nav-dropdown {
border-radius: 8px !important;
@@ -729,6 +749,13 @@ $gray-700: #334155;
}
}
/* NPA Mega Panel — 4 columns (Reports / Analysis / Classification / Recovery Management) */
.npa-grid {
grid-template-columns: repeat(4, minmax(200px, 1fr));
min-width: 880px;
max-width: 96vw;
}
/* Reconciliation Mega Panel — 5 columns, teal accent to match the icon palette */
.reconciliation-grid {
grid-template-columns: repeat(5, minmax(190px, 1fr));
@@ -856,6 +883,20 @@ $gray-700: #334155;
}
}
// Marks a menu link as actually wired to a working screen/dialog (vs a still-unwired
// placeholder) — a small green dot at the end of the row, so a reviewer can tell at a glance
// which items in a mega-menu are functional without having to click every one of them.
.gl-link--live::after {
content: '';
width: 6px;
height: 6px;
margin-left: auto;
flex-shrink: 0;
border-radius: 50%;
background: #16a34a;
box-shadow: 0 0 0 2px rgb(22 163 74 / 15%);
}
/* RBI Mega Menu */
.rbi-mega-menu {
width: 1100px;
+35 -4
View File
@@ -98,6 +98,10 @@ export class ShellComponent implements OnInit, OnDestroy {
rbiMenuOpen = false;
rbiSearch = '';
/** Investment (Treasury) mega menu state */
investmentMenuOpen = false;
investmentSearch = '';
/* Live KPI ticker — markup is static inside the template; the marquee
animation, LIVE badge, and pause-on-hover handle the live feel. */
@@ -116,6 +120,11 @@ export class ShellComponent implements OnInit, OnDestroy {
return label.toLowerCase().includes(this.rbiSearch.toLowerCase());
}
matchesInvestmentSearch(label: string): boolean {
if (!this.investmentSearch) return true;
return label.toLowerCase().includes(this.investmentSearch.toLowerCase());
}
/** Route for a facility entry in the Account Facilities hover dropdown — only "Cheque Book" has a
* dedicated action page today; every other facility falls back to the existing overview page. */
facilityRoute(label: string): string[] {
@@ -179,9 +188,9 @@ export class ShellComponent implements OnInit, OnDestroy {
return this.router.url;
}
/** True on the manager's full-screen withdrawal-request review screen. */
/** True on the manager's full-screen withdrawal-request (cash or share) review screen. */
get isWithdrawalReviewUrl(): boolean {
return /\/checker-inbox-and-tasks\/withdrawal-requests\/\d+\/review/.test(this.router.url);
return /\/checker-inbox-and-tasks\/(share-)?withdrawal-requests\/\d+\/review/.test(this.router.url);
}
/** ESC key — exit fullscreen at Clerk Dashboard / Election / review screen, restoring full shell header. */
@@ -242,7 +251,7 @@ export class ShellComponent implements OnInit, OnDestroy {
* GL Wise DR/CR / Misc Expense Ledger). Initial report selected via menu link.
*/
async openAccountingReportsDialog(
initialReport: 'PNL' | 'BALANCE-SHEET' | 'COMPARATIVE-BS' | 'GL-DR-CR' | 'MISC-EXP' = 'PNL'
initialReport: 'PNL' | 'BALANCE-SHEET' | 'COMPARATIVE-BS' | 'CONTINGENT-LIAB' | 'GL-DR-CR' | 'MISC-EXP' = 'PNL'
): Promise<void> {
const { AccountingReportsDialogComponent } =
await import('../../home/dashboard/accounting-reports-dialog/accounting-reports-dialog.component');
@@ -285,7 +294,9 @@ export class ShellComponent implements OnInit, OnDestroy {
* is selected based on the menu link the user clicked; user can switch between
* forms via the form-pill selector inside the dialog.
*/
async openCimsXbrlDialog(initialForm: 'OSS-2' | 'PSA' | 'RAL' | 'RCP-UCB' = 'OSS-2'): Promise<void> {
async openCimsXbrlDialog(
initialForm: 'OSS-2' | 'PSA' | 'RAL' | 'RCP-UCB' | 'OSS-4' | 'OSS-5' | 'OSS-6' | 'OSS-7' = 'OSS-2'
): Promise<void> {
const { CimsXbrlDialogComponent } =
await import('../../home/dashboard/cims-xbrl-dialog/cims-xbrl-dialog.component');
this.dialog.open(CimsXbrlDialogComponent, {
@@ -301,6 +312,26 @@ export class ShellComponent implements OnInit, OnDestroy {
});
}
/** Opens the FD / RD Entry dialog under Sub Ledger → Deposits. */
async openFdRdEntryDialog(): Promise<void> {
const { FdRdEntryDialogComponent } =
await import('../../home/dashboard/fd-rd-entry-dialog/fd-rd-entry-dialog.component');
this.dialog.open(FdRdEntryDialogComponent, {
width: '100vw',
height: '100vh',
maxWidth: '100vw',
maxHeight: '100vh',
panelClass: [
'crr-slr-dialog-panel',
'fdrd-fullscreen-panel'
],
autoFocus: false,
disableClose: false,
hasBackdrop: true,
backdropClass: 'fdrd-dlg-backdrop'
});
}
/**
* Opens the Asset Liability Management dialog (Form ALM-1).
* Covers maturity ladder, monthly snapshot, and structural liquidity returns.
@@ -266,7 +266,7 @@
<a
class="subledger-link"
[class.hidden]="!matchesSearch('Cash / Clearing / Transfer')"
(click)="subLedgerOpen = false"
(click)="navigateMenu('/cashier/cash-clearing-transfer-summary'); subLedgerOpen = false"
>
<fa-icon icon="money-bill" class="subledger-icon"></fa-icon> Cash / Clearing / Transfer
</a>
@@ -403,17 +403,26 @@
<div class="subledger-columns">
<div class="subledger-group">
<div class="subledger-group-title">Financial Statements</div>
<a class="subledger-link" [class.hidden]="!matchesGlSearch('Trial Balance')" (click)="glMenuOpen = false"
<a
class="subledger-link"
[class.hidden]="!matchesGlSearch('Trial Balance')"
routerLink="/gl/reports/trial-balance"
(click)="glMenuOpen = false"
><fa-icon icon="balance-scale" class="subledger-icon"></fa-icon> Trial Balance</a
>
<a class="subledger-link" [class.hidden]="!matchesGlSearch('Profit and Loss')" (click)="glMenuOpen = false"
<a
class="subledger-link"
[class.hidden]="!matchesGlSearch('Profit and Loss')"
routerLink="/gl/reports/profit-and-loss"
(click)="glMenuOpen = false"
><fa-icon icon="chart-line" class="subledger-icon"></fa-icon> Profit and Loss</a
>
<a
class="subledger-link"
[class.hidden]="!matchesGlSearch('Consolidate Balance Sheet')"
[class.hidden]="!matchesGlSearch('Consolidated Balance Sheet')"
routerLink="/gl/reports/balance-sheet"
(click)="glMenuOpen = false"
><fa-icon icon="file-alt" class="subledger-icon"></fa-icon> Consolidate Balance Sheet</a
><fa-icon icon="file-alt" class="subledger-icon"></fa-icon> Consolidated Balance Sheet</a
>
</div>
<div class="subledger-group">
@@ -448,8 +457,12 @@
(click)="glMenuOpen = false"
><fa-icon icon="users" class="subledger-icon"></fa-icon> Client wise Balance Report</a
>
<a class="subledger-link" [class.hidden]="!matchesGlSearch('Contingent Expense')" (click)="glMenuOpen = false"
><fa-icon icon="exclamation-circle" class="subledger-icon"></fa-icon> Contingent Expense</a
<a
class="subledger-link"
[class.hidden]="!matchesGlSearch('Contingent Liabilities')"
routerLink="/gl/reports/contingent-liabilities"
(click)="glMenuOpen = false"
><fa-icon icon="exclamation-circle" class="subledger-icon"></fa-icon> Contingent Liabilities</a
>
<a class="subledger-link" [class.hidden]="!matchesGlSearch('Banker Head Report')" (click)="glMenuOpen = false"
><fa-icon icon="university" class="subledger-icon"></fa-icon> Banker Head Report</a
@@ -365,6 +365,21 @@
</mat-form-field>
</form>
}
<!-- Compliance / AML Action Triage -->
<div class="compliance-triage" style="margin-top: 16px; padding: 12px; background: #fff8f8; border: 1px dashed #f5c2c2; border-radius: 8px;">
<h4 style="margin: 0 0 8px; font-weight: 600; color: #b91c1c; display: flex; align-items: center; gap: 6px;">
Compliance & AML Triage
</h4>
<div style="display: flex; flex-direction: column; gap: 8px;">
<mat-checkbox [(ngModel)]="flagAsSuspicious" [ngModelOptions]="{standalone: true}">
Flag as suspicious (escalate to STR cases under RBI compliance)
</mat-checkbox>
<mat-checkbox [(ngModel)]="markAsWhitelisted" [ngModelOptions]="{standalone: true}">
Mark as whitelisted (prevent future AML alerts for this account)
</mat-checkbox>
</div>
</div>
}
<div #lightboxRoot style="display: none"></div>
@@ -131,6 +131,90 @@ export class ItemDecisionDialogComponent implements OnInit, OnDestroy {
/** 'any' = mode of operation lets any one signer sign (Either/Anyone or Survivor); 'all' = every mandatory signer must sign. */
signerSelectionMode: 'any' | 'all' = 'any';
manualOverrideChecked = false;
flagAsSuspicious = false;
markAsWhitelisted = false;
handleComplianceActions(): void {
if (this.flagAsSuspicious) {
const existingStrData = localStorage.getItem('fiu_str_cases');
let cases: any[] = [];
if (existingStrData) {
try {
cases = JSON.parse(existingStrData);
} catch (e) {
cases = [];
}
}
const newRef = 101 + cases.length;
const newCase: any = {
reportType: 'STR',
reportRefNo: newRef,
branchCode: this.item.presentingBranchCode || '1',
accountNumber: this.item.accountNo || this.item.accountId || '110001101000838',
txnBranchCode: this.item.presentingBranchCode || '1',
depositOrWithdrawal: 'Withdrawal',
txnId: this.item.id,
txnDate: new Date().toLocaleDateString('en-GB').replace(/\//g, '-'),
txnTime: new Date().toLocaleTimeString('en-US', { hour12: false }),
txnAmount: this.item.amount,
instrumentType: 'Cheque',
instrumentId: this.item.chequeNumber,
nonCustomerRef: '',
relationshipFlag: 'Customer',
thirdPartyPan: '',
declaration: 'NA',
customerName: this.item.clientName || 'UNKNOWN CLIENT',
detectedOn: new Date().toLocaleDateString('en-GB').replace(/\//g, '-'),
flaggedBy: 'CTS Clearing Officer',
riskCategory: 'unusual-pattern',
narrative: `CTS Cheque #${this.item.chequeNumber} for amount ₹${this.item.amount} flagged as suspicious by Clearing Officer during inward presentment review. Action: ${this.mode}. Reason/Notes: ${this.mode === 'RETURN' ? this.returnForm.value.reason : this.passForm.value.note || 'Manual compliance escalation'}`,
status: 'po-review'
};
cases.unshift(newCase);
localStorage.setItem('fiu_str_cases', JSON.stringify(cases));
const storedCtr = localStorage.getItem('fiu_ctr_txns');
if (storedCtr) {
try {
const ctrList = JSON.parse(storedCtr);
const matching = ctrList.find((t: any) => t.accountNumber === newCase.accountNumber);
if (matching) {
matching.complianceStatus = 'SUSPICIOUS';
localStorage.setItem('fiu_ctr_txns', JSON.stringify(ctrList));
}
} catch (e) {}
}
} else if (this.markAsWhitelisted) {
const existingWhitelist = localStorage.getItem('compliance_whitelisted_accounts');
let whitelisted: string[] = [];
if (existingWhitelist) {
try {
whitelisted = JSON.parse(existingWhitelist);
} catch (e) {
whitelisted = [];
}
}
const account = this.item.accountNo || this.item.accountId;
if (account && !whitelisted.includes(account.toString())) {
whitelisted.push(account.toString());
localStorage.setItem('compliance_whitelisted_accounts', JSON.stringify(whitelisted));
}
const storedCtr = localStorage.getItem('fiu_ctr_txns');
if (storedCtr) {
try {
const ctrList = JSON.parse(storedCtr);
const matching = ctrList.find((t: any) => t.accountNumber === (account ? account.toString() : ''));
if (matching) {
matching.complianceStatus = 'WHITELISTED';
localStorage.setItem('fiu_ctr_txns', JSON.stringify(ctrList));
}
} catch (e) {}
}
}
}
/** Every automatic validation rule InwardClearingValidationService already computes, shown so the clerk sees WHY, not just a decision. */
readonly validationRules: { key: string; label: string; goodWhenTrue: boolean }[] = [
@@ -652,6 +736,7 @@ export class ItemDecisionDialogComponent implements OnInit, OnDestroy {
this.ctsService.passInwardItem(this.item.id, payload).subscribe({
next: () => {
this.busy = false;
this.handleComplianceActions();
this.notify('Cheque passed');
this.dialogRef.close({ actioned: true });
},
@@ -676,6 +761,7 @@ export class ItemDecisionDialogComponent implements OnInit, OnDestroy {
this.ctsService.returnInwardItem(this.item.id, payload).subscribe({
next: () => {
this.busy = false;
this.handleComplianceActions();
this.notify('Cheque returned');
this.dialogRef.close({ actioned: true });
},
+3 -3
View File
@@ -81,7 +81,7 @@ export interface DeafEligibleAccount {
/** Form I — Dec 2025 cycle */
export const FORM_I: DeafForm = {
bankName: 'Bharat Urban Bank Ltd, Mumbai Main Branch',
bankName: 'CBSNexora Bank Ltd, Mumbai Main Branch',
bankDeafCode: '1100',
remittedThroughSponsor: 'Y',
sponsorBankName: 'State Bank of India',
@@ -170,7 +170,7 @@ export const FORM_I: DeafForm = {
/** Form II — Jan 2026 (head office consolidated) */
export const FORM_II: DeafForm = {
bankName: 'Bharat Urban Bank Ltd, Head Office',
bankName: 'CBSNexora Bank Ltd, Head Office',
bankDeafCode: '1100',
remittedThroughSponsor: 'Y',
sponsorBankName: 'State Bank of India',
@@ -271,7 +271,7 @@ export const REFUND_CLAIMS: {
auditor: string;
rows: DeafClaim[];
} = {
bankName: 'Bharat Urban Bank Ltd',
bankName: 'CBSNexora Bank Ltd',
bankDeafCode: '1100',
currentAcWithRbi: '03010200099001',
sponsorBank: 'State Bank of India',
+1 -1
View File
@@ -53,7 +53,7 @@ export interface SahayDepositor {
}
export const DICGC_BANK = {
name: 'Bharat Urban Bank Ltd',
name: 'CBSNexora Bank Ltd',
branch: 'Head Office',
reportAsOn: '29-01-2026',
printDate: '29-01-2026 04:19 PM',
+732 -300
View File
File diff suppressed because it is too large Load Diff
+689 -75
View File
@@ -14,12 +14,25 @@
<div>
<h2 class="fiu-head-t">Financial Intelligence Unit – India (FIU-IND)</h2>
<div class="fiu-head-s">
{{ bank.name }} &middot; {{ bank.branch }} &middot; Reporting Month <strong>{{ bank.reportingMonth }}</strong>
{{ bank.name }} &middot; {{ scopeName }} &middot; Reporting Month <strong>{{ bank.reportingMonth }}</strong>
&middot; PMLA 2002 / FINnet 2.0
</div>
</div>
</div>
<div class="fiu-head-meta">
<div>
<span class="fiu-meta-l">Branch</span>
@if (isHeadOffice) {
<select class="fiu-inp" [ngModel]="branchFilter" (ngModelChange)="setBranch($event)">
<option value="">All Branches</option>
@for (b of branches; track b.code) {
<option [value]="b.code">{{ b.name }}</option>
}
</select>
} @else {
<span class="fiu-meta-v">{{ scopeName }}</span>
}
</div>
<div>
<span class="fiu-meta-l">Principal Officer</span><span class="fiu-meta-v">{{ bank.principalOfficer }}</span>
</div>
@@ -71,17 +84,19 @@
<div class="fiu-kpi fiu-kpi--amber">
<div class="fiu-kpi-ic"><fa-icon icon="exclamation-triangle"></fa-icon></div>
<div>
<div class="fiu-kpi-n">{{ pendingStr | number }}</div>
<div class="fiu-kpi-n">{{ liveStrPending | number }}</div>
<div class="fiu-kpi-l">STR Pending</div>
<div class="fiu-kpi-sub">draft / PO review</div>
<div class="fiu-kpi-sub">
{{ liveStrOpen }} to investigate &middot; {{ liveStrAwaitingFiling }} awaiting filing
</div>
</div>
</div>
<div class="fiu-kpi fiu-kpi--green">
<div class="fiu-kpi-ic"><fa-icon icon="check-circle"></fa-icon></div>
<div>
<div class="fiu-kpi-n">{{ filedStr | number }}</div>
<div class="fiu-kpi-n">{{ liveStrFiled | number }}</div>
<div class="fiu-kpi-l">STR Filed</div>
<div class="fiu-kpi-sub">acknowledged by FIU</div>
<div class="fiu-kpi-sub">{{ liveStrCleared }} concluded clear</div>
</div>
</div>
<div class="fiu-kpi fiu-kpi--purple">
@@ -94,6 +109,52 @@
</div>
</div>
<!-- WHEN TO REPORT — the five PMLA obligations -->
<div class="ob-block">
<div class="ob-head">
<div>
<h3 class="ob-title"><fa-icon icon="gavel"></fa-icon> When to report to FIU-IND</h3>
<p class="ob-sub">
The five reports a reporting entity owes under the Prevention of Money Laundering Act. All are filed
electronically through the FINnet 2.0 portal.
</p>
</div>
</div>
<div class="ob-grid">
@for (o of obligations; track o.code) {
<div class="ob-card" [class.ob-card--off]="!o.handled">
<div class="ob-card-h">
<fa-icon [icon]="o.icon" class="ob-ic"></fa-icon>
<span class="ob-code">{{ o.code }}</span>
<span class="ob-name">{{ o.name }}</span>
@if (o.handled) {
<span class="ob-flag ob-flag--on">Filed here</span>
} @else {
<span class="ob-flag">Tracked</span>
}
</div>
<div class="ob-row">
<label>Report when</label><span>{{ o.trigger }}</span>
</div>
<div class="ob-row">
<label>Deadline</label><span class="ob-due">{{ o.deadline }}</span>
</div>
<div class="ob-note">{{ o.note }}</div>
@if (o.code === 'CTR') {
<button class="dc-btn" (click)="showTab('ctr')">
<fa-icon icon="external-link-alt"></fa-icon> Open CTR
</button>
} @else if (o.code === 'STR') {
<button class="dc-btn" (click)="showTab('str')">
<fa-icon icon="external-link-alt"></fa-icon> Open STR
</button>
}
</div>
}
</div>
</div>
<!-- Compliance cards -->
<div class="fiu-compliance">
<div class="dc-card">
@@ -112,7 +173,12 @@
</div>
<div class="dc-meta">Filing window: <strong>within 7 days of detection</strong></div>
<div class="dc-meta">
Pending: <strong>{{ pendingStr }}</strong> &middot; Filed: <strong>{{ filedStr }}</strong>
To investigate: <strong>{{ liveStrOpen }}</strong> &middot; Awaiting filing:
<strong>{{ liveStrAwaitingFiling }}</strong>
</div>
<div class="dc-meta">
Filed: <strong>{{ liveStrFiled }}</strong> &middot; Concluded clear:
<strong>{{ liveStrCleared }}</strong>
</div>
<button class="dc-btn" (click)="showTab('str')"><fa-icon icon="external-link-alt"></fa-icon> Open</button>
</div>
@@ -129,87 +195,148 @@
<!-- CTR -->
@if (activeTab === 'ctr') {
<div class="fiu-section">
<!-- FINNET FILE SEQUENCE — the CTR submission, in prescribed order -->
<div class="seq-block">
<div class="fiu-form-meta">
<div class="fiu-form-title">
<h3>CTR Submission Files — FINnet 2.0</h3>
<p>
The CTR is built in this order: the account is described first, then who is behind it, then the two
transaction files. Every grid below is the exact content of its CSV. The customer cards (KC1 / KC2) travel
with the same bundle and are worked on the Customer Cards tab.
</p>
</div>
<button class="fiu-btn fiu-btn-xls" (click)="exportAll()">
<fa-icon icon="file-csv"></fa-icon> Download full bundle
</button>
</div>
<!-- Scope: these controls decide which transactions the six files carry. -->
<div class="fiu-filters">
<div class="fiu-filter">
<label>Type</label>
<select class="fiu-inp" [(ngModel)]="ctrFilterMode">
<option value="">All</option>
<option value="Deposit">Deposit</option>
<option value="Withdrawal">Withdrawal</option>
</select>
</div>
<div class="fiu-filter">
<label>Account contains</label>
<input class="fiu-inp" type="text" [(ngModel)]="ctrFilterAccount" placeholder="A/c partial" />
</div>
<div class="fiu-filter">
<label>Min Amount (&#8377;)</label>
<input class="fiu-inp" type="number" [(ngModel)]="ctrFilterMinAmount" placeholder="0" />
</div>
<button class="fiu-btn fiu-btn-g" (click)="resetCtrFilters()">Reset</button>
</div>
<!-- Step rail -->
<div class="seq-rail">
@for (f of finnetSequence; track f.key) {
<button class="seq-step" [class.active]="activeFinnetFile === f.key" (click)="showFinnetFile(f.key)">
<span class="seq-n">{{ f.step }}</span>
<span class="seq-txt">
<strong>{{ f.name }}</strong>
<em>{{ f.table.rows.length }} {{ f.table.rows.length === 1 ? 'row' : 'rows' }}</em>
</span>
</button>
@if (!$last) {
<fa-icon icon="chevron-right" class="seq-arrow"></fa-icon>
}
}
</div>
<!-- Open file -->
<div class="seq-panel">
<div class="seq-panel-h">
<div>
<h4>Step {{ openFinnetFile.step }} — {{ openFinnetFile.title }}</h4>
<p>{{ openFinnetFile.purpose }}</p>
</div>
<button class="fiu-btn fiu-btn-csv" (click)="downloadOpenFinnetFile()">
<fa-icon icon="file-csv"></fa-icon> {{ openFinnetFile.name }}.csv
</button>
</div>
<div class="seq-tbl-wrap">
<table class="seq-tbl">
<thead>
<tr>
<th class="seq-rownum">#</th>
@for (h of openFinnetFile.table.headers; track $index) {
<th>{{ h }}</th>
}
</tr>
</thead>
<tbody>
@for (r of openFinnetFile.table.rows; track $index) {
<tr>
<td class="seq-rownum">{{ $index + 1 }}</td>
@for (c of r; track $index) {
<td>{{ c }}</td>
}
</tr>
} @empty {
<tr>
<td [attr.colspan]="openFinnetFile.table.headers.length + 1" class="seq-empty">
@if (openFinnetFile.key === 'tc1') {
No ATM or card transactions match the current scope. When the bank books none at all, TC1 is
still submitted as a header-only file — that is what FIU-IND expects.
} @else {
No rows for the current filters.
}
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
<!-- LIVE: monthly CTR reports (mechanical, threshold-based) -->
<div class="fiu-form-meta">
<div class="fiu-form-title">
<h3>CTR — Cash Transaction Report</h3>
<h3>Monthly CTR Reports</h3>
<p>
All cash transactions ≥ &#8377;{{ bank.cashThreshold | number }} (single or aggregate per month per account)
Threshold: <strong>&#8377;{{ bank.cashThreshold | number }}</strong> per single txn / connected aggregate
per month
</p>
</div>
<button class="fiu-btn fiu-btn-csv" (click)="exportTc2()">
<fa-icon icon="file-csv"></fa-icon> Download TC2.csv
</button>
<div class="fiu-action-group">
<input class="fiu-inp" type="month" [(ngModel)]="ctrGenerateMonth" />
<button class="fiu-btn fiu-btn-g" *mifosxHasPermission="'REVIEW_AML_ALERT'" (click)="generateCtrReport()">
Generate
</button>
</div>
</div>
<div class="fiu-filters">
<div class="fiu-filter">
<label>Type</label>
<select class="fiu-inp" [(ngModel)]="ctrFilterMode">
<option value="">All</option>
<option value="Deposit">Deposit</option>
<option value="Withdrawal">Withdrawal</option>
</select>
</div>
<div class="fiu-filter">
<label>Account contains</label>
<input class="fiu-inp" type="text" [(ngModel)]="ctrFilterAccount" placeholder="A/c partial" />
</div>
<div class="fiu-filter">
<label>Min Amount (&#8377;)</label>
<input class="fiu-inp" type="number" [(ngModel)]="ctrFilterMinAmount" placeholder="0" />
</div>
<button class="fiu-btn fiu-btn-g" (click)="resetCtrFilters()">Reset</button>
</div>
<div class="fiu-tbl-wrap">
<table class="fiu-tbl">
<thead>
<tr>
<th class="ta-c">Ref</th>
<th>Account</th>
<th>Customer</th>
<th class="ta-c">Type</th>
<th>Date / Time</th>
<th class="ta-r">Amount</th>
<th>Instrument</th>
<th>Branch</th>
<th class="ta-c">Relationship</th>
<th>Month</th>
<th>Generated On</th>
<th class="ta-c">Records</th>
<th>Status</th>
<th>Submitted On</th>
</tr>
</thead>
<tbody>
@for (t of filteredCtr; track t.txnId) {
@for (r of amlCtrReports; track r.id) {
<tr>
<td class="ta-c">{{ t.reportRefNo }}</td>
<td class="mono">{{ t.accountNumber }}</td>
<td>{{ t.customerName }}</td>
<td class="ta-c">
<span
class="dir-pill"
[class.deposit]="t.depositOrWithdrawal === 'Deposit'"
[class.withdrawal]="t.depositOrWithdrawal === 'Withdrawal'"
>
{{ t.depositOrWithdrawal }}
</span>
</td>
<td>{{ r.reportMonth }}</td>
<td>{{ r.generatedOn | date: 'medium' }}</td>
<td class="ta-c">{{ r.recordCount }}</td>
<td>
{{ t.txnDate }} <span class="muted">{{ t.txnTime }}</span>
</td>
<td class="ta-r mono">
<strong>&#8377;{{ t.txnAmount | number: '1.0-0' }}</strong>
</td>
<td>
{{ t.instrumentType }} <span class="muted">{{ t.instrumentId }}</span>
</td>
<td class="mono">{{ t.branchCode }}</td>
<td class="ta-c">
<span class="rel-pill" [class.non-cust]="t.relationshipFlag === 'Non-Customer'">{{
t.relationshipFlag
}}</span>
<span [class]="amlStatusClass(r.status)">{{ r.status }}</span>
</td>
<td>{{ r.submittedOn ? (r.submittedOn | date: 'medium') : '—' }}</td>
</tr>
} @empty {
<tr>
<td colspan="9" class="fiu-empty">No transactions match the filters.</td>
<td colspan="5" class="fiu-empty">No CTR reports generated yet.</td>
</tr>
}
</tbody>
@@ -221,9 +348,282 @@
<!-- STR -->
@if (activeTab === 'str') {
<div class="fiu-section">
<!-- LIVE: suspicious AML alerts awaiting AML Officer review (Structuring, PEP, etc.) -->
<div class="fiu-form-meta">
<div class="fiu-form-title">
<h3>STR — Suspicious Transaction Report</h3>
<h3>Live AML Alerts (Awaiting Triage)</h3>
<p>Flagged by the monitoring engine for suspicious behavioral patterns — triage and whitelist or escalate</p>
</div>
<select class="fiu-inp" [(ngModel)]="amlAlertStatusFilter" (ngModelChange)="loadAmlAlerts()">
<option value="">All statuses</option>
<option value="RAISED">Raised</option>
<option value="UNDER_REVIEW">Under Review</option>
<option value="WHITELISTED">Whitelisted</option>
<option value="ESCALATED">Escalated</option>
</select>
</div>
<div class="fiu-tbl-wrap">
<table class="fiu-tbl">
<thead>
<tr>
<th>ID</th>
<th>Client</th>
<th>Type</th>
<th>Rule</th>
<th class="ta-r">Amount</th>
<th>Raised On</th>
<th>Status</th>
<th class="ta-c">Routes To</th>
<th>Notes</th>
<th>Action</th>
</tr>
</thead>
<tbody>
@for (alert of amlSuspiciousAlerts; track alert.id) {
<tr>
<td>{{ alert.id }}</td>
<td>{{ alert.clientDisplayName }}</td>
<td>{{ alert.transactionType }}</td>
<td>{{ alert.ruleCode }} &mdash; {{ alert.ruleDescription }}</td>
<td class="ta-r mono">&#8377;{{ alert.amount | number: '1.0-0' }}</td>
<td>{{ alert.raisedOn | date: 'medium' }}</td>
<td>
<span [class]="amlStatusClass(alert.status)">{{ alert.status }}</span>
</td>
<td class="ta-c">
<span [class]="alertRouteClass(alert.status, false)">{{ alertRouteLabel(alert.status, false) }}</span>
</td>
<td>
@if (alert.status === 'RAISED' || alert.status === 'UNDER_REVIEW') {
<input
type="text"
class="fiu-inp"
placeholder="Review notes"
[(ngModel)]="amlReviewNotes[alert.id]"
/>
} @else {
<span class="muted">{{ alert.reviewNotes }}</span>
}
</td>
<td>
@if (alert.status === 'RAISED' || alert.status === 'UNDER_REVIEW') {
<div class="fiu-action-group" *mifosxHasPermission="'REVIEW_AML_ALERT'">
<button class="fiu-btn fiu-btn-g" (click)="whitelistAlert(alert)">Whitelist</button>
<button class="fiu-btn fiu-btn-xml" (click)="escalateAlert(alert)">Escalate</button>
</div>
}
</td>
</tr>
} @empty {
<tr>
<td colspan="10" class="fiu-empty">No suspicious behavioral alerts.</td>
</tr>
}
</tbody>
</table>
</div>
<p class="fiu-tbl-legend">
<span class="report-pill report-pending">Pending</span> awaiting Whitelist/Escalate decision &middot;
<span class="report-pill report-cleared">Cleared</span> whitelisted — no report filed &middot;
<span class="report-pill report-str">→ STR</span> escalated to Principal Officer as an STR case
</p>
<hr style="border: 0; border-top: 1px solid var(--f-g200); margin: 8px 0" />
<!-- LIVE: STR cases with full Principal Officer actions -->
<div class="fiu-form-meta">
<div class="fiu-form-title">
<h3>Live STR Cases</h3>
<p>
Escalated by an AML Officer; Principal Officer investigates, concludes and files with FIU-IND within 7
working days
</p>
</div>
</div>
<div class="fiu-tbl-wrap">
<table class="fiu-tbl">
<thead>
<tr>
<th>ID</th>
<th>Client</th>
<th class="ta-c">Cumulative Cases</th>
<th>Assigned PO</th>
<th>Conclusion</th>
<th>FIU Due Date</th>
<th>Status</th>
<th>Action</th>
</tr>
</thead>
<tbody>
@for (sc of amlStrCases; track sc.id) {
<tr [class.selected-row]="selectedStrCase?.id === sc.id">
<td>{{ sc.id }}</td>
<td>{{ sc.clientDisplayName }}</td>
<td class="ta-c" [class.escalation-warning]="sc.cumulativeStrCaseCountForClient >= 3">
{{ sc.cumulativeStrCaseCountForClient }}
</td>
<td>{{ sc.assignedPoUsername || '—' }}</td>
<td>{{ sc.conclusion || '—' }}</td>
<td>{{ sc.fiuDueDate || '—' }}</td>
<td>
<span [class]="amlStatusClass(sc.status)">{{ sc.status }}</span>
</td>
<td><button class="fiu-btn fiu-btn-g" (click)="selectStrCase(sc)">Investigate</button></td>
</tr>
} @empty {
<tr>
<td colspan="8" class="fiu-empty">No STR cases yet.</td>
</tr>
}
</tbody>
</table>
</div>
@if (selectedStrCase) {
<div class="str-case-detail">
<div class="fiu-form-meta">
<div class="fiu-form-title">
<h3>STR Case #{{ selectedStrCase.id }} — {{ selectedStrCase.clientDisplayName }}</h3>
<p>
Alert #{{ selectedStrCase.alertId }} &middot;
{{ selectedStrCase.cumulativeStrCaseCountForClient }} cumulative STR case(s) for this client
@if (selectedStrCase.cumulativeStrCaseCountForClient >= 3) {
<span class="escalation-badge">Risk category auto-escalated</span>
}
</p>
</div>
<button class="fiu-btn fiu-btn-g" (click)="closeStrCaseDetail()">Close</button>
</div>
<div
class="case-outcome"
[class.case-outcome--clear]="selectedStrCase.status === 'CONCLUDED_CLEAR'"
[class.case-outcome--suspicious]="
selectedStrCase.status === 'CONCLUDED_SUSPICIOUS' || selectedStrCase.status === 'FILED'
"
>
<span [class]="amlStatusClass(selectedStrCase.status)">{{ selectedStrCase.status }}</span>
<span>{{ strCaseOutcome(selectedStrCase) }}</span>
</div>
<div class="case-form" *mifosxHasPermission="'CONCLUDE_AML_STR'">
<div class="form-row">
<label for="fiuInvestigationNotes">Investigation notes (source-of-transaction history reviewed)</label>
<textarea id="fiuInvestigationNotes" rows="3" [(ngModel)]="concludeInvestigationNotes"></textarea>
</div>
<div class="form-row">
<label for="fiuSourceOfFundsNotes">Source-of-funds notes</label>
<textarea id="fiuSourceOfFundsNotes" rows="2" [(ngModel)]="concludeSourceOfFundsNotes"></textarea>
</div>
<!-- Verification checklist — the verdict is derived, not chosen. -->
<div class="checklist">
<div class="checklist-head">
<span>Principal Officer verification — mark each point as found</span>
<span class="checklist-progress" [class.done]="allAnswered">
{{ answeredCount }} / {{ strChecklist.length }} answered
</span>
</div>
<table class="checklist-tbl">
<thead>
<tr>
<th>Verification point</th>
<th class="ta-c col-yes">Genuine</th>
<th class="ta-c col-no">Not satisfied</th>
</tr>
</thead>
<tbody>
@for (item of strChecklist; track item.key) {
<tr
[class.row-yes]="strFindings[item.key] === true"
[class.row-no]="strFindings[item.key] === false"
[class.row-pending]="strFindings[item.key] === null"
>
<td>
<div class="chk-label">{{ item.label }}</div>
<div class="chk-hint">{{ item.hint }}</div>
</td>
<td class="ta-c">
<button
type="button"
class="chk-box chk-box--yes"
[class.on]="strFindings[item.key] === true"
[attr.aria-pressed]="strFindings[item.key] === true"
[attr.aria-label]="'Mark ' + item.label + ' as genuine'"
(click)="setFinding(item.key, true)"
>
<fa-icon icon="check-circle"></fa-icon>
</button>
</td>
<td class="ta-c">
<button
type="button"
class="chk-box chk-box--no"
[class.on]="strFindings[item.key] === false"
[attr.aria-pressed]="strFindings[item.key] === false"
[attr.aria-label]="'Mark ' + item.label + ' as not satisfied'"
(click)="setFinding(item.key, false)"
>
<fa-icon icon="exclamation-triangle"></fa-icon>
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
<!-- Derived verdict -->
<div
class="verdict"
[class.verdict--clear]="allAnswered && derivedConclusion === 'NOT_SUSPICIOUS'"
[class.verdict--suspicious]="allAnswered && derivedConclusion === 'SUSPICIOUS'"
>
@if (!allAnswered) {
<fa-icon icon="info-circle"></fa-icon>
<span>
Answer every verification point to determine the outcome —
{{ strChecklist.length - answeredCount }} still pending.
</span>
} @else if (derivedConclusion === 'NOT_SUSPICIOUS') {
<fa-icon icon="check-circle"></fa-icon>
<span>
<strong>Not suspicious.</strong> All {{ strChecklist.length }} points found genuine — the case will be
closed and nothing filed with FIU-IND.
</span>
} @else {
<fa-icon icon="exclamation-triangle"></fa-icon>
<span>
<strong>Suspicious — STR must be filed.</strong>
{{ failedFindings.length }} of {{ strChecklist.length }} points not satisfied:
{{ failedFindings[0].label }}
@if (failedFindings.length > 1) {
<span> and {{ failedFindings.length - 1 }} more</span>
}
.
</span>
}
</div>
<button class="fiu-btn fiu-btn-xml" [disabled]="!allAnswered" (click)="concludeStrCase()">
Conclude as
{{ allAnswered ? (derivedConclusion === 'SUSPICIOUS' ? 'Suspicious' : 'Not Suspicious') : '…' }}
</button>
@if (selectedStrCase.status === 'CONCLUDED_SUSPICIOUS') {
<div class="file-str-row">
<label for="fiuFileReferenceNo">FIU-IND acknowledgement / reference number</label>
<input id="fiuFileReferenceNo" class="fiu-inp" type="text" [(ngModel)]="fileReferenceNo" />
<button class="fiu-btn fiu-btn-xml" (click)="fileStrCase()">Mark STR Filed</button>
</div>
}
</div>
</div>
}
<!-- FINnet export template (separate concern — STR XML file-format generation) -->
<div class="fiu-form-meta">
<div class="fiu-form-title">
<h3>FINnet STR XML Export Template</h3>
<p>Detected by AML rules engine + branch compliance officers; filed within 7 days</p>
</div>
<button class="fiu-btn fiu-btn-xml" (click)="exportStrXml()">
@@ -238,8 +638,9 @@
<option value="">All</option>
<option value="draft">Draft</option>
<option value="po-review">PO Review</option>
<option value="awaiting-filing">Awaiting Filing</option>
<option value="filed">Filed</option>
<option value="rejected">Rejected</option>
<option value="rejected">Closed — Clean</option>
</select>
</div>
<div class="fiu-filter">
@@ -259,7 +660,12 @@
<div class="fiu-str-list">
@for (s of filteredStr; track s.reportRefNo) {
<div class="str-card" [class.str-card--filed]="s.status === 'filed'">
<div
class="str-card"
[id]="'str-card-' + s.reportRefNo"
[class.str-card--filed]="s.status === 'filed'"
[class.str-card--highlight]="highlightedStrRef === s.reportRefNo"
>
<div class="str-card-h">
<div class="str-card-id">
<span class="str-ref">STR-{{ s.reportRefNo }}</span>
@@ -291,6 +697,9 @@
<div>
<label>Instrument</label><span>{{ s.instrumentType }} {{ s.instrumentId }}</span>
</div>
<div>
<label>Branch</label><span class="mono">{{ s.branchCode }} {{ branchNameFor(s.branchCode) }}</span>
</div>
</div>
<div class="str-narrative">
<label>Narrative</label>
@@ -302,6 +711,208 @@
<p>{{ s.poRemarks }}</p>
</div>
}
@if (s.poFinding) {
<div class="str-finding" [class.str-finding--suspicious]="s.poFinding === 'SUSPICIOUS'">
<label>
<fa-icon [icon]="s.poFinding === 'CLEAN' ? 'check-circle' : 'exclamation-triangle'"></fa-icon>
Principal Officer Finding
</label>
<p>{{ poFindingLabel(s.poFinding) }}</p>
</div>
}
<!-- Concluded suspicious but not yet submitted to FIU-IND -->
@if (s.status === 'awaiting-filing') {
<div class="str-filing" *mifosxHasPermission="'CONCLUDE_AML_STR'">
<label>
<fa-icon icon="exclamation-triangle"></fa-icon> Awaiting submission to FIU-IND — due within 7 days
of {{ s.detectedOn }}
</label>
<p class="filing-hint">
Generate the FINnet STR XML, upload it on the FIU-IND portal under
{{ bank.principalOfficer }}'s digital signature, then record the acknowledgement number here.
</p>
<div class="filing-row">
<button class="fiu-btn" (click)="exportSingleStrXml(s)">
<fa-icon icon="file-alt"></fa-icon> Generate STR XML
</button>
<input
class="fiu-inp"
type="text"
placeholder="FIU-IND acknowledgement no."
[(ngModel)]="localFileRefs[s.reportRefNo]"
/>
<button class="fiu-btn fiu-btn-xml" (click)="fileLocalStr(s)">Mark STR Filed</button>
</div>
</div>
}
@if (s.status === 'filed' && s.fiuReferenceNo) {
<div class="str-filed-ack">
<fa-icon icon="check-circle"></fa-icon>
<span>
Filed with FIU-IND on <strong>{{ s.filedOn }}</strong> &middot; acknowledgement
<strong class="mono">{{ s.fiuReferenceNo }}</strong>
</span>
</div>
}
@if (!s.poFinding && investigatingStr?.reportRefNo !== s.reportRefNo) {
<div class="str-po-action" *mifosxHasPermission="'CONCLUDE_AML_STR'">
<label>Principal Officer investigation — is the source of funds legitimate?</label>
<button class="fiu-btn fiu-btn-xml" (click)="investigateLocalStr(s)">
<fa-icon icon="search"></fa-icon> Investigate this case
</button>
</div>
}
<!-- Investigation workspace for this case -->
@if (investigatingStr?.reportRefNo === s.reportRefNo) {
<div class="str-investigation" *mifosxHasPermission="'CONCLUDE_AML_STR'">
<div class="inv-head">
<span>Principal Officer investigation — STR-{{ s.reportRefNo }}</span>
<button class="fiu-link-btn fiu-link-btn--muted" (click)="closeLocalStrInvestigation()">
Cancel
</button>
</div>
<!-- Step 1 — call for evidence -->
<div class="inv-step">
<div class="inv-step-t"><span class="inv-num">1</span> Documentary evidence called for</div>
<div class="inv-grid">
<div class="form-row">
<label for="invDocType">Document requested from the customer</label>
<select id="invDocType" class="fiu-inp" [(ngModel)]="evidenceDocType">
<option value="">Select document…</option>
@for (d of evidenceDocTypes; track d) {
<option [value]="d">{{ d }}</option>
}
</select>
</div>
<div class="form-row">
<label for="invReceived">Was it produced?</label>
<select id="invReceived" class="fiu-inp" [(ngModel)]="evidenceReceived">
<option value="">Select…</option>
<option value="YES">Yes — complete</option>
<option value="PARTIAL">Partially — incomplete</option>
<option value="NO">No — not produced</option>
</select>
</div>
<div class="form-row">
<label for="invRef">Document / reference number</label>
<input id="invRef" class="fiu-inp" type="text" [(ngModel)]="evidenceReference" />
</div>
</div>
</div>
<!-- Step 2 — customer's account of the funds -->
<div class="inv-step">
<div class="inv-step-t"><span class="inv-num">2</span> Customer’s explanation of the source</div>
<textarea
rows="2"
[(ngModel)]="customerExplanation"
placeholder="e.g. Proceeds of a flat sold in June; sale deed produced and matched to the deposit amount and date."
></textarea>
</div>
<!-- Step 3 — verification checklist -->
<div class="inv-step">
<div class="inv-step-t">
<span class="inv-num">3</span> Verification checklist
<span class="checklist-progress" [class.done]="allAnswered">
{{ answeredCount }} / {{ strChecklist.length }} answered
</span>
</div>
<table class="checklist-tbl">
<thead>
<tr>
<th>Verification point</th>
<th class="ta-c col-yes">Genuine</th>
<th class="ta-c col-no">Not satisfied</th>
</tr>
</thead>
<tbody>
@for (item of strChecklist; track item.key) {
<tr
[class.row-yes]="strFindings[item.key] === true"
[class.row-no]="strFindings[item.key] === false"
[class.row-pending]="strFindings[item.key] === null"
>
<td>
<div class="chk-label">{{ item.label }}</div>
<div class="chk-hint">{{ item.hint }}</div>
</td>
<td class="ta-c">
<button
type="button"
class="chk-box chk-box--yes"
[class.on]="strFindings[item.key] === true"
[attr.aria-label]="'Mark ' + item.label + ' as genuine'"
(click)="setFinding(item.key, true)"
>
<fa-icon icon="check-circle"></fa-icon>
</button>
</td>
<td class="ta-c">
<button
type="button"
class="chk-box chk-box--no"
[class.on]="strFindings[item.key] === false"
[attr.aria-label]="'Mark ' + item.label + ' as not satisfied'"
(click)="setFinding(item.key, false)"
>
<fa-icon icon="exclamation-triangle"></fa-icon>
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
<!-- Derived outcome -->
<div
class="verdict"
[class.verdict--clear]="canConcludeLocal && derivedConclusion === 'NOT_SUSPICIOUS'"
[class.verdict--suspicious]="canConcludeLocal && derivedConclusion === 'SUSPICIOUS'"
>
@if (!canConcludeLocal) {
<fa-icon icon="info-circle"></fa-icon>
<span>
Complete all three steps to determine the outcome.
@if (!evidenceRecorded) {
<span> Evidence not yet recorded.</span>
}
@if (!allAnswered) {
<span> {{ strChecklist.length - answeredCount }} verification point(s) pending.</span>
}
</span>
} @else if (derivedConclusion === 'NOT_SUSPICIOUS') {
<fa-icon icon="check-circle"></fa-icon>
<span>
<strong>Source of funds established.</strong> Case closes as clean — the transaction stays in
the CTR report and no STR is filed with FIU-IND.
</span>
} @else {
<fa-icon icon="exclamation-triangle"></fa-icon>
<span>
<strong>Source of funds not established.</strong> {{ failedFindings.length }} point(s)
unsatisfied — an STR will be filed with FIU-IND in addition to the CTR.
</span>
}
</div>
<button class="fiu-btn fiu-btn-xml" [disabled]="!canConcludeLocal" (click)="concludeLocalStr()">
{{
canConcludeLocal
? derivedConclusion === 'SUSPICIOUS'
? 'Conclude — file STR with FIU-IND'
: 'Conclude — close as clean (CTR only)'
: 'Conclude'
}}
</button>
</div>
}
</div>
</div>
} @empty {
@@ -407,10 +1018,13 @@
<div class="fiu-form-meta">
<div class="fiu-form-title">
<h3>FINnet 2.0 Submission Files</h3>
<p>6 CSV files in FIU-IND prescribed schema, plus STR XML</p>
<p>
6 CSV files in FIU-IND prescribed schema, plus STR XML. Counts reflect any filters set on the CTR and STR
tabs, so every file in the bundle covers the same transactions.
</p>
</div>
<button class="fiu-btn fiu-btn-xls" (click)="exportAll()">
<fa-icon icon="file-csv"></fa-icon> Download All
<fa-icon icon="file-csv"></fa-icon> Download All 7
</button>
</div>
@@ -422,7 +1036,7 @@
</div>
<div class="file-card">
<div class="file-card-h"><fa-icon icon="file-csv"></fa-icon>TC2.csv</div>
<p>Branch / cash transactions ({{ ctrTxns.length }} rows)</p>
<p>Branch / cash transactions ({{ filteredCtr.length }} rows)</p>
<button class="fiu-btn fiu-btn-csv" (click)="exportTc2()">
<fa-icon icon="file-csv"></fa-icon> Download
</button>
@@ -457,7 +1071,7 @@
</div>
<div class="file-card file-card--xml">
<div class="file-card-h"><fa-icon icon="file-alt"></fa-icon>STR.xml</div>
<p>Suspicious Transaction Report ({{ filedStr + pendingStr }} suspects)</p>
<p>Suspicious Transaction Report ({{ strSuspects.length }} suspects — cases concluded clean are excluded)</p>
<button class="fiu-btn fiu-btn-xml" (click)="exportStrXml()">
<fa-icon icon="file-alt"></fa-icon> Download XML
</button>
+955
View File
@@ -386,6 +386,172 @@
align-self: end;
}
/* CTR outcome 1: source of funds evidenced at the counter — CTR only. */
.fiu-btn-evidence {
padding: 5px 10px;
font-size: 11px;
font-weight: 600;
color: var(--f-green);
border-color: var(--f-green);
background: var(--f-green-l);
&:hover {
background: var(--f-green);
border-color: var(--f-green);
color: #fff;
}
}
/* Inline evidence capture row inside the TC2 table. */
.evidence-row td {
background: var(--f-green-l) !important;
padding: 0 !important;
}
.evidence-panel {
padding: 12px 16px;
border-left: 3px solid var(--f-green);
}
.evidence-t {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
font-weight: 700;
color: #14532d;
margin-bottom: 10px;
}
.evidence-grid {
display: grid;
grid-template-columns: 2fr 1fr auto;
gap: 12px;
align-items: end;
}
.evidence-note {
margin: 8px 0 0;
font-size: 11px;
color: var(--f-g700);
}
@media (width <= 900px) {
.evidence-grid {
grid-template-columns: 1fr;
}
}
/* CTR outcome 2: evidence unavailable — refer the transaction onward to STR. */
.fiu-btn-refer {
padding: 5px 10px;
font-size: 11px;
font-weight: 600;
color: var(--f-red);
border-color: var(--f-red);
background: var(--f-red-l);
&:hover {
background: var(--f-red);
border-color: var(--f-red);
color: #fff;
}
}
.fiu-link-btn {
display: inline-block;
margin-top: 3px;
padding: 0;
border: none;
background: none;
font-family: inherit;
font-size: 11px;
font-weight: 600;
color: var(--f-red);
cursor: pointer;
text-decoration: underline;
&:hover {
color: #9a2f22;
}
&--muted {
color: var(--f-g500);
&:hover {
color: var(--f-red);
}
}
}
/* PRINCIPAL OFFICER INVESTIGATION */
.str-po-action {
background: var(--f-amber-l);
border: 1px dashed #fcd34d;
border-radius: 8px;
padding: 10px 14px;
display: flex;
flex-direction: column;
gap: 8px;
> label {
font-size: 10px;
font-weight: 700;
color: #92400e;
text-transform: uppercase;
letter-spacing: 0.05em;
}
}
.fiu-btn-clean {
background: var(--f-green);
border-color: var(--f-green);
color: #fff;
&:hover {
background: #166534;
border-color: #166534;
color: #fff;
}
}
.str-finding {
background: var(--f-green-l);
border-radius: 8px;
padding: 10px 14px;
label {
font-size: 10px;
font-weight: 700;
color: var(--f-green);
text-transform: uppercase;
letter-spacing: 0.05em;
display: flex;
align-items: center;
gap: 5px;
margin-bottom: 4px;
}
p {
margin: 0;
font-size: 13px;
color: #14532d;
line-height: 1.5;
}
&--suspicious {
background: var(--f-red-l);
label {
color: var(--f-red);
}
p {
color: #7f1d1d;
}
}
}
/* INPUTS */
.fiu-inp {
width: 100%;
@@ -538,6 +704,50 @@
}
}
.report-pill {
display: inline-block;
padding: 2px 9px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
white-space: nowrap;
&.report-ctr {
background: var(--f-blue-l);
color: var(--f-blue);
}
&.report-str {
background: var(--f-red-l);
color: var(--f-red);
margin-left: 4px;
}
&.report-pending {
background: var(--f-amber-l);
color: var(--f-amber);
}
&.report-cleared {
background: var(--f-green-l);
color: var(--f-green);
}
}
.fiu-tbl-legend {
margin: -6px 2px 0;
font-size: 11px;
color: var(--f-g500);
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
.report-pill {
margin-left: 0;
}
}
.rel-pill {
display: inline-block;
padding: 2px 9px;
@@ -570,6 +780,11 @@
color: var(--f-amber);
}
&.st-awaiting-filing {
background: #ffedd5;
color: #9a3412;
}
&.st-filed {
background: var(--f-green-l);
color: var(--f-green);
@@ -579,6 +794,41 @@
background: var(--f-red-l);
color: var(--f-red);
}
&.status-raised {
background: var(--f-amber-l);
color: var(--f-amber);
}
&.status-under-review {
background: var(--f-blue-l);
color: var(--f-blue);
}
&.status-whitelisted,
&.status-concluded-clear,
&.status-generated,
&.status-submitted {
background: var(--f-green-l);
color: var(--f-green);
}
&.status-escalated,
&.status-open,
&.status-concluded-suspicious {
background: var(--f-red-l);
color: var(--f-red);
}
&.status-filed {
background: var(--f-purple-l);
color: var(--f-purple);
}
&.status-draft {
background: var(--f-g100);
color: var(--f-g500);
}
}
.risk-pill {
@@ -662,6 +912,11 @@
&--filed {
border-color: #86efac;
}
&--highlight {
border-color: var(--f-red);
box-shadow: 0 0 0 3px rgb(192 57 43 / 20%);
}
}
.str-card-h {
@@ -892,3 +1147,703 @@
grid-template-columns: 1fr;
}
}
.selected-row {
background: var(--f-blue-l);
}
.escalation-warning {
color: var(--f-red);
font-weight: 700;
}
.escalation-badge {
display: inline-block;
margin-left: 8px;
padding: 2px 8px;
border-radius: 10px;
background: var(--f-amber-l);
color: var(--f-amber);
font-size: 11px;
font-weight: 600;
}
.str-case-detail {
border: 1px solid var(--f-g200);
border-radius: 10px;
padding: 16px;
background: #fff;
margin-bottom: 16px;
}
.case-outcome {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin: 12px 0;
padding: 10px 14px;
border-radius: 8px;
background: var(--f-g100);
font-size: 13px;
color: var(--f-g700);
&--clear {
background: var(--f-green-l);
color: #14532d;
}
&--suspicious {
background: var(--f-red-l);
color: #7f1d1d;
}
}
.case-form {
display: flex;
flex-direction: column;
gap: 12px;
textarea {
width: 100%;
padding: 8px 10px;
border-radius: 6px;
border: 1px solid var(--f-g300);
font-family: inherit;
font-size: 13px;
}
}
.form-row {
display: flex;
flex-direction: column;
gap: 4px;
label {
font-size: 12px;
font-weight: 600;
color: var(--f-g500);
}
}
.conclusion-row {
flex-direction: row;
gap: 24px;
label {
display: flex;
align-items: center;
gap: 6px;
font-weight: 500;
}
}
/* ── Principal Officer verification checklist ───────────────────────────── */
.checklist {
border: 1px solid var(--f-g200);
border-radius: 10px;
overflow: hidden;
background: #fff;
}
.checklist-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 10px 14px;
background: var(--f-g50);
border-bottom: 1px solid var(--f-g200);
font-size: 12px;
font-weight: 700;
color: var(--f-g700);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.checklist-progress {
font-size: 11px;
font-weight: 700;
padding: 2px 10px;
border-radius: 999px;
background: var(--f-amber-l);
color: var(--f-amber);
text-transform: none;
letter-spacing: 0;
white-space: nowrap;
&.done {
background: var(--f-green-l);
color: var(--f-green);
}
}
.checklist-tbl {
width: 100%;
border-collapse: collapse;
th {
padding: 8px 12px;
font-size: 10px;
font-weight: 700;
color: var(--f-g500);
text-transform: uppercase;
letter-spacing: 0.05em;
background: var(--f-g50);
border-bottom: 1px solid var(--f-g200);
text-align: left;
white-space: nowrap;
}
th.col-yes {
color: var(--f-green);
width: 96px;
}
th.col-no {
color: var(--f-red);
width: 112px;
}
td {
padding: 9px 12px;
border-bottom: 1px solid var(--f-g100);
vertical-align: middle;
}
tr:last-child td {
border-bottom: none;
}
}
/* Answered rows tint so an unresolved finding is obvious at a glance. */
.row-yes td {
background: rgb(220 252 231 / 45%);
}
.row-no td {
background: rgb(253 236 234 / 60%);
}
.row-pending td {
background: #fff;
}
.chk-label {
font-size: 13px;
font-weight: 600;
color: var(--f-g900);
}
.chk-hint {
font-size: 11px;
color: var(--f-g500);
margin-top: 1px;
}
/* Two-sided toggle: one box on the genuine side, one on the not-satisfied side. */
.chk-box {
width: 30px;
height: 30px;
border-radius: 7px;
border: 2px solid var(--f-g300);
background: #fff;
color: var(--f-g300);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 14px;
transition: all 0.15s;
&:hover {
transform: scale(1.08);
}
&--yes:hover {
border-color: var(--f-green);
color: var(--f-green);
}
&--no:hover {
border-color: var(--f-red);
color: var(--f-red);
}
&--yes.on {
border-color: var(--f-green);
background: var(--f-green);
color: #fff;
}
&--no.on {
border-color: var(--f-red);
background: var(--f-red);
color: #fff;
}
}
/* Concluded suspicious, still to be submitted on the FINnet portal. */
.str-filing {
background: #fff7ed;
border: 1px solid #fdba74;
border-radius: 8px;
padding: 10px 14px;
label {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
font-weight: 700;
color: #9a3412;
text-transform: uppercase;
letter-spacing: 0.04em;
}
}
.filing-hint {
margin: 4px 0 8px;
font-size: 12px;
color: var(--f-g700);
}
.filing-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
.fiu-inp {
max-width: 260px;
}
}
.str-filed-ack {
display: flex;
align-items: center;
gap: 8px;
background: var(--f-green-l);
border: 1px solid #86efac;
border-radius: 8px;
padding: 9px 14px;
font-size: 12px;
color: #14532d;
}
/* ── CTR-referred case: Principal Officer investigation workspace ───────── */
.str-investigation {
margin-top: 12px;
border: 1px solid var(--f-purple);
border-radius: 10px;
background: #fdfcff;
display: flex;
flex-direction: column;
gap: 12px;
padding: 14px;
}
.inv-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
font-size: 12px;
font-weight: 700;
color: var(--f-purple);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.inv-step {
display: flex;
flex-direction: column;
gap: 8px;
textarea {
width: 100%;
padding: 8px 10px;
border-radius: 6px;
border: 1px solid var(--f-g300);
font-family: inherit;
font-size: 13px;
}
}
.inv-step-t {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 700;
color: var(--f-g900);
}
.inv-num {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--f-purple);
color: #fff;
font-size: 11px;
font-weight: 700;
flex-shrink: 0;
}
.inv-grid {
display: grid;
grid-template-columns: 2fr 1fr 1fr;
gap: 12px;
}
@media (width <= 900px) {
.inv-grid {
grid-template-columns: 1fr;
}
}
/* Derived verdict banner */
.verdict {
display: flex;
align-items: center;
gap: 10px;
padding: 11px 15px;
border-radius: 9px;
font-size: 13px;
background: var(--f-g100);
border: 1px solid var(--f-g200);
color: var(--f-g700);
&--clear {
background: var(--f-green-l);
border-color: #86efac;
color: #14532d;
}
&--suspicious {
background: var(--f-red-l);
border-color: #fca5a5;
color: #7f1d1d;
}
}
.case-form .fiu-btn-xml:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.file-str-row {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
padding-top: 12px;
border-top: 1px dashed var(--f-g300);
label {
font-size: 12px;
font-weight: 600;
color: var(--f-g500);
}
}
/* ═══ WHEN TO REPORT — the five PMLA obligations ═══ */
.ob-block {
margin: 18px 0 20px;
}
.ob-head {
margin-bottom: 12px;
}
.ob-title {
margin: 0;
font-size: 15px;
font-weight: 700;
display: flex;
align-items: center;
gap: 8px;
}
.ob-sub {
margin: 3px 0 0;
font-size: 12px;
color: #64748b;
max-width: 860px;
}
.ob-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 12px;
}
.ob-card {
background: #fff;
border: 1px solid #e2e8f0;
border-left: 4px solid #1a237e;
border-radius: 11px;
padding: 13px 15px;
display: flex;
flex-direction: column;
gap: 7px;
/* Obligations the module only tracks read back a shade quieter than the
two it actually files, so the distinction is visible at a glance. */
&--off {
border-left-color: #cbd5e1;
background: #fcfcfd;
}
}
.ob-card-h {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.ob-ic {
color: #1a237e;
font-size: 15px;
}
.ob-code {
font-weight: 800;
font-size: 13px;
letter-spacing: 0.02em;
}
.ob-name {
font-size: 12px;
color: #334155;
font-weight: 600;
}
.ob-flag {
margin-left: auto;
font-size: 10px;
font-weight: 700;
padding: 1px 8px;
border-radius: 999px;
background: #f1f5f9;
color: #64748b;
white-space: nowrap;
&--on {
background: #dcfce7;
color: #15803d;
}
}
.ob-row {
display: flex;
flex-direction: column;
gap: 1px;
label {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #94a3b8;
}
span {
font-size: 12.5px;
color: #0f172a;
line-height: 1.45;
}
}
.ob-due {
font-weight: 700;
color: #b45309 !important;
}
.ob-note {
font-size: 11.5px;
color: #64748b;
font-style: italic;
border-top: 1px dashed #e2e8f0;
padding-top: 6px;
}
/* ═══ FINNET FILE SEQUENCE ═══ */
.seq-block {
margin-top: 26px;
padding-top: 18px;
border-top: 2px solid #e2e8f0;
}
.seq-rail {
display: flex;
align-items: center;
gap: 4px;
flex-wrap: wrap;
margin-bottom: 14px;
}
.seq-step {
display: inline-flex;
align-items: center;
gap: 9px;
padding: 7px 13px;
border: 1px solid #cbd5e1;
background: #fff;
border-radius: 9px;
cursor: pointer;
font-family: inherit;
text-align: left;
transition: all 0.15s;
&:hover {
border-color: #1a237e;
}
&.active {
background: #1a237e;
border-color: #1a237e;
.seq-n {
background: #fff;
color: #1a237e;
}
.seq-txt strong,
.seq-txt em {
color: #fff;
}
}
}
.seq-n {
display: inline-flex;
align-items: center;
justify-content: center;
width: 21px;
height: 21px;
border-radius: 50%;
background: #1a237e;
color: #fff;
font-size: 11px;
font-weight: 800;
flex: 0 0 21px;
}
.seq-txt {
display: flex;
flex-direction: column;
line-height: 1.25;
strong {
font-size: 12.5px;
color: #0f172a;
font-weight: 700;
}
em {
font-size: 10.5px;
color: #64748b;
font-style: normal;
}
}
.seq-arrow {
color: #cbd5e1;
font-size: 11px;
}
.seq-panel {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 11px;
overflow: hidden;
}
.seq-panel-h {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
padding: 12px 16px;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
flex-wrap: wrap;
h4 {
margin: 0;
font-size: 13.5px;
font-weight: 700;
}
p {
margin: 2px 0 0;
font-size: 12px;
color: #64748b;
max-width: 780px;
}
}
.seq-tbl-wrap {
max-height: 460px;
overflow: auto;
}
.seq-tbl {
width: 100%;
border-collapse: collapse;
font-size: 11.5px;
white-space: nowrap;
th {
position: sticky;
top: 0;
z-index: 1;
text-align: left;
padding: 8px 10px;
background: #eef2f7;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.03em;
color: #334155;
border-bottom: 1px solid #cbd5e1;
}
td {
padding: 6px 10px;
border-bottom: 1px solid #f1f5f9;
font-family: 'DM Mono', monospace;
color: #0f172a;
}
tbody tr:hover td {
background: #f8fafc;
}
}
.seq-rownum {
color: #94a3b8 !important;
font-weight: 700;
text-align: right;
position: sticky;
left: 0;
background: #fff;
}
.seq-tbl thead .seq-rownum {
background: #eef2f7;
}
.seq-empty {
padding: 36px !important;
text-align: center;
color: #64748b;
font-family: inherit !important;
white-space: normal;
font-size: 12.5px;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,638 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { GL_BRANCHES, GL_PERIOD } from '../gl-reports/gl-reports-data';
/**
* Transactions of customers who banked on a Form 97 / Form 98 declaration.
*
* Rule 114B of the Income-tax Rules lists transactions where quoting PAN is
* mandatory. A customer who has no PAN gives a declaration instead:
*
* Form 97 — person with no PAN (income may be taxable).
* Form 98 — person whose only income is agricultural and who has no PAN.
*
* The bank must be able to produce, for any period, every transaction put
* through on the strength of such a declaration. That is what this report is.
*/
export type FormType = 'FORM_60' | 'FORM_61';
/** Screen and workbook labels — this bank numbers the declarations 97 and 98. */
export const FORM_LABEL: Record<FormType, string> = {
FORM_60: 'Form 97',
FORM_61: 'Form 98'
};
export interface FormDeclarant {
/** Declaration acknowledgement number, as issued by the branch. */
ackNo: string;
formType: FormType;
name: string;
fatherName: string;
dateOfBirth: string;
address: string;
branchCode: string;
accountNo: string;
/** Why the customer has no PAN — printed on the declaration. */
reasonForNoPan: string;
estimatedIncome: number;
declaredOn: string;
aadhaarLast4: string;
}
export type TxnMode = 'CASH' | 'TRANSFER' | 'CLEARING' | 'NEFT' | 'RTGS' | 'UPI' | 'ATM';
export interface FormDeclarantTxn {
txnId: string;
ackNo: string;
date: string;
branchCode: string;
accountNo: string;
mode: TxnMode;
drCr: 'DR' | 'CR';
amount: number;
/** What the transaction was — a Rule 114B category or ordinary activity. */
nature: string;
/** Coarse bucket the nature belongs to, e.g. Cash, Clearing, Loan. */
group: string;
/** True when the nature is one of the categories listed in Rule 114B. */
rule114B: boolean;
particulars: string;
voucherNo: string;
}
/** Deterministic linear congruential generator — same figures on every load. */
function lcg(seed: number): () => number {
let s = seed >>> 0;
return () => {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
function pick<T>(rand: () => number, list: T[]): T {
return list[Math.floor(rand() * list.length)];
}
function isoDate(d: Date): string {
const y = d.getUTCFullYear();
const m = `${d.getUTCMonth() + 1}`.padStart(2, '0');
const day = `${d.getUTCDate()}`.padStart(2, '0');
return `${y}-${m}-${day}`;
}
/**
* Thresholds that make a row worth a second look.
*
* `RULE_114B_CASH` is the cash-deposit limit above which a declaration is
* required at all; `CTR` is the aggregate cash figure that puts the customer
* into the monthly Cash Transaction Report.
*/
export const FORM_THRESHOLD = {
RULE_114B_CASH: 50_000,
CTR: 1_000_000
};
const FIRST_NAMES = [
'Ramesh',
'Sunita',
'Vitthal',
'Kamala',
'Ganpat',
'Shobha',
'Dnyaneshwar',
'Mangala',
'Bhaskar',
'Anita',
'Tukaram',
'Sarika',
'Nivrutti',
'Rekha',
'Popat',
'Chhaya',
'Ashok',
'Vaishali',
'Bapu',
'Nanda',
'Sitaram',
'Jayshree',
'Madhukar',
'Pushpa',
'Eknath',
'Sulochana',
'Baban',
'Indubai',
'Hanumant',
'Kalpana'
];
const SURNAMES = [
'Jadhav',
'Pawar',
'Shinde',
'Gaikwad',
'More',
'Kadam',
'Bhosale',
'Patil',
'Sawant',
'Chavan',
'Salunkhe',
'Mane',
'Deshmukh',
'Thorat',
'Nikam',
'Wagh',
'Kale',
'Ghadge',
'Bhoir',
'Tandel'
];
const VILLAGES = [
'Wadgaon',
'Kolhewadi',
'Shirgaon',
'Nandgaon',
'Pimpalgaon',
'Bhosari',
'Rajgurunagar',
'Talegaon',
'Karjat',
'Murbad',
'Shahapur',
'Vasind'
];
/** Reasons a Form 97 declarant gives — Form 98 has its own single reason. */
const NO_PAN_REASONS = [
'Applied for PAN, allotment awaited',
'Total income below taxable limit',
'Not required to file return of income',
'First-time account holder, PAN not yet obtained'
];
/**
* Every kind of transaction one of these customers puts through — not only the
* ones that triggered a declaration.
*
* `rule114B` marks the categories listed in Rule 114B, where quoting PAN (and
* therefore the Form 97/98 declaration) is mandatory above the limit. The rest
* are ordinary account activity, which the report still has to show: the point
* is the customer's whole picture, not just the declared slice.
*
* `drCr` is fixed where the category only goes one way; 'EITHER' lets the
* generator choose. `cashOnly` forces counter cash, `nonCash` excludes it.
*/
interface TxnKind {
nature: string;
group: string;
rule114B: boolean;
drCr: 'DR' | 'CR' | 'EITHER';
cashOnly?: boolean;
nonCash?: boolean;
/** Relative frequency — small routine postings outnumber big declared ones. */
weight: number;
/** Typical value band, before the occasional large-value tail. */
min: number;
max: number;
}
const TXN_KINDS: TxnKind[] = [
// ── Rule 114B categories: the transactions the declaration was taken for ──
{
nature: 'Cash deposit exceeding ₹50,000 in a day',
group: 'Cash',
rule114B: true,
drCr: 'CR',
cashOnly: true,
weight: 14,
min: 50_000,
max: 470_000
},
{
nature: 'Cash withdrawal exceeding ₹50,000',
group: 'Cash',
rule114B: true,
drCr: 'DR',
cashOnly: true,
weight: 8,
min: 50_000,
max: 380_000
},
{
nature: 'Opening of deposit account',
group: 'Account',
rule114B: true,
drCr: 'CR',
cashOnly: true,
weight: 3,
min: 5_000,
max: 50_000
},
{
nature: 'Term deposit exceeding ₹50,000',
group: 'Deposit',
rule114B: true,
drCr: 'DR',
weight: 5,
min: 50_000,
max: 900_000
},
{
nature: 'Purchase of demand draft in cash',
group: 'Remittance',
rule114B: true,
drCr: 'DR',
cashOnly: true,
weight: 4,
min: 50_000,
max: 260_000
},
{
nature: 'Sale/purchase of goods or services above ₹2,00,000',
group: 'Trade',
rule114B: true,
drCr: 'EITHER',
weight: 3,
min: 200_000,
max: 850_000
},
// ── Ordinary account activity ────────────────────────────────────────────
{
nature: 'Cash deposit',
group: 'Cash',
rule114B: false,
drCr: 'CR',
cashOnly: true,
weight: 16,
min: 500,
max: 49_900
},
{
nature: 'Cash withdrawal',
group: 'Cash',
rule114B: false,
drCr: 'DR',
cashOnly: true,
weight: 15,
min: 500,
max: 49_900
},
{
nature: 'Cheque deposit — outward clearing',
group: 'Clearing',
rule114B: false,
drCr: 'CR',
nonCash: true,
weight: 7,
min: 2_000,
max: 180_000
},
{
nature: 'Cheque paid — inward clearing',
group: 'Clearing',
rule114B: false,
drCr: 'DR',
nonCash: true,
weight: 6,
min: 2_000,
max: 160_000
},
{
nature: 'NEFT inward credit',
group: 'Remittance',
rule114B: false,
drCr: 'CR',
nonCash: true,
weight: 6,
min: 1_000,
max: 220_000
},
{
nature: 'NEFT outward remittance',
group: 'Remittance',
rule114B: false,
drCr: 'DR',
nonCash: true,
weight: 5,
min: 1_000,
max: 200_000
},
{
nature: 'RTGS inward credit',
group: 'Remittance',
rule114B: false,
drCr: 'CR',
nonCash: true,
weight: 2,
min: 200_000,
max: 1_200_000
},
{
nature: 'UPI / mobile banking transfer',
group: 'Digital',
rule114B: false,
drCr: 'EITHER',
nonCash: true,
weight: 10,
min: 100,
max: 24_000
},
{
nature: 'ATM cash withdrawal',
group: 'Digital',
rule114B: false,
drCr: 'DR',
cashOnly: true,
weight: 9,
min: 500,
max: 20_000
},
{
nature: 'Transfer between own accounts',
group: 'Transfer',
rule114B: false,
drCr: 'EITHER',
nonCash: true,
weight: 5,
min: 1_000,
max: 120_000
},
{
nature: 'Loan instalment (EMI) recovery',
group: 'Loan',
rule114B: false,
drCr: 'DR',
weight: 7,
min: 2_500,
max: 42_000
},
{
nature: 'Loan disbursement credited',
group: 'Loan',
rule114B: false,
drCr: 'CR',
nonCash: true,
weight: 2,
min: 50_000,
max: 700_000
},
{
nature: 'Interest credited on deposit',
group: 'Interest',
rule114B: false,
drCr: 'CR',
nonCash: true,
weight: 6,
min: 120,
max: 14_000
},
{
nature: 'Interest debited on loan',
group: 'Interest',
rule114B: false,
drCr: 'DR',
nonCash: true,
weight: 4,
min: 200,
max: 11_000
},
{
nature: 'Term deposit matured and credited',
group: 'Deposit',
rule114B: false,
drCr: 'CR',
nonCash: true,
weight: 3,
min: 25_000,
max: 620_000
},
{
nature: 'Recurring deposit instalment',
group: 'Deposit',
rule114B: false,
drCr: 'DR',
weight: 5,
min: 500,
max: 12_000
},
{
nature: 'Service charges / commission recovered',
group: 'Charges',
rule114B: false,
drCr: 'DR',
nonCash: true,
weight: 6,
min: 25,
max: 2_400
},
{
nature: 'Cheque return charges',
group: 'Charges',
rule114B: false,
drCr: 'DR',
nonCash: true,
weight: 2,
min: 100,
max: 900
},
{
nature: 'Locker rent recovered',
group: 'Charges',
rule114B: false,
drCr: 'DR',
nonCash: true,
weight: 1,
min: 800,
max: 4_500
},
{
nature: 'Dividend credited on shares',
group: 'Shares',
rule114B: false,
drCr: 'CR',
nonCash: true,
weight: 2,
min: 200,
max: 9_000
},
{
nature: 'Share capital subscription',
group: 'Shares',
rule114B: false,
drCr: 'DR',
weight: 2,
min: 500,
max: 25_000
}
];
/** Flattened by weight so a single draw picks a category at the right rate. */
const KIND_POOL: TxnKind[] = TXN_KINDS.flatMap((k) => Array<TxnKind>(k.weight).fill(k));
/** The distinct groups, for the report's category filter. */
export const TXN_GROUPS: string[] = [...new Set(TXN_KINDS.map((k) => k.group))].sort();
let declarantCache: FormDeclarant[] | null = null;
let txnCache: FormDeclarantTxn[] | null = null;
/**
* The customers who filed a declaration. Roughly one in five is a Form 98
* (agricultural income only), which matches the mix a semi-urban co-operative
* bank sees — the rural branches carry most of them.
*/
export function formDeclarants(): FormDeclarant[] {
if (declarantCache) {
return declarantCache;
}
const rand = lcg(31071998);
const branches = GL_BRANCHES.filter((b) => b.code !== 'HO');
const out: FormDeclarant[] = [];
for (let i = 0; i < 148; i++) {
const branch = branches[i % branches.length];
// Form 98 is the agricultural-income declaration, so it clusters in the
// outlying branches rather than being spread evenly.
const rural = branch.code === '0004' || branch.code === '0006';
const formType: FormType = rand() < (rural ? 0.42 : 0.08) ? 'FORM_61' : 'FORM_60';
const first = pick(rand, FIRST_NAMES);
const last = pick(rand, SURNAMES);
const seq = `${i + 1}`.padStart(3, '0');
// Declarations are taken in the run-up to and during the ledger period.
const declOffset = Math.floor(rand() * 150);
const declDate = new Date(`${GL_PERIOD.fyStart}T00:00:00Z`);
declDate.setUTCDate(declDate.getUTCDate() - 60 + declOffset);
const birthYear = 1955 + Math.floor(rand() * 45);
const birthMonth = 1 + Math.floor(rand() * 12);
const birthDay = 1 + Math.floor(rand() * 28);
out.push({
ackNo: `${branch.code}/F${formType === 'FORM_60' ? '97' : '98'}/${seq}`,
formType,
name: `${first} ${last}`,
fatherName: `${pick(rand, FIRST_NAMES)} ${last}`,
dateOfBirth: `${birthYear}-${`${birthMonth}`.padStart(2, '0')}-${`${birthDay}`.padStart(2, '0')}`,
address: `${pick(rand, VILLAGES)}, Tal. ${pick(rand, VILLAGES)}, Dist. Thane`,
branchCode: branch.code,
accountNo: `${branch.code}${(4100000 + i * 137).toString().padStart(8, '0')}`,
reasonForNoPan:
formType === 'FORM_61' ? 'Income consists only of agricultural income' : pick(rand, NO_PAN_REASONS),
// Form 98 declarants report agricultural income, which the Act exempts, so
// their declared figures sit below the Form 97 band.
estimatedIncome:
formType === 'FORM_61' ? 90_000 + Math.floor(rand() * 160_000) : 140_000 + Math.floor(rand() * 380_000),
declaredOn: isoDate(declDate),
aadhaarLast4: `${1000 + Math.floor(rand() * 9000)}`
});
}
declarantCache = out;
return out;
}
/**
* Every transaction these customers put through inside the ledger period.
*
* A declaration is taken per transaction category, so an active customer shows
* several rows; a customer who only opened an account shows one.
*/
export function formDeclarantTxns(): FormDeclarantTxn[] {
if (txnCache) {
return txnCache;
}
const rand = lcg(19980731);
const start = new Date(`${GL_PERIOD.fyStart}T00:00:00Z`);
const end = new Date(`${GL_PERIOD.fyEnd}T00:00:00Z`);
const span = Math.round((end.getTime() - start.getTime()) / 86_400_000);
const out: FormDeclarantTxn[] = [];
let voucher = 1;
for (const d of formDeclarants()) {
// Weighted low — most declarants are occasional users and a handful bank
// every week. Squaring the draw gives that long tail rather than a flat
// spread, while still leaving enough volume for a day-wise filter to show.
const count = 1 + Math.floor(rand() * rand() * 22);
for (let n = 0; n < count; n++) {
const day = new Date(start.getTime());
day.setUTCDate(day.getUTCDate() + Math.floor(rand() * span));
if (day.getUTCDay() === 0) {
day.setUTCDate(day.getUTCDate() + 1); // Sunday — no counter business.
}
const kind = pick(rand, KIND_POOL);
const drCr: 'DR' | 'CR' = kind.drCr === 'EITHER' ? (rand() < 0.5 ? 'DR' : 'CR') : kind.drCr;
// The channel follows the category: cash categories go over the counter,
// clearing goes through the house, and the rest split across the
// electronic rails a co-operative bank offers.
let mode: TxnMode;
if (kind.cashOnly) {
mode = kind.nature.startsWith('ATM') ? 'ATM' : 'CASH';
} else if (kind.nonCash) {
mode =
kind.group === 'Clearing'
? 'CLEARING'
: kind.nature.startsWith('RTGS')
? 'RTGS'
: kind.nature.startsWith('NEFT')
? 'NEFT'
: kind.group === 'Digital'
? 'UPI'
: 'TRANSFER';
} else {
// Categories that can go either way — a term deposit or an EMI is paid
// in cash as often as it is transferred at this kind of bank.
mode = rand() < 0.55 ? 'CASH' : 'TRANSFER';
}
// Inside the category's own band, with a thin large-value tail on the
// Rule 114B rows so the report has CTR-sized entries to flag.
let amount = kind.min + Math.floor(rand() * (kind.max - kind.min));
if (kind.rule114B && kind.cashOnly && rand() < 0.09) {
amount = FORM_THRESHOLD.CTR + Math.floor(rand() * 1_400_000);
}
amount = Math.round(amount / 100) * 100 || 100;
const cashWord = mode === 'CASH' || mode === 'ATM';
out.push({
txnId: `TXN${`${out.length + 1}`.padStart(5, '0')}`,
ackNo: d.ackNo,
date: isoDate(day),
branchCode: d.branchCode,
accountNo: d.accountNo,
mode,
drCr,
amount,
nature: kind.nature,
group: kind.group,
rule114B: kind.rule114B,
particulars:
drCr === 'DR'
? `${cashWord ? 'Cash paid to' : 'Debit to account of'} ${d.name} — ${kind.nature.toLowerCase()}`
: `${cashWord ? 'Cash received from' : 'Credit to account of'} ${d.name} — ${kind.nature.toLowerCase()}`,
voucherNo: `${d.branchCode}/V/${`${voucher++}`.padStart(5, '0')}`
});
}
}
out.sort((a, b) => (a.date === b.date ? a.txnId.localeCompare(b.txnId) : a.date.localeCompare(b.date)));
txnCache = out;
return out;
}
export function declarantByAck(ackNo: string): FormDeclarant | undefined {
return formDeclarants().find((d) => d.ackNo === ackNo);
}
@@ -0,0 +1,370 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="fd-root">
<!-- HEADER -->
<div class="fd-head">
<div class="fd-head-left">
<fa-icon icon="file-signature" class="fd-head-ic"></fa-icon>
<div>
<h2 class="fd-head-t">Form 97 / 98 Transaction Report</h2>
<div class="fd-head-s">
Every transaction put through on a declaration under Rule 114B, for customers who have no PAN. Form 97 is the
general declaration; Form 98 is for a person whose only income is agricultural.
</div>
</div>
</div>
<button class="fd-btn fd-btn--primary" [disabled]="generating" (click)="exportExcel()">
<fa-icon icon="file-excel"></fa-icon> {{ generating ? 'Generating…' : 'Download Excel' }}
</button>
</div>
<!-- FILTERS -->
<div class="fd-filters">
<div class="fd-filter-row">
<div class="fd-modes">
@for (m of periodModes; track m.key) {
<button class="fd-chip" [class.active]="periodMode === m.key" (click)="periodMode = m.key">
{{ m.label }}
</button>
}
</div>
@switch (periodMode) {
@case ('day') {
<label class="fd-field">
<span>Date</span>
<input type="date" [(ngModel)]="filterDay" [min]="period.fyStart" [max]="period.fyEnd" />
</label>
}
@case ('week') {
<label class="fd-field">
<span>Any date in week</span>
<input type="date" [(ngModel)]="filterWeek" [min]="period.fyStart" [max]="period.fyEnd" />
</label>
<span class="fd-resolved">Week: {{ rangeLabel }}</span>
}
@case ('month') {
<label class="fd-field">
<span>Month</span>
<input type="month" [(ngModel)]="filterMonth" min="2026-04" max="2026-07" />
</label>
}
@default {
<label class="fd-field">
<span>From</span>
<input type="date" [(ngModel)]="filterFrom" [min]="period.fyStart" [max]="period.fyEnd" />
</label>
<label class="fd-field">
<span>To</span>
<input type="date" [(ngModel)]="filterTo" [min]="period.fyStart" [max]="period.fyEnd" />
</label>
}
}
</div>
<div class="fd-filter-row">
<label class="fd-field">
<span>Branch</span>
<select [(ngModel)]="branchCode" [disabled]="!canSelectBranch">
@if (canSelectBranch) {
<option value="">All Branches</option>
}
@for (b of branches; track b.code) {
<option [value]="b.code">{{ b.code }} — {{ b.name }}</option>
}
</select>
</label>
<label class="fd-field">
<span>Declaration</span>
<select [(ngModel)]="formType">
<option value="">Both</option>
<option value="FORM_60">Form 97 — no PAN</option>
<option value="FORM_61">Form 98 — agricultural income</option>
</select>
</label>
<label class="fd-field">
<span>Transaction type</span>
<select [(ngModel)]="group">
<option value="">All types</option>
@for (g of txnGroups; track g) {
<option [value]="g">{{ g }}</option>
}
</select>
</label>
<label class="fd-field fd-field--grow">
<span>Search</span>
<input type="text" [(ngModel)]="search" placeholder="Name, account number or acknowledgement no…" />
</label>
<label class="fd-check">
<input type="checkbox" [(ngModel)]="flaggedOnly" />
Only CTR reportable
</label>
<button class="fd-btn" (click)="resetFilters()"><fa-icon icon="undo"></fa-icon> Reset</button>
</div>
@if (!canSelectBranch) {
<div class="fd-scope-note">
<fa-icon icon="lock"></fa-icon>
Showing {{ branchLabel }} only. Head Office logins can select any branch.
</div>
}
</div>
<!-- SUMMARY -->
<div class="fd-kpis">
<div class="fd-kpi">
<div class="fd-kpi-n">{{ summary.persons }}</div>
<div class="fd-kpi-l">Declarants</div>
<div class="fd-kpi-sub">{{ summary.form60Persons }} on Form 97 · {{ summary.form61Persons }} on Form 98</div>
</div>
<div class="fd-kpi fd-kpi--blue">
<div class="fd-kpi-n">{{ summary.txnCount }}</div>
<div class="fd-kpi-l">Transactions — all types</div>
<div class="fd-kpi-sub">{{ summary.declarationRequired }} in a Rule 114B category</div>
</div>
<div class="fd-kpi fd-kpi--amber">
<div class="fd-kpi-n">{{ summary.cashTotal | number: '1.0-0' }}</div>
<div class="fd-kpi-l">Cash turnover (₹)</div>
<div class="fd-kpi-sub">{{ summary.cashCount }} cash transactions</div>
</div>
<div class="fd-kpi" [class.fd-kpi--red]="summary.ctrReportable > 0">
<div class="fd-kpi-n">{{ summary.ctrReportable }}</div>
<div class="fd-kpi-l">CTR reportable</div>
<div class="fd-kpi-sub">cash at or above ₹10,00,000</div>
</div>
</div>
<!-- CATEGORY MIX — what these customers actually do, beyond the declared rows -->
@if (summary.byGroup.length) {
<div class="fd-mix">
<span class="fd-mix-t">Transaction mix</span>
@for (g of summary.byGroup; track g.group) {
<button
class="fd-mix-item"
[class.active]="group === g.group"
(click)="group = group === g.group ? '' : g.group"
>
{{ g.group }} <strong>{{ g.count }}</strong>
</button>
}
</div>
}
<!-- VIEW SWITCH -->
<div class="fd-views">
<button class="fd-chip" [class.active]="view === 'person'" (click)="view = 'person'">
<fa-icon icon="users"></fa-icon> Person wise
</button>
<button class="fd-chip" [class.active]="view === 'transaction'" (click)="view = 'transaction'">
<fa-icon icon="list"></fa-icon> Transaction wise
</button>
<span class="fd-spacer"></span>
<span class="fd-count">
{{ view === 'person' ? personRows.length + ' declarants' : txnRows.length + ' transactions' }}
</span>
</div>
<!-- PERSON WISE -->
@if (view === 'person') {
<div class="fd-table-wrap">
<table class="fd-table">
<thead>
<tr>
<th></th>
<th>Acknowledgement</th>
<th>Form</th>
<th>Name of declarant</th>
<th>Account No</th>
<th>Branch</th>
<th class="num">Txns</th>
<th class="num">Rule 114B</th>
<th class="num">Cash turnover</th>
<th class="num">Credit</th>
<th class="num">Debit</th>
<th class="num">Declared income</th>
<th class="num">Cash ÷ income</th>
</tr>
</thead>
<tbody>
@for (p of personRows; track p.declarant.ackNo) {
<tr class="fd-person" [class.open]="expanded === p.declarant.ackNo" (click)="toggle(p.declarant.ackNo)">
<td>
<fa-icon [icon]="expanded === p.declarant.ackNo ? 'caret-down' : 'caret-right'"></fa-icon>
</td>
<td class="mono">{{ p.declarant.ackNo }}</td>
<td>
<span class="fd-form" [class.fd-form--61]="p.declarant.formType === 'FORM_61'">
{{ formLabel[p.declarant.formType] }}
</span>
</td>
<td>
<strong>{{ p.declarant.name }}</strong>
@if (p.ctrCount > 0) {
<span class="fd-tag fd-tag--ctr">{{ p.ctrCount }} CTR</span>
}
</td>
<td class="mono">{{ p.declarant.accountNo }}</td>
<td>{{ p.declarant.branchCode }}</td>
<td class="num">{{ p.txnCount }}</td>
<td class="num">{{ p.declaredCount }}</td>
<td class="num strong">{{ p.cashTotal | number: '1.0-0' }}</td>
<td class="num">{{ p.totalCredit | number: '1.0-0' }}</td>
<td class="num">{{ p.totalDebit | number: '1.0-0' }}</td>
<td class="num">{{ p.declarant.estimatedIncome | number: '1.0-0' }}</td>
<td class="num">
<span [class]="ratioClass(p.incomeRatio)">{{ p.incomeRatio | number: '1.1-1' }}×</span>
</td>
</tr>
@if (expanded === p.declarant.ackNo) {
<tr class="fd-detail">
<td colspan="13">
<div class="fd-decl">
<div>
<label>Father's name</label><span>{{ p.declarant.fatherName }}</span>
</div>
<div>
<label>Date of birth</label><span>{{ p.declarant.dateOfBirth }}</span>
</div>
<div>
<label>Aadhaar</label><span class="mono">XXXX XXXX {{ p.declarant.aadhaarLast4 }}</span>
</div>
<div>
<label>Declared on</label><span>{{ p.declarant.declaredOn }}</span>
</div>
<div class="wide">
<label>Address</label><span>{{ p.declarant.address }}</span>
</div>
<div class="wide">
<label>Reason for no PAN</label><span>{{ p.declarant.reasonForNoPan }}</span>
</div>
</div>
<table class="fd-subtable">
<thead>
<tr>
<th>Date</th>
<th>Txn ID</th>
<th>Mode</th>
<th>Dr/Cr</th>
<th class="num">Amount</th>
<th>Nature of transaction (Rule 114B)</th>
<th>Voucher</th>
<th>Flag</th>
</tr>
</thead>
<tbody>
@for (t of p.txns; track t.txnId) {
<tr>
<td class="mono">{{ t.date }}</td>
<td class="mono">{{ t.txnId }}</td>
<td>
<span class="fd-mode">{{ t.mode }}</span>
</td>
<td [class.cr]="t.drCr === 'CR'" [class.dr]="t.drCr === 'DR'">{{ t.drCr }}</td>
<td class="num strong">{{ t.amount | number: '1.0-0' }}</td>
<td>{{ t.nature }}</td>
<td class="mono">{{ t.voucherNo }}</td>
<td>
@if (t.ctrReportable) {
<span class="fd-tag fd-tag--ctr">CTR</span>
} @else if (t.declarationRequired) {
<span class="fd-tag">Rule 114B</span>
}
</td>
</tr>
}
</tbody>
</table>
</td>
</tr>
}
} @empty {
<tr>
<td colspan="13" class="fd-empty">
No Form 97 or Form 98 transactions for {{ rangeLabel }} at {{ branchLabel }}.
</td>
</tr>
}
</tbody>
</table>
</div>
}
<!-- TRANSACTION WISE -->
@if (view === 'transaction') {
<div class="fd-table-wrap">
<table class="fd-table">
<thead>
<tr>
<th>Date</th>
<th>Txn ID</th>
<th>Acknowledgement</th>
<th>Form</th>
<th>Name of declarant</th>
<th>Account No</th>
<th>Branch</th>
<th>Category</th>
<th>Mode</th>
<th>Dr/Cr</th>
<th class="num">Amount</th>
<th>Nature of transaction</th>
<th>Voucher</th>
<th>Flag</th>
</tr>
</thead>
<tbody>
@for (t of txnRows; track t.txnId) {
<tr [class.fd-row--ctr]="t.ctrReportable">
<td class="mono">{{ t.date }}</td>
<td class="mono">{{ t.txnId }}</td>
<td class="mono">{{ t.ackNo }}</td>
<td>
<span class="fd-form" [class.fd-form--61]="t.formType === 'FORM_61'">{{ formLabel[t.formType] }}</span>
</td>
<td>{{ t.declarantName }}</td>
<td class="mono">{{ t.accountNo }}</td>
<td>{{ t.branchCode }}</td>
<td>
<span class="fd-group">{{ t.group }}</span>
</td>
<td>
<span class="fd-mode">{{ t.mode }}</span>
</td>
<td [class.cr]="t.drCr === 'CR'" [class.dr]="t.drCr === 'DR'">{{ t.drCr }}</td>
<td class="num strong">{{ t.amount | number: '1.0-0' }}</td>
<td>{{ t.nature }}</td>
<td class="mono">{{ t.voucherNo }}</td>
<td>
@if (t.ctrReportable) {
<span class="fd-tag fd-tag--ctr">CTR</span>
} @else if (t.declarationRequired) {
<span class="fd-tag">Rule 114B</span>
}
</td>
</tr>
} @empty {
<tr>
<td colspan="14" class="fd-empty">
No Form 97 or Form 98 transactions for {{ rangeLabel }} at {{ branchLabel }}.
</td>
</tr>
}
</tbody>
</table>
</div>
}
<div class="fd-toast" [class.show]="toastVisible">{{ toastMsg }}</div>
</div>

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