Compare commits

..
92 Commits
Author SHA1 Message Date
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
Sonali LikhitkarandClaude Sonnet 5.5 7c868ae073 feat(loan-products): add loan interest rate register UI
- Add Loan Interest Rate Register screen with CIBIL adjustment and edit-register views, backed by rate-register service and model
- Wire the new screen into products routing and the products menu
- Update loan product details and advance limit/tenure steps

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 16:20:36 +05:30
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
Sonali LikhitkarandClaude Sonnet 5.5 283ab2f733 feat(loan-products): add advance loan product stepper steps
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 18:06:08 +05:30
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
niteshandClaude Sonnet 5 3f59cc6b91 fix(cashier): route FD/RD cashier deposits through maker-checker approval
SYMPTOM
A cashier could credit an FD or RD account with cash from Transaction
Entry and the money moved in instantly, with no Manager approval -
unlike a Savings/Current deposit from the same screen, which correctly
creates a PENDING request under Approvals.

ROOT CAUSE
confirmSubmit()'s maker-checker branch for a cashier deposit only
matched row.acTyp in ('SD','CA','CB','GL'):
  if (!this.isClerk && !this.reviewMode && isDeposit &&
      (row.acTyp === 'SD' || row.acTyp === 'CA' || row.acTyp === 'CB' || row.acTyp === 'GL')) {
    this.submitCashierDepositRequest(row, ...);
    return;
  }
'FD' and 'RD' are their own distinct account-type codes in this
component (see creditEntryAccountType(): 'FD' -> 'FIXED_DEPOSIT', 'RD'
-> 'RECURRING_DEPOSIT', separate from 'SD'/'CA'/'CB') and were simply
never in this list, so a deposit into either fell through every later
branch (all of which are withdrawal/disbursement-only) straight to the
generic, unguarded direct POST at the bottom of confirmSubmit().

Found by tracing why a real FD funding deposit never showed up under
Branch Ops -> Approvals: it had posted directly, no request record
ever existed for it, and it later showed up as an unexplained cash
"Excess" in the Branch Day Register close (the money was physically
counted by the cashier via denominationBreakdown in the same direct
POST, but the approved-requests-only "book" total never counted it -
see the paired backend commit for the matching approve()-side fix and
how that stale data artifact was cleaned up).

FIX
Added 'FD' and 'RD' to the account-type list in that gate, so a
cashier's FD/RD deposit now creates a PENDING
m_cashier_deposit_request via submitCashierDepositRequest() exactly
like SD/CA/CB/GL already did. buildDepositPayload() needed no changes
- it already resolves accountType via creditEntryAccountType() and
sets savingsAccountId to the FD/RD account's own id correctly; only
this one gate condition excluded them.

SCOPE
This one condition, in this one method. Every other account type's
existing deposit/withdrawal behavior on this screen is unchanged.

IF THIS BREAKS SOMETHING LATER
- A cashier FD/RD deposit posting instantly again (no PENDING request
  created) -> check this gate's account-type list still includes
  'FD'/'RD', and that isClerk/reviewMode aren't being set unexpectedly
  for a cashier session.
- The backend rejecting or misrouting the resulting APPROVED request
  -> see the paired fix in CashierDepositRequestApiResource
  (cbsnexora-backend), resolveDepositAccountCategory() /
  executeFixedDepositDeposit() / executeRecurringDepositDeposit().
- A "Vault + Cashiers" vs "book" Excess reappearing in Branch Day
  Register close for a specific FD/RD deposit -> that combination
  (physically counted denomination with no approved request) should be
  impossible with this fix in place; if it recurs, this fix (or its
  backend half) has regressed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 14:27:16 +05:30
niteshandClaude Sonnet 5 b0777e9845 fix(deposits): FD Single Rate Calculator sent tenureInMonths=0 for tenures under 30 days
SYMPTOM
FD Single account create screen, Rate Calculator: entering a tenure
under 30 days (e.g. 27) and clicking "Find Rate" produced a Bad
Request - "The parameter `tenureInMonths` must be greater than 0."

ROOT CAUSE
calculateRate() (fixed-deposit-account-interest-rate-chart-step.
component.ts) always converted the Days-unit tenure to months before
calling the backend calculator:
  tenureInMonths = Math.floor(enteredDays / 30)
