Compare commits
17
Commits
dev
...
7c868ae073
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c868ae073 | ||
|
|
283ab2f733 | ||
|
|
3f59cc6b91 | ||
|
|
b0777e9845 | ||
|
|
a6e6220100 | ||
|
|
3c363d0372 | ||
|
|
dc7f668056 | ||
|
|
124f05d21d | ||
|
|
c7428d0cb2 | ||
|
|
17d97da380 | ||
|
|
f9b73a4930 | ||
|
|
e53dfb5a66 | ||
|
|
41540ad24e | ||
|
|
b71619c402 | ||
|
|
d18bd9cf37 | ||
|
|
0cef1b5a50 | ||
|
|
0fbf6ec58c |
-288
@@ -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
|
|
||||||
|
|
||||||
+6
-15
@@ -16,6 +16,7 @@ import { AccountingService } from '../../accounting.service';
|
|||||||
import { GLAccount } from 'app/shared/models/general.model';
|
import { GLAccount } from 'app/shared/models/general.model';
|
||||||
import { GlAccountSelectorComponent } from '../../../shared/accounting/gl-account-selector/gl-account-selector.component';
|
import { GlAccountSelectorComponent } from '../../../shared/accounting/gl-account-selector/gl-account-selector.component';
|
||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
import { accountOptionsForActivity } from '../financial-activity-account-options';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create financial activity mapping component.
|
* Create financial activity mapping component.
|
||||||
@@ -87,21 +88,11 @@ export class CreateFinancialActivityMappingComponent implements OnInit {
|
|||||||
*/
|
*/
|
||||||
setGlAccountData() {
|
setGlAccountData() {
|
||||||
this.financialActivityMappingForm.get('financialActivityId').valueChanges.subscribe((financialActivityId) => {
|
this.financialActivityMappingForm.get('financialActivityId').valueChanges.subscribe((financialActivityId) => {
|
||||||
switch (financialActivityId) {
|
this.glAccountData = accountOptionsForActivity(
|
||||||
case 100:
|
this.financialActivityData,
|
||||||
case 101:
|
financialActivityId,
|
||||||
case 102:
|
this.glAccountOptions
|
||||||
case 103:
|
);
|
||||||
this.glAccountData = this.glAccountOptions.assetAccountOptions;
|
|
||||||
break;
|
|
||||||
case 200:
|
|
||||||
case 201:
|
|
||||||
this.glAccountData = this.glAccountOptions.liabilityAccountOptions;
|
|
||||||
break;
|
|
||||||
case 300:
|
|
||||||
this.glAccountData = this.glAccountOptions.equityAccountOptions;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+6
-15
@@ -16,6 +16,7 @@ import { AccountingService } from '../../accounting.service';
|
|||||||
import { GLAccount } from 'app/shared/models/general.model';
|
import { GLAccount } from 'app/shared/models/general.model';
|
||||||
import { GlAccountSelectorComponent } from '../../../shared/accounting/gl-account-selector/gl-account-selector.component';
|
import { GlAccountSelectorComponent } from '../../../shared/accounting/gl-account-selector/gl-account-selector.component';
|
||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
import { accountOptionsForActivity } from '../financial-activity-account-options';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Edit financial activity mapping component.
|
* Edit financial activity mapping component.
|
||||||
@@ -98,21 +99,11 @@ export class EditFinancialActivityMappingComponent implements OnInit {
|
|||||||
*/
|
*/
|
||||||
setGLAccountData() {
|
setGLAccountData() {
|
||||||
this.financialActivityMappingForm.get('financialActivityId').valueChanges.subscribe((financialActivityId) => {
|
this.financialActivityMappingForm.get('financialActivityId').valueChanges.subscribe((financialActivityId) => {
|
||||||
switch (financialActivityId) {
|
this.glAccountData = accountOptionsForActivity(
|
||||||
case 100:
|
this.financialActivityData,
|
||||||
case 101:
|
financialActivityId,
|
||||||
case 102:
|
this.glAccountOptions
|
||||||
case 103:
|
);
|
||||||
this.glAccountData = this.glAccountOptions.assetAccountOptions;
|
|
||||||
break;
|
|
||||||
case 200:
|
|
||||||
case 201:
|
|
||||||
this.glAccountData = this.glAccountOptions.liabilityAccountOptions;
|
|
||||||
break;
|
|
||||||
case 300:
|
|
||||||
this.glAccountData = this.glAccountOptions.equityAccountOptions;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The GL accounts a financial activity may be mapped to.
|
||||||
|
*
|
||||||
|
* The activity itself says which kind of account it needs — the template returns `mappedGLAccountType` on every
|
||||||
|
* option — so the answer is read from the activity rather than worked out from its id. The screens used to switch on
|
||||||
|
* the id instead, listing the ones they knew:
|
||||||
|
*
|
||||||
|
* case 100, 101, 102, 103 → asset
|
||||||
|
* case 200, 201 → liability
|
||||||
|
* case 300 → equity
|
||||||
|
*
|
||||||
|
* Every activity added since fell through that switch and left the account dropdown empty, which is how dividendPaid
|
||||||
|
* (202), reserveForDividend (301), distributableProfit (302), retainedProfit (303) and dividendRoundOff (304) became
|
||||||
|
* unmappable — the screen offered no accounts and gave no reason. A list keyed on ids has to be extended by hand for
|
||||||
|
* each new activity, and is silently wrong until someone notices; asking the activity what it needs cannot fall out
|
||||||
|
* of step with the backend.
|
||||||
|
*
|
||||||
|
* @param activities the template's financialActivityOptions
|
||||||
|
* @param activityId the activity the user picked
|
||||||
|
* @param glAccountOptions the template's glAccountOptions, keyed assetAccountOptions / liabilityAccountOptions / …
|
||||||
|
*/
|
||||||
|
export function accountOptionsForActivity(activities: any[], activityId: number, glAccountOptions: any): any[] {
|
||||||
|
const activity = (activities || []).find((a) => a?.id === activityId);
|
||||||
|
const accountType = activity?.mappedGLAccountType;
|
||||||
|
if (!accountType || !glAccountOptions) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
// ASSET → assetAccountOptions, and so on for LIABILITY, EQUITY, INCOME and EXPENSE.
|
||||||
|
return glAccountOptions[`${accountType.toLowerCase()}AccountOptions`] ?? [];
|
||||||
|
}
|
||||||
@@ -34,6 +34,17 @@ export class AmlService {
|
|||||||
return this.http.post(`/aml/alerts/${alertId}/review`, { disposition, notes });
|
return this.http.post(`/aml/alerts/${alertId}/review`, { disposition, notes });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decides named rules within one alert, leaving its other rules open.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* The alert's own status follows from what is left undecided, so it is not sent: whitelisting five of six rules
|
||||||
|
* leaves the alert open on the sixth.
|
||||||
|
*/
|
||||||
|
reviewAlertRules(alertId: number, ruleCodes: string[], disposition: string, notes: string): Observable<any> {
|
||||||
|
return this.http.post(`/aml/alerts/${alertId}/review`, { disposition, notes, ruleCodes });
|
||||||
|
}
|
||||||
|
|
||||||
// ═══ Recurring-alert review queue ═══
|
// ═══ Recurring-alert review queue ═══
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -141,4 +152,38 @@ export class AmlService {
|
|||||||
getRiskCategoryHistory(clientId: number): Observable<any[]> {
|
getRiskCategoryHistory(clientId: number): Observable<any[]> {
|
||||||
return this.http.get<any[]>(`/clients/${clientId}/risk-category-history`);
|
return this.http.get<any[]>(`/clients/${clientId}/risk-category-history`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ═══ Risk-category changes (maker-checker) ═══
|
||||||
|
//
|
||||||
|
// The rating decides how often a customer is re-KYC'd and what AML thresholds apply to them, so
|
||||||
|
// it moves only once a checker agrees rather than on one officer's say-so.
|
||||||
|
|
||||||
|
/** @param requestedCategory 1 Low, 2 Medium, 3 High */
|
||||||
|
requestRiskChange(clientId: number, requestedCategory: number, reason: string): Observable<any> {
|
||||||
|
return this.http.post('/aml/risk-change-requests', { clientId, requestedCategory, reason });
|
||||||
|
}
|
||||||
|
|
||||||
|
getRiskChangeRequests(status?: string): Observable<any[]> {
|
||||||
|
const params = status ? new HttpParams().set('status', status) : undefined;
|
||||||
|
return this.http.get<any[]>('/aml/risk-change-requests', { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
approveRiskChange(requestId: number): Observable<any> {
|
||||||
|
return this.http.post(`/aml/risk-change-requests/${requestId}/approve`, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
rejectRiskChange(requestId: number, rejectionReason: string): Observable<any> {
|
||||||
|
return this.http.post(`/aml/risk-change-requests/${requestId}/reject`, { rejectionReason });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The latest behaviour window for each customer, as the nightly job computed it.
|
||||||
|
*
|
||||||
|
* One row per customer carrying the review period's figures and the rating they suggest. The Risk Review report
|
||||||
|
* prefers these over its own per-alert reckoning, because a rating describes a period rather than the handful of
|
||||||
|
* transactions that happened to trip a rule.
|
||||||
|
*/
|
||||||
|
getBehaviourWindows(): Observable<any[]> {
|
||||||
|
return this.http.get<any[]>('/risk-review/behaviour-windows');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">₹{{ 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 { AccountRegisterComponent } from './account-register/account-register.component';
|
||||||
import { ClosingCashReportComponent } from './closing-cash-report/closing-cash-report.component';
|
import { ClosingCashReportComponent } from './closing-cash-report/closing-cash-report.component';
|
||||||
import { ExcessTransactionReportComponent } from './excess-transaction-report/excess-transaction-report.component';
|
import { ExcessTransactionReportComponent } from './excess-transaction-report/excess-transaction-report.component';
|
||||||
import { ShareCollectionComponent } from './share-collection/share-collection.component';
|
|
||||||
|
|
||||||
const routes: Routes = [
|
const routes: Routes = [
|
||||||
Route.withShell([
|
Route.withShell([
|
||||||
@@ -345,11 +344,6 @@ const routes: Routes = [
|
|||||||
component: ExcessTransactionReportComponent,
|
component: ExcessTransactionReportComponent,
|
||||||
data: { title: 'Excess Transaction Report', breadcrumb: 'Excess Transaction Report' }
|
data: { title: 'Excess Transaction Report', breadcrumb: 'Excess Transaction Report' }
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: 'share-collection',
|
|
||||||
component: ShareCollectionComponent,
|
|
||||||
data: { title: 'Share Collection', breadcrumb: 'Share Collection' }
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: 'transaction-entry',
|
path: 'transaction-entry',
|
||||||
canActivate: [CashierOnlyGuard],
|
canActivate: [CashierOnlyGuard],
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import { ReconciliationComponent } from './reconciliation/reconciliation.compone
|
|||||||
import { CashHistoryComponent } from './cash-history/cash-history.component';
|
import { CashHistoryComponent } from './cash-history/cash-history.component';
|
||||||
import { ClosingCashReportComponent } from './closing-cash-report/closing-cash-report.component';
|
import { ClosingCashReportComponent } from './closing-cash-report/closing-cash-report.component';
|
||||||
import { ExcessTransactionReportComponent } from './excess-transaction-report/excess-transaction-report.component';
|
import { ExcessTransactionReportComponent } from './excess-transaction-report/excess-transaction-report.component';
|
||||||
import { ShareCollectionComponent } from './share-collection/share-collection.component';
|
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -35,8 +34,7 @@ import { ShareCollectionComponent } from './share-collection/share-collection.co
|
|||||||
ReconciliationComponent,
|
ReconciliationComponent,
|
||||||
CashHistoryComponent,
|
CashHistoryComponent,
|
||||||
ClosingCashReportComponent,
|
ClosingCashReportComponent,
|
||||||
ExcessTransactionReportComponent,
|
ExcessTransactionReportComponent
|
||||||
ShareCollectionComponent
|
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class CashierModule {}
|
export class CashierModule {}
|
||||||
|
|||||||
@@ -178,6 +178,13 @@ export class CashierService {
|
|||||||
return this.http.get('/vouchers/my-work', { params });
|
return this.http.get('/vouchers/my-work', { params });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One voucher and every debit/credit line it is made of — the full slip behind a summarised
|
||||||
|
* row (a "+2 more" badge in a register only ever shows the first side of each). */
|
||||||
|
voucherDetail(voucherNo: string): Observable<any> {
|
||||||
|
const params = new HttpParams().set('voucherNo', voucherNo);
|
||||||
|
return this.http.get('/vouchers/detail', { params });
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Account register for one date: what opened and closed that day, each account's balance as it
|
* Account register for one date: what opened and closed that day, each account's balance as it
|
||||||
* stood then, and what it holds now.
|
* stood then, and what it holds now.
|
||||||
@@ -357,19 +364,6 @@ export class CashierService {
|
|||||||
return this.http.get<any[]>('/paymenttypes');
|
return this.http.get<any[]>('/paymenttypes');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Share Collection ──
|
|
||||||
getShareAccountForCollection(accountId: string): Observable<any> {
|
|
||||||
return this.http.get(`/accounts/share/${accountId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
collectSharePayment(data: any): Observable<any> {
|
|
||||||
return this.http.post('/cashier/sharecollection', data);
|
|
||||||
}
|
|
||||||
|
|
||||||
getShareCollectionStatus(shareAccountId: number): Observable<any> {
|
|
||||||
return this.http.get(`/cashier/sharecollection/${shareAccountId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One unapplied Share Sundry receipt, found by the voucher number the member quotes. */
|
/** One unapplied Share Sundry receipt, found by the voucher number the member quotes. */
|
||||||
getShareAdvanceByVoucher(voucherNo: string, glAccountNo: string, clientId?: number): Observable<any> {
|
getShareAdvanceByVoucher(voucherNo: string, glAccountNo: string, clientId?: number): Observable<any> {
|
||||||
// The voucher travels as a query parameter, not a path segment: numbers are formatted
|
// The voucher travels as a query parameter, not a path segment: numbers are formatted
|
||||||
@@ -396,10 +390,18 @@ export class CashierService {
|
|||||||
return this.http.get('/shareadvancecollection/requirement', { params: { shareAccountId: String(shareAccountId) } });
|
return this.http.get('/shareadvancecollection/requirement', { params: { shareAccountId: String(shareAccountId) } });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Receipts still sitting in Share Sundry, optionally for a single member. */
|
/**
|
||||||
getUnappliedShareAdvances(clientId?: number): Observable<any> {
|
* Everything an Apply screen needs, in one call: `own` is this member's own unapplied receipts,
|
||||||
const suffix = clientId ? `?clientId=${clientId}` : '';
|
* ready to tick; `rows` is every other Share Collection receipt still available — unclaimed GL
|
||||||
return this.http.get(`/shareadvancecollection${suffix}`);
|
* deposits and receipts linked to another member — which attach by voucher instead.
|
||||||
|
*
|
||||||
|
* One request rather than two: both lists come from the same table under the same filters, and
|
||||||
|
* splitting them across endpoints only meant the same rows were fetched twice and, where they
|
||||||
|
* overlapped, drawn twice on the same screen.
|
||||||
|
*/
|
||||||
|
getShareCollectionReceipts(clientId?: number): Observable<any> {
|
||||||
|
const params = clientId ? new HttpParams().set('clientId', String(clientId)) : undefined;
|
||||||
|
return this.http.get('/shareadvancecollection/unattributed', { params });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Moves a receipt out of Sundry into share capital for an activated account. */
|
/** Moves a receipt out of Sundry into share capital for an activated account. */
|
||||||
@@ -407,6 +409,12 @@ export class CashierService {
|
|||||||
return this.http.post(`/shareadvancecollection/${collectionId}/apply`, data);
|
return this.http.post(`/shareadvancecollection/${collectionId}/apply`, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The receipt's own real Dr/Cr GL legs — never the GL account currently picked in a search box,
|
||||||
|
* which is unrelated screen state that can name a different account than this receipt sits in. */
|
||||||
|
getShareAdvanceGlEntries(collectionId: number): Observable<any> {
|
||||||
|
return this.http.get(`/shareadvancecollection/${collectionId}/gl-entries`);
|
||||||
|
}
|
||||||
|
|
||||||
/** Every additional-share tranche awaiting approval, across every share account. */
|
/** Every additional-share tranche awaiting approval, across every share account. */
|
||||||
getShareAdditionRequests(): Observable<any> {
|
getShareAdditionRequests(): Observable<any> {
|
||||||
return this.http.get('/shareadditionrequests');
|
return this.http.get('/shareadditionrequests');
|
||||||
@@ -419,4 +427,14 @@ export class CashierService {
|
|||||||
getShareAdditionRequest(transactionId: number): Observable<any> {
|
getShareAdditionRequest(transactionId: number): Observable<any> {
|
||||||
return this.http.get(`/shareadditionrequests/${transactionId}`);
|
return this.http.get(`/shareadditionrequests/${transactionId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entry Type options for a GL credit (General / Share Collection / DD Issue / …) — the
|
||||||
|
* "GLEntryType" code under Admin > System > Manage Codes, so an admin can add another type later
|
||||||
|
* without a deployment. Empty on failure rather than erroring, since a row missing this dropdown's
|
||||||
|
* options should still be usable for every other field.
|
||||||
|
*/
|
||||||
|
getEntryTypeOptions(): Observable<any[]> {
|
||||||
|
return this.http.get<any[]>('/codes/name/GLEntryType/codevalues').pipe(catchError(() => of([])));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -174,19 +174,24 @@ export class ClosingCashReportComponent implements OnInit {
|
|||||||
return this.noteRows.reduce((s, r) => s + Number(r.settledOutAmount ?? 0), 0);
|
return this.noteRows.reduce((s, r) => s + Number(r.settledOutAmount ?? 0), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Summed from each row's own `netCount`, not re-derived from today's legs — a row's `netCount`
|
||||||
|
* already carries forward what a cashier was holding from before today (`openingCount`), for the
|
||||||
|
* force-closed-day/late-settlement case. Recomputing here from only today's vaultIn/deposit/
|
||||||
|
* withdrawal/settledOut dropped that carry-forward and put the Total row back into negative
|
||||||
|
* numbers a cashier had already reconciled.
|
||||||
|
*/
|
||||||
get noteNetCount(): number {
|
get noteNetCount(): number {
|
||||||
return this.noteVaultInCount + this.noteDepositCount - this.noteWithdrawalCount - this.noteSettledOutCount;
|
return this.noteRows.reduce((s, r) => s + Number(r.netCount ?? 0), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What is left in the drawer in notes: everything that came in, less everything that went out.
|
* What is left in the drawer in notes: everything that came in, less everything that went out —
|
||||||
*
|
* including cash carried forward from before today. See `noteNetCount` for why this sums each
|
||||||
* Was deposits minus withdrawals, which is the counter's movement rather than the drawer's
|
* row's own `netAmount` rather than re-deriving it from today's legs.
|
||||||
* contents — it left out the cash drawn from the vault, so a teller holding a ₹500 note saw a net
|
|
||||||
* of zero and had nothing to count against.
|
|
||||||
*/
|
*/
|
||||||
get noteNetAmount(): number {
|
get noteNetAmount(): number {
|
||||||
return this.noteVaultInAmount + this.noteDepositAmount - this.noteWithdrawalAmount - this.noteSettledOutAmount;
|
return this.noteRows.reduce((s, r) => s + Number(r.netAmount ?? 0), 0);
|
||||||
}
|
}
|
||||||
get coinRows(): any[] {
|
get coinRows(): any[] {
|
||||||
return this.rows.filter((r) => r.kind === 'coin');
|
return this.rows.filter((r) => r.kind === 'coin');
|
||||||
|
|||||||
@@ -152,9 +152,21 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
@for (r of g.rows; track r.voucherNo) {
|
@for (r of g.rows; track r.voucherNo) {
|
||||||
<!-- The left edge repeats the section's own colour, so a row still says which part it
|
<!-- The left edge repeats the section's own colour, so a row still says which part it
|
||||||
is when the heading has scrolled away. -->
|
is when the heading has scrolled away. Clicking anywhere on the row opens the full
|
||||||
<tr [class]="'mw-row mw-row--' + r.myRole">
|
slip -- every line, not just the first debit and first credit this table has room
|
||||||
<td class="mw-vno">{{ r.voucherNo }}</td>
|
to show. -->
|
||||||
|
<tr
|
||||||
|
[class]="'mw-row mw-row--' + r.myRole"
|
||||||
|
class="mw-row--clickable"
|
||||||
|
(click)="openDetail(r.voucherNo)"
|
||||||
|
[attr.aria-busy]="loadingDetailFor === r.voucherNo"
|
||||||
|
>
|
||||||
|
<td class="mw-vno">
|
||||||
|
{{ r.voucherNo }}
|
||||||
|
@if (loadingDetailFor === r.voucherNo) {
|
||||||
|
<mat-icon class="mw-vno__spinner" inline>hourglass_empty</mat-icon>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="mw-badge" [class]="'mw-badge--' + (r.side || 'JV')">{{ sideLabel(r.side) }}</span>
|
<span class="mw-badge" [class]="'mw-badge--' + (r.side || 'JV')">{{ sideLabel(r.side) }}</span>
|
||||||
</td>
|
</td>
|
||||||
@@ -171,7 +183,7 @@
|
|||||||
<span class="mw-acct">{{ r.creditAccount }}</span>
|
<span class="mw-acct">{{ r.creditAccount }}</span>
|
||||||
}
|
}
|
||||||
@if (r.extraLines > 0) {
|
@if (r.extraLines > 0) {
|
||||||
<span class="mw-more">+{{ r.extraLines }} more</span>
|
<span class="mw-more" title="Click the row to see every line">+{{ r.extraLines }} more</span>
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
<td class="num">₹{{ r.amount | number: '1.2-2' }}</td>
|
<td class="num">₹{{ r.amount | number: '1.2-2' }}</td>
|
||||||
|
|||||||
@@ -178,6 +178,21 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mw-row--clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mw-vno__spinner {
|
||||||
|
margin-left: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
vertical-align: middle;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
/* One section per part played. The heading carries the section's own count and total, so a maker's
|
/* One section per part played. The heading carries the section's own count and total, so a maker's
|
||||||
tally and a checker's are each readable without adding rows up. */
|
tally and a checker's are each readable without adding rows up. */
|
||||||
.mw-group {
|
.mw-group {
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ import { Component, OnInit, inject } from '@angular/core';
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { MatIconModule } from '@angular/material/icon';
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
import { catchError, of } from 'rxjs';
|
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||||
|
import { catchError, finalize, of } from 'rxjs';
|
||||||
import { CashierService } from '../cashier.service';
|
import { CashierService } from '../cashier.service';
|
||||||
|
import { VoucherDetailDialogComponent } from './voucher-detail-dialog.component';
|
||||||
|
|
||||||
/** What the signed-in user's part in a voucher was. */
|
/** What the signed-in user's part in a voucher was. */
|
||||||
export type MyRole = 'RAISED' | 'VERIFIED' | 'RAISED_AND_VERIFIED';
|
export type MyRole = 'RAISED' | 'VERIFIED' | 'RAISED_AND_VERIFIED';
|
||||||
@@ -72,11 +74,17 @@ interface MyWorkReport {
|
|||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
MatIconModule
|
MatIconModule,
|
||||||
|
MatDialogModule
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class MyWorkComponent implements OnInit {
|
export class MyWorkComponent implements OnInit {
|
||||||
private service = inject(CashierService);
|
private service = inject(CashierService);
|
||||||
|
private dialog = inject(MatDialog);
|
||||||
|
|
||||||
|
/** Which voucher's detail is currently being fetched, so the row that was clicked can show its
|
||||||
|
* own spinner instead of a page-wide one for what is a small, single-row lookup. */
|
||||||
|
loadingDetailFor: string | null = null;
|
||||||
|
|
||||||
/** Day is the working default; a month is what a close or an audit asks for. */
|
/** Day is the working default; a month is what a close or an audit asks for. */
|
||||||
period: 'DAY' | 'MONTH' = 'DAY';
|
period: 'DAY' | 'MONTH' = 'DAY';
|
||||||
@@ -323,6 +331,31 @@ export class MyWorkComponent implements OnInit {
|
|||||||
return { from: first, to: lastIso };
|
return { from: first, to: lastIso };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Opens the full slip for one voucher — every debit and credit line, not just the first side of
|
||||||
|
* each a table row has room to show. */
|
||||||
|
openDetail(voucherNo: string): void {
|
||||||
|
if (this.loadingDetailFor) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.loadingDetailFor = voucherNo;
|
||||||
|
this.service
|
||||||
|
.voucherDetail(voucherNo)
|
||||||
|
.pipe(
|
||||||
|
catchError(() => of(null)),
|
||||||
|
finalize(() => (this.loadingDetailFor = null))
|
||||||
|
)
|
||||||
|
.subscribe((detail: any) => {
|
||||||
|
if (!detail) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.dialog.open(VoucherDetailDialogComponent, {
|
||||||
|
data: { detail },
|
||||||
|
width: '600px',
|
||||||
|
autoFocus: false
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private todayIso(): string {
|
private todayIso(): string {
|
||||||
const d = new Date();
|
const d = new Date();
|
||||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||||
|
|||||||
@@ -0,0 +1,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' }} · {{ 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 & Income GL when account is <strong>activated</strong>.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ── DENOMINATION (Cash Only) ── -->
|
|
||||||
@if (isCashPayment && allDenominations.length > 0) {
|
|
||||||
<div class="pg-card sc-denom">
|
|
||||||
<div class="sc-breakdown__header">
|
|
||||||
<mat-icon>payments</mat-icon>
|
|
||||||
<span>Denomination Entry</span>
|
|
||||||
@if (denominationTotal > 0) {
|
|
||||||
<span
|
|
||||||
class="sc-denom-badge"
|
|
||||||
[class.sc-denom-badge--match]="denomMatchesTotal"
|
|
||||||
[class.sc-denom-badge--mismatch]="!denomMatchesTotal"
|
|
||||||
>
|
|
||||||
₹{{ denominationTotal | number: '1.2-2' }}
|
|
||||||
{{ denomMatchesTotal ? '✓ Matches' : '≠ Mismatch' }}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
@if (denominationTotal === 0) {
|
|
||||||
<span class="sc-denom-required">⚠ Required for Cash</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<div class="sc-denom-body">
|
|
||||||
<table class="pg-table sc-denom-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Denomination</th>
|
|
||||||
<th class="num">Count</th>
|
|
||||||
<th class="num">Amount</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@for (d of allDenominations; track d.id) {
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<span class="sc-denom-chip" [class.sc-denom-chip--coin]="d.kind === 'coin'">{{ d.label }}</span>
|
|
||||||
</td>
|
|
||||||
<td class="num">
|
|
||||||
<div class="sc-denom-stepper">
|
|
||||||
<button type="button" (click)="bumpDenom(d, -1)" [disabled]="countFor(d) === 0">−</button>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
[value]="countFor(d) || ''"
|
|
||||||
placeholder="0"
|
|
||||||
(input)="onDenomChange(d, $any($event.target).value)"
|
|
||||||
/>
|
|
||||||
<button type="button" (click)="bumpDenom(d, 1)">+</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="num">{{ subtotalFor(d) > 0 ? (subtotalFor(d) | number: '1.2-2') : '—' }}</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
<tfoot>
|
|
||||||
<tr>
|
|
||||||
<td><strong>Total</strong></td>
|
|
||||||
<td></td>
|
|
||||||
<td class="num">
|
|
||||||
<strong>₹{{ denominationTotal | number: '1.2-2' }}</strong>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tfoot>
|
|
||||||
</table>
|
|
||||||
@if (!denomMatchesTotal && denominationTotal > 0) {
|
|
||||||
<div class="sc-denom-mismatch">
|
|
||||||
<mat-icon>warning</mat-icon>
|
|
||||||
Denomination ₹{{ denominationTotal | number: '1.2-2' }} must equal Total Payable ₹{{
|
|
||||||
totalAmount | number: '1.2-2'
|
|
||||||
}}.
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@if (denominationTotal > 0) {
|
|
||||||
<div class="sc-denom-actions">
|
|
||||||
<button type="button" class="pg-btn pg-btn--ghost" (click)="clearDenominations()">
|
|
||||||
<mat-icon>clear</mat-icon> Clear
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- Transaction Details -->
|
|
||||||
<div class="pg-toolbar pg-toolbar--mat">
|
|
||||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
|
||||||
<mat-label>Transaction Date *</mat-label>
|
|
||||||
<input matInput type="date" [(ngModel)]="transactionDate" />
|
|
||||||
</mat-form-field>
|
|
||||||
|
|
||||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
|
||||||
<mat-label>Payment Mode</mat-label>
|
|
||||||
<mat-select [(ngModel)]="paymentTypeId">
|
|
||||||
<mat-option [value]="null">— Cash (Default) —</mat-option>
|
|
||||||
@for (pt of paymentTypes; track pt.id) {
|
|
||||||
<mat-option [value]="pt.id">{{ pt.name }}</mat-option>
|
|
||||||
}
|
|
||||||
</mat-select>
|
|
||||||
</mat-form-field>
|
|
||||||
|
|
||||||
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="pg-note-field">
|
|
||||||
<mat-label>Note (Optional)</mat-label>
|
|
||||||
<input matInput type="text" [(ngModel)]="note" placeholder="e.g. Share purchase for 100 shares" />
|
|
||||||
</mat-form-field>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Footer Submit Bar -->
|
|
||||||
<div class="pg-total-bar sc-footer">
|
|
||||||
<div class="sc-footer__summary">
|
|
||||||
<span class="sc-footer__label">Total to Collect</span>
|
|
||||||
<span class="sc-footer__amount">{{ currencySymbol }}{{ totalAmount | number: '1.2-2' }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="sc-footer__actions">
|
|
||||||
<button type="button" class="pg-btn pg-btn--ghost" (click)="reset()"><mat-icon>close</mat-icon> Cancel</button>
|
|
||||||
<button type="button" class="pg-btn pg-btn--primary" [disabled]="!isValid || saving" (click)="submit()">
|
|
||||||
<mat-icon>payments</mat-icon>
|
|
||||||
{{ saving ? 'Posting…' : 'Collect & Post' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- ── SUCCESS ── -->
|
|
||||||
@if (pageState === 'success' && collectionResult) {
|
|
||||||
<div class="sc-success">
|
|
||||||
<div class="sc-success__icon-wrap">
|
|
||||||
<mat-icon>check_circle</mat-icon>
|
|
||||||
</div>
|
|
||||||
<h2 class="sc-success__title">Collection Posted Successfully</h2>
|
|
||||||
<p class="sc-success__sub">
|
|
||||||
Cash of
|
|
||||||
<strong>{{ collectionResult.currencyCode }} {{ collectionResult.totalAmount | number: '1.2-2' }}</strong>
|
|
||||||
collected from <strong>{{ collectionResult.clientName }}</strong> on
|
|
||||||
<strong>{{ collectionResult.collectionDate }}</strong> by <strong>{{ collectionResult.collectedBy }}</strong
|
|
||||||
>.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- Transaction reference -->
|
|
||||||
<div class="sc-success__ref">
|
|
||||||
<span class="sc-success__ref-label">Transaction ID</span>
|
|
||||||
<span class="sc-success__ref-val">{{ collectionResult.transactionId }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- GL entries from backend -->
|
|
||||||
<div class="sc-success__gl">
|
|
||||||
@for (entry of collectionResult.glEntries; track entry.account) {
|
|
||||||
<div
|
|
||||||
class="sc-success__gl-row"
|
|
||||||
[class.sc-success__gl-row--dr]="entry.type === 'Dr'"
|
|
||||||
[class.sc-success__gl-row--cr]="entry.type === 'Cr'"
|
|
||||||
>
|
|
||||||
<span>{{ entry.type }}. {{ entry.account }}</span>
|
|
||||||
<span>{{ entry.currency }} {{ entry.amount | number: '1.2-2' }}</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="sc-success__note">
|
|
||||||
Funds are held in Sundry GL. They will move to Share Equity GL when the account is activated.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<button type="button" class="pg-btn pg-btn--primary" (click)="reset()">
|
|
||||||
<mat-icon>add</mat-icon> New Collection
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
@@ -1,701 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright since 2025 Mifos Initiative
|
|
||||||
*
|
|
||||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
|
||||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
||||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
||||||
*/
|
|
||||||
|
|
||||||
// ── Search Bar ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
.sc-search-bar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
background: #fff;
|
|
||||||
border: 1px solid #e2e8f0;
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 10px 14px;
|
|
||||||
|
|
||||||
&__input-wrap {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
flex: 1 1 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__icon {
|
|
||||||
color: #94a3b8;
|
|
||||||
font-size: 20px;
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__input {
|
|
||||||
flex: 1 1 auto;
|
|
||||||
border: none;
|
|
||||||
outline: none;
|
|
||||||
font-size: 14px;
|
|
||||||
font-family: inherit;
|
|
||||||
color: #0d1b2a;
|
|
||||||
background: transparent;
|
|
||||||
|
|
||||||
&::placeholder {
|
|
||||||
color: #94a3b8;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Alert ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
.sc-alert {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 12px 16px;
|
|
||||||
border-radius: 10px;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 500;
|
|
||||||
|
|
||||||
mat-icon {
|
|
||||||
font-size: 20px;
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--error {
|
|
||||||
background: #fef2f2;
|
|
||||||
border: 1px solid #fecaca;
|
|
||||||
color: #dc2626;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Spinner ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
.sc-spin {
|
|
||||||
animation: sc-spin 1.2s linear infinite;
|
|
||||||
display: block;
|
|
||||||
margin: 0 auto 8px;
|
|
||||||
font-size: 32px !important;
|
|
||||||
width: 32px !important;
|
|
||||||
height: 32px !important;
|
|
||||||
color: #94a3b8;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes sc-spin {
|
|
||||||
from {
|
|
||||||
transform: rotate(0deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Member Card ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
.sc-member-card {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 16px;
|
|
||||||
background: #fff;
|
|
||||||
border: 1px solid #e2e8f0;
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 16px 20px;
|
|
||||||
|
|
||||||
&__avatar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 48px;
|
|
||||||
height: 48px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: #eff6ff;
|
|
||||||
flex-shrink: 0;
|
|
||||||
|
|
||||||
mat-icon {
|
|
||||||
color: #2563eb;
|
|
||||||
font-size: 24px;
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__info {
|
|
||||||
flex: 1 1 auto;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__name {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #1e293b;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__meta {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__shares {
|
|
||||||
flex-shrink: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Badges ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
.sc-badge {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
height: 22px;
|
|
||||||
padding: 0 8px;
|
|
||||||
border-radius: 5px;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
background: #f1f5f9;
|
|
||||||
color: #475569;
|
|
||||||
border: 1px solid #e2e8f0;
|
|
||||||
|
|
||||||
&--account {
|
|
||||||
background: #eff6ff;
|
|
||||||
color: #1d4ed8;
|
|
||||||
border-color: #bfdbfe;
|
|
||||||
font-family: 'Roboto Mono', monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--product {
|
|
||||||
background: #f0fdf4;
|
|
||||||
color: #15803d;
|
|
||||||
border-color: #bbf7d0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--pending {
|
|
||||||
background: #fffbeb;
|
|
||||||
color: #d97706;
|
|
||||||
border-color: #fde68a;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--active {
|
|
||||||
background: #f0fdf4;
|
|
||||||
color: #15803d;
|
|
||||||
border-color: #bbf7d0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Editable Share Inputs ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
.sc-shares-inputs {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sc-shares-field {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sc-shares-label {
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.07em;
|
|
||||||
color: #94a3b8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sc-shares-input {
|
|
||||||
width: 110px;
|
|
||||||
height: 38px;
|
|
||||||
border: 1.5px solid #e2e8f0;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 0 10px;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 700;
|
|
||||||
font-family: inherit;
|
|
||||||
color: #1353a0;
|
|
||||||
text-align: right;
|
|
||||||
outline: none;
|
|
||||||
background: #f8fafc;
|
|
||||||
transition: border-color 0.15s;
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
border-color: #1353a0;
|
|
||||||
background: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::-webkit-outer-spin-button,
|
|
||||||
&::-webkit-inner-spin-button {
|
|
||||||
appearance: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.sc-shares-sep {
|
|
||||||
font-size: 20px;
|
|
||||||
font-weight: 300;
|
|
||||||
color: #94a3b8;
|
|
||||||
padding-top: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sc-shares-result {
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sc-shares-total {
|
|
||||||
font-size: 20px;
|
|
||||||
font-weight: 800;
|
|
||||||
color: #0f4f3c;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Editable Charge Amount ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
.sc-charge-amount {
|
|
||||||
padding: 4px 14px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sc-charge-input {
|
|
||||||
width: 100px;
|
|
||||||
height: 32px;
|
|
||||||
border: 1.5px solid #e2e8f0;
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 0 8px;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 700;
|
|
||||||
font-family: inherit;
|
|
||||||
color: #1353a0;
|
|
||||||
text-align: right;
|
|
||||||
outline: none;
|
|
||||||
background: #f8fafc;
|
|
||||||
transition: border-color 0.15s;
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
border-color: #1353a0;
|
|
||||||
background: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::-webkit-outer-spin-button,
|
|
||||||
&::-webkit-inner-spin-button {
|
|
||||||
appearance: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Split Layout ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
.sc-split {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 16px;
|
|
||||||
|
|
||||||
@media (width <= 900px) {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Breakdown Card ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
.sc-breakdown {
|
|
||||||
&__header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 12px 16px;
|
|
||||||
background: #f8fafc;
|
|
||||||
border-bottom: 1px solid #e2e8f0;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #334155;
|
|
||||||
|
|
||||||
mat-icon {
|
|
||||||
font-size: 18px;
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
color: #0f4f3c;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.sc-gl-preview {
|
|
||||||
.sc-breakdown__header {
|
|
||||||
mat-icon {
|
|
||||||
color: #1d4ed8;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.sc-gl-note {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 10px 14px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #64748b;
|
|
||||||
background: #f8fafc;
|
|
||||||
border-top: 1px solid #e2e8f0;
|
|
||||||
|
|
||||||
mat-icon {
|
|
||||||
font-size: 16px;
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
margin-top: 1px;
|
|
||||||
color: #94a3b8;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Table helpers ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
.sc-dim {
|
|
||||||
color: #94a3b8;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sc-total-row td {
|
|
||||||
background: #f8fafc;
|
|
||||||
border-top: 2px solid #e2e8f0;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sc-total-amount {
|
|
||||||
color: #0f4f3c;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Dr / Cr badges ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
.sc-dr-badge,
|
|
||||||
.sc-cr-badge {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 32px;
|
|
||||||
height: 20px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 800;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sc-dr-badge {
|
|
||||||
background: #fef2f2;
|
|
||||||
color: #dc2626;
|
|
||||||
border: 1px solid #fecaca;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sc-cr-badge {
|
|
||||||
background: #f0fdf4;
|
|
||||||
color: #15803d;
|
|
||||||
border: 1px solid #bbf7d0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Footer ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
.sc-footer {
|
|
||||||
justify-content: space-between !important;
|
|
||||||
|
|
||||||
&__summary {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__label {
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.07em;
|
|
||||||
color: #64748b;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__amount {
|
|
||||||
font-size: 22px;
|
|
||||||
font-weight: 800;
|
|
||||||
color: #0f4f3c;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Denomination Panel ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
.sc-denom {
|
|
||||||
padding: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sc-denom-body {
|
|
||||||
padding: 14px 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sc-denom-table {
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
th,
|
|
||||||
td {
|
|
||||||
padding: 7px 10px;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
tfoot td {
|
|
||||||
border-top: 2px solid #e2e8f0;
|
|
||||||
background: #f8fafc;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.sc-denom-chip {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
height: 24px;
|
|
||||||
padding: 0 10px;
|
|
||||||
border-radius: 5px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
background: #eff6ff;
|
|
||||||
color: #1d4ed8;
|
|
||||||
border: 1px solid #bfdbfe;
|
|
||||||
|
|
||||||
&--coin {
|
|
||||||
background: #fffbeb;
|
|
||||||
color: #d97706;
|
|
||||||
border-color: #fde68a;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.sc-denom-stepper {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
justify-content: flex-end;
|
|
||||||
|
|
||||||
button {
|
|
||||||
width: 26px;
|
|
||||||
height: 26px;
|
|
||||||
border: 1.5px solid #e2e8f0;
|
|
||||||
border-radius: 5px;
|
|
||||||
background: #f8fafc;
|
|
||||||
color: #475569;
|
|
||||||
font-size: 16px;
|
|
||||||
line-height: 1;
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 0;
|
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
|
||||||
background: #e0f2fe;
|
|
||||||
border-color: #7dd3fc;
|
|
||||||
color: #0369a1;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:disabled {
|
|
||||||
opacity: 0.35;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
input {
|
|
||||||
width: 62px;
|
|
||||||
height: 28px;
|
|
||||||
border: 1.5px solid #e2e8f0;
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 0 6px;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
font-family: inherit;
|
|
||||||
text-align: right;
|
|
||||||
outline: none;
|
|
||||||
background: #fff;
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
border-color: #1353a0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::-webkit-outer-spin-button,
|
|
||||||
&::-webkit-inner-spin-button {
|
|
||||||
appearance: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.sc-denom-badge {
|
|
||||||
margin-left: auto;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
height: 22px;
|
|
||||||
padding: 0 10px;
|
|
||||||
border-radius: 5px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
|
|
||||||
&--match {
|
|
||||||
background: #f0fdf4;
|
|
||||||
color: #15803d;
|
|
||||||
border: 1px solid #bbf7d0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--mismatch {
|
|
||||||
background: #fef2f2;
|
|
||||||
color: #dc2626;
|
|
||||||
border: 1px solid #fecaca;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.sc-denom-required {
|
|
||||||
margin-left: auto;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #d97706;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sc-denom-mismatch {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
margin-top: 10px;
|
|
||||||
padding: 9px 12px;
|
|
||||||
background: #fef2f2;
|
|
||||||
border: 1px solid #fecaca;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #dc2626;
|
|
||||||
|
|
||||||
mat-icon {
|
|
||||||
font-size: 16px;
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.sc-denom-actions {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
margin-top: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Success ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
.sc-success {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
text-align: center;
|
|
||||||
padding: 40px 24px;
|
|
||||||
gap: 16px;
|
|
||||||
background: #fff;
|
|
||||||
border: 1px solid #e2e8f0;
|
|
||||||
border-radius: 16px;
|
|
||||||
|
|
||||||
&__icon-wrap {
|
|
||||||
width: 72px;
|
|
||||||
height: 72px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: #f0fdf4;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
mat-icon {
|
|
||||||
font-size: 40px;
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
color: #16a34a;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__title {
|
|
||||||
font-size: 22px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #1e293b;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__sub {
|
|
||||||
font-size: 14px;
|
|
||||||
color: #64748b;
|
|
||||||
max-width: 480px;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__ref {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
background: #f1f5f9;
|
|
||||||
border: 1px solid #e2e8f0;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 8px 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__ref-label {
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.07em;
|
|
||||||
color: #94a3b8;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__ref-val {
|
|
||||||
font-family: 'Roboto Mono', 'Courier New', monospace;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #1353a0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__note {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #94a3b8;
|
|
||||||
max-width: 420px;
|
|
||||||
margin: 0;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__gl {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 400px;
|
|
||||||
background: #f8fafc;
|
|
||||||
border: 1px solid #e2e8f0;
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 14px 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__gl-row {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
padding: 4px 0;
|
|
||||||
border-bottom: 1px dotted #e2e8f0;
|
|
||||||
|
|
||||||
&:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--dr {
|
|
||||||
color: #dc2626;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--cr {
|
|
||||||
color: #15803d;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,282 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright since 2025 Mifos Initiative
|
|
||||||
*
|
|
||||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
|
||||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
||||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Component, OnInit, inject } from '@angular/core';
|
|
||||||
import { CommonModule } from '@angular/common';
|
|
||||||
import { FormsModule } from '@angular/forms';
|
|
||||||
import { RouterModule } from '@angular/router';
|
|
||||||
import { MatIconModule } from '@angular/material/icon';
|
|
||||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
|
||||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
|
||||||
import { MatSelectModule } from '@angular/material/select';
|
|
||||||
import { MatInputModule } from '@angular/material/input';
|
|
||||||
import { catchError } from 'rxjs/operators';
|
|
||||||
import { of } from 'rxjs';
|
|
||||||
import { CashierService } from '../cashier.service';
|
|
||||||
|
|
||||||
type PageState = 'idle' | 'searching' | 'loaded' | 'submitting' | 'success';
|
|
||||||
|
|
||||||
interface Denomination {
|
|
||||||
id: number;
|
|
||||||
value: number;
|
|
||||||
kind: 'note' | 'coin';
|
|
||||||
label: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ShareCharge {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
amount: number;
|
|
||||||
currencySymbol: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'mifosx-share-collection',
|
|
||||||
templateUrl: './share-collection.component.html',
|
|
||||||
styleUrls: [
|
|
||||||
'../cashier-shared.scss',
|
|
||||||
'./share-collection.component.scss'
|
|
||||||
],
|
|
||||||
standalone: true,
|
|
||||||
imports: [
|
|
||||||
CommonModule,
|
|
||||||
FormsModule,
|
|
||||||
RouterModule,
|
|
||||||
MatIconModule,
|
|
||||||
MatSnackBarModule,
|
|
||||||
MatFormFieldModule,
|
|
||||||
MatSelectModule,
|
|
||||||
MatInputModule
|
|
||||||
]
|
|
||||||
})
|
|
||||||
export class ShareCollectionComponent implements OnInit {
|
|
||||||
private service = inject(CashierService);
|
|
||||||
private snackBar = inject(MatSnackBar);
|
|
||||||
|
|
||||||
pageState: PageState = 'idle';
|
|
||||||
accountInput = '';
|
|
||||||
transactionDate = '';
|
|
||||||
paymentTypeId: number | null = null;
|
|
||||||
paymentTypes: any[] = [];
|
|
||||||
note = '';
|
|
||||||
errorMsg = '';
|
|
||||||
saving = false;
|
|
||||||
|
|
||||||
account: any = null;
|
|
||||||
charges: ShareCharge[] = [];
|
|
||||||
collectionResult: any = null; // filled after successful POST
|
|
||||||
|
|
||||||
// Editable fields — cashier can override if API returns 0
|
|
||||||
requestedSharesInput = 0;
|
|
||||||
pricePerShare = 0;
|
|
||||||
|
|
||||||
// ── Denomination ────────────────────────────────────────────────────────────
|
|
||||||
notes: Denomination[] = [];
|
|
||||||
coins: Denomination[] = [];
|
|
||||||
denominationCounts: Record<string, number> = {};
|
|
||||||
|
|
||||||
get shareAmount(): number {
|
|
||||||
return this.requestedSharesInput * this.pricePerShare;
|
|
||||||
}
|
|
||||||
|
|
||||||
get totalChargeAmount(): number {
|
|
||||||
return this.charges.reduce((s, c) => s + c.amount, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
get totalAmount(): number {
|
|
||||||
return this.shareAmount + this.totalChargeAmount;
|
|
||||||
}
|
|
||||||
|
|
||||||
get currencySymbol(): string {
|
|
||||||
return this.account?.currency?.displaySymbol ?? '₹';
|
|
||||||
}
|
|
||||||
|
|
||||||
get isCashPayment(): boolean {
|
|
||||||
if (this.paymentTypeId === null) return true;
|
|
||||||
const pt = this.paymentTypes.find((p: any) => p.id === this.paymentTypeId);
|
|
||||||
return !!pt?.isCashPayment;
|
|
||||||
}
|
|
||||||
|
|
||||||
get allDenominations(): Denomination[] {
|
|
||||||
const desc = (a: Denomination, b: Denomination) => b.value - a.value;
|
|
||||||
return [
|
|
||||||
...[...this.notes].sort(desc),
|
|
||||||
...[...this.coins].sort(desc)
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
get denominationTotal(): number {
|
|
||||||
return this.allDenominations.reduce((s, d) => s + this.subtotalFor(d), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
get denomMatchesTotal(): boolean {
|
|
||||||
return this.denominationTotal === this.totalAmount;
|
|
||||||
}
|
|
||||||
|
|
||||||
get isValid(): boolean {
|
|
||||||
if (!this.account || this.totalAmount <= 0 || !this.transactionDate) return false;
|
|
||||||
if (this.isCashPayment && this.allDenominations.length > 0) {
|
|
||||||
return this.denomMatchesTotal;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
|
||||||
const d = new Date();
|
|
||||||
this.transactionDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
||||||
this.service
|
|
||||||
.getPaymentTypes()
|
|
||||||
.pipe(catchError(() => of([])))
|
|
||||||
.subscribe((types: any[]) => (this.paymentTypes = types || []));
|
|
||||||
this.loadDenominations();
|
|
||||||
}
|
|
||||||
|
|
||||||
private loadDenominations(): void {
|
|
||||||
this.service
|
|
||||||
.getActiveDenominations()
|
|
||||||
.pipe(catchError(() => of([])))
|
|
||||||
.subscribe((list: any[]) => {
|
|
||||||
const rows: Denomination[] = (list || []).map((d: any) => ({
|
|
||||||
id: d.id,
|
|
||||||
value: d.denomination ?? d.value,
|
|
||||||
kind: d.type === 'coin' ? 'coin' : 'note',
|
|
||||||
label: '₹' + (d.denomination ?? d.value)
|
|
||||||
}));
|
|
||||||
const order = (a: Denomination, b: Denomination) => b.value - a.value;
|
|
||||||
this.notes = rows.filter((r) => r.kind === 'note').sort(order);
|
|
||||||
this.coins = rows.filter((r) => r.kind === 'coin').sort(order);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Denomination helpers ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
denomKey(d: Denomination): string {
|
|
||||||
return `${d.kind}:${d.value}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
countFor(d: Denomination): number {
|
|
||||||
return this.denominationCounts[this.denomKey(d)] || 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
subtotalFor(d: Denomination): number {
|
|
||||||
return this.countFor(d) * d.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
onDenomChange(d: Denomination, raw: any): void {
|
|
||||||
this.denominationCounts[this.denomKey(d)] = Math.max(0, Math.floor(Number(raw) || 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
bumpDenom(d: Denomination, delta: number): void {
|
|
||||||
this.denominationCounts[this.denomKey(d)] = Math.max(0, this.countFor(d) + delta);
|
|
||||||
}
|
|
||||||
|
|
||||||
clearDenominations(): void {
|
|
||||||
this.denominationCounts = {};
|
|
||||||
}
|
|
||||||
|
|
||||||
searchAccount(): void {
|
|
||||||
const query = this.accountInput.trim();
|
|
||||||
if (!query) return;
|
|
||||||
this.pageState = 'searching';
|
|
||||||
this.account = null;
|
|
||||||
this.charges = [];
|
|
||||||
this.errorMsg = '';
|
|
||||||
|
|
||||||
this.service
|
|
||||||
.getShareAccountForCollection(query)
|
|
||||||
.pipe(
|
|
||||||
catchError((err) => {
|
|
||||||
this.errorMsg =
|
|
||||||
err?.error?.errors?.[0]?.defaultUserMessage ||
|
|
||||||
err?.error?.developerMessage ||
|
|
||||||
'Account not found. Please check the account ID and try again.';
|
|
||||||
this.pageState = 'idle';
|
|
||||||
return of(null);
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.subscribe((acc: any) => {
|
|
||||||
if (!acc) return;
|
|
||||||
this.account = acc;
|
|
||||||
|
|
||||||
// Auto-detect shares from summary or first pending purchasedShares entry
|
|
||||||
const pendingTx = (acc.purchasedShares || []).find(
|
|
||||||
(t: any) =>
|
|
||||||
t.status?.code === 'purchasedSharesStatusType.applied' && t.type?.code === 'purchasedSharesType.purchased'
|
|
||||||
);
|
|
||||||
this.requestedSharesInput = Number(
|
|
||||||
acc.summary?.totalPendingForApprovalShares || pendingTx?.numberOfShares || acc.requestedShares || 0
|
|
||||||
);
|
|
||||||
this.pricePerShare = Number(pendingTx?.purchasedPrice || acc.currentMarketPrice || acc.unitPrice || 0);
|
|
||||||
|
|
||||||
// Show ALL charges on the account — no type filtering
|
|
||||||
this.charges = (acc.charges || []).map((c: any) => ({
|
|
||||||
id: c.chargeId ?? c.id,
|
|
||||||
name: c.name,
|
|
||||||
amount: Number(c.amount ?? c.amountOutstanding ?? 0),
|
|
||||||
currencySymbol: c.currency?.displaySymbol ?? '₹'
|
|
||||||
}));
|
|
||||||
|
|
||||||
this.pageState = 'loaded';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
recalc(): void {
|
|
||||||
// triggered by input changes — nothing extra needed, getters auto-recalculate
|
|
||||||
}
|
|
||||||
|
|
||||||
submit(): void {
|
|
||||||
if (!this.isValid || this.saving) return;
|
|
||||||
this.saving = true;
|
|
||||||
const payload = {
|
|
||||||
shareAccountId: this.account.id,
|
|
||||||
accountNo: this.account.accountNo,
|
|
||||||
clientName: this.account.clientName,
|
|
||||||
transactionDate: this.transactionDate,
|
|
||||||
requestedShares: this.requestedSharesInput,
|
|
||||||
pricePerShare: this.pricePerShare,
|
|
||||||
shareAmount: this.shareAmount,
|
|
||||||
chargeAmount: this.totalChargeAmount,
|
|
||||||
totalAmount: this.totalAmount,
|
|
||||||
paymentTypeId: this.paymentTypeId || null,
|
|
||||||
note: this.note || null
|
|
||||||
};
|
|
||||||
|
|
||||||
this.service.collectSharePayment(payload).subscribe({
|
|
||||||
next: (result: any) => {
|
|
||||||
this.collectionResult = result;
|
|
||||||
this.snackBar.open('Share collection posted successfully.', 'Close', { duration: 3000 });
|
|
||||||
this.pageState = 'success';
|
|
||||||
this.saving = false;
|
|
||||||
},
|
|
||||||
error: (err: any) => {
|
|
||||||
const msg =
|
|
||||||
err?.error?.errors?.[0]?.defaultUserMessage ||
|
|
||||||
err?.error?.developerMessage ||
|
|
||||||
err?.error?.defaultUserMessage ||
|
|
||||||
'Collection failed. Please try again.';
|
|
||||||
this.snackBar.open(msg, 'Close', { duration: 8000 });
|
|
||||||
this.saving = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
reset(): void {
|
|
||||||
this.pageState = 'idle';
|
|
||||||
this.accountInput = '';
|
|
||||||
this.account = null;
|
|
||||||
this.charges = [];
|
|
||||||
this.errorMsg = '';
|
|
||||||
this.note = '';
|
|
||||||
this.paymentTypeId = null;
|
|
||||||
this.requestedSharesInput = 0;
|
|
||||||
this.pricePerShare = 0;
|
|
||||||
this.collectionResult = null;
|
|
||||||
this.saving = false;
|
|
||||||
this.clearDenominations();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
import { Component, OnInit, inject } from '@angular/core';
|
import { Component, OnInit, inject } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, FormArray, Validators } from '@angular/forms';
|
||||||
import { MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
|
import { MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
|
||||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
import { MatInputModule } from '@angular/material/input';
|
import { MatInputModule } from '@angular/material/input';
|
||||||
@@ -35,6 +35,35 @@ export interface CashierIssueDdDialogData {
|
|||||||
accountNo?: string;
|
accountNo?: string;
|
||||||
/** The account holder, for the same reason. */
|
/** The account holder, for the same reason. */
|
||||||
accountHolderName?: string;
|
accountHolderName?: string;
|
||||||
|
/**
|
||||||
|
* How many drafts this slip raises. Absent or 1 is the ordinary single draft and the dialog is
|
||||||
|
* unchanged; 2 or more turns it into a slip, asking for a face value, charge and beneficiary per
|
||||||
|
* draft and reporting the combined total the account will be debited for.
|
||||||
|
*/
|
||||||
|
ddCount?: number;
|
||||||
|
/**
|
||||||
|
* Whether the funding account is a GL account rather than a member's. Only affects wording: a GL
|
||||||
|
* account has no passbook, so it is not promised one.
|
||||||
|
*/
|
||||||
|
glFunded?: boolean;
|
||||||
|
/**
|
||||||
|
* The leaflets provisionally shown against each line of a slip.
|
||||||
|
*
|
||||||
|
* Only ever indicative. The server allocates the real numbers inside the locked transaction that
|
||||||
|
* submits the slip, so what is shown here is the next-available run at the moment the dialog was
|
||||||
|
* opened — accurate unless another clerk submits first, which is precisely why the numbers cannot
|
||||||
|
* be promised until submit returns them.
|
||||||
|
*/
|
||||||
|
previewDdNumbers?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One captured draft on a multi-DD slip. */
|
||||||
|
export interface DdSlipLineResult {
|
||||||
|
beneficiaryName: string;
|
||||||
|
payableAtBranch: string;
|
||||||
|
payableAtCity: string;
|
||||||
|
faceValue: number;
|
||||||
|
chargesAmount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -56,10 +85,20 @@ export interface CashierIssueDdDialogData {
|
|||||||
<div class="dd-dialog-header__title">
|
<div class="dd-dialog-header__title">
|
||||||
<mat-icon class="dd-icon">receipt_long</mat-icon>
|
<mat-icon class="dd-icon">receipt_long</mat-icon>
|
||||||
<div>
|
<div>
|
||||||
<h2>{{ isAccountDebit ? 'Account-Debit Demand Draft (DD)' : 'Walk-in Cash Demand Draft (DD)' }}</h2>
|
<h2>
|
||||||
|
@if (isSlip) {
|
||||||
|
Multi-DD Slip · {{ lineCount }} Demand Drafts
|
||||||
|
} @else {
|
||||||
|
{{ isAccountDebit ? 'Account-Debit Demand Draft (DD)' : 'Walk-in Cash Demand Draft (DD)' }}
|
||||||
|
}
|
||||||
|
</h2>
|
||||||
<p>
|
<p>
|
||||||
Next available DD leaflet auto-selected ·
|
@if (isSlip) {
|
||||||
{{ isAccountDebit ? 'Enter beneficiary details' : 'Enter purchaser & beneficiary details' }}
|
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 & beneficiary details' }}
|
||||||
|
}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -69,41 +108,91 @@ export interface CashierIssueDdDialogData {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<mat-dialog-content class="dd-dialog-body">
|
<mat-dialog-content class="dd-dialog-body">
|
||||||
<!-- Draft & Financials Summary Card -->
|
<!--
|
||||||
<div class="dd-summary-card">
|
Slip running total.
|
||||||
<!-- The number the customer will actually see printed on the instrument. "Auto-Selected
|
|
||||||
|
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
|
Leaflet" named the stock item rather than the draft, so it was not obvious that this
|
||||||
is the DD number being issued. -->
|
is the DD number being issued. -->
|
||||||
<div class="summary-col">
|
<div class="summary-col">
|
||||||
<span class="label">DD Number Being Issued</span>
|
<span class="label">DD Number Being Issued</span>
|
||||||
<strong class="dd-number-highlight">{{ data.leaflet.ddNumber }}</strong>
|
<strong class="dd-number-highlight">{{ data.leaflet.ddNumber }}</strong>
|
||||||
<span class="sublabel">{{ data.sponsorBankName || 'Sponsor Bank' }} · {{ data.branchName }}</span>
|
<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>
|
||||||
<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.
|
The account funding the draft, stated before the form.
|
||||||
@@ -121,123 +210,237 @@ export interface CashierIssueDdDialogData {
|
|||||||
· {{ data.accountHolderName }}
|
· {{ data.accountHolderName }}
|
||||||
}
|
}
|
||||||
<div class="dd-funding-sub">
|
<div class="dd-funding-sub">
|
||||||
₹{{ data.totalCashPayable | number: '1.2-2' }} will be debited from this account when a checker
|
@if (isSlip) {
|
||||||
approves. No cash is taken at the counter.
|
₹{{ 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>
|
</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>₹ </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>₹ </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
|
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
|
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.
|
re-capturing a name and Aadhaar here would invite a mismatch against the account on record.
|
||||||
-->
|
-->
|
||||||
@if (!isAccountDebit) {
|
@if (!isAccountDebit) {
|
||||||
<div class="form-section-title">Walk-in Purchaser Details</div>
|
<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">
|
<div class="form-row">
|
||||||
<mat-form-field appearance="outline" class="form-field-flex">
|
<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
|
<input
|
||||||
matInput
|
matInput
|
||||||
formControlName="walkinPurchaserName"
|
formControlName="payableAtBranch"
|
||||||
placeholder="e.g. RAMESH CHANDRA"
|
placeholder="e.g. FORT BRANCH"
|
||||||
|
maxlength="150"
|
||||||
style="text-transform: uppercase;"
|
style="text-transform: uppercase;"
|
||||||
/>
|
/>
|
||||||
<mat-error *ngIf="form.get('walkinPurchaserName')?.hasError('required')"
|
<mat-error *ngIf="form.get('payableAtBranch')?.hasError('required')"
|
||||||
>Purchaser name is required</mat-error
|
>Payable Branch 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>
|
||||||
|
|
||||||
<mat-form-field appearance="outline" class="form-field-flex">
|
<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
|
<input
|
||||||
matInput
|
matInput
|
||||||
formControlName="walkinPurchaserAadhaar"
|
formControlName="payableAtCity"
|
||||||
placeholder="12-digit Aadhaar"
|
placeholder="e.g. MUMBAI"
|
||||||
maxlength="12"
|
maxlength="100"
|
||||||
|
style="text-transform: uppercase;"
|
||||||
/>
|
/>
|
||||||
<mat-hint>Masked to last 4 digits before storage</mat-hint>
|
<mat-error *ngIf="form.get('payableAtCity')?.hasError('required')">Payable City is required</mat-error>
|
||||||
<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>
|
</mat-form-field>
|
||||||
</div>
|
</div>
|
||||||
|
</form>
|
||||||
<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>
|
|
||||||
</mat-dialog-content>
|
</mat-dialog-content>
|
||||||
|
|
||||||
<mat-dialog-actions class="dd-dialog-actions" align="end">
|
<mat-dialog-actions class="dd-dialog-actions" align="end">
|
||||||
|
@if (isSlip) {
|
||||||
|
<span class="dd-actions-total">
|
||||||
|
{{ lineCount }} drafts · <strong>₹{{ slipTotal | number: '1.2-2' }}</strong> to debit
|
||||||
|
</span>
|
||||||
|
}
|
||||||
<button mat-button type="button" (click)="onClose()">Cancel</button>
|
<button mat-button type="button" (click)="onClose()">Cancel</button>
|
||||||
<button mat-flat-button color="primary" [disabled]="form.invalid" (click)="onSubmit()">
|
<button mat-flat-button color="primary" [disabled]="activeForm.invalid" (click)="onSubmit()">
|
||||||
Save DD Details
|
{{ isSlip ? 'Save Slip Details' : 'Save DD Details' }}
|
||||||
</button>
|
</button>
|
||||||
</mat-dialog-actions>
|
</mat-dialog-actions>
|
||||||
</div>
|
</div>
|
||||||
@@ -395,6 +598,81 @@ export interface CashierIssueDdDialogData {
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
}
|
}
|
||||||
|
/* One card per draft on a slip — the boundary is what stops a clerk typing line 2's
|
||||||
|
beneficiary against line 1's amount. */
|
||||||
|
.dd-line-card {
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-left: 3px solid #0369a1;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 14px 16px 4px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
background: #fcfdff;
|
||||||
|
}
|
||||||
|
.dd-line-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.dd-line-badge {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.4px;
|
||||||
|
color: #ffffff;
|
||||||
|
background: #0369a1;
|
||||||
|
padding: 3px 9px;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
.dd-line-number {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
.dd-line-number em {
|
||||||
|
font-style: normal;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.dd-line-total {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #065f46;
|
||||||
|
background: #ecfdf5;
|
||||||
|
border: 1px solid #a7f3d0;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.dd-preview-note {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
padding: 9px 12px;
|
||||||
|
background: #fffbeb;
|
||||||
|
border: 1px solid #fde68a;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #78350f;
|
||||||
|
}
|
||||||
|
.dd-preview-note mat-icon {
|
||||||
|
color: #b45309;
|
||||||
|
font-size: 18px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.dd-actions-total {
|
||||||
|
margin-right: auto;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
.dd-actions-total strong {
|
||||||
|
color: #065f46;
|
||||||
|
}
|
||||||
`
|
`
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
@@ -404,13 +682,127 @@ export class CashierIssueDdDialogComponent implements OnInit {
|
|||||||
public data: CashierIssueDdDialogData = inject(MAT_DIALOG_DATA);
|
public data: CashierIssueDdDialogData = inject(MAT_DIALOG_DATA);
|
||||||
|
|
||||||
form!: FormGroup;
|
form!: FormGroup;
|
||||||
|
/** Built only in slip mode; `form` stays untouched so the single-draft path is unchanged. */
|
||||||
|
slipForm!: FormGroup;
|
||||||
|
|
||||||
/** Whether the draft is funded by debiting a member's account rather than by cash at the counter. */
|
/** Whether the draft is funded by debiting a member's account rather than by cash at the counter. */
|
||||||
get isAccountDebit(): boolean {
|
get isAccountDebit(): boolean {
|
||||||
return this.data.paymentMethod === 'ACCOUNT_DEBIT';
|
return this.data.paymentMethod === 'ACCOUNT_DEBIT';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** How many drafts this dialog is capturing — 1 unless the clerk asked for a slip. */
|
||||||
|
get lineCount(): number {
|
||||||
|
const n = Number(this.data.ddCount) || 1;
|
||||||
|
return n > 1 ? n : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether this dialog is capturing a multi-DD slip rather than a single draft. */
|
||||||
|
get isSlip(): boolean {
|
||||||
|
return this.lineCount > 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the debit lands on a GL account rather than a member's — wording only. */
|
||||||
|
get isGlFunded(): boolean {
|
||||||
|
return !!this.data.glFunded;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The form the Save button validates against — whichever of the two was built. */
|
||||||
|
get activeForm(): FormGroup {
|
||||||
|
return this.isSlip ? this.slipForm : this.form;
|
||||||
|
}
|
||||||
|
|
||||||
|
get slipLines(): FormArray {
|
||||||
|
return this.slipForm.get('lines') as FormArray;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The provisional leaflet number shown against a line, if the caller supplied a run of them. */
|
||||||
|
previewNumberAt(index: number): string | null {
|
||||||
|
return this.data.previewDdNumbers?.[index] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private lineValueAt(index: number, field: string): number {
|
||||||
|
return Number(this.slipLines.at(index)?.get(field)?.value) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GST on one line's charge.
|
||||||
|
*
|
||||||
|
* Rounded per line, matching the server, which computes and stores GST against each draft rather
|
||||||
|
* than once for the slip. Summing rounded lines and rounding a summed charge can differ by a
|
||||||
|
* paisa, and it is the per-line figure that ends up on the draft.
|
||||||
|
*/
|
||||||
|
lineGstAt(index: number): number {
|
||||||
|
const charges = this.lineValueAt(index, 'chargesAmount');
|
||||||
|
if (charges <= 0) return 0;
|
||||||
|
return Math.round(charges * (Number(this.data.gstPercentage) || 0)) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What one line adds to the debit — its face value, its charge and the GST on that charge. */
|
||||||
|
lineTotalAt(index: number): number {
|
||||||
|
return this.lineValueAt(index, 'faceValue') + this.lineValueAt(index, 'chargesAmount') + this.lineGstAt(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
get slipFaceValue(): number {
|
||||||
|
return this.slipLines.controls.reduce((sum, _c, i) => sum + this.lineValueAt(i, 'faceValue'), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
get slipCharges(): number {
|
||||||
|
return this.slipLines.controls.reduce((sum, _c, i) => sum + this.lineValueAt(i, 'chargesAmount'), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
get slipGst(): number {
|
||||||
|
return this.slipLines.controls.reduce((sum, _c, i) => sum + this.lineGstAt(i), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The figure the account is debited for — rounded up to the whole rupee, as the single flow does. */
|
||||||
|
get slipTotal(): number {
|
||||||
|
return Math.ceil(this.slipFaceValue + this.slipCharges + this.slipGst - 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
|
if (this.isSlip) {
|
||||||
|
this.slipForm = this.fb.group({
|
||||||
|
lines: this.fb.array(
|
||||||
|
Array.from({ length: this.lineCount }, () =>
|
||||||
|
this.fb.group({
|
||||||
|
beneficiaryName: [
|
||||||
|
'',
|
||||||
|
[
|
||||||
|
Validators.required,
|
||||||
|
Validators.maxLength(120)
|
||||||
|
]
|
||||||
|
],
|
||||||
|
payableAtBranch: [
|
||||||
|
'',
|
||||||
|
[Validators.required]
|
||||||
|
],
|
||||||
|
payableAtCity: [
|
||||||
|
'',
|
||||||
|
[Validators.required]
|
||||||
|
],
|
||||||
|
// Zero is rejected rather than merely warned about: the server refuses a line with a
|
||||||
|
// face value at or below zero, and a slip that fails there has already cost the clerk
|
||||||
|
// every other line they typed.
|
||||||
|
faceValue: [
|
||||||
|
null as
|
||||||
|
| number
|
||||||
|
| null,
|
||||||
|
[
|
||||||
|
Validators.required,
|
||||||
|
Validators.min(0.01)
|
||||||
|
]
|
||||||
|
],
|
||||||
|
chargesAmount: [
|
||||||
|
0,
|
||||||
|
[Validators.min(0)]
|
||||||
|
]
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Walk-in identity is required for cash and rejected outright for an account debit — the server
|
// Walk-in identity is required for cash and rejected outright for an account debit — the server
|
||||||
// refuses a walk-in name alongside an account — so the controls are only built when they apply.
|
// refuses a walk-in name alongside an account — so the controls are only built when they apply.
|
||||||
// Registering them unconditionally and merely hiding them would hold the form invalid on a
|
// Registering them unconditionally and merely hiding them would hold the form invalid on a
|
||||||
@@ -477,7 +869,25 @@ export class CashierIssueDdDialogComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onSubmit(): void {
|
onSubmit(): void {
|
||||||
if (this.form.invalid) return;
|
if (this.activeForm.invalid) return;
|
||||||
|
|
||||||
|
// A slip closes with its lines. There is no walk-in branch here: a slip is only ever offered on
|
||||||
|
// a member-account debit, where the purchaser is the account holder already on record.
|
||||||
|
if (this.isSlip) {
|
||||||
|
const lines: DdSlipLineResult[] = this.slipLines.controls.map((c) => {
|
||||||
|
const v = c.value;
|
||||||
|
return {
|
||||||
|
beneficiaryName: (v.beneficiaryName || '').trim().toUpperCase(),
|
||||||
|
payableAtBranch: (v.payableAtBranch || '').trim().toUpperCase(),
|
||||||
|
payableAtCity: (v.payableAtCity || '').trim().toUpperCase(),
|
||||||
|
faceValue: Number(v.faceValue) || 0,
|
||||||
|
chargesAmount: Number(v.chargesAmount) || 0
|
||||||
|
};
|
||||||
|
});
|
||||||
|
this.dialogRef.close({ lines });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const val = this.form.value;
|
const val = this.form.value;
|
||||||
const beneficiary = {
|
const beneficiary = {
|
||||||
beneficiaryName: (val.beneficiaryName || '').trim().toUpperCase(),
|
beneficiaryName: (val.beneficiaryName || '').trim().toUpperCase(),
|
||||||
|
|||||||
@@ -166,23 +166,50 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="ws-f ws-f--actyp">
|
<div class="ws-f ws-f--actyp">
|
||||||
<label class="ws-f__lbl" title="Account Type">AC. TP.</label>
|
<label class="ws-f__lbl" title="Account Type">AC. TP.</label>
|
||||||
<select
|
@if (acTypLockedByToken && row === selectedRow) {
|
||||||
class="ws-f__ctl"
|
<span
|
||||||
[(ngModel)]="row.acTyp"
|
class="ws-f__ctl ws-f__ctl--locked"
|
||||||
(click)="$event.stopPropagation()"
|
title="Locked — filled from a token-driven request, not a normal account entry"
|
||||||
(change)="
|
>{{ acTypLockedLabel }}</span
|
||||||
row.acTyp === 'GL' &&
|
>
|
||||||
row.typ !== 'Disbursement' &&
|
} @else {
|
||||||
row.typ !== 'Fund Transfer' &&
|
<select
|
||||||
row.typ !== 'Enter Bank Transfer' &&
|
class="ws-f__ctl"
|
||||||
(row.dbCr = 'Cr')
|
[(ngModel)]="row.acTyp"
|
||||||
"
|
(click)="$event.stopPropagation()"
|
||||||
>
|
(change)="
|
||||||
@for (a of acTypOptionsFor(row); track a) {
|
row.acTyp === 'GL' &&
|
||||||
<option [value]="a">{{ a }}</option>
|
row.typ !== 'Disbursement' &&
|
||||||
}
|
row.typ !== 'Fund Transfer' &&
|
||||||
</select>
|
row.typ !== 'Enter Bank Transfer' &&
|
||||||
|
(row.dbCr = 'Cr')
|
||||||
|
"
|
||||||
|
>
|
||||||
|
@for (a of acTypOptionsFor(row); track a) {
|
||||||
|
<option [value]="a">{{ a }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
@if (showsEntryType(row)) {
|
||||||
|
<div class="ws-f ws-f--paytype">
|
||||||
|
<label
|
||||||
|
class="ws-f__lbl ws-f__lbl--accent"
|
||||||
|
title="What this money was actually collected for — checked before a share application or DD issuance can adopt this voucher"
|
||||||
|
>ENTRY TYPE</label
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
class="ws-f__ctl ws-f__ctl--accent"
|
||||||
|
[(ngModel)]="row.entryTypeCvId"
|
||||||
|
(click)="$event.stopPropagation()"
|
||||||
|
>
|
||||||
|
<option [ngValue]="null">Select…</option>
|
||||||
|
@for (t of entryTypeOptions; track t.id) {
|
||||||
|
<option [ngValue]="t.id">{{ t.name }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -206,14 +233,20 @@
|
|||||||
? ddGlMapping?.ddPayableGlCode || 'Auto GL'
|
? ddGlMapping?.ddPayableGlCode || 'Auto GL'
|
||||||
: 'Account number'
|
: 'Account number'
|
||||||
"
|
"
|
||||||
[readonly]="row.typ === 'DD' && !isDdAccountDebitRow(row)"
|
[readonly]="
|
||||||
[class.ws-f__ctl--readonly]="row.typ === 'DD' && !isDdAccountDebitRow(row)"
|
(row.typ === 'DD' && !isDdAccountDebitRow(row)) || (acTypLockedByToken && row === selectedRow)
|
||||||
|
"
|
||||||
|
[class.ws-f__ctl--readonly]="
|
||||||
|
(row.typ === 'DD' && !isDdAccountDebitRow(row)) || (acTypLockedByToken && row === selectedRow)
|
||||||
|
"
|
||||||
[title]="
|
[title]="
|
||||||
isDdAccountDebitRow(row)
|
acTypLockedByToken && row === selectedRow
|
||||||
? 'Member account the draft is funded from'
|
? 'Locked — filled from a token-driven request, not a normal account entry'
|
||||||
: row.typ === 'DD'
|
: isDdAccountDebitRow(row)
|
||||||
? 'Configured Sponsor DD Payable GL (Auto-selected)'
|
? 'Member account the draft is funded from'
|
||||||
: 'Account number'
|
: row.typ === 'DD'
|
||||||
|
? 'Configured Sponsor DD Payable GL (Auto-selected)'
|
||||||
|
: 'Account number'
|
||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -286,9 +319,13 @@
|
|||||||
[value]="row.amount ?? ''"
|
[value]="row.amount ?? ''"
|
||||||
(input)="row.amount = +$any($event.target).value; syncShareLinkedAmounts()"
|
(input)="row.amount = +$any($event.target).value; syncShareLinkedAmounts()"
|
||||||
(click)="$event.stopPropagation()"
|
(click)="$event.stopPropagation()"
|
||||||
[placeholder]="row.acTyp === 'SHR' ? '0' : '0.00'"
|
[placeholder]="isMultiDdRow(row) ? 'Per draft' : row.acTyp === 'SHR' ? '0' : '0.00'"
|
||||||
[readonly]="!isClerk && !!(activeWithdrawalRequest || activeClosureRequest)"
|
[readonly]="
|
||||||
[class.ws-amt-input--locked]="!isClerk && !!(activeWithdrawalRequest || activeClosureRequest)"
|
isMultiDdRow(row) || (!isClerk && !!(activeWithdrawalRequest || activeClosureRequest))
|
||||||
|
"
|
||||||
|
[class.ws-amt-input--locked]="
|
||||||
|
isMultiDdRow(row) || (!isClerk && !!(activeWithdrawalRequest || activeClosureRequest))
|
||||||
|
"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@if (row.acTyp === 'SHR' && rowSharePrices[row.acNo]) {
|
@if (row.acTyp === 'SHR' && rowSharePrices[row.acNo]) {
|
||||||
@@ -298,6 +335,19 @@
|
|||||||
}}/share</span
|
}}/share</span
|
||||||
>
|
>
|
||||||
}
|
}
|
||||||
|
<!-- On a slip the face value belongs to each draft, so the row total is the sum of
|
||||||
|
them and is filled in by the dialog rather than typed here. A clerk-typed
|
||||||
|
figure could disagree with the drafts, and the account would be debited for
|
||||||
|
a total the drafts do not add up to. -->
|
||||||
|
@if (isMultiDdRow(row)) {
|
||||||
|
<span class="ws-f__hint">
|
||||||
|
{{
|
||||||
|
row.ddLines?.length
|
||||||
|
? 'Total of ' + row.ddLines?.length + ' drafts'
|
||||||
|
: 'Set per draft in Fill DD Details'
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
@if (row.acTyp === 'SHR' && (row.typ === 'Enter Bank Transfer' || row.typ === 'Fund Transfer')) {
|
@if (row.acTyp === 'SHR' && (row.typ === 'Enter Bank Transfer' || row.typ === 'Fund Transfer')) {
|
||||||
<div class="ws-f ws-f--cheqno">
|
<div class="ws-f ws-f--cheqno">
|
||||||
@@ -377,9 +427,36 @@
|
|||||||
<div class="ws-group ws-group--flex">
|
<div class="ws-group ws-group--flex">
|
||||||
<span class="ws-caption">DD Charges & GST — Admin Rate {{ ddGstPercentage }}%</span>
|
<span class="ws-caption">DD Charges & GST — Admin Rate {{ ddGstPercentage }}%</span>
|
||||||
<div class="ws-group__fields">
|
<div class="ws-group__fields">
|
||||||
|
<!-- How many drafts this one debit funds. Typed before the details are filled in,
|
||||||
|
because it is what decides how many beneficiary forms the dialog presents.
|
||||||
|
Offered only where the server can honour it: a member savings/current
|
||||||
|
account debit, never a GL or cash draft. -->
|
||||||
|
@if (canRowBeMultiDd(row)) {
|
||||||
|
<div class="ws-f ws-f--amt">
|
||||||
|
<label class="ws-f__lbl" title="Number of demand drafts to raise against this single debit"
|
||||||
|
>NO. OF DD</label
|
||||||
|
>
|
||||||
|
<div class="ws-amt-wrap">
|
||||||
|
<input
|
||||||
|
class="ws-f__ctl ws-amt-input"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
[max]="maxDdSlipLines"
|
||||||
|
step="1"
|
||||||
|
[value]="ddCountFor(row)"
|
||||||
|
(input)="onDdCountChange(row, +$any($event.target).value)"
|
||||||
|
(click)="$event.stopPropagation()"
|
||||||
|
placeholder="1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
@if (isMultiDdRow(row)) {
|
||||||
|
<span class="ws-f__hint">One debit · {{ ddCountFor(row) }} drafts</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
<div class="ws-f ws-f--amt">
|
<div class="ws-f ws-f--amt">
|
||||||
<label class="ws-f__lbl" title="DD issuance charges / commission">CHARGES</label>
|
<label class="ws-f__lbl" title="DD issuance charges / commission">CHARGES</label>
|
||||||
<div class="ws-amt-wrap">
|
<div class="ws-amt-wrap" [class.ws-amt-wrap--locked]="isMultiDdRow(row)">
|
||||||
<span class="ws-amt-prefix">₹</span>
|
<span class="ws-amt-prefix">₹</span>
|
||||||
<input
|
<input
|
||||||
class="ws-f__ctl ws-amt-input"
|
class="ws-f__ctl ws-amt-input"
|
||||||
@@ -388,8 +465,15 @@
|
|||||||
(input)="row.ddCharges = +$any($event.target).value"
|
(input)="row.ddCharges = +$any($event.target).value"
|
||||||
(click)="$event.stopPropagation()"
|
(click)="$event.stopPropagation()"
|
||||||
placeholder="0.00"
|
placeholder="0.00"
|
||||||
|
[readonly]="isMultiDdRow(row)"
|
||||||
|
[class.ws-amt-input--locked]="isMultiDdRow(row)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Charges are levied per draft and can differ between them, so on a slip they
|
||||||
|
are entered against each draft and totalled here. -->
|
||||||
|
@if (isMultiDdRow(row)) {
|
||||||
|
<span class="ws-f__hint">Set per draft</span>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="ws-f ws-f--amt">
|
<div class="ws-f ws-f--amt">
|
||||||
<label class="ws-f__lbl" title="GST on charges, at the admin-configured rate">
|
<label class="ws-f__lbl" title="GST on charges, at the admin-configured rate">
|
||||||
@@ -400,7 +484,7 @@
|
|||||||
<input
|
<input
|
||||||
class="ws-f__ctl ws-amt-input ws-amt-input--locked"
|
class="ws-f__ctl ws-amt-input ws-amt-input--locked"
|
||||||
type="text"
|
type="text"
|
||||||
[value]="ddGstAmountFor(row) | number: '1.2-2'"
|
[value]="ddGstDisplayFor(row)"
|
||||||
readonly
|
readonly
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -414,7 +498,7 @@
|
|||||||
<input
|
<input
|
||||||
class="ws-f__ctl ws-amt-input ws-amt-input--locked"
|
class="ws-f__ctl ws-amt-input ws-amt-input--locked"
|
||||||
type="text"
|
type="text"
|
||||||
[value]="ddTotalPayableFor(row)"
|
[value]="ddTotalDisplayFor(row)"
|
||||||
readonly
|
readonly
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -427,18 +511,27 @@
|
|||||||
[disabled]="saving"
|
[disabled]="saving"
|
||||||
(click)="openIssueWalkinDd(row); $event.stopPropagation()"
|
(click)="openIssueWalkinDd(row); $event.stopPropagation()"
|
||||||
[title]="
|
[title]="
|
||||||
isDdAccountDebitRow(row)
|
isMultiDdRow(row)
|
||||||
? 'Auto-selects next available leaflet and opens the beneficiary form'
|
? 'Opens one beneficiary form per draft; DD numbers are allocated on submission'
|
||||||
: 'Auto-selects next available leaflet and opens walk-in purchaser form'
|
: 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">{{
|
<mat-icon style="font-size: 18px; width: 18px; height: 18px">{{
|
||||||
row.ddDetails ? 'edit_note' : 'post_add'
|
row.ddDetails || row.ddLines?.length ? 'edit_note' : 'post_add'
|
||||||
}}</mat-icon>
|
}}</mat-icon>
|
||||||
<!-- Names the leaflet once details are captured, so the clerk can see at a
|
<!-- Names the leaflet once details are captured, so the clerk can see at a
|
||||||
glance which rows are ready before pressing Save. -->
|
glance which rows are ready before pressing Save. A slip names the count
|
||||||
|
instead: its numbers are not allocated until it is submitted. -->
|
||||||
<span>{{
|
<span>{{
|
||||||
row.ddDetails ? 'DD ' + row.ddDetails.ddNumber + ' · Edit Details' : 'Fill DD Details'
|
row.ddLines?.length
|
||||||
|
? row.ddLines?.length + ' Drafts Captured · Edit Slip'
|
||||||
|
: row.ddDetails
|
||||||
|
? 'DD ' + row.ddDetails.ddNumber + ' · Edit Details'
|
||||||
|
: isMultiDdRow(row)
|
||||||
|
? 'Fill ' + ddCountFor(row) + ' DD Details'
|
||||||
|
: 'Fill DD Details'
|
||||||
}}</span>
|
}}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1523,23 +1616,50 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="ws-f ws-f--actyp">
|
<div class="ws-f ws-f--actyp">
|
||||||
<label class="ws-f__lbl">AC. TP.</label>
|
<label class="ws-f__lbl">AC. TP.</label>
|
||||||
<select
|
@if (acTypLockedByToken && row === selectedRow) {
|
||||||
class="ws-f__ctl"
|
<span
|
||||||
[(ngModel)]="row.acTyp"
|
class="ws-f__ctl ws-f__ctl--locked"
|
||||||
(click)="$event.stopPropagation()"
|
title="Locked — filled from a token-driven request, not a normal account entry"
|
||||||
(change)="
|
>{{ acTypLockedLabel }}</span
|
||||||
row.acTyp === 'GL' &&
|
>
|
||||||
row.typ !== 'Disbursement' &&
|
} @else {
|
||||||
row.typ !== 'Fund Transfer' &&
|
<select
|
||||||
row.typ !== 'Enter Bank Transfer' &&
|
class="ws-f__ctl"
|
||||||
(row.dbCr = 'Cr')
|
[(ngModel)]="row.acTyp"
|
||||||
"
|
(click)="$event.stopPropagation()"
|
||||||
>
|
(change)="
|
||||||
@for (a of acTypOptionsFor(row); track a) {
|
row.acTyp === 'GL' &&
|
||||||
<option [value]="a">{{ a }}</option>
|
row.typ !== 'Disbursement' &&
|
||||||
}
|
row.typ !== 'Fund Transfer' &&
|
||||||
</select>
|
row.typ !== 'Enter Bank Transfer' &&
|
||||||
|
(row.dbCr = 'Cr')
|
||||||
|
"
|
||||||
|
>
|
||||||
|
@for (a of acTypOptionsFor(row); track a) {
|
||||||
|
<option [value]="a">{{ a }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
@if (showsEntryType(row)) {
|
||||||
|
<div class="ws-f ws-f--paytype">
|
||||||
|
<label
|
||||||
|
class="ws-f__lbl ws-f__lbl--accent"
|
||||||
|
title="What this money was actually collected for — checked before a share application or DD issuance can adopt this voucher"
|
||||||
|
>ENTRY TYPE</label
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
class="ws-f__ctl ws-f__ctl--accent"
|
||||||
|
[(ngModel)]="row.entryTypeCvId"
|
||||||
|
(click)="$event.stopPropagation()"
|
||||||
|
>
|
||||||
|
<option [ngValue]="null">Select…</option>
|
||||||
|
@for (t of entryTypeOptions; track t.id) {
|
||||||
|
<option [ngValue]="t.id">{{ t.name }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="ws-vdivider"></div>
|
<div class="ws-vdivider"></div>
|
||||||
@@ -1556,10 +1676,16 @@
|
|||||||
[placeholder]="
|
[placeholder]="
|
||||||
row.typ === 'DD' ? ddGlMapping?.ddPayableGlCode || 'Auto GL' : 'Account number'
|
row.typ === 'DD' ? ddGlMapping?.ddPayableGlCode || 'Auto GL' : 'Account number'
|
||||||
"
|
"
|
||||||
[readonly]="row.typ === 'DD'"
|
[readonly]="row.typ === 'DD' || (acTypLockedByToken && row === selectedRow)"
|
||||||
[class.ws-f__ctl--readonly]="row.typ === 'DD'"
|
[class.ws-f__ctl--readonly]="
|
||||||
|
row.typ === 'DD' || (acTypLockedByToken && row === selectedRow)
|
||||||
|
"
|
||||||
[title]="
|
[title]="
|
||||||
row.typ === 'DD' ? 'Configured Sponsor DD Payable GL (Auto-selected)' : 'Account number'
|
acTypLockedByToken && row === selectedRow
|
||||||
|
? 'Locked — filled from a token-driven request, not a normal account entry'
|
||||||
|
: row.typ === 'DD'
|
||||||
|
? 'Configured Sponsor DD Payable GL (Auto-selected)'
|
||||||
|
: 'Account number'
|
||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1628,10 +1754,12 @@
|
|||||||
[value]="row.amount ?? ''"
|
[value]="row.amount ?? ''"
|
||||||
(input)="row.amount = +$any($event.target).value; syncShareLinkedAmounts()"
|
(input)="row.amount = +$any($event.target).value; syncShareLinkedAmounts()"
|
||||||
(click)="$event.stopPropagation()"
|
(click)="$event.stopPropagation()"
|
||||||
[placeholder]="row.acTyp === 'SHR' ? '0' : '0.00'"
|
[placeholder]="isMultiDdRow(row) ? 'Per draft' : row.acTyp === 'SHR' ? '0' : '0.00'"
|
||||||
[readonly]="!isClerk && !!(activeWithdrawalRequest || activeClosureRequest)"
|
[readonly]="
|
||||||
|
isMultiDdRow(row) || (!isClerk && !!(activeWithdrawalRequest || activeClosureRequest))
|
||||||
|
"
|
||||||
[class.ws-amt-input--locked]="
|
[class.ws-amt-input--locked]="
|
||||||
!isClerk && !!(activeWithdrawalRequest || activeClosureRequest)
|
isMultiDdRow(row) || (!isClerk && !!(activeWithdrawalRequest || activeClosureRequest))
|
||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1642,6 +1770,19 @@
|
|||||||
}}/share</span
|
}}/share</span
|
||||||
>
|
>
|
||||||
}
|
}
|
||||||
|
<!-- On a slip the face value belongs to each draft, so the row total is the
|
||||||
|
sum of them and is filled in by the dialog rather than typed here. A
|
||||||
|
clerk-typed figure could disagree with the drafts, and the account would
|
||||||
|
be debited for a total the drafts do not add up to. -->
|
||||||
|
@if (isMultiDdRow(row)) {
|
||||||
|
<span class="ws-f__hint">
|
||||||
|
{{
|
||||||
|
row.ddLines?.length
|
||||||
|
? 'Total of ' + row.ddLines?.length + ' drafts'
|
||||||
|
: 'Set per draft in Fill DD Details'
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
@if (
|
@if (
|
||||||
row.acTyp === 'SHR' && (row.typ === 'Enter Bank Transfer' || row.typ === 'Fund Transfer')
|
row.acTyp === 'SHR' && (row.typ === 'Enter Bank Transfer' || row.typ === 'Fund Transfer')
|
||||||
@@ -1706,9 +1847,38 @@
|
|||||||
<div class="ws-group ws-group--flex">
|
<div class="ws-group ws-group--flex">
|
||||||
<span class="ws-caption">DD Charges & GST — Admin Rate {{ ddGstPercentage }}%</span>
|
<span class="ws-caption">DD Charges & GST — Admin Rate {{ ddGstPercentage }}%</span>
|
||||||
<div class="ws-group__fields">
|
<div class="ws-group__fields">
|
||||||
|
<!-- How many drafts this one debit funds. Typed before the details are filled
|
||||||
|
in, because it is what decides how many beneficiary forms the dialog
|
||||||
|
presents. Offered only where the server can honour it: a member
|
||||||
|
savings/current account debit, never a GL or cash draft. -->
|
||||||
|
@if (canRowBeMultiDd(row)) {
|
||||||
|
<div class="ws-f ws-f--amt">
|
||||||
|
<label
|
||||||
|
class="ws-f__lbl"
|
||||||
|
title="Number of demand drafts to raise against this single debit"
|
||||||
|
>NO. OF DD</label
|
||||||
|
>
|
||||||
|
<div class="ws-amt-wrap">
|
||||||
|
<input
|
||||||
|
class="ws-f__ctl ws-amt-input"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
[max]="maxDdSlipLines"
|
||||||
|
step="1"
|
||||||
|
[value]="ddCountFor(row)"
|
||||||
|
(input)="onDdCountChange(row, +$any($event.target).value)"
|
||||||
|
(click)="$event.stopPropagation()"
|
||||||
|
placeholder="1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
@if (isMultiDdRow(row)) {
|
||||||
|
<span class="ws-f__hint">One debit · {{ ddCountFor(row) }} drafts</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
<div class="ws-f ws-f--amt">
|
<div class="ws-f ws-f--amt">
|
||||||
<label class="ws-f__lbl" title="DD issuance charges / commission">CHARGES</label>
|
<label class="ws-f__lbl" title="DD issuance charges / commission">CHARGES</label>
|
||||||
<div class="ws-amt-wrap">
|
<div class="ws-amt-wrap" [class.ws-amt-wrap--locked]="isMultiDdRow(row)">
|
||||||
<span class="ws-amt-prefix">₹</span>
|
<span class="ws-amt-prefix">₹</span>
|
||||||
<input
|
<input
|
||||||
class="ws-f__ctl ws-amt-input"
|
class="ws-f__ctl ws-amt-input"
|
||||||
@@ -1717,8 +1887,15 @@
|
|||||||
(input)="row.ddCharges = +$any($event.target).value"
|
(input)="row.ddCharges = +$any($event.target).value"
|
||||||
(click)="$event.stopPropagation()"
|
(click)="$event.stopPropagation()"
|
||||||
placeholder="0.00"
|
placeholder="0.00"
|
||||||
|
[readonly]="isMultiDdRow(row)"
|
||||||
|
[class.ws-amt-input--locked]="isMultiDdRow(row)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Charges are levied per draft and can differ between them, so on a slip
|
||||||
|
they are entered against each draft and totalled here. -->
|
||||||
|
@if (isMultiDdRow(row)) {
|
||||||
|
<span class="ws-f__hint">Set per draft</span>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="ws-f ws-f--amt">
|
<div class="ws-f ws-f--amt">
|
||||||
<label class="ws-f__lbl" title="GST on charges, at the admin-configured rate">
|
<label class="ws-f__lbl" title="GST on charges, at the admin-configured rate">
|
||||||
@@ -1729,7 +1906,7 @@
|
|||||||
<input
|
<input
|
||||||
class="ws-f__ctl ws-amt-input ws-amt-input--locked"
|
class="ws-f__ctl ws-amt-input ws-amt-input--locked"
|
||||||
type="text"
|
type="text"
|
||||||
[value]="ddGstAmountFor(row) | number: '1.2-2'"
|
[value]="ddGstDisplayFor(row)"
|
||||||
readonly
|
readonly
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1743,7 +1920,7 @@
|
|||||||
<input
|
<input
|
||||||
class="ws-f__ctl ws-amt-input ws-amt-input--locked"
|
class="ws-f__ctl ws-amt-input ws-amt-input--locked"
|
||||||
type="text"
|
type="text"
|
||||||
[value]="ddTotalPayableFor(row)"
|
[value]="ddTotalDisplayFor(row)"
|
||||||
readonly
|
readonly
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1756,13 +1933,28 @@
|
|||||||
[disabled]="saving"
|
[disabled]="saving"
|
||||||
(click)="openIssueWalkinDd(row); $event.stopPropagation()"
|
(click)="openIssueWalkinDd(row); $event.stopPropagation()"
|
||||||
[title]="
|
[title]="
|
||||||
isDdAccountDebitRow(row)
|
isMultiDdRow(row)
|
||||||
? 'Auto-selects next available leaflet and opens the beneficiary form'
|
? 'Opens one beneficiary form per draft; DD numbers are allocated on submission'
|
||||||
: 'Auto-selects next available leaflet and opens walk-in purchaser form'
|
: 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>
|
<mat-icon style="font-size: 18px; width: 18px; height: 18px">{{
|
||||||
<span>Fill DD Details</span>
|
row.ddDetails || row.ddLines?.length ? 'edit_note' : 'post_add'
|
||||||
|
}}</mat-icon>
|
||||||
|
<!-- Names what has been captured, so the clerk can see at a glance which
|
||||||
|
rows are ready before pressing Save. A slip names its count instead:
|
||||||
|
its numbers are not allocated until it is submitted. -->
|
||||||
|
<span>{{
|
||||||
|
row.ddLines?.length
|
||||||
|
? row.ddLines?.length + ' Drafts Captured · Edit Slip'
|
||||||
|
: row.ddDetails
|
||||||
|
? 'DD ' + row.ddDetails.ddNumber + ' · Edit Details'
|
||||||
|
: isMultiDdRow(row)
|
||||||
|
? 'Fill ' + ddCountFor(row) + ' DD Details'
|
||||||
|
: 'Fill DD Details'
|
||||||
|
}}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -916,6 +916,20 @@ input.ws-f__ctl--readonly {
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* AC.TP shown as a locked label instead of the normal editable select, while the row is filled
|
||||||
|
from a token-driven request whose real account type row.acTyp cannot show (see
|
||||||
|
acTypLockedByToken in the component). */
|
||||||
|
span.ws-f__ctl--locked {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #475569;
|
||||||
|
cursor: not-allowed;
|
||||||
|
user-select: none;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
input.ws-amt-input--locked {
|
input.ws-amt-input--locked {
|
||||||
background: #fef9c3;
|
background: #fef9c3;
|
||||||
color: #713f12;
|
color: #713f12;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1142,6 +1142,46 @@
|
|||||||
</mat-select>
|
</mat-select>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
|
|
||||||
|
<!-- Risk profile. These are determinations about the customer rather than about anything they
|
||||||
|
transacted, and the re-rating policy reads them directly: a PEP or a sanctions match holds the
|
||||||
|
rating at High however ordinary the period's activity looks, and the declared income is what
|
||||||
|
turnover is measured against. Until they are recorded the policy has nothing to test. -->
|
||||||
|
<div class="full-span kyc-doc-fields">
|
||||||
|
<div class="kyc-doc-fields__head">
|
||||||
|
<h4 class="kyc-doc-fields__title">Risk Profile</h4>
|
||||||
|
<span class="kyc-doc-fields__hint">Used by the periodic risk review</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="kyc-risk-flags">
|
||||||
|
<mat-checkbox formControlName="isPep">
|
||||||
|
Politically Exposed Person
|
||||||
|
<span class="kyc-risk-hint">Holds the rating at High; never lowered on ordinary activity</span>
|
||||||
|
</mat-checkbox>
|
||||||
|
<mat-checkbox formControlName="sanctionsMatch">
|
||||||
|
Sanctions / watch-list match
|
||||||
|
<span class="kyc-risk-hint">UNSC, MHA or internal watch list</span>
|
||||||
|
</mat-checkbox>
|
||||||
|
<mat-checkbox formControlName="adverseMediaMatch">
|
||||||
|
Adverse media / regulatory action
|
||||||
|
<span class="kyc-risk-hint">FIR, court proceedings or published action</span>
|
||||||
|
</mat-checkbox>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="kyc-doc-fields__grid">
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Declared annual income (₹)</mat-label>
|
||||||
|
<input matInput type="number" min="0" formControlName="declaredAnnualIncome" />
|
||||||
|
<mat-hint>As stated by the customer</mat-hint>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Declared annual turnover (₹)</mat-label>
|
||||||
|
<input matInput type="number" min="0" formControlName="declaredAnnualTurnover" />
|
||||||
|
<mat-hint>For a business customer</mat-hint>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Officially Valid Document. Shown when one is on file or the alert is asking for
|
<!-- Officially Valid Document. Shown when one is on file or the alert is asking for
|
||||||
it, so re-collecting an expired document is done on this form rather than
|
it, so re-collecting an expired document is done on this form rather than
|
||||||
sending the officer somewhere else. -->
|
sending the officer somewhere else. -->
|
||||||
|
|||||||
@@ -1807,3 +1807,28 @@
|
|||||||
color: #15803d;
|
color: #15803d;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Risk profile ────────────────────────────────────────────────────────────
|
||||||
|
Determinations about the customer, which the periodic risk review reads directly. Grouped with
|
||||||
|
the document block so the form reads as "what we know about them" rather than scattering a PEP
|
||||||
|
flag among the identifier fields. */
|
||||||
|
|
||||||
|
.kyc-doc-fields__hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kyc-risk-flags {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 4px 0 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sits under its checkbox label: the consequence of ticking the box matters more than the box. */
|
||||||
|
.kyc-risk-hint {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #64748b;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|||||||
@@ -140,6 +140,13 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
|||||||
1,
|
1,
|
||||||
[Validators.required]
|
[Validators.required]
|
||||||
],
|
],
|
||||||
|
// Risk profile. Determinations about the customer, which the periodic risk review reads directly - a PEP or a
|
||||||
|
// sanctions match holds the rating at High whatever the period's activity looks like.
|
||||||
|
isPep: [false],
|
||||||
|
sanctionsMatch: [false],
|
||||||
|
adverseMediaMatch: [false],
|
||||||
|
declaredAnnualIncome: [null],
|
||||||
|
declaredAnnualTurnover: [null],
|
||||||
// The officially valid document, so a lapsed one can be re-collected on this form. The
|
// The officially valid document, so a lapsed one can be re-collected on this form. The
|
||||||
// backend has always accepted these four; only the fields were missing.
|
// backend has always accepted these four; only the fields were missing.
|
||||||
kycDocumentName: [''],
|
kycDocumentName: [''],
|
||||||
@@ -221,6 +228,11 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
|||||||
this.kycForm.patchValue({
|
this.kycForm.patchValue({
|
||||||
kycType: data.kycType || 1,
|
kycType: data.kycType || 1,
|
||||||
riskCategory: data.riskCategory || 1,
|
riskCategory: data.riskCategory || 1,
|
||||||
|
isPep: !!data.isPep,
|
||||||
|
sanctionsMatch: !!data.sanctionsMatch,
|
||||||
|
adverseMediaMatch: !!data.adverseMediaMatch,
|
||||||
|
declaredAnnualIncome: data.declaredAnnualIncome ?? null,
|
||||||
|
declaredAnnualTurnover: data.declaredAnnualTurnover ?? null,
|
||||||
panNumber: data.panNumber || '',
|
panNumber: data.panNumber || '',
|
||||||
// Aadhaar is stored masked on the server; user must re-enter
|
// Aadhaar is stored masked on the server; user must re-enter
|
||||||
// the full 12-digit number to re-verify, so don't pre-fill.
|
// the full 12-digit number to re-verify, so don't pre-fill.
|
||||||
@@ -446,6 +458,28 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
|||||||
return new Date();
|
return new Date();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The customer's risk profile, as every save has to carry it.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Sent from every path that writes the KYC record rather than only the main Save, because the form holds one set of
|
||||||
|
* values: a save triggered from the PAN declaration or the Aadhaar receipt would otherwise write the record without
|
||||||
|
* these and silently clear a PEP flag the officer had just ticked.
|
||||||
|
*/
|
||||||
|
private buildRiskProfileFields() {
|
||||||
|
const v = this.kycForm.value;
|
||||||
|
return {
|
||||||
|
isPep: !!v.isPep,
|
||||||
|
sanctionsMatch: !!v.sanctionsMatch,
|
||||||
|
adverseMediaMatch: !!v.adverseMediaMatch,
|
||||||
|
// Blank stays null rather than becoming zero: an income nobody asked for is unknown, and a zero would read as a
|
||||||
|
// customer who declared no income at all.
|
||||||
|
declaredAnnualIncome: v.declaredAnnualIncome === '' || v.declaredAnnualIncome == null ? null : v.declaredAnnualIncome,
|
||||||
|
declaredAnnualTurnover:
|
||||||
|
v.declaredAnnualTurnover === '' || v.declaredAnnualTurnover == null ? null : v.declaredAnnualTurnover
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private buildExceptionFields() {
|
private buildExceptionFields() {
|
||||||
const panVerified = this.verificationStatus.pan === 'VERIFIED' || this.kycData?.panVerified === true;
|
const panVerified = this.verificationStatus.pan === 'VERIFIED' || this.kycData?.panVerified === true;
|
||||||
const aadhaarVerified = this.verificationStatus.aadhaar === 'VERIFIED' || this.kycData?.aadhaarVerified === true;
|
const aadhaarVerified = this.verificationStatus.aadhaar === 'VERIFIED' || this.kycData?.aadhaarVerified === true;
|
||||||
@@ -484,7 +518,8 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
|||||||
// dateFormat is required so the backend can parse exceptionResolvedOn when an exception closes.
|
// dateFormat is required so the backend can parse exceptionResolvedOn when an exception closes.
|
||||||
dateFormat: 'dd MMMM yyyy',
|
dateFormat: 'dd MMMM yyyy',
|
||||||
...this.buildDocumentFields(),
|
...this.buildDocumentFields(),
|
||||||
...this.buildExceptionFields()
|
...this.buildRiskProfileFields(),
|
||||||
|
...this.buildExceptionFields()
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!this.kycId) {
|
if (!this.kycId) {
|
||||||
@@ -530,6 +565,7 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
|||||||
remarks: this.kycForm.value.remarks || null,
|
remarks: this.kycForm.value.remarks || null,
|
||||||
riskCategory: this.kycForm.value.riskCategory || 1,
|
riskCategory: this.kycForm.value.riskCategory || 1,
|
||||||
dateFormat: 'dd MMMM yyyy',
|
dateFormat: 'dd MMMM yyyy',
|
||||||
|
...this.buildRiskProfileFields(),
|
||||||
...this.buildExceptionFields()
|
...this.buildExceptionFields()
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -729,7 +765,8 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
|||||||
dateFormat,
|
dateFormat,
|
||||||
riskCategory: this.kycForm.value.riskCategory || 1,
|
riskCategory: this.kycForm.value.riskCategory || 1,
|
||||||
...declarationFields,
|
...declarationFields,
|
||||||
...this.buildExceptionFields()
|
...this.buildRiskProfileFields(),
|
||||||
|
...this.buildExceptionFields()
|
||||||
};
|
};
|
||||||
|
|
||||||
this.clientsService
|
this.clientsService
|
||||||
@@ -889,7 +926,8 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
|||||||
dateFormat,
|
dateFormat,
|
||||||
riskCategory: this.kycForm.value.riskCategory || 1,
|
riskCategory: this.kycForm.value.riskCategory || 1,
|
||||||
...receiptFields,
|
...receiptFields,
|
||||||
...this.buildExceptionFields()
|
...this.buildRiskProfileFields(),
|
||||||
|
...this.buildExceptionFields()
|
||||||
};
|
};
|
||||||
|
|
||||||
this.clientsService
|
this.clientsService
|
||||||
|
|||||||
@@ -310,41 +310,6 @@
|
|||||||
</nav>
|
</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
|
Main Header — 2-Row Navigation
|
||||||
============================================================
|
============================================================
|
||||||
@@ -623,18 +588,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- LIVE KPI TICKER (below the menu — pauses on hover) -->
|
<!-- The KPI ticker that sat here has been removed. Every figure in it was written into the template — cash on
|
||||||
<div class="kpi-ticker" role="marquee" aria-label="Live key-performance ticker">
|
hand, transactions today, CASA, CD ratio, gross NPA, a circular reference — and none came from the bank's
|
||||||
<span class="kpi-live">
|
own books. Under a "LIVE" badge on every screen in the application, that is not decoration: a figure a
|
||||||
<span class="kpi-live-dot"></span>
|
branch manager can read as the day's cash position, or an inspector as the NPA ratio, has to be the real
|
||||||
LIVE
|
one or must not be shown. The band returns when there is a source behind it. -->
|
||||||
</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>
|
|
||||||
}
|
}
|
||||||
<!-- end @if (!hideShellLayout) -->
|
<!-- end @if (!hideShellLayout) -->
|
||||||
|
|
||||||
@@ -713,6 +671,9 @@
|
|||||||
<button mat-menu-item routerLink="/reports/tds-remittance" tabindex="0">
|
<button mat-menu-item routerLink="/reports/tds-remittance" tabindex="0">
|
||||||
<i class="fa fa-landmark menu-item-icon"></i> TDS Remitted to Government
|
<i class="fa fa-landmark menu-item-icon"></i> TDS Remitted to Government
|
||||||
</button>
|
</button>
|
||||||
|
<button mat-menu-item routerLink="/reports/bor" tabindex="0">
|
||||||
|
<i class="fa fa-gavel menu-item-icon"></i> BOR Register — Share Capital
|
||||||
|
</button>
|
||||||
</mat-menu>
|
</mat-menu>
|
||||||
|
|
||||||
<!-- Admin Mega Menu — 3-column rectangular panel grouping Setup,
|
<!-- Admin Mega Menu — 3-column rectangular panel grouping Setup,
|
||||||
@@ -1339,72 +1300,79 @@
|
|||||||
<!-- Share Operations -->
|
<!-- Share Operations -->
|
||||||
<div class="deposit-menu-group">
|
<div class="deposit-menu-group">
|
||||||
<div class="deposit-group-title">Share Operations</div>
|
<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
|
<fa-icon icon="coins" class="menu-item-icon"></fa-icon> Nominal Share Report
|
||||||
</button>
|
</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
|
<fa-icon icon="file-alt" class="menu-item-icon"></fa-icon> Share Wise Report
|
||||||
</button>
|
</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
|
<fa-icon icon="user-shield" class="menu-item-icon"></fa-icon> Guarantor Detail
|
||||||
</button>
|
</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
|
<fa-icon icon="calculator" class="menu-item-icon"></fa-icon> Total Int Rec
|
||||||
</button>
|
</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
|
<fa-icon icon="search" class="menu-item-icon"></fa-icon> Share Enquiry
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<!-- Share Documents -->
|
<!-- Share Documents -->
|
||||||
<div class="deposit-menu-group">
|
<div class="deposit-menu-group">
|
||||||
<div class="deposit-group-title">Share Documents</div>
|
<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
|
<fa-icon icon="file-signature" class="menu-item-icon"></fa-icon> Share Payment Certificate
|
||||||
</button>
|
</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
|
<fa-icon icon="list-alt" class="menu-item-icon"></fa-icon> Share Detail
|
||||||
</button>
|
</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
|
<fa-icon icon="dollar-sign" class="menu-item-icon"></fa-icon> Dividend Statement
|
||||||
</button>
|
</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
|
<fa-icon icon="exchange" class="menu-item-icon"></fa-icon> Share Transfer
|
||||||
</button>
|
</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
|
<fa-icon icon="file-alt" class="menu-item-icon"></fa-icon> Folio Statement
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<!-- Share Reports -->
|
<!-- Share Reports -->
|
||||||
<div class="deposit-menu-group">
|
<div class="deposit-menu-group">
|
||||||
<div class="deposit-group-title">Share Reports</div>
|
<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
|
<fa-icon icon="chart-line" class="menu-item-icon"></fa-icon> Final Balance
|
||||||
</button>
|
</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
|
<fa-icon icon="file-invoice" class="menu-item-icon"></fa-icon> Account Statement
|
||||||
</button>
|
</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
|
<fa-icon icon="check-circle" class="menu-item-icon"></fa-icon> Share Issued
|
||||||
</button>
|
</button>
|
||||||
<button mat-menu-item tabindex="0" [routerLink]="['/shares/share-holders']">
|
<button mat-menu-item tabindex="0" [routerLink]="['/shares/share-holders']">
|
||||||
<fa-icon icon="users" class="menu-item-icon"></fa-icon> Share Holder
|
<fa-icon icon="users" class="menu-item-icon"></fa-icon> Share Holder
|
||||||
</button>
|
</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
|
<fa-icon icon="info-circle" class="menu-item-icon"></fa-icon> Share Status
|
||||||
</button>
|
</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>
|
</div>
|
||||||
<!-- Share Admin -->
|
<!-- Share Admin -->
|
||||||
<div class="deposit-menu-group">
|
<div class="deposit-menu-group">
|
||||||
<div class="deposit-group-title">Share Admin</div>
|
<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
|
<fa-icon icon="tag" class="menu-item-icon"></fa-icon> Share Devede No Detail
|
||||||
</button>
|
</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" [routerLink]="['/shares/reports/area-wise']">
|
||||||
<button mat-menu-item tabindex="0"><fa-icon icon="tag" class="menu-item-icon"></fa-icon> Share Label</button>
|
<fa-icon icon="map" class="menu-item-icon"></fa-icon> Area Wise
|
||||||
<button mat-menu-item tabindex="0">
|
</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
|
<fa-icon icon="envelope" class="menu-item-icon"></fa-icon> Share Letter
|
||||||
</button>
|
</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
|
<fa-icon icon="id-card" class="menu-item-icon"></fa-icon> Share Member Details
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+10
-4
@@ -334,14 +334,18 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
|
|||||||
const frequencyId = this.resolveFrequencyId(this.tenurePeriodUnit);
|
const frequencyId = this.resolveFrequencyId(this.tenurePeriodUnit);
|
||||||
|
|
||||||
if (this.principalAmount && this.principalAmount > 0) {
|
if (this.principalAmount && this.principalAmount > 0) {
|
||||||
// Convert tenure to months for backend API
|
// Convert tenure to months for backend API. A Days tenure under 30 days rounds down to 0
|
||||||
let tenureInMonths = 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') {
|
if (this.tenurePeriodUnit === 'Years') {
|
||||||
tenureInMonths = this.tenureValue * 12;
|
tenureInMonths = this.tenureValue * 12;
|
||||||
} else if (this.tenurePeriodUnit === 'Months') {
|
} else if (this.tenurePeriodUnit === 'Months') {
|
||||||
tenureInMonths = this.tenureValue;
|
tenureInMonths = this.tenureValue;
|
||||||
} else {
|
} else if (enteredDays >= 30) {
|
||||||
tenureInMonths = Math.floor(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)
|
// 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,
|
principalAmount: this.principalAmount,
|
||||||
annualInterestRate: rate,
|
annualInterestRate: rate,
|
||||||
tenureInMonths,
|
tenureInMonths,
|
||||||
|
tenureInDays,
|
||||||
interestCompoundingPeriodInMonths: compMonths, // 0 = daily, now supported by backend
|
interestCompoundingPeriodInMonths: compMonths, // 0 = daily, now supported by backend
|
||||||
interestPostingPeriodInMonths: postMonths,
|
interestPostingPeriodInMonths: postMonths,
|
||||||
submittedOnDate: submittedOnDateStr,
|
submittedOnDate: submittedOnDateStr,
|
||||||
@@ -417,7 +422,8 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
|
|||||||
Math.round(this.principalAmount * Math.pow(1 + r / daysInYearVal, enteredDays) * 100) / 100;
|
Math.round(this.principalAmount * Math.pow(1 + r / daysInYearVal, enteredDays) * 100) / 100;
|
||||||
} else {
|
} else {
|
||||||
const n = 12 / compMonths;
|
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);
|
const rPerCompounding = rate / (n * 100);
|
||||||
result.maturityAmount =
|
result.maturityAmount =
|
||||||
Math.round(this.principalAmount * Math.pow(1 + rPerCompounding, totalCompoundings) * 100) / 100;
|
Math.round(this.principalAmount * Math.pow(1 + rPerCompounding, totalCompoundings) * 100) / 100;
|
||||||
|
|||||||
@@ -133,7 +133,8 @@ export class FixedDepositsService {
|
|||||||
calculateFixedDepositInterest(params: {
|
calculateFixedDepositInterest(params: {
|
||||||
principalAmount: number;
|
principalAmount: number;
|
||||||
annualInterestRate: number;
|
annualInterestRate: number;
|
||||||
tenureInMonths: number;
|
tenureInMonths?: number;
|
||||||
|
tenureInDays?: number; // alternative to tenureInMonths, for sub-month FD Single tenures
|
||||||
interestCompoundingPeriodInMonths: number;
|
interestCompoundingPeriodInMonths: number;
|
||||||
interestPostingPeriodInMonths: number;
|
interestPostingPeriodInMonths: number;
|
||||||
submittedOnDate?: string; // yyyy-MM-dd — enables exact calendar calculation
|
submittedOnDate?: string; // yyyy-MM-dd — enables exact calendar calculation
|
||||||
|
|||||||
@@ -167,7 +167,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<!-- The 15th of the month after the one being reported, worked out rather than fixed at
|
<!-- 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. -->
|
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>
|
<button class="dc-btn" (click)="showTab('ctr')"><fa-icon icon="external-link-alt"></fa-icon> Open</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="dc-card">
|
<div class="dc-card">
|
||||||
@@ -220,9 +222,7 @@
|
|||||||
@if (ctrLinesLoading) {
|
@if (ctrLinesLoading) {
|
||||||
<div class="fiu-note">Loading the generated CTR lines…</div>
|
<div class="fiu-note">Loading the generated CTR lines…</div>
|
||||||
} @else if (ctrLinesError) {
|
} @else if (ctrLinesError) {
|
||||||
<div class="fiu-note fiu-note--warn">
|
<div class="fiu-note fiu-note--warn"><fa-icon icon="exclamation-triangle"></fa-icon> {{ ctrLinesError }}</div>
|
||||||
<fa-icon icon="exclamation-triangle"></fa-icon> {{ ctrLinesError }}
|
|
||||||
</div>
|
|
||||||
} @else if (!ctrTxns.length) {
|
} @else if (!ctrTxns.length) {
|
||||||
<div class="fiu-note fiu-note--warn">
|
<div class="fiu-note fiu-note--warn">
|
||||||
<fa-icon icon="exclamation-triangle"></fa-icon>
|
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||||
@@ -432,87 +432,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
<div class="fiu-section">
|
<div class="fiu-section">
|
||||||
<!-- LIVE: suspicious AML alerts awaiting AML Officer review (Structuring, PEP, etc.) -->
|
<!-- The triage queue that used to head this tab has gone. It listed the same open alerts as the AML Rules
|
||||||
<div class="fiu-form-meta">
|
Master, and listed them worse: without the severity bands, the per-rule findings or the batch decision that
|
||||||
<div class="fiu-form-title">
|
screen carries. Two places to work the same queue is how an alert gets cleared in one and escalated in the
|
||||||
<h3>Live AML Alerts (Awaiting Triage)</h3>
|
other. This tab keeps what is about the filing itself - what was cleared, and what became an STR case. -->
|
||||||
<p>Flagged by the monitoring engine for suspicious behavioral patterns — triage and whitelist or escalate</p>
|
<div class="fiu-note">
|
||||||
</div>
|
<fa-icon icon="info-circle"></fa-icon>
|
||||||
<!-- Whitelisted is deliberately absent: cleared alerts are not STR business and have
|
<span>
|
||||||
their own register below. -->
|
Alerts are triaged in <strong>RBI › AML Rules Master › Flagged Transactions</strong>. What an
|
||||||
<select class="fiu-inp" [(ngModel)]="amlAlertStatusFilter">
|
officer escalates there arrives here as an STR case.
|
||||||
<option value="">All statuses</option>
|
</span>
|
||||||
<option value="RAISED">Raised</option>
|
|
||||||
<option value="UNDER_REVIEW">Under Review</option>
|
|
||||||
<option value="ESCALATED">Escalated</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</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 }} — {{ alert.ruleDescription }}</td>
|
|
||||||
<td class="ta-r mono">₹{{ 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 ·
|
|
||||||
<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
|
<!-- Cleared register: whitelisted alerts, kept out of the STR queue but retained with the
|
||||||
evidence recorded at review time. -->
|
evidence recorded at review time. -->
|
||||||
@@ -590,7 +520,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@for (sc of amlStrCases; track sc.id) {
|
@for (sc of liveStrCases; track sc.id) {
|
||||||
<tr [class.selected-row]="selectedStrCase?.id === sc.id">
|
<tr [class.selected-row]="selectedStrCase?.id === sc.id">
|
||||||
<td>{{ sc.id }}</td>
|
<td>{{ sc.id }}</td>
|
||||||
<td>{{ sc.clientDisplayName }}</td>
|
<td>{{ sc.clientDisplayName }}</td>
|
||||||
@@ -607,13 +537,50 @@
|
|||||||
</tr>
|
</tr>
|
||||||
} @empty {
|
} @empty {
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="8" class="fiu-empty">No STR cases yet.</td>
|
<td colspan="8" class="fiu-empty">No STR cases outstanding.</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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) {
|
@if (selectedStrCase) {
|
||||||
<div class="str-case-detail">
|
<div class="str-case-detail">
|
||||||
<div class="fiu-form-meta">
|
<div class="fiu-form-meta">
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Component, OnInit, inject } from '@angular/core';
|
import { Component, OnInit, inject } from '@angular/core';
|
||||||
|
import { ActivatedRoute } from '@angular/router';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
@@ -25,8 +26,7 @@ import {
|
|||||||
KycIndividual,
|
KycIndividual,
|
||||||
PoFinding,
|
PoFinding,
|
||||||
StrStatus,
|
StrStatus,
|
||||||
StrTransaction,
|
StrTransaction
|
||||||
STR_TXNS
|
|
||||||
} from './fiu-data';
|
} from './fiu-data';
|
||||||
|
|
||||||
/** A FINnet file rendered as a grid — the same rows that go into the CSV. */
|
/** 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 amlService = inject(AmlService);
|
||||||
private sanctionsLogsService = inject(SanctionsLogsService);
|
private sanctionsLogsService = inject(SanctionsLogsService);
|
||||||
private authenticationService = inject(AuthenticationService);
|
private authenticationService = inject(AuthenticationService);
|
||||||
|
private route = inject(ActivatedRoute);
|
||||||
/** Active tab */
|
/** Active tab */
|
||||||
activeTab: 'dashboard' | 'ctr' | 'str' | 'customers' | 'files' = 'dashboard';
|
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.
|
* is honestly empty rather than populated with rows that cannot be filed.
|
||||||
*/
|
*/
|
||||||
tc1Txns: CardTransaction[] = [];
|
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.
|
* KC1 / KC2 — the customer cards that travel with the CTR bundle.
|
||||||
*
|
*
|
||||||
@@ -210,13 +220,29 @@ export class FiuComponent implements OnInit {
|
|||||||
*/
|
*/
|
||||||
sanctionsStrCases: any[] = [];
|
sanctionsStrCases: any[] = [];
|
||||||
sanctionsStrFilter = '';
|
sanctionsStrFilter = '';
|
||||||
amlAlertStatusFilter = '';
|
|
||||||
amlReviewNotes: Record<number, string> = {};
|
|
||||||
|
|
||||||
amlCtrReports: any[] = [];
|
amlCtrReports: any[] = [];
|
||||||
ctrGenerateMonth = '';
|
ctrGenerateMonth = '';
|
||||||
|
|
||||||
amlStrCases: any[] = [];
|
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;
|
selectedStrCase: any = null;
|
||||||
concludeConclusion: 'NOT_SUSPICIOUS' | 'SUSPICIOUS' = 'NOT_SUSPICIOUS';
|
concludeConclusion: 'NOT_SUSPICIOUS' | 'SUSPICIOUS' = 'NOT_SUSPICIOUS';
|
||||||
concludeInvestigationNotes = '';
|
concludeInvestigationNotes = '';
|
||||||
@@ -398,6 +424,14 @@ export class FiuComponent implements OnInit {
|
|||||||
this.isHeadOffice = this.authenticationService.isHeadOfficeUser();
|
this.isHeadOffice = this.authenticationService.isHeadOfficeUser();
|
||||||
this.currentOfficeName = creds?.officeName || 'Branch Office';
|
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
|
// 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
|
// "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.
|
// 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
|
* Always fetches every status. This tab shows what was cleared and what became an STR case, which are two views of
|
||||||
* same set, so filtering server-side would empty one whenever the other was narrowed — and the
|
* the same set, and the CTR tab reads the list as well — so narrowing it server-side for one view would empty
|
||||||
* CTR tab reads this list too, which a status filter chosen on the STR tab should not touch.
|
* another.
|
||||||
* amlAlertStatusFilter is applied client-side in {@link amlSuspiciousAlerts} instead.
|
|
||||||
*/
|
*/
|
||||||
loadAmlAlerts(): void {
|
loadAmlAlerts(): void {
|
||||||
this.amlService.getAlerts().subscribe((data) => (this.amlAlerts = data || []));
|
this.amlService.getAlerts().subscribe((data) => (this.amlAlerts = data || []));
|
||||||
@@ -661,7 +694,8 @@ export class FiuComponent implements OnInit {
|
|||||||
this.ctrLinesLoading = false;
|
this.ctrLinesLoading = false;
|
||||||
this.ctrTxns = [];
|
this.ctrTxns = [];
|
||||||
this.ctrLinesError =
|
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`);
|
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 {
|
generateCtrReport(): void {
|
||||||
if (!this.ctrGenerateMonth) {
|
if (!this.ctrGenerateMonth) {
|
||||||
this.toast('Pick a reporting month first');
|
this.toast('Pick a reporting month first');
|
||||||
@@ -990,9 +982,16 @@ export class FiuComponent implements OnInit {
|
|||||||
get liveStrFiled(): number {
|
get liveStrFiled(): number {
|
||||||
return this.amlStrCases.filter((c) => c.status === 'FILED').length;
|
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 {
|
get liveStrPending(): number {
|
||||||
return this.liveStrOpen + this.liveStrAwaitingFiling;
|
return this.amlStrCases.filter((c) => c?.status !== 'FILED' && c?.status !== 'CONCLUDED_CLEAR').length;
|
||||||
}
|
}
|
||||||
get totalCustomers(): number {
|
get totalCustomers(): number {
|
||||||
return this.kc1.length + this.kc2.length;
|
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
|
* 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}.
|
* 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. */
|
/** Cleared register: alerts closed as false positives, kept for audit with their evidence. */
|
||||||
get amlWhitelistedAlerts(): any[] {
|
get amlWhitelistedAlerts(): any[] {
|
||||||
return this.behaviouralAlerts.filter((a) => a.status === 'WHITELISTED');
|
return this.behaviouralAlerts.filter((a) => a.status === 'WHITELISTED');
|
||||||
@@ -1689,7 +1682,9 @@ export class FiuComponent implements OnInit {
|
|||||||
private writeLiveStrXml(cases: any[], filename: string): void {
|
private writeLiveStrXml(cases: any[], filename: string): void {
|
||||||
const xml: string[] = [];
|
const xml: string[] = [];
|
||||||
xml.push('<?xml version="1.0" encoding="UTF-8"?>');
|
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)}" />`);
|
xml.push(` <REBank name="${this.escapeXml(this.bank.name)}" />`);
|
||||||
if (this.principalOfficerName) {
|
if (this.principalOfficerName) {
|
||||||
xml.push(` <PrincipalOfficer name="${this.escapeXml(this.principalOfficerName)}" />`);
|
xml.push(` <PrincipalOfficer name="${this.escapeXml(this.principalOfficerName)}" />`);
|
||||||
|
|||||||
+6
-10
@@ -576,13 +576,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ───────── Print — only the page, no toolbar / dialog chrome ─────────
|
/* ───────── Print — only the page, no toolbar / dialog chrome ─────────
|
||||||
This dialog is opened with `maxHeight: '92vh'`, which Angular Material carries onto the
|
The document-level clip (`html, body { overflow: hidden }`) is lifted globally in main.scss's
|
||||||
overlay pane / .mat-mdc-dialog-container as an inline max-height + overflow:auto clip.
|
print block; what remains here is this dialog's own clip — it opens with `maxHeight: '92vh'`,
|
||||||
`position: absolute` still lives inside that clip and gets cut off — only the on-screen
|
which Angular Material carries onto the overlay pane / .mat-mdc-dialog-container as an inline
|
||||||
scroll position prints. `position: fixed` escapes it: a fixed element's containing block is
|
max-height plus overflow:auto. Freeing those lets the whole certificate flow onto the page in
|
||||||
the viewport itself, not the clipped ancestor (the classic "fixed child ignores an
|
normal static order (a fixed/absolute hack would pin it to the first page instead). */
|
||||||
overflow:hidden parent" behaviour), as long as nothing between here and the viewport sets a
|
|
||||||
transform/filter — the CDK overlay chrome doesn't. */
|
|
||||||
@media print {
|
@media print {
|
||||||
body > :not(.cdk-overlay-container) {
|
body > :not(.cdk-overlay-container) {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
@@ -611,9 +609,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.cpd-root {
|
.cpd-root {
|
||||||
position: fixed;
|
position: static;
|
||||||
left: 0;
|
|
||||||
top: 0;
|
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
height: auto !important;
|
height: auto !important;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
|
|||||||
+166
@@ -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>
|
||||||
+344
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+162
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+114
@@ -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>
|
||||||
+323
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+196
@@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+83
@@ -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 → > ₹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>
|
||||||
+212
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+102
-23
@@ -42,19 +42,94 @@
|
|||||||
</div>
|
</div>
|
||||||
</mat-tab>
|
</mat-tab>
|
||||||
|
|
||||||
<mat-tab>
|
@if (isAdvanceProduct) {
|
||||||
<ng-template mat-tab-label>
|
<mat-tab>
|
||||||
<i class="fas fa-cog lp-tab-icon"></i>
|
<ng-template mat-tab-label>
|
||||||
{{ 'labels.inputs.SETTINGS' | translate }}
|
<i class="fas fa-shield-alt lp-tab-icon"></i>
|
||||||
</ng-template>
|
SECURITY & LIEN
|
||||||
<div class="lp-tab-content">
|
</ng-template>
|
||||||
<mifosx-loan-product-settings-step
|
<div class="lp-tab-content">
|
||||||
[loanProductsTemplate]="loanProductsTemplate"
|
<mifosx-loan-product-security-lien-step
|
||||||
[isLinkedToFloatingInterestRates]="loanProductTermsForm.get('isLinkedToFloatingInterestRates')"
|
[loanProductsTemplate]="loanProductsTemplate"
|
||||||
(advancePaymentStrategy)="advancePaymentStrategy($event)"
|
[productTypeGroup]="loanProductDetailsForm.get('productTypeGroup')"
|
||||||
></mifosx-loan-product-settings-step>
|
[productTypeSubGroup]="loanProductDetailsForm.get('productTypeSubGroup')"
|
||||||
</div>
|
></mifosx-loan-product-security-lien-step>
|
||||||
</mat-tab>
|
</div>
|
||||||
|
</mat-tab>
|
||||||
|
|
||||||
|
<mat-tab>
|
||||||
|
<ng-template mat-tab-label>
|
||||||
|
<i class="fas fa-sliders-h lp-tab-icon"></i>
|
||||||
|
LIMIT & 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 & 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 & 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) {
|
@if (isAdvancedPaymentStrategy) {
|
||||||
<mat-tab>
|
<mat-tab>
|
||||||
@@ -90,15 +165,19 @@
|
|||||||
</mat-tab>
|
</mat-tab>
|
||||||
}
|
}
|
||||||
|
|
||||||
<mat-tab>
|
@if (!isAdvanceProduct) {
|
||||||
<ng-template mat-tab-label>
|
<mat-tab>
|
||||||
<i class="fas fa-sliders-h lp-tab-icon"></i>
|
<ng-template mat-tab-label>
|
||||||
{{ 'labels.inputs.TERMS' | translate }}
|
<i class="fas fa-sliders-h lp-tab-icon"></i>
|
||||||
</ng-template>
|
{{ 'labels.inputs.TERMS' | translate }}
|
||||||
<div class="lp-tab-content">
|
</ng-template>
|
||||||
<mifosx-loan-product-terms-step [loanProductsTemplate]="loanProductsTemplate"></mifosx-loan-product-terms-step>
|
<div class="lp-tab-content">
|
||||||
</div>
|
<mifosx-loan-product-terms-step
|
||||||
</mat-tab>
|
[loanProductsTemplate]="loanProductsTemplate"
|
||||||
|
></mifosx-loan-product-terms-step>
|
||||||
|
</div>
|
||||||
|
</mat-tab>
|
||||||
|
}
|
||||||
|
|
||||||
<mat-tab>
|
<mat-tab>
|
||||||
<ng-template mat-tab-label>
|
<ng-template mat-tab-label>
|
||||||
@@ -109,7 +188,7 @@
|
|||||||
<mifosx-loan-product-charges-step
|
<mifosx-loan-product-charges-step
|
||||||
[loanProductsTemplate]="loanProductsTemplate"
|
[loanProductsTemplate]="loanProductsTemplate"
|
||||||
[currencyCode]="loanProductCurrencyForm.get('currencyCode')"
|
[currencyCode]="loanProductCurrencyForm.get('currencyCode')"
|
||||||
[multiDisburseLoan]="loanProductSettingsForm.get('multiDisburseLoan')"
|
[multiDisburseLoan]="loanProductSettingsForm?.get('multiDisburseLoan') ?? multiDisburseFallback"
|
||||||
></mifosx-loan-product-charges-step>
|
></mifosx-loan-product-charges-step>
|
||||||
</div>
|
</div>
|
||||||
</mat-tab>
|
</mat-tab>
|
||||||
|
|||||||
+243
-24
@@ -13,6 +13,12 @@ import { ActivatedRoute, Router } from '@angular/router';
|
|||||||
/** Custom Components */
|
/** Custom Components */
|
||||||
import { LoanProductDetailsStepComponent } from '../loan-product-stepper/loan-product-details-step/loan-product-details-step.component';
|
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 { 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 { 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 { 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';
|
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 { Accounting } from 'app/core/utils/accounting';
|
||||||
import { StringEnumOptionData } from '../../../shared/models/option-data.model';
|
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 { 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 { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
import { LoanProductPaymentStrategyStepComponent } from '../loan-product-stepper/loan-product-payment-strategy-step/loan-product-payment-strategy-step.component';
|
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';
|
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,
|
FaIconComponent,
|
||||||
LoanProductDetailsStepComponent,
|
LoanProductDetailsStepComponent,
|
||||||
LoanProductCurrencyStepComponent,
|
LoanProductCurrencyStepComponent,
|
||||||
|
LoanProductSecurityLienStepComponent,
|
||||||
|
LoanProductAdvanceLimitTenureStepComponent,
|
||||||
|
LoanProductAdvanceInterestStepComponent,
|
||||||
|
LoanProductPenalNpaStepComponent,
|
||||||
|
LoanProductRenewalClosureStepComponent,
|
||||||
|
LoanProductChequeBookStepComponent,
|
||||||
LoanProductSettingsStepComponent,
|
LoanProductSettingsStepComponent,
|
||||||
LoanProductInterestRefundStepComponent,
|
LoanProductInterestRefundStepComponent,
|
||||||
LoanProductPaymentStrategyStepComponent,
|
LoanProductPaymentStrategyStepComponent,
|
||||||
@@ -73,12 +85,27 @@ export class CreateLoanProductComponent implements OnInit {
|
|||||||
@ViewChild(LoanProductDetailsStepComponent, { static: true }) loanProductDetailsStep: LoanProductDetailsStepComponent;
|
@ViewChild(LoanProductDetailsStepComponent, { static: true }) loanProductDetailsStep: LoanProductDetailsStepComponent;
|
||||||
@ViewChild(LoanProductCurrencyStepComponent, { static: true })
|
@ViewChild(LoanProductCurrencyStepComponent, { static: true })
|
||||||
loanProductCurrencyStep: LoanProductCurrencyStepComponent;
|
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 })
|
@ViewChild(LoanProductInterestRefundStepComponent, { static: true })
|
||||||
loanProductInterestRefundStep: LoanProductInterestRefundStepComponent;
|
loanProductInterestRefundStep: LoanProductInterestRefundStepComponent;
|
||||||
@ViewChild(LoanProductDeferredIncomeRecognitionStepComponent, { static: true })
|
@ViewChild(LoanProductDeferredIncomeRecognitionStepComponent, { static: true })
|
||||||
loanProductDeferredIncomeRecognitionStep: LoanProductDeferredIncomeRecognitionStepComponent;
|
loanProductDeferredIncomeRecognitionStep: LoanProductDeferredIncomeRecognitionStepComponent;
|
||||||
@ViewChild(LoanProductTermsStepComponent, { static: true }) loanProductTermsStep: LoanProductTermsStepComponent;
|
// Terms & Settings tabs are now conditional (Loans path only), so resolve them non-statically.
|
||||||
@ViewChild(LoanProductSettingsStepComponent, { static: true })
|
@ViewChild(LoanProductTermsStepComponent) loanProductTermsStep: LoanProductTermsStepComponent;
|
||||||
|
@ViewChild(LoanProductSettingsStepComponent)
|
||||||
loanProductSettingsStep: LoanProductSettingsStepComponent;
|
loanProductSettingsStep: LoanProductSettingsStepComponent;
|
||||||
@ViewChild(LoanProductChargesStepComponent, { static: true }) loanProductChargesStep: LoanProductChargesStepComponent;
|
@ViewChild(LoanProductChargesStepComponent, { static: true }) loanProductChargesStep: LoanProductChargesStepComponent;
|
||||||
@ViewChild(LoanProductAccountingStepComponent, { static: true })
|
@ViewChild(LoanProductAccountingStepComponent, { static: true })
|
||||||
@@ -143,7 +170,18 @@ export class CreateLoanProductComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get loanProductTermsForm() {
|
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) {
|
advancePaymentStrategy(value: string) {
|
||||||
@@ -231,47 +269,71 @@ export class CreateLoanProductComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get loanProductSettingsForm() {
|
get loanProductSettingsForm() {
|
||||||
return this.loanProductSettingsStep.loanProductSettingsForm;
|
// Absent on the Advances path (tab hidden), so guard for callers.
|
||||||
|
return this.loanProductSettingsStep?.loanProductSettingsForm;
|
||||||
}
|
}
|
||||||
|
|
||||||
get loanProductAccountingForm() {
|
get loanProductAccountingForm() {
|
||||||
return this.loanProductAccountingStep.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 {
|
get totalTabs(): number {
|
||||||
let tabs = 6; // details, currency, settings, terms, charges, accounting
|
let tabs = 4; // details, currency, charges, accounting (always)
|
||||||
if (this.isAdvancedPaymentStrategy) tabs += 3; // interest refund, payment allocation, deferred income
|
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
|
if (this.loanProductFormValid) tabs += 1; // preview
|
||||||
return tabs;
|
return tabs;
|
||||||
}
|
}
|
||||||
|
|
||||||
get loanProductFormValid() {
|
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 (
|
return (
|
||||||
this.loanProductDetailsForm.valid &&
|
base &&
|
||||||
this.loanProductCurrencyForm.valid &&
|
!!this.loanProductSecurityLienStep?.loanProductSecurityLienForm?.valid &&
|
||||||
this.loanProductTermsForm.valid &&
|
!!this.loanProductAdvanceLimitTenureStep?.loanProductAdvanceLimitTenureForm?.valid &&
|
||||||
this.loanProductSettingsForm.valid &&
|
!!this.loanProductAdvanceInterestStep?.loanProductAdvanceInterestForm?.valid &&
|
||||||
this.loanIncomeCapitalizationForm.valid &&
|
!!this.loanProductPenalNpaStep?.loanProductPenalNpaForm?.valid &&
|
||||||
this.loanProductAccountingForm.valid
|
!!this.loanProductRenewalClosureStep?.loanProductRenewalClosureForm?.valid &&
|
||||||
);
|
(!this.isOverdraftFacility || !!this.loanProductChequeBookStep?.loanProductChequeBookForm?.valid)
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
this.loanProductDetailsForm.valid &&
|
|
||||||
this.loanProductCurrencyForm.valid &&
|
|
||||||
this.loanProductTermsForm.valid &&
|
|
||||||
this.loanProductSettingsForm.valid &&
|
|
||||||
this.loanProductAccountingForm.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() {
|
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 = {
|
const loanProduct = {
|
||||||
...this.loanProductDetailsStep.loanProductDetails,
|
...this.loanProductDetailsStep.loanProductDetails,
|
||||||
...this.loanProductCurrencyStep.loanProductCurrency,
|
...this.loanProductCurrencyStep.loanProductCurrency,
|
||||||
...this.loanProductTermsStep.loanProductTerms,
|
...(this.loanProductTermsStep?.loanProductTerms ?? {}),
|
||||||
...this.loanProductSettingsStep.loanProductSettings,
|
...(this.loanProductSettingsStep?.loanProductSettings ?? {}),
|
||||||
...this.loanProductChargesStep.loanProductCharges,
|
...this.loanProductChargesStep.loanProductCharges,
|
||||||
...this.loanProductAccountingStep.loanProductAccounting
|
...this.loanProductAccountingStep.loanProductAccounting
|
||||||
};
|
};
|
||||||
@@ -304,6 +366,163 @@ export class CreateLoanProductComponent implements OnInit {
|
|||||||
return loanProduct;
|
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() {
|
submit() {
|
||||||
const loanProduct = this.loanProducts.buildPayload(this.loanProduct, this.itemsByDefault);
|
const loanProduct = this.loanProducts.buildPayload(this.loanProduct, this.itemsByDefault);
|
||||||
if (this.isAdvancedPaymentStrategy) {
|
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;
|
||||||
|
}
|
||||||
+120
@@ -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>
|
||||||
+105
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+155
@@ -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 & 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>
|
||||||
+171
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
+144
@@ -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 & 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 & 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>
|
||||||
+92
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+176
-164
@@ -7,178 +7,190 @@
|
|||||||
-->
|
-->
|
||||||
|
|
||||||
<form [formGroup]="loanProductDetailsForm">
|
<form [formGroup]="loanProductDetailsForm">
|
||||||
<div class="lpd-section">
|
<!-- Product Type — picked first; it decides which sections the rest of the wizard shows -->
|
||||||
<div class="lpd-section__header">
|
|
||||||
<i class="fas fa-info-circle"></i>
|
|
||||||
<h3>{{ 'labels.inputs.Product Name' | translate }} & {{ '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 -->
|
|
||||||
<div class="lpd-section">
|
<div class="lpd-section">
|
||||||
<div class="lpd-section__header">
|
<div class="lpd-section__header">
|
||||||
<i class="fas fa-sitemap"></i>
|
<i class="fas fa-sitemap"></i>
|
||||||
<h3>Product Classification</h3>
|
<h3>Product Type</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="lpd-grid">
|
<mat-radio-group formControlName="productTypeGroup" class="lpd-type-options">
|
||||||
<mat-form-field appearance="outline" class="lpd-field">
|
@for (group of productTypeGroups; track group.id) {
|
||||||
<mat-label>Product Type Group</mat-label>
|
<mat-radio-button [value]="group.id" class="lpd-type-option">
|
||||||
<mat-select formControlName="productTypeGroup">
|
<span class="lpd-type-option__label">{{ group.label }}</span>
|
||||||
@for (group of productTypeGroups; track group.id) {
|
<span class="lpd-type-option__hint">{{ group.hint }}</span>
|
||||||
<mat-option [value]="group.id">{{ group.label }}</mat-option>
|
</mat-radio-button>
|
||||||
}
|
}
|
||||||
</mat-select>
|
</mat-radio-group>
|
||||||
</mat-form-field>
|
|
||||||
|
|
||||||
@if (loanProductDetailsForm.get('productTypeGroup')?.value) {
|
@if (loanProductDetailsForm.get('productTypeGroup')?.value) {
|
||||||
|
<div class="lpd-grid">
|
||||||
<mat-form-field appearance="outline" class="lpd-field">
|
<mat-form-field appearance="outline" class="lpd-field">
|
||||||
<mat-label>GL Account ({{ glClassificationLabel }})</mat-label>
|
<mat-label>Product Sub Type</mat-label>
|
||||||
<mat-select formControlName="glAccount">
|
<mat-select formControlName="productTypeSubGroup" [required]="!isExistingProduct">
|
||||||
@for (acc of availableGlAccounts; track acc.id) {
|
@for (subGroup of availableSubGroups; track subGroup.id) {
|
||||||
<mat-option [value]="acc.id"> ({{ acc.glCode }}) {{ acc.name }} </mat-option>
|
<mat-option [value]="subGroup.id">{{ subGroup.label }}</mat-option>
|
||||||
}
|
}
|
||||||
</mat-select>
|
</mat-select>
|
||||||
<mat-error>GL Account is required</mat-error>
|
<mat-error>Product Sub Type is <strong>required</strong></mat-error>
|
||||||
</mat-form-field>
|
</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>
|
</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 }} & {{ '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>
|
</form>
|
||||||
|
|||||||
+51
@@ -61,6 +61,57 @@ $gray-900: #202124;
|
|||||||
&--full {
|
&--full {
|
||||||
grid-column: 1 / -1;
|
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 {
|
.lpd-checkbox-row {
|
||||||
|
|||||||
+153
-69
@@ -15,6 +15,7 @@ import { Dates } from 'app/core/utils/dates';
|
|||||||
import { SettingsService } from 'app/settings/settings.service';
|
import { SettingsService } from 'app/settings/settings.service';
|
||||||
import { MatTooltip } from '@angular/material/tooltip';
|
import { MatTooltip } from '@angular/material/tooltip';
|
||||||
import { CdkTextareaAutosize } from '@angular/cdk/text-field';
|
import { CdkTextareaAutosize } from '@angular/cdk/text-field';
|
||||||
|
import { MatRadioButton, MatRadioGroup } from '@angular/material/radio';
|
||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -24,7 +25,9 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
|||||||
imports: [
|
imports: [
|
||||||
...STANDALONE_SHARED_IMPORTS,
|
...STANDALONE_SHARED_IMPORTS,
|
||||||
MatTooltip,
|
MatTooltip,
|
||||||
CdkTextareaAutosize
|
CdkTextareaAutosize,
|
||||||
|
MatRadioGroup,
|
||||||
|
MatRadioButton
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class LoanProductDetailsStepComponent implements OnInit {
|
export class LoanProductDetailsStepComponent implements OnInit {
|
||||||
@@ -39,6 +42,14 @@ export class LoanProductDetailsStepComponent implements OnInit {
|
|||||||
minDate = new Date(2000, 0, 1);
|
minDate = new Date(2000, 0, 1);
|
||||||
maxDate = new Date(new Date().setFullYear(new Date().getFullYear() + 10));
|
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 {FormBuilder} formBuilder Form Builder.
|
||||||
* @param {Dates} dateUtils Date Utils.
|
* @param {Dates} dateUtils Date Utils.
|
||||||
@@ -50,90 +61,158 @@ export class LoanProductDetailsStepComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit() {
|
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({
|
this.loanProductDetailsForm.patchValue({
|
||||||
name: this.loanProductsTemplate.name,
|
name: this.loanProductsTemplate.name,
|
||||||
shortName: this.loanProductsTemplate.shortName,
|
shortName: this.loanProductsTemplate.shortName,
|
||||||
description: this.loanProductsTemplate.description,
|
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)
|
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 = [
|
readonly productTypeGroups = [
|
||||||
{ id: 'loans', label: 'Loan' },
|
{ id: 'advances', label: 'Advances A/C', hint: 'No EMI. Limit / drawing power against security.' },
|
||||||
{ id: 'advanced', label: 'Advanced' }
|
{ id: 'loans', label: 'Loans A/C', hint: 'Fixed disbursement, fixed tenure, EMI repayment.' }
|
||||||
];
|
];
|
||||||
|
|
||||||
readonly productTypeSubGroups: Record<string, { id: string; label: string }[]> = {
|
/** `shortName` is the two-letter code the product's Short Name is pre-filled with when the sub type is picked. */
|
||||||
loans: [
|
readonly productTypeSubGroups: Record<string, { id: string; label: string; shortName: string }[]> = {
|
||||||
{ id: 'emi', label: 'EMI' },
|
advances: [
|
||||||
{ id: 'cc', label: 'CC (Cash Credit)' },
|
{ id: 'fd', label: 'Loan against FD', shortName: 'FD' },
|
||||||
{ id: 'overdraft', label: 'Overdraft' },
|
{ id: 'rd', label: 'Loan against RD', shortName: 'RD' },
|
||||||
{ id: 'warehouse', label: 'Warehouse' },
|
{ id: 'dds', label: 'Loan against DDS', shortName: 'DS' },
|
||||||
{ id: 'gold', label: 'Gold' }
|
{ 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: [
|
loans: [
|
||||||
{ id: 'fd', label: 'FD (Fixed Deposit)' },
|
{ id: 'term', label: 'Term Loan', shortName: 'TL' },
|
||||||
{ id: 'rd', label: 'RD (Recurring Deposit)' }
|
{ 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 */
|
/** Regulatory Classification options (UI-only; RBI / bureau reporting). */
|
||||||
private readonly glDefaultMap: Record<string, string> = {
|
readonly loanProductCategoryOptions = [
|
||||||
loans: 'asset',
|
{ id: 'advances_deposits', label: 'Advances against deposits' },
|
||||||
advanced: 'asset'
|
{ 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() {
|
get availableSubGroups() {
|
||||||
const group = this.loanProductDetailsForm.get('productTypeGroup')?.value;
|
const group = this.loanProductDetailsForm.get('productTypeGroup')?.value;
|
||||||
return group ? (this.productTypeSubGroups[group] ?? []) : [];
|
return group ? (this.productTypeSubGroups[group] ?? []) : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
get glOptions(): string[] {
|
/**
|
||||||
return [
|
* Asset GLs offered for the product's ledger GL -- the GL the product's loan account numbers are
|
||||||
'asset',
|
* generated from. Separate from the Accounting tab mappings, which drive the journal entries.
|
||||||
'liability'
|
*/
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
assetAccountOptions: any[] = [];
|
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 {
|
get selectedGroupLabel(): string {
|
||||||
const group = this.loanProductDetailsForm.get('productTypeGroup')?.value;
|
const group = this.loanProductDetailsForm.get('productTypeGroup')?.value;
|
||||||
return this.productTypeGroups.find((g) => g.id === group)?.label ?? '';
|
return this.productTypeGroups.find((g) => g.id === group)?.label ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
readonly yesNoOptions = [
|
/**
|
||||||
{ value: true, label: 'YES' },
|
* A new product's short name is two capital letters, so what is typed or pasted is upper-cased, anything
|
||||||
{ value: false, label: 'NO' }
|
* 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() {
|
createLoanProductDetailsForm() {
|
||||||
this.loanProductDetailsForm = this.formBuilder.group({
|
this.loanProductDetailsForm = this.formBuilder.group({
|
||||||
@@ -150,21 +229,26 @@ export class LoanProductDetailsStepComponent implements OnInit {
|
|||||||
closeDate: [''],
|
closeDate: [''],
|
||||||
productTypeGroup: [''],
|
productTypeGroup: [''],
|
||||||
productTypeSubGroup: [''],
|
productTypeSubGroup: [''],
|
||||||
glClassification: ['asset'],
|
|
||||||
glAccount: [''],
|
glAccount: [''],
|
||||||
debitIntCalculation: [false],
|
// Regulatory Classification (UI-only; stripped in buildPayload)
|
||||||
separatePenalInterest: [false],
|
loanProductCategory: ['advances_deposits'],
|
||||||
creditIntCalculation: [false],
|
prioritySector: ['no'],
|
||||||
subAccountTypeBalance: [false],
|
cibilAccountType: ['loan_bank_deposits'],
|
||||||
finalIntRounding: [false],
|
kfsEnabled: ['YES'],
|
||||||
roundingUpTo: [null]
|
aprEnabled: ['YES']
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auto-set GL based on product type group
|
// A sub type belongs to one group, so changing the group clears it.
|
||||||
this.loanProductDetailsForm.get('productTypeGroup')?.valueChanges.subscribe((group: string) => {
|
this.loanProductDetailsForm.get('productTypeGroup')?.valueChanges.subscribe(() => {
|
||||||
const defaultVal = this.glDefaultMap[group] ?? 'asset';
|
this.loanProductDetailsForm.get('productTypeSubGroup')?.setValue('');
|
||||||
this.loanProductDetailsForm.get('glClassification')?.setValue(defaultVal);
|
});
|
||||||
this.loanProductDetailsForm.get('glAccount')?.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);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+106
@@ -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>
|
||||||
+90
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+69
@@ -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>
|
||||||
+64
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+160
@@ -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 & 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 & 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>
|
||||||
+117
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+196
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+1285
-1240
File diff suppressed because it is too large
Load Diff
+10
@@ -71,6 +71,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lps-panel {
|
||||||
|
& + & {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lps-section__body {
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.lps-full-width {
|
.lps-full-width {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
|
|||||||
+78
-8
@@ -22,6 +22,12 @@ import { MatTooltip } from '@angular/material/tooltip';
|
|||||||
import { MatCheckbox } from '@angular/material/checkbox';
|
import { MatCheckbox } from '@angular/material/checkbox';
|
||||||
import { MatIconButton } from '@angular/material/button';
|
import { MatIconButton } from '@angular/material/button';
|
||||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
|
import {
|
||||||
|
MatAccordion,
|
||||||
|
MatExpansionPanel,
|
||||||
|
MatExpansionPanelHeader,
|
||||||
|
MatExpansionPanelTitle
|
||||||
|
} from '@angular/material/expansion';
|
||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -35,7 +41,11 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
|||||||
MatCheckbox,
|
MatCheckbox,
|
||||||
MatIconButton,
|
MatIconButton,
|
||||||
FaIconComponent,
|
FaIconComponent,
|
||||||
ReactiveFormsModule
|
ReactiveFormsModule,
|
||||||
|
MatAccordion,
|
||||||
|
MatExpansionPanel,
|
||||||
|
MatExpansionPanelHeader,
|
||||||
|
MatExpansionPanelTitle
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class LoanProductSettingsStepComponent implements OnInit {
|
export class LoanProductSettingsStepComponent implements OnInit {
|
||||||
@@ -44,9 +54,35 @@ export class LoanProductSettingsStepComponent implements OnInit {
|
|||||||
|
|
||||||
@Input() toEdit: boolean;
|
@Input() toEdit: boolean;
|
||||||
@Input() loanProductsTemplate: any;
|
@Input() loanProductsTemplate: any;
|
||||||
@Input() isLinkedToFloatingInterestRates: UntypedFormControl;
|
@Input() productTypeGroup: UntypedFormControl;
|
||||||
@Output() advancePaymentStrategy = new EventEmitter<string>();
|
@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;
|
loanProductSettingsForm: UntypedFormGroup;
|
||||||
|
|
||||||
amortizationTypeData: any;
|
amortizationTypeData: any;
|
||||||
@@ -74,6 +110,15 @@ export class LoanProductSettingsStepComponent implements OnInit {
|
|||||||
daysInYearCustomStrategyOptions: OptionData[] = [];
|
daysInYearCustomStrategyOptions: OptionData[] = [];
|
||||||
useDaysInYearCustomStrategy = false;
|
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 */
|
/** Values to Days for Repayments */
|
||||||
defaultConfigValues: GlobalConfiguration[] = [];
|
defaultConfigValues: GlobalConfiguration[] = [];
|
||||||
|
|
||||||
@@ -84,12 +129,13 @@ export class LoanProductSettingsStepComponent implements OnInit {
|
|||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
this.defaultConfigValues = this.loanProductsTemplate['itemsByDefault'];
|
this.defaultConfigValues = this.loanProductsTemplate['itemsByDefault'];
|
||||||
this.isLinkedToFloatingInterestRates.valueChanges.subscribe((isLinkedToFloatingInterestRates: any) => {
|
|
||||||
if (isLinkedToFloatingInterestRates) {
|
if (this.productTypeGroup) {
|
||||||
this.loanProductSettingsForm.get('isInterestRecalculationEnabled').setValue(true);
|
this.applyAdvanceAccountRules(this.productTypeGroup.value === 'advances');
|
||||||
this.loanProductSettingsForm.get('allowPartialPeriodInterestCalculation').setValue(true);
|
this.productTypeGroup.valueChanges.subscribe((productTypeGroup: string) => {
|
||||||
}
|
this.applyAdvanceAccountRules(productTypeGroup === 'advances');
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
this.amortizationTypeData = this.loanProductsTemplate.amortizationTypeOptions;
|
this.amortizationTypeData = this.loanProductsTemplate.amortizationTypeOptions;
|
||||||
this.interestTypeData = this.loanProductsTemplate.interestTypeOptions;
|
this.interestTypeData = this.loanProductsTemplate.interestTypeOptions;
|
||||||
@@ -334,6 +380,8 @@ export class LoanProductSettingsStepComponent implements OnInit {
|
|||||||
Validators.required
|
Validators.required
|
||||||
],
|
],
|
||||||
allowAccrualPostingInArrears: [false],
|
allowAccrualPostingInArrears: [false],
|
||||||
|
// Advances A/C only; UI-only for now, stripped from the payload in loan-products.ts buildPayload().
|
||||||
|
loanPeriod: [''],
|
||||||
|
|
||||||
// Cheque Book Settings
|
// Cheque Book Settings
|
||||||
chequeBookIssueEnabled: ['NO'],
|
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() {
|
setConditionalControls() {
|
||||||
const allowAttributeOverrides = this.loanProductSettingsForm.get('allowAttributeOverrides');
|
const allowAttributeOverrides = this.loanProductSettingsForm.get('allowAttributeOverrides');
|
||||||
|
|
||||||
|
|||||||
+56
@@ -264,6 +264,62 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</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 -->
|
<!-- Interest Rate Slabs Section -->
|
||||||
<div class="lpt-section">
|
<div class="lpt-section">
|
||||||
<div class="lpt-section-header">
|
<div class="lpt-section-header">
|
||||||
|
|||||||
+15
-2
@@ -111,7 +111,13 @@ export class LoanProductTermsStepComponent implements OnInit, OnChanges {
|
|||||||
repaymentStartDateType: this.loanProductsTemplate.repaymentStartDateType.id || 1,
|
repaymentStartDateType: this.loanProductsTemplate.repaymentStartDateType.id || 1,
|
||||||
debitBalanceAllow: this.loanProductsTemplate.debitBalanceAllow ?? false,
|
debitBalanceAllow: this.loanProductsTemplate.debitBalanceAllow ?? false,
|
||||||
allowDebitTransAfterDueDate: this.loanProductsTemplate.allowDebitTransAfterDueDate ?? 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) {
|
if (this.loanProductsTemplate.allowApprovedDisbursedAmountsOverApplied) {
|
||||||
@@ -213,7 +219,14 @@ export class LoanProductTermsStepComponent implements OnInit, OnChanges {
|
|||||||
fixedLength: [null],
|
fixedLength: [null],
|
||||||
debitBalanceAllow: [false],
|
debitBalanceAllow: [false],
|
||||||
allowDebitTransAfterDueDate: [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,
|
dateFormat,
|
||||||
locale
|
locale
|
||||||
};
|
};
|
||||||
// Remove unnecessary properties
|
// Remove UI-only flags that are never valid loan-product parameters (both paths).
|
||||||
delete loanProduct.allowAttributeConfiguration;
|
delete loanProduct.allowAttributeConfiguration;
|
||||||
delete loanProduct.advancedAccountingRules;
|
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
|
// 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.
|
// 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 { 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 { 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 { 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 { CollateralResolver } from './collaterals/collateral.resolver';
|
||||||
import { CollateralsResolver } from './collaterals/collaterals.resolver';
|
import { CollateralsResolver } from './collaterals/collaterals.resolver';
|
||||||
import { CollateralTemplateResolver } from './collaterals/collaterals-template.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',
|
path: 'charges',
|
||||||
data: { title: 'Charges', breadcrumb: 'Charges' },
|
data: { title: 'Charges', breadcrumb: 'Charges' },
|
||||||
|
|||||||
@@ -106,6 +106,19 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</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>
|
<div #recurringDepositProducts>
|
||||||
<a
|
<a
|
||||||
class="pd-card"
|
class="pd-card"
|
||||||
|
|||||||
+63
-5
@@ -21,14 +21,31 @@
|
|||||||
<!-- The outcome, product by product. Which products were skipped and why matters as much as
|
<!-- 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. -->
|
which succeeded, so both are shown rather than just a success count. -->
|
||||||
<div class="pl-card dd-panel">
|
<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">
|
<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>
|
<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>
|
<p>
|
||||||
{{ result.declarationsCreated }}
|
{{ result.declarationsCreated }}
|
||||||
{{ result.declarationsCreated === 1 ? 'declaration' : 'declarations' }} created. Each still needs approval
|
{{ result.declarationsCreated === 1 ? 'declaration' : 'declarations' }} created. Each still needs approval
|
||||||
before members can claim.
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -128,7 +145,22 @@
|
|||||||
<span class="dd-amount">− {{ preview.totalAmount | number: '1.2-2' }}</span>
|
<span class="dd-amount">− {{ preview.totalAmount | number: '1.2-2' }}</span>
|
||||||
</div>
|
</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">
|
<div class="dd-note dd-note--error">
|
||||||
Short by {{ preview.fundingShortfall | number: '1.2-2' }}. There is not enough in
|
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.
|
{{ 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
|
{{ declareForm.value.ratePercentage || 0 }}% on each member’s share investment
|
||||||
</div>
|
</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) {
|
@for (product of eligibleProducts; track product.productId) {
|
||||||
<div class="dd-preview-row">
|
<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>
|
<span class="dd-amount">{{ product.amount | number: '1.2-2' }}</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (eligibleProducts.length > 0) {
|
@if (eligibleProducts.length > 0) {
|
||||||
<div class="dd-preview-row dd-preview-row--total">
|
<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>
|
<span class="dd-amount">{{ payableTotal | number: '1.2-2' }}</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
+13
@@ -148,6 +148,14 @@ form {
|
|||||||
text-align: right;
|
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 {
|
.dd-note {
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
@@ -194,6 +202,11 @@ form {
|
|||||||
font-size: 26px;
|
font-size: 26px;
|
||||||
color: #16a34a;
|
color: #16a34a;
|
||||||
margin-top: 2px;
|
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 {
|
.dd-table {
|
||||||
|
|||||||
+47
-4
@@ -73,6 +73,17 @@ export class DeclareDividendComponent implements OnInit {
|
|||||||
/** Set once the declaration has gone through, so the outcome per product can be shown. */
|
/** Set once the declaration has gone through, so the outcome per product can be shown. */
|
||||||
result: any = null;
|
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 {
|
ngOnInit(): void {
|
||||||
this.declareForm = this.formBuilder.group({
|
this.declareForm = this.formBuilder.group({
|
||||||
financialYear: [
|
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 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,
|
* The year in progress is listed but cannot be declared — the server rejects it, and the screen
|
||||||
* and five back covers a declaration delayed by an AGM that ran late. Built from the business date
|
* says so as soon as it is picked. It stays on the list so an officer looking for it finds it with
|
||||||
* rather than the browser's clock, so the list matches the year the branch is operating in.
|
* 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 {
|
private buildFinancialYears(): void {
|
||||||
const businessDate: Date = this.settingsService.businessDate ?? new Date();
|
const businessDate: Date = this.settingsService.businessDate ?? new Date();
|
||||||
@@ -171,8 +186,36 @@ export class DeclareDividendComponent implements OnInit {
|
|||||||
return !!this.preview?.exceedsDistributableProfit;
|
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 {
|
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 {
|
submit(): void {
|
||||||
|
|||||||
+1
-34
@@ -70,16 +70,9 @@ export class ShareProductAccountingStepComponent implements OnInit {
|
|||||||
shareProductAccountingForm: UntypedFormGroup;
|
shareProductAccountingForm: UntypedFormGroup;
|
||||||
accountingSectionConfigs: ShareAccountingSectionConfig[] = [];
|
accountingSectionConfigs: ShareAccountingSectionConfig[] = [];
|
||||||
|
|
||||||
assetAccountData: GLAccount[] = [];
|
|
||||||
liabilityAccountData: GLAccount[] = [];
|
liabilityAccountData: GLAccount[] = [];
|
||||||
incomeAccountData: GLAccount[] = [];
|
incomeAccountData: GLAccount[] = [];
|
||||||
equityAccountData: 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() {
|
constructor() {
|
||||||
this.createShareProductAccountingForm();
|
this.createShareProductAccountingForm();
|
||||||
@@ -87,18 +80,15 @@ export class ShareProductAccountingStepComponent implements OnInit {
|
|||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
const opts = this.shareProductsTemplate.accountingMappingOptions || {};
|
const opts = this.shareProductsTemplate.accountingMappingOptions || {};
|
||||||
this.assetAccountData = opts.assetAccountOptions || [];
|
|
||||||
this.liabilityAccountData = opts.liabilityAccountOptions || [];
|
this.liabilityAccountData = opts.liabilityAccountOptions || [];
|
||||||
this.incomeAccountData = opts.incomeAccountOptions || [];
|
this.incomeAccountData = opts.incomeAccountOptions || [];
|
||||||
this.equityAccountData = opts.equityAccountOptions || [];
|
this.equityAccountData = opts.equityAccountOptions || [];
|
||||||
this.referenceAccountData = this.assetAccountData.concat(this.liabilityAccountData);
|
|
||||||
|
|
||||||
this.buildAccountingSectionConfigs();
|
this.buildAccountingSectionConfigs();
|
||||||
|
|
||||||
if (this.shareProductsTemplate.accountingMappings) {
|
if (this.shareProductsTemplate.accountingMappings) {
|
||||||
const m = this.shareProductsTemplate.accountingMappings;
|
const m = this.shareProductsTemplate.accountingMappings;
|
||||||
this.shareProductAccountingForm.patchValue({
|
this.shareProductAccountingForm.patchValue({
|
||||||
shareReferenceId: m.shareReferenceId?.id || '',
|
|
||||||
shareSuspenseId: m.shareSuspenseId?.id || '',
|
shareSuspenseId: m.shareSuspenseId?.id || '',
|
||||||
incomeFromFeeAccountId: m.incomeFromFeeAccountId?.id || '',
|
incomeFromFeeAccountId: m.incomeFromFeeAccountId?.id || '',
|
||||||
shareEquityId: m.shareEquityId?.id || ''
|
shareEquityId: m.shareEquityId?.id || ''
|
||||||
@@ -109,10 +99,6 @@ export class ShareProductAccountingStepComponent implements OnInit {
|
|||||||
createShareProductAccountingForm() {
|
createShareProductAccountingForm() {
|
||||||
this.shareProductAccountingForm = this.formBuilder.group({
|
this.shareProductAccountingForm = this.formBuilder.group({
|
||||||
accountingRule: [2],
|
accountingRule: [2],
|
||||||
shareReferenceId: [
|
|
||||||
'',
|
|
||||||
Validators.required
|
|
||||||
],
|
|
||||||
shareSuspenseId: [
|
shareSuspenseId: [
|
||||||
'',
|
'',
|
||||||
Validators.required
|
Validators.required
|
||||||
@@ -125,7 +111,6 @@ export class ShareProductAccountingStepComponent implements OnInit {
|
|||||||
'',
|
'',
|
||||||
Validators.required
|
Validators.required
|
||||||
],
|
],
|
||||||
assetsOtherGlMappings: this.formBuilder.array([]),
|
|
||||||
liabilitiesOtherGlMappings: this.formBuilder.array([]),
|
liabilitiesOtherGlMappings: this.formBuilder.array([]),
|
||||||
incomeOtherGlMappings: this.formBuilder.array([]),
|
incomeOtherGlMappings: this.formBuilder.array([]),
|
||||||
equityOtherGlMappings: this.formBuilder.array([])
|
equityOtherGlMappings: this.formBuilder.array([])
|
||||||
@@ -134,24 +119,6 @@ export class ShareProductAccountingStepComponent implements OnInit {
|
|||||||
|
|
||||||
buildAccountingSectionConfigs() {
|
buildAccountingSectionConfigs() {
|
||||||
this.accountingSectionConfigs = [
|
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',
|
key: 'liabilities',
|
||||||
title: 'Liabilities',
|
title: 'Liabilities',
|
||||||
@@ -239,7 +206,7 @@ export class ShareProductAccountingStepComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get shareProductAccounting() {
|
get shareProductAccounting() {
|
||||||
const { assetsOtherGlMappings, liabilitiesOtherGlMappings, incomeOtherGlMappings, equityOtherGlMappings, ...rest } =
|
const { liabilitiesOtherGlMappings, incomeOtherGlMappings, equityOtherGlMappings, ...rest } =
|
||||||
this.shareProductAccountingForm.value;
|
this.shareProductAccountingForm.value;
|
||||||
return rest;
|
return rest;
|
||||||
}
|
}
|
||||||
|
|||||||
-8
@@ -213,14 +213,6 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (shareProduct.accountingRule === 2) {
|
@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-sub-header">{{ 'labels.heading.Liabilities' | translate }}</div>
|
||||||
<div class="shpp-row">
|
<div class="shpp-row">
|
||||||
<span class="shpp-label">{{ 'labels.inputs.Share suspense control' | translate }}:</span>
|
<span class="shpp-label">{{ 'labels.inputs.Share suspense control' | translate }}:</span>
|
||||||
|
|||||||
-10
@@ -208,16 +208,6 @@
|
|||||||
|
|
||||||
@if (shareProduct.accountingRule.id === 2) {
|
@if (shareProduct.accountingRule.id === 2) {
|
||||||
<div class="layout-row-wrap responsive-column flex-fill">
|
<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>
|
<h4 class="mat-h4 flex-fill">{{ 'labels.heading.Liabilities' | translate }}</h4>
|
||||||
<div class="flex-fill">
|
<div class="flex-fill">
|
||||||
<mifosx-gl-account-display
|
<mifosx-gl-account-display
|
||||||
|
|||||||
+311
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+407
-38
@@ -36,6 +36,17 @@
|
|||||||
<button mat-icon-button type="button" matTooltip="Refresh now" [disabled]="loading" (click)="fetch()">
|
<button mat-icon-button type="button" matTooltip="Refresh now" [disabled]="loading" (click)="fetch()">
|
||||||
<mat-icon>refresh</mat-icon>
|
<mat-icon>refresh</mat-icon>
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -44,6 +55,52 @@
|
|||||||
<span>Could not reach the alert feed. Retrying automatically.</span>
|
<span>Could not reach the alert feed. Retrying automatically.</span>
|
||||||
</div>
|
</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 -->
|
<!-- Narrow the feed by where each alert sits in the review workflow -->
|
||||||
<div class="status-filters">
|
<div class="status-filters">
|
||||||
<span class="status-filters-label">Status</span>
|
<span class="status-filters-label">Status</span>
|
||||||
@@ -60,23 +117,143 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Severity mix of what the table is showing -->
|
<!-- Narrow to the rules firing most often. A rule tripping repeatedly is either a real pattern in the book or a rule
|
||||||
<div class="risk-summary">
|
set too tight and filling the queue with noise — either way it is what an officer reads first, and the long tail
|
||||||
<div *ngFor="let tier of riskSummary" class="risk-tile" [ngClass]="tier.cls">
|
below it can wait. Ranked over the period and status already chosen. -->
|
||||||
<span class="risk-tile-n">{{ tier.count }}</span>
|
<div class="status-filters">
|
||||||
<span class="risk-tile-l">{{ tier.label }}</span>
|
<span class="status-filters-label">Top rules</span>
|
||||||
</div>
|
@for (n of topRuleOptions; track n) {
|
||||||
<div *ngIf="unclassifiedCount > 0" class="risk-tile risk-none" matTooltip="Raised by a user, not by a catalog rule">
|
<button type="button" class="status-chip" [class.active]="topRules === n" (click)="setTopRules(n)">
|
||||||
<span class="risk-tile-n">{{ unclassifiedCount }}</span>
|
{{ n === null ? 'All rules' : 'Top ' + n }}
|
||||||
<span class="risk-tile-l">Unclassified</span>
|
</button>
|
||||||
</div>
|
}
|
||||||
<div class="risk-tile risk-total">
|
@if (topRules !== null) {
|
||||||
<span class="risk-tile-n">{{ summaryTotal }}</span>
|
<span class="period-resolved">{{ distinctRuleCount }} rule{{ distinctRuleCount === 1 ? '' : 's' }} in view</span>
|
||||||
<span class="risk-tile-l">Total Flagged</span>
|
}
|
||||||
</div>
|
|
||||||
</div>
|
</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 -->
|
<!-- When the engine raised the alert -->
|
||||||
<ng-container matColumnDef="raisedOn">
|
<ng-container matColumnDef="raisedOn">
|
||||||
<th mat-header-cell *matHeaderCellDef>Raised On</th>
|
<th mat-header-cell *matHeaderCellDef>Raised On</th>
|
||||||
@@ -113,6 +290,162 @@
|
|||||||
<div class="stacked-cell">
|
<div class="stacked-cell">
|
||||||
<span class="rule-code">{{ alert.ruleCode }}</span>
|
<span class="rule-code">{{ alert.ruleCode }}</span>
|
||||||
<span class="cell-sub">{{ alert.ruleDescription }}</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>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
@@ -132,7 +465,7 @@
|
|||||||
<!-- Disposition. Hidden once an alert is already whitelisted or escalated. -->
|
<!-- Disposition. Hidden once an alert is already whitelisted or escalated. -->
|
||||||
<ng-container matColumnDef="actions">
|
<ng-container matColumnDef="actions">
|
||||||
<th mat-header-cell *matHeaderCellDef class="col-center">Action</th>
|
<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">
|
<ng-container *ngIf="canReview(alert); else reviewed">
|
||||||
<!-- Same gate the FIU-IND register uses; the endpoint enforces REVIEW_AML_ALERT server side,
|
<!-- 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. -->
|
so without this a user who cannot review still sees buttons that only fail on click. -->
|
||||||
@@ -158,37 +491,73 @@
|
|||||||
</div>
|
</div>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
<ng-template #reviewed>
|
<ng-template #reviewed>
|
||||||
<div class="reviewed-cell">
|
<!-- Held by somebody else: not a verdict, so it does not read like one. The endpoint refuses a disposition
|
||||||
<span class="reviewed-by">
|
from anyone but the officer who has it, and this says who to ask. -->
|
||||||
{{ alert.reviewedByUsername ? 'by ' + alert.reviewedByUsername : 'Reviewed' }}
|
@if (heldByAnother(alert)) {
|
||||||
</span>
|
<div class="reviewed-cell">
|
||||||
<!-- The evidence recorded at review time, so the trail is readable without leaving the feed -->
|
<mat-icon
|
||||||
<mat-icon
|
class="held-icon"
|
||||||
*ngIf="alert.reviewNotes"
|
[matTooltip]="'Open with ' + heldBy(alert) + ' — they decide it or hand it back'"
|
||||||
class="reviewed-evidence-icon"
|
>
|
||||||
[matTooltip]="alert.reviewNotes"
|
lock
|
||||||
matTooltipClass="evidence-tooltip"
|
</mat-icon>
|
||||||
>
|
<span class="held-by">with {{ heldBy(alert) }}</span>
|
||||||
description
|
</div>
|
||||||
</mat-icon>
|
} @else {
|
||||||
<span *ngIf="alert.status === 'ESCALATED'" class="str-routed" matTooltip="An STR case is open for this alert">
|
<div class="reviewed-cell">
|
||||||
→ STR
|
<span class="reviewed-by">
|
||||||
</span>
|
{{ alert.reviewedByUsername ? 'by ' + alert.reviewedByUsername : 'Reviewed' }}
|
||||||
</div>
|
</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>
|
</ng-template>
|
||||||
</td>
|
</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
<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>
|
</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">
|
<div *ngIf="alerts.data.length === 0 && !loading" class="no-alerts-message">
|
||||||
<mat-icon>{{ activeStatus ? 'filter_alt_off' : 'verified_user' }}</mat-icon>
|
<mat-icon>{{ activeStatus || activeSeverity !== null ? 'filter_alt_off' : 'verified_user' }}</mat-icon>
|
||||||
<span *ngIf="!activeStatus"
|
<span *ngIf="!activeStatus && activeSeverity === null"
|
||||||
>No transactions flagged. The feed updates automatically as the engine raises alerts.</span
|
>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>
|
</div>
|
||||||
|
|||||||
+475
-1
@@ -196,15 +196,43 @@
|
|||||||
padding: 0 0 16px;
|
padding: 0 0 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A button, not a div: each tile narrows the table to its own severity band. */
|
||||||
.risk-tile {
|
.risk-tile {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
border: 1px solid #e2e8f0;
|
border: 1px solid #e2e8f0;
|
||||||
border-left-width: 4px;
|
border-left-width: 4px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: #fff;
|
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 {
|
.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;
|
background: #f8fafc;
|
||||||
|
|
||||||
|
td {
|
||||||
|
border-bottom-color: transparent;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* A newly arrived alert stays tinted until acknowledged. */
|
/* A newly arrived alert stays tinted until acknowledged. */
|
||||||
@@ -380,6 +421,285 @@
|
|||||||
font-size: 12px;
|
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 {
|
.rule-code {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
@@ -537,3 +857,157 @@
|
|||||||
background: #fff;
|
background: #fff;
|
||||||
border-top: 1px solid #e2e8f0;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
+806
-25
@@ -7,7 +7,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/** Angular Imports */
|
/** 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 { MatTableModule, MatTableDataSource } from '@angular/material/table';
|
||||||
import { MatPaginatorModule, MatPaginator } from '@angular/material/paginator';
|
import { MatPaginatorModule, MatPaginator } from '@angular/material/paginator';
|
||||||
import { MatIconModule } from '@angular/material/icon';
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
@@ -15,15 +15,23 @@ import { MatButtonModule } from '@angular/material/button';
|
|||||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||||
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||||
|
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
import { Subject, timer, of } from 'rxjs';
|
import { Subject, timer, of } from 'rxjs';
|
||||||
import { switchMap, takeUntil, catchError, filter } from 'rxjs/operators';
|
import { switchMap, takeUntil, catchError, filter } from 'rxjs/operators';
|
||||||
|
|
||||||
/** Custom Services */
|
/** Custom Services */
|
||||||
import { AmlService } from '../../../aml/aml.service';
|
import { AmlService } from '../../../aml/aml.service';
|
||||||
import { AlertService } from '../../../core/alert/alert.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 { HasPermissionDirective } from '../../../directives/has-permission/has-permission.directive';
|
||||||
import { AmlReviewAlertDialogComponent } from './aml-review-alert-dialog.component';
|
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.
|
* Live feed of transactions the AML rule engine has flagged.
|
||||||
@@ -41,6 +49,7 @@ import { AmlReviewAlertDialogComponent } from './aml-review-alert-dialog.compone
|
|||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
MatTableModule,
|
MatTableModule,
|
||||||
MatPaginatorModule,
|
MatPaginatorModule,
|
||||||
MatIconModule,
|
MatIconModule,
|
||||||
@@ -48,13 +57,18 @@ import { AmlReviewAlertDialogComponent } from './aml-review-alert-dialog.compone
|
|||||||
MatTooltipModule,
|
MatTooltipModule,
|
||||||
MatProgressSpinnerModule,
|
MatProgressSpinnerModule,
|
||||||
MatDialogModule,
|
MatDialogModule,
|
||||||
|
MatCheckboxModule,
|
||||||
HasPermissionDirective
|
HasPermissionDirective
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class AmlFlaggedTransactionsComponent implements OnInit, OnDestroy {
|
export class AmlFlaggedTransactionsComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||||
private amlService = inject(AmlService);
|
private amlService = inject(AmlService);
|
||||||
private dialog = inject(MatDialog);
|
private dialog = inject(MatDialog);
|
||||||
private alertService = inject(AlertService);
|
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>();
|
private destroy$ = new Subject<void>();
|
||||||
|
|
||||||
/** Short enough to feel live to an officer watching the desk, long enough not to hammer the API. */
|
/** 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>([]);
|
alerts = new MatTableDataSource<any>([]);
|
||||||
displayedColumns: string[] = [
|
displayedColumns: string[] = [
|
||||||
|
'select',
|
||||||
'raisedOn',
|
'raisedOn',
|
||||||
'transaction',
|
'transaction',
|
||||||
'client',
|
'client',
|
||||||
@@ -71,25 +86,532 @@ export class AmlFlaggedTransactionsComponent implements OnInit, OnDestroy {
|
|||||||
'actions'
|
'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. */
|
/** Every alert as returned, before the status filter narrows the table. */
|
||||||
private allAlerts: any[] = [];
|
private allAlerts: any[] = [];
|
||||||
|
|
||||||
readonly statusFilters: { key: string; label: string }[] = [
|
readonly statusFilters: { key: string; label: string }[] = [
|
||||||
{ key: '', label: 'All alerts' },
|
{ key: '', label: 'All alerts' },
|
||||||
{ key: 'RAISED', label: 'Raised' },
|
{ 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: 'ESCALATED', label: 'Escalated' },
|
||||||
{ key: 'WHITELISTED', label: 'Whitelisted' }
|
{ key: 'WHITELISTED', label: 'Whitelisted' }
|
||||||
];
|
];
|
||||||
activeStatus = '';
|
activeStatus = '';
|
||||||
private statusCounts: Record<string, number> = {};
|
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
|
* 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
|
* 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.
|
* 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. */
|
/** Alerts whose rule has no catalog ancestor (MANUAL_REFERRAL), so no severity to report. */
|
||||||
unclassifiedCount = 0;
|
unclassifiedCount = 0;
|
||||||
|
|
||||||
@@ -106,6 +628,18 @@ export class AmlFlaggedTransactionsComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
@ViewChild(MatPaginator) paginator!: MatPaginator;
|
@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 {
|
ngOnInit(): void {
|
||||||
this.fetch();
|
this.fetch();
|
||||||
timer(AmlFlaggedTransactionsComponent.POLL_INTERVAL_MS, AmlFlaggedTransactionsComponent.POLL_INTERVAL_MS)
|
timer(AmlFlaggedTransactionsComponent.POLL_INTERVAL_MS, AmlFlaggedTransactionsComponent.POLL_INTERVAL_MS)
|
||||||
@@ -158,7 +692,22 @@ export class AmlFlaggedTransactionsComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
setStatus(key: string): void {
|
setStatus(key: string): void {
|
||||||
this.activeStatus = key;
|
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 {
|
statusCount(key: string): number {
|
||||||
@@ -203,58 +752,202 @@ export class AmlFlaggedTransactionsComponent implements OnInit, OnDestroy {
|
|||||||
this.allAlerts = list;
|
this.allAlerts = list;
|
||||||
this.recomputeStatusCounts();
|
this.recomputeStatusCounts();
|
||||||
this.applyStatusFilter();
|
this.applyStatusFilter();
|
||||||
|
this.pruneSelection();
|
||||||
this.lastUpdated = new Date();
|
this.lastUpdated = new Date();
|
||||||
}
|
}
|
||||||
|
|
||||||
private applyStatusFilter(): void {
|
/**
|
||||||
this.alerts.data = this.activeStatus
|
* @param resetPage
|
||||||
? this.allAlerts.filter((a) => a.status === this.activeStatus)
|
* true only when the user changed a filter. A poll must not reset the page: the feed
|
||||||
: this.allAlerts;
|
* 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) {
|
if (!this.alerts.paginator && this.paginator) {
|
||||||
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
|
* 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.
|
* disagreed with the table underneath it would be worse than no summary.
|
||||||
*/
|
*/
|
||||||
private recomputeRiskSummary(): void {
|
private recomputeRiskSummary(pool: any[]): void {
|
||||||
const pool = this.alerts.data;
|
|
||||||
const tiers = [
|
const tiers = [
|
||||||
{ key: 3, label: 'High Risk', cls: 'risk-high' },
|
{ key: 3, label: 'High Risk', cls: 'risk-high' },
|
||||||
{ key: 2, label: 'Medium Risk', cls: 'risk-medium' },
|
{ key: 2, label: 'Medium Risk', cls: 'risk-medium' },
|
||||||
{ key: 1, label: 'Low Risk', cls: 'risk-low' }
|
{ key: 1, label: 'Low Risk', cls: 'risk-low' }
|
||||||
];
|
];
|
||||||
this.riskSummary = tiers.map((t) => ({
|
this.riskSummary = tiers.map((t) => ({
|
||||||
|
key: t.key,
|
||||||
label: t.label,
|
label: t.label,
|
||||||
cls: t.cls,
|
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.
|
// Band 0 is everything ungraded, so the tiles always reconcile to the total.
|
||||||
this.unclassifiedCount = pool.filter(
|
this.unclassifiedCount = pool.filter((a) => this.severityBand(a) === 0).length;
|
||||||
(a) => ![
|
}
|
||||||
1,
|
|
||||||
2,
|
/**
|
||||||
3
|
* Everything the status filter leaves, whatever severity tile is in force — so the tiles still
|
||||||
].includes(Number(a.riskCategory))
|
* add up to the total while one of them is narrowing the table, and the total reads as the way
|
||||||
).length;
|
* 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 {
|
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 {
|
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) {
|
for (const s of this.statusFilters) {
|
||||||
if (s.key) {
|
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 {
|
statusClass(status: string): string {
|
||||||
return 'alert-status status-' + (status || 'raised').toLowerCase();
|
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.
|
* opening a second STR case — this gate is what prevents it.
|
||||||
*/
|
*/
|
||||||
canReview(alert: any): boolean {
|
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 {
|
review(alert: any, disposition: 'WHITELIST' | 'ESCALATE'): void {
|
||||||
@@ -330,4 +1042,73 @@ export class AmlFlaggedTransactionsComponent implements OnInit, OnDestroy {
|
|||||||
this.fetch();
|
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();
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-1
@@ -31,6 +31,11 @@ import { AmlService } from '../../../aml/aml.service';
|
|||||||
export interface AmlReviewDialogData {
|
export interface AmlReviewDialogData {
|
||||||
alert: any;
|
alert: any;
|
||||||
disposition: 'WHITELIST' | 'ESCALATE';
|
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 {
|
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';
|
return this.isWhitelist ? 'Whitelist Transaction' : 'Mark as Suspicious';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,7 +193,17 @@ export class AmlReviewAlertDialogComponent {
|
|||||||
|
|
||||||
this.submitting = true;
|
this.submitting = true;
|
||||||
this.errorMessage = '';
|
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: () => {
|
next: () => {
|
||||||
this.submitting = false;
|
this.submitting = false;
|
||||||
this.dialogRef.close(this.data.disposition);
|
this.dialogRef.close(this.data.disposition);
|
||||||
|
|||||||
@@ -93,6 +93,11 @@ const routes: Routes = [
|
|||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('../tds-reports/tds-remittance-report.component').then((m) => m.TdsRemittanceReportComponent)
|
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',
|
path: ':filter',
|
||||||
data: { routeParamBreadcrumb: '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">→</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.';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -372,13 +372,29 @@ export const MATERIALITY = {
|
|||||||
* Rules whose seriousness does not depend on the amount. A sanctions match or a
|
* 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
|
* deliberate structuring pattern is grave at any value, so activity being small
|
||||||
* is not a reason to bring the rating down.
|
* 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([
|
export const NON_MITIGABLE_RULE_PREFIXES = [
|
||||||
'SANCTIONS_HIT',
|
'SANCTIONS',
|
||||||
'PEP',
|
'PEP',
|
||||||
'STRUCTURING',
|
'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. */
|
/** Shape of one element of GET /aml/alerts. */
|
||||||
export interface LiveAmlAlert {
|
export interface LiveAmlAlert {
|
||||||
|
|||||||
@@ -304,7 +304,9 @@
|
|||||||
reason sits under it rather than under the button, because it explains
|
reason sits under it rather than under the button, because it explains
|
||||||
this conclusion and not the act of opening the KYC screen. -->
|
this conclusion and not the act of opening the KYC screen. -->
|
||||||
<td class="rr-move-cell">
|
<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>
|
<fa-icon [icon]="directionIcon(c)"></fa-icon>
|
||||||
{{ directionText(c) }}
|
{{ directionText(c) }}
|
||||||
</span>
|
</span>
|
||||||
@@ -323,15 +325,32 @@
|
|||||||
<!-- Only the control. The verdict and its reason moved to their own column,
|
<!-- Only the control. The verdict and its reason moved to their own column,
|
||||||
so this no longer wraps to two lines under the button. -->
|
so this no longer wraps to two lines under the button. -->
|
||||||
<td class="rr-action-cell">
|
<td class="rr-action-cell">
|
||||||
<button
|
<!-- Raises the change for approval rather than sending the reviewer to the
|
||||||
type="button"
|
KYC screen to make it themselves. A case with the Principal Officer is
|
||||||
class="rr-kyc-link"
|
held where it is until the STR concludes, so it only offers the record. -->
|
||||||
[class.rr-kyc-link--act]="c.direction !== 'same' && !c.escalated"
|
@if (canChangeRisk(c)) {
|
||||||
(click)="openKyc(c, $event)"
|
<button
|
||||||
[attr.aria-label]="kycActionText(c) + ' for ' + c.customerName"
|
type="button"
|
||||||
>
|
class="rr-kyc-link"
|
||||||
<fa-icon icon="id-card"></fa-icon> {{ kycActionText(c) }}
|
[class.rr-kyc-link--act]="c.direction !== 'same'"
|
||||||
</button>
|
(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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
|
|||||||
@@ -937,6 +937,13 @@
|
|||||||
background: var(--r-g100);
|
background: var(--r-g100);
|
||||||
color: var(--r-g500);
|
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 {
|
.rr-move-why {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
import { Component, OnInit, inject } from '@angular/core';
|
import { Component, OnInit, inject } from '@angular/core';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
|
import { MatDialog } from '@angular/material/dialog';
|
||||||
import { forkJoin, of } from 'rxjs';
|
import { forkJoin, of } from 'rxjs';
|
||||||
import { catchError, map } from 'rxjs/operators';
|
import { catchError, map } from 'rxjs/operators';
|
||||||
|
|
||||||
@@ -38,12 +39,14 @@ import {
|
|||||||
RiskLevel,
|
RiskLevel,
|
||||||
channelLabel,
|
channelLabel,
|
||||||
mapLiveAlert,
|
mapLiveAlert,
|
||||||
riskFromCategory
|
riskFromCategory,
|
||||||
|
riskRank
|
||||||
} from './risk-review-data';
|
} from './risk-review-data';
|
||||||
import {
|
import {
|
||||||
DateRange,
|
DateRange,
|
||||||
RatingDirection,
|
RatingDirection,
|
||||||
RiskAlertView,
|
RiskAlertView,
|
||||||
|
BehaviourWindowByClient,
|
||||||
RiskCustomerRow,
|
RiskCustomerRow,
|
||||||
RiskReviewFilter,
|
RiskReviewFilter,
|
||||||
RiskReviewModel,
|
RiskReviewModel,
|
||||||
@@ -53,6 +56,7 @@ import {
|
|||||||
monthRange,
|
monthRange,
|
||||||
weekRange
|
weekRange
|
||||||
} from './risk-review.compute';
|
} from './risk-review.compute';
|
||||||
|
import { RiskChangeDialogComponent, RiskChangeDialogData } from './risk-change-dialog.component';
|
||||||
|
|
||||||
type PeriodMode = 'day' | 'week' | 'month' | 'range';
|
type PeriodMode = 'day' | 'week' | 'month' | 'range';
|
||||||
type ViewMode = 'customer' | 'alert' | 'txn';
|
type ViewMode = 'customer' | 'alert' | 'txn';
|
||||||
@@ -109,6 +113,7 @@ export class RiskReviewReportComponent implements OnInit {
|
|||||||
private clients = inject(ClientsService);
|
private clients = inject(ClientsService);
|
||||||
private scope = inject(BranchScopeService);
|
private scope = inject(BranchScopeService);
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
|
private dialog = inject(MatDialog);
|
||||||
|
|
||||||
readonly period = GL_PERIOD;
|
readonly period = GL_PERIOD;
|
||||||
readonly branches = GL_BRANCHES;
|
readonly branches = GL_BRANCHES;
|
||||||
@@ -181,21 +186,37 @@ export class RiskReviewReportComponent implements OnInit {
|
|||||||
|
|
||||||
// ── Loading ────────────────────────────────────────────────────────────────
|
// ── 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 {
|
load(): void {
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
this.aml
|
// Both in one pass: the windows decide what each row recommends, so composing before they arrive would show a
|
||||||
.getAlerts()
|
// recommendation the screen then silently replaced.
|
||||||
.pipe(catchError(() => of([] as LiveAmlAlert[])))
|
forkJoin({
|
||||||
.subscribe((live) => {
|
alerts: this.aml.getAlerts().pipe(catchError(() => of([] as LiveAmlAlert[]))),
|
||||||
const rows = (live || []).map((a) => mapLiveAlert(a as unknown as LiveAmlAlert));
|
windows: this.aml.getBehaviourWindows().pipe(catchError(() => of([] as any[])))
|
||||||
this.liveAlerts = rows;
|
}).subscribe(({ alerts: live, windows }) => {
|
||||||
this.compose();
|
this.behaviourWindows = {};
|
||||||
if (!rows.length) {
|
for (const w of windows || []) {
|
||||||
this.loading = false;
|
if (w?.clientId != null) {
|
||||||
return;
|
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) => {
|
).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) => {
|
this.liveAlerts = rows.map((r) => {
|
||||||
const info = byClient.get(r.clientId);
|
const info = byClient.get(r.clientId);
|
||||||
return {
|
return {
|
||||||
@@ -336,9 +362,11 @@ export class RiskReviewReportComponent implements OnInit {
|
|||||||
private memo: RiskReviewModel = { alerts: [], customers: [], txns: [], summary: EMPTY_SUMMARY };
|
private memo: RiskReviewModel = { alerts: [], customers: [], txns: [], summary: EMPTY_SUMMARY };
|
||||||
|
|
||||||
private get model(): RiskReviewModel {
|
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) {
|
if (key !== this.memoKey) {
|
||||||
this.memo = buildRiskReview(this.alerts, this.filter);
|
this.memo = buildRiskReview(this.alerts, this.filter, this.behaviourWindows);
|
||||||
this.memoKey = key;
|
this.memoKey = key;
|
||||||
}
|
}
|
||||||
return this.memo;
|
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
|
* 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.
|
* 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[] {
|
get customerGroups(): RiskCustomerGroup[] {
|
||||||
const rows = this.customerRows;
|
const rows = this.customerRows;
|
||||||
const escalated = 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
|
||||||
const rest = rows.filter((c) => !c.escalated);
|
// 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[] = [
|
const groups: RiskCustomerGroup[] = [
|
||||||
{
|
{
|
||||||
@@ -378,25 +459,42 @@ export class RiskReviewReportComponent implements OnInit {
|
|||||||
rows: escalated
|
rows: escalated
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'up',
|
// Raising and lowering are one section, not two. The heading answers "is there anything to do about this
|
||||||
title: 'Rating needs raising',
|
// customer", and which way the rating moves is already on every row - as a verdict, an arrow and a reason.
|
||||||
hint: 'The rules point higher than the rating on file. These are under-rated customers — the real gap.',
|
// 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',
|
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',
|
// Its own section, and above the watching list, because it is the opposite of that. An unrated customer whose
|
||||||
title: 'Rating can be lowered',
|
// money is moving has no rating for the behaviour to be compared against, so nothing here can say whether it
|
||||||
hint: 'Activity no longer supports the rating on file.',
|
// is in keeping - the work is to give them one, and grouping them with the customers nothing need be done
|
||||||
tone: 'down',
|
// about buried the one action the report was asking for.
|
||||||
rows: rest.filter((c) => c.direction === 'down')
|
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',
|
key: 'same',
|
||||||
title: 'No change needed',
|
title: 'Watching — not enough to act on',
|
||||||
hint: 'The rating on file already matches the behaviour. Listed for the record.',
|
hint: 'In view, but the period does not yet support moving the rating. The reason on each row says what is missing.',
|
||||||
tone: 'hold',
|
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.
|
* 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.
|
* change nobody is authorised to make yet.
|
||||||
*/
|
*/
|
||||||
directionText(c: RiskCustomerRow): string {
|
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';
|
return 'Held — with Principal Officer';
|
||||||
}
|
}
|
||||||
if (c.direction === 'up') {
|
// Both ends of the move, not just the destination. "Lower to Medium" reads as a verdict on its own and leaves the
|
||||||
return `Raise to ${RISK_LABEL[c.recommendedRisk]}`;
|
// 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') {
|
// An unrated customer is told what to do, not what was concluded. "No rating on record" states the gap and stops
|
||||||
return `Lower to ${RISK_LABEL[c.recommendedRisk]}`;
|
// 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';
|
||||||
return c.currentRisk ? `Keep at ${RISK_LABEL[c.currentRisk]}` : 'No rating on record';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
directionIcon(c: RiskCustomerRow): string {
|
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';
|
return 'lock';
|
||||||
}
|
}
|
||||||
// 'check' rather than an equals sign: faEquals is not in the shared library, and an
|
if (c.direction === 'up') {
|
||||||
// unregistered icon throws at render and takes the whole row down with it.
|
return 'arrow-up';
|
||||||
return c.direction === 'up' ? 'arrow-up' : c.direction === 'down' ? 'arrow-down' : 'check';
|
}
|
||||||
|
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';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
AmlRuleMeta,
|
AmlRuleMeta,
|
||||||
CTR_THRESHOLD,
|
CTR_THRESHOLD,
|
||||||
MATERIALITY,
|
MATERIALITY,
|
||||||
NON_MITIGABLE_RULES,
|
isNonMitigableRule,
|
||||||
REVIEW_STATUS_META,
|
REVIEW_STATUS_META,
|
||||||
RISK_LABEL,
|
RISK_LABEL,
|
||||||
RiskAlertRow,
|
RiskAlertRow,
|
||||||
@@ -26,6 +26,17 @@ export type RatingDirection = 'up' | 'down' | 'same';
|
|||||||
|
|
||||||
const RANK_TO_LEVEL: Record<number, RiskLevel> = { 1: 'LOW', 2: 'MEDIUM', 3: 'HIGH' };
|
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 {
|
export interface DateRange {
|
||||||
from: string;
|
from: string;
|
||||||
to: string;
|
to: string;
|
||||||
@@ -109,6 +120,8 @@ export interface RiskCustomerRow {
|
|||||||
needsUpgrade: boolean;
|
needsUpgrade: boolean;
|
||||||
/** True when the rating on record is higher than the activity warrants. */
|
/** True when the rating on record is higher than the activity warrants. */
|
||||||
needsDowngrade: boolean;
|
needsDowngrade: boolean;
|
||||||
|
/** The review period the recommendation was drawn from, where the nightly job has computed one. */
|
||||||
|
behaviourWindow?: BehaviourWindowByClient[number] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RiskReviewSummary {
|
export interface RiskReviewSummary {
|
||||||
@@ -155,17 +168,26 @@ function recommendRating(
|
|||||||
largestAmount: number,
|
largestAmount: number,
|
||||||
totalAmount: number,
|
totalAmount: number,
|
||||||
escalated: boolean,
|
escalated: boolean,
|
||||||
ruleCodes: string[]
|
ruleCodes: string[],
|
||||||
|
alertCount: number,
|
||||||
|
distinctMonths: number
|
||||||
): { risk: RiskLevel; reason: string } {
|
): { risk: RiskLevel; reason: string } {
|
||||||
const implied = RANK_TO_LEVEL[impliedRank] ?? 'LOW';
|
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 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
|
// What the rating should be. Amounts only ever pull the rules' bound down, and
|
||||||
// only where nothing weightier holds it up.
|
// only where nothing weightier holds it up.
|
||||||
let risk: RiskLevel;
|
let risk: RiskLevel;
|
||||||
let basis: 'escalated' | 'grave' | 'material' | 'small' | 'moderate';
|
let basis: 'escalated' | 'grave' | 'material' | 'small' | 'moderate' | 'thin';
|
||||||
if (escalated) {
|
if (escalated) {
|
||||||
risk = 'HIGH';
|
risk = 'HIGH';
|
||||||
basis = 'escalated';
|
basis = 'escalated';
|
||||||
@@ -175,6 +197,12 @@ function recommendRating(
|
|||||||
} else if (material) {
|
} else if (material) {
|
||||||
risk = implied;
|
risk = implied;
|
||||||
basis = 'material';
|
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 {
|
} else {
|
||||||
risk = RANK_TO_LEVEL[Math.min(impliedRank, small ? 1 : 2)] ?? 'LOW';
|
risk = RANK_TO_LEVEL[Math.min(impliedRank, small ? 1 : 2)] ?? 'LOW';
|
||||||
basis = small ? 'small' : 'moderate';
|
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
|
// 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.
|
// read as "not assessed yet" on screen, which is the opposite of what the review concluded.
|
||||||
return {
|
return {
|
||||||
@@ -271,7 +320,36 @@ export function riskAlertViews(alerts: RiskAlertRow[], filter: RiskReviewFilter)
|
|||||||
* escalated, then rated below what the rules point at, then most alerts —
|
* escalated, then rated below what the rules point at, then most alerts —
|
||||||
* come to the top of the worklist.
|
* 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[]>();
|
const byClient = new Map<number, RiskAlertView[]>();
|
||||||
for (const v of views) {
|
for (const v of views) {
|
||||||
const list = byClient.get(v.clientId);
|
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 largestAmount = Math.max(...alerts.map((a) => a.amount));
|
||||||
const escalated = alerts.some((a) => a.status === 'ESCALATED_STR' || a.status === 'STR_FILED');
|
const escalated = alerts.some((a) => a.status === 'ESCALATED_STR' || a.status === 'STR_FILED');
|
||||||
|
|
||||||
const recommendation = recommendRating(
|
// The server's own reckoning, where the nightly job has produced one.
|
||||||
proposedRank,
|
//
|
||||||
currentRisk,
|
// It is the better answer, and not because it is newer: it is drawn from a review period rather than from the
|
||||||
largestAmount,
|
// alerts on this screen. A customer appears here only once a rule has fired, so judging them on those alerts
|
||||||
totalAmount,
|
// alone describes the exceptions and never the ordinary months around them - which is exactly what a rating is
|
||||||
escalated,
|
// supposed to reflect. The job also tests the things this screen cannot see at all: how many separate months the
|
||||||
alerts.map((a) => a.ruleCode)
|
// 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
|
// Where the customer carries no rating there is nothing to move, so an
|
||||||
// unrated customer is never counted as an upgrade or a downgrade.
|
// unrated customer is never counted as an upgrade or a downgrade.
|
||||||
const delta = currentRisk ? riskRank(recommendation.risk) - riskRank(currentRisk) : 0;
|
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({
|
rows.push({
|
||||||
clientId,
|
clientId,
|
||||||
@@ -332,7 +450,10 @@ export function riskCustomerRows(views: RiskAlertView[]): RiskCustomerRow[] {
|
|||||||
openCount: alerts.filter((a) => REVIEW_STATUS_META[a.status].open).length,
|
openCount: alerts.filter((a) => REVIEW_STATUS_META[a.status].open).length,
|
||||||
escalated,
|
escalated,
|
||||||
needsUpgrade: direction === 'up',
|
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
|
* the summary all run once per filter change rather than once per template
|
||||||
* binding, which matters because every view reads from the same result.
|
* 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 base = riskAlertViews(alerts, filter);
|
||||||
const narrowed = applyDirectionFilter(base, riskCustomerRows(base), filter.direction);
|
const narrowed = applyDirectionFilter(base, riskCustomerRows(base, windows), filter.direction);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
alerts: narrowed.views,
|
alerts: narrowed.views,
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ import {
|
|||||||
faGavel,
|
faGavel,
|
||||||
faGlobe,
|
faGlobe,
|
||||||
faHandHoldingUsd,
|
faHandHoldingUsd,
|
||||||
|
faHandPaper,
|
||||||
faHome,
|
faHome,
|
||||||
faKey,
|
faKey,
|
||||||
faLink,
|
faLink,
|
||||||
@@ -303,6 +304,7 @@ export class IconsModule {
|
|||||||
faHandHoldingHand,
|
faHandHoldingHand,
|
||||||
faHandHoldingHeart,
|
faHandHoldingHeart,
|
||||||
faHandHoldingUsd,
|
faHandHoldingUsd,
|
||||||
|
faHandPaper,
|
||||||
faHeart,
|
faHeart,
|
||||||
faHome,
|
faHome,
|
||||||
faKey,
|
faKey,
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ export class ShareAccountActionsResolver {
|
|||||||
const actionName = route.paramMap.get('name');
|
const actionName = route.paramMap.get('name');
|
||||||
const shareAccountId = route.paramMap.get('shareAccountId') || route.parent.parent.paramMap.get('shareAccountId');
|
const shareAccountId = route.paramMap.get('shareAccountId') || route.parent.parent.paramMap.get('shareAccountId');
|
||||||
switch (actionName) {
|
switch (actionName) {
|
||||||
case 'Apply Additional Shares':
|
|
||||||
case 'Redeem Shares':
|
case 'Redeem Shares':
|
||||||
case 'Approve Additional Shares':
|
case 'Approve Additional Shares':
|
||||||
case 'Reject Additional Shares':
|
case 'Reject Additional Shares':
|
||||||
|
|||||||
+209
@@ -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> </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> </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> </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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+152
@@ -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> </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>
|
||||||
+206
@@ -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
Reference in New Issue
Block a user