Compare commits

...
Author SHA1 Message Date
niteshandClaude Opus 5.5 fca550f4c4 feat(cfcfrms): I4C Live screens for the I4C-MHA integration
New CFCFRMS > I4C Live section backed by /v1/i4c/admin and /v1/i4c/cases
(the demo mock interceptor does not match these paths):

- Dashboard, complaints from I4C, holds register, approvals, responses and
  API log, bank code master, intimation settings.
- Hold popup: overview, hold transactions, customer intimation (automatic and
  manual), maker request (full / partial release, freeze, unfreeze), GRM / MRM
  and documents, request history.
- GRM grievances, MRM restorations and MRM balance report pages; shared date
  range filter (today, yesterday, week, month, custom), server-side paging and
  filters on every list; document upload / download.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 11:43:30 +05:30
niteshandClaude Opus 5.5 3e5cc26e49 feat(fixed-deposits): FD quote from the account engine, FD Special payout schedule popup, Special terms locked
- Rate chart step (account opening): the rate, maturity date, maturity amount
  and interest now come from POST fixeddepositaccounts/account-preview for
  every FD type, so the quote matches the account once created (the old
  calculate-fd-interest call counted quarters from the deposit date and was
  off, e.g. FD DOUBLE by 36,979.23). The client-side formula fallback is
  removed: on an error the message is shown, nothing is estimated.
- Dates are sent as the local calendar day (toISOString() turned 21 Oct at
  local midnight into 20 Oct).
- FD Special: total interest paid out monthly, monthly payout, principal at
  maturity, maturity date, number of payouts, and "View payout schedule",
  which opens the existing Monthly Interest Payout Schedule dialog in a new
  preview mode (Days column, no paid / pending status).
- Preview step: FD Special labels (total interest paid out monthly,
  principal at maturity).
- FD product terms: choosing FD Special locks Interest Calculation Type to
  Simple, as Compounding and Posting are locked to Monthly (the backend
  refuses anything else).
- Preview step: three existing `!= null` template checks rewritten for the
  eqeqeq lint rule, same meaning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 16:44:47 +05:30
niteshandClaude Opus 5.5 c3713f8a90 Merge feature/approval-live-badge-counts into dev-latest
Live pending-count badges on the Approval Center tabs and the Approvals menu,
served over one SSE stream.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 11:09:08 +05:30
niteshandClaude Sonnet 5.5 f804d356d7 fix(rd-products): save RD Interest Paid under expenses, as on FD and savings
The backend finds the account by name in any group. Products saved while it was under liabilities are still read from there, so the name is not left behind as an "other" liability row.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 10:48:37 +05:30
niteshandClaude Sonnet 5.5 1daa8e60a0 fix(cashier): keep the A/C TP kind on SD/FD/RD lookups in transaction entry
SD, FD and RD share one /savingsaccounts list that returns a deposit account of any kind for a number. The lookup now keeps only the deposit type chosen in A/C TP, and when the number belongs to another kind the error says so, so the clerk can fix A/C TP.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 10:48:23 +05:30
niteshandClaude Opus 5.5 55f5c1cf32 feat(ui): longer startup splash and updated loading animation
The startup splash now holds for at least 4 seconds (progress 2% per tick up to 90%)
instead of 2, and the loading animation is updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 12:31:24 +05:30
niteshandClaude Opus 5.5 6507e68203 feat(cashier): FD is credit-only on the transaction entry, and a Bank Transfer can fund an FD
- A Fixed Deposit row is credit-only on the clerk and cashier screens (isFdCreditOnly):
  its DR button is disabled, and Save refuses any FD row left on DR (for example by a
  Withdrawal Slip TYPE). Money leaves an FD only through its maturity or premature
  closure; the clerk's "Close this account" flow is the one exception.
- Bank Transfer / Fund Transfer accepts an FD on the CR side, so an FD can be funded from
  the customer's own Savings or Current account. The server enforces the funding rules
  (first and only funding, exactly the principal, the source account's owner, freeze
  status and available balance).

Requires the backend change "FD funding rules and no FD withdrawals from clerk/cashier requests".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 12:31:03 +05:30
niteshandClaude Sonnet 5.5 4d205672df feat(approvals): live pending-count badges on every Approval Center tab and the Approvals menu
The badges are pushed, not polled. ApprovalCountsService opens one
Server-Sent Events stream (/approvals/counts/stream) through HttpClient so the
login and tenant headers apply; the server writes a `counts` event the moment
the numbers change.

- Total badge on the Approvals menu, count badge on 26 Approval Center tabs
  (Reject Account and Terminated Account are lists, not requests: no badge).
- Last numbers are kept per login in localStorage, so a page refresh shows them
  at once until the stream's first event makes them exact.
- Stream recycles every few minutes and reconnects at once; after a failure it
  retries with a growing wait and reads /approvals/counts meanwhile.
- NO_PROGRESS_BAR token: the background stream does not run the top progress bar.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 15:49:36 +05:30
niteshandClaude Sonnet 5.5 ab8ed7e67e feat(vouchers): voucher correction screens and a correction status on the voucher registers
A cash deposit posted to the wrong account is moved to the right one in four steps: unpass the voucher, a manager
approves, the cashier enters the correct account, a manager approves again. Nothing changes on any account until
the final approval.

- Voucher Correction page with four-step strip and popups: today's vouchers to unpass (admin or clerk), the
  unpassed vouchers waiting for their correct account (cashier) with the wrong and correct account side by side
  and the balance check, and the caller's own requests. A cashier whose customer has used the money closes the
  request and deposits separately.
- Reached from the cashier bar, the clerk bar and the Cashier menu; the manager reviews both steps under
  Approvals > Voucher Corrections, laid out like the other approval tabs. Routes for Cash Transfers approvals are
  added to the same tab bar.
- A Correction column on My Vouchers, the day book and the voucher register (and in the voucher detail popup)
  says whether a voucher is unpassed, revoked in favour of another, a correction, or closed, with the reason.
- The deposit taken off the wrong account no longer shows on its passbook or transaction list.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 12:28:27 +05:30
niteshandClaude Sonnet 5.5 7ee230e58a feat(cashier): maker-checker on cash transfer, real branches, compact Approval Center
- Cash Transfer now raises a request ("Send for approval") instead of posting. A My Requests button in the
  page header opens the caller's requests with their status, and a Cash Transfers approvals page (review
  popup with the full denomination breakdown, approve, reject with a reason) lets a different manager release
  the cash.
- Transaction Entry's Branch dropdown lists the installation's real offices instead of a fixed list. A login
  that cannot read offices is offered only its own branch (read-only), so no "Unauthorized Request" popup.
- Approval Center header and tab bar made compact so more tabs fit on a 100% window.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 12:27:39 +05:30
niteshandClaude Sonnet 5.5 7f3c97a205 feat(cashier): GL withdrawal slips, GL account panel with parent GL, and Payment lock on token withdrawals
Transaction Entry:
- GL withdrawal slip: a GL Withdrawal Slip is always a debit. The clerk raises it as a
  request (GL, cash vault GL, office and currency ride on the payload), the manager
  approves it and the cashier pays it against its token; nothing posts directly from this
  screen any more (submitGlJournalEntry removed). Cashiers without an approved token are
  told to enter one. GL credit-only rules moved into isGlCreditOnly(), and extra rows are
  not allowed while a Withdrawal Slip is selected.
- A/C NO. now starts the automatic account search after six characters.
- Clerk: with AC. TP. = GL show a "GL Account Information" panel (code, name, type, usage,
  normal balance, parent GL, manual entries, status, description) instead of the empty
  client panels; the parent GL name is looked up from /glaccounts/{parentId} for GL only.
- Cashier: once a token (withdrawal / closure request) is verified, TYPE is disabled and
  fixed to Payment, and onTypChange refuses to change it.

Requires the backend change "GL withdrawal slips through request, approval and cashier payment".

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 13:51:38 +05:30
niteshandClaude Sonnet 5.5 4e3b49a96a feat(ui): remove Undo / Revert transaction actions
Removes the Undo button and Undo menu item from savings, current, fixed deposit, recurring deposit
and loan transactions, the account-transfer Undo, and the Revert Transaction button on the
journal entry transaction detail, to prevent accidental reversal of posted entries.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:41:39 +05:30
niteshandClaude Sonnet 5.5 915deddb74 feat(cashier): cashier-only cash operations, exchange PAN/denomination checks and Branch Cash Position
- Cash Exchange Entry: customer details with PAN (mandatory above Rs 50,000), cashier card,
  denomination-wise validation against the master and real-time impact preview.
- Inter-Cashier Transfer: sender fixed to the logged-in cashier, both sides' details and
  denomination holdings shown, blocked until both are allocated cash.
- Reconciliation, exchange and inter-cashier operate on the logged-in cashier only.
- New Branch Cash Position screen (vault + cashier drawers, denomination-wise).
- Customer search dialog shows the KYC PAN.
- Cashier menu cleanup: Excess/Closing report and Exchange Detail removed, nav compacted to one row.
- Reverse actions removed from cash transfer, settlement, exchange and inter-cashier.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:41:24 +05:30
niteshandClaude Opus 5.5 211027b9d8 fix(bank-calendar): follow Admin > Working Days for weekly offs
The Bank Calendar never read the Working Days setting. It hard-coded
Mon-Sat as working days and always marked only Sundays and the 2nd and
4th Saturday as off, so unticking Saturday (or any weekday) in
Admin > Working Days changed nothing on the calendar.

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

Verified with prettier, eslint, tsc and ng build.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:55:03 +05:30
keshav c91dcf4d99 Merge branch 'dev-latest' of https://git.lincpay.tech/CBSNexora/cbsnexora into dev-latest
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s
2026-10-12 10:23:26 +05:30
keshav 2d2eac8d03 feat: recurring deposit enhancements, dashboard, chart, and account view updates 2026-10-12 10:23:10 +05:30
niteshandClaude Opus 5.5 3928936b93 feat(ui): animated boot splash and loading screen
Show a Lottie-animated splash while the app bundle boots and dismiss it
once WebAppComponent initialises, plus a reusable mifosx-loading-screen
component with loading and error animations. lottie.min.js is served
from the lottie-web package instead of being vendored into src/assets.

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-08 18:51:22 +05:30
sonaliandClaude Sonnet 5 d61584f291 feat(loan-products): wire GL mapping, checker diff view, and approval flow for loan interest rate register
- Add GL account picker (gl-picker.component.ts) for mapping each product row to a GL account
- Extract shared diff logic into rate-register-diff.util.ts + loan-rate-register-diff.util.ts
- Add rate-register-diff component to render before/after changes for checker approval
- Rewire checker-inbox and view-checker-inbox to show loan interest rate register diffs
- Refactor rate-register.service.ts (mock-to-real HTTP wiring, GL-aware payload mapping)
- Simplify preview-register component, moving diff rendering into the shared diff component
- Add glAccountId field to RateChartRow model

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-06 10:56:25 +05:30
keshav 5ab4e9eaf0 Merge remote-tracking branch 'origin/dev-latest' into dev-latest 2026-10-05 12:41:38 +05:30
keshav 4fc6784695 feat: interest posting 2026-10-05 12:39:14 +05:30
nitesh 174a2e7b6b fix(accounting,deposits): allow future dates in premature close and GL search, fix FD/RD accounting mappings 2026-10-04 16:02:15 +05:30
nitesh 9f7d9d28fd feat: Fixed Deposit - premature close, payment dialog, account actions, translations and env updates 2026-10-04 09:12:06 +05:30
keshav 1ffac4b1e5 fix(savings-product): move savings interest paid to expenses mapping 2026-10-01 14:01:24 +05:30
niteshandClaude Sonnet 5 4dee1db360 Merge remote-tracking branch 'origin/dev-latest' into dev-latest
Brings in partner's RBI Reserve / Form 1 & 9 returns UI and interest
posting reports, alongside today's FD premature-closure work.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-01 13:22:39 +05:30
niteshandClaude Sonnet 5 0c6ebfdc1a Merge branch 'feature/bugfix' into dev-latest
Brings in partner's "Enforce same-user maker-checker restriction" and
related client-KYC-step / product-edit-diff-dialog work.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-01 13:21:19 +05:30
keshavandClaude Opus 5.5 b6994c0b76 feat(rbi-reserve,savings): Form 1/Form 9 returns UI, branch-wise interest posting reports and summary
- Reports > Interest Posting Reports: per branch and product, generate/regenerate
  at any time, approve; branch column, HO-on-behalf marker, posted txn per account
- Reports > Interest Posting Summary (branch-wise) for the head office, with Excel export
- Day Close warns and disables Close Day while the branch's interest report is unapproved
- Savings product accounting: Interest On Savings (expense), Savings Interest Payable
  and Paid (liability rows, required)
- RBI Form 1 statutory and Form 9 return screens, reserve config updates
- Interest posting report GETs exempted from the response cache

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 13:20:01 +05:30
tarunmalviya 885b39d970 DDS: approvals tab, vouchers, payout & close, Sundry GL search, closure tab, fixes 2026-10-01 13:08:52 +05:30
niteshandClaude Sonnet 5 4f02f24aae feat(deposits): RBI premature closure compliance UI, renewal-history status fix
Premature Close dialog: adds the Closure Reason dropdown, the explicit
Waive Penalty checkbox, a tenure/date preview panel, and an "Effective
Rate for this closure" display so the maker sees exactly what rate and
penalty treatment is about to apply.

Renewal History tab: the latest term's card kept showing an ACTIVE badge
even after the account was prematurely closed, since the archived
renewal-status column only ever updates on an actual renewal rollover.
For the last card only, status now cross-checks the account's own live
status instead. "Projected Maturity" now reads "Closed At" once that
term has prematurely closed, rather than implying it's still a projection.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-01 12:19:37 +05:30
anshul 73871a0993 Enforce same-user maker-checker restriction 2026-10-01 12:04:01 +05:30
anshul 7b0cb5bb63 Merge branch 'dev-latest' into feature/bugfix 2026-09-30 16:32:46 +05:30
niteshandClaude Sonnet 5 74a51a0626 fix(products): show named GL account changes in the pending-edit diff dialog
otherGlMappings (the nested document behind Savings/Current/FD/RD Interest
Payable/Paid and similar named accounts) was excluded from the generic
field diff entirely, and the backend was silently dropping its value
anyway (see the paired backend fix). Now that the backend correctly
stages this field, diff it explicitly: find each reserved account name
across original.otherGlMappingsMap and proposed.otherGlMappings, resolve
the GL code to a readable name, and show a row only when it actually
moved -- same convention as every other field in this dialog, instead of
a raw JSON blob or nothing at all.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-30 12:40:10 +05:30
anshul 646e73d08a Merge branch 'dev-latest' into feature/bugfix 2026-09-29 10:16:58 +05:30
niteshandClaude Sonnet 5 a7ed3883df Merge branch 'feature/bugfix' into dev-latest
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
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 18:36:31 +05:30
niteshandClaude Sonnet 5 18c3bb6356 fix(current-accounts): show bank/branch letterhead on passbook, matching savings
Current account passbook hardcoded "LincPay Co-operative Bank Ltd." and had
no branch identification line at all, unlike the savings passbook which
pulls the bank name and branch/IFSC/MICR details from the Institution and
Branch Profile. Wire up the same OrganizationService lookup so the current
account statement shows real bank/branch details too.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 18:18:35 +05:30
niteshandClaude Sonnet 5 6afa79bd37 fix: correct withdrawal-review account lookup, switch tenant to utkarshbank
- Manager withdrawal-review screen always searched the Savings account
  type regardless of the request's actual account_type, so reviewing a
  Current-account withdrawal failed with "Account not found". Reuse the
  existing SEARCH_ACCOUNT_TYPE_TO_AC_TYP mapping (already used by the
  cashier lookup path) instead of a hardcoded 'SD'.
- Point the UI tenant id at utkarshbank (was lincpay) to match the new
  backend tenant.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 17:53:50 +05:30
anshul ec23d57734 Merge branch 'dev-latest' into feature/bugfix 2026-09-28 17:23:53 +05:30
nitesh 192bc7725e Merge branch 'dev-latest' of https://git.lincpay.tech/CBSNexora/cbsnexora into dev-latest 2026-09-28 13:43:14 +05:30
keshav eb278978bd feat: update RBI reserve return form 1 & 9 UI and FD accounting mapping 2026-09-28 13:36:29 +05:30
niteshandClaude Sonnet 5 3f6485f652 feat(fd): restrict Payment to Matured accounts, unify its behavior with Dashboard Renew
Payment showed for both Active and Matured FDs, even though an Active FD
already has Premature Close for closing early. Now Matured-only.

That also meant a genuinely matured, past-grace FD closed via the account's
own Payment button (rather than the FD Dashboard's Renew action) silently
skipped crediting its gap-period interest -- the date-locking and
gap-interest preview/crediting were gated behind dashboard-only data, not
the account's actual matured state. Both now key off the same
renewal-preview response regardless of entry point, so Payment and Renew
are functionally identical once an FD has matured, as they should be since
they're already the same dialog component.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 13:03:47 +05:30
niteshandClaude Sonnet 5 8d0d8822f0 feat(fd): convert Premature Close to a dialog, with its full calculation shown
Was a full-page form at a dedicated route, reached by navigating away from
the account -- now a popup dialog in the same visual language as the
Payment/Renew dialog, just in this Danger Zone action's own red accent.
Also fixes a real gap in the form it replaces: the premature amount/action
options never loaded until a maker happened to touch the date picker, since
the calculation was only ever triggered on value change, never on open.

Added a breakdown panel showing exactly how the shown Maturity Amount was
reached -- Deposit Amount, Contracted Interest Rate, Pre-Closure Penalty
(when applicable), and the resulting Interest Earned -- using data the
account-view page already had, since the calculation endpoint itself only
ever returns the final number.

Removed the now-dead full-page component/route target it replaces.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 13:03:01 +05:30
niteshandClaude Sonnet 5 59e1eeacc3 fix(accounting): show latest journal entries first by default
The table fell back to the backend's own default order (entry_date, id
ascending) until a maker clicked a column header, so the oldest entries
sat at the top on every fresh load. Defaults the table's sort to id
descending instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 13:02:38 +05:30
anshul 4ca08f6ee9 feat: add new standalone components for system and organization management modules 2026-09-28 12:55:42 +05:30
anshul f451672ce7 Merge branch 'dev-latest' into feature/bugfix 2026-09-28 12:51:26 +05:30
niteshandClaude Sonnet 5 6cbb31b5c6 fix(fd): show the target account number for gap-period interest in the Renew preview
The preview panel said interest would go to "the linked Savings account"
without saying which one. Now shows its actual account number, both as its
own row and in the highlighted "paid to" line.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 14:12:44 +05:30
niteshandClaude Sonnet 5 90f26d32b7 fix(products): restore default minDepositTerm on FD product create
Removing the forced default on this hidden, backend-mandatory field fixed
a false "changed" diff on product edit, but also broke plain product
create -- there's no saved value yet for a brand-new product, so it went
in blank and the backend rejected it as missing. Now only skips the
default when editing an existing product (which always has its own saved
value); create still defaults to 1, same as before that diff-dialog fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 12:41:07 +05:30
anshul a5ed3fde21 Merge branch 'dev-latest' into feature/bugfix 2026-09-25 11:06:16 +05:30
niteshandClaude Sonnet 5 5ee0a3fc53 feat(fd): show gap-interest renewal preview in Payment dialog before confirming a late renewal
Fetches current balance, gap days since maturity, the linked savings
account's rate, and the resulting gap-interest amount and projected new
balance, and displays them above the dashboard-renewal timing text --
so staff see exactly what a late renewal is about to credit before
confirming anything. Read-only; failure to load the preview doesn't
block the renewal itself, since the existing timing text already
explains the action.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 18:21:19 +05:30
anshul 6f259ad06e Merge branch 'dev-latest' into feature/bugfix 2026-09-24 18:09:54 +05:30
keshav be35f3cbc3 feat: update TDS config, RBI reserve returns, current products, and dormancy reports 2026-09-24 18:07:54 +05:30
anshul 7b9a6394c8 Implement organization maker-checker workflows 2026-09-24 17:22:50 +05:30
niteshandClaude Sonnet 5 1eaa8cc8cf feat(fd): make the Payment dialog the FD Dashboard's Renew action too, redesign it
The Payment dialog (Transfer to Savings / Reinvest Full / Reinvest Principal Only / Partial
Reinvest, works whether an FD is Active or Matured) already had a complete, correct backend
implementation for every closure type -- including one this session found and fixed a real gap
in on the backend side (Reinvest Principal Only silently stranded its leftover interest). Rather
than duplicating that logic in a second, narrower dialog, the FD Dashboard's Renew action now
opens this same dialog in a `dashboardRenewal` mode: every closure type is still available, but
the Closed On Date is computed from the product's grace window instead of picked freely
(backdated to the original maturity date within grace, today once it's passed, picker hidden so
it can't be moved off that value), and submit() calls the new gap-interest pre-step endpoint
first when past grace, before the same generic `payment` command every other caller already used.

Also: full visual redesign (bigger dialog, gradient header, per-option accent colors, refined
spacing) applied to both entry points since they're the same component now; removes the native
"Close" action from a Matured FD's Account Actions menu, since what happens at maturity now
goes through the nightly job or this dialog, not a separate close command that bypassed both.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 16:12:45 +05:30
niteshandClaude Sonnet 5 a652ce1093 feat(fd): make Maturity Instructions optional at account-opening, pre-fill it on edit
Maturity Instructions can now be left unselected when opening a SINGLE/DOUBLE/SPECIAL FD --
a new "-- Not selected (decide later via FD Dashboard) --" option, plus a clear button next to
the field, since a maker had no way to go back to blank once something was picked. An account
left this way is never touched by the nightly maturity job; what happens to it at maturity is
decided later, manually, from the FD Dashboard's Renew action (see the backend/dashboard
commits) -- an explicit choice here still auto-processes on its own exactly as before.

Also fixes a real, separate pre-existing bug found in the same step: editing an existing
account never read back its own already-saved Maturity Instructions/transfer-target choice --
the Edit screen always opened onto a blank picker even for an account that genuinely had one
set, because it was being read from the wrong template object (the blank per-product default
template refetched from the Product dropdown, not the actual account+template response that
carries the account's own saved values).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 16:08:51 +05:30
niteshandClaude Sonnet 5 50b3542dfe fix(products): show only genuinely-changed fields in the product-edit diff
Three separate bugs in the checker's "Proposed changes" dialog, found while adding the
Renewal Grace Period field above:

- The "current" side resolved an enum field's value against the wrong payload key
  (preClosurePenalInterestOnTypeId, minDepositTermTypeId, etc. are exposed on the stored
  product as an object under a differently-named field, e.g. preClosurePenalInterestOnType.id)
  so it always read as blank -- these fields showed as changed on every single edit, never
  just the ones a maker actually touched.
- Enum/option fields resolved their label against a naive `<field>Options` key that doesn't
  match Fineract's actual (inconsistent) naming, so a genuinely changed field showed its raw
  numeric id instead of the option a maker picked.
- Renewal Grace Period Days and other newer fields never appeared at all, because the
  maker-checker pending-edit mirror table didn't carry them (see the backend commit for that).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 16:03:15 +05:30
niteshandClaude Sonnet 5 89c091671f feat(fd): add Renewal Grace Period (Days) field to FD product Terms step
Frontend half of the new per-product grace-period field: number input alongside the existing
term fields, and stops forcing real default values (1 / the first period-frequency option) onto
minDepositTerm/minDepositTermTypeId, two hidden fields nobody actually edits through this form
-- left blank like their max-/in-multiples-of- siblings already correctly were, so a governed
product edit's "Proposed changes" diff no longer showed these as changed on every unrelated edit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 16:02:35 +05:30
niteshandClaude Sonnet 5 865ad8ba88 feat(fd): add FD Dashboard -- maturity ladder + manual Renew action
New screen (Deposits menu, repurposing the dead "FD Maturity Report" shell button): every
active Fixed Deposit positioned against its own maturity date (Matured / Due Soon / Upcoming /
On Track), computed client-side from maturityDate so it can never drift out of step with the
account. Reads the new lean maturity-dashboard backend endpoint.

A matured, renewable row (no maturity instruction chosen at account-opening, or an explicit
Reinvest instruction still waiting on its product's grace period) gets a Renew action wired to
the account's own Payment dialog -- see the FD account-view commit for that side of it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 16:01:43 +05:30
anshul 08a427467a A 2026-09-23 15:51:10 +05:30
tarunmalviya d47e0860f6 Merge remote-tracking branch 'origin/dev-latest' into feature/dds-agent-settlement-reports
# Conflicts:
#	src/app/core/shell/shell.component.html
#	src/app/organization/organization-routing.module.ts
#	src/environments/.env.ts
2026-09-23 12:11:12 +05:30
tarunmalviyaandClaude Opus 5 e2eff9914f DDS: agent collection entry screen, per-agent GL account, maturity auto close
The Daily Transaction Entry screen no longer composes a Party Tran. An agent records their own round
elsewhere; this screen reads back what they wrote down, shows what it adds up to, and settles the
day once a clerk has counted the cash against it. Nothing is typed here and nothing is edited - what
settles is what the entries say, to the rupee.

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 11:54:49 +05:30
anshulandClaude Sonnet 5 cda7c52d11 Merge branch 'dev' into feature/bugfix
Conflicts resolved in:
- src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.html
- src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.ts

Kept dev's CKYC architecture (upload dialog, real per-artefact endpoints)
and layered feature/bugfix's fixes on top: upload file validation,
missing-file badge, silent preview download, and image/PDF view button.
Also includes the CKYC "Image of Client" upload-state fix and the
client-change state-leak fix in the same component.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 16:55:32 +05:30
anshul d132bbe716 feat: add CKYC management tab, checker inbox views, and maker-checker diff utilities 2026-09-16 15:17:51 +05:30
satyamsingh_8888 6eaf9b959f Add Harbor login to deploy step so cross-node pull auth propagates to dev2
continuous-integration/drone/push Build is passing
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
2026-09-14 18:41:34 +05:30
satyamsingh_8888 b184a0909c Deploy cbsnexora UI to dev2 node (environment==dev2 constraint)
continuous-integration/drone/push Build was killed
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-09-14 18:22:00 +05:30
tarunmalviya 57a61b3057 DDS: Agent settlement (Agent Tran), Close Party account UI, reports UI, Party-Savings link field 2026-08-25 10:56:03 +05:30
566 changed files with 80344 additions and 6312 deletions
+9 -2
View File
@@ -31,6 +31,8 @@
"@angular/localize/init"
],
"assets": [
"src/favicon.ico",
"src/apple-touch-icon.png",
"src/assets/images/cbsnexora-favicon.svg",
"src/robots.txt",
"src/manifest.json",
@@ -39,6 +41,11 @@
"glob": "**/*",
"input": "node_modules/tinymce",
"output": "assets/tinymce"
},
{
"glob": "lottie.min.js",
"input": "node_modules/lottie-web/build/player",
"output": "assets/js"
}
],
"styles": [
@@ -69,8 +76,8 @@
"budgets": [
{
"type": "initial",
"maximumWarning": "15mb",
"maximumError": "17mb"
"maximumWarning": "18mb",
"maximumError": "20mb"
},
{
"type": "anyComponentStyle",
+7
View File
@@ -41,6 +41,7 @@
"jspdf-autotable": "^5.0.2",
"lightgallery": "^2.9.0",
"lodash": "4.17.23",
"lottie-web": "^5.13.0",
"moment": "^2.29.4",
"ngx-image-cropper": "^9.1.6",
"ngx-mat-select-search": "^8.0.2",
@@ -18380,6 +18381,12 @@
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/lottie-web": {
"version": "5.13.0",
"resolved": "https://registry.npmjs.org/lottie-web/-/lottie-web-5.13.0.tgz",
"integrity": "sha512-+gfBXl6sxXMPe8tKQm7qzLnUy5DUPJPKIyRHwtpCpyUEYjHYRJC/5gjUvdkuO2c3JllrPtHXH5UJJK8LRYl5yQ==",
"license": "MIT"
},
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+2 -1
View File
@@ -15,7 +15,7 @@
"ng": "ng",
"build": "node version.js && npm run env -s && ng build --configuration production --output-hashing=none",
"build:prod": "node version.js && node --max-old-space-size=16384 ./node_modules/@angular/cli/bin/ng build --configuration production --output-hashing=none --base-href=/web-app/",
"start": "npm run env -s && cross-env NODE_OPTIONS=\"--max_old_space_size=16384\" ng serve --proxy-config proxy.localhost.conf.js",
"start": "npm run env -s && cross-env NODE_OPTIONS=\"--max_old_space_size=4096\" ng serve --proxy-config proxy.localhost.conf.js",
"start:share": "npm run env -s && cross-env NODE_OPTIONS=\"--max_old_space_size=16384\" ng serve --host 0.0.0.0 --proxy-config proxy.localhost.conf.js",
"start:demo": "npm run env -s && cross-env NODE_OPTIONS=\"--max_old_space_size=16384\" ng serve --proxy-config proxy.conf.js",
"serve:dev": "npm run env -s && cross-env NODE_OPTIONS=\"--max_old_space_size=16384\" ng serve --source-map --proxy-config proxy.localhost.conf.js",
@@ -71,6 +71,7 @@
"jspdf-autotable": "^5.0.2",
"lightgallery": "^2.9.0",
"lodash": "4.17.23",
"lottie-web": "^5.13.0",
"moment": "^2.29.4",
"ngx-image-cropper": "^9.1.6",
"ngx-mat-select-search": "^8.0.2",
@@ -7,16 +7,6 @@
-->
<div class="container">
<div class="container m-b-20 align-end gap-2px">
@if (!viewAccountTransferData.reversed) {
<span>
<button mat-raised-button color="warn" *mifosxHasPermission="'ADJUST_ACCOUNTTRANSFER'">
<fa-icon icon="undo" class="m-r-10"></fa-icon>{{ 'labels.buttons.Undo' | translate }}
</button>
</span>
}
</div>
<mat-card>
<mat-card-content>
<div class="transfer-container">
@@ -279,7 +279,7 @@
<div class="gl-fgroup">
<label class="gl-flabel"><fa-icon icon="calendar-alt"></fa-icon> From Date</label>
<mat-form-field appearance="outline" class="gl-ffield">
<input matInput [matDatepicker]="dpFrom" [min]="minDate" [max]="maxDate" [formControl]="transactionDateFrom" />
<input matInput [matDatepicker]="dpFrom" [min]="minDate" [formControl]="transactionDateFrom" />
<mat-datepicker-toggle matSuffix [for]="dpFrom"></mat-datepicker-toggle>
<mat-datepicker #dpFrom></mat-datepicker>
</mat-form-field>
@@ -288,7 +288,7 @@
<div class="gl-fgroup">
<label class="gl-flabel"><fa-icon icon="calendar-alt"></fa-icon> To Date</label>
<mat-form-field appearance="outline" class="gl-ffield">
<input matInput [matDatepicker]="dpTo" [min]="minDate" [max]="maxDate" [formControl]="transactionDateTo" />
<input matInput [matDatepicker]="dpTo" [min]="minDate" [formControl]="transactionDateTo" />
<mat-datepicker-toggle matSuffix [for]="dpTo"></mat-datepicker-toggle>
<mat-datepicker #dpTo></mat-datepicker>
</mat-form-field>
@@ -353,13 +353,7 @@
<div class="gl-fgroup">
<label class="gl-flabel"><fa-icon icon="calendar-check"></fa-icon> Submitted From</label>
<mat-form-field appearance="outline" class="gl-ffield">
<input
matInput
[matDatepicker]="dpSubFrom"
[min]="minDate"
[max]="maxDate"
[formControl]="submittedOnDateFrom"
/>
<input matInput [matDatepicker]="dpSubFrom" [min]="minDate" [formControl]="submittedOnDateFrom" />
<mat-datepicker-toggle matSuffix [for]="dpSubFrom"></mat-datepicker-toggle>
<mat-datepicker #dpSubFrom></mat-datepicker>
</mat-form-field>
@@ -368,7 +362,7 @@
<div class="gl-fgroup">
<label class="gl-flabel"><fa-icon icon="calendar-check"></fa-icon> Submitted To</label>
<mat-form-field appearance="outline" class="gl-ffield">
<input matInput [matDatepicker]="dpSubTo" [min]="minDate" [max]="maxDate" [formControl]="submittedOnDateTo" />
<input matInput [matDatepicker]="dpSubTo" [min]="minDate" [formControl]="submittedOnDateTo" />
<mat-datepicker-toggle matSuffix [for]="dpSubTo"></mat-datepicker-toggle>
<mat-datepicker #dpSubTo></mat-datepicker>
</mat-form-field>
@@ -539,7 +533,7 @@
</div>
<div class="gl-table-shell">
<mat-table [dataSource]="dataSource" matSort class="gl-mat-table">
<mat-table [dataSource]="dataSource" matSort matSortActive="id" matSortDirection="desc" class="gl-mat-table">
<!-- #ID -->
<ng-container matColumnDef="id">
<mat-header-cell *matHeaderCellDef mat-sort-header class="gl-th">#</mat-header-cell>
@@ -90,7 +90,7 @@ export class SearchJournalEntryComponent implements OnInit, OnDestroy, AfterView
// ── Misc ──────────────────────────────────────────────────────────────────
minDate = new Date(2000, 0, 1);
maxDate = new Date();
maxDate: Date = null;
/** Accounting features switched off for this installation — see disabled-features.ts. */
disabledFeatures = DISABLED_ACCOUNTING_FEATURES;
@@ -208,7 +208,7 @@ export class SearchJournalEntryComponent implements OnInit, OnDestroy, AfterView
}
ngOnInit() {
this.maxDate = this.settingsService.businessDate;
this.maxDate = null;
this.paginator.pageSize = 10;
this.restoreState();
this.setFilteredOffices();
@@ -11,6 +11,14 @@ import { Routes, RouterModule } from '@angular/router';
import { Route } from '../core/route/route.service';
import { AccountsListComponent } from './accounts-list/accounts-list.component';
import { PartyMasterComponent } from '../deposits/party-master/party-master.component';
import { CreatePartyComponent } from '../deposits/party-master/create-party/create-party.component';
import { DueForClosureComponent } from '../deposits/party-master/due-for-closure/due-for-closure.component';
import { DailyTransactionEntryComponent } from '../deposits/daily-transaction-entry/daily-transaction-entry.component';
import { DdsReportsComponent } from '../deposits/dds-reports/dds-reports.component';
import { ReportViewerComponent } from '../deposits/dds-reports/report-viewer/report-viewer.component';
import { DdsAccountsComponent } from '../deposits/dds-accounts/dds-accounts.component';
import { DdsAccountViewComponent } from '../deposits/dds-accounts/dds-account-view/dds-account-view.component';
const routes: Routes = [
Route.withShell([
@@ -41,6 +49,59 @@ const routes: Routes = [
title: 'Recurring Deposit Accounts',
breadcrumb: 'Recurring Deposit Accounts'
}
},
{
path: 'dds',
data: { title: 'Daily Deposit Scheme — Party Master', breadcrumb: 'Party Master' },
children: [
{ path: '', component: PartyMasterComponent },
{
path: 'create',
component: CreatePartyComponent,
data: { title: 'Create Party', breadcrumb: 'Create' }
},
{
// Ahead of the :partyId routes, so "dueforclosure" is not read as a party id.
path: 'dueforclosure',
component: DueForClosureComponent,
data: { title: 'Accounts Due for Closure', breadcrumb: 'Due for Closure' }
},
{
path: ':partyId/edit',
component: CreatePartyComponent,
data: { title: 'Edit Party', breadcrumb: 'Edit' }
}
]
},
{
// The DDS accounts listed like Savings Accounts, each opening onto its transactions.
path: 'dds-accounts',
data: { title: 'DDS Accounts', breadcrumb: 'DDS Accounts' },
children: [
{ path: '', component: DdsAccountsComponent },
{
path: ':partyId',
component: DdsAccountViewComponent,
data: { title: 'DDS Account', breadcrumb: 'Account', routeParamBreadcrumb: false }
}
]
},
{
path: 'dds-collection',
component: DailyTransactionEntryComponent,
data: { title: 'Daily Transaction Entry', breadcrumb: 'Daily Transaction Entry' }
},
{
path: 'dds-reports',
data: { title: 'DDS Reports', breadcrumb: 'DDS Reports' },
children: [
{ path: '', component: DdsReportsComponent },
{
path: 'view/:reportName',
component: ReportViewerComponent,
data: { title: 'DDS Report', breadcrumb: 'reportName', routeParamBreadcrumb: 'reportName' }
}
]
}
]
}
+16
View File
@@ -74,6 +74,22 @@ export class AccountsService {
return this.http.get('/fixeddepositaccounts', { params: httpParams });
}
/**
* FD Dashboard's data source: every ACTIVE FD, id/account/client/product identity, maturity date
* and deposit/maturity amounts only. Deliberately a separate, narrow endpoint rather than
* getFixedDepositAccounts() above -- that one's backend query joins in six more tables (audit
* who-did-what-when, currency, tax group) an unbounded dashboard fetch does not need, over columns
* that had no index until this feature added one (see backend migration 0571).
*/
getFixedDepositMaturityDashboard(): Observable<any> {
return this.http.get('/fixeddepositaccounts/maturity-dashboard');
}
/** RD Dashboard: running RDs with their installment position, closures and penalty income for the month. */
getRecurringDepositDashboard(): Observable<any> {
return this.http.get('/recurringdepositaccounts/dashboard');
}
getRecurringDepositAccounts(params: AccountListParams = {}): Observable<any> {
let httpParams = new HttpParams()
.set('paged', 'true')
+6
View File
@@ -63,6 +63,8 @@ import { CfcfrmsModule } from './cfcfrms/cfcfrms.module';
import { BankCalendarModule } from './bank-calendar/bank-calendar.module';
import { DeafModule } from './deaf/deaf.module';
import { SavingsDailyInterestModule } from './savings-daily-interest/savings-daily-interest.module';
import { FdDashboardModule } from './fd-dashboard/fd-dashboard.module';
import { RdDashboardModule } from './rd-dashboard/rd-dashboard.module';
import { DicgcModule } from './dicgc/dicgc.module';
import { FiuModule } from './fiu/fiu.module';
import { GlReportsModule } from './gl-reports/gl-reports.module';
@@ -95,6 +97,7 @@ import { TokenInterceptor as ZitadelTokenInterceptor } from './zitadel/token.int
import { AuthService } from './zitadel/auth.service';
import { environment } from '../environments/environment';
import { CallbackComponent } from './zitadel/callback/callback.component';
import { LoadingScreenComponent } from './shared/loading-screen/loading-screen.component';
import { OAuthModule } from 'angular-oauth2-oidc';
export class CustomMissingTranslationHandler implements MissingTranslationHandler {
@@ -176,6 +179,8 @@ export function HttpLoaderFactory(http: HttpClient) {
BankCalendarModule,
DeafModule,
SavingsDailyInterestModule,
FdDashboardModule,
RdDashboardModule,
DicgcModule,
FiuModule,
GlReportsModule,
@@ -193,6 +198,7 @@ export function HttpLoaderFactory(http: HttpClient) {
AppRoutingModule,
NotFoundComponent,
CallbackComponent,
LoadingScreenComponent,
OAuthModule.forRoot()
],
providers: [
@@ -44,8 +44,11 @@
<div class="cal-stat sat2nd4th">
<fa-icon icon="calendar-times" class="cal-stat__icon"></fa-icon>
<div class="cal-stat__info">
<div class="cal-stat__value">{{ overallStats.sat2nd4th }}</div>
<div class="cal-stat__label">2nd &amp; 4th Sat</div>
<div class="cal-stat__value">{{ overallStats.sat2nd4th + overallStats.weeklyOff }}</div>
<div class="cal-stat__label">
{{ saturdayIsWorkingDay ? '2nd & 4th Sat' : 'Saturdays'
}}{{ overallStats.weeklyOff > 0 ? ' / Weekly Off' : '' }}
</div>
</div>
</div>
<div class="cal-stat holidays">
@@ -69,7 +72,11 @@
<div class="cal-legend__items">
<div class="cal-legend__item"><span class="cal-legend__dot working"></span> Working Day</div>
<div class="cal-legend__item"><span class="cal-legend__dot sunday"></span> Sunday</div>
<div class="cal-legend__item"><span class="cal-legend__dot sat2nd4th"></span> 2nd / 4th Saturday</div>
<div class="cal-legend__item">
<span class="cal-legend__dot sat2nd4th"></span>
{{ saturdayIsWorkingDay ? '2nd / 4th Saturday' : 'Saturday'
}}{{ overallStats.weeklyOff > 0 ? ' / Weekly Off' : '' }}
</div>
<div class="cal-legend__item"><span class="cal-legend__dot holiday"></span> Holiday</div>
<div class="cal-legend__item"><span class="cal-legend__dot today"></span> Today</div>
</div>
@@ -319,7 +326,9 @@
[class.cal-grid__day--other]="!day.isCurrentMonth"
[class.cal-grid__day--today]="day.isToday"
[class.cal-grid__day--sunday]="day.isSunday && day.isCurrentMonth"
[class.cal-grid__day--sat2nd4th]="(day.is2ndSaturday || day.is4thSaturday) && day.isCurrentMonth"
[class.cal-grid__day--sat2nd4th]="
(day.is2ndSaturday || day.is4thSaturday || day.isWeeklyOff) && day.isCurrentMonth
"
[class.cal-grid__day--holiday]="day.isHoliday && day.isCurrentMonth"
[class.cal-grid__day--working]="day.isWorking && day.isCurrentMonth"
[matTooltip]="
@@ -328,9 +337,11 @@
? '2nd Saturday Off'
: day.is4thSaturday
? '4th Saturday Off'
: day.isSunday && day.isCurrentMonth
? 'Sunday'
: '')
: day.isWeeklyOff && day.isCurrentMonth
? weekDays[day.dayOfWeek] + ' Off (Working Days)'
: day.isSunday && day.isCurrentMonth
? 'Sunday'
: '')
"
>
{{ day.isCurrentMonth ? day.date : '' }}
@@ -343,10 +354,15 @@
@if (month.holidays > 0) {
<span class="summary-tag holiday">{{ month.holidays }} Holiday{{ month.holidays > 1 ? 's' : '' }}</span>
}
<span class="summary-tag sun">{{ month.sundays }} Sun</span>
@if (month.sundays > 0) {
<span class="summary-tag sun">{{ month.sundays }} Sun</span>
}
@if (month.secondFourthSat > 0) {
<span class="summary-tag sat">{{ month.secondFourthSat }} Sat Off</span>
}
@if (month.weeklyOff > 0) {
<span class="summary-tag sat">{{ month.weeklyOff }} Weekly Off</span>
}
</div>
</div>
}
@@ -63,6 +63,8 @@ interface CalendarDay {
isSunday: boolean;
is2ndSaturday: boolean;
is4thSaturday: boolean;
/** Off every week because Admin > Working Days leaves this weekday out (Sunday aside). */
isWeeklyOff: boolean;
holidayName?: string;
dayOfWeek: number; // 0=Sun, 6=Sat
}
@@ -76,9 +78,23 @@ interface MonthData {
nonWorkingDays: number;
holidays: number;
sundays: number;
/** Saturdays off: the 2nd and 4th when Saturday is a working day, every one when it is not. */
secondFourthSat: number;
/** Other weekdays off every week because Working Days leaves them out. */
weeklyOff: number;
}
/** Weekday codes in Date#getDay() order, as used by the /workingdays recurrence (BYDAY=MO,TU,...). */
const DAY_CODES = [
'SU',
'MO',
'TU',
'WE',
'TH',
'FR',
'SA'
];
@Component({
selector: 'mifosx-bank-calendar',
templateUrl: './bank-calendar.component.html',
@@ -118,7 +134,7 @@ export class BankCalendarComponent implements OnInit {
];
workingDaysConfig: string[] = [];
holidays: any[] = [];
overallStats = { working: 0, nonWorking: 0, holidays: 0, sundays: 0, sat2nd4th: 0, totalDays: 0 };
overallStats = { working: 0, nonWorking: 0, holidays: 0, sundays: 0, sat2nd4th: 0, weeklyOff: 0, totalDays: 0 };
// ───── Holiday list (office-wise) ─────
offices: OfficeLite[] = [];
@@ -202,7 +218,8 @@ export class BankCalendarComponent implements OnInit {
}
private loadWorkingDays() {
// Default Indian banking working days: Mon-Sat (Sun off, 2nd & 4th Sat off)
// Default Indian banking working days: Mon-Sat (Sun off, 2nd & 4th Sat off), used until
// Admin > Working Days answers, or if it cannot be read.
this.workingDaysConfig = [
'MO',
'TU',
@@ -215,6 +232,19 @@ export class BankCalendarComponent implements OnInit {
this.buildCalendar();
this.rebuildHolidayRows();
// The calendar has to agree with Admin > Working Days: a weekday unticked there is off every
// week here too.
this.organizationService.getWorkingDays().subscribe({
next: (data: any) => {
const configured = this.parseWorkingDays(data?.recurrence);
if (configured.length > 0) {
this.workingDaysConfig = configured;
this.buildCalendar();
}
},
error: () => {}
});
// Pull offices then fan-out a per-office holiday fetch so each office's
// calendar entries are surfaced in the list panel (calendar grid still
// unions everything for the year-at-a-glance view).
@@ -273,6 +303,23 @@ export class BankCalendarComponent implements OnInit {
});
}
/** Weekday codes out of a recurrence such as "FREQ=WEEKLY;INTERVAL=1;BYDAY=MO,TU,WE,TH,FR". */
private parseWorkingDays(recurrence: string | undefined): string[] {
const byDay = /BYDAY=([^;]*)/.exec(recurrence || '');
if (!byDay) {
return [];
}
return byDay[1]
.split(',')
.map((d) => d.trim().toUpperCase())
.filter((d) => DAY_CODES.includes(d));
}
/** True when Admin > Working Days counts Saturday as a working day (so only the 2nd and 4th are off). */
get saturdayIsWorkingDay(): boolean {
return this.workingDaysConfig.includes('SA');
}
/** Flatten the holidays array into a presentable list with office labels. */
private rebuildHolidayRows(): void {
this.holidayRows = this.holidays.map((h: any) => {
@@ -511,7 +558,15 @@ export class BankCalendarComponent implements OnInit {
buildCalendar() {
this.months = [];
this.overallStats = { working: 0, nonWorking: 0, holidays: 0, sundays: 0, sat2nd4th: 0, totalDays: 0 };
this.overallStats = {
working: 0,
nonWorking: 0,
holidays: 0,
sundays: 0,
sat2nd4th: 0,
weeklyOff: 0,
totalDays: 0
};
for (let m = 0; m < 12; m++) {
const monthData = this.buildMonth(m, this.currentYear);
@@ -521,6 +576,7 @@ export class BankCalendarComponent implements OnInit {
this.overallStats.holidays += monthData.holidays;
this.overallStats.sundays += monthData.sundays;
this.overallStats.sat2nd4th += monthData.secondFourthSat;
this.overallStats.weeklyOff += monthData.weeklyOff;
}
this.overallStats.totalDays = this.overallStats.working + this.overallStats.nonWorking;
}
@@ -552,6 +608,7 @@ export class BankCalendarComponent implements OnInit {
let holidayCount = 0;
let sundayCount = 0;
let sat2nd4thCount = 0;
let weeklyOffCount = 0;
// Previous month padding
const prevMonthDays = new Date(year, month, 0).getDate();
@@ -567,6 +624,7 @@ export class BankCalendarComponent implements OnInit {
isSunday: false,
is2ndSaturday: false,
is4thSaturday: false,
isWeeklyOff: false,
dayOfWeek: (startDayOfWeek - i - 1 + 7) % 7
});
}
@@ -576,24 +634,29 @@ export class BankCalendarComponent implements OnInit {
for (let d = 1; d <= daysInMonth; d++) {
const date = new Date(year, month, d);
const dow = date.getDay();
const isSunday = dow === 0;
const isSaturday = dow === 6;
const isWorkingWeekday = this.workingDaysConfig.includes(DAY_CODES[dow]);
const isSunday = dow === 0 && !isWorkingWeekday;
if (isSaturday) saturdayCount++;
const is2ndSat = isSaturday && saturdayCount === 2;
const is4thSat = isSaturday && saturdayCount === 4;
// RBI: when Saturday is a working day the 2nd and 4th are still off. When Working Days leaves
// Saturday out, every Saturday is a weekly off instead.
const is2ndSat = isSaturday && isWorkingWeekday && saturdayCount === 2;
const is4thSat = isSaturday && isWorkingWeekday && saturdayCount === 4;
const isWeeklyOff = dow !== 0 && !isWorkingWeekday;
const isToday = date.toDateString() === today.toDateString();
// Check if holiday
const holiday = this.getHolidayForDate(year, month, d);
const isHoliday = !!holiday;
// Non-working: Sunday, 2nd Sat, 4th Sat, or holiday
const isNonWorking = isSunday || is2ndSat || is4thSat || isHoliday;
// Non-working: Sunday, 2nd Sat, 4th Sat, a weekly off from Working Days, or holiday
const isNonWorking = isSunday || is2ndSat || is4thSat || isWeeklyOff || isHoliday;
if (isSunday) sundayCount++;
if (is2ndSat || is4thSat) sat2nd4thCount++;
if (is2ndSat || is4thSat || (isSaturday && isWeeklyOff)) sat2nd4thCount++;
if (isWeeklyOff && !isSaturday) weeklyOffCount++;
if (isHoliday) holidayCount++;
if (isNonWorking) {
@@ -613,6 +676,7 @@ export class BankCalendarComponent implements OnInit {
isSunday,
is2ndSaturday: is2ndSat,
is4thSaturday: is4thSat,
isWeeklyOff,
holidayName: holiday?.name,
dayOfWeek: dow
});
@@ -632,6 +696,7 @@ export class BankCalendarComponent implements OnInit {
isSunday: false,
is2ndSaturday: false,
is4thSaturday: false,
isWeeklyOff: false,
dayOfWeek: days.length % 7
});
}
@@ -645,7 +710,8 @@ export class BankCalendarComponent implements OnInit {
nonWorkingDays: nonWorkingCount,
holidays: holidayCount,
sundays: sundayCount,
secondFourthSat: sat2nd4thCount
secondFourthSat: sat2nd4thCount,
weeklyOff: weeklyOffCount
};
}
@@ -17,7 +17,12 @@
</p>
</div>
</div>
<button class="bor-btn bor-btn--xls" type="button" [disabled]="exporting || !filtered.length" (click)="exportExcel()">
<button
class="bor-btn bor-btn--xls"
type="button"
[disabled]="exporting || !filtered.length"
(click)="exportExcel()"
>
<fa-icon icon="file-excel"></fa-icon>
{{ exporting ? 'Exporting…' : 'Export' }}
</button>
@@ -132,7 +137,9 @@
<td class="num">{{ r.approvedShares | number }}</td>
<td class="num strong">{{ r.shareValue | number: '1.2-2' }}</td>
<td class="num">{{ r.trancheCount }}</td>
<td><span [class]="statusClass(r.statusEnum)">{{ statusText(r.statusEnum) }}</span></td>
<td>
<span [class]="statusClass(r.statusEnum)">{{ statusText(r.statusEnum) }}</span>
</td>
<td class="mono">{{ r.activatedDate || '—' }}</td>
</tr>
} @empty {
@@ -12,6 +12,7 @@ import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { catchError, of } from 'rxjs';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
interface RegisterRow {
accountId: number;
@@ -83,7 +84,7 @@ export class AccountRegisterComponent implements OnInit {
/** Local date — toISOString() would shift the day in any timezone behind UTC. */
private today(): string {
const d = new Date();
const d = businessToday();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
@@ -0,0 +1,114 @@
<!--
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">BRANCH OPERATIONS · BRANCH CASH POSITION</div>
<h1 class="pg-title">Branch Cash Position</h1>
<p class="pg-sub">
The notes and coins the branch holds, denomination by denomination: the vault plus every cashier's drawer. Cash
moving between the vault and a cashier nets out; deposits, withdrawals and exchanges change it.
</p>
</div>
</header>
<div class="pg-toolbar pg-toolbar--mat">
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>As of Date</mat-label>
<input matInput type="date" [value]="asOfDate" (change)="onDateChange($any($event.target).value)" />
</mat-form-field>
<button type="button" class="pg-btn pg-btn--primary" (click)="load()" [disabled]="loading">
<mat-icon>refresh</mat-icon> Refresh
</button>
</div>
@if (errorMsg) {
<div class="pg-empty bcp-error">{{ errorMsg }}</div>
}
@if (loading) {
<div class="pg-empty">Loading…</div>
}
@if (position; as p) {
<div class="bcp-totals">
<div class="bcp-total">
<span>Branch</span><strong>{{ p.officeName }}</strong>
</div>
<div class="bcp-total">
<span>Vault</span><strong>₹{{ p.vaultAmount | number: '1.2-2' }}</strong>
</div>
<div class="bcp-total">
<span>Cashiers' drawers</span><strong>₹{{ p.cashiersAmount | number: '1.2-2' }}</strong>
</div>
<div class="bcp-total bcp-total--main">
<span>Bank total</span><strong>₹{{ p.totalAmount | number: '1.2-2' }}</strong>
</div>
</div>
@if (!p.vaultHasBreakdown) {
<div class="pg-empty bcp-note">
No vault denomination breakdown was recorded on or before {{ p.asOfDate }}, so the vault column reads 0. Open
the business day with a note/coin count to start tracking it.
</div>
}
<ng-template #table let-rows let-label="label" let-coin="coin">
@if (rows.length > 0) {
<div class="pos-section-label" [class.pos-section-label--coin]="coin">{{ label }}</div>
<table class="pg-table">
<thead>
<tr>
<th>Denomination</th>
<th class="num">Vault</th>
<th class="num">Cashiers</th>
<th class="num">Bank total (count)</th>
<th class="num">Bank total (₹)</th>
<th class="num">Exchange today</th>
</tr>
</thead>
<tbody>
@for (d of rows; track d.denominationId) {
<tr>
<td>
<span class="pg-chip" [class.pg-chip--coin]="coin">₹{{ d.denominationValue }}</span>
</td>
<td class="num">{{ d.vaultCount }}</td>
<td class="num">{{ d.cashierCount }}</td>
<td class="num bcp-strong">{{ d.totalCount }}</td>
<td class="num">₹{{ d.totalAmount | number: '1.2-2' }}</td>
<td
class="num bcp-move"
[class.bcp-move--in]="d.exchangeNet > 0"
[class.bcp-move--out]="d.exchangeNet < 0"
>
{{ movement(d.exchangeNet) }}
</td>
</tr>
}
</tbody>
</table>
}
</ng-template>
<div class="pg-card bcp-card">
<ng-container
*ngTemplateOutlet="table; context: { $implicit: notes(), label: 'Notes', coin: false }"
></ng-container>
<ng-container
*ngTemplateOutlet="table; context: { $implicit: coins(), label: 'Coins', coin: true }"
></ng-container>
</div>
<p class="bcp-foot">
<strong>Exchange today</strong> is the notes taken in less the notes given out by cash exchanges on
{{ p.asOfDate }}. It is already part of the Cashiers column; it is shown beside the total so a change in the mix
can be seen.
</p>
}
</div>
@@ -0,0 +1,82 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
.bcp-error {
color: #dc2626;
}
.bcp-note {
margin-bottom: 12px;
text-align: left;
}
.bcp-totals {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 14px;
}
.bcp-total {
flex: 1 1 180px;
padding: 12px 14px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
span {
display: block;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #64748b;
}
strong {
font-size: 18px;
font-weight: 700;
color: #0f172a;
}
&--main {
background: #e6f4ee;
border-color: #1f7a4d;
strong {
color: #0f4f3c;
}
}
}
.bcp-card {
margin-bottom: 14px;
}
.bcp-strong {
font-weight: 700;
}
.bcp-move {
font-weight: 700;
color: #94a3b8;
&--in {
color: #16a34a;
}
&--out {
color: #dc2626;
}
}
.bcp-foot {
margin: 0;
font-size: 12px;
color: #64748b;
}
@@ -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 { MatIconModule } from '@angular/material/icon';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
interface BranchDenomRow {
denominationId: number;
denominationValue: number;
kind: string;
vaultCount: number;
cashierCount: number;
totalCount: number;
totalAmount: number;
/** Notes taken in less notes given out by exchanges on the day. */
exchangeNet: number;
}
interface BranchPosition {
officeId: number;
officeName: string;
asOfDate: string;
vaultHasBreakdown: boolean;
vaultAmount: number;
cashiersAmount: number;
totalAmount: number;
denominations: BranchDenomRow[];
}
/**
* What the branch holds, denomination by denomination: the vault plus every cashier's drawer. An exchange
* (a customer hands in 10 x ₹500 and leaves with 50 x ₹100) leaves the vault alone and changes a drawer, so
* it only shows up in this total -- +10 on ₹500, -50 on ₹100.
*/
@Component({
selector: 'mifosx-branch-cash-position',
templateUrl: './branch-cash-position.component.html',
styleUrls: [
'../cashier-shared.scss',
'../cashier-position/cashier-position.component.scss',
'./branch-cash-position.component.scss'
],
standalone: true,
imports: [
CommonModule,
MatIconModule,
MatFormFieldModule,
MatInputModule
]
})
export class BranchCashPositionComponent implements OnInit {
private service = inject(CashierService);
asOfDate = '';
loading = false;
errorMsg = '';
position: BranchPosition | null = null;
ngOnInit(): void {
const d = businessToday();
this.asOfDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.load();
}
load(): void {
this.loading = true;
this.errorMsg = '';
this.position = null;
this.service
.branchCashPosition(this.asOfDate)
.pipe(catchError(() => of(null)))
.subscribe((data: BranchPosition | null) => {
this.loading = false;
if (!data) {
this.errorMsg = 'Could not load the branch cash position.';
return;
}
this.position = data;
});
}
onDateChange(value: string): void {
this.asOfDate = value;
this.load();
}
notes(): BranchDenomRow[] {
return (this.position?.denominations || []).filter((d) => d.kind !== 'coin');
}
coins(): BranchDenomRow[] {
return (this.position?.denominations || []).filter((d) => d.kind === 'coin');
}
/** "+10", "-50", or a dash when the day's exchanges did not touch the denomination. */
movement(net: number): string {
if (!net) return '—';
return net > 0 ? `+${net}` : `${net}`;
}
}
@@ -107,11 +107,26 @@
</span>
</div>
@if (canOperate) {
<button type="button" class="pg-btn pg-btn--primary" (click)="startClose(openDay)">
<button
type="button"
class="pg-btn pg-btn--primary"
(click)="startClose(openDay)"
[disabled]="interestPending.length > 0"
>
<mat-icon>lock</mat-icon> Close Day
</button>
}
</div>
@if (interestPending.length > 0) {
<div class="bd-interest-alert">
<mat-icon>warning</mat-icon>
<span>
Savings interest posting report is not approved for <strong>{{ interestPending.join(', ') }}</strong
>. Approve it in <a routerLink="/reports/interest-posting">Reports → Interest Posting Reports</a> before
closing the day.
</span>
</div>
}
} @else if (loaded && canOperate) {
<div class="bd-current bd-current--shut">
<div class="bd-current__main">
@@ -337,6 +352,51 @@
</div>
}
<!-- Interest waiting to be posted as this branch closes. Posting runs as part of the close, so
anything unapproved stops it: the branch sees here what it has to approve and where, rather
than finding out from a refusal after filling in the whole count. -->
@if (preview?.fdInterestDue?.length) {
<div class="bd-calc bd-calc--fd">
<div class="bd-calc__head">
<mat-icon>savings</mat-icon>
<strong>Fixed deposit interest due today</strong>
</div>
@for (d of preview.fdInterestDue; track d.productId) {
<div class="bd-calc__row">
<span>
{{ d.productName }} · {{ d.fdType }} · {{ d.accountCount }} deposit(s)
@if (d.reportStatus === 'APPROVED' || d.reportStatus === 'POSTED') {
<span class="bd-fd-ok">{{ d.reportStatus }}</span>
} @else {
<span class="bd-fd-pending">{{ d.reportStatus === 'NONE' ? 'NO REPORT' : d.reportStatus }}</span>
}
</span>
<span class="bd-calc__amt">₹{{ d.totalInterest | number: '1.2-2' }}</span>
</div>
}
</div>
}
@if (preview?.fdInterestPending?.length) {
<div class="bd-warn">
<mat-icon>warning</mat-icon>
<div>
<strong>The day cannot be closed yet.</strong> Fixed deposit interest is not approved for:
{{ preview.fdInterestPending.join(', ') }}. Approve it under Reports → FD Interest Posting Reports, then
close the day. The user who generated a report cannot approve it.
</div>
</div>
}
@if (preview?.savingsInterestPending?.length) {
<div class="bd-warn">
<mat-icon>warning</mat-icon>
<div>
<strong>The day cannot be closed yet.</strong> Savings interest is not approved for:
{{ preview.savingsInterestPending.join(', ') }}. Approve it under Reports → Interest Posting Reports, then
close the day.
</div>
</div>
}
<!-- What every cash point under this branch is actually holding right now, fetched
automatically — so closing the day doesn't mean separately checking Vault Position and
every cashier's own Cashier Position screen by hand. -->
@@ -195,6 +195,75 @@
font-size: 11px;
color: #94a3b8;
}
/* Names the block, for the interest panels that sit beside the cash arithmetic. */
&__head {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
padding-bottom: 6px;
border-bottom: 1px solid #e2e8f0;
font-size: 13px;
color: #0d1b2a;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
color: #1d4ed8;
}
}
}
/* Interest is money leaving as the day closes, not cash counted in the drawer. */
.bd-calc--fd {
border-left: 3px solid #1d4ed8;
}
.bd-fd-ok,
.bd-fd-pending {
display: inline-block;
margin-left: 6px;
padding: 1px 7px;
border-radius: 999px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.02em;
}
.bd-fd-ok {
background: #dcfce7;
color: #15803d;
}
.bd-fd-pending {
background: #fef3c7;
color: #b45309;
}
/* Why the close will be refused, said before the count is filled in rather than after. */
.bd-warn {
display: flex;
align-items: flex-start;
gap: 8px;
max-width: 640px;
margin-bottom: 14px;
padding: 10px 14px;
background: #fffbeb;
border: 1px solid #fcd34d;
border-radius: 8px;
font-size: 12px;
line-height: 1.5;
color: #78350f;
mat-icon {
flex-shrink: 0;
font-size: 20px;
width: 20px;
height: 20px;
color: #b45309;
}
}
.bd-hint {
@@ -372,3 +441,30 @@
color: #d97706;
}
}
// Shown under the open day while a savings interest posting report due today is unapproved.
.bd-interest-alert {
display: flex;
align-items: flex-start;
gap: 8px;
margin: 8px 0 12px;
padding: 10px 14px;
border: 1px solid #f59e0b;
border-radius: 8px;
background: #fef3c7;
color: #92400e;
font-size: 13px;
mat-icon {
flex-shrink: 0;
font-size: 20px;
width: 20px;
height: 20px;
}
a {
color: inherit;
font-weight: 600;
text-decoration: underline;
}
}
@@ -189,6 +189,23 @@ export class BranchDayComponent implements OnInit {
return this.totalOf(this.closeCounts);
}
/**
* Savings products whose interest posting report is due today and not yet approved. The close is
* refused by the backend while any is pending; this only says so before the user tries.
*/
interestPending: string[] = [];
private checkInterestReports(): void {
if (!this.openDay?.businessDate) {
this.interestPending = [];
return;
}
this.service
.interestDayCloseCheck(this.openDay.businessDate, this.openDay.officeId)
.pipe(catchError(() => of(null)))
.subscribe((res) => (this.interestPending = res?.pendingProducts ?? []));
}
load(): void {
this.loading = true;
this.errorMsg = '';
@@ -210,6 +227,7 @@ export class BranchDayComponent implements OnInit {
this.canOperate = !!res?.canOperate;
this.openDate = this.today;
this.checkFirstOpenCashMigration();
this.checkInterestReports();
});
this.service
@@ -11,6 +11,7 @@ import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { AuthenticationService } from '../../core/authentication/authentication.service';
import { ExportSheetSpec, exportWorkbook } from '../cashier-export';
import { businessToday } from 'app/core/utils/business-today';
interface SummaryRow {
code: string;
@@ -1316,7 +1317,7 @@ export class CashClearingTransferSummaryComponent {
private dateTag = '2607';
constructor() {
const d = new Date();
const d = businessToday();
this.asOnDate = `${String(d.getDate()).padStart(2, '0')}-${String(d.getMonth() + 1).padStart(2, '0')}-${d.getFullYear()}`;
this.dateTag = `${String(d.getFullYear()).slice(-2)}${String(d.getMonth() + 1).padStart(2, '0')}`;
@@ -22,50 +22,255 @@
</div>
</header>
<!-- ── Customer Info Bar ── -->
<div class="cx-customer-bar">
<div
class="cx-customer-toggle"
[class.cx-customer-toggle--active]="isAccountHolder"
(click)="toggleAccountHolder()"
>
<mat-icon>{{ isAccountHolder ? 'check_box' : 'check_box_outline_blank' }}</mat-icon>
<span>Account Holder</span>
</div>
@if (isAccountHolder) {
<label class="cx-field cx-field--inline">
<span>Account No.</span>
<input
type="text"
[value]="accountNo"
(input)="accountNo = $any($event.target).value"
placeholder="e.g. 000101234567"
/>
</label>
} @else {
<span class="cx-walk-in"><mat-icon>person_outline</mat-icon> Walk-in Customer</span>
}
@if (needsIdProof) {
<div class="cx-pan-required">
<mat-icon>warning</mat-icon>
<span>Amount ≥ ₹50,000 — Customer PAN / Aadhaar required (RBI)</span>
<!-- ── Cashier: whose drawer takes the customer's notes and gives the change ── -->
@if (cashier) {
<section class="cx-cashier">
<div class="cx-avatar cx-avatar--sm">{{ cashierInitials }}</div>
<div class="cx-cashier__id">
<div class="cx-cashier__label">Cashier at the counter</div>
<div class="cx-cashier__name">{{ cashier.name || '—' }}</div>
<div class="cx-cashier__flow">
<span><mat-icon>south</mat-icon> Receives from customer</span>
<span><mat-icon>north</mat-icon> Gives to customer</span>
</div>
</div>
<label class="cx-field cx-field--inline cx-field--pan">
<span>PAN / Aadhaar *</span>
<input
type="text"
[value]="customerIdProof"
(input)="customerIdProof = $any($event.target).value"
placeholder="ABCDE1234F"
maxlength="20"
/>
</label>
}
</div>
<div class="cx-cashier__stats">
<div>
<span>Counter</span><strong>{{ cashier.tellerName || '—' }}</strong>
</div>
<div>
<span>Branch</span><strong>{{ cashier.officeName || '—' }}</strong>
</div>
<div>
<span>Allocated today</span
><strong>{{
cashier.allocatedAmount === null ? '—' : '₹' + (cashier.allocatedAmount | number: '1.2-2')
}}</strong>
</div>
<div>
<span>Cash in hand</span><strong>₹{{ cashier.totalAmount | number: '1.2-2' }}</strong>
</div>
</div>
</section>
} @else if (noCashier) {
<section class="cx-cashier cx-cashier--none">
<mat-icon>person_off</mat-icon>
<div>
<div class="cx-cashier__name">Your login is not linked to a cashier</div>
<div class="cx-cashier__label">
A cash exchange moves notes in and out of a cashier's drawer, so it can only be recorded by a cashier. Log in
as a cashier to continue.
</div>
</div>
</section>
}
<!-- ── Customer: an existing client (details from their record) or a walk-in identified at the counter ── -->
<section class="cx-cust">
<header class="cx-cust__head">
<div class="cx-cust__title">
<span class="cx-cust__title-icon"><mat-icon>groups</mat-icon></span>
<div>
<div class="cx-cust__title-text">Customer</div>
<div class="cx-cust__title-sub">Who is exchanging cash at the counter</div>
</div>
</div>
<div class="cx-cust__modes" role="tablist" aria-label="Customer type">
<button
type="button"
role="tab"
class="cx-cust__mode"
[class.cx-cust__mode--active]="customerMode === 'existing'"
[attr.aria-selected]="customerMode === 'existing'"
(click)="setCustomerMode('existing')"
>
<mat-icon>badge</mat-icon> Existing Customer
</button>
<button
type="button"
role="tab"
class="cx-cust__mode"
[class.cx-cust__mode--active]="customerMode === 'walkin'"
[attr.aria-selected]="customerMode === 'walkin'"
(click)="setCustomerMode('walkin')"
>
<mat-icon>person_outline</mat-icon> Walk-in Customer
</button>
</div>
</header>
<div class="cx-cust__body">
<!-- Who -->
<div class="cx-cust__who">
@if (customerMode === 'existing') {
@if (loadingCustomer) {
<div class="cx-cust__empty">
<mat-icon class="cx-cust__empty-icon cx-spin">autorenew</mat-icon>
<div class="cx-cust__empty-title">Loading customer details…</div>
</div>
} @else if (!selectedCustomer) {
<div class="cx-cust__empty">
<mat-icon class="cx-cust__empty-icon">person_search</mat-icon>
<div class="cx-cust__empty-title">No customer selected</div>
<div class="cx-cust__empty-sub">Search by name, account no., PAN or Aadhaar</div>
<button type="button" class="cx-btn cx-btn--primary" (click)="openCustomerSearch()">
<mat-icon>search</mat-icon> Search Customer
</button>
</div>
} @else {
<div class="cx-cust__card">
<div class="cx-cust__card-top">
<div class="cx-avatar">{{ customerInitials }}</div>
<div class="cx-cust__card-id">
<div class="cx-cust__name">{{ selectedCustomer.displayName }}</div>
<div class="cx-cust__badges">
<span
class="cx-kyc"
[class.cx-kyc--ok]="selectedCustomer.kycStatus === 'Verified'"
[class.cx-kyc--warn]="selectedCustomer.kycStatus !== 'Verified'"
>
<mat-icon>{{
selectedCustomer.kycStatus === 'Verified' ? 'verified_user' : 'gpp_maybe'
}}</mat-icon>
KYC: {{ selectedCustomer.kycStatus }}
</span>
@if (selectedCustomer.accountType) {
<span class="cx-chip-tag">{{ selectedCustomer.accountType }}</span>
}
</div>
</div>
<div class="cx-cust__card-actions">
<button type="button" class="cx-link-btn" (click)="openCustomerSearch()">
<mat-icon>swap_horiz</mat-icon> Change
</button>
<button type="button" class="cx-link-btn cx-link-btn--danger" (click)="clearCustomer()">
<mat-icon>close</mat-icon> Remove
</button>
</div>
</div>
<div class="cx-cust__grid">
<div>
<span>CIF No.</span><strong>{{ selectedCustomer.cifNumber || '—' }}</strong>
</div>
<div>
<span>Client ID</span><strong>{{ selectedCustomer.clientId }}</strong>
</div>
<div>
<span>Mobile</span><strong>{{ selectedCustomer.mobileNo || '—' }}</strong>
</div>
<div>
<span>Branch</span><strong>{{ selectedCustomer.officeName || '—' }}</strong>
</div>
<div>
<span>Account</span><strong>{{ selectedCustomer.accountNo || 'No account' }}</strong>
</div>
<div>
<span>PAN on record</span>
<strong>
{{ selectedCustomer.kycPan || 'Not on record' }}
@if (selectedCustomer.kycPan) {
<span
class="cx-kyc cx-kyc--sm"
[class.cx-kyc--ok]="selectedCustomer.panVerified"
[class.cx-kyc--warn]="!selectedCustomer.panVerified"
>{{ selectedCustomer.panVerified ? 'Verified' : 'Not verified' }}</span
>
}
</strong>
</div>
</div>
</div>
}
} @else {
<div class="cx-cust__walkin">
<label class="cx-field">
<span>Customer Name{{ needsPan ? ' *' : '' }}</span>
<input
type="text"
[value]="walkInName"
(input)="walkInName = $any($event.target).value"
placeholder="Full name as on ID"
maxlength="150"
autocomplete="off"
/>
</label>
<label class="cx-field">
<span>Mobile</span>
<input
type="tel"
inputmode="numeric"
[value]="walkInMobile"
(input)="walkInMobile = $any($event.target).value"
placeholder="10-digit mobile number"
maxlength="15"
autocomplete="off"
/>
</label>
</div>
}
</div>
<!-- PAN -->
<div
class="cx-pan"
[class.cx-pan--required]="panMissing"
[class.cx-pan--invalid]="panFormatInvalid"
[class.cx-pan--valid]="panValue && !panFormatInvalid"
>
<div class="cx-pan__head">
<mat-icon>credit_card</mat-icon>
<span class="cx-pan__label">PAN Card</span>
<span class="cx-pan__tag" [class.cx-pan__tag--on]="needsPan">{{ needsPan ? 'Mandatory' : 'Optional' }}</span>
</div>
<div class="cx-pan__input">
<input
type="text"
[value]="panNumber"
(input)="panNumber = $any($event.target).value.toUpperCase()"
placeholder="ABCDE1234F"
maxlength="10"
autocomplete="off"
spellcheck="false"
aria-label="PAN number"
/>
@if (panValue && !panFormatInvalid) {
<mat-icon class="cx-pan__state cx-pan__state--ok">check_circle</mat-icon>
} @else if (panFormatInvalid) {
<mat-icon class="cx-pan__state cx-pan__state--bad">error</mat-icon>
}
</div>
<div class="cx-pan__hint">
@if (panFormatInvalid) {
PAN is 5 letters, 4 digits, 1 letter (e.g. ABCDE1234F).
} @else if (panFromKyc) {
<mat-icon>link</mat-icon> Fetched from the customer's KYC record.
} @else if (panMissing) {
Required: this exchange is above ₹50,000 (Income Tax Rule 114B / RBI).
} @else {
Mandatory for exchanges above ₹50,000.
}
</div>
</div>
</div>
</section>
<div class="cx-cols">
<!-- The cashier's count of a denomination and what this exchange does to it, updated as counts are typed. -->
<ng-template #drawerCell let-d="d">
@if (availableFor(d) === null) {
—
} @else {
In drawer <strong>{{ availableFor(d) }}</strong>
@if (netFor(d) !== 0) {
<span class="cx-after">
<mat-icon>{{ netFor(d) > 0 ? 'trending_up' : 'trending_down' }}</mat-icon>
<strong>{{ drawerAfter(d) }}</strong>
<small>({{ netFor(d) > 0 ? '+' : '' }}{{ netFor(d) }})</small>
</span>
}
}
</ng-template>
<!-- RECEIVED -->
<section class="cx-col cx-col--received">
<header class="cx-col-head">
@@ -78,7 +283,7 @@
<div class="cx-subhead">Bank Notes</div>
}
@for (d of notes; track d.id) {
<div class="cx-row">
<div class="cx-row cx-row--stock">
<span class="cx-chip">{{ d.label }}</span>
<div class="cx-step">
<button type="button" (click)="bump('r', d, -1)" [disabled]="countFor('r', d) === 0">−</button>
@@ -90,6 +295,9 @@
/>
<button type="button" (click)="bump('r', d, 1)">+</button>
</div>
<span class="cx-avail" [class.cx-avail--up]="netFor(d) > 0" [class.cx-avail--down]="netFor(d) < 0">
<ng-container *ngTemplateOutlet="drawerCell; context: { d: d }"></ng-container>
</span>
<span class="cx-amt" [class.cx-amt--zero]="countFor('r', d) === 0"
>₹{{ subtotal('r', d) | number: '1.2-2' }}</span
>
@@ -99,7 +307,7 @@
<div class="cx-subhead">Coins</div>
}
@for (d of coins; track d.id) {
<div class="cx-row">
<div class="cx-row cx-row--stock">
<span class="cx-chip cx-chip--coin">{{ d.label }}</span>
<div class="cx-step">
<button type="button" (click)="bump('r', d, -1)" [disabled]="countFor('r', d) === 0">−</button>
@@ -111,6 +319,9 @@
/>
<button type="button" (click)="bump('r', d, 1)">+</button>
</div>
<span class="cx-avail" [class.cx-avail--up]="netFor(d) > 0" [class.cx-avail--down]="netFor(d) < 0">
<ng-container *ngTemplateOutlet="drawerCell; context: { d: d }"></ng-container>
</span>
<span class="cx-amt" [class.cx-amt--zero]="countFor('r', d) === 0"
>₹{{ subtotal('r', d) | number: '1.2-2' }}</span
>
@@ -131,7 +342,7 @@
<div class="cx-subhead">Bank Notes</div>
}
@for (d of notes; track d.id) {
<div class="cx-row">
<div class="cx-row cx-row--stock" [class.cx-row--over]="exceedsAvailable(d)">
<span class="cx-chip">{{ d.label }}</span>
<div class="cx-step">
<button type="button" (click)="bump('g', d, -1)" [disabled]="countFor('g', d) === 0">−</button>
@@ -141,8 +352,16 @@
[value]="countFor('g', d)"
(input)="onChange('g', d, $any($event.target).value)"
/>
<button type="button" (click)="bump('g', d, 1)">+</button>
<button type="button" (click)="bump('g', d, 1)" [disabled]="!canGiveMore(d)">+</button>
</div>
<span
class="cx-avail"
[class.cx-avail--up]="netFor(d) > 0"
[class.cx-avail--down]="netFor(d) < 0"
[class.cx-avail--over]="exceedsAvailable(d)"
>
<ng-container *ngTemplateOutlet="drawerCell; context: { d: d }"></ng-container>
</span>
<span class="cx-amt" [class.cx-amt--zero]="countFor('g', d) === 0"
>₹{{ subtotal('g', d) | number: '1.2-2' }}</span
>
@@ -152,7 +371,7 @@
<div class="cx-subhead">Coins</div>
}
@for (d of coins; track d.id) {
<div class="cx-row">
<div class="cx-row cx-row--stock" [class.cx-row--over]="exceedsAvailable(d)">
<span class="cx-chip cx-chip--coin">{{ d.label }}</span>
<div class="cx-step">
<button type="button" (click)="bump('g', d, -1)" [disabled]="countFor('g', d) === 0">−</button>
@@ -162,8 +381,16 @@
[value]="countFor('g', d)"
(input)="onChange('g', d, $any($event.target).value)"
/>
<button type="button" (click)="bump('g', d, 1)">+</button>
<button type="button" (click)="bump('g', d, 1)" [disabled]="!canGiveMore(d)">+</button>
</div>
<span
class="cx-avail"
[class.cx-avail--up]="netFor(d) > 0"
[class.cx-avail--down]="netFor(d) < 0"
[class.cx-avail--over]="exceedsAvailable(d)"
>
<ng-container *ngTemplateOutlet="drawerCell; context: { d: d }"></ng-container>
</span>
<span class="cx-amt" [class.cx-amt--zero]="countFor('g', d) === 0"
>₹{{ subtotal('g', d) | number: '1.2-2' }}</span
>
@@ -173,6 +400,30 @@
</section>
</div>
<!-- What this exchange does to the cashier's drawer, denomination by denomination. -->
@if (impactRows.length) {
<div class="cx-impact">
<div class="cx-impact__title"><mat-icon>inventory_2</mat-icon> Drawer after this exchange</div>
<div class="cx-impact__chips">
@for (r of impactRows; track r.id) {
<span
class="cx-impact__chip"
[class.cx-impact__chip--up]="r.net > 0"
[class.cx-impact__chip--down]="r.net < 0"
>
<b>{{ r.label }}</b>
{{ r.before }} → {{ r.after }}
<em>({{ r.net > 0 ? '+' : '' }}{{ r.net }})</em>
</span>
}
</div>
<div class="cx-impact__note">
The branch's total for each denomination moves by the same amount; the vault is not touched (Branch Ops → Branch
Cash Position).
</div>
</div>
}
<div class="cx-summary">
<div class="cx-sum">
<span>Received</span><strong>₹{{ receivedTotal | number: '1.2-2' }}</strong>
@@ -213,9 +464,15 @@
</div>
</div>
}
@if (needsIdProof && !customerIdProof.trim()) {
@if (panMissing) {
<div class="cx-warn cx-warn--pan">
<mat-icon>badge</mat-icon> Enter customer PAN / Aadhaar to proceed (amount ≥ ₹50,000).
<mat-icon>badge</mat-icon> Enter the customer's PAN to proceed (amount above ₹50,000).
</div>
}
@if (!customerIdentified && !loadingCustomer) {
<div class="cx-warn cx-warn--pan">
<mat-icon>person_search</mat-icon>
{{ customerMode === 'existing' ? 'Search and select the customer.' : "Enter the walk-in customer's name." }}
</div>
}
@@ -73,99 +73,584 @@
}
}
/* ── Customer info bar ── */
.cx-customer-bar {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
background: #f8fafc;
/* ── Customer: existing client or walk-in, plus PAN ── */
.cx-cust {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 10px;
flex-wrap: wrap;
}
border-radius: 14px;
box-shadow: 0 1px 2px rgb(15 23 42 / 4%);
overflow: hidden;
.cx-customer-toggle {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 600;
color: #475569;
cursor: pointer;
user-select: none;
&__head {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
padding: 12px 16px;
background: linear-gradient(180deg, #f8fafc, #f1f5f9);
border-bottom: 1px solid #e2e8f0;
}
mat-icon {
font-size: 20px;
height: 20px;
width: 20px;
&__title {
display: flex;
align-items: center;
gap: 10px;
}
&__title-icon {
display: grid;
place-items: center;
width: 36px;
height: 36px;
border-radius: 10px;
background: #e6f4ee;
color: #0f4f3c;
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
}
}
&__title-text {
font-size: 14px;
font-weight: 700;
color: #0f172a;
}
&__title-sub {
font-size: 12px;
color: #64748b;
}
&__modes {
display: inline-flex;
padding: 3px;
background: #e2e8f0;
border-radius: 10px;
}
&__mode {
display: inline-flex;
align-items: center;
gap: 6px;
height: 32px;
padding: 0 14px;
border: 0;
border-radius: 8px;
background: transparent;
font-size: 13px;
font-weight: 600;
color: #475569;
cursor: pointer;
transition:
background 0.15s ease,
color 0.15s ease;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
&:hover {
color: #0f172a;
}
&--active,
&--active:hover {
background: #fff;
color: #0f4f3c;
box-shadow: 0 1px 3px rgb(15 23 42 / 15%);
}
}
&__body {
display: grid;
grid-template-columns: minmax(0, 1.7fr) minmax(260px, 1fr);
gap: 16px;
padding: 16px;
}
&__who {
min-width: 0;
}
/* Nothing picked yet, or loading */
&__empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
min-height: 150px;
padding: 20px;
text-align: center;
background: #f8fafc;
border: 1.5px dashed #cbd5e1;
border-radius: 12px;
}
&__empty-icon {
font-size: 36px;
width: 36px;
height: 36px;
color: #94a3b8;
}
&--active {
color: #1e40af;
&__empty-title {
font-size: 14px;
font-weight: 600;
color: #334155;
}
&__empty-sub {
margin-bottom: 8px;
font-size: 12px;
color: #64748b;
}
/* A picked client */
&__card {
padding: 14px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
}
&__card-top {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
}
&__card-id {
flex: 1 1 180px;
min-width: 0;
}
&__name {
font-size: 16px;
font-weight: 700;
color: #0f172a;
overflow-wrap: anywhere;
}
&__badges {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
margin-top: 4px;
}
&__card-actions {
display: flex;
gap: 4px;
}
&__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 12px 16px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px dashed #e2e8f0;
span {
display: block;
margin-bottom: 2px;
font-size: 11px;
color: #64748b;
}
strong {
font-size: 13px;
font-weight: 600;
color: #0f172a;
}
}
/* A walk-in, identified at the counter */
&__walkin {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
.cx-field {
font-size: 12px;
}
.cx-field input {
width: 100%;
height: 40px;
box-sizing: border-box;
}
}
.cx-field input:focus {
outline: none;
border-color: #1f7a4d;
box-shadow: 0 0 0 3px rgb(31 122 77 / 15%);
}
}
.cx-avatar {
display: grid;
place-items: center;
flex: 0 0 auto;
width: 44px;
height: 44px;
border-radius: 50%;
background: linear-gradient(135deg, #1f7a4d, #0f4f3c);
font-size: 15px;
font-weight: 700;
letter-spacing: 0.02em;
color: #fff;
&--sm {
width: 40px;
height: 40px;
font-size: 14px;
}
}
/* The cashier at the counter, or why there is none */
.cx-cashier {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 14px 20px;
padding: 12px 16px;
background: #fff;
border: 1px solid #e2e8f0;
border-left: 4px solid #1f7a4d;
border-radius: 14px;
box-shadow: 0 1px 2px rgb(15 23 42 / 4%);
&__id {
flex: 1 1 220px;
min-width: 0;
}
&__label {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #64748b;
}
&__name {
font-size: 16px;
font-weight: 700;
color: #0f172a;
}
&__flow {
display: flex;
flex-wrap: wrap;
gap: 4px 14px;
margin-top: 2px;
font-size: 12px;
color: #475569;
span {
display: inline-flex;
align-items: center;
gap: 2px;
}
mat-icon {
color: #1e40af;
font-size: 14px;
width: 14px;
height: 14px;
color: #94a3b8;
}
}
&__stats {
display: flex;
flex-wrap: wrap;
gap: 10px 28px;
span {
display: block;
font-size: 11px;
color: #64748b;
}
strong {
font-size: 14px;
font-weight: 600;
color: #0f172a;
}
}
&--none {
background: #fffbeb;
border-color: #fcd34d;
border-left-color: #d97706;
mat-icon {
color: #d97706;
}
.cx-cashier__label {
font-weight: 500;
letter-spacing: 0;
text-transform: none;
color: #92400e;
}
}
}
.cx-walk-in {
display: flex;
.cx-kyc {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: #64748b;
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
mat-icon {
font-size: 16px;
height: 16px;
width: 16px;
font-size: 14px;
width: 14px;
height: 14px;
}
&--ok {
background: #dcfce7;
color: #166534;
}
&--warn {
background: #fef3c7;
color: #92400e;
}
&--sm {
margin-left: 6px;
padding: 1px 6px;
font-size: 10px;
}
}
.cx-pan-required {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
background: #fffbeb;
border: 1px solid #fcd34d;
.cx-chip-tag {
padding: 2px 8px;
border-radius: 6px;
background: #eef2ff;
font-size: 11px;
font-weight: 600;
color: #3730a3;
}
.cx-link-btn {
display: inline-flex;
align-items: center;
gap: 4px;
height: 30px;
padding: 0 10px;
border: 0;
border-radius: 8px;
background: transparent;
font-size: 12px;
font-weight: 600;
color: #92400e;
color: #1e40af;
cursor: pointer;
mat-icon {
font-size: 16px;
height: 16px;
width: 16px;
color: #d97706;
height: 16px;
}
&:hover {
background: #eff6ff;
}
&--danger {
color: #b91c1c;
&:hover {
background: #fef2f2;
}
}
}
.cx-field--inline {
flex-direction: row;
align-items: center;
/* PAN: neutral until needed, amber when required and missing, red when malformed, green when valid */
.cx-pan {
display: flex;
flex-direction: column;
align-self: start;
gap: 8px;
padding: 14px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 12px;
span {
white-space: nowrap;
&__head {
display: flex;
align-items: center;
gap: 8px;
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
color: #475569;
}
}
input {
width: 180px;
&__label {
font-size: 13px;
font-weight: 700;
color: #0f172a;
}
&__tag {
margin-left: auto;
padding: 2px 8px;
border-radius: 999px;
background: #e2e8f0;
font-size: 11px;
font-weight: 600;
color: #475569;
&--on {
background: #fef3c7;
color: #92400e;
}
}
&__input {
position: relative;
input {
width: 100%;
height: 44px;
box-sizing: border-box;
padding: 0 40px 0 12px;
background: #fff;
border: 1.5px solid #cbd5e1;
border-radius: 10px;
font-family: ui-monospace, sfmono-regular, menlo, consolas, monospace;
font-size: 18px;
font-weight: 600;
letter-spacing: 0.18em;
text-transform: uppercase;
color: #0f172a;
&::placeholder {
font-weight: 500;
color: #cbd5e1;
}
&:focus {
outline: none;
border-color: #1f7a4d;
box-shadow: 0 0 0 3px rgb(31 122 77 / 15%);
}
}
}
&__state {
position: absolute;
top: 50%;
right: 10px;
transform: translateY(-50%);
font-size: 22px;
width: 22px;
height: 22px;
&--ok {
color: #16a34a;
}
&--bad {
color: #dc2626;
}
}
&__hint {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: #64748b;
mat-icon {
font-size: 14px;
width: 14px;
height: 14px;
}
}
&--required {
background: #fffbeb;
border-color: #fcd34d;
.cx-pan__input input {
border-color: #f59e0b;
}
.cx-pan__hint {
font-weight: 600;
color: #92400e;
}
}
&--invalid {
background: #fef2f2;
border-color: #fecaca;
.cx-pan__input input {
border-color: #ef4444;
}
.cx-pan__hint {
font-weight: 600;
color: #b91c1c;
}
}
&--valid {
background: #f0fdf4;
border-color: #bbf7d0;
.cx-pan__input input {
border-color: #22c55e;
}
.cx-pan__hint {
color: #166534;
}
}
}
.cx-field--pan input {
width: 160px;
border-color: #fcd34d;
.cx-spin {
animation: cx-spin 1s linear infinite;
}
&:focus {
outline: none;
border-color: #d97706;
@keyframes cx-spin {
to {
transform: rotate(360deg);
}
}
@media (width <= 900px) {
.cx-cust__body {
grid-template-columns: 1fr;
}
}
@media (width <= 600px) {
.cx-cust__walkin {
grid-template-columns: 1fr;
}
}
@@ -345,6 +830,129 @@
border-bottom: 1px dotted #eef2f7;
}
/* Both sides: the cashier's count of each denomination sits between the counter and the amount. */
.cx-row--stock {
grid-template-columns: 64px 120px 1fr 96px;
}
.cx-row--over {
background: #fef2f2;
}
.cx-avail {
padding-left: 6px;
font-size: 12px;
color: #64748b;
strong {
font-weight: 700;
color: #0f172a;
}
/* the count after this exchange: green when it goes up, red when it goes down */
.cx-after {
display: inline-flex;
align-items: center;
gap: 3px;
margin-left: 6px;
font-weight: 600;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
small {
font-size: 11px;
}
}
&--up .cx-after,
&--up .cx-after strong {
color: #15803d;
}
&--down .cx-after,
&--down .cx-after strong {
color: #b45309;
}
&--over,
&--over strong,
&--over .cx-after,
&--over .cx-after strong {
color: #b91c1c;
}
}
/* The drawer after the whole exchange, one chip per denomination it changes */
.cx-impact {
padding: 12px 14px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
&__title {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 8px;
font-size: 13px;
font-weight: 700;
color: #0f172a;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
color: #475569;
}
}
&__chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
&__chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 10px;
border-radius: 8px;
background: #f1f5f9;
font-size: 12px;
color: #334155;
b {
font-weight: 700;
}
em {
font-style: normal;
font-weight: 700;
}
&--up {
background: #dcfce7;
color: #166534;
}
&--down {
background: #fef3c7;
color: #92400e;
}
}
&__note {
margin-top: 8px;
font-size: 11px;
color: #64748b;
}
}
.cx-chip {
display: inline-flex;
align-items: center;
@@ -11,7 +11,16 @@ import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { forkJoin, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
import { ClientsService } from 'app/clients/clients.service';
import {
CustomerSearchDialogComponent,
CustomerSearchResult
} from 'app/shared/customer-search-dialog/customer-search-dialog.component';
import { businessToday } from 'app/core/utils/business-today';
interface Denom {
id: number;
@@ -20,6 +29,47 @@ interface Denom {
label: string;
}
/** An existing client picked through Customer Search, with what the counter needs to identify them. */
interface SelectedCustomer {
clientId: number;
displayName: string;
cifNumber: string | null;
mobileNo: string | null;
officeName: string | null;
accountNo: string | null;
accountType: string | null;
/** PAN on the client's KYC record, if any. */
kycPan: string | null;
panVerified: boolean;
kycStatus: string;
}
/** The cashier at the counter: the one who takes the customer's notes and hands over the change. */
interface CashierInfo {
cashierId: number;
name: string;
tellerName: string | null;
officeName: string | null;
/** Cash the vault allocated to them for the day, once known. */
allocatedAmount: number | null;
/** Cash they hold now. */
totalAmount: number;
}
/** Same rule as ClientKycApiConstants.PAN_REGEX on the server: 5 letters, 4 digits, 1 letter. */
const PAN_PATTERN = /^[A-Z]{5}[0-9]{4}[A-Z]$/;
/** Income Tax Rule 114B / RBI: a cash exchange above this needs the customer's PAN. */
const PAN_MANDATORY_ABOVE = 50000;
const KYC_STATUS: Record<number, string> = {
100: 'Pending',
200: 'In Progress',
300: 'Verified',
400: 'Rejected',
500: 'Expired'
};
/** Cashier cash-exchange entry: customer gives some denominations and takes equal-value change (net 0). */
@Component({
selector: 'mifosx-cash-exchange-entry',
@@ -30,11 +80,14 @@ interface Denom {
CommonModule,
FormsModule,
MatIconModule,
MatSnackBarModule
MatSnackBarModule,
MatDialogModule
]
})
export class CashExchangeEntryComponent implements OnInit {
private service = inject(CashierService);
private clientsService = inject(ClientsService);
private dialog = inject(MatDialog);
private snackBar = inject(MatSnackBar);
notes: Denom[] = [];
@@ -46,11 +99,17 @@ export class CashExchangeEntryComponent implements OnInit {
saving = false;
// ── Customer info ──
isAccountHolder = false;
accountNo = '';
customerIdProof = '';
/** An existing client found through Customer Search, or a walk-in identified at the counter. */
customerMode: 'existing' | 'walkin' = 'walkin';
selectedCustomer: SelectedCustomer | null = null;
loadingCustomer = false;
walkInName = '';
walkInMobile = '';
panNumber = '';
// ── Cashier position (for denomination overdraft check) ──
// ── Cashier at the counter, and their position (for the denomination overdraft check) ──
/** The cashier linked to the logged-in user; null when the login is not a cashier's. */
cashier: CashierInfo | null = null;
private availableMap = new Map<number, number>();
positionLoaded = false;
@@ -63,7 +122,7 @@ export class CashExchangeEntryComponent implements OnInit {
}
ngOnInit(): void {
const d = new Date();
const d = businessToday();
this.exchangeDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.service.getActiveDenominations().subscribe({
next: (list) => {
@@ -87,7 +146,19 @@ export class CashExchangeEntryComponent implements OnInit {
((res?.denominations || []) as any[]).forEach((d: any) => {
if (d.denominationId) this.availableMap.set(Number(d.denominationId), Number(d.count ?? 0));
});
const cashierId = res?.cashierId ? Number(res.cashierId) : null;
this.cashier = cashierId
? {
cashierId,
name: res.cashierName || '',
tellerName: null,
officeName: null,
allocatedAmount: null,
totalAmount: Number(res.totalAmount ?? 0)
}
: null;
this.positionLoaded = true;
if (cashierId) this.loadCashierDetails(cashierId);
},
error: () => {
this.positionLoaded = true;
@@ -95,6 +166,31 @@ export class CashExchangeEntryComponent implements OnInit {
});
}
/** The counter, branch and today's allocation; the card still shows name and cash in hand without them. */
private loadCashierDetails(cashierId: number): void {
this.service
.getInterCashierCashierSummary(cashierId, this.exchangeDate)
.pipe(catchError(() => of(null)))
.subscribe((r: any) => {
if (!r || this.cashier?.cashierId !== cashierId) return;
this.cashier = {
...this.cashier,
tellerName: r.tellerName || null,
officeName: r.officeName || null,
allocatedAmount: Number(r.allocatedAmount ?? 0)
};
});
}
/** Up to two letters for the cashier's avatar. */
get cashierInitials(): string {
const words = (this.cashier?.name || '').trim().split(/\s+/).filter(Boolean);
if (words.length === 0) return '?';
const first = words[0].charAt(0);
const last = words.length > 1 ? words[words.length - 1].charAt(0) : '';
return (first + last).toUpperCase();
}
countFor(side: 'r' | 'g', d: Denom): number {
return (side === 'r' ? this.receivedCounts : this.givenCounts)[d.id] || 0;
}
@@ -128,14 +224,102 @@ export class CashExchangeEntryComponent implements OnInit {
return this.receivedTotal > 0 && this.receivedTotal === this.givenTotal;
}
/** Above ₹50,000 PAN/ID is mandatory per RBI guideline */
get needsIdProof(): boolean {
return this.receivedTotal >= 50000;
/** Above ₹50,000 the customer's PAN is mandatory (Income Tax Rule 114B, followed under RBI guidelines). */
get needsPan(): boolean {
return this.receivedTotal > PAN_MANDATORY_ABOVE;
}
/** Up to two letters for the avatar: first and last word of the customer's name. */
get customerInitials(): string {
const words = (this.selectedCustomer?.displayName || '').trim().split(/\s+/).filter(Boolean);
if (words.length === 0) return '?';
const first = words[0].charAt(0);
const last = words.length > 1 ? words[words.length - 1].charAt(0) : '';
return (first + last).toUpperCase();
}
get panValue(): string {
return this.panNumber.trim().toUpperCase();
}
/** A PAN that is entered must be well-formed, required or not. */
get panFormatInvalid(): boolean {
return !!this.panValue && !PAN_PATTERN.test(this.panValue);
}
get panMissing(): boolean {
return this.needsPan && !this.panValue;
}
/** The PAN comes from the client's KYC record, so the cashier confirms it rather than types it. */
get panFromKyc(): boolean {
return !!this.selectedCustomer?.kycPan && this.panValue === this.selectedCustomer.kycPan.toUpperCase();
}
/** Who the customer is has to be known: a picked client, or a walk-in's name once PAN is required. */
get customerIdentified(): boolean {
if (this.customerMode === 'existing') return !!this.selectedCustomer;
return !this.needsPan || this.walkInName.trim().length > 0;
}
/** A login with no cashier behind it has no drawer to take notes into or give change from. */
get noCashier(): boolean {
return this.positionLoaded && !this.cashier;
}
/** How many of the denomination the cashier holds, or null while that is not known (loading, or no cashier). */
availableFor(d: Denom): number | null {
if (!this.positionLoaded || !this.cashier) return null;
return this.availableMap.get(d.id) ?? 0;
}
/** The cashier is giving more of this denomination than they hold. */
exceedsAvailable(d: Denom): boolean {
const available = this.availableFor(d);
return available !== null && this.countFor('g', d) > available;
}
/** The + on the given side stops at what the cashier holds; a larger typed number is still caught and shown. */
canGiveMore(d: Denom): boolean {
const available = this.availableFor(d);
return available === null || this.countFor('g', d) < available;
}
/** What this exchange does to the cashier's count of the denomination: notes taken in less notes given out. */
netFor(d: Denom): number {
return this.countFor('r', d) - this.countFor('g', d);
}
/** The cashier's count of the denomination once this exchange is recorded, or null while it is not known. */
drawerAfter(d: Denom): number | null {
const available = this.availableFor(d);
return available === null ? null : available + this.netFor(d);
}
/**
* The denominations this exchange changes, with the drawer's count before and after, so the effect shows
* while the counts are being typed. The branch's total for each denomination moves by the same amount, as
* the customer's notes go into the drawer and the change comes out of it; the vault is not involved.
*/
get impactRows(): Array<{ id: number; label: string; kind: string; net: number; before: number; after: number }> {
if (!this.positionLoaded || !this.cashier) return [];
return this.denoms
.filter((d) => this.netFor(d) !== 0)
.map((d) => ({
id: d.id,
label: d.label,
kind: d.kind,
net: this.netFor(d),
before: this.availableFor(d) ?? 0,
after: this.drawerAfter(d) ?? 0
}));
}
/** Denominations where cashier is giving more than currently available. */
get givenOverdraftItems(): Array<{ label: string; available: number; requested: number }> {
if (!this.positionLoaded) return [];
// Without a cashier every denomination would read as "0 available"; the missing cashier is the
// thing to tell the user, not a stock shortage.
if (!this.positionLoaded || !this.cashier) return [];
return this.denoms
.map((d) => ({ label: d.label, available: this.availableMap.get(d.id) ?? 0, requested: this.countFor('g', d) }))
.filter((item) => item.requested > 0 && item.requested > item.available);
@@ -146,21 +330,77 @@ export class CashExchangeEntryComponent implements OnInit {
}
get valid(): boolean {
return this.matches && (!this.needsIdProof || this.customerIdProof.trim().length > 0) && !this.hasGivenOverdraft;
return (
this.matches &&
!!this.cashier &&
!this.hasGivenOverdraft &&
!this.panFormatInvalid &&
!this.panMissing &&
this.customerIdentified &&
!this.loadingCustomer
);
}
toggleAccountHolder(): void {
this.isAccountHolder = !this.isAccountHolder;
if (!this.isAccountHolder) this.accountNo = '';
setCustomerMode(mode: 'existing' | 'walkin'): void {
if (this.customerMode === mode) return;
this.customerMode = mode;
this.selectedCustomer = null;
this.walkInName = '';
this.walkInMobile = '';
this.panNumber = '';
}
/** Pick an existing client through Customer Search, then load their details and KYC (PAN). */
openCustomerSearch(): void {
this.dialog
// An existing customer without an account is still a known customer, so the account is not required.
.open(CustomerSearchDialogComponent, { width: '960px', maxWidth: '95vw', data: { requireAccount: false } })
.afterClosed()
.subscribe((result: CustomerSearchResult | undefined) => {
if (!result?.clientId) return;
this.loadCustomer(result);
});
}
private loadCustomer(result: CustomerSearchResult): void {
this.loadingCustomer = true;
const id = String(result.clientId);
forkJoin({
client: this.clientsService.getClientData(id, true).pipe(catchError(() => of(null))),
kyc: this.clientsService.getClientKyc(id, true).pipe(catchError(() => of(null)))
}).subscribe(({ client, kyc }: { client: any; kyc: any }) => {
const kycPan: string | null = kyc?.panNumber || result.panNumber || null;
this.selectedCustomer = {
clientId: result.clientId,
displayName: client?.displayName || result.displayName,
cifNumber: client?.cifNumber || null,
mobileNo: client?.mobileNo || result.mobileNo || null,
officeName: client?.officeName || result.officeName || null,
accountNo: result.accountNo || null,
accountType: result.accountType || null,
kycPan,
panVerified: !!kyc?.panVerified,
kycStatus: KYC_STATUS[Number(kyc?.kycStatus?.id ?? kyc?.kycStatus)] || 'Not on record'
};
this.panNumber = kycPan ? kycPan.toUpperCase() : '';
this.loadingCustomer = false;
});
}
clearCustomer(): void {
this.selectedCustomer = null;
this.panNumber = '';
}
clear(): void {
this.receivedCounts = {};
this.givenCounts = {};
this.note = '';
this.accountNo = '';
this.customerIdProof = '';
this.isAccountHolder = false;
this.customerMode = 'walkin';
this.selectedCustomer = null;
this.walkInName = '';
this.walkInMobile = '';
this.panNumber = '';
}
submit(): void {
@@ -170,11 +410,17 @@ export class CashExchangeEntryComponent implements OnInit {
this.denoms
.filter((d) => this.countFor(side, d) > 0)
.map((d) => ({ denominationId: d.id, denominationValue: d.value, count: this.countFor(side, d) }));
const customer = this.customerMode === 'existing' ? this.selectedCustomer : null;
const pan = this.panValue || null;
const payload: any = {
exchangeDate: this.exchangeDate,
note: this.note || null,
customerIdProof: this.customerIdProof.trim() || null,
accountNo: this.isAccountHolder && this.accountNo.trim() ? this.accountNo.trim() : null,
clientId: customer?.clientId ?? null,
accountNo: customer?.accountNo ?? null,
customerName: customer ? customer.displayName : this.walkInName.trim() || null,
customerMobile: customer ? customer.mobileNo : this.walkInMobile.trim() || null,
panNumber: pan,
customerIdProof: pan,
received: build('r'),
given: build('g')
};
@@ -11,25 +11,11 @@
<div>
<div class="pg-eyebrow">CASHIER · HISTORY</div>
<h1 class="pg-title">Cash Movement History</h1>
<p class="pg-sub">All transfers, settlements, and inter-cashier movements. Reverse any active transaction.</p>
<p class="pg-sub">All transfers, settlements, inter-cashier movements and cash exchanges.</p>
</div>
<button type="button" class="pg-btn pg-btn--ghost" (click)="load()"><mat-icon>refresh</mat-icon> Refresh</button>
</header>
<!-- Reversal confirm panel -->
@if (reversingId !== null) {
<div class="reverse-panel">
<mat-icon class="reverse-icon">warning</mat-icon>
<span
>Reverse <strong>#{{ reversingId }}</strong
>? This cannot be undone.</span
>
<input class="reverse-reason" type="text" [(ngModel)]="reverseReason" placeholder="Reason (optional)" />
<button type="button" class="pg-btn pg-btn--danger" (click)="confirmReverse()">Confirm Reversal</button>
<button type="button" class="pg-btn pg-btn--ghost" (click)="cancelReverse()">Cancel</button>
</div>
}
@if (loading) {
<div class="pg-empty">Loading…</div>
}
@@ -134,11 +120,6 @@
>
<mat-icon>visibility</mat-icon>
</button>
@if (!r.isReversed) {
<button type="button" class="pg-btn pg-btn--ghost btn-sm" title="Reverse" (click)="startReverse(r)">
<mat-icon>undo</mat-icon>
</button>
}
</div>
</td>
</tr>
@@ -187,6 +168,12 @@
<span class="detail-meta__label">Date</span>
<span class="detail-meta__val">{{ detail.row.date }}</span>
</div>
@if (detail.row.type === 'exchange') {
<div class="detail-meta__item">
<span class="detail-meta__label">Customer PAN</span>
<span class="detail-meta__val">{{ detail.row.panNumber || '—' }}</span>
</div>
}
<div class="detail-meta__item">
<span class="detail-meta__label">Total Amount</span>
<span class="detail-meta__val detail-meta__val--amount">₹{{ detail.row.amount | number: '1.2-2' }}</span>
@@ -47,38 +47,6 @@
}
}
.reverse-panel {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: #fffbeb;
border: 1px solid #fcd34d;
border-radius: 10px;
flex-wrap: wrap;
font-size: 13px;
color: #92400e;
.reverse-icon {
color: #f59e0b;
font-size: 20px;
height: 20px;
width: 20px;
}
.reverse-reason {
flex: 1 1 200px;
height: 34px;
padding: 0 10px;
border: 1px solid #fcd34d;
border-radius: 7px;
font-size: 13px;
font-family: inherit;
background: #fff;
color: #1a1a1a;
}
}
/* ── Action buttons cell ── */
.action-btns {
display: flex;
@@ -8,9 +8,7 @@
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { MatTabsModule } from '@angular/material/tabs';
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { forkJoin, of } from 'rxjs';
@@ -31,6 +29,8 @@ interface TxnRow {
reversedBy: number | null;
reversedAt: string | null;
reverseReason: string | null;
/** Customer PAN recorded on a cash exchange. */
panNumber?: string | null;
type: 'transfer' | 'settlement' | 'inter' | 'exchange';
}
@@ -52,7 +52,10 @@ interface DetailView {
loading: boolean;
}
/** Unified history of all cash movements with reversal and detail view support. */
/**
* Unified history of all cash movements with a detail view. Read-only: cash movements cannot be reversed
* from here (an earlier reversal still shows as Reversed, with who, when and why).
*/
@Component({
selector: 'mifosx-cash-history',
templateUrl: './cash-history.component.html',
@@ -63,16 +66,13 @@ interface DetailView {
standalone: true,
imports: [
CommonModule,
FormsModule,
MatIconModule,
MatSnackBarModule,
MatTabsModule,
MatPaginatorModule
]
})
export class CashHistoryComponent implements OnInit {
private service = inject(CashierService);
private snackBar = inject(MatSnackBar);
transfers: TxnRow[] = [];
settlements: TxnRow[] = [];
@@ -99,10 +99,6 @@ export class CashHistoryComponent implements OnInit {
private denomKindMap = new Map<number, 'note' | 'coin'>();
reversingId: number | null = null;
reversingType: string | null = null;
reverseReason = '';
detail: DetailView | null = null;
ngOnInit(): void {
@@ -212,7 +208,8 @@ export class CashHistoryComponent implements OnInit {
this.exchanges = (exchangeItems as any[]).map((e) => ({
id: e.id,
date: e.exchangeDate,
fromParty: e.accountNo ? `Account ${e.accountNo}` : 'Walk-in Customer',
fromParty: this.exchangeParty(e),
panNumber: e.panNumber ?? null,
toParty: e.performedByUsername || '—',
amount: e.exchangeAmount,
note: e.note,
@@ -310,7 +307,8 @@ export class CashHistoryComponent implements OnInit {
this.exchanges = (res.pageItems ?? []).map((e: any) => ({
id: e.id,
date: e.exchangeDate,
fromParty: e.accountNo ? `Account ${e.accountNo}` : 'Walk-in Customer',
fromParty: this.exchangeParty(e),
panNumber: e.panNumber ?? null,
toParty: e.performedByUsername || '—',
amount: e.exchangeAmount,
note: e.note,
@@ -392,39 +390,12 @@ export class CashHistoryComponent implements OnInit {
this.detail = null;
}
startReverse(row: TxnRow): void {
this.reversingId = row.id;
this.reversingType = row.type;
this.reverseReason = '';
}
cancelReverse(): void {
this.reversingId = null;
this.reversingType = null;
this.reverseReason = '';
}
confirmReverse(): void {
if (!this.reversingId || !this.reversingType) return;
const id = this.reversingId;
const type = this.reversingType;
const reason = this.reverseReason;
this.cancelReverse();
let obs$;
if (type === 'transfer') obs$ = this.service.reverseCashTransfer(id, reason);
else if (type === 'settlement') obs$ = this.service.reverseCashSettlement(id, reason);
else if (type === 'exchange') obs$ = this.service.reverseCashExchange(id, reason);
else obs$ = this.service.reverseInterCashierTransfer(id, reason);
obs$.pipe(catchError(() => of(null))).subscribe((res: any) => {
if (res?.reversed) {
this.snackBar.open('Transaction reversed successfully', 'Close', { duration: 3000 });
this.load();
} else {
this.snackBar.open('Reversal failed', 'Close', { duration: 3000 });
}
});
/** Who the exchange was with: the customer's name (and account, if one), else the account, else a walk-in. */
private exchangeParty(e: any): string {
if (e.customerName) {
return e.accountNo ? `${e.customerName} (A/C ${e.accountNo})` : e.customerName;
}
return e.accountNo ? `Account ${e.accountNo}` : 'Walk-in Customer';
}
typeLabel(type: string): string {
@@ -18,6 +18,7 @@ import { MatInputModule } from '@angular/material/input';
import { forkJoin, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
type PageState = 'loading' | 'no-cashier' | 'needs-reconciliation' | 'already-settled' | 'form';
@@ -90,7 +91,7 @@ export class CashSettlementComponent implements OnInit {
saving = false;
ngOnInit(): void {
const d = new Date();
const d = businessToday();
this.settlementDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.service
.getTellers()
@@ -0,0 +1,229 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="cv-tab-content cta-tab">
<!-- ── TITLE BAR ── -->
<div class="cta-action-bar">
<div class="cta-action-bar__left">
<mat-icon class="cta-action-bar__icon">swap_horiz</mat-icon>
<div>
<div class="cta-action-bar__title">Cash Transfers</div>
<div class="cta-action-bar__sub">
Approve or reject an allocation of cash from the vault to a cashier. The cash leaves the vault only when it is
approved.
</div>
</div>
</div>
<div class="cta-counts">
<span class="cta-count"
><strong>{{ all.length }}</strong> waiting</span
>
</div>
</div>
<!-- ── FILTER BAR ── -->
<div class="cta-filter-bar">
<div class="cta-search-box">
<mat-icon>search</mat-icon>
<input type="text" placeholder="Search by cashier, vault, user or note" [(ngModel)]="searchTerm" />
@if (searchTerm) {
<button type="button" class="cta-search-box__clear" (click)="searchTerm = ''">
<mat-icon>close</mat-icon>
</button>
}
</div>
<button type="button" class="cta-btn cta-btn--refresh" (click)="load()" [disabled]="loading">
<mat-icon>refresh</mat-icon>
</button>
</div>
<!-- ── TABLE ── -->
@if (loading && !loaded) {
<div class="cta-empty"><p class="cta-empty__title">Loading…</p></div>
} @else if (rows.length === 0) {
<div class="cta-empty">
<mat-icon class="cta-empty__icon">inbox</mat-icon>
<p class="cta-empty__title">No cash transfer waiting</p>
<p class="cta-empty__text">There is no cash allocation waiting for approval.</p>
</div>
} @else {
<div class="cta-table-wrap">
<table class="cta-table">
<thead>
<tr>
<th>Date</th>
<th>From</th>
<th>To (Cashier)</th>
<th class="num">Amount</th>
<th>Note</th>
<th>Raised By</th>
<th>Raised At</th>
<th class="cta-th-action">Action</th>
</tr>
</thead>
<tbody>
@for (r of rows; track r.id) {
<tr class="cta-row">
<td class="cta-dt">{{ r.transfer_date || '—' }}</td>
<td>{{ r.from_party || '—' }}</td>
<td class="cta-strong">{{ r.to_party || '—' }}</td>
<td class="num cta-amount">₹{{ r.total_amount | number: '1.2-2' }}</td>
<td class="cta-reason" [title]="r.note || ''">{{ r.note || '—' }}</td>
<td>{{ r.maker_name || '—' }}</td>
<td class="cta-dt">{{ formatDateTime(r.maker_at) }}</td>
<td class="cta-action-cell">
<button type="button" class="cta-btn cta-btn--approve" (click)="review(r)">
<mat-icon>rate_review</mat-icon> Review
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
<!-- ─── Popup: review a request, every note and coin it asks for ───────────── -->
<ng-template #reviewDialog>
@if (reviewing) {
<div class="cta-dlg">
<header class="cta-dlg__head">
<mat-icon class="cta-dlg__ico cta-dlg__ico--ok">swap_horiz</mat-icon>
<div class="cta-dlg__titles">
<div class="cta-dlg__title">Review cash transfer</div>
<div class="cta-dlg__sub">
Raised by {{ reviewing.maker_name || '—' }} · {{ formatDateTime(reviewing.maker_at) }}
</div>
</div>
<button type="button" class="cta-dlg__x" aria-label="Close" (click)="closeReview()">
<mat-icon>close</mat-icon>
</button>
</header>
<div class="cta-dlg__body">
<div class="cta-total">
<span>Amount to release</span>
<strong>₹{{ reviewing.total_amount | number: '1.2-2' }}</strong>
</div>
<div class="cta-grid">
<div class="cta-cell">
<div class="cta-label">Transfer date</div>
<div class="cta-value">{{ reviewing.transfer_date || '—' }}</div>
</div>
<div class="cta-cell">
<div class="cta-label">From</div>
<div class="cta-value">{{ reviewing.from_party || '—' }}</div>
</div>
<div class="cta-cell">
<div class="cta-label">To (cashier)</div>
<div class="cta-value">{{ reviewing.to_party || '—' }}</div>
</div>
<div class="cta-cell">
<div class="cta-label">Notes and coins</div>
<div class="cta-value">
{{ piecesOf(reviewing) }} pieces · {{ linesOf(reviewing).length }} denominations
</div>
</div>
@if (reviewing.note) {
<div class="cta-cell cta-cell--wide">
<div class="cta-label">Note</div>
<div class="cta-value">{{ reviewing.note }}</div>
</div>
}
</div>
<div class="cta-label">Denomination breakdown</div>
<table class="cta-mini">
<thead>
<tr>
<th>Denomination</th>
<th class="num">Count</th>
<th class="num">Amount</th>
</tr>
</thead>
<tbody>
@for (l of linesOf(reviewing); track l.value) {
<tr>
<td>₹{{ l.value }}</td>
<td class="num">{{ l.count }}</td>
<td class="num">₹{{ l.amount | number: '1.2-2' }}</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td>Total</td>
<td class="num">{{ piecesOf(reviewing) }}</td>
<td class="num">₹{{ reviewing.total_amount | number: '1.2-2' }}</td>
</tr>
</tfoot>
</table>
</div>
<footer class="cta-dlg__foot">
<button type="button" class="cta-btn cta-btn--ghost" (click)="closeReview()">Close</button>
<button
type="button"
class="cta-btn cta-btn--reject"
[disabled]="busyId === reviewing.id"
(click)="rejectFromReview(reviewing)"
>
<mat-icon>close</mat-icon> Reject
</button>
<button
type="button"
class="cta-btn cta-btn--approve"
[disabled]="busyId === reviewing.id"
(click)="approve(reviewing)"
>
<mat-icon>check</mat-icon> Approve
</button>
</footer>
</div>
}
</ng-template>
<!-- ─── Popup: reject with a reason ───────────────────────────────────────── -->
<ng-template #rejectDialog>
@if (rejecting) {
<div class="cta-dlg">
<header class="cta-dlg__head">
<mat-icon class="cta-dlg__ico">block</mat-icon>
<div class="cta-dlg__titles">
<div class="cta-dlg__title">Reject cash transfer</div>
<div class="cta-dlg__sub">₹{{ rejecting.total_amount | number: '1.2-2' }} to {{ rejecting.to_party }}</div>
</div>
<button type="button" class="cta-dlg__x" aria-label="Close" (click)="cancelReject()">
<mat-icon>close</mat-icon>
</button>
</header>
<div class="cta-dlg__body">
<label class="cta-label" for="cta-reason">Reason (required)</label>
<textarea
id="cta-reason"
rows="3"
placeholder="Why is this being rejected?"
[(ngModel)]="rejectReason"
></textarea>
</div>
<footer class="cta-dlg__foot">
<button type="button" class="cta-btn cta-btn--ghost" (click)="cancelReject()">Cancel</button>
<button
type="button"
class="cta-btn cta-btn--reject-solid"
[disabled]="!rejectReason.trim() || busyId === rejecting.id"
(click)="confirmReject()"
>
Confirm reject
</button>
</footer>
</div>
}
</ng-template>
@@ -0,0 +1,573 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
// Laid out like the other approval tabs (Withdrawal Requests): title bar, filter bar, one table.
:host {
display: block;
}
.cta-tab {
padding: 0;
}
.cta-action-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 10px 16px;
background: #fff;
border-bottom: 1px solid #e2e8f0;
&__left {
display: flex;
align-items: center;
gap: 12px;
}
&__icon {
font-size: 22px;
width: 22px;
height: 22px;
color: #0f4f3c;
}
&__title {
font-size: 13px;
font-weight: 700;
color: #0d1b2a;
}
&__sub {
max-width: 640px;
margin-top: 1px;
font-size: 11px;
color: #64748b;
}
}
.cta-counts {
display: flex;
gap: 8px;
}
.cta-count {
padding: 2px 10px;
border-radius: 999px;
background: #f1f5f9;
font-size: 11px;
color: #475569;
strong {
color: #0f4f3c;
}
}
.cta-filter-bar {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
padding: 6px 16px;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
}
.cta-fb-sep {
flex-shrink: 0;
width: 1px;
height: 22px;
margin: 0 4px;
background: #e2e8f0;
}
.cta-search-box {
display: inline-flex;
align-items: center;
gap: 6px;
height: 30px;
min-width: 240px;
padding: 0 10px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
mat-icon {
flex-shrink: 0;
font-size: 17px;
width: 17px;
height: 17px;
color: #94a3b8;
}
&:hover {
border-color: #cbd5e1;
}
&:focus-within {
border-color: #0f4f3c;
box-shadow: 0 0 0 3px rgb(15 79 60 / 8%);
}
input {
flex: 1;
border: none;
outline: none;
background: transparent;
font-family: inherit;
font-size: 12px;
color: #0d1b2a;
&::placeholder {
color: #94a3b8;
}
}
&__clear {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 20px;
height: 20px;
padding: 0;
border: none;
border-radius: 50%;
background: none;
color: #94a3b8;
cursor: pointer;
mat-icon {
font-size: 15px;
width: 15px;
height: 15px;
color: inherit;
}
&:hover {
color: #dc2626;
background: #fef2f2;
}
}
}
.cta-filter-select {
height: 30px;
min-width: 140px;
padding: 0 10px;
border: 1px solid #e2e8f0;
border-radius: 7px;
background: #fff;
font-family: inherit;
font-size: 12px;
color: #0d1b2a;
cursor: pointer;
&:focus {
outline: none;
border-color: #0f4f3c;
}
}
// ── Buttons ─────────────────────────────────────────────────────────────────
.cta-btn {
display: inline-flex;
align-items: center;
gap: 4px;
height: 26px;
padding: 0 10px;
border: 1px solid transparent;
border-radius: 7px;
font-family: inherit;
font-size: 11px;
font-weight: 600;
white-space: nowrap;
cursor: pointer;
mat-icon {
font-size: 14px;
width: 14px;
height: 14px;
}
&:disabled {
opacity: 0.45;
cursor: not-allowed;
}
&--refresh {
height: 30px;
width: 30px;
justify-content: center;
padding: 0;
margin-left: auto;
border-color: #e2e8f0;
background: #fff;
color: #475569;
}
&--approve {
background: #0f4f3c;
color: #fff;
&:hover:not(:disabled) {
background: #0a3d2e;
}
}
&--reject {
border-color: #fecaca;
background: #fff;
color: #dc2626;
&:hover:not(:disabled) {
background: #fef2f2;
}
}
&--reject-solid {
background: #dc2626;
color: #fff;
}
&--ghost {
border-color: #e2e8f0;
background: #fff;
color: #475569;
}
}
// ── Table ───────────────────────────────────────────────────────────────────
.cta-table-wrap {
overflow-x: auto;
background: #fff;
}
.cta-table {
width: 100%;
border-collapse: collapse;
font-size: 11px;
th {
padding: 7px 10px;
border-bottom: 1px solid #e2e8f0;
background: #f8fafc;
font-size: 9.5px;
font-weight: 800;
letter-spacing: 0.06em;
text-align: left;
text-transform: uppercase;
color: #94a3b8;
white-space: nowrap;
}
td {
padding: 5px 10px;
border-bottom: 1px solid #eef2f7;
vertical-align: middle;
color: #0d1b2a;
}
.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
.cta-th-action {
text-align: right;
}
}
.cta-row:hover td {
background: #f8fafc;
}
.cta-mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
letter-spacing: 0.02em;
}
.cta-voucher {
font-weight: 700;
color: #0f4f3c;
}
.cta-strong {
font-weight: 700;
}
.cta-sub {
margin-top: 1px;
font-size: 11px;
color: #64748b;
}
.cta-none {
color: #cbd5e1;
}
.cta-amount {
font-weight: 700;
}
.cta-reason {
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cta-dt {
white-space: nowrap;
color: #475569;
}
.cta-kind {
display: inline-block;
padding: 2px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
&--unpass {
background: #fef3c7;
color: #92400e;
}
&--correction {
background: #dbeafe;
color: #1e40af;
}
}
.cta-action-cell {
display: flex;
justify-content: flex-end;
gap: 6px;
white-space: nowrap;
}
// ── Empty ───────────────────────────────────────────────────────────────────
.cta-empty {
padding: 28px 16px;
text-align: center;
color: #94a3b8;
&__icon {
display: block;
margin: 0 auto 6px;
font-size: 30px;
width: 30px;
height: 30px;
opacity: 0.6;
}
&__title {
margin: 0;
font-size: 12px;
font-weight: 700;
color: #64748b;
}
&__text {
margin: 3px 0 0;
font-size: 11px;
}
}
// ── Reject popup ────────────────────────────────────────────────────────────
.cta-dlg {
font-size: 12px;
&__head {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-bottom: 1px solid #e2e8f0;
background: #f8fafc;
}
&__ico {
flex: 0 0 32px;
height: 32px;
width: 32px;
border-radius: 9px;
background: #fef2f2;
color: #dc2626;
font-size: 18px;
line-height: 32px;
text-align: center;
}
&__titles {
flex: 1;
min-width: 0;
}
&__title {
font-size: 14px;
font-weight: 700;
color: #0f172a;
}
&__sub {
font-size: 11px;
color: #64748b;
}
&__x {
display: inline-flex;
align-items: center;
justify-content: center;
height: 28px;
width: 28px;
border: 0;
border-radius: 50%;
background: transparent;
color: #64748b;
cursor: pointer;
&:hover {
background: #e2e8f0;
}
mat-icon {
font-size: 18px;
height: 18px;
width: 18px;
}
}
&__body {
padding: 12px 14px;
max-height: 64vh;
overflow-y: auto;
textarea {
width: 100%;
padding: 7px 10px;
border: 1px solid #cbd5e1;
border-radius: 8px;
font: inherit;
font-size: 12px;
resize: vertical;
&:focus {
outline: 0;
border-color: #0f4f3c;
box-shadow: 0 0 0 3px rgb(15 79 60 / 8%);
}
}
}
&__foot {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 10px 14px;
border-top: 1px solid #e2e8f0;
background: #f8fafc;
}
}
.cta-label {
display: block;
margin-bottom: 4px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: #94a3b8;
}
.cta-dlg__ico--ok {
background: #f0fdf4;
color: #1f7a4d;
}
.cta-total {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 10px;
padding: 8px 12px;
border-radius: 10px;
background: #ecfdf5;
border: 1px solid #a7f3d0;
color: #065f46;
font-size: 12px;
font-weight: 600;
strong {
font-size: 18px;
font-weight: 800;
font-variant-numeric: tabular-nums;
}
}
.cta-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 6px;
margin-bottom: 12px;
}
.cta-cell {
padding: 6px 10px;
border-radius: 8px;
background: #f8fafc;
border: 1px solid #eef2f7;
&--wide {
grid-column: 1 / -1;
}
}
.cta-value {
font-size: 12px;
font-weight: 600;
color: #0f172a;
word-break: break-word;
}
.cta-mini {
width: 100%;
margin-top: 4px;
border-collapse: collapse;
font-size: 12px;
th {
padding: 6px 10px;
border-bottom: 1px solid #e2e8f0;
background: #f8fafc;
font-size: 10px;
font-weight: 800;
letter-spacing: 0.05em;
text-align: left;
text-transform: uppercase;
color: #94a3b8;
}
td {
padding: 5px 10px;
border-bottom: 1px solid #eef2f7;
}
.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
tfoot td {
border-top: 2px solid #e2e8f0;
border-bottom: 0;
font-weight: 800;
background: #f8fafc;
}
}
@@ -0,0 +1,190 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, TemplateRef, ViewChild, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
/** A cash transfer request waiting for a checker, as the server keeps it (snake_case columns). */
interface CashTransferRequest {
id: number;
transfer_date: string | null;
from_party: string | null;
to_party: string | null;
note: string | null;
total_amount: number;
maker_name: string | null;
maker_at: string | null;
denominations: { denominationValue: number; count: number }[];
}
/**
* Checker side of Cash Transfer: approves or rejects an allocation of cash from the vault to a cashier. The cash
* leaves the vault only when it is approved, by someone other than the person who raised it.
*/
@Component({
selector: 'mifosx-cash-transfer-approvals',
templateUrl: './cash-transfer-approvals.component.html',
styleUrls: ['./cash-transfer-approvals.component.scss'],
standalone: true,
imports: [
CommonModule,
FormsModule,
MatDialogModule,
MatIconModule,
MatSnackBarModule
]
})
export class CashTransferApprovalsComponent implements OnInit {
private service = inject(CashierService);
private snackBar = inject(MatSnackBar);
private dialog = inject(MatDialog);
@ViewChild('rejectDialog') rejectDialog!: TemplateRef<unknown>;
@ViewChild('reviewDialog') reviewDialog!: TemplateRef<unknown>;
private dialogRef: MatDialogRef<unknown> | null = null;
private reviewRef: MatDialogRef<unknown> | null = null;
/** The request open in the review popup, with every note and coin it asks for. */
reviewing: CashTransferRequest | null = null;
all: CashTransferRequest[] = [];
loading = false;
loaded = false;
busyId: number | null = null;
searchTerm = '';
rejecting: CashTransferRequest | null = null;
rejectReason = '';
ngOnInit(): void {
this.load();
}
load(): void {
this.loading = true;
this.service
.getCashTransferRequests('PENDING')
.pipe(catchError(() => of([] as CashTransferRequest[])))
.subscribe((rows) => {
this.all = rows as CashTransferRequest[];
this.loading = false;
this.loaded = true;
});
}
get rows(): CashTransferRequest[] {
const term = this.searchTerm.trim().toLowerCase();
if (!term) return this.all;
return this.all.filter((r) => [
r.from_party,
r.to_party,
r.maker_name,
r.note
].some((v) => (v ?? '').toLowerCase().includes(term)));
}
/** One line per denomination asked for, largest first, with what each line adds up to. */
linesOf(r: CashTransferRequest): { value: number; count: number; amount: number }[] {
return [...(r.denominations ?? [])]
.filter((d) => d.count > 0)
.sort((a, b) => b.denominationValue - a.denominationValue)
.map((d) => ({ value: d.denominationValue, count: d.count, amount: d.denominationValue * d.count }));
}
/** How many notes and coins the request asks for in all. */
piecesOf(r: CashTransferRequest): number {
return this.linesOf(r).reduce((sum, l) => sum + l.count, 0);
}
review(r: CashTransferRequest): void {
this.reviewing = r;
this.reviewRef?.close();
this.reviewRef = this.dialog.open(this.reviewDialog, { width: '560px', maxWidth: '95vw', autoFocus: false });
this.reviewRef.afterClosed().subscribe(() => (this.reviewing = null));
}
closeReview(): void {
this.reviewRef?.close();
}
/** Rejecting from the review popup: the review closes, the reason popup opens for the same request. */
rejectFromReview(r: CashTransferRequest): void {
this.reviewRef?.close();
this.startReject(r);
}
formatDateTime(value: string | null): string {
if (!value) return '—';
const d = new Date(value);
return isNaN(d.getTime())
? value
: d.toLocaleString('en-IN', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' });
}
approve(r: CashTransferRequest): void {
if (this.busyId) return;
this.busyId = r.id;
this.service.approveCashTransferRequest(r.id).subscribe({
next: (res: any) => {
this.busyId = null;
this.reviewRef?.close();
this.snackBar.open(
res?.glWarning ? `Approved (GL warning: ${res.glWarning})` : 'Approved — cash allocated to the cashier',
'Close',
{ duration: 6000 }
);
this.load();
},
error: (err) => {
this.busyId = null;
this.fail(err, 'Could not approve');
}
});
}
startReject(r: CashTransferRequest): void {
this.rejecting = r;
this.rejectReason = '';
this.dialogRef = this.dialog.open(this.rejectDialog, { width: '440px', maxWidth: '95vw', autoFocus: false });
this.dialogRef.afterClosed().subscribe(() => (this.rejecting = null));
}
cancelReject(): void {
this.dialogRef?.close();
}
confirmReject(): void {
if (!this.rejecting || !this.rejectReason.trim() || this.busyId) return;
const id = this.rejecting.id;
this.busyId = id;
this.service.rejectCashTransferRequest(id, this.rejectReason.trim()).subscribe({
next: () => {
this.busyId = null;
this.dialogRef?.close();
this.snackBar.open('Rejected', 'Close', { duration: 4000 });
this.load();
},
error: (err) => {
this.busyId = null;
this.fail(err, 'Could not reject');
}
});
}
private fail(err: any, fallback: string): void {
const msg = err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.defaultUserMessage ?? fallback;
this.snackBar.open(msg, 'Close', { duration: 8000 });
}
}
@@ -13,7 +13,15 @@
<h1 class="pg-title">Cash Transfer</h1>
<p class="pg-sub">Move cash denomination-wise from one party to another (e.g. Vault → Cashier for opening).</p>
</div>
<button type="button" class="pg-btn pg-btn--ghost" (click)="clear()"><mat-icon>undo</mat-icon> Clear</button>
<div class="ct-head-actions">
<button type="button" class="pg-btn pg-btn--ghost" (click)="openRequests()">
<mat-icon>pending_actions</mat-icon> My Requests
@if (pendingCount) {
<span class="ct-badge">{{ pendingCount }}</span>
}
</button>
<button type="button" class="pg-btn pg-btn--ghost" (click)="clear()"><mat-icon>undo</mat-icon> Clear</button>
</div>
</header>
<div class="pg-toolbar pg-toolbar--mat">
@@ -155,7 +163,56 @@
<div class="pg-total-bar">
Total to transfer: <strong>₹{{ total | number: '1.2-2' }}</strong>
<button type="button" class="pg-btn pg-btn--primary" [disabled]="!valid || saving" (click)="submit()">
<mat-icon>swap_horiz</mat-icon> Record Transfer
<mat-icon>send</mat-icon> Send for approval
</button>
</div>
</div>
<!-- Popup: this person's own cash transfer requests, and where each stands. The cash moves only once a manager approves. -->
<ng-template #requestsDialog>
<div class="ct-dlg">
<header class="ct-dlg__head">
<mat-icon class="ct-dlg__ico">pending_actions</mat-icon>
<div class="ct-dlg__titles">
<div class="ct-dlg__title">My transfer requests</div>
<div class="ct-dlg__sub">Cash leaves the vault only after a manager approves</div>
</div>
<button type="button" class="ct-dlg__x" aria-label="Close" (click)="closeRequests()">
<mat-icon>close</mat-icon>
</button>
</header>
<div class="ct-dlg__body">
@if (myRequests.length) {
<table class="pg-table">
<thead>
<tr>
<th>Date</th>
<th>To</th>
<th class="num">Amount</th>
<th>Status</th>
<th>Approved by</th>
</tr>
</thead>
<tbody>
@for (r of myRequests; track r.id) {
<tr>
<td>{{ r.transfer_date || '—' }}</td>
<td>{{ r.to_party || '—' }}</td>
<td class="num">₹{{ r.total_amount | number: '1.2-2' }}</td>
<td>
<span class="ct-status" [class]="'ct-status ct-status--' + r.status">{{ r.status }}</span>
@if (r.rejection_reason) {
<div class="ct-requests__note">{{ r.rejection_reason }}</div>
}
</td>
<td>{{ r.checker_name || '—' }}</td>
</tr>
}
</tbody>
</table>
} @else {
<div class="pg-empty">You have not raised any cash transfer request yet.</div>
}
</div>
</div>
</ng-template>
@@ -49,3 +49,131 @@
font-size: 12px;
color: #991b1b;
}
.ct-requests {
padding: 0;
&__title {
padding: 10px 14px;
border-bottom: 1px solid #e2e8f0;
font-size: 13px;
font-weight: 700;
color: #0d1b2a;
}
&__note {
margin-top: 2px;
font-size: 11px;
color: #64748b;
}
}
.ct-status {
display: inline-block;
padding: 2px 10px;
border-radius: 999px;
background: #e2e8f0;
font-size: 11px;
font-weight: 700;
&--PENDING {
background: #fef3c7;
color: #92400e;
}
&--APPROVED {
background: #d1fae5;
color: #065f46;
}
&--REJECTED {
background: #fee2e2;
color: #991b1b;
}
}
.ct-head-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.ct-badge {
min-width: 18px;
padding: 0 6px;
border-radius: 999px;
background: #f59e0b;
color: #fff;
font-size: 11px;
font-weight: 700;
text-align: center;
}
.ct-dlg {
font-size: 12px;
&__head {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-bottom: 1px solid #e2e8f0;
background: #f8fafc;
}
&__ico {
flex: 0 0 32px;
height: 32px;
width: 32px;
border-radius: 9px;
background: #f0fdf4;
color: #1f7a4d;
font-size: 18px;
line-height: 32px;
text-align: center;
}
&__titles {
flex: 1;
min-width: 0;
}
&__title {
font-size: 14px;
font-weight: 700;
color: #0f172a;
}
&__sub {
font-size: 11px;
color: #64748b;
}
&__x {
display: inline-flex;
align-items: center;
justify-content: center;
height: 28px;
width: 28px;
border: 0;
border-radius: 50%;
background: transparent;
color: #64748b;
cursor: pointer;
&:hover {
background: #e2e8f0;
}
mat-icon {
font-size: 18px;
height: 18px;
width: 18px;
}
}
&__body {
max-height: 60vh;
overflow-y: auto;
}
}
@@ -6,9 +6,10 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { Component, OnInit, TemplateRef, ViewChild, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { MatFormFieldModule } from '@angular/material/form-field';
@@ -17,6 +18,7 @@ import { MatInputModule } from '@angular/material/input';
import { of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
interface Denom {
id: number;
@@ -38,6 +40,7 @@ interface Denom {
CommonModule,
FormsModule,
MatIconModule,
MatDialogModule,
MatSnackBarModule,
MatFormFieldModule,
MatSelectModule,
@@ -47,6 +50,10 @@ interface Denom {
export class CashTransferComponent implements OnInit {
private service = inject(CashierService);
private snackBar = inject(MatSnackBar);
private dialog = inject(MatDialog);
@ViewChild('requestsDialog') requestsDialog!: TemplateRef<unknown>;
private requestsRef: MatDialogRef<unknown> | null = null;
notes: Denom[] = [];
coins: Denom[] = [];
@@ -81,8 +88,35 @@ export class CashTransferComponent implements OnInit {
vaultHasBreakdown = true;
loadingVault = false;
/** The caller's own transfer requests, newest first, so a request can be followed to its approval. */
myRequests: any[] = [];
loadMyRequests(): void {
this.service
.getCashTransferRequests(undefined, true)
.pipe(catchError(() => of([] as any[])))
.subscribe((rows) => (this.myRequests = rows.slice(0, 20)));
}
/** How many of this person's requests are still waiting for a manager. */
get pendingCount(): number {
return this.myRequests.filter((r) => r.status === 'PENDING').length;
}
/** Opens the list of this person's requests, freshly read, so a request can be followed to its approval. */
openRequests(): void {
this.loadMyRequests();
this.requestsRef?.close();
this.requestsRef = this.dialog.open(this.requestsDialog, { width: '720px', maxWidth: '95vw', autoFocus: false });
}
closeRequests(): void {
this.requestsRef?.close();
}
ngOnInit(): void {
const d = new Date();
this.loadMyRequests();
const d = businessToday();
this.transferDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.service.getActiveDenominations().subscribe({
next: (list) => {
@@ -244,16 +278,11 @@ export class CashTransferComponent implements OnInit {
.filter((d) => this.countFor(d) > 0)
.map((d) => ({ denominationId: d.id, denominationValue: d.value, count: this.countFor(d) }))
};
this.service.createCashTransfer(payload).subscribe({
next: (res: any) => {
if (res?.glWarning) {
this.snackBar.open(`Transfer saved (GL warning: ${res.glWarning})`, 'Close', {
duration: 8000,
panelClass: ['snack-warn']
});
} else {
this.snackBar.open('Cash transfer recorded', 'Close', { duration: 3000 });
}
this.service.createCashTransferRequest(payload).subscribe({
next: () => {
// Nothing has moved yet: a manager approves the allocation first, and only then does the cash leave the vault.
this.snackBar.open('Cash transfer sent for manager approval', 'Close', { duration: 5000 });
this.loadMyRequests();
this.clear();
this.note = '';
this.fromParty = '';
@@ -17,6 +17,7 @@ import { forkJoin, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
import { AuthenticationService } from '../../core/authentication/authentication.service';
import { businessToday } from 'app/core/utils/business-today';
interface DenomRow {
denominationId: number;
@@ -71,7 +72,7 @@ export class CashierPositionComponent implements OnInit {
isCashierMode = false;
ngOnInit(): void {
const d = new Date();
const d = businessToday();
this.asOfDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.isCashierMode = this.authService.isCashierUser();
+21
View File
@@ -23,6 +23,7 @@ import { DayBookComponent } from './day-book/day-book.component';
import { VoucherReportComponent } from './voucher-report/voucher-report.component';
import { BranchDayComponent } from './branch-day/branch-day.component';
import { VaultPositionComponent } from './vault-position/vault-position.component';
import { BranchCashPositionComponent } from './branch-cash-position/branch-cash-position.component';
import { AccountRegisterComponent } from './account-register/account-register.component';
import { ClosingCashReportComponent } from './closing-cash-report/closing-cash-report.component';
import { ExcessTransactionReportComponent } from './excess-transaction-report/excess-transaction-report.component';
@@ -82,6 +83,13 @@ const routes: Routes = [
),
data: { title: 'My Share Requests', breadcrumb: 'My Share Requests' }
},
{
// The same page as the cashier's, reached from the clerk dashboard: a clerk unpasses vouchers here.
path: 'voucher-correction',
loadComponent: () =>
import('./voucher-correction/voucher-correction.component').then((m) => m.VoucherCorrectionComponent),
data: { title: 'Voucher Correction', breadcrumb: 'Voucher Correction' }
},
{
path: 'closure-requests',
loadComponent: () =>
@@ -225,6 +233,13 @@ const routes: Routes = [
component: VaultPositionComponent,
data: { title: 'Vault Position', breadcrumb: 'Vault Position' }
},
{
// What the whole branch holds per denomination: the vault plus every cashier's drawer, so an
// exchange (which leaves the vault alone) is visible in the bank's total.
path: 'branch-position',
component: BranchCashPositionComponent,
data: { title: 'Branch Cash Position', breadcrumb: 'Branch Cash Position' }
},
{
// The same day book the clerk and cashier read, reached from where a principal officer
// signing the day off actually looks. It carries the Officer Verification Sheet — each
@@ -283,6 +298,12 @@ const routes: Routes = [
component: ReconciliationComponent,
data: { title: 'Reconciliation', breadcrumb: 'Reconciliation' }
},
{
path: 'voucher-correction',
loadComponent: () =>
import('./voucher-correction/voucher-correction.component').then((m) => m.VoucherCorrectionComponent),
data: { title: 'Voucher Correction', breadcrumb: 'Voucher Correction' }
},
{
path: 'cash-history',
component: CashHistoryComponent,
+46 -13
View File
@@ -32,9 +32,6 @@ export class CashierService {
getCashExchange(id: number): Observable<any> {
return this.http.get(`/cashexchange/${id}`);
}
reverseCashExchange(id: number, reason: string): Observable<any> {
return this.http.post(`/cashexchange/${id}/reverse`, { reason });
}
// ── Tellers / Cashiers ──
getTellers(officeId?: number): Observable<any[]> {
@@ -50,8 +47,21 @@ export class CashierService {
}
// ── Cash Transfer ──
createCashTransfer(data: any): Observable<any> {
return this.http.post('/cashtransfer', data);
/** Cash leaves the vault only when a different person approves, so the screen raises a request, not a transfer. */
createCashTransferRequest(data: any): Observable<any> {
return this.http.post('/cashtransfer/requests', data);
}
/** Requests by status (comma separated); mine narrows them to the ones the caller raised. */
getCashTransferRequests(status?: string, mine = false): Observable<any[]> {
let params = new HttpParams().set('mine', String(mine));
if (status) params = params.set('status', status);
return this.http.get<any[]>('/cashtransfer/requests', { params });
}
approveCashTransferRequest(id: number): Observable<any> {
return this.http.post(`/cashtransfer/requests/${id}/approve`, {});
}
rejectCashTransferRequest(id: number, reason: string): Observable<any> {
return this.http.post(`/cashtransfer/requests/${id}/reject`, { reason });
}
getCashTransfers(offset = 0, limit = 20): Observable<any> {
const params = new HttpParams().set('offset', offset).set('limit', limit);
@@ -60,9 +70,6 @@ export class CashierService {
getCashTransferById(id: number): Observable<any> {
return this.http.get(`/cashtransfer/${id}`);
}
reverseCashTransfer(id: number, reason: string): Observable<any> {
return this.http.post(`/cashtransfer/${id}/reverse`, { reason });
}
// ── Cash Settlement ──
createCashSettlement(data: any): Observable<any> {
@@ -75,9 +82,6 @@ export class CashierService {
getCashSettlementById(id: number): Observable<any> {
return this.http.get(`/cashsettlement/${id}`);
}
reverseCashSettlement(id: number, reason: string): Observable<any> {
return this.http.post(`/cashsettlement/${id}/reverse`, { reason });
}
// ── Inter-cashier Transfer ──
createInterCashierTransfer(data: any): Observable<any> {
@@ -90,8 +94,10 @@ export class CashierService {
getInterCashierById(id: number): Observable<any> {
return this.http.get(`/cashintercashier/${id}`);
}
reverseInterCashierTransfer(id: number, reason: string): Observable<any> {
return this.http.post(`/cashintercashier/${id}/reverse`, { reason });
/** One cashier's details, today's vault allocation and denomination-wise cash, for either side of a transfer. */
getInterCashierCashierSummary(cashierId: number, date: string): Observable<any> {
const params = new HttpParams().set('cashierId', cashierId).set('date', date);
return this.http.get<any>('/cashintercashier/cashier-summary', { params });
}
// ── Reconciliation ──
@@ -205,6 +211,23 @@ export class CashierService {
return this.http.get('/branchday', { params });
}
/**
* The savings products whose interest posting report is due for the date and not yet approved —
* the backend refuses to close any branch's day while one is pending.
*/
interestDayCloseCheck(
date?: string,
officeId?: number
): Observable<{ businessDate: string; canClose: boolean; pendingProducts: string[] }> {
let params = new HttpParams();
if (date) params = params.set('date', date);
if (officeId) params = params.set('officeId', String(officeId));
return this.http.get<{ businessDate: string; canClose: boolean; pendingProducts: string[] }>(
'/savingsinterestpostingreports/day-close-check',
{ params }
);
}
/** Days opened and never closed — today's own open day excluded. */
branchDayMissed(officeId?: number): Observable<any> {
let params = new HttpParams();
@@ -301,6 +324,16 @@ export class CashierService {
if (asOfDate) params = params.set('asOfDate', asOfDate);
return this.http.get('/cashreports/vault-position', { params });
}
/**
* Denomination-wise cash the whole branch holds: its vault(s) plus every cashier's drawer, with the day's
* exchange movement beside it. Not for a cashier -- the server refuses it.
*/
branchCashPosition(asOfDate?: string, officeId?: number): Observable<any> {
let params = new HttpParams();
if (asOfDate) params = params.set('asOfDate', asOfDate);
if (officeId) params = params.set('officeId', String(officeId));
return this.http.get('/cashreports/branch-position', { params });
}
excessTransactionReport(threshold: number, fromDate?: string, toDate?: string): Observable<any[]> {
let params = new HttpParams().set('threshold', String(threshold));
if (fromDate) params = params.set('fromDate', fromDate);
@@ -51,6 +51,12 @@ export class ClerkShareWithdrawalService {
return this.http.post(`/clerk/share-withdrawal-requests/${id}/validate`, {});
}
/** Runs the exact same checklist approve() will run, against a draft that hasn't been submitted
* yet — so the clerk sees it in the confirm dialog, before committing to the request. */
previewValidate(data: any): Observable<any> {
return this.http.post('/clerk/share-withdrawal-requests/preview-validate', data);
}
approve(id: number): Observable<any> {
return this.http.post(`/clerk/share-withdrawal-requests/${id}/approve`, {});
}
@@ -12,6 +12,7 @@ import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
/** Day-wise denomination-wise closing cash collected from deposits. */
@Component({
@@ -204,7 +205,7 @@ export class ClosingCashReportComponent implements OnInit {
}
ngOnInit(): void {
const d = new Date();
const d = businessToday();
this.date = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.load();
this.service.getTellers().subscribe({ next: (t: any[]) => (this.tellers = t || []), error: () => {} });
@@ -202,6 +202,7 @@
<th class="num">Amount</th>
<th>Payment</th>
<th>Status</th>
<th>Correction</th>
<th>By</th>
<th>Approved By</th>
<th>Time</th>
@@ -231,6 +232,18 @@
<td>
<span [class]="statusClass(r.status)">{{ r.status }}</span>
</td>
<td>
<!-- Where the voucher stands after a correction: unpassed, revoked, closed or corrected. -->
<mifosx-voucher-status
[status]="r.voucherStatus"
[revokedBy]="r.revokedByVoucherNo"
[replaces]="r.replacesVoucherNo"
[note]="r.correctionNote"
></mifosx-voucher-status>
@if (!r.voucherStatus || r.voucherStatus === 'ACTIVE') {
<span class="db-muted">—</span>
}
</td>
<td>{{ r.handledBy ?? '—' }}</td>
<td>
{{ r.approvedBy ?? '—' }}
@@ -256,7 +269,7 @@
—
}
</td>
<td colspan="5"></td>
<td colspan="6"></td>
</tr>
</tfoot>
</table>
+10 -2
View File
@@ -13,6 +13,8 @@ import { MatIconModule } from '@angular/material/icon';
import { Router } from '@angular/router';
import { catchError, of } from 'rxjs';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
import { VoucherStatusComponent, VoucherStatus } from '../voucher-status/voucher-status.component';
/**
* Which part of the day's work a row came from. Set by the server, one per source register.
@@ -25,6 +27,11 @@ type DayBookSection = 'CASHIER' | 'CLERK' | 'SYSTEM';
interface DayBookRow {
voucherNo: string | null;
/** Where the voucher stands after a correction: active, revoked, under correction, or corrected. */
voucherStatus?: VoucherStatus;
revokedByVoucherNo?: string | null;
replacesVoucherNo?: string | null;
correctionNote?: string | null;
section: DayBookSection;
side: 'CR' | 'DR';
kind: string;
@@ -57,7 +64,8 @@ interface DayBookRow {
imports: [
CommonModule,
FormsModule,
MatIconModule
MatIconModule,
VoucherStatusComponent
]
})
export class DayBookComponent implements OnInit {
@@ -95,7 +103,7 @@ export class DayBookComponent implements OnInit {
/** Local date as yyyy-MM-dd — toISOString() would shift the day in any timezone behind UTC. */
private today(): string {
const d = new Date();
const d = businessToday();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
@@ -11,41 +11,29 @@
<div>
<div class="pg-eyebrow">CASHIER · INTER-CASHIER TRANSFER</div>
<h1 class="pg-title">Inter-Cashier Transfer</h1>
<p class="pg-sub">Move denomination-wise cash between two cashiers within the same teller.</p>
<p class="pg-sub">Hand over denomination-wise cash from your drawer to another cashier at your counter.</p>
</div>
<button type="button" class="pg-btn pg-btn--ghost" (click)="clear()"><mat-icon>undo</mat-icon> Clear</button>
</header>
<div class="pg-toolbar pg-toolbar--mat">
@if (tellers.length === 1) {
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Teller</mat-label>
<input matInput type="text" [value]="tellers[0].name" readonly />
</mat-form-field>
} @else {
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Teller</mat-label>
<mat-select [value]="selectedTellerId" (selectionChange)="onTellerSelect($event.value)">
@for (t of tellers; track t.id) {
<mat-option [value]="t.id">{{ t.name }}</mat-option>
}
@if (tellers.length === 0) {
<mat-option [value]="null" disabled>No tellers configured</mat-option>
}
</mat-select>
</mat-form-field>
}
@if (ownCashierMissing) {
<div class="ict-alert">
<mat-icon>person_off</mat-icon>
Your login is not linked to a cashier. An inter-cashier transfer is made by the cashier handing the cash over, so
log in as that cashier.
</div>
}
<div class="pg-toolbar pg-toolbar--mat">
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>From (Cashier)</mat-label>
<mat-select [value]="fromCashierId" (selectionChange)="onFromSelect($event.value)" [disabled]="!selectedTellerId">
@for (c of availableFrom; track c.id) {
<mat-option [value]="c.id">{{ c.label }}</mat-option>
}
@if (selectedTellerId && allCashiers.length === 0 && !loadingCashiers) {
<mat-option [value]="null" disabled>No cashiers</mat-option>
}
</mat-select>
<mat-label>Teller</mat-label>
<input matInput type="text" [value]="tellerName || '—'" readonly />
</mat-form-field>
<!-- The sender is always the cashier who is logged in; nothing to pick. -->
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>From (You)</mat-label>
<input matInput type="text" [value]="fromParty || '—'" readonly />
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
@@ -54,7 +42,7 @@
@for (c of availableTo; track c.id) {
<mat-option [value]="c.id">{{ c.label }}</mat-option>
}
@if (fromCashierId && availableTo.length === 0) {
@if (fromCashierId && !loadingCashiers && availableTo.length === 0) {
<mat-option [value]="null" disabled>No other cashiers</mat-option>
}
</mat-select>
@@ -62,7 +50,7 @@
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Date</mat-label>
<input matInput type="date" [value]="transferDate" (change)="transferDate = $any($event.target).value" />
<input matInput type="date" [value]="transferDate" (change)="onDateChange($any($event.target).value)" />
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="pg-note-field">
@@ -71,26 +59,102 @@
</mat-form-field>
</div>
<!-- Both sides of the transfer: who they are, whether the vault has allocated them cash today,
and what they hold now. -->
@if (fromCashierId || toCashierId) {
<div class="ict-parties">
<ng-container
*ngTemplateOutlet="
partyCard;
context: { title: 'From (Sending Cashier)', s: fromSummary, loading: loadingFrom, picked: !!fromCashierId }
"
></ng-container>
<mat-icon class="ict-parties__arrow">arrow_forward</mat-icon>
<ng-container
*ngTemplateOutlet="
partyCard;
context: { title: 'To (Receiving Cashier)', s: toSummary, loading: loadingTo, picked: !!toCashierId }
"
></ng-container>
</div>
}
<ng-template #partyCard let-title="title" let-s="s" let-loading="loading" let-picked="picked">
<div class="ict-party" [class.ict-party--blocked]="s && !s.cashAllocated">
<div class="ict-party__title">{{ title }}</div>
@if (!picked) {
<div class="ict-party__empty">Select a cashier</div>
} @else if (loading) {
<div class="ict-party__empty">Loading…</div>
} @else if (!s) {
<div class="ict-party__empty">Could not load this cashier's details.</div>
} @else {
<div class="ict-party__name">{{ s.cashierName || '—' }}</div>
<div class="ict-party__meta">
<span><mat-icon>point_of_sale</mat-icon> {{ s.tellerName || '—' }}</span>
<span><mat-icon>account_balance</mat-icon> {{ s.officeName || '—' }}</span>
</div>
<div class="ict-party__figures">
<div>
<div class="ict-party__label">Allocated today</div>
<div class="ict-party__value">₹{{ s.allocatedAmount | number: '1.2-2' }}</div>
</div>
<div>
<div class="ict-party__label">Cash in hand</div>
<div class="ict-party__value">₹{{ s.totalAmount | number: '1.2-2' }}</div>
</div>
</div>
@if (!s.cashAllocated) {
<div class="ict-party__warn">
<mat-icon>block</mat-icon> No cash allocated from the vault for this date — inter-cashier transfer is not
allowed.
</div>
}
}
</div>
</ng-template>
@if (fromSummary && toSummary && !bothAllocated) {
<div class="ict-alert">
<mat-icon>block</mat-icon>
Both cashiers must first be allocated cash from the vault (Cash Transfer) for {{ transferDate }} before an
inter-cashier transfer.
</div>
}
@if (anyExceedsHeld) {
<div class="ict-alert">
<mat-icon>error_outline</mat-icon>
The sending cashier does not hold enough of the highlighted denominations.
</div>
}
<div class="pg-card">
<table class="pg-table">
<thead>
<tr>
<th>Denomination</th>
<th class="num">From holds</th>
<th style="text-align: center">Count</th>
<th class="num">Amount</th>
<th class="num">To holds</th>
</tr>
</thead>
<tbody>
@if (notes.length) {
<tr class="pg-section">
<td colspan="3">Bank Notes</td>
<td colspan="5">Bank Notes</td>
</tr>
}
@for (d of notes; track d.id) {
<tr>
<tr [class.ict-row--over]="exceedsHeld(d)">
<td>
<span class="pg-chip">{{ d.label }}</span>
</td>
<td class="num" [class.ict-held--over]="exceedsHeld(d)">
<ng-container
*ngTemplateOutlet="heldCell; context: { n: heldBy(fromSummary, d), delta: -countFor(d) }"
></ng-container>
</td>
<td style="display: flex; justify-content: center">
<div class="pg-step">
<button type="button" (click)="bump(d, -1)" [disabled]="countFor(d) === 0">−</button>
@@ -99,18 +163,28 @@
</div>
</td>
<td class="num">₹{{ subtotal(d) | number: '1.2-2' }}</td>
<td class="num">
<ng-container
*ngTemplateOutlet="heldCell; context: { n: heldBy(toSummary, d), delta: countFor(d) }"
></ng-container>
</td>
</tr>
}
@if (coins.length) {
<tr class="pg-section">
<td colspan="3">Coins</td>
<td colspan="5">Coins</td>
</tr>
}
@for (d of coins; track d.id) {
<tr>
<tr [class.ict-row--over]="exceedsHeld(d)">
<td>
<span class="pg-chip pg-chip--coin">{{ d.label }}</span>
</td>
<td class="num" [class.ict-held--over]="exceedsHeld(d)">
<ng-container
*ngTemplateOutlet="heldCell; context: { n: heldBy(fromSummary, d), delta: -countFor(d) }"
></ng-container>
</td>
<td style="display: flex; justify-content: center">
<div class="pg-step">
<button type="button" (click)="bump(d, -1)" [disabled]="countFor(d) === 0">−</button>
@@ -119,17 +193,34 @@
</div>
</td>
<td class="num">₹{{ subtotal(d) | number: '1.2-2' }}</td>
<td class="num">
<ng-container
*ngTemplateOutlet="heldCell; context: { n: heldBy(toSummary, d), delta: countFor(d) }"
></ng-container>
</td>
</tr>
}
@if (denoms.length === 0) {
<tr>
<td colspan="3" class="pg-empty">No active denominations. Add some in Cash Denomination.</td>
<td colspan="5" class="pg-empty">No active denominations. Add some in Cash Denomination.</td>
</tr>
}
</tbody>
</table>
</div>
<!-- A held count, with what it becomes once this transfer is recorded. -->
<ng-template #heldCell let-n="n" let-delta="delta">
@if (n === null) {
—
} @else {
{{ n }}
@if (delta !== 0) {
<span class="ict-after">→ {{ n + delta }}</span>
}
}
</ng-template>
<div class="pg-total-bar">
Total to transfer: <strong>₹{{ total | number: '1.2-2' }}</strong>
<button type="button" class="pg-btn pg-btn--primary" [disabled]="!valid || saving" (click)="submit()">
@@ -0,0 +1,150 @@
/**
* 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/.
*/
.ict-parties {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: stretch;
gap: 12px;
margin-bottom: 12px;
&__arrow {
align-self: center;
color: #64748b;
}
@media (width <= 760px) {
grid-template-columns: 1fr;
&__arrow {
justify-self: center;
transform: rotate(90deg);
}
}
}
.ict-party {
padding: 12px 14px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 10px;
&--blocked {
background: #fef2f2;
border-color: #fecaca;
}
&__title {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #64748b;
}
&__empty {
margin-top: 8px;
font-size: 13px;
color: #94a3b8;
}
&__name {
margin-top: 4px;
font-size: 16px;
font-weight: 700;
color: #0f172a;
}
&__meta {
display: flex;
flex-wrap: wrap;
gap: 14px;
margin-top: 4px;
font-size: 12px;
color: #475569;
span {
display: inline-flex;
align-items: center;
gap: 4px;
}
mat-icon {
font-size: 14px;
width: 14px;
height: 14px;
}
}
&__figures {
display: flex;
gap: 24px;
margin-top: 10px;
}
&__label {
font-size: 11px;
color: #64748b;
}
&__value {
font-size: 15px;
font-weight: 700;
color: #0f172a;
}
&__warn {
display: flex;
align-items: center;
gap: 6px;
margin-top: 10px;
font-size: 12px;
font-weight: 600;
color: #991b1b;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
}
}
.ict-alert {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
padding: 8px 14px;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 8px;
font-size: 12px;
color: #991b1b;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
}
.ict-row--over {
background: #fef2f2;
}
.ict-held--over {
color: #b91c1c;
font-weight: 700;
}
.ict-after {
margin-left: 4px;
font-size: 11px;
color: #64748b;
}
@@ -17,6 +17,7 @@ import { MatInputModule } from '@angular/material/input';
import { of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
interface Denom {
id: number;
@@ -28,12 +29,27 @@ interface Cashier {
id: number;
label: string;
}
/** One side of the transfer, from GET /cashintercashier/cashier-summary. */
interface CashierSummary {
cashierId: number;
cashierName: string;
tellerName: string;
officeName: string;
cashAllocated: boolean;
allocatedAmount: number;
totalAmount: number;
/** Count held per denomination id. */
held: Record<number, number>;
}
/** Move cash denomination-wise between two cashiers under the same teller. */
@Component({
selector: 'mifosx-inter-cashier-transfer',
templateUrl: './inter-cashier-transfer.component.html',
styleUrls: ['../cashier-shared.scss'],
styleUrls: [
'../cashier-shared.scss',
'./inter-cashier-transfer.component.scss'
],
standalone: true,
imports: [
CommonModule,
@@ -56,7 +72,12 @@ export class InterCashierTransferComponent implements OnInit {
note = '';
saving = false;
tellers: { id: number; name: string }[] = [];
/**
* The sending cashier is always the one logged in: cash is handed over from their own drawer, so there is
* no one else to pick on that side. Only the receiving cashier is chosen.
*/
ownCashierMissing = false;
tellerName = '';
allCashiers: Cashier[] = [];
selectedTellerId: number | null = null;
fromCashierId: number | null = null;
@@ -65,21 +86,25 @@ export class InterCashierTransferComponent implements OnInit {
toParty = '';
loadingCashiers = false;
/** Details and denomination-wise cash of each side, so both are seen before the transfer is recorded. */
fromSummary: CashierSummary | null = null;
toSummary: CashierSummary | null = null;
loadingFrom = false;
loadingTo = false;
get denoms(): Denom[] {
return [
...this.notes,
...this.coins
];
}
get availableFrom(): Cashier[] {
return this.allCashiers;
}
/** Everyone under the same teller except the sender. */
get availableTo(): Cashier[] {
return this.allCashiers.filter((c) => c.id !== this.fromCashierId);
}
ngOnInit(): void {
const d = new Date();
const d = businessToday();
this.transferDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.service.getActiveDenominations().subscribe({
next: (list) => {
@@ -97,25 +122,20 @@ export class InterCashierTransferComponent implements OnInit {
this.coins = [];
}
});
this.service
.getTellers()
.pipe(catchError(() => of([] as any[])))
.subscribe((list) => {
this.tellers = (list || []).map((t: any) => ({ id: t.id, name: t.name }));
if (this.tellers.length === 1) {
this.onTellerSelect(this.tellers[0].id);
}
});
// The sender is the cashier behind this login; their counter then gives the receivers to pick from.
this.service.getCurrentCashierInfo().subscribe((info) => {
if (!info) {
this.ownCashierMissing = true;
return;
}
this.fromCashierId = info.cashierId;
this.fromParty = info.cashierName;
this.loadSummary('from');
});
}
onTellerSelect(tellerId: number): void {
this.selectedTellerId = tellerId;
this.fromCashierId = null;
this.toCashierId = null;
this.fromParty = '';
this.toParty = '';
this.allCashiers = [];
if (!tellerId) return;
/** The other cashiers at this counter, one of whom receives the cash. */
private loadReceivers(tellerId: number): void {
this.loadingCashiers = true;
this.service
.getCashiersForTeller(tellerId)
@@ -126,20 +146,90 @@ export class InterCashierTransferComponent implements OnInit {
});
}
onFromSelect(cashierId: number): void {
this.fromCashierId = cashierId;
const c = this.allCashiers.find((x) => x.id === cashierId);
this.fromParty = c ? c.label : '';
if (this.toCashierId === cashierId) {
this.toCashierId = null;
this.toParty = '';
}
}
onToSelect(cashierId: number): void {
this.toCashierId = cashierId;
const c = this.allCashiers.find((x) => x.id === cashierId);
this.toParty = c ? c.label : '';
this.loadSummary('to');
}
onDateChange(value: string): void {
this.transferDate = value;
this.loadSummary('from');
this.loadSummary('to');
}
/** Fetch one side's details, vault allocation and denomination-wise cash for the transfer date. */
private loadSummary(side: 'from' | 'to'): void {
const cashierId = side === 'from' ? this.fromCashierId : this.toCashierId;
if (side === 'from') {
this.fromSummary = null;
} else {
this.toSummary = null;
}
if (!cashierId || !this.transferDate) return;
this.setLoading(side, true);
this.service
.getInterCashierCashierSummary(cashierId, this.transferDate)
.pipe(catchError(() => of(null)))
.subscribe((r: any) => {
this.setLoading(side, false);
// A late answer for a cashier no longer selected on that side is dropped.
const current = side === 'from' ? this.fromCashierId : this.toCashierId;
if (!r || current !== cashierId) return;
// The sender's summary also tells which counter they are at, hence who they can hand cash to.
if (side === 'from' && r.tellerId && this.selectedTellerId !== Number(r.tellerId)) {
this.selectedTellerId = Number(r.tellerId);
this.tellerName = r.tellerName || '';
this.loadReceivers(this.selectedTellerId);
}
const held: Record<number, number> = {};
for (const d of r.denominations || []) {
held[d.denominationId] = (held[d.denominationId] || 0) + Number(d.count || 0);
}
const summary: CashierSummary = {
cashierId: r.cashierId,
cashierName: r.cashierName,
tellerName: r.tellerName,
officeName: r.officeName,
cashAllocated: !!r.cashAllocated,
allocatedAmount: Number(r.allocatedAmount || 0),
totalAmount: Number(r.totalAmount || 0),
held
};
if (side === 'from') {
this.fromSummary = summary;
} else {
this.toSummary = summary;
}
});
}
private setLoading(side: 'from' | 'to', loading: boolean): void {
if (side === 'from') {
this.loadingFrom = loading;
} else {
this.loadingTo = loading;
}
}
/** Count of the denomination the cashier holds, or null while their figures are not loaded. */
heldBy(summary: CashierSummary | null, d: Denom): number | null {
return summary ? summary.held[d.id] || 0 : null;
}
/** The sending cashier is asked to hand on more of this denomination than they hold. */
exceedsHeld(d: Denom): boolean {
return !!this.fromSummary && this.countFor(d) > (this.fromSummary.held[d.id] || 0);
}
get anyExceedsHeld(): boolean {
return this.denoms.some((d) => this.exceedsHeld(d));
}
/** Both sides must have been allocated cash from the vault for the day before they can transfer. */
get bothAllocated(): boolean {
return !!this.fromSummary?.cashAllocated && !!this.toSummary?.cashAllocated;
}
countFor(d: Denom): number {
@@ -159,7 +249,14 @@ export class InterCashierTransferComponent implements OnInit {
return this.denoms.reduce((s, d) => s + this.subtotal(d), 0);
}
get valid(): boolean {
return this.total > 0 && !!this.selectedTellerId && !!this.fromCashierId && !!this.toCashierId;
return (
this.total > 0 &&
!!this.selectedTellerId &&
!!this.fromCashierId &&
!!this.toCashierId &&
this.bothAllocated &&
!this.anyExceedsHeld
);
}
clear(): void {
@@ -186,10 +283,12 @@ export class InterCashierTransferComponent implements OnInit {
this.snackBar.open('Inter-cashier transfer recorded', 'Close', { duration: 3000 });
this.clear();
this.note = '';
this.fromCashierId = null;
// The sender stays the logged-in cashier; the receiver is picked afresh, and the sender's holdings
// are read again now that part of them has gone.
this.toCashierId = null;
this.fromParty = '';
this.toParty = '';
this.toSummary = null;
this.loadSummary('from');
this.saving = false;
},
error: () => {
@@ -146,6 +146,7 @@
<th class="num">Amount</th>
<th>My part</th>
<th>Status</th>
<th>Correction</th>
<th>Time</th>
</tr>
</thead>
@@ -203,6 +204,18 @@
{{ statusLabel(r.verificationStatus) }}
</span>
</td>
<td>
<!-- Where the voucher stands after a correction: unpassed, revoked, closed or corrected. -->
<mifosx-voucher-status
[status]="r.voucherStatus"
[revokedBy]="r.revokedByVoucherNo"
[replaces]="r.replacesVoucherNo"
[note]="r.correctionNote"
></mifosx-voucher-status>
@if (!r.voucherStatus || r.voucherStatus === 'ACTIVE') {
<span class="mw-acct">—</span>
}
</td>
<td class="mw-time">{{ r.createdAt | date: 'HH:mm' }}</td>
</tr>
}
+10 -2
View File
@@ -14,6 +14,8 @@ import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { catchError, finalize, of } from 'rxjs';
import { CashierService } from '../cashier.service';
import { VoucherDetailDialogComponent } from './voucher-detail-dialog.component';
import { businessToday } from 'app/core/utils/business-today';
import { VoucherStatusComponent, VoucherStatus } from '../voucher-status/voucher-status.component';
/** What the signed-in user's part in a voucher was. */
export type MyRole = 'RAISED' | 'VERIFIED' | 'RAISED_AND_VERIFIED';
@@ -23,6 +25,11 @@ export type VoucherSide = 'CR' | 'DR' | 'JV';
interface MyWorkRow {
voucherNo: string;
/** Where the voucher stands after a correction: active, revoked, under correction, or corrected. */
voucherStatus?: VoucherStatus;
revokedByVoucherNo?: string | null;
replacesVoucherNo?: string | null;
correctionNote?: string | null;
side: VoucherSide | null;
voucherType: string | null;
amount: number | null;
@@ -75,7 +82,8 @@ interface MyWorkReport {
CommonModule,
FormsModule,
MatIconModule,
MatDialogModule
MatDialogModule,
VoucherStatusComponent
]
})
export class MyWorkComponent implements OnInit {
@@ -357,7 +365,7 @@ export class MyWorkComponent implements OnInit {
}
private todayIso(): string {
const d = new Date();
const d = businessToday();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
}
@@ -11,6 +11,7 @@ import { CommonModule } from '@angular/common';
import { MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { VoucherStatusComponent, VoucherStatus } from '../voucher-status/voucher-status.component';
export interface VoucherDetailDialogData {
/** Already-loaded detail from CashierService.voucherDetail() — the caller fetches, this dialog
@@ -18,6 +19,10 @@ export interface VoucherDetailDialogData {
* duplicated inside every dialog that could ever show a voucher. */
detail: {
voucherNo: string;
voucherStatus?: VoucherStatus;
revokedByVoucherNo?: string | null;
replacesVoucherNo?: string | null;
correctionNote?: string | null;
voucherType: string | null;
amount: number | null;
branchName: string | null;
@@ -58,7 +63,8 @@ export interface VoucherDetailDialogData {
CommonModule,
MatDialogModule,
MatButtonModule,
MatIconModule
MatIconModule,
VoucherStatusComponent
],
template: `
<div class="vd-shell">
@@ -66,7 +72,15 @@ export interface VoucherDetailDialogData {
<div class="vd-header__title">
<mat-icon class="vd-icon">receipt_long</mat-icon>
<div>
<h2>{{ detail.voucherNo }}</h2>
<h2>
{{ detail.voucherNo }}
<mifosx-voucher-status
[status]="detail.voucherStatus"
[revokedBy]="detail.revokedByVoucherNo"
[replaces]="detail.replacesVoucherNo"
[note]="detail.correctionNote"
></mifosx-voucher-status>
</h2>
<p>{{ detail.voucherType || 'Voucher' }} &middot; {{ detail.branchName || 'Head Office' }}</p>
</div>
</div>
@@ -19,34 +19,21 @@
<!-- ── Entry Tab ── -->
<mat-tab label="New Reconciliation">
<div class="pg-tab-body">
@if (ownCashierMissing) {
<div class="pg-empty">
Your login is not linked to a cashier, so there is no cash drawer to reconcile. Ask an administrator to link
your user to a cashier.
</div>
}
<div class="pg-toolbar pg-toolbar--mat">
@if (tellers.length === 1) {
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Teller</mat-label>
<input matInput type="text" [value]="tellers[0].name" readonly />
</mat-form-field>
} @else {
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Teller</mat-label>
<mat-select [value]="selectedTellerId" (selectionChange)="onTellerSelect($event.value)">
@for (t of tellers; track t.id) {
<mat-option [value]="t.id">{{ t.name }}</mat-option>
}
</mat-select>
</mat-form-field>
}
<!-- A reconciliation is the cashier's own count of their own drawer: nothing to pick. -->
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Cashier</mat-label>
<mat-select
[value]="selectedCashierId"
(selectionChange)="onCashierSelect($event.value)"
[disabled]="!selectedTellerId"
>
@for (c of cashiers; track c.id) {
<mat-option [value]="c.id">{{ c.label }}</mat-option>
}
</mat-select>
<mat-label>Teller</mat-label>
<input matInput type="text" [value]="ownTellerName || '—'" readonly />
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Cashier (You)</mat-label>
<input matInput type="text" [value]="cashierName || '—'" readonly />
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
@@ -12,12 +12,12 @@ import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatSelectModule } from '@angular/material/select';
import { MatInputModule } from '@angular/material/input';
import { MatTabsModule } from '@angular/material/tabs';
import { forkJoin, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
interface DenomRow {
denominationId: number;
@@ -57,7 +57,6 @@ interface ReconRecord {
MatIconModule,
MatSnackBarModule,
MatFormFieldModule,
MatSelectModule,
MatInputModule,
MatTabsModule
]
@@ -66,9 +65,14 @@ export class ReconciliationComponent implements OnInit {
private service = inject(CashierService);
private snackBar = inject(MatSnackBar);
/**
* A reconciliation is the cashier's own count of their own drawer, so nobody else makes one and there is
* no cashier to pick: the screen is fixed to the logged-in cashier. The server refuses anything else.
*/
ownCashierMissing = false;
ownTellerName = '';
// ── Entry form ──
tellers: { id: number; name: string }[] = [];
cashiers: { id: number; label: string }[] = [];
selectedTellerId: number | null = null;
selectedCashierId: number | null = null;
cashierName = '';
@@ -85,44 +89,30 @@ export class ReconciliationComponent implements OnInit {
loadingHistory = false;
ngOnInit(): void {
const d = new Date();
const d = businessToday();
this.reconDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.service
.getTellers()
.pipe(catchError(() => of([] as any[])))
.subscribe((list) => {
this.tellers = (list || []).map((t: any) => ({ id: t.id, name: t.name }));
if (this.tellers.length === 1) {
this.onTellerSelect(this.tellers[0].id);
}
});
this.lockToOwnCashier();
this.loadHistory();
}
onTellerSelect(tellerId: number): void {
this.selectedTellerId = tellerId;
this.selectedCashierId = null;
this.cashierName = '';
this.cashiers = [];
this.rows = [];
if (!tellerId) return;
this.service
.getCashiersForTeller(tellerId)
.pipe(catchError(() => of({ cashiers: [] })))
.subscribe((r: any) => {
this.cashiers = (r?.cashiers || []).map((c: any) => ({ id: c.id, label: c.staffName }));
if (this.cashiers.length === 1) {
this.onCashierSelect(this.cashiers[0].id);
}
});
}
onCashierSelect(cashierId: number): void {
this.selectedCashierId = cashierId;
const c = this.cashiers.find((x) => x.id === cashierId);
this.cashierName = c ? c.label : '';
this.rows = [];
this.fetchExpected();
/** Take the logged-in cashier as the one being reconciled, with their counter for the record. */
private lockToOwnCashier(): void {
this.service.getCurrentCashierInfo().subscribe((info) => {
if (!info) {
this.ownCashierMissing = true;
return;
}
this.selectedCashierId = info.cashierId;
this.cashierName = info.cashierName;
this.service
.getInterCashierCashierSummary(info.cashierId, this.reconDate)
.pipe(catchError(() => of(null)))
.subscribe((r: any) => {
this.selectedTellerId = r?.tellerId ?? null;
this.ownTellerName = r?.tellerName || '';
});
this.fetchExpected();
});
}
fetchExpected(): void {
@@ -117,6 +117,8 @@
<select
class="ws-f__ctl"
[(ngModel)]="row.typ"
[disabled]="isTypLocked(row)"
[title]="isTypLocked(row) ? 'Token withdrawal - TYPE is fixed to Payment' : ''"
(click)="$event.stopPropagation()"
(change)="onTypChange(row)"
>
@@ -127,11 +129,16 @@
</div>
<div class="ws-f ws-f--branch">
<label class="ws-f__lbl" title="Branch">BRANCH</label>
<select class="ws-f__ctl" [(ngModel)]="row.br" (click)="$event.stopPropagation()">
<option [ngValue]="1">001 · Main</option>
<option [ngValue]="2">002 · M.G. Road</option>
<option [ngValue]="14">014 · Station Rd</option>
<option [ngValue]="27">027 · Industrial</option>
<select
class="ws-f__ctl"
[(ngModel)]="row.br"
[disabled]="branches.length <= 1"
[title]="branches.length <= 1 ? 'Your branch' : 'Branch'"
(click)="$event.stopPropagation()"
>
@for (b of branches; track b.id) {
<option [ngValue]="b.id">{{ b.label }}</option>
}
</select>
</div>
@if (row.typ === 'DD') {
@@ -177,13 +184,7 @@
class="ws-f__ctl"
[(ngModel)]="row.acTyp"
(click)="$event.stopPropagation()"
(change)="
row.acTyp === 'GL' &&
row.typ !== 'Disbursement' &&
row.typ !== 'Fund Transfer' &&
row.typ !== 'Enter Bank Transfer' &&
(row.dbCr = 'Cr')
"
(change)="isCreditOnly(row) && (row.dbCr = 'Cr')"
>
@for (a of acTypOptionsFor(row); track a) {
<option [value]="a">{{ a }}</option>
@@ -266,12 +267,7 @@
type="button"
class="ws-seg__btn ws-seg__btn--dr"
[class.ws-seg__btn--active-dr]="row.dbCr === 'Dr'"
[disabled]="
row.acTyp === 'GL' &&
row.typ !== 'Disbursement' &&
row.typ !== 'Fund Transfer' &&
row.typ !== 'Enter Bank Transfer'
"
[disabled]="isCreditOnly(row)"
(click)="row.dbCr = 'Dr'"
>
DR
@@ -280,6 +276,7 @@
type="button"
class="ws-seg__btn ws-seg__btn--cr"
[class.ws-seg__btn--active-cr]="row.dbCr === 'Cr'"
[disabled]="row.typ === 'Withdrawal Slip'"
(click)="row.dbCr = 'Cr'"
>
CR
@@ -711,70 +708,81 @@
<circle cx="12" cy="8" r="4" />
<path d="M4 20c0-4 3.6-7 8-7s8 3 8 7" />
</svg>
Personal Information
{{ isGlAccount ? 'GL Account Information' : 'Personal Information' }}
</div>
<div class="te-panel__body">
<div class="te-field">
<span class="te-label">Branch</span><span class="te-value">{{ officeName }}</span>
@if (isGlAccount) {
<div class="te-panel__body">
@for (f of glInfo; track f.label) {
<div class="te-field">
<span class="te-label">{{ f.label }}</span
><span class="te-value te-value-wrap" [class.te-mono]="f.mono">{{ f.value }}</span>
</div>
}
</div>
<div class="te-field">
<span class="te-label">A/c No.</span
><span class="te-value te-mono">{{ accountDetails.accountNo }}</span>
</div>
<div class="te-field">
<span class="te-label">Name</span><span class="te-value te-value-name">{{ clientName }}</span>
</div>
<div class="te-field">
<span class="te-label">S/D/W/H/O</span><span class="te-value">{{ guardianName }}</span>
</div>
<div class="te-field">
<span class="te-label">Address</span><span class="te-value te-value-wrap">{{ clientAddress }}</span>
</div>
<div class="te-field">
<span class="te-label">Email</span><span class="te-value">{{ clientEmail }}</span>
</div>
<div class="te-field-row">
} @else {
<div class="te-panel__body">
<div class="te-field">
<span class="te-label">Last Txn</span><span class="te-value">{{ lastTransactionDate }}</span>
<span class="te-label">Branch</span><span class="te-value">{{ officeName }}</span>
</div>
<div class="te-field">
<span class="te-label">Type</span
><span class="te-value">{{ accountDetails.depositType?.value ?? selectedRow?.acTyp ?? '—' }}</span>
<span class="te-label">A/c No.</span
><span class="te-value te-mono">{{ accountDetails.accountNo }}</span>
</div>
<div class="te-field">
<span class="te-label">Name</span><span class="te-value te-value-name">{{ clientName }}</span>
</div>
<div class="te-field">
<span class="te-label">S/D/W/H/O</span><span class="te-value">{{ guardianName }}</span>
</div>
<div class="te-field">
<span class="te-label">Address</span><span class="te-value te-value-wrap">{{ clientAddress }}</span>
</div>
<div class="te-field">
<span class="te-label">Email</span><span class="te-value">{{ clientEmail }}</span>
</div>
<div class="te-field-row">
<div class="te-field">
<span class="te-label">Last Txn</span><span class="te-value">{{ lastTransactionDate }}</span>
</div>
<div class="te-field">
<span class="te-label">Type</span
><span class="te-value">{{ accountDetails.depositType?.value ?? selectedRow?.acTyp ?? '—' }}</span>
</div>
</div>
<div class="te-field-row">
<div class="te-field">
<span class="te-label">Opening Dt</span><span class="te-value">{{ openingDate }}</span>
</div>
<div class="te-field">
<span class="te-label">Status</span
><span class="te-value">{{ accountDetails.status?.value ?? '—' }}</span>
</div>
</div>
<div class="te-field">
<span class="te-label">CIF No.</span><span class="te-value te-mono">{{ cifNumber }}</span>
</div>
@if (isNonIndividual) {
<div class="te-field">
<span class="te-label">Constitution</span>
<span class="te-value">{{
nonPersonDetails?.constitution?.name ?? nonPersonDetails?.constitutionType?.name ?? '—'
}}</span>
</div>
<div class="te-field">
<span class="te-label">Incorp. No.</span>
<span class="te-value te-mono">{{
nonPersonDetails?.incorpNo ?? nonPersonDetails?.incorporationNo ?? '—'
}}</span>
</div>
<div class="te-field">
<span class="te-label">Main Business</span>
<span class="te-value">{{
nonPersonDetails?.mainBusinessLine?.name ?? nonPersonDetails?.mainBusinessLineType?.name ?? '—'
}}</span>
</div>
}
</div>
<div class="te-field-row">
<div class="te-field">
<span class="te-label">Opening Dt</span><span class="te-value">{{ openingDate }}</span>
</div>
<div class="te-field">
<span class="te-label">Status</span
><span class="te-value">{{ accountDetails.status?.value ?? '—' }}</span>
</div>
</div>
<div class="te-field">
<span class="te-label">CIF No.</span><span class="te-value te-mono">{{ cifNumber }}</span>
</div>
@if (isNonIndividual) {
<div class="te-field">
<span class="te-label">Constitution</span>
<span class="te-value">{{
nonPersonDetails?.constitution?.name ?? nonPersonDetails?.constitutionType?.name ?? '—'
}}</span>
</div>
<div class="te-field">
<span class="te-label">Incorp. No.</span>
<span class="te-value te-mono">{{
nonPersonDetails?.incorpNo ?? nonPersonDetails?.incorporationNo ?? '—'
}}</span>
</div>
<div class="te-field">
<span class="te-label">Main Business</span>
<span class="te-value">{{
nonPersonDetails?.mainBusinessLine?.name ?? nonPersonDetails?.mainBusinessLineType?.name ?? '—'
}}</span>
</div>
}
</div>
}
</div>
<!-- Joint Holders + Nominees -->
@@ -929,76 +937,78 @@
<!-- Other Info + KYC + Account Balances -->
<div class="te-panel">
<div class="te-panel__header">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="4" width="18" height="18" rx="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
Other Information
</div>
<div class="te-panel__body">
<div class="te-field">
<span class="te-label">Phone No.</span><span class="te-value">{{ clientPhone }}</span>
@if (!isGlAccount) {
<div class="te-panel__header">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="4" width="18" height="18" rx="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
Other Information
</div>
<div class="te-field">
<span class="te-label">Client ID</span
><span class="te-value te-mono">{{ accountDetails.clientId }}</span>
</div>
<div class="te-field">
<span class="te-label">PAN</span><span class="te-value te-mono">{{ panNumber }}</span>
</div>
<div class="te-field">
<span class="te-label">KYC Status</span>
<span class="te-value" [style.color]="kycDetails?.kycStatus === 300 ? '#059669' : '#f59e0b'">{{
kycStatus
}}</span>
</div>
<div class="te-field">
<span class="te-label">CKYC No.</span><span class="te-value te-mono">{{ ckycNumber }}</span>
</div>
<div class="te-field">
<span class="te-label">Aadhaar</span><span class="te-value te-mono">{{ aadhaarNumber }}</span>
</div>
<div class="te-field">
<span class="te-label">Product</span>
<span class="te-value">{{
accountDetails.savingsProductName ||
accountDetails.depositProductName ||
accountDetails.productName ||
'—'
}}</span>
</div>
@if (selectedRow?.acTyp === 'SHR') {
<div class="te-panel__body">
<div class="te-field">
<span class="te-label">No. of Shares Held</span
><span class="te-value te-mono">{{ accountDetails.summary?.totalApprovedShares ?? 0 }}</span>
<span class="te-label">Phone No.</span><span class="te-value">{{ clientPhone }}</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
>
<span class="te-label">Client ID</span
><span class="te-value te-mono">{{ accountDetails.clientId }}</span>
</div>
<div class="te-field">
<span class="te-label">Resolution No.</span
><span class="te-value te-mono">{{ accountDetails.resolutionNumber || '—' }}</span>
<span class="te-label">PAN</span><span class="te-value te-mono">{{ panNumber }}</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>
<span class="te-label">KYC Status</span>
<span class="te-value" [style.color]="kycDetails?.kycStatus === 300 ? '#059669' : '#f59e0b'">{{
kycStatus
}}</span>
</div>
@if (accountDetails.linkedLoanAccountNo) {
<div class="te-field">
<span class="te-label">CKYC No.</span><span class="te-value te-mono">{{ ckycNumber }}</span>
</div>
<div class="te-field">
<span class="te-label">Aadhaar</span><span class="te-value te-mono">{{ aadhaarNumber }}</span>
</div>
<div class="te-field">
<span class="te-label">Product</span>
<span class="te-value">{{
accountDetails.savingsProductName ||
accountDetails.depositProductName ||
accountDetails.productName ||
'—'
}}</span>
</div>
@if (selectedRow?.acTyp === 'SHR') {
<div class="te-field">
<span class="te-label">Linked Loan A/C No.</span
><span class="te-value te-mono">{{ accountDetails.linkedLoanAccountNo }}</span>
<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>
}
<div class="te-panel__header te-panel__header--mt">
<div class="te-panel__header" [class.te-panel__header--mt]="!isGlAccount">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="1" x2="12" y2="23" />
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
@@ -1567,6 +1577,8 @@
<select
class="ws-f__ctl"
[(ngModel)]="row.typ"
[disabled]="isTypLocked(row)"
[title]="isTypLocked(row) ? 'Token withdrawal - TYPE is fixed to Payment' : ''"
(click)="$event.stopPropagation()"
(change)="onTypChange(row)"
>
@@ -1577,11 +1589,16 @@
</div>
<div class="ws-f ws-f--branch">
<label class="ws-f__lbl">BRANCH</label>
<select class="ws-f__ctl" [(ngModel)]="row.br" (click)="$event.stopPropagation()">
<option [ngValue]="1">001 · Main</option>
<option [ngValue]="2">002 · M.G. Road</option>
<option [ngValue]="14">014 · Station Rd</option>
<option [ngValue]="27">027 · Industrial</option>
<select
class="ws-f__ctl"
[(ngModel)]="row.br"
[disabled]="branches.length <= 1"
[title]="branches.length <= 1 ? 'Your branch' : 'Branch'"
(click)="$event.stopPropagation()"
>
@for (b of branches; track b.id) {
<option [ngValue]="b.id">{{ b.label }}</option>
}
</select>
</div>
@if (row.typ === 'DD') {
@@ -1627,13 +1644,7 @@
class="ws-f__ctl"
[(ngModel)]="row.acTyp"
(click)="$event.stopPropagation()"
(change)="
row.acTyp === 'GL' &&
row.typ !== 'Disbursement' &&
row.typ !== 'Fund Transfer' &&
row.typ !== 'Enter Bank Transfer' &&
(row.dbCr = 'Cr')
"
(change)="isCreditOnly(row) && (row.dbCr = 'Cr')"
>
@for (a of acTypOptionsFor(row); track a) {
<option [value]="a">{{ a }}</option>
@@ -1704,12 +1715,7 @@
type="button"
class="ws-seg__btn ws-seg__btn--dr"
[class.ws-seg__btn--active-dr]="row.dbCr === 'Dr'"
[disabled]="
row.acTyp === 'GL' &&
row.typ !== 'Disbursement' &&
row.typ !== 'Fund Transfer' &&
row.typ !== 'Enter Bank Transfer'
"
[disabled]="isCreditOnly(row)"
(click)="$event.stopPropagation(); row.dbCr = 'Dr'"
>
DR
@@ -1718,6 +1724,7 @@
type="button"
class="ws-seg__btn ws-seg__btn--cr"
[class.ws-seg__btn--active-cr]="row.dbCr === 'Cr'"
[disabled]="row.typ === 'Withdrawal Slip'"
(click)="$event.stopPropagation(); row.dbCr = 'Cr'"
>
CR
@@ -2043,7 +2050,13 @@
<button class="ws-btn ws-btn--ghost" type="button" (click)="onSearch()">Search</button>
<button class="ws-btn ws-btn--mode" type="button">Change Tr.Mode</button>
@if (isClerk) {
<button class="ws-btn ws-btn--add" type="button" (click)="addRow()" title="Add another transaction row">
<button
class="ws-btn ws-btn--add"
type="button"
[disabled]="selectedRow?.typ === 'Withdrawal Slip'"
(click)="addRow()"
title="Add another transaction row"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
@@ -11,7 +11,7 @@ import { CommonModule } from '@angular/common';
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
import { FormsModule } from '@angular/forms';
import { HttpClient, HttpParams } from '@angular/common/http';
import { catchError, debounceTime, distinctUntilChanged, map } from 'rxjs/operators';
import { catchError, debounceTime, distinctUntilChanged, map, timeout } from 'rxjs/operators';
import { forkJoin, of, Subject } from 'rxjs';
import { MatDialog, MatDialogModule, MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
@@ -33,6 +33,7 @@ import {
import { SponsorDDService } from '../../sponsor-dd/sponsor-dd.service';
import { OrganizationService } from '../../organization/organization.service';
import { CashierIssueDdDialogComponent } from './cashier-issue-dd-dialog.component';
import { businessToday, businessTodayIso } from 'app/core/utils/business-today';
interface TxnRow {
typ: string;
@@ -621,7 +622,7 @@ export class AccountStatementDialogComponent implements OnInit {
@ViewChild('statementContent') statementContent!: ElementRef;
today = new Date().toISOString().slice(0, 10);
today = businessTodayIso();
fromDate = '';
toDate = this.today;
@@ -1657,6 +1658,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
// Full account + client data
accountDetails: any = null;
/** Parent (header) GL of the selected GL account, shown in the GL Account Information panel. */
glParent: { glCode?: string; name?: string } | null = null;
clientDetails: any = null;
kycDetails: any = null;
nominees: any[] = [];
@@ -1684,10 +1687,12 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
private searchToken = 0;
private lastLoadedAcNo = '';
private acNoInput$ = new Subject<{ row: TxnRow; index: number; value: string }>();
/** How many characters of the A/C NO. start the automatic account search. */
private static readonly AC_NO_AUTO_SEARCH_MIN_LENGTH = 6;
cashVaultGlId: number | null = null;
/** The Cash Vault GL account's own code and name — the denomination breakdown for a manual GL
* entry has to be filed against THIS account, not whichever account the teller typed in, since
* that account is never the one physical notes actually moved through (see submitGlJournalEntry).
* that account is never the one physical notes actually moved through.
* Both resolved dynamically off whatever this tenant's own financial-activity mapping names as
* its cash/vault account (see loadCashVaultGl) — never a literal account name or code. */
cashVaultGlCode: string | null = null;
@@ -1870,8 +1875,9 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
saving = false;
ngOnInit(): void {
const d = new Date();
const d = businessToday();
this.today = `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}/${d.getFullYear()}`;
this.loadBranches();
this.addRow();
this.loadDenominations();
this.loadOpeningPosition();
@@ -1902,7 +1908,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
distinctUntilChanged((a, b) => a.value === b.value && a.row === b.row)
)
.subscribe(({ row, index, value }) => {
if (value.trim().length >= 4) {
// Searches on its own once six characters are typed, for clerk and cashier alike.
if (value.trim().length >= TransactionEntryComponent.AC_NO_AUTO_SEARCH_MIN_LENGTH) {
this.selectedRow = row;
this.loadAccountDetails(value.trim(), row.acTyp);
}
@@ -2103,7 +2110,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}
private loadOpeningPosition(): void {
const d = new Date();
const d = businessToday();
const asOfDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.cashierService
.getCashierPositionForMe(asOfDate)
@@ -2303,7 +2310,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
// anyway, just without the extra click to get there.
const row: TxnRow = {
typ: this.isClerk ? 'Withdrawal Slip' : 'Receipt',
br: 1,
br: this.defaultBranchId,
trTp: '',
acTyp: 'SD',
acNo: '',
@@ -2322,8 +2329,54 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
this.rows.push(row);
}
/**
* A GL row is credit-only (money received into the account) except on the transfer types, which pick
* either side, and on a Withdrawal Slip, which is always a debit: the GL is debited and the cash
* vault credited when cash is paid out against it (a clerk's request, approved, then paid by the cashier).
*/
isGlCreditOnly(row: TxnRow): boolean {
return (
row.acTyp === 'GL' &&
row.typ !== 'Disbursement' &&
row.typ !== 'Fund Transfer' &&
row.typ !== 'Enter Bank Transfer' &&
row.typ !== 'Withdrawal Slip'
);
}
/**
* A Fixed Deposit row is credit-only on both the clerk and the cashier screens: an FD is funded once, with
* exactly its principal, and money leaves it only through its maturity or premature closure. The one
* exception is the clerk's own "Close this account" flow, which raises a closure request.
*/
isFdCreditOnly(row: TxnRow): boolean {
return row.acTyp === 'FD' && !this.pendingClosure;
}
/** DR is not offered for this row (see isGlCreditOnly and isFdCreditOnly). */
isCreditOnly(row: TxnRow): boolean {
return this.isGlCreditOnly(row) || this.isFdCreditOnly(row);
}
/** Called when the TYPE dropdown changes — sets sensible defaults for TXN TP and DR/CR */
/**
* A cashier row that was filled from an approved token (withdrawal / account-closure) is cash
* leaving the branch to the member, so its TYPE is fixed to Payment and cannot be changed.
* Never true for the clerk screen or for rows keyed in by hand.
*/
isTypLocked(row: TxnRow): boolean {
return (
!this.isClerk &&
!!row.tokNo?.trim() &&
!!(this.activeWithdrawalRequest || this.activeDividendClaimRequest || this.activeClosureRequest)
);
}
onTypChange(row: TxnRow): void {
if (this.isTypLocked(row)) {
row.typ = 'Payment';
return;
}
switch (row.typ) {
case 'Withdrawal Slip':
row.dbCr = 'Dr';
@@ -2439,7 +2492,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
* such ambiguity — the account itself says what it's for.
*/
showsEntryType(row: TxnRow): boolean {
return row.acTyp === 'GL' && row.dbCr === 'Cr';
return row.acTyp === 'GL';
}
/**
@@ -2617,14 +2670,47 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return !!this.selectedRow && Math.abs(this.denominationTotal - this.ddTargetTotal) < 0.005;
}
/**
* The branches an entry can be posted against: the real offices of this installation. Read from the offices
* themselves rather than from a fixed list, so a branch that is added or renamed shows up here without a code change.
*/
branches: { id: number; label: string }[] = [];
private loadBranches(): void {
const own = this.authService.getCredentials();
const ownBranch = own?.officeId
? [{ id: own.officeId, label: this.branchLabel(own.officeId, own.officeName) }]
: [];
// The office list is only asked for by a login allowed to read it. A cashier or clerk is not, and asking anyway
// makes the server refuse and the screen raise an "Unauthorized Request" popup; such a login works in its own
// office, which is the one branch it is offered.
const permissions = own?.permissions ?? [];
const mayReadOffices = permissions.includes('ALL_FUNCTIONS') || permissions.includes('READ_OFFICE');
if (!mayReadOffices) {
this.branches = ownBranch;
return;
}
this.http
.get('/offices')
.pipe(catchError(() => of([])))
.subscribe((list: any) => {
const offices: any[] = Array.isArray(list) ? list : [];
this.branches =
offices.length > 0 ? offices.map((o) => ({ id: o.id, label: this.branchLabel(o.id, o.name) })) : ownBranch;
});
}
private branchLabel(id: number, name: string | null | undefined): string {
return `${String(id).padStart(3, '0')} · ${name ?? 'Branch ' + id}`;
}
/** The office the signed-in user works in, which a new entry is posted against unless they pick another. */
get defaultBranchId(): number {
return this.authService.getCredentials()?.officeId ?? 1;
}
branchNameFor(branchId: number): string {
const map: Record<number, string> = {
1: '001 · Main',
2: '002 · M.G. Road',
14: '014 · Station Rd',
27: '027 · Industrial'
};
return map[branchId] || `Branch ${branchId}`;
return this.branches.find((b) => b.id === branchId)?.label ?? `Branch ${branchId}`;
}
/**
@@ -2837,7 +2923,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
fundingAccountId: number,
fundingClientId: number | null
): void {
const d = new Date();
const d = businessToday();
const issuedDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
const isCurrent = row.acTyp === 'CA' || row.acTyp === 'CB';
const isGl = row.acTyp === 'GL';
@@ -2969,7 +3055,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
const d = new Date();
const d = businessToday();
const issuedDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
const isCurrent = row.acTyp === 'CA' || row.acTyp === 'CB';
@@ -3068,7 +3154,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
const d = new Date();
const d = businessToday();
const issuedDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
const payload = {
@@ -3391,7 +3477,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
const today = new Date();
const today = businessToday();
const requestedDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
// Full snapshot of the CR (settlement) row exactly as the clerk filled it in — saved verbatim
@@ -3425,6 +3511,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
meetingDate: shareRow.meetingDate,
description: shareRow.description || null,
settlementPaymentTypeId: savingsRow.paymentTypeId,
settlementMode: 'SAVINGS',
settlementRowSnapshot
};
@@ -3455,11 +3542,60 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
? 'Pending Manager approval — redemption, settlement and account closure all post together on approval.'
: 'Pending Manager approval — redemption and settlement both post together on approval.'
];
this.showConfirmAlert('info', 'Confirm Share Withdrawal Request', confirmLines, 'Submit for Approval', () =>
this.showShareWithdrawalConfirm(payload, confirmLines, () =>
this.doSubmitShareRedemptionTransfer(payload, shareRow, shares, linkedAcNo)
);
}
/**
* Shows the confirm dialog with the same checklist the Manager will see when approving this
* request — fetched live from the backend (`previewValidate`) rather than re-implemented here,
* so it can never show the clerk something different from what the approver actually sees. A
* failing check is shown, not blocked: approve() enforces its own hard rules separately (e.g.
* Share Advance Collection applied), this list is advisory so problems surface to whoever can
* still fix them, before a manager's time is spent on a request that was always going to fail.
*/
private showShareWithdrawalConfirm(
payload: Record<string, unknown>,
baseLines: string[],
onConfirm: () => void
): void {
this.clerkShareWithdrawalService
.previewValidate(payload)
.pipe(
// A request that never resolves (slow network, server busy) must not leave the Submit
// button looking dead — the dialog still has to open, just without the checklist.
timeout(5000),
catchError(() => of({ __previewFailed: true }))
)
.subscribe((res: any) => {
const checks: { label: string; pass: boolean; detail: string }[] = res?.checks ?? [];
// A failed call (most likely the backend not yet carrying the /preview-validate endpoint)
// says so explicitly rather than just omitting the section — a silently empty checklist
// looks identical to "everything passed, nothing to show", which is the one reading this
// must never give.
const checkLines = res?.__previewFailed ? [
'',
'⚠ Validation checklist unavailable right now — the request can still be submitted.'
] : checks.length ? [
'',
'Validation Checks (same as Manager approval):',
...checks.map((c) => `${c.pass ? '✓' : '⚠'} ${c.label}: ${c.detail}`)
] : [];
// baseLines ends with a blank line followed by the "Pending Manager approval…" footer
// sentence, which must stay last so the modal renders it as a footnote rather than a
// key/value row — so the checklist is spliced in just before that pair, not appended after.
const footerStart =
baseLines.length >= 2 && baseLines[baseLines.length - 2] === '' ? baseLines.length - 2 : baseLines.length;
const lines = [
...baseLines.slice(0, footerStart),
...checkLines,
...baseLines.slice(footerStart)
];
this.showConfirmAlert('info', 'Confirm Share Withdrawal Request', lines, 'Submit for Approval', onConfirm);
});
}
/**
* Share redemption settled to a GL account the clerk picks on the CR row, instead of the
* member's linked savings account — mirrors submitShareRedemptionTransfer() above, but the
@@ -3512,7 +3648,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
const today = new Date();
const today = businessToday();
const requestedDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
// Full snapshot of the CR (settlement) row exactly as the clerk filled it in — saved verbatim
@@ -3571,7 +3707,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
? 'Pending Manager approval — redemption, settlement and account closure all post together on approval.'
: 'Pending Manager approval — redemption and settlement both post together on approval.'
];
this.showConfirmAlert('info', 'Confirm Share Withdrawal Request', confirmLines, 'Submit for Approval', () =>
this.showShareWithdrawalConfirm(payload, confirmLines, () =>
this.doSubmitShareGlSettlementTransfer(payload, shareRow, shares, glRow.acNo)
);
}
@@ -3867,7 +4003,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
SAVINGS: 'SD',
CURRENT: 'CA',
FIXED_DEPOSIT: 'FD',
RECURRING_DEPOSIT: 'RD'
RECURRING_DEPOSIT: 'RD',
GL: 'GL'
};
openCustomerSearch(): void {
@@ -3923,8 +4060,11 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
.pipe(catchError(() => of(null)))
.subscribe((res: any) => {
if (token !== this.searchToken) return; // stale result — newer search started
const allItems: any[] = res?.pageItems ?? (Array.isArray(res) ? res : []);
const found: any[] = res?.pageItems ?? (Array.isArray(res) ? res : []);
const f = cfg.matchField;
// SD, FD and RD share one list that returns any kind of deposit account: keep only the kind chosen in A/C TP.
const allItems =
cfg.depositTypeId != null ? found.filter((x: any) => x?.depositType?.id === cfg.depositTypeId) : found;
// 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
const exactMatch = allItems.find((x: any) => String(x[f] ?? '').trim() === String(acNo).trim());
@@ -3954,7 +4094,18 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
this.loadingAccount = false;
this.accountError = 'Account not found for: ' + acNo;
// The number may exist as another kind of account (an FD typed under SD, say): say so, so the clerk can fix A/C TP.
const otherKind =
cfg.depositTypeId != null
? found.find(
(x: any) =>
x?.depositType?.id !== cfg.depositTypeId &&
(String(x[f] ?? '').trim() === String(acNo).trim() || String(x[f] ?? '').endsWith(acNo))
)
: null;
this.accountError = otherKind
? `${acTyp} account not found for: ${acNo} (this number is a ${otherKind.depositType?.value ?? 'different kind of'} account - change A/C TP)`
: `${acTyp} account not found for: ${acNo}`;
});
}
@@ -4018,6 +4169,10 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
* account type — used to keep Header GL accounts out of the results a clerk/cashier can pick
* for a transaction; a Header can never receive a posting, so it should never be offered. */
extraListQueryParams?: string;
/** SD, FD and RD are all served by the one /savingsaccounts list, which answers with a deposit
* account of any kind for a number. This is the depositType.id the chosen A/C type accepts
* (100 savings, 200 fixed, 300 recurring), so a number of another kind is "not found" here. */
depositTypeId?: number;
} {
const t = acTyp?.toUpperCase();
if (t === 'LA') {
@@ -4098,6 +4253,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return {
list: '/savingsaccounts',
detail: '/fixeddepositaccounts',
depositTypeId: 200,
nominee: 'FD',
search: 'fixeddeposit',
siAccountType: 2,
@@ -4115,6 +4271,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return {
list: '/savingsaccounts',
detail: '/recurringdepositaccounts',
depositTypeId: 300,
nominee: 'RD',
search: 'recurringdeposit',
siAccountType: 2,
@@ -4132,6 +4289,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return {
list: '/savingsaccounts',
detail: '/savingsaccounts',
depositTypeId: 100,
nominee: 'SB',
search: 'saving',
siAccountType: 2,
@@ -4173,7 +4331,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
): void {
const accountUrl = `${cfg.detail}/${accountId}${cfg.detailParams ?? '?associations=all'}`;
const todayStr = (() => {
const d = new Date();
const d = businessToday();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
})();
@@ -4192,6 +4350,17 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}
this.accountDetails = acc;
this.jointHolders = acc.jointHolders ?? [];
// GL only: look up the parent (header) GL's name. Never runs for any other account type.
this.glParent = null;
if (acTyp?.toUpperCase() === 'GL' && acc.parentId) {
this.http
.get<any>(`/glaccounts/${acc.parentId}`)
.pipe(catchError(() => of(null)))
.subscribe((p: any) => {
if (token !== this.searchToken) return;
this.glParent = p ? { glCode: p.glCode, name: p.name } : null;
});
}
// Cash Hold / Clearing Hold breakdown only exists for plain savings accounts (m_savings_account)
// — FD/RD/Current/Share/GL have no such row, so there's nothing to ask for.
if (acTyp?.toUpperCase() === 'SD' && acc.id) {
@@ -4549,7 +4718,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}
get todayIso(): string {
const d = new Date();
const d = businessToday();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
@@ -4762,6 +4931,40 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return this.accountDetails?.officeName ?? this.clientDetails?.officeName ?? '—';
}
/** True when the loaded account is a General Ledger account (/glaccounts), which has no client behind it. */
get isGlAccount(): boolean {
return !!this.accountDetails?.glCode && this.selectedRow?.acTyp === 'GL';
}
/** The GL account's own details, shown in place of the client panels. */
get glInfo(): { label: string; value: string; mono?: boolean }[] {
const gl = this.accountDetails;
if (!gl) return [];
const type = gl.type?.value ?? '—';
const nature = [
'ASSET',
'EXPENSE'
].includes(String(type).toUpperCase()) ? 'Debit' : 'Credit';
return [
{ label: 'GL Code', value: gl.glCode ?? '—', mono: true },
{ label: 'GL Name', value: gl.name ?? '—' },
{ label: 'Account Type', value: type },
{ label: 'Usage', value: gl.usage?.value ?? '—' },
{ label: 'Normal Balance', value: nature },
{
label: 'Parent GL',
value: this.glParent?.name
? `${this.glParent.glCode ? this.glParent.glCode.trim() + ' - ' : ''}${this.glParent.name.trim()}`
: gl.parentId != null
? `ID ${gl.parentId}`
: '—'
},
{ label: 'Manual Entries', value: gl.manualEntriesAllowed ? 'Allowed' : 'Not allowed' },
{ label: 'Status', value: gl.disabled ? 'Disabled' : 'Enabled' },
{ label: 'Description', value: gl.description || '—' }
];
}
get openingDate(): string {
const raw =
this.accountDetails?.timeline?.submittedOnDate ??
@@ -5071,7 +5274,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
this.sameDayChequeClearing = 0;
return;
}
const today = new Date();
const today = businessToday();
const sameDay = (d: any) => {
if (!d) return false;
const t = Array.isArray(d) && d.length >= 3 ? new Date(d[0], d[1] - 1, d[2]) : new Date(d);
@@ -5104,6 +5307,20 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}
onSave(): void {
// Money never leaves a Fixed Deposit from this screen, clerk or cashier: no withdrawal slip, payment,
// collection or transfer debits an FD. It is paid out only through its maturity or premature closure
// (the clerk's "Close this account" flow). The DR button is already disabled for FD rows (isFdCreditOnly);
// this also catches a row set to DR by its TYPE, such as a Withdrawal Slip.
const fdDebitRows = this.rows.filter((r) => this.isFdCreditOnly(r) && r.dbCr === 'Dr');
if (fdDebitRows.length > 0) {
this.showAlert('error', 'Fixed Deposit Cannot Be Debited', [
`FD account(s) ${fdDebitRows.map((r) => r.acNo).join(', ')} cannot be debited or withdrawn from.`,
'Money leaves a Fixed Deposit only through its maturity or premature closure.',
'An FD can only be credited — once, with exactly its principal.'
]);
return;
}
// Demand drafts commit here like everything else on the worksheet. Checked first because a DD
// row shares its shape with the ordinary entries below — without this it would fall through and
// post as a plain transaction, with the draft never raised and the charges silently dropped.
@@ -5181,7 +5398,9 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
// Bank transfer: only savings (SD) or current (CA/CB) accounts allowed
// Bank transfer: savings (SD) or current (CA/CB) accounts on either side, plus a Fixed Deposit (FD) on
// the CR side only -- funding it from the customer's own account. The server checks the funding rules
// (first and only funding, exactly the principal, the source account's owner and available balance).
const allowedTypes = new Set([
'SD',
'CA',
@@ -5189,13 +5408,14 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
]);
const invalidAccounts = [
...drRows,
...crRows
...crRows.filter((r) => r.acTyp !== 'FD')
].filter((r) => !allowedTypes.has(r.acTyp));
if (invalidAccounts.length > 0) {
this.showAlert('error', 'Invalid Account Type', [
`Account type(s) not allowed in Bank Transfer: ${invalidAccounts.map((r) => r.acTyp + ' (' + r.acNo + ')').join(', ')}`,
'Only Savings (SD) and Current (CA) accounts are permitted.',
'GL, FD, RD, SHR accounts are not allowed — use Disbursement/Collection instead.'
'Debit side: only Savings (SD) and Current (CA) accounts. Credit side: SD, CA or a Fixed Deposit (FD).',
'GL and SHR accounts are not allowed — use Disbursement/Collection instead.',
'RD installments are paid through a cashier Receipt or Auto Debit.'
]);
return;
}
@@ -5249,6 +5469,19 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
this.rows.length >= 2 &&
this.rows.every((r) => r.typ === 'Disbursement' || r.typ === 'Fund Transfer')
) {
// A Recurring Deposit is never paid this way: these requests post straight into the account, so the
// installment schedule never hears of the money -- installments stay unpaid, Auto Debit collects them
// again and the maturity amount comes out wrong. RD installments come in through the cashier's
// Receipt or the RD's own Auto Debit.
const rdRows = this.rows.filter((r) => r.acTyp === 'RD');
if (rdRows.length > 0) {
this.showAlert('error', 'Recurring Deposit Not Allowed', [
`RD account(s) ${rdRows.map((r) => r.acNo).join(', ')} cannot be used in a Disbursement, Collection or Fund Transfer.`,
'Pay RD installments through a cashier Receipt, or by Auto Debit from the linked savings account.'
]);
return;
}
const drRows = this.rows.filter((r) => r.dbCr === 'Dr');
const crRows = this.rows.filter((r) => r.dbCr === 'Cr');
@@ -5782,10 +6015,14 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
// GL withdrawal → direct journal entry (no maker-checker)
// GL deposit → falls through to maker-checker block below
if (row.acTyp === 'GL' && !isDeposit) {
this.submitGlJournalEntry(row, amount, isDeposit);
// A GL withdrawal slip is raised by the clerk and paid by the cashier against its token, exactly like a
// savings withdrawal (the clerk branch and the token branch below). Nothing posts from this screen directly:
// manual journal entries are switched off on the server.
if (row.acTyp === 'GL' && !isDeposit && !this.clerkMode && !this.activeWithdrawalRequest) {
this.showAlert('error', 'GL Withdrawal', [
'A GL withdrawal is raised by a clerk and paid by the cashier against its token.',
'Enter the token number of the approved request to pay it.'
]);
return;
}
@@ -5959,7 +6196,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
const today = new Date();
const today = businessToday();
const txnDate = `${String(today.getDate()).padStart(2, '0')}/${String(today.getMonth() + 1).padStart(2, '0')}/${today.getFullYear()}`;
const payload: any = {
@@ -6041,7 +6278,12 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
this.editRequest = req;
}
const acTyp = 'SD';
// Same bug, same fix as the token-driven withdrawal-slip lookup above (see its comment): the
// request carries its own real account_type (SAVINGS, CURRENT, FIXED_DEPOSIT or
// RECURRING_DEPOSIT), and hardcoding 'SD' here searched every non-savings account on the
// wrong endpoint, so reviewing a Current-account withdrawal always failed with "Account not
// found".
const acTyp = TransactionEntryComponent.SEARCH_ACCOUNT_TYPE_TO_AC_TYP[req.account_type || ''] || 'SD';
const row = this.rows[0];
row.acNo = req.account_no ?? '';
row.acTyp = acTyp;
@@ -6149,21 +6391,38 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
/** Build the withdrawal-request payload from the current row + loaded account. */
private buildWithdrawalPayload(row: TxnRow, amount: number): any {
const today = new Date();
const today = businessToday();
const txnDateIso = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
const selectedPt = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId);
const isGl = row.acTyp === 'GL';
// The Entry Type picked for a GL slip has no column of its own on the request, so it rides on the narration.
const entryTypeName = isGl ? this.entryTypeOptions.find((e) => e.id === row.entryTypeCvId)?.name : undefined;
const description = [
row.description,
entryTypeName ? `[Entry Type: ${entryTypeName}]` : ''
]
.filter(Boolean)
.join(' ');
return {
accountNo: this.accountDetails.accountNo,
accountNo: isGl ? (this.accountDetails.glCode ?? this.accountDetails.name) : this.accountDetails.accountNo,
accountType: this.creditEntryAccountType(row.acTyp),
savingsAccountId: this.accountDetails.id,
clientId: this.accountDetails.clientId ?? this.clientDetails?.id ?? null,
clientName: this.clientDetails?.displayName ?? this.accountDetails?.clientName ?? null,
savingsAccountId: isGl ? null : this.accountDetails.id,
// GL-specific fields: the GL being paid out of and the cash GL it is paid from (null for other types)
glAccountId: isGl ? this.accountDetails.id : null,
contraGlId: isGl ? this.cashVaultGlId : null,
officeId: isGl ? row.br || 1 : null,
currencyCode: isGl ? (this.accountDetails?.currency?.code ?? 'INR') : null,
clientId: isGl ? null : (this.accountDetails.clientId ?? this.clientDetails?.id ?? null),
clientName: isGl
? (this.accountDetails?.name ?? null)
: (this.clientDetails?.displayName ?? this.accountDetails?.clientName ?? null),
amount,
txnDate: txnDateIso,
paymentTypeId: row.paymentTypeId || null,
paymentTypeName: selectedPt?.name ?? 'Cash',
description: row.description || null,
description: description || null,
chequeNo: row.chequeNo || null,
bankName: row.bankName || null,
tokNo: row.tokNo?.trim() || null,
@@ -6254,7 +6513,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
/** Build the deposit-request payload from the current row + loaded account. */
private buildDepositPayload(row: TxnRow, amount: number): any {
const today = new Date();
const today = businessToday();
const txnDateIso = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
const selectedPt = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId);
// Send denominations only for cash payments — stale counts on a cheque
@@ -6355,106 +6614,6 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
this.addRow();
}
private submitGlJournalEntry(row: TxnRow, amount: number, isDeposit: boolean): void {
if (!this.cashVaultGlId) {
this.showAlert('error', 'Cash Vault GL Not Configured', [
'Financial Activity Account (CASH_AT_TELLER) is not configured.',
'Please configure it in System → Financial Activity Accounts.'
]);
this.saving = false;
return;
}
const today = new Date();
const txnDate = `${String(today.getDate()).padStart(2, '0')}/${String(today.getMonth() + 1).padStart(2, '0')}/${today.getFullYear()}`;
const txnDateIso = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
const selectedGlId = this.accountDetails.id;
const contraGlId = this.cashVaultGlId;
// isDeposit (CR button) = cashier receives cash → credit selected GL, debit contra (cash vault)
// isWithdrawal (DR button) = cashier pays out cash → debit selected GL, credit contra (cash vault)
const credits = isDeposit ? [{ glAccountId: selectedGlId, amount }] : [{ glAccountId: contraGlId, amount }];
const debits = isDeposit ? [{ glAccountId: contraGlId, amount }] : [{ glAccountId: selectedGlId, amount }];
const payload: any = {
officeId: row.br || 1,
transactionDate: txnDate,
dateFormat: 'dd/MM/yyyy',
locale: 'en',
currencyCode: this.accountDetails?.currency?.code ?? 'INR',
comments: row.description || null,
credits,
debits,
paymentTypeId: row.paymentTypeId
};
// Check if cash payment so we can record denomination after journal entry
const selectedPt = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId);
const isCashPayment = !!selectedPt?.isCashPayment;
this.saving = true;
this.http
.post('/journalentries', payload)
.pipe(
catchError((err: any) => {
const msg =
err?.error?.errors?.[0]?.defaultUserMessage ??
err?.error?.developerMessage ??
'Journal entry failed. Please try again.';
this.showAlert('error', 'Transaction Failed', [msg]);
this.saving = false;
return of(null);
})
)
.subscribe((res: any) => {
this.saving = false;
if (res) {
// Record denomination so cashier position stays accurate
// Same condition as savings: cash + denom entered + denom matches posted amount
if (isCashPayment && this.denominationTotal > 0 && this.denominationTotal === amount) {
const denomBreakdown = [
...this.notes,
...this.coins
]
.filter((d) => this.countFor(d) > 0)
.map((d) => ({
denominationId: d.id,
denominationValue: d.value,
kind: d.kind,
count: this.countFor(d),
subtotal: this.subtotalFor(d)
}));
// Filed against the Cash Vault GL, not the account the teller selected on screen: that
// selected account (e.g. Sundry Creditors) is the other leg of the journal entry, and
// never the one whose physical notes actually changed hands. Tagging the breakdown to it
// instead of the vault is why this cash never reached the cashier/vault position.
this.http
.post('/cashier/gl-denomination', {
txnId: res.transactionId ?? res.resourceId,
accountNo: this.cashVaultGlCode ?? row.acNo,
accountName: this.cashVaultGlName ?? 'Cash at Vault',
txnType: isDeposit ? 'DEPOSIT' : 'WITHDRAWAL',
txnDate: txnDateIso,
paymentTypeName: selectedPt?.name ?? 'Cash',
denominationBreakdown: denomBreakdown
})
.pipe(catchError(() => of(null)))
.subscribe();
}
const ref = res.transactionId ?? res.resourceId ?? 'N/A';
this.showAlert('success', 'GL Transaction Posted', [
`Journal entry of ₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2 })} posted successfully.`,
`Transaction ID: ${ref}`
]);
this.clearDenominations();
this.loadOpeningPosition();
this.loadAccountDetails(row.acNo.trim(), row.acTyp);
}
});
}
onSearch(): void {
if (this.selectedRow?.acNo.trim()) {
this.loadAccountDetails(this.selectedRow.acNo.trim(), this.selectedRow.acTyp);
@@ -16,6 +16,7 @@ import { MatInputModule } from '@angular/material/input';
import { of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
interface DenomRow {
denominationId: number;
@@ -67,7 +68,7 @@ export class VaultPositionComponent implements OnInit {
errorMsg = '';
ngOnInit(): void {
const d = new Date();
const d = businessToday();
this.asOfDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.service
@@ -0,0 +1,170 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="cv-tab-content vca-tab">
<!-- ── TITLE BAR ── -->
<div class="vca-action-bar">
<div class="vca-action-bar__left">
<mat-icon class="vca-action-bar__icon">undo</mat-icon>
<div>
<div class="vca-action-bar__title">Voucher Corrections</div>
<div class="vca-action-bar__sub">
Approve or reject a voucher unpass, and the correction entry that follows it. Only the correction approval
moves money.
</div>
</div>
</div>
<div class="vca-counts">
<span class="vca-count"
><strong>{{ unpassCount }}</strong> unpass</span
>
<span class="vca-count"
><strong>{{ correctionCount }}</strong> correction</span
>
</div>
</div>
<!-- ── FILTER BAR ── -->
<div class="vca-filter-bar">
<div class="vca-search-box">
<mat-icon>search</mat-icon>
<input type="text" placeholder="Search by voucher, account, customer or user" [(ngModel)]="searchTerm" />
@if (searchTerm) {
<button type="button" class="vca-search-box__clear" (click)="searchTerm = ''">
<mat-icon>close</mat-icon>
</button>
}
</div>
<span class="vca-fb-sep"></span>
<select class="vca-filter-select" [(ngModel)]="kindFilter">
@for (opt of kindOptions; track opt.value) {
<option [value]="opt.value">{{ opt.label }}</option>
}
</select>
<button type="button" class="vca-btn vca-btn--refresh" (click)="load()" [disabled]="loading">
<mat-icon>refresh</mat-icon>
</button>
</div>
<!-- ── TABLE ── -->
@if (loading && !loaded) {
<div class="vca-empty"><p class="vca-empty__title">Loading…</p></div>
} @else if (rows.length === 0) {
<div class="vca-empty">
<mat-icon class="vca-empty__icon">inbox</mat-icon>
<p class="vca-empty__title">No voucher correction waiting</p>
<p class="vca-empty__text">There is no unpass request or correction entry waiting for review.</p>
</div>
} @else {
<div class="vca-table-wrap">
<table class="vca-table">
<thead>
<tr>
<th>Voucher No</th>
<th>Request</th>
<th>Posted To (Wrong)</th>
<th>Correct Account</th>
<th class="num">Amount</th>
<th>Reason</th>
<th>Raised By</th>
<th>Raised At</th>
<th class="vca-th-action">Action</th>
</tr>
</thead>
<tbody>
@for (r of rows; track r.request.id) {
<tr class="vca-row">
<td class="vca-mono vca-voucher">{{ r.request.original_voucher_no }}</td>
<td>
<span class="vca-kind" [class]="'vca-kind vca-kind--' + r.kind">{{ kindLabel(r.kind) }}</span>
</td>
<td>
<div class="vca-mono">{{ r.request.wrong_account_no }}</div>
<div class="vca-sub">{{ r.request.wrong_client_name || '—' }}</div>
</td>
<td>
@if (r.kind === 'correction') {
<div class="vca-mono vca-strong">{{ r.request.correct_account_no }}</div>
<div class="vca-sub">{{ r.request.correct_client_name || '—' }}</div>
} @else {
<span class="vca-none">—</span>
}
</td>
<td class="num vca-amount">₹{{ r.request.amount | number: '1.2-2' }}</td>
<td class="vca-reason" [title]="r.request.reason || ''">{{ r.request.reason || '—' }}</td>
<td>{{ r.raisedBy || '—' }}</td>
<td class="vca-dt">{{ formatDateTime(r.raisedAt) }}</td>
<td class="vca-action-cell">
<button
type="button"
class="vca-btn vca-btn--reject"
[disabled]="busyId === r.request.id"
(click)="startReject(r)"
>
<mat-icon>close</mat-icon> Reject
</button>
<button
type="button"
class="vca-btn vca-btn--approve"
[disabled]="busyId === r.request.id"
(click)="approve(r)"
>
<mat-icon>check</mat-icon>
{{ r.kind === 'unpass' ? 'Approve unpass' : 'Approve correction' }}
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
<!-- ─── Popup: reject with a reason ───────────────────────────────────────── -->
<ng-template #rejectDialog>
@if (rejecting) {
<div class="vca-dlg">
<header class="vca-dlg__head">
<mat-icon class="vca-dlg__ico">block</mat-icon>
<div class="vca-dlg__titles">
<div class="vca-dlg__title">
Reject {{ rejecting.kind === 'unpass' ? 'unpass request' : 'correction entry' }}
</div>
<div class="vca-dlg__sub vca-mono">{{ rejecting.request.original_voucher_no }}</div>
</div>
<button type="button" class="vca-dlg__x" aria-label="Close" (click)="cancelReject()">
<mat-icon>close</mat-icon>
</button>
</header>
<div class="vca-dlg__body">
<label class="vca-label" for="vca-reason">Reason (required)</label>
<textarea
id="vca-reason"
rows="3"
placeholder="Why is this being rejected?"
[(ngModel)]="rejectReason"
></textarea>
</div>
<footer class="vca-dlg__foot">
<button type="button" class="vca-btn vca-btn--ghost" (click)="cancelReject()">Cancel</button>
<button
type="button"
class="vca-btn vca-btn--reject-solid"
[disabled]="!rejectReason.trim() || busyId === rejecting.request.id"
(click)="confirmReject()"
>
Confirm reject
</button>
</footer>
</div>
}
</ng-template>
@@ -0,0 +1,485 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
// Laid out like the other approval tabs (Withdrawal Requests): title bar, filter bar, one table.
:host {
display: block;
}
.vca-tab {
padding: 0;
}
.vca-action-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 10px 16px;
background: #fff;
border-bottom: 1px solid #e2e8f0;
&__left {
display: flex;
align-items: center;
gap: 12px;
}
&__icon {
font-size: 22px;
width: 22px;
height: 22px;
color: #0f4f3c;
}
&__title {
font-size: 13px;
font-weight: 700;
color: #0d1b2a;
}
&__sub {
max-width: 640px;
margin-top: 1px;
font-size: 11px;
color: #64748b;
}
}
.vca-counts {
display: flex;
gap: 8px;
}
.vca-count {
padding: 2px 10px;
border-radius: 999px;
background: #f1f5f9;
font-size: 11px;
color: #475569;
strong {
color: #0f4f3c;
}
}
.vca-filter-bar {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
padding: 6px 16px;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
}
.vca-fb-sep {
flex-shrink: 0;
width: 1px;
height: 22px;
margin: 0 4px;
background: #e2e8f0;
}
.vca-search-box {
display: inline-flex;
align-items: center;
gap: 6px;
height: 30px;
min-width: 240px;
padding: 0 10px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
mat-icon {
flex-shrink: 0;
font-size: 17px;
width: 17px;
height: 17px;
color: #94a3b8;
}
&:hover {
border-color: #cbd5e1;
}
&:focus-within {
border-color: #0f4f3c;
box-shadow: 0 0 0 3px rgb(15 79 60 / 8%);
}
input {
flex: 1;
border: none;
outline: none;
background: transparent;
font-family: inherit;
font-size: 12px;
color: #0d1b2a;
&::placeholder {
color: #94a3b8;
}
}
&__clear {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 20px;
height: 20px;
padding: 0;
border: none;
border-radius: 50%;
background: none;
color: #94a3b8;
cursor: pointer;
mat-icon {
font-size: 15px;
width: 15px;
height: 15px;
color: inherit;
}
&:hover {
color: #dc2626;
background: #fef2f2;
}
}
}
.vca-filter-select {
height: 30px;
min-width: 140px;
padding: 0 10px;
border: 1px solid #e2e8f0;
border-radius: 7px;
background: #fff;
font-family: inherit;
font-size: 12px;
color: #0d1b2a;
cursor: pointer;
&:focus {
outline: none;
border-color: #0f4f3c;
}
}
// ── Buttons ─────────────────────────────────────────────────────────────────
.vca-btn {
display: inline-flex;
align-items: center;
gap: 4px;
height: 26px;
padding: 0 10px;
border: 1px solid transparent;
border-radius: 7px;
font-family: inherit;
font-size: 11px;
font-weight: 600;
white-space: nowrap;
cursor: pointer;
mat-icon {
font-size: 14px;
width: 14px;
height: 14px;
}
&:disabled {
opacity: 0.45;
cursor: not-allowed;
}
&--refresh {
height: 30px;
width: 30px;
justify-content: center;
padding: 0;
margin-left: auto;
border-color: #e2e8f0;
background: #fff;
color: #475569;
}
&--approve {
background: #0f4f3c;
color: #fff;
&:hover:not(:disabled) {
background: #0a3d2e;
}
}
&--reject {
border-color: #fecaca;
background: #fff;
color: #dc2626;
&:hover:not(:disabled) {
background: #fef2f2;
}
}
&--reject-solid {
background: #dc2626;
color: #fff;
}
&--ghost {
border-color: #e2e8f0;
background: #fff;
color: #475569;
}
}
// ── Table ───────────────────────────────────────────────────────────────────
.vca-table-wrap {
overflow-x: auto;
background: #fff;
}
.vca-table {
width: 100%;
border-collapse: collapse;
font-size: 11px;
th {
padding: 7px 10px;
border-bottom: 1px solid #e2e8f0;
background: #f8fafc;
font-size: 9.5px;
font-weight: 800;
letter-spacing: 0.06em;
text-align: left;
text-transform: uppercase;
color: #94a3b8;
white-space: nowrap;
}
td {
padding: 5px 10px;
border-bottom: 1px solid #eef2f7;
vertical-align: middle;
color: #0d1b2a;
}
.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
.vca-th-action {
text-align: right;
}
}
.vca-row:hover td {
background: #f8fafc;
}
.vca-mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
letter-spacing: 0.02em;
}
.vca-voucher {
font-weight: 700;
color: #0f4f3c;
}
.vca-strong {
font-weight: 700;
}
.vca-sub {
margin-top: 1px;
font-size: 11px;
color: #64748b;
}
.vca-none {
color: #cbd5e1;
}
.vca-amount {
font-weight: 700;
}
.vca-reason {
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.vca-dt {
white-space: nowrap;
color: #475569;
}
.vca-kind {
display: inline-block;
padding: 2px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
&--unpass {
background: #fef3c7;
color: #92400e;
}
&--correction {
background: #dbeafe;
color: #1e40af;
}
}
.vca-action-cell {
display: flex;
justify-content: flex-end;
gap: 6px;
white-space: nowrap;
}
// ── Empty ───────────────────────────────────────────────────────────────────
.vca-empty {
padding: 28px 16px;
text-align: center;
color: #94a3b8;
&__icon {
display: block;
margin: 0 auto 6px;
font-size: 30px;
width: 30px;
height: 30px;
opacity: 0.6;
}
&__title {
margin: 0;
font-size: 12px;
font-weight: 700;
color: #64748b;
}
&__text {
margin: 3px 0 0;
font-size: 11px;
}
}
// ── Reject popup ────────────────────────────────────────────────────────────
.vca-dlg {
font-size: 12px;
&__head {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-bottom: 1px solid #e2e8f0;
background: #f8fafc;
}
&__ico {
flex: 0 0 32px;
height: 32px;
width: 32px;
border-radius: 9px;
background: #fef2f2;
color: #dc2626;
font-size: 18px;
line-height: 32px;
text-align: center;
}
&__titles {
flex: 1;
min-width: 0;
}
&__title {
font-size: 14px;
font-weight: 700;
color: #0f172a;
}
&__sub {
font-size: 11px;
color: #64748b;
}
&__x {
display: inline-flex;
align-items: center;
justify-content: center;
height: 28px;
width: 28px;
border: 0;
border-radius: 50%;
background: transparent;
color: #64748b;
cursor: pointer;
&:hover {
background: #e2e8f0;
}
mat-icon {
font-size: 18px;
height: 18px;
width: 18px;
}
}
&__body {
padding: 12px 14px;
textarea {
width: 100%;
padding: 7px 10px;
border: 1px solid #cbd5e1;
border-radius: 8px;
font: inherit;
font-size: 12px;
resize: vertical;
&:focus {
outline: 0;
border-color: #0f4f3c;
box-shadow: 0 0 0 3px rgb(15 79 60 / 8%);
}
}
}
&__foot {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 10px 14px;
border-top: 1px solid #e2e8f0;
background: #f8fafc;
}
}
.vca-label {
display: block;
margin-bottom: 4px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: #94a3b8;
}
@@ -0,0 +1,192 @@
/**
* 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, TemplateRef, ViewChild, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { VoucherCorrection, VoucherCorrectionService } from '../voucher-correction.service';
type Kind = 'unpass' | 'correction';
type KindFilter = 'ALL' | Kind;
/** One row of the approvals table: a request, and which of the two steps it is waiting at. */
interface ApprovalRow {
kind: Kind;
request: VoucherCorrection;
raisedBy: string | null;
raisedAt: string | null;
}
/**
* Manager side of the voucher correction, laid out like the other approval tabs: approves or rejects the unpass of a
* voucher, and later the correction entry the cashier made. Only the second approval moves money.
*/
@Component({
selector: 'mifosx-voucher-correction-approvals',
templateUrl: './voucher-correction-approvals.component.html',
styleUrls: ['./voucher-correction-approvals.component.scss'],
standalone: true,
imports: [
CommonModule,
FormsModule,
MatDialogModule,
MatIconModule,
MatSnackBarModule
]
})
export class VoucherCorrectionApprovalsComponent implements OnInit {
private service = inject(VoucherCorrectionService);
private snackBar = inject(MatSnackBar);
private dialog = inject(MatDialog);
@ViewChild('rejectDialog') rejectDialog!: TemplateRef<unknown>;
private dialogRef: MatDialogRef<unknown> | null = null;
all: ApprovalRow[] = [];
loading = false;
loaded = false;
busyId: number | null = null;
kindFilter: KindFilter = 'ALL';
searchTerm = '';
/** The row being rejected, and the reason typed for it. */
rejecting: ApprovalRow | null = null;
rejectReason = '';
readonly kindOptions: { value: KindFilter; label: string }[] = [
{ value: 'ALL', label: 'All requests' },
{ value: 'unpass', label: 'Unpass requests' },
{ value: 'correction', label: 'Correction entries' }
];
ngOnInit(): void {
this.load();
}
load(): void {
this.loading = true;
this.service
.list('UNPASS_PENDING,CORRECTION_PENDING')
.pipe(catchError(() => of([] as VoucherCorrection[])))
.subscribe((rows) => {
this.all = rows.map((r) =>
r.status === 'UNPASS_PENDING'
? { kind: 'unpass', request: r, raisedBy: r.unpass_by_name, raisedAt: r.unpass_at }
: { kind: 'correction', request: r, raisedBy: r.correction_by_name, raisedAt: r.correction_at }
);
this.loading = false;
this.loaded = true;
});
}
/** The table: the kind picked, narrowed by whatever is typed in the search box. */
get rows(): ApprovalRow[] {
const term = this.searchTerm.trim().toLowerCase();
return this.all.filter((r) => {
if (this.kindFilter !== 'ALL' && r.kind !== this.kindFilter) return false;
if (!term) return true;
const q = r.request;
return [
q.original_voucher_no,
q.wrong_account_no,
q.wrong_client_name,
q.correct_account_no,
q.correct_client_name,
r.raisedBy
].some((v) => (v ?? '').toLowerCase().includes(term));
});
}
get unpassCount(): number {
return this.all.filter((r) => r.kind === 'unpass').length;
}
get correctionCount(): number {
return this.all.filter((r) => r.kind === 'correction').length;
}
kindLabel(kind: Kind): string {
return kind === 'unpass' ? 'Unpass' : 'Correction';
}
formatDateTime(value: string | null): string {
if (!value) return '—';
const d = new Date(value);
return isNaN(d.getTime())
? value
: d.toLocaleString('en-IN', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' });
}
approve(row: ApprovalRow): void {
if (this.busyId) return;
const id = row.request.id;
this.busyId = id;
const call = row.kind === 'unpass' ? this.service.approveUnpass(id) : this.service.approveCorrection(id);
call.subscribe({
next: (res: any) => {
this.busyId = null;
this.snackBar.open(
row.kind === 'unpass'
? `Voucher ${row.request.original_voucher_no} unpassed — the cashier can now enter the correct account`
: `Corrected. New voucher ${res?.newVoucherNo ?? ''}`,
'Close',
{ duration: 6000 }
);
this.load();
},
error: (err) => {
this.busyId = null;
this.fail(err, 'Could not approve');
}
});
}
startReject(row: ApprovalRow): void {
this.rejecting = row;
this.rejectReason = '';
this.dialogRef = this.dialog.open(this.rejectDialog, { width: '440px', maxWidth: '95vw', autoFocus: false });
this.dialogRef.afterClosed().subscribe(() => (this.rejecting = null));
}
cancelReject(): void {
this.dialogRef?.close();
}
confirmReject(): void {
if (!this.rejecting || !this.rejectReason.trim() || this.busyId) return;
const row = this.rejecting;
const id = row.request.id;
this.busyId = id;
const reason = this.rejectReason.trim();
const call =
row.kind === 'unpass' ? this.service.rejectUnpass(id, reason) : this.service.rejectCorrection(id, reason);
call.subscribe({
next: () => {
this.busyId = null;
this.dialogRef?.close();
this.snackBar.open('Rejected', 'Close', { duration: 4000 });
this.load();
},
error: (err) => {
this.busyId = null;
this.fail(err, 'Could not reject');
}
});
}
private fail(err: any, fallback: string): void {
const msg = err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.defaultUserMessage ?? fallback;
this.snackBar.open(msg, 'Close', { duration: 8000 });
}
}
@@ -0,0 +1,120 @@
/**
* 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';
/** One row of a voucher correction, as the server keeps it (snake_case columns). */
export interface VoucherCorrection {
id: number;
original_voucher_no: string;
original_txn_id: number;
wrong_savings_account_id: number;
wrong_account_no: string;
wrong_client_name: string | null;
amount: number;
txn_date: string | null;
status: 'UNPASS_PENDING' | 'UNPASSED' | 'UNPASS_REJECTED' | 'CORRECTION_PENDING' | 'COMPLETED' | 'CLOSED';
reason: string | null;
rejection_reason: string | null;
unpass_by_name: string | null;
unpass_at: string | null;
unpass_approved_by_name: string | null;
correct_account_no: string | null;
correct_client_name: string | null;
correction_by_name: string | null;
correction_at: string | null;
correction_approved_by_name: string | null;
new_voucher_no: string | null;
}
/** A cash deposit voucher of today that can still be unpassed. */
export interface EligibleVoucher {
voucherNo: string;
amount: number;
createdAt: string;
cashier: string;
txnId: number;
savingsAccountId: number;
accountNo: string;
clientName: string | null;
paymentType: string;
}
/** What the server knows about a savings account typed in as the correct one. */
export interface AccountLookup {
id: number;
accountNo: string;
clientId: number | null;
clientName: string | null;
balance: number;
currencyCode: string;
canReceive: boolean;
}
/** HTTP service for the voucher correction workflow: unpass, approve, re-enter, approve. */
@Injectable({ providedIn: 'root' })
export class VoucherCorrectionService {
private http = inject(HttpClient);
private readonly base = '/vouchers/corrections';
eligible(search?: string): Observable<EligibleVoucher[]> {
let params = new HttpParams();
if (search) params = params.set('search', search);
return this.http.get<EligibleVoucher[]>(`${this.base}/eligible`, { params });
}
unpass(voucherNo: string, reason: string): Observable<any> {
return this.http.post(`${this.base}/unpass`, { voucherNo, reason });
}
list(status?: string, mine = false): Observable<VoucherCorrection[]> {
let params = new HttpParams().set('mine', String(mine));
if (status) params = params.set('status', status);
return this.http.get<VoucherCorrection[]>(this.base, { params });
}
get(id: number): Observable<VoucherCorrection> {
return this.http.get<VoucherCorrection>(`${this.base}/${id}`);
}
accountLookup(accountNo: string): Observable<AccountLookup> {
return this.http.get<AccountLookup>(`${this.base}/account-lookup`, { params: { accountNo } });
}
submitCorrection(id: number, correctAccountNo: string): Observable<any> {
return this.http.post(`${this.base}/${id}/correction`, { correctAccountNo });
}
/** The cashier gives up the correction, e.g. when the wrong account no longer holds the money. */
close(id: number, reason: string): Observable<any> {
return this.http.post(`${this.base}/${id}/close`, { reason });
}
approveUnpass(id: number): Observable<any> {
return this.http.post(`${this.base}/${id}/unpass/approve`, {});
}
rejectUnpass(id: number, reason: string): Observable<any> {
return this.http.post(`${this.base}/${id}/unpass/reject`, { reason });
}
approveCorrection(id: number): Observable<any> {
return this.http.post(`${this.base}/${id}/correction/approve`, {});
}
rejectCorrection(id: number, reason: string): Observable<any> {
return this.http.post(`${this.base}/${id}/correction/reject`, { reason });
}
/** Ids of the transactions of this account that a completed correction removed. */
voidedTransactions(savingsAccountId: number): Observable<number[]> {
return this.http.get<number[]>(`${this.base}/voided-transactions`, { params: { savingsAccountId } });
}
}
@@ -0,0 +1,446 @@
<!--
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">CASHIER · VOUCHER CORRECTION</div>
<h1 class="pg-title">Voucher Correction</h1>
<p class="pg-sub">
Move a cash deposit that was posted to the wrong account. Nothing changes on any account until the final
approval.
</p>
</div>
</header>
<!-- The four steps, so it is always clear where a voucher is. -->
<ol class="vc-steps">
<li class="vc-step" [class.vc-step--on]="tab === 'today'">
<span class="vc-step__no">1</span>
<span class="vc-step__txt"><strong>Unpass</strong> the voucher</span>
</li>
<li class="vc-step">
<span class="vc-step__no">2</span>
<span class="vc-step__txt"><strong>Manager</strong> approves</span>
</li>
<li class="vc-step" [class.vc-step--on]="tab === 'unpassed'">
<span class="vc-step__no">3</span>
<span class="vc-step__txt">Enter the <strong>correct account</strong></span>
</li>
<li class="vc-step">
<span class="vc-step__no">4</span>
<span class="vc-step__txt"><strong>Manager</strong> approves, deposit moves</span>
</li>
</ol>
<nav class="vc-tabs">
@if (mayUnpass) {
<button type="button" class="vc-tab" [class.vc-tab--on]="tab === 'today'" (click)="setTab('today')">
<mat-icon>receipt_long</mat-icon> Today's Vouchers
</button>
}
@if (mayCorrect) {
<button type="button" class="vc-tab" [class.vc-tab--on]="tab === 'unpassed'" (click)="setTab('unpassed')">
<mat-icon>edit_note</mat-icon> Unpassed — Correct Entry
@if (unpassed.length) {
<span class="vc-count">{{ unpassed.length }}</span>
}
</button>
}
<button type="button" class="vc-tab" [class.vc-tab--on]="tab === 'mine'" (click)="setTab('mine')">
<mat-icon>history</mat-icon> My Requests
</button>
</nav>
<!-- ─── Today's vouchers ─────────────────────────────────────────────── -->
@if (tab === 'today') {
<div class="vc-toolbar">
<div class="vc-search">
<mat-icon>search</mat-icon>
<input
type="text"
placeholder="Search by voucher, account or customer name"
[(ngModel)]="search"
(keyup.enter)="loadVouchers()"
/>
</div>
<button type="button" class="pg-btn pg-btn--primary" (click)="loadVouchers()">
<mat-icon>search</mat-icon> Search
</button>
<button type="button" class="pg-btn pg-btn--ghost" (click)="search = ''; loadVouchers()">
<mat-icon>refresh</mat-icon> Refresh
</button>
</div>
<div class="pg-card">
<table class="pg-table vc-table">
<thead>
<tr>
<th>Voucher</th>
<th>Account</th>
<th>Customer</th>
<th>Mode</th>
<th>Cashier</th>
<th class="num">Amount</th>
<th class="vc-col-action"></th>
</tr>
</thead>
<tbody>
@for (v of vouchers; track v.voucherNo) {
<tr [class.vc-row--on]="selectedVoucher?.voucherNo === v.voucherNo" (click)="pick(v)">
<td>
<span class="pg-chip">{{ v.voucherNo }}</span>
</td>
<td class="vc-mono">{{ v.accountNo }}</td>
<td>{{ v.clientName || '—' }}</td>
<td>{{ v.paymentType }}</td>
<td>{{ v.cashier }}</td>
<td class="num">₹{{ v.amount | number: '1.2-2' }}</td>
<td class="vc-col-action">
<button
type="button"
class="pg-btn pg-btn--ghost vc-btn-sm"
(click)="pick(v); $event.stopPropagation()"
>
<mat-icon>undo</mat-icon> Unpass
</button>
</td>
</tr>
}
@if (!vouchers.length) {
<tr>
<td colspan="7" class="vc-empty">
<mat-icon>{{ loadingVouchers ? 'hourglass_empty' : 'inbox' }}</mat-icon>
<div>{{ loadingVouchers ? 'Loading…' : 'No cash deposit voucher of today can be unpassed.' }}</div>
</td>
</tr>
}
</tbody>
</table>
</div>
}
<!-- ─── Unpassed: enter the correct account ──────────────────────────── -->
@if (tab === 'unpassed') {
<div class="pg-card">
<table class="pg-table vc-table">
<thead>
<tr>
<th>Voucher</th>
<th>Posted to</th>
<th>Customer</th>
<th>Reason</th>
<th class="num">Amount</th>
<th class="vc-col-action"></th>
</tr>
</thead>
<tbody>
@for (u of unpassed; track u.id) {
<tr [class.vc-row--on]="active?.id === u.id" (click)="open(u)">
<td>
<span class="pg-chip">{{ u.original_voucher_no }}</span>
</td>
<td class="vc-mono">{{ u.wrong_account_no }}</td>
<td>{{ u.wrong_client_name || '—' }}</td>
<td class="vc-clip">{{ u.reason }}</td>
<td class="num">₹{{ u.amount | number: '1.2-2' }}</td>
<td class="vc-col-action">
<button
type="button"
class="pg-btn pg-btn--primary vc-btn-sm"
(click)="open(u); $event.stopPropagation()"
>
<mat-icon>edit</mat-icon> Correct
</button>
</td>
</tr>
}
@if (!unpassed.length) {
<tr>
<td colspan="6" class="vc-empty">
<mat-icon>{{ loadingUnpassed ? 'hourglass_empty' : 'task_alt' }}</mat-icon>
<div>
{{ loadingUnpassed ? 'Loading…' : 'No unpassed voucher is waiting for its correct account.' }}
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
}
<!-- ─── My requests ──────────────────────────────────────────────────── -->
@if (tab === 'mine') {
<div class="pg-card">
<table class="pg-table vc-table">
<thead>
<tr>
<th>Voucher</th>
<th>Posted to</th>
<th>Corrected to</th>
<th class="num">Amount</th>
<th>Status</th>
<th>New voucher</th>
</tr>
</thead>
<tbody>
@for (m of mine; track m.id) {
<tr>
<td>
<span class="pg-chip">{{ m.original_voucher_no }}</span>
</td>
<td class="vc-mono">{{ m.wrong_account_no }}</td>
<td class="vc-mono">{{ m.correct_account_no || '—' }}</td>
<td class="num">₹{{ m.amount | number: '1.2-2' }}</td>
<td>
<span class="vc-status vc-status--{{ m.status }}">{{ statusLabel[m.status] }}</span>
@if (m.rejection_reason) {
<div class="vc-note">{{ m.rejection_reason }}</div>
}
</td>
<td>{{ m.new_voucher_no || '—' }}</td>
</tr>
}
@if (!mine.length) {
<tr>
<td colspan="6" class="vc-empty">
<mat-icon>{{ loadingMine ? 'hourglass_empty' : 'history' }}</mat-icon>
<div>{{ loadingMine ? 'Loading…' : 'You have no voucher corrections yet.' }}</div>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
<!-- ─── Popup: unpass a voucher ──────────────────────────────────────────── -->
<ng-template #unpassDialog>
@if (selectedVoucher) {
<div class="vc-dlg">
<header class="vc-dlg__head">
<mat-icon class="vc-dlg__ico">undo</mat-icon>
<div class="vc-dlg__titles">
<div class="vc-dlg__title">Unpass voucher</div>
<div class="vc-dlg__sub vc-mono">{{ selectedVoucher.voucherNo }}</div>
</div>
<button type="button" class="vc-dlg__x" aria-label="Close" (click)="closeUnpass()">
<mat-icon>close</mat-icon>
</button>
</header>
<div class="vc-dlg__body">
<div class="vc-dlg__amount">
<span>Deposit amount</span>
<strong>₹{{ selectedVoucher.amount | number: '1.2-2' }}</strong>
</div>
<div class="vc-grid">
<div class="vc-cell">
<div class="vc-label">Voucher</div>
<div class="vc-value vc-mono">{{ selectedVoucher.voucherNo }}</div>
</div>
<div class="vc-cell">
<div class="vc-label">Created</div>
<div class="vc-value">{{ selectedVoucher.createdAt | date: 'dd MMM yyyy, hh:mm a' }}</div>
</div>
<div class="vc-cell">
<div class="vc-label">Cashier</div>
<div class="vc-value">{{ selectedVoucher.cashier }}</div>
</div>
<div class="vc-cell">
<div class="vc-label">Mode</div>
<div class="vc-value">{{ selectedVoucher.paymentType }}</div>
</div>
<div class="vc-cell">
<div class="vc-label">Account</div>
<div class="vc-value vc-mono">{{ selectedVoucher.accountNo }}</div>
</div>
<div class="vc-cell">
<div class="vc-label">Customer</div>
<div class="vc-value">{{ selectedVoucher.clientName || '—' }}</div>
</div>
<div class="vc-cell">
<div class="vc-label">Account balance now</div>
<div class="vc-value">
@if (voucherAccount) {
₹{{ voucherAccount.balance | number: '1.2-2' }}
} @else {
—
}
</div>
</div>
<div class="vc-cell">
<div class="vc-label">Account status</div>
<div class="vc-value">
@if (voucherAccount) {
{{ voucherAccount.canReceive ? 'Active' : 'Not active' }} · {{ voucherAccount.currencyCode }}
} @else {
—
}
</div>
</div>
</div>
<label class="vc-label" for="vc-reason">Reason for unpassing</label>
<textarea
id="vc-reason"
class="vc-reason"
rows="2"
placeholder="Why is this voucher wrong? e.g. deposited in the wrong account (required)"
[(ngModel)]="unpassReason"
></textarea>
<div class="vc-info">
<mat-icon>info</mat-icon>
A manager approves the unpass first. Nothing changes on any account until the corrected entry is approved: the
deposit stays on {{ selectedVoucher.accountNo }} till then.
</div>
</div>
<footer class="vc-dlg__foot">
<button type="button" class="pg-btn pg-btn--ghost" (click)="closeUnpass()">Cancel</button>
<button type="button" class="pg-btn pg-btn--primary" [disabled]="!canUnpass" (click)="unpass()">
<mat-icon>send</mat-icon> Send for approval
</button>
</footer>
</div>
}
</ng-template>
<!-- ─── Popup: enter the correct account ─────────────────────────────────── -->
<ng-template #correctionDialog>
@if (active) {
<div class="vc-dlg">
<header class="vc-dlg__head">
<mat-icon class="vc-dlg__ico">edit_note</mat-icon>
<div class="vc-dlg__titles">
<div class="vc-dlg__title">Correction entry</div>
<div class="vc-dlg__sub vc-mono">{{ active.original_voucher_no }}</div>
</div>
<button type="button" class="vc-dlg__x" aria-label="Close" (click)="closeActive()">
<mat-icon>close</mat-icon>
</button>
</header>
<div class="vc-dlg__body">
<div class="vc-dlg__amount">
<span>Amount to move</span>
<strong>₹{{ active.amount | number: '1.2-2' }}</strong>
</div>
<!-- Everything but the account number is fixed. -->
<div class="vc-grid">
<div class="vc-cell">
<div class="vc-label">Transaction date</div>
<div class="vc-value">{{ active.txn_date || '—' }}</div>
</div>
<div class="vc-cell">
<div class="vc-label">Mode</div>
<div class="vc-value">Cash</div>
</div>
<div class="vc-cell">
<div class="vc-label">Unpassed by</div>
<div class="vc-value">{{ active.unpass_by_name || '—' }}</div>
</div>
<div class="vc-cell">
<div class="vc-label">Approved by</div>
<div class="vc-value">{{ active.unpass_approved_by_name || '—' }}</div>
</div>
<div class="vc-cell vc-cell--wide">
<div class="vc-label">Reason</div>
<div class="vc-value">{{ active.reason }}</div>
</div>
</div>
<div class="vc-compare">
<!-- The account the deposit went to -->
<div class="vc-acct vc-acct--wrong">
<div class="vc-acct__tag">Posted to (wrong)</div>
<div class="vc-acct__no vc-mono">{{ active.wrong_account_no }}</div>
<div class="vc-acct__name">{{ active.wrong_client_name || '—' }}</div>
<div class="vc-acct__bal">
Balance:
@if (wrongAccount) {
<strong>₹{{ wrongAccount.balance | number: '1.2-2' }}</strong>
@if (wrongHasBalance) {
<span class="vc-ok"><mat-icon>check_circle</mat-icon> covers the deposit</span>
} @else {
<span class="vc-bad"
><mat-icon>cancel</mat-icon> does not cover ₹{{ active.amount | number: '1.2-2' }}</span
>
}
} @else {
—
}
</div>
</div>
<mat-icon class="vc-arrow">arrow_forward</mat-icon>
<!-- The account it should have gone to -->
<div class="vc-acct" [class.vc-acct--ok]="correctOk" [class.vc-acct--wrong]="!!lookupError">
<div class="vc-acct__tag">Correct account</div>
<input
id="vc-correct"
class="vc-acct__input vc-mono"
type="text"
placeholder="Type the account number"
[ngModel]="correctAccountNo"
(ngModelChange)="onAccountInput($event)"
(blur)="lookup()"
/>
@if (lookingUp) {
<div class="vc-acct__bal">Looking up…</div>
}
@if (correctAccount) {
<div class="vc-acct__name">{{ correctAccount.clientName || '—' }}</div>
<div class="vc-acct__bal">
Balance: <strong>₹{{ correctAccount.balance | number: '1.2-2' }}</strong>
@if (correctOk) {
<span class="vc-ok"><mat-icon>check_circle</mat-icon> active, can receive</span>
}
</div>
}
@if (lookupError) {
<div class="vc-bad vc-acct__err"><mat-icon>error</mat-icon> {{ lookupError }}</div>
}
</div>
</div>
@if (wrongAccount && !wrongHasBalance) {
<div class="vc-warn">
<mat-icon>warning</mat-icon>
<div>
<strong>The customer has already used this money,</strong> so it cannot be taken back from
{{ active.wrong_account_no }}. Deposit ₹{{ active.amount | number: '1.2-2' }} into the correct account
from your own pocket as a fresh deposit (Transaction Entry), then press <strong>Close request</strong>.
</div>
</div>
}
</div>
<footer class="vc-dlg__foot">
<button
type="button"
class="pg-btn pg-btn--ghost vc-dlg__close-req"
[disabled]="sending"
(click)="closeRequest()"
>
<mat-icon>block</mat-icon> Close request
</button>
<button type="button" class="pg-btn pg-btn--ghost" (click)="closeActive()">Cancel</button>
<button type="button" class="pg-btn pg-btn--primary" [disabled]="!canSave" (click)="save()">
<mat-icon>check</mat-icon> Save &amp; send for approval
</button>
</footer>
</div>
}
</ng-template>
@@ -0,0 +1,599 @@
/**
* 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/.
*/
// Compact sizing: the page has to fit a 100% browser window without scrolling for the common case.
.pg {
padding: 14px 20px;
gap: 12px;
}
.pg-title {
font-size: 20px;
margin: 2px 0;
}
.pg-sub {
font-size: 12px;
}
.pg-btn {
height: 30px;
padding: 0 12px;
font-size: 12px;
mat-icon {
font-size: 16px;
height: 16px;
width: 16px;
}
&:disabled {
opacity: 0.45;
cursor: not-allowed;
}
}
.pg-table {
font-size: 12px;
th {
padding: 8px 12px;
font-size: 10px;
}
td {
padding: 7px 12px;
}
}
// ── Steps ───────────────────────────────────────────────────────────────────
.vc-steps {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
gap: 8px;
margin: 0;
padding: 0;
list-style: none;
}
.vc-step {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border: 1px solid #e2e8f0;
border-radius: 10px;
background: #fff;
font-size: 12px;
color: #64748b;
&__no {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 20px;
height: 20px;
border-radius: 50%;
background: #e2e8f0;
font-size: 11px;
font-weight: 700;
color: #475569;
}
&--on {
border-color: #1f7a4d;
background: #f0fdf4;
color: #0f4f3c;
.vc-step__no {
background: #1f7a4d;
color: #fff;
}
}
}
// ── Tabs ────────────────────────────────────────────────────────────────────
.vc-tabs {
display: flex;
gap: 2px;
border-bottom: 1px solid #e2e8f0;
flex-wrap: wrap;
}
.vc-tab {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 7px 12px;
margin-bottom: -1px;
border: 0;
border-bottom: 2px solid transparent;
background: transparent;
font: inherit;
font-size: 12px;
font-weight: 600;
color: #64748b;
cursor: pointer;
mat-icon {
font-size: 16px;
height: 16px;
width: 16px;
}
&:hover {
color: #0f4f3c;
}
&--on {
color: #0f4f3c;
border-bottom-color: #1f7a4d;
}
}
.vc-count {
padding: 0 7px;
border-radius: 999px;
background: #f59e0b;
color: #fff;
font-size: 10px;
font-weight: 700;
}
// ── Search ──────────────────────────────────────────────────────────────────
.vc-toolbar {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.vc-search {
display: flex;
align-items: center;
gap: 6px;
flex: 1 1 280px;
max-width: 420px;
height: 30px;
padding: 0 10px;
border: 1px solid #cbd5e1;
border-radius: 999px;
background: #fff;
&:focus-within {
border-color: #1f7a4d;
box-shadow: 0 0 0 3px rgb(31 122 77 / 12%);
}
mat-icon {
font-size: 16px;
height: 16px;
width: 16px;
color: #94a3b8;
}
input {
flex: 1;
min-width: 0;
border: 0;
outline: 0;
background: transparent;
font: inherit;
font-size: 12px;
}
}
// ── Table ───────────────────────────────────────────────────────────────────
.vc-table {
tbody tr {
cursor: pointer;
}
tr.vc-row--on td {
background: #f0fdf4;
&:first-child {
box-shadow: inset 3px 0 0 #1f7a4d;
}
}
.vc-col-action {
width: 1%;
text-align: right;
white-space: nowrap;
}
.vc-clip {
max-width: 240px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pg-chip {
font-size: 11px;
padding: 2px 8px;
}
}
.vc-btn-sm {
height: 26px;
padding: 0 10px;
font-size: 11px;
}
.vc-mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
letter-spacing: 0.02em;
}
.vc-empty {
padding: 24px 12px;
text-align: center;
color: #94a3b8;
font-size: 12px;
mat-icon {
display: block;
margin: 0 auto 4px;
font-size: 28px;
height: 28px;
width: 28px;
opacity: 0.6;
}
}
// ── Popups ──────────────────────────────────────────────────────────────────
.vc-dlg {
display: flex;
flex-direction: column;
max-height: 86vh;
font-size: 12px;
&__head {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-bottom: 1px solid #e2e8f0;
background: #f8fafc;
}
&__ico {
flex: 0 0 32px;
height: 32px;
width: 32px;
border-radius: 9px;
background: #f0fdf4;
color: #1f7a4d;
font-size: 18px;
line-height: 32px;
text-align: center;
}
&__titles {
flex: 1;
min-width: 0;
}
&__title {
font-size: 14px;
font-weight: 700;
color: #0f172a;
}
&__sub {
font-size: 11px;
color: #64748b;
}
&__x {
display: inline-flex;
align-items: center;
justify-content: center;
height: 28px;
width: 28px;
border: 0;
border-radius: 50%;
background: transparent;
color: #64748b;
cursor: pointer;
mat-icon {
font-size: 18px;
height: 18px;
width: 18px;
}
&:hover {
background: #e2e8f0;
}
}
&__body {
padding: 12px 14px;
overflow-y: auto;
}
&__amount {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 10px;
padding: 8px 12px;
border-radius: 10px;
background: #ecfdf5;
border: 1px solid #a7f3d0;
color: #065f46;
font-size: 12px;
font-weight: 600;
strong {
font-size: 18px;
font-weight: 800;
font-variant-numeric: tabular-nums;
}
}
&__foot {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 10px 14px;
border-top: 1px solid #e2e8f0;
background: #f8fafc;
}
}
.vc-warn {
display: flex;
align-items: flex-start;
gap: 8px;
margin-top: 10px;
padding: 8px 12px;
border-radius: 8px;
background: #fffbeb;
border: 1px solid #fde68a;
color: #92400e;
font-size: 11px;
mat-icon {
flex: 0 0 18px;
height: 18px;
width: 18px;
font-size: 18px;
}
}
.vc-dlg__close-req {
margin-right: auto;
color: #b45309;
}
.vc-label {
display: block;
margin-bottom: 3px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: #94a3b8;
}
.vc-reason {
width: 100%;
padding: 7px 10px;
border: 1px solid #cbd5e1;
border-radius: 8px;
font: inherit;
font-size: 12px;
resize: vertical;
&:focus {
outline: 0;
border-color: #1f7a4d;
box-shadow: 0 0 0 3px rgb(31 122 77 / 12%);
}
}
.vc-info {
display: flex;
align-items: flex-start;
gap: 6px;
margin-top: 8px;
padding: 7px 10px;
border-radius: 8px;
background: #eff6ff;
color: #1e40af;
font-size: 11px;
mat-icon {
flex: 0 0 16px;
height: 16px;
width: 16px;
font-size: 16px;
}
}
.vc-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
gap: 6px;
margin-bottom: 10px;
}
.vc-cell {
padding: 6px 10px;
border-radius: 8px;
background: #f8fafc;
border: 1px solid #eef2f7;
}
.vc-cell--wide {
grid-column: 1 / -1;
}
.vc-value {
font-size: 12px;
font-weight: 600;
color: #0f172a;
word-break: break-word;
}
// ── Wrong → Correct ─────────────────────────────────────────────────────────
.vc-compare {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: stretch;
gap: 8px;
@media (width <= 640px) {
grid-template-columns: 1fr;
}
}
.vc-arrow {
align-self: center;
color: #94a3b8;
@media (width <= 640px) {
transform: rotate(90deg);
justify-self: center;
}
}
.vc-acct {
padding: 10px 12px;
border: 1px solid #e2e8f0;
border-radius: 10px;
background: #fff;
&--wrong {
border-color: #fecaca;
background: #fef2f2;
}
&--ok {
border-color: #a7f3d0;
background: #ecfdf5;
}
&__tag {
margin-bottom: 4px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: #64748b;
}
&__no {
font-size: 14px;
font-weight: 700;
color: #0f172a;
}
&__input {
width: 100%;
height: 32px;
padding: 0 10px;
border: 1px solid #cbd5e1;
border-radius: 8px;
background: #fff;
font-size: 13px;
font-weight: 600;
&:focus {
outline: 0;
border-color: #1f7a4d;
box-shadow: 0 0 0 3px rgb(31 122 77 / 12%);
}
}
&__name {
margin-top: 4px;
font-size: 12px;
font-weight: 600;
color: #1e293b;
}
&__bal {
margin-top: 3px;
font-size: 11px;
color: #475569;
}
&__err {
margin-top: 6px;
}
}
.vc-ok,
.vc-bad {
display: inline-flex;
align-items: center;
gap: 3px;
margin-left: 6px;
font-weight: 700;
mat-icon {
font-size: 14px;
height: 14px;
width: 14px;
}
}
.vc-ok {
color: #059669;
}
.vc-bad {
color: #dc2626;
}
.vc-note {
margin-top: 3px;
font-size: 11px;
color: #64748b;
}
// ── Status ──────────────────────────────────────────────────────────────────
.vc-status {
display: inline-block;
padding: 2px 8px;
border-radius: 999px;
background: #e2e8f0;
font-size: 11px;
font-weight: 700;
&--UNPASS_PENDING,
&--CORRECTION_PENDING {
background: #fef3c7;
color: #92400e;
}
&--UNPASSED {
background: #dbeafe;
color: #1e40af;
}
&--COMPLETED {
background: #d1fae5;
color: #065f46;
}
&--CLOSED {
background: #f1f5f9;
color: #475569;
}
&--UNPASS_REJECTED {
background: #fee2e2;
color: #991b1b;
}
}
@@ -0,0 +1,307 @@
/**
* 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, TemplateRef, ViewChild, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { AuthenticationService } from '../../core/authentication/authentication.service';
import {
AccountLookup,
EligibleVoucher,
VoucherCorrection,
VoucherCorrectionService
} from '../voucher-correction.service';
type Tab = 'today' | 'unpassed' | 'mine';
/**
* Cashier side of correcting a cash deposit that went to the wrong account.
*
* Today vouchers: pick the voucher and unpass it (needs a manager approval; nothing moves).
* Unpassed: open the approved voucher, whose details are fixed, change only the account number and save
* (needs a manager approval again; only then do the accounts change).
*/
@Component({
selector: 'mifosx-voucher-correction',
templateUrl: './voucher-correction.component.html',
styleUrls: [
'../cashier-shared.scss',
'./voucher-correction.component.scss'
],
standalone: true,
imports: [
CommonModule,
FormsModule,
MatIconModule,
MatDialogModule,
MatSnackBarModule
]
})
export class VoucherCorrectionComponent implements OnInit {
private service = inject(VoucherCorrectionService);
private snackBar = inject(MatSnackBar);
private dialog = inject(MatDialog);
private auth = inject(AuthenticationService);
/** Unpassing is for the back office (an administrator or a clerk); re-entering the account is the cashier only. */
readonly mayUnpass = this.auth.isClerkUser() || !!this.auth.getCredentials()?.permissions?.includes('ALL_FUNCTIONS');
readonly mayCorrect = this.auth.isCashierUser();
@ViewChild('unpassDialog') unpassDialog!: TemplateRef<unknown>;
@ViewChild('correctionDialog') correctionDialog!: TemplateRef<unknown>;
private dialogRef: MatDialogRef<unknown> | null = null;
tab: Tab = this.mayUnpass ? 'today' : this.mayCorrect ? 'unpassed' : 'mine';
// Today vouchers
search = '';
vouchers: EligibleVoucher[] = [];
loadingVouchers = false;
selectedVoucher: EligibleVoucher | null = null;
/** The account the voucher was posted to, for the balance and status shown in the popup. */
voucherAccount: AccountLookup | null = null;
unpassReason = '';
sending = false;
// Unpassed vouchers waiting for the correct account
unpassed: VoucherCorrection[] = [];
loadingUnpassed = false;
active: VoucherCorrection | null = null;
wrongAccount: AccountLookup | null = null;
correctAccountNo = '';
correctAccount: AccountLookup | null = null;
lookupError = '';
lookingUp = false;
// The cashier own requests
mine: VoucherCorrection[] = [];
loadingMine = false;
readonly statusLabel: Record<string, string> = {
UNPASS_PENDING: 'Unpass awaiting approval',
UNPASSED: 'Unpassed — enter correct account',
UNPASS_REJECTED: 'Unpass rejected',
CORRECTION_PENDING: 'Correction awaiting approval',
COMPLETED: 'Corrected',
CLOSED: 'Closed — cashier deposits separately'
};
ngOnInit(): void {
if (this.mayUnpass) this.loadVouchers();
if (this.mayCorrect) this.loadUnpassed();
if (this.tab === 'mine') this.loadMine();
}
setTab(tab: Tab): void {
this.tab = tab;
if (tab === 'today') this.loadVouchers();
if (tab === 'unpassed') this.loadUnpassed();
if (tab === 'mine') this.loadMine();
}
// ── Today vouchers ─────────────────────────────────────────────────────────
loadVouchers(): void {
this.loadingVouchers = true;
this.service
.eligible(this.search.trim())
.pipe(catchError(() => of([] as EligibleVoucher[])))
.subscribe((rows) => {
this.vouchers = rows;
this.loadingVouchers = false;
if (this.selectedVoucher && !rows.some((r) => r.voucherNo === this.selectedVoucher?.voucherNo)) {
this.selectedVoucher = null;
}
});
}
pick(v: EligibleVoucher): void {
this.selectedVoucher = v;
this.unpassReason = '';
this.voucherAccount = null;
this.service
.accountLookup(v.accountNo)
.pipe(catchError(() => of(null)))
.subscribe((a) => (this.voucherAccount = a));
this.showDialog(this.unpassDialog, () => (this.selectedVoucher = null));
}
closeUnpass(): void {
this.dialogRef?.close();
}
get canUnpass(): boolean {
return !!this.selectedVoucher && this.unpassReason.trim().length > 0 && !this.sending;
}
unpass(): void {
if (!this.selectedVoucher || !this.canUnpass) return;
this.sending = true;
this.service.unpass(this.selectedVoucher.voucherNo, this.unpassReason.trim()).subscribe({
next: () => {
this.sending = false;
this.snackBar.open(`Voucher ${this.selectedVoucher?.voucherNo} sent for unpass approval`, 'Close', {
duration: 4000
});
this.dialogRef?.close();
this.unpassReason = '';
this.loadVouchers();
},
error: (err) => {
this.sending = false;
this.fail(err, 'Could not unpass the voucher');
}
});
}
// ── Unpassed vouchers: enter the correct account ───────────────────────────
loadUnpassed(): void {
this.loadingUnpassed = true;
this.service
.list('UNPASSED')
.pipe(catchError(() => of([] as VoucherCorrection[])))
.subscribe((rows) => {
this.unpassed = rows;
this.loadingUnpassed = false;
});
}
open(row: VoucherCorrection): void {
this.active = row;
this.correctAccountNo = '';
this.correctAccount = null;
this.lookupError = '';
this.wrongAccount = null;
// The account the deposit went to: its balance is what the "can this be taken back" check reads.
this.service
.accountLookup(row.wrong_account_no)
.pipe(catchError(() => of(null)))
.subscribe((a) => (this.wrongAccount = a));
this.showDialog(this.correctionDialog, () => (this.active = null));
}
closeActive(): void {
this.dialogRef?.close();
}
onAccountInput(value: string): void {
this.correctAccountNo = value;
this.correctAccount = null;
this.lookupError = '';
// Same rule as the Transaction Entry screen: searches on its own once six characters are typed.
if (value.trim().length >= 6) this.lookup();
}
lookup(): void {
const accountNo = this.correctAccountNo.trim();
if (!accountNo || this.lookingUp) return;
this.lookingUp = true;
this.service.accountLookup(accountNo).subscribe({
next: (a) => {
this.lookingUp = false;
// A late answer for a number no longer typed is dropped.
if (accountNo !== this.correctAccountNo.trim()) return;
this.correctAccount = a;
if (this.active && a.id === this.active.wrong_savings_account_id) {
this.lookupError = 'This is the account the deposit was already posted to.';
} else if (!a.canReceive) {
this.lookupError = 'This account is not active, so it cannot receive the deposit.';
}
},
error: (err) => {
this.lookingUp = false;
this.lookupError = err?.error?.errors?.[0]?.defaultUserMessage ?? 'Account not found.';
}
});
}
/** The wrong account still holds the deposit, so it can be taken back without overdrawing it. */
get wrongHasBalance(): boolean {
return !!this.wrongAccount && !!this.active && this.wrongAccount.balance >= this.active.amount;
}
get correctOk(): boolean {
return !!this.correctAccount && this.correctAccount.canReceive && !this.lookupError;
}
get canSave(): boolean {
return !!this.active && this.correctOk && this.wrongHasBalance && !this.sending;
}
save(): void {
if (!this.active || !this.canSave) return;
this.sending = true;
this.service.submitCorrection(this.active.id, this.correctAccountNo.trim()).subscribe({
next: () => {
this.sending = false;
this.snackBar.open('Correction sent for approval', 'Close', { duration: 4000 });
this.dialogRef?.close();
this.loadUnpassed();
},
error: (err) => {
this.sending = false;
this.fail(err, 'Could not save the correction');
}
});
}
/**
* Gives up this correction. The usual reason is that the wrong account no longer holds the money, so the cashier
* puts the amount into the right account from their own pocket as a fresh deposit instead.
*/
closeRequest(): void {
if (!this.active || this.sending) return;
const reason = this.wrongHasBalance
? 'Closed by the cashier'
: 'Wrong account no longer holds the amount; cashier deposits it into the correct account separately';
this.sending = true;
this.service.close(this.active.id, reason).subscribe({
next: () => {
this.sending = false;
this.snackBar.open('Request closed', 'Close', { duration: 4000 });
this.dialogRef?.close();
this.loadUnpassed();
},
error: (err) => {
this.sending = false;
this.fail(err, 'Could not close the request');
}
});
}
// ── My requests ────────────────────────────────────────────────────────────
loadMine(): void {
this.loadingMine = true;
this.service
.list(undefined, true)
.pipe(catchError(() => of([] as VoucherCorrection[])))
.subscribe((rows) => {
this.mine = rows;
this.loadingMine = false;
});
}
/** Opens one of the two popups; onClose runs however it is dismissed (button, backdrop or Esc). */
private showDialog(template: TemplateRef<unknown>, onClose: () => void): void {
this.dialogRef?.close();
this.dialogRef = this.dialog.open(template, { width: '600px', maxWidth: '95vw', autoFocus: false });
this.dialogRef.afterClosed().subscribe(onClose);
}
private fail(err: any, fallback: string): void {
const msg = err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.defaultUserMessage ?? fallback;
this.snackBar.open(msg, 'Close', { duration: 6000 });
}
}
@@ -169,6 +169,7 @@
<th>Debit</th>
<th>Credit</th>
<th class="num">Amount</th>
<th>Correction</th>
<th>User</th>
<!-- Two dates, not one, on the system register.
A voucher's timestamp is the minute it was written; the posting behind it carries
@@ -217,6 +218,18 @@
</td>
}
<td class="num">{{ row.amount | number: '1.2-2' }}</td>
<!-- Where the voucher stands after a correction: unpassed, revoked, closed or corrected. -->
<td>
<mifosx-voucher-status
[status]="row.voucherStatus"
[revokedBy]="row.revokedByVoucherNo"
[replaces]="row.replacesVoucherNo"
[note]="row.correctionNote"
></mifosx-voucher-status>
@if (!row.voucherStatus || row.voucherStatus === 'ACTIVE') {
<span class="vr-active">—</span>
}
</td>
<td>{{ row.username || '—' }}</td>
@if (systemOnly) {
<td class="vr-ledgerdate" [class.vr-ledgerdate--prior]="row.postedAfterClose">
@@ -92,3 +92,8 @@
text-transform: uppercase;
color: #7c3aed;
}
.vr-active {
font-size: 11px;
color: #94a3b8;
}
@@ -13,6 +13,8 @@ import { ActivatedRoute } from '@angular/router';
import { MatIconModule } from '@angular/material/icon';
import { catchError, of } from 'rxjs';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
import { VoucherStatusComponent, VoucherStatus } from '../voucher-status/voucher-status.component';
/**
* Which side of the register a screen shows.
@@ -25,6 +27,11 @@ export type VoucherSide = 'CR' | 'DR' | 'ALL';
/** One voucher as the register shows it: the number, the two sides, and who raised it. */
interface VoucherRow {
voucherNo: string;
/** Where the voucher stands after a correction: active, revoked, under correction, or corrected. */
voucherStatus?: VoucherStatus;
revokedByVoucherNo?: string | null;
replacesVoucherNo?: string | null;
correctionNote?: string | null;
voucherType: string | null;
amount: number | null;
createdAt: string | null;
@@ -81,7 +88,8 @@ interface VoucherReport {
imports: [
CommonModule,
FormsModule,
MatIconModule
MatIconModule,
VoucherStatusComponent
]
})
export class VoucherReportComponent implements OnInit {
@@ -227,7 +235,7 @@ export class VoucherReportComponent implements OnInit {
}
private todayIso(): string {
const now = new Date();
const now = businessToday();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
}
@@ -0,0 +1,137 @@
/**
* 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, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
/** Where a voucher stands after a voucher correction. */
export type VoucherStatus =
| 'ACTIVE'
| 'REVOKED'
| 'UNPASS_PENDING'
| 'UNPASSED'
| 'CORRECTION_PENDING'
| 'CORRECTED'
| 'CLOSED'
| null
| undefined;
/**
* A small badge saying whether a voucher was revoked, is under correction, or replaced another one.
* Nothing is shown for an ordinary (active) voucher, so the registers read as before.
*/
@Component({
selector: 'mifosx-voucher-status',
standalone: true,
imports: [CommonModule],
template: `
@if (label) {
<span class="vs" [class]="'vs vs--' + status" [title]="hint">{{ label }}</span>
@if (note) {
<div class="vs-note">{{ note }}</div>
}
}
`,
styles: [
`
.vs {
display: inline-block;
margin-left: 6px;
padding: 1px 8px;
border-radius: 999px;
background: #e2e8f0;
color: #334155;
font-size: 10px;
font-weight: 700;
white-space: nowrap;
vertical-align: middle;
}
.vs-note {
margin-top: 2px;
max-width: 280px;
font-size: 11px;
line-height: 1.3;
color: #64748b;
}
.vs--REVOKED {
background: #fee2e2;
color: #991b1b;
}
.vs--UNPASS_PENDING,
.vs--CORRECTION_PENDING {
background: #fef3c7;
color: #92400e;
}
.vs--UNPASSED {
background: #dbeafe;
color: #1e40af;
}
.vs--CLOSED {
background: #f1f5f9;
color: #475569;
}
.vs--CORRECTED {
background: #d1fae5;
color: #065f46;
}
`
]
})
export class VoucherStatusComponent {
@Input() status: VoucherStatus;
/** The voucher that replaced this one, when it is revoked. */
@Input() revokedBy: string | null | undefined;
/** The voucher this one replaced, when it is the corrected one. */
@Input() replaces: string | null | undefined;
/** Why: the reason the voucher was unpassed, or why the correction was closed. */
@Input() note: string | null | undefined;
get label(): string {
switch (this.status) {
case 'REVOKED':
return this.revokedBy ? `Revoked → ${this.revokedBy}` : 'Revoked';
case 'UNPASS_PENDING':
return 'Unpass pending';
case 'UNPASSED':
return 'Unpassed';
case 'CORRECTION_PENDING':
return 'Correction pending';
case 'CLOSED':
return 'Closed — cashier deposits separately';
case 'CORRECTED':
return this.replaces ? `Corrected (replaces ${this.replaces})` : 'Corrected';
default:
return '';
}
}
get hint(): string {
switch (this.status) {
case 'REVOKED':
return 'This voucher was revoked: its deposit was moved to another account and a new voucher replaced it.';
case 'UNPASS_PENDING':
return 'A manager has not yet approved unpassing this voucher.';
case 'UNPASSED':
return 'Unpassed: waiting for the cashier to enter the correct account.';
case 'CORRECTION_PENDING':
return 'The correct account is entered; waiting for a manager to approve.';
case 'CLOSED':
return 'The correction was closed by the cashier; the voucher stays as it was.';
case 'CORRECTED':
return 'This voucher was raised by a correction and replaces the earlier one.';
default:
return '';
}
}
}
+73
View File
@@ -19,6 +19,18 @@ import { ReportMuleComponent } from './report-mule/report-mule.component';
import { ReportFraudTxnComponent } from './report-fraud-txn/report-fraud-txn.component';
import { FmrSubmissionComponent } from './fmr-submission/fmr-submission.component';
import { CfrLookupComponent } from './cfr-lookup/cfr-lookup.component';
import { I4cShellComponent } from './i4c/i4c-shell/i4c-shell.component';
import { I4cDashboardComponent } from './i4c/i4c-dashboard/i4c-dashboard.component';
import { I4cRequestsComponent } from './i4c/i4c-requests/i4c-requests.component';
import { I4cRequestDetailComponent } from './i4c/i4c-request-detail/i4c-request-detail.component';
import { I4cHoldsComponent } from './i4c/i4c-holds/i4c-holds.component';
import { I4cApprovalsComponent } from './i4c/i4c-approvals/i4c-approvals.component';
import { I4cOutboxComponent } from './i4c/i4c-outbox/i4c-outbox.component';
import { I4cBankMasterComponent } from './i4c/i4c-bank-master/i4c-bank-master.component';
import { I4cIntimationSettingsComponent } from './i4c/i4c-intimation-settings/i4c-intimation-settings.component';
import { I4cGrievancesComponent } from './i4c/i4c-grievances/i4c-grievances.component';
import { I4cRestorationsComponent } from './i4c/i4c-restorations/i4c-restorations.component';
import { I4cMrmReportComponent } from './i4c/i4c-mrm-report/i4c-mrm-report.component';
const routes: Routes = [
Route.withShell([
@@ -76,6 +88,67 @@ const routes: Routes = [
path: 'cfr-lookup',
component: CfrLookupComponent,
data: { title: 'CFR Lookup', breadcrumb: 'CFR Lookup' }
},
{
// Live I4C-MHA integration (NCRP-CFCFRMS API). Served by /v1/i4c/admin on the CBS; the demo mock
// interceptor does not match these paths.
path: 'i4c',
component: I4cShellComponent,
data: { title: 'I4C Live Integration', breadcrumb: 'I4C Live' },
children: [
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
{
path: 'dashboard',
component: I4cDashboardComponent,
data: { title: 'I4C Dashboard', breadcrumb: 'Dashboard' }
},
{
path: 'requests',
component: I4cRequestsComponent,
data: { title: 'Complaints from I4C', breadcrumb: 'Complaints' }
},
{
path: 'requests/:id',
component: I4cRequestDetailComponent,
data: { title: 'I4C Complaint', breadcrumb: 'Complaint' }
},
{ path: 'holds', component: I4cHoldsComponent, data: { title: 'I4C Holds Register', breadcrumb: 'Holds' } },
{
path: 'approvals',
component: I4cApprovalsComponent,
data: { title: 'I4C Approvals', breadcrumb: 'Approvals' }
},
{
path: 'outbox',
component: I4cOutboxComponent,
data: { title: 'I4C Responses', breadcrumb: 'Responses' }
},
{
path: 'bank-master',
component: I4cBankMasterComponent,
data: { title: 'I4C Bank Code Master', breadcrumb: 'Bank Master' }
},
{
path: 'grievances',
component: I4cGrievancesComponent,
data: { title: 'I4C GRM Grievances', breadcrumb: 'GRM Grievances' }
},
{
path: 'restorations',
component: I4cRestorationsComponent,
data: { title: 'I4C MRM Restorations', breadcrumb: 'MRM Restorations' }
},
{
path: 'mrm-report',
component: I4cMrmReportComponent,
data: { title: 'I4C MRM Balance Report', breadcrumb: 'MRM Balance Report' }
},
{
path: 'intimation-settings',
component: I4cIntimationSettingsComponent,
data: { title: 'I4C Intimation Settings', breadcrumb: 'Intimation Settings' }
}
]
}
]
}
@@ -34,6 +34,17 @@
<fa-icon icon="chart-line" class="cfc-tab__ic"></fa-icon>
<span>Dashboard</span>
</a>
<a
mat-tab-link
class="cfc-tab"
[routerLink]="['./i4c']"
routerLinkActive
#i4cLive="routerLinkActive"
[active]="i4cLive.isActive"
>
<fa-icon icon="satellite-dish" class="cfc-tab__ic"></fa-icon>
<span>I4C Live</span>
</a>
<a
mat-tab-link
class="cfc-tab"
+144
View File
@@ -0,0 +1,144 @@
/**
* 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 by the GRM grievance, MRM restoration and MRM report pages.
@mixin i4c-case-page {
.modal-title-row {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.count-tag {
font-size: 11px;
font-weight: 700;
color: #64748b;
background: #f1f5f9;
padding: 2px 7px;
border-radius: 9999px;
border: 1px solid #e2e8f0;
}
.search-input {
width: 260px;
}
.footer-btn-close {
padding: 6px 14px;
border-radius: 8px;
background: #e2e8f0;
color: #334155;
border: none;
font-size: 11.5px;
font-weight: 600;
cursor: pointer;
&:hover {
background: #cbd5e1;
color: #0f172a;
}
}
.sub {
font-size: 11px;
color: #64748b;
}
.num {
text-align: right;
}
.wrap {
white-space: normal;
overflow-wrap: anywhere;
}
.step {
display: flex;
flex-direction: column;
gap: 10px;
&__title {
font-weight: 600;
font-size: 13px;
color: #0f172a;
}
&__row {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
mat-form-field {
width: 220px;
}
}
&__wide {
flex: 1 1 100%;
}
& + & {
margin-top: 14px;
padding-top: 14px;
border-top: 1px dashed #e2e8f0;
}
}
.flow {
display: flex;
flex-wrap: wrap;
gap: 4px;
align-items: center;
font-size: 11px;
&__stage {
padding: 3px 9px;
border-radius: 999px;
background: #f1f5f9;
color: #64748b;
font-weight: 600;
&--done {
background: #ecfdf5;
color: #047857;
}
&--now {
background: #2563eb;
color: #fff;
}
}
&__arrow {
color: #cbd5e1;
}
}
.deadline {
font-weight: 600;
&--late {
color: #dc2626;
}
}
.grid-2 {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
@media (width <= 900px) {
.grid-2 {
grid-template-columns: minmax(0, 1fr);
}
}
}
+789
View File
@@ -0,0 +1,789 @@
/**
* 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/.
*/
/* Modern design system for the I4C (NCRP-CFCFRMS) subsystem:
Glassmorphism, sleek modern cards, rich data tables, glowing status pills,
and animated popup window modals. */
@mixin i4c-skin {
.i4c-page {
display: flex;
flex-direction: column;
gap: 20px;
animation: i4c-fade-in 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
/* Modern Toolbar */
.i4c-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
padding: 8px 14px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
box-shadow: 0 2px 8px rgb(15 23 42 / 3%);
&__hint {
color: #64748b;
font-size: 11.5px;
font-weight: 500;
display: inline-flex;
align-items: center;
gap: 6px;
}
&__spacer {
flex: 1 1 auto;
}
mat-form-field {
min-width: 180px;
}
}
/* Extra filters and the date range, under the toolbar */
.i4c-filter-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px 14px;
padding: 10px 14px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
mat-form-field {
width: 170px;
}
&__clear {
margin-left: auto;
}
// Inside a card, under its head
&--inset {
border: 0;
border-bottom: 1px solid #f1f5f9;
border-radius: 0;
}
}
/* Paginator at the foot of a list card */
.i4c-paginator {
border-top: 1px solid #f1f5f9;
background: transparent;
}
/* Filter Chip bar */
.i4c-filter-chips {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
.filter-chip {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 10px;
border-radius: 9999px;
font-size: 11px;
font-weight: 600;
color: #475569;
background: #f1f5f9;
border: 1px solid #e2e8f0;
cursor: pointer;
transition: all 0.2s ease;
text-decoration: none;
&:hover {
background: #e2e8f0;
color: #0f172a;
transform: translateY(-1px);
}
&--active {
background: #2563eb;
color: #fff;
border-color: #2563eb;
box-shadow: 0 3px 8px rgb(37 99 235 / 25%);
&:hover {
background: #1d4ed8;
color: #fff;
}
.filter-chip-count {
background: rgb(255 255 255 / 25%);
color: #fff;
}
}
.filter-chip-count {
display: inline-block;
padding: 1px 5px;
border-radius: 9999px;
background: #e2e8f0;
font-size: 10px;
font-weight: 700;
}
}
}
/* Modern KPI Cards */
.i4c-kpis {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 12px;
}
.i4c-kpi {
position: relative;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 12px 14px;
box-shadow: 0 4px 10px rgb(15 23 42 / 3%);
transition: all 0.25s cubic-bezier(0.16, 1, 0.3, 1);
overflow: hidden;
text-decoration: none;
display: flex;
flex-direction: column;
gap: 3px;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(90deg, #3b82f6, #60a5fa);
border-radius: 12px 12px 0 0;
}
&:hover {
transform: translateY(-2px);
box-shadow: 0 8px 18px -4px rgb(15 23 42 / 8%);
border-color: #cbd5e1;
}
&__value {
font-size: 18px;
font-weight: 800;
color: #0f172a;
letter-spacing: -0.02em;
font-variant-numeric: tabular-nums;
}
&__label {
font-size: 11.5px;
font-weight: 600;
color: #475569;
}
&__sub {
font-size: 10px;
color: #94a3b8;
}
&--green::before {
background: linear-gradient(90deg, #10b981, #34d399);
}
&--amber::before {
background: linear-gradient(90deg, #f59e0b, #fbbf24);
}
&--red::before {
background: linear-gradient(90deg, #ef4444, #f87171);
}
&--purple::before {
background: linear-gradient(90deg, #8b5cf6, #a78bfa);
}
}
/* Modern Card Container */
.i4c-card {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
box-shadow: 0 4px 14px rgb(15 23 42 / 3%);
overflow: hidden;
transition: box-shadow 0.2s ease;
&:hover {
box-shadow: 0 6px 18px rgb(15 23 42 / 5%);
}
&__head {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
background: linear-gradient(180deg, #fafbfd 0%, #f4f6fa 100%);
border-bottom: 1px solid #e2e8f0;
font-size: 13px;
font-weight: 700;
color: #1e293b;
letter-spacing: -0.01em;
fa-icon {
color: #2563eb;
}
}
&__body {
padding: 14px 16px;
}
}
.i4c-grid-2 {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 14px;
}
.i4c-table-wrap {
overflow-x: auto;
}
/* Modern Data Table */
table.i4c-table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
font-size: 11.5px;
th {
position: sticky;
top: 0;
z-index: 2;
text-align: left;
font-weight: 600;
color: #475569;
background: #f8fafc;
padding: 8px 10px;
border-bottom: 1px solid #e2e8f0;
white-space: nowrap;
font-size: 10.5px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
td {
padding: 8px 10px;
border-bottom: 1px solid #f1f5f9;
color: #1e293b;
vertical-align: middle;
transition: background-color 0.15s ease;
}
tr:last-child td {
border-bottom: none;
}
tr.clickable {
cursor: pointer;
&:hover td {
background: #f8fafc;
}
}
tr.selected td {
background: #eff6ff;
border-bottom-color: #dbeafe;
}
.num {
text-align: right;
font-variant-numeric: tabular-nums;
font-weight: 600;
white-space: nowrap;
}
}
/* Glowing Modern Status Pills */
.i4c-badge {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 2px 8px;
border-radius: 9999px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.02em;
background: #e2e8f0;
color: #334155;
white-space: nowrap;
border: 1px solid transparent;
&::before {
content: '';
display: inline-block;
width: 5px;
height: 5px;
border-radius: 50%;
background: currentcolor;
}
&--green {
background: #ecfdf5;
color: #059669;
border-color: #a7f3d0;
&::before {
box-shadow: 0 0 6px #10b981;
}
}
&--amber {
background: #fffbeb;
color: #d97706;
border-color: #fde68a;
&::before {
box-shadow: 0 0 6px #f59e0b;
}
}
&--red {
background: #fef2f2;
color: #dc2626;
border-color: #fecaca;
&::before {
box-shadow: 0 0 6px #ef4444;
}
}
&--blue {
background: #eff6ff;
color: #2563eb;
border-color: #bfdbfe;
&::before {
box-shadow: 0 0 6px #3b82f6;
}
}
&--purple {
background: #f5f3ff;
color: #7c3aed;
border-color: #ddd6fe;
&::before {
box-shadow: 0 0 6px #8b5cf6;
}
}
}
/* Key-Value details layout */
.i4c-kv {
display: grid;
grid-template-columns: 160px 1fr;
gap: 8px 12px;
font-size: 11.5px;
margin: 0;
dt {
color: #64748b;
font-weight: 500;
}
dd {
margin: 0;
color: #0f172a;
font-weight: 600;
word-break: break-word;
}
}
.i4c-empty {
padding: 30px 20px;
text-align: center;
color: #64748b;
font-size: 12px;
.empty-icon {
font-size: 26px;
color: #cbd5e1;
margin-bottom: 6px;
}
}
.i4c-error {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border-radius: 10px;
background: #fef2f2;
border: 1px solid #fecaca;
color: #991b1b;
font-size: 11.5px;
font-weight: 500;
}
.i4c-note {
padding: 10px 14px;
border-radius: 8px;
background: #f8fafc;
border: 1px solid #e2e8f0;
color: #475569;
font-size: 11.5px;
line-height: 1.5;
}
/* Sleek Dark JSON / Code Viewer */
pre.i4c-json {
margin: 0;
padding: 10px 14px 20px;
max-height: 380px;
overflow: auto;
background: #0f172a;
color: #38bdf8;
border: 1px solid #1e293b;
border-radius: 10px;
font-family: 'JetBrains Mono', Consolas, Monaco, monospace;
font-size: 10.5px;
line-height: 1.5;
box-shadow: inset 0 2px 4px rgb(0 0 0 / 40%);
box-sizing: border-box;
white-space: pre-wrap;
word-break: break-all;
&::-webkit-scrollbar {
width: 7px;
height: 7px;
}
&::-webkit-scrollbar-track {
background: #1e293b;
border-radius: 4px;
}
&::-webkit-scrollbar-thumb {
background: #475569;
border-radius: 4px;
&:hover {
background: #64748b;
}
}
}
.i4c-form {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 8px 14px;
}
.i4c-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
/* Quick action button inside tables */
.table-action-btn {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 8px;
border-radius: 6px;
font-size: 11px;
font-weight: 600;
color: #2563eb;
background: #eff6ff;
border: 1px solid #bfdbfe;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: #2563eb;
color: #fff;
transform: translateY(-1px);
}
}
/* Copy Button */
.copy-chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 6px;
background: #f1f5f9;
color: #475569;
font-size: 11px;
cursor: pointer;
border: 1px solid #e2e8f0;
transition: all 0.15s ease;
&:hover {
background: #e2e8f0;
color: #0f172a;
}
}
/* Expandable JSON Row inside tables */
.finding-json-cell {
padding: 12px 16px !important;
background: #0b1329 !important;
border-bottom: 2px solid #1e293b !important;
}
.finding-json-box {
display: flex;
flex-direction: column;
gap: 8px;
.finding-json-head {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 11px;
font-weight: 600;
color: #94a3b8;
padding-bottom: 6px;
border-bottom: 1px solid #1e293b;
fa-icon {
color: #60a5fa;
}
}
pre.i4c-json {
max-height: none;
overflow: auto visible;
border: none;
background: transparent;
padding: 6px 0 16px;
box-shadow: none;
white-space: pre-wrap;
word-break: break-all;
font-size: 11px;
line-height: 1.6;
color: #38bdf8;
}
}
/* ========================================================
MODERN POPUP WINDOW MODAL SYSTEM (Ultra Modern Backdrop & Window)
======================================================== */
.i4c-modal-backdrop {
position: fixed;
inset: 0;
background: rgb(15 23 42 / 65%);
backdrop-filter: blur(6px);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
padding: 16px;
animation: i4c-fade-in 0.2s ease-out;
}
.i4c-modal-window {
background: #fff;
border-radius: 14px;
border: 1px solid rgb(255 255 255 / 20%);
box-shadow: 0 20px 50px -15px rgb(15 23 42 / 35%);
width: 100%;
max-width: 660px;
max-height: 86vh;
display: flex;
flex-direction: column;
overflow: hidden;
animation: i4c-modal-pop 0.25s cubic-bezier(0.16, 1, 0.3, 1);
&--wide {
max-width: 860px;
}
&--full {
max-width: 90vw;
max-height: 88vh;
}
&--compact {
max-width: 440px;
}
}
.i4c-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 11px 16px;
background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
color: #fff;
border-bottom: 1px solid rgb(255 255 255 / 10%);
flex-shrink: 0;
&__title {
display: flex;
align-items: center;
gap: 10px;
font-size: 14px;
font-weight: 700;
letter-spacing: -0.01em;
fa-icon {
color: #60a5fa;
}
}
&__sub {
font-size: 11px;
color: #94a3b8;
font-weight: normal;
margin-top: 2px;
}
&__actions {
display: flex;
align-items: center;
gap: 6px;
}
.modal-close-btn {
width: 26px;
height: 26px;
border-radius: 50%;
background: rgb(255 255 255 / 10%);
color: #fff;
border: none;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 11px;
transition: all 0.2s ease;
&:hover {
background: rgb(239 68 68 / 80%);
transform: rotate(90deg);
}
}
}
.i4c-modal-tabs {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 16px;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
overflow-x: auto;
flex-shrink: 0;
.modal-tab {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 10px;
border-radius: 6px;
font-size: 11px;
font-weight: 600;
color: #64748b;
background: transparent;
border: 1px solid transparent;
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
&:hover {
color: #0f172a;
background: #e2e8f0;
}
&--active {
color: #2563eb;
background: #fff;
border-color: #e2e8f0;
box-shadow: 0 1px 3px rgb(15 23 42 / 5%);
}
}
}
.i4c-modal-body {
padding: 14px 16px;
overflow: hidden auto;
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
gap: 12px;
overscroll-behavior: contain;
/* Content keeps its natural height and the body scrolls; a flex child would otherwise shrink to fit and
clip what is inside it (a card has overflow: hidden). */
> div {
flex-shrink: 0;
}
&::-webkit-scrollbar {
width: 7px;
}
&::-webkit-scrollbar-track {
background: #f1f5f9;
border-radius: 4px;
}
&::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 4px;
&:hover {
background: #94a3b8;
}
}
}
.i4c-modal-footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
padding: 10px 16px;
background: #f8fafc;
border-top: 1px solid #e2e8f0;
flex-shrink: 0;
}
}
/* Animations */
@keyframes i4c-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes i4c-modal-pop {
from {
opacity: 0;
transform: scale(0.92) translateY(12px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
@@ -0,0 +1,343 @@
<!--
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="i4c-page">
<!-- Modern Toolbar & Filter Chips -->
<div class="i4c-toolbar">
<div class="i4c-filter-chips">
@for (s of statuses; track s) {
<button class="filter-chip" [class.filter-chip--active]="status === s" (click)="setStatus(s)">
<span>{{ s || 'All Requests' }}</span>
</button>
}
</div>
<span class="i4c-toolbar__spacer"></span>
<div class="maker-checker-pill">
<fa-icon icon="shield-alt"></fa-icon>
<span>Maker-Checker Enforced: you cannot approve a request you raised.</span>
</div>
<button mat-flat-button color="primary" class="refresh-btn" (click)="load()" [disabled]="loading">
<fa-icon icon="sync" [animation]="loading ? 'spin' : undefined"></fa-icon> Refresh
</button>
</div>
<div class="i4c-filter-bar">
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="search-field">
<mat-label>Account / ack / order no. / officer</mat-label>
<input
matInput
[(ngModel)]="search"
(keyup.enter)="load(true)"
(blur)="load(true)"
placeholder="Type & hit enter..."
/>
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Action</mat-label>
<mat-select [(ngModel)]="action" (selectionChange)="load(true)">
<mat-option value="">All actions</mat-option>
<mat-option value="RELEASE">Release hold</mat-option>
<mat-option value="FREEZE">Freeze debits</mat-option>
<mat-option value="UNFREEZE">Unfreeze debits</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Reason</mat-label>
<mat-select [(ngModel)]="reasonType" (selectionChange)="load(true)">
<mat-option value="">All reasons</mat-option>
@for (r of reasonTypes; track r.value) {
<mat-option [value]="r.value">{{ r.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mifosx-i4c-date-range label="Raised on" [range]="dates" (rangeChange)="onDates($event)"></mifosx-i4c-date-range>
<button mat-stroked-button class="i4c-filter-bar__clear" (click)="clearFilters()" [disabled]="!hasFilters">
<fa-icon icon="times"></fa-icon> Clear filters
</button>
</div>
@if (loading) {
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
}
@if (error) {
<div class="i4c-error">
<fa-icon icon="exclamation-circle"></fa-icon>
<span>{{ error }}</span>
</div>
}
<!-- Approvals Table Card -->
<div class="i4c-card">
<div class="i4c-card__head">
<fa-icon icon="user-check"></fa-icon>
<span>Checker Approval Queue (I4C Account Holds)</span>
<span class="head-hint">{{ total }} requests in current queue</span>
</div>
<div class="i4c-table-wrap">
<table class="i4c-table">
<thead>
<tr>
<th>Raised On</th>
<th>Requested Action</th>
<th>Target Account / Complaint</th>
<th class="num">Held Amount (₹)</th>
<th>Reason &amp; Legal Order</th>
<th>Maker Remarks</th>
<th>Status</th>
<th>Decision Actions</th>
</tr>
</thead>
<tbody>
@for (r of rows; track r.id) {
<tr>
<td>
<span class="date-main">{{ r.maker_at | date: 'dd MMM yyyy, HH:mm' }}</span>
<div class="sub">
Raised by: <b>{{ r.maker_name }}</b>
</div>
</td>
<td>
<span
class="i4c-badge"
[class.i4c-badge--red]="r.action !== 'RELEASE'"
[class.i4c-badge--blue]="r.action === 'RELEASE'"
>
{{ r.action }}
</span>
</td>
<td>
<a [routerLink]="['../holds']" [queryParams]="{ view: '' }" class="acc-link font-semibold">
{{ r.account_no }}
</a>
<div class="sub">{{ r.account_type }} • Ack: {{ r.acknowledgement_no }}</div>
</td>
<td class="num">
<span class="font-bold text-accent">₹{{ r.amount_held | number: '1.2-2' }}</span>
<div class="sub">Disputed: ₹{{ r.disputed_amount | number: '1.2-2' }}</div>
</td>
<td>
<div class="reason-title">{{ reasonLabel(r.reason_type) }}</div>
<div class="sub">
Ref: {{ r.order_reference }} {{ r.order_date ? '(' + r.order_date + ')' : '' }}
{{ r.authority ? '• ' + r.authority : '' }}
</div>
</td>
<td>
<div class="remarks-text">{{ r.remarks || '-' }}</div>
</td>
<td>
<span
class="i4c-badge"
[class.i4c-badge--green]="r.status === 'APPROVED'"
[class.i4c-badge--red]="r.status === 'REJECTED' || r.status === 'FAILED'"
[class.i4c-badge--amber]="r.status === 'PENDING'"
>
{{ r.status }}
</span>
@if (r.checker_name) {
<div class="sub">Checked by {{ r.checker_name }}, {{ r.checker_at | date: 'dd MMM, HH:mm' }}</div>
}
@if (r.rejection_reason || r.failure_message) {
<div class="sub err-text">{{ r.rejection_reason || r.failure_message }}</div>
}
</td>
<td class="actions-cell">
@if (r.status === 'PENDING') {
<div class="i4c-actions">
<button class="approve-btn" (click)="startApprove(r)" [disabled]="busyId === r.id">
<fa-icon icon="check"></fa-icon> Approve
</button>
<button class="reject-btn" (click)="startReject(r)" [disabled]="busyId === r.id">
<fa-icon icon="times"></fa-icon> Reject
</button>
</div>
} @else {
<span class="text-muted">Decision Recorded</span>
}
</td>
</tr>
} @empty {
<tr>
<td colspan="8" class="i4c-empty">
<div class="empty-icon"><fa-icon icon="user-check"></fa-icon></div>
<div>No approval requests in this category.</div>
</td>
</tr>
}
</tbody>
</table>
</div>
<mat-paginator
class="i4c-paginator"
[length]="total"
[pageIndex]="pageIndex"
[pageSize]="pageSize"
[pageSizeOptions]="pageSizes"
(page)="onPage($event)"
showFirstLastButtons
aria-label="Approval requests pages"
></mat-paginator>
</div>
<!-- ========================================================
MODERN POPUP WINDOW: APPROVE CONFIRMATION MODAL
======================================================== -->
@if (approvingRow) {
<div class="i4c-modal-backdrop" (click)="closeApprove()">
<div class="i4c-modal-window i4c-modal-window--compact" (click)="$event.stopPropagation()">
<div class="i4c-modal-header approve-header">
<div class="i4c-modal-header__title">
<fa-icon icon="check-circle" class="text-green"></fa-icon>
<div>
<span>Confirm Checker Approval</span>
<div class="i4c-modal-header__sub">Executing live CBS account action</div>
</div>
</div>
<button class="modal-close-btn" (click)="closeApprove()" aria-label="Close">
<fa-icon icon="times"></fa-icon>
</button>
</div>
<div class="i4c-modal-body">
<div class="approve-card">
<div class="approve-action-badge">
<span
>ACTION: <b>{{ approvingRow.action }}</b></span
>
</div>
<dl class="i4c-kv">
<dt>Account Number</dt>
<dd>
<b>{{ approvingRow.account_no }}</b>
</dd>
<dt>Held Amount</dt>
<dd>
<b class="text-accent">₹{{ approvingRow.amount_held | number: '1.2-2' }}</b>
</dd>
<dt>Reason</dt>
<dd>{{ reasonLabel(approvingRow.reason_type) }}</dd>
<dt>Order Reference</dt>
<dd>
{{ approvingRow.order_reference }}
{{ approvingRow.authority ? '(' + approvingRow.authority + ')' : '' }}
</dd>
<dt>Maker Details</dt>
<dd>{{ approvingRow.maker_name }} on {{ approvingRow.maker_at | date: 'dd MMM yyyy, HH:mm' }}</dd>
</dl>
</div>
<div class="i4c-note warn-note">
<fa-icon icon="exclamation-triangle"></fa-icon>
<span
>Warning: Once confirmed, the hold change or freeze will be executed immediately on the core banking
system.</span
>
</div>
</div>
<div class="i4c-modal-footer">
<button class="footer-btn-close" (click)="closeApprove()">Cancel</button>
<button
mat-flat-button
color="primary"
class="confirm-approve-btn"
(click)="confirmApprove()"
[disabled]="busyId === approvingRow.id"
>
<fa-icon
[icon]="busyId === approvingRow.id ? 'sync' : 'check'"
[animation]="busyId === approvingRow.id ? 'spin' : undefined"
></fa-icon>
Confirm &amp; Approve
</button>
</div>
</div>
</div>
}
<!-- ========================================================
MODERN POPUP WINDOW: REJECT REASON MODAL
======================================================== -->
@if (rejectingRow) {
<div class="i4c-modal-backdrop" (click)="closeReject()">
<div class="i4c-modal-window" (click)="$event.stopPropagation()">
<div class="i4c-modal-header reject-header">
<div class="i4c-modal-header__title">
<fa-icon icon="times-circle" class="text-red"></fa-icon>
<div>
<span>Reject Request #{{ rejectingRow.id }}</span>
<div class="i4c-modal-header__sub">Provide the reason for rejecting this hold action</div>
</div>
</div>
<button class="modal-close-btn" (click)="closeReject()" aria-label="Close">
<fa-icon icon="times"></fa-icon>
</button>
</div>
<div class="i4c-modal-body">
<div class="reject-summary">
<span
>Rejecting: <b>{{ rejectingRow.action }}</b> on Account <b>{{ rejectingRow.account_no }}</b></span
>
</div>
<!-- Preset Reason Chips -->
<div class="preset-box">
<div class="preset-title">Quick Preset Reasons:</div>
<div class="preset-chips">
@for (preset of presetReasons; track preset) {
<button type="button" class="preset-chip" (click)="applyPreset(preset)">
{{ preset }}
</button>
}
</div>
</div>
<!-- Rejection Reason Input -->
<mat-form-field appearance="outline" class="w-full">
<mat-label>Rejection Reason (Required)</mat-label>
<textarea
matInput
rows="3"
[(ngModel)]="rejectReason"
placeholder="Explain why this request is rejected for the maker's audit log..."
maxlength="1000"
></textarea>
<mat-hint align="end">{{ rejectReason.length }}/1000</mat-hint>
</mat-form-field>
</div>
<div class="i4c-modal-footer">
<button class="footer-btn-close" (click)="closeReject()">Cancel</button>
<button
mat-flat-button
color="warn"
class="confirm-reject-btn"
(click)="confirmReject()"
[disabled]="busyId === rejectingRow.id || !rejectReason.trim()"
>
<fa-icon
[icon]="busyId === rejectingRow.id ? 'sync' : 'times'"
[animation]="busyId === rejectingRow.id ? 'spin' : undefined"
></fa-icon>
Confirm Rejection
</button>
</div>
</div>
</div>
}
</div>
@@ -0,0 +1,244 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
@use '../i4c' as *;
@include i4c-skin;
.maker-checker-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 9999px;
background: #f1f5f9;
border: 1px solid #e2e8f0;
font-size: 11px;
font-weight: 600;
color: #475569;
fa-icon {
color: #2563eb;
}
}
.refresh-btn {
height: 36px;
border-radius: 8px;
font-weight: 600;
font-size: 11.5px;
}
.head-hint {
margin-left: auto;
font-size: 11px;
color: #94a3b8;
font-weight: normal;
}
.date-main {
font-weight: 600;
color: #1e293b;
}
.acc-link {
color: #2563eb;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
.text-accent {
color: #0f172a;
}
.reason-title {
font-weight: 600;
color: #1e293b;
}
.remarks-text {
font-size: 12px;
color: #475569;
max-width: 220px;
}
.sub {
font-size: 11px;
color: #64748b;
margin-top: 2px;
}
.err-text {
color: #dc2626;
font-size: 11px;
}
.actions-cell {
min-width: 190px;
}
.approve-btn {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 10px;
border-radius: 6px;
background: #10b981;
color: #fff;
border: none;
font-size: 11px;
font-weight: 700;
cursor: pointer;
transition: all 0.2s;
&:hover:not(:disabled) {
background: #059669;
box-shadow: 0 4px 10px rgb(16 185 129 / 30%);
transform: translateY(-1px);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.reject-btn {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 10px;
border-radius: 6px;
background: #fef2f2;
color: #dc2626;
border: 1px solid #fecaca;
font-size: 11px;
font-weight: 700;
cursor: pointer;
transition: all 0.2s;
&:hover:not(:disabled) {
background: #fee2e2;
transform: translateY(-1px);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
/* Approve Modal styles */
.approve-card {
padding: 12px 14px;
border-radius: 10px;
background: #f8fafc;
border: 1px solid #e2e8f0;
display: flex;
flex-direction: column;
gap: 12px;
}
.approve-action-badge {
font-size: 12px;
letter-spacing: 0.05em;
color: #1e40af;
background: #eff6ff;
padding: 4px 10px;
border-radius: 6px;
align-self: flex-start;
}
.warn-note {
display: flex;
align-items: center;
gap: 10px;
background: #fffbeb;
border-color: #fde68a;
color: #92400e;
}
.text-green {
color: #10b981;
}
.text-red {
color: #ef4444;
}
.text-muted {
color: #94a3b8;
font-size: 12px;
}
/* Preset reasons */
.preset-box {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 12px;
}
.preset-title {
font-size: 12px;
font-weight: 600;
color: #64748b;
}
.preset-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.preset-chip {
padding: 3px 8px;
border-radius: 9999px;
background: #f1f5f9;
border: 1px solid #e2e8f0;
font-size: 10.5px;
color: #334155;
cursor: pointer;
transition: all 0.15s;
&:hover {
background: #e2e8f0;
color: #0f172a;
border-color: #cbd5e1;
}
}
.reject-summary {
font-size: 11.5px;
color: #475569;
margin-bottom: 6px;
}
.w-full {
width: 100%;
}
.footer-btn-close {
padding: 6px 14px;
border-radius: 8px;
background: #e2e8f0;
color: #334155;
border: none;
font-size: 11.5px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: #cbd5e1;
color: #0f172a;
}
}
@@ -0,0 +1,231 @@
/**
* 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 { DatePipe, DecimalPipe } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { RouterLink } from '@angular/router';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { MatSnackBar } from '@angular/material/snack-bar';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { HOLD_REASON_TYPES, I4cService, i4cErrorMessage } from '../i4c.service';
import { ALL_DATES, I4cDateRange, I4cDateRangeComponent } from '../i4c-date-range/i4c-date-range.component';
/**
* Checker's queue: release / freeze / unfreeze requests raised on I4C holds. Approving carries the change out on the
* account at once; the server refuses an approval by the officer who raised the request.
*/
@Component({
selector: 'mifosx-i4c-approvals',
templateUrl: './i4c-approvals.component.html',
styleUrls: ['./i4c-approvals.component.scss'],
imports: [
DatePipe,
DecimalPipe,
FormsModule,
RouterLink,
MatButtonModule,
MatFormFieldModule,
MatInputModule,
MatSelectModule,
MatProgressBarModule,
MatPaginatorModule,
I4cDateRangeComponent,
FaIconComponent
]
})
export class I4cApprovalsComponent implements OnInit {
private i4c = inject(I4cService);
private snackBar = inject(MatSnackBar);
readonly statuses = [
'PENDING',
'APPROVED',
'REJECTED',
'FAILED',
''
];
status = 'PENDING';
search = '';
action = '';
reasonType = '';
readonly reasonTypes = HOLD_REASON_TYPES;
dates: I4cDateRange = { ...ALL_DATES };
total = 0;
pageIndex = 0;
pageSize = 20;
readonly pageSizes = [
10,
20,
50,
100
];
rows: any[] = [];
loading = false;
busyId: number | null = null;
error = '';
// Modern Approval Popup Modal state
approvingRow: any = null;
// Modern Rejection Popup Modal state
rejectingRow: any = null;
rejectReason = '';
readonly presetReasons = [
'Insufficient court / IO documentation provided.',
'Invalid or unverified reference number.',
'Duplicate action request raised.',
'Wrong account number specified in order.',
'Investigation still in progress; hold must remain.'
];
ngOnInit(): void {
this.load();
}
/** Reload the current page; a changed filter passes `fromFirstPage` so the officer lands on page one. */
load(fromFirstPage = false): void {
if (fromFirstPage) {
this.pageIndex = 0;
}
this.loading = true;
this.error = '';
this.i4c
.holdActions({
status: this.status,
search: this.search.trim(),
action: this.action,
reasonType: this.reasonType,
fromDate: this.dates.fromDate,
toDate: this.dates.toDate,
offset: this.pageIndex * this.pageSize,
limit: this.pageSize
})
.subscribe({
next: (page) => {
this.rows = page.pageItems;
this.total = page.totalFilteredRecords;
this.loading = false;
},
error: (e) => {
this.error = i4cErrorMessage(e);
this.loading = false;
}
});
}
setStatus(s: string): void {
this.status = s;
this.load(true);
}
onPage(e: PageEvent): void {
this.pageIndex = e.pageIndex;
this.pageSize = e.pageSize;
this.load();
}
onDates(range: I4cDateRange): void {
this.dates = range;
this.load(true);
}
get hasFilters(): boolean {
return !!(this.search || this.action || this.reasonType || this.dates.preset !== 'ALL');
}
/** Clears the extra filters; the status chip stays as chosen. */
clearFilters(): void {
this.search = '';
this.action = '';
this.reasonType = '';
this.dates = { ...ALL_DATES };
this.load(true);
}
// Open modern approval popup modal
startApprove(row: any): void {
this.approvingRow = row;
}
closeApprove(): void {
this.approvingRow = null;
}
confirmApprove(): void {
if (!this.approvingRow) {
return;
}
const row = this.approvingRow;
this.busyId = row.id;
this.i4c.approveHoldAction(row.id).subscribe({
next: () => {
this.busyId = null;
this.approvingRow = null;
this.snackBar.open('Approved! The action has been executed immediately on the CBS account.', 'OK', {
duration: 5000
});
this.load();
},
error: (e) => {
this.busyId = null;
this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 8000 });
this.load();
}
});
}
// Open modern rejection popup modal
startReject(row: any): void {
this.rejectingRow = row;
this.rejectReason = '';
}
closeReject(): void {
this.rejectingRow = null;
this.rejectReason = '';
}
applyPreset(preset: string): void {
this.rejectReason = preset;
}
confirmReject(): void {
if (!this.rejectingRow) {
return;
}
if (!this.rejectReason.trim()) {
this.snackBar.open('A reason is required to reject.', 'Close', { duration: 4000 });
return;
}
const row = this.rejectingRow;
this.busyId = row.id;
this.i4c.rejectHoldAction(row.id, this.rejectReason.trim()).subscribe({
next: () => {
this.busyId = null;
this.rejectingRow = null;
this.snackBar.open('Request rejected.', 'OK', { duration: 3000 });
this.load();
},
error: (e) => {
this.busyId = null;
this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 });
}
});
}
reasonLabel(value: string): string {
return HOLD_REASON_TYPES.find((r) => r.value === value)?.label || value;
}
}
@@ -0,0 +1,258 @@
<!--
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="i4c-page">
<!-- Top Filters & Search Toolbar -->
<div class="i4c-toolbar">
<div class="i4c-filter-chips">
@for (t of types; track t.value) {
<button class="filter-chip" [class.filter-chip--active]="type === t.value" (click)="setType(t.value)">
<span>{{ t.label }}</span>
@if (type === t.value) {
<span class="filter-chip-count">{{ total }}</span>
}
</button>
}
</div>
<span class="i4c-toolbar__spacer"></span>
<div class="search-box">
<fa-icon icon="search" class="search-icon"></fa-icon>
<input
type="text"
[(ngModel)]="search"
placeholder="Filter by name, code or IFSC prefix..."
(keyup.enter)="load(true)"
class="search-input"
/>
@if (search) {
<button class="search-clear-btn" (click)="search = ''; load(true)" aria-label="Clear search">
<fa-icon icon="times"></fa-icon>
</button>
}
<button mat-flat-button color="primary" class="search-btn" (click)="load(true)" [disabled]="loading">
Search
</button>
</div>
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="ifsc-filter">
<mat-label>IFSC prefix</mat-label>
<mat-select [(ngModel)]="ifsc" (selectionChange)="load(true)">
<mat-option value="">Any</mat-option>
<mat-option value="KNOWN">Prefix known</mat-option>
<mat-option value="UNKNOWN">Prefix not known</mat-option>
</mat-select>
</mat-form-field>
</div>
@if (loading) {
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
}
@if (error) {
<div class="i4c-error">
<fa-icon icon="exclamation-circle"></fa-icon>
<span>{{ error }}</span>
</div>
}
<!-- Main Directory Card -->
<div class="i4c-card">
<div class="i4c-card-head">
<fa-icon icon="university"></fa-icon>
<span>I4C Intermediary &amp; Bank Master Directory</span>
<span class="head-hint">Official MHA-I4C 2.0 routing table for beneficiary &amp; payee bank mapping</span>
<span class="i4c-toolbar-spacer"></span>
<span class="entries-counter">{{ total }} routing codes</span>
</div>
<div class="i4c-table-wrap">
<table class="i4c-table">
<thead>
<tr>
<th class="num">Bank Code</th>
<th>Institution / Intermediary Name</th>
<th>Entity Type</th>
<th>IFSC Prefix Pattern</th>
<th>Routing Standard</th>
<th>Action</th>
</tr>
</thead>
<tbody>
@for (b of rows; track b.bank_code) {
<tr class="clickable" (click)="openModal(b)">
<td class="num">
<span class="code-pill font-mono">#{{ b.bank_code }}</span>
</td>
<td>
<div class="bank-name-cell">
<div class="entity-avatar entity-avatar--{{ b.entity_type?.toLowerCase() }}">
<fa-icon [icon]="getTypeIcon(b.entity_type)"></fa-icon>
</div>
<div class="bank-title-wrap">
<span class="bank-name font-semibold">{{ b.bank_name }}</span>
<span class="bank-code-sub">Reported as payee_bank_code</span>
</div>
</div>
</td>
<td>
<span class="entity-type-badge entity-type-badge--{{ b.entity_type?.toLowerCase() }}">
{{ b.entity_type }}
</span>
</td>
<td>
@if (b.ifsc_prefix) {
<button
class="copy-chip copy-chip--ifsc"
(click)="copyText(b.ifsc_prefix, 'ifsc-' + b.bank_code, $event)"
title="Click to copy IFSC prefix"
>
<fa-icon [icon]="copiedKey === 'ifsc-' + b.bank_code ? 'check' : 'copy'"></fa-icon>
<span class="font-mono">{{ b.ifsc_prefix }}</span>
@if (copiedKey === 'ifsc-' + b.bank_code) {
<span class="copy-success-text">Copied!</span>
}
</button>
} @else {
<span class="text-muted text-xs">Dynamic / Non-IFSC</span>
}
</td>
<td>
<span class="standard-tag"> <fa-icon icon="shield-alt"></fa-icon> MHA-I4C 2.0 </span>
</td>
<td (click)="$event.stopPropagation()">
<button class="table-action-btn" (click)="openModal(b)"><fa-icon icon="eye"></fa-icon> Details</button>
</td>
</tr>
} @empty {
<tr>
<td colspan="6" class="i4c-empty">
<div class="empty-icon"><fa-icon icon="university"></fa-icon></div>
<div>No banking intermediaries found matching "{{ search }}".</div>
</td>
</tr>
}
</tbody>
</table>
</div>
<mat-paginator
class="i4c-paginator"
[length]="total"
[pageIndex]="pageIndex"
[pageSize]="pageSize"
[pageSizeOptions]="pageSizes"
(page)="onPage($event)"
showFirstLastButtons
aria-label="Bank codes pages"
></mat-paginator>
</div>
<!-- ========================================================
MODERN POPUP WINDOW: BANK CODE & ROUTING DETAILS MODAL
======================================================== -->
@if (selectedBankModal) {
<div class="i4c-modal-backdrop" (click)="closeModal()">
<div class="i4c-modal-window i4c-modal-window--wide" (click)="$event.stopPropagation()">
<!-- Modal Header -->
<div class="i4c-modal-header">
<div class="i4c-modal-header-title">
<div
class="entity-avatar entity-avatar--lg entity-avatar--{{ selectedBankModal.entity_type?.toLowerCase() }}"
>
<fa-icon [icon]="getTypeIcon(selectedBankModal.entity_type)"></fa-icon>
</div>
<div>
<div class="modal-title-row">
<span>{{ selectedBankModal.bank_name }}</span>
<span class="entity-type-badge entity-type-badge--{{ selectedBankModal.entity_type?.toLowerCase() }}">
{{ selectedBankModal.entity_type }}
</span>
<span class="code-pill font-mono">Code #{{ selectedBankModal.bank_code }}</span>
</div>
<div class="modal-header-sub">MHA-I4C Intermediary Specification 2.0 Directory Routing Node</div>
</div>
</div>
<button class="modal-close-btn" (click)="closeModal()" aria-label="Close modal">
<fa-icon icon="times"></fa-icon>
</button>
</div>
<!-- Modal Body -->
<div class="i4c-modal-body">
<!-- Information Grid -->
<div class="details-grid">
<div class="detail-card">
<span class="detail-card-label">I4C Root Bank ID</span>
<div class="detail-card-val font-mono">
<span>{{ selectedBankModal.bank_code }}</span>
<button
class="copy-chip copy-chip--sm"
(click)="copyText(selectedBankModal.bank_code, 'bank-code-modal')"
>
<fa-icon [icon]="copiedKey === 'bank-code-modal' ? 'check' : 'copy'"></fa-icon>
{{ copiedKey === 'bank-code-modal' ? 'Copied' : 'Copy' }}
</button>
</div>
<span class="detail-card-sub">Used in root_bankid and payee_bank_code fields</span>
</div>
<div class="detail-card">
<span class="detail-card-label">IFSC Prefix</span>
<div class="detail-card-val font-mono">
<span>{{ selectedBankModal.ifsc_prefix || 'Dynamic Routing' }}</span>
@if (selectedBankModal.ifsc_prefix) {
<button
class="copy-chip copy-chip--sm"
(click)="copyText(selectedBankModal.ifsc_prefix, 'ifsc-modal')"
>
<fa-icon [icon]="copiedKey === 'ifsc-modal' ? 'check' : 'copy'"></fa-icon>
{{ copiedKey === 'ifsc-modal' ? 'Copied' : 'Copy' }}
</button>
}
</div>
<span class="detail-card-sub">Matches starting characters of Indian Financial System Code</span>
</div>
<div class="detail-card">
<span class="detail-card-label">Entity Classification</span>
<div class="detail-card-val">
<span class="entity-type-badge entity-type-badge--{{ selectedBankModal.entity_type?.toLowerCase() }}">
{{ selectedBankModal.entity_type }}
</span>
</div>
<span class="detail-card-sub">Classification recognized by Indian Cyber Crime Coordination Centre</span>
</div>
<div class="detail-card">
<span class="detail-card-label">Supported Layer Depth</span>
<div class="detail-card-val font-semibold text-accent">Layer 1 to Layer 10+</div>
<span class="detail-card-sub">Valid for multi-layered money trail tracing</span>
</div>
</div>
<!-- Section 6 Integration Preview -->
<div class="payload-box">
<div class="payload-box-title">
<span>Section 6 Response Payload Example</span>
<button class="copy-chip" (click)="copyText(sampleJson(selectedBankModal), 'json-modal')">
<fa-icon [icon]="copiedKey === 'json-modal' ? 'check' : 'copy'"></fa-icon>
{{ copiedKey === 'json-modal' ? 'Copied!' : 'Copy Schema Snippet' }}
</button>
</div>
<pre class="i4c-json">{{ sampleJson(selectedBankModal) }}</pre>
</div>
</div>
<!-- Modal Footer -->
<div class="i4c-modal-footer">
<button class="footer-btn-close" (click)="closeModal()">Close</button>
</div>
</div>
</div>
}
</div>
@@ -0,0 +1,360 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
@use '../i4c' as *;
@include i4c-skin;
.search-box {
display: inline-flex;
align-items: center;
background: #fff;
border: 1px solid #cbd5e1;
border-radius: 9px;
padding: 3px 5px 3px 10px;
gap: 6px;
box-shadow: 0 1px 3px rgb(15 23 42 / 5%);
transition: all 0.2s ease;
&:focus-within {
border-color: #2563eb;
box-shadow: 0 0 0 3px rgb(37 99 235 / 15%);
}
.search-icon {
color: #94a3b8;
font-size: 12px;
}
.search-input {
border: none;
outline: none;
font-size: 11.5px;
color: #0f172a;
width: 220px;
background: transparent;
&::placeholder {
color: #94a3b8;
}
}
.search-clear-btn {
background: none;
border: none;
color: #94a3b8;
cursor: pointer;
padding: 3px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
&:hover {
color: #0f172a;
background: #f1f5f9;
}
}
.search-btn {
height: 30px;
border-radius: 7px;
font-weight: 600;
font-size: 11px;
padding: 0 12px;
}
}
.i4c-card-head {
display: flex;
align-items: center;
gap: 8px;
padding: 9px 14px;
background: #fff;
border-bottom: 1px solid #f1f5f9;
font-size: 13px;
font-weight: 700;
color: #0f172a;
fa-icon {
color: #2563eb;
}
.head-hint {
font-size: 11px;
color: #64748b;
font-weight: normal;
}
.i4c-toolbar-spacer {
flex: 1 1 auto;
}
.entries-counter {
font-size: 11px;
font-weight: 600;
color: #475569;
background: #f1f5f9;
padding: 2px 8px;
border-radius: 9999px;
border: 1px solid #e2e8f0;
}
}
.bank-name-cell {
display: flex;
align-items: center;
gap: 10px;
.bank-title-wrap {
display: flex;
flex-direction: column;
gap: 1px;
}
.bank-name {
color: #0f172a;
font-size: 12px;
}
.bank-code-sub {
font-size: 10px;
color: #94a3b8;
}
}
.entity-avatar {
width: 28px;
height: 28px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
background: #f1f5f9;
color: #475569;
border: 1px solid #e2e8f0;
flex-shrink: 0;
&--lg {
width: 36px;
height: 36px;
font-size: 15px;
border-radius: 10px;
}
&--bnk {
background: #eff6ff;
color: #1d4ed8;
border-color: #bfdbfe;
}
&--wlt {
background: #f5f3ff;
color: #7c3aed;
border-color: #ddd6fe;
}
&--mer {
background: #ecfdf5;
color: #059669;
border-color: #a7f3d0;
}
&--ppi {
background: #fffbeb;
color: #d97706;
border-color: #fde68a;
}
}
.entity-type-badge {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 6px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.02em;
background: #f1f5f9;
color: #475569;
border: 1px solid #e2e8f0;
&--bnk {
background: #eff6ff;
color: #1d4ed8;
border-color: #bfdbfe;
}
&--wlt {
background: #f5f3ff;
color: #7c3aed;
border-color: #ddd6fe;
}
&--mer {
background: #ecfdf5;
color: #059669;
border-color: #a7f3d0;
}
&--ppi {
background: #fffbeb;
color: #d97706;
border-color: #fde68a;
}
}
.code-pill {
display: inline-block;
padding: 3px 8px;
border-radius: 6px;
background: #f8fafc;
border: 1px solid #e2e8f0;
color: #0f172a;
font-weight: 700;
font-size: 12px;
}
.copy-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 6px;
background: #f8fafc;
border: 1px solid #cbd5e1;
color: #334155;
font-size: 11px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s ease;
&:hover {
background: #e2e8f0;
color: #0f172a;
}
&--ifsc {
background: #f0fdf4;
border-color: #bbf7d0;
color: #166534;
&:hover {
background: #dcfce7;
}
}
&--sm {
padding: 2px 8px;
font-size: 10px;
}
.copy-success-text {
color: #16a34a;
font-weight: 700;
}
}
.standard-tag {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 11px;
color: #64748b;
font-weight: 600;
}
.modal-title-row {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.modal-header-sub {
font-size: 12px;
color: #94a3b8;
font-weight: normal;
margin-top: 2px;
}
.details-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 10px;
}
.detail-card {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 3px;
&-label {
font-size: 10px;
font-weight: 700;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.03em;
}
&-val {
font-size: 13px;
font-weight: 700;
color: #0f172a;
display: flex;
align-items: center;
gap: 6px;
}
&-sub {
font-size: 10px;
color: #94a3b8;
margin-top: 1px;
}
}
.payload-box {
display: flex;
flex-direction: column;
gap: 6px;
&-title {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 12px;
font-weight: 700;
color: #1e293b;
}
}
.footer-btn-close {
padding: 6px 14px;
border-radius: 8px;
background: #e2e8f0;
color: #334155;
border: none;
font-size: 11.5px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: #cbd5e1;
color: #0f172a;
}
}
.ifsc-filter {
width: 170px;
}
@@ -0,0 +1,167 @@
/**
* 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 { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { I4cService, i4cErrorMessage } from '../i4c.service';
/** The I4C Bank Code Master from the specification: codes the bank reports as root_bankid / payee_bank_code. */
@Component({
selector: 'mifosx-i4c-bank-master',
templateUrl: './i4c-bank-master.component.html',
styleUrls: ['./i4c-bank-master.component.scss'],
imports: [
CommonModule,
FormsModule,
MatButtonModule,
MatFormFieldModule,
MatInputModule,
MatSelectModule,
MatProgressBarModule,
MatPaginatorModule,
FaIconComponent
]
})
export class I4cBankMasterComponent implements OnInit {
private i4c = inject(I4cService);
readonly types = [
{ value: '', label: 'All Entities' },
{ value: 'BNK', label: 'Commercial Banks' },
{ value: 'WLT', label: 'Digital Wallets' },
{ value: 'MER', label: 'Payment Merchants' },
{ value: 'PPI', label: 'Prepaid Instruments' },
{ value: 'CBD', label: 'CBD' },
{ value: 'INS', label: 'Insurance' }
];
search = '';
type = '';
ifsc = '';
total = 0;
pageIndex = 0;
pageSize = 50;
readonly pageSizes = [
25,
50,
100,
200
];
rows: any[] = [];
loading = false;
error = '';
copiedKey = '';
selectedBankModal: any = null;
ngOnInit(): void {
this.load();
}
setType(t: string): void {
if (this.type === t) return;
this.type = t;
this.load(true);
}
onPage(e: PageEvent): void {
this.pageIndex = e.pageIndex;
this.pageSize = e.pageSize;
this.load();
}
/** Reload the current page; a changed filter passes `fromFirstPage` so the officer lands on page one. */
load(fromFirstPage = false): void {
if (fromFirstPage) {
this.pageIndex = 0;
}
this.loading = true;
this.error = '';
this.i4c
.bankMaster({
search: this.search.trim(),
type: this.type,
ifsc: this.ifsc,
offset: this.pageIndex * this.pageSize,
limit: this.pageSize
})
.subscribe({
next: (page) => {
this.rows = page.pageItems;
this.total = page.totalFilteredRecords;
this.loading = false;
},
error: (e) => {
this.error = i4cErrorMessage(e);
this.loading = false;
}
});
}
openModal(bank: any): void {
this.selectedBankModal = bank;
}
closeModal(): void {
this.selectedBankModal = null;
}
copyText(text: string, key: string, e?: MouseEvent): void {
if (e) {
e.stopPropagation();
}
if (!text) return;
navigator.clipboard?.writeText(text);
this.copiedKey = key;
setTimeout(() => {
if (this.copiedKey === key) {
this.copiedKey = '';
}
}, 2000);
}
getTypeIcon(type: string): string {
switch (type?.toUpperCase()) {
case 'BNK':
return 'university';
case 'WLT':
return 'mobile-alt';
case 'MER':
return 'coins';
case 'PPI':
return 'credit-card';
default:
return 'building';
}
}
sampleJson(b: any): string {
if (!b) return '';
const sample = {
layer_number: 1,
transaction_details: {
payee_bank_code: b.bank_code,
payee_bank_name: b.bank_name,
payee_ifsc_prefix: b.ifsc_prefix || 'N/A',
entity_category: b.entity_type
},
mha_routing: {
root_bankid: b.bank_code,
standard: 'I4C-MHA-2.0'
}
};
return JSON.stringify(sample, null, 2);
}
}
@@ -0,0 +1,484 @@
<!--
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="i4c-page">
<!-- Modern Toolbar -->
<div class="i4c-toolbar">
<div class="i4c-toolbar__hint">
<fa-icon icon="info-circle" class="hint-ic"></fa-icon>
<span
>Live NCRP-CFCFRMS Integration: automated complaint processing, targeted account holds &amp; MHA
responses.</span
>
</div>
<span class="i4c-toolbar__spacer"></span>
<button mat-stroked-button class="action-btn" (click)="showConfigModal = true" [disabled]="!data">
<fa-icon icon="cog"></fa-icon> Integration Config
</button>
<button mat-flat-button color="primary" class="action-btn" (click)="load()" [disabled]="loading">
<fa-icon icon="sync" [animation]="loading ? 'spin' : undefined"></fa-icon> Refresh
</button>
</div>
@if (loading) {
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
}
@if (error) {
<div class="i4c-error">
<fa-icon icon="exclamation-circle"></fa-icon>
<span>{{ error }}</span>
</div>
}
@if (data) {
@if (setupGaps.length) {
<div class="setup-banner">
<div class="setup-banner__icon">
<fa-icon icon="exclamation-triangle"></fa-icon>
</div>
<div class="setup-banner__content">
<div class="setup-banner__title">Configuration &amp; Production Setup Needed</div>
<ul class="setup-banner__list">
@for (gap of setupGaps; track gap) {
<li>{{ gap }}</li>
}
</ul>
</div>
</div>
}
<!-- KPI Groups: Complaints, Holds, Needs Action -->
<div class="kpi-groups">
<!-- Section 1: Complaints -->
<section class="kpi-group">
<div class="kpi-group__head">
<fa-icon icon="inbox"></fa-icon>
<span>Complaints</span>
</div>
<div class="kpi-group__row">
<a class="modern-kpi" routerLink="../requests">
<div class="kpi-top">
<span class="kpi-icon-circle kpi-icon-circle--blue"><fa-icon icon="clock"></fa-icon></span>
<span class="kpi-trend">Last 24h</span>
</div>
<span class="modern-kpi__value">{{ data.requestsLast24h }}</span>
<span class="modern-kpi__label">Received (24 h)</span>
<span class="modern-kpi__sub">{{ data.requestsLast30d }} received in 30 days</span>
</a>
<a class="modern-kpi" routerLink="../requests" [class.modern-kpi--warn]="data.requestsPending > 0">
<div class="kpi-top">
<span class="kpi-icon-circle kpi-icon-circle--amber"><fa-icon icon="tasks"></fa-icon></span>
@if (data.requestsPending > 0) {
<span class="kpi-trend kpi-trend--warn">Needs processing</span>
}
</div>
<span class="modern-kpi__value">{{ data.requestsPending }}</span>
<span class="modern-kpi__label">Still Processing</span>
<span class="modern-kpi__sub">queued / retry queue</span>
</a>
</div>
</section>
<!-- Section 2: Holds -->
<section class="kpi-group">
<div class="kpi-group__head">
<fa-icon icon="lock"></fa-icon>
<span>Holds Register</span>
</div>
<div class="kpi-group__row">
<a class="modern-kpi" routerLink="../holds" [queryParams]="{ view: 'ACTIVE' }">
<div class="kpi-top">
<span class="kpi-icon-circle kpi-icon-circle--green"><fa-icon icon="shield-alt"></fa-icon></span>
<span class="kpi-trend kpi-trend--green">Active</span>
</div>
<span class="modern-kpi__value">{{ data.activeHolds }}</span>
<span class="modern-kpi__label">Active Account Holds</span>
<span class="modern-kpi__sub">disputed amounts restricted</span>
</a>
<a class="modern-kpi" routerLink="../holds" [queryParams]="{ view: 'ACTIVE' }">
<div class="kpi-top">
<span class="kpi-icon-circle kpi-icon-circle--blue"><fa-icon icon="coins"></fa-icon></span>
</div>
<span class="modern-kpi__value">₹{{ data.amountHeld | number: '1.2-2' }}</span>
<span class="modern-kpi__label">Total Amount Held</span>
<span class="modern-kpi__sub">customer account balance</span>
</a>
<a
class="modern-kpi"
routerLink="../holds"
[queryParams]="{ view: 'PROSPECTIVE' }"
[class.modern-kpi--warn]="data.prospectiveShortfall > 0"
>
<div class="kpi-top">
<span class="kpi-icon-circle kpi-icon-circle--amber"><fa-icon icon="exclamation-circle"></fa-icon></span>
</div>
<span class="modern-kpi__value">₹{{ data.prospectiveShortfall | number: '1.2-2' }}</span>
<span class="modern-kpi__label">Prospective Shortfall</span>
<span class="modern-kpi__sub">funds pending deposit</span>
</a>
</div>
</section>
<!-- Section 3: Needs Action -->
<section class="kpi-group kpi-group--action">
<div class="kpi-group__head">
<fa-icon icon="bell"></fa-icon>
<span>Needs Immediate Action</span>
</div>
<div class="kpi-group__row">
<a
class="modern-kpi"
routerLink="../holds"
[queryParams]="{ view: 'INTIMATION_DUE' }"
[class.modern-kpi--alert]="data.intimationOverdue > 0"
>
<div class="kpi-top">
<span class="kpi-icon-circle kpi-icon-circle--red"><fa-icon icon="envelope"></fa-icon></span>
@if (data.intimationOverdue > 0) {
<span class="kpi-trend kpi-trend--alert">Overdue</span>
}
</div>
<span class="modern-kpi__value">{{ data.intimationOverdue }}</span>
<span class="modern-kpi__label">Intimation Overdue</span>
<span class="modern-kpi__sub">RBI notification window</span>
</a>
<a
class="modern-kpi"
routerLink="../holds"
[queryParams]="{ view: 'REVIEW_DUE' }"
[class.modern-kpi--warn]="data.holdsReviewDue > 0"
>
<div class="kpi-top">
<span class="kpi-icon-circle kpi-icon-circle--amber"><fa-icon icon="calendar-check"></fa-icon></span>
</div>
<span class="modern-kpi__value">{{ data.holdsReviewDue }}</span>
<span class="modern-kpi__label">90-Day Review Due</span>
<span class="modern-kpi__sub">due within 15 days</span>
</a>
<a class="modern-kpi" routerLink="../approvals" [class.modern-kpi--warn]="data.pendingApprovals > 0">
<div class="kpi-top">
<span class="kpi-icon-circle kpi-icon-circle--purple"><fa-icon icon="user-check"></fa-icon></span>
@if (data.pendingApprovals > 0) {
<span class="kpi-trend kpi-trend--warn">Pending Checker</span>
}
</div>
<span class="modern-kpi__value">{{ data.pendingApprovals }}</span>
<span class="modern-kpi__label">Awaiting Approval</span>
<span class="modern-kpi__sub">hold release / freeze</span>
</a>
<a
class="modern-kpi"
routerLink="../outbox"
[queryParams]="{ status: 'FAILED' }"
[class.modern-kpi--alert]="data.outboxFailed > 0"
>
<div class="kpi-top">
<span class="kpi-icon-circle kpi-icon-circle--red"><fa-icon icon="paper-plane"></fa-icon></span>
@if (data.outboxFailed > 0) {
<span class="kpi-trend kpi-trend--alert">Outbox Fail</span>
}
</div>
<span class="modern-kpi__value">{{ data.outboxFailed }}</span>
<span class="modern-kpi__label">Delivery Failed</span>
<span class="modern-kpi__sub">{{ data.outboxPending }} queued to send</span>
</a>
</div>
</section>
</div>
<!-- Middle Row: API Performance + Complaints Status -->
<div class="dashboard-grid">
<!-- API Performance 30 Days -->
<div class="i4c-card card-perf">
<div class="i4c-card__head">
<fa-icon icon="tachometer-alt"></fa-icon>
<span>API Gateway Performance (Last 30 Days)</span>
<span class="rbi-badge"><fa-icon icon="shield-alt"></fa-icon> RBI Review Periodic Spec</span>
</div>
<div class="i4c-table-wrap">
<table class="i4c-table">
<thead>
<tr>
<th>Direction</th>
<th class="num">Calls</th>
<th class="num">Succeeded</th>
<th class="num">Failed</th>
<th class="num">Success Rate</th>
<th class="num">Avg (ms)</th>
<th class="num">Max (ms)</th>
</tr>
</thead>
<tbody>
@for (row of data.apiPerformance30d; track row.direction) {
<tr>
<td>
<span class="direction-chip direction-chip--{{ row.direction?.toLowerCase() }}">
{{ row.direction === 'IN' ? '↓ Inbound (I4C → Bank)' : '↑ Outbound (Bank → I4C)' }}
</span>
</td>
<td class="num font-semibold">{{ row.calls }}</td>
<td class="num success-num">{{ row.succeeded }}</td>
<td class="num" [class.fail-num]="row.failed > 0">{{ row.failed }}</td>
<td class="num">
@if (successRate(row) === null) {
<span class="text-muted">-</span>
} @else {
<div class="modern-rate">
<div class="modern-rate__bar">
<div class="modern-rate__fill" [style.width.%]="successRate(row)"></div>
</div>
<span class="modern-rate__text">{{ successRate(row) | number: '1.0-1' }}%</span>
</div>
}
</td>
<td class="num latency-pill">{{ row.avg_ms ? row.avg_ms + ' ms' : '-' }}</td>
<td class="num latency-pill">{{ row.max_ms ? row.max_ms + ' ms' : '-' }}</td>
</tr>
} @empty {
<tr>
<td colspan="7" class="i4c-empty">No API calls recorded in the last 30 days.</td>
</tr>
}
</tbody>
</table>
</div>
<div class="card-footer-info">
<div class="footer-stat">
<fa-icon icon="clock"></fa-icon>
<span>Average time from complaint arrival to response delivery:</span>
<b class="stat-highlight">{{
data.avgAckToResponseSeconds === null ? '-' : data.avgAckToResponseSeconds + ' seconds'
}}</b>
</div>
<div class="footer-note">
Note: Failed inbound calls include unauthenticated requests and invalid signature payloads.
</div>
</div>
</div>
<!-- Complaints by Status -->
<div class="i4c-card card-status">
<div class="i4c-card__head">
<fa-icon icon="tasks"></fa-icon>
<span>Complaints by Status</span>
</div>
<div class="status-grid">
@for (row of data.requestsByStatus; track row.status) {
<a class="status-tile" [routerLink]="['../requests']" [queryParams]="{ status: row.status }">
<div class="status-tile__left">
<span class="i4c-badge" [class]="statusClass(row.status)">{{ row.status }}</span>
<span class="status-tile__hint">View complaints</span>
</div>
<span class="status-tile__count">{{ row.count }}</span>
</a>
} @empty {
<div class="i4c-empty">No complaints received yet.</div>
}
</div>
</div>
</div>
<!-- Bottom Row: Findings + Configuration Preview -->
<div class="dashboard-grid dashboard-grid--equal">
<!-- Reported to I4C -->
<div class="i4c-card">
<div class="i4c-card__head">
<fa-icon icon="list-alt"></fa-icon>
<span>Findings Reported to I4C (Section 6)</span>
</div>
<div class="i4c-table-wrap">
<table class="i4c-table">
<thead>
<tr>
<th>Transaction / Hold Type</th>
<th class="num">Entries</th>
<th class="num">Reported Amount</th>
</tr>
</thead>
<tbody>
@for (row of data.findingsByType; track row.txn_type) {
<tr>
<td>
<b>{{ row.txn_type }}</b>
</td>
<td class="num">{{ row.count }}</td>
<td class="num highlight-amt">₹{{ row.amount | number: '1.2-2' }}</td>
</tr>
} @empty {
<tr>
<td colspan="3" class="i4c-empty">No findings reported to I4C yet.</td>
</tr>
}
</tbody>
</table>
</div>
</div>
<!-- Configuration Snapshot -->
<div class="i4c-card">
<div class="i4c-card__head">
<fa-icon icon="cog"></fa-icon>
<span>Live Gateway Configuration</span>
<button class="config-popup-trigger" (click)="showConfigModal = true">
<fa-icon icon="external-link-alt"></fa-icon> Details
</button>
</div>
<div class="config-grid">
<div class="config-tile">
<div class="config-tile__label">Gateway Status</div>
<div class="config-tile__value">
<span class="i4c-badge" [class]="data.configuration.enabled ? 'i4c-badge--green' : 'i4c-badge--red'">
{{ data.configuration.enabled ? 'ENABLED' : 'DISABLED' }}
</span>
</div>
</div>
<div class="config-tile">
<div class="config-tile__label">Tenant</div>
<div class="config-tile__value">{{ data.configuration.tenant }}</div>
</div>
<div class="config-tile">
<div class="config-tile__label">Bank Code (I4C root)</div>
<div class="config-tile__value">
<b>{{ data.configuration.bankCode || 'Not configured' }}</b>
</div>
</div>
<div class="config-tile">
<div class="config-tile__label">Encryption Mode</div>
<div class="config-tile__value">
<span class="crypto-chip">{{ data.configuration.cryptoMode }}</span>
</div>
</div>
<div class="config-tile config-tile--full">
<div class="config-tile__label">I4C Outbound API Base URL</div>
<div class="config-tile__value url-val">
<span>{{ data.configuration.outboundBaseUrl || 'Not configured' }}</span>
@if (data.configuration.outboundBaseUrl) {
<button class="copy-btn" (click)="copyText(data.configuration.outboundBaseUrl, 'url')">
{{ copiedKey === 'url' ? 'Copied!' : 'Copy' }}
</button>
}
</div>
</div>
<div class="config-tile">
<div class="config-tile__label">Review Window</div>
<div class="config-tile__value">{{ data.configuration.holdReviewDays }} Days (90-day cycle)</div>
</div>
<div class="config-tile">
<div class="config-tile__label">Match Window</div>
<div class="config-tile__value">± {{ data.configuration.matchDateWindowDays }} Days</div>
</div>
</div>
</div>
</div>
}
<!-- ========================================================
MODERN POPUP WINDOW: CONFIGURATION DETAILS MODAL
======================================================== -->
@if (showConfigModal && data?.configuration) {
<div class="i4c-modal-backdrop" (click)="showConfigModal = false">
<div class="i4c-modal-window i4c-modal-window--wide" (click)="$event.stopPropagation()">
<div class="i4c-modal-header">
<div class="i4c-modal-header__title">
<fa-icon icon="cog"></fa-icon>
<div>
<span>I4C-MHA Integration Configuration</span>
<div class="i4c-modal-header__sub">
NCRP-CFCFRMS Specification 2.0 Security &amp; Connection Parameters
</div>
</div>
</div>
<button class="modal-close-btn" (click)="showConfigModal = false" aria-label="Close">
<fa-icon icon="times"></fa-icon>
</button>
</div>
<div class="i4c-modal-body">
<dl class="i4c-kv">
<dt>Integration Enabled</dt>
<dd>
<span class="i4c-badge" [class]="data.configuration.enabled ? 'i4c-badge--green' : 'i4c-badge--red'">
{{ data.configuration.enabled ? 'ACTIVE (Online)' : 'DISABLED' }}
</span>
</dd>
<dt>Tenant ID</dt>
<dd>{{ data.configuration.tenant }}</dd>
<dt>Bank Code (I4C ID)</dt>
<dd>
<b>{{ data.configuration.bankCode }}</b>
</dd>
<dt>Inbound Credentials</dt>
<dd>
{{ data.configuration.inboundCredentialsConfigured ? 'Configured' : 'Not configured' }}
@if (data.configuration.inboundPasswordHashed) {
<span class="badge-mini">bcrypt hash active</span>
}
</dd>
<dt>Allowed I4C IPs</dt>
<dd>{{ data.configuration.allowedIpCount || 'Any / All (UAT mode)' }}</dd>
<dt>Payload Encryption</dt>
<dd>
<span class="crypto-chip">{{ data.configuration.cryptoMode }}</span>
@if (data.configuration.cryptoPassphraseConfigured) {
<span class="badge-mini">passphrase active</span>
}
</dd>
<dt>Outbound Base URL</dt>
<dd class="font-mono">{{ data.configuration.outboundBaseUrl || 'Not set' }}</dd>
<dt>Automatic Hold Review</dt>
<dd>{{ data.configuration.holdReviewDays }} days</dd>
<dt>Transaction Match Window</dt>
<dd>± {{ data.configuration.matchDateWindowDays }} days</dd>
<dt>Simulator Status</dt>
<dd>
<span
class="i4c-badge"
[class]="data.configuration.simulatorEnabled ? 'i4c-badge--amber' : 'i4c-badge--green'"
>
{{ data.configuration.simulatorEnabled ? 'SIMULATOR ACTIVE (UAT)' : 'PRODUCTION' }}
</span>
</dd>
</dl>
<div class="i4c-note">
<fa-icon icon="shield-alt"></fa-icon>
<span
>All passwords, encryption keys, and tokens are stored in the server keystore and never revealed on the
UI.</span
>
</div>
</div>
<div class="i4c-modal-footer">
<button class="footer-btn-close" (click)="showConfigModal = false">Close</button>
</div>
</div>
</div>
}
</div>
@@ -0,0 +1,499 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
@use '../i4c' as *;
@include i4c-skin;
/* Setup Banner */
.setup-banner {
display: flex;
gap: 12px;
align-items: flex-start;
padding: 10px 14px;
border: 1px solid #fde68a;
background: linear-gradient(135deg, #fffbeb 0%, #fef3c7 100%);
border-radius: 10px;
box-shadow: 0 4px 10px rgb(245 158 11 / 8%);
&__icon {
font-size: 18px;
color: #d97706;
margin-top: 1px;
}
&__title {
font-weight: 700;
color: #92400e;
font-size: 12.5px;
}
&__list {
margin: 4px 0 0;
padding-left: 18px;
color: #92400e;
font-size: 11.5px;
line-height: 1.5;
}
}
/* KPI Groups */
.kpi-groups {
display: grid;
grid-template-columns: 2fr 3fr 4fr;
gap: 12px;
}
@media (width <= 1280px) {
.kpi-groups {
grid-template-columns: 1fr 1fr;
.kpi-group--action {
grid-column: 1 / -1;
}
}
}
@media (width <= 768px) {
.kpi-groups {
grid-template-columns: 1fr;
}
}
.kpi-group {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
&__head {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #64748b;
fa-icon {
color: #2563eb;
}
}
&__row {
display: grid;
grid-auto-flow: column;
grid-auto-columns: minmax(0, 1fr);
gap: 8px;
}
}
@media (width <= 580px) {
.kpi-group__row {
grid-auto-flow: row;
grid-template-columns: 1fr;
}
}
/* Modern Fintech KPI Tile */
.modern-kpi {
display: flex;
flex-direction: column;
gap: 4px;
padding: 12px 14px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
text-decoration: none;
color: inherit;
box-shadow: 0 3px 10px rgb(15 23 42 / 3%);
transition: all 0.25s cubic-bezier(0.16, 1, 0.3, 1);
position: relative;
overflow: hidden;
&:hover {
transform: translateY(-2px);
border-color: #93c5fd;
box-shadow: 0 8px 16px -3px rgb(37 99 235 / 12%);
}
.kpi-top {
display: flex;
align-items: center;
justify-content: space-between;
}
.kpi-icon-circle {
width: 28px;
height: 28px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
&--blue {
background: #eff6ff;
color: #2563eb;
}
&--green {
background: #ecfdf5;
color: #059669;
}
&--amber {
background: #fffbeb;
color: #d97706;
}
&--red {
background: #fef2f2;
color: #dc2626;
}
&--purple {
background: #f5f3ff;
color: #7c3aed;
}
}
.kpi-trend {
font-size: 9.5px;
font-weight: 700;
text-transform: uppercase;
padding: 1px 6px;
border-radius: 9999px;
background: #f1f5f9;
color: #64748b;
&--green {
background: #ecfdf5;
color: #059669;
}
&--warn {
background: #fffbeb;
color: #d97706;
}
&--alert {
background: #fef2f2;
color: #dc2626;
}
}
&__value {
font-size: 18px;
font-weight: 800;
color: #0f172a;
letter-spacing: -0.02em;
font-variant-numeric: tabular-nums;
}
&__label {
font-size: 11px;
font-weight: 600;
color: #334155;
}
&__sub {
font-size: 10px;
color: #94a3b8;
}
&--warn {
border-color: #fde68a;
background: #fffdf5;
.modern-kpi__value {
color: #b45309;
}
}
&--alert {
border-color: #fecaca;
background: #fffbfb;
.modern-kpi__value {
color: #b91c1c;
}
}
}
/* Dashboard Grid */
.dashboard-grid {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 18px;
&--equal {
grid-template-columns: 1fr 1fr;
}
}
@media (width <= 1024px) {
.dashboard-grid,
.dashboard-grid--equal {
grid-template-columns: 1fr;
}
}
/* API Performance */
.rbi-badge {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 11px;
font-weight: 600;
color: #1e40af;
background: #dbeafe;
padding: 3px 10px;
border-radius: 9999px;
}
.direction-chip {
display: inline-block;
padding: 3px 9px;
border-radius: 6px;
font-size: 11px;
font-weight: 700;
&--in {
background: #eff6ff;
color: #1d4ed8;
}
&--out {
background: #f0fdf4;
color: #15803d;
}
}
.success-num {
color: #059669;
font-weight: 700;
}
.fail-num {
color: #dc2626;
font-weight: 700;
}
.modern-rate {
display: inline-flex;
align-items: center;
gap: 8px;
justify-content: flex-end;
&__bar {
width: 60px;
height: 7px;
background: #fee2e2;
border-radius: 9999px;
overflow: hidden;
}
&__fill {
display: block;
height: 100%;
background: linear-gradient(90deg, #10b981, #059669);
border-radius: 9999px;
}
&__text {
font-size: 12px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
}
.latency-pill {
color: #64748b;
font-size: 12px;
}
.card-footer-info {
padding: 12px 20px;
background: #f8fafc;
border-top: 1px solid #e2e8f0;
display: flex;
flex-direction: column;
gap: 4px;
font-size: 12px;
color: #475569;
.footer-stat {
display: flex;
align-items: center;
gap: 8px;
fa-icon {
color: #3b82f6;
}
}
.stat-highlight {
color: #0f172a;
font-weight: 700;
}
.footer-note {
font-size: 11px;
color: #94a3b8;
}
}
/* Status Tile List */
.status-grid {
display: flex;
flex-direction: column;
gap: 8px;
padding: 16px;
}
.status-tile {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
border-radius: 12px;
border: 1px solid #f1f5f9;
background: #f8fafc;
text-decoration: none;
color: inherit;
transition: all 0.2s;
&:hover {
background: #fff;
border-color: #cbd5e1;
box-shadow: 0 4px 10px rgb(15 23 42 / 5%);
transform: translateY(-1px);
}
&__left {
display: flex;
flex-direction: column;
gap: 4px;
}
&__hint {
font-size: 11px;
color: #94a3b8;
}
&__count {
font-size: 18px;
font-weight: 800;
color: #0f172a;
font-variant-numeric: tabular-nums;
}
}
/* Configuration Grid */
.config-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
padding: 16px 20px;
}
.config-tile {
display: flex;
flex-direction: column;
gap: 4px;
&--full {
grid-column: 1 / -1;
}
&__label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #94a3b8;
}
&__value {
font-size: 13px;
color: #0f172a;
font-weight: 500;
}
.url-val {
display: flex;
align-items: center;
gap: 8px;
font-family: monospace;
font-size: 12px;
word-break: break-all;
}
}
.config-popup-trigger {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 10px;
border-radius: 8px;
background: #f1f5f9;
border: 1px solid #cbd5e1;
color: #2563eb;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: #e2e8f0;
color: #1d4ed8;
}
}
.crypto-chip {
padding: 2px 8px;
border-radius: 6px;
background: #eff6ff;
color: #1e40af;
font-size: 11px;
font-weight: 700;
}
.copy-btn {
padding: 2px 8px;
border-radius: 6px;
background: #2563eb;
color: #fff;
border: none;
font-size: 10px;
font-weight: 600;
cursor: pointer;
&:hover {
background: #1d4ed8;
}
}
.badge-mini {
display: inline-block;
padding: 1px 6px;
border-radius: 4px;
background: #f1f5f9;
font-size: 11px;
color: #64748b;
margin-left: 6px;
}
.highlight-amt {
color: #0f172a;
font-weight: 700;
}
@@ -0,0 +1,126 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { DecimalPipe } from '@angular/common';
import { RouterLink } from '@angular/router';
import { MatButtonModule } from '@angular/material/button';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { I4cService, i4cErrorMessage } from '../i4c.service';
/**
* Integration health at a glance, and the API performance figures the RBI letter (05 Dec 2025, para 4) asks to be
* reviewed periodically: calls, failures and response times in each direction.
*/
@Component({
selector: 'mifosx-i4c-dashboard',
templateUrl: './i4c-dashboard.component.html',
styleUrls: ['./i4c-dashboard.component.scss'],
imports: [
DecimalPipe,
RouterLink,
MatButtonModule,
MatProgressBarModule,
FaIconComponent
]
})
export class I4cDashboardComponent implements OnInit {
private i4c = inject(I4cService);
data: any = null;
loading = false;
error = '';
showConfigModal = false;
copiedKey = '';
ngOnInit(): void {
this.load();
}
load(): void {
this.loading = true;
this.error = '';
this.i4c.dashboard().subscribe({
next: (d) => {
this.data = d;
this.loading = false;
},
error: (e) => {
this.error = i4cErrorMessage(e);
this.loading = false;
}
});
}
copyText(text: string, key: string): void {
if (!text) {
return;
}
navigator.clipboard.writeText(text);
this.copiedKey = key;
setTimeout(() => {
if (this.copiedKey === key) {
this.copiedKey = '';
}
}, 2000);
}
statusClass(status: string): string {
switch (status) {
case 'PROCESSED':
return 'i4c-badge--green';
case 'RETRY':
return 'i4c-badge--red';
case 'RECEIVED':
case 'PROCESSING':
return 'i4c-badge--amber';
default:
return '';
}
}
/** Success rate of calls in one direction, as a percentage; null when there were none. */
successRate(row: any): number | null {
const calls = Number(row?.calls || 0);
return calls === 0 ? null : (Number(row?.succeeded || 0) * 100) / calls;
}
/** What still stands between this bank and live traffic, worked out from the configuration. */
get setupGaps(): string[] {
const c = this.data?.configuration;
if (!c) {
return [];
}
const gaps: string[] = [];
if (!c.enabled) {
gaps.push('Integration is switched off (fineract.i4c.enabled=false): I4C calls are refused with 503.');
}
if (!c.inboundCredentialsConfigured) {
gaps.push('No inbound username/password set for I4C to obtain a token.');
} else if (!c.inboundPasswordHashed) {
gaps.push('Inbound password is configured in plain text; set the bcrypt hash for production.');
}
if (!c.allowedIpCount) {
gaps.push('No I4C source IP allowlist: any address may call (UAT only).');
}
if (c.cryptoMode !== 'NONE' && !c.cryptoPassphraseConfigured) {
gaps.push('Encryption mode is ' + c.cryptoMode + ' but no passphrase is configured.');
}
if (!c.outboundConfigured) {
gaps.push('I4C response URL is not configured: replies wait in the outbox.');
}
if (!c.bankCode) {
gaps.push("This bank's I4C bank code is not set (fineract.i4c.bank-code); root_bankid is sent as 0.");
}
if (c.simulatorEnabled) {
gaps.push('The local I4C simulator is enabled; switch it off in production.');
}
return gaps;
}
}
@@ -0,0 +1,49 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="date-range" role="group" [attr.aria-label]="label + ' filter'">
<span class="date-range__label"><fa-icon icon="calendar-alt"></fa-icon> {{ label }}</span>
<div class="date-range__presets">
@for (p of presets; track p.value) {
<button
type="button"
class="date-chip"
[class.date-chip--active]="range.preset === p.value"
[attr.aria-pressed]="range.preset === p.value"
(click)="pick(p.value)"
>
{{ p.label }}
</button>
}
</div>
@if (range.preset !== 'ALL') {
<div class="date-range__custom">
<label>
From
<input
type="date"
[max]="range.toDate || today"
[(ngModel)]="range.fromDate"
(change)="customChanged()"
[attr.aria-label]="label + ' from'"
/>
</label>
<label>
To
<input
type="date"
[min]="range.fromDate"
[max]="today"
[(ngModel)]="range.toDate"
(change)="customChanged()"
[attr.aria-label]="label + ' to'"
/>
</label>
</div>
}
</div>
@@ -0,0 +1,95 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
:host {
display: block;
}
.date-range {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 12px;
&__label {
font-size: 12px;
font-weight: 600;
color: #475569;
display: inline-flex;
align-items: center;
gap: 6px;
}
&__presets {
display: inline-flex;
flex-wrap: wrap;
gap: 4px;
padding: 3px;
border-radius: 999px;
background: #f1f5f9;
}
&__custom {
display: inline-flex;
flex-wrap: wrap;
gap: 8px;
label {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: #475569;
}
input {
height: 32px;
padding: 0 8px;
border: 1px solid #cbd5e1;
border-radius: 8px;
font: inherit;
font-size: 13px;
color: #0f172a;
background: #fff;
&:focus-visible {
outline: 2px solid #2563eb;
outline-offset: 1px;
}
}
}
}
.date-chip {
border: 0;
background: transparent;
border-radius: 999px;
padding: 4px 12px;
font-size: 12px;
font-weight: 600;
color: #475569;
cursor: pointer;
&:hover {
color: #1d4ed8;
}
&:focus-visible {
outline: 2px solid #2563eb;
outline-offset: 1px;
}
&--active {
background: #2563eb;
color: #fff;
&:hover {
color: #fff;
}
}
}
@@ -0,0 +1,102 @@
/**
* 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, Output } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
export type I4cDatePreset = 'ALL' | 'TODAY' | 'YESTERDAY' | 'WEEK' | 'MONTH' | 'CUSTOM';
/** A date range as the list APIs take it: yyyy-MM-dd, both days included, empty for "no limit". */
export interface I4cDateRange {
preset: I4cDatePreset;
fromDate: string;
toDate: string;
}
export const ALL_DATES: I4cDateRange = { preset: 'ALL', fromDate: '', toDate: '' };
/** yyyy-MM-dd in the browser's own time zone (toISOString would shift to UTC). */
function iso(d: Date): string {
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
/** The days a preset stands for, worked out from today. */
export function presetRange(preset: I4cDatePreset): I4cDateRange {
const today = new Date();
const day = (offset: number) => new Date(today.getFullYear(), today.getMonth(), today.getDate() + offset);
switch (preset) {
case 'TODAY':
return { preset, fromDate: iso(today), toDate: iso(today) };
case 'YESTERDAY':
return { preset, fromDate: iso(day(-1)), toDate: iso(day(-1)) };
case 'WEEK': {
// The week starts on Monday.
const sinceMonday = (today.getDay() + 6) % 7;
return { preset, fromDate: iso(day(-sinceMonday)), toDate: iso(today) };
}
case 'MONTH':
return { preset, fromDate: iso(new Date(today.getFullYear(), today.getMonth(), 1)), toDate: iso(today) };
default:
return { ...ALL_DATES };
}
}
/**
* Date filter shared by the I4C lists: All, Today, Yesterday, This Week, This Month, or a custom From / To. Emits the
* new range whenever it changes; the page then reloads from page one.
*/
@Component({
selector: 'mifosx-i4c-date-range',
templateUrl: './i4c-date-range.component.html',
styleUrls: ['./i4c-date-range.component.scss'],
imports: [
FormsModule,
FaIconComponent
]
})
export class I4cDateRangeComponent {
@Input() label = 'Date';
@Input() range: I4cDateRange = { ...ALL_DATES };
@Output() rangeChange = new EventEmitter<I4cDateRange>();
readonly presets: { value: I4cDatePreset; label: string }[] = [
{ value: 'ALL', label: 'All' },
{ value: 'TODAY', label: 'Today' },
{ value: 'YESTERDAY', label: 'Yesterday' },
{ value: 'WEEK', label: 'This Week' },
{ value: 'MONTH', label: 'This Month' },
{ value: 'CUSTOM', label: 'Custom' }
];
readonly today = iso(new Date());
pick(preset: I4cDatePreset): void {
if (preset === 'CUSTOM') {
// Keep the days already shown so the officer can adjust them.
this.range = { ...this.range, preset };
return;
}
this.emit(presetRange(preset));
}
customChanged(): void {
const { fromDate, toDate } = this.range;
if (fromDate && toDate && toDate < fromDate) {
// A reversed range would match nothing; read it the way it was meant.
this.range = { preset: 'CUSTOM', fromDate: toDate, toDate: fromDate };
}
this.emit({ ...this.range, preset: 'CUSTOM' });
}
private emit(range: I4cDateRange): void {
this.range = range;
this.rangeChange.emit(range);
}
}
@@ -0,0 +1,83 @@
<!--
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="docs">
@if (missing.length) {
<div class="docs__missing">
<fa-icon icon="exclamation-triangle"></fa-icon>
Still needed:
@for (m of missing; track m; let last = $last) {
<b>{{ label(m) }}</b
>{{ last ? '' : ', ' }}
}
</div>
}
<table class="i4c-table">
<thead>
<tr>
<th>Type</th>
<th>File</th>
<th>Remarks</th>
<th>Uploaded</th>
<th></th>
</tr>
</thead>
<tbody>
@for (d of rows; track d.id) {
<tr>
<td>{{ label(d.doc_type) }}</td>
<td>
<span class="docs__name">{{ d.file_name }}</span>
<div class="docs__sub" [title]="'SHA-256 ' + d.sha256">
{{ size(d.size_bytes) }} · {{ d.sha256.slice(0, 12) }}…
</div>
</td>
<td>{{ d.remarks || '-' }}</td>
<td>
{{ d.uploaded_at | date: 'dd MMM yyyy, HH:mm' }}
<div class="docs__sub">{{ d.uploaded_by }}</div>
</td>
<td>
<button mat-stroked-button type="button" (click)="download(d)">
<fa-icon icon="download"></fa-icon> Download
</button>
</td>
</tr>
} @empty {
<tr>
<td colspan="5" class="i4c-empty">No documents attached yet.</td>
</tr>
}
</tbody>
</table>
@if (canUpload) {
<div class="docs__upload">
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Document type</mat-label>
<mat-select [(ngModel)]="docType">
@for (t of types; track t.value) {
<mat-option [value]="t.value">{{ t.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="docs__remarks">
<mat-label>Remarks (order no., date…)</mat-label>
<input matInput [(ngModel)]="remarks" maxlength="500" />
</mat-form-field>
<label class="docs__file">
<input #fileInput type="file" accept="application/pdf,image/jpeg,image/png" (change)="pick($event)" />
</label>
<button mat-flat-button color="primary" type="button" (click)="upload(fileInput)" [disabled]="uploading || !file">
<fa-icon icon="upload"></fa-icon> {{ uploading ? 'Uploading…' : 'Attach' }}
</button>
<span class="docs__sub">PDF, JPEG or PNG, up to 5 MB. Stored with a SHA-256; never changed once attached.</span>
</div>
}
</div>
@@ -0,0 +1,66 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
@use '../i4c' as *;
@include i4c-skin;
:host {
display: block;
}
.docs {
display: flex;
flex-direction: column;
gap: 10px;
&__missing {
padding: 8px 12px;
border-radius: 8px;
border: 1px solid #fde68a;
background: #fffbeb;
color: #92400e;
font-size: 12.5px;
display: flex;
flex-wrap: wrap;
gap: 4px;
align-items: center;
}
&__name {
font-weight: 600;
overflow-wrap: anywhere;
}
&__sub {
font-size: 11px;
color: #64748b;
}
&__upload {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
padding-top: 10px;
border-top: 1px solid #f1f5f9;
mat-form-field {
width: 200px;
}
}
&__remarks {
flex: 1 1 220px;
}
&__file input {
font-size: 12px;
max-width: 240px;
}
}
@@ -0,0 +1,174 @@
/**
* 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, OnChanges, Output, inject } from '@angular/core';
import { DatePipe } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { MatSnackBar } from '@angular/material/snack-bar';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { DOCUMENT_TYPES, I4cDocument, I4cDocumentOwner, I4cService, i4cErrorMessage, saveBlob } from '../i4c.service';
const MAX_BYTES = 5 * 1024 * 1024;
const ACCEPTED = [
'application/pdf',
'image/jpeg',
'image/png'
];
/**
* Documents behind a hold, grievance or restoration: list, download, and upload (PDF / JPEG / PNG up to 5 MB). Shows
* which of the {@link required} types are still missing.
*/
@Component({
selector: 'mifosx-i4c-documents',
templateUrl: './i4c-documents.component.html',
styleUrls: ['./i4c-documents.component.scss'],
imports: [
DatePipe,
FormsModule,
MatButtonModule,
MatFormFieldModule,
MatInputModule,
MatSelectModule,
FaIconComponent
]
})
export class I4cDocumentsComponent implements OnChanges {
private i4c = inject(I4cService);
private snackBar = inject(MatSnackBar);
@Input({ required: true }) ownerType!: I4cDocumentOwner;
@Input({ required: true }) ownerId!: number;
/** Document types this record needs; missing ones are flagged. */
@Input() required: string[] = [];
/** Types offered first in the picker. */
@Input() suggested: string[] = [];
@Input() canUpload = true;
/** Documents already loaded with the record, to avoid a second call. */
@Input() documents: I4cDocument[] | null = null;
@Output() changed = new EventEmitter<void>();
rows: I4cDocument[] = [];
docType = '';
remarks = '';
file: File | null = null;
uploading = false;
ngOnChanges(): void {
if (this.documents) {
this.rows = this.documents;
} else {
this.reload();
}
if (!this.docType) {
this.docType = this.suggested[0] || this.missing[0] || 'OTHER';
}
}
get types(): { value: string; label: string }[] {
const first = DOCUMENT_TYPES.filter((t) => this.suggested.includes(t.value) || this.required.includes(t.value));
return [
...first,
...DOCUMENT_TYPES.filter((t) => !first.includes(t))
];
}
get missing(): string[] {
return this.required.filter((t) => !this.rows.some((d) => d.doc_type === t));
}
label(type: string): string {
return DOCUMENT_TYPES.find((t) => t.value === type)?.label || type;
}
reload(): void {
if (!this.ownerId) {
return;
}
this.i4c.documents(this.ownerType, this.ownerId).subscribe({
next: (rows) => (this.rows = rows),
error: (e) => this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 })
});
}
pick(event: Event): void {
const input = event.target as HTMLInputElement;
const f = input.files?.[0] || null;
if (f && !ACCEPTED.includes(f.type)) {
this.snackBar.open('Only PDF, JPEG and PNG files can be attached.', 'Close', { duration: 5000 });
input.value = '';
return;
}
if (f && f.size > MAX_BYTES) {
this.snackBar.open('A document can be at most 5 MB.', 'Close', { duration: 5000 });
input.value = '';
return;
}
this.file = f;
}
upload(input: HTMLInputElement): void {
if (!this.file || !this.docType) {
this.snackBar.open('Choose the document type and the file.', 'Close', { duration: 4000 });
return;
}
const file = this.file;
this.uploading = true;
const reader = new FileReader();
reader.onload = () => {
const dataUrl = String(reader.result || '');
const content = dataUrl.substring(dataUrl.indexOf(',') + 1);
this.i4c
.uploadDocument({
ownerType: this.ownerType,
ownerId: this.ownerId,
docType: this.docType,
fileName: file.name,
contentType: file.type,
content,
remarks: this.remarks.trim() || undefined
})
.subscribe({
next: () => {
this.uploading = false;
this.file = null;
this.remarks = '';
input.value = '';
this.snackBar.open(this.label(this.docType) + ' attached.', 'OK', { duration: 3000 });
this.documents = null;
this.reload();
this.changed.emit();
},
error: (e) => {
this.uploading = false;
this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 });
}
});
};
reader.onerror = () => {
this.uploading = false;
this.snackBar.open('The file could not be read.', 'Close', { duration: 5000 });
};
reader.readAsDataURL(file);
}
download(d: I4cDocument): void {
this.i4c.downloadDocument(d.id).subscribe({
next: (blob) => saveBlob(blob, d.file_name),
error: (e) => this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 })
});
}
size(bytes: number): string {
return bytes < 1024 * 1024 ? Math.ceil(bytes / 1024) + ' KB' : (bytes / 1024 / 1024).toFixed(1) + ' MB';
}
}
@@ -0,0 +1,558 @@
<!--
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="i4c-page">
<div class="i4c-toolbar">
<div class="i4c-filter-chips">
<button class="filter-chip" [class.filter-chip--active]="stage === 'OPEN'" (click)="setStage('OPEN')">
<span>All open</span>
</button>
@for (s of stages; track s.value) {
<button class="filter-chip" [class.filter-chip--active]="stage === s.value" (click)="setStage(s.value)">
<span>{{ s.label }}</span>
</button>
}
<button class="filter-chip" [class.filter-chip--active]="stage === ''" (click)="setStage('')">
<span>All</span>
</button>
</div>
<span class="i4c-toolbar__spacer"></span>
<span class="count-tag">{{ total }} grievances</span>
</div>
<div class="i4c-filter-bar">
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="search-input">
<mat-label>GR id / account / ack no. / customer</mat-label>
<input matInput [(ngModel)]="search" (keyup.enter)="load(true)" (blur)="load(true)" />
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Outcome</mat-label>
<mat-select [(ngModel)]="status" (selectionChange)="load(true)">
<mat-option value="">Any</mat-option>
<mat-option value="OPEN">Pending decision</mat-option>
<mat-option value="ALLOWED">Allowed (order given)</mat-option>
<mat-option value="REJECTED">Rejected</mat-option>
<mat-option value="COURT">Left to the court</mat-option>
<mat-option value="WITHDRAWN">Withdrawn</mat-option>
</mat-select>
</mat-form-field>
<mat-checkbox [(ngModel)]="overdueOnly" (change)="load(true)">Past deadline only</mat-checkbox>
<mifosx-i4c-date-range label="Raised" [range]="dates" (rangeChange)="onDates($event)"></mifosx-i4c-date-range>
<button mat-stroked-button class="i4c-filter-bar__clear" (click)="clearFilters()" [disabled]="!hasFilters">
<fa-icon icon="times"></fa-icon> Clear filters
</button>
</div>
@if (loading) {
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
}
@if (error) {
<div class="i4c-error">
<fa-icon icon="exclamation-circle"></fa-icon> <span>{{ error }}</span>
</div>
}
<div class="i4c-card">
<div class="i4c-card__head">
<fa-icon icon="balance-scale"></fa-icon>
<span>GRM Grievances (NCRP Grievance Redressal Module)</span>
<span class="head-hint"
>Raise a grievance from the hold (Holds Register → hold → GRM / MRM). Click a row to work on it.</span
>
</div>
<div class="i4c-table-wrap">
<table class="i4c-table">
<thead>
<tr>
<th>Raised</th>
<th>GR id</th>
<th>Account / Customer</th>
<th>Customer asks</th>
<th class="num">Held (₹)</th>
<th>Stage</th>
<th>Deadline</th>
<th>Outcome</th>
</tr>
</thead>
<tbody>
@for (g of rows; track g.id) {
<tr class="clickable" (click)="open(g.id)">
<td>
{{ g.created_at | date: 'dd MMM yyyy' }}
<div class="sub">{{ g.created_by }}</div>
</td>
<td>
<b>{{ g.grievance_no || 'Draft #' + g.id }}</b>
</td>
<td>
<b>{{ g.account_no }}</b>
<div class="sub">{{ g.client_name }} · Ack {{ g.acknowledgement_no }}</div>
</td>
<td>{{ requestLabel(g.customer_request) }}</td>
<td class="num">{{ g.amount_held | number: '1.2-2' }}</td>
<td>
<span class="i4c-badge">{{ stageLabel(g.stage) }}</span>
</td>
<td>
@if (g.compliance_due_at && g.stage !== 'CLOSED') {
<span class="deadline" [class.deadline--late]="g.compliance_overdue">
Carry out by {{ g.compliance_due_at | date: 'dd MMM, HH:mm' }}
</span>
} @else if (g.due_at) {
<span class="deadline" [class.deadline--late]="g.overdue">{{ g.due_at | date: 'dd MMM yyyy' }}</span>
} @else {
-
}
</td>
<td>
<span
class="i4c-badge"
[class.i4c-badge--green]="g.status === 'ALLOWED'"
[class.i4c-badge--red]="g.status === 'REJECTED' || g.status === 'COURT'"
[class.i4c-badge--amber]="g.status === 'OPEN'"
>{{ g.status }}</span
>
@if (g.decision) {
<div class="sub">{{ g.decision_level }}: {{ decisionLabel(g.decision) }}</div>
}
</td>
</tr>
} @empty {
<tr>
<td colspan="8" class="i4c-empty">No grievances match these filters.</td>
</tr>
}
</tbody>
</table>
</div>
<mat-paginator
class="i4c-paginator"
[length]="total"
[pageIndex]="pageIndex"
[pageSize]="pageSize"
[pageSizeOptions]="pageSizes"
(page)="onPage($event)"
showFirstLastButtons
aria-label="Grievance pages"
></mat-paginator>
</div>
@if (selected; as g) {
<div class="i4c-modal-backdrop" (click)="close()">
<div class="i4c-modal-window i4c-modal-window--wide" (click)="$event.stopPropagation()">
<div class="i4c-modal-header">
<div class="i4c-modal-header__title">
<fa-icon icon="balance-scale"></fa-icon>
<div>
<div class="modal-title-row">
<span>{{ g.grievance_no || 'Draft grievance #' + g.id }}</span>
<span class="i4c-badge">{{ stageLabel(g.stage) }}</span>
<span
class="i4c-badge"
[class.i4c-badge--green]="g.status === 'ALLOWED'"
[class.i4c-badge--red]="g.status === 'REJECTED' || g.status === 'COURT'"
>{{ g.status }}</span
>
@if (g.overdue || g.compliance_overdue) {
<span class="i4c-badge i4c-badge--red">PAST DEADLINE</span>
}
</div>
<div class="i4c-modal-header__sub">
Account <b>{{ g.account_no }}</b> ({{ g.client_name }}) · Ack {{ g.acknowledgement_no }} · Held ₹{{
g.amount_held | number: '1.2-2'
}}{{ g.debit_frozen ? ' · debits frozen' : '' }}
</div>
</div>
</div>
<div class="i4c-modal-header__actions">
<button class="modal-close-btn" (click)="close()" aria-label="Close">
<fa-icon icon="times"></fa-icon>
</button>
</div>
</div>
<div class="i4c-modal-tabs">
<button class="modal-tab" [class.modal-tab--active]="tab === 'workflow'" (click)="tab = 'workflow'">
<fa-icon icon="tasks"></fa-icon> Next step
</button>
<button class="modal-tab" [class.modal-tab--active]="tab === 'overview'" (click)="tab = 'overview'">
<fa-icon icon="info-circle"></fa-icon> Details &amp; EDD
</button>
<button class="modal-tab" [class.modal-tab--active]="tab === 'documents'" (click)="tab = 'documents'">
<fa-icon icon="paperclip"></fa-icon> Documents ({{ g.documents?.length || 0 }})
</button>
<button class="modal-tab" [class.modal-tab--active]="tab === 'history'" (click)="tab = 'history'">
<fa-icon icon="history"></fa-icon> History ({{ g.events?.length || 0 }})
</button>
</div>
<div class="i4c-modal-body">
<div class="flow">
@for (f of flow(g); track f.value; let last = $last) {
<span
class="flow__stage"
[class.flow__stage--done]="f.state === 'done'"
[class.flow__stage--now]="f.state === 'now'"
>{{ f.label }}</span
>
@if (!last) {
<span class="flow__arrow">›</span>
}
}
</div>
@if (tab === 'workflow') {
<div class="i4c-card">
<div class="i4c-card__body">
@if (g.stage === 'DRAFT') {
@if (!g.edd_done) {
<div class="step">
<div class="step__title">1. Enhanced due diligence (before raising on GRM)</div>
<div class="step__row">
<mat-checkbox [(ngModel)]="edd.kycVerified">KYC re-verified</mat-checkbox>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Risk</mat-label>
<mat-select [(ngModel)]="edd.risk">
<mat-option value="LOW">Low</mat-option>
<mat-option value="MEDIUM">Medium</mat-option>
<mat-option value="HIGH">High</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Bank's view</mat-label>
<mat-select [(ngModel)]="edd.recommendation">
<mat-option value="SUPPORT">Supports the request</mat-option>
<mat-option value="NOT_SUPPORT">Does not support it</mat-option>
</mat-select>
</mat-form-field>
</div>
<mat-form-field appearance="outline" class="step__wide">
<mat-label>Source of the disputed credit, as the customer explains it *</mat-label>
<textarea matInput rows="2" [(ngModel)]="edd.sourceOfFunds" maxlength="2000"></textarea>
</mat-form-field>
<mat-form-field appearance="outline" class="step__wide">
<mat-label>Customer's statement / notes</mat-label>
<textarea matInput rows="2" [(ngModel)]="edd.customerStatement" maxlength="2000"></textarea>
</mat-form-field>
<div class="i4c-actions">
<button mat-flat-button color="primary" (click)="saveEdd()" [disabled]="busy">
<fa-icon icon="user-shield"></fa-icon> Save due diligence
</button>
</div>
</div>
} @else {
<div class="step">
<div class="step__title">2. Raise it on GRM, then record the GR id</div>
<div class="sub">
GRM's entry form needs the KYC document, the customer's application, a photograph and the bank
statement. Attach them on the Documents tab (the statement can be downloaded from the hold).
</div>
@if (missingDocs(g).length) {
<div class="i4c-note">Still to attach: {{ missingDocs(g).join(', ') }}</div>
}
<div class="step__row">
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>GR id from GRM *</mat-label>
<input matInput [(ngModel)]="grievanceNo" maxlength="40" placeholder="GR003260316001" />
</mat-form-field>
<button
mat-flat-button
color="primary"
(click)="submit()"
[disabled]="busy || !grievanceNo.trim()"
>
<fa-icon icon="paper-plane"></fa-icon> Mark raised on GRM
</button>
</div>
</div>
}
}
@if (g.stage === 'NODAL_REVIEW') {
<div class="step">
<div class="step__title">Bank GRM nodal officer's decision</div>
<mat-form-field appearance="outline" class="step__wide">
<mat-label>Remarks (required to reject)</mat-label>
<input matInput [(ngModel)]="remarks" maxlength="2000" />
</mat-form-field>
<div class="i4c-actions">
<button mat-flat-button color="primary" (click)="nodal(true)" [disabled]="busy">
<fa-icon icon="check"></fa-icon> Approved on GRM (to IO)
</button>
<button
mat-stroked-button
color="warn"
(click)="nodal(false)"
[disabled]="busy || !remarks.trim()"
>
<fa-icon icon="times"></fa-icon> Rejected by nodal
</button>
</div>
</div>
}
@if (withOfficer && g.status === 'OPEN') {
<div class="step">
<div class="step__title">
Decision of the {{ level }}
@if (g.due_at) {
<span class="deadline" [class.deadline--late]="isLate(g.due_at)">
· due {{ g.due_at | date: 'dd MMM yyyy' }}</span
>
}
</div>
<div class="step__row">
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="step__wide">
<mat-label>Decision on GRM</mat-label>
<mat-select [(ngModel)]="decision.decision">
@for (d of decisions; track d.value) {
<mat-option [value]="d.value">{{ d.label }}</mat-option>
}
</mat-select>
</mat-form-field>
@if (decision.decision === 'PARTIAL_HOLD') {
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Amount to keep on hold (₹)</mat-label>
<input matInput type="number" min="0" [(ngModel)]="decision.keepAmount" />
</mat-form-field>
}
@if (decision.decision !== 'SEEK_INFO' && decision.decision !== 'REJECTED_CONTINUE') {
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Order no. *</mat-label>
<input matInput [(ngModel)]="decision.orderRef" maxlength="200" />
</mat-form-field>
}
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Order / decision date</mat-label>
<input matInput type="date" [(ngModel)]="decision.orderDate" />
</mat-form-field>
</div>
<mat-form-field appearance="outline" class="step__wide">
<mat-label
>Remarks{{ decision.decision === 'REJECTED_CONTINUE' ? ' (reason given) *' : '' }}</mat-label
>
<input matInput [(ngModel)]="decision.remarks" maxlength="2000" />
</mat-form-field>
@if (decision.decision !== 'SEEK_INFO' && decision.decision !== 'REJECTED_CONTINUE') {
<div class="sub">
Attach the signed and stamped order copy (GRM order or IO order) on the Documents tab first.
</div>
}
<div class="i4c-actions">
<button mat-flat-button color="primary" (click)="decide()" [disabled]="busy">
<fa-icon icon="gavel"></fa-icon> Record decision
</button>
</div>
</div>
<div class="step">
<div class="step__title">Something else happened on GRM</div>
<div class="step__row">
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Event</mat-label>
<mat-select [(ngModel)]="noteType">
@for (n of noteEvents; track n.value) {
<mat-option [value]="n.value">{{ n.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="step__wide">
<mat-label>Remarks</mat-label>
<input matInput [(ngModel)]="remarks" maxlength="2000" />
</mat-form-field>
</div>
<div class="i4c-actions">
<button mat-stroked-button (click)="note()" [disabled]="busy">
<fa-icon icon="plus"></fa-icon> Add to history
</button>
@if (g.stage === 'WITH_IO') {
<button mat-stroked-button (click)="investigation()" [disabled]="busy">
<fa-icon icon="search"></fa-icon> IO is investigating (+15 days)
</button>
}
</div>
</div>
}
@if (g.status === 'REJECTED' && g.stage !== 'CLOSED') {
<div class="step">
<div class="step__title">Rejected by the {{ g.decision_level }}: {{ g.decision_remarks }}</div>
<div class="sub">
If the customer is not satisfied, the branch raises a review: IO → District GRO → State GRO (15
days each); after the State GRO the customer may approach the jurisdictional court.
</div>
<mat-form-field appearance="outline" class="step__wide">
<mat-label>Remarks</mat-label>
<input matInput [(ngModel)]="remarks" maxlength="2000" />
</mat-form-field>
<div class="i4c-actions">
<button mat-flat-button color="primary" (click)="escalate()" [disabled]="busy">
<fa-icon icon="arrow-up"></fa-icon> {{ escalateLabel }}
</button>
</div>
</div>
}
@if (g.status === 'ALLOWED' && g.stage !== 'CLOSED') {
<div class="step">
<div class="step__title">
Order: {{ decisionLabel(g.decision) }}
@if (g.decision_keep_amount !== null) {
(keep ₹{{ g.decision_keep_amount | number: '1.2-2' }})
}
· {{ g.decision_order_ref }}
</div>
<div class="sub">
Carry it out on the account by
<span class="deadline" [class.deadline--late]="g.compliance_overdue">{{
g.compliance_due_at | date: 'dd MMM yyyy, HH:mm'
}}</span
>. The change goes to a second officer for approval; the grievance closes when it is approved.
</div>
@if (g.actionRequest?.length) {
<div class="sub">
Request #{{ g.actionRequest[0].id }} ({{ g.actionRequest[0].action }}) is
<b>{{ g.actionRequest[0].status }}</b>
{{ g.actionRequest[0].failure_message || g.actionRequest[0].rejection_reason || '' }}
</div>
}
@if (
!g.actionRequest?.length ||
g.actionRequest[0].status === 'REJECTED' ||
g.actionRequest[0].status === 'FAILED'
) {
<div class="i4c-actions">
<button mat-flat-button color="warn" (click)="raiseAction()" [disabled]="busy">
<fa-icon icon="hand-paper"></fa-icon> Raise the account change for approval
</button>
</div>
}
</div>
}
@if (g.stage === 'CLOSED') {
<div class="i4c-note">This grievance is closed ({{ g.status }}).</div>
} @else {
<div class="step">
<div class="i4c-actions">
<button mat-button color="warn" (click)="withdraw()" [disabled]="busy">
Customer withdrew the grievance
</button>
</div>
</div>
}
</div>
</div>
}
@if (tab === 'overview') {
<div class="grid-2">
<div class="i4c-card">
<div class="i4c-card__head"><fa-icon icon="user"></fa-icon> Customer's request</div>
<div class="i4c-card__body">
<dl class="i4c-kv">
<dt>Asks to</dt>
<dd>{{ requestLabel(g.customer_request) }}</dd>
<dt>Reason given</dt>
<dd class="wrap">{{ g.customer_reason || '-' }}</dd>
<dt>Opened</dt>
<dd>{{ g.created_at | date: 'dd MMM yyyy, HH:mm' }} by {{ g.created_by }}</dd>
<dt>Decision</dt>
<dd>
{{ g.decision ? g.decision_level + ': ' + decisionLabel(g.decision) : '-' }}
@if (g.decision_order_ref) {
<div class="sub">
Order {{ g.decision_order_ref }} {{ g.decision_date | date: 'dd MMM yyyy' }}
</div>
}
</dd>
</dl>
</div>
</div>
<div class="i4c-card">
<div class="i4c-card__head"><fa-icon icon="user-shield"></fa-icon> Enhanced due diligence</div>
<div class="i4c-card__body">
@if (g.edd_done) {
<dl class="i4c-kv">
<dt>KYC re-verified</dt>
<dd>{{ g.edd_kyc_verified ? 'Yes' : 'No' }}</dd>
<dt>Source of funds</dt>
<dd class="wrap">{{ g.edd_source_of_funds }}</dd>
<dt>Statement</dt>
<dd class="wrap">{{ g.edd_customer_statement || '-' }}</dd>
<dt>Risk / view</dt>
<dd>
{{ g.edd_risk }} ·
{{ g.edd_recommendation === 'SUPPORT' ? 'Bank supports' : 'Bank does not support' }}
</dd>
<dt>By</dt>
<dd>{{ g.edd_by }}, {{ g.edd_at | date: 'dd MMM yyyy, HH:mm' }}</dd>
</dl>
} @else {
<div class="i4c-note">Not done yet.</div>
}
</div>
</div>
</div>
}
@if (tab === 'documents') {
<div class="i4c-card">
<div class="i4c-card__body">
<mifosx-i4c-documents
ownerType="GRIEVANCE"
[ownerId]="g.id"
[required]="g.requiredDocuments || []"
[suggested]="['GRM_ORDER', 'IO_ORDER', 'EDD_REPORT']"
[documents]="g.documents"
[canUpload]="g.stage !== 'CLOSED'"
(changed)="open(g.id)"
></mifosx-i4c-documents>
</div>
</div>
}
@if (tab === 'history') {
<div class="i4c-card">
<div class="i4c-table-wrap">
<table class="i4c-table">
<thead>
<tr>
<th>When</th>
<th>Event</th>
<th>Stage</th>
<th>Remarks</th>
<th>By</th>
</tr>
</thead>
<tbody>
@for (e of g.events; track e.id) {
<tr>
<td>{{ e.created_at | date: 'dd MMM yyyy, HH:mm' }}</td>
<td>
<span class="i4c-badge">{{ e.event_type }}</span>
</td>
<td>{{ stageLabel(e.stage) }}</td>
<td class="wrap">{{ e.remarks || '-' }}</td>
<td>{{ e.actor }}</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
</div>
<div class="i4c-modal-footer">
<button class="footer-btn-close" (click)="close()">Close</button>
</div>
</div>
</div>
}
</div>
@@ -6,6 +6,8 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
.container {
max-width: 37rem;
}
@use '../i4c' as *;
@use '../i4c-cases' as *;
@include i4c-skin;
@include i4c-case-page;
@@ -0,0 +1,359 @@
/**
* 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 { DatePipe, DecimalPipe } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute } from '@angular/router';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { MatSnackBar } from '@angular/material/snack-bar';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { I4cService, i4cErrorMessage } from '../i4c.service';
import { ALL_DATES, I4cDateRange, I4cDateRangeComponent } from '../i4c-date-range/i4c-date-range.component';
import { I4cDocumentsComponent } from '../i4c-documents/i4c-documents.component';
export const GRM_STAGES: { value: string; label: string }[] = [
{ value: 'DRAFT', label: 'Draft (EDD)' },
{ value: 'NODAL_REVIEW', label: 'Bank nodal review' },
{ value: 'WITH_IO', label: 'With IO (7 days)' },
{ value: 'IO_INVESTIGATION', label: 'IO investigating (15 days)' },
{ value: 'WITH_DGRO', label: 'District GRO review (15 days)' },
{ value: 'WITH_SGRO', label: 'State GRO review (15 days)' },
{ value: 'CLOSED', label: 'Closed' }
];
export const GRM_DECISIONS: { value: string; label: string }[] = [
{ value: 'REMOVE_HOLD', label: 'Remove put on hold' },
{ value: 'UNFREEZE_HOLD_CONTINUES', label: 'Unfreeze the account, hold continues' },
{ value: 'UNFREEZE_AND_REMOVE_HOLD', label: 'Unfreeze and remove put on hold' },
{ value: 'PARTIAL_HOLD', label: 'Keep a smaller amount on hold' },
{ value: 'REJECTED_CONTINUE', label: 'Request rejected, hold / freeze continues' },
{ value: 'SEEK_INFO', label: 'Seek additional information' }
];
const REQUESTS: Record<string, string> = {
REMOVE_HOLD: 'Remove the hold',
UNFREEZE: 'Unfreeze the account',
RESTORE_DIGITAL_SERVICES: 'Restore digital services'
};
/**
* GRM grievances raised by the bank for account holders who contest an I4C hold: EDD, raising on GRM, the IO / DGRO /
* SGRO decision with its deadlines, escalation, and carrying an allowing order out on the account.
*/
@Component({
selector: 'mifosx-i4c-grievances',
templateUrl: './i4c-grievances.component.html',
styleUrls: ['./i4c-grievances.component.scss'],
imports: [
DatePipe,
DecimalPipe,
FormsModule,
MatButtonModule,
MatCheckboxModule,
MatFormFieldModule,
MatInputModule,
MatSelectModule,
MatProgressBarModule,
MatPaginatorModule,
FaIconComponent,
I4cDateRangeComponent,
I4cDocumentsComponent
]
})
export class I4cGrievancesComponent implements OnInit {
private i4c = inject(I4cService);
private route = inject(ActivatedRoute);
private snackBar = inject(MatSnackBar);
readonly stages = GRM_STAGES;
readonly decisions = GRM_DECISIONS;
readonly noteEvents = [
{ value: 'VC_REQUESTED', label: 'IO requested a VC' },
{ value: 'VC_DONE', label: 'VC held' },
{ value: 'VC_NOT_REQUIRED', label: 'VC not required' },
{ value: 'INFO_SOUGHT', label: 'Additional information sought' },
{ value: 'INFO_SENT', label: 'Information sent' },
{ value: 'NOTE', label: 'Note' }
];
readonly pageSizes = [
10,
20,
50,
100
];
stage = 'OPEN';
status = '';
overdueOnly = false;
search = '';
dates: I4cDateRange = { ...ALL_DATES };
rows: any[] = [];
total = 0;
pageIndex = 0;
pageSize = 20;
loading = false;
error = '';
selected: any = null;
busy = false;
tab: 'overview' | 'workflow' | 'documents' | 'history' = 'workflow';
edd = { kycVerified: true, sourceOfFunds: '', customerStatement: '', risk: 'LOW', recommendation: 'SUPPORT' };
grievanceNo = '';
remarks = '';
noteType = 'VC_REQUESTED';
decision = { decision: 'REMOVE_HOLD', keepAmount: null as number | null, orderRef: '', orderDate: '', remarks: '' };
ngOnInit(): void {
const open = Number(this.route.snapshot.queryParamMap.get('open'));
this.load();
if (open) {
this.open(open);
}
}
load(fromFirstPage = false): void {
if (fromFirstPage) {
this.pageIndex = 0;
}
this.loading = true;
this.error = '';
this.i4c
.grievances({
stage: this.stage,
status: this.status,
overdue: this.overdueOnly || null,
search: this.search.trim(),
fromDate: this.dates.fromDate,
toDate: this.dates.toDate,
offset: this.pageIndex * this.pageSize,
limit: this.pageSize
})
.subscribe({
next: (page) => {
this.rows = page.pageItems;
this.total = page.totalFilteredRecords;
this.loading = false;
},
error: (e) => {
this.error = i4cErrorMessage(e);
this.loading = false;
}
});
}
setStage(s: string): void {
this.stage = s;
this.load(true);
}
onPage(e: PageEvent): void {
this.pageIndex = e.pageIndex;
this.pageSize = e.pageSize;
this.load();
}
onDates(range: I4cDateRange): void {
this.dates = range;
this.load(true);
}
get hasFilters(): boolean {
return !!(this.status || this.overdueOnly || this.search || this.dates.preset !== 'ALL' || this.stage !== 'OPEN');
}
clearFilters(): void {
this.stage = 'OPEN';
this.status = '';
this.overdueOnly = false;
this.search = '';
this.dates = { ...ALL_DATES };
this.load(true);
}
open(id: number): void {
this.i4c.grievance(id).subscribe({
next: (g) => {
const same = this.selected?.id === g.id;
this.selected = g;
if (!same) {
this.tab = 'workflow';
this.remarks = '';
this.grievanceNo = g.grievance_no || '';
this.decision = { decision: 'REMOVE_HOLD', keepAmount: null, orderRef: '', orderDate: '', remarks: '' };
}
},
error: (e) => this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 })
});
}
close(): void {
this.selected = null;
}
stageLabel(s: string): string {
return GRM_STAGES.find((x) => x.value === s)?.label || s;
}
decisionLabel(d: string): string {
return GRM_DECISIONS.find((x) => x.value === d)?.label || d;
}
requestLabel(r: string): string {
return REQUESTS[r] || r;
}
/** The stages on the way, for the little progress line; escalation stages show only once reached. */
flow(g: any): { value: string; label: string; state: 'done' | 'now' | 'todo' }[] {
const path = [
'DRAFT',
'NODAL_REVIEW',
'WITH_IO',
'IO_INVESTIGATION',
'WITH_DGRO',
'WITH_SGRO',
'CLOSED'
];
const at = path.indexOf(g.stage);
const visible = path.filter((s, i) => i <= Math.max(at, 3) || s === 'CLOSED' || i <= at);
return visible.map((s) => ({
value: s,
label: (GRM_STAGES.find((x) => x.value === s)?.label || s).replace(/ \(.*\)/, ''),
state: path.indexOf(s) < at ? 'done' : s === g.stage ? 'now' : 'todo'
}));
}
get level(): string {
const s = this.selected?.stage;
return s === 'WITH_DGRO' ? 'DGRO' : s === 'WITH_SGRO' ? 'SGRO' : 'IO';
}
get withOfficer(): boolean {
return [
'WITH_IO',
'IO_INVESTIGATION',
'WITH_DGRO',
'WITH_SGRO'
].includes(this.selected?.stage);
}
isLate(ts: string | null): boolean {
return !!ts && new Date(ts).getTime() < Date.now();
}
step(step: string, body: Record<string, any>, done: string): void {
if (!this.selected) {
return;
}
this.busy = true;
this.i4c.grievanceStep(this.selected.id, step, body).subscribe({
next: (g) => {
this.busy = false;
this.selected = g;
this.remarks = '';
this.snackBar.open(done, 'OK', { duration: 4000 });
this.load();
},
error: (e) => {
this.busy = false;
this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 7000 });
}
});
}
saveEdd(): void {
this.step('edd', this.edd, 'Due diligence recorded.');
}
submit(): void {
this.step('submit', { grievanceNo: this.grievanceNo.trim() }, 'Grievance marked as raised on GRM.');
}
nodal(approve: boolean): void {
this.step(
'nodal',
{ approve, remarks: this.remarks },
approve ? 'Approved; now with the IO (7 days).' : 'Grievance rejected by the nodal officer.'
);
}
note(): void {
this.step('events', { eventType: this.noteType, remarks: this.remarks }, 'Noted.');
}
investigation(): void {
this.step('investigation', { remarks: this.remarks }, 'IO investigation recorded (15 days).');
}
decide(): void {
this.step(
'decision',
{
level: this.level,
decision: this.decision.decision,
keepAmount: this.decision.decision === 'PARTIAL_HOLD' ? this.decision.keepAmount : null,
orderRef: this.decision.orderRef,
orderDate: this.decision.orderDate || null,
remarks: this.decision.remarks
},
'Decision recorded.'
);
}
escalate(): void {
this.step('escalate', { remarks: this.remarks }, 'Escalated.');
}
withdraw(): void {
this.step('withdraw', { remarks: this.remarks }, 'Grievance withdrawn.');
}
raiseAction(): void {
if (!this.selected) {
return;
}
this.busy = true;
this.i4c.raiseGrievanceAction(this.selected.id).subscribe({
next: (r) => {
this.busy = false;
this.snackBar.open(
r.actionRequestId
? 'Request ' + r.actionRequestId + ' raised. Another officer approves it on the Approvals page.'
: 'Nothing to change on the account; grievance closed.',
'OK',
{ duration: 6000 }
);
this.open(this.selected.id);
this.load();
},
error: (e) => {
this.busy = false;
this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 7000 });
}
});
}
missingDocs(g: any): string[] {
return (g.requiredDocuments || []).filter((t: string) => !(g.documents || []).some((d: any) => d.doc_type === t));
}
get escalateLabel(): string {
const s = this.selected?.stage;
return s === 'WITH_SGRO'
? 'Close: customer may approach the court'
: s === 'WITH_DGRO'
? 'Escalate to State GRO'
: 'Escalate to District GRO';
}
}
@@ -0,0 +1,794 @@
<!--
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="i4c-page">
<!-- Modern Toolbar & Filter Chips -->
<div class="i4c-toolbar">
<div class="i4c-filter-chips">
@for (v of views; track v.value) {
<button class="filter-chip" [class.filter-chip--active]="view === v.value" (click)="setView(v.value)">
<span>{{ v.label }}</span>
</button>
}
</div>
<span class="i4c-toolbar__spacer"></span>
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="search-input">
<mat-label>Account / acknowledgement / customer</mat-label>
<input matInput [(ngModel)]="search" (keyup.enter)="load(true)" placeholder="Type & hit enter..." />
</mat-form-field>
<button mat-flat-button color="primary" class="search-btn" (click)="load(true)">
<fa-icon icon="search"></fa-icon> Search
</button>
<span class="count-tag">{{ total }} holds</span>
</div>
<div class="i4c-filter-bar">
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Account type</mat-label>
<mat-select [(ngModel)]="accountType" (selectionChange)="load(true)">
<mat-option value="">All accounts</mat-option>
<mat-option value="SAVINGS">Savings</mat-option>
<mat-option value="CURRENT">Current</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Customer intimation</mat-label>
<mat-select [(ngModel)]="intimationFilter" (selectionChange)="load(true)">
<mat-option value="">Any</mat-option>
<mat-option value="DONE">Customer told</mat-option>
<mat-option value="PENDING">Not yet told</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Layer</mat-label>
<mat-select [(ngModel)]="layer" (selectionChange)="load(true)">
<mat-option value="">All layers</mat-option>
<mat-option value="0">Layer 0</mat-option>
<mat-option value="1">Layer 1+</mat-option>
</mat-select>
</mat-form-field>
<mat-checkbox [(ngModel)]="frozenOnly" (change)="load(true)">Debits frozen only</mat-checkbox>
<mifosx-i4c-date-range label="Held on" [range]="dates" (rangeChange)="onDates($event)"></mifosx-i4c-date-range>
<button mat-stroked-button class="i4c-filter-bar__clear" (click)="clearFilters()" [disabled]="!hasFilters">
<fa-icon icon="times"></fa-icon> Clear filters
</button>
</div>
@if (loading || busy) {
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
}
@if (error) {
<div class="i4c-error">
<fa-icon icon="exclamation-circle"></fa-icon>
<span>{{ error }}</span>
</div>
}
<!-- Holds Register Table -->
<div class="i4c-card">
<div class="i4c-card__head">
<fa-icon icon="lock"></fa-icon>
<span>I4C Account Holds Register</span>
<span class="head-hint">Only disputed amounts are restricted; remaining balance stays usable by customer.</span>
</div>
<div class="i4c-table-wrap">
<table class="i4c-table">
<thead>
<tr>
<th>Customer Account</th>
<th>Account Holder / Branch</th>
<th>Ack No.</th>
<th class="num">Layer</th>
<th class="num">Disputed (₹)</th>
<th class="num">Held (₹)</th>
<th class="num">Short (₹)</th>
<th>Hold Status</th>
<th>Customer Intimation</th>
<th>90-Day Review</th>
</tr>
</thead>
<tbody>
@for (h of rows; track h.id) {
<tr class="clickable" (click)="select(h.id)">
<td>
<div class="acc-cell">
<b>{{ h.account_no }}</b>
<span class="type-tag">{{ h.account_type }}</span>
</div>
</td>
<td>
<div class="client-name">{{ h.client_name || 'N/A' }}</div>
<div class="sub">{{ h.office_name }}</div>
</td>
<td>
<div class="ack-cell">
<span class="ack-no">{{ h.acknowledgement_no }}</span>
<button
class="copy-chip"
(click)="copyText(h.acknowledgement_no, 'ack-' + h.id, $event)"
title="Copy Ack No."
>
<fa-icon icon="copy"></fa-icon>
</button>
</div>
</td>
<td class="num font-semibold">{{ h.layer }}</td>
<td class="num">₹{{ h.disputed_amount | number: '1.2-2' }}</td>
<td class="num font-bold text-accent">₹{{ h.amount_held | number: '1.2-2' }}</td>
<td class="num">
@if (h.shortfall > 0) {
<span class="short-pill">₹{{ h.shortfall | number: '1.2-2' }}</span>
} @else {
<span class="text-muted">-</span>
}
</td>
<td>
<span class="i4c-badge" [class]="statusClass(h)">{{ h.status }}</span>
@if (h.debit_frozen) {
<span class="i4c-badge i4c-badge--red">DEBIT FROZEN</span>
}
@if (h.pending_actions) {
<span class="i4c-badge i4c-badge--purple">APPROVAL PENDING</span>
}
</td>
<td>
@if (h.customer_intimated_at) {
<span class="intimation-ok">
<fa-icon icon="check-circle"></fa-icon>
<span>{{ h.customer_intimated_at | date: 'dd MMM' }} ({{ h.intimation_mode }})</span>
</span>
} @else if (isOverdue(h)) {
<span class="i4c-badge i4c-badge--red">OVERDUE</span>
} @else if (h.amount_held > 0 && h.status === 'ACTIVE') {
<span class="i4c-badge i4c-badge--amber">INTIMATION DUE</span>
} @else {
<span class="text-muted">-</span>
}
</td>
<td>
<span [class.due-alert]="isReviewDue(h)" class="review-date">
@if (isReviewDue(h)) {
<fa-icon icon="exclamation-circle"></fa-icon>
}
{{ h.review_due_on }}
</span>
</td>
</tr>
} @empty {
<tr>
<td colspan="10" class="i4c-empty">
<div class="empty-icon"><fa-icon icon="lock"></fa-icon></div>
<div>No holds found in this view.</div>
</td>
</tr>
}
</tbody>
</table>
</div>
<mat-paginator
class="i4c-paginator"
[length]="total"
[pageIndex]="pageIndex"
[pageSize]="pageSize"
[pageSizeOptions]="pageSizes"
(page)="onPage($event)"
showFirstLastButtons
aria-label="Holds pages"
></mat-paginator>
</div>
<!-- ========================================================
MODERN POPUP WINDOW: HOLD DETAILS & MAKER ACTIONS MODAL
======================================================== -->
@if (selected; as h) {
<div class="i4c-modal-backdrop" (click)="close()">
<div class="i4c-modal-window i4c-modal-window--wide" (click)="$event.stopPropagation()">
<!-- Modal Header -->
<div class="i4c-modal-header">
<div class="i4c-modal-header__title">
<fa-icon icon="lock"></fa-icon>
<div>
<div class="modal-title-row">
<span>Hold #{{ h.id }} on Account {{ h.account_no }}</span>
<span class="i4c-badge" [class]="statusClass(h)">{{ h.status }}</span>
@if (h.debit_frozen) {
<span class="i4c-badge i4c-badge--red">DEBIT FROZEN</span>
}
@if (hasPendingAction) {
<span class="i4c-badge i4c-badge--purple">APPROVAL PENDING</span>
}
</div>
<div class="i4c-modal-header__sub">
Customer: <b>{{ h.client_name || 'N/A' }}</b> ({{ h.office_name }}) • Ack: {{ h.acknowledgement_no }}
</div>
</div>
</div>
<div class="i4c-modal-header__actions">
<button class="modal-close-btn" (click)="close()" aria-label="Close">
<fa-icon icon="times"></fa-icon>
</button>
</div>
</div>
<!-- Navigation Tabs inside the Modal -->
<div class="i4c-modal-tabs">
<button class="modal-tab" [class.modal-tab--active]="modalTab === 'overview'" (click)="modalTab = 'overview'">
<fa-icon icon="info-circle"></fa-icon> Overview &amp; Compliance
</button>
<button class="modal-tab" [class.modal-tab--active]="modalTab === 'txns'" (click)="modalTab = 'txns'">
<fa-icon icon="table"></fa-icon> Hold Transactions ({{ h.transactions?.length || 0 }})
</button>
@if ((h.status === 'ACTIVE' && h.amount_held > 0) || h.intimationLog?.length) {
<button
class="modal-tab"
[class.modal-tab--active]="modalTab === 'intimation'"
(click)="modalTab = 'intimation'"
>
<fa-icon icon="bell"></fa-icon> Customer Intimation
@if (!h.customer_intimated_at && h.status === 'ACTIVE' && h.amount_held > 0) {
<span class="i4c-badge i4c-badge--amber">Pending</span>
}
</button>
}
@if (allowedActions.length > 0) {
<button class="modal-tab" [class.modal-tab--active]="modalTab === 'action'" (click)="modalTab = 'action'">
<fa-icon icon="hand-paper"></fa-icon> Request Action (Maker)
</button>
}
<button class="modal-tab" [class.modal-tab--active]="modalTab === 'cases'" (click)="modalTab = 'cases'">
<fa-icon icon="balance-scale"></fa-icon> GRM / MRM &amp; Documents
@if (openGrievance || openRestoration) {
<span class="i4c-badge i4c-badge--amber">Open</span>
}
</button>
@if (h.actions?.length) {
<button class="modal-tab" [class.modal-tab--active]="modalTab === 'history'" (click)="modalTab = 'history'">
<fa-icon icon="history"></fa-icon> Request Audit ({{ h.actions.length }})
</button>
}
</div>
<!-- Modal Body -->
<div class="i4c-modal-body">
<!-- TAB 1: OVERVIEW -->
@if (modalTab === 'overview') {
<div class="i4c-grid-2">
<div class="i4c-card">
<div class="i4c-card__head"><fa-icon icon="shield-alt"></fa-icon> Hold Balances &amp; Account</div>
<div class="i4c-card__body">
<dl class="i4c-kv">
<dt>Customer Name</dt>
<dd>{{ h.client_name || '-' }} ({{ h.office_name }})</dd>
<dt>Account Number</dt>
<dd>
<b>{{ h.account_no }}</b> ({{ h.account_type }})
</dd>
<dt>I4C Complaint Ack</dt>
<dd>
<a [routerLink]="['../requests', h.request_id]" (click)="close()">{{ h.acknowledgement_no }}</a>
(Layer {{ h.layer }})
</dd>
<dt>Disputed Amount</dt>
<dd>₹{{ h.disputed_amount | number: '1.2-2' }}</dd>
<dt>Amount Held</dt>
<dd>
<b class="amount-held-text">₹{{ h.amount_held | number: '1.2-2' }}</b>
</dd>
<dt>Shortfall</dt>
<dd>
@if (h.shortfall > 0) {
<span class="err-text">₹{{ h.shortfall | number: '1.2-2' }} (Prospective hold)</span>
} @else {
<span>Fully covered (₹0.00)</span>
}
</dd>
</dl>
</div>
</div>
<div class="i4c-card">
<div class="i4c-card__head"><fa-icon icon="calendar-check"></fa-icon> Compliance &amp; Directions</div>
<div class="i4c-card__body">
<dl class="i4c-kv">
<dt>Hold Placed On</dt>
<dd>{{ h.held_at | date: 'dd MMM yyyy, HH:mm:ss' }} (System automated)</dd>
<dt>90-Day Review Due</dt>
<dd>
<span [class.due-alert]="isReviewDue(h)">{{ h.review_due_on }}</span>
</dd>
<dt>Debit Freeze Status</dt>
<dd>
@if (h.debit_frozen) {
<span class="i4c-badge i4c-badge--red">FROZEN</span>
} @else {
<span>Active (Debit Not Frozen)</span>
}
</dd>
<dt>Customer Intimation</dt>
<dd>
@if (h.customer_intimated_at) {
<span class="intimation-ok">
{{ h.customer_intimated_at | date: 'dd MMM yyyy, HH:mm' }} by {{ h.intimated_by }} ({{
h.intimation_mode
}}{{ h.intimation_reference ? ', ' + h.intimation_reference : '' }})
</span>
} @else {
<span class="err-text">
Not told yet{{
h.intimation_due_by ? ' - due by ' + (h.intimation_due_by | date: 'dd MMM yyyy, HH:mm') : ''
}}
</span>
}
</dd>
@if (h.status === 'RELEASED') {
<dt>Released On</dt>
<dd>
{{ h.released_at | date: 'dd MMM yyyy, HH:mm' }} -
{{ reasonLabel(h.release_reason_type || '') }}
{{ h.release_reference }}
</dd>
}
</dl>
</div>
</div>
</div>
}
<!-- TAB 2: TRANSACTIONS -->
@if (modalTab === 'txns') {
<div class="i4c-card">
<div class="i4c-card__head"><fa-icon icon="table"></fa-icon> Account Hold Transactions</div>
<div class="i4c-card__body i4c-table-wrap">
<table class="i4c-table">
<thead>
<tr>
<th>Account Txn ID</th>
<th class="num">Held Amount (₹)</th>
<th>Placed Timestamp</th>
<th>Released Timestamp</th>
</tr>
</thead>
<tbody>
@for (t of h.transactions; track t.id) {
<tr>
<td>
<b>{{ t.account_txn_id }}</b>
</td>
<td class="num">₹{{ t.amount | number: '1.2-2' }}</td>
<td>{{ t.placed_at | date: 'dd MMM yyyy, HH:mm' }}</td>
<td>{{ t.released_at ? (t.released_at | date: 'dd MMM yyyy, HH:mm') : '-' }}</td>
</tr>
} @empty {
<tr>
<td colspan="4" class="i4c-empty">
No money held yet (prospective hold waiting for funds deposit).
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
<!-- TAB 3: CUSTOMER INTIMATION (automatic channels, manual record, history) -->
@if (modalTab === 'intimation') {
@if (h.customer_intimated_at) {
<div class="intim-status intim-status--done">
<fa-icon icon="check"></fa-icon>
<div>
<b>Customer told</b> on {{ h.customer_intimated_at | date: 'dd MMM yyyy, HH:mm' }} by
{{ channelLabel(h.intimation_mode || '')
}}{{ h.intimation_reference ? ' - ' + h.intimation_reference : '' }} ({{ h.intimated_by }})
</div>
</div>
} @else if (h.automaticChannels?.length) {
<div class="intim-status intim-status--auto">
<fa-icon icon="paper-plane"></fa-icon>
<div>
<b>Automatic intimation is on</b> ({{ autoChannelsText(h) }}).
@if (h.intimationLog?.length) {
The last automatic attempt did not reach the customer; the system retries up to 3 times. You can
send again now, or call the customer and record it below.
} @else {
The notice is sent as soon as the hold is placed. If it has not gone yet, send it now.
}
</div>
<button mat-flat-button color="primary" (click)="sendIntimationNow()" [disabled]="busy">
<fa-icon icon="paper-plane"></fa-icon> Send now
</button>
</div>
} @else {
<div class="intim-status intim-status--manual">
<fa-icon icon="phone"></fa-icon>
<div>
<b>No automatic channel is configured.</b> Call the customer (or send a letter / meet in person), then
record it below. Channels are switched on under Intimation Settings.
</div>
</div>
}
@if (!h.customer_intimated_at && h.status === 'ACTIVE' && h.amount_held > 0) {
<div class="i4c-card">
<div class="i4c-card__head"><fa-icon icon="bell"></fa-icon> Record Customer Intimation (manual)</div>
<div class="i4c-card__body">
<div class="i4c-note">
RBI draft directions mandate that the account holder be notified of an account hold by the end of
the next day, including reason, restoration options, and Grievance Officer contacts.
</div>
<div class="i4c-form">
<mat-form-field appearance="outline">
<mat-label>How the customer was told</mat-label>
<mat-select [(ngModel)]="intimation.mode">
@for (m of intimationModes; track m) {
<mat-option [value]="m">{{ channelLabel(m) }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Reference (call note, letter no.)</mat-label>
<input
matInput
[(ngModel)]="intimation.reference"
maxlength="200"
placeholder="e.g. Called 98xxxx1234, customer informed"
/>
</mat-form-field>
</div>
<button mat-flat-button color="primary" (click)="submitIntimation()" [disabled]="busy">
<fa-icon icon="bell"></fa-icon> Record Customer Intimation
</button>
</div>
</div>
}
<div class="i4c-card">
<div class="i4c-card__head">
<fa-icon icon="history"></fa-icon> Intimation History ({{ h.intimationLog?.length || 0 }})
</div>
<div class="i4c-card__body">
<table class="i4c-table">
<thead>
<tr>
<th>When</th>
<th>Channel</th>
<th>Type</th>
<th>Status</th>
<th>Sent to</th>
<th>Reference / Reason</th>
<th>By</th>
</tr>
</thead>
<tbody>
@for (l of h.intimationLog || []; track l.id) {
<tr>
<td>{{ l.created_at | date: 'dd MMM yyyy, HH:mm' }}</td>
<td>{{ channelLabel(l.channel) }}</td>
<td>
{{ l.mode === 'AUTO' ? 'Automatic' : l.mode === 'RESEND' ? 'Sent by officer' : 'Recorded' }}
</td>
<td>
<span
class="i4c-badge"
[class.i4c-badge--green]="
l.status === 'SENT' || l.status === 'DELIVERED' || l.status === 'RECORDED'
"
[class.i4c-badge--red]="l.status === 'FAILED'"
[class.i4c-badge--amber]="l.status === 'SKIPPED_NO_DESTINATION'"
>{{ l.status }}</span
>
</td>
<td>{{ l.destination_masked || '-' }}</td>
<td class="wrap">{{ l.failure_reason || l.provider_reference || '-' }}</td>
<td>{{ l.actor || '-' }}</td>
</tr>
} @empty {
<tr>
<td colspan="7" class="i4c-empty">No notice sent or recorded yet.</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
<!-- TAB 4: MAKER CHANGE REQUEST FORM -->
@if (modalTab === 'action') {
<div class="i4c-card">
<div class="i4c-card__head"><fa-icon icon="hand-paper"></fa-icon> Raise Request on Hold (Maker)</div>
<div class="i4c-card__body">
@if (hasPendingAction) {
<div class="i4c-note">
A request on this hold is already waiting for checker approval on the Approvals page.
</div>
} @else {
<div class="i4c-form">
<mat-form-field appearance="outline">
<mat-label>Requested Action</mat-label>
<mat-select [(ngModel)]="action.action">
@for (a of allowedActions; track a.value) {
<mat-option [value]="a.value">{{ a.label }}</mat-option>
}
</mat-select>
</mat-form-field>
@if (action.action === 'RELEASE') {
<mat-form-field appearance="outline">
<mat-label>Amount to release (₹, empty = all)</mat-label>
<input matInput type="number" min="0" [max]="h.amount_held" [(ngModel)]="action.amount" />
@if (remainingAfterRelease !== null) {
<mat-hint
>₹{{ remainingAfterRelease | number: '1.2-2' }} stays on hold (order names the amount to
keep)</mat-hint
>
}
</mat-form-field>
}
<mat-form-field appearance="outline">
<mat-label>Reason</mat-label>
<mat-select [(ngModel)]="action.reasonType">
@for (r of reasonTypes; track r.value) {
<mat-option [value]="r.value">{{ r.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Order / Reference No.{{ orderRequired ? ' *' : '' }}</mat-label>
<input
matInput
[(ngModel)]="action.orderReference"
maxlength="200"
placeholder="IO / Court reference..."
/>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Order Date</mat-label>
<input matInput type="date" [(ngModel)]="action.orderDate" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Issuing Authority (IO, Court, DGRO...)</mat-label>
<input
matInput
[(ngModel)]="action.authority"
maxlength="200"
placeholder="e.g. Cyber Cell IO..."
/>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Maker Remarks</mat-label>
<input
matInput
[(ngModel)]="action.remarks"
maxlength="1000"
placeholder="Notes for checker..."
/>
</mat-form-field>
</div>
<button mat-flat-button color="warn" (click)="submitAction()" [disabled]="busy">
<fa-icon icon="hand-paper"></fa-icon> Submit for Checker Approval
</button>
}
</div>
</div>
}
<!-- TAB: GRM grievance, MRM restoration, statement and documents of this hold -->
@if (modalTab === 'cases') {
<div class="cases-grid">
<div class="i4c-card">
<div class="i4c-card__head">
<fa-icon icon="balance-scale"></fa-icon> GRM grievance (customer contests the hold)
</div>
<div class="i4c-card__body">
@for (g of h.grievances || []; track g.id) {
<div
class="case-row"
(click)="goTo('grievances', g.id)"
(keyup.enter)="goTo('grievances', g.id)"
tabindex="0"
>
<b>{{ g.grievance_no || 'Draft #' + g.id }}</b>
<span class="i4c-badge">{{ g.stage }}</span>
<span
class="i4c-badge"
[class.i4c-badge--green]="g.status === 'ALLOWED'"
[class.i4c-badge--red]="g.status === 'REJECTED'"
>{{ g.status }}</span
>
<span class="sub">{{ g.created_at | date: 'dd MMM yyyy' }}</span>
</div>
}
@if (h.status === 'ACTIVE' && !openGrievance) {
<div class="i4c-form">
<mat-form-field appearance="outline">
<mat-label>Customer asks to</mat-label>
<mat-select [(ngModel)]="grievanceForm.customerRequest">
<mat-option value="REMOVE_HOLD">Remove the hold</mat-option>
<mat-option value="UNFREEZE">Unfreeze the account</mat-option>
<mat-option value="RESTORE_DIGITAL_SERVICES">Restore digital services</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Customer's reason</mat-label>
<input matInput [(ngModel)]="grievanceForm.customerReason" maxlength="2000" />
</mat-form-field>
</div>
<button mat-flat-button color="primary" (click)="raiseGrievance()" [disabled]="busy">
<fa-icon icon="plus"></fa-icon> Open grievance (EDD next)
</button>
} @else if (!h.grievances?.length) {
<div class="sub">No grievance on this hold.</div>
}
</div>
</div>
<div class="i4c-card">
<div class="i4c-card__head"><fa-icon icon="hand-holding-usd"></fa-icon> MRM money restoration</div>
<div class="i4c-card__body">
@for (r of h.restorations || []; track r.id) {
<div
class="case-row"
(click)="goTo('restorations', r.id)"
(keyup.enter)="goTo('restorations', r.id)"
tabindex="0"
>
<b>{{ r.mrm_request_no || 'Restoration #' + r.id }}</b>
<span class="i4c-badge">{{ r.status }}</span>
<span class="sub"
>{{ r.basis }}
{{ r.restorable_amount ? '₹' + (r.restorable_amount | number: '1.2-2') : '' }}</span
>
</div>
}
@if (h.status === 'ACTIVE' && !openRestoration) {
<div class="i4c-form">
<mat-form-field appearance="outline">
<mat-label>Restoration</mat-label>
<mat-select [(ngModel)]="assessForm.basis">
@for (b of mrmBases; track b.value) {
<mat-option [value]="b.value">{{ b.label }}</mat-option>
}
</mat-select>
</mat-form-field>
@if (assessForm.basis === 'ATTRIBUTION' || assessForm.basis === 'PRO_RATA') {
<mat-form-field appearance="outline">
<mat-label>Restorable amount (₹, at most {{ h.amount_held | number: '1.2-2' }})</mat-label>
<input matInput type="number" min="0" [(ngModel)]="assessForm.restorableAmount" />
</mat-form-field>
}
<mat-form-field appearance="outline">
<mat-label>Remarks</mat-label>
<input matInput [(ngModel)]="assessForm.remarks" maxlength="1000" />
</mat-form-field>
</div>
<button mat-flat-button color="primary" (click)="assessForMrm()" [disabled]="busy">
<fa-icon icon="check"></fa-icon> Assess for MRM
</button>
} @else if (!h.restorations?.length) {
<div class="sub">No restoration on this hold.</div>
}
</div>
</div>
</div>
<div class="i4c-card">
<div class="i4c-card__head">
<fa-icon icon="file-csv"></fa-icon> Bank statement (GRM / MRM ask for it)
</div>
<div class="i4c-card__body">
<div class="i4c-form">
<mat-form-field appearance="outline">
<mat-label>From (default 90 days back)</mat-label>
<input matInput type="date" [(ngModel)]="statement.fromDate" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>To (default today)</mat-label>
<input matInput type="date" [(ngModel)]="statement.toDate" />
</mat-form-field>
</div>
<button mat-stroked-button (click)="downloadStatement()">
<fa-icon icon="download"></fa-icon> Download statement (CSV)
</button>
</div>
</div>
<div class="i4c-card">
<div class="i4c-card__head">
<fa-icon icon="paperclip"></fa-icon> Documents on this hold (IO / court orders...)
</div>
<div class="i4c-card__body">
<mifosx-i4c-documents
ownerType="HOLD"
[ownerId]="h.id"
[suggested]="['IO_ORDER', 'COURT_ORDER', 'OTHER']"
[documents]="h.documents || null"
(changed)="select(h.id)"
></mifosx-i4c-documents>
</div>
</div>
}
<!-- TAB 5: REQUEST AUDIT HISTORY -->
@if (modalTab === 'history') {
<div class="i4c-card">
<div class="i4c-card__head"><fa-icon icon="history"></fa-icon> Requests Audit History on this Hold</div>
<div class="i4c-card__body i4c-table-wrap">
<table class="i4c-table">
<thead>
<tr>
<th>Action</th>
<th>Reason / Order</th>
<th>Status</th>
<th>Maker Details</th>
<th>Checker Details</th>
</tr>
</thead>
<tbody>
@for (a of h.actions; track a.id) {
<tr>
<td>
<b>{{ a.action }}</b>
</td>
<td>
{{ reasonLabel(a.reason_type) }}
<div class="sub">{{ a.order_reference }} {{ a.authority ? '- ' + a.authority : '' }}</div>
</td>
<td>
<span class="i4c-badge">{{ a.status }}</span>
@if (a.rejection_reason || a.failure_message) {
<div class="sub err-text">{{ a.rejection_reason || a.failure_message }}</div>
}
</td>
<td>
{{ a.maker_name }}
<div class="sub">{{ a.maker_at | date: 'dd MMM, HH:mm' }}</div>
</td>
<td>
{{ a.checker_name || '-' }}
@if (a.checker_at) {
<div class="sub">{{ a.checker_at | date: 'dd MMM, HH:mm' }}</div>
}
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
</div>
<!-- Modal Footer -->
<div class="i4c-modal-footer">
<button class="footer-btn-close" (click)="close()">Close</button>
</div>
</div>
</div>
}
</div>
@@ -0,0 +1,236 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
@use '../i4c' as *;
@include i4c-skin;
.search-input {
width: 240px;
}
.search-btn {
height: 38px;
border-radius: 8px;
font-weight: 600;
font-size: 11.5px;
}
.count-tag {
font-size: 11px;
font-weight: 700;
color: #64748b;
background: #f1f5f9;
padding: 2px 7px;
border-radius: 9999px;
border: 1px solid #e2e8f0;
}
.head-hint {
margin-left: auto;
font-size: 11px;
color: #94a3b8;
font-weight: normal;
}
.acc-cell {
display: flex;
align-items: center;
gap: 6px;
b {
color: #0f172a;
font-size: 12px;
}
}
.type-tag {
padding: 1px 6px;
border-radius: 4px;
background: #f1f5f9;
font-size: 10px;
font-weight: 700;
color: #64748b;
}
.client-name {
font-weight: 600;
color: #1e293b;
}
.ack-cell {
display: flex;
align-items: center;
gap: 6px;
.ack-no {
font-weight: 600;
color: #334155;
font-size: 12px;
}
}
.short-pill {
display: inline-block;
padding: 2px 6px;
border-radius: 6px;
background: #fef2f2;
color: #dc2626;
font-weight: 700;
font-size: 11px;
}
.intimation-ok {
display: inline-flex;
align-items: center;
gap: 5px;
color: #059669;
font-weight: 600;
font-size: 12px;
fa-icon {
font-size: 13px;
}
}
.review-date {
font-size: 12px;
color: #475569;
font-weight: 500;
}
.due-alert {
color: #d97706;
font-weight: 700;
display: inline-flex;
align-items: center;
gap: 4px;
}
.modal-title-row {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.sub {
font-size: 11px;
color: #64748b;
margin-top: 2px;
}
.err-text {
color: #dc2626;
font-size: 12px;
}
.text-accent {
color: #2563eb;
}
.amount-held-text {
color: #0f172a;
font-size: 13.5px;
}
.text-muted {
color: #94a3b8;
}
.footer-btn-close {
padding: 6px 14px;
border-radius: 8px;
background: #e2e8f0;
color: #334155;
border: none;
font-size: 11.5px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: #cbd5e1;
color: #0f172a;
}
}
.intim-status {
display: flex;
gap: 12px;
align-items: center;
padding: 12px 16px;
border-radius: 10px;
margin-bottom: 12px;
font-size: 13px;
line-height: 1.5;
border: 1px solid;
> div {
flex: 1 1 auto;
}
&--done {
border-color: #bbf7d0;
background: #f0fdf4;
color: #166534;
}
&--auto {
border-color: #bfdbfe;
background: #eff6ff;
color: #1e3a8a;
}
&--manual {
border-color: #fde68a;
background: #fffbeb;
color: #92400e;
}
}
.wrap {
white-space: normal;
overflow-wrap: anywhere;
max-width: 320px;
}
.cases-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
@media (width <= 900px) {
.cases-grid {
grid-template-columns: minmax(0, 1fr);
}
}
.case-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
padding: 8px 10px;
margin-bottom: 8px;
border: 1px solid #e2e8f0;
border-radius: 8px;
cursor: pointer;
&:hover,
&:focus-visible {
border-color: #2563eb;
outline: none;
}
.sub {
font-size: 11px;
color: #64748b;
}
}
@@ -0,0 +1,455 @@
/**
* 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 { DatePipe, DecimalPipe } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { MatSnackBar } from '@angular/material/snack-bar';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { HOLD_REASON_TYPES, HoldActionRequest, I4cHold, I4cService, i4cErrorMessage, saveBlob } from '../i4c.service';
import { I4cDocumentsComponent } from '../i4c-documents/i4c-documents.component';
import { MRM_BASES } from '../i4c-restorations/i4c-restorations.component';
import { ALL_DATES, I4cDateRange, I4cDateRangeComponent } from '../i4c-date-range/i4c-date-range.component';
/**
* The register of every hold placed for I4C. Placing is automatic; from here an officer asks for a release or a
* debit freeze/unfreeze (approved by someone else on the Approvals page), and records that the customer was told
* about the hold.
*/
@Component({
selector: 'mifosx-i4c-holds',
templateUrl: './i4c-holds.component.html',
styleUrls: ['./i4c-holds.component.scss'],
imports: [
DatePipe,
DecimalPipe,
FormsModule,
RouterLink,
MatButtonModule,
MatFormFieldModule,
MatInputModule,
MatSelectModule,
MatCheckboxModule,
MatProgressBarModule,
MatPaginatorModule,
I4cDateRangeComponent,
I4cDocumentsComponent,
FaIconComponent
]
})
export class I4cHoldsComponent implements OnInit {
private i4c = inject(I4cService);
private route = inject(ActivatedRoute);
private router = inject(Router);
private snackBar = inject(MatSnackBar);
readonly views = [
{ value: 'ACTIVE', label: 'Active' },
{ value: 'PROSPECTIVE', label: 'Prospective (still short)' },
{ value: 'INTIMATION_DUE', label: 'Customer not yet told' },
{ value: 'REVIEW_DUE', label: '90-day review due' },
{ value: 'NOT_HOLDABLE', label: 'Not holdable' },
{ value: 'RELEASED', label: 'Released' },
{ value: '', label: 'All' }
];
readonly reasonTypes = HOLD_REASON_TYPES;
/** Ways an officer can tell the customer and record it by hand; phone call comes first. */
readonly intimationModes = [
'PHONE',
'LETTER',
'IN_PERSON',
'SMS',
'EMAIL'
];
view = 'ACTIVE';
search = '';
accountType = '';
intimationFilter = '';
layer = '';
frozenOnly = false;
dates: I4cDateRange = { ...ALL_DATES };
total = 0;
pageIndex = 0;
pageSize = 20;
readonly pageSizes = [
10,
20,
50,
100
];
rows: I4cHold[] = [];
selected: I4cHold | null = null;
loading = false;
busy = false;
error = '';
modalTab: 'overview' | 'txns' | 'intimation' | 'action' | 'cases' | 'history' = 'overview';
readonly mrmBases = MRM_BASES;
grievanceForm = { customerRequest: 'REMOVE_HOLD', customerReason: '' };
assessForm = { basis: 'ATTRIBUTION', restorableAmount: null as number | null, remarks: '' };
statement = { fromDate: '', toDate: '' };
copiedKey = '';
action: HoldActionRequest = this.emptyAction();
intimation = { mode: 'PHONE', reference: '' };
ngOnInit(): void {
const view = this.route.snapshot.queryParamMap.get('view');
if (view !== null) {
this.view = view;
}
this.load();
}
setView(v: string): void {
this.view = v;
this.load(true);
}
onPage(e: PageEvent): void {
this.pageIndex = e.pageIndex;
this.pageSize = e.pageSize;
this.load();
}
onDates(range: I4cDateRange): void {
this.dates = range;
this.load(true);
}
get hasFilters(): boolean {
return !!(
this.search ||
this.accountType ||
this.intimationFilter ||
this.layer ||
this.frozenOnly ||
this.dates.preset !== 'ALL'
);
}
/** Clears the extra filters; the view chip (Active, Released, ...) stays as chosen. */
clearFilters(): void {
this.search = '';
this.accountType = '';
this.intimationFilter = '';
this.layer = '';
this.frozenOnly = false;
this.dates = { ...ALL_DATES };
this.load(true);
}
/** Reload the current page; a changed filter passes `fromFirstPage` so the officer lands on page one. */
load(fromFirstPage = false): void {
if (fromFirstPage) {
this.pageIndex = 0;
}
this.loading = true;
this.error = '';
this.i4c
.holds({
view: this.view,
search: this.search.trim(),
accountType: this.accountType,
intimation: this.intimationFilter,
layer: this.layer,
frozen: this.frozenOnly || null,
fromDate: this.dates.fromDate,
toDate: this.dates.toDate,
offset: this.pageIndex * this.pageSize,
limit: this.pageSize
})
.subscribe({
next: (page) => {
this.rows = page.pageItems;
this.total = page.totalFilteredRecords;
this.loading = false;
if (this.selected) {
this.select(this.selected.id);
}
},
error: (e) => {
this.error = i4cErrorMessage(e);
this.loading = false;
}
});
}
select(id: number): void {
this.i4c.hold(id).subscribe({
next: (hold) => {
this.selected = hold;
this.action = this.emptyAction();
this.intimation = { mode: 'PHONE', reference: '' };
this.modalTab = 'overview';
},
error: (e) => this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 })
});
}
copyText(text: string, key: string, event?: Event): void {
if (event) {
event.stopPropagation();
}
if (!text) {
return;
}
navigator.clipboard.writeText(text);
this.copiedKey = key;
this.snackBar.open(`Copied: ${text}`, 'OK', { duration: 2500 });
setTimeout(() => {
if (this.copiedKey === key) {
this.copiedKey = '';
}
}, 2000);
}
close(): void {
this.selected = null;
}
/** What a maker may ask for on this hold, given its state. */
get allowedActions(): { value: string; label: string }[] {
const h = this.selected;
if (!h || h.status === 'RELEASED') {
return [];
}
const list = [{ value: 'RELEASE', label: 'Release hold' }];
if (h.account_type === 'SAVINGS') {
list.push(
h.debit_frozen
? { value: 'UNFREEZE', label: 'Unfreeze debits, hold continues' }
: { value: 'FREEZE', label: 'Freeze all debits (on explicit LEA / court direction only)' }
);
}
if (h.debit_frozen) {
list.push({ value: 'UNFREEZE_AND_RELEASE', label: 'Unfreeze and remove the hold' });
}
return list;
}
get orderRequired(): boolean {
return (
(this.action.action === 'RELEASE' || this.action.action === 'UNFREEZE_AND_RELEASE') &&
this.action.reasonType !== 'REVIEW_90_DAYS'
);
}
/** What stays on hold after a partial release; null when the whole hold is released. */
get remainingAfterRelease(): number | null {
const h = this.selected;
const amount = Number(this.action.amount);
if (!h || this.action.action !== 'RELEASE' || !amount) {
return null;
}
return Math.max(0, Number(h.amount_held) - amount);
}
get openGrievance(): any {
return (this.selected?.grievances || []).find((g: any) => g.stage !== 'CLOSED');
}
get openRestoration(): any {
return (this.selected?.restorations || []).find((r: any) => r.status !== 'CLOSED');
}
raiseGrievance(): void {
if (!this.selected) {
return;
}
this.busy = true;
this.i4c.createGrievance(this.selected.id, this.grievanceForm).subscribe({
next: (r) => {
this.busy = false;
this.router.navigate(['/cfcfrms/i4c/grievances'], { queryParams: { open: r.resourceId } });
},
error: (e) => {
this.busy = false;
this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 7000 });
}
});
}
assessForMrm(): void {
if (!this.selected) {
return;
}
const possible = this.assessForm.basis === 'ATTRIBUTION' || this.assessForm.basis === 'PRO_RATA';
this.busy = true;
this.i4c
.assessRestoration(this.selected.id, {
basis: this.assessForm.basis,
restorableAmount: possible ? this.assessForm.restorableAmount : null,
remarks: this.assessForm.remarks
})
.subscribe({
next: (r) => {
this.busy = false;
this.router.navigate(['/cfcfrms/i4c/restorations'], { queryParams: { open: r.resourceId } });
},
error: (e) => {
this.busy = false;
this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 7000 });
}
});
}
goTo(page: 'grievances' | 'restorations', id: number): void {
this.router.navigate(['/cfcfrms/i4c/' + page], { queryParams: { open: id } });
}
downloadStatement(): void {
if (!this.selected) {
return;
}
const h = this.selected;
this.i4c.statementCsv(h.id, this.statement.fromDate || undefined, this.statement.toDate || undefined).subscribe({
next: (blob) => saveBlob(blob, 'statement-' + h.account_no + '.csv'),
error: (e) => this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 })
});
}
get hasPendingAction(): boolean {
return (this.selected?.actions || []).some((a: any) => a.status === 'PENDING');
}
submitAction(): void {
if (!this.selected) {
return;
}
if (!this.action.reasonType) {
this.snackBar.open('Choose the reason.', 'Close', { duration: 4000 });
return;
}
if (this.orderRequired && !this.action.orderReference?.trim()) {
this.snackBar.open('An order / reference number is required to release a hold.', 'Close', { duration: 5000 });
return;
}
this.busy = true;
this.i4c.requestHoldAction(this.selected.id, this.action).subscribe({
next: () => {
this.busy = false;
this.snackBar.open('Request raised. Another officer must approve it on the Approvals page.', 'OK', {
duration: 5000
});
this.load();
},
error: (e) => {
this.busy = false;
this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 });
}
});
}
submitIntimation(): void {
if (!this.selected) {
return;
}
this.busy = true;
this.i4c.recordIntimation(this.selected.id, this.intimation.mode, this.intimation.reference).subscribe({
next: () => {
this.busy = false;
this.snackBar.open('Customer intimation recorded.', 'OK', { duration: 3000 });
this.load();
},
error: (e) => {
this.busy = false;
this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 });
}
});
}
/** Send the hold notice now on the switched-on automatic channels (Email today). */
sendIntimationNow(): void {
if (!this.selected) {
return;
}
this.busy = true;
this.i4c.sendIntimation(this.selected.id).subscribe({
next: (r) => {
this.busy = false;
this.snackBar.open(r.message, 'OK', { duration: 6000 });
this.load();
},
error: (e) => {
this.busy = false;
this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 });
}
});
}
channelLabel(channel: string): string {
return (
(
{
EMAIL: 'Email',
SMS: 'SMS',
WHATSAPP: 'WhatsApp',
PHONE: 'Phone call',
LETTER: 'Letter',
IN_PERSON: 'In person'
} as Record<string, string>
)[channel] ?? channel
);
}
autoChannelsText(hold: I4cHold): string {
return (hold.automaticChannels || []).map((c) => this.channelLabel(c)).join(', then ');
}
isOverdue(hold: I4cHold): boolean {
return (
hold.status === 'ACTIVE' &&
!hold.customer_intimated_at &&
!!hold.intimation_due_by &&
new Date(hold.intimation_due_by).getTime() < Date.now()
);
}
isReviewDue(hold: I4cHold): boolean {
if (hold.status !== 'ACTIVE' || !hold.review_due_on) {
return false;
}
const due = new Date(hold.review_due_on + 'T00:00:00').getTime();
return due - Date.now() <= 15 * 24 * 3600 * 1000;
}
statusClass(hold: I4cHold): string {
if (hold.status === 'RELEASED') {
return 'i4c-badge--blue';
}
if (hold.status === 'NOT_HOLDABLE') {
return 'i4c-badge--red';
}
return hold.shortfall > 0 ? 'i4c-badge--amber' : 'i4c-badge--green';
}
reasonLabel(value: string): string {
return this.reasonTypes.find((r) => r.value === value)?.label || value;
}
private emptyAction(): HoldActionRequest {
return {
action: 'RELEASE',
amount: null,
reasonType: '',
orderReference: '',
orderDate: '',
authority: '',
remarks: ''
};
}
}
@@ -0,0 +1,148 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="i4c-page">
@if (loading) {
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
}
@if (error) {
<div class="i4c-error">{{ error }}</div>
}
<div class="summary" [class.summary--manual]="!enabledChannels.length">
<fa-icon [icon]="enabledChannels.length ? 'check' : 'exclamation-triangle'" class="summary__icon"></fa-icon>
<div>
@if (enabledChannels.length) {
<div class="summary__title">
Customers are told automatically by
@for (c of enabledChannels; track c.channel; let last = $last) {
<b>{{ info(c.channel).label }}</b
>{{ last ? '' : ', then ' }}
}
</div>
<div class="summary__text">
As soon as money is held, the channels are tried in this order. The first that reaches the customer records
the intimation on the hold. If none does (no email on file, gateway down), the hold stays "customer not yet
told" for an officer to call and record by hand.
</div>
} @else {
<div class="summary__title">No automatic channel is switched on</div>
<div class="summary__text">
Every hold waits for an officer to contact the customer (phone, letter, in person) and record it on the hold's
Record Intimation tab.
</div>
}
</div>
</div>
@for (c of channels; track c.channel) {
<div class="i4c-card channel" [class.channel--off]="!c.enabled">
<div class="i4c-card__head">
<fa-icon [icon]="info(c.channel).icon"></fa-icon>
<span>{{ info(c.channel).label }}</span>
<span class="i4c-badge" [class.i4c-badge--green]="c.enabled">{{ c.enabled ? 'ON' : 'OFF' }}</span>
<span class="i4c-toolbar__spacer"></span>
<mat-slide-toggle
[id]="'toggle-' + c.channel"
[(ngModel)]="c.enabled"
(change)="toggle(c)"
[disabled]="c.saving"
[attr.aria-label]="'Send automatically by ' + info(c.channel).label"
>
Send automatically
</mat-slide-toggle>
</div>
<div class="i4c-card__body">
<div class="gateway">Sent through: {{ info(c.channel).gateway }}</div>
<div class="grid">
<div class="edit">
<div class="row">
<mat-form-field appearance="outline" class="priority">
<mat-label>Order</mat-label>
<input
matInput
type="number"
min="1"
max="99"
[id]="'priority-' + c.channel"
[(ngModel)]="c.priority"
(ngModelChange)="c.dirty = true"
/>
</mat-form-field>
@if (c.channel === 'EMAIL') {
<mat-form-field appearance="outline" class="subject">
<mat-label>Subject</mat-label>
<input
matInput
maxlength="300"
[id]="'subject-' + c.channel"
[(ngModel)]="c.subject"
(ngModelChange)="c.dirty = true"
/>
</mat-form-field>
}
</div>
<mat-form-field appearance="outline" class="full">
<mat-label>Message</mat-label>
<textarea
matInput
rows="9"
[id]="'body-' + c.channel"
[(ngModel)]="c.body"
(ngModelChange)="c.dirty = true"
></textarea>
</mat-form-field>
<div class="chips">
<span class="chips__label">Insert:</span>
@for (p of placeholders; track p) {
<button type="button" class="chip" (click)="insert(c, p)">{{ p }}</button>
}
</div>
<div class="i4c-actions">
<button mat-flat-button color="primary" (click)="saveText(c)" [disabled]="!c.dirty || c.saving">
<fa-icon icon="check"></fa-icon> Save {{ info(c.channel).label }} message
</button>
@if (c.updated_at) {
<span class="muted"
>Last changed {{ c.updated_at | date: 'dd MMM yyyy, HH:mm' }} by {{ c.updated_by }}</span
>
}
</div>
</div>
<div class="preview">
<div class="preview__label">Preview (sample values)</div>
@if (c.channel === 'EMAIL') {
<div class="preview__subject">{{ preview(c.subject) }}</div>
}
<div class="preview__body">{{ preview(c.body) }}</div>
</div>
</div>
@if (c.channel === 'EMAIL') {
<div class="test">
<mat-form-field appearance="outline" class="test__field" subscriptSizing="dynamic">
<mat-label>Send a test to</mat-label>
<input matInput type="email" id="test-email-to" [(ngModel)]="testTo" placeholder="you@bank.in" />
</mat-form-field>
<button mat-stroked-button (click)="sendTest()" [disabled]="testing">
<fa-icon icon="paper-plane"></fa-icon> Send test email
</button>
<span class="muted">Uses the saved message with sample values; the subject starts with [TEST].</span>
</div>
}
</div>
</div>
}
<div class="i4c-note">
Phone, letter and in-person intimation are always recorded by an officer on the hold's Record Intimation tab; the
CBS cannot send them itself. Every notice, automatic or recorded, is listed in the hold's intimation history.
</div>
</div>
@@ -0,0 +1,172 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
@use '../i4c' as *;
@include i4c-skin;
.summary {
display: flex;
gap: 12px;
align-items: flex-start;
padding: 12px 16px;
border-radius: 10px;
border: 1px solid #bbf7d0;
background: #f0fdf4;
color: #166534;
&--manual {
border-color: #fde68a;
background: #fffbeb;
color: #92400e;
}
&__icon {
margin-top: 2px;
}
&__title {
font-weight: 600;
font-size: 14px;
}
&__text {
font-size: 13px;
margin-top: 2px;
line-height: 1.5;
}
}
.channel--off {
opacity: 0.85;
}
.gateway {
font-size: 12px;
color: #64748b;
margin-bottom: 10px;
}
.grid {
display: grid;
grid-template-columns: minmax(0, 3fr) minmax(0, 2fr);
gap: 16px;
}
@media (width <= 1024px) {
.grid {
grid-template-columns: minmax(0, 1fr);
}
}
.edit {
min-width: 0;
}
.row {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.priority {
width: 110px;
}
.subject {
flex: 1 1 320px;
}
.full {
width: 100%;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
margin-bottom: 12px;
&__label {
font-size: 12px;
color: #64748b;
}
}
.chip {
border: 1px solid #cbd5e1;
background: #f8fafc;
border-radius: 999px;
padding: 2px 10px;
font-size: 11px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
color: #334155;
cursor: pointer;
&:hover {
border-color: #2563eb;
color: #1d4ed8;
}
&:focus-visible {
outline: 2px solid #2563eb;
outline-offset: 1px;
}
}
.preview {
min-width: 0;
border: 1px solid #e2e8f0;
border-radius: 10px;
background: #f8fafc;
padding: 12px 14px;
align-self: start;
&__label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #94a3b8;
margin-bottom: 8px;
}
&__subject {
font-weight: 600;
color: #0f172a;
font-size: 13px;
margin-bottom: 8px;
}
&__body {
white-space: pre-wrap;
font-size: 13px;
line-height: 1.55;
color: #1e293b;
overflow-wrap: anywhere;
}
}
.test {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
margin-top: 14px;
padding-top: 14px;
border-top: 1px solid #f1f5f9;
&__field {
width: 280px;
}
}
.muted {
font-size: 12px;
color: #64748b;
}
@@ -0,0 +1,185 @@
/**
* 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 { DatePipe } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatSnackBar } from '@angular/material/snack-bar';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { I4cService, IntimationChannel, i4cErrorMessage } from '../i4c.service';
/** Sample values for the live preview; the server fills the real ones per hold. */
const SAMPLE: Record<string, string> = {
customerName: 'Anshul Rathore',
accountNumber: 'XXXXXXXXXXX0006',
amountHeld: '1500.00',
disputedAmount: '1500.00',
ackNo: '31610201726120',
holdDate: '20-10-2026',
reviewDate: '18-01-2027',
bankName: 'LincPay Demo Co-operative Bank',
branchName: 'Head Office',
grievanceEmail: 'grievance@lpdb.example.in',
customerCare: '1800-200-4570'
};
const CHANNEL_INFO: Record<string, { label: string; icon: string; gateway: string }> = {
EMAIL: {
label: 'Email',
icon: 'envelope',
gateway: "CBS mail server (spring.mail settings), to the customer's email on file"
},
SMS: {
label: 'SMS',
icon: 'mobile-alt',
gateway: "SMS gateway (fineract.notification.sms), to the customer's mobile on file"
},
WHATSAPP: {
label: 'WhatsApp',
icon: 'phone',
gateway: "WhatsApp gateway (fineract.notification.whatsapp), to the customer's mobile"
}
};
interface EditableChannel extends IntimationChannel {
dirty?: boolean;
saving?: boolean;
}
/**
* Which channels tell the account holder about an I4C hold on their own. Switched-on channels are tried in priority
* order as soon as money is held; the first that reaches the customer records the intimation. With none switched on,
* or none reaching the customer, the hold waits for an officer to phone or write and record it by hand.
*/
@Component({
selector: 'mifosx-i4c-intimation-settings',
templateUrl: './i4c-intimation-settings.component.html',
styleUrls: ['./i4c-intimation-settings.component.scss'],
imports: [
DatePipe,
FormsModule,
MatButtonModule,
MatFormFieldModule,
MatInputModule,
MatSlideToggleModule,
MatProgressBarModule,
FaIconComponent
]
})
export class I4cIntimationSettingsComponent implements OnInit {
private i4c = inject(I4cService);
private snackBar = inject(MatSnackBar);
channels: EditableChannel[] = [];
placeholders: string[] = [];
loading = false;
error = '';
testTo = '';
testing = false;
ngOnInit(): void {
this.load();
}
load(): void {
this.loading = true;
this.error = '';
this.i4c.intimationChannels().subscribe({
next: (s) => {
this.channels = s.channels.map((c) => ({ ...c }));
this.placeholders = s.placeholders;
this.loading = false;
},
error: (e) => {
this.error = i4cErrorMessage(e);
this.loading = false;
}
});
}
get enabledChannels(): EditableChannel[] {
return this.channels.filter((c) => c.enabled).sort((a, b) => a.priority - b.priority);
}
info(channel: string) {
return CHANNEL_INFO[channel] ?? { label: channel, icon: 'bell', gateway: '' };
}
preview(text: string | undefined): string {
return (text ?? '').replace(/\{(\w+)\}/g, (match, key) => SAMPLE[key] ?? match);
}
insert(channel: EditableChannel, placeholder: string): void {
channel.body = (channel.body ?? '') + placeholder;
channel.dirty = true;
}
/** The switch saves at once: it decides whether customers get told automatically. */
toggle(channel: EditableChannel): void {
this.save(channel, { enabled: channel.enabled });
}
saveText(channel: EditableChannel): void {
if (!channel.body?.trim()) {
this.snackBar.open('The message text cannot be empty.', 'Close', { duration: 4000 });
return;
}
if (channel.channel === 'EMAIL' && !channel.subject?.trim()) {
this.snackBar.open('An email needs a subject.', 'Close', { duration: 4000 });
return;
}
this.save(channel, { priority: channel.priority, subject: channel.subject, body: channel.body });
}
private save(
channel: EditableChannel,
body: { enabled?: boolean; priority?: number; subject?: string; body?: string }
): void {
channel.saving = true;
this.i4c.updateIntimationChannel(channel.channel, body).subscribe({
next: (rows) => {
const fresh = rows.find((r) => r.channel === channel.channel);
if (fresh) {
Object.assign(channel, fresh, { dirty: false });
}
channel.saving = false;
this.snackBar.open(this.info(channel.channel).label + ' settings saved.', 'OK', { duration: 3000 });
},
error: (e) => {
channel.saving = false;
this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 });
this.load();
}
});
}
sendTest(): void {
if (!this.testTo.trim()) {
this.snackBar.open('Enter an email address for the test.', 'Close', { duration: 4000 });
return;
}
this.testing = true;
this.i4c.testIntimationChannel('EMAIL', this.testTo.trim()).subscribe({
next: (r) => {
this.testing = false;
this.snackBar.open(r.outcome === 'SENT' ? 'Test email sent to ' + this.testTo + '.' : r.message, 'OK', {
duration: 6000
});
},
error: (e) => {
this.testing = false;
this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 });
}
});
}
}
@@ -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/.
-->
<div class="i4c-page">
<div class="i4c-toolbar">
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="search-input">
<mat-label>Filter account / customer / branch / ack</mat-label>
<input matInput [(ngModel)]="filter" />
</mat-form-field>
<span class="i4c-toolbar__spacer"></span>
<span class="count-tag"
>{{ shown.length }} accounts · {{ totals.complaints }} complaints · ₹{{
totals.held | number: '1.2-2'
}}
held</span
>
<button mat-stroked-button (click)="load()" [disabled]="loading"><fa-icon icon="sync"></fa-icon> Refresh</button>
<button mat-flat-button color="primary" (click)="download()">
<fa-icon icon="file-csv"></fa-icon> Download for MRM (CSV)
</button>
</div>
@if (loading) {
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
}
@if (error) {
<div class="i4c-error">
<fa-icon icon="exclamation-circle"></fa-icon> <span>{{ error }}</span>
</div>
}
<div class="i4c-card">
<div class="i4c-card__head">
<fa-icon icon="university"></fa-icon>
<span>MRM: Update Account Balance</span>
<span class="head-hint">
Every account with an active I4C hold. Enter these balances on MRM → Money Distribution Process (one by one or
with MRM's bulk format). Click an account for its pro-rata split.
</span>
</div>
<div class="i4c-table-wrap">
<table class="i4c-table">
<thead>
<tr>
<th>Account</th>
<th>Holder / Branch</th>
<th>Status</th>
<th class="num">Ledger balance</th>
<th class="num">Available</th>
<th class="num">On hold</th>
<th class="num">Disputed</th>
<th class="num">Complaints</th>
<th>Acknowledgement nos.</th>
</tr>
</thead>
<tbody>
@for (r of shown; track r.account_type + r.account_id) {
<tr class="clickable" (click)="pick(r)">
<td>
<b>{{ r.account_no }}</b>
<div class="sub">{{ r.account_type }}{{ r.debit_frozen ? ' · debits frozen' : '' }}</div>
</td>
<td>
{{ r.client_name }}
<div class="sub">{{ r.office_name }}</div>
</td>
<td>{{ r.account_status || '-' }}</td>
<td class="num">{{ r.ledger_balance | number: '1.2-2' }}</td>
<td class="num">{{ r.available_balance | number: '1.2-2' }}</td>
<td class="num">
<b>{{ r.held | number: '1.2-2' }}</b>
</td>
<td class="num">{{ r.disputed | number: '1.2-2' }}</td>
<td class="num">{{ r.complaints }}</td>
<td class="wrap">{{ r.acknowledgement_nos }}</td>
</tr>
} @empty {
<tr>
<td colspan="9" class="i4c-empty">No accounts with active I4C holds.</td>
</tr>
}
</tbody>
</table>
</div>
</div>
<div class="i4c-card">
<div class="i4c-card__head">
<fa-icon icon="calculator"></fa-icon>
<span>Pro-rata distribution</span>
<span class="head-hint">
MRM: when money from several complaints is mixed in one account, it is shared in proportion to the disputed
amounts. Leave the amount empty to share everything held.
</span>
</div>
<div class="i4c-card__body">
<div class="i4c-filter-bar i4c-filter-bar--inset">
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Account no.</mat-label>
<input matInput [(ngModel)]="proRataAccount" (keyup.enter)="calculate()" />
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Amount to share (₹)</mat-label>
<input matInput type="number" min="0" [(ngModel)]="proRataAmount" (keyup.enter)="calculate()" />
</mat-form-field>
<button mat-flat-button color="primary" (click)="calculate()" [disabled]="!proRataAccount.trim()">
<fa-icon icon="calculator"></fa-icon> Calculate
</button>
</div>
@if (proRata) {
<table class="i4c-table">
<thead>
<tr>
<th>Complaint (ack)</th>
<th class="num">Layer</th>
<th class="num">Disputed</th>
<th class="num">Held for it</th>
<th class="num">Share</th>
</tr>
</thead>
<tbody>
@for (h of proRata.holds; track h.id) {
<tr>
<td>{{ h.acknowledgement_no }}</td>
<td class="num">{{ h.layer }}</td>
<td class="num">{{ h.disputed_amount | number: '1.2-2' }}</td>
<td class="num">{{ h.amount_held | number: '1.2-2' }}</td>
<td class="num">
<b>{{ h.share | number: '1.2-2' }}</b>
</td>
</tr>
}
</tbody>
</table>
<div class="sub">
₹{{ proRata.amount | number: '1.2-2' }} of ₹{{ proRata.totalHeld | number: '1.2-2' }} held on
{{ proRata.accountNo }}, by disputed amount (total ₹{{ proRata.totalDisputed | number: '1.2-2' }}). A share
never exceeds what that complaint's hold has; use the figure as the restorable amount of that complaint's
restoration.
</div>
}
</div>
</div>
</div>
@@ -0,0 +1,13 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
@use '../i4c' as *;
@use '../i4c-cases' as *;
@include i4c-skin;
@include i4c-case-page;
@@ -0,0 +1,123 @@
/**
* 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 { DecimalPipe } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatSnackBar } from '@angular/material/snack-bar';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { I4cService, i4cErrorMessage, saveBlob } from '../i4c.service';
/**
* MRM "Update Account Balance": every account with an active I4C hold and its balances now, for the bank to update on
* MRM (one by one or with the bulk sheet), plus the pro-rata calculator for accounts with several complaints.
*/
@Component({
selector: 'mifosx-i4c-mrm-report',
templateUrl: './i4c-mrm-report.component.html',
styleUrls: ['./i4c-mrm-report.component.scss'],
imports: [
DecimalPipe,
FormsModule,
MatButtonModule,
MatFormFieldModule,
MatInputModule,
MatProgressBarModule,
FaIconComponent
]
})
export class I4cMrmReportComponent implements OnInit {
private i4c = inject(I4cService);
private snackBar = inject(MatSnackBar);
rows: any[] = [];
loading = false;
error = '';
filter = '';
proRataAccount = '';
proRataAmount: number | null = null;
proRata: any = null;
ngOnInit(): void {
this.load();
}
load(): void {
this.loading = true;
this.error = '';
this.i4c.mrmBalanceReport().subscribe({
next: (rows) => {
this.rows = rows;
this.loading = false;
},
error: (e) => {
this.error = i4cErrorMessage(e);
this.loading = false;
}
});
}
get shown(): any[] {
const f = this.filter.trim().toLowerCase();
return f
? this.rows.filter((r) =>
[
r.account_no,
r.client_name,
r.office_name,
r.acknowledgement_nos
].some((v) =>
String(v || '')
.toLowerCase()
.includes(f)
)
)
: this.rows;
}
get totals(): { held: number; ledger: number; complaints: number } {
return this.shown.reduce(
(t, r) => ({
held: t.held + Number(r.held || 0),
ledger: t.ledger + Number(r.ledger_balance || 0),
complaints: t.complaints + Number(r.complaints || 0)
}),
{ held: 0, ledger: 0, complaints: 0 }
);
}
download(): void {
this.i4c.mrmBalanceReportCsv().subscribe({
next: (blob) => saveBlob(blob, 'mrm-balance-report-' + new Date().toISOString().slice(0, 10) + '.csv'),
error: (e) => this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 })
});
}
pick(r: any): void {
this.proRataAccount = r.account_no;
this.proRataAmount = null;
this.calculate();
}
calculate(): void {
if (!this.proRataAccount.trim()) {
return;
}
this.i4c.proRata(this.proRataAccount.trim(), this.proRataAmount).subscribe({
next: (p) => (this.proRata = p),
error: (e) => {
this.proRata = null;
this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 });
}
});
}
}
@@ -0,0 +1,497 @@
<!--
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="i4c-page">
<!-- View Switcher & Toolbar -->
<div class="i4c-toolbar">
<div class="i4c-filter-chips">
<button class="filter-chip" [class.filter-chip--active]="activeSection === 'all'" (click)="activeSection = 'all'">
<fa-icon icon="list-ul"></fa-icon> <span>All (Outbox &amp; Log)</span>
</button>
<button
class="filter-chip"
[class.filter-chip--active]="activeSection === 'outbox'"
(click)="activeSection = 'outbox'"
>
<fa-icon icon="paper-plane"></fa-icon> <span>Responses to I4C ({{ outboxTotal }})</span>
</button>
<button class="filter-chip" [class.filter-chip--active]="activeSection === 'log'" (click)="activeSection = 'log'">
<fa-icon icon="history"></fa-icon> <span>API Audit Log ({{ logTotal }})</span>
</button>
</div>
<span class="i4c-toolbar__spacer"></span>
<button mat-flat-button color="primary" class="refresh-btn" (click)="loadOutbox(); loadLog()" [disabled]="loading">
<fa-icon icon="sync" [animation]="loading ? 'spin' : undefined"></fa-icon> Refresh
</button>
</div>
@if (loading) {
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
}
@if (error) {
<div class="i4c-error">
<fa-icon icon="exclamation-circle"></fa-icon>
<span>{{ error }}</span>
</div>
}
<!-- SECTION 1: RESPONSES TO I4C (OUTBOX) -->
@if (activeSection === 'all' || activeSection === 'outbox') {
<div class="i4c-card">
<div class="i4c-card__head">
<fa-icon icon="paper-plane"></fa-icon>
<span>Outbox Queue: Delivery to I4C</span>
<span class="head-hint">Automated HTTP callbacks carrying bank findings to I4C</span>
<span class="i4c-toolbar__spacer"></span>
<!-- Status Filter Chips -->
<div class="mini-chips">
@for (s of outboxStatuses; track s) {
<button class="mini-chip" [class.mini-chip--active]="outboxStatus === s" (click)="setOutboxStatus(s)">
{{ s || 'All' }}
</button>
}
</div>
</div>
<div class="i4c-filter-bar i4c-filter-bar--inset">
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Ack no. / job id</mat-label>
<input matInput [(ngModel)]="outboxSearch" (keyup.enter)="loadOutbox(true)" (blur)="loadOutbox(true)" />
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Type</mat-label>
<mat-select [(ngModel)]="outboxType" (selectionChange)="loadOutbox(true)">
<mat-option value="">ACK and RESPONSE</mat-option>
<mat-option value="ACK">ACK only</mat-option>
<mat-option value="RESPONSE">RESPONSE only</mat-option>
</mat-select>
</mat-form-field>
<mifosx-i4c-date-range
label="Queued"
[range]="outboxDates"
(rangeChange)="onOutboxDates($event)"
></mifosx-i4c-date-range>
<button
mat-stroked-button
class="i4c-filter-bar__clear"
(click)="clearOutboxFilters()"
[disabled]="!outboxHasFilters"
>
<fa-icon icon="times"></fa-icon> Clear filters
</button>
</div>
<div class="i4c-table-wrap">
<table class="i4c-table">
<thead>
<tr>
<th>Created At</th>
<th>Complaint Ack No.</th>
<th title="ACK: separate acknowledgement callback. RESPONSE: the Section 6 findings.">Type</th>
<th>Delivery Status</th>
<th class="num">Attempts</th>
<th>Next Retry</th>
<th>HTTP Code</th>
<th>Sent Timestamp</th>
<th>Error</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (o of outbox; track o.id) {
<tr class="clickable" (click)="openOutboxModal(o)">
<td>
<span class="date-main">{{ o.created_at | date: 'dd MMM, HH:mm:ss' }}</span>
</td>
<td>
<a
[routerLink]="['../requests', o.request_id]"
class="font-semibold text-accent"
(click)="$event.stopPropagation()"
>
{{ o.acknowledgement_no }}
</a>
</td>
<td>
<span class="i4c-badge" [class.i4c-badge--blue]="o.message_type === 'ACK'">{{
o.message_type === 'ACK' ? 'ACK' : 'RESPONSE'
}}</span>
</td>
<td>
<span
class="i4c-badge"
[class.i4c-badge--green]="o.status === 'SENT'"
[class.i4c-badge--red]="o.status === 'FAILED'"
[class.i4c-badge--amber]="o.status === 'PENDING' || o.status === 'SENDING'"
>
{{ o.status }}
</span>
</td>
<td class="num font-semibold">{{ o.attempts }}</td>
<td>{{ o.status === 'PENDING' ? (o.next_attempt_at | date: 'dd MMM, HH:mm:ss') : '-' }}</td>
<td>
<span class="font-mono">{{ o.http_status ?? '-' }}</span>
</td>
<td>{{ o.sent_at ? (o.sent_at | date: 'dd MMM, HH:mm:ss') : '-' }}</td>
<td class="err-text">{{ o.last_error || '-' }}</td>
<td (click)="$event.stopPropagation()">
<div class="i4c-actions">
<button class="table-action-btn" (click)="openOutboxModal(o)">
<fa-icon icon="eye"></fa-icon> Payload
</button>
@if (o.status === 'FAILED') {
<button class="retry-btn" (click)="retry(o)" [disabled]="busyId === o.id">
<fa-icon icon="redo" [animation]="busyId === o.id ? 'spin' : undefined"></fa-icon> Retry
</button>
}
</div>
</td>
</tr>
} @empty {
<tr>
<td colspan="10" class="i4c-empty">
<div class="empty-icon"><fa-icon icon="paper-plane"></fa-icon></div>
<div>No responses in the outbox queue matching current filter.</div>
</td>
</tr>
}
</tbody>
</table>
</div>
<mat-paginator
class="i4c-paginator"
[length]="outboxTotal"
[pageIndex]="outboxPageIndex"
[pageSize]="outboxPageSize"
[pageSizeOptions]="pageSizes"
(page)="onOutboxPage($event)"
showFirstLastButtons
aria-label="Outbox pages"
></mat-paginator>
</div>
}
<!-- SECTION 2: API LOG -->
@if (activeSection === 'all' || activeSection === 'log') {
<div class="i4c-card">
<div class="i4c-card__head">
<fa-icon icon="history"></fa-icon>
<span>API Gateway Audit Trail (Masked)</span>
<span class="head-hint">Every call to and from I4C. Click a row to see its request and response.</span>
<span class="i4c-toolbar__spacer"></span>
<!-- Direction Filters -->
<div class="mini-chips">
<button class="mini-chip" [class.mini-chip--active]="direction === ''" (click)="setDirection('')">
Both
</button>
<button class="mini-chip" [class.mini-chip--active]="direction === 'IN'" (click)="setDirection('IN')">
↓ Inbound
</button>
<button class="mini-chip" [class.mini-chip--active]="direction === 'OUT'" (click)="setDirection('OUT')">
↑ Outbound
</button>
</div>
<mat-checkbox [(ngModel)]="failuresOnly" (change)="loadLog(true)" class="failures-chk">
Failures Only
</mat-checkbox>
</div>
<div class="i4c-filter-bar i4c-filter-bar--inset">
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Endpoint / ack no. / IP</mat-label>
<input matInput [(ngModel)]="logSearch" (keyup.enter)="loadLog(true)" (blur)="loadLog(true)" />
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>HTTP status</mat-label>
<mat-select [(ngModel)]="logHttpStatus" (selectionChange)="loadLog(true)">
<mat-option value="">Any</mat-option>
<mat-option value="200">200 OK</mat-option>
<mat-option value="400">400 Bad request</mat-option>
<mat-option value="401">401 Not authorised</mat-option>
<mat-option value="403">403 Forbidden</mat-option>
<mat-option value="409">409 Duplicate</mat-option>
<mat-option value="500">500 Server error</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Response code</mat-label>
<input
matInput
[(ngModel)]="logResponseCode"
(keyup.enter)="loadLog(true)"
(blur)="loadLog(true)"
placeholder="e.g. 00, 11"
/>
</mat-form-field>
<mifosx-i4c-date-range
label="Call time"
[range]="logDates"
(rangeChange)="onLogDates($event)"
></mifosx-i4c-date-range>
<button
mat-stroked-button
class="i4c-filter-bar__clear"
(click)="clearLogFilters()"
[disabled]="!logHasFilters"
>
<fa-icon icon="times"></fa-icon> Clear filters
</button>
</div>
<div class="i4c-table-wrap">
<table class="i4c-table">
<thead>
<tr>
<th>Time</th>
<th>Direction</th>
<th>Endpoint</th>
<th>HTTP Status</th>
<th>Response Code</th>
<th>Ack No.</th>
<th class="num">Latency (ms)</th>
<th>Caller IP</th>
<th>Error Details</th>
</tr>
</thead>
<tbody>
@for (l of log; track l.id) {
<tr class="clickable" (click)="openLogModal(l)">
<td>{{ l.created_at | date: 'dd MMM, HH:mm:ss' }}</td>
<td>
<span class="direction-badge direction-badge--{{ l.direction?.toLowerCase() }}">
{{ l.direction === 'IN' ? '↓ I4C → Bank' : '↑ Bank → I4C' }}
</span>
</td>
<td>
<span class="font-mono text-xs">{{ l.endpoint }}</span>
</td>
<td>
<span
class="i4c-badge"
[class.i4c-badge--green]="ok(l.http_status)"
[class.i4c-badge--red]="!ok(l.http_status)"
>
{{ l.http_status ?? 'N/A' }}
</span>
</td>
<td>{{ l.response_code || '-' }}</td>
<td>
<b>{{ l.acknowledgement_no || '-' }}</b>
</td>
<td class="num">{{ l.duration_ms }} ms</td>
<td>{{ l.client_ip || '-' }}</td>
<td [class.err-text]="isFailedCall(l)" [class.note-text]="!isFailedCall(l)">
{{ logNote(l.error) || '-' }}
</td>
</tr>
} @empty {
<tr>
<td colspan="9" class="i4c-empty">
<div class="empty-icon"><fa-icon icon="history"></fa-icon></div>
<div>No API calls recorded.</div>
</td>
</tr>
}
</tbody>
</table>
</div>
<mat-paginator
class="i4c-paginator"
[length]="logTotal"
[pageIndex]="logPageIndex"
[pageSize]="logPageSize"
[pageSizeOptions]="pageSizes"
(page)="onLogPage($event)"
showFirstLastButtons
aria-label="API log pages"
></mat-paginator>
</div>
}
<!-- ========================================================
MODERN POPUP WINDOW: API LOG JSON INSPECTION MODAL
======================================================== -->
@if (selectedLogModal) {
<div class="i4c-modal-backdrop" (click)="closeLogModal()">
<div class="i4c-modal-window i4c-modal-window--wide" (click)="$event.stopPropagation()">
<!-- Modal Header -->
<div class="i4c-modal-header">
<div class="i4c-modal-header__title">
<fa-icon icon="code"></fa-icon>
<div>
<div class="modal-title-row">
<span
>{{ selectedLogModal.direction === 'IN' ? 'Inbound Call' : 'Outbound Call' }}:
{{ selectedLogModal.endpoint }}</span
>
<span
class="i4c-badge"
[class.i4c-badge--green]="ok(selectedLogModal.http_status)"
[class.i4c-badge--red]="!ok(selectedLogModal.http_status)"
>
HTTP {{ selectedLogModal.http_status ?? 'N/A' }}
</span>
</div>
<div class="i4c-modal-header__sub">
Time: {{ selectedLogModal.created_at | date: 'dd MMM yyyy, HH:mm:ss' }} • Latency:
{{ selectedLogModal.duration_ms }} ms • Client IP: {{ selectedLogModal.client_ip }}
</div>
</div>
</div>
<button class="modal-close-btn" (click)="closeLogModal()" aria-label="Close">
<fa-icon icon="times"></fa-icon>
</button>
</div>
<!-- Navigation Tabs inside the Modal -->
<div class="i4c-modal-tabs">
<button class="modal-tab" [class.modal-tab--active]="logModalTab === 'req'" (click)="logModalTab = 'req'">
<fa-icon icon="arrow-up"></fa-icon> Request Payload
</button>
<button class="modal-tab" [class.modal-tab--active]="logModalTab === 'res'" (click)="logModalTab = 'res'">
<fa-icon icon="arrow-down"></fa-icon> Response Payload
</button>
</div>
<!-- Modal Body -->
<div class="i4c-modal-body">
@if (logModalTab === 'req') {
<div class="payload-box">
<div class="payload-box__title">
<span>HTTP Request Payload (Masked)</span>
<button class="copy-chip" (click)="copyPayload(pretty(selectedLogModal.request_body), 'req-body')">
<fa-icon icon="copy"></fa-icon> {{ copiedKey === 'req-body' ? 'Copied!' : 'Copy Request' }}
</button>
</div>
<pre class="i4c-json">{{ pretty(selectedLogModal.request_body) || 'No request body sent.' }}</pre>
</div>
}
@if (logModalTab === 'res') {
<div class="payload-box">
<div class="payload-box__title">
<span>HTTP Response Payload</span>
<button class="copy-chip" (click)="copyPayload(pretty(selectedLogModal.response_body), 'res-body')">
<fa-icon icon="copy"></fa-icon> {{ copiedKey === 'res-body' ? 'Copied!' : 'Copy Response' }}
</button>
</div>
<pre class="i4c-json">{{ pretty(selectedLogModal.response_body) || 'No response body received.' }}</pre>
</div>
}
@if (selectedLogModal.error) {
@if (isFailedCall(selectedLogModal)) {
<div class="i4c-error">
<fa-icon icon="exclamation-circle"></fa-icon>
<span>Error logged: {{ logNote(selectedLogModal.error) }}</span>
</div>
} @else {
<div class="i4c-note">
<fa-icon icon="info-circle"></fa-icon>
<span>Note: {{ logNote(selectedLogModal.error) }}</span>
</div>
}
}
</div>
<!-- Modal Footer -->
<div class="i4c-modal-footer">
<button class="footer-btn-close" (click)="closeLogModal()">Close</button>
</div>
</div>
</div>
}
<!-- ========================================================
MODERN POPUP WINDOW: OUTBOX RESPONSE INSPECTION MODAL
======================================================== -->
@if (selectedOutboxModal) {
<div class="i4c-modal-backdrop" (click)="closeOutboxModal()">
<div class="i4c-modal-window i4c-modal-window--wide" (click)="$event.stopPropagation()">
<div class="i4c-modal-header">
<div class="i4c-modal-header__title">
<fa-icon icon="paper-plane"></fa-icon>
<div>
<div class="modal-title-row">
<span
>Outbox Response #{{ selectedOutboxModal.id }} (Ack #{{
selectedOutboxModal.acknowledgement_no
}})</span
>
<span
class="i4c-badge"
[class.i4c-badge--green]="selectedOutboxModal.status === 'SENT'"
[class.i4c-badge--red]="selectedOutboxModal.status === 'FAILED'"
>
{{ selectedOutboxModal.status }}
</span>
</div>
<div class="i4c-modal-header__sub">
Created: {{ selectedOutboxModal.created_at | date: 'dd MMM yyyy, HH:mm:ss' }} • Attempts:
{{ selectedOutboxModal.attempts }}
</div>
</div>
</div>
<button class="modal-close-btn" (click)="closeOutboxModal()" aria-label="Close">
<fa-icon icon="times"></fa-icon>
</button>
</div>
<div class="i4c-modal-body">
<div class="payload-box">
<div class="payload-box__title">
<span>Report Payload Prepared for I4C Delivery</span>
<button class="copy-chip" (click)="copyPayload(pretty(selectedOutboxModal.payload), 'outbox-payload')">
<fa-icon icon="copy"></fa-icon> {{ copiedKey === 'outbox-payload' ? 'Copied!' : 'Copy Payload' }}
</button>
</div>
<pre class="i4c-json">{{ pretty(selectedOutboxModal.payload) || 'No payload recorded.' }}</pre>
</div>
@if (selectedOutboxModal.response_body) {
<div class="payload-box">
<div class="payload-box__title">
<span>Response Received from I4C Gateway</span>
<button
class="copy-chip"
(click)="copyPayload(pretty(selectedOutboxModal.response_body), 'outbox-res')"
>
<fa-icon icon="copy"></fa-icon> {{ copiedKey === 'outbox-res' ? 'Copied!' : 'Copy Response' }}
</button>
</div>
<pre class="i4c-json">{{ pretty(selectedOutboxModal.response_body) }}</pre>
</div>
}
@if (selectedOutboxModal.last_error) {
<div class="i4c-error">
<fa-icon icon="exclamation-circle"></fa-icon>
<span>Last Delivery Error: {{ selectedOutboxModal.last_error }}</span>
</div>
}
</div>
<div class="i4c-modal-footer">
@if (selectedOutboxModal.status === 'FAILED') {
<button mat-flat-button color="warn" (click)="retry(selectedOutboxModal); closeOutboxModal()">
<fa-icon icon="redo"></fa-icon> Retry Send Now
</button>
}
<button class="footer-btn-close" (click)="closeOutboxModal()">Close</button>
</div>
</div>
</div>
}
</div>
@@ -0,0 +1,191 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
@use '../i4c' as *;
@include i4c-skin;
.compact {
width: 160px;
font-size: 13px;
}
.sub {
font-size: 11px;
color: #64748b;
margin-bottom: 4px;
}
.err-text {
color: #dc2626;
font-size: 11px;
max-width: 240px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.head-hint {
font-size: 11px;
color: #94a3b8;
font-weight: normal;
margin-left: 8px;
}
.date-main {
font-weight: 600;
color: #1e293b;
}
.mini-chips {
display: flex;
align-items: center;
gap: 5px;
.mini-chip {
padding: 2px 8px;
border-radius: 9999px;
font-size: 10.5px;
font-weight: 600;
color: #475569;
background: #f1f5f9;
border: 1px solid #e2e8f0;
cursor: pointer;
transition: all 0.15s ease;
&:hover {
background: #e2e8f0;
color: #0f172a;
}
&--active {
background: #2563eb;
color: #fff;
border-color: #2563eb;
box-shadow: 0 2px 6px rgb(37 99 235 / 25%);
}
}
}
.failures-chk {
margin-left: 10px;
font-size: 11px;
font-weight: 600;
color: #475569;
}
.direction-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 7px;
border-radius: 5px;
font-size: 10px;
font-weight: 700;
&--in {
background: #eff6ff;
color: #1d4ed8;
border: 1px solid #bfdbfe;
}
&--out {
background: #f5f3ff;
color: #6d28d9;
border: 1px solid #ddd6fe;
}
}
.retry-btn {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 8px;
border-radius: 5px;
font-size: 10.5px;
font-weight: 700;
background: #fef2f2;
color: #dc2626;
border: 1px solid #fecaca;
cursor: pointer;
transition: all 0.15s ease;
&:hover:not(:disabled) {
background: #fee2e2;
border-color: #fca5a5;
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.modal-title-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.payload-box {
display: flex;
flex-direction: column;
gap: 6px;
&__title {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 12px;
font-weight: 700;
color: #1e293b;
}
}
.copy-chip {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 8px;
border-radius: 5px;
background: #f1f5f9;
border: 1px solid #cbd5e1;
color: #334155;
font-size: 10.5px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s ease;
&:hover {
background: #e2e8f0;
color: #0f172a;
}
}
.footer-btn-close {
padding: 6px 14px;
border-radius: 8px;
background: #e2e8f0;
color: #334155;
border: none;
font-size: 11.5px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: #cbd5e1;
color: #0f172a;
}
}
.note-text {
color: #64748b;
font-size: 12px;
}
@@ -0,0 +1,306 @@
/**
* 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 { DatePipe } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { MatSnackBar } from '@angular/material/snack-bar';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { I4cService, i4cErrorMessage } from '../i4c.service';
import { ALL_DATES, I4cDateRange, I4cDateRangeComponent } from '../i4c-date-range/i4c-date-range.component';
/**
* Responses waiting for, or delivered to, I4C, with a retry for the failed ones; and the log of every call in
* either direction, which is what the periodic review of the API integration reads.
*/
@Component({
selector: 'mifosx-i4c-outbox',
templateUrl: './i4c-outbox.component.html',
styleUrls: ['./i4c-outbox.component.scss'],
imports: [
DatePipe,
FormsModule,
RouterLink,
MatButtonModule,
MatFormFieldModule,
MatInputModule,
MatSelectModule,
MatCheckboxModule,
MatProgressBarModule,
MatPaginatorModule,
I4cDateRangeComponent,
FaIconComponent
]
})
export class I4cOutboxComponent implements OnInit {
private i4c = inject(I4cService);
private route = inject(ActivatedRoute);
private snackBar = inject(MatSnackBar);
readonly outboxStatuses = [
'',
'PENDING',
'SENDING',
'SENT',
'FAILED'
];
readonly pageSizes = [
10,
20,
50,
100
];
outboxStatus = '';
outboxType = '';
outboxSearch = '';
outboxDates: I4cDateRange = { ...ALL_DATES };
outboxTotal = 0;
outboxPageIndex = 0;
outboxPageSize = 20;
outbox: any[] = [];
direction = '';
failuresOnly = false;
logSearch = '';
logHttpStatus = '';
logResponseCode = '';
logDates: I4cDateRange = { ...ALL_DATES };
logTotal = 0;
logPageIndex = 0;
logPageSize = 20;
log: any[] = [];
logEntry: any = null;
loading = false;
busyId: number | null = null;
error = '';
activeSection: 'all' | 'outbox' | 'log' = 'all';
selectedLogModal: any = null;
selectedOutboxModal: any = null;
logModalTab: 'req' | 'res' = 'req';
copiedKey = '';
ngOnInit(): void {
this.outboxStatus = this.route.snapshot.queryParamMap.get('status') || '';
this.loadOutbox();
this.loadLog();
}
setOutboxStatus(s: string): void {
this.outboxStatus = s;
this.loadOutbox(true);
}
setDirection(d: string): void {
this.direction = d;
this.loadLog(true);
}
onOutboxPage(e: PageEvent): void {
this.outboxPageIndex = e.pageIndex;
this.outboxPageSize = e.pageSize;
this.loadOutbox();
}
onOutboxDates(range: I4cDateRange): void {
this.outboxDates = range;
this.loadOutbox(true);
}
get outboxHasFilters(): boolean {
return !!(this.outboxStatus || this.outboxType || this.outboxSearch || this.outboxDates.preset !== 'ALL');
}
clearOutboxFilters(): void {
this.outboxStatus = '';
this.outboxType = '';
this.outboxSearch = '';
this.outboxDates = { ...ALL_DATES };
this.loadOutbox(true);
}
onLogPage(e: PageEvent): void {
this.logPageIndex = e.pageIndex;
this.logPageSize = e.pageSize;
this.loadLog();
}
onLogDates(range: I4cDateRange): void {
this.logDates = range;
this.loadLog(true);
}
get logHasFilters(): boolean {
return !!(
this.direction ||
this.failuresOnly ||
this.logSearch ||
this.logHttpStatus ||
this.logResponseCode ||
this.logDates.preset !== 'ALL'
);
}
clearLogFilters(): void {
this.direction = '';
this.failuresOnly = false;
this.logSearch = '';
this.logHttpStatus = '';
this.logResponseCode = '';
this.logDates = { ...ALL_DATES };
this.loadLog(true);
}
/** Reload the current page; a changed filter passes `fromFirstPage` so the officer lands on page one. */
loadOutbox(fromFirstPage = false): void {
if (fromFirstPage) {
this.outboxPageIndex = 0;
}
this.loading = true;
this.error = '';
this.i4c
.outbox({
status: this.outboxStatus,
type: this.outboxType,
search: this.outboxSearch.trim(),
fromDate: this.outboxDates.fromDate,
toDate: this.outboxDates.toDate,
offset: this.outboxPageIndex * this.outboxPageSize,
limit: this.outboxPageSize
})
.subscribe({
next: (page) => {
this.outbox = page.pageItems;
this.outboxTotal = page.totalFilteredRecords;
this.loading = false;
},
error: (e) => {
this.error = i4cErrorMessage(e);
this.loading = false;
}
});
}
loadLog(fromFirstPage = false): void {
if (fromFirstPage) {
this.logPageIndex = 0;
}
this.i4c
.apiLog({
direction: this.direction,
failures: this.failuresOnly || null,
search: this.logSearch.trim(),
httpStatus: this.logHttpStatus,
responseCode: this.logResponseCode.trim(),
fromDate: this.logDates.fromDate,
toDate: this.logDates.toDate,
offset: this.logPageIndex * this.logPageSize,
limit: this.logPageSize
})
.subscribe({
next: (page) => {
this.log = page.pageItems;
this.logTotal = page.totalFilteredRecords;
},
error: (e) => (this.error = i4cErrorMessage(e))
});
}
retry(row: any): void {
this.busyId = row.id;
this.i4c.retryOutbox(row.id).subscribe({
next: (r) => {
this.busyId = null;
this.snackBar.open(r?.sent ? 'Delivered to I4C.' : 'Queued; it will be sent again shortly.', 'OK', {
duration: 4000
});
this.loadOutbox();
this.loadLog();
},
error: (e) => {
this.busyId = null;
this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 });
}
});
}
openLogModal(row: any): void {
this.selectedLogModal = { ...row };
this.logModalTab = 'req';
this.i4c.apiLogEntry(row.id).subscribe({
next: (entry) => (this.selectedLogModal = entry),
error: (e) => this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 })
});
}
closeLogModal(): void {
this.selectedLogModal = null;
}
openOutboxModal(row: any): void {
this.selectedOutboxModal = row;
}
closeOutboxModal(): void {
this.selectedOutboxModal = null;
}
copyPayload(text: string, key: string): void {
if (!text) {
return;
}
navigator.clipboard.writeText(text);
this.copiedKey = key;
this.snackBar.open('Payload copied to clipboard.', 'OK', { duration: 2500 });
setTimeout(() => {
if (this.copiedKey === key) {
this.copiedKey = '';
}
}, 2000);
}
/** A call failed when HTTP says so or I4C/the bank answered with a code other than 00. */
isFailedCall(log: any): boolean {
return (log?.http_status ?? 0) >= 400 || (!!log?.response_code && log.response_code !== '00');
}
/** Successful calls keep a short note in the error column, e.g. how I4C logged in; spell it out. */
logNote(text: string): string {
const modes: Record<string, string> = {
TOKEN: 'I4C logged in with a Bearer token',
BASIC: 'I4C logged in with Basic authentication',
BODY: 'I4C sent its username and password in the request body'
};
return (text || '').replace(/auth (TOKEN|BASIC|BODY)/g, (m, mode) => modes[mode] ?? m);
}
pretty(text: string): string {
if (!text) {
return '';
}
try {
return JSON.stringify(JSON.parse(text), null, 2);
} catch {
return text;
}
}
ok(status: number | null): boolean {
return status !== null && status !== undefined && status >= 200 && status < 300;
}
}
@@ -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/.
-->
<div class="i4c-page">
<!-- Top Action Toolbar -->
<div class="i4c-toolbar">
<a mat-stroked-button routerLink="../" class="back-btn"> <fa-icon icon="arrow-left"></fa-icon> All Complaints </a>
@if (data) {
<div class="ack-badge-strip">
<span class="label">Ack No:</span>
<b class="val">{{ data.acknowledgement_no }}</b>
<button class="copy-chip" (click)="copyAck(data.acknowledgement_no)">
<fa-icon icon="copy"></fa-icon> Copy
</button>
</div>
}
<span class="i4c-toolbar__spacer"></span>
@if (canReprocess) {
<button mat-flat-button color="primary" (click)="reprocess()" [disabled]="busy">
<fa-icon icon="redo" [animation]="busy ? 'spin' : undefined"></fa-icon> Reprocess Now
</button>
}
<button mat-stroked-button (click)="load()" [disabled]="loading">
<fa-icon icon="sync" [animation]="loading ? 'spin' : undefined"></fa-icon> Refresh
</button>
</div>
@if (loading || busy) {
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
}
@if (error) {
<div class="i4c-error">
<fa-icon icon="exclamation-circle"></fa-icon>
<span>{{ error }}</span>
</div>
}
@if (data) {
<!-- Main Top Info: Customer Target Account + Processing -->
<div class="i4c-grid-2">
<!-- Target Account Info -->
<div class="i4c-card">
<div class="i4c-card__head">
<fa-icon icon="inbox"></fa-icon>
<span>Complaint Details (From I4C)</span>
</div>
<div class="i4c-card__body">
<dl class="i4c-kv">
<dt>Acknowledgement No.</dt>
<dd>
<b class="text-accent">{{ data.acknowledgement_no }}</b>
</dd>
<dt>CBS Internal Job ID</dt>
<dd>{{ data.job_id }}</dd>
<dt>Sub-Category</dt>
<dd>
<span class="cat-chip">{{ data.sub_category }}</span>
</dd>
<dt>Account in this Bank</dt>
<dd>
<b>{{ data.payer_account_number }}</b>
</dd>
<dt>Complainant Bank</dt>
<dd>{{ data.payer_bank }} {{ data.payer_bank_code ? '(' + data.payer_bank_code + ')' : '' }}</dd>
<dt>Payment Mode / Type</dt>
<dd>{{ data.mode_of_payment || '-' }} {{ data.transaction_type ? '/ ' + data.transaction_type : '' }}</dd>
<dt>Wallet Reference</dt>
<dd>{{ data.wallet || 'None' }}</dd>
<dt>State &amp; District</dt>
<dd>{{ data.state }} / {{ data.district }}</dd>
<dt>Received At</dt>
<dd>{{ data.received_at | date: 'dd MMM yyyy, HH:mm:ss' }} from {{ data.source_ip }}</dd>
</dl>
</div>
</div>
<!-- Processing State Info -->
<div class="i4c-card">
<div class="i4c-card__head">
<fa-icon icon="tasks"></fa-icon>
<span>Automation &amp; Processing Outcome</span>
</div>
<div class="i4c-card__body">
<dl class="i4c-kv">
<dt>Processing Status</dt>
<dd>
<span class="i4c-badge">{{ data.status }}</span>
</dd>
<dt>Processing Attempts</dt>
<dd>{{ data.processing_attempts }}</dd>
<dt>Processed On</dt>
<dd>{{ data.processed_at ? (data.processed_at | date: 'dd MMM yyyy, HH:mm:ss') : 'Not yet processed' }}</dd>
<dt>Total Amount Held</dt>
<dd>
<b class="text-accent">₹{{ data.amount_held | number: '1.2-2' }}</b>
</dd>
<dt>Outbox Response Status</dt>
<dd>
<span class="i4c-badge">{{ data.response_status || 'NOT YET SENT' }}</span>
</dd>
@if (data.processing_error) {
<dt>Last Processing Error</dt>
<dd class="err-text">{{ data.processing_error }}</dd>
}
</dl>
</div>
</div>
</div>
<!-- Incidents Table -->
<div class="i4c-card">
<div class="i4c-card__head">
<fa-icon icon="list-alt"></fa-icon>
<span>Reported Incidents &amp; CBS Matching Engine Outcomes</span>
</div>
<div class="i4c-card__body i4c-table-wrap">
<table class="i4c-table">
<thead>
<tr>
<th>#</th>
<th>RRN</th>
<th>Date &amp; Time</th>
<th class="num">Amount (₹)</th>
<th class="num">Disputed (₹)</th>
<th class="num">Layer</th>
<th>Match Method</th>
<th>Outcome</th>
<th class="num">Held Amount</th>
<th>Remarks</th>
</tr>
</thead>
<tbody>
@for (inc of data.incidents; track inc.id) {
<tr>
<td>
<b>{{ inc.seq_no }}</b>
</td>
<td>
<span class="font-mono">{{ inc.rrn }}</span>
</td>
<td>{{ inc.transaction_date }} {{ inc.transaction_time }}</td>
<td class="num">{{ inc.amount | number: '1.2-2' }}</td>
<td class="num">{{ inc.disputed_amount | number: '1.2-2' }}</td>
<td class="num font-semibold">{{ inc.layer }}</td>
<td>
<span>{{ methodLabel(inc.match_method) }}</span>
@if (inc.matched_txn_id) {
<div class="sub">Txn #{{ inc.matched_txn_id }}</div>
}
</td>
<td>
<span class="i4c-badge" [class]="outcomeClass(inc.outcome)">{{ inc.outcome || 'PENDING' }}</span>
<div class="sub">Code: {{ inc.status_code || '-' }}</div>
</td>
<td class="num">
@if (holdFor(inc.id); as hold) {
₹{{ hold.amount_held | number: '1.2-2' }}
@if (hold.shortfall > 0) {
<div class="sub err-text">Short: ₹{{ hold.shortfall | number: '1.2-2' }}</div>
}
} @else {
-
}
</td>
<td>{{ inc.remarks || '-' }}</td>
</tr>
}
</tbody>
</table>
</div>
</div>
<!-- Section 6 Findings -->
<div class="i4c-card">
<div class="i4c-card__head">
<fa-icon icon="paper-plane"></fa-icon>
<span>Entries Reported to I4C (Section 6 Compliance)</span>
</div>
<div class="i4c-card__body i4c-table-wrap">
<table class="i4c-table">
<thead>
<tr>
<th>Incident</th>
<th>Transaction Type</th>
<th class="num">Amount (₹)</th>
<th class="num">Disputed (₹)</th>
<th>Status Code</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (f of data.findings; track f.id) {
<tr>
<td>
<b>{{ f.incident_id }}</b>
</td>
<td>{{ f.txn_type }} ({{ f.txn_type_id }})</td>
<td class="num">₹{{ f.amount | number: '1.2-2' }}</td>
<td class="num">₹{{ f.disputed_amount | number: '1.2-2' }}</td>
<td>
<span class="i4c-badge">{{ f.status_code }}</span>
</td>
<td>
<button class="table-action-btn" (click)="openEntry = openEntry === f.id ? null : f.id">
<fa-icon [icon]="openEntry === f.id ? 'eye-slash' : 'eye'"></fa-icon>
<span>{{ openEntry === f.id ? 'Hide JSON' : 'View JSON' }}</span>
</button>
</td>
</tr>
@if (openEntry === f.id) {
<tr class="finding-json-row">
<td colspan="6" class="finding-json-cell">
<div class="finding-json-box">
<div class="finding-json-head">
<span> <fa-icon icon="code"></fa-icon> Finding #{{ f.id }} Payload </span>
<button type="button" class="copy-chip" (click)="copyAck(pretty(f.entry_json))">
<fa-icon icon="copy"></fa-icon>
<span>Copy</span>
</button>
</div>
<pre class="i4c-json">{{ pretty(f.entry_json) }}</pre>
</div>
</td>
</tr>
}
} @empty {
<tr>
<td colspan="6" class="i4c-empty">No section 6 findings reported yet.</td>
</tr>
}
</tbody>
</table>
</div>
</div>
<!-- Outbox Delivery -->
<div class="i4c-card">
<div class="i4c-card__head">
<fa-icon icon="history"></fa-icon>
<span>Delivery to I4C Outbox Queue</span>
</div>
<div class="i4c-card__body i4c-table-wrap">
<table class="i4c-table">
<thead>
<tr>
<th>Created At</th>
<th>Status</th>
<th class="num">Attempts</th>
<th>HTTP Code</th>
<th>Sent Timestamp</th>
<th>Error</th>
<th></th>
</tr>
</thead>
<tbody>
@for (o of data.outbox; track o.id) {
<tr>
<td>{{ o.created_at | date: 'dd MMM, HH:mm:ss' }}</td>
<td>
<span class="i4c-badge">{{ o.status }}</span>
</td>
<td class="num">{{ o.attempts }}</td>
<td>
<span class="font-mono">{{ o.http_status ?? '-' }}</span>
</td>
<td>{{ o.sent_at ? (o.sent_at | date: 'dd MMM, HH:mm:ss') : '-' }}</td>
<td class="err-text">{{ o.last_error || '-' }}</td>
<td>
@if (o.status === 'FAILED') {
<button mat-stroked-button color="warn" (click)="retry(o.id)" [disabled]="busy">
<fa-icon icon="redo"></fa-icon> Retry Send
</button>
}
</td>
</tr>
} @empty {
<tr>
<td colspan="7" class="i4c-empty">No response written to outbox yet.</td>
</tr>
}
</tbody>
</table>
</div>
</div>
<!-- Raw Payloads Accordion -->
<div class="i4c-card">
<div class="i4c-card__head">
<fa-icon icon="code"></fa-icon>
<span>Cryptographic &amp; Raw Payloads Inspection</span>
<span class="i4c-toolbar__spacer"></span>
<button mat-button (click)="showRaw = !showRaw">
<fa-icon [icon]="showRaw ? 'eye-slash' : 'eye'"></fa-icon>
<span>{{ showRaw ? 'Hide Payloads' : 'Inspect Payloads' }}</span>
</button>
</div>
@if (showRaw) {
<div class="i4c-card__body payloads">
<div>
<div class="sub-title">Received from I4C Gateway (Sensitive credentials and PAN masked)</div>
<pre class="i4c-json">{{ pretty(data.raw_payload) }}</pre>
</div>
@for (o of data.outbox; track o.id) {
<div>
<div class="sub-title">Response #{{ o.id }} Sent to I4C</div>
<pre class="i4c-json">{{ pretty(o.payload) }}</pre>
</div>
}
</div>
}
</div>
}
</div>
@@ -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/.
*/
@use '../i4c' as *;
@include i4c-skin;
.ack-badge-strip {
display: flex;
align-items: center;
gap: 6px;
padding: 3px 10px;
border-radius: 9999px;
background: #f1f5f9;
border: 1px solid #e2e8f0;
.label {
font-size: 10px;
font-weight: 600;
color: #64748b;
}
.val {
font-size: 12px;
color: #0f172a;
}
}
.text-accent {
color: #2563eb;
}
.cat-chip {
display: inline-block;
padding: 1px 6px;
border-radius: 4px;
background: #f1f5f9;
font-size: 10px;
color: #475569;
}
.sub {
font-size: 10.5px;
color: #64748b;
margin-top: 1px;
}
.sub-title {
font-size: 11.5px;
font-weight: 600;
color: #475569;
margin-bottom: 4px;
}
.err-text {
color: #dc2626;
font-size: 11px;
}
.payloads {
display: flex;
flex-direction: column;
gap: 12px;
}
@@ -0,0 +1,166 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { DatePipe, DecimalPipe } from '@angular/common';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { MatButtonModule } from '@angular/material/button';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatSnackBar } from '@angular/material/snack-bar';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { I4cService, i4cErrorMessage } from '../i4c.service';
/**
* One complaint from I4C end to end: what I4C sent, what the bank matched in its books, the holds it placed, every
* entry it reported back, and each attempt to deliver that report.
*/
@Component({
selector: 'mifosx-i4c-request-detail',
templateUrl: './i4c-request-detail.component.html',
styleUrls: ['./i4c-request-detail.component.scss'],
imports: [
DatePipe,
DecimalPipe,
RouterLink,
MatButtonModule,
MatProgressBarModule,
FaIconComponent
]
})
export class I4cRequestDetailComponent implements OnInit {
private i4c = inject(I4cService);
private route = inject(ActivatedRoute);
private snackBar = inject(MatSnackBar);
data: any = null;
loading = false;
busy = false;
error = '';
showRaw = false;
openEntry: number | null = null;
ngOnInit(): void {
this.load();
}
get id(): number {
return Number(this.route.snapshot.paramMap.get('id'));
}
load(): void {
this.loading = true;
this.error = '';
this.i4c.request(this.id).subscribe({
next: (d) => {
this.data = d;
this.loading = false;
},
error: (e) => {
this.error = i4cErrorMessage(e);
this.loading = false;
}
});
}
get canReprocess(): boolean {
return this.data?.status === 'RETRY' || this.data?.status === 'RECEIVED';
}
reprocess(): void {
this.busy = true;
this.i4c.reprocess(this.id).subscribe({
next: (d) => {
this.data = d;
this.busy = false;
this.snackBar.open('Complaint processed again.', 'OK', { duration: 3000 });
},
error: (e) => {
this.busy = false;
this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 });
}
});
}
retry(outboxId: number): void {
this.busy = true;
this.i4c.retryOutbox(outboxId).subscribe({
next: (r) => {
this.busy = false;
this.snackBar.open(
r?.sent ? 'Response delivered to I4C.' : 'Retry queued; it will be sent again shortly.',
'OK',
{
duration: 4000
}
);
this.load();
},
error: (e) => {
this.busy = false;
this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 });
}
});
}
findingsFor(incidentId: number): any[] {
return (this.data?.findings || []).filter((f: any) => f.incident_id === incidentId);
}
holdFor(incidentId: number): any {
return (this.data?.holds || []).find((h: any) => h.incident_id === incidentId);
}
pretty(json: string): string {
try {
return JSON.stringify(JSON.parse(json), null, 2);
} catch {
return json;
}
}
outcomeClass(outcome: string): string {
switch (outcome) {
case 'HELD':
case 'TRACED':
return 'i4c-badge--green';
case 'PARTIALLY_HELD':
case 'PROSPECTIVE_HOLD':
return 'i4c-badge--amber';
case 'NOT_FOUND':
case 'NO_MATCH':
case 'NOT_HOLDABLE':
case 'ERROR':
return 'i4c-badge--red';
default:
return '';
}
}
methodLabel(method: string): string {
switch (method) {
case 'RRN_FUND_TRANSFER':
return 'RRN on outward transfer';
case 'RRN_REFERENCE_TEXT':
return 'RRN in transaction narration';
case 'AMOUNT_DATE':
return 'Amount + date (no RRN in CBS)';
case 'NONE':
return 'Not matched';
default:
return method || '-';
}
}
copyAck(text: string): void {
if (!text) {
return;
}
navigator.clipboard.writeText(text);
this.snackBar.open(`Copied: ${text}`, 'OK', { duration: 2500 });
}
}
@@ -0,0 +1,566 @@
<!--
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="i4c-page">
<!-- Modern Toolbar & Filter Chips -->
<div class="i4c-toolbar">
<div class="i4c-filter-chips">
<button class="filter-chip" [class.filter-chip--active]="status === ''" (click)="setStatus('')">
<span>All Complaints</span>
</button>
@for (s of statuses; track s) {
<button class="filter-chip" [class.filter-chip--active]="status === s" (click)="setStatus(s)">
<span>{{ s }}</span>
</button>
}
</div>
<span class="i4c-toolbar__spacer"></span>
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="search-input">
<mat-label>Search acknowledgement / account / job id</mat-label>
<input matInput [(ngModel)]="search" (keyup.enter)="load(true)" placeholder="Type & hit enter..." />
</mat-form-field>
<button mat-flat-button color="primary" class="search-btn" (click)="load(true)">
<fa-icon icon="search"></fa-icon> Search
</button>
<span class="count-tag">{{ total }} records</span>
</div>
<div class="i4c-filter-bar">
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Sub-category</mat-label>
<input matInput [(ngModel)]="subCategory" (keyup.enter)="load(true)" (blur)="load(true)" placeholder="e.g. UPI" />
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Layer</mat-label>
<mat-select [(ngModel)]="layer" (selectionChange)="load(true)">
<mat-option value="">All layers</mat-option>
<mat-option value="0">Layer 0 (victim's account)</mat-option>
<mat-option value="1">Layer 1+ (money landed here)</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Response to I4C</mat-label>
<mat-select [(ngModel)]="response" (selectionChange)="load(true)">
<mat-option value="">Any</mat-option>
<mat-option value="SENT">Sent</mat-option>
<mat-option value="PENDING">Pending / sending</mat-option>
<mat-option value="FAILED">Failed</mat-option>
<mat-option value="NONE">Not yet created</mat-option>
</mat-select>
</mat-form-field>
<mifosx-i4c-date-range label="Received" [range]="dates" (rangeChange)="onDates($event)"></mifosx-i4c-date-range>
<button mat-stroked-button class="i4c-filter-bar__clear" (click)="clearFilters()" [disabled]="!hasFilters">
<fa-icon icon="times"></fa-icon> Clear filters
</button>
</div>
@if (loading) {
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
}
@if (error) {
<div class="i4c-error">
<fa-icon icon="exclamation-circle"></fa-icon>
<span>{{ error }}</span>
</div>
}
<!-- Complaints Data Table -->
<div class="i4c-card">
<div class="i4c-card__head">
<fa-icon icon="inbox"></fa-icon>
<span>I4C-MHA Cyber Fraud Complaints Log</span>
<span class="head-hint">Click any complaint to open modern detail popup window</span>
</div>
<div class="i4c-table-wrap">
<table class="i4c-table">
<thead>
<tr>
<th>Received</th>
<th>Acknowledgement No.</th>
<th>Sub-Category</th>
<th>Target Account</th>
<th class="num">Layer</th>
<th class="num">Incidents</th>
<th class="num">Held (₹)</th>
<th>Status</th>
<th>I4C Response</th>
</tr>
</thead>
<tbody>
@for (row of rows; track row.id) {
<tr class="clickable" (click)="openModal(row)">
<td>
<span class="date-main">{{ row.received_at | date: 'dd MMM yyyy' }}</span>
<div class="sub">{{ row.received_at | date: 'HH:mm:ss' }}</div>
</td>
<td>
<div class="ack-cell">
<span class="ack-no">{{ row.acknowledgement_no }}</span>
<button
class="copy-chip"
(click)="copyAck(row.acknowledgement_no, 'ack-' + row.id, $event)"
title="Copy Ack No."
>
<fa-icon icon="copy"></fa-icon>
<span>{{ copiedId === 'ack-' + row.id ? 'Copied' : 'Copy' }}</span>
</button>
</div>
<div class="sub">Job: {{ row.job_id }}</div>
</td>
<td>
<span class="cat-pill">{{ row.sub_category || 'N/A' }}</span>
</td>
<td>
<b>{{ row.payer_account_number }}</b>
</td>
<td class="num font-semibold">{{ row.max_layer }}</td>
<td class="num">{{ row.incidents }}</td>
<td class="num font-bold">₹{{ row.amount_held | number: '1.2-2' }}</td>
<td>
<span class="i4c-badge" [class]="statusClass(row.status)">{{ row.status }}</span>
@if (row.processing_error) {
<div class="sub err-text" [title]="row.processing_error">{{ row.processing_error }}</div>
}
</td>
<td>
<span class="i4c-badge" [class]="responseClass(row.response_status)">
{{ row.response_status || 'NOT YET' }}
</span>
</td>
</tr>
} @empty {
<tr>
<td colspan="9" class="i4c-empty">
<div class="empty-icon"><fa-icon icon="inbox"></fa-icon></div>
<div>No complaints from I4C matched your query.</div>
</td>
</tr>
}
</tbody>
</table>
</div>
<mat-paginator
class="i4c-paginator"
[length]="total"
[pageIndex]="pageIndex"
[pageSize]="pageSize"
[pageSizeOptions]="pageSizes"
(page)="onPage($event)"
showFirstLastButtons
aria-label="Complaints pages"
></mat-paginator>
</div>
<!-- ========================================================
MODERN POPUP WINDOW: COMPLAINT DETAILS MODAL
======================================================== -->
@if (selectedComplaint) {
<div class="i4c-modal-backdrop" (click)="closeModal()">
<div class="i4c-modal-window i4c-modal-window--wide" (click)="$event.stopPropagation()">
<!-- Header -->
<div class="i4c-modal-header">
<div class="i4c-modal-header__title">
<fa-icon icon="inbox"></fa-icon>
<div>
<div class="modal-title-row">
<span>Ack #{{ selectedComplaint.acknowledgement_no }}</span>
<span class="i4c-badge" [class]="statusClass(selectedComplaint.status)">{{
selectedComplaint.status
}}</span>
<button class="copy-chip" (click)="copyAck(selectedComplaint.acknowledgement_no, 'modal-ack')">
<fa-icon icon="copy"></fa-icon>
<span>{{ copiedId === 'modal-ack' ? 'Copied' : 'Copy Ack' }}</span>
</button>
</div>
<div class="i4c-modal-header__sub">
Job ID: {{ selectedComplaint.job_id }} • Received:
{{ selectedComplaint.received_at | date: 'dd MMM yyyy, HH:mm:ss' }}
</div>
</div>
</div>
<div class="i4c-modal-header__actions">
<button class="modal-close-btn" (click)="closeModal()" aria-label="Close">
<fa-icon icon="times"></fa-icon>
</button>
</div>
</div>
<!-- Navigation Tabs inside the Modal -->
<div class="i4c-modal-tabs">
<button class="modal-tab" [class.modal-tab--active]="modalTab === 'overview'" (click)="modalTab = 'overview'">
<fa-icon icon="info-circle"></fa-icon> Overview &amp; Account
</button>
<button
class="modal-tab"
[class.modal-tab--active]="modalTab === 'incidents'"
(click)="modalTab = 'incidents'"
>
<fa-icon icon="tasks"></fa-icon> Incidents ({{
selectedComplaint.incidents?.length || selectedComplaint.incidents || 0
}})
</button>
<button class="modal-tab" [class.modal-tab--active]="modalTab === 'findings'" (click)="modalTab = 'findings'">
<fa-icon icon="list-alt"></fa-icon> Section 6 Findings ({{ selectedComplaint.findings?.length || 0 }})
</button>
<button class="modal-tab" [class.modal-tab--active]="modalTab === 'outbox'" (click)="modalTab = 'outbox'">
<fa-icon icon="paper-plane"></fa-icon> Outbox Responses ({{ selectedComplaint.outbox?.length || 0 }})
</button>
<button class="modal-tab" [class.modal-tab--active]="modalTab === 'payloads'" (click)="modalTab = 'payloads'">
<fa-icon icon="code"></fa-icon> Raw JSON
</button>
</div>
<!-- Modal Body -->
<div class="i4c-modal-body">
@if (detailLoading) {
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
}
@if (detailError) {
<div class="i4c-error">{{ detailError }}</div>
}
<!-- TAB 1: OVERVIEW -->
@if (modalTab === 'overview') {
<div class="i4c-grid-2">
<div class="i4c-card">
<div class="i4c-card__head"><fa-icon icon="user-shield"></fa-icon> Target Customer Account</div>
<div class="i4c-card__body">
<dl class="i4c-kv">
<dt>Account Number</dt>
<dd>
<b>{{ selectedComplaint.payer_account_number }}</b>
</dd>
<dt>Sub-Category</dt>
<dd>{{ selectedComplaint.sub_category }}</dd>
<dt>Complainant Bank</dt>
<dd>
{{ selectedComplaint.payer_bank }}
{{ selectedComplaint.payer_bank_code ? '(' + selectedComplaint.payer_bank_code + ')' : '' }}
</dd>
<dt>Payment Mode</dt>
<dd>
{{ selectedComplaint.mode_of_payment || 'N/A' }}
{{ selectedComplaint.transaction_type ? '/ ' + selectedComplaint.transaction_type : '' }}
</dd>
<dt>Wallet Reference</dt>
<dd>{{ selectedComplaint.wallet || 'None' }}</dd>
<dt>State / District</dt>
<dd>{{ selectedComplaint.state }} / {{ selectedComplaint.district }}</dd>
<dt>Source IP</dt>
<dd>{{ selectedComplaint.source_ip || '-' }}</dd>
</dl>
</div>
</div>
<div class="i4c-card">
<div class="i4c-card__head"><fa-icon icon="sync"></fa-icon> Processing Status</div>
<div class="i4c-card__body">
<dl class="i4c-kv">
<dt>Processing Status</dt>
<dd>
<span class="i4c-badge" [class]="statusClass(selectedComplaint.status)">{{
selectedComplaint.status
}}</span>
</dd>
<dt>Processing Attempts</dt>
<dd>{{ selectedComplaint.processing_attempts ?? 1 }}</dd>
<dt>Processed Timestamp</dt>
<dd>
{{
selectedComplaint.processed_at
? (selectedComplaint.processed_at | date: 'dd MMM yyyy, HH:mm:ss')
: 'Pending processing'
}}
</dd>
<dt>Total Held Amount</dt>
<dd>
<b class="amount-accent">₹{{ selectedComplaint.amount_held | number: '1.2-2' }}</b>
</dd>
<dt>Response to I4C</dt>
<dd>
<span class="i4c-badge" [class]="responseClass(selectedComplaint.response_status)">
{{ selectedComplaint.response_status || 'NOT YET SENT' }}
</span>
</dd>
@if (selectedComplaint.processing_error) {
<dt>Last Error</dt>
<dd class="err-text">{{ selectedComplaint.processing_error }}</dd>
}
</dl>
</div>
</div>
</div>
}
<!-- TAB 2: INCIDENTS -->
@if (modalTab === 'incidents') {
<div class="i4c-card">
<div class="i4c-table-wrap">
<table class="i4c-table">
<thead>
<tr>
<th>#</th>
<th>RRN</th>
<th>Date</th>
<th class="num">Amount (₹)</th>
<th class="num">Disputed (₹)</th>
<th class="num">Layer</th>
<th>Matched By</th>
<th>Outcome</th>
<th class="num">Held (₹)</th>
</tr>
</thead>
<tbody>
@for (inc of selectedComplaint.incidents; track inc.id) {
<tr>
<td>
<b>{{ inc.seq_no }}</b>
</td>
<td>{{ inc.rrn }}</td>
<td>{{ inc.transaction_date }} {{ inc.transaction_time }}</td>
<td class="num">{{ inc.amount | number: '1.2-2' }}</td>
<td class="num">{{ inc.disputed_amount | number: '1.2-2' }}</td>
<td class="num">{{ inc.layer }}</td>
<td>
<span>{{ inc.match_method || 'EXACT' }}</span>
@if (inc.matched_txn_id) {
<div class="sub">Txn #{{ inc.matched_txn_id }}</div>
}
</td>
<td>
<span class="i4c-badge" [class]="outcomeClass(inc.outcome)">{{
inc.outcome || 'PENDING'
}}</span>
<div class="sub">Code: {{ inc.status_code || '-' }}</div>
</td>
<td class="num">
@if (holdFor(inc.id); as hold) {
₹{{ hold.amount_held | number: '1.2-2' }}
@if (hold.shortfall > 0) {
<div class="sub err-text">Short ₹{{ hold.shortfall | number: '1.2-2' }}</div>
}
} @else {
-
}
</td>
</tr>
} @empty {
<tr>
<td colspan="9" class="i4c-empty">No incident details found.</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
<!-- TAB 3: FINDINGS (SECTION 6) -->
@if (modalTab === 'findings') {
<div class="i4c-card">
<div class="i4c-table-wrap">
<table class="i4c-table">
<thead>
<tr>
<th>Incident ID</th>
<th>Transaction Type</th>
<th class="num">Amount (₹)</th>
<th class="num">Disputed (₹)</th>
<th>Status Code</th>
<th>Raw JSON</th>
</tr>
</thead>
<tbody>
@for (f of selectedComplaint.findings; track f.id) {
<tr>
<td>{{ f.incident_id }}</td>
<td>{{ f.txn_type }} ({{ f.txn_type_id }})</td>
<td class="num">₹{{ f.amount | number: '1.2-2' }}</td>
@if (f.disputed_amount === null || f.disputed_amount === undefined) {
<td
class="num muted-cell"
title="The specification has no disputed amount in a Transaction Put on Hold entry"
>
-
</td>
} @else {
<td class="num">₹{{ f.disputed_amount | number: '1.2-2' }}</td>
}
<td>
<span
class="i4c-badge"
[class]="statusCodeClass(f.status_code)"
[title]="statusCodeMeaning(f.status_code)"
>
{{ f.status_code }} · {{ statusCodeMeaning(f.status_code) }}
</span>
</td>
<td>
<button class="table-action-btn" (click)="toggleFindingJson(f.id)">
{{ openFindingJsonId === f.id ? 'Hide JSON' : 'View JSON' }}
</button>
</td>
</tr>
@if (openFindingJsonId === f.id) {
<tr class="finding-json-row">
<td colspan="6" class="finding-json-cell">
<div class="finding-json-box">
<div class="finding-json-head">
<span> <fa-icon icon="code"></fa-icon> Finding #{{ f.id }} Payload </span>
<button
type="button"
class="copy-chip"
(click)="copyAck(prettyJson(f.entry_json), 'finding-' + f.id, $event)"
>
<fa-icon icon="copy"></fa-icon>
<span>{{ copiedId === 'finding-' + f.id ? 'Copied!' : 'Copy' }}</span>
</button>
</div>
<pre class="i4c-json">{{ prettyJson(f.entry_json) }}</pre>
</div>
</td>
</tr>
}
} @empty {
<tr>
<td colspan="6" class="i4c-empty">No section 6 findings reported yet.</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
<!-- TAB 4: OUTBOX RESPONSES -->
@if (modalTab === 'outbox') {
<div class="i4c-card">
<div class="i4c-table-wrap">
<table class="i4c-table">
<thead>
<tr>
<th>Created</th>
<th title="ACK: separate acknowledgement callback. RESPONSE: the Section 6 findings.">Type</th>
<th>Status</th>
<th class="num">Attempts</th>
<th>HTTP Code</th>
<th>Sent Time</th>
<th>Error</th>
<th>Action</th>
</tr>
</thead>
<tbody>
@for (o of selectedComplaint.outbox; track o.id) {
<tr>
<td>{{ o.created_at | date: 'dd MMM, HH:mm:ss' }}</td>
<td>
<span class="i4c-badge" [class.i4c-badge--blue]="o.message_type === 'ACK'">{{
o.message_type === 'ACK' ? 'ACK' : 'RESPONSE'
}}</span>
</td>
<td>
<span class="i4c-badge" [class]="responseClass(o.status)">{{ o.status }}</span>
</td>
<td class="num">{{ o.attempts }}</td>
<td>{{ o.http_status ?? '-' }}</td>
<td>{{ o.sent_at ? (o.sent_at | date: 'dd MMM, HH:mm:ss') : '-' }}</td>
<td class="err-text">{{ o.last_error }}</td>
<td>
@if (o.status === 'FAILED') {
<button
class="table-action-btn"
(click)="retryOutboxItem(o.id)"
[disabled]="retryingOutboxId === o.id"
>
<fa-icon
icon="redo"
[animation]="retryingOutboxId === o.id ? 'spin' : undefined"
></fa-icon>
Retry
</button>
}
</td>
</tr>
} @empty {
<tr>
<td colspan="8" class="i4c-empty">No outbox delivery records yet.</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
<!-- TAB 5: RAW PAYLOADS -->
@if (modalTab === 'payloads') {
<div class="payloads-view">
<div class="payload-box">
<div class="payload-box__title">
<fa-icon icon="inbox"></fa-icon>
<span>Payload Received from I4C Gateway (Masked)</span>
<button class="copy-chip" (click)="copyAck(prettyJson(selectedComplaint.raw_payload), 'payload-in')">
<fa-icon icon="copy"></fa-icon> Copy
</button>
</div>
<pre class="i4c-json">{{ prettyJson(selectedComplaint.raw_payload) }}</pre>
</div>
@for (o of selectedComplaint.outbox; track o.id) {
<div class="payload-box">
<div class="payload-box__title">
<fa-icon icon="paper-plane"></fa-icon>
<span>Response #{{ o.id }} Delivered to I4C</span>
<button class="copy-chip" (click)="copyAck(prettyJson(o.payload), 'payload-out-' + o.id)">
<fa-icon icon="copy"></fa-icon> Copy
</button>
</div>
<pre class="i4c-json">{{ prettyJson(o.payload) }}</pre>
</div>
}
</div>
}
</div>
<!-- Footer -->
<div class="i4c-modal-footer">
@if (selectedComplaint.status === 'RETRY' || selectedComplaint.status === 'PROCESSING') {
<button
mat-flat-button
color="primary"
(click)="reprocessModal(selectedComplaint.id)"
[disabled]="reprocessing"
>
<fa-icon icon="redo" [animation]="reprocessing ? 'spin' : undefined"></fa-icon> Process Now
</button>
}
<button mat-stroked-button (click)="openFullPage(selectedComplaint.id)">
<fa-icon icon="external-link-alt"></fa-icon> Open in Full Page
</button>
<button class="footer-btn-close" (click)="closeModal()">Close</button>
</div>
</div>
</div>
}
</div>

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