For any tenure under 30 days this floors to 0, which the backend
correctly rejects (paired backend commit: "FD Single Rate Calculator
crashed for tenures under 30 days").

FIX
- fixed-deposits.service.ts: calculateFixedDepositInterest() -
  tenureInMonths is now optional, added optional tenureInDays.
- fixed-deposit-account-interest-rate-chart-step.component.ts -
  calculateRate(): for a Days-unit tenure >= 30 days, unchanged
  (tenureInMonths = floor(days/30)); for < 30 days, sends tenureInDays
  instead and leaves tenureInMonths undefined. Also fixed the
  API-failure fallback's periodic-compounding math, which divided by
  tenureInMonths directly and would have produced NaN once
  tenureInMonths could legitimately be undefined - it now falls back
  to (enteredDays / 30) when tenureInMonths is absent.

SCOPE
Only the Rate Calculator preview inside the FD Single/Double/Special
account create screen. Does not touch how depositPeriod /
depositPeriodFrequencyId get sent on actual account submission.

IF THIS BREAKS SOMETHING LATER
- The same 400 reappearing for a short FD Single tenure -> confirm the
  Find Rate network call carries tenureInDays and NOT a zero
  tenureInMonths; check calculateRate()'s branch choosing between the
  two.
- A wrong maturity preview number specifically when the backend API
  call fails and the frontend falls back to its own formula -> check
  the `effectiveMonths = tenureInMonths ?? enteredDays / 30` fallback
  line in calculateRate().

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 14:21:57 +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
keshav a6e6220100 fix: update sponsor DD approvals, cashier transaction entry, FIU and dividend declaration 2026-09-21 10:24:08 +05:30
niteshandClaude Opus 5 3c363d0372 feat(shares): one-voucher-per-slot receipt dialogs, Apply Additional Shares as a popup
Apply Collection and Apply Additional Shares settle the same Share Collection
receipts for the same member, so they are now deliberately the same screen.

Apply Collection takes exactly two receipts and no more: one for the share value,
one for the entry fee, in two separate slots. They are different things -- the
share receipt is money moving into share capital, while the fee receipt only
proves the fee was already collected and is never posted a second time -- and
picking both out of one ticked list made that distinction invisible.

Apply Additional Shares moves from its own route to a dialog. It was a full page
a clerk had to leave the account for and come back from, to fill in a short form
with the member standing at the counter. Its old route, resolver case and action-
host branch are removed rather than left behind as dead paths.

Both screens now draw their receipts from a single pool, whether a receipt
already names the member or is a voucher paid straight into a GL account; an
unattributed one is attached at the moment it is picked, so what used to be an
Attach in one table and a tick in another is one deliberate click. The by-voucher
search and the GL-entry detail both sit behind buttons, as exceptions rather than
permanent furniture.

Dialog chrome and the receipt shape are shared (share-dialog.shared.scss,
share-receipt-option.ts) so the two cannot drift apart again.

Also fixes content being clipped at the dialog edges: the wrapper cancelled a
24px surface padding that the panel class had already zeroed, pushing every edge
outside the surface where overflow:hidden cut it off, and the height cap sat on
the scrolling body rather than the wrapper, letting the footer stack past the
bottom of the viewport.

Adds the product-wise share summary report (authorised, issued, available and
unpaid shares, distinctive range and account count per product).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 18:56:34 +05:30
nitesh dc7f668056 feat(shares): move voucher search behind a popup, not an always-visible toolbar
The GL Account / Voucher No lookup is for the exception -- a receipt the
unattributed list and the member's own list do not already carry -- so it now
sits behind a "Search a Voucher Not Listed Below" button in both Apply
Collection and Apply Additional Shares, opening the same lookup in a popup
(matching the existing receipt-detail popup) instead of occupying screen space
whether or not anyone needs it. A successful find closes the popup and the
result appears ticked in the list behind it, same as before.
2026-09-18 13:57:14 +05:30
nitesh 124f05d21d Merge branch 'dev-latest' of https://git.lincpay.tech/CBSNexora/cbsnexora into dev-latest
# Conflicts:
#	src/environments/.env.ts
2026-09-18 13:15:48 +05:30
niteshandClaude Sonnet 5 c7428d0cb2 feat(shares): dynamic Share Reports module, voucher attach/GL sync fixes
- Add the full Share Reports surface (final balance, account statement,
  issued/status/nominal/share-wise/guarantor/member-interest reports, enquiry,
  payment certificate, share detail, dividend statement, folio statement,
  distinctive numbers, area-wise, mailing labels/letters, share transfer) as
  standalone routed components, wired into the Share menu
- Wire the unattributed Share Collection receipt list into Apply Collection
  and Apply Additional Shares, with an Attach action independent of
  account/client
- Sync the GL Account and Voucher No fields to whichever receipt is ticked or
  found, and surface the receipt's real Dr/Cr GL entries in its detail popup
- Block Apply Additional Shares until the account's original application is
  settled

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 13:06:02 +05:30
keshav 17d97da380 Merge branch 'dev-latest' of https://git.lincpay.tech/CBSNexora/cbsnexora into dev-latest 2026-09-18 10:33:28 +05:30
keshav f9b73a4930 feat(aml,risk-review,accounting): update financial activity mapping options, AML flagged transactions filters, and risk review report 2026-09-18 10:32:47 +05:30
niteshandClaude Sonnet 5 e53dfb5a66 feat(shares, cashier): Entry Type tagging on Transaction Entry, real GL details on receipts
Adds an Entry Type field to Transaction Entry's GL-crediting rows (Receipt / Collection),
fetched from the backend's GLEntryType code, so the counter can flag what a GL credit is
actually for (General / Share Collection / DD Issue) instead of leaving every GL account
picker throughout Share Advance Collection guessing from the whole chart of accounts.

Apply Share / Additional Share screens:
- GL Account picker narrowed to accounts that actually have an eligible receipt, and only
  auto-selected when exactly one candidate exists -- no more defaulting to whichever account
  sorts first (Cash at Vault).
- Voucher tables show Description and Collected By, and a Detail view exposes the receipt's
  real Dr/Cr GL legs instead of the GL Account dropdown's current unrelated selection.
- Additional Share funds from a single voucher (radio-style selection); ticking a receipt
  syncs the GL Account picker to its own actual account.
- Checker's Additional Share Request review dialog gets the same Description + real GL entry
  detail, expandable per voucher, so approval is never a guess about what will post.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 17:45:05 +05:30
niteshandClaude Opus 5 41540ad24e feat(share-products): retire the Share Reference mapping, remove dead Cashier Share Collection screen
Share Products:
- "Share Reference (Cash Vault or Suspense)" removed from the accounting step (create, edit,
  preview and the read-only view tab) -- every register now books a share transaction against the
  account the money actually reached, so no product needs a placeholder asset mapping any more.
  The field is now optional server-side, not required.

Cashier:
- Removed the Share Collection screen and its three service methods. It read
  m_share_account.cashier_collected, a column that has never existed in this schema, and no other
  screen ever called it -- it could not have produced a working entry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 13:04:09 +05:30
niteshandClaude Opus 5 b71619c402 feat(cashier, sponsor-dd): GL settlement and DD funding entry points, DD registry redesign
Transaction Entry (clerk):
- Route a two-row Fund Transfer of Dr SHR / Cr GL to the share-withdrawal
  request with settlementMode GL, so a clerk can settle a share withdrawal
  into any GL account instead of the member's linked savings account.
- Offer GL as an account type for Demand Draft funding, and submit it as a
  GL debit (no client or savings/current account is involved).

My Work:
- Add a voucher detail dialog so a clerk can open a raised voucher from the
  request list and see its GL lines.

Closing Cash Report:
- Fix the total row so it reflects the corrected opening balance.

DD Leaflet Registry and DD Inventory:
- Redesign both pages onto the shared usr-* visual language: gradient header
  badge, status filter chips with counts, card-wrapped sticky-header table,
  and one primary action per row with the rest behind an overflow menu.
  Filtering, pagination and every action condition are unchanged.
- Register faHandPaper, needed by the new Stop Payment menu item.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 17:17:22 +05:30
nitesh d18bd9cf37 fix(print): fix app-wide printing and remove a global print-CSS leak
Root cause of blank/clipped print output across the app: the document root
(html/body) is globally locked to one viewport height with overflow hidden,
and Angular Material's drawer/tab-body containers each add their own
on-screen scroll clip on top of that. main.scss now frees the full chain for
print (document root, CDK drawer/sidenav containers, mat-tab-body, and the
account-view's mat-tab-nav-bar which was rendering as a stray grey scrollable
strip since it isn't covered by the mat-tab-group header rule already hidden).

Also fixed both certificate print dialogs (share-certificate-dialog,
deposit-certificate-dialog) reusing a `::ng-deep body > :not(.cdk-overlay-
container)` rule to hide the shell during their own print: under Angular's
style encapsulation this style is emitted fully unscoped into the global
stylesheet and is never removed once the component has loaded once in a
session, silently blanking every other page's print output for the rest of
the session. Dropped `::ng-deep` from that one selector so it goes back to
being properly scoped (and therefore inert, matching the other two dialogs
that never had this bug) — the app shell is already hidden for every print
via the global main.scss rules, so no dialog needs its own global override.
2026-09-16 17:17:12 +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
keshav 0cef1b5a50 feat: KYC risk profiling, AML flagged transactions bulk review, Risk Review report & Board Resolution register 2026-09-16 12:51:24 +05:30
lincpayadmin 0fbf6ec58c Delete .drone.yml 2026-09-14 11:48:46 +00:00
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
628 changed files with 92991 additions and 11165 deletions
-302
View File
@@ -1,302 +0,0 @@
kind: pipeline
type: docker
name: auto-deploy
trigger:
branch:
- dev
event:
- push
steps:
# ============================================================
# DOCKER BUILD & PUSH
#
# Angular build (npm ci + ng build) Dockerfile ke andar
# multi-stage me hota hai, isliye alag build step nahi chahiye.
# ============================================================
- name: docker-build-push
image: docker
volumes:
- name: dockersock
path: /var/run/docker.sock
commands:
- |
set -e
# --------------------------------------------------------
# Repository name comes from Drone
# --------------------------------------------------------
REPO="$${DRONE_REPO_NAME}"
REPO="$$(echo "$$REPO" | tr '[:upper:]' '[:lower:]')"
IMAGE="harbor.lincpay:8082/lincpay/$$REPO:$${DRONE_BUILD_NUMBER}"
echo "=========================================="
echo "DOCKER BUILD & PUSH"
echo "=========================================="
echo "Repository : $$REPO"
echo "Build : $${DRONE_BUILD_NUMBER}"
echo "Image : $$IMAGE"
echo "=========================================="
# --------------------------------------------------------
# Harbor login
# --------------------------------------------------------
echo 'QVgW5QbqlKKtBU2dYApTY28Dd6J36GK3' | \
docker login harbor.lincpay:8082 \
-u 'robot$lincpay+drone' \
--password-stdin
# --------------------------------------------------------
# Build
# --------------------------------------------------------
echo "Building Docker image..."
docker build \
-t "$$IMAGE" \
.
# --------------------------------------------------------
# Push
# --------------------------------------------------------
echo "Pushing Docker image..."
docker push "$$IMAGE"
echo "Docker image pushed successfully."
# ============================================================
# DEPLOY
# ============================================================
- name: deploy
image: docker
volumes:
- name: dockersock
path: /var/run/docker.sock
- name: envfiles
path: /platform-env
commands:
- |
set -e
# ========================================================
# REPOSITORY / SERVICE / IMAGE
# ========================================================
REPO="$${DRONE_REPO_NAME}"
REPO="$$(echo "$$REPO" | tr '[:upper:]' '[:lower:]')"
SERVICE_NAME="$${REPO}_app"
IMAGE="harbor.lincpay:8082/lincpay/$$REPO:$${DRONE_BUILD_NUMBER}"
echo "=========================================="
echo "DEPLOYMENT"
echo "=========================================="
echo "Repository : $$REPO"
echo "Service : $$SERVICE_NAME"
echo "Image : $$IMAGE"
echo "Placement : node.labels.environment == dev2"
echo "=========================================="
# ========================================================
# VALIDATION
# ========================================================
if [ -z "$$REPO" ]; then
echo "ERROR: DRONE_REPO_NAME is empty."
exit 1
fi
if [ -z "$$SERVICE_NAME" ]; then
echo "ERROR: Service name is empty."
exit 1
fi
if [ -z "$$IMAGE" ]; then
echo "ERROR: Docker image is empty."
exit 1
fi
echo "Repository verified : $$REPO"
echo "Service verified : $$SERVICE_NAME"
echo "Image verified : $$IMAGE"
# ========================================================
# HARBOR LOGIN
#
# Zaroori hai taaki --with-registry-auth worker node (dev2) ko
# valid Harbor credentials propagate kare — warna cross-node pull
# "No such image" / "no basic auth credentials" se fail hota hai.
# ========================================================
echo 'QVgW5QbqlKKtBU2dYApTY28Dd6J36GK3' | \
docker login harbor.lincpay:8082 \
-u 'robot$lincpay+drone' \
--password-stdin
# ========================================================
# EXISTING SERVICE
# ========================================================
if docker service ls --format '{{.Name}}' | grep -Fxq "$$SERVICE_NAME"; then
echo ""
echo "=========================================="
echo "EXISTING SERVICE"
echo "=========================================="
echo "Service: $$SERVICE_NAME"
# ------------------------------------------------------
# Check whether dev constraint already exists
# ------------------------------------------------------
CONSTRAINT_EXISTS=$$(docker service inspect "$$SERVICE_NAME" \
--format '{{range .Spec.TaskTemplate.Placement.Constraints}}{{println .}}{{end}}' \
| grep -Fx 'node.labels.environment == dev2' || true)
# ------------------------------------------------------
# Add constraint if missing
# ------------------------------------------------------
if [ -z "$$CONSTRAINT_EXISTS" ]; then
echo "Constraint missing."
echo "Adding: node.labels.environment == dev2"
docker service update \
--constraint-add 'node.labels.environment == dev2' \
"$$SERVICE_NAME"
else
echo "Constraint already exists."
fi
# ------------------------------------------------------
# Update image
# ------------------------------------------------------
echo "Updating service image..."
docker service update \
--image "$$IMAGE" \
--replicas 1 \
--with-registry-auth \
"$$SERVICE_NAME"
# ========================================================
# NEW SERVICE
# ========================================================
else
echo ""
echo "=========================================="
echo "NEW SERVICE"
echo "=========================================="
echo "Creating: $$SERVICE_NAME"
# ------------------------------------------------------
# Traefik rule
#
# Construct it without allowing Drone to consume
# the shell backticks.
# ------------------------------------------------------
TRAEFIK_RULE='Host(`'$$REPO'.lincpay`)'
echo "Traefik rule: $$TRAEFIK_RULE"
# ------------------------------------------------------
# Create service
# ------------------------------------------------------
docker service create \
--name "$$SERVICE_NAME" \
--network edge-network \
--replicas 1 \
--constraint 'node.labels.environment == dev2' \
--with-registry-auth \
--env-file /platform-env/global.env \
--env-file /platform-env/dev.env \
--label traefik.enable=true \
--label "traefik.docker.network=edge-network" \
--label "traefik.http.routers.$$REPO.rule=$$TRAEFIK_RULE" \
--label "traefik.http.services.$$REPO.loadbalancer.server.port=80" \
"$$IMAGE"
fi
# ========================================================
# FINAL STATUS
# ========================================================
echo ""
echo "=========================================="
echo "FINAL SERVICE STATUS"
echo "=========================================="
docker service ls \
--filter "name=$$SERVICE_NAME"
echo ""
echo "=========================================="
echo "PLACEMENT CONSTRAINT"
echo "=========================================="
docker service inspect "$$SERVICE_NAME" \
--format '{{json .Spec.TaskTemplate.Placement}}'
echo ""
echo "=========================================="
echo "SERVICE TASKS"
echo "=========================================="
docker service ps "$$SERVICE_NAME" --no-trunc
echo ""
echo "=========================================="
echo "DEPLOYMENT COMPLETE"
echo "=========================================="
volumes:
# ============================================================
# DOCKER SOCKET
# ============================================================
- name: dockersock
host:
path: /var/run/docker.sock
# ============================================================
# ENVIRONMENT FILES
# ============================================================
- name: envfiles
host:
path: /opt/platform/env
+9 -2
View File
@@ -31,6 +31,8 @@
"@angular/localize/init" "@angular/localize/init"
], ],
"assets": [ "assets": [
"src/favicon.ico",
"src/apple-touch-icon.png",
"src/assets/images/cbsnexora-favicon.svg", "src/assets/images/cbsnexora-favicon.svg",
"src/robots.txt", "src/robots.txt",
"src/manifest.json", "src/manifest.json",
@@ -39,6 +41,11 @@
"glob": "**/*", "glob": "**/*",
"input": "node_modules/tinymce", "input": "node_modules/tinymce",
"output": "assets/tinymce" "output": "assets/tinymce"
},
{
"glob": "lottie.min.js",
"input": "node_modules/lottie-web/build/player",
"output": "assets/js"
} }
], ],
"styles": [ "styles": [
@@ -69,8 +76,8 @@
"budgets": [ "budgets": [
{ {
"type": "initial", "type": "initial",
"maximumWarning": "15mb", "maximumWarning": "18mb",
"maximumError": "17mb" "maximumError": "20mb"
}, },
{ {
"type": "anyComponentStyle", "type": "anyComponentStyle",
+7
View File
@@ -41,6 +41,7 @@
"jspdf-autotable": "^5.0.2", "jspdf-autotable": "^5.0.2",
"lightgallery": "^2.9.0", "lightgallery": "^2.9.0",
"lodash": "4.17.23", "lodash": "4.17.23",
"lottie-web": "^5.13.0",
"moment": "^2.29.4", "moment": "^2.29.4",
"ngx-image-cropper": "^9.1.6", "ngx-image-cropper": "^9.1.6",
"ngx-mat-select-search": "^8.0.2", "ngx-mat-select-search": "^8.0.2",
@@ -18380,6 +18381,12 @@
"url": "https://github.com/chalk/wrap-ansi?sponsor=1" "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": { "node_modules/lru-cache": {
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+2 -1
View File
@@ -15,7 +15,7 @@
"ng": "ng", "ng": "ng",
"build": "node version.js && npm run env -s && ng build --configuration production --output-hashing=none", "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/", "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: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", "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", "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", "jspdf-autotable": "^5.0.2",
"lightgallery": "^2.9.0", "lightgallery": "^2.9.0",
"lodash": "4.17.23", "lodash": "4.17.23",
"lottie-web": "^5.13.0",
"moment": "^2.29.4", "moment": "^2.29.4",
"ngx-image-cropper": "^9.1.6", "ngx-image-cropper": "^9.1.6",
"ngx-mat-select-search": "^8.0.2", "ngx-mat-select-search": "^8.0.2",
@@ -7,16 +7,6 @@
--> -->
<div class="container"> <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>
<mat-card-content> <mat-card-content>
<div class="transfer-container"> <div class="transfer-container">
@@ -16,6 +16,7 @@ import { AccountingService } from '../../accounting.service';
import { GLAccount } from 'app/shared/models/general.model'; import { GLAccount } from 'app/shared/models/general.model';
import { GlAccountSelectorComponent } from '../../../shared/accounting/gl-account-selector/gl-account-selector.component'; import { GlAccountSelectorComponent } from '../../../shared/accounting/gl-account-selector/gl-account-selector.component';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { accountOptionsForActivity } from '../financial-activity-account-options';
/** /**
* Create financial activity mapping component. * Create financial activity mapping component.
@@ -87,21 +88,11 @@ export class CreateFinancialActivityMappingComponent implements OnInit {
*/ */
setGlAccountData() { setGlAccountData() {
this.financialActivityMappingForm.get('financialActivityId').valueChanges.subscribe((financialActivityId) => { this.financialActivityMappingForm.get('financialActivityId').valueChanges.subscribe((financialActivityId) => {
switch (financialActivityId) { this.glAccountData = accountOptionsForActivity(
case 100: this.financialActivityData,
case 101: financialActivityId,
case 102: this.glAccountOptions
case 103: );
this.glAccountData = this.glAccountOptions.assetAccountOptions;
break;
case 200:
case 201:
this.glAccountData = this.glAccountOptions.liabilityAccountOptions;
break;
case 300:
this.glAccountData = this.glAccountOptions.equityAccountOptions;
break;
}
}); });
} }
@@ -16,6 +16,7 @@ import { AccountingService } from '../../accounting.service';
import { GLAccount } from 'app/shared/models/general.model'; import { GLAccount } from 'app/shared/models/general.model';
import { GlAccountSelectorComponent } from '../../../shared/accounting/gl-account-selector/gl-account-selector.component'; import { GlAccountSelectorComponent } from '../../../shared/accounting/gl-account-selector/gl-account-selector.component';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { accountOptionsForActivity } from '../financial-activity-account-options';
/** /**
* Edit financial activity mapping component. * Edit financial activity mapping component.
@@ -98,21 +99,11 @@ export class EditFinancialActivityMappingComponent implements OnInit {
*/ */
setGLAccountData() { setGLAccountData() {
this.financialActivityMappingForm.get('financialActivityId').valueChanges.subscribe((financialActivityId) => { this.financialActivityMappingForm.get('financialActivityId').valueChanges.subscribe((financialActivityId) => {
switch (financialActivityId) { this.glAccountData = accountOptionsForActivity(
case 100: this.financialActivityData,
case 101: financialActivityId,
case 102: this.glAccountOptions
case 103: );
this.glAccountData = this.glAccountOptions.assetAccountOptions;
break;
case 200:
case 201:
this.glAccountData = this.glAccountOptions.liabilityAccountOptions;
break;
case 300:
this.glAccountData = this.glAccountOptions.equityAccountOptions;
break;
}
}); });
} }
@@ -0,0 +1,38 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/**
* The GL accounts a financial activity may be mapped to.
*
* The activity itself says which kind of account it needs — the template returns `mappedGLAccountType` on every
* option — so the answer is read from the activity rather than worked out from its id. The screens used to switch on
* the id instead, listing the ones they knew:
*
* case 100, 101, 102, 103 → asset
* case 200, 201 → liability
* case 300 → equity
*
* Every activity added since fell through that switch and left the account dropdown empty, which is how dividendPaid
* (202), reserveForDividend (301), distributableProfit (302), retainedProfit (303) and dividendRoundOff (304) became
* unmappable — the screen offered no accounts and gave no reason. A list keyed on ids has to be extended by hand for
* each new activity, and is silently wrong until someone notices; asking the activity what it needs cannot fall out
* of step with the backend.
*
* @param activities the template's financialActivityOptions
* @param activityId the activity the user picked
* @param glAccountOptions the template's glAccountOptions, keyed assetAccountOptions / liabilityAccountOptions / …
*/
export function accountOptionsForActivity(activities: any[], activityId: number, glAccountOptions: any): any[] {
const activity = (activities || []).find((a) => a?.id === activityId);
const accountType = activity?.mappedGLAccountType;
if (!accountType || !glAccountOptions) {
return [];
}
// ASSET → assetAccountOptions, and so on for LIABILITY, EQUITY, INCOME and EXPENSE.
return glAccountOptions[`${accountType.toLowerCase()}AccountOptions`] ?? [];
}
@@ -279,7 +279,7 @@
<div class="gl-fgroup"> <div class="gl-fgroup">
<label class="gl-flabel"><fa-icon icon="calendar-alt"></fa-icon> From Date</label> <label class="gl-flabel"><fa-icon icon="calendar-alt"></fa-icon> From Date</label>
<mat-form-field appearance="outline" class="gl-ffield"> <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-toggle matSuffix [for]="dpFrom"></mat-datepicker-toggle>
<mat-datepicker #dpFrom></mat-datepicker> <mat-datepicker #dpFrom></mat-datepicker>
</mat-form-field> </mat-form-field>
@@ -288,7 +288,7 @@
<div class="gl-fgroup"> <div class="gl-fgroup">
<label class="gl-flabel"><fa-icon icon="calendar-alt"></fa-icon> To Date</label> <label class="gl-flabel"><fa-icon icon="calendar-alt"></fa-icon> To Date</label>
<mat-form-field appearance="outline" class="gl-ffield"> <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-toggle matSuffix [for]="dpTo"></mat-datepicker-toggle>
<mat-datepicker #dpTo></mat-datepicker> <mat-datepicker #dpTo></mat-datepicker>
</mat-form-field> </mat-form-field>
@@ -353,13 +353,7 @@
<div class="gl-fgroup"> <div class="gl-fgroup">
<label class="gl-flabel"><fa-icon icon="calendar-check"></fa-icon> Submitted From</label> <label class="gl-flabel"><fa-icon icon="calendar-check"></fa-icon> Submitted From</label>
<mat-form-field appearance="outline" class="gl-ffield"> <mat-form-field appearance="outline" class="gl-ffield">
<input <input matInput [matDatepicker]="dpSubFrom" [min]="minDate" [formControl]="submittedOnDateFrom" />
matInput
[matDatepicker]="dpSubFrom"
[min]="minDate"
[max]="maxDate"
[formControl]="submittedOnDateFrom"
/>
<mat-datepicker-toggle matSuffix [for]="dpSubFrom"></mat-datepicker-toggle> <mat-datepicker-toggle matSuffix [for]="dpSubFrom"></mat-datepicker-toggle>
<mat-datepicker #dpSubFrom></mat-datepicker> <mat-datepicker #dpSubFrom></mat-datepicker>
</mat-form-field> </mat-form-field>
@@ -368,7 +362,7 @@
<div class="gl-fgroup"> <div class="gl-fgroup">
<label class="gl-flabel"><fa-icon icon="calendar-check"></fa-icon> Submitted To</label> <label class="gl-flabel"><fa-icon icon="calendar-check"></fa-icon> Submitted To</label>
<mat-form-field appearance="outline" class="gl-ffield"> <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-toggle matSuffix [for]="dpSubTo"></mat-datepicker-toggle>
<mat-datepicker #dpSubTo></mat-datepicker> <mat-datepicker #dpSubTo></mat-datepicker>
</mat-form-field> </mat-form-field>
@@ -539,7 +533,7 @@
</div> </div>
<div class="gl-table-shell"> <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 --> <!-- #ID -->
<ng-container matColumnDef="id"> <ng-container matColumnDef="id">
<mat-header-cell *matHeaderCellDef mat-sort-header class="gl-th">#</mat-header-cell> <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 ────────────────────────────────────────────────────────────────── // ── Misc ──────────────────────────────────────────────────────────────────
minDate = new Date(2000, 0, 1); minDate = new Date(2000, 0, 1);
maxDate = new Date(); maxDate: Date = null;
/** Accounting features switched off for this installation — see disabled-features.ts. */ /** Accounting features switched off for this installation — see disabled-features.ts. */
disabledFeatures = DISABLED_ACCOUNTING_FEATURES; disabledFeatures = DISABLED_ACCOUNTING_FEATURES;
@@ -208,7 +208,7 @@ export class SearchJournalEntryComponent implements OnInit, OnDestroy, AfterView
} }
ngOnInit() { ngOnInit() {
this.maxDate = this.settingsService.businessDate; this.maxDate = null;
this.paginator.pageSize = 10; this.paginator.pageSize = 10;
this.restoreState(); this.restoreState();
this.setFilteredOffices(); this.setFilteredOffices();
@@ -11,6 +11,14 @@ import { Routes, RouterModule } from '@angular/router';
import { Route } from '../core/route/route.service'; import { Route } from '../core/route/route.service';
import { AccountsListComponent } from './accounts-list/accounts-list.component'; 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 = [ const routes: Routes = [
Route.withShell([ Route.withShell([
@@ -41,6 +49,59 @@ const routes: Routes = [
title: 'Recurring Deposit Accounts', title: 'Recurring Deposit Accounts',
breadcrumb: '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 }); 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> { getRecurringDepositAccounts(params: AccountListParams = {}): Observable<any> {
let httpParams = new HttpParams() let httpParams = new HttpParams()
.set('paged', 'true') .set('paged', 'true')
+45
View File
@@ -34,6 +34,17 @@ export class AmlService {
return this.http.post(`/aml/alerts/${alertId}/review`, { disposition, notes }); return this.http.post(`/aml/alerts/${alertId}/review`, { disposition, notes });
} }
/**
* Decides named rules within one alert, leaving its other rules open.
*
* <p>
* The alert's own status follows from what is left undecided, so it is not sent: whitelisting five of six rules
* leaves the alert open on the sixth.
*/
reviewAlertRules(alertId: number, ruleCodes: string[], disposition: string, notes: string): Observable<any> {
return this.http.post(`/aml/alerts/${alertId}/review`, { disposition, notes, ruleCodes });
}
// ═══ Recurring-alert review queue ═══ // ═══ Recurring-alert review queue ═══
/** /**
@@ -141,4 +152,38 @@ export class AmlService {
getRiskCategoryHistory(clientId: number): Observable<any[]> { getRiskCategoryHistory(clientId: number): Observable<any[]> {
return this.http.get<any[]>(`/clients/${clientId}/risk-category-history`); return this.http.get<any[]>(`/clients/${clientId}/risk-category-history`);
} }
// ═══ Risk-category changes (maker-checker) ═══
//
// The rating decides how often a customer is re-KYC'd and what AML thresholds apply to them, so
// it moves only once a checker agrees rather than on one officer's say-so.
/** @param requestedCategory 1 Low, 2 Medium, 3 High */
requestRiskChange(clientId: number, requestedCategory: number, reason: string): Observable<any> {
return this.http.post('/aml/risk-change-requests', { clientId, requestedCategory, reason });
}
getRiskChangeRequests(status?: string): Observable<any[]> {
const params = status ? new HttpParams().set('status', status) : undefined;
return this.http.get<any[]>('/aml/risk-change-requests', { params });
}
approveRiskChange(requestId: number): Observable<any> {
return this.http.post(`/aml/risk-change-requests/${requestId}/approve`, {});
}
rejectRiskChange(requestId: number, rejectionReason: string): Observable<any> {
return this.http.post(`/aml/risk-change-requests/${requestId}/reject`, { rejectionReason });
}
/**
* The latest behaviour window for each customer, as the nightly job computed it.
*
* One row per customer carrying the review period's figures and the rating they suggest. The Risk Review report
* prefers these over its own per-alert reckoning, because a rating describes a period rather than the handful of
* transactions that happened to trip a rule.
*/
getBehaviourWindows(): Observable<any[]> {
return this.http.get<any[]>('/risk-review/behaviour-windows');
}
} }
+6
View File
@@ -63,6 +63,8 @@ import { CfcfrmsModule } from './cfcfrms/cfcfrms.module';
import { BankCalendarModule } from './bank-calendar/bank-calendar.module'; import { BankCalendarModule } from './bank-calendar/bank-calendar.module';
import { DeafModule } from './deaf/deaf.module'; import { DeafModule } from './deaf/deaf.module';
import { SavingsDailyInterestModule } from './savings-daily-interest/savings-daily-interest.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 { DicgcModule } from './dicgc/dicgc.module';
import { FiuModule } from './fiu/fiu.module'; import { FiuModule } from './fiu/fiu.module';
import { GlReportsModule } from './gl-reports/gl-reports.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 { AuthService } from './zitadel/auth.service';
import { environment } from '../environments/environment'; import { environment } from '../environments/environment';
import { CallbackComponent } from './zitadel/callback/callback.component'; import { CallbackComponent } from './zitadel/callback/callback.component';
import { LoadingScreenComponent } from './shared/loading-screen/loading-screen.component';
import { OAuthModule } from 'angular-oauth2-oidc'; import { OAuthModule } from 'angular-oauth2-oidc';
export class CustomMissingTranslationHandler implements MissingTranslationHandler { export class CustomMissingTranslationHandler implements MissingTranslationHandler {
@@ -176,6 +179,8 @@ export function HttpLoaderFactory(http: HttpClient) {
BankCalendarModule, BankCalendarModule,
DeafModule, DeafModule,
SavingsDailyInterestModule, SavingsDailyInterestModule,
FdDashboardModule,
RdDashboardModule,
DicgcModule, DicgcModule,
FiuModule, FiuModule,
GlReportsModule, GlReportsModule,
@@ -193,6 +198,7 @@ export function HttpLoaderFactory(http: HttpClient) {
AppRoutingModule, AppRoutingModule,
NotFoundComponent, NotFoundComponent,
CallbackComponent, CallbackComponent,
LoadingScreenComponent,
OAuthModule.forRoot() OAuthModule.forRoot()
], ],
providers: [ providers: [
@@ -44,8 +44,11 @@
<div class="cal-stat sat2nd4th"> <div class="cal-stat sat2nd4th">
<fa-icon icon="calendar-times" class="cal-stat__icon"></fa-icon> <fa-icon icon="calendar-times" class="cal-stat__icon"></fa-icon>
<div class="cal-stat__info"> <div class="cal-stat__info">
<div class="cal-stat__value">{{ overallStats.sat2nd4th }}</div> <div class="cal-stat__value">{{ overallStats.sat2nd4th + overallStats.weeklyOff }}</div>
<div class="cal-stat__label">2nd &amp; 4th Sat</div> <div class="cal-stat__label">
{{ saturdayIsWorkingDay ? '2nd & 4th Sat' : 'Saturdays'
}}{{ overallStats.weeklyOff > 0 ? ' / Weekly Off' : '' }}
</div>
</div> </div>
</div> </div>
<div class="cal-stat holidays"> <div class="cal-stat holidays">
@@ -69,7 +72,11 @@
<div class="cal-legend__items"> <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 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 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 holiday"></span> Holiday</div>
<div class="cal-legend__item"><span class="cal-legend__dot today"></span> Today</div> <div class="cal-legend__item"><span class="cal-legend__dot today"></span> Today</div>
</div> </div>
@@ -319,7 +326,9 @@
[class.cal-grid__day--other]="!day.isCurrentMonth" [class.cal-grid__day--other]="!day.isCurrentMonth"
[class.cal-grid__day--today]="day.isToday" [class.cal-grid__day--today]="day.isToday"
[class.cal-grid__day--sunday]="day.isSunday && day.isCurrentMonth" [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--holiday]="day.isHoliday && day.isCurrentMonth"
[class.cal-grid__day--working]="day.isWorking && day.isCurrentMonth" [class.cal-grid__day--working]="day.isWorking && day.isCurrentMonth"
[matTooltip]=" [matTooltip]="
@@ -328,6 +337,8 @@
? '2nd Saturday Off' ? '2nd Saturday Off'
: day.is4thSaturday : day.is4thSaturday
? '4th Saturday Off' ? '4th Saturday Off'
: day.isWeeklyOff && day.isCurrentMonth
? weekDays[day.dayOfWeek] + ' Off (Working Days)'
: day.isSunday && day.isCurrentMonth : day.isSunday && day.isCurrentMonth
? 'Sunday' ? 'Sunday'
: '') : '')
@@ -343,10 +354,15 @@
@if (month.holidays > 0) { @if (month.holidays > 0) {
<span class="summary-tag holiday">{{ month.holidays }} Holiday{{ month.holidays > 1 ? 's' : '' }}</span> <span class="summary-tag holiday">{{ month.holidays }} Holiday{{ month.holidays > 1 ? 's' : '' }}</span>
} }
@if (month.sundays > 0) {
<span class="summary-tag sun">{{ month.sundays }} Sun</span> <span class="summary-tag sun">{{ month.sundays }} Sun</span>
}
@if (month.secondFourthSat > 0) { @if (month.secondFourthSat > 0) {
<span class="summary-tag sat">{{ month.secondFourthSat }} Sat Off</span> <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>
</div> </div>
} }
@@ -63,6 +63,8 @@ interface CalendarDay {
isSunday: boolean; isSunday: boolean;
is2ndSaturday: boolean; is2ndSaturday: boolean;
is4thSaturday: boolean; is4thSaturday: boolean;
/** Off every week because Admin > Working Days leaves this weekday out (Sunday aside). */
isWeeklyOff: boolean;
holidayName?: string; holidayName?: string;
dayOfWeek: number; // 0=Sun, 6=Sat dayOfWeek: number; // 0=Sun, 6=Sat
} }
@@ -76,9 +78,23 @@ interface MonthData {
nonWorkingDays: number; nonWorkingDays: number;
holidays: number; holidays: number;
sundays: number; sundays: number;
/** Saturdays off: the 2nd and 4th when Saturday is a working day, every one when it is not. */
secondFourthSat: number; 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({ @Component({
selector: 'mifosx-bank-calendar', selector: 'mifosx-bank-calendar',
templateUrl: './bank-calendar.component.html', templateUrl: './bank-calendar.component.html',
@@ -118,7 +134,7 @@ export class BankCalendarComponent implements OnInit {
]; ];
workingDaysConfig: string[] = []; workingDaysConfig: string[] = [];
holidays: any[] = []; 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) ───── // ───── Holiday list (office-wise) ─────
offices: OfficeLite[] = []; offices: OfficeLite[] = [];
@@ -202,7 +218,8 @@ export class BankCalendarComponent implements OnInit {
} }
private loadWorkingDays() { 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 = [ this.workingDaysConfig = [
'MO', 'MO',
'TU', 'TU',
@@ -215,6 +232,19 @@ export class BankCalendarComponent implements OnInit {
this.buildCalendar(); this.buildCalendar();
this.rebuildHolidayRows(); 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 // 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 // calendar entries are surfaced in the list panel (calendar grid still
// unions everything for the year-at-a-glance view). // 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. */ /** Flatten the holidays array into a presentable list with office labels. */
private rebuildHolidayRows(): void { private rebuildHolidayRows(): void {
this.holidayRows = this.holidays.map((h: any) => { this.holidayRows = this.holidays.map((h: any) => {
@@ -511,7 +558,15 @@ export class BankCalendarComponent implements OnInit {
buildCalendar() { buildCalendar() {
this.months = []; 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++) { for (let m = 0; m < 12; m++) {
const monthData = this.buildMonth(m, this.currentYear); const monthData = this.buildMonth(m, this.currentYear);
@@ -521,6 +576,7 @@ export class BankCalendarComponent implements OnInit {
this.overallStats.holidays += monthData.holidays; this.overallStats.holidays += monthData.holidays;
this.overallStats.sundays += monthData.sundays; this.overallStats.sundays += monthData.sundays;
this.overallStats.sat2nd4th += monthData.secondFourthSat; this.overallStats.sat2nd4th += monthData.secondFourthSat;
this.overallStats.weeklyOff += monthData.weeklyOff;
} }
this.overallStats.totalDays = this.overallStats.working + this.overallStats.nonWorking; this.overallStats.totalDays = this.overallStats.working + this.overallStats.nonWorking;
} }
@@ -552,6 +608,7 @@ export class BankCalendarComponent implements OnInit {
let holidayCount = 0; let holidayCount = 0;
let sundayCount = 0; let sundayCount = 0;
let sat2nd4thCount = 0; let sat2nd4thCount = 0;
let weeklyOffCount = 0;
// Previous month padding // Previous month padding
const prevMonthDays = new Date(year, month, 0).getDate(); const prevMonthDays = new Date(year, month, 0).getDate();
@@ -567,6 +624,7 @@ export class BankCalendarComponent implements OnInit {
isSunday: false, isSunday: false,
is2ndSaturday: false, is2ndSaturday: false,
is4thSaturday: false, is4thSaturday: false,
isWeeklyOff: false,
dayOfWeek: (startDayOfWeek - i - 1 + 7) % 7 dayOfWeek: (startDayOfWeek - i - 1 + 7) % 7
}); });
} }
@@ -576,24 +634,29 @@ export class BankCalendarComponent implements OnInit {
for (let d = 1; d <= daysInMonth; d++) { for (let d = 1; d <= daysInMonth; d++) {
const date = new Date(year, month, d); const date = new Date(year, month, d);
const dow = date.getDay(); const dow = date.getDay();
const isSunday = dow === 0;
const isSaturday = dow === 6; const isSaturday = dow === 6;
const isWorkingWeekday = this.workingDaysConfig.includes(DAY_CODES[dow]);
const isSunday = dow === 0 && !isWorkingWeekday;
if (isSaturday) saturdayCount++; if (isSaturday) saturdayCount++;
const is2ndSat = isSaturday && saturdayCount === 2; // RBI: when Saturday is a working day the 2nd and 4th are still off. When Working Days leaves
const is4thSat = isSaturday && saturdayCount === 4; // 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(); const isToday = date.toDateString() === today.toDateString();
// Check if holiday // Check if holiday
const holiday = this.getHolidayForDate(year, month, d); const holiday = this.getHolidayForDate(year, month, d);
const isHoliday = !!holiday; const isHoliday = !!holiday;
// Non-working: Sunday, 2nd Sat, 4th Sat, or holiday // Non-working: Sunday, 2nd Sat, 4th Sat, a weekly off from Working Days, or holiday
const isNonWorking = isSunday || is2ndSat || is4thSat || isHoliday; const isNonWorking = isSunday || is2ndSat || is4thSat || isWeeklyOff || isHoliday;
if (isSunday) sundayCount++; if (isSunday) sundayCount++;
if (is2ndSat || is4thSat) sat2nd4thCount++; if (is2ndSat || is4thSat || (isSaturday && isWeeklyOff)) sat2nd4thCount++;
if (isWeeklyOff && !isSaturday) weeklyOffCount++;
if (isHoliday) holidayCount++; if (isHoliday) holidayCount++;
if (isNonWorking) { if (isNonWorking) {
@@ -613,6 +676,7 @@ export class BankCalendarComponent implements OnInit {
isSunday, isSunday,
is2ndSaturday: is2ndSat, is2ndSaturday: is2ndSat,
is4thSaturday: is4thSat, is4thSaturday: is4thSat,
isWeeklyOff,
holidayName: holiday?.name, holidayName: holiday?.name,
dayOfWeek: dow dayOfWeek: dow
}); });
@@ -632,6 +696,7 @@ export class BankCalendarComponent implements OnInit {
isSunday: false, isSunday: false,
is2ndSaturday: false, is2ndSaturday: false,
is4thSaturday: false, is4thSaturday: false,
isWeeklyOff: false,
dayOfWeek: days.length % 7 dayOfWeek: days.length % 7
}); });
} }
@@ -645,7 +710,8 @@ export class BankCalendarComponent implements OnInit {
nonWorkingDays: nonWorkingCount, nonWorkingDays: nonWorkingCount,
holidays: holidayCount, holidays: holidayCount,
sundays: sundayCount, sundays: sundayCount,
secondFourthSat: sat2nd4thCount secondFourthSat: sat2nd4thCount,
weeklyOff: weeklyOffCount
}; };
} }
@@ -0,0 +1,154 @@
<!--
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="bor-root">
<div class="bor-head">
<div class="bor-head-left">
<fa-icon icon="gavel" class="bor-head-ic"></fa-icon>
<div>
<h2 class="bor-head-t">Board of Resolution Register — Share Capital</h2>
<p class="bor-head-s">
Every share account with the board resolution that authorised the allotment. Accounts with no resolution on
record sort to the top.
</p>
</div>
</div>
<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>
</div>
@if (loading) {
<div class="bor-note">Loading the register…</div>
} @else if (loadError) {
<div class="bor-note bor-note--warn"><fa-icon icon="exclamation-triangle"></fa-icon> {{ loadError }}</div>
}
@if (!loading && !loadError) {
<!-- Counted over the whole register, not the filtered view: it is a standing figure the
secretary acts on, and hiding it behind a filter would hide the problem. -->
@if (missingResolutionCount > 0) {
<div class="bor-note bor-note--warn">
<fa-icon icon="exclamation-triangle"></fa-icon>
{{ missingResolutionCount }} share account{{ missingResolutionCount === 1 ? '' : 's' }} carr{{
missingResolutionCount === 1 ? 'ies' : 'y'
}}
no resolution number. Share capital allotted without a board resolution cannot be evidenced to the Registrar.
</div>
}
<div class="bor-kpis">
<div class="bor-kpi">
<div class="bor-kpi-n">{{ filtered.length | number }}</div>
<div class="bor-kpi-l">Share Accounts</div>
</div>
<div class="bor-kpi bor-kpi--blue">
<div class="bor-kpi-n">{{ distinctResolutions | number }}</div>
<div class="bor-kpi-l">Resolutions</div>
</div>
<div class="bor-kpi bor-kpi--green">
<div class="bor-kpi-n">{{ totalShares | number }}</div>
<div class="bor-kpi-l">Shares Allotted</div>
</div>
<div class="bor-kpi bor-kpi--amber">
<div class="bor-kpi-n">&#8377;{{ totalValue | number: '1.0-0' }}</div>
<div class="bor-kpi-l">Share Value</div>
</div>
</div>
<div class="bor-filters">
<div class="bor-filter">
<label>Resolution No.</label>
<input class="bor-inp" type="text" [(ngModel)]="filterResolution" placeholder="e.g. BOR/2026-27/001" />
</div>
<div class="bor-filter">
<label>Branch</label>
<select class="bor-inp" [(ngModel)]="filterBranch">
<option value="">All branches</option>
@for (b of branches; track b) {
<option [value]="b">{{ b }}</option>
}
</select>
</div>
<div class="bor-filter">
<label>Status</label>
<select class="bor-inp" [(ngModel)]="filterStatus">
<option [ngValue]="''">Any status</option>
@for (s of statuses; track s) {
<option [ngValue]="s">{{ statusText(s) }}</option>
}
</select>
</div>
<div class="bor-filter bor-filter--grow">
<label>Search</label>
<input class="bor-inp" type="text" [(ngModel)]="search" placeholder="Member name, share A/c or CIF…" />
</div>
<label class="bor-check">
<input type="checkbox" [(ngModel)]="missingOnly" />
No resolution only
</label>
<button class="bor-btn" type="button" (click)="resetFilters()"><fa-icon icon="undo"></fa-icon> Reset</button>
</div>
<div class="bor-table-wrap">
<table class="bor-table">
<thead>
<tr>
<th>Resolution No.</th>
<th>Resolution Date</th>
<th>Share A/c No.</th>
<th>CIF No.</th>
<th>Member Name</th>
<th>Branch</th>
<th>Product</th>
<th class="num">Shares</th>
<th class="num">Share Value</th>
<th class="num">Tranches</th>
<th>Status</th>
<th>Activated On</th>
</tr>
</thead>
<tbody>
@for (r of filtered; track r.shareAccountId) {
<tr [class.bor-row--missing]="!r.resolutionNumber">
<td>
@if (r.resolutionNumber) {
<strong class="bor-res">{{ r.resolutionNumber }}</strong>
} @else {
<span class="bor-missing">Not recorded</span>
}
</td>
<td class="mono">{{ r.resolutionDate || '—' }}</td>
<td class="mono">{{ r.accountNo }}</td>
<td class="mono">{{ r.cifNumber || '—' }}</td>
<td>{{ r.clientName }}</td>
<td>{{ r.officeName || '—' }}</td>
<td>{{ r.productName || '—' }}</td>
<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 class="mono">{{ r.activatedDate || '—' }}</td>
</tr>
} @empty {
<tr>
<td colspan="12" class="bor-empty">No share account matches the filters.</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
@@ -0,0 +1,313 @@
/**
* 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/.
*/
.bor-root {
padding: 20px 24px 40px;
background: #f8fafc;
min-height: 100%;
}
.bor-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
}
.bor-head-left {
display: flex;
align-items: flex-start;
gap: 12px;
}
.bor-head-ic {
font-size: 26px;
color: #0f4f3c;
margin-top: 2px;
}
.bor-head-t {
margin: 0 0 3px;
font-size: 20px;
font-weight: 700;
color: #1e293b;
}
.bor-head-s {
margin: 0;
max-width: 780px;
font-size: 13px;
line-height: 1.5;
color: #64748b;
}
.bor-note {
display: flex;
align-items: flex-start;
gap: 9px;
padding: 11px 14px;
margin-bottom: 14px;
border: 1px solid #bfdbfe;
border-radius: 9px;
background: #eff6ff;
color: #1e3a8a;
font-size: 12.5px;
line-height: 1.55;
fa-icon {
flex: 0 0 auto;
margin-top: 1px;
}
/* An allotment with no resolution behind it is the finding, not a detail. */
&--warn {
border-color: #fde68a;
background: #fffbeb;
color: #92400e;
fa-icon {
color: #d97706;
}
}
}
.bor-kpis {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.bor-kpi {
background: #fff;
border: 1px solid #e2e8f0;
border-left: 4px solid #0f4f3c;
border-radius: 11px;
padding: 12px 16px;
&--blue {
border-left-color: #2563eb;
}
&--green {
border-left-color: #15803d;
}
&--amber {
border-left-color: #d97706;
}
}
.bor-kpi-n {
font-size: 22px;
font-weight: 700;
color: #1e293b;
line-height: 1.2;
}
.bor-kpi-l {
font-size: 12px;
color: #64748b;
margin-top: 2px;
}
.bor-filters {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 12px;
padding: 14px 16px;
margin-bottom: 14px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 11px;
}
.bor-filter {
display: flex;
flex-direction: column;
gap: 4px;
label {
font-size: 10.5px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #64748b;
}
&--grow {
flex: 1 1 240px;
}
}
.bor-inp {
height: 34px;
min-width: 170px;
padding: 0 10px;
border: 1px solid #cbd5e1;
border-radius: 7px;
font: inherit;
font-size: 13px;
color: #1e293b;
background: #fff;
&:focus {
outline: 2px solid #2563eb;
outline-offset: -1px;
}
}
.bor-check {
display: flex;
align-items: center;
gap: 6px;
height: 34px;
font-size: 12.5px;
color: #334155;
cursor: pointer;
}
.bor-btn {
display: inline-flex;
align-items: center;
gap: 7px;
height: 34px;
padding: 0 14px;
border: 1px solid #cbd5e1;
border-radius: 7px;
background: #fff;
font: inherit;
font-size: 12.5px;
font-weight: 600;
color: #334155;
cursor: pointer;
&:hover:not(:disabled) {
background: #f1f5f9;
}
&:disabled {
opacity: 0.55;
cursor: default;
}
&--xls {
border-color: #15803d;
background: #16a34a;
color: #fff;
&:hover:not(:disabled) {
background: #15803d;
}
}
}
.bor-table-wrap {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 11px;
overflow-x: auto;
}
.bor-table {
width: 100%;
border-collapse: collapse;
font-size: 12.5px;
th {
text-align: left;
padding: 10px;
background: #f1f5f9;
font-size: 10.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #475569;
white-space: nowrap;
border-bottom: 1px solid #e2e8f0;
}
td {
padding: 11px 10px;
border-bottom: 1px solid #f1f5f9;
vertical-align: middle;
color: #334155;
}
.num {
text-align: right;
font-family: 'DM Mono', monospace;
}
.mono {
font-family: 'DM Mono', monospace;
}
.strong {
font-weight: 700;
}
tbody tr:hover {
background: #f8fafc;
}
}
/* The row an auditor is looking for, marked in the table as well as counted above it. */
.bor-row--missing {
background: #fffbeb;
}
.bor-res {
color: #0f4f3c;
font-family: 'DM Mono', monospace;
}
.bor-missing {
display: inline-block;
padding: 2px 8px;
border-radius: 999px;
background: #fef3c7;
color: #92400e;
font-size: 11px;
font-weight: 700;
}
.bor-status {
display: inline-block;
padding: 2px 9px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
background: #f1f5f9;
color: #475569;
&--active {
background: #dcfce7;
color: #15803d;
}
&--submitted,
&--approved {
background: #fef3c7;
color: #b45309;
}
&--rejected,
&--closed {
background: #fee2e2;
color: #b91c1c;
}
}
.bor-empty {
padding: 30px;
text-align: center;
color: #94a3b8;
}
@@ -0,0 +1,221 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { exportWorkbook } from '../cashier/cashier-export';
import { BorRegisterRow, BorReportService } from './bor-report.service';
/** m_share_account.status_enum, as the register labels it. */
const STATUS_LABEL: Record<number, string> = {
100: 'Submitted',
200: 'Approved',
300: 'Active',
400: 'Rejected',
500: 'Closed',
600: 'Inactive'
};
/**
* The Board of Resolution register for share capital.
*
* A co-operative allots shares only under a board resolution, and this is the list the Registrar or
* an auditor asks for: every member's holding against the resolution that authorised it. The
* account with no resolution recorded is the point of the register, so it sorts to the top and is
* counted on its own rather than being left to be noticed.
*/
@Component({
selector: 'mifosx-bor-report',
templateUrl: './bor-report.component.html',
styleUrls: ['./bor-report.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FaIconComponent,
FormsModule
]
})
export class BorReportComponent implements OnInit {
private borService = inject(BorReportService);
readonly statusLabel = STATUS_LABEL;
rows: BorRegisterRow[] = [];
loading = false;
loadError = '';
/** '' = every status. */
filterStatus: number | '' = '';
filterBranch = '';
filterResolution = '';
search = '';
/** Only the accounts with no resolution on file — the ones that need chasing. */
missingOnly = false;
exporting = false;
ngOnInit(): void {
this.load();
}
load(): void {
this.loading = true;
this.loadError = '';
this.borService.getRegister().subscribe({
next: (rows) => {
this.loading = false;
this.rows = rows;
},
error: (err: any) => {
this.loading = false;
this.rows = [];
this.loadError = err?.error?.defaultUserMessage || 'Could not load the BOR register.';
}
});
}
/** Branches present in the data, so the filter offers only what can actually be selected. */
get branches(): string[] {
return [...new Set(this.rows.map((r) => r.officeName).filter(Boolean))].sort();
}
get statuses(): number[] {
return [...new Set(this.rows.map((r) => r.statusEnum))].sort((a, b) => a - b);
}
get filtered(): BorRegisterRow[] {
const needle = this.search.trim().toLowerCase();
const resolutionNeedle = this.filterResolution.trim().toLowerCase();
return this.rows.filter((r) => {
if (this.filterStatus !== '' && r.statusEnum !== this.filterStatus) {
return false;
}
if (this.filterBranch && r.officeName !== this.filterBranch) {
return false;
}
if (this.missingOnly && r.resolutionNumber) {
return false;
}
if (resolutionNeedle && !r.resolutionNumber.toLowerCase().includes(resolutionNeedle)) {
return false;
}
if (
needle &&
!(
r.clientName.toLowerCase().includes(needle) ||
r.accountNo.toLowerCase().includes(needle) ||
r.cifNumber.toLowerCase().includes(needle)
)
) {
return false;
}
return true;
});
}
get totalShares(): number {
return this.filtered.reduce((s, r) => s + r.approvedShares, 0);
}
get totalValue(): number {
return this.filtered.reduce((s, r) => s + r.shareValue, 0);
}
/** How many resolutions the filtered holdings were authorised under. */
get distinctResolutions(): number {
return new Set(this.filtered.map((r) => r.resolutionNumber).filter(Boolean)).size;
}
get missingResolutionCount(): number {
return this.rows.filter((r) => !r.resolutionNumber).length;
}
statusText(statusEnum: number): string {
return STATUS_LABEL[statusEnum] || '—';
}
statusClass(statusEnum: number): string {
return 'bor-status bor-status--' + (STATUS_LABEL[statusEnum] || 'unknown').toLowerCase();
}
resetFilters(): void {
this.filterStatus = '';
this.filterBranch = '';
this.filterResolution = '';
this.search = '';
this.missingOnly = false;
}
async exportExcel(): Promise<void> {
this.exporting = true;
try {
const rows = this.filtered;
await exportWorkbook(
[
{
sheetName: 'BOR Register',
title: 'Board of Resolution Register — Share Capital',
subtitle: `${rows.length} share account(s) · ${this.distinctResolutions} resolution(s) · ${this.totalShares} shares`,
headers: [
'Resolution No.',
'Resolution Date',
'Share A/c No.',
'CIF No.',
'Member Name',
'Branch',
'Product',
'Shares',
'Unit Price',
'Share Value',
'Tranches',
'Status',
'Activated On'
],
widths: [
20,
16,
18,
16,
30,
18,
18,
10,
12,
14,
10,
12,
14
],
rows: rows.map((r) => [
// Named rather than left blank, because an empty cell in a filing reads as an
// oversight in the export rather than a gap in the register.
r.resolutionNumber || 'NOT RECORDED',
r.resolutionDate || '—',
r.accountNo,
r.cifNumber,
r.clientName,
r.officeName,
r.productName,
r.approvedShares,
r.unitPrice,
r.shareValue,
r.trancheCount,
this.statusText(r.statusEnum),
r.activatedDate || '—'
])
}
],
'BOR-Register'
);
} finally {
this.exporting = false;
}
}
}
@@ -0,0 +1,94 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/** Angular Imports */
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable, map } from 'rxjs';
/** Custom Helpers */
import { toIsoDate } from '../core/utils/api-date';
/** One share account and the board resolution that authorised it. */
export interface BorRegisterRow {
shareAccountId: number;
accountNo: string;
clientId: number;
cifNumber: string;
clientName: string;
officeId: number | null;
officeName: string;
productName: string;
currencyCode: string;
statusEnum: number;
approvedShares: number;
pendingShares: number;
unitPrice: number;
shareValue: number;
resolutionNumber: string;
/** ISO date, or '' where no meeting date was ever recorded against the allotment. */
resolutionDate: string;
/** How many purchase tranches make up the holding. */
trancheCount: number;
submittedDate: string;
approvedDate: string;
activatedDate: string;
closedDate: string;
}
/**
* The BOR register.
*
* One call returns every account with its resolution. The share allotment report next door fetches
* each account separately and stops at two hundred, which is a limit on a register rather than on a
* page of one.
*/
@Injectable({ providedIn: 'root' })
export class BorReportService {
private http = inject(HttpClient);
getRegister(status?: number, officeId?: number): Observable<BorRegisterRow[]> {
let params = new HttpParams();
if (status != null) {
params = params.set('status', String(status));
}
if (officeId != null) {
params = params.set('officeId', String(officeId));
}
return this.http
.get<any[]>('/shareaccounts/bor-register', { params })
.pipe(map((rows) => (rows || []).map((r) => this.toRow(r))));
}
/** Dates arrive as [y, M, d] arrays; flattened to ISO so they sort and filter as strings. */
private toRow(r: any): BorRegisterRow {
return {
shareAccountId: r.shareAccountId,
accountNo: r.accountNo || '',
clientId: r.clientId,
cifNumber: r.cifNumber || '',
clientName: r.clientName || '',
officeId: r.officeId ?? null,
officeName: r.officeName || '',
productName: r.productName || '',
currencyCode: r.currencyCode || 'INR',
statusEnum: Number(r.statusEnum) || 0,
approvedShares: Number(r.approvedShares) || 0,
pendingShares: Number(r.pendingShares) || 0,
unitPrice: Number(r.unitPrice) || 0,
shareValue: Number(r.shareValue) || 0,
resolutionNumber: (r.resolutionNumber || '').trim(),
resolutionDate: toIsoDate(r.resolutionDate),
trancheCount: Number(r.trancheCount) || 0,
submittedDate: toIsoDate(r.submittedDate),
approvedDate: toIsoDate(r.approvedDate),
activatedDate: toIsoDate(r.activatedDate),
closedDate: toIsoDate(r.closedDate)
};
}
}
@@ -12,6 +12,7 @@ import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
import { catchError, of } from 'rxjs'; import { catchError, of } from 'rxjs';
import { CashierService } from '../cashier.service'; import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
interface RegisterRow { interface RegisterRow {
accountId: number; accountId: number;
@@ -83,7 +84,7 @@ export class AccountRegisterComponent implements OnInit {
/** Local date — toISOString() would shift the day in any timezone behind UTC. */ /** Local date — toISOString() would shift the day in any timezone behind UTC. */
private today(): string { 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')}`; 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> </span>
</div> </div>
@if (canOperate) { @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 <mat-icon>lock</mat-icon> Close Day
</button> </button>
} }
</div> </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) { } @else if (loaded && canOperate) {
<div class="bd-current bd-current--shut"> <div class="bd-current bd-current--shut">
<div class="bd-current__main"> <div class="bd-current__main">
@@ -337,6 +352,51 @@
</div> </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 <!-- 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 automatically — so closing the day doesn't mean separately checking Vault Position and
every cashier's own Cashier Position screen by hand. --> every cashier's own Cashier Position screen by hand. -->
@@ -195,6 +195,75 @@
font-size: 11px; font-size: 11px;
color: #94a3b8; 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 { .bd-hint {
@@ -372,3 +441,30 @@
color: #d97706; 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); 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 { load(): void {
this.loading = true; this.loading = true;
this.errorMsg = ''; this.errorMsg = '';
@@ -210,6 +227,7 @@ export class BranchDayComponent implements OnInit {
this.canOperate = !!res?.canOperate; this.canOperate = !!res?.canOperate;
this.openDate = this.today; this.openDate = this.today;
this.checkFirstOpenCashMigration(); this.checkFirstOpenCashMigration();
this.checkInterestReports();
}); });
this.service this.service
@@ -11,6 +11,7 @@ import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { AuthenticationService } from '../../core/authentication/authentication.service'; import { AuthenticationService } from '../../core/authentication/authentication.service';
import { ExportSheetSpec, exportWorkbook } from '../cashier-export'; import { ExportSheetSpec, exportWorkbook } from '../cashier-export';
import { businessToday } from 'app/core/utils/business-today';
interface SummaryRow { interface SummaryRow {
code: string; code: string;
@@ -1316,7 +1317,7 @@ export class CashClearingTransferSummaryComponent {
private dateTag = '2607'; private dateTag = '2607';
constructor() { 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.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')}`; this.dateTag = `${String(d.getFullYear()).slice(-2)}${String(d.getMonth() + 1).padStart(2, '0')}`;
@@ -22,50 +22,255 @@
</div> </div>
</header> </header>
<!-- ── Customer Info Bar ── --> <!-- ── Cashier: whose drawer takes the customer's notes and gives the change ── -->
<div class="cx-customer-bar"> @if (cashier) {
<div <section class="cx-cashier">
class="cx-customer-toggle" <div class="cx-avatar cx-avatar--sm">{{ cashierInitials }}</div>
[class.cx-customer-toggle--active]="isAccountHolder" <div class="cx-cashier__id">
(click)="toggleAccountHolder()" <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>
<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>{{ isAccountHolder ? 'check_box' : 'check_box_outline_blank' }}</mat-icon> <mat-icon>badge</mat-icon> Existing Customer
<span>Account Holder</span> </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> </div>
</header>
@if (isAccountHolder) { <div class="cx-cust__body">
<label class="cx-field cx-field--inline"> <!-- Who -->
<span>Account No.</span> <div class="cx-cust__who">
<input @if (customerMode === 'existing') {
type="text" @if (loadingCustomer) {
[value]="accountNo" <div class="cx-cust__empty">
(input)="accountNo = $any($event.target).value" <mat-icon class="cx-cust__empty-icon cx-spin">autorenew</mat-icon>
placeholder="e.g. 000101234567" <div class="cx-cust__empty-title">Loading customer details…</div>
/> </div>
</label> } @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 { } @else {
<span class="cx-walk-in"><mat-icon>person_outline</mat-icon> Walk-in Customer</span> <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>
} }
@if (needsIdProof) {
<div class="cx-pan-required">
<mat-icon>warning</mat-icon>
<span>Amount ≥ ₹50,000 — Customer PAN / Aadhaar required (RBI)</span>
</div> </div>
<label class="cx-field cx-field--inline cx-field--pan"> </div>
<span>PAN / Aadhaar *</span> <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 <input
type="text" type="text"
[value]="customerIdProof" [value]="walkInName"
(input)="customerIdProof = $any($event.target).value" (input)="walkInName = $any($event.target).value"
placeholder="ABCDE1234F" placeholder="Full name as on ID"
maxlength="20" maxlength="150"
autocomplete="off"
/> />
</label> </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> </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"> <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 --> <!-- RECEIVED -->
<section class="cx-col cx-col--received"> <section class="cx-col cx-col--received">
<header class="cx-col-head"> <header class="cx-col-head">
@@ -78,7 +283,7 @@
<div class="cx-subhead">Bank Notes</div> <div class="cx-subhead">Bank Notes</div>
} }
@for (d of notes; track d.id) { @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> <span class="cx-chip">{{ d.label }}</span>
<div class="cx-step"> <div class="cx-step">
<button type="button" (click)="bump('r', d, -1)" [disabled]="countFor('r', d) === 0">−</button> <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> <button type="button" (click)="bump('r', d, 1)">+</button>
</div> </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" <span class="cx-amt" [class.cx-amt--zero]="countFor('r', d) === 0"
>₹{{ subtotal('r', d) | number: '1.2-2' }}</span >₹{{ subtotal('r', d) | number: '1.2-2' }}</span
> >
@@ -99,7 +307,7 @@
<div class="cx-subhead">Coins</div> <div class="cx-subhead">Coins</div>
} }
@for (d of coins; track d.id) { @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> <span class="cx-chip cx-chip--coin">{{ d.label }}</span>
<div class="cx-step"> <div class="cx-step">
<button type="button" (click)="bump('r', d, -1)" [disabled]="countFor('r', d) === 0">−</button> <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> <button type="button" (click)="bump('r', d, 1)">+</button>
</div> </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" <span class="cx-amt" [class.cx-amt--zero]="countFor('r', d) === 0"
>₹{{ subtotal('r', d) | number: '1.2-2' }}</span >₹{{ subtotal('r', d) | number: '1.2-2' }}</span
> >
@@ -131,7 +342,7 @@
<div class="cx-subhead">Bank Notes</div> <div class="cx-subhead">Bank Notes</div>
} }
@for (d of notes; track d.id) { @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> <span class="cx-chip">{{ d.label }}</span>
<div class="cx-step"> <div class="cx-step">
<button type="button" (click)="bump('g', d, -1)" [disabled]="countFor('g', d) === 0">−</button> <button type="button" (click)="bump('g', d, -1)" [disabled]="countFor('g', d) === 0">−</button>
@@ -141,8 +352,16 @@
[value]="countFor('g', d)" [value]="countFor('g', d)"
(input)="onChange('g', d, $any($event.target).value)" (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> </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" <span class="cx-amt" [class.cx-amt--zero]="countFor('g', d) === 0"
>₹{{ subtotal('g', d) | number: '1.2-2' }}</span >₹{{ subtotal('g', d) | number: '1.2-2' }}</span
> >
@@ -152,7 +371,7 @@
<div class="cx-subhead">Coins</div> <div class="cx-subhead">Coins</div>
} }
@for (d of coins; track d.id) { @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> <span class="cx-chip cx-chip--coin">{{ d.label }}</span>
<div class="cx-step"> <div class="cx-step">
<button type="button" (click)="bump('g', d, -1)" [disabled]="countFor('g', d) === 0">−</button> <button type="button" (click)="bump('g', d, -1)" [disabled]="countFor('g', d) === 0">−</button>
@@ -162,8 +381,16 @@
[value]="countFor('g', d)" [value]="countFor('g', d)"
(input)="onChange('g', d, $any($event.target).value)" (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> </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" <span class="cx-amt" [class.cx-amt--zero]="countFor('g', d) === 0"
>₹{{ subtotal('g', d) | number: '1.2-2' }}</span >₹{{ subtotal('g', d) | number: '1.2-2' }}</span
> >
@@ -173,6 +400,30 @@
</section> </section>
</div> </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-summary">
<div class="cx-sum"> <div class="cx-sum">
<span>Received</span><strong>₹{{ receivedTotal | number: '1.2-2' }}</strong> <span>Received</span><strong>₹{{ receivedTotal | number: '1.2-2' }}</strong>
@@ -213,9 +464,15 @@
</div> </div>
</div> </div>
} }
@if (needsIdProof && !customerIdProof.trim()) { @if (panMissing) {
<div class="cx-warn cx-warn--pan"> <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> </div>
} }
@@ -73,45 +73,510 @@
} }
} }
/* ── Customer info bar ── */ /* ── Customer: existing client or walk-in, plus PAN ── */
.cx-customer-bar { .cx-cust {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 14px;
box-shadow: 0 1px 2px rgb(15 23 42 / 4%);
overflow: hidden;
&__head {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; justify-content: space-between;
padding: 10px 14px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 10px;
flex-wrap: wrap; flex-wrap: wrap;
gap: 12px;
padding: 12px 16px;
background: linear-gradient(180deg, #f8fafc, #f1f5f9);
border-bottom: 1px solid #e2e8f0;
} }
.cx-customer-toggle { &__title {
display: flex; display: flex;
align-items: center; 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; gap: 6px;
height: 32px;
padding: 0 14px;
border: 0;
border-radius: 8px;
background: transparent;
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
color: #475569; color: #475569;
cursor: pointer; cursor: pointer;
user-select: none; transition:
background 0.15s ease,
color 0.15s ease;
mat-icon { mat-icon {
font-size: 20px; font-size: 18px;
height: 20px; width: 18px;
width: 20px; 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; color: #94a3b8;
} }
&--active { &__empty-title {
color: #1e40af; 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 { 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 { .cx-kyc {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
mat-icon {
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-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: #1e40af;
cursor: pointer;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
&:hover {
background: #eff6ff;
}
&--danger {
color: #b91c1c;
&:hover {
background: #fef2f2;
}
}
}
/* 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;
&__head {
display: flex;
align-items: center;
gap: 8px;
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
color: #475569;
}
}
&__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; display: flex;
align-items: center; align-items: center;
gap: 4px; gap: 4px;
@@ -119,53 +584,73 @@
color: #64748b; color: #64748b;
mat-icon { mat-icon {
font-size: 16px; font-size: 14px;
height: 16px; width: 14px;
width: 16px; height: 14px;
} }
} }
.cx-pan-required { &--required {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
background: #fffbeb; background: #fffbeb;
border: 1px solid #fcd34d;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
color: #92400e;
mat-icon {
font-size: 16px;
height: 16px;
width: 16px;
color: #d97706;
}
}
.cx-field--inline {
flex-direction: row;
align-items: center;
gap: 8px;
span {
white-space: nowrap;
}
input {
width: 180px;
}
}
.cx-field--pan input {
width: 160px;
border-color: #fcd34d; border-color: #fcd34d;
&:focus { .cx-pan__input input {
outline: none; border-color: #f59e0b;
border-color: #d97706; }
.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-spin {
animation: cx-spin 1s linear infinite;
}
@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; 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 { .cx-chip {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -11,7 +11,16 @@ import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; 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 { 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 { interface Denom {
id: number; id: number;
@@ -20,6 +29,47 @@ interface Denom {
label: string; 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). */ /** Cashier cash-exchange entry: customer gives some denominations and takes equal-value change (net 0). */
@Component({ @Component({
selector: 'mifosx-cash-exchange-entry', selector: 'mifosx-cash-exchange-entry',
@@ -30,11 +80,14 @@ interface Denom {
CommonModule, CommonModule,
FormsModule, FormsModule,
MatIconModule, MatIconModule,
MatSnackBarModule MatSnackBarModule,
MatDialogModule
] ]
}) })
export class CashExchangeEntryComponent implements OnInit { export class CashExchangeEntryComponent implements OnInit {
private service = inject(CashierService); private service = inject(CashierService);
private clientsService = inject(ClientsService);
private dialog = inject(MatDialog);
private snackBar = inject(MatSnackBar); private snackBar = inject(MatSnackBar);
notes: Denom[] = []; notes: Denom[] = [];
@@ -46,11 +99,17 @@ export class CashExchangeEntryComponent implements OnInit {
saving = false; saving = false;
// ── Customer info ── // ── Customer info ──
isAccountHolder = false; /** An existing client found through Customer Search, or a walk-in identified at the counter. */
accountNo = ''; customerMode: 'existing' | 'walkin' = 'walkin';
customerIdProof = ''; 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>(); private availableMap = new Map<number, number>();
positionLoaded = false; positionLoaded = false;
@@ -63,7 +122,7 @@ export class CashExchangeEntryComponent implements OnInit {
} }
ngOnInit(): void { 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.exchangeDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.service.getActiveDenominations().subscribe({ this.service.getActiveDenominations().subscribe({
next: (list) => { next: (list) => {
@@ -87,7 +146,19 @@ export class CashExchangeEntryComponent implements OnInit {
((res?.denominations || []) as any[]).forEach((d: any) => { ((res?.denominations || []) as any[]).forEach((d: any) => {
if (d.denominationId) this.availableMap.set(Number(d.denominationId), Number(d.count ?? 0)); 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; this.positionLoaded = true;
if (cashierId) this.loadCashierDetails(cashierId);
}, },
error: () => { error: () => {
this.positionLoaded = true; 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 { countFor(side: 'r' | 'g', d: Denom): number {
return (side === 'r' ? this.receivedCounts : this.givenCounts)[d.id] || 0; 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; return this.receivedTotal > 0 && this.receivedTotal === this.givenTotal;
} }
/** Above ₹50,000 PAN/ID is mandatory per RBI guideline */ /** Above ₹50,000 the customer's PAN is mandatory (Income Tax Rule 114B, followed under RBI guidelines). */
get needsIdProof(): boolean { get needsPan(): boolean {
return this.receivedTotal >= 50000; 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. */ /** Denominations where cashier is giving more than currently available. */
get givenOverdraftItems(): Array<{ label: string; available: number; requested: number }> { 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 return this.denoms
.map((d) => ({ label: d.label, available: this.availableMap.get(d.id) ?? 0, requested: this.countFor('g', d) })) .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); .filter((item) => item.requested > 0 && item.requested > item.available);
@@ -146,21 +330,77 @@ export class CashExchangeEntryComponent implements OnInit {
} }
get valid(): boolean { 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 { setCustomerMode(mode: 'existing' | 'walkin'): void {
this.isAccountHolder = !this.isAccountHolder; if (this.customerMode === mode) return;
if (!this.isAccountHolder) this.accountNo = ''; 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 { clear(): void {
this.receivedCounts = {}; this.receivedCounts = {};
this.givenCounts = {}; this.givenCounts = {};
this.note = ''; this.note = '';
this.accountNo = ''; this.customerMode = 'walkin';
this.customerIdProof = ''; this.selectedCustomer = null;
this.isAccountHolder = false; this.walkInName = '';
this.walkInMobile = '';
this.panNumber = '';
} }
submit(): void { submit(): void {
@@ -170,11 +410,17 @@ export class CashExchangeEntryComponent implements OnInit {
this.denoms this.denoms
.filter((d) => this.countFor(side, d) > 0) .filter((d) => this.countFor(side, d) > 0)
.map((d) => ({ denominationId: d.id, denominationValue: d.value, count: this.countFor(side, d) })); .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 = { const payload: any = {
exchangeDate: this.exchangeDate, exchangeDate: this.exchangeDate,
note: this.note || null, note: this.note || null,
customerIdProof: this.customerIdProof.trim() || null, clientId: customer?.clientId ?? null,
accountNo: this.isAccountHolder && this.accountNo.trim() ? this.accountNo.trim() : 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'), received: build('r'),
given: build('g') given: build('g')
}; };
@@ -11,25 +11,11 @@
<div> <div>
<div class="pg-eyebrow">CASHIER · HISTORY</div> <div class="pg-eyebrow">CASHIER · HISTORY</div>
<h1 class="pg-title">Cash Movement History</h1> <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> </div>
<button type="button" class="pg-btn pg-btn--ghost" (click)="load()"><mat-icon>refresh</mat-icon> Refresh</button> <button type="button" class="pg-btn pg-btn--ghost" (click)="load()"><mat-icon>refresh</mat-icon> Refresh</button>
</header> </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) { @if (loading) {
<div class="pg-empty">Loading…</div> <div class="pg-empty">Loading…</div>
} }
@@ -134,11 +120,6 @@
> >
<mat-icon>visibility</mat-icon> <mat-icon>visibility</mat-icon>
</button> </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> </div>
</td> </td>
</tr> </tr>
@@ -187,6 +168,12 @@
<span class="detail-meta__label">Date</span> <span class="detail-meta__label">Date</span>
<span class="detail-meta__val">{{ detail.row.date }}</span> <span class="detail-meta__val">{{ detail.row.date }}</span>
</div> </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"> <div class="detail-meta__item">
<span class="detail-meta__label">Total Amount</span> <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> <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 buttons cell ── */
.action-btns { .action-btns {
display: flex; display: flex;
@@ -8,9 +8,7 @@
import { Component, OnInit, inject } from '@angular/core'; import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { MatTabsModule } from '@angular/material/tabs'; import { MatTabsModule } from '@angular/material/tabs';
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator'; import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { forkJoin, of } from 'rxjs'; import { forkJoin, of } from 'rxjs';
@@ -31,6 +29,8 @@ interface TxnRow {
reversedBy: number | null; reversedBy: number | null;
reversedAt: string | null; reversedAt: string | null;
reverseReason: string | null; reverseReason: string | null;
/** Customer PAN recorded on a cash exchange. */
panNumber?: string | null;
type: 'transfer' | 'settlement' | 'inter' | 'exchange'; type: 'transfer' | 'settlement' | 'inter' | 'exchange';
} }
@@ -52,7 +52,10 @@ interface DetailView {
loading: boolean; 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({ @Component({
selector: 'mifosx-cash-history', selector: 'mifosx-cash-history',
templateUrl: './cash-history.component.html', templateUrl: './cash-history.component.html',
@@ -63,16 +66,13 @@ interface DetailView {
standalone: true, standalone: true,
imports: [ imports: [
CommonModule, CommonModule,
FormsModule,
MatIconModule, MatIconModule,
MatSnackBarModule,
MatTabsModule, MatTabsModule,
MatPaginatorModule MatPaginatorModule
] ]
}) })
export class CashHistoryComponent implements OnInit { export class CashHistoryComponent implements OnInit {
private service = inject(CashierService); private service = inject(CashierService);
private snackBar = inject(MatSnackBar);
transfers: TxnRow[] = []; transfers: TxnRow[] = [];
settlements: TxnRow[] = []; settlements: TxnRow[] = [];
@@ -99,10 +99,6 @@ export class CashHistoryComponent implements OnInit {
private denomKindMap = new Map<number, 'note' | 'coin'>(); private denomKindMap = new Map<number, 'note' | 'coin'>();
reversingId: number | null = null;
reversingType: string | null = null;
reverseReason = '';
detail: DetailView | null = null; detail: DetailView | null = null;
ngOnInit(): void { ngOnInit(): void {
@@ -212,7 +208,8 @@ export class CashHistoryComponent implements OnInit {
this.exchanges = (exchangeItems as any[]).map((e) => ({ this.exchanges = (exchangeItems as any[]).map((e) => ({
id: e.id, id: e.id,
date: e.exchangeDate, date: e.exchangeDate,
fromParty: e.accountNo ? `Account ${e.accountNo}` : 'Walk-in Customer', fromParty: this.exchangeParty(e),
panNumber: e.panNumber ?? null,
toParty: e.performedByUsername || '—', toParty: e.performedByUsername || '—',
amount: e.exchangeAmount, amount: e.exchangeAmount,
note: e.note, note: e.note,
@@ -310,7 +307,8 @@ export class CashHistoryComponent implements OnInit {
this.exchanges = (res.pageItems ?? []).map((e: any) => ({ this.exchanges = (res.pageItems ?? []).map((e: any) => ({
id: e.id, id: e.id,
date: e.exchangeDate, date: e.exchangeDate,
fromParty: e.accountNo ? `Account ${e.accountNo}` : 'Walk-in Customer', fromParty: this.exchangeParty(e),
panNumber: e.panNumber ?? null,
toParty: e.performedByUsername || '—', toParty: e.performedByUsername || '—',
amount: e.exchangeAmount, amount: e.exchangeAmount,
note: e.note, note: e.note,
@@ -392,39 +390,12 @@ export class CashHistoryComponent implements OnInit {
this.detail = null; this.detail = null;
} }
startReverse(row: TxnRow): void { /** Who the exchange was with: the customer's name (and account, if one), else the account, else a walk-in. */
this.reversingId = row.id; private exchangeParty(e: any): string {
this.reversingType = row.type; if (e.customerName) {
this.reverseReason = ''; return e.accountNo ? `${e.customerName} (A/C ${e.accountNo})` : e.customerName;
} }
return e.accountNo ? `Account ${e.accountNo}` : 'Walk-in Customer';
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 });
}
});
} }
typeLabel(type: string): string { typeLabel(type: string): string {
@@ -18,6 +18,7 @@ import { MatInputModule } from '@angular/material/input';
import { forkJoin, of } from 'rxjs'; import { forkJoin, of } from 'rxjs';
import { catchError } from 'rxjs/operators'; import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service'; import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
type PageState = 'loading' | 'no-cashier' | 'needs-reconciliation' | 'already-settled' | 'form'; type PageState = 'loading' | 'no-cashier' | 'needs-reconciliation' | 'already-settled' | 'form';
@@ -90,7 +91,7 @@ export class CashSettlementComponent implements OnInit {
saving = false; saving = false;
ngOnInit(): void { 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.settlementDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.service this.service
.getTellers() .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> <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> <p class="pg-sub">Move cash denomination-wise from one party to another (e.g. Vault → Cashier for opening).</p>
</div> </div>
<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> <button type="button" class="pg-btn pg-btn--ghost" (click)="clear()"><mat-icon>undo</mat-icon> Clear</button>
</div>
</header> </header>
<div class="pg-toolbar pg-toolbar--mat"> <div class="pg-toolbar pg-toolbar--mat">
@@ -155,7 +163,56 @@
<div class="pg-total-bar"> <div class="pg-total-bar">
Total to transfer: <strong>₹{{ total | number: '1.2-2' }}</strong> Total to transfer: <strong>₹{{ total | number: '1.2-2' }}</strong>
<button type="button" class="pg-btn pg-btn--primary" [disabled]="!valid || saving" (click)="submit()"> <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> </button>
</div> </div>
</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; font-size: 12px;
color: #991b1b; 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/. * 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 { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
@@ -17,6 +18,7 @@ import { MatInputModule } from '@angular/material/input';
import { of } from 'rxjs'; import { of } from 'rxjs';
import { catchError } from 'rxjs/operators'; import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service'; import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
interface Denom { interface Denom {
id: number; id: number;
@@ -38,6 +40,7 @@ interface Denom {
CommonModule, CommonModule,
FormsModule, FormsModule,
MatIconModule, MatIconModule,
MatDialogModule,
MatSnackBarModule, MatSnackBarModule,
MatFormFieldModule, MatFormFieldModule,
MatSelectModule, MatSelectModule,
@@ -47,6 +50,10 @@ interface Denom {
export class CashTransferComponent implements OnInit { export class CashTransferComponent implements OnInit {
private service = inject(CashierService); private service = inject(CashierService);
private snackBar = inject(MatSnackBar); private snackBar = inject(MatSnackBar);
private dialog = inject(MatDialog);
@ViewChild('requestsDialog') requestsDialog!: TemplateRef<unknown>;
private requestsRef: MatDialogRef<unknown> | null = null;
notes: Denom[] = []; notes: Denom[] = [];
coins: Denom[] = []; coins: Denom[] = [];
@@ -81,8 +88,35 @@ export class CashTransferComponent implements OnInit {
vaultHasBreakdown = true; vaultHasBreakdown = true;
loadingVault = false; 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 { 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.transferDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.service.getActiveDenominations().subscribe({ this.service.getActiveDenominations().subscribe({
next: (list) => { next: (list) => {
@@ -244,16 +278,11 @@ export class CashTransferComponent implements OnInit {
.filter((d) => this.countFor(d) > 0) .filter((d) => this.countFor(d) > 0)
.map((d) => ({ denominationId: d.id, denominationValue: d.value, count: this.countFor(d) })) .map((d) => ({ denominationId: d.id, denominationValue: d.value, count: this.countFor(d) }))
}; };
this.service.createCashTransfer(payload).subscribe({ this.service.createCashTransferRequest(payload).subscribe({
next: (res: any) => { next: () => {
if (res?.glWarning) { // Nothing has moved yet: a manager approves the allocation first, and only then does the cash leave the vault.
this.snackBar.open(`Transfer saved (GL warning: ${res.glWarning})`, 'Close', { this.snackBar.open('Cash transfer sent for manager approval', 'Close', { duration: 5000 });
duration: 8000, this.loadMyRequests();
panelClass: ['snack-warn']
});
} else {
this.snackBar.open('Cash transfer recorded', 'Close', { duration: 3000 });
}
this.clear(); this.clear();
this.note = ''; this.note = '';
this.fromParty = ''; this.fromParty = '';
@@ -17,6 +17,7 @@ import { forkJoin, of } from 'rxjs';
import { catchError } from 'rxjs/operators'; import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service'; import { CashierService } from '../cashier.service';
import { AuthenticationService } from '../../core/authentication/authentication.service'; import { AuthenticationService } from '../../core/authentication/authentication.service';
import { businessToday } from 'app/core/utils/business-today';
interface DenomRow { interface DenomRow {
denominationId: number; denominationId: number;
@@ -71,7 +72,7 @@ export class CashierPositionComponent implements OnInit {
isCashierMode = false; isCashierMode = false;
ngOnInit(): void { 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.asOfDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.isCashierMode = this.authService.isCashierUser(); this.isCashierMode = this.authService.isCashierUser();
+21 -6
View File
@@ -23,10 +23,10 @@ import { DayBookComponent } from './day-book/day-book.component';
import { VoucherReportComponent } from './voucher-report/voucher-report.component'; import { VoucherReportComponent } from './voucher-report/voucher-report.component';
import { BranchDayComponent } from './branch-day/branch-day.component'; import { BranchDayComponent } from './branch-day/branch-day.component';
import { VaultPositionComponent } from './vault-position/vault-position.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 { AccountRegisterComponent } from './account-register/account-register.component';
import { ClosingCashReportComponent } from './closing-cash-report/closing-cash-report.component'; import { ClosingCashReportComponent } from './closing-cash-report/closing-cash-report.component';
import { ExcessTransactionReportComponent } from './excess-transaction-report/excess-transaction-report.component'; import { ExcessTransactionReportComponent } from './excess-transaction-report/excess-transaction-report.component';
import { ShareCollectionComponent } from './share-collection/share-collection.component';
const routes: Routes = [ const routes: Routes = [
Route.withShell([ Route.withShell([
@@ -83,6 +83,13 @@ const routes: Routes = [
), ),
data: { title: 'My Share Requests', breadcrumb: 'My Share Requests' } 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', path: 'closure-requests',
loadComponent: () => loadComponent: () =>
@@ -226,6 +233,13 @@ const routes: Routes = [
component: VaultPositionComponent, component: VaultPositionComponent,
data: { title: 'Vault Position', breadcrumb: 'Vault Position' } 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 // 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 // signing the day off actually looks. It carries the Officer Verification Sheet — each
@@ -284,6 +298,12 @@ const routes: Routes = [
component: ReconciliationComponent, component: ReconciliationComponent,
data: { title: 'Reconciliation', breadcrumb: 'Reconciliation' } 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', path: 'cash-history',
component: CashHistoryComponent, component: CashHistoryComponent,
@@ -345,11 +365,6 @@ const routes: Routes = [
component: ExcessTransactionReportComponent, component: ExcessTransactionReportComponent,
data: { title: 'Excess Transaction Report', breadcrumb: 'Excess Transaction Report' } data: { title: 'Excess Transaction Report', breadcrumb: 'Excess Transaction Report' }
}, },
{
path: 'share-collection',
component: ShareCollectionComponent,
data: { title: 'Share Collection', breadcrumb: 'Share Collection' }
},
{ {
path: 'transaction-entry', path: 'transaction-entry',
canActivate: [CashierOnlyGuard], canActivate: [CashierOnlyGuard],
+1 -3
View File
@@ -20,7 +20,6 @@ import { ReconciliationComponent } from './reconciliation/reconciliation.compone
import { CashHistoryComponent } from './cash-history/cash-history.component'; import { CashHistoryComponent } from './cash-history/cash-history.component';
import { ClosingCashReportComponent } from './closing-cash-report/closing-cash-report.component'; import { ClosingCashReportComponent } from './closing-cash-report/closing-cash-report.component';
import { ExcessTransactionReportComponent } from './excess-transaction-report/excess-transaction-report.component'; import { ExcessTransactionReportComponent } from './excess-transaction-report/excess-transaction-report.component';
import { ShareCollectionComponent } from './share-collection/share-collection.component';
@NgModule({ @NgModule({
imports: [ imports: [
@@ -35,8 +34,7 @@ import { ShareCollectionComponent } from './share-collection/share-collection.co
ReconciliationComponent, ReconciliationComponent,
CashHistoryComponent, CashHistoryComponent,
ClosingCashReportComponent, ClosingCashReportComponent,
ExcessTransactionReportComponent, ExcessTransactionReportComponent
ShareCollectionComponent
] ]
}) })
export class CashierModule {} export class CashierModule {}
+81 -30
View File
@@ -32,9 +32,6 @@ export class CashierService {
getCashExchange(id: number): Observable<any> { getCashExchange(id: number): Observable<any> {
return this.http.get(`/cashexchange/${id}`); return this.http.get(`/cashexchange/${id}`);
} }
reverseCashExchange(id: number, reason: string): Observable<any> {
return this.http.post(`/cashexchange/${id}/reverse`, { reason });
}
// ── Tellers / Cashiers ── // ── Tellers / Cashiers ──
getTellers(officeId?: number): Observable<any[]> { getTellers(officeId?: number): Observable<any[]> {
@@ -50,8 +47,21 @@ export class CashierService {
} }
// ── Cash Transfer ── // ── Cash Transfer ──
createCashTransfer(data: any): Observable<any> { /** Cash leaves the vault only when a different person approves, so the screen raises a request, not a transfer. */
return this.http.post('/cashtransfer', data); 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> { getCashTransfers(offset = 0, limit = 20): Observable<any> {
const params = new HttpParams().set('offset', offset).set('limit', limit); const params = new HttpParams().set('offset', offset).set('limit', limit);
@@ -60,9 +70,6 @@ export class CashierService {
getCashTransferById(id: number): Observable<any> { getCashTransferById(id: number): Observable<any> {
return this.http.get(`/cashtransfer/${id}`); return this.http.get(`/cashtransfer/${id}`);
} }
reverseCashTransfer(id: number, reason: string): Observable<any> {
return this.http.post(`/cashtransfer/${id}/reverse`, { reason });
}
// ── Cash Settlement ── // ── Cash Settlement ──
createCashSettlement(data: any): Observable<any> { createCashSettlement(data: any): Observable<any> {
@@ -75,9 +82,6 @@ export class CashierService {
getCashSettlementById(id: number): Observable<any> { getCashSettlementById(id: number): Observable<any> {
return this.http.get(`/cashsettlement/${id}`); return this.http.get(`/cashsettlement/${id}`);
} }
reverseCashSettlement(id: number, reason: string): Observable<any> {
return this.http.post(`/cashsettlement/${id}/reverse`, { reason });
}
// ── Inter-cashier Transfer ── // ── Inter-cashier Transfer ──
createInterCashierTransfer(data: any): Observable<any> { createInterCashierTransfer(data: any): Observable<any> {
@@ -90,8 +94,10 @@ export class CashierService {
getInterCashierById(id: number): Observable<any> { getInterCashierById(id: number): Observable<any> {
return this.http.get(`/cashintercashier/${id}`); return this.http.get(`/cashintercashier/${id}`);
} }
reverseInterCashierTransfer(id: number, reason: string): Observable<any> { /** One cashier's details, today's vault allocation and denomination-wise cash, for either side of a transfer. */
return this.http.post(`/cashintercashier/${id}/reverse`, { reason }); 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 ── // ── Reconciliation ──
@@ -178,6 +184,13 @@ export class CashierService {
return this.http.get('/vouchers/my-work', { params }); return this.http.get('/vouchers/my-work', { params });
} }
/** One voucher and every debit/credit line it is made of — the full slip behind a summarised
* row (a "+2 more" badge in a register only ever shows the first side of each). */
voucherDetail(voucherNo: string): Observable<any> {
const params = new HttpParams().set('voucherNo', voucherNo);
return this.http.get('/vouchers/detail', { params });
}
/** /**
* Account register for one date: what opened and closed that day, each account's balance as it * Account register for one date: what opened and closed that day, each account's balance as it
* stood then, and what it holds now. * stood then, and what it holds now.
@@ -198,6 +211,23 @@ export class CashierService {
return this.http.get('/branchday', { params }); 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. */ /** Days opened and never closed — today's own open day excluded. */
branchDayMissed(officeId?: number): Observable<any> { branchDayMissed(officeId?: number): Observable<any> {
let params = new HttpParams(); let params = new HttpParams();
@@ -294,6 +324,16 @@ export class CashierService {
if (asOfDate) params = params.set('asOfDate', asOfDate); if (asOfDate) params = params.set('asOfDate', asOfDate);
return this.http.get('/cashreports/vault-position', { params }); 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[]> { excessTransactionReport(threshold: number, fromDate?: string, toDate?: string): Observable<any[]> {
let params = new HttpParams().set('threshold', String(threshold)); let params = new HttpParams().set('threshold', String(threshold));
if (fromDate) params = params.set('fromDate', fromDate); if (fromDate) params = params.set('fromDate', fromDate);
@@ -357,19 +397,6 @@ export class CashierService {
return this.http.get<any[]>('/paymenttypes'); return this.http.get<any[]>('/paymenttypes');
} }
// ── Share Collection ──
getShareAccountForCollection(accountId: string): Observable<any> {
return this.http.get(`/accounts/share/${accountId}`);
}
collectSharePayment(data: any): Observable<any> {
return this.http.post('/cashier/sharecollection', data);
}
getShareCollectionStatus(shareAccountId: number): Observable<any> {
return this.http.get(`/cashier/sharecollection/${shareAccountId}`);
}
/** One unapplied Share Sundry receipt, found by the voucher number the member quotes. */ /** One unapplied Share Sundry receipt, found by the voucher number the member quotes. */
getShareAdvanceByVoucher(voucherNo: string, glAccountNo: string, clientId?: number): Observable<any> { getShareAdvanceByVoucher(voucherNo: string, glAccountNo: string, clientId?: number): Observable<any> {
// The voucher travels as a query parameter, not a path segment: numbers are formatted // The voucher travels as a query parameter, not a path segment: numbers are formatted
@@ -396,10 +423,18 @@ export class CashierService {
return this.http.get('/shareadvancecollection/requirement', { params: { shareAccountId: String(shareAccountId) } }); return this.http.get('/shareadvancecollection/requirement', { params: { shareAccountId: String(shareAccountId) } });
} }
/** Receipts still sitting in Share Sundry, optionally for a single member. */ /**
getUnappliedShareAdvances(clientId?: number): Observable<any> { * Everything an Apply screen needs, in one call: `own` is this member's own unapplied receipts,
const suffix = clientId ? `?clientId=${clientId}` : ''; * ready to tick; `rows` is every other Share Collection receipt still available — unclaimed GL
return this.http.get(`/shareadvancecollection${suffix}`); * deposits and receipts linked to another member — which attach by voucher instead.
*
* One request rather than two: both lists come from the same table under the same filters, and
* splitting them across endpoints only meant the same rows were fetched twice and, where they
* overlapped, drawn twice on the same screen.
*/
getShareCollectionReceipts(clientId?: number): Observable<any> {
const params = clientId ? new HttpParams().set('clientId', String(clientId)) : undefined;
return this.http.get('/shareadvancecollection/unattributed', { params });
} }
/** Moves a receipt out of Sundry into share capital for an activated account. */ /** Moves a receipt out of Sundry into share capital for an activated account. */
@@ -407,6 +442,12 @@ export class CashierService {
return this.http.post(`/shareadvancecollection/${collectionId}/apply`, data); return this.http.post(`/shareadvancecollection/${collectionId}/apply`, data);
} }
/** The receipt's own real Dr/Cr GL legs — never the GL account currently picked in a search box,
* which is unrelated screen state that can name a different account than this receipt sits in. */
getShareAdvanceGlEntries(collectionId: number): Observable<any> {
return this.http.get(`/shareadvancecollection/${collectionId}/gl-entries`);
}
/** Every additional-share tranche awaiting approval, across every share account. */ /** Every additional-share tranche awaiting approval, across every share account. */
getShareAdditionRequests(): Observable<any> { getShareAdditionRequests(): Observable<any> {
return this.http.get('/shareadditionrequests'); return this.http.get('/shareadditionrequests');
@@ -419,4 +460,14 @@ export class CashierService {
getShareAdditionRequest(transactionId: number): Observable<any> { getShareAdditionRequest(transactionId: number): Observable<any> {
return this.http.get(`/shareadditionrequests/${transactionId}`); return this.http.get(`/shareadditionrequests/${transactionId}`);
} }
/**
* Entry Type options for a GL credit (General / Share Collection / DD Issue / …) — the
* "GLEntryType" code under Admin > System > Manage Codes, so an admin can add another type later
* without a deployment. Empty on failure rather than erroring, since a row missing this dropdown's
* options should still be usable for every other field.
*/
getEntryTypeOptions(): Observable<any[]> {
return this.http.get<any[]>('/codes/name/GLEntryType/codevalues').pipe(catchError(() => of([])));
}
} }
@@ -51,6 +51,12 @@ export class ClerkShareWithdrawalService {
return this.http.post(`/clerk/share-withdrawal-requests/${id}/validate`, {}); 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> { approve(id: number): Observable<any> {
return this.http.post(`/clerk/share-withdrawal-requests/${id}/approve`, {}); 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 { MatIconModule } from '@angular/material/icon';
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator'; import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { CashierService } from '../cashier.service'; import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
/** Day-wise denomination-wise closing cash collected from deposits. */ /** Day-wise denomination-wise closing cash collected from deposits. */
@Component({ @Component({
@@ -174,19 +175,24 @@ export class ClosingCashReportComponent implements OnInit {
return this.noteRows.reduce((s, r) => s + Number(r.settledOutAmount ?? 0), 0); return this.noteRows.reduce((s, r) => s + Number(r.settledOutAmount ?? 0), 0);
} }
/**
* Summed from each row's own `netCount`, not re-derived from today's legs — a row's `netCount`
* already carries forward what a cashier was holding from before today (`openingCount`), for the
* force-closed-day/late-settlement case. Recomputing here from only today's vaultIn/deposit/
* withdrawal/settledOut dropped that carry-forward and put the Total row back into negative
* numbers a cashier had already reconciled.
*/
get noteNetCount(): number { get noteNetCount(): number {
return this.noteVaultInCount + this.noteDepositCount - this.noteWithdrawalCount - this.noteSettledOutCount; return this.noteRows.reduce((s, r) => s + Number(r.netCount ?? 0), 0);
} }
/** /**
* What is left in the drawer in notes: everything that came in, less everything that went out. * What is left in the drawer in notes: everything that came in, less everything that went out —
* * including cash carried forward from before today. See `noteNetCount` for why this sums each
* Was deposits minus withdrawals, which is the counter's movement rather than the drawer's * row's own `netAmount` rather than re-deriving it from today's legs.
* contents — it left out the cash drawn from the vault, so a teller holding a ₹500 note saw a net
* of zero and had nothing to count against.
*/ */
get noteNetAmount(): number { get noteNetAmount(): number {
return this.noteVaultInAmount + this.noteDepositAmount - this.noteWithdrawalAmount - this.noteSettledOutAmount; return this.noteRows.reduce((s, r) => s + Number(r.netAmount ?? 0), 0);
} }
get coinRows(): any[] { get coinRows(): any[] {
return this.rows.filter((r) => r.kind === 'coin'); return this.rows.filter((r) => r.kind === 'coin');
@@ -199,7 +205,7 @@ export class ClosingCashReportComponent implements OnInit {
} }
ngOnInit(): void { 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.date = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.load(); this.load();
this.service.getTellers().subscribe({ next: (t: any[]) => (this.tellers = t || []), error: () => {} }); this.service.getTellers().subscribe({ next: (t: any[]) => (this.tellers = t || []), error: () => {} });
@@ -202,6 +202,7 @@
<th class="num">Amount</th> <th class="num">Amount</th>
<th>Payment</th> <th>Payment</th>
<th>Status</th> <th>Status</th>
<th>Correction</th>
<th>By</th> <th>By</th>
<th>Approved By</th> <th>Approved By</th>
<th>Time</th> <th>Time</th>
@@ -231,6 +232,18 @@
<td> <td>
<span [class]="statusClass(r.status)">{{ r.status }}</span> <span [class]="statusClass(r.status)">{{ r.status }}</span>
</td> </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.handledBy ?? '—' }}</td>
<td> <td>
{{ r.approvedBy ?? '—' }} {{ r.approvedBy ?? '—' }}
@@ -256,7 +269,7 @@
— —
} }
</td> </td>
<td colspan="5"></td> <td colspan="6"></td>
</tr> </tr>
</tfoot> </tfoot>
</table> </table>
+10 -2
View File
@@ -13,6 +13,8 @@ import { MatIconModule } from '@angular/material/icon';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { catchError, of } from 'rxjs'; import { catchError, of } from 'rxjs';
import { CashierService } from '../cashier.service'; 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. * 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 { interface DayBookRow {
voucherNo: string | null; 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; section: DayBookSection;
side: 'CR' | 'DR'; side: 'CR' | 'DR';
kind: string; kind: string;
@@ -57,7 +64,8 @@ interface DayBookRow {
imports: [ imports: [
CommonModule, CommonModule,
FormsModule, FormsModule,
MatIconModule MatIconModule,
VoucherStatusComponent
] ]
}) })
export class DayBookComponent implements OnInit { 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. */ /** Local date as yyyy-MM-dd — toISOString() would shift the day in any timezone behind UTC. */
private today(): string { 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')}`; return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
} }
@@ -11,41 +11,29 @@
<div> <div>
<div class="pg-eyebrow">CASHIER · INTER-CASHIER TRANSFER</div> <div class="pg-eyebrow">CASHIER · INTER-CASHIER TRANSFER</div>
<h1 class="pg-title">Inter-Cashier Transfer</h1> <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> </div>
<button type="button" class="pg-btn pg-btn--ghost" (click)="clear()"><mat-icon>undo</mat-icon> Clear</button> <button type="button" class="pg-btn pg-btn--ghost" (click)="clear()"><mat-icon>undo</mat-icon> Clear</button>
</header> </header>
<div class="pg-toolbar pg-toolbar--mat"> @if (ownCashierMissing) {
@if (tellers.length === 1) { <div class="ict-alert">
<mat-form-field appearance="outline" subscriptSizing="dynamic"> <mat-icon>person_off</mat-icon>
<mat-label>Teller</mat-label> Your login is not linked to a cashier. An inter-cashier transfer is made by the cashier handing the cash over, so
<input matInput type="text" [value]="tellers[0].name" readonly /> log in as that cashier.
</mat-form-field> </div>
} @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>
} }
<div class="pg-toolbar pg-toolbar--mat">
<mat-form-field appearance="outline" subscriptSizing="dynamic"> <mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>From (Cashier)</mat-label> <mat-label>Teller</mat-label>
<mat-select [value]="fromCashierId" (selectionChange)="onFromSelect($event.value)" [disabled]="!selectedTellerId"> <input matInput type="text" [value]="tellerName || '—'" readonly />
@for (c of availableFrom; track c.id) { </mat-form-field>
<mat-option [value]="c.id">{{ c.label }}</mat-option>
} <!-- The sender is always the cashier who is logged in; nothing to pick. -->
@if (selectedTellerId && allCashiers.length === 0 && !loadingCashiers) { <mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-option [value]="null" disabled>No cashiers</mat-option> <mat-label>From (You)</mat-label>
} <input matInput type="text" [value]="fromParty || '—'" readonly />
</mat-select>
</mat-form-field> </mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic"> <mat-form-field appearance="outline" subscriptSizing="dynamic">
@@ -54,7 +42,7 @@
@for (c of availableTo; track c.id) { @for (c of availableTo; track c.id) {
<mat-option [value]="c.id">{{ c.label }}</mat-option> <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-option [value]="null" disabled>No other cashiers</mat-option>
} }
</mat-select> </mat-select>
@@ -62,7 +50,7 @@
<mat-form-field appearance="outline" subscriptSizing="dynamic"> <mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Date</mat-label> <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>
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="pg-note-field"> <mat-form-field appearance="outline" subscriptSizing="dynamic" class="pg-note-field">
@@ -71,26 +59,102 @@
</mat-form-field> </mat-form-field>
</div> </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"> <div class="pg-card">
<table class="pg-table"> <table class="pg-table">
<thead> <thead>
<tr> <tr>
<th>Denomination</th> <th>Denomination</th>
<th class="num">From holds</th>
<th style="text-align: center">Count</th> <th style="text-align: center">Count</th>
<th class="num">Amount</th> <th class="num">Amount</th>
<th class="num">To holds</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@if (notes.length) { @if (notes.length) {
<tr class="pg-section"> <tr class="pg-section">
<td colspan="3">Bank Notes</td> <td colspan="5">Bank Notes</td>
</tr> </tr>
} }
@for (d of notes; track d.id) { @for (d of notes; track d.id) {
<tr> <tr [class.ict-row--over]="exceedsHeld(d)">
<td> <td>
<span class="pg-chip">{{ d.label }}</span> <span class="pg-chip">{{ d.label }}</span>
</td> </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"> <td style="display: flex; justify-content: center">
<div class="pg-step"> <div class="pg-step">
<button type="button" (click)="bump(d, -1)" [disabled]="countFor(d) === 0">−</button> <button type="button" (click)="bump(d, -1)" [disabled]="countFor(d) === 0">−</button>
@@ -99,18 +163,28 @@
</div> </div>
</td> </td>
<td class="num">₹{{ subtotal(d) | number: '1.2-2' }}</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> </tr>
} }
@if (coins.length) { @if (coins.length) {
<tr class="pg-section"> <tr class="pg-section">
<td colspan="3">Coins</td> <td colspan="5">Coins</td>
</tr> </tr>
} }
@for (d of coins; track d.id) { @for (d of coins; track d.id) {
<tr> <tr [class.ict-row--over]="exceedsHeld(d)">
<td> <td>
<span class="pg-chip pg-chip--coin">{{ d.label }}</span> <span class="pg-chip pg-chip--coin">{{ d.label }}</span>
</td> </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"> <td style="display: flex; justify-content: center">
<div class="pg-step"> <div class="pg-step">
<button type="button" (click)="bump(d, -1)" [disabled]="countFor(d) === 0">−</button> <button type="button" (click)="bump(d, -1)" [disabled]="countFor(d) === 0">−</button>
@@ -119,17 +193,34 @@
</div> </div>
</td> </td>
<td class="num">₹{{ subtotal(d) | number: '1.2-2' }}</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> </tr>
} }
@if (denoms.length === 0) { @if (denoms.length === 0) {
<tr> <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> </tr>
} }
</tbody> </tbody>
</table> </table>
</div> </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"> <div class="pg-total-bar">
Total to transfer: <strong>₹{{ total | number: '1.2-2' }}</strong> Total to transfer: <strong>₹{{ total | number: '1.2-2' }}</strong>
<button type="button" class="pg-btn pg-btn--primary" [disabled]="!valid || saving" (click)="submit()"> <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 { of } from 'rxjs';
import { catchError } from 'rxjs/operators'; import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service'; import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
interface Denom { interface Denom {
id: number; id: number;
@@ -28,12 +29,27 @@ interface Cashier {
id: number; id: number;
label: string; 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. */ /** Move cash denomination-wise between two cashiers under the same teller. */
@Component({ @Component({
selector: 'mifosx-inter-cashier-transfer', selector: 'mifosx-inter-cashier-transfer',
templateUrl: './inter-cashier-transfer.component.html', templateUrl: './inter-cashier-transfer.component.html',
styleUrls: ['../cashier-shared.scss'], styleUrls: [
'../cashier-shared.scss',
'./inter-cashier-transfer.component.scss'
],
standalone: true, standalone: true,
imports: [ imports: [
CommonModule, CommonModule,
@@ -56,7 +72,12 @@ export class InterCashierTransferComponent implements OnInit {
note = ''; note = '';
saving = false; 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[] = []; allCashiers: Cashier[] = [];
selectedTellerId: number | null = null; selectedTellerId: number | null = null;
fromCashierId: number | null = null; fromCashierId: number | null = null;
@@ -65,21 +86,25 @@ export class InterCashierTransferComponent implements OnInit {
toParty = ''; toParty = '';
loadingCashiers = false; 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[] { get denoms(): Denom[] {
return [ return [
...this.notes, ...this.notes,
...this.coins ...this.coins
]; ];
} }
get availableFrom(): Cashier[] { /** Everyone under the same teller except the sender. */
return this.allCashiers;
}
get availableTo(): Cashier[] { get availableTo(): Cashier[] {
return this.allCashiers.filter((c) => c.id !== this.fromCashierId); return this.allCashiers.filter((c) => c.id !== this.fromCashierId);
} }
ngOnInit(): void { 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.transferDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.service.getActiveDenominations().subscribe({ this.service.getActiveDenominations().subscribe({
next: (list) => { next: (list) => {
@@ -97,25 +122,20 @@ export class InterCashierTransferComponent implements OnInit {
this.coins = []; this.coins = [];
} }
}); });
this.service // The sender is the cashier behind this login; their counter then gives the receivers to pick from.
.getTellers() this.service.getCurrentCashierInfo().subscribe((info) => {
.pipe(catchError(() => of([] as any[]))) if (!info) {
.subscribe((list) => { this.ownCashierMissing = true;
this.tellers = (list || []).map((t: any) => ({ id: t.id, name: t.name })); return;
if (this.tellers.length === 1) {
this.onTellerSelect(this.tellers[0].id);
} }
this.fromCashierId = info.cashierId;
this.fromParty = info.cashierName;
this.loadSummary('from');
}); });
} }
onTellerSelect(tellerId: number): void { /** The other cashiers at this counter, one of whom receives the cash. */
this.selectedTellerId = tellerId; private loadReceivers(tellerId: number): void {
this.fromCashierId = null;
this.toCashierId = null;
this.fromParty = '';
this.toParty = '';
this.allCashiers = [];
if (!tellerId) return;
this.loadingCashiers = true; this.loadingCashiers = true;
this.service this.service
.getCashiersForTeller(tellerId) .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 { onToSelect(cashierId: number): void {
this.toCashierId = cashierId; this.toCashierId = cashierId;
const c = this.allCashiers.find((x) => x.id === cashierId); const c = this.allCashiers.find((x) => x.id === cashierId);
this.toParty = c ? c.label : ''; 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 { countFor(d: Denom): number {
@@ -159,7 +249,14 @@ export class InterCashierTransferComponent implements OnInit {
return this.denoms.reduce((s, d) => s + this.subtotal(d), 0); return this.denoms.reduce((s, d) => s + this.subtotal(d), 0);
} }
get valid(): boolean { 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 { clear(): void {
@@ -186,10 +283,12 @@ export class InterCashierTransferComponent implements OnInit {
this.snackBar.open('Inter-cashier transfer recorded', 'Close', { duration: 3000 }); this.snackBar.open('Inter-cashier transfer recorded', 'Close', { duration: 3000 });
this.clear(); this.clear();
this.note = ''; 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.toCashierId = null;
this.fromParty = '';
this.toParty = ''; this.toParty = '';
this.toSummary = null;
this.loadSummary('from');
this.saving = false; this.saving = false;
}, },
error: () => { error: () => {
+29 -4
View File
@@ -146,15 +146,28 @@
<th class="num">Amount</th> <th class="num">Amount</th>
<th>My part</th> <th>My part</th>
<th>Status</th> <th>Status</th>
<th>Correction</th>
<th>Time</th> <th>Time</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@for (r of g.rows; track r.voucherNo) { @for (r of g.rows; track r.voucherNo) {
<!-- The left edge repeats the section's own colour, so a row still says which part it <!-- The left edge repeats the section's own colour, so a row still says which part it
is when the heading has scrolled away. --> is when the heading has scrolled away. Clicking anywhere on the row opens the full
<tr [class]="'mw-row mw-row--' + r.myRole"> slip -- every line, not just the first debit and first credit this table has room
<td class="mw-vno">{{ r.voucherNo }}</td> to show. -->
<tr
[class]="'mw-row mw-row--' + r.myRole"
class="mw-row--clickable"
(click)="openDetail(r.voucherNo)"
[attr.aria-busy]="loadingDetailFor === r.voucherNo"
>
<td class="mw-vno">
{{ r.voucherNo }}
@if (loadingDetailFor === r.voucherNo) {
<mat-icon class="mw-vno__spinner" inline>hourglass_empty</mat-icon>
}
</td>
<td> <td>
<span class="mw-badge" [class]="'mw-badge--' + (r.side || 'JV')">{{ sideLabel(r.side) }}</span> <span class="mw-badge" [class]="'mw-badge--' + (r.side || 'JV')">{{ sideLabel(r.side) }}</span>
</td> </td>
@@ -171,7 +184,7 @@
<span class="mw-acct">{{ r.creditAccount }}</span> <span class="mw-acct">{{ r.creditAccount }}</span>
} }
@if (r.extraLines > 0) { @if (r.extraLines > 0) {
<span class="mw-more">+{{ r.extraLines }} more</span> <span class="mw-more" title="Click the row to see every line">+{{ r.extraLines }} more</span>
} }
</td> </td>
<td class="num">₹{{ r.amount | number: '1.2-2' }}</td> <td class="num">₹{{ r.amount | number: '1.2-2' }}</td>
@@ -191,6 +204,18 @@
{{ statusLabel(r.verificationStatus) }} {{ statusLabel(r.verificationStatus) }}
</span> </span>
</td> </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> <td class="mw-time">{{ r.createdAt | date: 'HH:mm' }}</td>
</tr> </tr>
} }
@@ -178,6 +178,21 @@
} }
} }
.mw-row--clickable {
cursor: pointer;
&:hover {
background: #f8fafc;
}
}
.mw-vno__spinner {
margin-left: 4px;
font-size: 14px;
vertical-align: middle;
color: #94a3b8;
}
/* One section per part played. The heading carries the section's own count and total, so a maker's /* One section per part played. The heading carries the section's own count and total, so a maker's
tally and a checker's are each readable without adding rows up. */ tally and a checker's are each readable without adding rows up. */
.mw-group { .mw-group {
+44 -3
View File
@@ -10,8 +10,12 @@ import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
import { catchError, of } from 'rxjs'; import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { catchError, finalize, of } from 'rxjs';
import { CashierService } from '../cashier.service'; 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. */ /** What the signed-in user's part in a voucher was. */
export type MyRole = 'RAISED' | 'VERIFIED' | 'RAISED_AND_VERIFIED'; export type MyRole = 'RAISED' | 'VERIFIED' | 'RAISED_AND_VERIFIED';
@@ -21,6 +25,11 @@ export type VoucherSide = 'CR' | 'DR' | 'JV';
interface MyWorkRow { interface MyWorkRow {
voucherNo: string; 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; side: VoucherSide | null;
voucherType: string | null; voucherType: string | null;
amount: number | null; amount: number | null;
@@ -72,11 +81,18 @@ interface MyWorkReport {
imports: [ imports: [
CommonModule, CommonModule,
FormsModule, FormsModule,
MatIconModule MatIconModule,
MatDialogModule,
VoucherStatusComponent
] ]
}) })
export class MyWorkComponent implements OnInit { export class MyWorkComponent implements OnInit {
private service = inject(CashierService); private service = inject(CashierService);
private dialog = inject(MatDialog);
/** Which voucher's detail is currently being fetched, so the row that was clicked can show its
* own spinner instead of a page-wide one for what is a small, single-row lookup. */
loadingDetailFor: string | null = null;
/** Day is the working default; a month is what a close or an audit asks for. */ /** Day is the working default; a month is what a close or an audit asks for. */
period: 'DAY' | 'MONTH' = 'DAY'; period: 'DAY' | 'MONTH' = 'DAY';
@@ -323,8 +339,33 @@ export class MyWorkComponent implements OnInit {
return { from: first, to: lastIso }; return { from: first, to: lastIso };
} }
/** Opens the full slip for one voucher — every debit and credit line, not just the first side of
* each a table row has room to show. */
openDetail(voucherNo: string): void {
if (this.loadingDetailFor) {
return;
}
this.loadingDetailFor = voucherNo;
this.service
.voucherDetail(voucherNo)
.pipe(
catchError(() => of(null)),
finalize(() => (this.loadingDetailFor = null))
)
.subscribe((detail: any) => {
if (!detail) {
return;
}
this.dialog.open(VoucherDetailDialogComponent, {
data: { detail },
width: '600px',
autoFocus: false
});
});
}
private todayIso(): string { 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')}`; return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
} }
} }
@@ -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/.
*/
import { Component, inject } from '@angular/core';
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
* only renders, so one loading state lives in the row's own click handler rather than being
* 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;
createdBy: string | null;
createdAt: string | null;
totalDebit: number;
totalCredit: number;
balanced: boolean;
lines: {
lineNo: number;
entryType: 'DR' | 'CR';
partyName: string | null;
accountNo: string | null;
amount: number;
description: string | null;
}[];
/** GL entries a native platform command posted for the same underlying event, outside this
* voucher entirely — a share redemption's Dr Share Capital / Cr Share Sundry pair, posted at
* manager approval, before this voucher's own Dr Sundry / Cr Cash at Vault settlement leg even
* exists. Empty for every voucher that already is the whole transaction on its own. */
relatedLines: {
stage: string;
entryType: 'DR' | 'CR';
partyName: string | null;
accountNo: string | null;
amount: number;
description: string | null;
}[];
};
}
/** The full slip behind a summarised register row — every debit and credit line a voucher is
* made of, not just the first side of each that a table row has room for. */
@Component({
selector: 'mifosx-voucher-detail-dialog',
standalone: true,
imports: [
CommonModule,
MatDialogModule,
MatButtonModule,
MatIconModule,
VoucherStatusComponent
],
template: `
<div class="vd-shell">
<div class="vd-header">
<div class="vd-header__title">
<mat-icon class="vd-icon">receipt_long</mat-icon>
<div>
<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>
<button mat-icon-button mat-dialog-close aria-label="Close">
<mat-icon>close</mat-icon>
</button>
</div>
<div class="vd-meta">
<span
>Raised by <strong>{{ detail.createdBy || '—' }}</strong></span
>
@if (detail.createdAt) {
<span>{{ detail.createdAt | date: 'dd MMM yyyy, h:mm a' }}</span>
}
@if (!detail.balanced) {
<span class="vd-unbalanced">
<mat-icon inline>warning</mat-icon>
Debit and credit do not balance
</span>
}
</div>
<table class="vd-table">
<thead>
<tr>
<th>Side</th>
<th>Party</th>
<th>Account No.</th>
<th class="num">Amount</th>
</tr>
</thead>
<tbody>
@for (line of detail.lines; track line.lineNo) {
<tr>
<td>
<span
class="vd-side"
[class.vd-side--dr]="line.entryType === 'DR'"
[class.vd-side--cr]="line.entryType === 'CR'"
>
{{ line.entryType === 'DR' ? 'Debit' : 'Credit' }}
</span>
</td>
<td>{{ line.partyName || '—' }}</td>
<td class="vd-acct">{{ line.accountNo || '—' }}</td>
<td class="num">₹{{ line.amount | number: '1.2-2' }}</td>
</tr>
@if (line.description) {
<tr class="vd-desc-row">
<td></td>
<td colspan="3" class="vd-desc">{{ line.description }}</td>
</tr>
}
} @empty {
<tr>
<td colspan="4" class="vd-empty">This voucher's individual lines were not recorded.</td>
</tr>
}
</tbody>
@if (detail.lines.length > 0) {
<tfoot>
<tr>
<td colspan="3">Total</td>
<td class="num">
₹{{ detail.totalDebit | number: '1.2-2' }} / ₹{{ detail.totalCredit | number: '1.2-2' }}
</td>
</tr>
</tfoot>
}
</table>
@if (detail.relatedLines.length > 0) {
<div class="vd-related">
<div class="vd-related__title">
<mat-icon inline>link</mat-icon>
Also posted for this transaction — not part of this voucher
</div>
<table class="vd-table vd-table--related">
<tbody>
@for (line of detail.relatedLines; track $index) {
<tr>
<td>
<span
class="vd-side"
[class.vd-side--dr]="line.entryType === 'DR'"
[class.vd-side--cr]="line.entryType === 'CR'"
>
{{ line.entryType === 'DR' ? 'Debit' : 'Credit' }}
</span>
</td>
<td>{{ line.partyName || '—' }}</td>
<td class="vd-acct">{{ line.accountNo || '—' }}</td>
<td class="num">₹{{ line.amount | number: '1.2-2' }}</td>
</tr>
}
</tbody>
</table>
<p class="vd-related__hint">
{{ detail.relatedLines[0].stage }} — this and the lines above are the same transaction's full accounting.
</p>
</div>
}
<div class="vd-actions">
<button mat-flat-button color="primary" mat-dialog-close>Close</button>
</div>
</div>
`,
styles: [
`
.vd-shell {
display: flex;
flex-direction: column;
gap: 16px;
padding: 20px 24px 16px;
min-width: 420px;
max-width: 560px;
}
.vd-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
}
.vd-header__title {
display: flex;
align-items: center;
gap: 12px;
}
.vd-header__title h2 {
margin: 0;
font-size: 18px;
font-weight: 600;
}
.vd-header__title p {
margin: 2px 0 0;
font-size: 13px;
color: rgba(0, 0, 0, 0.6);
}
.vd-icon {
color: #1976d2;
}
.vd-meta {
display: flex;
flex-wrap: wrap;
gap: 6px 16px;
font-size: 13px;
color: rgba(0, 0, 0, 0.65);
}
.vd-unbalanced {
display: inline-flex;
align-items: center;
gap: 4px;
color: #b91c1c;
font-weight: 600;
}
.vd-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.vd-table th {
text-align: left;
text-transform: uppercase;
font-size: 11px;
letter-spacing: 0.03em;
color: rgba(0, 0, 0, 0.5);
padding: 6px 8px;
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
}
.vd-table td {
padding: 8px;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
.vd-table .num {
text-align: right;
font-variant-numeric: tabular-nums;
}
.vd-acct {
color: rgba(0, 0, 0, 0.55);
}
.vd-side {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
}
.vd-side--dr {
background: #fee2e2;
color: #b91c1c;
}
.vd-side--cr {
background: #dcfce7;
color: #15803d;
}
.vd-desc-row td {
padding-top: 0;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
.vd-desc {
font-size: 12px;
color: rgba(0, 0, 0, 0.5);
font-style: italic;
}
.vd-empty {
text-align: center;
color: rgba(0, 0, 0, 0.5);
padding: 16px 8px;
}
.vd-table tfoot td {
font-weight: 600;
border-bottom: none;
border-top: 1px solid rgba(0, 0, 0, 0.1);
}
.vd-related {
border-top: 1px dashed rgba(0, 0, 0, 0.15);
padding-top: 12px;
}
.vd-related__title {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
color: #6d28d9;
margin-bottom: 8px;
}
.vd-table--related td {
background: #faf5ff;
}
.vd-related__hint {
margin: 6px 0 0;
font-size: 12px;
color: rgba(0, 0, 0, 0.5);
}
.vd-actions {
display: flex;
justify-content: flex-end;
}
`
]
})
export class VoucherDetailDialogComponent {
dialogRef = inject(MatDialogRef<VoucherDetailDialogComponent>);
private data = inject<VoucherDetailDialogData>(MAT_DIALOG_DATA);
detail = this.data.detail;
}
@@ -19,34 +19,21 @@
<!-- ── Entry Tab ── --> <!-- ── Entry Tab ── -->
<mat-tab label="New Reconciliation"> <mat-tab label="New Reconciliation">
<div class="pg-tab-body"> <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"> <div class="pg-toolbar pg-toolbar--mat">
@if (tellers.length === 1) { <!-- A reconciliation is the cashier's own count of their own drawer: nothing to pick. -->
<mat-form-field appearance="outline" subscriptSizing="dynamic"> <mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Teller</mat-label> <mat-label>Teller</mat-label>
<input matInput type="text" [value]="tellers[0].name" readonly /> <input matInput type="text" [value]="ownTellerName || '—'" readonly />
</mat-form-field> </mat-form-field>
} @else {
<mat-form-field appearance="outline" subscriptSizing="dynamic"> <mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Teller</mat-label> <mat-label>Cashier (You)</mat-label>
<mat-select [value]="selectedTellerId" (selectionChange)="onTellerSelect($event.value)"> <input matInput type="text" [value]="cashierName || '—'" readonly />
@for (t of tellers; track t.id) {
<mat-option [value]="t.id">{{ t.name }}</mat-option>
}
</mat-select>
</mat-form-field>
}
<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-form-field> </mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic"> <mat-form-field appearance="outline" subscriptSizing="dynamic">
@@ -12,12 +12,12 @@ import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
import { MatSelectModule } from '@angular/material/select';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
import { MatTabsModule } from '@angular/material/tabs'; import { MatTabsModule } from '@angular/material/tabs';
import { forkJoin, of } from 'rxjs'; import { forkJoin, of } from 'rxjs';
import { catchError } from 'rxjs/operators'; import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service'; import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
interface DenomRow { interface DenomRow {
denominationId: number; denominationId: number;
@@ -57,7 +57,6 @@ interface ReconRecord {
MatIconModule, MatIconModule,
MatSnackBarModule, MatSnackBarModule,
MatFormFieldModule, MatFormFieldModule,
MatSelectModule,
MatInputModule, MatInputModule,
MatTabsModule MatTabsModule
] ]
@@ -66,9 +65,14 @@ export class ReconciliationComponent implements OnInit {
private service = inject(CashierService); private service = inject(CashierService);
private snackBar = inject(MatSnackBar); 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 ── // ── Entry form ──
tellers: { id: number; name: string }[] = [];
cashiers: { id: number; label: string }[] = [];
selectedTellerId: number | null = null; selectedTellerId: number | null = null;
selectedCashierId: number | null = null; selectedCashierId: number | null = null;
cashierName = ''; cashierName = '';
@@ -85,44 +89,30 @@ export class ReconciliationComponent implements OnInit {
loadingHistory = false; loadingHistory = false;
ngOnInit(): void { 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.reconDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.service this.lockToOwnCashier();
.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.loadHistory(); this.loadHistory();
} }
onTellerSelect(tellerId: number): void { /** Take the logged-in cashier as the one being reconciled, with their counter for the record. */
this.selectedTellerId = tellerId; private lockToOwnCashier(): void {
this.selectedCashierId = null; this.service.getCurrentCashierInfo().subscribe((info) => {
this.cashierName = ''; if (!info) {
this.cashiers = []; this.ownCashierMissing = true;
this.rows = []; return;
if (!tellerId) return; }
this.selectedCashierId = info.cashierId;
this.cashierName = info.cashierName;
this.service this.service
.getCashiersForTeller(tellerId) .getInterCashierCashierSummary(info.cashierId, this.reconDate)
.pipe(catchError(() => of({ cashiers: [] }))) .pipe(catchError(() => of(null)))
.subscribe((r: any) => { .subscribe((r: any) => {
this.cashiers = (r?.cashiers || []).map((c: any) => ({ id: c.id, label: c.staffName })); this.selectedTellerId = r?.tellerId ?? null;
if (this.cashiers.length === 1) { this.ownTellerName = r?.tellerName || '';
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(); this.fetchExpected();
});
} }
fetchExpected(): void { fetchExpected(): void {
@@ -1,375 +0,0 @@
<!--
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 ── -->
<header class="pg-head">
<div>
<div class="pg-eyebrow">CASHIER · SHARE COLLECTION</div>
<h1 class="pg-title">Share Collection</h1>
<p class="pg-sub">
Collect cash from member for share purchase — amounts are held in Sundry GL until the account is activated.
</p>
</div>
</header>
<!-- ── SEARCH BAR ── -->
@if (pageState !== 'success') {
<div class="sc-search-bar">
<div class="sc-search-bar__input-wrap">
<mat-icon class="sc-search-bar__icon">search</mat-icon>
<input
class="sc-search-bar__input"
type="text"
placeholder="Enter Share Account ID or Account No."
[(ngModel)]="accountInput"
(keyup.enter)="searchAccount()"
/>
</div>
<button
type="button"
class="pg-btn pg-btn--primary"
[disabled]="pageState === 'searching' || !accountInput.trim()"
(click)="searchAccount()"
>
<mat-icon>manage_search</mat-icon>
{{ pageState === 'searching' ? 'Searching…' : 'Search' }}
</button>
</div>
}
<!-- ── ERROR ── -->
@if (errorMsg) {
<div class="sc-alert sc-alert--error">
<mat-icon>error_outline</mat-icon>
<span>{{ errorMsg }}</span>
</div>
}
<!-- ── SEARCHING SPINNER ── -->
@if (pageState === 'searching') {
<div class="pg-empty">
<mat-icon class="sc-spin">hourglass_empty</mat-icon>
<p>Looking up account…</p>
</div>
}
<!-- ── ACCOUNT LOADED ── -->
@if (pageState === 'loaded') {
<!-- Member Card -->
<div class="sc-member-card">
<div class="sc-member-card__avatar">
<mat-icon>person</mat-icon>
</div>
<div class="sc-member-card__info">
<div class="sc-member-card__name">{{ account.clientName }}</div>
<div class="sc-member-card__meta">
<span class="sc-badge sc-badge--account">{{ account.accountNo }}</span>
<span class="sc-badge sc-badge--product">{{ account.productName }}</span>
<span
class="sc-badge"
[class.sc-badge--pending]="account.status?.code?.includes('pending')"
[class.sc-badge--active]="account.status?.code?.includes('active')"
>{{ account.status?.value }}</span
>
</div>
</div>
<!-- Editable Shares + Price -->
<div class="sc-member-card__shares">
<div class="sc-shares-inputs">
<div class="sc-shares-field">
<label class="sc-shares-label">No. of Shares</label>
<input
class="sc-shares-input"
type="number"
min="0"
[(ngModel)]="requestedSharesInput"
(input)="recalc()"
placeholder="0"
/>
</div>
<div class="sc-shares-sep">×</div>
<div class="sc-shares-field">
<label class="sc-shares-label">Price / Share (₹)</label>
<input
class="sc-shares-input"
type="number"
min="0"
[(ngModel)]="pricePerShare"
(input)="recalc()"
placeholder="0.00"
/>
</div>
<div class="sc-shares-sep">=</div>
<div class="sc-shares-result">
<div class="sc-shares-label">Share Amount</div>
<div class="sc-shares-total">{{ currencySymbol }}{{ shareAmount | number: '1.2-2' }}</div>
</div>
</div>
</div>
</div>
<!-- Amount Breakdown + GL Preview -->
<div class="sc-split">
<!-- LEFT: Amount Breakdown -->
<div class="pg-card sc-breakdown">
<div class="sc-breakdown__header">
<mat-icon>receipt_long</mat-icon>
<span>Amount Breakdown</span>
</div>
<table class="pg-table">
<thead>
<tr>
<th>Description</th>
<th class="num">Amount</th>
</tr>
</thead>
<tbody>
<tr>
<td>
Share Amount
<span class="sc-dim">
({{ requestedSharesInput }} shares × {{ currencySymbol }}{{ pricePerShare | number: '1.2-2' }})
</span>
</td>
<td class="num">{{ currencySymbol }}{{ shareAmount | number: '1.2-2' }}</td>
</tr>
@for (charge of charges; track charge.id) {
<tr>
<td>{{ charge.name }}</td>
<td class="num sc-charge-amount">
<input class="sc-charge-input" type="number" min="0" [(ngModel)]="charge.amount" (input)="recalc()" />
</td>
</tr>
}
@if (charges.length === 0) {
<tr>
<td colspan="2">
<span class="sc-dim">No charges applicable</span>
</td>
</tr>
}
</tbody>
<tfoot>
<tr class="sc-total-row">
<td><strong>Total Payable</strong></td>
<td class="num sc-total-amount">
<strong>{{ currencySymbol }}{{ totalAmount | number: '1.2-2' }}</strong>
</td>
</tr>
</tfoot>
</table>
</div>
<!-- RIGHT: GL Preview -->
<div class="pg-card sc-gl-preview">
<div class="sc-breakdown__header">
<mat-icon>account_tree</mat-icon>
<span>GL Entries Preview</span>
</div>
<table class="pg-table">
<thead>
<tr>
<th>Account</th>
<th>Dr / Cr</th>
<th class="num">Amount</th>
</tr>
</thead>
<tbody>
<tr class="gl-row--dr">
<td class="gl-acname">Cash Vault GL</td>
<td><span class="sc-dr-badge">Dr</span></td>
<td class="num gl-amt--dr">{{ currencySymbol }}{{ totalAmount | number: '1.2-2' }}</td>
</tr>
<tr class="gl-row--cr">
<td class="gl-acname">Share Sundry GL</td>
<td><span class="sc-cr-badge">Cr</span></td>
<td class="num gl-amt--cr">{{ currencySymbol }}{{ shareAmount | number: '1.2-2' }}</td>
</tr>
@if (totalChargeAmount > 0) {
<tr class="gl-row--cr">
<td class="gl-acname">Charge Receivable GL</td>
<td><span class="sc-cr-badge">Cr</span></td>
<td class="num gl-amt--cr">{{ currencySymbol }}{{ totalChargeAmount | number: '1.2-2' }}</td>
</tr>
}
</tbody>
</table>
<div class="sc-gl-note">
<mat-icon>info_outline</mat-icon>
Funds move to Share GL &amp; Income GL when account is <strong>activated</strong>.
</div>
</div>
</div>
<!-- ── DENOMINATION (Cash Only) ── -->
@if (isCashPayment && allDenominations.length > 0) {
<div class="pg-card sc-denom">
<div class="sc-breakdown__header">
<mat-icon>payments</mat-icon>
<span>Denomination Entry</span>
@if (denominationTotal > 0) {
<span
class="sc-denom-badge"
[class.sc-denom-badge--match]="denomMatchesTotal"
[class.sc-denom-badge--mismatch]="!denomMatchesTotal"
>
₹{{ denominationTotal | number: '1.2-2' }}
{{ denomMatchesTotal ? '✓ Matches' : '≠ Mismatch' }}
</span>
}
@if (denominationTotal === 0) {
<span class="sc-denom-required">⚠ Required for Cash</span>
}
</div>
<div class="sc-denom-body">
<table class="pg-table sc-denom-table">
<thead>
<tr>
<th>Denomination</th>
<th class="num">Count</th>
<th class="num">Amount</th>
</tr>
</thead>
<tbody>
@for (d of allDenominations; track d.id) {
<tr>
<td>
<span class="sc-denom-chip" [class.sc-denom-chip--coin]="d.kind === 'coin'">{{ d.label }}</span>
</td>
<td class="num">
<div class="sc-denom-stepper">
<button type="button" (click)="bumpDenom(d, -1)" [disabled]="countFor(d) === 0">−</button>
<input
type="number"
min="0"
[value]="countFor(d) || ''"
placeholder="0"
(input)="onDenomChange(d, $any($event.target).value)"
/>
<button type="button" (click)="bumpDenom(d, 1)">+</button>
</div>
</td>
<td class="num">{{ subtotalFor(d) > 0 ? (subtotalFor(d) | number: '1.2-2') : '—' }}</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td><strong>Total</strong></td>
<td></td>
<td class="num">
<strong>₹{{ denominationTotal | number: '1.2-2' }}</strong>
</td>
</tr>
</tfoot>
</table>
@if (!denomMatchesTotal && denominationTotal > 0) {
<div class="sc-denom-mismatch">
<mat-icon>warning</mat-icon>
Denomination ₹{{ denominationTotal | number: '1.2-2' }} must equal Total Payable ₹{{
totalAmount | number: '1.2-2'
}}.
</div>
}
@if (denominationTotal > 0) {
<div class="sc-denom-actions">
<button type="button" class="pg-btn pg-btn--ghost" (click)="clearDenominations()">
<mat-icon>clear</mat-icon> Clear
</button>
</div>
}
</div>
</div>
}
<!-- Transaction Details -->
<div class="pg-toolbar pg-toolbar--mat">
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Transaction Date *</mat-label>
<input matInput type="date" [(ngModel)]="transactionDate" />
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Payment Mode</mat-label>
<mat-select [(ngModel)]="paymentTypeId">
<mat-option [value]="null">— Cash (Default) —</mat-option>
@for (pt of paymentTypes; track pt.id) {
<mat-option [value]="pt.id">{{ pt.name }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="pg-note-field">
<mat-label>Note (Optional)</mat-label>
<input matInput type="text" [(ngModel)]="note" placeholder="e.g. Share purchase for 100 shares" />
</mat-form-field>
</div>
<!-- Footer Submit Bar -->
<div class="pg-total-bar sc-footer">
<div class="sc-footer__summary">
<span class="sc-footer__label">Total to Collect</span>
<span class="sc-footer__amount">{{ currencySymbol }}{{ totalAmount | number: '1.2-2' }}</span>
</div>
<div class="sc-footer__actions">
<button type="button" class="pg-btn pg-btn--ghost" (click)="reset()"><mat-icon>close</mat-icon> Cancel</button>
<button type="button" class="pg-btn pg-btn--primary" [disabled]="!isValid || saving" (click)="submit()">
<mat-icon>payments</mat-icon>
{{ saving ? 'Posting…' : 'Collect & Post' }}
</button>
</div>
</div>
}
<!-- ── SUCCESS ── -->
@if (pageState === 'success' && collectionResult) {
<div class="sc-success">
<div class="sc-success__icon-wrap">
<mat-icon>check_circle</mat-icon>
</div>
<h2 class="sc-success__title">Collection Posted Successfully</h2>
<p class="sc-success__sub">
Cash of
<strong>{{ collectionResult.currencyCode }} {{ collectionResult.totalAmount | number: '1.2-2' }}</strong>
collected from <strong>{{ collectionResult.clientName }}</strong> on
<strong>{{ collectionResult.collectionDate }}</strong> by <strong>{{ collectionResult.collectedBy }}</strong
>.
</p>
<!-- Transaction reference -->
<div class="sc-success__ref">
<span class="sc-success__ref-label">Transaction ID</span>
<span class="sc-success__ref-val">{{ collectionResult.transactionId }}</span>
</div>
<!-- GL entries from backend -->
<div class="sc-success__gl">
@for (entry of collectionResult.glEntries; track entry.account) {
<div
class="sc-success__gl-row"
[class.sc-success__gl-row--dr]="entry.type === 'Dr'"
[class.sc-success__gl-row--cr]="entry.type === 'Cr'"
>
<span>{{ entry.type }}. {{ entry.account }}</span>
<span>{{ entry.currency }} {{ entry.amount | number: '1.2-2' }}</span>
</div>
}
</div>
<p class="sc-success__note">
Funds are held in Sundry GL. They will move to Share Equity GL when the account is activated.
</p>
<button type="button" class="pg-btn pg-btn--primary" (click)="reset()">
<mat-icon>add</mat-icon> New Collection
</button>
</div>
}
</div>
@@ -1,701 +0,0 @@
/**
* 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/.
*/
// ── Search Bar ───────────────────────────────────────────────────────────────
.sc-search-bar {
display: flex;
align-items: center;
gap: 10px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 10px 14px;
&__input-wrap {
display: flex;
align-items: center;
gap: 8px;
flex: 1 1 auto;
}
&__icon {
color: #94a3b8;
font-size: 20px;
width: 20px;
height: 20px;
flex-shrink: 0;
}
&__input {
flex: 1 1 auto;
border: none;
outline: none;
font-size: 14px;
font-family: inherit;
color: #0d1b2a;
background: transparent;
&::placeholder {
color: #94a3b8;
}
}
}
// ── Alert ────────────────────────────────────────────────────────────────────
.sc-alert {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
border-radius: 10px;
font-size: 13px;
font-weight: 500;
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
flex-shrink: 0;
}
&--error {
background: #fef2f2;
border: 1px solid #fecaca;
color: #dc2626;
}
}
// ── Spinner ──────────────────────────────────────────────────────────────────
.sc-spin {
animation: sc-spin 1.2s linear infinite;
display: block;
margin: 0 auto 8px;
font-size: 32px !important;
width: 32px !important;
height: 32px !important;
color: #94a3b8;
}
@keyframes sc-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
// ── Member Card ──────────────────────────────────────────────────────────────
.sc-member-card {
display: flex;
align-items: center;
gap: 16px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 16px 20px;
&__avatar {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border-radius: 50%;
background: #eff6ff;
flex-shrink: 0;
mat-icon {
color: #2563eb;
font-size: 24px;
width: 24px;
height: 24px;
}
}
&__info {
flex: 1 1 auto;
min-width: 0;
}
&__name {
font-size: 16px;
font-weight: 700;
color: #1e293b;
margin-bottom: 6px;
}
&__meta {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
&__shares {
flex-shrink: 0;
display: flex;
align-items: center;
}
}
// ── Badges ───────────────────────────────────────────────────────────────────
.sc-badge {
display: inline-flex;
align-items: center;
height: 22px;
padding: 0 8px;
border-radius: 5px;
font-size: 11px;
font-weight: 700;
background: #f1f5f9;
color: #475569;
border: 1px solid #e2e8f0;
&--account {
background: #eff6ff;
color: #1d4ed8;
border-color: #bfdbfe;
font-family: 'Roboto Mono', monospace;
}
&--product {
background: #f0fdf4;
color: #15803d;
border-color: #bbf7d0;
}
&--pending {
background: #fffbeb;
color: #d97706;
border-color: #fde68a;
}
&--active {
background: #f0fdf4;
color: #15803d;
border-color: #bbf7d0;
}
}
// ── Editable Share Inputs ────────────────────────────────────────────────────
.sc-shares-inputs {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
justify-content: flex-end;
}
.sc-shares-field {
display: flex;
flex-direction: column;
gap: 3px;
}
.sc-shares-label {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.07em;
color: #94a3b8;
}
.sc-shares-input {
width: 110px;
height: 38px;
border: 1.5px solid #e2e8f0;
border-radius: 8px;
padding: 0 10px;
font-size: 14px;
font-weight: 700;
font-family: inherit;
color: #1353a0;
text-align: right;
outline: none;
background: #f8fafc;
transition: border-color 0.15s;
&:focus {
border-color: #1353a0;
background: #fff;
}
&::-webkit-outer-spin-button,
&::-webkit-inner-spin-button {
appearance: none;
}
}
.sc-shares-sep {
font-size: 20px;
font-weight: 300;
color: #94a3b8;
padding-top: 16px;
}
.sc-shares-result {
text-align: right;
}
.sc-shares-total {
font-size: 20px;
font-weight: 800;
color: #0f4f3c;
font-variant-numeric: tabular-nums;
}
// ── Editable Charge Amount ───────────────────────────────────────────────────
.sc-charge-amount {
padding: 4px 14px !important;
}
.sc-charge-input {
width: 100px;
height: 32px;
border: 1.5px solid #e2e8f0;
border-radius: 6px;
padding: 0 8px;
font-size: 13px;
font-weight: 700;
font-family: inherit;
color: #1353a0;
text-align: right;
outline: none;
background: #f8fafc;
transition: border-color 0.15s;
&:focus {
border-color: #1353a0;
background: #fff;
}
&::-webkit-outer-spin-button,
&::-webkit-inner-spin-button {
appearance: none;
}
}
// ── Split Layout ─────────────────────────────────────────────────────────────
.sc-split {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
@media (width <= 900px) {
grid-template-columns: 1fr;
}
}
// ── Breakdown Card ───────────────────────────────────────────────────────────
.sc-breakdown {
&__header {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
font-size: 13px;
font-weight: 700;
color: #334155;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
color: #0f4f3c;
}
}
}
.sc-gl-preview {
.sc-breakdown__header {
mat-icon {
color: #1d4ed8;
}
}
}
.sc-gl-note {
display: flex;
align-items: flex-start;
gap: 6px;
padding: 10px 14px;
font-size: 12px;
color: #64748b;
background: #f8fafc;
border-top: 1px solid #e2e8f0;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
flex-shrink: 0;
margin-top: 1px;
color: #94a3b8;
}
}
// ── Table helpers ────────────────────────────────────────────────────────────
.sc-dim {
color: #94a3b8;
font-size: 12px;
}
.sc-total-row td {
background: #f8fafc;
border-top: 2px solid #e2e8f0;
font-size: 14px;
}
.sc-total-amount {
color: #0f4f3c;
}
// ── Dr / Cr badges ───────────────────────────────────────────────────────────
.sc-dr-badge,
.sc-cr-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 20px;
border-radius: 4px;
font-size: 11px;
font-weight: 800;
letter-spacing: 0.04em;
}
.sc-dr-badge {
background: #fef2f2;
color: #dc2626;
border: 1px solid #fecaca;
}
.sc-cr-badge {
background: #f0fdf4;
color: #15803d;
border: 1px solid #bbf7d0;
}
// ── Footer ───────────────────────────────────────────────────────────────────
.sc-footer {
justify-content: space-between !important;
&__summary {
display: flex;
flex-direction: column;
gap: 2px;
}
&__label {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.07em;
color: #64748b;
}
&__amount {
font-size: 22px;
font-weight: 800;
color: #0f4f3c;
font-variant-numeric: tabular-nums;
}
&__actions {
display: flex;
align-items: center;
gap: 10px;
}
}
// ── Denomination Panel ────────────────────────────────────────────────────────
.sc-denom {
padding: 0;
overflow: hidden;
}
.sc-denom-body {
padding: 14px 16px;
}
.sc-denom-table {
width: 100%;
th,
td {
padding: 7px 10px;
font-size: 13px;
}
tfoot td {
border-top: 2px solid #e2e8f0;
background: #f8fafc;
font-size: 14px;
}
}
.sc-denom-chip {
display: inline-flex;
align-items: center;
height: 24px;
padding: 0 10px;
border-radius: 5px;
font-size: 12px;
font-weight: 700;
background: #eff6ff;
color: #1d4ed8;
border: 1px solid #bfdbfe;
&--coin {
background: #fffbeb;
color: #d97706;
border-color: #fde68a;
}
}
.sc-denom-stepper {
display: flex;
align-items: center;
gap: 4px;
justify-content: flex-end;
button {
width: 26px;
height: 26px;
border: 1.5px solid #e2e8f0;
border-radius: 5px;
background: #f8fafc;
color: #475569;
font-size: 16px;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
&:hover:not(:disabled) {
background: #e0f2fe;
border-color: #7dd3fc;
color: #0369a1;
}
&:disabled {
opacity: 0.35;
cursor: not-allowed;
}
}
input {
width: 62px;
height: 28px;
border: 1.5px solid #e2e8f0;
border-radius: 6px;
padding: 0 6px;
font-size: 13px;
font-weight: 600;
font-family: inherit;
text-align: right;
outline: none;
background: #fff;
&:focus {
border-color: #1353a0;
}
&::-webkit-outer-spin-button,
&::-webkit-inner-spin-button {
appearance: none;
}
}
}
.sc-denom-badge {
margin-left: auto;
display: inline-flex;
align-items: center;
height: 22px;
padding: 0 10px;
border-radius: 5px;
font-size: 12px;
font-weight: 700;
&--match {
background: #f0fdf4;
color: #15803d;
border: 1px solid #bbf7d0;
}
&--mismatch {
background: #fef2f2;
color: #dc2626;
border: 1px solid #fecaca;
}
}
.sc-denom-required {
margin-left: auto;
font-size: 12px;
font-weight: 700;
color: #d97706;
}
.sc-denom-mismatch {
display: flex;
align-items: center;
gap: 6px;
margin-top: 10px;
padding: 9px 12px;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 8px;
font-size: 12px;
font-weight: 600;
color: #dc2626;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
flex-shrink: 0;
}
}
.sc-denom-actions {
display: flex;
justify-content: flex-end;
margin-top: 10px;
}
// ── Success ──────────────────────────────────────────────────────────────────
.sc-success {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: 40px 24px;
gap: 16px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 16px;
&__icon-wrap {
width: 72px;
height: 72px;
border-radius: 50%;
background: #f0fdf4;
display: flex;
align-items: center;
justify-content: center;
mat-icon {
font-size: 40px;
width: 40px;
height: 40px;
color: #16a34a;
}
}
&__title {
font-size: 22px;
font-weight: 700;
color: #1e293b;
margin: 0;
}
&__sub {
font-size: 14px;
color: #64748b;
max-width: 480px;
margin: 0;
}
&__ref {
display: flex;
align-items: center;
gap: 10px;
background: #f1f5f9;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 8px 16px;
}
&__ref-label {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.07em;
color: #94a3b8;
white-space: nowrap;
}
&__ref-val {
font-family: 'Roboto Mono', 'Courier New', monospace;
font-size: 13px;
font-weight: 600;
color: #1353a0;
}
&__note {
font-size: 12px;
color: #94a3b8;
max-width: 420px;
margin: 0;
text-align: center;
}
&__gl {
display: flex;
flex-direction: column;
gap: 6px;
width: 100%;
max-width: 400px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 14px 16px;
}
&__gl-row {
display: flex;
justify-content: space-between;
font-size: 13px;
font-weight: 600;
padding: 4px 0;
border-bottom: 1px dotted #e2e8f0;
&:last-child {
border-bottom: none;
}
&--dr {
color: #dc2626;
}
&--cr {
color: #15803d;
}
}
}
@@ -1,282 +0,0 @@
/**
* 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 { RouterModule } from '@angular/router';
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 { catchError } from 'rxjs/operators';
import { of } from 'rxjs';
import { CashierService } from '../cashier.service';
type PageState = 'idle' | 'searching' | 'loaded' | 'submitting' | 'success';
interface Denomination {
id: number;
value: number;
kind: 'note' | 'coin';
label: string;
}
interface ShareCharge {
id: number;
name: string;
amount: number;
currencySymbol: string;
}
@Component({
selector: 'mifosx-share-collection',
templateUrl: './share-collection.component.html',
styleUrls: [
'../cashier-shared.scss',
'./share-collection.component.scss'
],
standalone: true,
imports: [
CommonModule,
FormsModule,
RouterModule,
MatIconModule,
MatSnackBarModule,
MatFormFieldModule,
MatSelectModule,
MatInputModule
]
})
export class ShareCollectionComponent implements OnInit {
private service = inject(CashierService);
private snackBar = inject(MatSnackBar);
pageState: PageState = 'idle';
accountInput = '';
transactionDate = '';
paymentTypeId: number | null = null;
paymentTypes: any[] = [];
note = '';
errorMsg = '';
saving = false;
account: any = null;
charges: ShareCharge[] = [];
collectionResult: any = null; // filled after successful POST
// Editable fields — cashier can override if API returns 0
requestedSharesInput = 0;
pricePerShare = 0;
// ── Denomination ────────────────────────────────────────────────────────────
notes: Denomination[] = [];
coins: Denomination[] = [];
denominationCounts: Record<string, number> = {};
get shareAmount(): number {
return this.requestedSharesInput * this.pricePerShare;
}
get totalChargeAmount(): number {
return this.charges.reduce((s, c) => s + c.amount, 0);
}
get totalAmount(): number {
return this.shareAmount + this.totalChargeAmount;
}
get currencySymbol(): string {
return this.account?.currency?.displaySymbol ?? '₹';
}
get isCashPayment(): boolean {
if (this.paymentTypeId === null) return true;
const pt = this.paymentTypes.find((p: any) => p.id === this.paymentTypeId);
return !!pt?.isCashPayment;
}
get allDenominations(): Denomination[] {
const desc = (a: Denomination, b: Denomination) => b.value - a.value;
return [
...[...this.notes].sort(desc),
...[...this.coins].sort(desc)
];
}
get denominationTotal(): number {
return this.allDenominations.reduce((s, d) => s + this.subtotalFor(d), 0);
}
get denomMatchesTotal(): boolean {
return this.denominationTotal === this.totalAmount;
}
get isValid(): boolean {
if (!this.account || this.totalAmount <= 0 || !this.transactionDate) return false;
if (this.isCashPayment && this.allDenominations.length > 0) {
return this.denomMatchesTotal;
}
return true;
}
ngOnInit(): void {
const d = new Date();
this.transactionDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.service
.getPaymentTypes()
.pipe(catchError(() => of([])))
.subscribe((types: any[]) => (this.paymentTypes = types || []));
this.loadDenominations();
}
private loadDenominations(): void {
this.service
.getActiveDenominations()
.pipe(catchError(() => of([])))
.subscribe((list: any[]) => {
const rows: Denomination[] = (list || []).map((d: any) => ({
id: d.id,
value: d.denomination ?? d.value,
kind: d.type === 'coin' ? 'coin' : 'note',
label: '₹' + (d.denomination ?? d.value)
}));
const order = (a: Denomination, b: Denomination) => b.value - a.value;
this.notes = rows.filter((r) => r.kind === 'note').sort(order);
this.coins = rows.filter((r) => r.kind === 'coin').sort(order);
});
}
// ── Denomination helpers ──────────────────────────────────────────────────
denomKey(d: Denomination): string {
return `${d.kind}:${d.value}`;
}
countFor(d: Denomination): number {
return this.denominationCounts[this.denomKey(d)] || 0;
}
subtotalFor(d: Denomination): number {
return this.countFor(d) * d.value;
}
onDenomChange(d: Denomination, raw: any): void {
this.denominationCounts[this.denomKey(d)] = Math.max(0, Math.floor(Number(raw) || 0));
}
bumpDenom(d: Denomination, delta: number): void {
this.denominationCounts[this.denomKey(d)] = Math.max(0, this.countFor(d) + delta);
}
clearDenominations(): void {
this.denominationCounts = {};
}
searchAccount(): void {
const query = this.accountInput.trim();
if (!query) return;
this.pageState = 'searching';
this.account = null;
this.charges = [];
this.errorMsg = '';
this.service
.getShareAccountForCollection(query)
.pipe(
catchError((err) => {
this.errorMsg =
err?.error?.errors?.[0]?.defaultUserMessage ||
err?.error?.developerMessage ||
'Account not found. Please check the account ID and try again.';
this.pageState = 'idle';
return of(null);
})
)
.subscribe((acc: any) => {
if (!acc) return;
this.account = acc;
// Auto-detect shares from summary or first pending purchasedShares entry
const pendingTx = (acc.purchasedShares || []).find(
(t: any) =>
t.status?.code === 'purchasedSharesStatusType.applied' && t.type?.code === 'purchasedSharesType.purchased'
);
this.requestedSharesInput = Number(
acc.summary?.totalPendingForApprovalShares || pendingTx?.numberOfShares || acc.requestedShares || 0
);
this.pricePerShare = Number(pendingTx?.purchasedPrice || acc.currentMarketPrice || acc.unitPrice || 0);
// Show ALL charges on the account — no type filtering
this.charges = (acc.charges || []).map((c: any) => ({
id: c.chargeId ?? c.id,
name: c.name,
amount: Number(c.amount ?? c.amountOutstanding ?? 0),
currencySymbol: c.currency?.displaySymbol ?? '₹'
}));
this.pageState = 'loaded';
});
}
recalc(): void {
// triggered by input changes — nothing extra needed, getters auto-recalculate
}
submit(): void {
if (!this.isValid || this.saving) return;
this.saving = true;
const payload = {
shareAccountId: this.account.id,
accountNo: this.account.accountNo,
clientName: this.account.clientName,
transactionDate: this.transactionDate,
requestedShares: this.requestedSharesInput,
pricePerShare: this.pricePerShare,
shareAmount: this.shareAmount,
chargeAmount: this.totalChargeAmount,
totalAmount: this.totalAmount,
paymentTypeId: this.paymentTypeId || null,
note: this.note || null
};
this.service.collectSharePayment(payload).subscribe({
next: (result: any) => {
this.collectionResult = result;
this.snackBar.open('Share collection posted successfully.', 'Close', { duration: 3000 });
this.pageState = 'success';
this.saving = false;
},
error: (err: any) => {
const msg =
err?.error?.errors?.[0]?.defaultUserMessage ||
err?.error?.developerMessage ||
err?.error?.defaultUserMessage ||
'Collection failed. Please try again.';
this.snackBar.open(msg, 'Close', { duration: 8000 });
this.saving = false;
}
});
}
reset(): void {
this.pageState = 'idle';
this.accountInput = '';
this.account = null;
this.charges = [];
this.errorMsg = '';
this.note = '';
this.paymentTypeId = null;
this.requestedSharesInput = 0;
this.pricePerShare = 0;
this.collectionResult = null;
this.saving = false;
this.clearDenominations();
}
}
@@ -8,7 +8,7 @@
import { Component, OnInit, inject } from '@angular/core'; import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms'; import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, FormArray, Validators } from '@angular/forms';
import { MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; import { MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
@@ -35,6 +35,35 @@ export interface CashierIssueDdDialogData {
accountNo?: string; accountNo?: string;
/** The account holder, for the same reason. */ /** The account holder, for the same reason. */
accountHolderName?: string; accountHolderName?: string;
/**
* How many drafts this slip raises. Absent or 1 is the ordinary single draft and the dialog is
* unchanged; 2 or more turns it into a slip, asking for a face value, charge and beneficiary per
* draft and reporting the combined total the account will be debited for.
*/
ddCount?: number;
/**
* Whether the funding account is a GL account rather than a member's. Only affects wording: a GL
* account has no passbook, so it is not promised one.
*/
glFunded?: boolean;
/**
* The leaflets provisionally shown against each line of a slip.
*
* Only ever indicative. The server allocates the real numbers inside the locked transaction that
* submits the slip, so what is shown here is the next-available run at the moment the dialog was
* opened — accurate unless another clerk submits first, which is precisely why the numbers cannot
* be promised until submit returns them.
*/
previewDdNumbers?: string[];
}
/** One captured draft on a multi-DD slip. */
export interface DdSlipLineResult {
beneficiaryName: string;
payableAtBranch: string;
payableAtCity: string;
faceValue: number;
chargesAmount: number;
} }
@Component({ @Component({
@@ -56,10 +85,20 @@ export interface CashierIssueDdDialogData {
<div class="dd-dialog-header__title"> <div class="dd-dialog-header__title">
<mat-icon class="dd-icon">receipt_long</mat-icon> <mat-icon class="dd-icon">receipt_long</mat-icon>
<div> <div>
<h2>{{ isAccountDebit ? 'Account-Debit Demand Draft (DD)' : 'Walk-in Cash Demand Draft (DD)' }}</h2> <h2>
@if (isSlip) {
Multi-DD Slip · {{ lineCount }} Demand Drafts
} @else {
{{ isAccountDebit ? 'Account-Debit Demand Draft (DD)' : 'Walk-in Cash Demand Draft (DD)' }}
}
</h2>
<p> <p>
@if (isSlip) {
One debit funds all {{ lineCount }} drafts · Enter each draft's beneficiary and amount
} @else {
Next available DD leaflet auto-selected · Next available DD leaflet auto-selected ·
{{ isAccountDebit ? 'Enter beneficiary details' : 'Enter purchaser &amp; beneficiary details' }} {{ isAccountDebit ? 'Enter beneficiary details' : 'Enter purchaser &amp; beneficiary details' }}
}
</p> </p>
</div> </div>
</div> </div>
@@ -69,6 +108,55 @@ export interface CashierIssueDdDialogData {
</div> </div>
<mat-dialog-content class="dd-dialog-body"> <mat-dialog-content class="dd-dialog-body">
<!--
Slip running total.
A slip's figures are not known until every line is typed — different drafts carry different
face values and different charges — so the total the account will be debited for is built
up here as the clerk works, rather than shown as a fixed figure up front. Answering "what
is this going to cost my member" without it would mean adding the lines by hand.
-->
@if (isSlip) {
<div class="dd-summary-card">
<div class="summary-col">
<span class="label">Drafts On This Slip</span>
<strong class="dd-number-highlight">{{ lineCount }}</strong>
<span class="sublabel">{{ data.sponsorBankName || 'Sponsor Bank' }} · {{ data.branchName }}</span>
</div>
<div class="summary-divider"></div>
<div class="summary-col">
<span class="label">Total Face Value</span>
<strong class="amount">₹{{ slipFaceValue | number: '1.2-2' }}</strong>
<span class="sublabel">+ Charges ₹{{ slipCharges | number: '1.2-2' }}</span>
</div>
<div class="summary-divider"></div>
<div class="summary-col">
<span class="label">GST ({{ data.gstPercentage }}%)</span>
<strong class="amount">₹{{ slipGst | number: '1.2-2' }}</strong>
<span class="sublabel">on charges only</span>
</div>
<div class="summary-divider"></div>
<div class="summary-col highlight-col">
<span class="label">Total To Debit</span>
<strong class="total-amount">₹{{ slipTotal | number: '1.2-2' }}</strong>
<span class="sublabel">{{ data.accountNo || 'Account' }} · one debit</span>
</div>
</div>
<!--
Said plainly because the numbers on screen are the one thing in this dialog the clerk
cannot rely on. They are the next-available run as it stood when the dialog opened; the
binding allocation happens inside the submit transaction, so a slip filed after another
clerk's gets the following numbers instead.
-->
<div class="dd-preview-note">
<mat-icon>info</mat-icon>
<div>
DD numbers below are <strong>provisional</strong>. The final numbers are allocated when the slip is
submitted and will be confirmed on the receipt.
</div>
</div>
} @else {
<!-- Draft & Financials Summary Card --> <!-- Draft & Financials Summary Card -->
<div class="dd-summary-card"> <div class="dd-summary-card">
<!-- The number the customer will actually see printed on the instrument. "Auto-Selected <!-- The number the customer will actually see printed on the instrument. "Auto-Selected
@@ -104,6 +192,7 @@ export interface CashierIssueDdDialogData {
</span> </span>
</div> </div>
</div> </div>
}
<!-- <!--
The account funding the draft, stated before the form. The account funding the draft, stated before the form.
@@ -121,13 +210,121 @@ export interface CashierIssueDdDialogData {
· {{ data.accountHolderName }} · {{ data.accountHolderName }}
} }
<div class="dd-funding-sub"> <div class="dd-funding-sub">
@if (isSlip) {
₹{{ slipTotal | number: '1.2-2' }} will be debited from this account as a
<strong>single transaction</strong> covering all {{ lineCount }} drafts, when a checker approves the
slip —
<!-- A GL account has no passbook, so the reassurance a member gets is phrased for
the ledger instead of promising a statement line that does not exist. -->
@if (isGlFunded) {
one journal entry against this GL account, not {{ lineCount }}.
} @else {
the member's passbook shows one entry, not {{ lineCount }}.
}
} @else {
₹{{ data.totalCashPayable | number: '1.2-2' }} will be debited from this account when a checker ₹{{ data.totalCashPayable | number: '1.2-2' }} will be debited from this account when a checker
approves. No cash is taken at the counter. approves. No cash is taken at the counter.
}
</div> </div>
</div> </div>
</div> </div>
} }
<!--
A slip's drafts, one card each.
Face value and charge sit on the line rather than on the slip because that is how the bank
actually uses this: three drafts to three institutions are three different amounts, and the
charge is levied per draft. The server stores and posts them per line for the same reason.
-->
@if (isSlip) {
<form [formGroup]="slipForm" class="dd-form">
<div formArrayName="lines">
@for (lineCtrl of slipLines.controls; track $index) {
<div class="dd-line-card" [formGroupName]="$index">
<div class="dd-line-head">
<span class="dd-line-badge">DD {{ $index + 1 }} of {{ lineCount }}</span>
@if (previewNumberAt($index)) {
<span class="dd-line-number">No. {{ previewNumberAt($index) }} <em>(provisional)</em></span>
}
<span class="dd-line-total"> Debit ₹{{ lineTotalAt($index) | number: '1.2-2' }} </span>
</div>
<mat-form-field appearance="outline" class="full-width">
<mat-label>Beneficiary Name (In Favour Of) *</mat-label>
<input
matInput
formControlName="beneficiaryName"
placeholder="e.g. UNIVERSITY OF MUMBAI"
maxlength="120"
style="text-transform: uppercase;"
/>
<mat-error *ngIf="lineCtrl.get('beneficiaryName')?.hasError('required')"
>Beneficiary name is required</mat-error
>
<mat-error *ngIf="lineCtrl.get('beneficiaryName')?.hasError('maxlength')"
>Max 120 characters</mat-error
>
</mat-form-field>
<div class="form-row">
<mat-form-field appearance="outline" class="form-field-flex">
<mat-label>Payable At - Branch *</mat-label>
<input
matInput
formControlName="payableAtBranch"
placeholder="e.g. FORT BRANCH"
maxlength="150"
style="text-transform: uppercase;"
/>
<mat-error *ngIf="lineCtrl.get('payableAtBranch')?.hasError('required')"
>Payable Branch is required</mat-error
>
</mat-form-field>
<mat-form-field appearance="outline" class="form-field-flex">
<mat-label>Payable At - City *</mat-label>
<input
matInput
formControlName="payableAtCity"
placeholder="e.g. MUMBAI"
maxlength="100"
style="text-transform: uppercase;"
/>
<mat-error *ngIf="lineCtrl.get('payableAtCity')?.hasError('required')"
>Payable City is required</mat-error
>
</mat-form-field>
</div>
<div class="form-row">
<mat-form-field appearance="outline" class="form-field-flex">
<mat-label>Face Value *</mat-label>
<input matInput type="number" formControlName="faceValue" placeholder="0.00" />
<span matPrefix>₹&nbsp;</span>
<mat-error *ngIf="lineCtrl.get('faceValue')?.hasError('required')"
>Face value is required</mat-error
>
<mat-error *ngIf="lineCtrl.get('faceValue')?.hasError('min')"
>Face value must be greater than zero</mat-error
>
</mat-form-field>
<mat-form-field appearance="outline" class="form-field-flex">
<mat-label>Charges</mat-label>
<input matInput type="number" formControlName="chargesAmount" placeholder="0.00" />
<span matPrefix>₹&nbsp;</span>
<mat-hint>GST ₹{{ lineGstAt($index) | number: '1.2-2' }} at {{ data.gstPercentage }}%</mat-hint>
<mat-error *ngIf="lineCtrl.get('chargesAmount')?.hasError('min')"
>Charges cannot be negative</mat-error
>
</mat-form-field>
</div>
</div>
}
</div>
</form>
} @else {
<form [formGroup]="form" class="dd-form"> <form [formGroup]="form" class="dd-form">
<!-- <!--
Purchaser identity is asked for only when the bank has none: a walk-in paying cash. On an Purchaser identity is asked for only when the bank has none: a walk-in paying cash. On an
@@ -232,12 +429,18 @@ export interface CashierIssueDdDialogData {
</mat-form-field> </mat-form-field>
</div> </div>
</form> </form>
}
</mat-dialog-content> </mat-dialog-content>
<mat-dialog-actions class="dd-dialog-actions" align="end"> <mat-dialog-actions class="dd-dialog-actions" align="end">
@if (isSlip) {
<span class="dd-actions-total">
{{ lineCount }} drafts · <strong>₹{{ slipTotal | number: '1.2-2' }}</strong> to debit
</span>
}
<button mat-button type="button" (click)="onClose()">Cancel</button> <button mat-button type="button" (click)="onClose()">Cancel</button>
<button mat-flat-button color="primary" [disabled]="form.invalid" (click)="onSubmit()"> <button mat-flat-button color="primary" [disabled]="activeForm.invalid" (click)="onSubmit()">
Save DD Details {{ isSlip ? 'Save Slip Details' : 'Save DD Details' }}
</button> </button>
</mat-dialog-actions> </mat-dialog-actions>
</div> </div>
@@ -395,6 +598,81 @@ export interface CashierIssueDdDialogData {
line-height: 1.5; line-height: 1.5;
color: #64748b; color: #64748b;
} }
/* One card per draft on a slip — the boundary is what stops a clerk typing line 2's
beneficiary against line 1's amount. */
.dd-line-card {
border: 1px solid #e2e8f0;
border-left: 3px solid #0369a1;
border-radius: 8px;
padding: 14px 16px 4px;
margin-bottom: 14px;
background: #fcfdff;
}
.dd-line-head {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.dd-line-badge {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.4px;
color: #ffffff;
background: #0369a1;
padding: 3px 9px;
border-radius: 999px;
}
.dd-line-number {
font-family: monospace;
font-size: 12px;
color: #475569;
}
.dd-line-number em {
font-style: normal;
color: #94a3b8;
font-size: 11px;
}
.dd-line-total {
margin-left: auto;
font-size: 12px;
font-weight: 600;
color: #065f46;
background: #ecfdf5;
border: 1px solid #a7f3d0;
padding: 3px 10px;
border-radius: 6px;
}
.dd-preview-note {
display: flex;
gap: 8px;
align-items: flex-start;
margin: 0 0 16px;
padding: 9px 12px;
background: #fffbeb;
border: 1px solid #fde68a;
border-radius: 6px;
font-size: 12px;
line-height: 1.5;
color: #78350f;
}
.dd-preview-note mat-icon {
color: #b45309;
font-size: 18px;
width: 18px;
height: 18px;
flex-shrink: 0;
}
.dd-actions-total {
margin-right: auto;
font-size: 13px;
color: #475569;
}
.dd-actions-total strong {
color: #065f46;
}
` `
] ]
}) })
@@ -404,13 +682,127 @@ export class CashierIssueDdDialogComponent implements OnInit {
public data: CashierIssueDdDialogData = inject(MAT_DIALOG_DATA); public data: CashierIssueDdDialogData = inject(MAT_DIALOG_DATA);
form!: FormGroup; form!: FormGroup;
/** Built only in slip mode; `form` stays untouched so the single-draft path is unchanged. */
slipForm!: FormGroup;
/** Whether the draft is funded by debiting a member's account rather than by cash at the counter. */ /** Whether the draft is funded by debiting a member's account rather than by cash at the counter. */
get isAccountDebit(): boolean { get isAccountDebit(): boolean {
return this.data.paymentMethod === 'ACCOUNT_DEBIT'; return this.data.paymentMethod === 'ACCOUNT_DEBIT';
} }
/** How many drafts this dialog is capturing — 1 unless the clerk asked for a slip. */
get lineCount(): number {
const n = Number(this.data.ddCount) || 1;
return n > 1 ? n : 1;
}
/** Whether this dialog is capturing a multi-DD slip rather than a single draft. */
get isSlip(): boolean {
return this.lineCount > 1;
}
/** Whether the debit lands on a GL account rather than a member's — wording only. */
get isGlFunded(): boolean {
return !!this.data.glFunded;
}
/** The form the Save button validates against — whichever of the two was built. */
get activeForm(): FormGroup {
return this.isSlip ? this.slipForm : this.form;
}
get slipLines(): FormArray {
return this.slipForm.get('lines') as FormArray;
}
/** The provisional leaflet number shown against a line, if the caller supplied a run of them. */
previewNumberAt(index: number): string | null {
return this.data.previewDdNumbers?.[index] ?? null;
}
private lineValueAt(index: number, field: string): number {
return Number(this.slipLines.at(index)?.get(field)?.value) || 0;
}
/**
* GST on one line's charge.
*
* Rounded per line, matching the server, which computes and stores GST against each draft rather
* than once for the slip. Summing rounded lines and rounding a summed charge can differ by a
* paisa, and it is the per-line figure that ends up on the draft.
*/
lineGstAt(index: number): number {
const charges = this.lineValueAt(index, 'chargesAmount');
if (charges <= 0) return 0;
return Math.round(charges * (Number(this.data.gstPercentage) || 0)) / 100;
}
/** What one line adds to the debit — its face value, its charge and the GST on that charge. */
lineTotalAt(index: number): number {
return this.lineValueAt(index, 'faceValue') + this.lineValueAt(index, 'chargesAmount') + this.lineGstAt(index);
}
get slipFaceValue(): number {
return this.slipLines.controls.reduce((sum, _c, i) => sum + this.lineValueAt(i, 'faceValue'), 0);
}
get slipCharges(): number {
return this.slipLines.controls.reduce((sum, _c, i) => sum + this.lineValueAt(i, 'chargesAmount'), 0);
}
get slipGst(): number {
return this.slipLines.controls.reduce((sum, _c, i) => sum + this.lineGstAt(i), 0);
}
/** The figure the account is debited for — rounded up to the whole rupee, as the single flow does. */
get slipTotal(): number {
return Math.ceil(this.slipFaceValue + this.slipCharges + this.slipGst - 1e-9);
}
ngOnInit(): void { ngOnInit(): void {
if (this.isSlip) {
this.slipForm = this.fb.group({
lines: this.fb.array(
Array.from({ length: this.lineCount }, () =>
this.fb.group({
beneficiaryName: [
'',
[
Validators.required,
Validators.maxLength(120)
]
],
payableAtBranch: [
'',
[Validators.required]
],
payableAtCity: [
'',
[Validators.required]
],
// Zero is rejected rather than merely warned about: the server refuses a line with a
// face value at or below zero, and a slip that fails there has already cost the clerk
// every other line they typed.
faceValue: [
null as
| number
| null,
[
Validators.required,
Validators.min(0.01)
]
],
chargesAmount: [
0,
[Validators.min(0)]
]
})
)
)
});
return;
}
// Walk-in identity is required for cash and rejected outright for an account debit — the server // Walk-in identity is required for cash and rejected outright for an account debit — the server
// refuses a walk-in name alongside an account — so the controls are only built when they apply. // refuses a walk-in name alongside an account — so the controls are only built when they apply.
// Registering them unconditionally and merely hiding them would hold the form invalid on a // Registering them unconditionally and merely hiding them would hold the form invalid on a
@@ -477,7 +869,25 @@ export class CashierIssueDdDialogComponent implements OnInit {
} }
onSubmit(): void { onSubmit(): void {
if (this.form.invalid) return; if (this.activeForm.invalid) return;
// A slip closes with its lines. There is no walk-in branch here: a slip is only ever offered on
// a member-account debit, where the purchaser is the account holder already on record.
if (this.isSlip) {
const lines: DdSlipLineResult[] = this.slipLines.controls.map((c) => {
const v = c.value;
return {
beneficiaryName: (v.beneficiaryName || '').trim().toUpperCase(),
payableAtBranch: (v.payableAtBranch || '').trim().toUpperCase(),
payableAtCity: (v.payableAtCity || '').trim().toUpperCase(),
faceValue: Number(v.faceValue) || 0,
chargesAmount: Number(v.chargesAmount) || 0
};
});
this.dialogRef.close({ lines });
return;
}
const val = this.form.value; const val = this.form.value;
const beneficiary = { const beneficiary = {
beneficiaryName: (val.beneficiaryName || '').trim().toUpperCase(), beneficiaryName: (val.beneficiaryName || '').trim().toUpperCase(),
@@ -117,6 +117,8 @@
<select <select
class="ws-f__ctl" class="ws-f__ctl"
[(ngModel)]="row.typ" [(ngModel)]="row.typ"
[disabled]="isTypLocked(row)"
[title]="isTypLocked(row) ? 'Token withdrawal - TYPE is fixed to Payment' : ''"
(click)="$event.stopPropagation()" (click)="$event.stopPropagation()"
(change)="onTypChange(row)" (change)="onTypChange(row)"
> >
@@ -127,11 +129,16 @@
</div> </div>
<div class="ws-f ws-f--branch"> <div class="ws-f ws-f--branch">
<label class="ws-f__lbl" title="Branch">BRANCH</label> <label class="ws-f__lbl" title="Branch">BRANCH</label>
<select class="ws-f__ctl" [(ngModel)]="row.br" (click)="$event.stopPropagation()"> <select
<option [ngValue]="1">001 · Main</option> class="ws-f__ctl"
<option [ngValue]="2">002 · M.G. Road</option> [(ngModel)]="row.br"
<option [ngValue]="14">014 · Station Rd</option> [disabled]="branches.length <= 1"
<option [ngValue]="27">027 · Industrial</option> [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> </select>
</div> </div>
@if (row.typ === 'DD') { @if (row.typ === 'DD') {
@@ -166,23 +173,44 @@
</div> </div>
<div class="ws-f ws-f--actyp"> <div class="ws-f ws-f--actyp">
<label class="ws-f__lbl" title="Account Type">AC. TP.</label> <label class="ws-f__lbl" title="Account Type">AC. TP.</label>
@if (acTypLockedByToken && row === selectedRow) {
<span
class="ws-f__ctl ws-f__ctl--locked"
title="Locked — filled from a token-driven request, not a normal account entry"
>{{ acTypLockedLabel }}</span
>
} @else {
<select <select
class="ws-f__ctl" class="ws-f__ctl"
[(ngModel)]="row.acTyp" [(ngModel)]="row.acTyp"
(click)="$event.stopPropagation()" (click)="$event.stopPropagation()"
(change)=" (change)="isGlCreditOnly(row) && (row.dbCr = 'Cr')"
row.acTyp === 'GL' &&
row.typ !== 'Disbursement' &&
row.typ !== 'Fund Transfer' &&
row.typ !== 'Enter Bank Transfer' &&
(row.dbCr = 'Cr')
"
> >
@for (a of acTypOptionsFor(row); track a) { @for (a of acTypOptionsFor(row); track a) {
<option [value]="a">{{ a }}</option> <option [value]="a">{{ a }}</option>
} }
</select> </select>
}
</div> </div>
@if (showsEntryType(row)) {
<div class="ws-f ws-f--paytype">
<label
class="ws-f__lbl ws-f__lbl--accent"
title="What this money was actually collected for — checked before a share application or DD issuance can adopt this voucher"
>ENTRY TYPE</label
>
<select
class="ws-f__ctl ws-f__ctl--accent"
[(ngModel)]="row.entryTypeCvId"
(click)="$event.stopPropagation()"
>
<option [ngValue]="null">Select…</option>
@for (t of entryTypeOptions; track t.id) {
<option [ngValue]="t.id">{{ t.name }}</option>
}
</select>
</div>
}
</div> </div>
</div> </div>
@@ -206,10 +234,16 @@
? ddGlMapping?.ddPayableGlCode || 'Auto GL' ? ddGlMapping?.ddPayableGlCode || 'Auto GL'
: 'Account number' : 'Account number'
" "
[readonly]="row.typ === 'DD' && !isDdAccountDebitRow(row)" [readonly]="
[class.ws-f__ctl--readonly]="row.typ === 'DD' && !isDdAccountDebitRow(row)" (row.typ === 'DD' && !isDdAccountDebitRow(row)) || (acTypLockedByToken && row === selectedRow)
"
[class.ws-f__ctl--readonly]="
(row.typ === 'DD' && !isDdAccountDebitRow(row)) || (acTypLockedByToken && row === selectedRow)
"
[title]=" [title]="
isDdAccountDebitRow(row) acTypLockedByToken && row === selectedRow
? 'Locked — filled from a token-driven request, not a normal account entry'
: isDdAccountDebitRow(row)
? 'Member account the draft is funded from' ? 'Member account the draft is funded from'
: row.typ === 'DD' : row.typ === 'DD'
? 'Configured Sponsor DD Payable GL (Auto-selected)' ? 'Configured Sponsor DD Payable GL (Auto-selected)'
@@ -233,12 +267,7 @@
type="button" type="button"
class="ws-seg__btn ws-seg__btn--dr" class="ws-seg__btn ws-seg__btn--dr"
[class.ws-seg__btn--active-dr]="row.dbCr === 'Dr'" [class.ws-seg__btn--active-dr]="row.dbCr === 'Dr'"
[disabled]=" [disabled]="isGlCreditOnly(row)"
row.acTyp === 'GL' &&
row.typ !== 'Disbursement' &&
row.typ !== 'Fund Transfer' &&
row.typ !== 'Enter Bank Transfer'
"
(click)="row.dbCr = 'Dr'" (click)="row.dbCr = 'Dr'"
> >
DR DR
@@ -247,6 +276,7 @@
type="button" type="button"
class="ws-seg__btn ws-seg__btn--cr" class="ws-seg__btn ws-seg__btn--cr"
[class.ws-seg__btn--active-cr]="row.dbCr === 'Cr'" [class.ws-seg__btn--active-cr]="row.dbCr === 'Cr'"
[disabled]="row.typ === 'Withdrawal Slip'"
(click)="row.dbCr = 'Cr'" (click)="row.dbCr = 'Cr'"
> >
CR CR
@@ -286,9 +316,13 @@
[value]="row.amount ?? ''" [value]="row.amount ?? ''"
(input)="row.amount = +$any($event.target).value; syncShareLinkedAmounts()" (input)="row.amount = +$any($event.target).value; syncShareLinkedAmounts()"
(click)="$event.stopPropagation()" (click)="$event.stopPropagation()"
[placeholder]="row.acTyp === 'SHR' ? '0' : '0.00'" [placeholder]="isMultiDdRow(row) ? 'Per draft' : row.acTyp === 'SHR' ? '0' : '0.00'"
[readonly]="!isClerk && !!(activeWithdrawalRequest || activeClosureRequest)" [readonly]="
[class.ws-amt-input--locked]="!isClerk && !!(activeWithdrawalRequest || activeClosureRequest)" isMultiDdRow(row) || (!isClerk && !!(activeWithdrawalRequest || activeClosureRequest))
"
[class.ws-amt-input--locked]="
isMultiDdRow(row) || (!isClerk && !!(activeWithdrawalRequest || activeClosureRequest))
"
/> />
</div> </div>
@if (row.acTyp === 'SHR' && rowSharePrices[row.acNo]) { @if (row.acTyp === 'SHR' && rowSharePrices[row.acNo]) {
@@ -298,6 +332,19 @@
}}/share</span }}/share</span
> >
} }
<!-- On a slip the face value belongs to each draft, so the row total is the sum of
them and is filled in by the dialog rather than typed here. A clerk-typed
figure could disagree with the drafts, and the account would be debited for
a total the drafts do not add up to. -->
@if (isMultiDdRow(row)) {
<span class="ws-f__hint">
{{
row.ddLines?.length
? 'Total of ' + row.ddLines?.length + ' drafts'
: 'Set per draft in Fill DD Details'
}}
</span>
}
</div> </div>
@if (row.acTyp === 'SHR' && (row.typ === 'Enter Bank Transfer' || row.typ === 'Fund Transfer')) { @if (row.acTyp === 'SHR' && (row.typ === 'Enter Bank Transfer' || row.typ === 'Fund Transfer')) {
<div class="ws-f ws-f--cheqno"> <div class="ws-f ws-f--cheqno">
@@ -377,9 +424,36 @@
<div class="ws-group ws-group--flex"> <div class="ws-group ws-group--flex">
<span class="ws-caption">DD Charges &amp; GST — Admin Rate {{ ddGstPercentage }}%</span> <span class="ws-caption">DD Charges &amp; GST — Admin Rate {{ ddGstPercentage }}%</span>
<div class="ws-group__fields"> <div class="ws-group__fields">
<!-- How many drafts this one debit funds. Typed before the details are filled in,
because it is what decides how many beneficiary forms the dialog presents.
Offered only where the server can honour it: a member savings/current
account debit, never a GL or cash draft. -->
@if (canRowBeMultiDd(row)) {
<div class="ws-f ws-f--amt">
<label class="ws-f__lbl" title="Number of demand drafts to raise against this single debit"
>NO. OF DD</label
>
<div class="ws-amt-wrap">
<input
class="ws-f__ctl ws-amt-input"
type="number"
min="1"
[max]="maxDdSlipLines"
step="1"
[value]="ddCountFor(row)"
(input)="onDdCountChange(row, +$any($event.target).value)"
(click)="$event.stopPropagation()"
placeholder="1"
/>
</div>
@if (isMultiDdRow(row)) {
<span class="ws-f__hint">One debit · {{ ddCountFor(row) }} drafts</span>
}
</div>
}
<div class="ws-f ws-f--amt"> <div class="ws-f ws-f--amt">
<label class="ws-f__lbl" title="DD issuance charges / commission">CHARGES</label> <label class="ws-f__lbl" title="DD issuance charges / commission">CHARGES</label>
<div class="ws-amt-wrap"> <div class="ws-amt-wrap" [class.ws-amt-wrap--locked]="isMultiDdRow(row)">
<span class="ws-amt-prefix">₹</span> <span class="ws-amt-prefix">₹</span>
<input <input
class="ws-f__ctl ws-amt-input" class="ws-f__ctl ws-amt-input"
@@ -388,8 +462,15 @@
(input)="row.ddCharges = +$any($event.target).value" (input)="row.ddCharges = +$any($event.target).value"
(click)="$event.stopPropagation()" (click)="$event.stopPropagation()"
placeholder="0.00" placeholder="0.00"
[readonly]="isMultiDdRow(row)"
[class.ws-amt-input--locked]="isMultiDdRow(row)"
/> />
</div> </div>
<!-- Charges are levied per draft and can differ between them, so on a slip they
are entered against each draft and totalled here. -->
@if (isMultiDdRow(row)) {
<span class="ws-f__hint">Set per draft</span>
}
</div> </div>
<div class="ws-f ws-f--amt"> <div class="ws-f ws-f--amt">
<label class="ws-f__lbl" title="GST on charges, at the admin-configured rate"> <label class="ws-f__lbl" title="GST on charges, at the admin-configured rate">
@@ -400,7 +481,7 @@
<input <input
class="ws-f__ctl ws-amt-input ws-amt-input--locked" class="ws-f__ctl ws-amt-input ws-amt-input--locked"
type="text" type="text"
[value]="ddGstAmountFor(row) | number: '1.2-2'" [value]="ddGstDisplayFor(row)"
readonly readonly
/> />
</div> </div>
@@ -414,7 +495,7 @@
<input <input
class="ws-f__ctl ws-amt-input ws-amt-input--locked" class="ws-f__ctl ws-amt-input ws-amt-input--locked"
type="text" type="text"
[value]="ddTotalPayableFor(row)" [value]="ddTotalDisplayFor(row)"
readonly readonly
/> />
</div> </div>
@@ -427,18 +508,27 @@
[disabled]="saving" [disabled]="saving"
(click)="openIssueWalkinDd(row); $event.stopPropagation()" (click)="openIssueWalkinDd(row); $event.stopPropagation()"
[title]=" [title]="
isDdAccountDebitRow(row) isMultiDdRow(row)
? 'Opens one beneficiary form per draft; DD numbers are allocated on submission'
: isDdAccountDebitRow(row)
? 'Auto-selects next available leaflet and opens the beneficiary form' ? 'Auto-selects next available leaflet and opens the beneficiary form'
: 'Auto-selects next available leaflet and opens walk-in purchaser form' : 'Auto-selects next available leaflet and opens walk-in purchaser form'
" "
> >
<mat-icon style="font-size: 18px; width: 18px; height: 18px">{{ <mat-icon style="font-size: 18px; width: 18px; height: 18px">{{
row.ddDetails ? 'edit_note' : 'post_add' row.ddDetails || row.ddLines?.length ? 'edit_note' : 'post_add'
}}</mat-icon> }}</mat-icon>
<!-- Names the leaflet once details are captured, so the clerk can see at a <!-- Names the leaflet once details are captured, so the clerk can see at a
glance which rows are ready before pressing Save. --> glance which rows are ready before pressing Save. A slip names the count
instead: its numbers are not allocated until it is submitted. -->
<span>{{ <span>{{
row.ddDetails ? 'DD ' + row.ddDetails.ddNumber + ' · Edit Details' : 'Fill DD Details' row.ddLines?.length
? row.ddLines?.length + ' Drafts Captured · Edit Slip'
: row.ddDetails
? 'DD ' + row.ddDetails.ddNumber + ' · Edit Details'
: isMultiDdRow(row)
? 'Fill ' + ddCountFor(row) + ' DD Details'
: 'Fill DD Details'
}}</span> }}</span>
</button> </button>
</div> </div>
@@ -618,8 +708,18 @@
<circle cx="12" cy="8" r="4" /> <circle cx="12" cy="8" r="4" />
<path d="M4 20c0-4 3.6-7 8-7s8 3 8 7" /> <path d="M4 20c0-4 3.6-7 8-7s8 3 8 7" />
</svg> </svg>
Personal Information {{ isGlAccount ? 'GL Account Information' : 'Personal Information' }}
</div> </div>
@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>
} @else {
<div class="te-panel__body"> <div class="te-panel__body">
<div class="te-field"> <div class="te-field">
<span class="te-label">Branch</span><span class="te-value">{{ officeName }}</span> <span class="te-label">Branch</span><span class="te-value">{{ officeName }}</span>
@@ -682,6 +782,7 @@
</div> </div>
} }
</div> </div>
}
</div> </div>
<!-- Joint Holders + Nominees --> <!-- Joint Holders + Nominees -->
@@ -836,6 +937,7 @@
<!-- Other Info + KYC + Account Balances --> <!-- Other Info + KYC + Account Balances -->
<div class="te-panel"> <div class="te-panel">
@if (!isGlAccount) {
<div class="te-panel__header"> <div class="te-panel__header">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <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" /> <rect x="3" y="4" width="18" height="18" rx="2" />
@@ -904,8 +1006,9 @@
} }
} }
</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"> <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" /> <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" /> <path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
@@ -1474,6 +1577,8 @@
<select <select
class="ws-f__ctl" class="ws-f__ctl"
[(ngModel)]="row.typ" [(ngModel)]="row.typ"
[disabled]="isTypLocked(row)"
[title]="isTypLocked(row) ? 'Token withdrawal - TYPE is fixed to Payment' : ''"
(click)="$event.stopPropagation()" (click)="$event.stopPropagation()"
(change)="onTypChange(row)" (change)="onTypChange(row)"
> >
@@ -1484,11 +1589,16 @@
</div> </div>
<div class="ws-f ws-f--branch"> <div class="ws-f ws-f--branch">
<label class="ws-f__lbl">BRANCH</label> <label class="ws-f__lbl">BRANCH</label>
<select class="ws-f__ctl" [(ngModel)]="row.br" (click)="$event.stopPropagation()"> <select
<option [ngValue]="1">001 · Main</option> class="ws-f__ctl"
<option [ngValue]="2">002 · M.G. Road</option> [(ngModel)]="row.br"
<option [ngValue]="14">014 · Station Rd</option> [disabled]="branches.length <= 1"
<option [ngValue]="27">027 · Industrial</option> [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> </select>
</div> </div>
@if (row.typ === 'DD') { @if (row.typ === 'DD') {
@@ -1523,23 +1633,44 @@
</div> </div>
<div class="ws-f ws-f--actyp"> <div class="ws-f ws-f--actyp">
<label class="ws-f__lbl">AC. TP.</label> <label class="ws-f__lbl">AC. TP.</label>
@if (acTypLockedByToken && row === selectedRow) {
<span
class="ws-f__ctl ws-f__ctl--locked"
title="Locked — filled from a token-driven request, not a normal account entry"
>{{ acTypLockedLabel }}</span
>
} @else {
<select <select
class="ws-f__ctl" class="ws-f__ctl"
[(ngModel)]="row.acTyp" [(ngModel)]="row.acTyp"
(click)="$event.stopPropagation()" (click)="$event.stopPropagation()"
(change)=" (change)="isGlCreditOnly(row) && (row.dbCr = 'Cr')"
row.acTyp === 'GL' &&
row.typ !== 'Disbursement' &&
row.typ !== 'Fund Transfer' &&
row.typ !== 'Enter Bank Transfer' &&
(row.dbCr = 'Cr')
"
> >
@for (a of acTypOptionsFor(row); track a) { @for (a of acTypOptionsFor(row); track a) {
<option [value]="a">{{ a }}</option> <option [value]="a">{{ a }}</option>
} }
</select> </select>
}
</div> </div>
@if (showsEntryType(row)) {
<div class="ws-f ws-f--paytype">
<label
class="ws-f__lbl ws-f__lbl--accent"
title="What this money was actually collected for — checked before a share application or DD issuance can adopt this voucher"
>ENTRY TYPE</label
>
<select
class="ws-f__ctl ws-f__ctl--accent"
[(ngModel)]="row.entryTypeCvId"
(click)="$event.stopPropagation()"
>
<option [ngValue]="null">Select…</option>
@for (t of entryTypeOptions; track t.id) {
<option [ngValue]="t.id">{{ t.name }}</option>
}
</select>
</div>
}
</div> </div>
</div> </div>
<div class="ws-vdivider"></div> <div class="ws-vdivider"></div>
@@ -1556,10 +1687,16 @@
[placeholder]=" [placeholder]="
row.typ === 'DD' ? ddGlMapping?.ddPayableGlCode || 'Auto GL' : 'Account number' row.typ === 'DD' ? ddGlMapping?.ddPayableGlCode || 'Auto GL' : 'Account number'
" "
[readonly]="row.typ === 'DD'" [readonly]="row.typ === 'DD' || (acTypLockedByToken && row === selectedRow)"
[class.ws-f__ctl--readonly]="row.typ === 'DD'" [class.ws-f__ctl--readonly]="
row.typ === 'DD' || (acTypLockedByToken && row === selectedRow)
"
[title]=" [title]="
row.typ === 'DD' ? 'Configured Sponsor DD Payable GL (Auto-selected)' : 'Account number' acTypLockedByToken && row === selectedRow
? 'Locked — filled from a token-driven request, not a normal account entry'
: row.typ === 'DD'
? 'Configured Sponsor DD Payable GL (Auto-selected)'
: 'Account number'
" "
/> />
</div> </div>
@@ -1578,12 +1715,7 @@
type="button" type="button"
class="ws-seg__btn ws-seg__btn--dr" class="ws-seg__btn ws-seg__btn--dr"
[class.ws-seg__btn--active-dr]="row.dbCr === 'Dr'" [class.ws-seg__btn--active-dr]="row.dbCr === 'Dr'"
[disabled]=" [disabled]="isGlCreditOnly(row)"
row.acTyp === 'GL' &&
row.typ !== 'Disbursement' &&
row.typ !== 'Fund Transfer' &&
row.typ !== 'Enter Bank Transfer'
"
(click)="$event.stopPropagation(); row.dbCr = 'Dr'" (click)="$event.stopPropagation(); row.dbCr = 'Dr'"
> >
DR DR
@@ -1592,6 +1724,7 @@
type="button" type="button"
class="ws-seg__btn ws-seg__btn--cr" class="ws-seg__btn ws-seg__btn--cr"
[class.ws-seg__btn--active-cr]="row.dbCr === 'Cr'" [class.ws-seg__btn--active-cr]="row.dbCr === 'Cr'"
[disabled]="row.typ === 'Withdrawal Slip'"
(click)="$event.stopPropagation(); row.dbCr = 'Cr'" (click)="$event.stopPropagation(); row.dbCr = 'Cr'"
> >
CR CR
@@ -1628,10 +1761,12 @@
[value]="row.amount ?? ''" [value]="row.amount ?? ''"
(input)="row.amount = +$any($event.target).value; syncShareLinkedAmounts()" (input)="row.amount = +$any($event.target).value; syncShareLinkedAmounts()"
(click)="$event.stopPropagation()" (click)="$event.stopPropagation()"
[placeholder]="row.acTyp === 'SHR' ? '0' : '0.00'" [placeholder]="isMultiDdRow(row) ? 'Per draft' : row.acTyp === 'SHR' ? '0' : '0.00'"
[readonly]="!isClerk && !!(activeWithdrawalRequest || activeClosureRequest)" [readonly]="
isMultiDdRow(row) || (!isClerk && !!(activeWithdrawalRequest || activeClosureRequest))
"
[class.ws-amt-input--locked]=" [class.ws-amt-input--locked]="
!isClerk && !!(activeWithdrawalRequest || activeClosureRequest) isMultiDdRow(row) || (!isClerk && !!(activeWithdrawalRequest || activeClosureRequest))
" "
/> />
</div> </div>
@@ -1642,6 +1777,19 @@
}}/share</span }}/share</span
> >
} }
<!-- On a slip the face value belongs to each draft, so the row total is the
sum of them and is filled in by the dialog rather than typed here. A
clerk-typed figure could disagree with the drafts, and the account would
be debited for a total the drafts do not add up to. -->
@if (isMultiDdRow(row)) {
<span class="ws-f__hint">
{{
row.ddLines?.length
? 'Total of ' + row.ddLines?.length + ' drafts'
: 'Set per draft in Fill DD Details'
}}
</span>
}
</div> </div>
@if ( @if (
row.acTyp === 'SHR' && (row.typ === 'Enter Bank Transfer' || row.typ === 'Fund Transfer') row.acTyp === 'SHR' && (row.typ === 'Enter Bank Transfer' || row.typ === 'Fund Transfer')
@@ -1706,9 +1854,38 @@
<div class="ws-group ws-group--flex"> <div class="ws-group ws-group--flex">
<span class="ws-caption">DD Charges &amp; GST — Admin Rate {{ ddGstPercentage }}%</span> <span class="ws-caption">DD Charges &amp; GST — Admin Rate {{ ddGstPercentage }}%</span>
<div class="ws-group__fields"> <div class="ws-group__fields">
<!-- How many drafts this one debit funds. Typed before the details are filled
in, because it is what decides how many beneficiary forms the dialog
presents. Offered only where the server can honour it: a member
savings/current account debit, never a GL or cash draft. -->
@if (canRowBeMultiDd(row)) {
<div class="ws-f ws-f--amt">
<label
class="ws-f__lbl"
title="Number of demand drafts to raise against this single debit"
>NO. OF DD</label
>
<div class="ws-amt-wrap">
<input
class="ws-f__ctl ws-amt-input"
type="number"
min="1"
[max]="maxDdSlipLines"
step="1"
[value]="ddCountFor(row)"
(input)="onDdCountChange(row, +$any($event.target).value)"
(click)="$event.stopPropagation()"
placeholder="1"
/>
</div>
@if (isMultiDdRow(row)) {
<span class="ws-f__hint">One debit · {{ ddCountFor(row) }} drafts</span>
}
</div>
}
<div class="ws-f ws-f--amt"> <div class="ws-f ws-f--amt">
<label class="ws-f__lbl" title="DD issuance charges / commission">CHARGES</label> <label class="ws-f__lbl" title="DD issuance charges / commission">CHARGES</label>
<div class="ws-amt-wrap"> <div class="ws-amt-wrap" [class.ws-amt-wrap--locked]="isMultiDdRow(row)">
<span class="ws-amt-prefix">₹</span> <span class="ws-amt-prefix">₹</span>
<input <input
class="ws-f__ctl ws-amt-input" class="ws-f__ctl ws-amt-input"
@@ -1717,8 +1894,15 @@
(input)="row.ddCharges = +$any($event.target).value" (input)="row.ddCharges = +$any($event.target).value"
(click)="$event.stopPropagation()" (click)="$event.stopPropagation()"
placeholder="0.00" placeholder="0.00"
[readonly]="isMultiDdRow(row)"
[class.ws-amt-input--locked]="isMultiDdRow(row)"
/> />
</div> </div>
<!-- Charges are levied per draft and can differ between them, so on a slip
they are entered against each draft and totalled here. -->
@if (isMultiDdRow(row)) {
<span class="ws-f__hint">Set per draft</span>
}
</div> </div>
<div class="ws-f ws-f--amt"> <div class="ws-f ws-f--amt">
<label class="ws-f__lbl" title="GST on charges, at the admin-configured rate"> <label class="ws-f__lbl" title="GST on charges, at the admin-configured rate">
@@ -1729,7 +1913,7 @@
<input <input
class="ws-f__ctl ws-amt-input ws-amt-input--locked" class="ws-f__ctl ws-amt-input ws-amt-input--locked"
type="text" type="text"
[value]="ddGstAmountFor(row) | number: '1.2-2'" [value]="ddGstDisplayFor(row)"
readonly readonly
/> />
</div> </div>
@@ -1743,7 +1927,7 @@
<input <input
class="ws-f__ctl ws-amt-input ws-amt-input--locked" class="ws-f__ctl ws-amt-input ws-amt-input--locked"
type="text" type="text"
[value]="ddTotalPayableFor(row)" [value]="ddTotalDisplayFor(row)"
readonly readonly
/> />
</div> </div>
@@ -1756,13 +1940,28 @@
[disabled]="saving" [disabled]="saving"
(click)="openIssueWalkinDd(row); $event.stopPropagation()" (click)="openIssueWalkinDd(row); $event.stopPropagation()"
[title]=" [title]="
isDdAccountDebitRow(row) isMultiDdRow(row)
? 'Opens one beneficiary form per draft; DD numbers are allocated on submission'
: isDdAccountDebitRow(row)
? 'Auto-selects next available leaflet and opens the beneficiary form' ? 'Auto-selects next available leaflet and opens the beneficiary form'
: 'Auto-selects next available leaflet and opens walk-in purchaser form' : 'Auto-selects next available leaflet and opens walk-in purchaser form'
" "
> >
<mat-icon style="font-size: 18px; width: 18px; height: 18px">post_add</mat-icon> <mat-icon style="font-size: 18px; width: 18px; height: 18px">{{
<span>Fill DD Details</span> row.ddDetails || row.ddLines?.length ? 'edit_note' : 'post_add'
}}</mat-icon>
<!-- Names what has been captured, so the clerk can see at a glance which
rows are ready before pressing Save. A slip names its count instead:
its numbers are not allocated until it is submitted. -->
<span>{{
row.ddLines?.length
? row.ddLines?.length + ' Drafts Captured · Edit Slip'
: row.ddDetails
? 'DD ' + row.ddDetails.ddNumber + ' · Edit Details'
: isMultiDdRow(row)
? 'Fill ' + ddCountFor(row) + ' DD Details'
: 'Fill DD Details'
}}</span>
</button> </button>
</div> </div>
</div> </div>
@@ -1851,7 +2050,13 @@
<button class="ws-btn ws-btn--ghost" type="button" (click)="onSearch()">Search</button> <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> <button class="ws-btn ws-btn--mode" type="button">Change Tr.Mode</button>
@if (isClerk) { @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"> <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="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" /> <line x1="5" y1="12" x2="19" y2="12" />
@@ -916,6 +916,20 @@ input.ws-f__ctl--readonly {
font-size: 11px; font-size: 11px;
} }
/* AC.TP shown as a locked label instead of the normal editable select, while the row is filled
from a token-driven request whose real account type row.acTyp cannot show (see
acTypLockedByToken in the component). */
span.ws-f__ctl--locked {
display: inline-flex;
align-items: center;
background: #f1f5f9;
color: #475569;
cursor: not-allowed;
user-select: none;
font-size: 11px;
font-weight: 600;
}
input.ws-amt-input--locked { input.ws-amt-input--locked {
background: #fef9c3; background: #fef9c3;
color: #713f12; color: #713f12;
File diff suppressed because it is too large Load Diff
@@ -16,6 +16,7 @@ import { MatInputModule } from '@angular/material/input';
import { of } from 'rxjs'; import { of } from 'rxjs';
import { catchError } from 'rxjs/operators'; import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service'; import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
interface DenomRow { interface DenomRow {
denominationId: number; denominationId: number;
@@ -67,7 +68,7 @@ export class VaultPositionComponent implements OnInit {
errorMsg = ''; errorMsg = '';
ngOnInit(): void { 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.asOfDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.service 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>Debit</th>
<th>Credit</th> <th>Credit</th>
<th class="num">Amount</th> <th class="num">Amount</th>
<th>Correction</th>
<th>User</th> <th>User</th>
<!-- Two dates, not one, on the system register. <!-- Two dates, not one, on the system register.
A voucher's timestamp is the minute it was written; the posting behind it carries A voucher's timestamp is the minute it was written; the posting behind it carries
@@ -217,6 +218,18 @@
</td> </td>
} }
<td class="num">{{ row.amount | number: '1.2-2' }}</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> <td>{{ row.username || '—' }}</td>
@if (systemOnly) { @if (systemOnly) {
<td class="vr-ledgerdate" [class.vr-ledgerdate--prior]="row.postedAfterClose"> <td class="vr-ledgerdate" [class.vr-ledgerdate--prior]="row.postedAfterClose">
@@ -92,3 +92,8 @@
text-transform: uppercase; text-transform: uppercase;
color: #7c3aed; 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 { MatIconModule } from '@angular/material/icon';
import { catchError, of } from 'rxjs'; import { catchError, of } from 'rxjs';
import { CashierService } from '../cashier.service'; 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. * 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. */ /** One voucher as the register shows it: the number, the two sides, and who raised it. */
interface VoucherRow { interface VoucherRow {
voucherNo: string; 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; voucherType: string | null;
amount: number | null; amount: number | null;
createdAt: string | null; createdAt: string | null;
@@ -81,7 +88,8 @@ interface VoucherReport {
imports: [ imports: [
CommonModule, CommonModule,
FormsModule, FormsModule,
MatIconModule MatIconModule,
VoucherStatusComponent
] ]
}) })
export class VoucherReportComponent implements OnInit { export class VoucherReportComponent implements OnInit {
@@ -227,7 +235,7 @@ export class VoucherReportComponent implements OnInit {
} }
private todayIso(): string { 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')}`; 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 '';
}
}
}
@@ -129,6 +129,31 @@
<span>* Date of Meeting</span> <span>* Date of Meeting</span>
<input type="date" [(ngModel)]="meetingDate" [disabled]="isReview" /> <input type="date" [(ngModel)]="meetingDate" [disabled]="isReview" />
</label> </label>
<label class="inv-form-field">
<span>* Settlement Destination</span>
@if (!isReview) {
<select [ngModel]="settlementMode" (ngModelChange)="onSettlementModeChange($event)">
<option value="SAVINGS">Member's Linked Savings Account</option>
<option value="GL">GL Account</option>
</select>
} @else {
<input
type="text"
[value]="reviewRequest?.settlement_mode === 'GL' ? 'GL Account' : 'Linked Savings Account'"
readonly
/>
}
</label>
@if (!isReview && settlementMode === 'GL') {
<label class="inv-form-field">
<span>* Settlement GL Account</span>
<mifosx-gl-account-selector
[inputFormControl]="toGlAccountControl"
[glAccountList]="glAccountsData"
[required]="true"
></mifosx-gl-account-selector>
</label>
}
<label class="inv-form-field"> <label class="inv-form-field">
<span>* Settlement Payment Type</span> <span>* Settlement Payment Type</span>
@if (!isReview) { @if (!isReview) {
@@ -387,13 +412,24 @@
<td>{{ meetingDate }}</td> <td>{{ meetingDate }}</td>
</tr> </tr>
<tr> <tr>
<td>Settlement Payment Type</td> <td>Settlement Destination</td>
<td>{{ settlementPaymentTypeName }}</td> <td>{{ settlementMode === 'GL' ? 'GL Account' : 'Linked Savings Account' }}</td>
</tr> </tr>
@if (settlementMode === 'GL') {
<tr>
<td>Settlement GL Account</td>
<td>{{ selectedSettlementGlName }}</td>
</tr>
} @else {
<tr> <tr>
<td>Linked Savings Account</td> <td>Linked Savings Account</td>
<td>{{ account?.savingsAccountNumber || 'None — settlement will not run automatically' }}</td> <td>{{ account?.savingsAccountNumber || 'None — settlement will not run automatically' }}</td>
</tr> </tr>
}
<tr>
<td>Settlement Payment Type</td>
<td>{{ settlementPaymentTypeName }}</td>
</tr>
</tbody> </tbody>
</table> </table>
@@ -8,7 +8,7 @@
import { Component, EventEmitter, Input, OnInit, Output, inject } from '@angular/core'; import { Component, EventEmitter, Input, OnInit, Output, inject } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule, UntypedFormControl } from '@angular/forms';
import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
@@ -19,6 +19,9 @@ import { SharesService } from '../../shares/shares.service';
import { ClerkShareWithdrawalService } from '../../cashier/clerk-share-withdrawal.service'; import { ClerkShareWithdrawalService } from '../../cashier/clerk-share-withdrawal.service';
import { CashierService } from '../../cashier/cashier.service'; import { CashierService } from '../../cashier/cashier.service';
import { SearchSelectComponent, SearchSelectOption } from '../../shared/search-select/search-select.component'; import { SearchSelectComponent, SearchSelectOption } from '../../shared/search-select/search-select.component';
import { GlAccountSelectorComponent } from '../../shared/accounting/gl-account-selector/gl-account-selector.component';
import { AccountingService } from '../../accounting/accounting.service';
import { GLAccount } from '../../shared/models/general.model';
export type ShareWdMode = 'create' | 'review'; export type ShareWdMode = 'create' | 'review';
@@ -51,7 +54,8 @@ interface ActivityEntry {
RouterModule, RouterModule,
MatIconModule, MatIconModule,
MatSnackBarModule, MatSnackBarModule,
SearchSelectComponent SearchSelectComponent,
GlAccountSelectorComponent
] ]
}) })
export class ShareWithdrawalRequestComponent implements OnInit { export class ShareWithdrawalRequestComponent implements OnInit {
@@ -60,6 +64,7 @@ export class ShareWithdrawalRequestComponent implements OnInit {
private sharesService = inject(SharesService); private sharesService = inject(SharesService);
private wdService = inject(ClerkShareWithdrawalService); private wdService = inject(ClerkShareWithdrawalService);
private cashierService = inject(CashierService); private cashierService = inject(CashierService);
private accountingService = inject(AccountingService);
private snackBar = inject(MatSnackBar); private snackBar = inject(MatSnackBar);
/** When embedded (e.g. opened as a popup from a list screen instead of routed to directly), mode /** When embedded (e.g. opened as a popup from a list screen instead of routed to directly), mode
@@ -91,6 +96,17 @@ export class ShareWithdrawalRequestComponent implements OnInit {
paymentTypes: any[] = []; paymentTypes: any[] = [];
settlementPaymentTypeId: number | null = null; settlementPaymentTypeId: number | null = null;
/** Where the redeemed value goes. 'SAVINGS' (the long-standing default) credits the member's
* linked savings account. 'GL' lets the clerk pick any postable GL account directly — same
* option the Cashier's Transaction Entry screen already offers on its CR row, now available
* here too so both entry points behave the same way. Naming the product's own Share Sundry GL
* here is still fine: the backend recognises it and books straight to Sundry with no further
* settlement leg (see ClerkShareWithdrawalRequestApiResource#submit). */
settlementMode: 'SAVINGS' | 'GL' = 'SAVINGS';
settlementGlAccountId: number | null = null;
glAccountsData: GLAccount[] = [];
toGlAccountControl = new UntypedFormControl('');
activityLog: ActivityEntry[] = []; activityLog: ActivityEntry[] = [];
reviewRequest: any = null; reviewRequest: any = null;
showRejectDialog = false; showRejectDialog = false;
@@ -132,6 +148,17 @@ export class ShareWithdrawalRequestComponent implements OnInit {
return this.selectedSettlementPaymentType?.name || '—'; return this.selectedSettlementPaymentType?.name || '—';
} }
/** The chosen GL account's display name, for the confirm dialog and the checklist. Falls back to
* the raw id while the GL account list is still loading. */
get selectedSettlementGlName(): string {
if (!this.settlementGlAccountId) {
return '—';
}
return (
this.glAccountsData.find((g) => g.id === this.settlementGlAccountId)?.name || `GL #${this.settlementGlAccountId}`
);
}
get isFormValid(): boolean { get isFormValid(): boolean {
if (!this.account) return false; if (!this.account) return false;
if (!this.requestedShares || this.requestedShares <= 0) return false; if (!this.requestedShares || this.requestedShares <= 0) return false;
@@ -141,6 +168,7 @@ export class ShareWithdrawalRequestComponent implements OnInit {
if (!this.resolutionNumber.trim()) return false; if (!this.resolutionNumber.trim()) return false;
if (!this.meetingDate) return false; if (!this.meetingDate) return false;
if (!this.settlementPaymentTypeId) return false; if (!this.settlementPaymentTypeId) return false;
if (this.settlementMode === 'GL' && !this.settlementGlAccountId) return false;
return true; return true;
} }
@@ -160,10 +188,17 @@ export class ShareWithdrawalRequestComponent implements OnInit {
pass: shares > 0 && shares <= held, pass: shares > 0 && shares <= held,
detail: `${shares} of ${held} shares held` detail: `${shares} of ${held} shares held`
}, },
{ this.settlementMode === 'GL'
? {
label: 'Settlement GL Account selected',
pass: !!this.settlementGlAccountId,
detail: this.settlementGlAccountId ? this.selectedSettlementGlName : 'Missing'
}
: {
label: 'Linked savings account confirmed', label: 'Linked savings account confirmed',
pass: !!this.account?.savingsAccountNumber, pass: !!this.account?.savingsAccountNumber,
detail: this.account?.savingsAccountNumber || 'No linked savings account — settlement cannot run automatically' detail:
this.account?.savingsAccountNumber || 'No linked savings account — settlement cannot run automatically'
}, },
{ {
label: 'Settlement Payment Type selected (non-cash)', label: 'Settlement Payment Type selected (non-cash)',
@@ -214,11 +249,37 @@ export class ShareWithdrawalRequestComponent implements OnInit {
.pipe(catchError(() => of([]))) .pipe(catchError(() => of([])))
.subscribe((types: any[]) => (this.paymentTypes = types ?? [])); .subscribe((types: any[]) => (this.paymentTypes = types ?? []));
this.toGlAccountControl.valueChanges.subscribe((id: any) => {
this.settlementGlAccountId = id ? Number(id) : null;
});
if (this.requestId && this.mode === 'review') { if (this.requestId && this.mode === 'review') {
this.loadReviewRequest(this.requestId); this.loadReviewRequest(this.requestId);
} }
} }
/** Flips the settlement destination. Switching away from GL clears whatever was picked so a
* stale GL id can never ride along on a SAVINGS submission. */
onSettlementModeChange(mode: 'SAVINGS' | 'GL'): void {
this.settlementMode = mode;
if (mode === 'GL') {
this.loadGlAccounts();
} else {
this.settlementGlAccountId = null;
this.toGlAccountControl.setValue('');
}
}
/** Fetched on first use rather than with the page, since most withdrawals settle to savings. */
private loadGlAccounts(): void {
if (this.glAccountsData.length) {
return;
}
this.accountingService.getGlAccounts().subscribe((accounts: GLAccount[]) => {
this.glAccountsData = accounts ?? [];
});
}
onAccountSelected(accountId: string): void { onAccountSelected(accountId: string): void {
this.selectedAccountId = accountId; this.selectedAccountId = accountId;
if (!accountId) { if (!accountId) {
@@ -246,6 +307,11 @@ export class ShareWithdrawalRequestComponent implements OnInit {
this.resolutionNumber = req.resolution_number ?? ''; this.resolutionNumber = req.resolution_number ?? '';
this.meetingDate = req.meeting_date ? String(req.meeting_date).split('T')[0].split(' ')[0] : this.meetingDate; this.meetingDate = req.meeting_date ? String(req.meeting_date).split('T')[0].split(' ')[0] : this.meetingDate;
this.description = req.description ?? ''; this.description = req.description ?? '';
this.settlementMode = req.settlement_mode === 'GL' ? 'GL' : 'SAVINGS';
this.settlementGlAccountId = req.settlement_gl_account_id ? Number(req.settlement_gl_account_id) : null;
if (this.settlementMode === 'GL') {
this.loadGlAccounts();
}
if (req.activity_log) { if (req.activity_log) {
try { try {
@@ -289,7 +355,9 @@ export class ShareWithdrawalRequestComponent implements OnInit {
resolutionNumber: this.resolutionNumber.trim(), resolutionNumber: this.resolutionNumber.trim(),
meetingDate: this.meetingDate, meetingDate: this.meetingDate,
description: this.description, description: this.description,
settlementPaymentTypeId: this.settlementPaymentTypeId settlementPaymentTypeId: this.settlementPaymentTypeId,
settlementMode: this.settlementMode,
...(this.settlementMode === 'GL' ? { settlementGlAccountId: this.settlementGlAccountId } : {})
}; };
this.wdService.submit(payload).subscribe({ this.wdService.submit(payload).subscribe({
next: () => { next: () => {
@@ -643,8 +643,9 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
this.createClientForm.get('degreeQualificationId')?.setValue(''); this.createClientForm.get('degreeQualificationId')?.setValue('');
} }
}); });
// Fetch all constitution options from code ID 17 // Constitution options by code NAME, never by id: code ids differ per tenant (on abc_bank id 17 is the empty
this.clientService.getCodeValuesById(17).subscribe((options) => { // ClientClassification code and Constitution is id 24), so a hardcoded id left the dropdown empty.
this.clientService.getCodeValuesByName('Constitution').subscribe((options) => {
if (options?.length) { if (options?.length) {
this.constitutionOptions = options; this.constitutionOptions = options;
// Sort master by value length descending so longer/more-specific keys match first // Sort master by value length descending so longer/more-specific keys match first
@@ -1321,6 +1321,11 @@
kycForm.get('kycDocumentNumber')?.hasError('pattern') && kycForm.get('kycDocumentNumber')?.touched kycForm.get('kycDocumentNumber')?.hasError('pattern') && kycForm.get('kycDocumentNumber')?.touched
) { ) {
<mat-error>Only letters, numbers, hyphens and slashes allowed</mat-error> <mat-error>Only letters, numbers, hyphens and slashes allowed</mat-error>
} @else if (
kycForm.get('kycDocumentNumber')?.hasError('aadhaarLast4Mismatch') &&
kycForm.get('kycDocumentNumber')?.touched
) {
<mat-error>Last 4 digits do not match the Aadhaar number entered above</mat-error>
} }
</mat-form-field> </mat-form-field>
@@ -8,7 +8,14 @@
import { Component, Input, OnInit, OnChanges, OnDestroy, SimpleChanges, inject } from '@angular/core'; import { Component, Input, OnInit, OnChanges, OnDestroy, SimpleChanges, inject } from '@angular/core';
import { forkJoin, catchError, of } from 'rxjs'; import { forkJoin, catchError, of } from 'rxjs';
import { UntypedFormBuilder, UntypedFormGroup, Validators, FormsModule } from '@angular/forms'; import {
UntypedFormBuilder,
UntypedFormGroup,
Validators,
FormsModule,
AbstractControl,
ValidationErrors
} from '@angular/forms';
import { import {
MatDialog, MatDialog,
MAT_DIALOG_DATA, MAT_DIALOG_DATA,
@@ -567,6 +574,28 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
/** Existing signature document ID (for re-upload/delete) */ /** Existing signature document ID (for re-upload/delete) */
existingSignatureDocId: string = ''; existingSignatureDocId: string = '';
/**
* Cross-field check for the OVD "Document Number" field (kycDocumentNumber): when Aadhaar Card
* is the selected KYC document, its value must identify the same Aadhaar as the aadhaarNumber
* field captured earlier in this step - the last 4 digits are what's compared, since
* aadhaarNumber may hold either the raw 12-digit number or a masked value (masking only ever
* obscures the first 8 digits - see aadhaarMasked in the kycData getter below - so the last 4
* characters are always the real digits either way). Not applicable to any other document type,
* where kycDocumentNumber is unrelated to Aadhaar entirely.
*/
private aadhaarLast4MatchValidator = (control: AbstractControl): ValidationErrors | null => {
if (this.selectedKycDoc?.code !== 'AADHAAR') {
return null;
}
const docNumber = (control.value || '').toString().trim();
const aadhaarValue = (control.parent?.get('aadhaarNumber')?.value || '').toString().trim();
if (docNumber.length < 4 || aadhaarValue.length < 4) {
// Required/pattern validators already flag an empty or too-short value on their own.
return null;
}
return docNumber.slice(-4) === aadhaarValue.slice(-4) ? null : { aadhaarLast4Mismatch: true };
};
/** KYC Form */ /** KYC Form */
kycForm: UntypedFormGroup = this.formBuilder.group({ kycForm: UntypedFormGroup = this.formBuilder.group({
hasPan: [true], hasPan: [true],
@@ -616,7 +645,8 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
[ [
Validators.required, Validators.required,
Validators.pattern(/^[A-Za-z0-9\-\/\s]+$/), Validators.pattern(/^[A-Za-z0-9\-\/\s]+$/),
Validators.maxLength(30) Validators.maxLength(30),
this.aadhaarLast4MatchValidator
] ]
], ],
kycDocIssueDate: [''], kycDocIssueDate: [''],
@@ -739,6 +769,14 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
this.kycForm.get('ecgcSalFlag')?.disable(); this.kycForm.get('ecgcSalFlag')?.disable();
this.kycForm.get('dormantFlag')?.disable(); this.kycForm.get('dormantFlag')?.disable();
} }
// aadhaarLast4MatchValidator (on kycDocumentNumber) reads this control's value directly rather
// than through its own valueChanges, so a re-check has to be triggered explicitly whenever the
// Aadhaar field itself changes - otherwise fixing aadhaarNumber after kycDocumentNumber was
// already entered would leave a stale mismatch error in place.
this.kycForm.get('aadhaarNumber')?.valueChanges.subscribe(() => {
this.kycForm.get('kycDocumentNumber')?.updateValueAndValidity({ onlySelf: true });
});
} }
/** Seed the document-type picker from defaults (entity RBI docs vs. individual OVDs, /** Seed the document-type picker from defaults (entity RBI docs vs. individual OVDs,
@@ -1009,6 +1047,9 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
// Reset uploads + merged preview when document type changes // Reset uploads + merged preview when document type changes
this.kycDocFiles = { front: null, back: null }; this.kycDocFiles = { front: null, back: null };
this.kycDocMergedPreview = null; this.kycDocMergedPreview = null;
// aadhaarLast4MatchValidator only applies while Aadhaar Card is selected - re-check so switching
// to/away from it immediately picks up (or clears) the mismatch error.
this.kycForm.get('kycDocumentNumber')?.updateValueAndValidity({ onlySelf: true });
} }
isKycDocSelected(proof: ProofType): boolean { isKycDocSelected(proof: ProofType): boolean {
@@ -1189,6 +1230,9 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
// Sync chip-picker selection with the prefilled document name // Sync chip-picker selection with the prefilled document name
if (data.kycDocumentName) { if (data.kycDocumentName) {
this.selectedKycDoc = this.findKycDocProofByName(data.kycDocumentName); this.selectedKycDoc = this.findKycDocProofByName(data.kycDocumentName);
// patchValue above ran kycDocumentNumber's validators before selectedKycDoc was known,
// so aadhaarLast4MatchValidator needs a fresh check now that it is.
this.kycForm.get('kycDocumentNumber')?.updateValueAndValidity({ onlySelf: true });
} }
// Set verification statuses from existing data // Set verification statuses from existing data
if (data.panVerified) { if (data.panVerified) {
@@ -61,6 +61,7 @@ import { KycTabComponent } from './clients-view/kyc-tab/kyc-tab.component';
import { CkycTabComponent } from './clients-view/ckyc-tab/ckyc-tab.component'; import { CkycTabComponent } from './clients-view/ckyc-tab/ckyc-tab.component';
import { Form60TabComponent } from './clients-view/form60-tab/form60-tab.component'; import { Form60TabComponent } from './clients-view/form60-tab/form60-tab.component';
import { Form121TabComponent } from './clients-view/form121-tab/form121-tab.component'; import { Form121TabComponent } from './clients-view/form121-tab/form121-tab.component';
import { tdsConfigResolver } from '../letter-mgmt/certificates/form-15gh/tds-config.service';
import { EntityClassificationTabComponent } from './clients-view/entity-classification-tab/entity-classification-tab.component'; import { EntityClassificationTabComponent } from './clients-view/entity-classification-tab/entity-classification-tab.component';
import { AuthorizedSignatoriesTabComponent } from './clients-view/authorized-signatories-tab/authorized-signatories-tab.component'; import { AuthorizedSignatoriesTabComponent } from './clients-view/authorized-signatories-tab/authorized-signatories-tab.component';
import { familyAllowedGuard, nomineeAllowedGuard } from './utils/family-nominee.guard'; import { familyAllowedGuard, nomineeAllowedGuard } from './utils/family-nominee.guard';
@@ -198,6 +199,7 @@ const routes: Routes = [
{ {
path: 'form121', path: 'form121',
component: Form121TabComponent, component: Form121TabComponent,
resolve: { tdsConfig: tdsConfigResolver },
data: { title: 'Form 121 / TDS', breadcrumb: 'Form 121 / TDS', routeParamBreadcrumb: false } data: { title: 'Form 121 / TDS', breadcrumb: 'Form 121 / TDS', routeParamBreadcrumb: false }
}, },
{ {
@@ -202,9 +202,14 @@
<div> <div>
<strong>Already available</strong> <strong>Already available</strong>
<span class="ckyc-existing__source">from {{ existing.ADDRESS_PROOF.source }}</span> <span class="ckyc-existing__source">from {{ existing.ADDRESS_PROOF.source }}</span>
@if (existing.ADDRESS_PROOF.previewUnavailable) {
<span class="ckyc-existing__warn">stored file unavailable</span>
}
</div> </div>
</div> </div>
@if (existing.ADDRESS_PROOF.previewUrl || existing.ADDRESS_PROOF.previewUrlBack) { @if (
(existing.ADDRESS_PROOF.previewUrl || existing.ADDRESS_PROOF.previewUrlBack) && isImage('ADDRESS_PROOF')
) {
<div class="ckyc-existing__thumbs"> <div class="ckyc-existing__thumbs">
@if (existing.ADDRESS_PROOF.previewUrl) { @if (existing.ADDRESS_PROOF.previewUrl) {
<img <img
@@ -222,6 +227,17 @@
} }
</div> </div>
} }
@if (existing.ADDRESS_PROOF.previewUrl) {
<button
mat-stroked-button
type="button"
class="ckyc-existing__view"
(click)="openDocument('ADDRESS_PROOF')"
>
<fa-icon icon="eye"></fa-icon>
&nbsp;View document
</button>
}
<div class="ckyc-existing__meta"> <div class="ckyc-existing__meta">
<fa-icon icon="file-alt"></fa-icon> <fa-icon icon="file-alt"></fa-icon>
<span>{{ existing.ADDRESS_PROOF.name }}</span> <span>{{ existing.ADDRESS_PROOF.name }}</span>
@@ -299,9 +315,12 @@
<div> <div>
<strong>Already available</strong> <strong>Already available</strong>
<span class="ckyc-existing__source">from {{ existing.PHOTO_PROOF.source }}</span> <span class="ckyc-existing__source">from {{ existing.PHOTO_PROOF.source }}</span>
@if (existing.PHOTO_PROOF.previewUnavailable) {
<span class="ckyc-existing__warn">stored file unavailable</span>
}
</div> </div>
</div> </div>
@if (existing.PHOTO_PROOF.previewUrl || existing.PHOTO_PROOF.previewUrlBack) { @if ((existing.PHOTO_PROOF.previewUrl || existing.PHOTO_PROOF.previewUrlBack) && isImage('PHOTO_PROOF')) {
<div class="ckyc-existing__thumbs"> <div class="ckyc-existing__thumbs">
@if (existing.PHOTO_PROOF.previewUrl) { @if (existing.PHOTO_PROOF.previewUrl) {
<img <img
@@ -319,6 +338,17 @@
} }
</div> </div>
} }
@if (existing.PHOTO_PROOF.previewUrl) {
<button
mat-stroked-button
type="button"
class="ckyc-existing__view"
(click)="openDocument('PHOTO_PROOF')"
>
<fa-icon icon="eye"></fa-icon>
&nbsp;View document
</button>
}
<div class="ckyc-existing__meta"> <div class="ckyc-existing__meta">
<fa-icon icon="id-card"></fa-icon> <fa-icon icon="id-card"></fa-icon>
<span>{{ existing.PHOTO_PROOF.name }}</span> <span>{{ existing.PHOTO_PROOF.name }}</span>
@@ -393,11 +423,25 @@
<div> <div>
<strong>Already available</strong> <strong>Already available</strong>
<span class="ckyc-existing__source">from {{ existing.CLIENT_IMAGE.source }}</span> <span class="ckyc-existing__source">from {{ existing.CLIENT_IMAGE.source }}</span>
@if (existing.CLIENT_IMAGE.previewUnavailable) {
<span class="ckyc-existing__warn">stored file unavailable</span>
}
</div> </div>
</div> </div>
@if (existing.CLIENT_IMAGE.previewUrl) { @if (existing.CLIENT_IMAGE.previewUrl && isImage('CLIENT_IMAGE')) {
<img [src]="existing.CLIENT_IMAGE.previewUrl" alt="Client photo" class="ckyc-existing__thumb" /> <img [src]="existing.CLIENT_IMAGE.previewUrl" alt="Client photo" class="ckyc-existing__thumb" />
} }
@if (existing.CLIENT_IMAGE.previewUrl) {
<button
mat-stroked-button
type="button"
class="ckyc-existing__view"
(click)="openDocument('CLIENT_IMAGE')"
>
<fa-icon icon="eye"></fa-icon>
&nbsp;View document
</button>
}
<button <button
mat-stroked-button mat-stroked-button
type="button" type="button"
@@ -448,15 +492,29 @@
<div> <div>
<strong>Already available</strong> <strong>Already available</strong>
<span class="ckyc-existing__source">from {{ existing.CLIENT_SIGNATURE.source }}</span> <span class="ckyc-existing__source">from {{ existing.CLIENT_SIGNATURE.source }}</span>
@if (existing.CLIENT_SIGNATURE.previewUnavailable) {
<span class="ckyc-existing__warn">stored file unavailable</span>
}
</div> </div>
</div> </div>
@if (existing.CLIENT_SIGNATURE.previewUrl) { @if (existing.CLIENT_SIGNATURE.previewUrl && isImage('CLIENT_SIGNATURE')) {
<img <img
[src]="existing.CLIENT_SIGNATURE.previewUrl" [src]="existing.CLIENT_SIGNATURE.previewUrl"
alt="Client signature" alt="Client signature"
class="ckyc-existing__thumb ckyc-existing__thumb--wide" class="ckyc-existing__thumb ckyc-existing__thumb--wide"
/> />
} }
@if (existing.CLIENT_SIGNATURE.previewUrl) {
<button
mat-stroked-button
type="button"
class="ckyc-existing__view"
(click)="openDocument('CLIENT_SIGNATURE')"
>
<fa-icon icon="eye"></fa-icon>
&nbsp;View document
</button>
}
<div class="ckyc-existing__meta"> <div class="ckyc-existing__meta">
<fa-icon icon="file-signature"></fa-icon> <fa-icon icon="file-signature"></fa-icon>
<span>{{ existing.CLIENT_SIGNATURE.name }}</span> <span>{{ existing.CLIENT_SIGNATURE.name }}</span>
@@ -623,6 +623,35 @@
} }
} }
/* Opens the uploaded artefact full size in a new tab. Sits directly under the thumbnail, and is
the only way to inspect a PDF - which cannot render as an inline <img>. */
.ckyc-existing__view {
align-self: flex-start;
color: #1d6fd0;
border-color: #bcd8f5;
background: #fff;
font-size: 13px;
font-weight: 600;
&:hover {
background: #eef6ff;
}
}
/* Shown when a document row exists but its file could not be fetched from the content store,
so "Already available" is not silently misleading. */
.ckyc-existing__warn {
display: inline-block;
margin-left: 8px;
padding: 1px 8px;
border-radius: 10px;
background: #fdecec;
border: 1px solid #f3c2c2;
color: #a12b2b;
font-size: 11px;
font-weight: 600;
}
.ckyc-existing { .ckyc-existing {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -36,11 +36,41 @@ interface ExistingArtefact {
* already writes/reads for address proof; now shared by the photo/identity proof too. */ * already writes/reads for address proof; now shared by the photo/identity proof too. */
description?: string; description?: string;
fileCount?: number; fileCount?: number;
/** Set when the stored file could not be fetched - the row exists but its content is gone. */
previewUnavailable?: boolean;
/** Stored MIME type, so the card knows whether it can render an inline thumbnail. */
mimeType?: string;
} }
type ProofKind = 'ADDRESS_PROOF' | 'PHOTO_PROOF'; type ProofKind = 'ADDRESS_PROOF' | 'PHOTO_PROOF';
type CkycDocKind = ProofKind | 'CLIENT_IMAGE' | 'CLIENT_SIGNATURE'; type CkycDocKind = ProofKind | 'CLIENT_IMAGE' | 'CLIENT_SIGNATURE';
/**
* Client-side upload guards, kept deliberately in step with the backend so a file that passes here
* is not rejected server-side:
* - 5 MB matches `spring.servlet.multipart.max-file-size`
* - the extension list is the intersection of the backend `fineract.content.regex-whitelist`
* with what these cards can actually render.
* The dialog's `accept=` is only a file-picker hint and is trivially bypassed, so the real check
* runs here, on the file the dialog hands back, before anything is sent.
*/
const MAX_UPLOAD_BYTES = 5 * 1024 * 1024;
const IMAGE_EXTENSIONS = [
'png',
'jpeg',
'jpg'
];
const DOCUMENT_EXTENSIONS = [
...IMAGE_EXTENSIONS,
'pdf'
];
/** Cards that render an inline <img>, so a PDF would not display in them. */
const IMAGE_ONLY_KINDS: CkycDocKind[] = [
'CLIENT_IMAGE',
'CLIENT_SIGNATURE'
];
@Component({ @Component({
selector: 'mifosx-ckyc-tab', selector: 'mifosx-ckyc-tab',
templateUrl: './ckyc-tab.component.html', templateUrl: './ckyc-tab.component.html',
@@ -150,6 +180,11 @@ export class CkycTabComponent implements OnInit, OnDestroy {
ngOnInit(): void { ngOnInit(): void {
this.route.parent.params.subscribe((params: any) => { this.route.parent.params.subscribe((params: any) => {
this.clientId = params['clientId']; this.clientId = params['clientId'];
// Angular reuses this component when only the clientId in the route changes, and
// loadExistingArtefacts() only ever *assigns* what it finds - it has no "not found" branch.
// Without clearing first, a client with no photo (or no signature, or no proof) keeps showing
// the previous client's card.
this.resetArtefactState();
this.loadClientInfo(); this.loadClientInfo();
this.loadAccounts(); this.loadAccounts();
this.loadCkycData(); this.loadCkycData();
@@ -157,6 +192,46 @@ export class CkycTabComponent implements OnInit, OnDestroy {
}); });
} }
/**
* Returns every per-client card to its empty state, so nothing from the client just viewed can
* survive into the next one. Covers all four cards plus the details staged for an upload - a file
* or document number left over from another client must never be submitted against this one.
*/
private resetArtefactState(): void {
this.revokePreviewUrls();
this.existing = {
ADDRESS_PROOF: { present: false },
PHOTO_PROOF: { present: false },
CLIENT_IMAGE: { present: false },
CLIENT_SIGNATURE: { present: false }
};
this.selectedProofType = { ADDRESS_PROOF: null, PHOTO_PROOF: null };
this.docFiles = {
ADDRESS_PROOF: { front: null, back: null },
PHOTO_PROOF: { front: null, back: null }
};
this.documentDetails = {
ADDRESS_PROOF: { documentKey: '', issueDate: null, expiryDate: null },
PHOTO_PROOF: { documentKey: '', issueDate: null, expiryDate: null }
};
this.clientImageFile = null;
this.clientSignatureFile = null;
this.uploadErrors = {
ADDRESS_PROOF: null,
PHOTO_PROOF: null,
CLIENT_IMAGE: null,
CLIENT_SIGNATURE: null
};
}
/** Frees the blob URLs held by the current cards before their references are dropped. */
private revokePreviewUrls(): void {
Object.values(this.existing).forEach((e) => {
if (e.previewUrl?.startsWith('blob:')) URL.revokeObjectURL(e.previewUrl);
if (e.previewUrlBack?.startsWith('blob:')) URL.revokeObjectURL(e.previewUrlBack);
});
}
private loadClientInfo(): void { private loadClientInfo(): void {
this.route.parent.data.subscribe((data: any) => { this.route.parent.data.subscribe((data: any) => {
const cv = data?.clientViewData; const cv = data?.clientViewData;
@@ -280,19 +355,29 @@ export class CkycTabComponent implements OnInit, OnDestroy {
present: true, present: true,
source: 'Client registration', source: 'Client registration',
name: signatureDoc.fileName || 'Client signature', name: signatureDoc.fileName || 'Client signature',
documentId: signatureDoc.id documentId: signatureDoc.id,
mimeType: signatureDoc.type
}; };
// Fetch blob for preview // Same endpoint getClientSignatureImage() uses, but opted out of the global error alert:
// a m_document row whose file is no longer in the content store returns 500, and the card
// below reports that itself. Without `silent` the interceptor pops an "Internal Server
// Error" modal on every page load, for a failure the user cannot act on.
this.clientsService this.clientsService
.getClientSignatureImage(this.clientId, signatureDoc.id) .downloadClientDocument(this.clientId, String(signatureDoc.id), true)
.pipe(takeUntil(this.destroy$)) .pipe(takeUntil(this.destroy$))
.subscribe({ .subscribe({
next: (blob: any) => { next: (blob: any) => {
if (blob) { if (blob && blob.size > 0) {
this.existing.CLIENT_SIGNATURE.previewUrl = URL.createObjectURL(blob); this.existing.CLIENT_SIGNATURE.previewUrl = URL.createObjectURL(blob);
} else {
// A 200 with an empty body still means there is nothing to show. Without this the
// card would claim "Already available" with no thumbnail and no explanation.
this.existing.CLIENT_SIGNATURE.previewUnavailable = true;
} }
}, },
error: () => undefined error: () => {
this.existing.CLIENT_SIGNATURE.previewUnavailable = true;
}
}); });
} }
@@ -364,6 +449,7 @@ export class CkycTabComponent implements OnInit, OnDestroy {
const list = Array.isArray(docs) ? docs : []; const list = Array.isArray(docs) ? docs : [];
if (!list.length) return; if (!list.length) return;
this.existing[kind].fileCount = list.length; this.existing[kind].fileCount = list.length;
this.existing[kind].previewUnavailable = false;
const byNewest = (a: any, b: any) => b.id - a.id; const byNewest = (a: any, b: any) => b.id - a.id;
const label = (d: any) => (d?.name || d?.fileName || '').toString(); const label = (d: any) => (d?.name || d?.fileName || '').toString();
@@ -372,6 +458,9 @@ export class CkycTabComponent implements OnInit, OnDestroy {
[...list].filter((d: any) => /front/i.test(label(d))).sort(byNewest)[0] || [...list].filter((d: any) => /front/i.test(label(d))).sort(byNewest)[0] ||
[...list].filter((d: any) => d.id !== back?.id).sort(byNewest)[0]; [...list].filter((d: any) => d.id !== back?.id).sort(byNewest)[0];
// A proof can be scanned to PDF, which no <img> can render - remember what was stored so the
// card offers "View document" instead of a broken thumbnail.
if (front) this.existing[kind].mimeType = front.type;
if (front) this.downloadIdentifierDocPreview(identifierId, front.id, kind, 'front'); if (front) this.downloadIdentifierDocPreview(identifierId, front.id, kind, 'front');
if (back && back.id !== front?.id) this.downloadIdentifierDocPreview(identifierId, back.id, kind, 'back'); if (back && back.id !== front?.id) this.downloadIdentifierDocPreview(identifierId, back.id, kind, 'back');
}); });
@@ -388,12 +477,19 @@ export class CkycTabComponent implements OnInit, OnDestroy {
.pipe(takeUntil(this.destroy$)) .pipe(takeUntil(this.destroy$))
.subscribe({ .subscribe({
next: (blob: any) => { next: (blob: any) => {
if (!blob) return; if (!blob || blob.size === 0) {
this.existing[kind].previewUnavailable = true;
return;
}
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
if (slot === 'front') this.existing[kind].previewUrl = url; if (slot === 'front') this.existing[kind].previewUrl = url;
else this.existing[kind].previewUrlBack = url; else this.existing[kind].previewUrlBack = url;
}, },
error: () => undefined // The row exists but its file is gone from the content store. Saying so on the card beats
// showing "Already available" next to a blank space.
error: () => {
this.existing[kind].previewUnavailable = true;
}
}); });
} }
@@ -452,6 +548,18 @@ export class CkycTabComponent implements OnInit, OnDestroy {
ref.afterClosed().subscribe((result: CkycUploadDialogResult | null) => { ref.afterClosed().subscribe((result: CkycUploadDialogResult | null) => {
if (!result) return; if (!result) return;
// The dialog's `accept=` filter is only a hint to the file picker and is trivially bypassed,
// so the size/type guard runs here on what actually came back. Catching it now turns a raw
// server rejection into a message on the card.
const rejection =
this.validationError(result.front, kind) ?? (result.back ? this.validationError(result.back, kind) : null);
if (rejection) {
this.uploadErrors[kind] = rejection;
return;
}
this.uploadErrors[kind] = null;
if (isProofKind) { if (isProofKind) {
const proofKind = kind as ProofKind; const proofKind = kind as ProofKind;
this.selectedProofType[proofKind] = result.proofType || null; this.selectedProofType[proofKind] = result.proofType || null;
@@ -470,6 +578,66 @@ export class CkycTabComponent implements OnInit, OnDestroy {
}); });
} }
/** Extensions a given card can both store and render. */
private allowedExtensions(kind: CkycDocKind): string[] {
return IMAGE_ONLY_KINDS.includes(kind) ? IMAGE_EXTENSIONS : DOCUMENT_EXTENSIONS;
}
/**
* Rejects a file the backend would refuse (or the card could not render) before it is uploaded,
* so the user gets a clear message instead of a raw server error. Returns null when the file is
* acceptable.
*/
private validationError(file: File | null | undefined, kind: CkycDocKind): string | null {
if (!file) {
return null;
}
const allowed = this.allowedExtensions(kind);
const extension = (file.name.split('.').pop() || '').toLowerCase();
if (!allowed.includes(extension)) {
return `Unsupported file type ".${extension}". Allowed: ${allowed.map((e) => e.toUpperCase()).join(', ')}.`;
}
if (file.size > MAX_UPLOAD_BYTES) {
const mb = (file.size / (1024 * 1024)).toFixed(1);
return `File is ${mb} MB. The maximum allowed size is 5 MB.`;
}
if (file.size === 0) {
return 'The selected file is empty.';
}
return null;
}
/**
* True when the stored file can be shown as an inline thumbnail. A PDF cannot, so its card falls
* back to the "View document" button alone rather than rendering a broken image.
*/
isImage(kind: CkycDocKind): boolean {
const entry = this.existing[kind];
// The profile image arrives as a `data:image/...;base64,` URL, which declares its own type -
// it has neither a stored MIME type nor a file extension for the checks below to read.
if (entry?.previewUrl?.startsWith('data:')) {
return entry.previewUrl.startsWith('data:image/');
}
if (entry?.mimeType) {
return entry.mimeType.startsWith('image/');
}
// Older rows can have no stored MIME type - fall back to the file extension.
const extension = (entry?.name?.split('.').pop() || '').toLowerCase();
return IMAGE_EXTENSIONS.includes(extension);
}
/**
* Opens the uploaded document full size in a new tab. Uses the blob already fetched for the
* thumbnail, so it works for PDFs too and costs no extra request.
*/
openDocument(kind: CkycDocKind): void {
const url = this.existing[kind]?.previewUrl;
if (url) {
window.open(url, '_blank', 'noopener');
}
}
/** Reads back the "Issue date: yyyy-MM-dd, Expiry date: yyyy-MM-dd" convention this tab (and /** Reads back the "Issue date: yyyy-MM-dd, Expiry date: yyyy-MM-dd" convention this tab (and
* client-india-address-step, for address proof) writes into the identifier's description. */ * client-india-address-step, for address proof) writes into the identifier's description. */
private parseDescriptionDates(description: string | undefined): { issueDate: Date | null; expiryDate: Date | null } { private parseDescriptionDates(description: string | undefined): { issueDate: Date | null; expiryDate: Date | null } {
@@ -533,6 +701,10 @@ export class CkycTabComponent implements OnInit, OnDestroy {
request$.pipe(takeUntil(this.destroy$)).subscribe({ request$.pipe(takeUntil(this.destroy$)).subscribe({
next: () => { next: () => {
this.uploading[documentType] = false; this.uploading[documentType] = false;
// The card is gated on `present`; without this a successful upload left it reading
// "Not uploaded yet". uploadProofDocument() sets the same flag for the proof cards.
this.existing[key].present = true;
this.existing[key].source = this.existing[key].source || 'CKYC upload';
this.alertService.alert({ type: 'success', message: 'Document uploaded successfully.' }); this.alertService.alert({ type: 'success', message: 'Document uploaded successfully.' });
this.refreshPreview(documentType); this.refreshPreview(documentType);
}, },
@@ -653,13 +825,22 @@ export class CkycTabComponent implements OnInit, OnDestroy {
if (this.existing[staleKey].previewUrlBack?.startsWith('blob:')) { if (this.existing[staleKey].previewUrlBack?.startsWith('blob:')) {
URL.revokeObjectURL(this.existing[staleKey].previewUrlBack!); URL.revokeObjectURL(this.existing[staleKey].previewUrlBack!);
} }
// The upload that triggered this refresh is what repairs a previously missing file, so the
// warning has to be cleared before the re-read - otherwise a fixed card keeps the old badge.
this.existing[staleKey].previewUrl = undefined;
this.existing[staleKey].previewUrlBack = undefined;
this.existing[staleKey].previewUnavailable = false;
switch (documentType) { switch (documentType) {
case 'CLIENT_IMAGE': case 'CLIENT_IMAGE':
this.clientsService this.clientsService
.getClientProfileImage(this.clientId) .getClientProfileImage(this.clientId)
.pipe(takeUntil(this.destroy$)) .pipe(takeUntil(this.destroy$))
.subscribe((photo: any) => { .subscribe((photo: any) => {
if (photo) this.existing.CLIENT_IMAGE.previewUrl = photo; if (photo) {
this.existing.CLIENT_IMAGE.previewUrl = photo;
} else {
this.existing.CLIENT_IMAGE.previewUnavailable = true;
}
}); });
break; break;
case 'CLIENT_SIGNATURE': case 'CLIENT_SIGNATURE':
@@ -672,11 +853,21 @@ export class CkycTabComponent implements OnInit, OnDestroy {
?.sort((a: any, b: any) => b.id - a.id)?.[0]; ?.sort((a: any, b: any) => b.id - a.id)?.[0];
if (!doc) return; if (!doc) return;
this.existing.CLIENT_SIGNATURE.documentId = doc.id; this.existing.CLIENT_SIGNATURE.documentId = doc.id;
this.existing.CLIENT_SIGNATURE.mimeType = doc.type;
this.clientsService this.clientsService
.getClientSignatureImage(this.clientId, doc.id) .downloadClientDocument(this.clientId, String(doc.id), true)
.pipe(takeUntil(this.destroy$)) .pipe(takeUntil(this.destroy$))
.subscribe((blob: any) => { .subscribe({
if (blob) this.existing.CLIENT_SIGNATURE.previewUrl = URL.createObjectURL(blob); next: (blob: any) => {
if (blob && blob.size > 0) {
this.existing.CLIENT_SIGNATURE.previewUrl = URL.createObjectURL(blob);
} else {
this.existing.CLIENT_SIGNATURE.previewUnavailable = true;
}
},
error: () => {
this.existing.CLIENT_SIGNATURE.previewUnavailable = true;
}
}); });
}); });
break; break;
@@ -690,10 +881,6 @@ export class CkycTabComponent implements OnInit, OnDestroy {
ngOnDestroy(): void { ngOnDestroy(): void {
this.destroy$.next(); this.destroy$.next();
this.destroy$.complete(); this.destroy$.complete();
// Release object URLs this.revokePreviewUrls();
Object.values(this.existing).forEach((e) => {
if (e.previewUrl?.startsWith('blob:')) URL.revokeObjectURL(e.previewUrl);
if (e.previewUrlBack?.startsWith('blob:')) URL.revokeObjectURL(e.previewUrlBack);
});
} }
} }
@@ -55,7 +55,10 @@
<div class="f121__note"> <div class="f121__note">
<mat-icon>info_outline</mat-icon> <mat-icon>info_outline</mat-icon>
<div> <div>
@if (!dateOfBirth) { @if (configMissing) {
TDS rates for FY {{ fy }} are not configured, so the tax cannot be computed. Set them up under Organization
› TDS Configuration.
} @else if (!dateOfBirth) {
Date of birth is not on record, so the senior-citizen threshold cannot be determined. Add it on the Personal Date of birth is not on record, so the senior-citizen threshold cannot be determined. Add it on the Personal
Data tab. Data tab.
} @else { } @else {
@@ -113,8 +116,8 @@
<div class="f121__note f121__note--warn"> <div class="f121__note f121__note--warn">
<mat-icon>warning_amber</mat-icon> <mat-icon>warning_amber</mat-icon>
<div> <div>
A Form 121 declaration is on record for this year, but withholding is still switched on for one or more A declaration is on record for this year, but one or more deposits still carry a tax group. Those accounts
deposits. Turn it off on those accounts, or tax will keep being deducted. deduct on their own and ignore the declaration — remove the tax group from them.
</div> </div>
</div> </div>
} }
@@ -189,20 +192,90 @@
} }
</span> </span>
</div> </div>
@if (declarationOnRecord && declarationFy) { @if (declarationRecord; as d) {
<div class="f121__kv-row"> <div class="f121__kv-row">
<span>Declared for</span> <span>Declared for</span>
<span>FY {{ declarationFy }}</span> <span>FY {{ d.fy }} · Form {{ d.formType }}</span>
</div> </div>
} <div class="f121__kv-row">
@if (declarationOnRecord && !acceptanceDateKnown) { <span>Accepted on</span>
<div class="f121__kv-row f121__kv-row--muted">
<span>Acceptance date</span>
<span> <span>
Not captured — the declaration is treated as covering the whole year. A mid-year acceptance would change {{ d.acceptedOn | date: 'd MMM y' }} — no TDS on interest credited from this day to 31 March. Interest
the figures below. credited before it is taxed as usual.
</span> </span>
</div> </div>
@if (d.uin) {
<div class="f121__kv-row">
<span>UIN</span>
<span class="f121__mono">{{ d.uin }}</span>
</div>
}
<div class="f121__kv-row">
<span></span>
<span>
<button type="button" class="f121__btn f121__btn--warn" (click)="revokeDeclaration()">
Withdraw declaration
</button>
</span>
</div>
} @else if (fy && !configMissing) {
<div class="f121__kv-row">
<span></span>
<span>
@if (!showDeclarationForm) {
<button type="button" class="f121__btn" (click)="openDeclarationForm()" [disabled]="!panWellFormed">
Record {{ formLabel }} for FY {{ fy }}
</button>
@if (!panWellFormed) {
<div class="muted">A valid PAN must be on the KYC first — a declaration without one is void.</div>
}
}
</span>
</div>
@if (showDeclarationForm) {
<div class="f121__decl-form">
<label>
<span>Accepted on *</span>
<input type="date" [(ngModel)]="newDeclaration.acceptedOn" />
</label>
<label>
<span>UIN</span>
<input type="text" [(ngModel)]="newDeclaration.uin" placeholder="Allotted on acceptance" />
</label>
<label>
<span>Estimated income for the year (₹)</span>
<input type="number" min="0" [(ngModel)]="newDeclaration.estimatedIncome" />
</label>
@if (declarationError) {
<div class="f121__note f121__note--warn">
<mat-icon>error_outline</mat-icon>
<div>{{ declarationError }}</div>
</div>
}
<div class="f121__decl-actions">
<button type="button" class="f121__btn" (click)="showDeclarationForm = false">Cancel</button>
<button
type="button"
class="f121__btn f121__btn--primary"
(click)="saveDeclaration()"
[disabled]="savingDeclaration"
>
{{ savingDeclaration ? 'Saving…' : 'Save declaration' }}
</button>
</div>
</div>
}
}
@for (old of declarations; track old.id) {
@if (old.revoked && old.fy === fy) {
<div class="f121__kv-row f121__kv-row--muted">
<span>Withdrawn</span>
<span>
Form {{ old.formType }} accepted {{ old.acceptedOn | date: 'd MMM y' }}, withdrawn
{{ old.revokedOn | date: 'd MMM y' }} — {{ old.revokeReason }}
</span>
</div>
}
} }
@if (eligibility) { @if (eligibility) {
@for (reason of eligibility.reasons; track reason) { @for (reason of eligibility.reasons; track reason) {
@@ -332,3 +332,61 @@
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-weight: 600; font-weight: 600;
} }
/* ── Recording a declaration ── */
.f121__btn {
padding: 6px 14px;
border: 1px solid #d1d5db;
border-radius: 6px;
background: #fff;
font: inherit;
font-size: 12.5px;
font-weight: 600;
cursor: pointer;
&:disabled {
opacity: 0.55;
cursor: not-allowed;
}
&--primary {
border-color: #1d4ed8;
background: #1d4ed8;
color: #fff;
}
&--warn {
border-color: #b45309;
color: #b45309;
}
}
.f121__decl-form {
display: grid;
gap: 10px;
padding: 12px 16px;
label {
display: grid;
gap: 4px;
font-size: 12px;
span {
color: #6b7280;
font-weight: 600;
}
input {
max-width: 280px;
padding: 6px 8px;
border: 1px solid #d1d5db;
border-radius: 6px;
font: inherit;
}
}
}
.f121__decl-actions {
display: flex;
gap: 8px;
}
@@ -23,6 +23,8 @@ import { SavingsService } from 'app/savings/savings.service';
import { FixedDepositsService } from 'app/deposits/fixed-deposits/fixed-deposits.service'; import { FixedDepositsService } from 'app/deposits/fixed-deposits/fixed-deposits.service';
import { RecurringDepositsService } from 'app/deposits/recurring-deposits/recurring-deposits.service'; import { RecurringDepositsService } from 'app/deposits/recurring-deposits/recurring-deposits.service';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { TdsDeclarationRecord, TdsReportsService } from 'app/tds-reports/tds-reports.service';
import { TdsDeductee } from 'app/tds-reports/tds-reports-data';
/** Domain logic */ /** Domain logic */
import { import {
@@ -111,6 +113,7 @@ export class Form121TabComponent implements OnInit, OnDestroy {
private savingsService = inject(SavingsService); private savingsService = inject(SavingsService);
private fixedDepositsService = inject(FixedDepositsService); private fixedDepositsService = inject(FixedDepositsService);
private recurringDepositsService = inject(RecurringDepositsService); private recurringDepositsService = inject(RecurringDepositsService);
private tdsService = inject(TdsReportsService);
private destroy$ = new Subject<void>(); private destroy$ = new Subject<void>();
@@ -124,23 +127,25 @@ export class Form121TabComponent implements OnInit, OnDestroy {
pan = ''; pan = '';
panWellFormed = false; panWellFormed = false;
// ── Declaration on record ── // ── Declarations on record ──
declarationOnRecord = false; /**
* Every declaration the customer has given, each for one year and from the day the branch
* accepted it. The KYC record only ever held a type and a year, which made a September
* declaration shield interest back to April and let last year's form run on.
*/
declarations: TdsDeclarationRecord[] = [];
/** /**
* The form actually in force for the selected year. From FY 2026-27 a single * The form actually in force for the selected year. From FY 2026-27 a single
* Form 121 replaced the 15G/15H split, so this is derived from the year rather * Form 121 replaced the 15G/15H split, so this is derived from the year rather
* than from the payee's age. * than from the payee's age.
*/ */
declarationForm: DeclarationForm = '121'; declarationForm: DeclarationForm = '121';
/** Kind recorded at KYC, meaningful only for a pre-2026 year. */
declarationKind: '15G' | '15H' = '15G'; // ── Recording a declaration ──
declarationFy = ''; showDeclarationForm = false;
/** savingDeclaration = false;
* The KYC step records only the year, not the date the branch accepted the declarationError = '';
* declaration, so it has to be treated as covering the whole year. Surfaced on newDeclaration = { acceptedOn: '', uin: '', estimatedIncome: null as number | null };
* screen rather than hidden — a mid-year acceptance would change the figures.
*/
acceptanceDateKnown = false;
eligibility: EligibilityResult | null = null; eligibility: EligibilityResult | null = null;
@@ -153,11 +158,13 @@ export class Form121TabComponent implements OnInit, OnDestroy {
schedule: TdsSchedule | null = null; schedule: TdsSchedule | null = null;
actualTds = 0; actualTds = 0;
tdsSaved = 0; tdsSaved = 0;
/** The selected year has no entry on Organization › TDS Configuration. */
configMissing = false;
/** Raw account inputs, kept so the year selector can recompute cheaply. */ /** Raw account inputs, kept so the year selector can recompute cheaply. */
private accountInputs: (TdsAccountInput & { meta: Form121AccountRow })[] = []; private accountInputs: (TdsAccountInput & { meta: Form121AccountRow; taxLines: InterestCredit[] })[] = [];
/** Withheld-tax postings by FY, from the ledger. */ /** This customer's FD/RD interest received in savings, and TDS taken there, per year. */
private actualTdsByFy = new Map<string, number>(); private platformByFy = new Map<string, TdsDeductee | null>();
ngOnInit(): void { ngOnInit(): void {
this.route.parent.params.pipe(takeUntil(this.destroy$)).subscribe((params: any) => { this.route.parent.params.pipe(takeUntil(this.destroy$)).subscribe((params: any) => {
@@ -180,13 +187,15 @@ export class Form121TabComponent implements OnInit, OnDestroy {
forkJoin({ forkJoin({
client: this.clientsService.getClientData(this.clientId, true).pipe(catchError(() => of(null))), client: this.clientsService.getClientData(this.clientId, true).pipe(catchError(() => of(null))),
kyc: this.clientsService.getClientKyc(this.clientId, true).pipe(catchError(() => of(null))), kyc: this.clientsService.getClientKyc(this.clientId, true).pipe(catchError(() => of(null))),
accounts: this.clientsService.getClientAccountData(this.clientId).pipe(catchError(() => of(null))) accounts: this.clientsService.getClientAccountData(this.clientId).pipe(catchError(() => of(null))),
declarations: this.tdsService.getDeclarations(Number(this.clientId)).pipe(catchError(() => of([])))
}) })
.pipe( .pipe(
takeUntil(this.destroy$), takeUntil(this.destroy$),
switchMap(({ client, kyc, accounts }) => { switchMap(({ client, kyc, accounts, declarations }) => {
this.applyClient(client); this.applyClient(client);
this.applyKyc(kyc); this.applyKyc(kyc);
this.declarations = declarations || [];
const deposits = this.depositAccountsOf(accounts); const deposits = this.depositAccountsOf(accounts);
return deposits.length ? forkJoin(deposits.map((a) => this.fetchAccount(a))) : of([] as any[]); return deposits.length ? forkJoin(deposits.map((a) => this.fetchAccount(a))) : of([] as any[]);
}), }),
@@ -210,11 +219,8 @@ export class Form121TabComponent implements OnInit, OnDestroy {
private applyKyc(kyc: any): void { private applyKyc(kyc: any): void {
this.pan = (kyc?.panNumber ?? '').toUpperCase(); this.pan = (kyc?.panNumber ?? '').toUpperCase();
this.panWellFormed = isWellFormedPan(this.pan); this.panWellFormed = isWellFormedPan(this.pan);
this.declarationOnRecord = !!kyc?.form15Applicable; this.declarationForm =
this.declarationKind = kyc?.form15Type === '15H' ? '15H' : '15G'; this.fy && FY_TAX_CONFIG[normaliseFy(this.fy)] ? declarationFormFor(this.fy, this.dateOfBirth) : '121';
this.declarationForm = this.fy ? declarationFormFor(this.fy, this.dateOfBirth) : '121';
this.declarationFy = kyc?.form15FY ?? '';
this.acceptanceDateKnown = false;
} }
/** Savings, FD and RD accounts — everything that can credit interest. */ /** Savings, FD and RD accounts — everything that can credit interest. */
@@ -248,7 +254,7 @@ export class Form121TabComponent implements OnInit, OnDestroy {
*/ */
private ingest(details: any[]): void { private ingest(details: any[]): void {
this.accountInputs = []; this.accountInputs = [];
this.actualTdsByFy = new Map<string, number>(); this.platformByFy = new Map<string, TdsDeductee | null>();
const fys = new Set<string>(); const fys = new Set<string>();
const clientIdNum = Number(this.clientId); const clientIdNum = Number(this.clientId);
@@ -269,6 +275,7 @@ export class Form121TabComponent implements OnInit, OnDestroy {
}; };
const credits: InterestCredit[] = []; const credits: InterestCredit[] = [];
const taxLines: InterestCredit[] = [];
let tdsDeducted = 0; let tdsDeducted = 0;
for (const t of d.transactions ?? []) { for (const t of d.transactions ?? []) {
@@ -286,8 +293,7 @@ export class Form121TabComponent implements OnInit, OnDestroy {
} else if (t.transactionType?.withholdTax) { } else if (t.transactionType?.withholdTax) {
const amount = t.amount ?? 0; const amount = t.amount ?? 0;
tdsDeducted += amount; tdsDeducted += amount;
const txnFy = fyOf(iso); taxLines.push({ date: iso, amount, accountNo: ref.accountNo });
this.actualTdsByFy.set(txnFy, (this.actualTdsByFy.get(txnFy) ?? 0) + amount);
} }
} }
@@ -310,12 +316,13 @@ export class Form121TabComponent implements OnInit, OnDestroy {
deducteeClientId: deductee deducteeClientId: deductee
}; };
this.accountInputs.push({ ...ref, credits, meta }); this.accountInputs.push({ ...ref, credits, taxLines, meta });
} }
this.availableFys = [...fys].sort().reverse(); this.availableFys = [...fys].sort().reverse();
if (!this.availableFys.includes(this.fy)) { if (!this.availableFys.includes(this.fy)) {
this.fy = this.availableFys[0] ?? (this.declarationFy ? normaliseFy(this.declarationFy) : ''); this.fy =
this.availableFys[0] ?? this.declarations[0]?.fy ?? Object.keys(FY_TAX_CONFIG).sort().reverse()[0] ?? '';
} }
} }
@@ -333,14 +340,37 @@ export class Form121TabComponent implements OnInit, OnDestroy {
} }
const year = normaliseFy(this.fy); const year = normaliseFy(this.fy);
this.configMissing = !FY_TAX_CONFIG[year];
// TDS is tracked on FD/RD interest once it reaches savings, and deducted there. Those figures live
// on the platform, not in the deposit's own transactions, so they are fetched once per year and
// the computation waits for them.
if (!this.platformByFy.has(year)) {
this.loadPlatformYear(year);
return;
}
const platform = this.platformByFy.get(year);
for (const a of this.accountInputs) {
if (!a.meta.tdsApplicable) {
continue;
}
const match = platform?.accounts.find((p) => p.accountNo === a.accountNo);
a.credits = match?.credits ?? [];
a.taxLines = match?.deductions ?? [];
}
// Per-account interest for the selected year, for the three lists. // Per-account interest for the selected year, for the three lists.
for (const a of this.accountInputs) { for (const a of this.accountInputs) {
a.meta.interest = a.credits.filter((c) => fyOf(c.date) === year).reduce((s, c) => s + c.amount, 0); a.meta.interest = a.credits.filter((c) => fyOf(c.date) === year).reduce((s, c) => s + c.amount, 0);
// The selected year only - this used to total every year the account had ever been taxed in.
a.meta.tdsDeducted = a.meta.tdsApplicable
? a.taxLines.filter((c) => fyOf(c.date) === year).reduce((s, c) => s + c.amount, 0)
: 0;
} }
this.actualTds = this.actualTdsByFy.get(year) ?? 0; // What was actually debited for this customer as deductee, wherever the savings account sits.
this.actualTds = this.accountInputs.filter((a) => a.meta.tdsApplicable).reduce((s, a) => s + a.meta.tdsDeducted, 0);
if (!this.dateOfBirth) { if (!this.dateOfBirth || this.configMissing) {
return; return;
} }
@@ -378,21 +408,95 @@ export class Form121TabComponent implements OnInit, OnDestroy {
return applicable.some((a) => a.meta.mode === 'QUARTERLY') ? 'QUARTERLY' : 'ANNUAL'; return applicable.some((a) => a.meta.mode === 'QUARTERLY') ? 'QUARTERLY' : 'ANNUAL';
} }
/** The declaration, but only when it was filed for the year being shown. */ private loadPlatformYear(year: string): void {
private declarationForSelectedFy(): Declaration | null { this.tdsService
if (!this.declarationOnRecord || !this.fy) { .getDepositInterest(Number(year.slice(0, 4)), Number(this.clientId))
.pipe(
catchError(() => of([])),
takeUntil(this.destroy$)
)
.subscribe((rows) => {
this.platformByFy.set(year, rows.find((r) => `${r.clientId}` === `${this.clientId}`) ?? null);
this.recompute();
});
}
/** The live declaration recorded for the year being shown, if any. */
get declarationRecord(): TdsDeclarationRecord | null {
if (!this.fy) {
return null; return null;
} }
const year = normaliseFy(this.fy); const year = normaliseFy(this.fy);
if (this.declarationFy && normaliseFy(this.declarationFy) !== year) { return this.declarations.find((d) => d.fy === year && !d.revoked) ?? null;
}
get declarationOnRecord(): boolean {
return !!this.declarationRecord;
}
/** The declaration as the engine takes it — operative from the day it was accepted. */
private declarationForSelectedFy(): Declaration | null {
const d = this.declarationRecord;
if (!d) {
return null; return null;
} }
return { const kind = d.formType === '15H' ? '15H' : d.formType === '15G' ? '15G' : formKindFor(this.dateOfBirth, d.fy);
kind: this.declarationKind, return { kind, acceptedOn: d.acceptedOn, fy: d.fy, uin: d.uin || undefined };
// No acceptance date on record — treated as covering the whole year. }
acceptedOn: `${year.slice(0, 4)}-04-01`,
fy: year openDeclarationForm(): void {
}; this.newDeclaration = { acceptedOn: new Date().toISOString().slice(0, 10), uin: '', estimatedIncome: null };
this.declarationError = '';
this.showDeclarationForm = true;
}
saveDeclaration(): void {
if (!this.newDeclaration.acceptedOn) {
this.declarationError = 'Enter the date the branch accepted the form.';
return;
}
this.savingDeclaration = true;
this.declarationError = '';
this.tdsService
.createDeclaration({
clientId: Number(this.clientId),
fy: normaliseFy(this.fy),
formType: declarationFormFor(this.fy, this.dateOfBirth),
acceptedOn: this.newDeclaration.acceptedOn,
uin: this.newDeclaration.uin || undefined,
estimatedIncome: this.newDeclaration.estimatedIncome
})
.subscribe({
next: (rows) => {
this.savingDeclaration = false;
this.showDeclarationForm = false;
this.declarations = rows || [];
this.recompute();
},
error: (err: any) => {
this.savingDeclaration = false;
this.declarationError =
err?.error?.errors?.[0]?.defaultUserMessage || err?.error?.defaultUserMessage || 'Could not save.';
}
});
}
revokeDeclaration(): void {
const d = this.declarationRecord;
if (!d) {
return;
}
const reason = prompt('Why is this declaration being withdrawn? From today, TDS will be deducted again.');
if (!reason) {
return;
}
this.tdsService.revokeDeclaration(d.id, reason).subscribe({
next: (rows) => {
this.declarations = rows || [];
this.recompute();
},
error: (err: any) => alert(err?.error?.defaultUserMessage || 'Could not revoke the declaration.')
});
} }
// ───────────────────────── Template helpers ───────────────────────── // ───────────────────────── Template helpers ─────────────────────────
@@ -411,7 +515,7 @@ export class Form121TabComponent implements OnInit, OnDestroy {
/** The form the payee would file for the selected year. */ /** The form the payee would file for the selected year. */
get formLabel(): string { get formLabel(): string {
if (!this.fy) { if (!this.fy || this.configMissing) {
return '—'; return '—';
} }
const form = declarationFormFor(this.fy, this.dateOfBirth); const form = declarationFormFor(this.fy, this.dateOfBirth);
@@ -470,11 +574,12 @@ export class Form121TabComponent implements OnInit, OnDestroy {
} }
/** /**
* A declaration is on record but deposit accounts are still set to withhold — * A declaration is on record but a deposit still carries a stock Fineract tax group. Those
* the branch has not switched them off. * accounts withhold on their own rules and never consult the declaration record, so the form
* does not stop their deduction until the tax group is removed.
*/ */
get declarationNotAppliedToAccounts(): boolean { get declarationNotAppliedToAccounts(): boolean {
return !!this.declarationForSelectedFy() && this.liableAccounts.some((a) => a.withHoldTax); return !!this.declarationForSelectedFy() && this.liableAccounts.some((a) => !!a.taxGroup);
} }
/** /**
@@ -591,16 +591,13 @@
<i class="fa fa-calendar section-header__icon"></i> <i class="fa fa-calendar section-header__icon"></i>
<h3 class="section-header__title">{{ 'labels.heading.Recurring Deposit Accounts' | translate }}</h3> <h3 class="section-header__title">{{ 'labels.heading.Recurring Deposit Accounts' | translate }}</h3>
</div> </div>
<div class="section-header__right">
<button mat-raised-button color="primary" (click)="toggleRecurringAccountsOverview()">
{{ viewAccountsLabel(showClosedRecurringAccounts) | translate }}
</button>
</div>
</div> </div>
@if (!showClosedRecurringAccounts) { <!-- Active and ended recurring deposits share one table; the Status column says how each one ended (matured or
closed early), so the client's RD history stays visible without switching views. -->
@if (savingAccounts) {
<div class="table-wrapper"> <div class="table-wrapper">
<table mat-table [dataSource]="savingAccounts | accountsFilter: 'saving' : 'open' : 'isRecurring'"> <table mat-table [dataSource]="savingAccounts | accountsFilter: 'saving' : 'all' : 'isRecurring'">
<ng-container matColumnDef="Account No"> <ng-container matColumnDef="Account No">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Account No' | translate }}</th> <th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Account No' | translate }}</th>
<td mat-cell *matCellDef="let element"> <td mat-cell *matCellDef="let element">
@@ -614,6 +611,15 @@
<mifosx-long-text textValue="{{ element.productName }}" chars="35"></mifosx-long-text> <mifosx-long-text textValue="{{ element.productName }}" chars="35"></mifosx-long-text>
</td> </td>
</ng-container> </ng-container>
<ng-container matColumnDef="Status">
<th mat-header-cell *matHeaderCellDef>Status</th>
<td mat-cell *matCellDef="let element">
<span class="rd-status" [class.rd-status--ended]="element.timeline?.closedOnDate">{{ rdStatusLabel(element) }}</span>
@if (element.timeline?.closedOnDate) {
<div class="rd-status__date">on {{ element.timeline.closedOnDate | dateFormat }}</div>
}
</td>
</ng-container>
<ng-container matColumnDef="Last Active"> <ng-container matColumnDef="Last Active">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Last Active' | translate }}</th> <th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Last Active' | translate }}</th>
<td mat-cell *matCellDef="let element">{{ element.lastActiveTransactionDate | dateFormat }}</td> <td mat-cell *matCellDef="let element">{{ element.lastActiveTransactionDate | dateFormat }}</td>
@@ -638,7 +644,7 @@
<i class="fa fa-check"></i> <i class="fa fa-check"></i>
</button> </button>
} }
@if (!element.status.submittedAndPendingApproval && !element.status.active) { @if (element.status.approved) {
<button <button
class="account-action-button" class="account-action-button"
mat-raised-button mat-raised-button
@@ -664,7 +670,7 @@
</ng-container> </ng-container>
<ng-container matColumnDef="no-data"> <ng-container matColumnDef="no-data">
<td mat-footer-cell *matFooterCellDef [attr.colspan]="openSavingsColumns.length"> <td mat-footer-cell *matFooterCellDef [attr.colspan]="recurringColumns.length">
<div class="table-empty-state"> <div class="table-empty-state">
<i class="fa fa-folder-open-o table-empty-state__icon"></i> <i class="fa fa-folder-open-o table-empty-state__icon"></i>
<p class="table-empty-state__text"> <p class="table-empty-state__text">
@@ -674,67 +680,17 @@
</td> </td>
</ng-container> </ng-container>
<tr mat-header-row *matHeaderRowDef="openSavingsColumns"></tr> <tr mat-header-row *matHeaderRowDef="recurringColumns"></tr>
<tr <tr
mat-row mat-row
*matRowDef="let row; columns: openSavingsColumns" *matRowDef="let row; columns: recurringColumns"
[routerLink]="['../', 'recurring-deposits-accounts', row.id, 'general']" [routerLink]="['../', 'recurring-deposits-accounts', row.id, 'general']"
class="select-row" class="select-row"
></tr> ></tr>
<tr <tr
mat-footer-row mat-footer-row
*matFooterRowDef=" *matFooterRowDef="
(savingAccounts | accountsFilter: 'saving' : 'open' : 'isRecurring')?.length === 0 ? ['no-data'] : [] (savingAccounts | accountsFilter: 'saving' : 'all' : 'isRecurring')?.length === 0 ? ['no-data'] : []
"
></tr>
</table>
</div>
}
<!-- Closed Recurring Deposit Accounts -->
@if (showClosedRecurringAccounts) {
<div class="table-wrapper">
<table mat-table [dataSource]="savingAccounts | accountsFilter: 'saving' : 'closed' : 'isRecurring'">
<ng-container matColumnDef="Account No">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Account No' | translate }}</th>
<td mat-cell *matCellDef="let element">
<i class="fa fa-stop" [ngClass]="element.status.code | statusLookup"></i>
<mifosx-account-number accountNo="{{ element.accountNo }}"></mifosx-account-number>
</td>
</ng-container>
<ng-container matColumnDef="Saving Account">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Recurring Deposit Product' | translate }}</th>
<td mat-cell *matCellDef="let element">
<mifosx-long-text textValue="{{ element.productName }}" chars="35"></mifosx-long-text>
</td>
</ng-container>
<ng-container matColumnDef="Closed Date">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Closed Date' | translate }}</th>
<td mat-cell *matCellDef="let element">{{ element.timeline.closedOnDate | dateFormat }}</td>
</ng-container>
<ng-container matColumnDef="no-data">
<td mat-footer-cell *matFooterCellDef [attr.colspan]="closedSavingsColumns.length">
<div class="table-empty-state">
<i class="fa fa-folder-open-o table-empty-state__icon"></i>
<p class="table-empty-state__text">
{{ 'labels.messages.No Closed Recurring Deposit Accounts Available' | translate }}
</p>
</div>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="closedSavingsColumns"></tr>
<tr
mat-row
*matRowDef="let row; columns: closedSavingsColumns"
[routerLink]="['../', 'recurring-deposits-accounts', row.id, 'general']"
class="select-row"
></tr>
<tr
mat-footer-row
*matFooterRowDef="
(savingAccounts | accountsFilter: 'saving' : 'closed' : 'isRecurring')?.length === 0 ? ['no-data'] : []
" "
></tr> ></tr>
</table> </table>
@@ -419,3 +419,24 @@ table[mat-table] {
font-size: 24px; font-size: 24px;
z-index: 1; z-index: 1;
} }
.rd-status {
display: inline-block;
padding: 2px 8px;
border-radius: 10px;
font-size: 12px;
font-weight: 600;
background: #dcfce7;
color: #166534;
&--ended {
background: #e2e8f0;
color: #475569;
}
}
.rd-status__date {
margin-top: 2px;
font-size: 11px;
color: #64748b;
}
@@ -187,6 +187,15 @@ export class GeneralTabComponent implements OnDestroy {
'Saving Account', 'Saving Account',
'Closed Date' 'Closed Date'
]; ];
/** Closed Recurring Deposit Accounts Columns - with how each one ended, for tracking. */
recurringColumns: string[] = [
'Account No',
'Saving Account',
'Status',
'Last Active',
'Balance',
'Actions'
];
/** Open Shares Accounts Columns */ /** Open Shares Accounts Columns */
openSharesColumns: string[] = [ openSharesColumns: string[] = [
'Account No', 'Account No',
@@ -475,6 +484,30 @@ export class GeneralTabComponent implements OnDestroy {
); );
} }
/** Where a recurring deposit stands - for one that has ended, how it ended. */
rdStatusLabel(account: any): string {
switch (account?.status?.code) {
case 'savingsAccountStatusType.active':
return 'Active';
case 'savingsAccountStatusType.matured':
return 'Matured (not closed)';
case 'savingsAccountStatusType.submitted.and.pending.approval':
return 'Pending Approval';
case 'savingsAccountStatusType.approved':
return 'Approved';
case 'savingsAccountStatusType.closed':
return 'Matured & Closed';
case 'savingsAccountStatusType.pre.mature.closure':
return 'Premature Closed';
case 'savingsAccountStatusType.rejected':
return 'Rejected';
case 'savingsAccountStatusType.withdrawn.by.applicant':
return 'Withdrawn by Client';
default:
return account?.status?.value ?? '';
}
}
viewAccountsLabel(closed: boolean): string { viewAccountsLabel(closed: boolean): string {
if (closed) { if (closed) {
return 'labels.buttons.View Active Accounts'; return 'labels.buttons.View Active Accounts';
@@ -1142,6 +1142,46 @@
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
<!-- Risk profile. These are determinations about the customer rather than about anything they
transacted, and the re-rating policy reads them directly: a PEP or a sanctions match holds the
rating at High however ordinary the period's activity looks, and the declared income is what
turnover is measured against. Until they are recorded the policy has nothing to test. -->
<div class="full-span kyc-doc-fields">
<div class="kyc-doc-fields__head">
<h4 class="kyc-doc-fields__title">Risk Profile</h4>
<span class="kyc-doc-fields__hint">Used by the periodic risk review</span>
</div>
<div class="kyc-risk-flags">
<mat-checkbox formControlName="isPep">
Politically Exposed Person
<span class="kyc-risk-hint">Holds the rating at High; never lowered on ordinary activity</span>
</mat-checkbox>
<mat-checkbox formControlName="sanctionsMatch">
Sanctions / watch-list match
<span class="kyc-risk-hint">UNSC, MHA or internal watch list</span>
</mat-checkbox>
<mat-checkbox formControlName="adverseMediaMatch">
Adverse media / regulatory action
<span class="kyc-risk-hint">FIR, court proceedings or published action</span>
</mat-checkbox>
</div>
<div class="kyc-doc-fields__grid">
<mat-form-field appearance="outline">
<mat-label>Declared annual income (₹)</mat-label>
<input matInput type="number" min="0" formControlName="declaredAnnualIncome" />
<mat-hint>As stated by the customer</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Declared annual turnover (₹)</mat-label>
<input matInput type="number" min="0" formControlName="declaredAnnualTurnover" />
<mat-hint>For a business customer</mat-hint>
</mat-form-field>
</div>
</div>
<!-- Officially Valid Document. Shown when one is on file or the alert is asking for <!-- Officially Valid Document. Shown when one is on file or the alert is asking for
it, so re-collecting an expired document is done on this form rather than it, so re-collecting an expired document is done on this form rather than
sending the officer somewhere else. --> sending the officer somewhere else. -->
@@ -1207,9 +1247,7 @@
</div> </div>
@if (!docScansLoading && !docScans.front && !docScans.back) { @if (!docScansLoading && !docScans.front && !docScans.back) {
<div class="kyc-doc-scans__empty"> <div class="kyc-doc-scans__empty">No scan is on file for this document. Upload one below.</div>
No scan is on file for this document. Upload one below.
</div>
} }
<div class="kyc-doc-scans__sides"> <div class="kyc-doc-scans__sides">
@@ -1807,3 +1807,28 @@
color: #15803d; color: #15803d;
} }
} }
/* ── Risk profile ────────────────────────────────────────────────────────────
Determinations about the customer, which the periodic risk review reads directly. Grouped with
the document block so the form reads as "what we know about them" rather than scattering a PEP
flag among the identifier fields. */
.kyc-doc-fields__hint {
font-size: 12px;
color: #64748b;
}
.kyc-risk-flags {
display: flex;
flex-direction: column;
gap: 10px;
padding: 4px 0 14px;
}
/* Sits under its checkbox label: the consequence of ticking the box matters more than the box. */
.kyc-risk-hint {
display: block;
font-size: 12px;
color: #64748b;
line-height: 1.4;
}
@@ -140,6 +140,13 @@ export class KycTabComponent implements OnInit, OnDestroy {
1, 1,
[Validators.required] [Validators.required]
], ],
// Risk profile. Determinations about the customer, which the periodic risk review reads directly - a PEP or a
// sanctions match holds the rating at High whatever the period's activity looks like.
isPep: [false],
sanctionsMatch: [false],
adverseMediaMatch: [false],
declaredAnnualIncome: [null],
declaredAnnualTurnover: [null],
// The officially valid document, so a lapsed one can be re-collected on this form. The // The officially valid document, so a lapsed one can be re-collected on this form. The
// backend has always accepted these four; only the fields were missing. // backend has always accepted these four; only the fields were missing.
kycDocumentName: [''], kycDocumentName: [''],
@@ -175,7 +182,10 @@ export class KycTabComponent implements OnInit, OnDestroy {
docScans: { front: DocScanSide | null; back: DocScanSide | null } = { front: null, back: null }; docScans: { front: DocScanSide | null; back: DocScanSide | null } = { front: null, back: null };
/** The two sides, in display order, so the template loops rather than repeating itself. */ /** The two sides, in display order, so the template loops rather than repeating itself. */
readonly docScanSlots: ('front' | 'back')[] = ['front', 'back']; readonly docScanSlots: ('front' | 'back')[] = [
'front',
'back'
];
docScanOf(slot: 'front' | 'back') { docScanOf(slot: 'front' | 'back') {
return this.docScans[slot]; return this.docScans[slot];
@@ -221,6 +231,11 @@ export class KycTabComponent implements OnInit, OnDestroy {
this.kycForm.patchValue({ this.kycForm.patchValue({
kycType: data.kycType || 1, kycType: data.kycType || 1,
riskCategory: data.riskCategory || 1, riskCategory: data.riskCategory || 1,
isPep: !!data.isPep,
sanctionsMatch: !!data.sanctionsMatch,
adverseMediaMatch: !!data.adverseMediaMatch,
declaredAnnualIncome: data.declaredAnnualIncome ?? null,
declaredAnnualTurnover: data.declaredAnnualTurnover ?? null,
panNumber: data.panNumber || '', panNumber: data.panNumber || '',
// Aadhaar is stored masked on the server; user must re-enter // Aadhaar is stored masked on the server; user must re-enter
// the full 12-digit number to re-verify, so don't pre-fill. // the full 12-digit number to re-verify, so don't pre-fill.
@@ -446,6 +461,29 @@ export class KycTabComponent implements OnInit, OnDestroy {
return new Date(); return new Date();
} }
/**
* The customer's risk profile, as every save has to carry it.
*
* <p>
* Sent from every path that writes the KYC record rather than only the main Save, because the form holds one set of
* values: a save triggered from the PAN declaration or the Aadhaar receipt would otherwise write the record without
* these and silently clear a PEP flag the officer had just ticked.
*/
private buildRiskProfileFields() {
const v = this.kycForm.value;
return {
isPep: !!v.isPep,
sanctionsMatch: !!v.sanctionsMatch,
adverseMediaMatch: !!v.adverseMediaMatch,
// Blank stays null rather than becoming zero: an income nobody asked for is unknown, and a zero would read as a
// customer who declared no income at all.
declaredAnnualIncome:
v.declaredAnnualIncome === '' || v.declaredAnnualIncome == null ? null : v.declaredAnnualIncome,
declaredAnnualTurnover:
v.declaredAnnualTurnover === '' || v.declaredAnnualTurnover == null ? null : v.declaredAnnualTurnover
};
}
private buildExceptionFields() { private buildExceptionFields() {
const panVerified = this.verificationStatus.pan === 'VERIFIED' || this.kycData?.panVerified === true; const panVerified = this.verificationStatus.pan === 'VERIFIED' || this.kycData?.panVerified === true;
const aadhaarVerified = this.verificationStatus.aadhaar === 'VERIFIED' || this.kycData?.aadhaarVerified === true; const aadhaarVerified = this.verificationStatus.aadhaar === 'VERIFIED' || this.kycData?.aadhaarVerified === true;
@@ -484,6 +522,7 @@ export class KycTabComponent implements OnInit, OnDestroy {
// dateFormat is required so the backend can parse exceptionResolvedOn when an exception closes. // dateFormat is required so the backend can parse exceptionResolvedOn when an exception closes.
dateFormat: 'dd MMMM yyyy', dateFormat: 'dd MMMM yyyy',
...this.buildDocumentFields(), ...this.buildDocumentFields(),
...this.buildRiskProfileFields(),
...this.buildExceptionFields() ...this.buildExceptionFields()
}; };
@@ -530,6 +569,7 @@ export class KycTabComponent implements OnInit, OnDestroy {
remarks: this.kycForm.value.remarks || null, remarks: this.kycForm.value.remarks || null,
riskCategory: this.kycForm.value.riskCategory || 1, riskCategory: this.kycForm.value.riskCategory || 1,
dateFormat: 'dd MMMM yyyy', dateFormat: 'dd MMMM yyyy',
...this.buildRiskProfileFields(),
...this.buildExceptionFields() ...this.buildExceptionFields()
}; };
@@ -729,6 +769,7 @@ export class KycTabComponent implements OnInit, OnDestroy {
dateFormat, dateFormat,
riskCategory: this.kycForm.value.riskCategory || 1, riskCategory: this.kycForm.value.riskCategory || 1,
...declarationFields, ...declarationFields,
...this.buildRiskProfileFields(),
...this.buildExceptionFields() ...this.buildExceptionFields()
}; };
@@ -889,6 +930,7 @@ export class KycTabComponent implements OnInit, OnDestroy {
dateFormat, dateFormat,
riskCategory: this.kycForm.value.riskCategory || 1, riskCategory: this.kycForm.value.riskCategory || 1,
...receiptFields, ...receiptFields,
...this.buildRiskProfileFields(),
...this.buildExceptionFields() ...this.buildExceptionFields()
}; };
@@ -1417,7 +1459,10 @@ I further confirm that:
this.docScansLoading = false; this.docScansLoading = false;
// This runs again after every replace, so the previews being thrown away are revoked // This runs again after every replace, so the previews being thrown away are revoked
// rather than left holding their blobs. // rather than left holding their blobs.
for (const old of [this.docScans.front, this.docScans.back]) { for (const old of [
this.docScans.front,
this.docScans.back
]) {
if (old?.previewUrl) { if (old?.previewUrl) {
URL.revokeObjectURL(old.previewUrl); URL.revokeObjectURL(old.previewUrl);
} }
@@ -1459,7 +1504,10 @@ I further confirm that:
} }
this.docScans = sides; this.docScans = sides;
for (const key of ['front', 'back'] as const) { for (const key of [
'front',
'back'
] as const) {
if (sides[key]) { if (sides[key]) {
this.loadScanPreview(sides[key]!); this.loadScanPreview(sides[key]!);
} }
@@ -1520,7 +1568,9 @@ I further confirm that:
error: (err: any) => { error: (err: any) => {
this.docScanUploading = null; this.docScanUploading = null;
this.docScanError = this.docScanError =
err?.error?.defaultUserMessage || err?.error?.developerMessage || `Could not replace the ${label.toLowerCase()} side.`; err?.error?.defaultUserMessage ||
err?.error?.developerMessage ||
`Could not replace the ${label.toLowerCase()} side.`;
} }
}); });
} }
@@ -1703,7 +1753,10 @@ I further confirm that:
this.destroy$.next(); this.destroy$.next();
this.destroy$.complete(); this.destroy$.complete();
// Object URLs are held by the browser until revoked, so the scan blobs would outlive the tab. // Object URLs are held by the browser until revoked, so the scan blobs would outlive the tab.
for (const side of [this.docScans.front, this.docScans.back]) { for (const side of [
this.docScans.front,
this.docScans.back
]) {
if (side?.previewUrl) { if (side?.previewUrl) {
URL.revokeObjectURL(side.previewUrl); URL.revokeObjectURL(side.previewUrl);
} }
+33 -6
View File
@@ -569,8 +569,19 @@ export class ClientsService {
return this.http.get(`/clients/${clientId}/documents`); return this.http.get(`/clients/${clientId}/documents`);
} }
downloadClientDocument(parentEntityId: string, documentId: string) { /**
return this.http.get(`/clients/${parentEntityId}/documents/${documentId}/attachment`, { responseType: 'blob' }); * Fetches a client document's stored file.
*
* `silent` opts the request out of ErrorHandlerInterceptor's alert. Pass it when the caller
* renders its own failure state - notably thumbnail fetches, where a `m_document` row whose file
* is no longer in the content store returns 500 and would otherwise pop a modal the user can do
* nothing about. The interceptor still rethrows, so the caller's error callback continues to run.
*/
downloadClientDocument(parentEntityId: string, documentId: string, silent = false) {
return this.http.get(`/clients/${parentEntityId}/documents/${documentId}/attachment`, {
responseType: 'blob',
...(silent ? { context: new HttpContext().set(SILENT_HTTP_ERROR, true) } : {})
});
} }
uploadClientDocument(clientId: string, documentData: any) { uploadClientDocument(clientId: string, documentData: any) {
@@ -992,12 +1003,28 @@ export class ClientsService {
return this.http.post(`/clients/${clientId}/entityclassification/${category}`, data); return this.http.post(`/clients/${clientId}/entityclassification/${category}`, data);
} }
getClientCkyc(clientId: string): Observable<any> { /**
return this.http.get(`/clients/${clientId}/ckyc`); * Uploads a single CKYC artefact against the client, using the platform's generic document
* endpoint (DocumentApiResource, POST /v1/{entityType}/{entityId}/documents). There is no
* CKYC-specific document API on the backend - the earlier `/clients/{id}/ckyc/documents` path
* was never implemented and returned 404.
*
* The multipart body must carry `file`, `name` and `description`; the caller builds those so the
* stored document stays identifiable by CKYC artefact type. See ckyc-tab.component.ts.
*/
uploadCkycDocument(clientId: string, formData: FormData): Observable<any> {
return this.http.post(`/clients/${clientId}/documents`, formData);
} }
uploadCkycDocument(clientId: string, formData: FormData): Observable<any> { /**
return this.http.post(`/clients/${clientId}/ckyc/documents`, formData); * Replaces the content of an existing client document in place (DocumentApiResource PUT
* /v1/{entityType}/{entityId}/documents/{documentId}), keeping the same document id.
*
* Used by the CKYC screen's "Upload new version" so a re-upload updates the existing artefact
* rather than adding another row. Takes the same multipart body as the create call.
*/
replaceClientDocument(clientId: string, documentId: string, formData: FormData): Observable<any> {
return this.http.put(`/clients/${clientId}/documents/${documentId}`, formData);
} }
/** /**
@@ -377,6 +377,8 @@
</button> </button>
} }
@if (selectedTabIndex < totalTabs - 1) { @if (selectedTabIndex < totalTabs - 1) {
<!-- Tooltip sits on a wrapper: a disabled button fires no mouse events, so it can't host it. -->
<span [matTooltip]="currentStepBlockerMessage" [matTooltipDisabled]="!currentStepBlockerMessage">
<button mat-raised-button color="primary" (click)="onNext()" [disabled]="savingClient || !currentStepValid"> <button mat-raised-button color="primary" (click)="onNext()" [disabled]="savingClient || !currentStepValid">
@if (savingClient) { @if (savingClient) {
<mat-progress-spinner diameter="18" mode="indeterminate" class="m-r-10"></mat-progress-spinner> <mat-progress-spinner diameter="18" mode="indeterminate" class="m-r-10"></mat-progress-spinner>
@@ -390,6 +392,7 @@
<fa-icon icon="arrow-right" class="m-l-10"></fa-icon> <fa-icon icon="arrow-right" class="m-l-10"></fa-icon>
} }
</button> </button>
</span>
} }
@if (selectedTabIndex === totalTabs - 1) { @if (selectedTabIndex === totalTabs - 1) {
<button mat-raised-button color="primary" [routerLink]="['../']"> <button mat-raised-button color="primary" [routerLink]="['../']">
@@ -393,6 +393,12 @@ export class EditClientComponent {
if (this.selectedTabIndex === 0 && this.clientGeneralForm?.valid && !this.contactVerificationComplete) { if (this.selectedTabIndex === 0 && this.clientGeneralForm?.valid && !this.contactVerificationComplete) {
return 'Verify the OTP sent to both the old and new mobile number/email before saving.'; return 'Verify the OTP sent to both the old and new mobile number/email before saving.';
} }
if (this.selectedTabIndex === 0 && this.clientGeneralForm && !this.clientGeneralForm.valid) {
const labels = this.invalidGeneralFieldLabels();
if (labels.length) {
return `Please fill/correct: ${labels.join(', ')}.`;
}
}
if (this.selectedTabIndex === 1) { if (this.selectedTabIndex === 1) {
const labels = this.clientKycStep?.invalidFieldLabels || []; const labels = this.clientKycStep?.invalidFieldLabels || [];
if (labels.length) { if (labels.length) {
@@ -408,6 +414,42 @@ export class EditClientComponent {
return 'Please fill all mandatory fields on this tab to continue.'; return 'Please fill all mandatory fields on this tab to continue.';
} }
/**
* Readable names of the General-tab controls that are invalid. Older clients can be missing
* fields that are now mandatory (e.g. mobile/email), which leaves Save disabled with nothing
* on screen saying why — this feeds the button tooltip.
*/
private invalidGeneralFieldLabels(): string[] {
const labels: Record<string, string> = {
officeId: 'Office',
legalFormId: 'Legal Form',
genderId: 'Gender',
mobileNo: 'Mobile No',
alternateMobileNo: 'Alternate Mobile No',
emailAddress: 'Email',
dateOfBirth: 'Date of Birth',
submittedOnDate: 'Submitted On',
activationDate: 'Activation Date',
savingsProductId: 'Savings Product',
firstname: 'First Name',
middlename: 'Middle Name',
lastname: 'Last Name',
fullname: 'Name',
monthlyIncome: 'Monthly Income',
annualIncome: 'Annual Income',
monthlyTurnover: 'Monthly Turnover',
annualTurnover: 'Yearly Turnover',
agricultureIncome: 'Agriculture Income',
nonAgricultureIncome: 'Non-Agriculture Income',
guardianDetails: 'Guardian',
clientNonPersonDetails: 'Entity Details'
};
const controls = this.clientGeneralForm?.controls || {};
return Object.keys(controls)
.filter((name) => controls[name].invalid)
.map((name) => labels[name] || name);
}
/** Whether the current tab has unsaved edits */ /** Whether the current tab has unsaved edits */
isCurrentTabEdited(): boolean { isCurrentTabEdited(): boolean {
return this.tabEditMode[this.selectedTabIndex] === true; return this.tabEditMode[this.selectedTabIndex] === true;
@@ -109,13 +109,7 @@
</button> </button>
} }
</div> </div>
<input <input #sideInput type="file" hidden accept="image/*,application/pdf" (change)="onFilePicked($event, slot)" />
#sideInput
type="file"
hidden
accept="image/*,application/pdf"
(change)="onFilePicked($event, slot)"
/>
</div> </div>
} }
</div> </div>
+36 -1
View File
@@ -25,6 +25,41 @@ import { environment } from '../../../environments/environment';
export class CacheInterceptor implements HttpInterceptor { export class CacheInterceptor implements HttpInterceptor {
private forceUpdate = false; private forceUpdate = false;
/**
* Endpoints that compute a live figure from the current ledger/configuration and must never be
* served from cache. Caching them meant a report kept showing a stale response after its
* underlying GL mapping changed — the request was answered from cachedData and the backend was
* never hit again. Matched as a substring of the request URL.
*/
private static readonly NEVER_CACHE = [
'/rbireservereturn',
'/rbireserveconfig',
'/tdsconfig',
'/tds/deposit-interest',
// Declarations and challans are entered on the very screens that read them back.
'/tds/declarations',
'/tds/challans',
'/tds/deducted',
'/savingsinterestpostingreports',
// Cash in vaults and drawers changes with every transaction; a cached teller list or position
// kept showing a branch's old tellers and figures at day close.
'/tellers',
'/cashreports',
'/cashreconciliation',
'/cashsettlement',
'/cashtransfer',
'/branchday',
// A deposit's lien moves with every repayment of the loan it secures, and decides whether the
// deposit may be closed at all - a stale copy would show a released lien as still blocking.
'/liens',
// The RD Dashboard counts late installments as of today; a cached copy would hide a payment just taken.
'/recurringdepositaccounts/dashboard'
];
private isCacheable(url: string): boolean {
return !CacheInterceptor.NEVER_CACHE.some((fragment) => url.includes(fragment));
}
// eslint-disable-next-line @angular-eslint/prefer-inject // eslint-disable-next-line @angular-eslint/prefer-inject
constructor(private httpCacheService: HttpCacheService) {} constructor(private httpCacheService: HttpCacheService) {}
@@ -42,7 +77,7 @@ export class CacheInterceptor implements HttpInterceptor {
} }
intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> { intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
if (request.method !== 'GET') { if (request.method !== 'GET' || !this.isCacheable(request.urlWithParams)) {
return next.handle(request); return next.handle(request);
} }

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