Compare commits

...
17 Commits
Author SHA1 Message Date
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
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
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
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
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
163 changed files with 26879 additions and 5985 deletions
-288
View File
@@ -1,288 +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 == dev"
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"
# ========================================================
# 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 == dev' || true)
# ------------------------------------------------------
# Add constraint if missing
# ------------------------------------------------------
if [ -z "$$CONSTRAINT_EXISTS" ]; then
echo "Constraint missing."
echo "Adding: node.labels.environment == dev"
docker service update \
--constraint-add 'node.labels.environment == dev' \
"$$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 == dev' \
--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
@@ -16,6 +16,7 @@ import { AccountingService } from '../../accounting.service';
import { GLAccount } from 'app/shared/models/general.model';
import { GlAccountSelectorComponent } from '../../../shared/accounting/gl-account-selector/gl-account-selector.component';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { accountOptionsForActivity } from '../financial-activity-account-options';
/**
* Create financial activity mapping component.
@@ -87,21 +88,11 @@ export class CreateFinancialActivityMappingComponent implements OnInit {
*/
setGlAccountData() {
this.financialActivityMappingForm.get('financialActivityId').valueChanges.subscribe((financialActivityId) => {
switch (financialActivityId) {
case 100:
case 101:
case 102:
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;
}
this.glAccountData = accountOptionsForActivity(
this.financialActivityData,
financialActivityId,
this.glAccountOptions
);
});
}
@@ -16,6 +16,7 @@ import { AccountingService } from '../../accounting.service';
import { GLAccount } from 'app/shared/models/general.model';
import { GlAccountSelectorComponent } from '../../../shared/accounting/gl-account-selector/gl-account-selector.component';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { accountOptionsForActivity } from '../financial-activity-account-options';
/**
* Edit financial activity mapping component.
@@ -98,21 +99,11 @@ export class EditFinancialActivityMappingComponent implements OnInit {
*/
setGLAccountData() {
this.financialActivityMappingForm.get('financialActivityId').valueChanges.subscribe((financialActivityId) => {
switch (financialActivityId) {
case 100:
case 101:
case 102:
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;
}
this.glAccountData = accountOptionsForActivity(
this.financialActivityData,
financialActivityId,
this.glAccountOptions
);
});
}
@@ -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`] ?? [];
}
+45
View File
@@ -34,6 +34,17 @@ export class AmlService {
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 ═══
/**
@@ -141,4 +152,38 @@ export class AmlService {
getRiskCategoryHistory(clientId: number): Observable<any[]> {
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');
}
}
@@ -0,0 +1,147 @@
<!--
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)
};
}
}
@@ -26,7 +26,6 @@ import { VaultPositionComponent } from './vault-position/vault-position.componen
import { AccountRegisterComponent } from './account-register/account-register.component';
import { ClosingCashReportComponent } from './closing-cash-report/closing-cash-report.component';
import { ExcessTransactionReportComponent } from './excess-transaction-report/excess-transaction-report.component';
import { ShareCollectionComponent } from './share-collection/share-collection.component';
const routes: Routes = [
Route.withShell([
@@ -345,11 +344,6 @@ const routes: Routes = [
component: ExcessTransactionReportComponent,
data: { title: 'Excess Transaction Report', breadcrumb: 'Excess Transaction Report' }
},
{
path: 'share-collection',
component: ShareCollectionComponent,
data: { title: 'Share Collection', breadcrumb: 'Share Collection' }
},
{
path: 'transaction-entry',
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 { ClosingCashReportComponent } from './closing-cash-report/closing-cash-report.component';
import { ExcessTransactionReportComponent } from './excess-transaction-report/excess-transaction-report.component';
import { ShareCollectionComponent } from './share-collection/share-collection.component';
@NgModule({
imports: [
@@ -35,8 +34,7 @@ import { ShareCollectionComponent } from './share-collection/share-collection.co
ReconciliationComponent,
CashHistoryComponent,
ClosingCashReportComponent,
ExcessTransactionReportComponent,
ShareCollectionComponent
ExcessTransactionReportComponent
]
})
export class CashierModule {}
+35 -17
View File
@@ -178,6 +178,13 @@ export class CashierService {
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
* stood then, and what it holds now.
@@ -357,19 +364,6 @@ export class CashierService {
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. */
getShareAdvanceByVoucher(voucherNo: string, glAccountNo: string, clientId?: number): Observable<any> {
// The voucher travels as a query parameter, not a path segment: numbers are formatted
@@ -396,10 +390,18 @@ export class CashierService {
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> {
const suffix = clientId ? `?clientId=${clientId}` : '';
return this.http.get(`/shareadvancecollection${suffix}`);
/**
* Everything an Apply screen needs, in one call: `own` is this member's own unapplied receipts,
* ready to tick; `rows` is every other Share Collection receipt still available — unclaimed GL
* 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. */
@@ -407,6 +409,12 @@ export class CashierService {
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. */
getShareAdditionRequests(): Observable<any> {
return this.http.get('/shareadditionrequests');
@@ -419,4 +427,14 @@ export class CashierService {
getShareAdditionRequest(transactionId: number): Observable<any> {
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([])));
}
}
@@ -174,19 +174,24 @@ export class ClosingCashReportComponent implements OnInit {
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 {
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.
*
* Was deposits minus withdrawals, which is the counter's movement rather than the drawer's
* 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.
* 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
* row's own `netAmount` rather than re-deriving it from today's legs.
*/
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[] {
return this.rows.filter((r) => r.kind === 'coin');
+16 -4
View File
@@ -152,9 +152,21 @@
<tbody>
@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
is when the heading has scrolled away. -->
<tr [class]="'mw-row mw-row--' + r.myRole">
<td class="mw-vno">{{ r.voucherNo }}</td>
is when the heading has scrolled away. Clicking anywhere on the row opens the full
slip -- every line, not just the first debit and first credit this table has room
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>
<span class="mw-badge" [class]="'mw-badge--' + (r.side || 'JV')">{{ sideLabel(r.side) }}</span>
</td>
@@ -171,7 +183,7 @@
<span class="mw-acct">{{ r.creditAccount }}</span>
}
@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 class="num">₹{{ r.amount | number: '1.2-2' }}</td>
@@ -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
tally and a checker's are each readable without adding rows up. */
.mw-group {
+35 -2
View File
@@ -10,8 +10,10 @@ import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
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 { VoucherDetailDialogComponent } from './voucher-detail-dialog.component';
/** What the signed-in user's part in a voucher was. */
export type MyRole = 'RAISED' | 'VERIFIED' | 'RAISED_AND_VERIFIED';
@@ -72,11 +74,17 @@ interface MyWorkReport {
imports: [
CommonModule,
FormsModule,
MatIconModule
MatIconModule,
MatDialogModule
]
})
export class MyWorkComponent implements OnInit {
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. */
period: 'DAY' | 'MONTH' = 'DAY';
@@ -323,6 +331,31 @@ export class MyWorkComponent implements OnInit {
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 {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
@@ -0,0 +1,320 @@
/**
* 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';
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;
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
],
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 }}</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;
}
@@ -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 { 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 { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
@@ -35,6 +35,35 @@ export interface CashierIssueDdDialogData {
accountNo?: string;
/** The account holder, for the same reason. */
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({
@@ -56,10 +85,20 @@ export interface CashierIssueDdDialogData {
<div class="dd-dialog-header__title">
<mat-icon class="dd-icon">receipt_long</mat-icon>
<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>
Next available DD leaflet auto-selected ·
{{ isAccountDebit ? 'Enter beneficiary details' : 'Enter purchaser &amp; beneficiary details' }}
@if (isSlip) {
One debit funds all {{ lineCount }} drafts · Enter each draft's beneficiary and amount
} @else {
Next available DD leaflet auto-selected ·
{{ isAccountDebit ? 'Enter beneficiary details' : 'Enter purchaser &amp; beneficiary details' }}
}
</p>
</div>
</div>
@@ -69,41 +108,91 @@ export interface CashierIssueDdDialogData {
</div>
<mat-dialog-content class="dd-dialog-body">
<!-- Draft & Financials Summary Card -->
<div class="dd-summary-card">
<!-- The number the customer will actually see printed on the instrument. "Auto-Selected
<!--
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 -->
<div class="dd-summary-card">
<!-- The number the customer will actually see printed on the instrument. "Auto-Selected
Leaflet" named the stock item rather than the draft, so it was not obvious that this
is the DD number being issued. -->
<div class="summary-col">
<span class="label">DD Number Being Issued</span>
<strong class="dd-number-highlight">{{ data.leaflet.ddNumber }}</strong>
<span class="sublabel">{{ data.sponsorBankName || 'Sponsor Bank' }} · {{ data.branchName }}</span>
<div class="summary-col">
<span class="label">DD Number Being Issued</span>
<strong class="dd-number-highlight">{{ data.leaflet.ddNumber }}</strong>
<span class="sublabel">{{ data.sponsorBankName || 'Sponsor Bank' }} · {{ data.branchName }}</span>
</div>
<div class="summary-divider"></div>
<div class="summary-col">
<span class="label">Face Value</span>
<strong class="amount">₹{{ data.faceValue | number: '1.2-2' }}</strong>
<span class="sublabel">+ Charges ₹{{ data.chargesAmount | 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">₹{{ data.gstAmount | 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">{{ isAccountDebit ? 'Total Debited' : 'Total Cash Collected' }}</span>
<strong class="total-amount">₹{{ data.totalCashPayable | number: '1.2-2' }}</strong>
<span class="sublabel">
@if (isAccountDebit) {
{{ data.accountNo || 'Account' }}
} @else {
Denominations verified
}
</span>
</div>
</div>
<div class="summary-divider"></div>
<div class="summary-col">
<span class="label">Face Value</span>
<strong class="amount">₹{{ data.faceValue | number: '1.2-2' }}</strong>
<span class="sublabel">+ Charges ₹{{ data.chargesAmount | 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">₹{{ data.gstAmount | 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">{{ isAccountDebit ? 'Total Debited' : 'Total Cash Collected' }}</span>
<strong class="total-amount">₹{{ data.totalCashPayable | number: '1.2-2' }}</strong>
<span class="sublabel">
@if (isAccountDebit) {
{{ data.accountNo || 'Account' }}
} @else {
Denominations verified
}
</span>
</div>
</div>
}
<!--
The account funding the draft, stated before the form.
@@ -121,123 +210,237 @@ export interface CashierIssueDdDialogData {
· {{ data.accountHolderName }}
}
<div class="dd-funding-sub">
₹{{ data.totalCashPayable | number: '1.2-2' }} will be debited from this account when a checker
approves. No cash is taken at the counter.
@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
approves. No cash is taken at the counter.
}
</div>
</div>
</div>
}
<form [formGroup]="form" class="dd-form">
<!--
<!--
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">
<!--
Purchaser identity is asked for only when the bank has none: a walk-in paying cash. On an
account debit the purchaser IS the account holder, already identified and KYC'd, and
re-capturing a name and Aadhaar here would invite a mismatch against the account on record.
-->
@if (!isAccountDebit) {
<div class="form-section-title">Walk-in Purchaser Details</div>
@if (!isAccountDebit) {
<div class="form-section-title">Walk-in Purchaser Details</div>
<div class="form-row">
<mat-form-field appearance="outline" class="form-field-flex">
<mat-label>Walk-in Purchaser Name *</mat-label>
<input
matInput
formControlName="walkinPurchaserName"
placeholder="e.g. RAMESH CHANDRA"
style="text-transform: uppercase;"
/>
<mat-error *ngIf="form.get('walkinPurchaserName')?.hasError('required')"
>Purchaser name is required</mat-error
>
<mat-error *ngIf="form.get('walkinPurchaserName')?.hasError('minlength')"
>Must be at least 3 characters</mat-error
>
</mat-form-field>
<mat-form-field appearance="outline" class="form-field-flex">
<mat-label>12-Digit Aadhaar Number *</mat-label>
<input
matInput
formControlName="walkinPurchaserAadhaar"
placeholder="12-digit Aadhaar"
maxlength="12"
/>
<mat-hint>Masked to last 4 digits before storage</mat-hint>
<mat-error *ngIf="form.get('walkinPurchaserAadhaar')?.hasError('required')"
>Aadhaar number is required</mat-error
>
<mat-error *ngIf="form.get('walkinPurchaserAadhaar')?.hasError('pattern')"
>Must be exactly 12 numeric digits</mat-error
>
</mat-form-field>
</div>
<mat-form-field appearance="outline" class="full-width">
<mat-label>Purchaser Address / Location *</mat-label>
<input
matInput
formControlName="walkinPurchaserAddress"
placeholder="e.g. 14, SECTOR 5, MAIN ROAD, MUMBAI"
style="text-transform: uppercase;"
/>
<mat-error *ngIf="form.get('walkinPurchaserAddress')?.hasError('required')"
>Address is required</mat-error
>
</mat-form-field>
}
<!-- Beneficiary Section -->
<div class="form-section-title">Beneficiary & Payment Details</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="form.get('beneficiaryName')?.hasError('required')"
>Beneficiary name is required</mat-error
>
<mat-error *ngIf="form.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>Walk-in Purchaser Name *</mat-label>
<mat-label>Payable At - Branch *</mat-label>
<input
matInput
formControlName="walkinPurchaserName"
placeholder="e.g. RAMESH CHANDRA"
formControlName="payableAtBranch"
placeholder="e.g. FORT BRANCH"
maxlength="150"
style="text-transform: uppercase;"
/>
<mat-error *ngIf="form.get('walkinPurchaserName')?.hasError('required')"
>Purchaser name is required</mat-error
>
<mat-error *ngIf="form.get('walkinPurchaserName')?.hasError('minlength')"
>Must be at least 3 characters</mat-error
<mat-error *ngIf="form.get('payableAtBranch')?.hasError('required')"
>Payable Branch is required</mat-error
>
</mat-form-field>
<mat-form-field appearance="outline" class="form-field-flex">
<mat-label>12-Digit Aadhaar Number *</mat-label>
<mat-label>Payable At - City *</mat-label>
<input
matInput
formControlName="walkinPurchaserAadhaar"
placeholder="12-digit Aadhaar"
maxlength="12"
formControlName="payableAtCity"
placeholder="e.g. MUMBAI"
maxlength="100"
style="text-transform: uppercase;"
/>
<mat-hint>Masked to last 4 digits before storage</mat-hint>
<mat-error *ngIf="form.get('walkinPurchaserAadhaar')?.hasError('required')"
>Aadhaar number is required</mat-error
>
<mat-error *ngIf="form.get('walkinPurchaserAadhaar')?.hasError('pattern')"
>Must be exactly 12 numeric digits</mat-error
>
<mat-error *ngIf="form.get('payableAtCity')?.hasError('required')">Payable City is required</mat-error>
</mat-form-field>
</div>
<mat-form-field appearance="outline" class="full-width">
<mat-label>Purchaser Address / Location *</mat-label>
<input
matInput
formControlName="walkinPurchaserAddress"
placeholder="e.g. 14, SECTOR 5, MAIN ROAD, MUMBAI"
style="text-transform: uppercase;"
/>
<mat-error *ngIf="form.get('walkinPurchaserAddress')?.hasError('required')"
>Address is required</mat-error
>
</mat-form-field>
}
<!-- Beneficiary Section -->
<div class="form-section-title">Beneficiary & Payment Details</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="form.get('beneficiaryName')?.hasError('required')"
>Beneficiary name is required</mat-error
>
<mat-error *ngIf="form.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="form.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="form.get('payableAtCity')?.hasError('required')">Payable City is required</mat-error>
</mat-form-field>
</div>
</form>
</form>
}
</mat-dialog-content>
<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-flat-button color="primary" [disabled]="form.invalid" (click)="onSubmit()">
Save DD Details
<button mat-flat-button color="primary" [disabled]="activeForm.invalid" (click)="onSubmit()">
{{ isSlip ? 'Save Slip Details' : 'Save DD Details' }}
</button>
</mat-dialog-actions>
</div>
@@ -395,6 +598,81 @@ export interface CashierIssueDdDialogData {
line-height: 1.5;
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);
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. */
get isAccountDebit(): boolean {
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 {
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
// 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
@@ -477,7 +869,25 @@ export class CashierIssueDdDialogComponent implements OnInit {
}
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 beneficiary = {
beneficiaryName: (val.beneficiaryName || '').trim().toUpperCase(),
@@ -166,23 +166,50 @@
</div>
<div class="ws-f ws-f--actyp">
<label class="ws-f__lbl" title="Account Type">AC. TP.</label>
<select
class="ws-f__ctl"
[(ngModel)]="row.acTyp"
(click)="$event.stopPropagation()"
(change)="
row.acTyp === 'GL' &&
row.typ !== 'Disbursement' &&
row.typ !== 'Fund Transfer' &&
row.typ !== 'Enter Bank Transfer' &&
(row.dbCr = 'Cr')
"
>
@for (a of acTypOptionsFor(row); track a) {
<option [value]="a">{{ a }}</option>
}
</select>
@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
class="ws-f__ctl"
[(ngModel)]="row.acTyp"
(click)="$event.stopPropagation()"
(change)="
row.acTyp === 'GL' &&
row.typ !== 'Disbursement' &&
row.typ !== 'Fund Transfer' &&
row.typ !== 'Enter Bank Transfer' &&
(row.dbCr = 'Cr')
"
>
@for (a of acTypOptionsFor(row); track a) {
<option [value]="a">{{ a }}</option>
}
</select>
}
</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>
@@ -206,14 +233,20 @@
? ddGlMapping?.ddPayableGlCode || 'Auto GL'
: 'Account number'
"
[readonly]="row.typ === 'DD' && !isDdAccountDebitRow(row)"
[class.ws-f__ctl--readonly]="row.typ === 'DD' && !isDdAccountDebitRow(row)"
[readonly]="
(row.typ === 'DD' && !isDdAccountDebitRow(row)) || (acTypLockedByToken && row === selectedRow)
"
[class.ws-f__ctl--readonly]="
(row.typ === 'DD' && !isDdAccountDebitRow(row)) || (acTypLockedByToken && row === selectedRow)
"
[title]="
isDdAccountDebitRow(row)
? 'Member account the draft is funded from'
: 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'
: isDdAccountDebitRow(row)
? 'Member account the draft is funded from'
: row.typ === 'DD'
? 'Configured Sponsor DD Payable GL (Auto-selected)'
: 'Account number'
"
/>
</div>
@@ -286,9 +319,13 @@
[value]="row.amount ?? ''"
(input)="row.amount = +$any($event.target).value; syncShareLinkedAmounts()"
(click)="$event.stopPropagation()"
[placeholder]="row.acTyp === 'SHR' ? '0' : '0.00'"
[readonly]="!isClerk && !!(activeWithdrawalRequest || activeClosureRequest)"
[class.ws-amt-input--locked]="!isClerk && !!(activeWithdrawalRequest || activeClosureRequest)"
[placeholder]="isMultiDdRow(row) ? 'Per draft' : row.acTyp === 'SHR' ? '0' : '0.00'"
[readonly]="
isMultiDdRow(row) || (!isClerk && !!(activeWithdrawalRequest || activeClosureRequest))
"
[class.ws-amt-input--locked]="
isMultiDdRow(row) || (!isClerk && !!(activeWithdrawalRequest || activeClosureRequest))
"
/>
</div>
@if (row.acTyp === 'SHR' && rowSharePrices[row.acNo]) {
@@ -298,6 +335,19 @@
}}/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>
@if (row.acTyp === 'SHR' && (row.typ === 'Enter Bank Transfer' || row.typ === 'Fund Transfer')) {
<div class="ws-f ws-f--cheqno">
@@ -377,9 +427,36 @@
<div class="ws-group ws-group--flex">
<span class="ws-caption">DD Charges &amp; GST — Admin Rate {{ ddGstPercentage }}%</span>
<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">
<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>
<input
class="ws-f__ctl ws-amt-input"
@@ -388,8 +465,15 @@
(input)="row.ddCharges = +$any($event.target).value"
(click)="$event.stopPropagation()"
placeholder="0.00"
[readonly]="isMultiDdRow(row)"
[class.ws-amt-input--locked]="isMultiDdRow(row)"
/>
</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 class="ws-f ws-f--amt">
<label class="ws-f__lbl" title="GST on charges, at the admin-configured rate">
@@ -400,7 +484,7 @@
<input
class="ws-f__ctl ws-amt-input ws-amt-input--locked"
type="text"
[value]="ddGstAmountFor(row) | number: '1.2-2'"
[value]="ddGstDisplayFor(row)"
readonly
/>
</div>
@@ -414,7 +498,7 @@
<input
class="ws-f__ctl ws-amt-input ws-amt-input--locked"
type="text"
[value]="ddTotalPayableFor(row)"
[value]="ddTotalDisplayFor(row)"
readonly
/>
</div>
@@ -427,18 +511,27 @@
[disabled]="saving"
(click)="openIssueWalkinDd(row); $event.stopPropagation()"
[title]="
isDdAccountDebitRow(row)
? 'Auto-selects next available leaflet and opens the beneficiary form'
: 'Auto-selects next available leaflet and opens walk-in purchaser form'
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 walk-in purchaser form'
"
>
<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>
<!-- 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>{{
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>
</button>
</div>
@@ -1523,23 +1616,50 @@
</div>
<div class="ws-f ws-f--actyp">
<label class="ws-f__lbl">AC. TP.</label>
<select
class="ws-f__ctl"
[(ngModel)]="row.acTyp"
(click)="$event.stopPropagation()"
(change)="
row.acTyp === 'GL' &&
row.typ !== 'Disbursement' &&
row.typ !== 'Fund Transfer' &&
row.typ !== 'Enter Bank Transfer' &&
(row.dbCr = 'Cr')
"
>
@for (a of acTypOptionsFor(row); track a) {
<option [value]="a">{{ a }}</option>
}
</select>
@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
class="ws-f__ctl"
[(ngModel)]="row.acTyp"
(click)="$event.stopPropagation()"
(change)="
row.acTyp === 'GL' &&
row.typ !== 'Disbursement' &&
row.typ !== 'Fund Transfer' &&
row.typ !== 'Enter Bank Transfer' &&
(row.dbCr = 'Cr')
"
>
@for (a of acTypOptionsFor(row); track a) {
<option [value]="a">{{ a }}</option>
}
</select>
}
</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 class="ws-vdivider"></div>
@@ -1556,10 +1676,16 @@
[placeholder]="
row.typ === 'DD' ? ddGlMapping?.ddPayableGlCode || 'Auto GL' : 'Account number'
"
[readonly]="row.typ === 'DD'"
[class.ws-f__ctl--readonly]="row.typ === 'DD'"
[readonly]="row.typ === 'DD' || (acTypLockedByToken && row === selectedRow)"
[class.ws-f__ctl--readonly]="
row.typ === 'DD' || (acTypLockedByToken && row === selectedRow)
"
[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>
@@ -1628,10 +1754,12 @@
[value]="row.amount ?? ''"
(input)="row.amount = +$any($event.target).value; syncShareLinkedAmounts()"
(click)="$event.stopPropagation()"
[placeholder]="row.acTyp === 'SHR' ? '0' : '0.00'"
[readonly]="!isClerk && !!(activeWithdrawalRequest || activeClosureRequest)"
[placeholder]="isMultiDdRow(row) ? 'Per draft' : row.acTyp === 'SHR' ? '0' : '0.00'"
[readonly]="
isMultiDdRow(row) || (!isClerk && !!(activeWithdrawalRequest || activeClosureRequest))
"
[class.ws-amt-input--locked]="
!isClerk && !!(activeWithdrawalRequest || activeClosureRequest)
isMultiDdRow(row) || (!isClerk && !!(activeWithdrawalRequest || activeClosureRequest))
"
/>
</div>
@@ -1642,6 +1770,19 @@
}}/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>
@if (
row.acTyp === 'SHR' && (row.typ === 'Enter Bank Transfer' || row.typ === 'Fund Transfer')
@@ -1706,9 +1847,38 @@
<div class="ws-group ws-group--flex">
<span class="ws-caption">DD Charges &amp; GST — Admin Rate {{ ddGstPercentage }}%</span>
<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">
<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>
<input
class="ws-f__ctl ws-amt-input"
@@ -1717,8 +1887,15 @@
(input)="row.ddCharges = +$any($event.target).value"
(click)="$event.stopPropagation()"
placeholder="0.00"
[readonly]="isMultiDdRow(row)"
[class.ws-amt-input--locked]="isMultiDdRow(row)"
/>
</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 class="ws-f ws-f--amt">
<label class="ws-f__lbl" title="GST on charges, at the admin-configured rate">
@@ -1729,7 +1906,7 @@
<input
class="ws-f__ctl ws-amt-input ws-amt-input--locked"
type="text"
[value]="ddGstAmountFor(row) | number: '1.2-2'"
[value]="ddGstDisplayFor(row)"
readonly
/>
</div>
@@ -1743,7 +1920,7 @@
<input
class="ws-f__ctl ws-amt-input ws-amt-input--locked"
type="text"
[value]="ddTotalPayableFor(row)"
[value]="ddTotalDisplayFor(row)"
readonly
/>
</div>
@@ -1756,13 +1933,28 @@
[disabled]="saving"
(click)="openIssueWalkinDd(row); $event.stopPropagation()"
[title]="
isDdAccountDebitRow(row)
? 'Auto-selects next available leaflet and opens the beneficiary form'
: 'Auto-selects next available leaflet and opens walk-in purchaser form'
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 walk-in purchaser form'
"
>
<mat-icon style="font-size: 18px; width: 18px; height: 18px">post_add</mat-icon>
<span>Fill DD Details</span>
<mat-icon style="font-size: 18px; width: 18px; height: 18px">{{
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>
</div>
</div>
@@ -916,6 +916,20 @@ input.ws-f__ctl--readonly {
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 {
background: #fef9c3;
color: #713f12;
File diff suppressed because it is too large Load Diff
@@ -1142,6 +1142,46 @@
</mat-select>
</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
it, so re-collecting an expired document is done on this form rather than
sending the officer somewhere else. -->
@@ -1807,3 +1807,28 @@
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,
[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
// backend has always accepted these four; only the fields were missing.
kycDocumentName: [''],
@@ -221,6 +228,11 @@ export class KycTabComponent implements OnInit, OnDestroy {
this.kycForm.patchValue({
kycType: data.kycType || 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 || '',
// Aadhaar is stored masked on the server; user must re-enter
// the full 12-digit number to re-verify, so don't pre-fill.
@@ -446,6 +458,28 @@ export class KycTabComponent implements OnInit, OnDestroy {
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() {
const panVerified = this.verificationStatus.pan === 'VERIFIED' || this.kycData?.panVerified === true;
const aadhaarVerified = this.verificationStatus.aadhaar === 'VERIFIED' || this.kycData?.aadhaarVerified === true;
@@ -484,7 +518,8 @@ export class KycTabComponent implements OnInit, OnDestroy {
// dateFormat is required so the backend can parse exceptionResolvedOn when an exception closes.
dateFormat: 'dd MMMM yyyy',
...this.buildDocumentFields(),
...this.buildExceptionFields()
...this.buildRiskProfileFields(),
...this.buildExceptionFields()
};
if (!this.kycId) {
@@ -530,6 +565,7 @@ export class KycTabComponent implements OnInit, OnDestroy {
remarks: this.kycForm.value.remarks || null,
riskCategory: this.kycForm.value.riskCategory || 1,
dateFormat: 'dd MMMM yyyy',
...this.buildRiskProfileFields(),
...this.buildExceptionFields()
};
@@ -729,7 +765,8 @@ export class KycTabComponent implements OnInit, OnDestroy {
dateFormat,
riskCategory: this.kycForm.value.riskCategory || 1,
...declarationFields,
...this.buildExceptionFields()
...this.buildRiskProfileFields(),
...this.buildExceptionFields()
};
this.clientsService
@@ -889,7 +926,8 @@ export class KycTabComponent implements OnInit, OnDestroy {
dateFormat,
riskCategory: this.kycForm.value.riskCategory || 1,
...receiptFields,
...this.buildExceptionFields()
...this.buildRiskProfileFields(),
...this.buildExceptionFields()
};
this.clientsService
+34 -66
View File
@@ -310,41 +310,6 @@
</nav>
}
}
<!-- Live KPI ticker items — referenced twice below for seamless marquee loop -->
<ng-template #kpiTickerItems>
<span class="ti"><span class="ti-k">CASH</span><span class="ti-v amber">₹14,82,500</span></span>
<span class="ti-d">│</span>
<span class="ti">
<span class="ti-k">TXN TODAY</span>
<span class="ti-v">1,247</span>
<span class="ti-v up">↑ 8.2%</span>
</span>
<span class="ti-d">│</span>
<span class="ti"><span class="ti-k">CLEARING IN</span><span class="ti-v">42</span></span>
<span class="ti-d">│</span>
<span class="ti"><span class="ti-k">CLEARING OUT</span><span class="ti-v">38</span></span>
<span class="ti-d">│</span>
<span class="ti">
<span class="ti-k">NPA·GROSS</span>
<span class="ti-v amber">3.47%</span>
<span class="ti-v down">↑ 0.12</span>
</span>
<span class="ti-d">│</span>
<span class="ti"><span class="ti-k">CASA</span><span class="ti-v">58.2%</span></span>
<span class="ti-d">│</span>
<span class="ti"><span class="ti-k">CD RATIO</span><span class="ti-v">71.8%</span></span>
<span class="ti-d">│</span>
<span class="ti">
<span class="ti-k">RBI CIRC</span>
<span class="ti-v">DBR.AML.NO.4/14.01.001</span>
<span class="ti-v amber">NEW</span>
</span>
<span class="ti-d">│</span>
<span class="ti"><span class="ti-k">REPO</span><span class="ti-v">6.50%</span></span>
<span class="ti-d">│</span>
</ng-template>
<!-- ============================================================
Main Header — 2-Row Navigation
============================================================
@@ -623,18 +588,11 @@
</div>
</div>
<!-- LIVE KPI TICKER (below the menu — pauses on hover) -->
<div class="kpi-ticker" role="marquee" aria-label="Live key-performance ticker">
<span class="kpi-live">
<span class="kpi-live-dot"></span>
LIVE
</span>
<div class="kpi-track">
<!-- Render the item block twice so the marquee loops seamlessly. -->
<ng-container *ngTemplateOutlet="kpiTickerItems"></ng-container>
<ng-container *ngTemplateOutlet="kpiTickerItems"></ng-container>
</div>
</div>
<!-- The KPI ticker that sat here has been removed. Every figure in it was written into the template — cash on
hand, transactions today, CASA, CD ratio, gross NPA, a circular reference — and none came from the bank's
own books. Under a "LIVE" badge on every screen in the application, that is not decoration: a figure a
branch manager can read as the day's cash position, or an inspector as the NPA ratio, has to be the real
one or must not be shown. The band returns when there is a source behind it. -->
}
<!-- end @if (!hideShellLayout) -->
@@ -713,6 +671,9 @@
<button mat-menu-item routerLink="/reports/tds-remittance" tabindex="0">
<i class="fa fa-landmark menu-item-icon"></i> TDS Remitted to Government
</button>
<button mat-menu-item routerLink="/reports/bor" tabindex="0">
<i class="fa fa-gavel menu-item-icon"></i> BOR Register &mdash; Share Capital
</button>
</mat-menu>
<!-- Admin Mega Menu — 3-column rectangular panel grouping Setup,
@@ -1339,72 +1300,79 @@
<!-- Share Operations -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Share Operations</div>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/nominal-shares']">
<fa-icon icon="coins" class="menu-item-icon"></fa-icon> Nominal Share Report
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/share-wise']">
<fa-icon icon="file-alt" class="menu-item-icon"></fa-icon> Share Wise Report
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/guarantor-detail']">
<fa-icon icon="user-shield" class="menu-item-icon"></fa-icon> Guarantor Detail
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/member-interest']">
<fa-icon icon="calculator" class="menu-item-icon"></fa-icon> Total Int Rec
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/enquiry']">
<fa-icon icon="search" class="menu-item-icon"></fa-icon> Share Enquiry
</button>
</div>
<!-- Share Documents -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Share Documents</div>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/payment-certificate']">
<fa-icon icon="file-signature" class="menu-item-icon"></fa-icon> Share Payment Certificate
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/share-detail']">
<fa-icon icon="list-alt" class="menu-item-icon"></fa-icon> Share Detail
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/dividend-statement']">
<fa-icon icon="dollar-sign" class="menu-item-icon"></fa-icon> Dividend Statement
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" [routerLink]="['/shares/transfers']">
<fa-icon icon="exchange" class="menu-item-icon"></fa-icon> Share Transfer
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/folio-statement']">
<fa-icon icon="file-alt" class="menu-item-icon"></fa-icon> Folio Statement
</button>
</div>
<!-- Share Reports -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Share Reports</div>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/final-balance']">
<fa-icon icon="chart-line" class="menu-item-icon"></fa-icon> Final Balance
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/account-statement']">
<fa-icon icon="file-invoice" class="menu-item-icon"></fa-icon> Account Statement
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/share-issued']">
<fa-icon icon="check-circle" class="menu-item-icon"></fa-icon> Share Issued
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/shares/share-holders']">
<fa-icon icon="users" class="menu-item-icon"></fa-icon> Share Holder
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/share-status']">
<fa-icon icon="info-circle" class="menu-item-icon"></fa-icon> Share Status
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/product-summary']">
<fa-icon icon="layer-group" class="menu-item-icon"></fa-icon> Product Summary
</button>
</div>
<!-- Share Admin -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Share Admin</div>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/distinctive-numbers']">
<fa-icon icon="tag" class="menu-item-icon"></fa-icon> Share Devede No Detail
</button>
<button mat-menu-item tabindex="0"><fa-icon icon="map" class="menu-item-icon"></fa-icon> Area Wise</button>
<button mat-menu-item tabindex="0"><fa-icon icon="tag" class="menu-item-icon"></fa-icon> Share Label</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/area-wise']">
<fa-icon icon="map" class="menu-item-icon"></fa-icon> Area Wise
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/labels']">
<fa-icon icon="tag" class="menu-item-icon"></fa-icon> Share Label
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/letters']">
<fa-icon icon="envelope" class="menu-item-icon"></fa-icon> Share Letter
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" [routerLink]="['/letter-mgmt/share-member']">
<fa-icon icon="id-card" class="menu-item-icon"></fa-icon> Share Member Details
</button>
</div>
@@ -334,14 +334,18 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
const frequencyId = this.resolveFrequencyId(this.tenurePeriodUnit);
if (this.principalAmount && this.principalAmount > 0) {
// Convert tenure to months for backend API
let tenureInMonths = 0;
// Convert tenure to months for backend API. A Days tenure under 30 days rounds down to 0
// months, which the backend rejects -- send it as tenureInDays instead in that case.
let tenureInMonths: number | undefined;
let tenureInDays: number | undefined;
if (this.tenurePeriodUnit === 'Years') {
tenureInMonths = this.tenureValue * 12;
} else if (this.tenurePeriodUnit === 'Months') {
tenureInMonths = this.tenureValue;
} else {
} else if (enteredDays >= 30) {
tenureInMonths = Math.floor(enteredDays / 30);
} else {
tenureInDays = enteredDays;
}
// Map compounding period type id → months (0=Daily, 1=Daily, 4=Monthly, 5=Quarterly, 6=SemiAnnual, 7=Annual)
@@ -395,6 +399,7 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
principalAmount: this.principalAmount,
annualInterestRate: rate,
tenureInMonths,
tenureInDays,
interestCompoundingPeriodInMonths: compMonths, // 0 = daily, now supported by backend
interestPostingPeriodInMonths: postMonths,
submittedOnDate: submittedOnDateStr,
@@ -417,7 +422,8 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
Math.round(this.principalAmount * Math.pow(1 + r / daysInYearVal, enteredDays) * 100) / 100;
} else {
const n = 12 / compMonths;
const totalCompoundings = Math.floor(tenureInMonths / compMonths);
const effectiveMonths = tenureInMonths ?? enteredDays / 30;
const totalCompoundings = Math.floor(effectiveMonths / compMonths);
const rPerCompounding = rate / (n * 100);
result.maturityAmount =
Math.round(this.principalAmount * Math.pow(1 + rPerCompounding, totalCompoundings) * 100) / 100;
@@ -133,7 +133,8 @@ export class FixedDepositsService {
calculateFixedDepositInterest(params: {
principalAmount: number;
annualInterestRate: number;
tenureInMonths: number;
tenureInMonths?: number;
tenureInDays?: number; // alternative to tenureInMonths, for sub-month FD Single tenures
interestCompoundingPeriodInMonths: number;
interestPostingPeriodInMonths: number;
submittedOnDate?: string; // yyyy-MM-dd — enables exact calendar calculation
+53 -86
View File
@@ -167,7 +167,9 @@
</div>
<!-- The 15th of the month after the one being reported, worked out rather than fixed at
15 Feb 2026 — a due date that never moves stops being a due date. -->
<div class="dc-meta">Due: <strong>{{ ctrDueDateLabel }}</strong></div>
<div class="dc-meta">
Due: <strong>{{ ctrDueDateLabel }}</strong>
</div>
<button class="dc-btn" (click)="showTab('ctr')"><fa-icon icon="external-link-alt"></fa-icon> Open</button>
</div>
<div class="dc-card">
@@ -220,9 +222,7 @@
@if (ctrLinesLoading) {
<div class="fiu-note">Loading the generated CTR lines…</div>
} @else if (ctrLinesError) {
<div class="fiu-note fiu-note--warn">
<fa-icon icon="exclamation-triangle"></fa-icon> {{ ctrLinesError }}
</div>
<div class="fiu-note fiu-note--warn"><fa-icon icon="exclamation-triangle"></fa-icon> {{ ctrLinesError }}</div>
} @else if (!ctrTxns.length) {
<div class="fiu-note fiu-note--warn">
<fa-icon icon="exclamation-triangle"></fa-icon>
@@ -432,87 +432,17 @@
}
<div class="fiu-section">
<!-- LIVE: suspicious AML alerts awaiting AML Officer review (Structuring, PEP, etc.) -->
<div class="fiu-form-meta">
<div class="fiu-form-title">
<h3>Live AML Alerts (Awaiting Triage)</h3>
<p>Flagged by the monitoring engine for suspicious behavioral patterns — triage and whitelist or escalate</p>
</div>
<!-- Whitelisted is deliberately absent: cleared alerts are not STR business and have
their own register below. -->
<select class="fiu-inp" [(ngModel)]="amlAlertStatusFilter">
<option value="">All statuses</option>
<option value="RAISED">Raised</option>
<option value="UNDER_REVIEW">Under Review</option>
<option value="ESCALATED">Escalated</option>
</select>
<!-- The triage queue that used to head this tab has gone. It listed the same open alerts as the AML Rules
Master, and listed them worse: without the severity bands, the per-rule findings or the batch decision that
screen carries. Two places to work the same queue is how an alert gets cleared in one and escalated in the
other. This tab keeps what is about the filing itself - what was cleared, and what became an STR case. -->
<div class="fiu-note">
<fa-icon icon="info-circle"></fa-icon>
<span>
Alerts are triaged in <strong>RBI &rsaquo; AML Rules Master &rsaquo; Flagged Transactions</strong>. What an
officer escalates there arrives here as an STR case.
</span>
</div>
<div class="fiu-tbl-wrap">
<table class="fiu-tbl">
<thead>
<tr>
<th>ID</th>
<th>Client</th>
<th>Type</th>
<th>Rule</th>
<th class="ta-r">Amount</th>
<th>Raised On</th>
<th>Status</th>
<th class="ta-c">Routes To</th>
<th>Notes</th>
<th>Action</th>
</tr>
</thead>
<tbody>
@for (alert of amlSuspiciousAlerts; track alert.id) {
<tr>
<td>{{ alert.id }}</td>
<td>{{ alert.clientDisplayName }}</td>
<td>{{ alert.transactionType }}</td>
<td>{{ alert.ruleCode }} &mdash; {{ alert.ruleDescription }}</td>
<td class="ta-r mono">&#8377;{{ alert.amount | number: '1.0-0' }}</td>
<td>{{ asDate(alert.raisedOn) | date: 'medium' }}</td>
<td>
<span [class]="amlStatusClass(alert.status)">{{ alert.status }}</span>
</td>
<td class="ta-c">
<span [class]="alertRouteClass(alert.status, false)">{{ alertRouteLabel(alert.status, false) }}</span>
</td>
<td>
@if (alert.status === 'RAISED' || alert.status === 'UNDER_REVIEW') {
<input
type="text"
class="fiu-inp"
placeholder="Review notes"
[(ngModel)]="amlReviewNotes[alert.id]"
/>
} @else {
<span class="muted">{{ alert.reviewNotes }}</span>
}
</td>
<td>
@if (alert.status === 'RAISED' || alert.status === 'UNDER_REVIEW') {
<div class="fiu-action-group" *mifosxHasPermission="'REVIEW_AML_ALERT'">
<button class="fiu-btn fiu-btn-g" (click)="whitelistAlert(alert)">Whitelist</button>
<button class="fiu-btn fiu-btn-xml" (click)="escalateAlert(alert)">Escalate</button>
</div>
}
</td>
</tr>
} @empty {
<tr>
<td colspan="10" class="fiu-empty">No suspicious behavioral alerts awaiting triage.</td>
</tr>
}
</tbody>
</table>
</div>
<p class="fiu-tbl-legend">
<span class="report-pill report-pending">Pending</span> awaiting Whitelist/Escalate decision &middot;
<span class="report-pill report-str">→ STR</span> escalated to Principal Officer as an STR case
</p>
<hr style="border: 0; border-top: 1px solid var(--f-g200); margin: 8px 0" />
<!-- Cleared register: whitelisted alerts, kept out of the STR queue but retained with the
evidence recorded at review time. -->
@@ -590,7 +520,7 @@
</tr>
</thead>
<tbody>
@for (sc of amlStrCases; track sc.id) {
@for (sc of liveStrCases; track sc.id) {
<tr [class.selected-row]="selectedStrCase?.id === sc.id">
<td>{{ sc.id }}</td>
<td>{{ sc.clientDisplayName }}</td>
@@ -607,13 +537,50 @@
</tr>
} @empty {
<tr>
<td colspan="8" class="fiu-empty">No STR cases yet.</td>
<td colspan="8" class="fiu-empty">No STR cases outstanding.</td>
</tr>
}
</tbody>
</table>
</div>
<!-- Filed cases are finished work, not outstanding work, so they sit apart from the live queue — but they are
the bank's record of what went to FIU-IND and against which reference, so they stay visible. -->
@if (filedStrCases.length) {
<div class="fiu-form-meta" style="margin-top: 18px">
<div class="fiu-form-title">
<h3>Filed with FIU-IND</h3>
<p>Reported and closed — kept for the record, with the reference the filing was made under.</p>
</div>
</div>
<div class="fiu-tbl-wrap">
<table class="fiu-tbl">
<thead>
<tr>
<th>ID</th>
<th>Client</th>
<th>Conclusion</th>
<th>Filed On</th>
<th>FIU Reference</th>
<th>Action</th>
</tr>
</thead>
<tbody>
@for (sc of filedStrCases; track sc.id) {
<tr>
<td>{{ sc.id }}</td>
<td>{{ sc.clientDisplayName }}</td>
<td>{{ sc.conclusion || '—' }}</td>
<td>{{ asDate(sc.strFiledOn) | date: 'dd MMM yyyy' }}</td>
<td class="mono">{{ sc.strReferenceNo || '—' }}</td>
<td><button class="fiu-btn fiu-btn-g" (click)="selectStrCase(sc)">View</button></td>
</tr>
}
</tbody>
</table>
</div>
}
@if (selectedStrCase) {
<div class="str-case-detail">
<div class="fiu-form-meta">
+56 -61
View File
@@ -7,6 +7,7 @@
*/
import { Component, OnInit, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
@@ -25,8 +26,7 @@ import {
KycIndividual,
PoFinding,
StrStatus,
StrTransaction,
STR_TXNS
StrTransaction
} from './fiu-data';
/** A FINnet file rendered as a grid — the same rows that go into the CSV. */
@@ -130,6 +130,7 @@ export class FiuComponent implements OnInit {
private amlService = inject(AmlService);
private sanctionsLogsService = inject(SanctionsLogsService);
private authenticationService = inject(AuthenticationService);
private route = inject(ActivatedRoute);
/** Active tab */
activeTab: 'dashboard' | 'ctr' | 'str' | 'customers' | 'files' = 'dashboard';
@@ -154,7 +155,16 @@ export class FiuComponent implements OnInit {
* is honestly empty rather than populated with rows that cannot be filed.
*/
tc1Txns: CardTransaction[] = [];
strTxns: StrTransaction[] = STR_TXNS;
/**
* The STR transaction file.
*
* Empty, for the same reason TC1 and the customer cards are. The four rows that sat here named customers this bank
* has never onboarded, against account numbers it does not hold, for sums it never received — on the screen an
* officer files suspicious-transaction reports from. A filing built on invented rows is not a draft; it is a false
* report to FIU-IND. The live alerts the engine has actually escalated are listed on this tab already, and the
* file fills from those when the STR export is wired to them.
*/
strTxns: StrTransaction[] = [];
/**
* KC1 / KC2 — the customer cards that travel with the CTR bundle.
*
@@ -210,13 +220,29 @@ export class FiuComponent implements OnInit {
*/
sanctionsStrCases: any[] = [];
sanctionsStrFilter = '';
amlAlertStatusFilter = '';
amlReviewNotes: Record<number, string> = {};
amlCtrReports: any[] = [];
ctrGenerateMonth = '';
amlStrCases: any[] = [];
/**
* The cases still carrying work, which is what "Live" means here.
*
* <p>
* A case that has been filed with FIU-IND is finished: the report is gone, the reference number is on it, and there
* is nothing left for the Principal Officer to do. Leaving it in the live queue is how a working list stops being
* one — every filed case stays on it for good, and an officer opening the tab has to tell the outstanding work from
* the closed by reading a status column. Filed cases are listed below, where they can still be looked up.
*/
get liveStrCases(): any[] {
return this.amlStrCases.filter((c) => c?.status !== 'FILED');
}
/** Cases already filed with FIU-IND, kept for the record rather than as work. */
get filedStrCases(): any[] {
return this.amlStrCases.filter((c) => c?.status === 'FILED');
}
selectedStrCase: any = null;
concludeConclusion: 'NOT_SUSPICIOUS' | 'SUSPICIOUS' = 'NOT_SUSPICIOUS';
concludeInvestigationNotes = '';
@@ -398,6 +424,14 @@ export class FiuComponent implements OnInit {
this.isHeadOffice = this.authenticationService.isHeadOfficeUser();
this.currentOfficeName = creds?.officeName || 'Branch Office';
// Opened on a particular tab when another screen sends the officer here for a reason - the Risk
// Review Report links a case that is with the Principal Officer straight to the STR queue, so
// landing on the dashboard would make them find it themselves.
const wantedTab = this.route.snapshot.queryParamMap.get('tab');
if (wantedTab === 'str' || wantedTab === 'ctr' || wantedTab === 'customers' || wantedTab === 'files') {
this.activeTab = wantedTab;
}
// The header named a hard-coded officer and a hard-coded month — "Karthik Kamath" and
// "January 2026" — on a screen whose whole purpose is to say which month is being filed and
// by whom. Both now come from the session and the clock.
@@ -615,10 +649,9 @@ export class FiuComponent implements OnInit {
}
/**
* Always fetches every status. The triage table and the cleared register are two views of the
* same set, so filtering server-side would empty one whenever the other was narrowed — and the
* CTR tab reads this list too, which a status filter chosen on the STR tab should not touch.
* amlAlertStatusFilter is applied client-side in {@link amlSuspiciousAlerts} instead.
* Always fetches every status. This tab shows what was cleared and what became an STR case, which are two views of
* the same set, and the CTR tab reads the list as well — so narrowing it server-side for one view would empty
* another.
*/
loadAmlAlerts(): void {
this.amlService.getAlerts().subscribe((data) => (this.amlAlerts = data || []));
@@ -661,7 +694,8 @@ export class FiuComponent implements OnInit {
this.ctrLinesLoading = false;
this.ctrTxns = [];
this.ctrLinesError =
err?.error?.defaultUserMessage || 'Could not load the generated CTR lines. Generate a report for the month first.';
err?.error?.defaultUserMessage ||
'Could not load the generated CTR lines. Generate a report for the month first.';
}
});
}
@@ -761,48 +795,6 @@ export class FiuComponent implements OnInit {
return (err: any) => this.toast(err?.error?.defaultUserMessage || `${action} failed — please retry`);
}
/**
* Clearing or escalating an alert has to carry a reason: review_notes is the only audit record of
* why a flagged transaction was disposed of, and an empty string leaves no trace of the decision.
*/
private reviewNotesOf(alert: any): string | null {
const notes = (this.amlReviewNotes[alert.id] || '').trim();
if (!notes) {
this.toast(`Enter review notes before disposing of alert #${alert.id}`);
return null;
}
return notes;
}
whitelistAlert(alert: any): void {
const notes = this.reviewNotesOf(alert);
if (!notes) {
return;
}
this.amlService.reviewAlert(alert.id, 'WHITELIST', notes).subscribe({
next: () => {
this.toast(`Alert #${alert.id} whitelisted — no STR raised`);
this.loadAmlAlerts();
},
error: this.apiError(`Whitelisting alert #${alert.id}`)
});
}
escalateAlert(alert: any): void {
const notes = this.reviewNotesOf(alert);
if (!notes) {
return;
}
this.amlService.reviewAlert(alert.id, 'ESCALATE', notes).subscribe({
next: () => {
this.toast(`Alert #${alert.id} escalated — now an STR case for the Principal Officer`);
this.loadAmlAlerts();
this.loadAmlStrCases();
},
error: this.apiError(`Escalating alert #${alert.id}`)
});
}
generateCtrReport(): void {
if (!this.ctrGenerateMonth) {
this.toast('Pick a reporting month first');
@@ -990,9 +982,16 @@ export class FiuComponent implements OnInit {
get liveStrFiled(): number {
return this.amlStrCases.filter((c) => c.status === 'FILED').length;
}
/** Still needs PO action: untouched, or concluded suspicious but not yet filed with FIU-IND. */
/**
* Still needs PO action: anything neither filed with FIU-IND nor concluded clear.
*
* Counted by what is unfinished rather than by naming the statuses that are. Adding up OPEN and CONCLUDED_SUSPICIOUS
* gives the same figure today, but it is the shape that fails quietly: a case in any status those two do not name
* drops out of the KPI while still sitting in the queue below it, and the dashboard then under-reports the work.
* That is exactly how five undecided alerts hid behind a pending count of 1 on the flagged-transactions screen.
*/
get liveStrPending(): number {
return this.liveStrOpen + this.liveStrAwaitingFiling;
return this.amlStrCases.filter((c) => c?.status !== 'FILED' && c?.status !== 'CONCLUDED_CLEAR').length;
}
get totalCustomers(): number {
return this.kc1.length + this.kc2.length;
@@ -1021,12 +1020,6 @@ export class FiuComponent implements OnInit {
* The STR triage queue: still to decide, or already escalated. Whitelisted alerts are closed
* with no report, so they are not STR business — they live in {@link amlWhitelistedAlerts}.
*/
get amlSuspiciousAlerts(): any[] {
return this.behaviouralAlerts.filter(
(a) => a.status !== 'WHITELISTED' && (!this.amlAlertStatusFilter || a.status === this.amlAlertStatusFilter)
);
}
/** Cleared register: alerts closed as false positives, kept for audit with their evidence. */
get amlWhitelistedAlerts(): any[] {
return this.behaviouralAlerts.filter((a) => a.status === 'WHITELISTED');
@@ -1689,7 +1682,9 @@ export class FiuComponent implements OnInit {
private writeLiveStrXml(cases: any[], filename: string): void {
const xml: string[] = [];
xml.push('<?xml version="1.0" encoding="UTF-8"?>');
xml.push(`<STR xmlns="http://fiuind.gov.in/finnet2/str" reportingMonth="${this.escapeXml(this.reportingMonthLabel)}">`);
xml.push(
`<STR xmlns="http://fiuind.gov.in/finnet2/str" reportingMonth="${this.escapeXml(this.reportingMonthLabel)}">`
);
xml.push(` <REBank name="${this.escapeXml(this.bank.name)}" />`);
if (this.principalOfficerName) {
xml.push(` <PrincipalOfficer name="${this.escapeXml(this.principalOfficerName)}" />`);
@@ -576,13 +576,11 @@
}
/* ───────── Print — only the page, no toolbar / dialog chrome ─────────
This dialog is opened with `maxHeight: '92vh'`, which Angular Material carries onto the
overlay pane / .mat-mdc-dialog-container as an inline max-height + overflow:auto clip.
`position: absolute` still lives inside that clip and gets cut off — only the on-screen
scroll position prints. `position: fixed` escapes it: a fixed element's containing block is
the viewport itself, not the clipped ancestor (the classic "fixed child ignores an
overflow:hidden parent" behaviour), as long as nothing between here and the viewport sets a
transform/filter — the CDK overlay chrome doesn't. */
The document-level clip (`html, body { overflow: hidden }`) is lifted globally in main.scss's
print block; what remains here is this dialog's own clip — it opens with `maxHeight: '92vh'`,
which Angular Material carries onto the overlay pane / .mat-mdc-dialog-container as an inline
max-height plus overflow:auto. Freeing those lets the whole certificate flow onto the page in
normal static order (a fixed/absolute hack would pin it to the first page instead). */
@media print {
body > :not(.cdk-overlay-container) {
display: none !important;
@@ -611,9 +609,7 @@
}
.cpd-root {
position: fixed;
left: 0;
top: 0;
position: static;
width: 100% !important;
height: auto !important;
background: #fff;
@@ -0,0 +1,166 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="ca" *ngIf="register">
<div class="ca__header">
<div class="ca__header-icon">CS</div>
<div class="ca__header-text">
<h2>CIBIL / credit score adjustment</h2>
<p>Applied on top of the chart rate at loan sanction · reused across all loan products linked to this register</p>
</div>
<button mat-stroked-button (click)="backToRegister()">← Back to register</button>
</div>
<div class="ca__body">
<div class="ca__left">
<div class="ca__card">
<div class="ca__card-head">
<div class="ca__card-title">Credit score slabs</div>
<button mat-stroked-button class="ca__dash-btn"><i class="fas fa-plus"></i> Add slab</button>
</div>
<table class="ca__table">
<thead>
<tr>
<th>#</th>
<th class="ca__num">Score from</th>
<th class="ca__num">Score to</th>
<th>Risk tier</th>
<th>Type</th>
<th class="ca__num">Adjustment</th>
<th></th>
</tr>
</thead>
<tbody>
<tr *ngFor="let slab of register.cibilSlabs; let i = index" [class.ca__row-alt]="i % 2 === 1">
<td>{{ i + 1 }}</td>
<td class="ca__num">{{ slab.from }}</td>
<td class="ca__num">{{ slab.to }}</td>
<td>
<span [class]="tierClass(slab.tier)">{{ slab.tier }}</span>
</td>
<td>{{ slab.type }}</td>
<td class="ca__num">
<span [class]="adjustmentClass(slab.adjustmentPct)">{{ formatPct(slab.adjustmentPct) }}</span>
</td>
<td class="ca__dots">⋯</td>
</tr>
</tbody>
</table>
</div>
<div class="ca__card">
<div class="ca__card-body">
<div class="ca__card-title">Fallback behaviour</div>
<div class="ca__fallback-grid">
<div class="ca__field">
<label>NO CIBIL AVAILABLE</label>
<select [(ngModel)]="register.fallbackNoCibilSlab">
<option *ngFor="let slab of register.cibilSlabs; let i = index" [value]="i + 1">
Use slab {{ i + 1 }} ({{ slab.tier }}, {{ formatPct(slab.adjustmentPct) }})
</option>
</select>
</div>
<div class="ca__field">
<label>CIBIL BELOW MIN SLAB</label>
<select [(ngModel)]="register.fallbackBelowMin">
<option value="use-min-slab">Use minimum slab adjustment</option>
<option value="block">Block loan — manual approval</option>
</select>
</div>
</div>
</div>
</div>
</div>
<div class="ca__right">
<div class="ca__card">
<div class="ca__card-head">
<div class="ca__card-title">Live preview</div>
<div class="ca__card-hint">pick a scenario</div>
</div>
<div class="ca__card-body">
<div class="ca__preview-grid">
<div class="ca__field">
<label>PRODUCT</label>
<select [(ngModel)]="previewProductSrNo">
<option *ngFor="let row of register.rows" [value]="row.srNo">{{ row.particulars }}</option>
</select>
</div>
<div class="ca__field">
<label>LOAN AMOUNT</label>
<input type="number" [(ngModel)]="previewAmount" />
</div>
<div class="ca__field">
<label>CIBIL SCORE</label>
<input type="number" min="0" max="900" [(ngModel)]="previewCibil" />
</div>
<div class="ca__field">
<label>TENURE (MONTHS)</label>
<input type="number" [(ngModel)]="previewTenureMonths" />
</div>
</div>
<div class="ca__derivation">
<div class="ca__derivation-label">RATE DERIVATION</div>
<div class="ca__derivation-row">
<span>Chart cell — {{ productLabel(previewProductSrNo) }}</span>
<span class="ca__derivation-val">{{
chartCell() !== null ? (chartCell() | number: '1.2-2') + '%' : 'N.A.'
}}</span>
</div>
<div class="ca__derivation-row">
<span>CIBIL {{ previewCibil }} ({{ cibilSlab()?.tier ?? '—' }})</span>
<span
class="ca__derivation-val"
[class.ca__derivation-val--pos]="(cibilSlab()?.adjustmentPct ?? 0) < 0"
[class.ca__derivation-val--neg]="(cibilSlab()?.adjustmentPct ?? 0) > 0"
>
{{ formatPct(cibilSlab()?.adjustmentPct ?? 0) }}
</span>
</div>
<div class="ca__derivation-row ca__derivation-row--muted">
<span>Product floor / ceiling</span>
<span>no clamp</span>
</div>
<div class="ca__divider"></div>
<div class="ca__final">
<span>Final rate</span>
<span class="ca__final-val">{{ finalRate() }}</span>
</div>
</div>
</div>
</div>
<div class="ca__card">
<div class="ca__card-body">
<div class="ca__card-title">What changes if…</div>
<div class="ca__scenarios">
<div class="ca__scenario">
<span>CIBIL = 700</span>
<span>{{ rateFor(700) }}</span>
</div>
<div class="ca__scenario">
<span>CIBIL = 870</span>
<span class="ca__scenario-val--pos">{{ rateFor(870) }}</span>
</div>
<div class="ca__scenario">
<span>No CIBIL fetched</span>
<span class="ca__scenario-val--warn">{{ rateForFallback() }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="ca__footer">
<button mat-stroked-button (click)="backToRegister()">← Register</button>
<div class="ca__footer-note">Changes apply to new sanctions once the register is approved</div>
<button mat-flat-button color="primary" (click)="save()">Save adjustments</button>
</div>
</div>
@@ -0,0 +1,344 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
.ca {
background: #f8fafc;
min-height: calc(100vh - 160px);
padding: 20px 32px;
display: flex;
flex-direction: column;
gap: 16px;
&__header {
display: flex;
align-items: center;
gap: 14px;
padding: 20px 24px;
background: white;
border: 1px solid #e2e8f0;
border-radius: 10px;
}
&__header-icon {
width: 40px;
height: 40px;
border-radius: 9px;
background: #dbeafe;
color: #1d4ed8;
display: flex;
align-items: center;
justify-content: center;
font-size: 15px;
font-weight: 700;
}
&__header-text {
flex: 1;
h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
}
p {
margin: 3px 0 0;
font-size: 12px;
color: #64748b;
}
}
&__body {
display: grid;
grid-template-columns: 1.3fr 1fr;
gap: 16px;
}
&__left,
&__right {
display: flex;
flex-direction: column;
gap: 14px;
}
&__card {
background: white;
border: 1px solid #e2e8f0;
border-radius: 10px;
overflow: hidden;
}
&__card-head {
padding: 12px 16px;
border-bottom: 1px solid #f1f5f9;
display: flex;
align-items: center;
gap: 10px;
}
&__card-title {
font-size: 14px;
font-weight: 600;
}
&__card-hint {
margin-left: auto;
font-size: 11px;
color: #64748b;
}
&__card-body {
padding: 14px 18px;
display: flex;
flex-direction: column;
gap: 12px;
}
&__dash-btn {
margin-left: auto;
height: 28px;
font-size: 11px;
font-weight: 600;
color: #1d4ed8;
border: 1px dashed #1d4ed8;
i {
margin-right: 3px;
}
}
&__table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
thead tr {
background: #f8fafc;
color: #64748b;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.3px;
th {
text-align: left;
padding: 9px 14px;
font-weight: 600;
}
}
tbody tr {
border-top: 1px solid #f1f5f9;
td {
padding: 11px 14px;
}
}
}
&__row-alt {
background: #fafbfc;
}
&__num {
text-align: right;
}
&__dots {
text-align: center;
color: #94a3b8;
cursor: pointer;
}
&__fallback-grid,
&__preview-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
&__field {
display: flex;
flex-direction: column;
label {
font-size: 11px;
color: #64748b;
font-weight: 600;
letter-spacing: 0.3px;
margin-bottom: 4px;
}
input,
select {
height: 36px;
border: 1px solid #cbd5e1;
background: white;
border-radius: 6px;
padding: 0 12px;
font-size: 13px;
color: #0f172a;
font-family: inherit;
&:focus {
outline: 2px solid #1d4ed8;
outline-offset: -1px;
}
}
}
&__derivation {
padding: 12px 14px;
background: #f8fafc;
border-radius: 8px;
}
&__derivation-label {
font-size: 11px;
color: #64748b;
font-weight: 600;
letter-spacing: 0.3px;
margin-bottom: 8px;
}
&__derivation-row {
display: flex;
justify-content: space-between;
font-size: 12px;
padding: 3px 0;
&--muted {
color: #64748b;
}
}
&__derivation-val {
font-weight: 600;
&--pos {
color: #059669;
}
&--neg {
color: #dc2626;
}
}
&__divider {
height: 1px;
background: #e2e8f0;
margin: 8px 0;
}
&__final {
display: flex;
justify-content: space-between;
align-items: center;
span:first-child {
font-size: 12px;
font-weight: 600;
}
}
&__final-val {
font-size: 20px;
font-weight: 700;
color: #1d4ed8;
}
&__scenarios {
display: flex;
flex-direction: column;
gap: 8px;
font-size: 12px;
color: #334155;
}
&__scenario {
padding: 8px 10px;
background: #f8fafc;
border-radius: 6px;
display: flex;
justify-content: space-between;
span:last-child {
font-weight: 600;
}
}
&__scenario-val--pos {
color: #059669;
}
&__scenario-val--warn {
color: #d97706;
}
&__footer {
background: white;
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 10px 16px;
display: flex;
align-items: center;
justify-content: space-between;
}
&__footer-note {
font-size: 11px;
color: #64748b;
}
}
.tier {
padding: 3px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
&--poor {
background: #fee2e2;
color: #991b1b;
}
&--fair {
background: #fef3c7;
color: #92400e;
}
&--good {
background: #dbeafe;
color: #1e40af;
}
&--vgood {
background: #dcfce7;
color: #166534;
}
&--excellent {
background: #d1fae5;
color: #065f46;
}
}
.adj {
font-weight: 600;
&--negative {
color: #059669;
}
&--positive {
color: #d97706;
}
&--high {
color: #dc2626;
}
}
@@ -0,0 +1,162 @@
/**
* 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 { ActivatedRoute, Router } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { RateRegisterService } from '../rate-register.service';
import { CibilSlab, RateRegister } from '../models/rate-register.model';
/**
* CIBIL/credit-score adjustment editor for a Rate Register — slab table, fallback behaviour
* and a live preview that resolves {product, loan amount, CIBIL} down to the final rate.
*/
@Component({
selector: 'mifosx-cibil-adjustment',
templateUrl: './cibil-adjustment.component.html',
styleUrls: ['./cibil-adjustment.component.scss'],
imports: [
CommonModule,
FormsModule,
MatButtonModule,
MatFormFieldModule,
MatInputModule,
MatSelectModule
]
})
export class CibilAdjustmentComponent implements OnInit {
private readonly service = inject(RateRegisterService);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
register!: RateRegister;
// Live preview inputs.
previewProductSrNo = 7; // House Purchase / Construction
previewAmount = 2500000;
previewCibil = 820;
previewTenureMonths = 120;
ngOnInit(): void {
const idParam = this.route.snapshot.queryParamMap.get('id');
const id = idParam ? Number(idParam) : null;
this.service.list().subscribe((all) => {
this.register = (id ? all.find((r) => r.id === id) : undefined) ?? this.service.getActiveOrFirstDraft();
});
}
tierClass(tier: CibilSlab['tier']): string {
const map: Record<string, string> = {
Poor: 'tier tier--poor',
Fair: 'tier tier--fair',
Good: 'tier tier--good',
'V. Good': 'tier tier--vgood',
Excellent: 'tier tier--excellent'
};
return map[tier] ?? 'tier';
}
adjustmentClass(pct: number): string {
if (pct < 0) {
return 'adj adj--negative';
}
if (pct > 0) {
return pct >= 1 ? 'adj adj--high' : 'adj adj--positive';
}
return 'adj';
}
formatPct(pct: number): string {
if (pct === 0) {
return '0.00%';
}
const sign = pct > 0 ? '+' : '−';
return `${sign}${Math.abs(pct).toFixed(2)}%`;
}
/** Looks up the chart cell for the currently previewed scenario. */
private chartRateFor(productSrNo: number, amount: number): number | null {
const row = this.register.rows.find((r) => r.srNo === productSrNo);
if (!row) {
return null;
}
for (let i = 0; i < this.register.slabs.length; i++) {
const slab = this.register.slabs[i];
const upto = slab.amountUpto;
if (upto === null || amount <= upto) {
const cell = row.rates[i];
if (cell === null) {
return null;
}
if (typeof cell === 'string') {
// Formula cell — in a real derive we'd resolve against the pledged deposit's rate.
return null;
}
return cell;
}
}
return null;
}
private cibilAdjustmentFor(score: number): CibilSlab | null {
return this.register.cibilSlabs.find((s) => score >= s.from && score <= s.to) ?? null;
}
chartCell(): number | null {
return this.chartRateFor(this.previewProductSrNo, this.previewAmount);
}
cibilSlab(): CibilSlab | null {
return this.cibilAdjustmentFor(this.previewCibil);
}
finalRate(): string {
const base = this.chartCell();
const slab = this.cibilSlab();
if (base === null) {
return 'N.A.';
}
const adjustment = slab?.adjustmentPct ?? 0;
return `${(base + adjustment).toFixed(2)}% p.a.`;
}
productLabel(srNo: number): string {
return this.register.rows.find((r) => r.srNo === srNo)?.particulars ?? '—';
}
rateFor(cibil: number): string {
const base = this.chartRateFor(this.previewProductSrNo, this.previewAmount);
if (base === null) {
return 'N.A.';
}
const slab = this.cibilAdjustmentFor(cibil);
return `${(base + (slab?.adjustmentPct ?? 0)).toFixed(2)}%`;
}
rateForFallback(): string {
const base = this.chartRateFor(this.previewProductSrNo, this.previewAmount);
if (base === null) {
return 'N.A.';
}
const fallback = this.register.cibilSlabs[this.register.fallbackNoCibilSlab - 1];
return `${(base + (fallback?.adjustmentPct ?? 0)).toFixed(2)}%`;
}
backToRegister(): void {
this.router.navigate(['/products/loan-interest-rate-register/edit'], { queryParams: { id: this.register.id } });
}
save(): void {
this.backToRegister();
}
}
@@ -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="er" *ngIf="register">
<div class="er__meta-bar">
<div class="er__field">
<label>REGISTER NAME</label>
<input type="text" [(ngModel)]="register.name" />
</div>
<div class="er__field er__field--date">
<label>EFFECTIVE FROM</label>
<input type="date" [(ngModel)]="register.effectiveFrom" />
</div>
<div class="er__field er__field--date">
<label>EFFECTIVE TO</label>
<input type="date" [(ngModel)]="register.effectiveTo" />
</div>
<div class="er__field er__field--curr">
<label>CURRENCY</label>
<select [(ngModel)]="register.currency">
<option value="INR">INR</option>
<option value="USD">USD</option>
</select>
</div>
<button mat-stroked-button class="er__meta-btn"><i class="fas fa-arrow-down"></i> Import</button>
<button mat-stroked-button class="er__meta-btn"><i class="fas fa-arrow-up"></i> Export</button>
</div>
<div class="er__grid-card">
<div class="er__grid-head">
<div class="er__grid-title">Rate chart grid</div>
<div class="er__grid-sub">
{{ rows.length }} products × {{ slabs.length }} amount slabs · click any cell to edit
</div>
<div class="er__grid-actions">
<button mat-stroked-button class="er__dash-btn" (click)="addProduct()">
<i class="fas fa-plus"></i> Add product
</button>
<button mat-stroked-button class="er__dash-btn" (click)="addSlab()">
<i class="fas fa-plus"></i> Add slab
</button>
</div>
</div>
<div class="er__grid-scroll">
<table class="er__grid">
<thead>
<tr>
<th class="er__col-particulars">Particulars</th>
<th *ngFor="let slab of slabs" class="er__col-rate">{{ slab.label }}</th>
</tr>
</thead>
<tbody>
<tr
*ngFor="let row of rows; let i = index"
[class.er__row-alt]="i % 2 === 1"
[class.er__row--formula]="isFormulaRow(row)"
>
<td class="er__col-particulars">
<span class="er__srno">{{ row.srNo }}</span
>{{ row.particulars }}
</td>
<td
*ngFor="let cell of row.rates; let j = index"
class="er__col-rate er__cell"
[class.er__cell--na]="isNa(cell)"
[class.er__cell--formula]="isFormulaRow(row)"
[class.er__cell--editing]="isEditing(i, j)"
(click)="!isEditing(i, j) && startEdit(i, j, cell)"
>
<input
*ngIf="isEditing(i, j); else cellView"
#cellInput
type="text"
class="er__cell-input"
[(ngModel)]="editValue"
(blur)="saveEdit(i, j)"
(keydown.enter)="saveEdit(i, j)"
(keydown.escape)="cancelEdit()"
/>
<ng-template #cellView>{{ display(cell) }}</ng-template>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="er__cibil-strip" (click)="editCibil()">
<div class="er__cibil-badge">CS</div>
<div class="er__cibil-text">
<div class="er__cibil-title">CIBIL / credit score adjustment</div>
<div class="er__cibil-sub">
{{ register.cibilSlabs.length }} slabs · +1.00% (poor) to −0.20% (excellent) · applied on top of chart rate
</div>
</div>
<a class="er__cibil-edit">Edit →</a>
</div>
<div class="er__footer">
<button mat-stroked-button (click)="backToList()">Cancel</button>
<div class="er__footer-note">Rates in % p.a. · N.A. = slab not offered · formulas resolved at sanction</div>
<div class="er__footer-actions">
<button mat-stroked-button>Save draft</button>
<button mat-flat-button class="er__submit" (click)="submitForApproval()">
<i class="fas fa-check"></i> Submit for approval
</button>
</div>
</div>
</div>
@@ -0,0 +1,323 @@
/**
* 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/.
*/
.er {
background: #f8fafc;
min-height: calc(100vh - 160px);
padding: 20px 24px;
display: flex;
flex-direction: column;
gap: 12px;
&__meta-bar {
background: white;
border: 1px solid #e2e8f0;
border-radius: 9px;
padding: 12px 14px;
display: flex;
gap: 10px;
align-items: flex-end;
}
&__field {
display: flex;
flex-direction: column;
flex: 1.5;
label {
font-size: 10px;
color: #64748b;
font-weight: 600;
letter-spacing: 0.3px;
margin-bottom: 3px;
}
input,
select {
height: 32px;
border: 1px solid #cbd5e1;
background: white;
border-radius: 6px;
padding: 0 10px;
font-size: 13px;
font-family: inherit;
color: #0f172a;
&:focus {
outline: 2px solid #1d4ed8;
outline-offset: -1px;
}
}
&--date {
flex: 0.9;
}
&--curr {
flex: 0.5;
}
}
&__meta-btn {
height: 32px;
font-size: 11px;
i {
margin-right: 4px;
}
}
&__grid-card {
background: white;
border: 1px solid #e2e8f0;
border-radius: 9px;
overflow: hidden;
display: flex;
flex-direction: column;
flex: 1;
min-height: 500px;
}
&__grid-head {
padding: 8px 14px;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
display: flex;
align-items: center;
gap: 10px;
}
&__grid-title {
font-size: 12px;
font-weight: 600;
}
&__grid-sub {
font-size: 10px;
color: #64748b;
}
&__grid-actions {
margin-left: auto;
display: flex;
gap: 6px;
}
&__dash-btn {
height: 26px;
font-size: 10px;
font-weight: 600;
color: #1d4ed8;
border: 1px dashed #1d4ed8;
i {
margin-right: 3px;
}
}
&__grid-scroll {
overflow: auto;
flex: 1;
}
&__grid {
width: 100%;
border-collapse: separate;
border-spacing: 0;
font-size: 11px;
thead tr {
background: #f8fafc;
color: #475569;
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.3px;
th {
padding: 8px 7px;
font-weight: 600;
position: sticky;
top: 0;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
z-index: 2;
white-space: nowrap;
}
}
tbody tr {
border-top: 1px solid #f1f5f9;
}
td {
padding: 7px;
text-align: right;
cursor: text;
&:first-child {
text-align: left;
}
}
}
&__col-particulars {
position: sticky;
left: 0;
background: white;
border-right: 1px solid #e2e8f0;
font-weight: 500;
min-width: 240px;
text-align: left !important;
padding-left: 12px !important;
}
&__row-alt &__col-particulars {
background: #fafbfc;
}
&__row-alt {
background: #fafbfc;
}
&__row--formula {
.er__col-particulars {
background: #eff6ff;
color: #1d4ed8;
font-weight: 600;
}
}
&__srno {
color: #94a3b8;
margin-right: 6px;
}
&__cell {
cursor: cell;
transition: background-color 0.1s;
&:hover {
background: #f1f5f9;
outline: 1px solid #cbd5e1;
outline-offset: -1px;
}
&--na {
color: #cbd5e1;
}
&--formula {
background: #eff6ff;
color: #1d4ed8;
font-weight: 600;
}
&--editing {
padding: 0 !important;
background: #fef9c3 !important;
outline: 2px solid #1d4ed8 !important;
outline-offset: -2px;
}
}
&__cell-input {
width: 100%;
height: 100%;
min-height: 28px;
border: none;
outline: none;
background: transparent;
padding: 7px;
font-size: 11px;
text-align: right;
font-family: inherit;
color: #0f172a;
box-sizing: border-box;
}
&__cibil-strip {
background: white;
border: 1px solid #e2e8f0;
border-radius: 9px;
padding: 9px 14px;
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
transition: background-color 0.1s;
&:hover {
background: #f8fafc;
}
}
&__cibil-badge {
width: 24px;
height: 24px;
border-radius: 12px;
background: #dbeafe;
color: #1d4ed8;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 700;
}
&__cibil-text {
flex: 1;
}
&__cibil-title {
font-size: 12px;
font-weight: 600;
}
&__cibil-sub {
font-size: 10px;
color: #64748b;
margin-top: 1px;
}
&__cibil-edit {
font-size: 11px;
font-weight: 600;
color: #1d4ed8;
cursor: pointer;
}
&__footer {
background: white;
border: 1px solid #e2e8f0;
border-radius: 9px;
padding: 10px 14px;
display: flex;
align-items: center;
justify-content: space-between;
}
&__footer-note {
font-size: 11px;
color: #64748b;
}
&__footer-actions {
display: flex;
gap: 6px;
}
&__submit {
background: #059669 !important;
color: white !important;
font-weight: 600;
i {
margin-right: 4px;
}
}
}
@@ -0,0 +1,196 @@
/**
* 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, ElementRef, OnInit, ViewChild, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { MatIconModule } from '@angular/material/icon';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { MatNativeDateModule } from '@angular/material/core';
import { RateRegisterService } from '../rate-register.service';
import { RateCell, RateChartRow, RateRegister, SlabColumn } from '../models/rate-register.model';
/**
* Grid editor for one Loan Interest Rate Register — Excel-shaped: 34 products as rows and 10
* amount slabs as columns. Cells can hold a plain rate, N.A., or a formula ("1% + FD"). All
* data today comes from {@link RateRegisterService}; wire-up to the real API comes later.
*/
@Component({
selector: 'mifosx-edit-register',
templateUrl: './edit-register.component.html',
styleUrls: ['./edit-register.component.scss'],
imports: [
CommonModule,
FormsModule,
RouterLink,
MatButtonModule,
MatFormFieldModule,
MatInputModule,
MatSelectModule,
MatIconModule,
MatDatepickerModule,
MatNativeDateModule
]
})
export class EditRegisterComponent implements OnInit {
private readonly service = inject(RateRegisterService);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
register!: RateRegister;
slabs: SlabColumn[] = [];
rows: RateChartRow[] = [];
/** The cell currently being edited (row/column indices into the grid), or null when nothing is open. */
editing: { row: number; col: number } | null = null;
editValue = '';
@ViewChild('cellInput') cellInput?: ElementRef<HTMLInputElement>;
ngOnInit(): void {
const idParam = this.route.snapshot.queryParamMap.get('id');
const id = idParam ? Number(idParam) : null;
this.service.list().subscribe((all) => {
this.register = (id ? all.find((r) => r.id === id) : undefined) ?? this.service.getActiveOrFirstDraft();
this.slabs = this.register.slabs;
this.rows = this.register.rows;
});
}
/** Formatted display for a cell. */
display(cell: RateCell): string {
if (cell === null) {
return 'N.A.';
}
if (typeof cell === 'string') {
return cell;
}
return cell.toFixed(2);
}
isFormulaRow(row: RateChartRow): boolean {
return !!row.isFormula;
}
isNa(cell: RateCell): boolean {
return cell === null;
}
/** Click on a cell → open it as an editable input, pre-filled with the current value. */
startEdit(rowIdx: number, colIdx: number, current: RateCell): void {
this.editing = { row: rowIdx, col: colIdx };
if (current === null) {
this.editValue = '';
} else if (typeof current === 'string') {
this.editValue = current;
} else {
this.editValue = current.toString();
}
// Let the view render the input, then focus and select its contents.
setTimeout(() => {
const el = this.cellInput?.nativeElement;
if (el) {
el.focus();
el.select();
}
}, 0);
}
isEditing(rowIdx: number, colIdx: number): boolean {
return !!this.editing && this.editing.row === rowIdx && this.editing.col === colIdx;
}
/**
* Save the cell:
* - empty / "N.A." / "NA" → the slab is marked unavailable
* - value with letters, "%" or "+" → persisted as a formula string ("1% + FD")
* - plain number → stored as a number so it formats as a %
*/
saveEdit(rowIdx: number, colIdx: number): void {
if (!this.editing) {
return;
}
const raw = this.editValue.trim();
let next: RateCell;
if (!raw || raw.toLowerCase() === 'n.a.' || raw.toLowerCase() === 'na') {
next = null;
} else if (/[a-zA-Z+%]/.test(raw)) {
next = raw;
} else {
const num = Number(raw);
next = Number.isFinite(num) ? num : this.rows[rowIdx].rates[colIdx];
}
this.rows[rowIdx].rates[colIdx] = next;
this.rows[rowIdx].isFormula = this.rows[rowIdx].rates.some((c) => typeof c === 'string');
this.editing = null;
}
cancelEdit(): void {
this.editing = null;
}
/** Append a new empty product row at the bottom of the grid. */
addProduct(): void {
const name = window.prompt('New product name', 'New product');
if (!name) {
return;
}
const next = this.rows.length > 0 ? Math.max(...this.rows.map((r) => r.srNo)) + 1 : 1;
this.rows.push({
srNo: next,
particulars: name.trim(),
rates: Array(this.slabs.length).fill(null),
isFormula: false
});
}
/** Append a new amount slab column; every existing row grows by one blank cell. */
addSlab(): void {
const label = window.prompt('Slab label (e.g. "Upto 50,00,000" or "> 50,00,000")', 'New slab');
if (!label) {
return;
}
const amountText = window.prompt('Amount upper bound in ₹ (leave blank for no cap)', '');
const amountUpto = amountText && amountText.trim() ? Number(amountText) : null;
this.slabs.push({
key: `s_${Date.now()}`,
label: label.trim(),
amountUpto: amountUpto !== null && !Number.isFinite(amountUpto) ? null : amountUpto
});
this.rows.forEach((r) => r.rates.push(null));
}
editCibil(): void {
this.router.navigate(['/products/loan-interest-rate-register/cibil'], { queryParams: { id: this.register.id } });
}
backToList(): void {
this.router.navigate(['/products/loan-interest-rate-register']);
}
submitForApproval(): void {
this.register.status = 'Active';
this.router.navigate(['/products/loan-interest-rate-register']);
}
statusChipClass(): string {
switch (this.register?.status) {
case 'Active':
return 'chip chip--active';
case 'Draft':
return 'chip chip--draft';
default:
return 'chip chip--expired';
}
}
}
@@ -0,0 +1,83 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="lirr">
<div class="lirr__header">
<div class="lirr__header-icon"><i class="fas fa-percentage"></i></div>
<div class="lirr__header-text">
<h2>Loan Interest Rate Register</h2>
<p>Central CIBIL-based rate chart used across all loan products</p>
</div>
<button mat-stroked-button class="lirr__excel-btn">
<i class="fas fa-exchange-alt"></i>
Excel
</button>
<button mat-flat-button color="primary" class="lirr__create-btn" (click)="create()">
<i class="fas fa-plus"></i>
Create register
</button>
</div>
<div class="lirr__body">
<div class="lirr__kpi-grid">
<div class="lirr__kpi">
<div class="lirr__kpi-label">ACTIVE REGISTER</div>
<div class="lirr__kpi-value">{{ active?.name ?? 'None' }}</div>
<div class="lirr__kpi-sub lirr__kpi-sub--ok">Effective {{ activeWindow() }}</div>
</div>
<div class="lirr__kpi">
<div class="lirr__kpi-label">SLABS</div>
<div class="lirr__kpi-value">10 amount slabs</div>
<div class="lirr__kpi-sub">₹10k → &gt; ₹25L</div>
</div>
<div class="lirr__kpi">
<div class="lirr__kpi-label">CIBIL ADJUSTMENTS</div>
<div class="lirr__kpi-value">{{ active?.cibilSlabs?.length ?? 0 }} slabs</div>
<div class="lirr__kpi-sub">+1.00% → −0.20%</div>
</div>
</div>
<div class="lirr__table-card">
<div class="lirr__table-head">
<div class="lirr__table-title">All registers</div>
</div>
<table class="lirr__table">
<thead>
<tr>
<th>Name</th>
<th>Effective from</th>
<th>Effective to</th>
<th>Status</th>
<th>Created by</th>
<th>Last modified</th>
<th></th>
</tr>
</thead>
<tbody>
<tr *ngFor="let r of registers" [class.lirr__row--active]="r.status === 'Active'" (click)="edit(r)">
<td>
<div class="lirr__name">{{ r.name }}</div>
<div class="lirr__name-sub" *ngIf="r.note">{{ r.note }}</div>
</td>
<td>{{ r.effectiveFrom | date: 'dd MMM yyyy' }}</td>
<td>{{ r.effectiveTo ? (r.effectiveTo | date: 'dd MMM yyyy') : '—' }}</td>
<td>
<span [class]="statusChipClass(r.status)">{{ r.status === 'Active' ? '● Active' : r.status }}</span>
</td>
<td>{{ r.createdBy }}</td>
<td>{{ r.lastModified | date: 'dd MMM yyyy' }}</td>
<td class="lirr__row-actions">{{ r.status === 'Draft' ? 'Edit · Submit' : 'View' }}</td>
</tr>
</tbody>
</table>
<div class="lirr__table-foot">
<div>Showing 1–{{ registers.length }} of {{ registers.length }}</div>
</div>
</div>
</div>
</div>
@@ -0,0 +1,212 @@
/**
* 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/.
*/
.lirr {
background: #f8fafc;
min-height: calc(100vh - 160px);
&__header {
display: flex;
align-items: center;
gap: 14px;
padding: 22px 32px 14px;
background: white;
border-bottom: 1px solid #e2e8f0;
}
&__header-icon {
width: 40px;
height: 40px;
border-radius: 9px;
background: #eff6ff;
color: #1d4ed8;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
}
&__header-text {
flex: 1;
h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
}
p {
margin: 3px 0 0;
font-size: 12px;
color: #64748b;
}
}
&__excel-btn,
&__create-btn {
i {
margin-right: 6px;
}
}
&__body {
padding: 20px 32px;
}
&__kpi-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 12px;
margin-bottom: 18px;
}
&__kpi {
background: white;
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 14px 16px;
}
&__kpi-label {
font-size: 11px;
color: #64748b;
font-weight: 600;
letter-spacing: 0.3px;
}
&__kpi-value {
font-size: 18px;
font-weight: 600;
margin-top: 4px;
}
&__kpi-sub {
font-size: 11px;
color: #64748b;
margin-top: 4px;
&--ok {
color: #059669;
}
}
&__table-card {
background: white;
border: 1px solid #e2e8f0;
border-radius: 10px;
overflow: hidden;
}
&__table-head {
padding: 12px 20px;
border-bottom: 1px solid #f1f5f9;
display: flex;
align-items: center;
}
&__table-title {
font-size: 14px;
font-weight: 600;
}
&__table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
thead {
background: #f8fafc;
color: #64748b;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.3px;
th {
text-align: left;
padding: 10px 12px;
font-weight: 600;
&:first-child {
padding-left: 20px;
}
}
}
tbody tr {
border-top: 1px solid #f1f5f9;
cursor: pointer;
transition: background-color 0.1s;
&:hover {
background: #f8fafc;
}
td {
padding: 12px;
&:first-child {
padding-left: 20px;
}
}
}
}
&__row--active {
background: #f0fdf4;
}
&__name {
font-weight: 600;
}
&__name-sub {
font-size: 11px;
color: #64748b;
margin-top: 2px;
}
&__row-actions {
text-align: right;
color: #64748b;
padding-right: 20px !important;
}
&__table-foot {
padding: 10px 20px;
border-top: 1px solid #f1f5f9;
font-size: 12px;
color: #64748b;
}
}
.chip {
padding: 3px 10px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
&--active {
background: #dcfce7;
color: #166534;
}
&--draft {
background: #fef3c7;
color: #92400e;
}
&--expired {
background: #f1f5f9;
color: #475569;
}
&--scheduled {
background: #e0e7ff;
color: #3730a3;
}
}
@@ -0,0 +1,78 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { DatePipe } from '@angular/common';
import { Router } from '@angular/router';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { RateRegisterService } from './rate-register.service';
import { RateRegister } from './models/rate-register.model';
/**
* List screen for the central Loan Interest Rate Register — three KPI cards followed by the
* registers table (name, effective window, status, author, last modified).
*/
@Component({
selector: 'mifosx-loan-interest-rate-register',
templateUrl: './loan-interest-rate-register.component.html',
styleUrls: ['./loan-interest-rate-register.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
DatePipe
]
})
export class LoanInterestRateRegisterComponent implements OnInit {
private readonly service = inject(RateRegisterService);
private readonly router = inject(Router);
registers: RateRegister[] = [];
active?: RateRegister;
ngOnInit(): void {
this.service.list().subscribe((rows) => {
this.registers = rows;
this.active = rows.find((r) => r.status === 'Active');
});
}
edit(register: RateRegister): void {
this.router.navigate(['/products/loan-interest-rate-register/edit'], { queryParams: { id: register.id } });
}
create(): void {
this.router.navigate(['/products/loan-interest-rate-register/edit']);
}
statusChipClass(status: RateRegister['status']): string {
switch (status) {
case 'Active':
return 'chip chip--active';
case 'Draft':
return 'chip chip--draft';
case 'Expired':
return 'chip chip--expired';
default:
return 'chip chip--scheduled';
}
}
activeWindow(): string {
if (!this.active) {
return '—';
}
return `${this.formatShort(this.active.effectiveFrom)} → ${this.formatShort(this.active.effectiveTo)}`;
}
private formatShort(iso: string | null): string {
if (!iso) {
return '—';
}
const d = new Date(iso);
return d.toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: '2-digit' });
}
}
@@ -0,0 +1,52 @@
/**
* 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/.
*/
export type RegisterStatus = 'Active' | 'Draft' | 'Expired' | 'Scheduled';
export interface SlabColumn {
key: string;
label: string;
amountUpto: number | null;
}
/** A single rate cell: number for a %, string for a formula ("1% + FD"), null for N.A. */
export type RateCell = number | string | null;
export interface RateChartRow {
srNo: number;
particulars: string;
isFormula?: boolean;
rates: RateCell[];
}
export type RiskTier = 'Poor' | 'Fair' | 'Good' | 'V. Good' | 'Excellent';
export interface CibilSlab {
from: number;
to: number;
tier: RiskTier;
type: 'Flat';
adjustmentPct: number;
}
export interface RateRegister {
id: number;
name: string;
effectiveFrom: string;
effectiveTo: string | null;
currency: string;
status: RegisterStatus;
createdBy: string;
lastModified: string;
note?: string;
slabs: SlabColumn[];
rows: RateChartRow[];
cibilSlabs: CibilSlab[];
fallbackNoCibilSlab: number;
fallbackBelowMin: 'use-min-slab' | 'block';
}
@@ -0,0 +1,457 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Injectable } from '@angular/core';
import { Observable, of } from 'rxjs';
import { CibilSlab, RateChartRow, RateRegister, SlabColumn } from './models/rate-register.model';
/**
* In-memory stand-in for the central Loan Interest Rate Register.
*
* Mirrors the Excel chart the bank's Board approves each quarter (34 products × 10 amount slabs).
* Backing API will land later; today the service only serves the demo data in memory so the
* screens can be driven end-to-end without a server.
*/
@Injectable({ providedIn: 'root' })
export class RateRegisterService {
private readonly slabs: SlabColumn[] = [
{ key: 's1', label: 'Upto 10,000', amountUpto: 10000 },
{ key: 's2', label: 'Upto 25,000', amountUpto: 25000 },
{ key: 's3', label: 'Upto 1,00,000', amountUpto: 100000 },
{ key: 's4', label: 'Upto 2,00,000', amountUpto: 200000 },
{ key: 's5', label: 'Upto 5,00,000', amountUpto: 500000 },
{ key: 's6', label: 'Upto 10,00,000', amountUpto: 1000000 },
{ key: 's7', label: 'Upto 15,00,000', amountUpto: 1500000 },
{ key: 's8', label: 'Upto 20,00,000', amountUpto: 2000000 },
{ key: 's9', label: 'Upto 25,00,000', amountUpto: 2500000 },
{ key: 's10', label: '> 25,00,000', amountUpto: null }
];
// Values taken from the bank's approved Interest Rate of Loans register.
private readonly rows: RateChartRow[] = [
this.row(1, 'Consumer Loan', [
18,
18,
15,
13,
null,
null,
null,
null,
null,
null
]),
this.row(
2,
'Self Employment Loan',
[
18,
18,
15,
13,
12.5,
12,
11.5,
11,
10.5,
10.5
]
),
this.row(
3,
'Business Term Loan for running',
[
18,
18,
15,
13,
12.5,
11,
10.5,
10,
9.5,
9.5
]
),
this.row(
4,
'Cash Credit',
[
18,
18,
15,
13,
13,
11,
10.5,
10,
9.5,
9.5
]
),
this.row(
5,
'Limit (OD) on property — MSME',
[
18,
18,
15,
13,
13,
11,
10.5,
10,
9.5,
9.5
]
),
this.row(
6,
'Limit (OD) on property — Personal',
[
18,
18,
15,
14,
13.5,
12,
11.5,
11,
10.5,
10.5
]
),
this.row(7, 'House Purchase / Construction', [
null,
null,
15,
12,
12,
9.25,
8.75,
8.25,
8,
8
]),
this.row(8, 'House Repairing / Renovation', [
null,
null,
15,
14,
14,
13,
12,
11,
10.5,
10
]),
this.row(
9,
'Commercial Real Estate Loan',
[
18,
18,
15,
13,
12.5,
12,
11,
10.5,
10.5,
10
]
),
this.row(10, 'Loan against Jewellery', this.flat(10.8)),
this.row(11, 'Loan against Warehouse Receipt (A)', this.flat(13)),
this.row(
12,
'Loan against Warehouse Receipt (B)',
[
10.5,
10.5,
10.5,
10.5,
10.5,
10.5,
10,
10,
10,
9
]
),
this.row(13, 'Agriculture Loan', [
null,
null,
15,
12,
12,
12,
11.75,
11.5,
11.25,
11
]),
this.row(14, 'Personal Loan (Unsecured)', [
18,
18,
16,
15,
null,
null,
null,
null,
null,
null
]),
this.row(
15,
'Personal Loan (Secured / Property)',
[
18,
18,
16,
15,
15,
13,
12,
11.5,
11,
11
]
),
this.row(16, 'Two Wheeler Loan', this.flat(13)),
this.row(17, 'Car Loan (4-Wheel Domestic)', [
null,
null,
10,
9.5,
9.5,
8.75,
8.5,
8.35,
8.25,
8.25
]),
this.row(18, 'Old Car Loan (4-Wheel Domestic)', [
null,
null,
13,
12.5,
12.5,
12,
11.5,
11,
10.5,
10.5
]),
this.row(19, 'Vehicle Loan (Commercial & Three-wheeler)', [
null,
null,
13,
12,
12,
10.5,
10,
10,
10,
10
]),
this.row(20, 'Old Vehicle Loan (Commercial)', [
null,
null,
15,
14,
14,
13.5,
13.5,
13,
12.5,
12.5
]),
this.row(
21,
'Machinery & Plant Loan',
[
18,
18,
15,
13,
12.5,
12,
11,
10.5,
10.5,
10
]
),
this.row(
22,
'Office Chamber / Equipment Loan',
[
18,
18,
15,
13,
12.5,
12,
11,
10.5,
10.5,
10
]
),
this.formulaRow(23, 'Loan against F.D.R.', [
'1% + FD',
'1% + FD',
'1% + FD',
'1% + FD',
'1% + FD',
'1% + FD',
'0.75% + FD',
'0.50% + FD',
'0.50% + FD',
'0.50% + FD'
]),
this.row(24, 'Loan against Daily Deposit & Gold', this.flat(10)),
this.formulaRow(25, 'Loan against Recurring Deposit', Array(10).fill('1% + RD')),
this.row(26, 'Loan against Staff Service Fund', this.flat(7)),
this.row(27, 'Over Draft A/c', this.flat(null)),
this.row(28, 'Loan on LIC Policy', this.flat(null)),
this.row(29, 'Loan on KVP / NSC', this.flat(null)),
this.row(30, 'Bill Discount', this.flat(null)),
this.row(31, 'Temporary Limit', this.flat(15)),
this.row(32, 'Bank Guarantee (commission)', this.flat(0.25)),
this.row(33, 'Sugam Vyapar Loan', this.flat(null)),
this.row(34, 'OD to Staff (upto 2 lakh)', this.flat(null))
];
private readonly cibilSlabs: CibilSlab[] = [
{ from: 0, to: 649, tier: 'Poor', type: 'Flat', adjustmentPct: 1.0 },
{ from: 650, to: 749, tier: 'Fair', type: 'Flat', adjustmentPct: 0.5 },
{ from: 750, to: 799, tier: 'Good', type: 'Flat', adjustmentPct: 0 },
{ from: 800, to: 850, tier: 'V. Good', type: 'Flat', adjustmentPct: -0.1 },
{ from: 851, to: 900, tier: 'Excellent', type: 'Flat', adjustmentPct: -0.2 }
];
private readonly registers: RateRegister[] = [
{
id: 1001,
name: 'Q1 2027 Rates',
effectiveFrom: '2027-01-01',
effectiveTo: '2027-03-31',
currency: 'INR',
status: 'Draft',
createdBy: 'Sonali',
lastModified: '2026-10-01',
note: 'Scheduled · future effective',
slabs: this.slabs,
rows: this.rows,
cibilSlabs: this.cibilSlabs,
fallbackNoCibilSlab: 2,
fallbackBelowMin: 'block'
},
{
id: 1002,
name: 'Interest Rate of Loans — Oct 2026',
effectiveFrom: '2026-10-01',
effectiveTo: '2026-12-31',
currency: 'INR',
status: 'Active',
createdBy: 'Admin',
lastModified: '2026-09-25',
note: 'Approved by Board on 25 Sep',
slabs: this.slabs,
rows: this.rows,
cibilSlabs: this.cibilSlabs,
fallbackNoCibilSlab: 2,
fallbackBelowMin: 'block'
},
{
id: 1003,
name: 'Q3 2026 Rates',
effectiveFrom: '2026-07-01',
effectiveTo: '2026-09-30',
currency: 'INR',
status: 'Expired',
createdBy: 'Admin',
lastModified: '2026-06-15',
slabs: this.slabs,
rows: this.rows,
cibilSlabs: this.cibilSlabs,
fallbackNoCibilSlab: 2,
fallbackBelowMin: 'block'
},
{
id: 1004,
name: 'Q2 2026 Rates',
effectiveFrom: '2026-04-01',
effectiveTo: '2026-06-30',
currency: 'INR',
status: 'Expired',
createdBy: 'Admin',
lastModified: '2026-03-10',
slabs: this.slabs,
rows: this.rows,
cibilSlabs: this.cibilSlabs,
fallbackNoCibilSlab: 2,
fallbackBelowMin: 'block'
},
{
id: 1005,
name: 'Q1 2026 Rates',
effectiveFrom: '2026-01-01',
effectiveTo: '2026-03-31',
currency: 'INR',
status: 'Expired',
createdBy: 'Admin',
lastModified: '2025-12-20',
slabs: this.slabs,
rows: this.rows,
cibilSlabs: this.cibilSlabs,
fallbackNoCibilSlab: 2,
fallbackBelowMin: 'block'
}
];
list(): Observable<RateRegister[]> {
return of(this.registers);
}
get(id: number): Observable<RateRegister | undefined> {
return of(this.registers.find((r) => r.id === id));
}
/** The one shown by default on list → edit navigation. */
getActiveOrFirstDraft(): RateRegister {
return (
this.registers.find((r) => r.status === 'Draft') ??
this.registers.find((r) => r.status === 'Active') ??
this.registers[0]
);
}
getSlabColumns(): SlabColumn[] {
return this.slabs;
}
getCibilSlabs(): CibilSlab[] {
return this.cibilSlabs;
}
private row(srNo: number, particulars: string, rates: (number | null)[]): RateChartRow {
return { srNo, particulars, rates };
}
private formulaRow(srNo: number, particulars: string, rates: string[]): RateChartRow {
return { srNo, particulars, isFormula: true, rates };
}
private flat(value: number | null): (number | null)[] {
return Array(10).fill(value);
}
}
@@ -42,19 +42,94 @@
</div>
</mat-tab>
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-cog lp-tab-icon"></i>
{{ 'labels.inputs.SETTINGS' | translate }}
</ng-template>
<div class="lp-tab-content">
<mifosx-loan-product-settings-step
[loanProductsTemplate]="loanProductsTemplate"
[isLinkedToFloatingInterestRates]="loanProductTermsForm.get('isLinkedToFloatingInterestRates')"
(advancePaymentStrategy)="advancePaymentStrategy($event)"
></mifosx-loan-product-settings-step>
</div>
</mat-tab>
@if (isAdvanceProduct) {
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-shield-alt lp-tab-icon"></i>
SECURITY &amp; LIEN
</ng-template>
<div class="lp-tab-content">
<mifosx-loan-product-security-lien-step
[loanProductsTemplate]="loanProductsTemplate"
[productTypeGroup]="loanProductDetailsForm.get('productTypeGroup')"
[productTypeSubGroup]="loanProductDetailsForm.get('productTypeSubGroup')"
></mifosx-loan-product-security-lien-step>
</div>
</mat-tab>
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-sliders-h lp-tab-icon"></i>
LIMIT &amp; TENURE
</ng-template>
<div class="lp-tab-content">
<mifosx-loan-product-advance-limit-tenure-step
[productSubType]="advanceSubType"
></mifosx-loan-product-advance-limit-tenure-step>
</div>
</mat-tab>
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-percent lp-tab-icon"></i>
INTEREST
</ng-template>
<div class="lp-tab-content">
<mifosx-loan-product-advance-interest-step></mifosx-loan-product-advance-interest-step>
</div>
</mat-tab>
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-triangle-exclamation lp-tab-icon"></i>
PENAL &amp; NPA
</ng-template>
<div class="lp-tab-content">
<mifosx-loan-product-penal-npa-step
[loanProductsTemplate]="loanProductsTemplate"
></mifosx-loan-product-penal-npa-step>
</div>
</mat-tab>
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-arrows-rotate lp-tab-icon"></i>
RENEWAL &amp; CLOSURE
</ng-template>
<div class="lp-tab-content">
<mifosx-loan-product-renewal-closure-step></mifosx-loan-product-renewal-closure-step>
</div>
</mat-tab>
@if (isOverdraftFacility) {
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-money-check lp-tab-icon"></i>
CHEQUE BOOK
</ng-template>
<div class="lp-tab-content">
<mifosx-loan-product-cheque-book-step></mifosx-loan-product-cheque-book-step>
</div>
</mat-tab>
}
}
@if (!isAdvanceProduct) {
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-cog lp-tab-icon"></i>
{{ 'labels.inputs.SETTINGS' | translate }}
</ng-template>
<div class="lp-tab-content">
<mifosx-loan-product-settings-step
[loanProductsTemplate]="loanProductsTemplate"
[isLinkedToFloatingInterestRates]="loanProductTermsForm?.get('isLinkedToFloatingInterestRates')"
[productTypeGroup]="loanProductDetailsForm.get('productTypeGroup')"
(advancePaymentStrategy)="advancePaymentStrategy($event)"
></mifosx-loan-product-settings-step>
</div>
</mat-tab>
}
@if (isAdvancedPaymentStrategy) {
<mat-tab>
@@ -90,15 +165,19 @@
</mat-tab>
}
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-sliders-h lp-tab-icon"></i>
{{ 'labels.inputs.TERMS' | translate }}
</ng-template>
<div class="lp-tab-content">
<mifosx-loan-product-terms-step [loanProductsTemplate]="loanProductsTemplate"></mifosx-loan-product-terms-step>
</div>
</mat-tab>
@if (!isAdvanceProduct) {
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-sliders-h lp-tab-icon"></i>
{{ 'labels.inputs.TERMS' | translate }}
</ng-template>
<div class="lp-tab-content">
<mifosx-loan-product-terms-step
[loanProductsTemplate]="loanProductsTemplate"
></mifosx-loan-product-terms-step>
</div>
</mat-tab>
}
<mat-tab>
<ng-template mat-tab-label>
@@ -109,7 +188,7 @@
<mifosx-loan-product-charges-step
[loanProductsTemplate]="loanProductsTemplate"
[currencyCode]="loanProductCurrencyForm.get('currencyCode')"
[multiDisburseLoan]="loanProductSettingsForm.get('multiDisburseLoan')"
[multiDisburseLoan]="loanProductSettingsForm?.get('multiDisburseLoan') ?? multiDisburseFallback"
></mifosx-loan-product-charges-step>
</div>
</mat-tab>
@@ -13,6 +13,12 @@ import { ActivatedRoute, Router } from '@angular/router';
/** Custom Components */
import { LoanProductDetailsStepComponent } from '../loan-product-stepper/loan-product-details-step/loan-product-details-step.component';
import { LoanProductCurrencyStepComponent } from '../loan-product-stepper/loan-product-currency-step/loan-product-currency-step.component';
import { LoanProductSecurityLienStepComponent } from '../loan-product-stepper/loan-product-security-lien-step/loan-product-security-lien-step.component';
import { LoanProductAdvanceLimitTenureStepComponent } from '../loan-product-stepper/loan-product-advance-limit-tenure-step/loan-product-advance-limit-tenure-step.component';
import { LoanProductAdvanceInterestStepComponent } from '../loan-product-stepper/loan-product-advance-interest-step/loan-product-advance-interest-step.component';
import { LoanProductPenalNpaStepComponent } from '../loan-product-stepper/loan-product-penal-npa-step/loan-product-penal-npa-step.component';
import { LoanProductRenewalClosureStepComponent } from '../loan-product-stepper/loan-product-renewal-closure-step/loan-product-renewal-closure-step.component';
import { LoanProductChequeBookStepComponent } from '../loan-product-stepper/loan-product-cheque-book-step/loan-product-cheque-book-step.component';
import { LoanProductTermsStepComponent } from '../loan-product-stepper/loan-product-terms-step/loan-product-terms-step.component';
import { LoanProductSettingsStepComponent } from '../loan-product-stepper/loan-product-settings-step/loan-product-settings-step.component';
import { LoanProductChargesStepComponent } from '../loan-product-stepper/loan-product-charges-step/loan-product-charges-step.component';
@@ -34,7 +40,7 @@ import {
import { Accounting } from 'app/core/utils/accounting';
import { StringEnumOptionData } from '../../../shared/models/option-data.model';
import { LoanProductDeferredIncomeRecognitionStepComponent } from '../loan-product-stepper/loan-product-capitalized-income-step/loan-product-deferred-income-recognition-step.component';
import { UntypedFormGroup } from '@angular/forms';
import { UntypedFormControl, UntypedFormGroup } from '@angular/forms';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { LoanProductPaymentStrategyStepComponent } from '../loan-product-stepper/loan-product-payment-strategy-step/loan-product-payment-strategy-step.component';
import { LoanProductPreviewStepComponent } from '../loan-product-stepper/loan-product-preview-step/loan-product-preview-step.component';
@@ -51,6 +57,12 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
FaIconComponent,
LoanProductDetailsStepComponent,
LoanProductCurrencyStepComponent,
LoanProductSecurityLienStepComponent,
LoanProductAdvanceLimitTenureStepComponent,
LoanProductAdvanceInterestStepComponent,
LoanProductPenalNpaStepComponent,
LoanProductRenewalClosureStepComponent,
LoanProductChequeBookStepComponent,
LoanProductSettingsStepComponent,
LoanProductInterestRefundStepComponent,
LoanProductPaymentStrategyStepComponent,
@@ -73,12 +85,27 @@ export class CreateLoanProductComponent implements OnInit {
@ViewChild(LoanProductDetailsStepComponent, { static: true }) loanProductDetailsStep: LoanProductDetailsStepComponent;
@ViewChild(LoanProductCurrencyStepComponent, { static: true })
loanProductCurrencyStep: LoanProductCurrencyStepComponent;
// Conditional tabs (Advances A/C only): static false — resolved after the @if renders them.
@ViewChild(LoanProductSecurityLienStepComponent)
loanProductSecurityLienStep: LoanProductSecurityLienStepComponent;
@ViewChild(LoanProductAdvanceLimitTenureStepComponent)
loanProductAdvanceLimitTenureStep: LoanProductAdvanceLimitTenureStepComponent;
@ViewChild(LoanProductAdvanceInterestStepComponent)
loanProductAdvanceInterestStep: LoanProductAdvanceInterestStepComponent;
@ViewChild(LoanProductPenalNpaStepComponent) loanProductPenalNpaStep: LoanProductPenalNpaStepComponent;
@ViewChild(LoanProductRenewalClosureStepComponent)
loanProductRenewalClosureStep: LoanProductRenewalClosureStepComponent;
@ViewChild(LoanProductChequeBookStepComponent) loanProductChequeBookStep: LoanProductChequeBookStepComponent;
/** Charges step needs a real control; Settings is absent on the Advances path, so fall back to this. */
readonly multiDisburseFallback = new UntypedFormControl(false);
@ViewChild(LoanProductInterestRefundStepComponent, { static: true })
loanProductInterestRefundStep: LoanProductInterestRefundStepComponent;
@ViewChild(LoanProductDeferredIncomeRecognitionStepComponent, { static: true })
loanProductDeferredIncomeRecognitionStep: LoanProductDeferredIncomeRecognitionStepComponent;
@ViewChild(LoanProductTermsStepComponent, { static: true }) loanProductTermsStep: LoanProductTermsStepComponent;
@ViewChild(LoanProductSettingsStepComponent, { static: true })
// Terms & Settings tabs are now conditional (Loans path only), so resolve them non-statically.
@ViewChild(LoanProductTermsStepComponent) loanProductTermsStep: LoanProductTermsStepComponent;
@ViewChild(LoanProductSettingsStepComponent)
loanProductSettingsStep: LoanProductSettingsStepComponent;
@ViewChild(LoanProductChargesStepComponent, { static: true }) loanProductChargesStep: LoanProductChargesStepComponent;
@ViewChild(LoanProductAccountingStepComponent, { static: true })
@@ -143,7 +170,18 @@ export class CreateLoanProductComponent implements OnInit {
}
get loanProductTermsForm() {
return this.loanProductTermsStep.loanProductTermsForm;
// Absent on the Advances path (tab hidden), so guard for callers.
return this.loanProductTermsStep?.loanProductTermsForm;
}
/** True once the Advance Limit & Tenure step reports an Overdraft (running) facility. */
get isOverdraftFacility(): boolean {
return this.isAdvanceProduct && !!this.loanProductAdvanceLimitTenureStep?.isOverdraft;
}
/** Sub type chosen on the Details step; drives the FD 12–15 month tenure policy downstream. */
get advanceSubType(): string {
return this.loanProductDetailsStep?.loanProductDetailsForm?.get('productTypeSubGroup')?.value ?? '';
}
advancePaymentStrategy(value: string) {
@@ -231,47 +269,71 @@ export class CreateLoanProductComponent implements OnInit {
}
get loanProductSettingsForm() {
return this.loanProductSettingsStep.loanProductSettingsForm;
// Absent on the Advances path (tab hidden), so guard for callers.
return this.loanProductSettingsStep?.loanProductSettingsForm;
}
get loanProductAccountingForm() {
return this.loanProductAccountingStep.loanProductAccountingForm;
}
/** The Details step's chosen product type; drives the Advances-only tabs. */
get isAdvanceProduct(): boolean {
return this.loanProductDetailsForm?.get('productTypeGroup')?.value === 'advances';
}
get totalTabs(): number {
let tabs = 6; // details, currency, settings, terms, charges, accounting
if (this.isAdvancedPaymentStrategy) tabs += 3; // interest refund, payment allocation, deferred income
let tabs = 4; // details, currency, charges, accounting (always)
if (this.isAdvanceProduct) {
// security & lien, limit & tenure, interest, penal & npa, renewal & closure
tabs += 5;
if (this.isOverdraftFacility) tabs += 1; // cheque book
} else {
tabs += 2; // settings, terms
if (this.isAdvancedPaymentStrategy) tabs += 3; // interest refund, payment allocation, deferred income
}
if (this.loanProductFormValid) tabs += 1; // preview
return tabs;
}
get loanProductFormValid() {
if (this.isAdvancedPaymentStrategy) {
const base =
this.loanProductDetailsForm.valid && this.loanProductCurrencyForm.valid && this.loanProductAccountingForm.valid;
// Advances A/C: the curated Figma steps gate the form; Settings/Terms tabs are hidden.
if (this.isAdvanceProduct) {
return (
this.loanProductDetailsForm.valid &&
this.loanProductCurrencyForm.valid &&
this.loanProductTermsForm.valid &&
this.loanProductSettingsForm.valid &&
this.loanIncomeCapitalizationForm.valid &&
this.loanProductAccountingForm.valid
);
} else {
return (
this.loanProductDetailsForm.valid &&
this.loanProductCurrencyForm.valid &&
this.loanProductTermsForm.valid &&
this.loanProductSettingsForm.valid &&
this.loanProductAccountingForm.valid
base &&
!!this.loanProductSecurityLienStep?.loanProductSecurityLienForm?.valid &&
!!this.loanProductAdvanceLimitTenureStep?.loanProductAdvanceLimitTenureForm?.valid &&
!!this.loanProductAdvanceInterestStep?.loanProductAdvanceInterestForm?.valid &&
!!this.loanProductPenalNpaStep?.loanProductPenalNpaForm?.valid &&
!!this.loanProductRenewalClosureStep?.loanProductRenewalClosureForm?.valid &&
(!this.isOverdraftFacility || !!this.loanProductChequeBookStep?.loanProductChequeBookForm?.valid)
);
}
// Loans A/C: original Settings + Terms path.
const loansValid = !!this.loanProductTermsForm?.valid && !!this.loanProductSettingsForm?.valid;
if (this.isAdvancedPaymentStrategy) {
return base && loansValid && !!this.loanIncomeCapitalizationForm?.valid;
}
return base && loansValid;
}
get loanProduct() {
// Advances A/C builds its own payload: the curated steps map onto Fineract's mandatory core fields
// (principal, tenure, rate, amortization defaults) plus the whitelisted satellite params.
if (this.isAdvanceProduct) {
return this.assembleAdvanceProduct();
}
// Loans A/C: original composition (Settings/Terms present).
const loanProduct = {
...this.loanProductDetailsStep.loanProductDetails,
...this.loanProductCurrencyStep.loanProductCurrency,
...this.loanProductTermsStep.loanProductTerms,
...this.loanProductSettingsStep.loanProductSettings,
...(this.loanProductTermsStep?.loanProductTerms ?? {}),
...(this.loanProductSettingsStep?.loanProductSettings ?? {}),
...this.loanProductChargesStep.loanProductCharges,
...this.loanProductAccountingStep.loanProductAccounting
};
@@ -304,6 +366,163 @@ export class CreateLoanProductComponent implements OnInit {
return loanProduct;
}
/**
* Builds the create payload for an Advances A/C product: the curated wizard steps mapped onto Fineract's
* mandatory core fields (so the standard product saves) plus the whitelisted satellite params (persisted by
* LoanProductAdvanceExtService). Only keys the backend accepts are emitted — an unknown key would be
* rejected by checkForUnsupportedParameters.
*/
private assembleAdvanceProduct(): any {
const num = (v: any): number | undefined => (v === '' || v === null || v === undefined ? undefined : Number(v));
const d = this.loanProductDetailsStep.loanProductDetails;
const cur = this.loanProductCurrencyStep.loanProductCurrency;
const sec = this.loanProductSecurityLienStep.loanProductSecurityLien;
const lt = this.loanProductAdvanceLimitTenureStep.loanProductAdvanceLimitTenure;
const intr = this.loanProductAdvanceInterestStep.loanProductAdvanceInterest;
const pn = this.loanProductPenalNpaStep.loanProductPenalNpa;
const rc = this.loanProductRenewalClosureStep.loanProductRenewalClosure;
const cb = this.isOverdraftFacility ? this.loanProductChequeBookStep?.loanProductChequeBook : null;
const defaultStrategy = this.loanProductsTemplate?.transactionProcessingStrategyOptions?.[0]?.code;
const minTenure = num(lt.minTenure);
const minRate = num(intr.minRate);
const payload: any = {
// identity + currency
name: d.name,
shortName: d.shortName,
description: d.description,
startDate: d.startDate,
closeDate: d.closeDate,
currencyCode: cur.currencyCode,
digitsAfterDecimal: cur.digitsAfterDecimal,
inMultiplesOf: 1,
// limit -> principal
minPrincipal: num(lt.minLimit),
principal: num(lt.defaultLimit) ?? num(lt.minLimit),
maxPrincipal: num(lt.maxLimit),
// tenure -> repayments (monthly)
minNumberOfRepayments: minTenure,
numberOfRepayments: num(lt.defaultTenure) ?? minTenure,
maxNumberOfRepayments: num(lt.maxTenure),
repaymentEvery: 1,
repaymentFrequencyType: 2, // months
// rate
minInterestRatePerPeriod: minRate,
interestRatePerPeriod: minRate,
maxInterestRatePerPeriod: num(intr.maxRate),
interestRateFrequencyType: 3, // per year
// Fineract mandatory engine defaults for an Advance (no EMI schedule shown to the officer)
amortizationType: 1, // equal installments
interestType: 0, // declining balance
interestCalculationPeriodType: 1, // same as repayment period
transactionProcessingStrategyCode: defaultStrategy,
daysInYearType: 1, // actual
daysInMonthType: 1, // actual
isInterestRecalculationEnabled: false,
// NPA / delinquency map onto core product fields
overdueDaysForNPA: num(pn.npaDays),
graceOnArrearsAgeing: num(pn.graceOnArrearsAgeing),
inArrearsTolerance: num(pn.inArrearsTolerance),
delinquencyBucketId: pn.delinquencyBucketId || undefined,
// accounting rule + GL mappings + charges (all standard, whitelisted)
...this.loanProductAccountingStep.loanProductAccounting,
...this.loanProductChargesStep.loanProductCharges,
// ---- satellite params (must match LoanProductAdvanceExtService.ADVANCE_PARAMETERS) ----
productTypeGroup: d.productTypeGroup,
productTypeSubGroup: d.productTypeSubGroup,
glAccount: d.glAccount || undefined,
loanProductCategory: d.loanProductCategory,
prioritySector: d.prioritySector,
cibilAccountType: d.cibilAccountType,
kfsEnabled: d.kfsEnabled,
aprEnabled: d.aprEnabled,
facilityType: lt.facilityType,
securityType: sec.securityType,
eligibleAmountBasis: sec.eligibleAmountBasis,
margin: num(sec.margin),
ltv: sec.ltv,
dpMethod: 'security_value_x_ltv',
lienRequired: this.loanProductSecurityLienStep.isLienSubType,
lienMarkingStage: sec.lienMarkingStage,
lienCertificateRequired: sec.lienCertificateRequired,
lienCertificatePrefix: sec.lienCertificatePrefix,
printCertificateOnDisbursement: sec.printOnDisbursement,
interestBasis: intr.interestBasis,
spread: num(intr.spread),
interestChargedOn: intr.interestChargedOn,
interestPostingFrequency: intr.postingFrequency,
penalApplicable: pn.penalApplicable,
penalRate: num(pn.penalRate),
penalAppliedOn: pn.penalAppliedOn,
penalGraceDays: num(pn.penalGraceDays),
npaTrigger: pn.npaTrigger,
interestAfterNpa: pn.interestAfterNpa,
renewalAllowed: rc.renewalAllowed,
renewalPeriodMonths: num(rc.renewalPeriodMonths),
renewalGraceDays: num(rc.renewalGraceDays),
preclosureAllowed: rc.preclosureAllowed,
lockInMonths: num(rc.lockInMonths),
interestOnPreclosure: rc.interestOnPreclosure,
chequeBookEnabled: this.isOverdraftFacility,
chequeType: cb?.chequeType,
defaultChequeBookSize: num(cb?.defaultBookSize),
ppsThreshold: num(cb?.ppsThreshold),
ppsMandatoryAbove: num(cb?.ppsMandatoryAbove),
// ---- remaining wizard fields ----
// security & lien extras (deposit list joined; rule checkboxes)
eligibleDepositProducts: (sec.eligibleDepositProducts ?? []).join('|'),
lienTenureNotExceedMaturity: sec.lienRules?.tenureNotExceedMaturity,
lienAutoReleaseOnClosure: sec.lienRules?.autoReleaseOnClosure,
lienAllowMultipleDeposits: sec.lienRules?.allowMultipleDeposits,
lienAdjustFromMaturity: sec.lienRules?.adjustFromMaturity,
lienAllowThirdParty: sec.lienRules?.allowThirdPartyDeposit,
lienAllowRenewalUnderLien: sec.lienRules?.allowRenewalUnderLien,
// limit & tenure extras
repaymentType: lt.repaymentType,
appropriationOrder: lt.appropriationOrder,
debitBalanceAllow: lt.debitBalanceAllow,
allowDebitAfterDueDate: lt.allowDebitTransAfterDueDate,
branchLevelAccountType: lt.branchLevelAccountType,
// interest extras
interestCalcMethod: intr.calcMethod,
interestOnCreditBalance: intr.interestOnCreditBalance,
interestPostingDay: intr.postingDay,
// NPA upgrade rules
npaUpgradeOnlyAllArrears: pn.upgradeOnlyAllArrears,
npaKeepStandardIfDepositCovers: pn.keepStandardIfDepositCovers,
// cheque book full config
chequeBookSizes: (cb?.bookSizes ?? []).join('|'),
chequeMaxBooksOutstanding: num(cb?.maxBooksOutstanding),
chequeReissueWhenLeaves: num(cb?.reissueWhenLeaves),
chequeFirstBookFree: cb?.firstBookFree,
chequeFreeBooksPerYear: num(cb?.freeBooksPerYear),
chequeNamePrinted: cb?.namePrinted,
chequePrintDescription: cb?.chequePrintDescription,
chequeStopPaymentAllowed: cb?.stopPaymentAllowed,
chequeMaintainStockRegister: cb?.maintainStockRegister,
chequeAllowWithdrawalSlip: cb?.allowWithdrawalSlip,
chequeBlockNpa: cb?.blockNpa,
chequeBlockLimitExpired: cb?.blockLimitExpired,
chequeBlockOverDp: cb?.blockOverDp,
chequeBlockFrozen: cb?.blockFrozen,
chequeBlockLienReleased: cb?.blockLienReleased
};
// Drop undefined keys so we never send an unresolved value.
Object.keys(payload).forEach((k) => payload[k] === undefined && delete payload[k]);
return payload;
}
submit() {
const loanProduct = this.loanProducts.buildPayload(this.loanProduct, this.itemsByDefault);
if (this.isAdvancedPaymentStrategy) {
@@ -0,0 +1,174 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/*
* Shared styling for the Advances A/C wizard steps (Security & Lien, Limit & Tenure,
* Interest, Penal & NPA, Renewal & Closure, Cheque Book).
*/
$accent: #0ea5e9;
$border: #e2e8f0;
$gray-50: #f8f9fa;
$gray-200: #e8eaed;
$gray-900: #202124;
.adv-section {
border: 1px solid $border;
border-radius: 10px;
overflow: hidden;
background: #fff;
& + & {
margin-top: 16px;
}
&__header {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 24px;
background: $gray-50;
border-bottom: 1px solid $gray-200;
i {
font-size: 14px;
color: $accent;
}
h3 {
margin: 0;
font-size: 14px;
font-weight: 600;
color: $gray-900;
}
}
&__hint {
margin-left: auto;
font-size: 12px;
color: #5f6368;
}
}
.adv-subsection {
padding: 16px 24px;
& + & {
border-top: 1px solid $border;
}
}
.adv-group {
margin: 0 0 12px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #5f6368;
}
.adv-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 4px 16px;
padding: 16px 24px;
@media (width <= 600px) {
grid-template-columns: 1fr;
}
}
.adv-grid--3 {
grid-template-columns: repeat(3, 1fr);
@media (width <= 900px) {
grid-template-columns: repeat(2, 1fr);
}
@media (width <= 600px) {
grid-template-columns: 1fr;
}
}
.adv-subsection .adv-grid {
padding: 0;
}
.adv-field {
width: 100%;
&--full {
grid-column: 1 / -1;
}
}
.adv-checks {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px 32px;
@media (width <= 600px) {
grid-template-columns: 1fr;
}
}
.adv-type-options {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
padding: 16px 24px 0;
@media (width <= 600px) {
grid-template-columns: 1fr;
}
}
.adv-type-option {
border: 1px solid #cbd5e1;
border-radius: 8px;
padding: 10px 14px;
&__label {
display: block;
font-weight: 600;
color: $gray-900;
}
&__hint {
display: block;
font-size: 12px;
color: #5f6368;
}
}
.adv-note {
display: flex;
align-items: flex-start;
gap: 8px;
margin-top: 16px;
padding: 12px 24px;
border: 1px dashed $border;
border-radius: 10px;
font-size: 13px;
color: #5f6368;
i {
color: $accent;
margin-top: 2px;
}
}
.adv-info {
padding: 12px 16px;
border-radius: 8px;
background: #f0f9ff;
border: 1px solid #bae6fd;
font-size: 13px;
color: #075985;
line-height: 1.5;
}
@@ -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/.
-->
<form [formGroup]="loanProductAdvanceInterestForm">
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-percent"></i>
<h3>Rate</h3>
</div>
<div class="adv-grid adv-grid--3">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Interest Basis</mat-label>
<mat-select formControlName="interestBasis">
@for (opt of interestBasisOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
@if (showSpread) {
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Spread over Deposit Rate (%)</mat-label>
<input matInput type="number" formControlName="spread" min="0" max="20" step="0.01" />
<mat-hint>Advance rate = deposit rate + spread</mat-hint>
</mat-form-field>
} @else {
<div class="adv-field"></div>
}
<div class="adv-field"></div>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Minimum Rate (% p.a.)</mat-label>
<input matInput type="number" formControlName="minRate" min="0" step="0.01" required />
<mat-error>Minimum Rate is <strong>required</strong></mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Maximum Rate (% p.a.)</mat-label>
<input matInput type="number" formControlName="maxRate" min="0" step="0.01" required />
<mat-error>Maximum Rate is <strong>required</strong></mat-error>
</mat-form-field>
</div>
</div>
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-sliders-h"></i>
<h3>Calculation</h3>
</div>
<div class="adv-grid adv-grid--3">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Calculation Method</mat-label>
<mat-select formControlName="calcMethod">
@for (opt of calcMethodOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Days in Year</mat-label>
<mat-select formControlName="daysInYear">
@for (opt of daysInYearOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Days in Month</mat-label>
<mat-select formControlName="daysInMonth">
@for (opt of daysInMonthOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Interest Charged On</mat-label>
<mat-select formControlName="interestChargedOn">
@for (opt of chargedOnOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Interest on Credit Balance</mat-label>
<mat-select formControlName="interestOnCreditBalance">
@for (opt of yesNoOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</div>
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-clock"></i>
<h3>Posting</h3>
</div>
<div class="adv-grid">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Posting Frequency</mat-label>
<mat-select formControlName="postingFrequency">
@for (opt of postingFrequencyOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Posting Day</mat-label>
<mat-select formControlName="postingDay">
@for (opt of postingDayOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</div>
</form>
@@ -0,0 +1,105 @@
/**
* 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 { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* Interest step for an Advances A/C product (Figma step 5). Interest basis + spread, rate bounds,
* daily calculation and monthly posting. UI-only for now — not sent in the create payload.
*/
@Component({
selector: 'mifosx-loan-product-advance-interest-step',
templateUrl: './loan-product-advance-interest-step.component.html',
styleUrls: ['../advance-steps.scss'],
imports: [...STANDALONE_SHARED_IMPORTS]
})
export class LoanProductAdvanceInterestStepComponent {
private formBuilder = inject(UntypedFormBuilder);
loanProductAdvanceInterestForm: UntypedFormGroup;
readonly interestBasisOptions = [
{ id: 'deposit_spread', label: 'Deposit Rate + Spread' },
{ id: 'fixed', label: 'Fixed rate' },
{ id: 'floating', label: 'Floating (EBLR-linked)' }
];
readonly calcMethodOptions = [
{ id: 'daily', label: 'Daily product (daily outstanding)' },
{ id: 'monthly', label: 'Monthly average balance' }
];
readonly daysInYearOptions = [
'Actual (365 / 366)',
'365',
'360'
];
readonly daysInMonthOptions = [
'Actual',
'30'
];
readonly chargedOnOptions = [
{ id: 'utilised', label: 'Utilised (debit) balance' },
{ id: 'limit', label: 'Sanctioned limit' }
];
readonly postingFrequencyOptions = [
'Monthly',
'Quarterly',
'Half-yearly',
'At Maturity'
];
readonly postingDayOptions = [
'Last day of month',
'1st of month',
'Anniversary day'
];
readonly yesNoOptions = [
'YES',
'NO'
];
constructor() {
this.loanProductAdvanceInterestForm = this.formBuilder.group({
interestBasis: ['deposit_spread'],
spread: [
2.0,
[
Validators.min(0),
Validators.max(20)
]
],
minRate: [
'',
Validators.required
],
maxRate: [
'',
Validators.required
],
calcMethod: ['daily'],
daysInYear: ['Actual (365 / 366)'],
daysInMonth: ['Actual'],
interestChargedOn: ['utilised'],
interestOnCreditBalance: ['NO'],
postingFrequency: ['Monthly'],
postingDay: ['Last day of month']
});
}
get showSpread(): boolean {
return this.loanProductAdvanceInterestForm.get('interestBasis').value === 'deposit_spread';
}
get loanProductAdvanceInterest() {
return this.loanProductAdvanceInterestForm.getRawValue();
}
}
@@ -0,0 +1,155 @@
<!--
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/.
-->
<form [formGroup]="loanProductAdvanceLimitTenureForm">
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-layer-group"></i>
<h3>Facility Type</h3>
</div>
<mat-radio-group formControlName="facilityType" class="adv-type-options">
<mat-radio-button value="demand" class="adv-type-option">
<span class="adv-type-option__label">Demand Loan</span>
<span class="adv-type-option__hint">Paid out once; repaid at maturity. No cheque book.</span>
</mat-radio-button>
<mat-radio-button value="overdraft" class="adv-type-option">
<span class="adv-type-option__label">Overdraft (Running)</span>
<span class="adv-type-option__hint">Draw and repay any time within the limit. Cheque book allowed.</span>
</mat-radio-button>
</mat-radio-group>
<div style="padding: 16px 24px 20px">
<div class="adv-info">
This choice decides whether the <strong>Cheque Book</strong> step appears. CC / OD sub types are always running.
</div>
</div>
</div>
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-coins"></i>
<h3>Limit</h3>
</div>
<div class="adv-grid adv-grid--3">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Minimum Limit (₹)</mat-label>
<input matInput type="number" formControlName="minLimit" min="0" required />
<mat-error>Minimum Limit is <strong>required</strong></mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Default Limit (₹)</mat-label>
<input matInput type="number" formControlName="defaultLimit" min="0" />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Maximum Limit (₹)</mat-label>
<input matInput type="number" formControlName="maxLimit" min="0" required />
<mat-error>Maximum Limit is <strong>required</strong></mat-error>
</mat-form-field>
</div>
</div>
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-clock"></i>
<h3>Tenure &amp; Repayment</h3>
</div>
<div class="adv-grid adv-grid--3">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Minimum Tenure (months)</mat-label>
<input matInput type="number" formControlName="minTenure" [min]="tenureMin" [max]="tenureMax" required />
@if (isFdTenureLocked) {
<mat-hint>FD policy: 12–15 months only</mat-hint>
}
<mat-error>
@if (isFdTenureLocked) {
Must be between <strong>12 and 15</strong> months
} @else {
Minimum Tenure is <strong>required</strong>
}
</mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Default Tenure (months)</mat-label>
<input matInput type="number" formControlName="defaultTenure" [min]="tenureMin" [max]="tenureMax" />
@if (isFdTenureLocked) {
<mat-hint>Between 12 and 15</mat-hint>
<mat-error>Must be between <strong>12 and 15</strong> months</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Maximum Tenure (months)</mat-label>
<input matInput type="number" formControlName="maxTenure" [min]="tenureMin" [max]="tenureMax" required />
@if (isFdTenureLocked) {
<mat-hint>FD policy: max 15 months (also capped at deposit maturity)</mat-hint>
} @else {
<mat-hint>Also capped at the deposit's maturity (Lien rule)</mat-hint>
}
<mat-error>
@if (isFdTenureLocked) {
Must be between <strong>12 and 15</strong> months
} @else {
Maximum Tenure is <strong>required</strong>
}
</mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Repayment Type</mat-label>
<mat-select formControlName="repaymentType">
@for (opt of repaymentTypeOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field adv-field--full">
<mat-label>Appropriation Order</mat-label>
<mat-select formControlName="appropriationOrder">
@for (opt of appropriationOrderOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</div>
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-toggle-on"></i>
<h3>Account Behaviour</h3>
</div>
<div class="adv-grid adv-grid--3">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Debit Balance Allow</mat-label>
<mat-select formControlName="debitBalanceAllow">
@for (opt of yesNoOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Allow Debit Trans After Due Date</mat-label>
<mat-select formControlName="allowDebitTransAfterDueDate">
@for (opt of yesNoOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Branch Level A/c Type</mat-label>
<mat-select formControlName="branchLevelAccountType">
@for (opt of yesNoOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</div>
<div class="adv-note">
<i class="fas fa-circle-info"></i>
<span>EMI fields (Number of Repayments, Repaid Every, Variable Installments) are hidden for an Advance.</span>
</div>
</form>
@@ -0,0 +1,171 @@
/**
* 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, Input } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
import { MatRadioButton, MatRadioGroup } from '@angular/material/radio';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* Limit & Tenure step for an Advances A/C product (Figma step 4).
*
* Facility Type here decides the rest of the flow: an Overdraft (running) facility gets a cheque book,
* a Demand Loan does not. UI-only for now — not sent in the create payload.
*/
@Component({
selector: 'mifosx-loan-product-advance-limit-tenure-step',
templateUrl: './loan-product-advance-limit-tenure-step.component.html',
styleUrls: ['../advance-steps.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatRadioGroup,
MatRadioButton
]
})
export class LoanProductAdvanceLimitTenureStepComponent {
private formBuilder = inject(UntypedFormBuilder);
loanProductAdvanceLimitTenureForm: UntypedFormGroup;
/**
* Product sub type picked on the Details step. "Loan against FD" is bank-policy capped to a
* 12–15 month tenure, so the officer cannot create an FD product outside that band. Other
* advance sub types (Gold, RD, CC, …) stay open.
*/
private _productSubType = '';
@Input()
set productSubType(value: string) {
const next = value || '';
if (next === this._productSubType) {
return;
}
this._productSubType = next;
this.applyTenurePolicy();
}
get productSubType(): string {
return this._productSubType;
}
readonly repaymentTypeOptions = [
{ id: 'bullet', label: 'Bullet – principal + interest at maturity' },
{ id: 'running', label: 'Running – any time; clear by limit expiry' }
];
readonly appropriationOrderOptions = [
{ id: 'charges_penal_interest_principal', label: 'Charges → Penal → Interest → Principal' },
{ id: 'principal_interest_penal_charges', label: 'Principal → Interest → Penal → Charges' }
];
readonly yesNoOptions = [
'YES',
'NO'
];
constructor() {
this.loanProductAdvanceLimitTenureForm = this.formBuilder.group({
facilityType: ['demand'],
minLimit: [
'',
Validators.required
],
defaultLimit: [''],
maxLimit: [
'',
Validators.required
],
minTenure: [
12,
[
Validators.required,
Validators.min(1)
]
],
defaultTenure: [12],
maxTenure: [
15,
[
Validators.required,
Validators.min(1)
]
],
repaymentType: ['bullet'],
appropriationOrder: ['charges_penal_interest_principal'],
debitBalanceAllow: ['YES'],
allowDebitTransAfterDueDate: ['NO'],
branchLevelAccountType: ['NO']
});
}
/** Read by the wizard to decide whether the Cheque Book step applies. */
get isOverdraft(): boolean {
return this.loanProductAdvanceLimitTenureForm.get('facilityType').value === 'overdraft';
}
get loanProductAdvanceLimitTenure() {
return this.loanProductAdvanceLimitTenureForm.getRawValue();
}
/** True when the FD 12–15 month cap is in force (drives the hints/errors in the template). */
get isFdTenureLocked(): boolean {
return this._productSubType === 'fd';
}
get tenureMin(): number {
return this.isFdTenureLocked ? 12 : 1;
}
get tenureMax(): number | null {
return this.isFdTenureLocked ? 15 : null;
}
/**
* FD advances are bank-policy capped to 12–15 months; every other sub type stays open (min 1).
* Called whenever the sub type changes so the validators — and thus the wizard's validity gate —
* match the selected product.
*/
private applyTenurePolicy(): void {
const form = this.loanProductAdvanceLimitTenureForm;
if (!form) {
return;
}
const min = form.get('minTenure');
const def = form.get('defaultTenure');
const max = form.get('maxTenure');
if (this._productSubType === 'fd') {
const bounds = [
Validators.min(12),
Validators.max(15)
];
min.setValidators([
Validators.required,
...bounds
]);
def.setValidators(bounds);
max.setValidators([
Validators.required,
...bounds
]);
min.setValue(12, { emitEvent: false });
def.setValue(12, { emitEvent: false });
max.setValue(15, { emitEvent: false });
} else {
min.setValidators([
Validators.required,
Validators.min(1)
]);
def.setValidators([Validators.min(1)]);
max.setValidators([
Validators.required,
Validators.min(1)
]);
}
min.updateValueAndValidity({ emitEvent: false });
def.updateValueAndValidity({ emitEvent: false });
max.updateValueAndValidity({ emitEvent: false });
}
}
@@ -0,0 +1,144 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<form [formGroup]="loanProductChequeBookForm">
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-money-check"></i>
<h3>Cheque Book Facility</h3>
<span class="adv-section__hint">Overdraft (running) only</span>
</div>
<div class="adv-grid adv-grid--3">
<mat-form-field appearance="outline" class="adv-field adv-field--full" style="grid-column: span 2">
<mat-label>Cheque Type</mat-label>
<mat-select formControlName="chequeType">
@for (opt of chequeTypeOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Book Sizes Allowed (leaves)</mat-label>
<mat-select formControlName="bookSizes" multiple>
@for (opt of bookSizeOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Default Book Size (leaves)</mat-label>
<mat-select formControlName="defaultBookSize">
@for (opt of bookSizeOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Max Books Outstanding</mat-label>
<input matInput type="number" formControlName="maxBooksOutstanding" min="0" />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Reissue When Unused Leaves ≤</mat-label>
<input matInput type="number" formControlName="reissueWhenLeaves" min="0" />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>First Cheque Book Free</mat-label>
<mat-select formControlName="firstBookFree">
@for (opt of yesNoOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Free Cheque Books per Year</mat-label>
<input matInput type="number" formControlName="freeBooksPerYear" min="0" />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Name Printed on Cheque</mat-label>
<mat-select formControlName="namePrinted">
@for (opt of namePrintedOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field adv-field--full" style="grid-column: span 2">
<mat-label>Cheque Print Description</mat-label>
<input matInput formControlName="chequePrintDescription" />
</mat-form-field>
</div>
</div>
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-shield-halved"></i>
<h3>Validation &amp; Security</h3>
</div>
<div class="adv-grid adv-grid--3">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Positive Pay (PPS) Threshold (₹)</mat-label>
<input matInput type="number" formControlName="ppsThreshold" min="0" required />
<mat-hint>Issuer confirms cheques at or above this</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>PPS Mandatory Above (₹)</mat-label>
<input matInput type="number" formControlName="ppsMandatoryAbove" min="0" />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Cheque Validity</mat-label>
<input matInput value="3 months (RBI)" disabled />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Stop Payment Allowed</mat-label>
<mat-select formControlName="stopPaymentAllowed">
@for (opt of yesNoOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Maintain Cheque Stock Register</mat-label>
<mat-select formControlName="maintainStockRegister">
@for (opt of yesNoOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Allow Withdrawal Slip</mat-label>
<mat-select formControlName="allowWithdrawalSlip">
@for (opt of yesNoOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</div>
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-ban"></i>
<h3>Block Cheque Issue &amp; Clearing When</h3>
</div>
<div class="adv-subsection">
<div class="adv-checks">
<mat-checkbox formControlName="blockNpa">Account is NPA or out of order</mat-checkbox>
<mat-checkbox formControlName="blockLimitExpired">Limit has expired (renewal due)</mat-checkbox>
<mat-checkbox formControlName="blockOverDp">Debit would exceed the limit / drawing power</mat-checkbox>
<mat-checkbox formControlName="blockFrozen">Account is frozen or inoperative</mat-checkbox>
<mat-checkbox formControlName="blockLienReleased">
Pledged deposit has matured or its lien was released
</mat-checkbox>
</div>
</div>
</div>
<div class="adv-note">
<i class="fas fa-circle-info"></i>
<span>Cheque book fees (issue, per leaf, return, stop payment) are set on the Charges step.</span>
</div>
</form>
@@ -0,0 +1,92 @@
/**
* 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 { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
import { MatCheckbox } from '@angular/material/checkbox';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* Cheque Book step (Figma step 8), shown only when the Facility Type is Overdraft (running).
* UI-only for now — not sent in the create payload.
*/
@Component({
selector: 'mifosx-loan-product-cheque-book-step',
templateUrl: './loan-product-cheque-book-step.component.html',
styleUrls: ['../advance-steps.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatCheckbox
]
})
export class LoanProductChequeBookStepComponent {
private formBuilder = inject(UntypedFormBuilder);
loanProductChequeBookForm: UntypedFormGroup;
readonly chequeTypeOptions = [
'CTS-2010 · Payable at par (multi-city)',
'CTS-2010 · Local',
'Non-CTS'
];
readonly bookSizeOptions = [
'10',
'25',
'50',
'100'
];
readonly namePrintedOptions = [
'Account holder name',
'Firm / entity name',
'As per instruction'
];
readonly yesNoOptions = [
'YES',
'NO'
];
constructor() {
this.loanProductChequeBookForm = this.formBuilder.group({
chequeType: ['CTS-2010 · Payable at par (multi-city)'],
bookSizes: [
[
'10',
'25',
'50'
]
],
defaultBookSize: ['25'],
maxBooksOutstanding: [1],
reissueWhenLeaves: [5],
firstBookFree: ['YES'],
freeBooksPerYear: [1],
namePrinted: ['Account holder name'],
chequePrintDescription: [''],
ppsThreshold: [
50000,
[
Validators.required,
Validators.min(0)
]
],
ppsMandatoryAbove: [500000],
stopPaymentAllowed: ['YES'],
maintainStockRegister: ['YES'],
allowWithdrawalSlip: ['NO'],
blockNpa: [true],
blockLimitExpired: [true],
blockOverDp: [true],
blockFrozen: [true],
blockLienReleased: [true]
});
}
get loanProductChequeBook() {
return this.loanProductChequeBookForm.getRawValue();
}
}
@@ -7,178 +7,190 @@
-->
<form [formGroup]="loanProductDetailsForm">
<div class="lpd-section">
<div class="lpd-section__header">
<i class="fas fa-info-circle"></i>
<h3>{{ 'labels.inputs.Product Name' | translate }} &amp; {{ 'labels.inputs.Description' | translate }}</h3>
</div>
<div class="lpd-grid">
<mat-form-field appearance="outline" class="lpd-field lpd-field--full">
<mat-label>{{ 'labels.inputs.Product Name' | translate }}</mat-label>
<input
matInput
formControlName="name"
matTooltip="{{ 'tooltips.Product name is a unique identifier' | translate }}"
required
/>
<mat-error>
{{ 'labels.inputs.Product Name' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>{{ 'labels.inputs.Short Name' | translate }}</mat-label>
<input
matInput
formControlName="shortName"
maxlength="4"
matTooltip="{{ 'tooltips.Short name is a unique identifier' | translate }}"
required
/>
<mat-error>
{{ 'labels.inputs.Short Name' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
</mat-form-field>
</div>
</div>
<div class="lpd-section">
<div class="lpd-section__header">
<i class="fas fa-calendar-alt"></i>
<h3>{{ 'labels.inputs.Availability' | translate }}</h3>
</div>
<div class="lpd-grid">
<mat-form-field appearance="outline" class="lpd-field" (click)="startDatePicker.open()">
<mat-label>{{ 'labels.inputs.Start Date' | translate }}</mat-label>
<input
matInput
[min]="minDate"
[max]="maxDate"
[matDatepicker]="startDatePicker"
matTooltip="{{ 'tooltips.loan product will be active and available to clients' | translate }}"
formControlName="startDate"
/>
<mat-datepicker-toggle matSuffix [for]="startDatePicker"></mat-datepicker-toggle>
<mat-datepicker #startDatePicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field" (click)="closeDatePicker.open()">
<mat-label>{{ 'labels.inputs.Close Date' | translate }}</mat-label>
<input
matInput
[min]="minDate"
[max]="maxDate"
[matDatepicker]="closeDatePicker"
matTooltip="{{ 'tooltips.loan product will become inactive and unavailable to clients' | translate }}"
formControlName="closeDate"
/>
<mat-datepicker-toggle matSuffix [for]="closeDatePicker"></mat-datepicker-toggle>
<mat-datepicker #closeDatePicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field lpd-field--full">
<mat-label>{{ 'labels.inputs.Description' | translate }}</mat-label>
<textarea
matInput
matTooltip="{{ 'tooltips.Provide additional information' | translate }}"
formControlName="description"
cdkTextareaAutosize
cdkAutosizeMinRows="2"
></textarea>
</mat-form-field>
</div>
</div>
<!-- Interest Information -->
<div class="lpd-section">
<div class="lpd-section__header">
<i class="fas fa-percentage"></i>
<h3>Interest Information</h3>
</div>
<div class="lpd-grid">
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>Debit Int. Calculation</mat-label>
<mat-select formControlName="debitIntCalculation">
@for (opt of yesNoOptions; track opt.value) {
<mat-option [value]="opt.value">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>Separate Penal Int.</mat-label>
<mat-select formControlName="separatePenalInterest">
@for (opt of yesNoOptions; track opt.value) {
<mat-option [value]="opt.value">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>Credit Int. Calculation</mat-label>
<mat-select formControlName="creditIntCalculation">
@for (opt of yesNoOptions; track opt.value) {
<mat-option [value]="opt.value">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>Sub A/c Type Balance</mat-label>
<mat-select formControlName="subAccountTypeBalance">
@for (opt of yesNoOptions; track opt.value) {
<mat-option [value]="opt.value">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>Final Int. Rounding</mat-label>
<mat-select formControlName="finalIntRounding">
@for (opt of yesNoOptions; track opt.value) {
<mat-option [value]="opt.value">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
@if (loanProductDetailsForm.get('finalIntRounding')?.value) {
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>Rounding UpTo</mat-label>
<input matInput type="number" formControlName="roundingUpTo" min="0" />
</mat-form-field>
}
</div>
</div>
<!-- Product Classification + GL Account — combined section -->
<!-- Product Type — picked first; it decides which sections the rest of the wizard shows -->
<div class="lpd-section">
<div class="lpd-section__header">
<i class="fas fa-sitemap"></i>
<h3>Product Classification</h3>
<h3>Product Type</h3>
</div>
<div class="lpd-grid">
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>Product Type Group</mat-label>
<mat-select formControlName="productTypeGroup">
@for (group of productTypeGroups; track group.id) {
<mat-option [value]="group.id">{{ group.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-radio-group formControlName="productTypeGroup" class="lpd-type-options">
@for (group of productTypeGroups; track group.id) {
<mat-radio-button [value]="group.id" class="lpd-type-option">
<span class="lpd-type-option__label">{{ group.label }}</span>
<span class="lpd-type-option__hint">{{ group.hint }}</span>
</mat-radio-button>
}
</mat-radio-group>
@if (loanProductDetailsForm.get('productTypeGroup')?.value) {
@if (loanProductDetailsForm.get('productTypeGroup')?.value) {
<div class="lpd-grid">
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>GL Account ({{ glClassificationLabel }})</mat-label>
<mat-select formControlName="glAccount">
@for (acc of availableGlAccounts; track acc.id) {
<mat-option [value]="acc.id"> ({{ acc.glCode }}) {{ acc.name }} </mat-option>
<mat-label>Product Sub Type</mat-label>
<mat-select formControlName="productTypeSubGroup" [required]="!isExistingProduct">
@for (subGroup of availableSubGroups; track subGroup.id) {
<mat-option [value]="subGroup.id">{{ subGroup.label }}</mat-option>
}
</mat-select>
<mat-error>GL Account is required</mat-error>
<mat-error>Product Sub Type is <strong>required</strong></mat-error>
</mat-form-field>
}
</div>
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>GL Account (Asset)</mat-label>
<mat-select
formControlName="glAccount"
[required]="!isExistingProduct"
matTooltip="Ledger GL of this product. Account numbers of its loan accounts are generated from this GL."
>
@for (acc of assetAccountOptions; track acc.id) {
<mat-option [value]="acc.id">({{ acc.glCode }}) {{ acc.name }}</mat-option>
}
</mat-select>
<mat-error>GL Account is <strong>required</strong></mat-error>
</mat-form-field>
</div>
}
</div>
@if (!isProductTypeChosen) {
<div class="lpd-type-pending">
<i class="fas fa-circle-info"></i>
Select a Product Type and Sub Type to continue.
</div>
}
@if (isProductTypeChosen) {
<div class="lpd-section">
<div class="lpd-section__header">
<i class="fas fa-info-circle"></i>
<h3>{{ 'labels.inputs.Product Name' | translate }} &amp; {{ 'labels.inputs.Description' | translate }}</h3>
</div>
<div class="lpd-grid">
<mat-form-field appearance="outline" class="lpd-field lpd-field--full">
<mat-label>{{ 'labels.inputs.Product Name' | translate }}</mat-label>
<input
matInput
formControlName="name"
matTooltip="{{ 'tooltips.Product name is a unique identifier' | translate }}"
required
/>
<mat-error>
{{ 'labels.inputs.Product Name' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>{{ 'labels.inputs.Short Name' | translate }}</mat-label>
<input
matInput
formControlName="shortName"
[maxlength]="isExistingProduct ? 4 : null"
matTooltip="{{ 'tooltips.Short name is a unique identifier' | translate }}"
required
(input)="normalizeShortName($event)"
/>
@if (loanProductDetailsForm.get('shortName').hasError('pattern')) {
<mat-error>Short Name must be exactly <strong>2 capital letters</strong> (A–Z)</mat-error>
} @else {
<mat-error>
{{ 'labels.inputs.Short Name' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field lpd-field--row-start" (click)="startDatePicker.open()">
<mat-label>{{ 'labels.inputs.Start Date' | translate }}</mat-label>
<input
matInput
[min]="startDateMin"
[max]="startDateMax"
[matDatepicker]="startDatePicker"
matTooltip="{{ 'tooltips.loan product will be active and available to clients' | translate }}"
formControlName="startDate"
/>
<mat-datepicker-toggle matSuffix [for]="startDatePicker"></mat-datepicker-toggle>
<mat-datepicker #startDatePicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field" (click)="closeDatePicker.open()">
<mat-label>{{ 'labels.inputs.Close Date' | translate }}</mat-label>
<input
matInput
[min]="closeDateMin"
[max]="maxDate"
[matDatepicker]="closeDatePicker"
matTooltip="{{ 'tooltips.loan product will become inactive and unavailable to clients' | translate }}"
formControlName="closeDate"
/>
<mat-datepicker-toggle matSuffix [for]="closeDatePicker"></mat-datepicker-toggle>
<mat-datepicker #closeDatePicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field lpd-field--full">
<mat-label>{{ 'labels.inputs.Description' | translate }}</mat-label>
<textarea
matInput
matTooltip="{{ 'tooltips.Provide additional information' | translate }}"
formControlName="description"
cdkTextareaAutosize
cdkAutosizeMinRows="2"
></textarea>
</mat-form-field>
</div>
</div>
<div class="lpd-section">
<div class="lpd-section__header">
<i class="fas fa-flag"></i>
<h3>Regulatory Classification</h3>
</div>
<div class="lpd-grid">
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>Loan Product Category (RBI)</mat-label>
<mat-select formControlName="loanProductCategory">
@for (opt of loanProductCategoryOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>Priority Sector</mat-label>
<mat-select formControlName="prioritySector">
@for (opt of prioritySectorOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>CIBIL Account Type</mat-label>
<mat-select formControlName="cibilAccountType">
@for (opt of cibilAccountTypeOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>Key Fact Statement (KFS)</mat-label>
<mat-select formControlName="kfsEnabled">
@for (opt of yesNoOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>APR Calculation</mat-label>
<mat-select formControlName="aprEnabled">
@for (opt of yesNoOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</div>
}
</form>
@@ -61,6 +61,57 @@ $gray-900: #202124;
&--full {
grid-column: 1 / -1;
}
// Starts a new row, so Start / Close Date sit together instead of one of them beside Short Name.
&--row-start {
grid-column-start: 1;
}
}
// Product Type — first section
.lpd-type-options {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
padding: 16px 24px 0;
@media (width <= 600px) {
grid-template-columns: 1fr;
}
}
.lpd-type-option {
border: 1px solid $border;
border-radius: 8px;
padding: 8px 12px;
&__label {
display: block;
font-weight: 600;
color: $gray-900;
}
&__hint {
display: block;
font-size: 12px;
color: #5f6368;
}
}
.lpd-type-pending {
display: flex;
align-items: center;
gap: 8px;
margin-top: 16px;
padding: 12px 24px;
border: 1px dashed $border;
border-radius: 10px;
font-size: 13px;
color: #5f6368;
i {
color: $accent;
}
}
.lpd-checkbox-row {
@@ -15,6 +15,7 @@ import { Dates } from 'app/core/utils/dates';
import { SettingsService } from 'app/settings/settings.service';
import { MatTooltip } from '@angular/material/tooltip';
import { CdkTextareaAutosize } from '@angular/cdk/text-field';
import { MatRadioButton, MatRadioGroup } from '@angular/material/radio';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@Component({
@@ -24,7 +25,9 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
imports: [
...STANDALONE_SHARED_IMPORTS,
MatTooltip,
CdkTextareaAutosize
CdkTextareaAutosize,
MatRadioGroup,
MatRadioButton
]
})
export class LoanProductDetailsStepComponent implements OnInit {
@@ -39,6 +42,14 @@ export class LoanProductDetailsStepComponent implements OnInit {
minDate = new Date(2000, 0, 1);
maxDate = new Date(new Date().setFullYear(new Date().getFullYear() + 10));
// Start Date policy: a new product must start on the current day — the picker allows only today
// (neither past nor future). Widened for an existing product so its saved start date stays valid.
startDateMin: Date = new Date();
startDateMax: Date = new Date();
// Close (End) Date accepts only future dates — default min is tomorrow; widened for an existing product.
closeDateMin: Date = new Date();
/**
* @param {FormBuilder} formBuilder Form Builder.
* @param {Dates} dateUtils Date Utils.
@@ -50,90 +61,158 @@ export class LoanProductDetailsStepComponent implements OnInit {
}
ngOnInit() {
// Product Type is not persisted by the backend yet, so an existing product comes back without one.
// Only a new product is made to pick it first; editing must not hide or block the rest of the form.
this.isExistingProduct = !!this.loanProductsTemplate?.id;
if (!this.isExistingProduct) {
this.loanProductDetailsForm.get('productTypeGroup').setValidators(Validators.required);
this.loanProductDetailsForm.get('productTypeSubGroup').setValidators(Validators.required);
this.loanProductDetailsForm.get('glAccount').setValidators(Validators.required);
// A new product's short name is exactly two capital letters. Saved products keep the old 4-character
// rule, so an existing short name such as 'PW16' does not stop the product from being edited.
this.loanProductDetailsForm.get('shortName').setValidators([
Validators.required,
Validators.pattern(/^[A-Z]{2}$/)
]);
this.loanProductDetailsForm.get('productTypeGroup').updateValueAndValidity({ emitEvent: false });
this.loanProductDetailsForm.get('productTypeSubGroup').updateValueAndValidity({ emitEvent: false });
this.loanProductDetailsForm.get('glAccount').updateValueAndValidity({ emitEvent: false });
this.loanProductDetailsForm.get('shortName').updateValueAndValidity({ emitEvent: false });
}
// A loan / advance is an asset of the bank, so its ledger GL is always an asset account.
this.assetAccountOptions = this.loanProductsTemplate?.accountingMappingOptions?.assetAccountOptions ?? [];
// Start Date: default to today for a new product and lock the picker to the current day.
// An existing product keeps the wider range so its saved date remains selectable/valid.
if (this.isExistingProduct) {
this.startDateMin = this.minDate;
this.startDateMax = this.maxDate;
this.closeDateMin = this.minDate;
} else {
this.startDateMin = new Date();
this.startDateMax = new Date();
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
this.closeDateMin = tomorrow;
}
this.loanProductDetailsForm.patchValue({
name: this.loanProductsTemplate.name,
shortName: this.loanProductsTemplate.shortName,
description: this.loanProductsTemplate.description,
startDate: this.loanProductsTemplate.startDate && new Date(this.loanProductsTemplate.startDate),
startDate: this.loanProductsTemplate.startDate ? new Date(this.loanProductsTemplate.startDate) : new Date(),
closeDate: this.loanProductsTemplate.closeDate && new Date(this.loanProductsTemplate.closeDate)
});
const mappingOptions = this.loanProductsTemplate?.accountingMappingOptions ?? {};
this.assetAccountOptions = mappingOptions.assetAccountOptions ?? [];
this.liabilityAccountOptions = mappingOptions.liabilityAccountOptions ?? [];
this.loanProductDetailsForm.patchValue({
debitIntCalculation: this.loanProductsTemplate.debitIntCalculation ?? false,
separatePenalInterest: this.loanProductsTemplate.separatePenalInterest ?? false,
creditIntCalculation: this.loanProductsTemplate.creditIntCalculation ?? false,
subAccountTypeBalance: this.loanProductsTemplate.subAccountTypeBalance ?? false,
finalIntRounding: this.loanProductsTemplate.finalIntRounding ?? false,
roundingUpTo: this.loanProductsTemplate.roundingUpTo ?? null
});
}
/** Product Classification data */
/** True when editing a saved product; see ngOnInit. */
isExistingProduct = false;
/** Product Type -- picked first, since it decides which sections the rest of the wizard shows. */
readonly productTypeGroups = [
{ id: 'loans', label: 'Loan' },
{ id: 'advanced', label: 'Advanced' }
{ id: 'advances', label: 'Advances A/C', hint: 'No EMI. Limit / drawing power against security.' },
{ id: 'loans', label: 'Loans A/C', hint: 'Fixed disbursement, fixed tenure, EMI repayment.' }
];
readonly productTypeSubGroups: Record<string, { id: string; label: string }[]> = {
loans: [
{ id: 'emi', label: 'EMI' },
{ id: 'cc', label: 'CC (Cash Credit)' },
{ id: 'overdraft', label: 'Overdraft' },
{ id: 'warehouse', label: 'Warehouse' },
{ id: 'gold', label: 'Gold' }
/** `shortName` is the two-letter code the product's Short Name is pre-filled with when the sub type is picked. */
readonly productTypeSubGroups: Record<string, { id: string; label: string; shortName: string }[]> = {
advances: [
{ id: 'fd', label: 'Loan against FD', shortName: 'FD' },
{ id: 'rd', label: 'Loan against RD', shortName: 'RD' },
{ id: 'dds', label: 'Loan against DDS', shortName: 'DS' },
{ id: 'lic', label: 'Loan against LIC Policy', shortName: 'LI' },
{ id: 'gold', label: 'Loan against Gold', shortName: 'GD' },
{ id: 'warehouse', label: 'Warehouse Receipt', shortName: 'WR' },
{ id: 'cc', label: 'Cash Credit (CC)', shortName: 'CC' },
{ id: 'overdraft', label: 'Overdraft (OD)', shortName: 'OD' },
{ id: 'kcc', label: 'Kisan Credit Card (KCC)', shortName: 'KC' },
{ id: 'bills', label: 'Bills Purchased / Discounted', shortName: 'BP' }
],
advanced: [
{ id: 'fd', label: 'FD (Fixed Deposit)' },
{ id: 'rd', label: 'RD (Recurring Deposit)' }
loans: [
{ id: 'term', label: 'Term Loan', shortName: 'TL' },
{ id: 'personal', label: 'Personal Loan', shortName: 'PL' },
{ id: 'housing', label: 'Housing Loan', shortName: 'HL' },
{ id: 'vehicle', label: 'Vehicle Loan', shortName: 'VL' },
{ id: 'education', label: 'Education Loan', shortName: 'EL' },
{ id: 'business', label: 'Business / MSME Loan', shortName: 'BL' },
{ id: 'gold_emi', label: 'Gold EMI Loan', shortName: 'GE' }
]
};
/** Default GL per product type group */
private readonly glDefaultMap: Record<string, string> = {
loans: 'asset',
advanced: 'asset'
};
/** Regulatory Classification options (UI-only; RBI / bureau reporting). */
readonly loanProductCategoryOptions = [
{ id: 'advances_deposits', label: 'Advances against deposits' },
{ id: 'gold_loan', label: 'Gold loan' },
{ id: 'agriculture', label: 'Agriculture' },
{ id: 'msme', label: 'MSME' },
{ id: 'personal', label: 'Personal / retail' },
{ id: 'housing', label: 'Housing' },
{ id: 'other', label: 'Other' }
];
readonly prioritySectorOptions = [
{ id: 'yes', label: 'Yes' },
{ id: 'no', label: 'No' }
];
readonly cibilAccountTypeOptions = [
{ id: 'loan_bank_deposits', label: 'Loan against Bank Deposits' },
{ id: 'gold_loan', label: 'Gold Loan' },
{ id: 'overdraft', label: 'Overdraft' },
{ id: 'business_loan', label: 'Business Loan' },
{ id: 'other', label: 'Other' }
];
readonly yesNoOptions = [
'YES',
'NO'
];
/** The rest of the Details form stays hidden on a new product until its type and sub type are picked. */
get isProductTypeChosen(): boolean {
if (this.isExistingProduct) {
return true;
}
const value = this.loanProductDetailsForm.value;
return !!value.productTypeGroup && !!value.productTypeSubGroup;
}
get availableSubGroups() {
const group = this.loanProductDetailsForm.get('productTypeGroup')?.value;
return group ? (this.productTypeSubGroups[group] ?? []) : [];
}
get glOptions(): string[] {
return [
'asset',
'liability'
];
}
/**
* Asset GLs offered for the product's ledger GL -- the GL the product's loan account numbers are
* generated from. Separate from the Accounting tab mappings, which drive the journal entries.
*/
assetAccountOptions: any[] = [];
liabilityAccountOptions: any[] = [];
get availableGlAccounts(): any[] {
const type = this.loanProductDetailsForm.get('glClassification')?.value;
if (type === 'asset') return this.assetAccountOptions;
if (type === 'liability') return this.liabilityAccountOptions;
return [];
}
get glClassificationLabel(): string {
const type = this.loanProductDetailsForm.get('glClassification')?.value;
return type === 'liability' ? 'Liability' : 'Asset';
}
get selectedGroupLabel(): string {
const group = this.loanProductDetailsForm.get('productTypeGroup')?.value;
return this.productTypeGroups.find((g) => g.id === group)?.label ?? '';
}
readonly yesNoOptions = [
{ value: true, label: 'YES' },
{ value: false, label: 'NO' }
];
/**
* A new product's short name is two capital letters, so what is typed or pasted is upper-cased, anything
* that is not a letter is dropped, and only the first two letters are kept. Trimming here rather than with
* maxlength means a paste like 'g1old' still yields 'GO' instead of being cut to 'g1' first.
*/
normalizeShortName(event: Event) {
if (this.isExistingProduct) {
return;
}
// Read from the input itself: this handler can run before the form control has taken the new value.
const typed = (event.target as HTMLInputElement).value ?? '';
const cleaned = typed
.toUpperCase()
.replace(/[^A-Z]/g, '')
.slice(0, 2);
if (cleaned !== typed) {
this.loanProductDetailsForm.get('shortName').setValue(cleaned);
}
}
createLoanProductDetailsForm() {
this.loanProductDetailsForm = this.formBuilder.group({
@@ -150,21 +229,26 @@ export class LoanProductDetailsStepComponent implements OnInit {
closeDate: [''],
productTypeGroup: [''],
productTypeSubGroup: [''],
glClassification: ['asset'],
glAccount: [''],
debitIntCalculation: [false],
separatePenalInterest: [false],
creditIntCalculation: [false],
subAccountTypeBalance: [false],
finalIntRounding: [false],
roundingUpTo: [null]
// Regulatory Classification (UI-only; stripped in buildPayload)
loanProductCategory: ['advances_deposits'],
prioritySector: ['no'],
cibilAccountType: ['loan_bank_deposits'],
kfsEnabled: ['YES'],
aprEnabled: ['YES']
});
// Auto-set GL based on product type group
this.loanProductDetailsForm.get('productTypeGroup')?.valueChanges.subscribe((group: string) => {
const defaultVal = this.glDefaultMap[group] ?? 'asset';
this.loanProductDetailsForm.get('glClassification')?.setValue(defaultVal);
this.loanProductDetailsForm.get('glAccount')?.setValue('');
// A sub type belongs to one group, so changing the group clears it.
this.loanProductDetailsForm.get('productTypeGroup')?.valueChanges.subscribe(() => {
this.loanProductDetailsForm.get('productTypeSubGroup')?.setValue('');
});
// Picking a sub type pre-fills the Short Name with its code; the user can still change it.
this.loanProductDetailsForm.get('productTypeSubGroup')?.valueChanges.subscribe((subGroupId: string) => {
const subGroup = this.availableSubGroups.find((s) => s.id === subGroupId);
if (subGroup) {
this.loanProductDetailsForm.get('shortName').setValue(subGroup.shortName);
}
});
}
@@ -0,0 +1,106 @@
<!--
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/.
-->
<form [formGroup]="loanProductPenalNpaForm">
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-percent"></i>
<h3>Penal Charges</h3>
</div>
<div class="adv-grid adv-grid--3">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Penal Charges Applicable</mat-label>
<mat-select formControlName="penalApplicable">
@for (opt of yesNoOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Penal Rate (% p.a.)</mat-label>
<input matInput type="number" formControlName="penalRate" min="0" max="100" step="0.01" />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Applied On</mat-label>
<mat-select formControlName="penalAppliedOn">
@for (opt of appliedOnOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Penal Grace Days</mat-label>
<input matInput type="number" formControlName="penalGraceDays" min="0" />
</mat-form-field>
<div class="adv-field adv-field--full" style="grid-column: span 2">
<div class="adv-info">
RBI: penal charges are kept separate from interest, never capitalised, and shown in the Key Fact Statement.
</div>
</div>
</div>
</div>
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-triangle-exclamation"></i>
<h3>Asset Classification (SMA / NPA)</h3>
</div>
<div class="adv-grid adv-grid--3">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Delinquency Bucket (SMA)</mat-label>
<mat-select formControlName="delinquencyBucketId">
<mat-option [value]="''">— None —</mat-option>
@for (bucket of delinquencyBucketOptions; track bucket.id) {
<mat-option [value]="bucket.id">{{ bucket.name }}</mat-option>
}
</mat-select>
<mat-hint>SMA 0–30 / 31–60 / 61–90</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>NPA After (days overdue)</mat-label>
<input matInput type="number" formControlName="npaDays" min="0" required />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>NPA Trigger</mat-label>
<mat-select formControlName="npaTrigger">
@for (opt of npaTriggerOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Interest after NPA</mat-label>
<mat-select formControlName="interestAfterNpa">
@for (opt of interestAfterNpaOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Grace on Arrears Ageing (days)</mat-label>
<input matInput type="number" formControlName="graceOnArrearsAgeing" min="0" />
<mat-hint>Days before an amount counts as overdue</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>In-Arrears Tolerance (₹)</mat-label>
<input matInput type="number" formControlName="inArrearsTolerance" min="0" />
<mat-hint>Small balance ignored for arrears</mat-hint>
</mat-form-field>
</div>
<div class="adv-subsection">
<div class="adv-checks">
<mat-checkbox formControlName="upgradeOnlyAllArrears">
Upgrade from NPA only when all arrears are cleared
</mat-checkbox>
<mat-checkbox formControlName="keepStandardIfDepositCovers">
Keep standard while the pledged deposit fully covers the dues
</mat-checkbox>
</div>
</div>
</div>
</form>
@@ -0,0 +1,90 @@
/**
* 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, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
import { MatCheckbox } from '@angular/material/checkbox';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* Penal charges & asset-classification (SMA / NPA) step for an Advances A/C product (Figma step 6).
* UI-only for now — not sent in the create payload.
*/
@Component({
selector: 'mifosx-loan-product-penal-npa-step',
templateUrl: './loan-product-penal-npa-step.component.html',
styleUrls: ['../advance-steps.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatCheckbox
]
})
export class LoanProductPenalNpaStepComponent {
private formBuilder = inject(UntypedFormBuilder);
@Input() loanProductsTemplate: any;
loanProductPenalNpaForm: UntypedFormGroup;
readonly yesNoOptions = [
'YES',
'NO'
];
readonly appliedOnOptions = [
{ id: 'overdue', label: 'Overdue amount' },
{ id: 'outstanding', label: 'Full outstanding' }
];
readonly npaTriggerOptions = [
{ id: 'maturity', label: 'Dues overdue after maturity' },
{ id: 'out_of_order', label: 'Out of order 90 days (CC/OD)' }
];
readonly interestAfterNpaOptions = [
{ id: 'undebited', label: 'Move to Undebited Interest GL' },
{ id: 'suspense', label: 'Move to Interest Suspense GL' }
];
get delinquencyBucketOptions(): any[] {
return this.loanProductsTemplate?.delinquencyBucketOptions ?? [];
}
constructor() {
this.loanProductPenalNpaForm = this.formBuilder.group({
penalApplicable: ['YES'],
penalRate: [
2.0,
[
Validators.min(0),
Validators.max(100)
]
],
penalAppliedOn: ['overdue'],
penalGraceDays: [0],
delinquencyBucketId: [''],
npaDays: [
90,
[
Validators.required,
Validators.min(0)
]
],
npaTrigger: ['maturity'],
interestAfterNpa: ['undebited'],
graceOnArrearsAgeing: [0],
inArrearsTolerance: [0],
upgradeOnlyAllArrears: [true],
keepStandardIfDepositCovers: [true]
});
}
get loanProductPenalNpa() {
return this.loanProductPenalNpaForm.getRawValue();
}
}
@@ -0,0 +1,69 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<form [formGroup]="loanProductRenewalClosureForm">
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-arrows-rotate"></i>
<h3>Limit Renewal</h3>
</div>
<div class="adv-grid adv-grid--3">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Renewal Allowed</mat-label>
<mat-select formControlName="renewalAllowed">
@for (opt of yesNoOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Renewal Period (months)</mat-label>
<input matInput type="number" formControlName="renewalPeriodMonths" min="1" required />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Renewal Grace Days</mat-label>
<input matInput type="number" formControlName="renewalGraceDays" min="0" />
<mat-hint>Days allowed after the renewal date</mat-hint>
</mat-form-field>
</div>
</div>
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-door-open"></i>
<h3>Pre-closure</h3>
</div>
<div class="adv-grid adv-grid--3">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Pre-closure Allowed</mat-label>
<mat-select formControlName="preclosureAllowed">
@for (opt of preclosureOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Lock-in Period (months)</mat-label>
<input matInput type="number" formControlName="lockInMonths" min="0" />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Interest on Pre-closure</mat-label>
<mat-select formControlName="interestOnPreclosure">
@for (opt of preclosureInterestOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</div>
<div class="adv-note">
<i class="fas fa-circle-info"></i>
<span>Renewal and pre-closure <strong>fees</strong> are set once, on the Charges step.</span>
</div>
</form>
@@ -0,0 +1,64 @@
/**
* 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 { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* Limit renewal & pre-closure rules for an Advances A/C product (Figma step 7). Fees live on the
* Charges step. UI-only for now — not sent in the create payload.
*/
@Component({
selector: 'mifosx-loan-product-renewal-closure-step',
templateUrl: './loan-product-renewal-closure-step.component.html',
styleUrls: ['../advance-steps.scss'],
imports: [...STANDALONE_SHARED_IMPORTS]
})
export class LoanProductRenewalClosureStepComponent {
private formBuilder = inject(UntypedFormBuilder);
loanProductRenewalClosureForm: UntypedFormGroup;
readonly yesNoOptions = [
'YES',
'NO'
];
readonly preclosureOptions = [
{ id: 'anytime', label: 'Anytime' },
{ id: 'after_lockin', label: 'After lock-in period' },
{ id: 'not_allowed', label: 'Not allowed' }
];
readonly preclosureInterestOptions = [
{ id: 'till_closing', label: 'Till closing date (broken period)' },
{ id: 'full_period', label: 'Full current period' }
];
constructor() {
this.loanProductRenewalClosureForm = this.formBuilder.group({
renewalAllowed: ['YES'],
renewalPeriodMonths: [
12,
[
Validators.required,
Validators.min(1)
]
],
renewalGraceDays: [30],
preclosureAllowed: ['anytime'],
lockInMonths: [0],
interestOnPreclosure: ['till_closing']
});
}
get loanProductRenewalClosure() {
return this.loanProductRenewalClosureForm.getRawValue();
}
}
@@ -0,0 +1,160 @@
<!--
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/.
-->
<form [formGroup]="loanProductSecurityLienForm">
@if (!isAdvance) {
<div class="lsl-note">
<i class="fas fa-circle-info"></i>
Security &amp; Lien applies to an <strong>Advances A/C</strong> product. Choose it on the Details step.
</div>
}
@if (isAdvance) {
<div class="lsl-section">
<div class="lsl-section__header">
<i class="fas fa-shield-alt"></i>
<h3>Security &amp; Margin</h3>
</div>
<div class="lsl-grid">
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Security Type</mat-label>
<input matInput formControlName="securityType" matTooltip="Set from the Product Sub Type" />
<mat-hint>From Sub Type — not chosen again</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Eligible Amount Basis</mat-label>
<mat-select formControlName="eligibleAmountBasis" required>
@for (opt of eligibleAmountBasisOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
<mat-error>Eligible Amount Basis is <strong>required</strong></mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Margin (%)</mat-label>
<input matInput type="number" formControlName="margin" min="0" max="100" step="0.01" required />
<mat-hint>0 – 100. Drawing Power = security value × LTV</mat-hint>
@if (loanProductSecurityLienForm.get('margin').hasError('required')) {
<mat-error>Margin is <strong>required</strong></mat-error>
} @else {
<mat-error>Margin must be between 0 and 100</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>LTV (%)</mat-label>
<input matInput [value]="ltv" disabled />
<mat-hint>= 100 − Margin</mat-hint>
</mat-form-field>
</div>
</div>
@if (isLienSubType) {
<div class="lsl-section">
<div class="lsl-section__header">
<i class="fas fa-lock"></i>
<h3>Lien Settings</h3>
<span class="lsl-section__hint">FD · RD · DDS · LIC</span>
</div>
<div class="lsl-subsection">
<p class="lsl-group">Lien marking</p>
<div class="lsl-grid">
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Lien Required</mat-label>
<input matInput value="YES (mandatory)" disabled />
</mat-form-field>
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Lien Marking Stage</mat-label>
<mat-select formControlName="lienMarkingStage" required>
@for (opt of lienMarkingStageOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="lsl-field lsl-field--full">
<mat-label>Eligible Deposit Products</mat-label>
<mat-select formControlName="eligibleDepositProducts" multiple required>
@for (product of depositProductOptions; track product) {
<mat-option [value]="product">{{ product }}</mat-option>
}
</mat-select>
<mat-hint>Only accounts of these deposit products can be pledged for this advance</mat-hint>
<mat-error>Pick at least one deposit product</mat-error>
</mat-form-field>
</div>
</div>
<div class="lsl-subsection">
<p class="lsl-group">Rules</p>
<div class="lsl-checks">
<mat-checkbox formControlName="tenureNotExceedMaturity">
Tenure cannot exceed deposit maturity date
</mat-checkbox>
<mat-checkbox formControlName="autoReleaseOnClosure">
Auto-release lien when the advance is closed
</mat-checkbox>
<mat-checkbox formControlName="allowMultipleDeposits">
Allow multiple deposits for one advance
</mat-checkbox>
<mat-checkbox formControlName="adjustFromMaturity">
Adjust advance from deposit proceeds at maturity
</mat-checkbox>
<mat-checkbox formControlName="allowThirdPartyDeposit">
Allow third-party deposit (consent letter required)
</mat-checkbox>
<mat-checkbox formControlName="allowRenewalUnderLien">
Allow deposit renewal under lien (lien carries forward)
</mat-checkbox>
</div>
</div>
<div class="lsl-subsection">
<p class="lsl-group">Lien certificate</p>
<div class="lsl-grid">
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Lien Certificate Required</mat-label>
<mat-select formControlName="lienCertificateRequired">
@for (opt of yesNoOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
@if (loanProductSecurityLienForm.get('lienCertificateRequired').value === 'YES') {
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Certificate No. Prefix</mat-label>
<input matInput formControlName="lienCertificatePrefix" required />
<mat-error>Prefix is <strong>required</strong></mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Print on Disbursement</mat-label>
<mat-select formControlName="printOnDisbursement">
@for (opt of yesNoOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
}
</div>
</div>
</div>
<div class="lsl-note">
<i class="fas fa-circle-info"></i>
Interest basis and spread are set on the <strong>Interest</strong> step; the lien-certificate fee on the
<strong>Charges</strong> step.
</div>
}
}
</form>
@@ -0,0 +1,117 @@
/**
* 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/.
*/
$accent: #0ea5e9;
$border: #e2e8f0;
$gray-50: #f8f9fa;
$gray-200: #e8eaed;
$gray-900: #202124;
.lsl-section {
border: 1px solid $border;
border-radius: 10px;
overflow: hidden;
background: #fff;
& + & {
margin-top: 16px;
}
&__header {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 24px;
background: $gray-50;
border-bottom: 1px solid $gray-200;
i {
font-size: 14px;
color: $accent;
}
h3 {
margin: 0;
font-size: 14px;
font-weight: 600;
color: $gray-900;
}
}
&__hint {
margin-left: auto;
font-size: 12px;
color: #5f6368;
}
}
.lsl-subsection {
padding: 16px 24px;
& + & {
border-top: 1px solid $border;
}
}
.lsl-group {
margin: 0 0 12px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #5f6368;
}
.lsl-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 4px 16px;
padding: 16px 24px;
@media (width <= 600px) {
grid-template-columns: 1fr;
}
}
.lsl-subsection .lsl-grid {
padding: 0;
}
.lsl-field {
width: 100%;
&--full {
grid-column: 1 / -1;
}
}
.lsl-checks {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px 32px;
@media (width <= 600px) {
grid-template-columns: 1fr;
}
}
.lsl-note {
display: flex;
align-items: center;
gap: 8px;
margin-top: 16px;
padding: 12px 24px;
border: 1px dashed $border;
border-radius: 10px;
font-size: 13px;
color: #5f6368;
i {
color: $accent;
}
}
@@ -0,0 +1,196 @@
/**
* 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, Input, inject } from '@angular/core';
import { AbstractControl, UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
import { MatTooltip } from '@angular/material/tooltip';
import { MatCheckbox } from '@angular/material/checkbox';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* Security & Lien step, shown only for an "Advances A/C" product.
*
* For a deposit-backed advance (Loan against FD / RD / DDS / LIC) the bank marks a lien on the
* customer's own deposit. This step captures the product-level rules for that: the margin (from which
* the LTV and drawing power follow), which deposit products may be pledged, and the lien certificate.
*
* UI-only for now: its values are NOT sent in the create payload, because the backend has no column to
* store them yet. Once the satellite table lands, wire `loanProductSecurityLien` into the payload.
*/
@Component({
selector: 'mifosx-loan-product-security-lien-step',
templateUrl: './loan-product-security-lien-step.component.html',
styleUrls: ['./loan-product-security-lien-step.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatTooltip,
MatCheckbox
]
})
export class LoanProductSecurityLienStepComponent implements OnInit {
private formBuilder = inject(UntypedFormBuilder);
@Input() loanProductsTemplate: any;
/** The Details step's Product Type controls, so this step reacts to the chosen group / sub type. */
@Input() productTypeGroup: AbstractControl;
@Input() productTypeSubGroup: AbstractControl;
loanProductSecurityLienForm: UntypedFormGroup;
/** Sub type -> the security it is backed by. Only deposit / policy sub types get a lien section. */
private readonly securityTypeBySubGroup: Record<string, string> = {
fd: 'Fixed Deposit',
rd: 'Recurring Deposit',
dds: 'Daily Deposit Scheme',
lic: 'LIC Policy',
gold: 'Gold Ornaments',
warehouse: 'Warehouse Receipt',
cc: 'Stock / Book Debts',
overdraft: 'Deposit / Property'
};
/** Sub types whose security is lien-marked on a deposit (rather than pledged / hypothecated). */
private readonly lienSubGroups = [
'fd',
'rd',
'dds',
'lic'
];
/** Default margin % per sub type (bank policy in real use); LTV is derived from it. */
private readonly defaultMarginBySubGroup: Record<string, number> = {
fd: 10,
rd: 10,
dds: 10,
lic: 40
};
readonly eligibleAmountBasisOptions = [
{ id: 'balance_accrued', label: 'Deposit balance + accrued interest' },
{ id: 'balance', label: 'Deposit balance only' },
{ id: 'surrender', label: 'Surrender value (LIC)' }
];
readonly lienMarkingStageOptions = [
{ id: 'disbursement', label: 'At Disbursement' },
{ id: 'sanction', label: 'At Sanction' }
];
readonly depositProductOptions = [
'Fixed Deposit – Regular',
'Fixed Deposit – Senior Citizen',
'Recurring Deposit – Regular',
'Daily Deposit Scheme'
];
readonly yesNoOptions = [
'YES',
'NO'
];
constructor() {
this.createForm();
}
ngOnInit() {
this.applySubGroup(this.productTypeSubGroup?.value);
// Keep Security Type and the default margin in step with the sub type chosen on the Details step.
this.productTypeSubGroup?.valueChanges.subscribe((subGroup: string) => this.applySubGroup(subGroup));
}
private applySubGroup(subGroup: string): void {
this.loanProductSecurityLienForm
.get('securityType')
.setValue(this.securityTypeBySubGroup[subGroup] ?? '', { emitEvent: false });
const margin = this.defaultMarginBySubGroup[subGroup];
if (margin != null && !this.loanProductSecurityLienForm.get('margin').dirty) {
this.loanProductSecurityLienForm.get('margin').setValue(margin, { emitEvent: false });
}
}
/** This whole step is only relevant to an Advances A/C product. */
get isAdvance(): boolean {
return this.productTypeGroup?.value === 'advances';
}
/** The lien block shows only for deposit / policy sub types; gold, warehouse, CC etc. are pledged. */
get isLienSubType(): boolean {
return this.lienSubGroups.includes(this.productTypeSubGroup?.value);
}
/** LTV = 100 − margin, shown read-only next to the margin. */
get ltv(): number | null {
const margin = this.loanProductSecurityLienForm.get('margin').value;
if (margin === null || margin === '' || isNaN(margin)) {
return null;
}
return 100 - Number(margin);
}
createForm() {
this.loanProductSecurityLienForm = this.formBuilder.group({
securityType: [{ value: '', disabled: true }],
eligibleAmountBasis: [
'balance_accrued',
Validators.required
],
margin: [
10,
[
Validators.required,
Validators.min(0),
Validators.max(100)
]
],
lienMarkingStage: [
'disbursement',
Validators.required
],
eligibleDepositProducts: [
['Fixed Deposit – Regular'],
Validators.required
],
tenureNotExceedMaturity: [true],
autoReleaseOnClosure: [true],
allowMultipleDeposits: [false],
adjustFromMaturity: [true],
allowThirdPartyDeposit: [false],
allowRenewalUnderLien: [true],
lienCertificateRequired: ['YES'],
lienCertificatePrefix: [
'LC/FD/',
Validators.required
],
printOnDisbursement: ['YES']
});
}
/** Flat values for the payload. Not wired into submit yet — see the class comment. */
get loanProductSecurityLien() {
const value = this.loanProductSecurityLienForm.getRawValue();
return {
securityType: value.securityType,
eligibleAmountBasis: value.eligibleAmountBasis,
margin: value.margin,
ltv: this.ltv,
lienMarkingStage: value.lienMarkingStage,
eligibleDepositProducts: value.eligibleDepositProducts,
lienRules: {
tenureNotExceedMaturity: value.tenureNotExceedMaturity,
autoReleaseOnClosure: value.autoReleaseOnClosure,
allowMultipleDeposits: value.allowMultipleDeposits,
adjustFromMaturity: value.adjustFromMaturity,
allowThirdPartyDeposit: value.allowThirdPartyDeposit,
allowRenewalUnderLien: value.allowRenewalUnderLien
},
lienCertificateRequired: value.lienCertificateRequired,
lienCertificatePrefix: value.lienCertificatePrefix,
printOnDisbursement: value.printOnDisbursement
};
}
}
@@ -71,6 +71,16 @@
}
}
.lps-panel {
& + & {
margin-top: 12px;
}
.lps-section__body {
padding-top: 8px;
}
}
.lps-full-width {
grid-column: 1 / -1;
}
@@ -22,6 +22,12 @@ import { MatTooltip } from '@angular/material/tooltip';
import { MatCheckbox } from '@angular/material/checkbox';
import { MatIconButton } from '@angular/material/button';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import {
MatAccordion,
MatExpansionPanel,
MatExpansionPanelHeader,
MatExpansionPanelTitle
} from '@angular/material/expansion';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@Component({
@@ -35,7 +41,11 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
MatCheckbox,
MatIconButton,
FaIconComponent,
ReactiveFormsModule
ReactiveFormsModule,
MatAccordion,
MatExpansionPanel,
MatExpansionPanelHeader,
MatExpansionPanelTitle
]
})
export class LoanProductSettingsStepComponent implements OnInit {
@@ -44,9 +54,35 @@ export class LoanProductSettingsStepComponent implements OnInit {
@Input() toEdit: boolean;
@Input() loanProductsTemplate: any;
@Input() isLinkedToFloatingInterestRates: UntypedFormControl;
@Input() productTypeGroup: UntypedFormControl;
@Output() advancePaymentStrategy = new EventEmitter<string>();
/**
* Provided from the Terms step's control. It can arrive after this component's ngOnInit (the Terms tab
* resolves later now that the tabs are conditional), so subscribe from the setter when it is present
* rather than assuming it in ngOnInit — otherwise a null control blanks the whole wizard.
*/
private _isLinkedToFloatingInterestRates: UntypedFormControl;
private isLinkedSubscribed = false;
@Input()
set isLinkedToFloatingInterestRates(control: UntypedFormControl) {
this._isLinkedToFloatingInterestRates = control;
if (control && !this.isLinkedSubscribed) {
this.isLinkedSubscribed = true;
control.valueChanges.subscribe((isLinked: any) => {
if (isLinked) {
this.loanProductSettingsForm.get('isInterestRecalculationEnabled').setValue(true);
this.loanProductSettingsForm.get('allowPartialPeriodInterestCalculation').setValue(true);
}
});
}
}
get isLinkedToFloatingInterestRates(): UntypedFormControl {
return this._isLinkedToFloatingInterestRates;
}
loanProductSettingsForm: UntypedFormGroup;
amortizationTypeData: any;
@@ -74,6 +110,15 @@ export class LoanProductSettingsStepComponent implements OnInit {
daysInYearCustomStrategyOptions: OptionData[] = [];
useDaysInYearCustomStrategy = false;
/** True when the product being created is an "Advances A/C" (no-EMI, limit-based) product. */
isAdvanceAccount = false;
/** UI-only for now; not yet wired into the submitted payload (see loan-products.ts buildPayload). */
readonly loanPeriodOptions = [
{ value: 12, label: '12 Months' },
{ value: 15, label: '15 Months' }
];
/** Values to Days for Repayments */
defaultConfigValues: GlobalConfiguration[] = [];
@@ -84,12 +129,13 @@ export class LoanProductSettingsStepComponent implements OnInit {
ngOnInit() {
this.defaultConfigValues = this.loanProductsTemplate['itemsByDefault'];
this.isLinkedToFloatingInterestRates.valueChanges.subscribe((isLinkedToFloatingInterestRates: any) => {
if (isLinkedToFloatingInterestRates) {
this.loanProductSettingsForm.get('isInterestRecalculationEnabled').setValue(true);
this.loanProductSettingsForm.get('allowPartialPeriodInterestCalculation').setValue(true);
}
});
if (this.productTypeGroup) {
this.applyAdvanceAccountRules(this.productTypeGroup.value === 'advances');
this.productTypeGroup.valueChanges.subscribe((productTypeGroup: string) => {
this.applyAdvanceAccountRules(productTypeGroup === 'advances');
});
}
this.amortizationTypeData = this.loanProductsTemplate.amortizationTypeOptions;
this.interestTypeData = this.loanProductsTemplate.interestTypeOptions;
@@ -334,6 +380,8 @@ export class LoanProductSettingsStepComponent implements OnInit {
Validators.required
],
allowAccrualPostingInArrears: [false],
// Advances A/C only; UI-only for now, stripped from the payload in loan-products.ts buildPayload().
loanPeriod: [''],
// Cheque Book Settings
chequeBookIssueEnabled: ['NO'],
@@ -453,6 +501,28 @@ export class LoanProductSettingsStepComponent implements OnInit {
}
}
/**
* Advances A/C has no EMI, so the Loan Schedule section is simplified: schedule type is locked to
* Cumulative (Progressive only makes sense with Advanced Payment Allocation, which is an EMI concept),
* installment-amount fixing doesn't apply, and Days in year/month are replaced by a placeholder
* Loan Period pick. loanScheduleType/canDefineInstallmentAmount stay enabled (not `.disable()`d) so they
* remain in `loanProductSettingsForm.value` and the submitted payload is unaffected by this UI change.
*/
private applyAdvanceAccountRules(isAdvance: boolean): void {
this.isAdvanceAccount = isAdvance;
const loanPeriodControl = this.loanProductSettingsForm.get('loanPeriod');
if (isAdvance) {
this.loanProductSettingsForm.patchValue({
loanScheduleType: LoanProducts.LOAN_SCHEDULE_TYPE_CUMULATIVE,
canDefineInstallmentAmount: false
});
loanPeriodControl.setValidators(Validators.required);
} else {
loanPeriodControl.clearValidators();
}
loanPeriodControl.updateValueAndValidity();
}
setConditionalControls() {
const allowAttributeOverrides = this.loanProductSettingsForm.get('allowAttributeOverrides');
@@ -264,6 +264,62 @@
}
</div>
<!-- Interest Information (moved here from the Details tab, next to the interest rates it qualifies) -->
<div class="lpt-section">
<div class="lpt-section-header">
<i class="fas fa-percentage lpt-section-icon"></i>
<h3>Interest Information</h3>
</div>
<div class="lpt-grid">
<mat-form-field appearance="outline">
<mat-label>Debit Int. Calculation</mat-label>
<mat-select formControlName="debitIntCalculation">
<mat-option [value]="true">YES</mat-option>
<mat-option [value]="false">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Separate Penal Int.</mat-label>
<mat-select formControlName="separatePenalInterest">
<mat-option [value]="true">YES</mat-option>
<mat-option [value]="false">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Credit Int. Calculation</mat-label>
<mat-select formControlName="creditIntCalculation">
<mat-option [value]="true">YES</mat-option>
<mat-option [value]="false">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Sub A/c Type Balance</mat-label>
<mat-select formControlName="subAccountTypeBalance">
<mat-option [value]="true">YES</mat-option>
<mat-option [value]="false">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Final Int. Rounding</mat-label>
<mat-select formControlName="finalIntRounding">
<mat-option [value]="true">YES</mat-option>
<mat-option [value]="false">NO</mat-option>
</mat-select>
</mat-form-field>
@if (loanProductTermsForm.value.finalIntRounding) {
<mat-form-field appearance="outline">
<mat-label>Rounding UpTo</mat-label>
<input matInput type="number" formControlName="roundingUpTo" min="0" />
</mat-form-field>
}
</div>
</div>
<!-- Interest Rate Slabs Section -->
<div class="lpt-section">
<div class="lpt-section-header">
@@ -111,7 +111,13 @@ export class LoanProductTermsStepComponent implements OnInit, OnChanges {
repaymentStartDateType: this.loanProductsTemplate.repaymentStartDateType.id || 1,
debitBalanceAllow: this.loanProductsTemplate.debitBalanceAllow ?? false,
allowDebitTransAfterDueDate: this.loanProductsTemplate.allowDebitTransAfterDueDate ?? false,
branchLevelAccountType: this.loanProductsTemplate.branchLevelAccountType ?? false
branchLevelAccountType: this.loanProductsTemplate.branchLevelAccountType ?? false,
debitIntCalculation: this.loanProductsTemplate.debitIntCalculation ?? false,
separatePenalInterest: this.loanProductsTemplate.separatePenalInterest ?? false,
creditIntCalculation: this.loanProductsTemplate.creditIntCalculation ?? false,
subAccountTypeBalance: this.loanProductsTemplate.subAccountTypeBalance ?? false,
finalIntRounding: this.loanProductsTemplate.finalIntRounding ?? false,
roundingUpTo: this.loanProductsTemplate.roundingUpTo ?? null
});
if (this.loanProductsTemplate.allowApprovedDisbursedAmountsOverApplied) {
@@ -213,7 +219,14 @@ export class LoanProductTermsStepComponent implements OnInit, OnChanges {
fixedLength: [null],
debitBalanceAllow: [false],
allowDebitTransAfterDueDate: [false],
branchLevelAccountType: [false]
branchLevelAccountType: [false],
// Interest Information (moved here from the Details tab)
debitIntCalculation: [false],
separatePenalInterest: [false],
creditIntCalculation: [false],
subAccountTypeBalance: [false],
finalIntRounding: [false],
roundingUpTo: [null]
});
}
@@ -45,9 +45,29 @@ export class LoanProducts {
dateFormat,
locale
};
// Remove unnecessary properties
// Remove UI-only flags that are never valid loan-product parameters (both paths).
delete loanProduct.allowAttributeConfiguration;
delete loanProduct.advancedAccountingRules;
delete loanProduct.loanPeriod;
// The Advances payload is assembled explicitly in the create component and already carries only
// whitelisted params (core Fineract fields + satellite ADVANCE_PARAMETERS), so it is NOT stripped.
// For the Loans path, the Details/Security fields below are not backend params, so drop them.
if (loanProduct.productTypeGroup !== 'advances') {
delete loanProduct.productTypeGroup;
delete loanProduct.productTypeSubGroup;
delete loanProduct.glAccount;
delete loanProduct.loanProductCategory;
delete loanProduct.prioritySector;
delete loanProduct.cibilAccountType;
delete loanProduct.kfsEnabled;
delete loanProduct.aprEnabled;
delete loanProduct.securityType;
delete loanProduct.lienCertificateRequired;
delete loanProduct.securityMargin;
delete loanProduct.dpType;
delete loanProduct.dpValue;
}
// In Fineract, the POST and PUT endpoints for /v1/loanproducts have a typo in the field
// allowPartialPeriodInterestCalculation. Until that is fixed, we need to replace the field name in the payload.
@@ -106,6 +106,9 @@ import { ChargesTemplateAndResolver } from './charges/charges-template-and-resol
import { EditFixedDepositProductComponent } from './fixed-deposit-products/edit-fixed-deposit-product/edit-fixed-deposit-product.component';
import { FixedDepositProductAndTemplateResolver } from './fixed-deposit-products/edit-fixed-deposit-product/fixed-deposit-product-and-template.resolver';
import { BaseFdInterestRateChartComponent } from './fixed-deposit-products/base-fd-interest-rate-chart/base-fd-interest-rate-chart.component';
import { LoanInterestRateRegisterComponent } from './loan-interest-rate-register/loan-interest-rate-register.component';
import { EditRegisterComponent } from './loan-interest-rate-register/edit-register/edit-register.component';
import { CibilAdjustmentComponent } from './loan-interest-rate-register/cibil-adjustment/cibil-adjustment.component';
import { CollateralResolver } from './collaterals/collateral.resolver';
import { CollateralsResolver } from './collaterals/collaterals.resolver';
import { CollateralTemplateResolver } from './collaterals/collaterals-template.resolver';
@@ -894,6 +897,26 @@ const routes: Routes = [
}
]
},
{
path: 'loan-interest-rate-register',
data: { title: 'Loan Interest Rate Register', breadcrumb: 'Loan Interest Rate Register' },
children: [
{
path: '',
component: LoanInterestRateRegisterComponent
},
{
path: 'edit',
component: EditRegisterComponent,
data: { title: 'Edit Register', breadcrumb: 'Edit' }
},
{
path: 'cibil',
component: CibilAdjustmentComponent,
data: { title: 'CIBIL Adjustment', breadcrumb: 'CIBIL Adjustment' }
}
]
},
{
path: 'charges',
data: { title: 'Charges', breadcrumb: 'Charges' },
+13
View File
@@ -106,6 +106,19 @@
</a>
</div>
<div>
<a class="pd-card" [routerLink]="['loan-interest-rate-register']" *mifosxHasPermission="'READ_LOANPRODUCT'">
<div class="pd-card__icon pd-card__icon--blue">
<i class="fas fa-percentage"></i>
</div>
<div class="pd-card__body">
<h3 class="pd-card__title">Loan Interest Rate Register</h3>
<p class="pd-card__desc">Maintain central CIBIL-based interest rates for all loan products</p>
</div>
<i class="fas fa-chevron-right pd-card__arrow"></i>
</a>
</div>
<div #recurringDepositProducts>
<a
class="pd-card"
@@ -21,14 +21,31 @@
<!-- The outcome, product by product. Which products were skipped and why matters as much as
which succeeded, so both are shown rather than just a success count. -->
<div class="pl-card dd-panel">
<!-- A run that skipped a product is not a clean success: those members get nothing, and saying
only "declared" is how that goes unnoticed. The heading states it either way. -->
<div class="dd-result-head">
<fa-icon icon="check-circle" class="dd-result-ic"></fa-icon>
<fa-icon
[icon]="hasSkipped ? 'exclamation-triangle' : 'check-circle'"
class="dd-result-ic"
[class.dd-result-ic--warn]="hasSkipped"
></fa-icon>
<div>
<h3>Dividend declared for {{ result.financialYear }}</h3>
<h3>
@if (hasSkipped) {
Declared for {{ result.financialYear }} — {{ result.skipped.length }}
{{ result.skipped.length === 1 ? 'product was' : 'products were' }} skipped
} @else {
Dividend declared for {{ result.financialYear }}
}
</h3>
<p>
{{ result.declarationsCreated }}
{{ result.declarationsCreated === 1 ? 'declaration' : 'declarations' }} created. Each still needs approval
before members can claim.
@if (hasSkipped) {
Members of the skipped {{ result.skipped.length === 1 ? 'product' : 'products' }} below will receive
nothing for this year — check the reason and declare separately if that is not intended.
}
</p>
</div>
</div>
@@ -128,7 +145,22 @@
<span class="dd-amount">− {{ preview.totalAmount | number: '1.2-2' }}</span>
</div>
@if (isUnderfunded) {
@if (exceedsMaximumRate) {
<!-- Checked before the year and funding notes: a rate over the cap is not payable at
all, so what it would cost is beside the point until it is lowered. -->
<div class="dd-note dd-note--error">
{{ declareForm.value.ratePercentage }}% is above the society's maximum of
{{ preview.maximumRatePercentage }}%. Lower the rate, or raise the limit in Configurations if the board
has resolved a higher one.
</div>
} @else if (isPeriodNotFinished) {
<!-- Shown ahead of the funding check: a year still running cannot be declared at any
rate, so the shortfall below it would be advice on a choice that is not available. -->
<div class="dd-note dd-note--error">
{{ preview.financialYear }} has not finished yet, so its profit is not final. A dividend can only be
declared once the financial year is over.
</div>
} @else if (isUnderfunded) {
<div class="dd-note dd-note--error">
Short by {{ preview.fundingShortfall | number: '1.2-2' }}. There is not enough in
{{ preview.profitGlCode }} to fund this dividend — lower the rate, or close the year's profit first.
@@ -146,16 +178,42 @@
{{ declareForm.value.ratePercentage || 0 }}% on each member’s share investment
</div>
<!-- The member count sits beside each amount because an amount alone does not say who it
reaches. A product paying a plausible total to one member is the case worth catching
here, while the rate can still be changed. -->
@for (product of eligibleProducts; track product.productId) {
<div class="dd-preview-row">
<span>{{ product.productName }}</span>
<span>
{{ product.productName }}
<span class="dd-members">
{{ product.memberCount }} {{ product.memberCount === 1 ? 'member' : 'members' }}
@if (product.accountCount !== product.memberCount) {
· {{ product.accountCount }} accounts
}
</span>
</span>
<span class="dd-amount">{{ product.amount | number: '1.2-2' }}</span>
</div>
}
@if (eligibleProducts.length > 0) {
<div class="dd-preview-row dd-preview-row--total">
<span>Total to declare</span>
<span>
Total to declare
@if (preview.totalMemberCount) {
<!-- Members are distinct people, accounts are what actually gets paid: someone
holding shares in two products is one member with two entitlements, each
claimed on its own draft. Both are shown so the difference reads as the
split it is rather than a miscount. -->
<span class="dd-members">
{{ preview.totalMemberCount }}
{{ preview.totalMemberCount === 1 ? 'member' : 'members' }}
@if (preview.totalAccountCount !== preview.totalMemberCount) {
· {{ preview.totalAccountCount }} accounts
}
</span>
}
</span>
<span class="dd-amount">{{ payableTotal | number: '1.2-2' }}</span>
</div>
}
@@ -148,6 +148,14 @@ form {
text-align: right;
}
// Who a product's amount reaches, set quietly beside the product name so it reads as a
// qualifier on the amount rather than competing with it.
.dd-members {
margin-left: 8px;
font-size: 12px;
color: #94a3b8;
}
.dd-note {
margin-top: 12px;
padding: 10px 14px;
@@ -194,6 +202,11 @@ form {
font-size: 26px;
color: #16a34a;
margin-top: 2px;
// A run that left a product out is amber, not green: it succeeded, but not for everyone.
&--warn {
color: #d97706;
}
}
.dd-table {
@@ -73,6 +73,17 @@ export class DeclareDividendComponent implements OnInit {
/** Set once the declaration has gone through, so the outcome per product can be shown. */
result: any = null;
/**
* Whether any product was left out of the declaration that just ran.
*
* Drives the outcome heading. A run that skipped a product still returns success — one product
* being ineligible is not a reason to lose the others — but those members receive nothing for the
* year, so the screen has to say so rather than report a plain "declared".
*/
get hasSkipped(): boolean {
return (this.result?.skipped?.length ?? 0) > 0;
}
ngOnInit(): void {
this.declareForm = this.formBuilder.group({
financialYear: [
@@ -96,9 +107,13 @@ export class DeclareDividendComponent implements OnInit {
/**
* The financial years a dividend can be declared for: the current one and the five before it.
*
* The current year is included because a society may declare an interim dividend part-way through,
* and five back covers a declaration delayed by an AGM that ran late. Built from the business date
* rather than the browser's clock, so the list matches the year the branch is operating in.
* The year in progress is listed but cannot be declared — the server rejects it, and the screen
* says so as soon as it is picked. It stays on the list so an officer looking for it finds it with
* the reason attached, rather than wondering why the year they are in is missing. Five back covers
* a declaration delayed by an AGM that ran late.
*
* Built from the business date rather than the browser's clock, so the list matches the year the
* branch is operating in.
*/
private buildFinancialYears(): void {
const businessDate: Date = this.settingsService.businessDate ?? new Date();
@@ -171,8 +186,36 @@ export class DeclareDividendComponent implements OnInit {
return !!this.preview?.exceedsDistributableProfit;
}
/**
* Whether the chosen year is still running.
*
* A dividend is a share of the year's profit, so it cannot be declared until the year is over and
* that profit is final. The server enforces this; the flag simply lets the screen say so before the
* officer commits to a rate.
*/
get isPeriodNotFinished(): boolean {
return !!this.preview?.periodNotFinished;
}
/**
* Whether the entered rate is above the society's configured maximum.
*
* The bye-laws cap what may be paid in a year, so a rate above it needs a board resolution rather
* than a typed figure. The server enforces the same limit.
*/
get exceedsMaximumRate(): boolean {
return !!this.preview?.exceedsMaximumRate;
}
get canSubmit(): boolean {
return this.declareForm.valid && !this.submitting && this.eligibleProducts.length > 0 && !this.isUnderfunded;
return (
this.declareForm.valid &&
!this.submitting &&
this.eligibleProducts.length > 0 &&
!this.isUnderfunded &&
!this.isPeriodNotFinished &&
!this.exceedsMaximumRate
);
}
submit(): void {
@@ -70,16 +70,9 @@ export class ShareProductAccountingStepComponent implements OnInit {
shareProductAccountingForm: UntypedFormGroup;
accountingSectionConfigs: ShareAccountingSectionConfig[] = [];
assetAccountData: GLAccount[] = [];
liabilityAccountData: GLAccount[] = [];
incomeAccountData: GLAccount[] = [];
equityAccountData: GLAccount[] = [];
/** "Share Reference" is where a redemption's credit side lands pending settlement — some banks
* model that as a receivable-like Asset (e.g. "Cash Vault", this field's original default),
* others as a payable-like Liability suspense account (e.g. an existing "Sundry" account
* already used elsewhere as Shares Suspense). Both are valid conventions, so this field accepts
* either instead of Asset-only. */
referenceAccountData: GLAccount[] = [];
constructor() {
this.createShareProductAccountingForm();
@@ -87,18 +80,15 @@ export class ShareProductAccountingStepComponent implements OnInit {
ngOnInit() {
const opts = this.shareProductsTemplate.accountingMappingOptions || {};
this.assetAccountData = opts.assetAccountOptions || [];
this.liabilityAccountData = opts.liabilityAccountOptions || [];
this.incomeAccountData = opts.incomeAccountOptions || [];
this.equityAccountData = opts.equityAccountOptions || [];
this.referenceAccountData = this.assetAccountData.concat(this.liabilityAccountData);
this.buildAccountingSectionConfigs();
if (this.shareProductsTemplate.accountingMappings) {
const m = this.shareProductsTemplate.accountingMappings;
this.shareProductAccountingForm.patchValue({
shareReferenceId: m.shareReferenceId?.id || '',
shareSuspenseId: m.shareSuspenseId?.id || '',
incomeFromFeeAccountId: m.incomeFromFeeAccountId?.id || '',
shareEquityId: m.shareEquityId?.id || ''
@@ -109,10 +99,6 @@ export class ShareProductAccountingStepComponent implements OnInit {
createShareProductAccountingForm() {
this.shareProductAccountingForm = this.formBuilder.group({
accountingRule: [2],
shareReferenceId: [
'',
Validators.required
],
shareSuspenseId: [
'',
Validators.required
@@ -125,7 +111,6 @@ export class ShareProductAccountingStepComponent implements OnInit {
'',
Validators.required
],
assetsOtherGlMappings: this.formBuilder.array([]),
liabilitiesOtherGlMappings: this.formBuilder.array([]),
incomeOtherGlMappings: this.formBuilder.array([]),
equityOtherGlMappings: this.formBuilder.array([])
@@ -134,24 +119,6 @@ export class ShareProductAccountingStepComponent implements OnInit {
buildAccountingSectionConfigs() {
this.accountingSectionConfigs = [
{
key: 'assets',
title: 'Assets',
pillLabel: 'Asset',
pillClass: 'green',
rows: [
{
key: 'shareReferenceId',
label: 'Share Reference (Cash Vault or Suspense)',
inputLabel: 'Share reference',
controlName: 'shareReferenceId',
options: this.referenceAccountData,
required: true
}
],
otherMappingsControlName: 'assetsOtherGlMappings',
otherAccountOptions: this.assetAccountData
},
{
key: 'liabilities',
title: 'Liabilities',
@@ -239,7 +206,7 @@ export class ShareProductAccountingStepComponent implements OnInit {
}
get shareProductAccounting() {
const { assetsOtherGlMappings, liabilitiesOtherGlMappings, incomeOtherGlMappings, equityOtherGlMappings, ...rest } =
const { liabilitiesOtherGlMappings, incomeOtherGlMappings, equityOtherGlMappings, ...rest } =
this.shareProductAccountingForm.value;
return rest;
}
@@ -213,14 +213,6 @@
</div>
@if (shareProduct.accountingRule === 2) {
<div class="shpp-sub-header">{{ 'labels.heading.Assets' | translate }}</div>
<div class="shpp-row">
<span class="shpp-label">{{ 'labels.inputs.Share reference' | translate }}:</span>
<span class="shpp-value">{{
shareProduct.shareReferenceId
| find: shareProductsTemplate.accountingMappingOptions.assetAccountOptions : 'id' : 'name'
}}</span>
</div>
<div class="shpp-sub-header">{{ 'labels.heading.Liabilities' | translate }}</div>
<div class="shpp-row">
<span class="shpp-label">{{ 'labels.inputs.Share suspense control' | translate }}:</span>
@@ -208,16 +208,6 @@
@if (shareProduct.accountingRule.id === 2) {
<div class="layout-row-wrap responsive-column flex-fill">
<h4 class="mat-h4 flex-fill">{{ 'labels.heading.Assets' | translate }}</h4>
<div class="flex-fill">
<mifosx-gl-account-display
class="flex-100"
[accountTitle]="'Share reference'"
[glAccount]="shareProduct.accountingMappings.shareReferenceId"
[withTitle]="'40%'"
>
</mifosx-gl-account-display>
</div>
<h4 class="mat-h4 flex-fill">{{ 'labels.heading.Liabilities' | translate }}</h4>
<div class="flex-fill">
<mifosx-gl-account-display
@@ -0,0 +1,311 @@
/**
* 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, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormBuilder, FormArray, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { forkJoin, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { AmlService } from 'app/aml/aml.service';
export interface AmlBulkReviewData {
alerts: any[];
disposition: 'WHITELIST' | 'ESCALATE';
}
/**
* One disposition applied to several alerts at once.
*
* <p>
* Low and medium-severity alerts arrive as runs of the same thing - a customer whose ordinary cash
* deposits each cross a threshold - and clearing them one dialog at a time is what keeps an officer
* from reaching the alert that matters. The reason and evidence are asked once and written against
* every alert in the batch, so each still carries its own record of why it was disposed of.
*
* <p>
* High-severity alerts never reach here: the caller will not tick them. An alert in the high band is
* a judgement about that particular transaction and gets its own decision.
*/
@Component({
selector: 'mifosx-aml-bulk-review-dialog',
standalone: true,
imports: [
CommonModule,
ReactiveFormsModule,
MatDialogModule,
MatButtonModule,
MatCheckboxModule,
MatFormFieldModule,
MatInputModule
],
template: `
<h2 mat-dialog-title>
{{ isWhitelist ? 'Whitelist' : 'Escalate' }} {{ data.alerts.length }} Alert{{
data.alerts.length === 1 ? '' : 's'
}}
</h2>
<mat-dialog-content>
<p class="bk-intro">
The same decision, reason and evidence are recorded against each alert below. Every one stays individually
auditable.
</p>
<div class="bk-list">
<div class="bk-row" *ngFor="let a of data.alerts">
<span class="bk-txn">{{ a.transactionType }} #{{ a.transactionId }}</span>
<span class="bk-client">{{ a.clientDisplayName }}</span>
<span class="bk-rule">{{ a.ruleCode }}</span>
<span class="bk-amt">₹{{ a.amount | number: '1.0-0' }}</span>
</div>
</div>
<form [formGroup]="form">
<!-- Clearing an alert has to rest on something. Escalation does not: the officer is saying
it needs investigating, which is the opposite of claiming it is explained. -->
<div class="bk-evidence" *ngIf="isWhitelist">
<div class="bk-label">Evidence relied on</div>
<div formArrayName="evidence" class="bk-checks">
<mat-checkbox *ngFor="let t of evidenceTypes; let i = index" [formControlName]="i">{{ t }}</mat-checkbox>
</div>
<p class="bk-err" *ngIf="evidenceMissing">Tick at least one piece of evidence</p>
<mat-form-field appearance="outline" class="bk-full">
<mat-label>Reference (optional)</mat-label>
<input matInput formControlName="evidenceRef" maxlength="100" />
</mat-form-field>
</div>
<mat-form-field appearance="outline" class="bk-full">
<mat-label>{{ isWhitelist ? 'Evidence detail' : 'What makes these suspicious?' }}</mat-label>
<textarea matInput formControlName="notes" rows="3" maxlength="800"></textarea>
<mat-error *ngIf="form.get('notes')?.hasError('required')">A reason is required</mat-error>
</mat-form-field>
</form>
<p class="bk-effect" [class.bk-effect--warn]="!isWhitelist">
{{
isWhitelist
? 'Cleared as false positives — no report is filed, and the evidence is retained against each alert.'
: 'Each alert opens its own STR case for the Principal Officer.'
}}
</p>
<p class="bk-err" *ngIf="errorMessage">{{ errorMessage }}</p>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-stroked-button type="button" (click)="dialogRef.close(false)">Cancel</button>
<button
mat-flat-button
[color]="isWhitelist ? 'primary' : 'warn'"
type="button"
[disabled]="form.invalid || submitting"
(click)="submit()"
>
{{ submitting ? 'Applying…' : isWhitelist ? 'Whitelist All' : 'Escalate All' }}
</button>
</mat-dialog-actions>
`,
styles: [
`
.bk-intro {
font-size: 13px;
color: #64748b;
margin: 0 0 12px;
line-height: 1.5;
}
.bk-list {
max-height: 190px;
overflow-y: auto;
border: 1px solid #e2e8f0;
border-radius: 8px;
margin-bottom: 16px;
background: #f8fafc;
}
.bk-row {
display: grid;
grid-template-columns: 1.1fr 1.4fr 1.6fr 0.9fr;
gap: 10px;
padding: 8px 12px;
font-size: 12px;
border-bottom: 1px solid #eef2f7;
}
.bk-row:last-child {
border-bottom: none;
}
.bk-txn {
font-family: 'DM Mono', monospace;
color: #334155;
}
.bk-client {
color: #1e293b;
}
.bk-rule {
color: #7c3aed;
font-size: 11px;
}
.bk-amt {
text-align: right;
font-family: 'DM Mono', monospace;
color: #1e293b;
}
.bk-label {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #64748b;
margin-bottom: 6px;
}
.bk-checks {
display: flex;
flex-direction: column;
gap: 2px;
margin-bottom: 10px;
}
.bk-full {
width: 100%;
}
.bk-effect {
font-size: 12.5px;
line-height: 1.5;
color: #15803d;
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: 6px;
padding: 10px 12px;
margin: 4px 0 0;
}
.bk-effect--warn {
color: #b91c1c;
background: #fef2f2;
border-color: #fecaca;
}
.bk-err {
margin: 10px 0 0;
color: #b91c1c;
font-size: 12.5px;
}
`
]
})
export class AmlBulkReviewDialogComponent {
private fb = inject(FormBuilder);
private amlService = inject(AmlService);
dialogRef = inject(MatDialogRef<AmlBulkReviewDialogComponent>);
readonly evidenceTypes = [
'Customer explanation obtained',
'Source of funds documented',
'KYC / identity re-verified',
'Consistent with declared income or turnover',
'Established business or salary pattern',
'Duplicate or system-generated alert',
'Other'
];
form: FormGroup;
submitting = false;
errorMessage = '';
data: AmlBulkReviewData = inject(MAT_DIALOG_DATA);
constructor() {
this.form = this.fb.group({
evidence: this.fb.array(this.evidenceTypes.map(() => false)),
evidenceRef: [
'',
Validators.maxLength(100)
],
notes: [
'',
[
Validators.required,
Validators.maxLength(800)
]
]
});
}
get isWhitelist(): boolean {
return this.data.disposition === 'WHITELIST';
}
get evidenceArray(): FormArray {
return this.form.get('evidence') as FormArray;
}
get selectedEvidence(): string[] {
return this.evidenceTypes.filter((_, i) => this.evidenceArray.at(i)?.value === true);
}
get evidenceMissing(): boolean {
return this.isWhitelist && this.evidenceArray.touched && this.selectedEvidence.length === 0;
}
/** Exactly what is written to each alert, so the record reads the same as a single review. */
private composedNotes(): string {
const v = this.form.value;
const detail = (v.notes || '').trim();
if (!this.isWhitelist) {
return detail ? `Grounds: ${detail}` : '';
}
const parts: string[] = [];
if (this.selectedEvidence.length) {
parts.push(`Evidence: ${this.selectedEvidence.join('; ')}`);
}
if (v.evidenceRef) {
parts.push(`Ref: ${(v.evidenceRef || '').trim()}`);
}
const head = parts.join(' | ');
const composed = head && detail ? `${head} — ${detail}` : head || detail;
// Batch note, so each alert says it was disposed of alongside others rather than on its own.
return `${composed} [batch of ${this.data.alerts.length}]`.slice(0, 1000);
}
submit(): void {
this.evidenceArray.markAllAsTouched();
if (this.form.invalid || (this.isWhitelist && this.selectedEvidence.length === 0)) {
this.form.markAllAsTouched();
return;
}
this.submitting = true;
this.errorMessage = '';
const notes = this.composedNotes();
// Each alert is reviewed on its own call: one that fails - already disposed of by someone else,
// say - must not take the rest of the batch with it. Failures are counted and reported.
forkJoin(
this.data.alerts.map((a) =>
this.amlService
.reviewAlert(a.id, this.data.disposition, notes)
.pipe(catchError(() => of({ __failed: true, id: a.id })))
)
).subscribe((results: any[]) => {
this.submitting = false;
const failed = results.filter((r) => r?.__failed).length;
if (failed === results.length) {
this.errorMessage = 'None of the alerts could be updated. They may already have been reviewed.';
return;
}
if (failed > 0) {
this.errorMessage = `${results.length - failed} updated, ${failed} could not be — those may already have been reviewed.`;
// Still closes as done: most of the batch landed, and the list behind must refresh.
setTimeout(() => this.dialogRef.close(true), 2200);
return;
}
this.dialogRef.close(true);
});
}
}
@@ -36,6 +36,17 @@
<button mat-icon-button type="button" matTooltip="Refresh now" [disabled]="loading" (click)="fetch()">
<mat-icon>refresh</mat-icon>
</button>
<!-- Exports what is on screen, not the whole feed: the workbook has to reconcile with the report it came from. -->
<button
type="button"
class="export-btn"
[disabled]="exporting || !alerts.data.length"
[matTooltip]="alerts.data.length ? 'Download the alerts currently in view as Excel' : 'Nothing in view to export'"
(click)="exportExcel()"
>
<mat-icon>file_download</mat-icon>
{{ exporting ? 'Preparing…' : 'Excel' }}
</button>
</div>
</div>
@@ -44,6 +55,52 @@
<span>Could not reach the alert feed. Retrying automatically.</span>
</div>
<!-- Narrow the feed to one day. The feed accumulates, so without this an officer asking "what did we decide today"
reads past a week of older alerts to find out. Composes with the status chips below: Today + Escalated answers
"what did we mark suspicious today". -->
<div class="status-filters">
<span class="status-filters-label">Period</span>
@for (m of periodModes; track m.key) {
<button type="button" class="status-chip" [class.active]="periodMode === m.key" (click)="setPeriodMode(m.key)">
{{ m.label }}
</button>
}
<!-- One control per mode, so the picker always matches the granularity chosen. A week is picked by naming any day
inside it; the resolved Monday-to-Sunday span is printed beside it so the officer sees what was selected. -->
@if (periodMode === 'day') {
<input type="date" class="day-input" [(ngModel)]="filterDay" (ngModelChange)="onPeriodChange()" aria-label="Day" />
}
@if (periodMode === 'week') {
<input
type="date"
class="day-input"
[(ngModel)]="filterWeek"
(ngModelChange)="onPeriodChange()"
aria-label="Any day in the week"
/>
}
@if (periodMode === 'month') {
<input
type="month"
class="day-input"
[(ngModel)]="filterMonth"
(ngModelChange)="onPeriodChange()"
aria-label="Month"
/>
}
@if (periodMode === 'year') {
<select class="day-input" [(ngModel)]="filterYear" (ngModelChange)="onPeriodChange()" aria-label="Year">
@for (y of availableYears; track y) {
<option [value]="y">{{ y }}</option>
}
</select>
}
@if (periodLabel) {
<span class="period-resolved">{{ periodLabel }}</span>
}
</div>
<!-- Narrow the feed by where each alert sits in the review workflow -->
<div class="status-filters">
<span class="status-filters-label">Status</span>
@@ -60,23 +117,143 @@
</button>
</div>
<!-- Severity mix of what the table is showing -->
<div class="risk-summary">
<div *ngFor="let tier of riskSummary" class="risk-tile" [ngClass]="tier.cls">
<span class="risk-tile-n">{{ tier.count }}</span>
<span class="risk-tile-l">{{ tier.label }}</span>
</div>
<div *ngIf="unclassifiedCount > 0" class="risk-tile risk-none" matTooltip="Raised by a user, not by a catalog rule">
<span class="risk-tile-n">{{ unclassifiedCount }}</span>
<span class="risk-tile-l">Unclassified</span>
</div>
<div class="risk-tile risk-total">
<span class="risk-tile-n">{{ summaryTotal }}</span>
<span class="risk-tile-l">Total Flagged</span>
</div>
<!-- Narrow to the rules firing most often. A rule tripping repeatedly is either a real pattern in the book or a rule
set too tight and filling the queue with noise — either way it is what an officer reads first, and the long tail
below it can wait. Ranked over the period and status already chosen. -->
<div class="status-filters">
<span class="status-filters-label">Top rules</span>
@for (n of topRuleOptions; track n) {
<button type="button" class="status-chip" [class.active]="topRules === n" (click)="setTopRules(n)">
{{ n === null ? 'All rules' : 'Top ' + n }}
</button>
}
@if (topRules !== null) {
<span class="period-resolved">{{ distinctRuleCount }} rule{{ distinctRuleCount === 1 ? '' : 's' }} in view</span>
}
</div>
<table mat-table [dataSource]="alerts" class="flagged-table mat-elevation-z1">
<!-- Which rules are driving the queue, and by how much. Ranking without the counts narrows the table and leaves the
officer to work out what it kept. -->
@if (ruleFrequency.length > 1) {
<div class="rule-freq">
@for (r of ruleFrequency; track r.code) {
<span class="rule-freq-item">
{{ r.code }}
<span class="rule-freq-n">{{ r.count }}</span>
</span>
}
</div>
}
<!-- Severity mix of what the table is showing. Each tile narrows the table to its own band;
clicking the one already in force clears it, as does Total Flagged. -->
<div class="risk-summary">
<button
*ngFor="let tier of riskSummary"
type="button"
class="risk-tile"
[ngClass]="tier.cls"
[class.tile-active]="isSeverityActive(tier.key)"
[disabled]="tier.count === 0 && !isSeverityActive(tier.key)"
[matTooltip]="isSeverityActive(tier.key) ? 'Show every severity again' : 'Show only ' + tier.label + ' alerts'"
(click)="setSeverity(tier.key)"
>
<span class="risk-tile-n">{{ tier.count }}</span>
<span class="risk-tile-l">{{ tier.label }}</span>
</button>
<button
*ngIf="unclassifiedCount > 0"
type="button"
class="risk-tile risk-none"
[class.tile-active]="isSeverityActive(0)"
matTooltip="Raised by a user, not by a catalog rule"
(click)="setSeverity(0)"
>
<span class="risk-tile-n">{{ unclassifiedCount }}</span>
<span class="risk-tile-l">Unclassified</span>
</button>
<button
type="button"
class="risk-tile risk-total"
[class.tile-active]="activeSeverity === null"
matTooltip="Show every severity"
(click)="clearSeverity()"
>
<span class="risk-tile-n">{{ summaryTotal }}</span>
<span class="risk-tile-l">Total Flagged</span>
</button>
</div>
<!-- Appears only once something is ticked, so the table does not carry a permanently empty bar. -->
@if (selectedCount > 0) {
<div class="bulk-bar">
<span class="bulk-count">{{ selectedCount }} selected</span>
<span class="bulk-note"
>Low / medium only — a transaction that tripped a high-severity rule is decided on its own.</span
>
<span class="bulk-spacer"></span>
<button mat-stroked-button type="button" (click)="selectedIds.clear()">Clear</button>
<button
mat-stroked-button
color="primary"
type="button"
*mifosxHasPermission="'REVIEW_AML_ALERT'"
(click)="reviewSelected('WHITELIST')"
>
Whitelist {{ selectedCount }}
</button>
<button
mat-flat-button
color="warn"
type="button"
*mifosxHasPermission="'REVIEW_AML_ALERT'"
(click)="reviewSelected('ESCALATE')"
>
Escalate {{ selectedCount }}
</button>
</div>
}
<!-- multiTemplateDataRows: each alert renders through both row definitions — the alert row and its
detail row beneath it. Without it MatTable renders one row per alert, the two definitions both
claim it, and the table throws rather than rendering anything. All three tabs share one tab
group, so that error blanked Repeat Customers and Rules Master as well. -->
<table mat-table [dataSource]="alerts" multiTemplateDataRows class="flagged-table mat-elevation-z1">
<!-- Ticked for a batch decision. Disabled where the transaction tripped a high-severity rule,
where nothing it matched carried a severity (the CTR rules), or where it is already decided. -->
<ng-container matColumnDef="select">
<th mat-header-cell *matHeaderCellDef class="select-cell">
<!-- Hidden outright when nothing here can be batched — filtered to high severity, say. -->
@if (selectableRows.length) {
<mat-checkbox
[checked]="allSelectableSelected"
(change)="toggleSelectAll()"
matTooltip="Select every low / medium alert on this page"
></mat-checkbox>
}
</th>
<td mat-cell *matCellDef="let alert" class="select-cell" (click)="$event.stopPropagation()">
<!-- No checkbox at all where the transaction cannot be part of a batch. A disabled box still
reads as "this could be ticked", and on a high-severity row that is the wrong thing to
suggest: the decision is never a batch one. A dash says the column does not apply. -->
@if (isSelectable(alert)) {
<mat-checkbox [checked]="isSelected(alert)" (change)="toggleSelected(alert)"></mat-checkbox>
} @else {
<span
class="select-na"
[matTooltip]="
isHighSeverity(alert)
? 'High severity — this transaction is decided on its own'
: !canReview(alert)
? 'Already decided'
: 'A CTR is a statutory return, not a graded suspicion — decided on its own'
"
>—</span
>
}
</td>
</ng-container>
<!-- When the engine raised the alert -->
<ng-container matColumnDef="raisedOn">
<th mat-header-cell *matHeaderCellDef>Raised On</th>
@@ -113,6 +290,162 @@
<div class="stacked-cell">
<span class="rule-code">{{ alert.ruleCode }}</span>
<span class="cell-sub">{{ alert.ruleDescription }}</span>
<!-- The transaction is one case under one rule, but an analyst still needs to see
everything it tripped — a deposit can be both reportable and on a new account. The
detail row carries each one with its own severity; this is only the count. -->
@if (matchedRuleCount(alert) > 1) {
<button type="button" class="rule-more" (click)="toggleExpanded(alert); $event.stopPropagation()">
<mat-icon>{{ isExpanded(alert) ? 'expand_less' : 'expand_more' }}</mat-icon>
{{ matchedRuleCount(alert) }} rules matched
</button>
}
</div>
</td>
</ng-container>
<!-- The expanded pane. One cell spanning the table, collapsed to zero height until its row is
opened, so the detail sits directly under the row it belongs to. -->
<ng-container matColumnDef="expandedDetail">
<!-- No header cell: this column never appears in the header row, only in its own data row. -->
<td mat-cell *matCellDef="let alert" [attr.colspan]="displayedColumns.length" class="detail-cell">
<!-- The pane is inside a row that toggles on click, so anything interactive in here must keep its own
clicks from reaching it — otherwise ticking a rule closes the pane. -->
<div class="detail-pane" [class.detail-open]="isExpanded(alert)" (click)="$event.stopPropagation()">
@if (isExpanded(alert)) {
<div class="detail-inner">
<div class="detail-head">
<span
>Rules matched by {{ alert.transactionType | lowercase }} #{{ alert.transactionId }} —
{{ amount(alert) }}</span
>
@if (selectableRulesOf(alert).length > 1) {
<button type="button" class="rule-select-all" (click)="toggleAllRules(alert)">
{{ allRulesSelected(alert) ? 'Clear all' : 'Select all open' }}
</button>
}
</div>
<!-- Governing rule first: it is the one the alert was raised under, and the one the
disposition is recorded against. -->
@for (rule of matchedRules(alert); track rule.ruleCode) {
<div
class="detail-rule"
[class.detail-rule-governing]="rule.governing"
[class.detail-rule-decided]="rule.disposition"
>
<!-- One checkbox per rule: a transaction can trip six rules where five are explained by the
customer's business and the sixth is not, and those are different decisions. A rule already
decided shows its verdict instead. -->
@if (isRuleSelectable(rule)) {
<mat-checkbox
[checked]="isRuleSelected(rule)"
(change)="toggleRuleSelected(rule)"
*mifosxHasPermission="'REVIEW_AML_ALERT'"
></mat-checkbox>
} @else if (!isGradedRule(rule)) {
<!-- A statutory return, not a graded suspicion: filed on the amount whatever the officer decides
about the transaction, so there is nothing here to tick. -->
<mat-icon
class="rule-decided-icon decided-statutory"
matTooltip="A statutory return — filed on the amount, whatever is decided about this transaction. Nothing to review here."
matTooltipClass="evidence-tooltip"
>
gavel
</mat-icon>
} @else {
<mat-icon
class="rule-decided-icon"
[class.decided-escalated]="rule.disposition === 'ESCALATE' && rule.directlyDecided"
[class.decided-carried]="!rule.directlyDecided"
[matTooltip]="
(rule.directlyDecided ? ruleDispositionLabel(rule) : 'Closed with the case') +
(rule.reviewedByUsername ? ' by ' + rule.reviewedByUsername : '') +
(rule.reviewNotes ? ' — ' + rule.reviewNotes : '')
"
matTooltipClass="evidence-tooltip"
>
{{
!rule.directlyDecided
? 'remove_circle_outline'
: rule.disposition === 'ESCALATE'
? 'report'
: 'verified'
}}
</mat-icon>
}
<span class="sev-chip" [ngClass]="severityClass(rule.riskCategory)">{{
severityLabel(rule.riskCategory)
}}</span>
<div class="detail-rule-text">
<span class="detail-rule-code">
{{ rule.ruleCode }}
@if (rule.governing) {
<span class="governing-tag" matTooltip="The alert is raised and decided under this rule"
>governing</span
>
}
<!-- Says where a statutory rule actually goes, so the absence of a checkbox does not read as the
transaction being unreported. -->
@if (!isGradedRule(rule)) {
<span class="ctr-tag" matTooltip="Goes to the monthly CTR filing for FIU-IND, not to an STR">
→ CTR filing
</span>
}
<!-- The rule the officer named reads as their decision; a rule closed alongside it says so,
because an audit of the finding turns on which one they put their name to. -->
@if (rule.disposition) {
<span
class="rule-verdict"
[class.verdict-escalated]="rule.disposition === 'ESCALATE'"
[class.verdict-carried]="!rule.directlyDecided"
[matTooltip]="
rule.directlyDecided
? ruleDispositionLabel(rule) +
(rule.reviewedByUsername ? ' by ' + rule.reviewedByUsername : '')
: 'Closed with the transaction when another rule was marked suspicious — not decided on its own'
"
>
{{ rule.directlyDecided ? ruleDispositionLabel(rule) : 'Closed with case' }}
</span>
}
</span>
<span class="detail-rule-desc">{{ rule.ruleDescription }}</span>
</div>
</div>
}
<!-- Acts on the ticked rules only. The alert's own status follows from what is left undecided, so
clearing five of six rules leaves the case open on the sixth. -->
@if (selectedRuleCount > 0) {
<div class="rule-action-bar" *mifosxHasPermission="'REVIEW_AML_ALERT'">
<span class="rule-action-count"
>{{ selectedRuleCount }} rule{{ selectedRuleCount > 1 ? 's' : '' }} selected</span
>
<span class="bulk-spacer"></span>
<button mat-stroked-button type="button" (click)="selectedRuleCodes.clear()">Clear</button>
<button
mat-stroked-button
color="primary"
type="button"
(click)="reviewSelectedRules(alert, 'WHITELIST')"
>
Whitelist
</button>
<button mat-flat-button color="warn" type="button" (click)="reviewSelectedRules(alert, 'ESCALATE')">
Suspicious
</button>
</div>
}
<div class="detail-foot">
@if (isHighSeverity(alert)) {
A high-severity rule fired, so this transaction is decided on its own rather than in a batch.
} @else if (!alert.highestSeverity) {
A statutory return rather than a graded suspicion — decided on its own.
} @else {
Every rule here is low or medium, so this transaction can be decided together with others.
}
</div>
</div>
}
</div>
</td>
</ng-container>
@@ -132,7 +465,7 @@
<!-- Disposition. Hidden once an alert is already whitelisted or escalated. -->
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef class="col-center">Action</th>
<td mat-cell *matCellDef="let alert" class="col-center actions-cell">
<td mat-cell *matCellDef="let alert" class="col-center actions-cell" (click)="$event.stopPropagation()">
<ng-container *ngIf="canReview(alert); else reviewed">
<!-- Same gate the FIU-IND register uses; the endpoint enforces REVIEW_AML_ALERT server side,
so without this a user who cannot review still sees buttons that only fail on click. -->
@@ -158,37 +491,73 @@
</div>
</ng-container>
<ng-template #reviewed>
<div class="reviewed-cell">
<span class="reviewed-by">
{{ alert.reviewedByUsername ? 'by ' + alert.reviewedByUsername : 'Reviewed' }}
</span>
<!-- The evidence recorded at review time, so the trail is readable without leaving the feed -->
<mat-icon
*ngIf="alert.reviewNotes"
class="reviewed-evidence-icon"
[matTooltip]="alert.reviewNotes"
matTooltipClass="evidence-tooltip"
>
description
</mat-icon>
<span *ngIf="alert.status === 'ESCALATED'" class="str-routed" matTooltip="An STR case is open for this alert">
→ STR
</span>
</div>
<!-- Held by somebody else: not a verdict, so it does not read like one. The endpoint refuses a disposition
from anyone but the officer who has it, and this says who to ask. -->
@if (heldByAnother(alert)) {
<div class="reviewed-cell">
<mat-icon
class="held-icon"
[matTooltip]="'Open with ' + heldBy(alert) + ' — they decide it or hand it back'"
>
lock
</mat-icon>
<span class="held-by">with {{ heldBy(alert) }}</span>
</div>
} @else {
<div class="reviewed-cell">
<span class="reviewed-by">
{{ alert.reviewedByUsername ? 'by ' + alert.reviewedByUsername : 'Reviewed' }}
</span>
<!-- The evidence recorded at review time, so the trail is readable without leaving the feed -->
<mat-icon
*ngIf="alert.reviewNotes"
class="reviewed-evidence-icon"
[matTooltip]="alert.reviewNotes"
matTooltipClass="evidence-tooltip"
>
description
</mat-icon>
<span
*ngIf="alert.status === 'ESCALATED'"
class="str-routed"
matTooltip="An STR case is open for this alert"
>
→ STR
</span>
</div>
}
</ng-template>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns" class="alert-row" [class.row-new]="isNew(row)"></tr>
<!-- Clicking anywhere on the row opens its detail. The checkbox and the action buttons stop the
event themselves, so ticking a row or disposing of it does not also expand it. -->
<tr
mat-row
*matRowDef="let row; columns: displayedColumns"
class="alert-row"
[class.row-new]="isNew(row)"
[class.row-expanded]="isExpanded(row)"
(click)="toggleExpanded(row)"
></tr>
<tr mat-row *matRowDef="let row; columns: ['expandedDetail']" class="detail-row"></tr>
</table>
<mat-paginator [pageSizeOptions]="[10, 25, 50, 100]" showFirstLastButtons class="flagged-paginator"></mat-paginator>
<!-- pageSize stated explicitly: without it the paginator starts at 0, and anything that touches the
paginator before the data source has bound to it leaves that 0 in force, which pages every row
out of the table. -->
<mat-paginator
[pageSize]="10"
[pageSizeOptions]="[10, 25, 50, 100]"
showFirstLastButtons
class="flagged-paginator"
></mat-paginator>
<div *ngIf="alerts.data.length === 0 && !loading" class="no-alerts-message">
<mat-icon>{{ activeStatus ? 'filter_alt_off' : 'verified_user' }}</mat-icon>
<span *ngIf="!activeStatus"
<mat-icon>{{ activeStatus || activeSeverity !== null ? 'filter_alt_off' : 'verified_user' }}</mat-icon>
<span *ngIf="!activeStatus && activeSeverity === null"
>No transactions flagged. The feed updates automatically as the engine raises alerts.</span
>
<span *ngIf="activeStatus">No alerts in this status.</span>
<span *ngIf="activeStatus || activeSeverity !== null">No alerts match the filters in force.</span>
</div>
@@ -196,15 +196,43 @@
padding: 0 0 16px;
}
/* A button, not a div: each tile narrows the table to its own severity band. */
.risk-tile {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
padding: 12px 14px;
border: 1px solid #e2e8f0;
border-left-width: 4px;
border-radius: 8px;
background: #fff;
font-family: inherit;
text-align: left;
cursor: pointer;
transition:
box-shadow 0.15s,
transform 0.1s;
&:hover:not(:disabled) {
box-shadow: 0 2px 8px rgb(15 23 42 / 12%);
}
&:active:not(:disabled) {
transform: translateY(1px);
}
/* A band with nothing in it would filter to an empty table. */
&:disabled {
opacity: 0.5;
cursor: default;
}
}
/* The band currently narrowing the table. The ring reads on every tile colour, where a
background change would fight the severity tint each tile already carries. */
.risk-tile.tile-active {
box-shadow: 0 0 0 2px #1a237e;
}
.risk-tile-n {
@@ -293,8 +321,21 @@
}
}
.alert-row:hover {
.alert-row {
cursor: pointer;
&:hover {
background: #f8fafc;
}
}
/* The open row and its pane read as one block. */
.alert-row.row-expanded {
background: #f8fafc;
td {
border-bottom-color: transparent;
}
}
/* A newly arrived alert stays tinted until acknowledged. */
@@ -380,6 +421,285 @@
font-size: 12px;
}
/* Quieter than the governing rule: it is context on one case, not a second case. */
.rule-also {
margin-top: 3px;
color: #92400e;
background: #fffbeb;
border: 1px solid #fde68a;
border-radius: 4px;
padding: 1px 6px;
font-size: 11px;
align-self: flex-start;
}
/* Says how many rules fired and opens the detail. Replaces the block of codes that used to sit in
this cell: the codes belong in the pane, where each can carry its own severity. */
.rule-more {
display: inline-flex;
align-items: center;
gap: 3px;
margin-top: 4px;
padding: 2px 8px 2px 4px;
align-self: flex-start;
border: 1px solid #fde68a;
border-radius: 999px;
background: #fffbeb;
color: #92400e;
font-family: inherit;
font-size: 11px;
font-weight: 700;
cursor: pointer;
mat-icon {
font-size: 15px;
width: 15px;
height: 15px;
}
&:hover {
background: #fef3c7;
}
}
/* ── Expanded detail ─────────────────────────────────────────────────────────────────────── */
.detail-row {
height: 0;
}
.detail-cell {
padding: 0 !important;
border-bottom: none !important;
}
/* Animating max-height rather than height: the pane's height depends on how many rules fired, and
a transition needs a figure to move between. */
.detail-pane {
max-height: 0;
overflow: hidden;
transition: max-height 0.2s ease;
background: #f8fafc;
/* Generous enough for a transaction that tripped every rule it could, plus the action bar. Too low
silently clips the last rule, which is the one an officer most needs to see. */
&.detail-open {
max-height: 1200px;
}
}
.detail-inner {
padding: 14px 16px 16px 60px;
border-bottom: 1px solid #e2e8f0;
}
.detail-head {
display: flex;
align-items: center;
gap: 12px;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #64748b;
margin-bottom: 10px;
}
.rule-select-all {
border: none;
background: none;
padding: 0;
font-family: inherit;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #1a237e;
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
/* A rule that already carries a verdict is context, not a choice. */
.detail-rule-decided {
opacity: 0.75;
}
.rule-decided-icon {
flex: none;
font-size: 18px;
width: 18px;
height: 18px;
color: #15803d;
cursor: help;
&.decided-escalated {
color: #b91c1c;
}
/* Closed alongside another rule rather than judged on its own — stated, but not as a verdict. */
&.decided-carried {
color: #94a3b8;
}
/* A statutory return: not the officer's decision at all. */
&.decided-statutory {
color: #475569;
}
}
/* Where a statutory rule goes, said next to the rule rather than left to be inferred from a
missing checkbox. */
.ctr-tag {
margin-left: 6px;
padding: 1px 6px;
border-radius: 4px;
background: #e2e8f0;
color: #334155;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.03em;
text-transform: uppercase;
white-space: nowrap;
}
.rule-verdict {
margin-left: 6px;
padding: 1px 6px;
border-radius: 4px;
background: #dcfce7;
color: #15803d;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.03em;
text-transform: uppercase;
&.verdict-escalated {
background: #fee2e2;
color: #b91c1c;
}
/* Wins over the disposition colours above: this rule was not itself the finding, and colouring it
like one would claim the officer condemned it. */
&.verdict-carried {
background: #f1f5f9;
color: #64748b;
}
}
/* Acts on the ticked rules only, and sits with them rather than at the top of the table. */
.rule-action-bar {
display: flex;
align-items: center;
gap: 10px;
margin-top: 12px;
padding: 10px 12px;
border: 1px solid #bfdbfe;
border-radius: 8px;
background: #eff6ff;
}
.rule-action-count {
font-size: 12.5px;
font-weight: 700;
color: #1e3a8a;
white-space: nowrap;
}
.detail-rule {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 7px 10px;
border-radius: 6px;
background: #fff;
border: 1px solid #e2e8f0;
& + & {
margin-top: 6px;
}
}
/* The rule the alert was raised under, and the one the disposition is recorded against. */
.detail-rule-governing {
border-color: #cbd5e1;
box-shadow: inset 3px 0 0 #1a237e;
}
.detail-rule-text {
display: flex;
flex-direction: column;
gap: 1px;
min-width: 0;
}
.detail-rule-code {
font-size: 12.5px;
font-weight: 700;
color: #1e293b;
}
.detail-rule-desc {
font-size: 12px;
color: #64748b;
}
.governing-tag {
margin-left: 6px;
padding: 1px 6px;
border-radius: 4px;
background: #e0e7ff;
color: #3730a3;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.03em;
text-transform: uppercase;
}
/* Severity of one rule inside the pane. Same colours as the summary tiles, so a band reads the
same wherever it appears. */
.sev-chip {
flex: none;
min-width: 62px;
text-align: center;
padding: 2px 8px;
border-radius: 999px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
&.sev-high {
background: #fee2e2;
color: #b91c1c;
}
&.sev-medium {
background: #fef3c7;
color: #b45309;
}
&.sev-low {
background: #dcfce7;
color: #15803d;
}
&.sev-none {
background: #e2e8f0;
color: #475569;
}
}
/* Why this transaction can or cannot be decided in a batch, said where the officer is deciding. */
.detail-foot {
margin-top: 10px;
font-size: 12px;
color: #475569;
font-style: italic;
}
.rule-code {
font-weight: 600;
}
@@ -537,3 +857,157 @@
background: #fff;
border-top: 1px solid #e2e8f0;
}
/* Batch disposition bar — only rendered while rows are ticked. */
.bulk-bar {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
margin-bottom: 10px;
border: 1px solid #bfdbfe;
border-radius: 10px;
background: #eff6ff;
}
.bulk-count {
font-size: 13px;
font-weight: 700;
color: #1e3a8a;
white-space: nowrap;
}
/* Says why some rows cannot be ticked, where the officer is looking rather than in a tooltip. */
.bulk-note {
font-size: 12px;
color: #475569;
}
.bulk-spacer {
flex: 1;
}
.select-cell {
width: 44px;
padding-right: 0;
}
/* Stands in for the checkbox on a row that cannot be batched, so the column still reads as a
column rather than leaving a hole the eye has to account for. */
.select-na {
display: inline-block;
width: 18px;
text-align: center;
color: #cbd5e1;
cursor: help;
user-select: none;
}
/* Day picker. Sized and bordered to sit level with the status chips beside it, so the two rows read
as one control strip rather than a form field dropped above a set of buttons. */
.day-input {
padding: 5px 11px;
border: 1px solid #cbd5e1;
border-radius: 999px;
background: #fff;
font-family: inherit;
font-size: 12.5px;
font-weight: 600;
color: #334155;
cursor: pointer;
&:hover {
border-color: #1a237e;
}
&:focus {
outline: none;
border-color: #1a237e;
}
}
/* What the chosen mode resolved to. A week picked by naming one day inside it is otherwise invisible — the officer
has no way to tell which Monday-to-Sunday span they are looking at. */
.period-resolved {
font-size: 12px;
color: #64748b;
font-variant-numeric: tabular-nums;
}
/* Sits with the pause and refresh controls, green because it produces something the officer takes away rather
than changing what is on screen. */
.export-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 13px;
border: 1px solid #bbf7d0;
border-radius: 999px;
background: #f0fdf4;
color: #15803d;
font-family: inherit;
font-size: 12.5px;
font-weight: 700;
cursor: pointer;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
&:hover:not(:disabled) {
background: #dcfce7;
border-color: #86efac;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
/* Held by another officer. Grey, not red: nobody has done anything wrong, the case is simply somebody else's. */
.held-icon {
font-size: 16px;
width: 16px;
height: 16px;
color: #94a3b8;
cursor: help;
}
.held-by {
color: #64748b;
font-size: 12px;
font-style: italic;
}
/* Rule frequency strip. Sits under the filters as a reading aid, not a control — clicking a rule would be a fourth
filter competing with the three above it. */
.rule-freq {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 0 0 14px;
}
.rule-freq-item {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 9px;
border: 1px solid #e2e8f0;
border-radius: 999px;
background: #f8fafc;
font-size: 11px;
font-weight: 600;
color: #475569;
}
.rule-freq-n {
padding: 0 6px;
border-radius: 999px;
background: #e2e8f0;
color: #1e293b;
font-variant-numeric: tabular-nums;
}
@@ -7,7 +7,7 @@
*/
/** Angular Imports */
import { Component, OnInit, OnDestroy, inject, ViewChild } from '@angular/core';
import { Component, OnInit, AfterViewInit, OnDestroy, inject, ViewChild } from '@angular/core';
import { MatTableModule, MatTableDataSource } from '@angular/material/table';
import { MatPaginatorModule, MatPaginator } from '@angular/material/paginator';
import { MatIconModule } from '@angular/material/icon';
@@ -15,15 +15,23 @@ import { MatButtonModule } from '@angular/material/button';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Subject, timer, of } from 'rxjs';
import { switchMap, takeUntil, catchError, filter } from 'rxjs/operators';
/** Custom Services */
import { AmlService } from '../../../aml/aml.service';
import { AlertService } from '../../../core/alert/alert.service';
import { AuthenticationService } from '../../../core/authentication/authentication.service';
import { HasPermissionDirective } from '../../../directives/has-permission/has-permission.directive';
import { AmlReviewAlertDialogComponent } from './aml-review-alert-dialog.component';
import { AmlBulkReviewDialogComponent } from './aml-bulk-review-dialog.component';
import { exportWorkbook } from '../../../cashier/cashier-export';
/** How the feed is narrowed by time. */
type PeriodMode = 'all' | 'day' | 'week' | 'month' | 'year';
/**
* Live feed of transactions the AML rule engine has flagged.
@@ -41,6 +49,7 @@ import { AmlReviewAlertDialogComponent } from './aml-review-alert-dialog.compone
standalone: true,
imports: [
CommonModule,
FormsModule,
MatTableModule,
MatPaginatorModule,
MatIconModule,
@@ -48,13 +57,18 @@ import { AmlReviewAlertDialogComponent } from './aml-review-alert-dialog.compone
MatTooltipModule,
MatProgressSpinnerModule,
MatDialogModule,
MatCheckboxModule,
HasPermissionDirective
]
})
export class AmlFlaggedTransactionsComponent implements OnInit, OnDestroy {
export class AmlFlaggedTransactionsComponent implements OnInit, AfterViewInit, OnDestroy {
private amlService = inject(AmlService);
private dialog = inject(MatDialog);
private alertService = inject(AlertService);
private auth = inject(AuthenticationService);
/** Who is signed in, so the screen can tell an alert this officer holds from one somebody else does. */
private readonly currentUsername = this.auth.getCredentials()?.username ?? '';
private destroy$ = new Subject<void>();
/** Short enough to feel live to an officer watching the desk, long enough not to hammer the API. */
@@ -62,6 +76,7 @@ export class AmlFlaggedTransactionsComponent implements OnInit, OnDestroy {
alerts = new MatTableDataSource<any>([]);
displayedColumns: string[] = [
'select',
'raisedOn',
'transaction',
'client',
@@ -71,25 +86,532 @@ export class AmlFlaggedTransactionsComponent implements OnInit, OnDestroy {
'actions'
];
/**
* Alerts ticked for a single disposition across several transactions.
*
* <p>
* Low and medium-severity alerts arrive in numbers and are mostly the same decision repeated -
* a run of ordinary cash deposits that each tripped a threshold. Dealing with them one dialog at
* a time is what stops an officer getting to the one alert that matters. High severity is
* deliberately excluded: an alert that reached the high band is a judgement about that customer's
* transaction, and it gets its own decision.
*/
selectedIds = new Set<number>();
/** m_client_risk_rule.risk_category — 3 is the high band. */
private static readonly HIGH_SEVERITY = 3;
/**
* Whether any rule this transaction matched reached the high band.
*
* <p>
* Read from highestSeverity, not from the governing rule's own riskCategory. One transaction
* raises one alert under whichever rule most warrants attention, and a statutory CTR outranks
* everything — so a high-severity rule can sit in "also matched" while the row itself reports the
* CTR, which carries no severity at all. Judging the row by the governing rule would offer such a
* transaction up for a batch decision.
*/
isHighSeverity(alert: any): boolean {
return Number(alert?.highestSeverity) === AmlFlaggedTransactionsComponent.HIGH_SEVERITY;
}
/**
* The band this alert counts as, for the summary tiles.
*
* <p>
* The same figure the checkbox is gated on, so the tiles and the table cannot disagree. Reading
* riskCategory here put a ₹12,00,000 cash deposit that had tripped a high-severity rule under
* "Unclassified", because the alert reports under the statutory CTR and a CTR carries no band of
* its own. Alerts raised before highest_severity existed fall back to the governing rule's band.
*/
private severityBand(alert: any): number {
const worst = Number(alert?.highestSeverity);
if (worst === 1 || worst === 2 || worst === 3) {
return worst;
}
const governing = Number(alert?.riskCategory);
if (governing === 1 || governing === 2 || governing === 3) {
return governing;
}
// 0, never NaN: the Unclassified tile filters on this value, and NaN === NaN is false, so a
// NaN band would leave that tile showing a count it could never select.
return 0;
}
/**
* Only an open alert whose every matched rule sits in the low or medium band can be swept up with
* others.
*
* <p>
* Two things are held back. A transaction that tripped any high-severity rule, because that is a
* judgement about the transaction itself. And one where no matched rule carried a severity at
* all, which is how the CTR rules arrive - they are not graded, being returns the bank owes
* rather than suspicions. Neither belongs in a batch disposed of on a single reason.
*/
isSelectable(alert: any): boolean {
const band = Number(alert?.highestSeverity);
const graded = band === 1 || band === 2;
return this.canReview(alert) && graded;
}
/** The alert whose detail row is open. One at a time: this is a reading pane, not a comparison. */
expandedId: number | null = null;
/**
* Opens or closes the detail pane. Nothing is recorded by reading.
*
* <p>
* Expanding a row used to mark the alert UNDER_REVIEW, on the reasoning that opening the pane is where the work
* starts. It is not: most expands are an officer glancing at what a transaction tripped, and a status that changes
* on a single click describes the click rather than any review. The queue filled with alerts nobody was working,
* and the one state the flag existed to make visible — this one is actually being worked — became meaningless.
*/
toggleExpanded(alert: any): void {
this.expandedId = this.expandedId === alert.id ? null : alert.id;
// Ticks belong to the alert they were made on, not to whichever pane is open next.
this.selectedRuleCodes.clear();
}
isExpanded(alert: any): boolean {
return this.expandedId === alert.id;
}
/**
* Every rule the transaction matched, governing one first.
*
* <p>
* Served by the API. The fallback rebuilds the list from the flat fields for an alert read before the API carried
* it, so an older row still expands to something rather than appearing to have matched nothing.
*/
matchedRules(
alert: any
): { ruleCode: string; ruleDescription: string; riskCategory: number | null; governing: boolean }[] {
if (Array.isArray(alert?.matchedRules) && alert.matchedRules.length) {
return alert.matchedRules;
}
const rules = [
{
ruleCode: alert?.ruleCode,
ruleDescription: alert?.ruleDescription,
riskCategory: alert?.riskCategory ?? null,
governing: true
}
];
for (const code of String(alert?.alsoMatchedRules || '')
.split(',')
.map((c: string) => c.trim())
.filter((c: string) => c)) {
rules.push({ ruleCode: code, ruleDescription: code, riskCategory: null, governing: false });
}
return rules;
}
/** How many rules the transaction matched, for the count shown on the collapsed row. */
matchedRuleCount(alert: any): number {
return this.matchedRules(alert).length;
}
severityLabel(band: number | null | undefined): string {
switch (Number(band)) {
case 1:
return 'Low';
case 2:
return 'Medium';
case 3:
return 'High';
default:
return 'Statutory';
}
}
/**
* Rule codes ticked inside the open detail pane.
*
* <p>
* Cleared whenever a different alert is opened: the codes are only meaningful against the alert they were ticked on,
* and carrying them across would apply one transaction's selection to another's rules.
*/
selectedRuleCodes = new Set<string>();
isRuleSelected(rule: any): boolean {
return this.selectedRuleCodes.has(rule.ruleCode);
}
/**
* Whether this rule is the officer's to decide.
*
* <p>
* A rule already decided keeps its verdict. A rule with no severity band is not a graded suspicion at all - the CTR
* rules are statutory returns the bank owes FIU-IND on any cash transaction at or above ten lakh, filed on the
* amount whether or not the customer is above suspicion. There is no "whitelist" for one: clearing the transaction
* does not stop the CTR, and escalating it does not describe what the rule found.
*/
isRuleSelectable(rule: any): boolean {
return !rule?.disposition && this.isGradedRule(rule);
}
/** A rule that carries a severity band, as against a statutory reporting rule that carries none. */
isGradedRule(rule: any): boolean {
const band = Number(rule?.riskCategory);
return band === 1 || band === 2 || band === 3;
}
toggleRuleSelected(rule: any): void {
if (!this.isRuleSelectable(rule)) {
return;
}
if (this.selectedRuleCodes.has(rule.ruleCode)) {
this.selectedRuleCodes.delete(rule.ruleCode);
} else {
this.selectedRuleCodes.add(rule.ruleCode);
}
}
selectableRulesOf(alert: any): any[] {
return this.matchedRules(alert).filter((r) => this.isRuleSelectable(r));
}
get selectedRuleCount(): number {
return this.selectedRuleCodes.size;
}
allRulesSelected(alert: any): boolean {
const open = this.selectableRulesOf(alert);
return open.length > 0 && open.every((r) => this.selectedRuleCodes.has(r.ruleCode));
}
toggleAllRules(alert: any): void {
if (this.allRulesSelected(alert)) {
this.selectedRuleCodes.clear();
return;
}
for (const rule of this.selectableRulesOf(alert)) {
this.selectedRuleCodes.add(rule.ruleCode);
}
}
ruleDispositionLabel(rule: any): string {
return rule?.disposition === 'ESCALATE' ? 'Escalated' : rule?.disposition === 'WHITELIST' ? 'Whitelisted' : '';
}
severityClass(band: number | null | undefined): string {
switch (Number(band)) {
case 1:
return 'sev-low';
case 2:
return 'sev-medium';
case 3:
return 'sev-high';
default:
return 'sev-none';
}
}
isSelected(alert: any): boolean {
return this.selectedIds.has(alert.id);
}
toggleSelected(alert: any): void {
if (!this.isSelectable(alert)) {
return;
}
if (this.selectedIds.has(alert.id)) {
this.selectedIds.delete(alert.id);
} else {
this.selectedIds.add(alert.id);
}
}
/** The rows a "select all" would act on: what is on screen, open, and below the high band. */
get selectableRows(): any[] {
return this.alerts.data.filter((a) => this.isSelectable(a));
}
get allSelectableSelected(): boolean {
const rows = this.selectableRows;
return rows.length > 0 && rows.every((a) => this.selectedIds.has(a.id));
}
toggleSelectAll(): void {
if (this.allSelectableSelected) {
this.selectedIds.clear();
return;
}
for (const a of this.selectableRows) {
this.selectedIds.add(a.id);
}
}
get selectedCount(): number {
return this.selectedIds.size;
}
private selectedAlerts(): any[] {
return this.alerts.data.filter((a) => this.selectedIds.has(a.id));
}
/**
* Drops ticks that no longer stand, after every poll.
*
* <p>
* The feed refreshes itself every few seconds while the officer works. Without this, an alert
* another officer disposed of in the meantime — or one the status filter has since hidden — stays
* in the set, and the bar goes on offering to decide it: the count reads higher than the ticks on
* screen, and the batch carries alerts nobody chose.
*/
private pruneSelection(): void {
const stillSelectable = new Set(this.selectableRows.map((a) => a.id));
for (const id of Array.from(this.selectedIds)) {
if (!stillSelectable.has(id)) {
this.selectedIds.delete(id);
}
}
// Same for the open detail pane: an alert filtered away or disposed of elsewhere should not
// leave the table holding a pane for a row that is no longer on it.
if (this.expandedId !== null && !this.alerts.data.some((a) => a.id === this.expandedId)) {
this.expandedId = null;
}
}
/** Every alert as returned, before the status filter narrows the table. */
private allAlerts: any[] = [];
readonly statusFilters: { key: string; label: string }[] = [
{ key: '', label: 'All alerts' },
{ key: 'RAISED', label: 'Raised' },
{ key: 'UNDER_REVIEW', label: 'Under review' },
// No "Under review" chip: nothing sets that status now that reading a row no longer records anything, so the
// chip would sit at zero for good. The status itself is kept — alerts predating this change still carry it, and
// canReview still treats them as open — but it is not offered as a filter nobody can populate.
{ key: 'ESCALATED', label: 'Escalated' },
{ key: 'WHITELISTED', label: 'Whitelisted' }
];
activeStatus = '';
private statusCounts: Record<string, number> = {};
/**
* The period being worked.
*
* A monitoring feed accumulates: last week's alerts sit under today's, and an officer asked "what did we decide
* today" has to read past them. The period filter is what turns the feed into a day's, a week's or a month's work
* — and it composes with the status chips, so Today + Escalated answers "what did we mark suspicious today".
*/
readonly periodModes: { key: PeriodMode; label: string }[] = [
{ key: 'all', label: 'All time' },
{ key: 'day', label: 'Day wise' },
{ key: 'week', label: 'Week wise' },
{ key: 'month', label: 'Month wise' },
{ key: 'year', label: 'Year wise' }
];
periodMode: PeriodMode = 'all';
/** The chosen day / any day inside the chosen week / the chosen month / the chosen year. */
filterDay = '';
filterWeek = '';
filterMonth = '';
filterYear = '';
/**
* Narrows the table to the rules firing most often in the chosen period.
*
* <p>
* A rule tripping repeatedly is a different question from one transaction tripping a rule: it is either a real
* pattern in the book, or a rule set too tight and filling the queue with noise. Either way the rules at the top of
* the count are the ones worth an officer's attention first, and the long tail below them can wait.
*
* <p>
* Null shows every rule. The options are deliberately small — a "top 10" that lists 40 rules answers nothing.
*/
readonly topRuleOptions: (number | null)[] = [
null,
10,
20,
50
];
topRules: number | null = null;
setTopRules(n: number | null): void {
this.topRules = n;
this.applyStatusFilter(true);
}
/**
* The rule codes to keep, most-triggered first, or null when every rule is in view.
*
* <p>
* Counted over the period and status already chosen rather than over the whole feed, so "top 10 this month" means
* the ten busiest rules of that month. Ties are broken on the rule code so the same set comes back each time
* instead of shifting between polls.
*/
private topRuleCodes(pool: any[]): Set<string> | null {
if (this.topRules === null) {
return null;
}
const counts = new Map<string, number>();
for (const a of pool) {
const code = a?.ruleCode;
if (code) {
counts.set(code, (counts.get(code) ?? 0) + 1);
}
}
if (counts.size <= this.topRules) {
return null;
}
const ranked = Array.from(counts.entries())
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
.slice(0, this.topRules)
.map(([code]) => code);
return new Set(ranked);
}
/** How many distinct rules the current view holds, so the chip can say what it is narrowing. */
get distinctRuleCount(): number {
return new Set(this.alerts.data.map((a) => a.ruleCode).filter((c) => c)).size;
}
/**
* The rules in view with how often each fired, busiest first.
*
* <p>
* The point of ranking rules is to see which ones are driving the queue, and a count per rule is that answer.
* Without it "Top 10" narrows the table and leaves the officer to work out what it kept.
*/
get ruleFrequency(): { code: string; count: number }[] {
const counts = new Map<string, number>();
for (const a of this.alerts.data) {
if (a?.ruleCode) {
counts.set(a.ruleCode, (counts.get(a.ruleCode) ?? 0) + 1);
}
}
return Array.from(counts.entries())
.map(
([
code,
count
]) => ({ code, count })
)
.sort((a, b) => b.count - a.count || a.code.localeCompare(b.code));
}
setPeriodMode(mode: PeriodMode): void {
this.periodMode = mode;
// Seeded with today the first time a mode is picked, so choosing "Month wise" shows this month rather than an
// empty field the officer has to fill before anything happens.
const now = new Date();
const iso = this.isoOf(now);
if (mode === 'day' && !this.filterDay) {
this.filterDay = iso;
}
if (mode === 'week' && !this.filterWeek) {
this.filterWeek = iso;
}
if (mode === 'month' && !this.filterMonth) {
this.filterMonth = iso.slice(0, 7);
}
if (mode === 'year' && !this.filterYear) {
this.filterYear = String(now.getFullYear());
}
this.applyStatusFilter(true);
}
onPeriodChange(): void {
this.applyStatusFilter(true);
}
/** The years the feed actually covers, newest first, so the picker offers nothing empty. */
get availableYears(): string[] {
const years = new Set<string>();
for (const a of this.allAlerts) {
if (a.raisedOnDate) {
years.add(String(a.raisedOnDate.getFullYear()));
}
}
const list = Array.from(years).sort().reverse();
return list.length ? list : [String(new Date().getFullYear())];
}
/** Reads back the resolved period, so the officer can see what "week wise" actually resolved to. */
get periodLabel(): string {
const range = this.periodRange();
if (!range) {
return '';
}
return range.from === range.to ? range.from : `${range.from} to ${range.to}`;
}
private isoOf(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
/**
* The chosen period as an inclusive from/to, or null for all time.
*
* Week runs Monday to Sunday. Built from local date parts rather than by parsing to UTC: an alert raised late in
* the evening is on that evening's date for the officer looking at it, and a UTC round-trip would move it.
*/
private periodRange(): { from: string; to: string } | null {
switch (this.periodMode) {
case 'day':
return this.filterDay ? { from: this.filterDay, to: this.filterDay } : null;
case 'week': {
if (!this.filterWeek) {
return null;
}
const [
y,
m,
d
] = this.filterWeek.split('-').map(Number);
const picked = new Date(y, m - 1, d);
const backToMonday = picked.getDay() === 0 ? 6 : picked.getDay() - 1;
const monday = new Date(y, m - 1, d - backToMonday);
const sunday = new Date(y, m - 1, d - backToMonday + 6);
return { from: this.isoOf(monday), to: this.isoOf(sunday) };
}
case 'month': {
if (!this.filterMonth) {
return null;
}
const [
y,
m
] = this.filterMonth.split('-').map(Number);
// Day 0 of the next month is the last day of this one, which handles February and leap years without a table.
return { from: `${this.filterMonth}-01`, to: this.isoOf(new Date(y, m, 0)) };
}
case 'year':
return this.filterYear ? { from: `${this.filterYear}-01-01`, to: `${this.filterYear}-12-31` } : null;
default:
return null;
}
}
/** Whether an alert was raised inside the chosen period. */
private inActivePeriod(alert: any): boolean {
const range = this.periodRange();
if (!range) {
return true;
}
const d: Date | null = alert?.raisedOnDate;
if (!d) {
return false;
}
const iso = this.isoOf(d);
return iso >= range.from && iso <= range.to;
}
/**
* The severity tile currently narrowing the table: 1 Low, 2 Medium, 3 High, 0 unclassified, or
* null for all. Independent of {@link activeStatus} — the two compose, so an officer can ask for
* the high-severity alerts that are still open.
*/
activeSeverity: number | null = null;
/**
* Severity mix of the flagged transactions currently in the table. riskCategory is the severity
* of the rule that fired, resolved server-side from the originating catalog entry — the same
* field the Rules Master tab shows in its Risk Category column.
*/
riskSummary: { label: string; count: number; cls: string }[] = [];
riskSummary: { key: number; label: string; count: number; cls: string }[] = [];
/** Alerts whose rule has no catalog ancestor (MANUAL_REFERRAL), so no severity to report. */
unclassifiedCount = 0;
@@ -106,6 +628,18 @@ export class AmlFlaggedTransactionsComponent implements OnInit, OnDestroy {
@ViewChild(MatPaginator) paginator!: MatPaginator;
/**
* Binds the paginator as soon as the view exists.
*
* <p>
* The first poll can return before the view has initialised, and until this the data source was
* only ever wired up as a side effect of a later filter pass — so the table could render every
* alert unpaginated, then jump once the next poll arrived.
*/
ngAfterViewInit(): void {
this.alerts.paginator = this.paginator;
}
ngOnInit(): void {
this.fetch();
timer(AmlFlaggedTransactionsComponent.POLL_INTERVAL_MS, AmlFlaggedTransactionsComponent.POLL_INTERVAL_MS)
@@ -158,7 +692,22 @@ export class AmlFlaggedTransactionsComponent implements OnInit, OnDestroy {
setStatus(key: string): void {
this.activeStatus = key;
this.applyStatusFilter();
this.applyStatusFilter(true);
}
/** Clicking the tile already in force clears it, so the same click both narrows and undoes. */
setSeverity(band: number): void {
this.activeSeverity = this.activeSeverity === band ? null : band;
this.applyStatusFilter(true);
}
isSeverityActive(band: number): boolean {
return this.activeSeverity === band;
}
clearSeverity(): void {
this.activeSeverity = null;
this.applyStatusFilter(true);
}
statusCount(key: string): number {
@@ -203,58 +752,202 @@ export class AmlFlaggedTransactionsComponent implements OnInit, OnDestroy {
this.allAlerts = list;
this.recomputeStatusCounts();
this.applyStatusFilter();
this.pruneSelection();
this.lastUpdated = new Date();
}
private applyStatusFilter(): void {
this.alerts.data = this.activeStatus
? this.allAlerts.filter((a) => a.status === this.activeStatus)
: this.allAlerts;
/**
* @param resetPage
* true only when the user changed a filter. A poll must not reset the page: the feed
* refreshes every few seconds, and an officer reading page 3 would be thrown back to
* page 1 each time. Nor may it touch the paginator before the data source has bound to
* it — doing that on the first pass left pageSize at 0 and emptied the table.
*/
private applyStatusFilter(resetPage = false): void {
// The day narrows everything else: the status chips and the severity tiles are asking "of what is in view", and
// an officer who has picked a day expects their counts to describe that day rather than the whole feed.
const byDay = this.allAlerts.filter((a) => this.inActivePeriod(a));
const byStatus = this.activeStatus ? byDay.filter((a) => this.matchesStatus(a, this.activeStatus)) : byDay;
const bySeverity =
this.activeSeverity === null ? byStatus : byStatus.filter((a) => this.severityBand(a) === this.activeSeverity);
// Ranked last, over what the other filters left: "top 10 this month" is the ten busiest rules of that month, not
// the ten busiest overall then filtered down to a handful.
const keep = this.topRuleCodes(bySeverity);
this.alerts.data = keep ? bySeverity.filter((a) => keep.has(a.ruleCode)) : bySeverity;
if (!this.alerts.paginator && this.paginator) {
this.alerts.paginator = this.paginator;
}
this.recomputeRiskSummary();
if (resetPage && this.alerts.paginator) {
// Otherwise a filter applied while on page 2 leaves the table blank on a one-page result.
this.alerts.paginator.firstPage();
}
// Counted before the severity filter, so every tile keeps its real figure and stays clickable.
// Counting the filtered rows would zero the other tiles the moment one was picked, leaving no
// way back to them.
this.recomputeRiskSummary(byStatus);
}
/**
* Counted over the rows the status filter leaves visible, not the whole set — a summary that
* disagreed with the table underneath it would be worse than no summary.
*/
private recomputeRiskSummary(): void {
const pool = this.alerts.data;
private recomputeRiskSummary(pool: any[]): void {
const tiers = [
{ key: 3, label: 'High Risk', cls: 'risk-high' },
{ key: 2, label: 'Medium Risk', cls: 'risk-medium' },
{ key: 1, label: 'Low Risk', cls: 'risk-low' }
];
this.riskSummary = tiers.map((t) => ({
key: t.key,
label: t.label,
cls: t.cls,
count: pool.filter((a) => Number(a.riskCategory) === t.key).length
count: pool.filter((a) => this.severityBand(a) === t.key).length
}));
// Anything outside 1-3, including null, so the tiles always reconcile to the total.
this.unclassifiedCount = pool.filter(
(a) => ![
1,
2,
3
].includes(Number(a.riskCategory))
).length;
// Band 0 is everything ungraded, so the tiles always reconcile to the total.
this.unclassifiedCount = pool.filter((a) => this.severityBand(a) === 0).length;
}
/**
* Everything the status filter leaves, whatever severity tile is in force — so the tiles still
* add up to the total while one of them is narrowing the table, and the total reads as the way
* back to all of them.
*/
exporting = false;
/**
* Exports exactly what the table is showing.
*
* <p>
* Built from `alerts.data` rather than from the full feed, so the period, status and severity an officer chose are
* the period, status and severity in the file. A workbook that quietly held more than the screen did would be
* worse than none: the figures in it would not reconcile with the report it came from, and an officer sending it
* on has no way to tell.
*
* <p>
* The filters are written into the subtitle and the file name for the same reason — a sheet that says "10 alerts"
* with nothing about which month it covers cannot be checked later by anyone, including the person who made it.
*/
async exportExcel(): Promise<void> {
const rows = this.alerts.data;
if (!rows.length || this.exporting) {
return;
}
this.exporting = true;
try {
const scope = [
this.periodLabel ? `Period: ${this.periodLabel}` : 'Period: all time',
`Status: ${this.activeStatus ? this.statusLabel(this.activeStatus) : 'all'}`,
`Severity: ${this.activeSeverity === null ? 'all' : this.severityLabel(this.activeSeverity)}`,
`Rules: ${this.topRules === null ? 'all' : `top ${this.topRules} by frequency`}`
].join(' · ');
await exportWorkbook(
[
{
sheetName: 'Flagged Transactions',
title: 'AML — Flagged Transactions',
subtitle: `${scope} · ${rows.length} alert${rows.length === 1 ? '' : 's'} · generated ${new Date().toLocaleString('en-IN')}`,
headers: [
'Alert ID',
'Raised On',
'Customer',
'Customer ID',
'Transaction',
'Txn ID',
'Governing Rule',
'Rule Description',
'All Rules Matched',
'Severity',
'Amount',
'Status',
'Reviewed By',
'Review Notes'
],
rows: rows.map((a) => [
a.id ?? null,
a.raisedOnDate ? a.raisedOnDate.toLocaleString('en-IN') : '',
a.clientDisplayName || '',
a.clientId ?? null,
a.transactionType || '',
a.transactionId ?? null,
a.ruleCode || '',
a.ruleDescription || '',
// Every rule the transaction tripped, not only the one it reports under: the governing rule alone can
// hide a high-severity finding that a reader of the sheet would need.
this.matchedRules(a)
.map((r) => r.ruleCode)
.join(', '),
this.severityLabel(this.severityBand(a)),
Number(a.amount) || 0,
a.status || '',
a.reviewedByUsername || '',
a.reviewNotes || ''
]),
widths: [
9,
20,
24,
12,
12,
10,
30,
38,
44,
11,
15,
14,
14,
50
]
}
],
`AML-Flagged-${this.periodLabel || 'all-time'}`
);
} finally {
this.exporting = false;
}
}
private statusLabel(key: string): string {
return this.statusFilters.find((s) => s.key === key)?.label ?? key;
}
get summaryTotal(): number {
return this.alerts.data.length;
const pool = this.allAlerts.filter((a) => this.inActivePeriod(a));
return this.activeStatus ? pool.filter((a) => this.matchesStatus(a, this.activeStatus)).length : pool.length;
}
private recomputeStatusCounts(): void {
this.statusCounts = { '': this.allAlerts.length };
// Counted within the chosen day, so "Escalated 1" means one escalated that day rather than one in the whole feed.
// A chip whose figure describes a different set from the table under it is worse than no figure.
const pool = this.allAlerts.filter((a) => this.inActivePeriod(a));
this.statusCounts = { '': pool.length };
for (const s of this.statusFilters) {
if (s.key) {
this.statusCounts[s.key] = this.allAlerts.filter((a) => a.status === s.key).length;
this.statusCounts[s.key] = pool.filter((a) => this.matchesStatus(a, s.key)).length;
}
}
}
/**
* Whether an alert belongs under a status chip.
*
* Not an equality test, deliberately. Matching the chip's key exactly meant any status the chip list did not name
* belonged to no chip at all while still counting toward "All alerts" — so an alert could sit in the feed, need a
* decision, and appear nowhere an officer looks for work. That is what UNDER_REVIEW did here: five undecided
* alerts, the largest ₹18,00,000, behind a pending figure that read 1.
*
* Pending is therefore defined by what has not been decided rather than by one status name. Any status that is
* neither whitelisted nor escalated surfaces under Raised on its own, which is the safe direction to fail — an
* alert shown as work that turns out to be finished costs a glance; one hidden costs a filing.
*/
private matchesStatus(alert: any, key: string): boolean {
if (key === 'RAISED') {
return alert?.status !== 'WHITELISTED' && alert?.status !== 'ESCALATED';
}
return alert?.status === key;
}
statusClass(status: string): string {
return 'alert-status status-' + (status || 'raised').toLowerCase();
}
@@ -302,7 +995,26 @@ export class AmlFlaggedTransactionsComponent implements OnInit, OnDestroy {
* opening a second STR case — this gate is what prevents it.
*/
canReview(alert: any): boolean {
return alert?.status === 'RAISED' || alert?.status === 'UNDER_REVIEW';
if (alert?.status !== 'RAISED' && alert?.status !== 'UNDER_REVIEW') {
return false;
}
// An alert another officer has open is theirs until they decide it or hand it back. The endpoint refuses it
// anyway; hiding the buttons is what stops an officer working a case they will not be allowed to close.
return !this.heldByAnother(alert);
}
/** True when this alert is under review by somebody other than the signed-in officer. */
heldByAnother(alert: any): boolean {
return (
alert?.status === 'UNDER_REVIEW' &&
!!alert?.reviewedByUsername &&
alert.reviewedByUsername !== this.currentUsername
);
}
/** Who has the alert, for the message shown in place of the action buttons. */
heldBy(alert: any): string {
return alert?.reviewedByUsername || 'another officer';
}
review(alert: any, disposition: 'WHITELIST' | 'ESCALATE'): void {
@@ -330,4 +1042,73 @@ export class AmlFlaggedTransactionsComponent implements OnInit, OnDestroy {
this.fetch();
});
}
/**
* Applies one disposition to every ticked alert.
*
* <p>
* Each still goes through the review dialog's own requirements - a reason, and for a whitelist
* the evidence behind it - asked once and recorded against every alert in the batch. That is the
* point of the batch: the officer is making one decision about one kind of activity, not the same
* decision twenty times.
*/
/**
* Applies one disposition to the rules ticked inside the open pane.
*
* <p>
* The alert's own status is left to the server, which derives it from what remains: whitelisting five of six rules
* leaves the case open on the sixth rather than closing it.
*/
reviewSelectedRules(alert: any, disposition: 'WHITELIST' | 'ESCALATE'): void {
const codes = Array.from(this.selectedRuleCodes);
if (!codes.length) {
return;
}
this.dialog
.open(AmlReviewAlertDialogComponent, {
width: '560px',
maxWidth: '95vw',
autoFocus: false,
disableClose: true,
data: { alert, disposition, ruleCodes: codes }
})
.afterClosed()
.subscribe((result: string | undefined) => {
if (!result) {
return;
}
this.alertService.alert({
type: disposition === 'WHITELIST' ? 'Rules Whitelisted' : 'Rules Escalated',
message: `${codes.length} rule${codes.length > 1 ? 's' : ''} on ${alert.transactionType} #${alert.transactionId} ${
disposition === 'WHITELIST' ? 'cleared' : 'escalated'
}.`
});
this.selectedRuleCodes.clear();
// The alert's status may have changed with the last rule decided, so re-read rather than patching locally.
this.fetch();
});
}
reviewSelected(disposition: 'WHITELIST' | 'ESCALATE'): void {
const batch = this.selectedAlerts();
if (!batch.length) {
return;
}
this.dialog
.open(AmlBulkReviewDialogComponent, {
width: '620px',
maxWidth: '95vw',
autoFocus: false,
disableClose: true,
data: { alerts: batch, disposition }
})
.afterClosed()
.subscribe((done: boolean | undefined) => {
if (!done) {
return;
}
this.selectedIds.clear();
this.fetch();
});
}
}
@@ -31,6 +31,11 @@ import { AmlService } from '../../../aml/aml.service';
export interface AmlReviewDialogData {
alert: any;
disposition: 'WHITELIST' | 'ESCALATE';
/**
* The rules being decided, when the officer picked some inside the expanded pane rather than acting on the whole
* transaction. Absent means the decision covers the alert and every rule under it.
*/
ruleCodes?: string[];
}
/**
@@ -162,6 +167,13 @@ export class AmlReviewAlertDialogComponent {
}
get title(): string {
// Named differently when only some of the transaction's rules are being decided, so the officer is not told they
// are clearing a transaction when five of its six rules stay open.
const n = this.data.ruleCodes?.length ?? 0;
if (n > 0) {
const subject = n === 1 ? 'Rule' : `${n} Rules`;
return this.isWhitelist ? `Whitelist ${subject}` : `Mark ${subject} as Suspicious`;
}
return this.isWhitelist ? 'Whitelist Transaction' : 'Mark as Suspicious';
}
@@ -181,7 +193,17 @@ export class AmlReviewAlertDialogComponent {
this.submitting = true;
this.errorMessage = '';
this.amlService.reviewAlert(this.data.alert.id, this.data.disposition, this.composedNotes).subscribe({
// Deciding named rules within the alert rather than the whole alert, when the caller passed some. The evidence
// requirements are the same either way, which is why this dialog serves both.
const request = this.data.ruleCodes?.length
? this.amlService.reviewAlertRules(
this.data.alert.id,
this.data.ruleCodes,
this.data.disposition,
this.composedNotes
)
: this.amlService.reviewAlert(this.data.alert.id, this.data.disposition, this.composedNotes);
request.subscribe({
next: () => {
this.submitting = false;
this.dialogRef.close(this.data.disposition);
@@ -93,6 +93,11 @@ const routes: Routes = [
loadComponent: () =>
import('../tds-reports/tds-remittance-report.component').then((m) => m.TdsRemittanceReportComponent)
},
{
path: 'bor',
data: { title: 'BOR Register — Share Capital', breadcrumb: 'BOR Register' },
loadComponent: () => import('../board-resolution/bor-report.component').then((m) => m.BorReportComponent)
},
{
path: ':filter',
data: { routeParamBreadcrumb: 'filter' },
@@ -0,0 +1,401 @@
/**
* 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, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MAT_DIALOG_DATA, MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { AmlService } from 'app/aml/aml.service';
const CATEGORY_LABEL: Record<number, string> = { 1: 'Low', 2: 'Medium', 3: 'High' };
/**
* The approval queue for risk-category changes raised from the Risk Review Report.
*
* <p>
* Approving here moves the customer's KYC rating and their re-KYC due date together, so the row
* shows where the rating is going from and to, and why the reviewer asked - the checker is deciding
* how closely the bank watches that customer, not clearing a queue item.
*/
@Component({
selector: 'mifosx-risk-change-approvals',
standalone: true,
imports: [
CommonModule,
MatButtonModule,
MatDialogModule,
MatSnackBarModule
],
template: `
<div class="rca-root">
<div class="rca-head">
<div>
<h2 class="rca-title">Risk Category Approvals</h2>
<p class="rca-sub">
Changes to a customer's KYC risk category, raised from the Risk Review Report. Approving moves the rating
and the re-KYC due date together, and writes the reason into the customer's risk history.
</p>
</div>
</div>
<div class="rca-filters">
<button
*ngFor="let s of statuses"
type="button"
class="rca-chip"
[class.rca-chip--on]="selectedStatus === s"
(click)="filterBy(s)"
>
{{ s === '' ? 'All' : s }} <span class="rca-count">({{ countOf(s) }})</span>
</button>
</div>
<div class="rca-table-wrap">
<table class="rca-table">
<thead>
<tr>
<th>Req. ID</th>
<th>Customer</th>
<th>CIF</th>
<th>Branch</th>
<th>Rating change</th>
<th>Reason</th>
<th>Status</th>
<th>Requested By</th>
<th>Requested On</th>
<th>Action</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let r of filtered">
<td class="mono">{{ r.id }}</td>
<td>
<strong>{{ r.clientName }}</strong>
</td>
<td class="mono">{{ r.cifNumber || '—' }}</td>
<td>{{ r.officeName || '—' }}</td>
<td>
<span class="rca-move">
{{ labelOf(r.currentCategory) }} <span class="rca-arrow">&rarr;</span>
<strong>{{ labelOf(r.requestedCategory) }}</strong>
</span>
</td>
<td class="rca-reason">{{ r.reason }}</td>
<td>
<span class="rca-status" [ngClass]="'rca-status--' + (r.status || '').toLowerCase()">{{
r.status
}}</span>
</td>
<td>{{ r.makerUsername }}</td>
<td class="mono">{{ r.createdDate }}</td>
<td>
<div class="rca-actions" *ngIf="r.status === 'PENDING'">
<button mat-flat-button color="primary" type="button" (click)="approve(r)">Approve</button>
<button mat-stroked-button color="warn" type="button" (click)="reject(r)">Reject</button>
</div>
<span class="rca-done" *ngIf="r.status === 'APPROVED'">
by {{ r.checkerUsername }}, {{ r.checkerDate }}
</span>
<span class="rca-done" *ngIf="r.status === 'REJECTED'">{{ r.rejectionReason }}</span>
</td>
</tr>
<tr *ngIf="!filtered.length">
<td colspan="10" class="rca-empty">No risk category change matches this filter.</td>
</tr>
</tbody>
</table>
</div>
</div>
`,
styles: [
`
.rca-root {
padding: 20px 24px 40px;
background: #f8fafc;
min-height: 100%;
}
.rca-head {
margin-bottom: 16px;
}
.rca-title {
margin: 0 0 3px;
font-size: 20px;
font-weight: 700;
color: #1e293b;
}
.rca-sub {
margin: 0;
max-width: 800px;
font-size: 13px;
line-height: 1.5;
color: #64748b;
}
.rca-filters {
display: flex;
gap: 8px;
margin-bottom: 14px;
flex-wrap: wrap;
}
.rca-chip {
height: 32px;
padding: 0 14px;
border: 1px solid #cbd5e1;
border-radius: 999px;
background: #fff;
font: inherit;
font-size: 12.5px;
font-weight: 600;
color: #334155;
cursor: pointer;
}
.rca-chip--on {
border-color: #1e3a8a;
background: #eff6ff;
color: #1e3a8a;
}
.rca-count {
color: #94a3b8;
font-weight: 500;
}
.rca-table-wrap {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 11px;
overflow-x: auto;
}
.rca-table {
width: 100%;
border-collapse: collapse;
font-size: 12.5px;
}
.rca-table 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;
}
.rca-table td {
padding: 11px 10px;
border-bottom: 1px solid #f1f5f9;
vertical-align: middle;
color: #334155;
}
.mono {
font-family: 'DM Mono', monospace;
}
.rca-move {
white-space: nowrap;
}
.rca-arrow {
color: #94a3b8;
margin: 0 4px;
}
.rca-reason {
max-width: 280px;
}
.rca-status {
display: inline-block;
padding: 2px 9px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
background: #f1f5f9;
color: #475569;
}
.rca-status--pending {
background: #fef3c7;
color: #b45309;
}
.rca-status--approved {
background: #dcfce7;
color: #15803d;
}
.rca-status--rejected {
background: #fee2e2;
color: #b91c1c;
}
.rca-actions {
display: flex;
gap: 8px;
}
.rca-done {
font-size: 11.5px;
color: #64748b;
}
.rca-empty {
padding: 30px;
text-align: center;
color: #94a3b8;
}
`
]
})
export class RiskChangeApprovalsComponent implements OnInit {
private amlService = inject(AmlService);
private dialog = inject(MatDialog);
private snackBar = inject(MatSnackBar);
rows: any[] = [];
readonly statuses = [
'PENDING',
'APPROVED',
'REJECTED',
''
];
selectedStatus = 'PENDING';
ngOnInit(): void {
this.load();
}
load(): void {
this.amlService.getRiskChangeRequests().subscribe({
next: (rows) => (this.rows = rows || []),
error: (err: any) =>
this.snackBar.open('Could not load requests: ' + (err?.error?.defaultUserMessage || err.message), 'Close', {
duration: 5000
})
});
}
get filtered(): any[] {
return this.selectedStatus ? this.rows.filter((r) => r.status === this.selectedStatus) : this.rows;
}
countOf(status: string): number {
return status ? this.rows.filter((r) => r.status === status).length : this.rows.length;
}
filterBy(status: string): void {
this.selectedStatus = status;
}
labelOf(category: number | null): string {
return category == null ? 'Unrated' : CATEGORY_LABEL[category] || '—';
}
approve(r: any): void {
this.amlService.approveRiskChange(r.id).subscribe({
next: () => {
this.snackBar.open(
`${r.clientName} moved to ${this.labelOf(r.requestedCategory)} — re-KYC due date updated.`,
'Close',
{ duration: 6000 }
);
this.load();
},
error: (err: any) =>
this.snackBar.open('Error: ' + (err?.error?.defaultUserMessage || err.message), 'Close', { duration: 7000 })
});
}
reject(r: any): void {
this.dialog
.open(RejectRiskChangeDialogComponent, { width: '450px', data: { request: r }, disableClose: true })
.afterClosed()
.subscribe((done) => {
if (done) {
this.load();
}
});
}
}
// ── REJECT DIALOG ──
@Component({
selector: 'mifosx-reject-risk-change-dialog',
standalone: true,
imports: [
CommonModule,
ReactiveFormsModule,
MatDialogModule,
MatFormFieldModule,
MatInputModule,
MatButtonModule
],
template: `
<h2 mat-dialog-title>Reject Risk Category Change</h2>
<mat-dialog-content>
<p class="hint">
{{ data.request.clientName }} stays on their current rating. The reason is shown to the reviewer who raised it.
</p>
<form [formGroup]="form">
<mat-form-field appearance="outline" class="full">
<mat-label>Rejection reason</mat-label>
<textarea matInput formControlName="rejectionReason" rows="3"></textarea>
<mat-error *ngIf="form.get('rejectionReason')?.hasError('required')">A reason is required</mat-error>
</mat-form-field>
</form>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-stroked-button type="button" (click)="dialogRef.close(false)">Cancel</button>
<button mat-flat-button color="warn" type="button" [disabled]="form.invalid || busy" (click)="submit()">
Reject
</button>
</mat-dialog-actions>
`,
styles: [
`
.full {
width: 100%;
}
.hint {
font-size: 13px;
color: #64748b;
margin: 0 0 12px;
line-height: 1.5;
}
`
]
})
export class RejectRiskChangeDialogComponent {
private fb = inject(FormBuilder);
private amlService = inject(AmlService);
private snackBar = inject(MatSnackBar);
dialogRef = inject(MatDialogRef<RejectRiskChangeDialogComponent>);
data: { request: any } = inject(MAT_DIALOG_DATA);
form: FormGroup;
busy = false;
constructor() {
this.form = this.fb.group({ rejectionReason: [
'',
Validators.required
] });
}
submit(): void {
if (this.form.invalid) {
return;
}
this.busy = true;
this.amlService.rejectRiskChange(this.data.request.id, this.form.value.rejectionReason).subscribe({
next: () => {
this.busy = false;
this.snackBar.open('Risk category change rejected', 'Close', { duration: 3000 });
this.dialogRef.close(true);
},
error: (err: any) => {
this.busy = false;
this.snackBar.open('Error: ' + (err?.error?.defaultUserMessage || err.message), 'Close', { duration: 6000 });
}
});
}
}
@@ -0,0 +1,222 @@
/**
* 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, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { MatSnackBar } from '@angular/material/snack-bar';
import { AmlService } from 'app/aml/aml.service';
export interface RiskChangeDialogData {
clientId: number;
customerName: string;
/** 1 Low, 2 Medium, 3 High; null where the customer carries no rating yet. */
currentCategory: number | null;
/** What the report's own computation says the rating should be, used as the default. */
recommendedCategory: number | null;
/** The one-line explanation the report already shows against the row. */
recommendationReason: string;
}
/**
* Raises a change to a customer's KYC risk category for approval.
*
* <p>
* The rating governs how often the customer is re-KYC'd and what AML thresholds they are measured
* against, so it is not something one officer moves on their own. The dialog collects what it
* should become and why; a checker applies it.
*/
@Component({
selector: 'mifosx-risk-change-dialog',
standalone: true,
imports: [
CommonModule,
ReactiveFormsModule,
MatDialogModule,
MatButtonModule,
MatFormFieldModule,
MatInputModule,
MatSelectModule
],
template: `
<h2 mat-dialog-title>Change Risk Category</h2>
<mat-dialog-content>
<div class="rc-summary">
<div class="rc-row">
<span class="rc-label">Customer</span>
<strong class="rc-value">{{ data.customerName }}</strong>
</div>
<div class="rc-row">
<span class="rc-label">Rating on file</span>
<strong class="rc-value">{{ labelOf(data.currentCategory) }}</strong>
</div>
</div>
<!-- The report already worked this out; showing it saves the reviewer re-deriving it, and
says plainly that the figure is a recommendation rather than a decision. -->
<p class="rc-reason" *ngIf="data.recommendationReason">
<span class="rc-label">Why this came up</span><br />{{ data.recommendationReason }}
</p>
<form [formGroup]="form" class="rc-form">
<mat-form-field appearance="outline" class="rc-full">
<mat-label>New risk category</mat-label>
<mat-select formControlName="requestedCategory">
<mat-option [value]="1">Low</mat-option>
<mat-option [value]="2">Medium</mat-option>
<mat-option [value]="3">High</mat-option>
</mat-select>
<mat-error *ngIf="form.get('requestedCategory')?.hasError('required')">Pick the new category</mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="rc-full">
<mat-label>Reason</mat-label>
<textarea
matInput
formControlName="reason"
rows="3"
placeholder="e.g. Repeated large cash deposits inconsistent with declared income"
></textarea>
<mat-hint>Recorded against the customer's risk history</mat-hint>
<mat-error *ngIf="form.get('reason')?.hasError('required')">A reason is required</mat-error>
</mat-form-field>
</form>
<p class="rc-effect">
The rating does not move now. It goes to a checker, and on approval the customer's KYC record and re-KYC due
date are updated together.
</p>
<p class="rc-error" *ngIf="errorMessage">{{ errorMessage }}</p>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-stroked-button type="button" (click)="dialogRef.close(false)">Cancel</button>
<button mat-flat-button color="primary" type="button" [disabled]="form.invalid || submitting" (click)="submit()">
{{ submitting ? 'Sending…' : 'Send for Approval' }}
</button>
</mat-dialog-actions>
`,
styles: [
`
.rc-summary {
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 12px 14px;
margin-bottom: 14px;
background: #f8fafc;
}
.rc-row {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 16px;
padding: 3px 0;
}
.rc-label {
color: #64748b;
font-size: 12.5px;
}
.rc-value {
color: #1e293b;
font-size: 13.5px;
}
.rc-reason {
font-size: 12.5px;
line-height: 1.5;
color: #334155;
margin: 0 0 14px;
}
.rc-form {
display: flex;
flex-direction: column;
}
.rc-full {
width: 100%;
}
.rc-effect {
font-size: 12.5px;
line-height: 1.55;
color: #1e3a8a;
background: #eff6ff;
border: 1px solid #bfdbfe;
border-radius: 6px;
padding: 10px 12px;
margin: 4px 0 0;
}
.rc-error {
margin: 12px 0 0;
padding: 10px 12px;
border: 1px solid #fecaca;
border-radius: 6px;
background: #fef2f2;
color: #b91c1c;
font-size: 12.5px;
}
`
]
})
export class RiskChangeDialogComponent {
private fb = inject(FormBuilder);
private amlService = inject(AmlService);
private snackBar = inject(MatSnackBar);
dialogRef = inject(MatDialogRef<RiskChangeDialogComponent>);
form: FormGroup;
submitting = false;
errorMessage = '';
constructor(@Inject(MAT_DIALOG_DATA) public data: RiskChangeDialogData) {
this.form = this.fb.group({
// Defaulted to what the report recommends, but left editable: the reviewer has seen the
// customer and the computation has not.
requestedCategory: [
data.recommendedCategory ?? null,
Validators.required
],
reason: [
'',
Validators.required
]
});
}
labelOf(category: number | null): string {
return category === 1 ? 'Low' : category === 2 ? 'Medium' : category === 3 ? 'High' : 'Unrated';
}
submit(): void {
if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}
this.submitting = true;
this.errorMessage = '';
const v = this.form.value;
this.amlService.requestRiskChange(this.data.clientId, v.requestedCategory, (v.reason || '').trim()).subscribe({
next: () => {
this.submitting = false;
this.snackBar.open(
`Risk change to ${this.labelOf(v.requestedCategory)} sent for approval — ${this.data.customerName}`,
'Close',
{ duration: 5000 }
);
this.dialogRef.close(true);
},
error: (err: any) => {
this.submitting = false;
this.errorMessage = err?.error?.defaultUserMessage || err?.error?.message || 'Could not send the request.';
}
});
}
}
+20 -4
View File
@@ -372,13 +372,29 @@ export const MATERIALITY = {
* Rules whose seriousness does not depend on the amount. A sanctions match or a
* deliberate structuring pattern is grave at any value, so activity being small
* is not a reason to bring the rating down.
*
* Matched as prefixes, not as whole codes. The rule set names these by variant —
* STRUCTURING_BELOW_50K, STRUCTURING_BELOW_10L, PEP_ACCOUNT_MONITORING — so an
* exact-match Set never matched any of them and the protection was dead: a
* customer caught structuring could be recommended for downgrade purely because
* the amounts involved were small, which is the one case structuring exists to
* catch.
*/
export const NON_MITIGABLE_RULES = new Set([
'SANCTIONS_HIT',
export const NON_MITIGABLE_RULE_PREFIXES = [
'SANCTIONS',
'PEP',
'STRUCTURING',
'HIGH_RISK_COUNTRY'
]);
'STRUCTURED',
'HIGH_RISK_COUNTRY',
'ADVERSE_MEDIA',
'WILFUL_DEFAULTER'
];
/** Whether a rule code names activity that stays grave however small the amounts are. */
export function isNonMitigableRule(ruleCode: string): boolean {
const code = (ruleCode || '').toUpperCase();
return NON_MITIGABLE_RULE_PREFIXES.some((prefix) => code.startsWith(prefix));
}
/** Shape of one element of GET /aml/alerts. */
export interface LiveAmlAlert {
@@ -304,7 +304,9 @@
reason sits under it rather than under the button, because it explains
this conclusion and not the act of opening the KYC screen. -->
<td class="rr-move-cell">
<span class="rr-move" [class]="'rr-move--' + c.direction">
<!-- An unrated customer is its own state: no rating exists, so "same" would be claiming the
rating on file matches the behaviour when there is no rating on file. -->
<span class="rr-move" [class]="'rr-move--' + (c.currentRisk ? c.direction : 'unrated')">
<fa-icon [icon]="directionIcon(c)"></fa-icon>
{{ directionText(c) }}
</span>
@@ -323,15 +325,32 @@
<!-- Only the control. The verdict and its reason moved to their own column,
so this no longer wraps to two lines under the button. -->
<td class="rr-action-cell">
<button
type="button"
class="rr-kyc-link"
[class.rr-kyc-link--act]="c.direction !== 'same' && !c.escalated"
(click)="openKyc(c, $event)"
[attr.aria-label]="kycActionText(c) + ' for ' + c.customerName"
>
<fa-icon icon="id-card"></fa-icon> {{ kycActionText(c) }}
</button>
<!-- Raises the change for approval rather than sending the reviewer to the
KYC screen to make it themselves. A case with the Principal Officer is
held where it is until the STR concludes, so it only offers the record. -->
@if (canChangeRisk(c)) {
<button
type="button"
class="rr-kyc-link"
[class.rr-kyc-link--act]="c.direction !== 'same'"
(click)="changeRisk(c, $event)"
[attr.aria-label]="'Change risk category for ' + c.customerName"
>
<fa-icon icon="shield-alt"></fa-icon> Change Risk
</button>
} @else {
<!-- The rating cannot move while the STR is open, so the case itself is
what this row can usefully offer. -->
<button
type="button"
class="rr-kyc-link"
(click)="openStrCase(c, $event)"
title="The rating is held until the Principal Officer concludes the STR — open the case"
[attr.aria-label]="'Open the STR case for ' + c.customerName"
>
<fa-icon icon="exclamation-triangle"></fa-icon> Open STR Case
</button>
}
</td>
</tr>
@@ -937,6 +937,13 @@
background: var(--r-g100);
color: var(--r-g500);
}
/* An unrated customer reads neither as settled nor as a movement: there is no rating to move. Amber says the row
is outstanding without claiming the customer is a concern — the gap is the bank's, not theirs. */
&--unrated {
background: var(--r-amber-l, #fef3c7);
color: var(--r-amber, #b45309);
}
}
.rr-move-why {
@@ -10,6 +10,7 @@
import { Component, OnInit, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { MatDialog } from '@angular/material/dialog';
import { forkJoin, of } from 'rxjs';
import { catchError, map } from 'rxjs/operators';
@@ -38,12 +39,14 @@ import {
RiskLevel,
channelLabel,
mapLiveAlert,
riskFromCategory
riskFromCategory,
riskRank
} from './risk-review-data';
import {
DateRange,
RatingDirection,
RiskAlertView,
BehaviourWindowByClient,
RiskCustomerRow,
RiskReviewFilter,
RiskReviewModel,
@@ -53,6 +56,7 @@ import {
monthRange,
weekRange
} from './risk-review.compute';
import { RiskChangeDialogComponent, RiskChangeDialogData } from './risk-change-dialog.component';
type PeriodMode = 'day' | 'week' | 'month' | 'range';
type ViewMode = 'customer' | 'alert' | 'txn';
@@ -109,6 +113,7 @@ export class RiskReviewReportComponent implements OnInit {
private clients = inject(ClientsService);
private scope = inject(BranchScopeService);
private router = inject(Router);
private dialog = inject(MatDialog);
readonly period = GL_PERIOD;
readonly branches = GL_BRANCHES;
@@ -181,21 +186,37 @@ export class RiskReviewReportComponent implements OnInit {
// ── Loading ────────────────────────────────────────────────────────────────
/**
* The nightly job's verdict per customer, keyed on client id.
*
* Empty until the first load, and stays empty on a tenant where the job has not run - the report then falls back to
* its own per-alert reckoning rather than showing nothing.
*/
private behaviourWindows: BehaviourWindowByClient = {};
load(): void {
this.loading = true;
this.aml
.getAlerts()
.pipe(catchError(() => of([] as LiveAmlAlert[])))
.subscribe((live) => {
const rows = (live || []).map((a) => mapLiveAlert(a as unknown as LiveAmlAlert));
this.liveAlerts = rows;
this.compose();
if (!rows.length) {
this.loading = false;
return;
// Both in one pass: the windows decide what each row recommends, so composing before they arrive would show a
// recommendation the screen then silently replaced.
forkJoin({
alerts: this.aml.getAlerts().pipe(catchError(() => of([] as LiveAmlAlert[]))),
windows: this.aml.getBehaviourWindows().pipe(catchError(() => of([] as any[])))
}).subscribe(({ alerts: live, windows }) => {
this.behaviourWindows = {};
for (const w of windows || []) {
if (w?.clientId != null) {
this.behaviourWindows[w.clientId] = w;
}
this.enrichCurrentRisk(rows);
});
}
const rows = (live || []).map((a) => mapLiveAlert(a as unknown as LiveAmlAlert));
this.liveAlerts = rows;
this.compose();
if (!rows.length) {
this.loading = false;
return;
}
this.enrichCurrentRisk(rows);
});
}
/**
@@ -243,7 +264,12 @@ export class RiskReviewReportComponent implements OnInit {
)
)
).subscribe((results) => {
const byClient = new Map(results.map((r) => [r.id, r]));
const byClient = new Map(
results.map((r) => [
r.id,
r
])
);
this.liveAlerts = rows.map((r) => {
const info = byClient.get(r.clientId);
return {
@@ -336,9 +362,11 @@ export class RiskReviewReportComponent implements OnInit {
private memo: RiskReviewModel = { alerts: [], customers: [], txns: [], summary: EMPTY_SUMMARY };
private get model(): RiskReviewModel {
const key = JSON.stringify(this.filter) + `|${this.alerts.length}`;
// The window count belongs in the key: the windows decide what each row recommends, and they arrive in the same
// load as the alerts. Keying on the alerts alone would serve the memo computed before they landed.
const key = JSON.stringify(this.filter) + `|${this.alerts.length}|${Object.keys(this.behaviourWindows).length}`;
if (key !== this.memoKey) {
this.memo = buildRiskReview(this.alerts, this.filter);
this.memo = buildRiskReview(this.alerts, this.filter, this.behaviourWindows);
this.memoKey = key;
}
return this.memo;
@@ -364,10 +392,63 @@ export class RiskReviewReportComponent implements OnInit {
* a regulatory clock, an under-rated customer is the real compliance gap, an over-rated one is
* only a correction, and matching ratings need nothing at all.
*/
/**
* A report row for a customer the behaviour review named but no alert brought onto the report.
*
* <p>
* The alert-derived fields are empty because there are none in the window, and that is the point being shown: the
* case rests on the period, not on anything that fired. The figures the verdict was drawn from travel on
* behaviourWindow, so the row can still explain itself.
*/
private rowFromWindow(w: any): RiskCustomerRow {
const RANK: Record<number, RiskLevel> = { 1: 'LOW', 2: 'MEDIUM', 3: 'HIGH' };
const current = RANK[w.riskCategoryAtReview] ?? null;
const recommended = RANK[w.suggestedCategory] ?? current ?? 'LOW';
return {
clientId: w.clientId,
customerName: w.clientName || `Client ${w.clientId}`,
accountNo: w.accountNo || '',
branchCode: w.officeName || '',
currentRisk: current,
alerts: [],
alertCount: 0,
totalAmount: Number(w.creditTotal || 0) + Number(w.debitTotal || 0),
largestAmount: Number(w.largestTxn || 0),
proposedRisk: recommended,
recommendedRisk: recommended,
direction: w.suggestedDirection === 'UP' ? 'up' : w.suggestedDirection === 'DOWN' ? 'down' : 'same',
directionReason: w.suggestionReason || '',
ruleLabels: [],
firstAlertDate: '',
lastAlertDate: '',
openCount: 0,
escalated: false,
needsUpgrade: w.suggestedDirection === 'UP',
needsDowngrade: w.suggestedDirection === 'DOWN',
behaviourWindow: w
};
}
get customerGroups(): RiskCustomerGroup[] {
const rows = this.customerRows;
const escalated = rows.filter((c) => c.escalated);
const rest = rows.filter((c) => !c.escalated);
// An escalated customer whose rating still has to move belongs with the work, not with the cases that are waiting
// on someone else. An open STR holds a rating at High, and if the customer is not yet at High that is a change
// somebody has to apply - filing the STR does not make it. Only a customer whose rating already matches sits under
// the Principal Officer with nothing to do.
const escalated = rows.filter((c) => c.escalated && c.direction === 'same');
const rest = rows.filter((c) => !(c.escalated && c.direction === 'same'));
// Customers the nightly review says to move who raised no alert inside the filter window.
//
// The report is built from alerts, so a customer appears only once a rule has fired on them recently. That is the
// wrong test for a downgrade: the whole case for lowering a rating is that nothing has fired for a year, so the
// customer with the strongest case is precisely the one the alert feed has stopped mentioning. Without this they
// would sit in the behaviour table with a verdict nobody ever sees.
const onReport = new Set(rows.map((r) => r.clientId));
const offReport: RiskCustomerRow[] = Object.values(this.behaviourWindows)
.filter((w: any) => w?.clientId != null && !onReport.has(w.clientId))
.filter((w: any) => w.suggestedDirection === 'UP' || w.suggestedDirection === 'DOWN')
.map((w: any) => this.rowFromWindow(w));
const groups: RiskCustomerGroup[] = [
{
@@ -378,25 +459,42 @@ export class RiskReviewReportComponent implements OnInit {
rows: escalated
},
{
key: 'up',
title: 'Rating needs raising',
hint: 'The rules point higher than the rating on file. These are under-rated customers — the real gap.',
// Raising and lowering are one section, not two. The heading answers "is there anything to do about this
// customer", and which way the rating moves is already on every row - as a verdict, an arrow and a reason.
// Splitting them put the same answer in two places and left an officer reading two lists to find one day's
// work. Raises sort first within the section, because an under-rated customer is the more urgent gap.
key: 'change',
title: 'Change needed',
hint: 'Every condition for moving the rating is met. Apply the change; it goes to a checker for approval.',
tone: 'up',
rows: rest.filter((c) => c.direction === 'up')
rows: [
...rest.filter((c) => c.currentRisk && c.direction === 'up'),
...offReport.filter((c) => c.direction === 'up'),
...rest.filter((c) => c.currentRisk && c.direction === 'down'),
...offReport.filter((c) => c.direction === 'down')
]
},
{
key: 'down',
title: 'Rating can be lowered',
hint: 'Activity no longer supports the rating on file.',
tone: 'down',
rows: rest.filter((c) => c.direction === 'down')
// Its own section, and above the watching list, because it is the opposite of that. An unrated customer whose
// money is moving has no rating for the behaviour to be compared against, so nothing here can say whether it
// is in keeping - the work is to give them one, and grouping them with the customers nothing need be done
// about buried the one action the report was asking for.
key: 'unrated',
title: 'Update risk',
hint: 'These customers are transacting with no rating on file. Nothing can be judged against a rating that does not exist — rate them first.',
tone: 'up',
rows: rest.filter((c) => !c.currentRisk)
},
{
// Everything an alert put on the report that the policy will not move yet. "No change needed" read as though
// these customers were fine, which is not what it means: a customer with four open alerts sits here, and the
// reason is that the period does not yet carry enough to act on - not that there is nothing to look at.
// "Watching" says what the section is for, and the reason on each row says which condition fell short.
key: 'same',
title: 'No change needed',
hint: 'The rating on file already matches the behaviour. Listed for the record.',
title: 'Watching — not enough to act on',
hint: 'In view, but the period does not yet support moving the rating. The reason on each row says what is missing.',
tone: 'hold',
rows: rest.filter((c) => c.direction === 'same')
rows: rest.filter((c) => c.currentRisk && c.direction === 'same')
}
];
@@ -575,6 +673,67 @@ export class RiskReviewReportComponent implements OnInit {
]);
}
/**
* Sends a case that is with the Principal Officer to the STR queue.
*
* The rating cannot be moved while an STR is open, so the useful thing to offer is the case
* itself. Sending the reviewer to the customer's KYC record instead made them land on a screen
* where the one action they might take is the one thing they must not do yet.
*/
openStrCase(c: RiskCustomerRow, event: Event): void {
event.stopPropagation();
this.router.navigate(['/fiu'], { queryParams: { tab: 'str' } });
}
/**
* Whether this row's rating can be moved from here.
*
* A case with the Principal Officer is deliberately excluded: the rating is held where it is
* until the STR concludes, which is what the section heading on that group says. Offering the
* button there would invite a change nobody is in a position to make yet.
*/
canChangeRisk(c: RiskCustomerRow): boolean {
return !c.escalated;
}
/**
* Raises a rating change for approval, rather than sending the reviewer to the KYC screen to make
* it themselves.
*
* The button used to navigate away: the reviewer left the report, found the customer, edited the
* record, and the change took effect on their say-so alone with no second pair of eyes and no
* record of why. The rating governs re-KYC frequency and AML thresholds, so it goes through a
* checker like every other decision of that weight.
*/
changeRisk(c: RiskCustomerRow, event: Event): void {
event.stopPropagation();
if (!this.canChangeRisk(c)) {
return;
}
this.dialog
.open(RiskChangeDialogComponent, {
width: '520px',
maxWidth: '95vw',
disableClose: true,
data: {
clientId: c.clientId,
customerName: c.customerName,
currentCategory: riskRank(c.currentRisk) || null,
recommendedCategory: riskRank(c.recommendedRisk) || null,
recommendationReason: c.directionReason
} as RiskChangeDialogData
})
.afterClosed()
.subscribe((sent) => {
if (sent) {
// The rating has not moved yet - only a request exists - so the figures on screen are
// still correct. Reloaded anyway so a second click cannot raise a duplicate request the
// server would refuse.
this.load();
}
});
}
/**
* What the reviewer is going to the KYC screen to do.
*
@@ -589,25 +748,41 @@ export class RiskReviewReportComponent implements OnInit {
* change nobody is authorised to make yet.
*/
directionText(c: RiskCustomerRow): string {
if (c.escalated) {
// Escalation is only the whole story when the rating already matches. An STR holds a customer at High, so one
// filed against a Medium-rated customer leaves a rating that still has to be moved - saying "held" there reports
// the case and hides the work, which is how this row came to sit under "Change needed" announcing that nothing
// needed doing.
if (c.escalated && c.direction === 'same') {
return 'Held — with Principal Officer';
}
if (c.direction === 'up') {
return `Raise to ${RISK_LABEL[c.recommendedRisk]}`;
// Both ends of the move, not just the destination. "Lower to Medium" reads as a verdict on its own and leaves the
// officer joining it to the rating column to see what is actually changing; "High → Medium" is the whole fact in
// one glance, and on an exported sheet it still says what happened without the column beside it.
if (c.direction === 'up' || c.direction === 'down') {
const from = c.currentRisk ? RISK_LABEL[c.currentRisk] : 'Unrated';
return `${from} → ${RISK_LABEL[c.recommendedRisk]}`;
}
if (c.direction === 'down') {
return `Lower to ${RISK_LABEL[c.recommendedRisk]}`;
}
return c.currentRisk ? `Keep at ${RISK_LABEL[c.currentRisk]}` : 'No rating on record';
// An unrated customer is told what to do, not what was concluded. "No rating on record" states the gap and stops
// there, which on a worklist reads as though the row needs nothing - and it is in fact the row that needs the most.
return c.currentRisk ? `Keep at ${RISK_LABEL[c.currentRisk]}` : 'Rate this customer';
}
directionIcon(c: RiskCustomerRow): string {
if (c.escalated) {
// Same test as directionText: a padlock says the case is out of the officer's hands, which is untrue while the
// rating still has to be moved.
if (c.escalated && c.direction === 'same') {
return 'lock';
}
// 'check' rather than an equals sign: faEquals is not in the shared library, and an
// unregistered icon throws at render and takes the whole row down with it.
return c.direction === 'up' ? 'arrow-up' : c.direction === 'down' ? 'arrow-down' : 'check';
if (c.direction === 'up') {
return 'arrow-up';
}
if (c.direction === 'down') {
return 'arrow-down';
}
// A tick claims the row is settled. An unrated customer is not settled, so it gets the mark that asks a question
// rather than the one that closes it. 'exclamation-circle' rather than plain 'exclamation': only the registered
// names resolve, and an unregistered icon throws at render and takes the whole row down with it.
return c.currentRisk ? 'check' : 'exclamation-circle';
}
/**
+143 -18
View File
@@ -10,7 +10,7 @@ import {
AmlRuleMeta,
CTR_THRESHOLD,
MATERIALITY,
NON_MITIGABLE_RULES,
isNonMitigableRule,
REVIEW_STATUS_META,
RISK_LABEL,
RiskAlertRow,
@@ -26,6 +26,17 @@ export type RatingDirection = 'up' | 'down' | 'same';
const RANK_TO_LEVEL: Record<number, RiskLevel> = { 1: 'LOW', 2: 'MEDIUM', 3: 'HIGH' };
/**
* What has to have been seen before this screen's own reckoning will argue for lowering a rating.
*
* The nightly behaviour review is the real test — it reads a 270-day window, counts transactions rather than alerts,
* and applies the blocks. These are the floor for the fallback used until that job has run: deliberately modest,
* because the fallback sees only the alerts inside the report's date filter, but not absent, because judging on
* amounts alone let three small deposits in one month argue for taking a customer off Medium.
*/
const MIN_ALERTS_TO_LOWER = 5;
const MIN_MONTHS_TO_LOWER = 3;
export interface DateRange {
from: string;
to: string;
@@ -109,6 +120,8 @@ export interface RiskCustomerRow {
needsUpgrade: boolean;
/** True when the rating on record is higher than the activity warrants. */
needsDowngrade: boolean;
/** The review period the recommendation was drawn from, where the nightly job has computed one. */
behaviourWindow?: BehaviourWindowByClient[number] | null;
}
export interface RiskReviewSummary {
@@ -155,17 +168,26 @@ function recommendRating(
largestAmount: number,
totalAmount: number,
escalated: boolean,
ruleCodes: string[]
ruleCodes: string[],
alertCount: number,
distinctMonths: number
): { risk: RiskLevel; reason: string } {
const implied = RANK_TO_LEVEL[impliedRank] ?? 'LOW';
const grave = ruleCodes.find((c) => NON_MITIGABLE_RULES.has(c));
const grave = ruleCodes.find((c) => isNonMitigableRule(c));
const material = largestAmount >= CTR_THRESHOLD || totalAmount >= MATERIALITY.highTotal;
const small = largestAmount < MATERIALITY.smallTxn && totalAmount < MATERIALITY.smallTotal;
// Small amounts are not on their own a reason to lower a rating. A rating describes a period of behaviour, so
// bringing one down needs a period to have been observed: enough transactions to be a pattern, and spread over
// enough months that they are not one busy week. Judging on amounts alone let three small deposits in a single
// month argue for taking a customer off Medium — which is the same mistake as re-rating on one transaction, only
// three times over.
const enoughActivity = alertCount >= MIN_ALERTS_TO_LOWER && distinctMonths >= MIN_MONTHS_TO_LOWER;
const small = enoughActivity && largestAmount < MATERIALITY.smallTxn && totalAmount < MATERIALITY.smallTotal;
// What the rating should be. Amounts only ever pull the rules' bound down, and
// only where nothing weightier holds it up.
let risk: RiskLevel;
let basis: 'escalated' | 'grave' | 'material' | 'small' | 'moderate';
let basis: 'escalated' | 'grave' | 'material' | 'small' | 'moderate' | 'thin';
if (escalated) {
risk = 'HIGH';
basis = 'escalated';
@@ -175,6 +197,12 @@ function recommendRating(
} else if (material) {
risk = implied;
basis = 'material';
} else if (!enoughActivity) {
// Too little observed to say anything about the rating either way, so it stands. Stating this rather than
// quietly leaving the rating alone is the point: an officer reading the row learns the period was too thin,
// not that the customer was examined and found unremarkable.
risk = currentRisk ?? implied;
basis = 'thin';
} else {
risk = RANK_TO_LEVEL[Math.min(impliedRank, small ? 1 : 2)] ?? 'LOW';
basis = small ? 'small' : 'moderate';
@@ -218,6 +246,27 @@ function recommendRating(
};
}
if (basis === 'thin') {
// A customer with no rating is not waiting for more evidence — there is no rating for evidence to move. Telling
// them the period is too thin, under a verdict that says "Rate this customer", asks for the work and argues
// against it in the same row. The thin-period test is about lowering a rating that exists.
if (!currentRisk) {
return {
risk,
reason: `Transacting with no rating on file — ${inr(totalAmount)} across ${alertCount} alert${
alertCount === 1 ? '' : 's'
}. Rate the customer; behaviour can only be judged against a rating.`
};
}
// Says which of the two tests fell short, because "not enough activity" leaves an officer unable to tell whether
// the customer needs watching for another month or another five.
const shortOn =
alertCount < MIN_ALERTS_TO_LOWER
? `only ${alertCount} alert${alertCount === 1 ? '' : 's'} in the period, ${MIN_ALERTS_TO_LOWER} needed`
: `activity falls in ${distinctMonths} month${distinctMonths === 1 ? '' : 's'}, ${MIN_MONTHS_TO_LOWER} separate months needed`;
return { risk, reason: `Too little to re-rate on — ${shortOn}. ${rated}; it stands until more is seen.` };
}
// The rating on file is where the activity puts it. Saying so is the verdict — leaving it blank
// read as "not assessed yet" on screen, which is the opposite of what the review concluded.
return {
@@ -271,7 +320,36 @@ export function riskAlertViews(alerts: RiskAlertRow[], filter: RiskReviewFilter)
* escalated, then rated below what the rules point at, then most alerts —
* come to the top of the worklist.
*/
export function riskCustomerRows(views: RiskAlertView[]): RiskCustomerRow[] {
/**
* The behaviour window the nightly job computed for one customer, where there is one.
*
* Keyed on client id. A customer with no window - one the job has not seen yet, or a tenant where the job has never
* run - falls back to the per-alert reckoning below, so the report renders either way.
*/
export interface BehaviourWindowByClient {
[clientId: number]: {
clientId: number;
clientName: string | null;
accountNo: string | null;
officeName: string | null;
riskCategoryAtReview: number | null;
txnCount: number | null;
distinctMonths: number | null;
windowFrom: any;
windowTo: any;
creditTotal: number | null;
debitTotal: number | null;
largestTxn: number | null;
cashTotal: number | null;
alertCount: number | null;
strCaseCount: number | null;
suggestedDirection: string | null;
suggestedCategory: number | null;
suggestionReason: string | null;
};
}
export function riskCustomerRows(views: RiskAlertView[], windows: BehaviourWindowByClient = {}): RiskCustomerRow[] {
const byClient = new Map<number, RiskAlertView[]>();
for (const v of views) {
const list = byClient.get(v.clientId);
@@ -297,19 +375,59 @@ export function riskCustomerRows(views: RiskAlertView[]): RiskCustomerRow[] {
const largestAmount = Math.max(...alerts.map((a) => a.amount));
const escalated = alerts.some((a) => a.status === 'ESCALATED_STR' || a.status === 'STR_FILED');
const recommendation = recommendRating(
proposedRank,
currentRisk,
largestAmount,
totalAmount,
escalated,
alerts.map((a) => a.ruleCode)
);
// The server's own reckoning, where the nightly job has produced one.
//
// It is the better answer, and not because it is newer: it is drawn from a review period rather than from the
// alerts on this screen. A customer appears here only once a rule has fired, so judging them on those alerts
// alone describes the exceptions and never the ordinary months around them - which is exactly what a rating is
// supposed to reflect. The job also tests the things this screen cannot see at all: how many separate months the
// activity falls in, how long the customer has held the rating, whether an STR inside the block window forbids
// lowering it.
//
// Without a window - a tenant where the job has not run, or a customer it has not reached - the per-alert
// reckoning below still stands, so the report never renders blank while the two are in step.
// A window is only good for as long as the rating it was computed against. The job takes a snapshot each night
// and records the rating the customer held at the time; once an officer moves that rating the snapshot describes
// a customer who no longer exists, and the screen would go on asking for a change that has already been applied
// and approved. Dropping it here falls back to the per-alert reckoning until tonight's job catches up.
const raw = windows[clientId];
const staleWindow =
raw?.riskCategoryAtReview != null &&
currentRisk != null &&
RANK_TO_LEVEL[raw.riskCategoryAtReview] !== currentRisk;
const window = staleWindow ? undefined : raw;
const recommendation = window?.suggestedDirection
? {
risk: RANK_TO_LEVEL[window.suggestedCategory ?? 0] ?? currentRisk ?? 'LOW',
reason: window.suggestionReason ?? ''
}
: recommendRating(
proposedRank,
currentRisk,
largestAmount,
totalAmount,
escalated,
alerts.map((a) => a.ruleCode),
alerts.length,
// Distinct calendar months the alerts fall in. Count alone does not establish a period: five alerts in one
// week is a busy fortnight, not five months of steady conduct.
new Set(alerts.map((a) => a.alertDate.slice(0, 7))).size
);
// Where the customer carries no rating there is nothing to move, so an
// unrated customer is never counted as an upgrade or a downgrade.
const delta = currentRisk ? riskRank(recommendation.risk) - riskRank(currentRisk) : 0;
const direction: RatingDirection = delta > 0 ? 'up' : delta < 0 ? 'down' : 'same';
const direction: RatingDirection = window?.suggestedDirection
? window.suggestedDirection === 'UP'
? 'up'
: window.suggestedDirection === 'DOWN'
? 'down'
: 'same'
: delta > 0
? 'up'
: delta < 0
? 'down'
: 'same';
rows.push({
clientId,
@@ -332,7 +450,10 @@ export function riskCustomerRows(views: RiskAlertView[]): RiskCustomerRow[] {
openCount: alerts.filter((a) => REVIEW_STATUS_META[a.status].open).length,
escalated,
needsUpgrade: direction === 'up',
needsDowngrade: direction === 'down'
needsDowngrade: direction === 'down',
// Carried through so the screen can show the period a recommendation was drawn from. A rating an officer is
// asked to approve has to show its working, and the alerts on this row are not it.
behaviourWindow: window ?? null
});
}
@@ -461,9 +582,13 @@ export interface RiskReviewModel {
* the summary all run once per filter change rather than once per template
* binding, which matters because every view reads from the same result.
*/
export function buildRiskReview(alerts: RiskAlertRow[], filter: RiskReviewFilter): RiskReviewModel {
export function buildRiskReview(
alerts: RiskAlertRow[],
filter: RiskReviewFilter,
windows: BehaviourWindowByClient = {}
): RiskReviewModel {
const base = riskAlertViews(alerts, filter);
const narrowed = applyDirectionFilter(base, riskCustomerRows(base), filter.direction);
const narrowed = applyDirectionFilter(base, riskCustomerRows(base, windows), filter.direction);
return {
alerts: narrowed.views,
+2
View File
@@ -56,6 +56,7 @@ import {
faGavel,
faGlobe,
faHandHoldingUsd,
faHandPaper,
faHome,
faKey,
faLink,
@@ -303,6 +304,7 @@ export class IconsModule {
faHandHoldingHand,
faHandHoldingHeart,
faHandHoldingUsd,
faHandPaper,
faHeart,
faHome,
faKey,
@@ -32,7 +32,6 @@ export class ShareAccountActionsResolver {
const actionName = route.paramMap.get('name');
const shareAccountId = route.paramMap.get('shareAccountId') || route.parent.parent.paramMap.get('shareAccountId');
switch (actionName) {
case 'Apply Additional Shares':
case 'Redeem Shares':
case 'Approve Additional Shares':
case 'Reject Additional Shares':
@@ -0,0 +1,209 @@
<!--
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="cert-root">
<div class="cert-head">
<div class="cert-head-l">
<fa-icon icon="file-invoice" class="cert-head-ic"></fa-icon>
<div>
<h2 class="cert-head-t">Share Account Statement</h2>
<div class="cert-head-s">
One member's share account, movement by movement, with the holding each movement left behind.
</div>
</div>
</div>
<div class="cert-head-r">
<button class="cert-cta-btn" (click)="exportCsv()" [disabled]="loading || !lines.length">
<fa-icon icon="file-alt"></fa-icon> Export CSV
</button>
<button class="cert-cta-btn" (click)="print()" [disabled]="loading || !lines.length">
<fa-icon icon="print"></fa-icon> Print
</button>
</div>
</div>
<!-- Account picker -->
<div class="cert-filters">
<div class="cert-filter" style="flex: 2 1 320px; position: relative">
<label>Share account</label>
<div class="cert-input-ic">
<fa-icon icon="search"></fa-icon>
<input
type="text"
[ngModel]="accountSearch"
(ngModelChange)="onAccountSearch($event)"
placeholder="Search member name or share account number"
/>
</div>
@if (accountOptions.length && !selectedAccountId) {
<div class="srep-picker">
@for (opt of accountOptions; track opt.id) {
<button type="button" class="srep-picker-item" (click)="selectAccount(opt)">
<span class="srep-picker-name">{{ opt.clientName }}</span>
<span class="srep-picker-meta srep-mono">{{ opt.accountNo }}</span>
<span class="srep-picker-meta">{{ opt.productName }}</span>
</button>
}
</div>
}
</div>
<div class="cert-filter">
<label>From</label>
<input type="date" [(ngModel)]="fromDate" (change)="load()" [disabled]="!selectedAccountId" />
</div>
<div class="cert-filter">
<label>To</label>
<input type="date" [(ngModel)]="toDate" (change)="load()" [disabled]="!selectedAccountId" />
</div>
<div class="cert-filter">
<label>&nbsp;</label>
<button class="cert-cta-btn" (click)="clearPeriod()" [disabled]="!selectedAccountId">Full history</button>
</div>
</div>
@if (!selectedAccountId) {
<div class="srep-state">Search for a member or share account number above to open its statement.</div>
} @else if (loading) {
<div class="srep-state">Loading statement…</div>
} @else if (loadError) {
<div class="srep-state srep-state--error"><fa-icon icon="exclamation-circle"></fa-icon> {{ loadError }}</div>
} @else if (account) {
<!-- Statement header -->
<div class="srep-stmt-head">
<div class="srep-stmt-grid">
<div>
<div class="srep-stmt-l">Member</div>
<div class="srep-stmt-v">{{ account.clientName }}</div>
</div>
<div>
<div class="srep-stmt-l">Membership No</div>
<div class="srep-stmt-v srep-mono">{{ account.cifNumber || '—' }}</div>
</div>
<div>
<div class="srep-stmt-l">Share A/c No</div>
<div class="srep-stmt-v srep-mono">{{ account.accountNo }}</div>
</div>
<div>
<div class="srep-stmt-l">Product</div>
<div class="srep-stmt-v">{{ account.productName }}</div>
</div>
<div>
<div class="srep-stmt-l">Branch</div>
<div class="srep-stmt-v">{{ account.officeName || '—' }}</div>
</div>
<div>
<div class="srep-stmt-l">Status</div>
<div class="srep-stmt-v">
<span class="srep-pill" [ngClass]="statusClass(account.statusEnum)">{{
statusLabel(account.statusEnum)
}}</span>
</div>
</div>
<div>
<div class="srep-stmt-l">Linked Savings</div>
<div class="srep-stmt-v srep-mono">{{ account.linkedSavingsAccountNo || 'Not linked' }}</div>
</div>
<div>
<div class="srep-stmt-l">Activated</div>
<div class="srep-stmt-v">{{ account.activatedDate || '—' }}</div>
</div>
</div>
<button class="cert-cta-btn srep-no-print" (click)="openAccount()">
<fa-icon icon="external-link-alt"></fa-icon> Open account
</button>
</div>
@if (totals) {
<div class="cert-kpis">
<div class="cert-kpi cert-kpi--green">
<fa-icon icon="coins" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ totals.closingShares | number }}</div>
<div class="cert-kpi-l">Closing Shares</div>
</div>
</div>
<div class="cert-kpi cert-kpi--teal">
<fa-icon icon="balance-scale" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">₹{{ totals.closingValue | number: '1.2-2' }}</div>
<div class="cert-kpi-l">Closing Value</div>
</div>
</div>
<div class="cert-kpi cert-kpi--blue">
<fa-icon icon="arrow-up" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ totals.sharesIn | number }}</div>
<div class="cert-kpi-l">Shares Purchased</div>
</div>
</div>
<div class="cert-kpi cert-kpi--amber">
<fa-icon icon="arrow-down" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ totals.sharesOut | number }}</div>
<div class="cert-kpi-l">Shares Redeemed</div>
</div>
</div>
</div>
}
@if (!lines.length) {
<div class="srep-state">
No settled movement on this account{{ fromDate || toDate ? ' in the selected period' : '' }}.
</div>
} @else {
<div class="srep-table-wrap">
<table class="srep-table">
<thead>
<tr>
<th>Date</th>
<th>Particulars</th>
<th>Board Resolution</th>
<th>Funded By</th>
<th class="srep-num">Shares In</th>
<th class="srep-num">Shares Out</th>
<th class="srep-num">Unit Price</th>
<th class="srep-num">Amount</th>
<th class="srep-num">Balance Shares</th>
<th class="srep-num">Balance Value</th>
</tr>
</thead>
<tbody>
@for (line of lines; track line.transactionId) {
<tr>
<td>{{ line.transactionDate }}</td>
<td>
<span class="srep-pill" [ngClass]="typeClass(line.typeEnum)">{{ typeLabel(line.typeEnum) }}</span>
</td>
<td class="srep-mono">{{ line.resolutionNumber || '—' }}</td>
<td>{{ funding(line.fundingSource) }}</td>
<td class="srep-num">{{ line.sharesIn ? (line.sharesIn | number) : '' }}</td>
<td class="srep-num">{{ line.sharesOut ? (line.sharesOut | number) : '' }}</td>
<td class="srep-num">{{ line.unitPrice | number: '1.2-2' }}</td>
<td class="srep-num">{{ line.amount | number: '1.2-2' }}</td>
<td class="srep-num">{{ line.balanceShares | number }}</td>
<td class="srep-num">₹{{ line.balanceValue | number: '1.2-2' }}</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="4">Closing balance</td>
<td class="srep-num">{{ totals?.sharesIn | number }}</td>
<td class="srep-num">{{ totals?.sharesOut | number }}</td>
<td class="srep-num"></td>
<td class="srep-num"></td>
<td class="srep-num">{{ totals?.closingShares | number }}</td>
<td class="srep-num">₹{{ totals?.closingValue | number: '1.2-2' }}</td>
</tr>
</tfoot>
</table>
</div>
}
}
</div>
@@ -0,0 +1,238 @@
/**
* 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 { ActivatedRoute, Router } from '@angular/router';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { Subject, of } from 'rxjs';
import { catchError, debounceTime, distinctUntilChanged, switchMap } from 'rxjs/operators';
import { SharesService } from '../../shares.service';
import {
SHARE_ACCOUNT_STATUS,
SHARE_TXN_TYPE,
fundingLabel,
shareReportCsv,
shareReportPrint
} from '../share-report.util';
interface StatementLine {
transactionId: number;
transactionDate: string;
typeEnum: number;
statusEnum: number;
sharesIn: number;
sharesOut: number;
unitPrice: number;
amount: number;
chargeAmount: number;
amountPaid: number;
resolutionNumber: string;
meetingDate: string;
fundingSource: string;
balanceShares: number;
balanceValue: number;
}
/**
* Share Account Statement — one member's share account, movement by movement, with the holding each
* movement left behind.
*
* <p>
* The account is chosen first because a statement is always about one account; the search box is
* server-side (the same summary endpoint the Share Holder report uses), so it finds a member across
* every branch rather than only among accounts already downloaded.
*
* <p>
* A period narrows which lines are shown but never how the running balance was reached — the server
* walks the account's whole life and filters afterwards, so a mid-year statement opens at the
* holding the member actually had, not at zero.
*/
@Component({
selector: 'mifosx-share-account-statement',
templateUrl: './share-account-statement.component.html',
styleUrls: [
'../../../letter-mgmt/certificates/certificates.shared.scss',
'../share-report.shared.scss'
],
imports: [
CommonModule,
FormsModule,
FaIconComponent
]
})
export class ShareAccountStatementComponent implements OnInit {
private sharesService = inject(SharesService);
private route = inject(ActivatedRoute);
private router = inject(Router);
/** Account picker. */
accountSearch = '';
accountOptions: any[] = [];
searchingAccounts = false;
selectedAccountId: number | null = null;
/** Statement. */
loading = false;
loadError = '';
account: any = null;
lines: StatementLine[] = [];
totals: any = null;
fromDate = '';
toDate = '';
private searchTerm$ = new Subject<string>();
ngOnInit(): void {
this.searchTerm$
.pipe(
debounceTime(350),
distinctUntilChanged(),
switchMap((term) => {
this.searchingAccounts = true;
return this.sharesService.getShareAccountSummaries(term).pipe(catchError(() => of([])));
})
)
.subscribe((res: any) => {
this.accountOptions = Array.isArray(res) ? res : res?.pageItems || [];
this.searchingAccounts = false;
});
// Deep-linkable: the account view's own statement button, and any report that drills in here,
// can hand over ?shareAccountId= and land on a loaded statement rather than an empty picker.
const preselected = this.route.snapshot.queryParamMap.get('shareAccountId');
if (preselected) {
this.selectedAccountId = Number(preselected);
this.load();
}
this.searchTerm$.next('');
}
onAccountSearch(term: string): void {
this.accountSearch = term;
this.searchTerm$.next(term.trim());
}
selectAccount(option: any): void {
this.selectedAccountId = option.id;
this.accountOptions = [];
this.accountSearch = `${option.clientName} — ${option.accountNo}`;
this.load();
}
load(): void {
if (!this.selectedAccountId) {
return;
}
this.loading = true;
this.loadError = '';
this.sharesService
.getShareAccountStatement(this.selectedAccountId, { fromDate: this.fromDate, toDate: this.toDate })
.pipe(
catchError((err) => {
this.loadError = this.readError(err, 'The share account statement could not be loaded.');
return of(null);
})
)
.subscribe((res: any) => {
this.account = res?.account || null;
this.lines = res?.lines || [];
this.totals = res?.totals || null;
this.loading = false;
});
}
clearPeriod(): void {
this.fromDate = '';
this.toDate = '';
this.load();
}
typeLabel(typeEnum: number): string {
return SHARE_TXN_TYPE[typeEnum]?.label || String(typeEnum ?? '');
}
typeClass(typeEnum: number): string {
return SHARE_TXN_TYPE[typeEnum]?.cls || 'srep-pill--grey';
}
statusLabel(statusEnum: number): string {
return SHARE_ACCOUNT_STATUS[statusEnum]?.label || String(statusEnum ?? '');
}
statusClass(statusEnum: number): string {
return SHARE_ACCOUNT_STATUS[statusEnum]?.cls || 'srep-pill--grey';
}
funding(source: string): string {
return fundingLabel(source);
}
openAccount(): void {
if (this.account) {
this.router.navigate([
'/clients',
this.account.clientId,
'shares-accounts',
this.account.shareAccountId,
'general'
]);
}
}
exportCsv(): void {
if (!this.account) {
return;
}
shareReportCsv(
`share-statement-${this.account.accountNo}`,
[
'Date',
'Particulars',
'Board Resolution',
'Funded By',
'Shares In',
'Shares Out',
'Unit Price',
'Amount',
'Entry Fee',
'Balance Shares',
'Balance Value'
],
this.lines.map((l) => [
l.transactionDate,
this.typeLabel(l.typeEnum),
l.resolutionNumber,
this.funding(l.fundingSource),
l.sharesIn || '',
l.sharesOut || '',
l.unitPrice,
l.amount,
l.chargeAmount,
l.balanceShares,
l.balanceValue
])
);
}
print(): void {
shareReportPrint();
}
private readError(err: any, fallback: string): string {
return (
err?.error?.errors?.[0]?.defaultUserMessage ||
err?.error?.defaultUserMessage ||
err?.error?.developerMessage ||
fallback
);
}
}
@@ -0,0 +1,174 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="cert-root">
<div class="cert-head">
<div class="cert-head-l">
<fa-icon icon="map" class="cert-head-ic"></fa-icon>
<div>
<h2 class="cert-head-t">Area Wise Shareholding</h2>
<div class="cert-head-s">
Where the society's members and its share capital sit — grouped by the member's own recorded address.
</div>
</div>
</div>
<div class="cert-head-r">
<button class="cert-cta-btn" (click)="exportCsv()" [disabled]="loading || !filteredMembers.length">
<fa-icon icon="file-alt"></fa-icon> Export CSV
</button>
<button class="cert-cta-btn" (click)="print()" [disabled]="loading || !filteredMembers.length">
<fa-icon icon="print"></fa-icon> Print
</button>
</div>
</div>
@if (totals) {
<div class="cert-kpis">
<div class="cert-kpi cert-kpi--blue">
<fa-icon icon="map" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ totals.areaCount | number }}</div>
<div class="cert-kpi-l">Areas</div>
</div>
</div>
<div class="cert-kpi cert-kpi--green">
<fa-icon icon="users" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ totals.memberCount | number }}</div>
<div class="cert-kpi-l">Members</div>
</div>
</div>
<div class="cert-kpi cert-kpi--teal">
<fa-icon icon="balance-scale" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">₹{{ totals.shareValue | number: '1.2-2' }}</div>
<div class="cert-kpi-l">Share Capital</div>
</div>
</div>
<div class="cert-kpi cert-kpi--amber">
<fa-icon icon="coins" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ totals.shares | number }}</div>
<div class="cert-kpi-l">Shares Held</div>
</div>
</div>
</div>
}
@if (loading) {
<div class="srep-state">Loading area-wise shareholding…</div>
} @else if (loadError) {
<div class="srep-state srep-state--error"><fa-icon icon="exclamation-circle"></fa-icon> {{ loadError }}</div>
} @else if (!members.length) {
<div class="srep-state">No member holds share capital yet.</div>
} @else {
<!-- Area summary — click one to narrow the member list below -->
<div class="srep-table-wrap">
<table class="srep-table">
<thead>
<tr>
<th>Area</th>
<th class="srep-num">Members</th>
<th style="width: 22%">Capital</th>
<th class="srep-num">Shares</th>
<th class="srep-num">Share Value</th>
<th class="srep-num">% of Capital</th>
</tr>
</thead>
<tbody>
@for (a of areas; track a.area) {
<tr class="srep-row--click" (click)="selectArea(a.area)">
<td>
{{ a.area }}
@if (areaFilter === a.area) {
<span class="srep-pill srep-pill--blue srep-no-print">filtered</span>
}
</td>
<td class="srep-num">{{ a.memberCount | number }}</td>
<td>
<div class="srep-bar"><div class="srep-bar-fill" [style.width.%]="barWidth(a)"></div></div>
</td>
<td class="srep-num">{{ a.shares | number }}</td>
<td class="srep-num">₹{{ a.shareValue | number: '1.2-2' }}</td>
<td class="srep-num">{{ capitalShare(a) | number: '1.2-2' }}%</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td>All areas</td>
<td class="srep-num">{{ totals?.memberCount | number }}</td>
<td></td>
<td class="srep-num">{{ totals?.shares | number }}</td>
<td class="srep-num">₹{{ totals?.shareValue | number: '1.2-2' }}</td>
<td class="srep-num">100.00%</td>
</tr>
</tfoot>
</table>
</div>
<!-- Members -->
<div class="cert-filters" style="margin-top: 0.85rem">
<div class="cert-filter">
<label>Member / Membership No / Area</label>
<div class="cert-input-ic">
<fa-icon icon="search"></fa-icon>
<input type="text" [(ngModel)]="search" placeholder="e.g. member name or Bhopal" />
</div>
</div>
<div class="cert-filter">
<label>Area</label>
<select [(ngModel)]="areaFilter">
<option value="">All areas</option>
@for (a of areas; track a.area) {
<option [value]="a.area">{{ a.area }}</option>
}
</select>
</div>
<div class="cert-filter">
<label>&nbsp;</label>
<button class="cert-cta-btn" (click)="clearFilters()">Clear filters</button>
</div>
</div>
@if (!filteredMembers.length) {
<div class="srep-state">No member matches the filters applied.</div>
} @else {
<div class="srep-table-wrap">
<table class="srep-table">
<thead>
<tr>
<th>Area</th>
<th>Member</th>
<th>Membership No</th>
<th>Mobile</th>
<th>Branch</th>
<th>Postal Code</th>
<th class="srep-num">Shares Held</th>
<th class="srep-num">Share Value</th>
</tr>
</thead>
<tbody>
@for (m of filteredMembers; track m.clientId) {
<tr class="srep-row--click" (click)="openMember(m)">
<td>{{ m.area }}</td>
<td>{{ m.clientName }}</td>
<td class="srep-mono">{{ m.cifNumber || '—' }}</td>
<td>{{ m.mobileNo || '—' }}</td>
<td>{{ m.officeName || '—' }}</td>
<td class="srep-mono">{{ m.postalCode || '—' }}</td>
<td class="srep-num">{{ m.sharesHeld | number }}</td>
<td class="srep-num">₹{{ m.shareValue | number: '1.2-2' }}</td>
</tr>
}
</tbody>
</table>
</div>
}
}
</div>
@@ -0,0 +1,188 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { SharesService } from '../../shares.service';
import { shareReportCsv, shareReportPrint } from '../share-report.util';
interface AreaRow {
area: string;
memberCount: number;
shares: number;
shareValue: number;
}
interface AreaMemberRow {
clientId: number;
cifNumber: string;
clientName: string;
mobileNo: string;
officeName: string;
area: string;
postalCode: string;
sharesHeld: number;
shareValue: number;
}
/**
* Area Wise — where the society's members and its share capital actually sit.
*
* <p>
* A co-operative's area of operation is part of its licence, so "how much of our capital comes from
* which area" is a question both the board and the Registrar ask. Grouped on the member's own
* recorded address rather than the branch that serves them: the two answer different questions, and
* a licence is written against the address.
*
* <p>
* Members with no address are their own group rather than dropped — an unaddressable member is a gap
* in the register worth seeing.
*/
@Component({
selector: 'mifosx-share-area-wise',
templateUrl: './share-area-wise.component.html',
styleUrls: [
'../../../letter-mgmt/certificates/certificates.shared.scss',
'../share-report.shared.scss'
],
imports: [
CommonModule,
FormsModule,
FaIconComponent
]
})
export class ShareAreaWiseComponent implements OnInit {
private sharesService = inject(SharesService);
private router = inject(Router);
loading = true;
loadError = '';
areas: AreaRow[] = [];
members: AreaMemberRow[] = [];
totals: any = null;
search = '';
/** Set by clicking an area row; '' means every area. */
areaFilter = '';
ngOnInit(): void {
this.load();
}
load(): void {
this.loading = true;
this.loadError = '';
this.sharesService
.getShareAreaWise({})
.pipe(
catchError((err) => {
this.loadError = this.readError(err, 'The area-wise report could not be loaded.');
return of(null);
})
)
.subscribe((res: any) => {
this.areas = res?.areas || [];
this.members = res?.members || [];
this.totals = res?.totals || null;
this.loading = false;
});
}
get filteredMembers(): AreaMemberRow[] {
const term = this.search.trim().toLowerCase();
return this.members.filter((m) => {
if (this.areaFilter && m.area !== this.areaFilter) {
return false;
}
if (!term) {
return true;
}
return [
m.clientName,
m.cifNumber,
m.area,
m.postalCode
]
.filter(Boolean)
.some((field) => String(field).toLowerCase().includes(term));
});
}
/** An area's share of the society's capital — the reading this report exists for. */
capitalShare(row: AreaRow): number {
const total = Number(this.totals?.shareValue || 0);
return total ? (Number(row.shareValue || 0) * 100) / total : 0;
}
barWidth(row: AreaRow): number {
const largest = Math.max(...this.areas.map((a) => Number(a.shareValue || 0)), 1);
return (Number(row.shareValue || 0) * 100) / largest;
}
selectArea(area: string): void {
this.areaFilter = this.areaFilter === area ? '' : area;
}
openMember(row: AreaMemberRow): void {
this.router.navigate([
'/clients',
row.clientId,
'general'
]);
}
clearFilters(): void {
this.search = '';
this.areaFilter = '';
}
exportCsv(): void {
shareReportCsv(
'share-area-wise',
[
'Area',
'Member',
'Membership No',
'Mobile',
'Branch',
'Postal Code',
'Shares Held',
'Share Value'
],
this.filteredMembers.map((m) => [
m.area,
m.clientName,
m.cifNumber,
m.mobileNo,
m.officeName,
m.postalCode,
m.sharesHeld,
m.shareValue
])
);
}
print(): void {
shareReportPrint();
}
private readError(err: any, fallback: string): string {
return (
err?.error?.errors?.[0]?.defaultUserMessage ||
err?.error?.defaultUserMessage ||
err?.error?.developerMessage ||
fallback
);
}
}
@@ -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/.
-->
<div class="cert-root">
<div class="cert-head">
<div class="cert-head-l">
<fa-icon icon="list-alt" class="cert-head-ic"></fa-icon>
<div>
<h2 class="cert-head-t">Share Detail</h2>
<div class="cert-head-s">
The complete share register — every account with its member, class and the terms it was opened under.
</div>
</div>
</div>
<div class="cert-head-r">
<button class="cert-cta-btn" (click)="exportCsv()" [disabled]="loading || !filteredRows.length">
<fa-icon icon="file-alt"></fa-icon> Export CSV
</button>
<button class="cert-cta-btn" (click)="print()" [disabled]="loading || !filteredRows.length">
<fa-icon icon="print"></fa-icon> Print
</button>
</div>
</div>
@if (totals) {
<div class="cert-kpis">
<div class="cert-kpi cert-kpi--blue">
<fa-icon icon="file-alt" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ totals.accountCount | number }}</div>
<div class="cert-kpi-l">Share Accounts</div>
</div>
</div>
<div class="cert-kpi cert-kpi--green">
<fa-icon icon="coins" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ totals.sharesHeld | number }}</div>
<div class="cert-kpi-l">Shares Held</div>
</div>
</div>
<div class="cert-kpi cert-kpi--teal">
<fa-icon icon="balance-scale" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">₹{{ totals.shareValue | number: '1.2-2' }}</div>
<div class="cert-kpi-l">Share Capital</div>
</div>
</div>
<div class="cert-kpi" [ngClass]="totals.chargeOutstanding ? 'cert-kpi--amber' : 'cert-kpi--blue'">
<fa-icon icon="exclamation-circle" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">₹{{ totals.chargeOutstanding | number: '1.2-2' }}</div>
<div class="cert-kpi-l">Charges Outstanding</div>
</div>
</div>
</div>
}
<div class="cert-filters">
<div class="cert-filter">
<label>Member / A/c No / Resolution</label>
<div class="cert-input-ic">
<fa-icon icon="search"></fa-icon>
<input type="text" [(ngModel)]="search" placeholder="e.g. member name or BR-2026-14" />
</div>
</div>
<div class="cert-filter">
<label>Status</label>
<select [(ngModel)]="statusFilter">
<option [ngValue]="''">All statuses</option>
@for (s of statusOptions; track s) {
<option [ngValue]="s">{{ statusLabel(s) }}</option>
}
</select>
</div>
<div class="cert-filter">
<label>Branch</label>
<select [(ngModel)]="branchFilter">
<option value="">All branches</option>
@for (b of branchOptions; track b) {
<option [value]="b">{{ b }}</option>
}
</select>
</div>
<div class="cert-filter">
<label>Share product</label>
<select [(ngModel)]="productFilter">
<option value="">All products</option>
@for (p of productOptions; track p) {
<option [value]="p">{{ p }}</option>
}
</select>
</div>
<div class="cert-filter">
<label>&nbsp;</label>
<button class="cert-cta-btn" (click)="clearFilters()">Clear filters</button>
</div>
</div>
@if (loading) {
<div class="srep-state">Loading the share register…</div>
} @else if (loadError) {
<div class="srep-state srep-state--error"><fa-icon icon="exclamation-circle"></fa-icon> {{ loadError }}</div>
} @else if (!rows.length) {
<div class="srep-state">No share account exists yet.</div>
} @else if (!filteredRows.length) {
<div class="srep-state">No share account matches the filters applied.</div>
} @else {
<div class="srep-table-wrap">
<table class="srep-table">
<thead>
<tr>
<th>Member</th>
<th>Membership No</th>
<th>Share A/c No</th>
<th>Branch</th>
<th>Product</th>
<th>State</th>
<th class="srep-num">Shares</th>
<th class="srep-num">Value</th>
<th class="srep-num">Tranches</th>
<th>Board Resolution</th>
<th>Lock-in</th>
<th>Min Active</th>
<th>Linked Savings</th>
<th class="srep-num">Charges O/S</th>
<th>Activated</th>
</tr>
</thead>
<tbody>
@for (row of filteredRows; track row.shareAccountId) {
<tr class="srep-row--click" [ngClass]="stateRowClass(row.holdingState)" (click)="openAccount(row)">
<td>
{{ row.clientName }}
@if (row.mobileNo) {
<div class="srep-scope-note" style="margin: 0">{{ row.mobileNo }}</div>
}
</td>
<td class="srep-mono">{{ row.cifNumber || '—' }}</td>
<td class="srep-mono">{{ row.accountNo }}</td>
<td>{{ row.officeName || '—' }}</td>
<td>{{ row.productName }}</td>
<td>
<span
class="srep-pill"
[ngClass]="stateClass(row.holdingState)"
[title]="stateNote(row.holdingState)"
>{{ stateLabel(row.holdingState) }}</span
>
</td>
<td class="srep-num">
{{ row.sharesHeld | number }}
@if (row.holdingState === 'UNPAID') {
<div class="srep-sub">{{ row.unpaidShares | number }} unpaid</div>
}
</td>
<td class="srep-num">₹{{ row.shareValue | number: '1.2-2' }}</td>
<td class="srep-num">{{ row.trancheCount | number }}</td>
<td class="srep-mono">
{{ row.resolutionNumber || '—' }}
@if (row.resolutionDate) {
<div class="srep-scope-note" style="margin: 0">{{ row.resolutionDate }}</div>
}
</td>
<td>{{ lockin(row) }}</td>
<td>{{ minimumActive(row) }}</td>
<td class="srep-mono">{{ row.linkedSavingsAccountNo || '—' }}</td>
<td class="srep-num">
{{ row.chargeOutstanding ? '₹' + (row.chargeOutstanding | number: '1.2-2') : '—' }}
</td>
<td>{{ row.activatedDate || '—' }}</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="6">
{{ isFiltered ? 'Total (filtered)' : 'Total' }} — {{ filteredRows.length | number }} accounts
</td>
<td class="srep-num">{{ shownShares | number }}</td>
<td class="srep-num">₹{{ shownValue | number: '1.2-2' }}</td>
<td colspan="7"></td>
</tr>
</tfoot>
</table>
</div>
}
</div>
@@ -0,0 +1,293 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { SharesService } from '../../shares.service';
import {
SHARE_ACCOUNT_STATUS,
holdingStateClass,
holdingStateLabel,
holdingStateNote,
holdingStateRowClass,
periodLabel,
shareReportCsv,
shareReportPrint
} from '../share-report.util';
interface DetailRow {
shareAccountId: number;
accountNo: string;
externalId: string;
clientId: number;
cifNumber: string;
clientName: string;
mobileNo: string;
officeName: string;
productId: number;
productName: string;
productShortName: string;
currencyCode: string;
statusEnum: number;
holdingState: string;
unpaidShares: number;
unitPrice: number;
sharesHeld: number;
shareValue: number;
pendingShares: number;
trancheCount: number;
resolutionNumber: string;
resolutionDate: string;
lockinPeriod: number;
lockinPeriodEnum: number;
minimumActivePeriod: number;
minimumActivePeriodEnum: number;
allowDividendsInactive: boolean;
linkedSavingsAccountNo: string;
chargeTotal: number;
chargeOutstanding: number;
submittedDate: string;
approvedDate: string;
activatedDate: string;
closedDate: string;
}
/**
* Share Detail — the complete register: every account with its member, share class, and the terms it
* was opened under.
*
* <p>
* Where the Share Holder list answers "who holds shares", this answers "what exactly does this
* account say": lock-in, minimum active period, linked savings, charges, tranche count and governing
* resolution, which are otherwise spread one tab at a time across each account.
*/
@Component({
selector: 'mifosx-share-detail-report',
templateUrl: './share-detail-report.component.html',
styleUrls: [
'../../../letter-mgmt/certificates/certificates.shared.scss',
'../share-report.shared.scss'
],
imports: [
CommonModule,
FormsModule,
FaIconComponent
]
})
export class ShareDetailReportComponent implements OnInit {
private sharesService = inject(SharesService);
private router = inject(Router);
loading = true;
loadError = '';
rows: DetailRow[] = [];
totals: any = null;
search = '';
branchFilter = '';
productFilter = '';
statusFilter: number | '' = '';
ngOnInit(): void {
this.load();
}
load(): void {
this.loading = true;
this.loadError = '';
this.sharesService
.getShareDetailRegister({})
.pipe(
catchError((err) => {
this.loadError = this.readError(err, 'The share register could not be loaded.');
return of(null);
})
)
.subscribe((res: any) => {
this.rows = res?.rows || [];
this.totals = res?.totals || null;
this.loading = false;
});
}
get branchOptions(): string[] {
return [...new Set(this.rows.map((r) => r.officeName).filter(Boolean))].sort();
}
get productOptions(): string[] {
return [...new Set(this.rows.map((r) => r.productName).filter(Boolean))].sort();
}
get statusOptions(): number[] {
return [...new Set(this.rows.map((r) => r.statusEnum))].sort((a, b) => a - b);
}
get filteredRows(): DetailRow[] {
const term = this.search.trim().toLowerCase();
return this.rows.filter((r) => {
if (this.branchFilter && r.officeName !== this.branchFilter) {
return false;
}
if (this.productFilter && r.productName !== this.productFilter) {
return false;
}
if (this.statusFilter !== '' && r.statusEnum !== this.statusFilter) {
return false;
}
if (!term) {
return true;
}
return [
r.clientName,
r.accountNo,
r.cifNumber,
r.productName,
r.resolutionNumber,
r.externalId
]
.filter(Boolean)
.some((field) => String(field).toLowerCase().includes(term));
});
}
get shownShares(): number {
return this.filteredRows.reduce((sum, r) => sum + Number(r.sharesHeld || 0), 0);
}
get shownValue(): number {
return this.filteredRows.reduce((sum, r) => sum + Number(r.shareValue || 0), 0);
}
get isFiltered(): boolean {
return !!(this.search.trim() || this.branchFilter || this.productFilter || this.statusFilter !== '');
}
statusLabel(statusEnum: number): string {
return SHARE_ACCOUNT_STATUS[statusEnum]?.label || String(statusEnum ?? '');
}
lockin(row: DetailRow): string {
return periodLabel(row.lockinPeriod, row.lockinPeriodEnum);
}
minimumActive(row: DetailRow): string {
return periodLabel(row.minimumActivePeriod, row.minimumActivePeriodEnum);
}
stateLabel(state: string): string {
return holdingStateLabel(state);
}
stateClass(state: string): string {
return holdingStateClass(state);
}
stateRowClass(state: string): string {
return holdingStateRowClass(state);
}
stateNote(state: string): string {
return holdingStateNote(state);
}
clearFilters(): void {
this.search = '';
this.branchFilter = '';
this.productFilter = '';
this.statusFilter = '';
}
openAccount(row: DetailRow): void {
this.router.navigate([
'/clients',
row.clientId,
'shares-accounts',
row.shareAccountId,
'general'
]);
}
exportCsv(): void {
shareReportCsv(
'share-detail-register',
[
'Member',
'Membership No',
'Mobile',
'Share A/c No',
'External Id',
'Branch',
'Product',
'Status',
'Holding State',
'Allotted Unpaid',
'Shares Held',
'Unit Price',
'Share Value',
'Pending Shares',
'Tranches',
'Board Resolution',
'Lock-in',
'Min Active Period',
'Dividends If Inactive',
'Linked Savings',
'Charges',
'Charges Outstanding',
'Submitted',
'Activated',
'Closed'
],
this.filteredRows.map((r) => [
r.clientName,
r.cifNumber,
r.mobileNo,
r.accountNo,
r.externalId,
r.officeName,
r.productName,
this.statusLabel(r.statusEnum),
this.stateLabel(r.holdingState),
r.unpaidShares,
r.sharesHeld,
r.unitPrice,
r.shareValue,
r.pendingShares,
r.trancheCount,
r.resolutionNumber,
this.lockin(r),
this.minimumActive(r),
r.allowDividendsInactive ? 'Yes' : 'No',
r.linkedSavingsAccountNo,
r.chargeTotal,
r.chargeOutstanding,
r.submittedDate,
r.activatedDate,
r.closedDate
])
);
}
print(): void {
shareReportPrint();
}
private readError(err: any, fallback: string): string {
return (
err?.error?.errors?.[0]?.defaultUserMessage ||
err?.error?.defaultUserMessage ||
err?.error?.developerMessage ||
fallback
);
}
}
@@ -0,0 +1,152 @@
<!--
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="cert-root">
<div class="cert-head">
<div class="cert-head-l">
<fa-icon icon="tag" class="cert-head-ic"></fa-icon>
<div>
<h2 class="cert-head-t">Share Distinctive Number Detail</h2>
<div class="cert-head-s">
Which numbered shares each member holds — the range every allotment covers, per share class.
</div>
</div>
</div>
<div class="cert-head-r">
<button class="cert-cta-btn" (click)="exportCsv()" [disabled]="loading || !filteredRows.length">
<fa-icon icon="file-alt"></fa-icon> Export CSV
</button>
<button class="cert-cta-btn" (click)="print()" [disabled]="loading || !filteredRows.length">
<fa-icon icon="print"></fa-icon> Print
</button>
</div>
</div>
@if (totals) {
<div class="cert-kpis">
<div class="cert-kpi cert-kpi--blue">
<fa-icon icon="file-alt" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ totals.allotmentCount | number }}</div>
<div class="cert-kpi-l">Numbered Allotments</div>
</div>
</div>
<div class="cert-kpi cert-kpi--green">
<fa-icon icon="coins" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ totals.sharesNumbered | number }}</div>
<div class="cert-kpi-l">Shares Numbered</div>
</div>
</div>
</div>
}
<!-- The numbers are derived, not recorded. Said plainly so nobody reads them as an independent
register the society has been maintaining. -->
<div class="srep-warn">
<fa-icon icon="info-circle"></fa-icon>
<span>
Distinctive numbers are <b>derived from issue order</b> within each share class — no distinctive-number column
exists in the system, so each paid-up allotment takes the next block in sequence. Redeemed shares leave a gap
rather than renumbering everybody else, exactly as a physical register would. Only paid-up allotments are
numbered.
</span>
</div>
<div class="cert-filters">
<div class="cert-filter">
<label>Find share number</label>
<div class="cert-input-ic">
<fa-icon icon="search"></fa-icon>
<input type="number" min="1" [(ngModel)]="shareNumberLookup" placeholder="e.g. 175 — who holds it?" />
</div>
</div>
<div class="cert-filter">
<label>Member / A/c No / Resolution</label>
<div class="cert-input-ic">
<fa-icon icon="search"></fa-icon>
<input type="text" [(ngModel)]="search" placeholder="e.g. member name" />
</div>
</div>
<div class="cert-filter">
<label>Share class</label>
<select [(ngModel)]="productFilter">
<option value="">All classes</option>
@for (p of productOptions; track p) {
<option [value]="p">{{ p }}</option>
}
</select>
</div>
<div class="cert-filter">
<label>&nbsp;</label>
<button class="cert-cta-btn" (click)="clearFilters()">Clear filters</button>
</div>
</div>
@if (loading) {
<div class="srep-state">Loading the distinctive number register…</div>
} @else if (loadError) {
<div class="srep-state srep-state--error"><fa-icon icon="exclamation-circle"></fa-icon> {{ loadError }}</div>
} @else if (!rows.length) {
<div class="srep-state">No paid-up allotment has been made yet, so no share carries a number.</div>
} @else if (!filteredRows.length) {
<div class="srep-state">
@if (shareNumberLookup) {
Share number {{ shareNumberLookup | number }} has not been allotted in this share class.
} @else {
No allotment matches the filters applied.
}
</div>
} @else {
<div class="srep-table-wrap">
<table class="srep-table">
<thead>
<tr>
<th>Share Class</th>
<th>Distinctive Numbers</th>
<th class="srep-num">Shares</th>
<th>Member</th>
<th>Membership No</th>
<th>Share A/c No</th>
<th>Branch</th>
<th>Allotment Date</th>
<th>Funded By</th>
<th>Board Resolution</th>
</tr>
</thead>
<tbody>
@for (row of filteredRows; track row.transactionId) {
<tr class="srep-row--click" (click)="openAccount(row)">
<td>{{ row.productName }}</td>
<td class="srep-mono">
<b>{{ range(row) }}</b>
</td>
<td class="srep-num">{{ row.shares | number }}</td>
<td>{{ row.clientName }}</td>
<td class="srep-mono">{{ row.cifNumber || '—' }}</td>
<td class="srep-mono">{{ row.accountNo }}</td>
<td>{{ row.officeName || '—' }}</td>
<td>{{ row.allotmentDate }}</td>
<td>{{ funding(row.fundingSource) }}</td>
<td class="srep-mono">{{ row.resolutionNumber || '—' }}</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="2">
{{ isFiltered ? 'Total (filtered)' : 'Total' }} — {{ filteredRows.length | number }} allotments
</td>
<td class="srep-num">{{ shownShares | number }}</td>
<td colspan="7"></td>
</tr>
</tfoot>
</table>
</div>
}
</div>
@@ -0,0 +1,206 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { SharesService } from '../../shares.service';
import { fundingLabel, shareReportCsv, shareReportPrint } from '../share-report.util';
interface DistinctiveRow {
transactionId: number;
allotmentDate: string;
shareAccountId: number;
accountNo: string;
clientId: number;
cifNumber: string;
clientName: string;
officeName: string;
productId: number;
productName: string;
shares: number;
unitPrice: number;
amount: number;
resolutionNumber: string;
fundingSource: string;
distinctiveFrom: number;
distinctiveTo: number;
}
/**
* Share Devede (Distinctive) Number Detail — which numbered shares each member actually holds.
*
* <p>
* A share certificate names the distinctive numbers it covers, and the register has to be able to
* say who holds which. Nothing in the schema records those numbers, so they are derived the way the
* society would have allotted them: shares numbered in issue order within each share class, each
* allotment taking the next block. That is stated plainly on the screen rather than presented as an
* independently-kept number, because it is a derivation and a reader should know it.
*/
@Component({
selector: 'mifosx-share-distinctive-numbers',
templateUrl: './share-distinctive-numbers.component.html',
styleUrls: [
'../../../letter-mgmt/certificates/certificates.shared.scss',
'../share-report.shared.scss'
],
imports: [
CommonModule,
FormsModule,
FaIconComponent
]
})
export class ShareDistinctiveNumbersComponent implements OnInit {
private sharesService = inject(SharesService);
private router = inject(Router);
loading = true;
loadError = '';
rows: DistinctiveRow[] = [];
totals: any = null;
search = '';
productFilter = '';
/** Find who holds a given share number — the question a certificate query actually arrives as. */
shareNumberLookup: number | null = null;
ngOnInit(): void {
this.load();
}
load(): void {
this.loading = true;
this.loadError = '';
this.sharesService
.getShareDistinctiveNumbers({})
.pipe(
catchError((err) => {
this.loadError = this.readError(err, 'The distinctive number register could not be loaded.');
return of(null);
})
)
.subscribe((res: any) => {
this.rows = res?.rows || [];
this.totals = res?.totals || null;
this.loading = false;
});
}
get productOptions(): string[] {
return [...new Set(this.rows.map((r) => r.productName).filter(Boolean))].sort();
}
get filteredRows(): DistinctiveRow[] {
const term = this.search.trim().toLowerCase();
const lookup = Number(this.shareNumberLookup);
return this.rows.filter((r) => {
if (this.productFilter && r.productName !== this.productFilter) {
return false;
}
// A share number belongs to exactly one block, so the lookup is a range test rather than a
// match — which is the whole point of holding the register this way.
if (lookup > 0 && !(lookup >= Number(r.distinctiveFrom) && lookup <= Number(r.distinctiveTo))) {
return false;
}
if (!term) {
return true;
}
return [
r.clientName,
r.accountNo,
r.cifNumber,
r.productName,
r.resolutionNumber
]
.filter(Boolean)
.some((field) => String(field).toLowerCase().includes(term));
});
}
get shownShares(): number {
return this.filteredRows.reduce((sum, r) => sum + Number(r.shares || 0), 0);
}
get isFiltered(): boolean {
return !!(this.search.trim() || this.productFilter || Number(this.shareNumberLookup) > 0);
}
funding(source: string): string {
return fundingLabel(source);
}
range(row: DistinctiveRow): string {
return `${Number(row.distinctiveFrom).toLocaleString('en-IN')} – ${Number(row.distinctiveTo).toLocaleString('en-IN')}`;
}
clearFilters(): void {
this.search = '';
this.productFilter = '';
this.shareNumberLookup = null;
}
openAccount(row: DistinctiveRow): void {
this.router.navigate([
'/clients',
row.clientId,
'shares-accounts',
row.shareAccountId,
'general'
]);
}
exportCsv(): void {
shareReportCsv(
'share-distinctive-numbers',
[
'Share Class',
'Distinctive From',
'Distinctive To',
'Shares',
'Member',
'Membership No',
'Share A/c No',
'Branch',
'Allotment Date',
'Funded By',
'Board Resolution'
],
this.filteredRows.map((r) => [
r.productName,
r.distinctiveFrom,
r.distinctiveTo,
r.shares,
r.clientName,
r.cifNumber,
r.accountNo,
r.officeName,
r.allotmentDate,
this.funding(r.fundingSource),
r.resolutionNumber
])
);
}
print(): void {
shareReportPrint();
}
private readError(err: any, fallback: string): string {
return (
err?.error?.errors?.[0]?.defaultUserMessage ||
err?.error?.defaultUserMessage ||
err?.error?.developerMessage ||
fallback
);
}
}

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