Compare commits
2
Commits
1eaa8cc8cf
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6eaf9b959f | ||
|
|
b184a0909c |
+302
@@ -0,0 +1,302 @@
|
|||||||
|
kind: pipeline
|
||||||
|
type: docker
|
||||||
|
name: auto-deploy
|
||||||
|
|
||||||
|
trigger:
|
||||||
|
branch:
|
||||||
|
- dev
|
||||||
|
event:
|
||||||
|
- push
|
||||||
|
|
||||||
|
steps:
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# DOCKER BUILD & PUSH
|
||||||
|
#
|
||||||
|
# Angular build (npm ci + ng build) Dockerfile ke andar
|
||||||
|
# multi-stage me hota hai, isliye alag build step nahi chahiye.
|
||||||
|
# ============================================================
|
||||||
|
- name: docker-build-push
|
||||||
|
image: docker
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- name: dockersock
|
||||||
|
path: /var/run/docker.sock
|
||||||
|
|
||||||
|
commands:
|
||||||
|
- |
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# --------------------------------------------------------
|
||||||
|
# Repository name comes from Drone
|
||||||
|
# --------------------------------------------------------
|
||||||
|
REPO="$${DRONE_REPO_NAME}"
|
||||||
|
REPO="$$(echo "$$REPO" | tr '[:upper:]' '[:lower:]')"
|
||||||
|
|
||||||
|
IMAGE="harbor.lincpay:8082/lincpay/$$REPO:$${DRONE_BUILD_NUMBER}"
|
||||||
|
|
||||||
|
echo "=========================================="
|
||||||
|
echo "DOCKER BUILD & PUSH"
|
||||||
|
echo "=========================================="
|
||||||
|
echo "Repository : $$REPO"
|
||||||
|
echo "Build : $${DRONE_BUILD_NUMBER}"
|
||||||
|
echo "Image : $$IMAGE"
|
||||||
|
echo "=========================================="
|
||||||
|
|
||||||
|
# --------------------------------------------------------
|
||||||
|
# Harbor login
|
||||||
|
# --------------------------------------------------------
|
||||||
|
echo 'QVgW5QbqlKKtBU2dYApTY28Dd6J36GK3' | \
|
||||||
|
docker login harbor.lincpay:8082 \
|
||||||
|
-u 'robot$lincpay+drone' \
|
||||||
|
--password-stdin
|
||||||
|
|
||||||
|
# --------------------------------------------------------
|
||||||
|
# Build
|
||||||
|
# --------------------------------------------------------
|
||||||
|
echo "Building Docker image..."
|
||||||
|
|
||||||
|
docker build \
|
||||||
|
-t "$$IMAGE" \
|
||||||
|
.
|
||||||
|
|
||||||
|
# --------------------------------------------------------
|
||||||
|
# Push
|
||||||
|
# --------------------------------------------------------
|
||||||
|
echo "Pushing Docker image..."
|
||||||
|
|
||||||
|
docker push "$$IMAGE"
|
||||||
|
|
||||||
|
echo "Docker image pushed successfully."
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# DEPLOY
|
||||||
|
# ============================================================
|
||||||
|
- name: deploy
|
||||||
|
image: docker
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- name: dockersock
|
||||||
|
path: /var/run/docker.sock
|
||||||
|
|
||||||
|
- name: envfiles
|
||||||
|
path: /platform-env
|
||||||
|
|
||||||
|
commands:
|
||||||
|
- |
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# ========================================================
|
||||||
|
# REPOSITORY / SERVICE / IMAGE
|
||||||
|
# ========================================================
|
||||||
|
|
||||||
|
REPO="$${DRONE_REPO_NAME}"
|
||||||
|
REPO="$$(echo "$$REPO" | tr '[:upper:]' '[:lower:]')"
|
||||||
|
|
||||||
|
SERVICE_NAME="$${REPO}_app"
|
||||||
|
|
||||||
|
IMAGE="harbor.lincpay:8082/lincpay/$$REPO:$${DRONE_BUILD_NUMBER}"
|
||||||
|
|
||||||
|
echo "=========================================="
|
||||||
|
echo "DEPLOYMENT"
|
||||||
|
echo "=========================================="
|
||||||
|
echo "Repository : $$REPO"
|
||||||
|
echo "Service : $$SERVICE_NAME"
|
||||||
|
echo "Image : $$IMAGE"
|
||||||
|
echo "Placement : node.labels.environment == dev2"
|
||||||
|
echo "=========================================="
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================
|
||||||
|
# VALIDATION
|
||||||
|
# ========================================================
|
||||||
|
|
||||||
|
if [ -z "$$REPO" ]; then
|
||||||
|
echo "ERROR: DRONE_REPO_NAME is empty."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$$SERVICE_NAME" ]; then
|
||||||
|
echo "ERROR: Service name is empty."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$$IMAGE" ]; then
|
||||||
|
echo "ERROR: Docker image is empty."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Repository verified : $$REPO"
|
||||||
|
echo "Service verified : $$SERVICE_NAME"
|
||||||
|
echo "Image verified : $$IMAGE"
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================
|
||||||
|
# HARBOR LOGIN
|
||||||
|
#
|
||||||
|
# Zaroori hai taaki --with-registry-auth worker node (dev2) ko
|
||||||
|
# valid Harbor credentials propagate kare — warna cross-node pull
|
||||||
|
# "No such image" / "no basic auth credentials" se fail hota hai.
|
||||||
|
# ========================================================
|
||||||
|
|
||||||
|
echo 'QVgW5QbqlKKtBU2dYApTY28Dd6J36GK3' | \
|
||||||
|
docker login harbor.lincpay:8082 \
|
||||||
|
-u 'robot$lincpay+drone' \
|
||||||
|
--password-stdin
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================
|
||||||
|
# EXISTING SERVICE
|
||||||
|
# ========================================================
|
||||||
|
|
||||||
|
if docker service ls --format '{{.Name}}' | grep -Fxq "$$SERVICE_NAME"; then
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=========================================="
|
||||||
|
echo "EXISTING SERVICE"
|
||||||
|
echo "=========================================="
|
||||||
|
echo "Service: $$SERVICE_NAME"
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------
|
||||||
|
# Check whether dev constraint already exists
|
||||||
|
# ------------------------------------------------------
|
||||||
|
|
||||||
|
CONSTRAINT_EXISTS=$$(docker service inspect "$$SERVICE_NAME" \
|
||||||
|
--format '{{range .Spec.TaskTemplate.Placement.Constraints}}{{println .}}{{end}}' \
|
||||||
|
| grep -Fx 'node.labels.environment == dev2' || true)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------
|
||||||
|
# Add constraint if missing
|
||||||
|
# ------------------------------------------------------
|
||||||
|
|
||||||
|
if [ -z "$$CONSTRAINT_EXISTS" ]; then
|
||||||
|
|
||||||
|
echo "Constraint missing."
|
||||||
|
echo "Adding: node.labels.environment == dev2"
|
||||||
|
|
||||||
|
docker service update \
|
||||||
|
--constraint-add 'node.labels.environment == dev2' \
|
||||||
|
"$$SERVICE_NAME"
|
||||||
|
|
||||||
|
else
|
||||||
|
|
||||||
|
echo "Constraint already exists."
|
||||||
|
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------
|
||||||
|
# Update image
|
||||||
|
# ------------------------------------------------------
|
||||||
|
|
||||||
|
echo "Updating service image..."
|
||||||
|
|
||||||
|
docker service update \
|
||||||
|
--image "$$IMAGE" \
|
||||||
|
--replicas 1 \
|
||||||
|
--with-registry-auth \
|
||||||
|
"$$SERVICE_NAME"
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================
|
||||||
|
# NEW SERVICE
|
||||||
|
# ========================================================
|
||||||
|
|
||||||
|
else
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=========================================="
|
||||||
|
echo "NEW SERVICE"
|
||||||
|
echo "=========================================="
|
||||||
|
echo "Creating: $$SERVICE_NAME"
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------
|
||||||
|
# Traefik rule
|
||||||
|
#
|
||||||
|
# Construct it without allowing Drone to consume
|
||||||
|
# the shell backticks.
|
||||||
|
# ------------------------------------------------------
|
||||||
|
|
||||||
|
TRAEFIK_RULE='Host(`'$$REPO'.lincpay`)'
|
||||||
|
|
||||||
|
echo "Traefik rule: $$TRAEFIK_RULE"
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------
|
||||||
|
# Create service
|
||||||
|
# ------------------------------------------------------
|
||||||
|
|
||||||
|
docker service create \
|
||||||
|
--name "$$SERVICE_NAME" \
|
||||||
|
--network edge-network \
|
||||||
|
--replicas 1 \
|
||||||
|
--constraint 'node.labels.environment == dev2' \
|
||||||
|
--with-registry-auth \
|
||||||
|
--env-file /platform-env/global.env \
|
||||||
|
--env-file /platform-env/dev.env \
|
||||||
|
--label traefik.enable=true \
|
||||||
|
--label "traefik.docker.network=edge-network" \
|
||||||
|
--label "traefik.http.routers.$$REPO.rule=$$TRAEFIK_RULE" \
|
||||||
|
--label "traefik.http.services.$$REPO.loadbalancer.server.port=80" \
|
||||||
|
"$$IMAGE"
|
||||||
|
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================
|
||||||
|
# FINAL STATUS
|
||||||
|
# ========================================================
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=========================================="
|
||||||
|
echo "FINAL SERVICE STATUS"
|
||||||
|
echo "=========================================="
|
||||||
|
|
||||||
|
docker service ls \
|
||||||
|
--filter "name=$$SERVICE_NAME"
|
||||||
|
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=========================================="
|
||||||
|
echo "PLACEMENT CONSTRAINT"
|
||||||
|
echo "=========================================="
|
||||||
|
|
||||||
|
docker service inspect "$$SERVICE_NAME" \
|
||||||
|
--format '{{json .Spec.TaskTemplate.Placement}}'
|
||||||
|
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=========================================="
|
||||||
|
echo "SERVICE TASKS"
|
||||||
|
echo "=========================================="
|
||||||
|
|
||||||
|
docker service ps "$$SERVICE_NAME" --no-trunc
|
||||||
|
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=========================================="
|
||||||
|
echo "DEPLOYMENT COMPLETE"
|
||||||
|
echo "=========================================="
|
||||||
|
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# DOCKER SOCKET
|
||||||
|
# ============================================================
|
||||||
|
- name: dockersock
|
||||||
|
host:
|
||||||
|
path: /var/run/docker.sock
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# ENVIRONMENT FILES
|
||||||
|
# ============================================================
|
||||||
|
- name: envfiles
|
||||||
|
host:
|
||||||
|
path: /opt/platform/env
|
||||||
|
|
||||||
+15
-6
@@ -16,7 +16,6 @@ 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.
|
||||||
@@ -88,11 +87,21 @@ export class CreateFinancialActivityMappingComponent implements OnInit {
|
|||||||
*/
|
*/
|
||||||
setGlAccountData() {
|
setGlAccountData() {
|
||||||
this.financialActivityMappingForm.get('financialActivityId').valueChanges.subscribe((financialActivityId) => {
|
this.financialActivityMappingForm.get('financialActivityId').valueChanges.subscribe((financialActivityId) => {
|
||||||
this.glAccountData = accountOptionsForActivity(
|
switch (financialActivityId) {
|
||||||
this.financialActivityData,
|
case 100:
|
||||||
financialActivityId,
|
case 101:
|
||||||
this.glAccountOptions
|
case 102:
|
||||||
);
|
case 103:
|
||||||
|
this.glAccountData = this.glAccountOptions.assetAccountOptions;
|
||||||
|
break;
|
||||||
|
case 200:
|
||||||
|
case 201:
|
||||||
|
this.glAccountData = this.glAccountOptions.liabilityAccountOptions;
|
||||||
|
break;
|
||||||
|
case 300:
|
||||||
|
this.glAccountData = this.glAccountOptions.equityAccountOptions;
|
||||||
|
break;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+15
-6
@@ -16,7 +16,6 @@ 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.
|
||||||
@@ -99,11 +98,21 @@ export class EditFinancialActivityMappingComponent implements OnInit {
|
|||||||
*/
|
*/
|
||||||
setGLAccountData() {
|
setGLAccountData() {
|
||||||
this.financialActivityMappingForm.get('financialActivityId').valueChanges.subscribe((financialActivityId) => {
|
this.financialActivityMappingForm.get('financialActivityId').valueChanges.subscribe((financialActivityId) => {
|
||||||
this.glAccountData = accountOptionsForActivity(
|
switch (financialActivityId) {
|
||||||
this.financialActivityData,
|
case 100:
|
||||||
financialActivityId,
|
case 101:
|
||||||
this.glAccountOptions
|
case 102:
|
||||||
);
|
case 103:
|
||||||
|
this.glAccountData = this.glAccountOptions.assetAccountOptions;
|
||||||
|
break;
|
||||||
|
case 200:
|
||||||
|
case 201:
|
||||||
|
this.glAccountData = this.glAccountOptions.liabilityAccountOptions;
|
||||||
|
break;
|
||||||
|
case 300:
|
||||||
|
this.glAccountData = this.glAccountOptions.equityAccountOptions;
|
||||||
|
break;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,38 +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/.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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`] ?? [];
|
|
||||||
}
|
|
||||||
@@ -74,17 +74,6 @@ export class AccountsService {
|
|||||||
return this.http.get('/fixeddepositaccounts', { params: httpParams });
|
return this.http.get('/fixeddepositaccounts', { params: httpParams });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* FD Dashboard's data source: every ACTIVE FD, id/account/client/product identity, maturity date
|
|
||||||
* and deposit/maturity amounts only. Deliberately a separate, narrow endpoint rather than
|
|
||||||
* getFixedDepositAccounts() above -- that one's backend query joins in six more tables (audit
|
|
||||||
* who-did-what-when, currency, tax group) an unbounded dashboard fetch does not need, over columns
|
|
||||||
* that had no index until this feature added one (see backend migration 0571).
|
|
||||||
*/
|
|
||||||
getFixedDepositMaturityDashboard(): Observable<any> {
|
|
||||||
return this.http.get('/fixeddepositaccounts/maturity-dashboard');
|
|
||||||
}
|
|
||||||
|
|
||||||
getRecurringDepositAccounts(params: AccountListParams = {}): Observable<any> {
|
getRecurringDepositAccounts(params: AccountListParams = {}): Observable<any> {
|
||||||
let httpParams = new HttpParams()
|
let httpParams = new HttpParams()
|
||||||
.set('paged', 'true')
|
.set('paged', 'true')
|
||||||
|
|||||||
@@ -34,17 +34,6 @@ 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 ═══
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -152,38 +141,4 @@ 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');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,7 +63,6 @@ import { CfcfrmsModule } from './cfcfrms/cfcfrms.module';
|
|||||||
import { BankCalendarModule } from './bank-calendar/bank-calendar.module';
|
import { BankCalendarModule } from './bank-calendar/bank-calendar.module';
|
||||||
import { DeafModule } from './deaf/deaf.module';
|
import { DeafModule } from './deaf/deaf.module';
|
||||||
import { SavingsDailyInterestModule } from './savings-daily-interest/savings-daily-interest.module';
|
import { SavingsDailyInterestModule } from './savings-daily-interest/savings-daily-interest.module';
|
||||||
import { FdDashboardModule } from './fd-dashboard/fd-dashboard.module';
|
|
||||||
import { DicgcModule } from './dicgc/dicgc.module';
|
import { DicgcModule } from './dicgc/dicgc.module';
|
||||||
import { FiuModule } from './fiu/fiu.module';
|
import { FiuModule } from './fiu/fiu.module';
|
||||||
import { GlReportsModule } from './gl-reports/gl-reports.module';
|
import { GlReportsModule } from './gl-reports/gl-reports.module';
|
||||||
@@ -177,7 +176,6 @@ export function HttpLoaderFactory(http: HttpClient) {
|
|||||||
BankCalendarModule,
|
BankCalendarModule,
|
||||||
DeafModule,
|
DeafModule,
|
||||||
SavingsDailyInterestModule,
|
SavingsDailyInterestModule,
|
||||||
FdDashboardModule,
|
|
||||||
DicgcModule,
|
DicgcModule,
|
||||||
FiuModule,
|
FiuModule,
|
||||||
GlReportsModule,
|
GlReportsModule,
|
||||||
|
|||||||
@@ -1,147 +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="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>
|
|
||||||
@@ -1,313 +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/.
|
|
||||||
*/
|
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
@@ -1,221 +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 { 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,94 +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/.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** 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,6 +26,7 @@ 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([
|
||||||
@@ -344,6 +345,11 @@ 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,6 +20,7 @@ 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: [
|
||||||
@@ -34,7 +35,8 @@ import { ExcessTransactionReportComponent } from './excess-transaction-report/ex
|
|||||||
ReconciliationComponent,
|
ReconciliationComponent,
|
||||||
CashHistoryComponent,
|
CashHistoryComponent,
|
||||||
ClosingCashReportComponent,
|
ClosingCashReportComponent,
|
||||||
ExcessTransactionReportComponent
|
ExcessTransactionReportComponent,
|
||||||
|
ShareCollectionComponent
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class CashierModule {}
|
export class CashierModule {}
|
||||||
|
|||||||
@@ -178,13 +178,6 @@ 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.
|
||||||
@@ -364,6 +357,19 @@ 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
|
||||||
@@ -390,18 +396,10 @@ 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. */
|
||||||
* Everything an Apply screen needs, in one call: `own` is this member's own unapplied receipts,
|
getUnappliedShareAdvances(clientId?: number): Observable<any> {
|
||||||
* ready to tick; `rows` is every other Share Collection receipt still available — unclaimed GL
|
const suffix = clientId ? `?clientId=${clientId}` : '';
|
||||||
* deposits and receipts linked to another member — which attach by voucher instead.
|
return this.http.get(`/shareadvancecollection${suffix}`);
|
||||||
*
|
|
||||||
* 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. */
|
||||||
@@ -409,12 +407,6 @@ 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');
|
||||||
@@ -427,14 +419,4 @@ 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,24 +174,19 @@ 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.noteRows.reduce((s, r) => s + Number(r.netCount ?? 0), 0);
|
return this.noteVaultInCount + this.noteDepositCount - this.noteWithdrawalCount - this.noteSettledOutCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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
|
*
|
||||||
* row's own `netAmount` rather than re-deriving it from today's legs.
|
* Was deposits minus withdrawals, which is the counter's movement rather than the drawer's
|
||||||
|
* contents — it left out the cash drawn from the vault, so a teller holding a ₹500 note saw a net
|
||||||
|
* of zero and had nothing to count against.
|
||||||
*/
|
*/
|
||||||
get noteNetAmount(): number {
|
get noteNetAmount(): number {
|
||||||
return this.noteRows.reduce((s, r) => s + Number(r.netAmount ?? 0), 0);
|
return this.noteVaultInAmount + this.noteDepositAmount - this.noteWithdrawalAmount - this.noteSettledOutAmount;
|
||||||
}
|
}
|
||||||
get coinRows(): any[] {
|
get coinRows(): any[] {
|
||||||
return this.rows.filter((r) => r.kind === 'coin');
|
return this.rows.filter((r) => r.kind === 'coin');
|
||||||
|
|||||||
@@ -152,21 +152,9 @@
|
|||||||
<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. Clicking anywhere on the row opens the full
|
is when the heading has scrolled away. -->
|
||||||
slip -- every line, not just the first debit and first credit this table has room
|
<tr [class]="'mw-row mw-row--' + r.myRole">
|
||||||
to show. -->
|
<td class="mw-vno">{{ r.voucherNo }}</td>
|
||||||
<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>
|
||||||
@@ -183,7 +171,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" title="Click the row to see every line">+{{ r.extraLines }} more</span>
|
<span class="mw-more">+{{ 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,21 +178,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.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,10 +10,8 @@ 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 { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
import { catchError, of } from 'rxjs';
|
||||||
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';
|
||||||
@@ -74,17 +72,11 @@ 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';
|
||||||
@@ -331,31 +323,6 @@ 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')}`;
|
||||||
|
|||||||
@@ -1,320 +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, 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;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,375 @@
|
|||||||
|
<!--
|
||||||
|
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>
|
||||||
@@ -0,0 +1,701 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
/**
|
||||||
|
* 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, FormArray, Validators } from '@angular/forms';
|
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, 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,35 +35,6 @@ 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({
|
||||||
@@ -85,20 +56,10 @@ export interface DdSlipLineResult {
|
|||||||
<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>
|
<h2>{{ isAccountDebit ? 'Account-Debit Demand Draft (DD)' : 'Walk-in Cash Demand Draft (DD)' }}</h2>
|
||||||
@if (isSlip) {
|
|
||||||
Multi-DD Slip · {{ lineCount }} Demand Drafts
|
|
||||||
} @else {
|
|
||||||
{{ isAccountDebit ? 'Account-Debit Demand Draft (DD)' : 'Walk-in Cash Demand Draft (DD)' }}
|
|
||||||
}
|
|
||||||
</h2>
|
|
||||||
<p>
|
<p>
|
||||||
@if (isSlip) {
|
|
||||||
One debit funds all {{ lineCount }} drafts · Enter each draft's beneficiary and amount
|
|
||||||
} @else {
|
|
||||||
Next available DD leaflet auto-selected ·
|
Next available DD leaflet auto-selected ·
|
||||||
{{ isAccountDebit ? 'Enter beneficiary details' : 'Enter purchaser & beneficiary details' }}
|
{{ isAccountDebit ? 'Enter beneficiary details' : 'Enter purchaser & beneficiary details' }}
|
||||||
}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -108,55 +69,6 @@ export interface DdSlipLineResult {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<mat-dialog-content class="dd-dialog-body">
|
<mat-dialog-content class="dd-dialog-body">
|
||||||
<!--
|
|
||||||
Slip running total.
|
|
||||||
|
|
||||||
A slip's figures are not known until every line is typed — different drafts carry different
|
|
||||||
face values and different charges — so the total the account will be debited for is built
|
|
||||||
up here as the clerk works, rather than shown as a fixed figure up front. Answering "what
|
|
||||||
is this going to cost my member" without it would mean adding the lines by hand.
|
|
||||||
-->
|
|
||||||
@if (isSlip) {
|
|
||||||
<div class="dd-summary-card">
|
|
||||||
<div class="summary-col">
|
|
||||||
<span class="label">Drafts On This Slip</span>
|
|
||||||
<strong class="dd-number-highlight">{{ lineCount }}</strong>
|
|
||||||
<span class="sublabel">{{ data.sponsorBankName || 'Sponsor Bank' }} · {{ data.branchName }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="summary-divider"></div>
|
|
||||||
<div class="summary-col">
|
|
||||||
<span class="label">Total Face Value</span>
|
|
||||||
<strong class="amount">₹{{ slipFaceValue | number: '1.2-2' }}</strong>
|
|
||||||
<span class="sublabel">+ Charges ₹{{ slipCharges | number: '1.2-2' }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="summary-divider"></div>
|
|
||||||
<div class="summary-col">
|
|
||||||
<span class="label">GST ({{ data.gstPercentage }}%)</span>
|
|
||||||
<strong class="amount">₹{{ slipGst | number: '1.2-2' }}</strong>
|
|
||||||
<span class="sublabel">on charges only</span>
|
|
||||||
</div>
|
|
||||||
<div class="summary-divider"></div>
|
|
||||||
<div class="summary-col highlight-col">
|
|
||||||
<span class="label">Total To Debit</span>
|
|
||||||
<strong class="total-amount">₹{{ slipTotal | number: '1.2-2' }}</strong>
|
|
||||||
<span class="sublabel">{{ data.accountNo || 'Account' }} · one debit</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!--
|
|
||||||
Said plainly because the numbers on screen are the one thing in this dialog the clerk
|
|
||||||
cannot rely on. They are the next-available run as it stood when the dialog opened; the
|
|
||||||
binding allocation happens inside the submit transaction, so a slip filed after another
|
|
||||||
clerk's gets the following numbers instead.
|
|
||||||
-->
|
|
||||||
<div class="dd-preview-note">
|
|
||||||
<mat-icon>info</mat-icon>
|
|
||||||
<div>
|
|
||||||
DD numbers below are <strong>provisional</strong>. The final numbers are allocated when the slip is
|
|
||||||
submitted and will be confirmed on the receipt.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
} @else {
|
|
||||||
<!-- Draft & Financials Summary Card -->
|
<!-- Draft & Financials Summary Card -->
|
||||||
<div class="dd-summary-card">
|
<div class="dd-summary-card">
|
||||||
<!-- The number the customer will actually see printed on the instrument. "Auto-Selected
|
<!-- The number the customer will actually see printed on the instrument. "Auto-Selected
|
||||||
@@ -192,7 +104,6 @@ export interface DdSlipLineResult {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
The account funding the draft, stated before the form.
|
The account funding the draft, stated before the form.
|
||||||
@@ -210,121 +121,13 @@ export interface DdSlipLineResult {
|
|||||||
· {{ data.accountHolderName }}
|
· {{ data.accountHolderName }}
|
||||||
}
|
}
|
||||||
<div class="dd-funding-sub">
|
<div class="dd-funding-sub">
|
||||||
@if (isSlip) {
|
|
||||||
₹{{ slipTotal | number: '1.2-2' }} will be debited from this account as a
|
|
||||||
<strong>single transaction</strong> covering all {{ lineCount }} drafts, when a checker approves the
|
|
||||||
slip —
|
|
||||||
<!-- A GL account has no passbook, so the reassurance a member gets is phrased for
|
|
||||||
the ledger instead of promising a statement line that does not exist. -->
|
|
||||||
@if (isGlFunded) {
|
|
||||||
one journal entry against this GL account, not {{ lineCount }}.
|
|
||||||
} @else {
|
|
||||||
the member's passbook shows one entry, not {{ lineCount }}.
|
|
||||||
}
|
|
||||||
} @else {
|
|
||||||
₹{{ data.totalCashPayable | number: '1.2-2' }} will be debited from this account when a checker
|
₹{{ data.totalCashPayable | number: '1.2-2' }} will be debited from this account when a checker
|
||||||
approves. No cash is taken at the counter.
|
approves. No cash is taken at the counter.
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
<!--
|
|
||||||
A slip's drafts, one card each.
|
|
||||||
|
|
||||||
Face value and charge sit on the line rather than on the slip because that is how the bank
|
|
||||||
actually uses this: three drafts to three institutions are three different amounts, and the
|
|
||||||
charge is levied per draft. The server stores and posts them per line for the same reason.
|
|
||||||
-->
|
|
||||||
@if (isSlip) {
|
|
||||||
<form [formGroup]="slipForm" class="dd-form">
|
|
||||||
<div formArrayName="lines">
|
|
||||||
@for (lineCtrl of slipLines.controls; track $index) {
|
|
||||||
<div class="dd-line-card" [formGroupName]="$index">
|
|
||||||
<div class="dd-line-head">
|
|
||||||
<span class="dd-line-badge">DD {{ $index + 1 }} of {{ lineCount }}</span>
|
|
||||||
@if (previewNumberAt($index)) {
|
|
||||||
<span class="dd-line-number">No. {{ previewNumberAt($index) }} <em>(provisional)</em></span>
|
|
||||||
}
|
|
||||||
<span class="dd-line-total"> Debit ₹{{ lineTotalAt($index) | number: '1.2-2' }} </span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<mat-form-field appearance="outline" class="full-width">
|
|
||||||
<mat-label>Beneficiary Name (In Favour Of) *</mat-label>
|
|
||||||
<input
|
|
||||||
matInput
|
|
||||||
formControlName="beneficiaryName"
|
|
||||||
placeholder="e.g. UNIVERSITY OF MUMBAI"
|
|
||||||
maxlength="120"
|
|
||||||
style="text-transform: uppercase;"
|
|
||||||
/>
|
|
||||||
<mat-error *ngIf="lineCtrl.get('beneficiaryName')?.hasError('required')"
|
|
||||||
>Beneficiary name is required</mat-error
|
|
||||||
>
|
|
||||||
<mat-error *ngIf="lineCtrl.get('beneficiaryName')?.hasError('maxlength')"
|
|
||||||
>Max 120 characters</mat-error
|
|
||||||
>
|
|
||||||
</mat-form-field>
|
|
||||||
|
|
||||||
<div class="form-row">
|
|
||||||
<mat-form-field appearance="outline" class="form-field-flex">
|
|
||||||
<mat-label>Payable At - Branch *</mat-label>
|
|
||||||
<input
|
|
||||||
matInput
|
|
||||||
formControlName="payableAtBranch"
|
|
||||||
placeholder="e.g. FORT BRANCH"
|
|
||||||
maxlength="150"
|
|
||||||
style="text-transform: uppercase;"
|
|
||||||
/>
|
|
||||||
<mat-error *ngIf="lineCtrl.get('payableAtBranch')?.hasError('required')"
|
|
||||||
>Payable Branch is required</mat-error
|
|
||||||
>
|
|
||||||
</mat-form-field>
|
|
||||||
|
|
||||||
<mat-form-field appearance="outline" class="form-field-flex">
|
|
||||||
<mat-label>Payable At - City *</mat-label>
|
|
||||||
<input
|
|
||||||
matInput
|
|
||||||
formControlName="payableAtCity"
|
|
||||||
placeholder="e.g. MUMBAI"
|
|
||||||
maxlength="100"
|
|
||||||
style="text-transform: uppercase;"
|
|
||||||
/>
|
|
||||||
<mat-error *ngIf="lineCtrl.get('payableAtCity')?.hasError('required')"
|
|
||||||
>Payable City is required</mat-error
|
|
||||||
>
|
|
||||||
</mat-form-field>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-row">
|
|
||||||
<mat-form-field appearance="outline" class="form-field-flex">
|
|
||||||
<mat-label>Face Value *</mat-label>
|
|
||||||
<input matInput type="number" formControlName="faceValue" placeholder="0.00" />
|
|
||||||
<span matPrefix>₹ </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">
|
<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
|
||||||
@@ -429,18 +232,12 @@ export interface DdSlipLineResult {
|
|||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
}
|
|
||||||
</mat-dialog-content>
|
</mat-dialog-content>
|
||||||
|
|
||||||
<mat-dialog-actions class="dd-dialog-actions" align="end">
|
<mat-dialog-actions class="dd-dialog-actions" align="end">
|
||||||
@if (isSlip) {
|
|
||||||
<span class="dd-actions-total">
|
|
||||||
{{ lineCount }} drafts · <strong>₹{{ slipTotal | number: '1.2-2' }}</strong> to debit
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
<button mat-button type="button" (click)="onClose()">Cancel</button>
|
<button mat-button type="button" (click)="onClose()">Cancel</button>
|
||||||
<button mat-flat-button color="primary" [disabled]="activeForm.invalid" (click)="onSubmit()">
|
<button mat-flat-button color="primary" [disabled]="form.invalid" (click)="onSubmit()">
|
||||||
{{ isSlip ? 'Save Slip Details' : 'Save DD Details' }}
|
Save DD Details
|
||||||
</button>
|
</button>
|
||||||
</mat-dialog-actions>
|
</mat-dialog-actions>
|
||||||
</div>
|
</div>
|
||||||
@@ -598,81 +395,6 @@ export interface DdSlipLineResult {
|
|||||||
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;
|
|
||||||
}
|
|
||||||
`
|
`
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
@@ -682,127 +404,13 @@ 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
|
||||||
@@ -869,25 +477,7 @@ export class CashierIssueDdDialogComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onSubmit(): void {
|
onSubmit(): void {
|
||||||
if (this.activeForm.invalid) return;
|
if (this.form.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,13 +166,6 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="ws-f ws-f--actyp">
|
<div class="ws-f ws-f--actyp">
|
||||||
<label class="ws-f__lbl" title="Account Type">AC. TP.</label>
|
<label class="ws-f__lbl" title="Account Type">AC. TP.</label>
|
||||||
@if (acTypLockedByToken && row === selectedRow) {
|
|
||||||
<span
|
|
||||||
class="ws-f__ctl ws-f__ctl--locked"
|
|
||||||
title="Locked — filled from a token-driven request, not a normal account entry"
|
|
||||||
>{{ acTypLockedLabel }}</span
|
|
||||||
>
|
|
||||||
} @else {
|
|
||||||
<select
|
<select
|
||||||
class="ws-f__ctl"
|
class="ws-f__ctl"
|
||||||
[(ngModel)]="row.acTyp"
|
[(ngModel)]="row.acTyp"
|
||||||
@@ -189,27 +182,7 @@
|
|||||||
<option [value]="a">{{ a }}</option>
|
<option [value]="a">{{ a }}</option>
|
||||||
}
|
}
|
||||||
</select>
|
</select>
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
@if (showsEntryType(row)) {
|
|
||||||
<div class="ws-f ws-f--paytype">
|
|
||||||
<label
|
|
||||||
class="ws-f__lbl ws-f__lbl--accent"
|
|
||||||
title="What this money was actually collected for — checked before a share application or DD issuance can adopt this voucher"
|
|
||||||
>ENTRY TYPE</label
|
|
||||||
>
|
|
||||||
<select
|
|
||||||
class="ws-f__ctl ws-f__ctl--accent"
|
|
||||||
[(ngModel)]="row.entryTypeCvId"
|
|
||||||
(click)="$event.stopPropagation()"
|
|
||||||
>
|
|
||||||
<option [ngValue]="null">Select…</option>
|
|
||||||
@for (t of entryTypeOptions; track t.id) {
|
|
||||||
<option [ngValue]="t.id">{{ t.name }}</option>
|
|
||||||
}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -233,16 +206,10 @@
|
|||||||
? ddGlMapping?.ddPayableGlCode || 'Auto GL'
|
? ddGlMapping?.ddPayableGlCode || 'Auto GL'
|
||||||
: 'Account number'
|
: 'Account number'
|
||||||
"
|
"
|
||||||
[readonly]="
|
[readonly]="row.typ === 'DD' && !isDdAccountDebitRow(row)"
|
||||||
(row.typ === 'DD' && !isDdAccountDebitRow(row)) || (acTypLockedByToken && row === selectedRow)
|
[class.ws-f__ctl--readonly]="row.typ === 'DD' && !isDdAccountDebitRow(row)"
|
||||||
"
|
|
||||||
[class.ws-f__ctl--readonly]="
|
|
||||||
(row.typ === 'DD' && !isDdAccountDebitRow(row)) || (acTypLockedByToken && row === selectedRow)
|
|
||||||
"
|
|
||||||
[title]="
|
[title]="
|
||||||
acTypLockedByToken && row === selectedRow
|
isDdAccountDebitRow(row)
|
||||||
? 'Locked — filled from a token-driven request, not a normal account entry'
|
|
||||||
: isDdAccountDebitRow(row)
|
|
||||||
? 'Member account the draft is funded from'
|
? 'Member account the draft is funded from'
|
||||||
: row.typ === 'DD'
|
: row.typ === 'DD'
|
||||||
? 'Configured Sponsor DD Payable GL (Auto-selected)'
|
? 'Configured Sponsor DD Payable GL (Auto-selected)'
|
||||||
@@ -319,13 +286,9 @@
|
|||||||
[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]="isMultiDdRow(row) ? 'Per draft' : row.acTyp === 'SHR' ? '0' : '0.00'"
|
[placeholder]="row.acTyp === 'SHR' ? '0' : '0.00'"
|
||||||
[readonly]="
|
[readonly]="!isClerk && !!(activeWithdrawalRequest || activeClosureRequest)"
|
||||||
isMultiDdRow(row) || (!isClerk && !!(activeWithdrawalRequest || activeClosureRequest))
|
[class.ws-amt-input--locked]="!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]) {
|
||||||
@@ -335,19 +298,6 @@
|
|||||||
}}/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">
|
||||||
@@ -427,36 +377,9 @@
|
|||||||
<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" [class.ws-amt-wrap--locked]="isMultiDdRow(row)">
|
<div class="ws-amt-wrap">
|
||||||
<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"
|
||||||
@@ -465,15 +388,8 @@
|
|||||||
(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">
|
||||||
@@ -484,7 +400,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]="ddGstDisplayFor(row)"
|
[value]="ddGstAmountFor(row) | number: '1.2-2'"
|
||||||
readonly
|
readonly
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -498,7 +414,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]="ddTotalDisplayFor(row)"
|
[value]="ddTotalPayableFor(row)"
|
||||||
readonly
|
readonly
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -511,27 +427,18 @@
|
|||||||
[disabled]="saving"
|
[disabled]="saving"
|
||||||
(click)="openIssueWalkinDd(row); $event.stopPropagation()"
|
(click)="openIssueWalkinDd(row); $event.stopPropagation()"
|
||||||
[title]="
|
[title]="
|
||||||
isMultiDdRow(row)
|
isDdAccountDebitRow(row)
|
||||||
? 'Opens one beneficiary form per draft; DD numbers are allocated on submission'
|
|
||||||
: isDdAccountDebitRow(row)
|
|
||||||
? 'Auto-selects next available leaflet and opens the beneficiary form'
|
? 'Auto-selects next available leaflet and opens the beneficiary form'
|
||||||
: 'Auto-selects next available leaflet and opens walk-in purchaser form'
|
: 'Auto-selects next available leaflet and opens walk-in purchaser form'
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<mat-icon style="font-size: 18px; width: 18px; height: 18px">{{
|
<mat-icon style="font-size: 18px; width: 18px; height: 18px">{{
|
||||||
row.ddDetails || row.ddLines?.length ? 'edit_note' : 'post_add'
|
row.ddDetails ? '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. A slip names the count
|
glance which rows are ready before pressing Save. -->
|
||||||
instead: its numbers are not allocated until it is submitted. -->
|
|
||||||
<span>{{
|
<span>{{
|
||||||
row.ddLines?.length
|
row.ddDetails ? 'DD ' + row.ddDetails.ddNumber + ' · Edit Details' : 'Fill DD Details'
|
||||||
? 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>
|
||||||
@@ -1616,13 +1523,6 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="ws-f ws-f--actyp">
|
<div class="ws-f ws-f--actyp">
|
||||||
<label class="ws-f__lbl">AC. TP.</label>
|
<label class="ws-f__lbl">AC. TP.</label>
|
||||||
@if (acTypLockedByToken && row === selectedRow) {
|
|
||||||
<span
|
|
||||||
class="ws-f__ctl ws-f__ctl--locked"
|
|
||||||
title="Locked — filled from a token-driven request, not a normal account entry"
|
|
||||||
>{{ acTypLockedLabel }}</span
|
|
||||||
>
|
|
||||||
} @else {
|
|
||||||
<select
|
<select
|
||||||
class="ws-f__ctl"
|
class="ws-f__ctl"
|
||||||
[(ngModel)]="row.acTyp"
|
[(ngModel)]="row.acTyp"
|
||||||
@@ -1639,27 +1539,7 @@
|
|||||||
<option [value]="a">{{ a }}</option>
|
<option [value]="a">{{ a }}</option>
|
||||||
}
|
}
|
||||||
</select>
|
</select>
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
@if (showsEntryType(row)) {
|
|
||||||
<div class="ws-f ws-f--paytype">
|
|
||||||
<label
|
|
||||||
class="ws-f__lbl ws-f__lbl--accent"
|
|
||||||
title="What this money was actually collected for — checked before a share application or DD issuance can adopt this voucher"
|
|
||||||
>ENTRY TYPE</label
|
|
||||||
>
|
|
||||||
<select
|
|
||||||
class="ws-f__ctl ws-f__ctl--accent"
|
|
||||||
[(ngModel)]="row.entryTypeCvId"
|
|
||||||
(click)="$event.stopPropagation()"
|
|
||||||
>
|
|
||||||
<option [ngValue]="null">Select…</option>
|
|
||||||
@for (t of entryTypeOptions; track t.id) {
|
|
||||||
<option [ngValue]="t.id">{{ t.name }}</option>
|
|
||||||
}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="ws-vdivider"></div>
|
<div class="ws-vdivider"></div>
|
||||||
@@ -1676,16 +1556,10 @@
|
|||||||
[placeholder]="
|
[placeholder]="
|
||||||
row.typ === 'DD' ? ddGlMapping?.ddPayableGlCode || 'Auto GL' : 'Account number'
|
row.typ === 'DD' ? ddGlMapping?.ddPayableGlCode || 'Auto GL' : 'Account number'
|
||||||
"
|
"
|
||||||
[readonly]="row.typ === 'DD' || (acTypLockedByToken && row === selectedRow)"
|
[readonly]="row.typ === 'DD'"
|
||||||
[class.ws-f__ctl--readonly]="
|
[class.ws-f__ctl--readonly]="row.typ === 'DD'"
|
||||||
row.typ === 'DD' || (acTypLockedByToken && row === selectedRow)
|
|
||||||
"
|
|
||||||
[title]="
|
[title]="
|
||||||
acTypLockedByToken && row === selectedRow
|
row.typ === 'DD' ? 'Configured Sponsor DD Payable GL (Auto-selected)' : 'Account number'
|
||||||
? '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>
|
||||||
@@ -1754,12 +1628,10 @@
|
|||||||
[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]="isMultiDdRow(row) ? 'Per draft' : row.acTyp === 'SHR' ? '0' : '0.00'"
|
[placeholder]="row.acTyp === 'SHR' ? '0' : '0.00'"
|
||||||
[readonly]="
|
[readonly]="!isClerk && !!(activeWithdrawalRequest || activeClosureRequest)"
|
||||||
isMultiDdRow(row) || (!isClerk && !!(activeWithdrawalRequest || activeClosureRequest))
|
|
||||||
"
|
|
||||||
[class.ws-amt-input--locked]="
|
[class.ws-amt-input--locked]="
|
||||||
isMultiDdRow(row) || (!isClerk && !!(activeWithdrawalRequest || activeClosureRequest))
|
!isClerk && !!(activeWithdrawalRequest || activeClosureRequest)
|
||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1770,19 +1642,6 @@
|
|||||||
}}/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')
|
||||||
@@ -1847,38 +1706,9 @@
|
|||||||
<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" [class.ws-amt-wrap--locked]="isMultiDdRow(row)">
|
<div class="ws-amt-wrap">
|
||||||
<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"
|
||||||
@@ -1887,15 +1717,8 @@
|
|||||||
(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">
|
||||||
@@ -1906,7 +1729,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]="ddGstDisplayFor(row)"
|
[value]="ddGstAmountFor(row) | number: '1.2-2'"
|
||||||
readonly
|
readonly
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1920,7 +1743,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]="ddTotalDisplayFor(row)"
|
[value]="ddTotalPayableFor(row)"
|
||||||
readonly
|
readonly
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1933,28 +1756,13 @@
|
|||||||
[disabled]="saving"
|
[disabled]="saving"
|
||||||
(click)="openIssueWalkinDd(row); $event.stopPropagation()"
|
(click)="openIssueWalkinDd(row); $event.stopPropagation()"
|
||||||
[title]="
|
[title]="
|
||||||
isMultiDdRow(row)
|
isDdAccountDebitRow(row)
|
||||||
? 'Opens one beneficiary form per draft; DD numbers are allocated on submission'
|
|
||||||
: isDdAccountDebitRow(row)
|
|
||||||
? 'Auto-selects next available leaflet and opens the beneficiary form'
|
? 'Auto-selects next available leaflet and opens the beneficiary form'
|
||||||
: 'Auto-selects next available leaflet and opens walk-in purchaser form'
|
: 'Auto-selects next available leaflet and opens walk-in purchaser form'
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<mat-icon style="font-size: 18px; width: 18px; height: 18px">{{
|
<mat-icon style="font-size: 18px; width: 18px; height: 18px">post_add</mat-icon>
|
||||||
row.ddDetails || row.ddLines?.length ? 'edit_note' : 'post_add'
|
<span>Fill DD Details</span>
|
||||||
}}</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,20 +916,6 @@ 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,46 +1142,6 @@
|
|||||||
</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,28 +1807,3 @@
|
|||||||
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,13 +140,6 @@ 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: [''],
|
||||||
@@ -228,11 +221,6 @@ 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.
|
||||||
@@ -458,28 +446,6 @@ 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;
|
||||||
@@ -518,7 +484,6 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
|||||||
// dateFormat is required so the backend can parse exceptionResolvedOn when an exception closes.
|
// dateFormat is required so the backend can parse exceptionResolvedOn when an exception closes.
|
||||||
dateFormat: 'dd MMMM yyyy',
|
dateFormat: 'dd MMMM yyyy',
|
||||||
...this.buildDocumentFields(),
|
...this.buildDocumentFields(),
|
||||||
...this.buildRiskProfileFields(),
|
|
||||||
...this.buildExceptionFields()
|
...this.buildExceptionFields()
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -565,7 +530,6 @@ 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()
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -765,7 +729,6 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
|||||||
dateFormat,
|
dateFormat,
|
||||||
riskCategory: this.kycForm.value.riskCategory || 1,
|
riskCategory: this.kycForm.value.riskCategory || 1,
|
||||||
...declarationFields,
|
...declarationFields,
|
||||||
...this.buildRiskProfileFields(),
|
|
||||||
...this.buildExceptionFields()
|
...this.buildExceptionFields()
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -926,7 +889,6 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
|||||||
dateFormat,
|
dateFormat,
|
||||||
riskCategory: this.kycForm.value.riskCategory || 1,
|
riskCategory: this.kycForm.value.riskCategory || 1,
|
||||||
...receiptFields,
|
...receiptFields,
|
||||||
...this.buildRiskProfileFields(),
|
|
||||||
...this.buildExceptionFields()
|
...this.buildExceptionFields()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -65,14 +65,6 @@
|
|||||||
<span class="cashier-nav__esc-hint" (click)="onEscapeKey()"> <kbd>ESC</kbd> Show Header </span>
|
<span class="cashier-nav__esc-hint" (click)="onEscapeKey()"> <kbd>ESC</kbd> Show Header </span>
|
||||||
</nav>
|
</nav>
|
||||||
}
|
}
|
||||||
<!-- FD Dashboard fullscreen — ESC hint bar -->
|
|
||||||
@else if (currentUrl.includes('/fd-dashboard')) {
|
|
||||||
<nav class="cashier-nav cashier-nav--election">
|
|
||||||
<span class="cashier-nav__esc-hint" (click)="onEscapeKey()">
|
|
||||||
<kbd>ESC</kbd> Exit Fullscreen (Show Header & Footer)
|
|
||||||
</span>
|
|
||||||
</nav>
|
|
||||||
}
|
|
||||||
<!-- Clerk Dashboard fullscreen nav — only visible at /clerk/* -->
|
<!-- Clerk Dashboard fullscreen nav — only visible at /clerk/* -->
|
||||||
@else if (currentUrl.includes('/clerk/')) {
|
@else if (currentUrl.includes('/clerk/')) {
|
||||||
<nav class="cashier-nav cashier-nav--clerk">
|
<nav class="cashier-nav cashier-nav--clerk">
|
||||||
@@ -318,6 +310,41 @@
|
|||||||
</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
|
||||||
============================================================
|
============================================================
|
||||||
@@ -596,11 +623,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- The KPI ticker that sat here has been removed. Every figure in it was written into the template — cash on
|
<!-- LIVE KPI TICKER (below the menu — pauses on hover) -->
|
||||||
hand, transactions today, CASA, CD ratio, gross NPA, a circular reference — and none came from the bank's
|
<div class="kpi-ticker" role="marquee" aria-label="Live key-performance ticker">
|
||||||
own books. Under a "LIVE" badge on every screen in the application, that is not decoration: a figure a
|
<span class="kpi-live">
|
||||||
branch manager can read as the day's cash position, or an inspector as the NPA ratio, has to be the real
|
<span class="kpi-live-dot"></span>
|
||||||
one or must not be shown. The band returns when there is a source behind it. -->
|
LIVE
|
||||||
|
</span>
|
||||||
|
<div class="kpi-track">
|
||||||
|
<!-- Render the item block twice so the marquee loops seamlessly. -->
|
||||||
|
<ng-container *ngTemplateOutlet="kpiTickerItems"></ng-container>
|
||||||
|
<ng-container *ngTemplateOutlet="kpiTickerItems"></ng-container>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
<!-- end @if (!hideShellLayout) -->
|
<!-- end @if (!hideShellLayout) -->
|
||||||
|
|
||||||
@@ -679,9 +713,6 @@
|
|||||||
<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,
|
||||||
@@ -1211,8 +1242,8 @@
|
|||||||
<button mat-menu-item tabindex="0">
|
<button mat-menu-item tabindex="0">
|
||||||
<fa-icon icon="clock" class="menu-item-icon"></fa-icon> Account Expiry
|
<fa-icon icon="clock" class="menu-item-icon"></fa-icon> Account Expiry
|
||||||
</button>
|
</button>
|
||||||
<button mat-menu-item tabindex="0" [routerLink]="['/fd-dashboard']">
|
<button mat-menu-item tabindex="0">
|
||||||
<fa-icon icon="calendar-check" class="menu-item-icon"></fa-icon> FD Dashboard
|
<fa-icon icon="file-alt" class="menu-item-icon"></fa-icon> FD Maturity Report
|
||||||
</button>
|
</button>
|
||||||
<button mat-menu-item tabindex="0">
|
<button mat-menu-item tabindex="0">
|
||||||
<fa-icon icon="chart-line" class="menu-item-icon"></fa-icon> FD Interest Report
|
<fa-icon icon="chart-line" class="menu-item-icon"></fa-icon> FD Interest Report
|
||||||
@@ -1308,79 +1339,72 @@
|
|||||||
<!-- 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" [routerLink]="['/shares/reports/nominal-shares']">
|
<button mat-menu-item tabindex="0">
|
||||||
<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" [routerLink]="['/shares/reports/share-wise']">
|
<button mat-menu-item tabindex="0">
|
||||||
<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" [routerLink]="['/shares/reports/guarantor-detail']">
|
<button mat-menu-item tabindex="0">
|
||||||
<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" [routerLink]="['/shares/reports/member-interest']">
|
<button mat-menu-item tabindex="0">
|
||||||
<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" [routerLink]="['/shares/reports/enquiry']">
|
<button mat-menu-item tabindex="0">
|
||||||
<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" [routerLink]="['/shares/reports/payment-certificate']">
|
<button mat-menu-item tabindex="0">
|
||||||
<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" [routerLink]="['/shares/reports/share-detail']">
|
<button mat-menu-item tabindex="0">
|
||||||
<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" [routerLink]="['/shares/reports/dividend-statement']">
|
<button mat-menu-item tabindex="0">
|
||||||
<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" [routerLink]="['/shares/transfers']">
|
<button mat-menu-item tabindex="0">
|
||||||
<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" [routerLink]="['/shares/reports/folio-statement']">
|
<button mat-menu-item tabindex="0">
|
||||||
<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" [routerLink]="['/shares/reports/final-balance']">
|
<button mat-menu-item tabindex="0">
|
||||||
<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" [routerLink]="['/shares/reports/account-statement']">
|
<button mat-menu-item tabindex="0">
|
||||||
<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" [routerLink]="['/shares/reports/share-issued']">
|
<button mat-menu-item tabindex="0">
|
||||||
<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" [routerLink]="['/shares/reports/share-status']">
|
<button mat-menu-item tabindex="0">
|
||||||
<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" [routerLink]="['/shares/reports/distinctive-numbers']">
|
<button mat-menu-item tabindex="0">
|
||||||
<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" [routerLink]="['/shares/reports/area-wise']">
|
<button mat-menu-item tabindex="0"><fa-icon icon="map" class="menu-item-icon"></fa-icon> Area Wise</button>
|
||||||
<fa-icon icon="map" class="menu-item-icon"></fa-icon> Area Wise
|
<button mat-menu-item tabindex="0"><fa-icon icon="tag" class="menu-item-icon"></fa-icon> Share Label</button>
|
||||||
</button>
|
<button mat-menu-item tabindex="0">
|
||||||
<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" [routerLink]="['/letter-mgmt/share-member']">
|
<button mat-menu-item tabindex="0">
|
||||||
<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>
|
||||||
|
|||||||
@@ -167,9 +167,7 @@ export class ShellComponent implements OnInit, OnDestroy {
|
|||||||
url.includes('/clerk/withdrawal-request') ||
|
url.includes('/clerk/withdrawal-request') ||
|
||||||
url.includes('/clerk/my-disbursement-requests');
|
url.includes('/clerk/my-disbursement-requests');
|
||||||
const isElectionRoute = url.includes('/election');
|
const isElectionRoute = url.includes('/election');
|
||||||
const isFdDashboardRoute = url.includes('/fd-dashboard');
|
this.hideShellLayout = isCashierOnly || isClerkRoute || isElectionRoute || this.isWithdrawalReviewUrl;
|
||||||
this.hideShellLayout =
|
|
||||||
isCashierOnly || isClerkRoute || isElectionRoute || isFdDashboardRoute || this.isWithdrawalReviewUrl;
|
|
||||||
this.cdr.detectChanges();
|
this.cdr.detectChanges();
|
||||||
};
|
};
|
||||||
this.routeSub = this.router.events.pipe(filter((e) => e instanceof NavigationEnd)).subscribe(() => checkLayout());
|
this.routeSub = this.router.events.pipe(filter((e) => e instanceof NavigationEnd)).subscribe(() => checkLayout());
|
||||||
@@ -182,7 +180,10 @@ export class ShellComponent implements OnInit, OnDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Toggles the current collapsed state of sidenav according to the emitted event. */
|
/**
|
||||||
|
* Toggles the current collapsed state of sidenav according to the emitted event.
|
||||||
|
* @param {boolean} event denotes state of sidenav
|
||||||
|
*/
|
||||||
toggleCollapse($event: boolean) {
|
toggleCollapse($event: boolean) {
|
||||||
this.sidenavCollapsed = $event;
|
this.sidenavCollapsed = $event;
|
||||||
this.cdr.detectChanges();
|
this.cdr.detectChanges();
|
||||||
@@ -203,15 +204,12 @@ export class ShellComponent implements OnInit, OnDestroy {
|
|||||||
return /\/checker-inbox-and-tasks\/(share-)?withdrawal-requests\/\d+\/review/.test(this.router.url);
|
return /\/checker-inbox-and-tasks\/(share-)?withdrawal-requests\/\d+\/review/.test(this.router.url);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** ESC key — exit fullscreen at Clerk Dashboard / Election / FD Dashboard / review screen, restoring full shell header. */
|
/** ESC key — exit fullscreen at Clerk Dashboard / Election / review screen, restoring full shell header. */
|
||||||
@HostListener('document:keydown.escape')
|
@HostListener('document:keydown.escape')
|
||||||
onEscapeKey(): void {
|
onEscapeKey(): void {
|
||||||
if (
|
if (
|
||||||
this.hideShellLayout &&
|
this.hideShellLayout &&
|
||||||
(this.router.url.includes('/clerk/') ||
|
(this.router.url.includes('/clerk/') || this.router.url.includes('/election') || this.isWithdrawalReviewUrl)
|
||||||
this.router.url.includes('/election') ||
|
|
||||||
this.router.url.includes('/fd-dashboard') ||
|
|
||||||
this.isWithdrawalReviewUrl)
|
|
||||||
) {
|
) {
|
||||||
this.hideShellLayout = false;
|
this.hideShellLayout = false;
|
||||||
this.cdr.detectChanges();
|
this.cdr.detectChanges();
|
||||||
|
|||||||
+4
-10
@@ -334,18 +334,14 @@ 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. A Days tenure under 30 days rounds down to 0
|
// Convert tenure to months for backend API
|
||||||
// months, which the backend rejects -- send it as tenureInDays instead in that case.
|
let tenureInMonths = 0;
|
||||||
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 if (enteredDays >= 30) {
|
|
||||||
tenureInMonths = Math.floor(enteredDays / 30);
|
|
||||||
} else {
|
} else {
|
||||||
tenureInDays = enteredDays;
|
tenureInMonths = Math.floor(enteredDays / 30);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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)
|
||||||
@@ -399,7 +395,6 @@ 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,
|
||||||
@@ -422,8 +417,7 @@ 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 effectiveMonths = tenureInMonths ?? enteredDays / 30;
|
const totalCompoundings = Math.floor(tenureInMonths / compMonths);
|
||||||
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;
|
||||||
|
|||||||
+5
-19
@@ -7,29 +7,17 @@
|
|||||||
-->
|
-->
|
||||||
|
|
||||||
<form [formGroup]="closureSettingsForm" class="layout-row-wrap gap-2px responsive-column">
|
<form [formGroup]="closureSettingsForm" class="layout-row-wrap gap-2px responsive-column">
|
||||||
<h4 class="mat-h4 flex-98">{{ 'labels.heading.Maturity Instructions' | translate }}</h4>
|
<h4 class="mat-h4 flex-98">{{ 'labels.heading.For Pre-mature closure' | translate }}</h4>
|
||||||
|
|
||||||
<mat-form-field class="flex-48">
|
<mat-form-field class="flex-48">
|
||||||
<mat-label>{{ 'labels.inputs.Maturity Instructions' | translate }}</mat-label>
|
<mat-label>{{ 'labels.inputs.Maturity Instructions' | translate }}</mat-label>
|
||||||
<mat-select formControlName="maturityInstructionId">
|
<mat-select formControlName="maturityInstructionId">
|
||||||
<mat-option [value]="''">-- Not selected (decide later via FD Dashboard) --</mat-option>
|
|
||||||
@for (maturityInstruction of maturityInstructionOptions; track maturityInstruction) {
|
@for (maturityInstruction of maturityInstructionOptions; track maturityInstruction) {
|
||||||
<mat-option [value]="maturityInstruction.id">
|
<mat-option [value]="maturityInstruction.id">
|
||||||
{{ maturityInstruction.value | translateKey: 'catalogs' }}
|
{{ maturityInstruction.value | translateKey: 'catalogs' }}
|
||||||
</mat-option>
|
</mat-option>
|
||||||
}
|
}
|
||||||
</mat-select>
|
</mat-select>
|
||||||
@if (closureSettingsForm.value.maturityInstructionId) {
|
|
||||||
<button
|
|
||||||
mat-icon-button
|
|
||||||
matSuffix
|
|
||||||
type="button"
|
|
||||||
title="Clear -- decide later via FD Dashboard"
|
|
||||||
(click)="$event.stopPropagation(); closureSettingsForm.get('maturityInstructionId')?.setValue('')"
|
|
||||||
>
|
|
||||||
<mat-icon>close</mat-icon>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
|
|
||||||
@if (closureSettingsForm.contains('transferToSavingsId')) {
|
@if (closureSettingsForm.contains('transferToSavingsId')) {
|
||||||
@@ -40,8 +28,8 @@
|
|||||||
<mat-icon>savings</mat-icon>
|
<mat-icon>savings</mat-icon>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 class="dal-card__title">{{ transferAccountCardTitle }}</h3>
|
<h3 class="dal-card__title">{{ 'labels.inputs.Transfer to savings account' | translate }}</h3>
|
||||||
<span class="dal-card__sub">{{ transferAccountCardSubtitle }}</span>
|
<span class="dal-card__sub">Maturity proceeds will be credited to this account</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -81,7 +69,7 @@
|
|||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<mat-form-field appearance="outline" class="flex-98">
|
<mat-form-field appearance="outline" class="flex-98">
|
||||||
<mat-label>{{ transferAccountCardTitle }}</mat-label>
|
<mat-label>{{ 'labels.inputs.Transfer to savings account' | translate }}</mat-label>
|
||||||
<mat-select formControlName="transferToSavingsId" required>
|
<mat-select formControlName="transferToSavingsId" required>
|
||||||
@for (account of savingsAccountsData; track account) {
|
@for (account of savingsAccountsData; track account) {
|
||||||
<mat-option [value]="account.id">
|
<mat-option [value]="account.id">
|
||||||
@@ -89,15 +77,13 @@
|
|||||||
</mat-option>
|
</mat-option>
|
||||||
}
|
}
|
||||||
</mat-select>
|
</mat-select>
|
||||||
<mat-error>{{ transferAccountCardTitle }} is required</mat-error>
|
<mat-error>Transfer to savings account is required</mat-error>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
<h4 class="mat-h4 flex-98">{{ 'labels.heading.For Pre-mature closure' | translate }}</h4>
|
|
||||||
|
|
||||||
<mat-checkbox class="flex-98 margin-v" labelPosition="before" formControlName="preClosurePenalApplicable">
|
<mat-checkbox class="flex-98 margin-v" labelPosition="before" formControlName="preClosurePenalApplicable">
|
||||||
{{ 'labels.inputs.Apply Penal Interest (less)' | translate }}
|
{{ 'labels.inputs.Apply Penal Interest (less)' | translate }}
|
||||||
</mat-checkbox>
|
</mat-checkbox>
|
||||||
|
|||||||
+5
-43
@@ -11,7 +11,6 @@ import { Component, Input, OnChanges, SimpleChanges, inject } from '@angular/cor
|
|||||||
import { UntypedFormGroup, UntypedFormBuilder, UntypedFormControl, Validators } from '@angular/forms';
|
import { UntypedFormGroup, UntypedFormBuilder, UntypedFormControl, Validators } from '@angular/forms';
|
||||||
import { MatCheckbox } from '@angular/material/checkbox';
|
import { MatCheckbox } from '@angular/material/checkbox';
|
||||||
import { MatIcon } from '@angular/material/icon';
|
import { MatIcon } from '@angular/material/icon';
|
||||||
import { MatIconButton } from '@angular/material/button';
|
|
||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -26,8 +25,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
|||||||
imports: [
|
imports: [
|
||||||
...STANDALONE_SHARED_IMPORTS,
|
...STANDALONE_SHARED_IMPORTS,
|
||||||
MatCheckbox,
|
MatCheckbox,
|
||||||
MatIcon,
|
MatIcon
|
||||||
MatIconButton
|
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class FixedDepositAccountMaturityTermsStepComponent implements OnChanges {
|
export class FixedDepositAccountMaturityTermsStepComponent implements OnChanges {
|
||||||
@@ -55,12 +53,9 @@ export class FixedDepositAccountMaturityTermsStepComponent implements OnChanges
|
|||||||
preClosurePenalInterestOnTypeId: ['']
|
preClosurePenalInterestOnTypeId: ['']
|
||||||
});
|
});
|
||||||
this.closureSettingsForm.get('maturityInstructionId').valueChanges.subscribe((value: number) => {
|
this.closureSettingsForm.get('maturityInstructionId').valueChanges.subscribe((value: number) => {
|
||||||
// 200 = Transfer to Savings -- the whole matured balance goes here. 400 = Reinvest Principal
|
// 200 = Transfer to Savings -- Withdraw Deposit (100) needs no target account, and both
|
||||||
// Only -- the principal rolls into a new FD, but the leftover interest still needs the same
|
// Reinvest options (300/400) open a brand-new FD instead, so they ignore this field too.
|
||||||
// kind of target so it isn't left stranded on the closed account (a real gap the account used
|
if (value === 200) {
|
||||||
// to silently hit before this field also asked for it here). Reinvest Principal + Interest
|
|
||||||
// (300) rolls the whole balance into the same account, so it never needs a target at all.
|
|
||||||
if (value === 200 || value === 400) {
|
|
||||||
this.closureSettingsForm.addControl(
|
this.closureSettingsForm.addControl(
|
||||||
'transferToSavingsId',
|
'transferToSavingsId',
|
||||||
new UntypedFormControl(this.linkedSavingsAccountId ?? '', Validators.required)
|
new UntypedFormControl(this.linkedSavingsAccountId ?? '', Validators.required)
|
||||||
@@ -96,48 +91,15 @@ export class FixedDepositAccountMaturityTermsStepComponent implements OnChanges
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Editing an existing account: its own already-saved Maturity Instructions choice lives on
|
|
||||||
// `fixedDepositsAccountTemplate` (the real account+template GET, FixedDepositAccountData's
|
|
||||||
// `onAccountClosure`) -- NOT on `fixedDepositsAccountProductTemplate`, which is only the blank
|
|
||||||
// per-product default template refetched from the Product dropdown's own productId (via
|
|
||||||
// getFixedDepositsAccountTemplate(clientId, productId)) and carries no account-specific data at
|
|
||||||
// all. This was never read from the right place before, so Edit always opened onto a blank
|
|
||||||
// picker even for an account that really does have an instruction saved. `.dirty` guards it the
|
|
||||||
// same way the penal-interest fields above do, so a maker's own in-progress edit here is never
|
|
||||||
// silently overwritten if this input fires again mid-edit.
|
|
||||||
if (this.fixedDepositsAccountTemplate && !this.closureSettingsForm.get('maturityInstructionId').dirty) {
|
|
||||||
this.closureSettingsForm
|
|
||||||
.get('maturityInstructionId')
|
|
||||||
?.setValue(this.fixedDepositsAccountTemplate.onAccountClosure?.id ?? '');
|
|
||||||
const transferControl = this.closureSettingsForm.get('transferToSavingsId');
|
|
||||||
if (transferControl) {
|
|
||||||
transferControl.setValue(
|
|
||||||
this.fixedDepositsAccountTemplate.transferToSavingsId ?? this.linkedSavingsAccountId ?? ''
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The linked-savings account for the transfer-to-savings / reinvest-principal-only-interest
|
/** The linked-savings account for the transfer-to-savings maturity instruction, once picked. */
|
||||||
* maturity instruction, once picked. */
|
|
||||||
get selectedTransferAccount(): any {
|
get selectedTransferAccount(): any {
|
||||||
const id = this.closureSettingsForm.value.transferToSavingsId;
|
const id = this.closureSettingsForm.value.transferToSavingsId;
|
||||||
if (!id) return null;
|
if (!id) return null;
|
||||||
return this.savingsAccountsData.find((account: any) => account.id === id) ?? null;
|
return this.savingsAccountsData.find((account: any) => account.id === id) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
get transferAccountCardTitle(): string {
|
|
||||||
return this.closureSettingsForm.value.maturityInstructionId === 400
|
|
||||||
? 'Interest payout account'
|
|
||||||
: 'Transfer to savings account';
|
|
||||||
}
|
|
||||||
|
|
||||||
get transferAccountCardSubtitle(): string {
|
|
||||||
return this.closureSettingsForm.value.maturityInstructionId === 400
|
|
||||||
? 'The principal renews into a new FD; interest earned is paid out to this account.'
|
|
||||||
: 'Maturity proceeds will be credited to this account.';
|
|
||||||
}
|
|
||||||
|
|
||||||
clearTransferAccount(): void {
|
clearTransferAccount(): void {
|
||||||
this.closureSettingsForm.get('transferToSavingsId')?.setValue('');
|
this.closureSettingsForm.get('transferToSavingsId')?.setValue('');
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-42
@@ -8,25 +8,12 @@
|
|||||||
|
|
||||||
<div class="pmt">
|
<div class="pmt">
|
||||||
<div class="pmt__header">
|
<div class="pmt__header">
|
||||||
<div class="pmt__header-left">
|
|
||||||
<span class="pmt__header-ico"><fa-icon icon="sync-alt"></fa-icon></span>
|
<span class="pmt__header-ico"><fa-icon icon="sync-alt"></fa-icon></span>
|
||||||
<div class="pmt__header-text">
|
<div class="pmt__header-text">
|
||||||
<h2 class="pmt__title">
|
<h2 class="pmt__title">{{ 'labels.heading.Payment' | translate }}</h2>
|
||||||
{{ isDashboardRenewalMode ? 'Renew Fixed Deposit' : ('labels.heading.Payment' | translate) }}
|
<p class="pmt__subtitle">Process this Fixed Deposit now -- works whether it's Active or Matured.</p>
|
||||||
</h2>
|
|
||||||
<p class="pmt__subtitle">
|
|
||||||
{{
|
|
||||||
isDashboardRenewalMode
|
|
||||||
? 'Renew ' + (dashboardRenewalRow?.accountNo ?? 'this matured FD') + '.'
|
|
||||||
: "Process this Fixed Deposit now -- works whether it's Active or Matured."
|
|
||||||
}}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="pmt__header-close" mat-dialog-close [disabled]="submitting" title="Close">
|
|
||||||
<fa-icon icon="times"></fa-icon>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div mat-dialog-content class="pmt__content">
|
<div mat-dialog-content class="pmt__content">
|
||||||
@if (loadingTemplate) {
|
@if (loadingTemplate) {
|
||||||
@@ -40,27 +27,19 @@
|
|||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<form [formGroup]="paymentForm">
|
<form [formGroup]="paymentForm">
|
||||||
@if (isDashboardRenewalMode) {
|
|
||||||
<p class="pmt__hint">
|
|
||||||
<fa-icon icon="info-circle" class="m-r-5"></fa-icon>
|
|
||||||
{{ dashboardRenewalTimingText }}
|
|
||||||
</p>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (reinvestCap !== null && reinvestCap !== undefined) {
|
@if (reinvestCap !== null && reinvestCap !== undefined) {
|
||||||
<div class="pmt__stat">
|
<div class="pmt__stat">
|
||||||
<span class="pmt__stat-label">
|
<span class="pmt__stat-label">
|
||||||
{{
|
{{
|
||||||
isDashboardRenewalMode || !remainderIsApproximate
|
remainderIsApproximate
|
||||||
? ('labels.inputs.Maturity Amount' | translate)
|
? 'Amount available on the selected date'
|
||||||
: 'Amount available on the selected date'
|
: ('labels.inputs.Maturity Amount' | translate)
|
||||||
}}
|
}}
|
||||||
</span>
|
</span>
|
||||||
<span class="pmt__stat-value">{{ reinvestCap | formatNumber }}</span>
|
<span class="pmt__stat-value">{{ reinvestCap | formatNumber }}</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (!isDashboardRenewalMode) {
|
|
||||||
<mat-form-field appearance="outline" class="pmt__date" (click)="closedOnDatePicker.open()">
|
<mat-form-field appearance="outline" class="pmt__date" (click)="closedOnDatePicker.open()">
|
||||||
<mat-label>{{ 'labels.inputs.Closed On Date' | translate }}</mat-label>
|
<mat-label>{{ 'labels.inputs.Closed On Date' | translate }}</mat-label>
|
||||||
<input
|
<input
|
||||||
@@ -79,7 +58,6 @@
|
|||||||
</mat-error>
|
</mat-error>
|
||||||
}
|
}
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
}
|
|
||||||
|
|
||||||
<div class="pmt__section-label" id="pmt-action-label">{{ 'labels.inputs.Action' | translate }}</div>
|
<div class="pmt__section-label" id="pmt-action-label">{{ 'labels.inputs.Action' | translate }}</div>
|
||||||
<div class="pmt__options" role="radiogroup" aria-labelledby="pmt-action-label">
|
<div class="pmt__options" role="radiogroup" aria-labelledby="pmt-action-label">
|
||||||
@@ -184,12 +162,10 @@
|
|||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (!isDashboardRenewalMode) {
|
|
||||||
<mat-form-field appearance="outline">
|
<mat-form-field appearance="outline">
|
||||||
<mat-label>{{ 'labels.inputs.Note' | translate }}</mat-label>
|
<mat-label>{{ 'labels.inputs.Note' | translate }}</mat-label>
|
||||||
<textarea matInput formControlName="note" cdkTextareaAutosize cdkAutosizeMinRows="2"></textarea>
|
<textarea matInput formControlName="note" cdkTextareaAutosize cdkAutosizeMinRows="2"></textarea>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
}
|
|
||||||
|
|
||||||
@if (submitError) {
|
@if (submitError) {
|
||||||
<p class="pmt__error" role="alert">
|
<p class="pmt__error" role="alert">
|
||||||
@@ -202,25 +178,14 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div mat-dialog-actions align="end" class="pmt__actions">
|
<div mat-dialog-actions align="end" class="pmt__actions">
|
||||||
<button mat-button mat-dialog-close [disabled]="submitting" class="pmt__btn-cancel">
|
<button mat-button mat-dialog-close [disabled]="submitting">{{ 'labels.buttons.Cancel' | translate }}</button>
|
||||||
{{ 'labels.buttons.Cancel' | translate }}
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
mat-raised-button
|
mat-raised-button
|
||||||
color="primary"
|
color="primary"
|
||||||
class="pmt__btn-submit"
|
|
||||||
[disabled]="!paymentForm?.valid || submitting || loadFailed"
|
[disabled]="!paymentForm?.valid || submitting || loadFailed"
|
||||||
(click)="submit()"
|
(click)="submit()"
|
||||||
>
|
>
|
||||||
{{
|
{{ submitting ? ('labels.buttons.Processing' | translate) : ('labels.buttons.Confirm' | translate) }}
|
||||||
submitting
|
|
||||||
? isDashboardRenewalMode
|
|
||||||
? 'Renewing...'
|
|
||||||
: ('labels.buttons.Processing' | translate)
|
|
||||||
: isDashboardRenewalMode
|
|
||||||
? 'Confirm & Renew'
|
|
||||||
: ('labels.buttons.Confirm' | translate)
|
|
||||||
}}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+185
-298
@@ -6,404 +6,291 @@
|
|||||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
$navy: #0d1b2a;
|
||||||
* Payment / Renewal Dialog Styles
|
$slate: #475569;
|
||||||
* CBSNexora Core Banking System
|
$muted: #64748b;
|
||||||
*/
|
$border: #e2e8f0;
|
||||||
|
$soft: #f8fafc;
|
||||||
|
$blue: #1e40af;
|
||||||
|
$blue-soft: #eff6ff;
|
||||||
|
$green: #166534;
|
||||||
|
$green-soft: #f0fdf4;
|
||||||
|
|
||||||
.pmt {
|
.pmt {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100%;
|
min-height: 320px;
|
||||||
max-height: 92vh;
|
max-height: 85vh;
|
||||||
box-sizing: border-box;
|
|
||||||
font-family:
|
|
||||||
'DM Sans',
|
|
||||||
system-ui,
|
|
||||||
-apple-system,
|
|
||||||
sans-serif;
|
|
||||||
color: #0f172a;
|
|
||||||
border-radius: 16px;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow: 0 24px 48px -12px rgb(15 23 42 / 25%);
|
margin: -24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pmt__header {
|
.pmt__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
justify-content: space-between;
|
gap: 14px;
|
||||||
gap: 16px;
|
padding: 22px 28px 18px;
|
||||||
padding: 26px 30px;
|
background: linear-gradient(135deg, #1e3a8a 0%, #1e40af 55%, #4338ca 100%);
|
||||||
background: linear-gradient(135deg, #1e3a8a 0%, #1d4ed8 55%, #2563eb 100%);
|
|
||||||
color: #fff;
|
color: #fff;
|
||||||
flex-shrink: 0;
|
flex: 0 0 auto;
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
&::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
background: radial-gradient(circle at 88% -20%, rgb(255 255 255 / 22%), transparent 55%);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&-left {
|
.pmt__header-ico {
|
||||||
display: flex;
|
width: 40px;
|
||||||
align-items: center;
|
height: 40px;
|
||||||
gap: 16px;
|
min-width: 40px;
|
||||||
position: relative;
|
border-radius: 10px;
|
||||||
z-index: 1;
|
background: rgb(255 255 255 / 15%);
|
||||||
}
|
|
||||||
|
|
||||||
&-ico {
|
|
||||||
width: 48px;
|
|
||||||
height: 48px;
|
|
||||||
border-radius: 14px;
|
|
||||||
background: rgb(255 255 255 / 16%);
|
|
||||||
border: 1px solid rgb(255 255 255 / 25%);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 20px;
|
font-size: 18px;
|
||||||
color: #fff;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&-text {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
&-close {
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
background: rgb(255 255 255 / 14%);
|
|
||||||
border: none;
|
|
||||||
color: #fff;
|
|
||||||
width: 34px;
|
|
||||||
height: 34px;
|
|
||||||
border-radius: 50%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
flex-shrink: 0;
|
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
|
||||||
background: rgb(255 255 255 / 28%);
|
|
||||||
transform: rotate(90deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.pmt__title {
|
.pmt__title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 20px;
|
font-size: 19px;
|
||||||
font-weight: 800;
|
font-weight: 700;
|
||||||
color: #fff;
|
|
||||||
line-height: 1.25;
|
|
||||||
letter-spacing: -0.01em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.pmt__subtitle {
|
.pmt__subtitle {
|
||||||
margin: 5px 0 0;
|
margin: 4px 0 0;
|
||||||
font-size: 13px;
|
font-size: 12.5px;
|
||||||
color: rgb(255 255 255 / 88%);
|
color: rgb(255 255 255 / 82%);
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pmt__content {
|
.pmt__content {
|
||||||
padding: 26px 30px !important;
|
padding: 22px 28px 8px !important;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
background: #fbfcfe;
|
|
||||||
|
|
||||||
mat-form-field {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.pmt__loading {
|
.pmt__loading {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
min-height: 240px;
|
min-height: 260px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pmt__stat {
|
.pmt__stat {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
gap: 2px;
|
||||||
background: linear-gradient(135deg, #eff6ff 0%, #e0e9ff 100%);
|
background: $blue-soft;
|
||||||
border: 1px solid #bfdbfe;
|
border: 1px solid #dbeafe;
|
||||||
border-radius: 14px;
|
border-radius: 10px;
|
||||||
padding: 18px 22px;
|
padding: 12px 16px;
|
||||||
margin-bottom: 18px;
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
&-label {
|
.pmt__stat-label {
|
||||||
font-size: 11.5px;
|
font-size: 11px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.07em;
|
letter-spacing: 0.05em;
|
||||||
color: #1d4ed8;
|
color: $blue;
|
||||||
}
|
}
|
||||||
|
|
||||||
&-value {
|
.pmt__stat-value {
|
||||||
font-family: 'JetBrains Mono', 'DM Sans', monospace;
|
font-size: 22px;
|
||||||
font-size: 28px;
|
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
color: #0f172a;
|
color: $navy;
|
||||||
letter-spacing: -0.01em;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pmt__date {
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pmt__section-label {
|
.pmt__section-label {
|
||||||
font-size: 12px;
|
font-size: 11.5px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.05em;
|
||||||
color: #475569;
|
color: $slate;
|
||||||
margin: 10px 0 12px;
|
margin: 4px 0 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Action Options Grid - 2 Equal Columns, comfortably wide now that the dialog itself is wider */
|
|
||||||
.pmt__options {
|
.pmt__options {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
|
||||||
gap: 14px;
|
gap: 10px;
|
||||||
margin-bottom: 18px;
|
margin-bottom: 6px;
|
||||||
|
|
||||||
@media (width <= 560px) {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.pmt__option {
|
.pmt__option {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 14px;
|
gap: 10px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border: 1.5px solid #e2e8f0;
|
border: 1.5px solid $border;
|
||||||
border-radius: 14px;
|
border-radius: 10px;
|
||||||
padding: 16px;
|
padding: 12px 14px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
transition:
|
||||||
position: relative;
|
border-color 0.15s,
|
||||||
|
background 0.15s,
|
||||||
&:hover {
|
box-shadow 0.15s;
|
||||||
border-color: #93c5fd;
|
|
||||||
background: #f8fafc;
|
|
||||||
box-shadow: 0 6px 16px rgb(15 23 42 / 6%);
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&--active {
|
.pmt__option:hover {
|
||||||
border-color: #2563eb;
|
border-color: #93c5fd;
|
||||||
background: #eff6ff;
|
background: $blue-soft;
|
||||||
box-shadow: 0 0 0 1.5px #2563eb;
|
}
|
||||||
|
|
||||||
|
.pmt__option--active {
|
||||||
|
border-color: $blue;
|
||||||
|
background: $blue-soft;
|
||||||
|
box-shadow: 0 0 0 1px $blue;
|
||||||
|
}
|
||||||
|
|
||||||
.pmt__option-ico {
|
.pmt__option-ico {
|
||||||
background: #2563eb;
|
width: 30px;
|
||||||
color: #fff;
|
height: 30px;
|
||||||
border-color: #2563eb;
|
min-width: 30px;
|
||||||
}
|
border-radius: 8px;
|
||||||
}
|
background: $soft;
|
||||||
|
color: $blue;
|
||||||
&-ico {
|
|
||||||
width: 38px;
|
|
||||||
height: 38px;
|
|
||||||
min-width: 38px;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: #f1f5f9;
|
|
||||||
border: 1px solid #e2e8f0;
|
|
||||||
color: #2563eb;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 15px;
|
font-size: 13px;
|
||||||
flex-shrink: 0;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Distinct accent per option while unselected, purely presentational -- helps the four cards read
|
.pmt__option--active .pmt__option-ico {
|
||||||
// apart at a glance. Selecting a card always fills its icon blue (see &--active above), matching the
|
background: $blue;
|
||||||
// blue selection border/check regardless of which option it is, rather than mixing selection-blue
|
color: #fff;
|
||||||
// with the option's own accent.
|
|
||||||
&:not(.pmt__option--active):nth-child(2) .pmt__option-ico {
|
|
||||||
color: #7c3aed;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&:not(.pmt__option--active):nth-child(3) .pmt__option-ico {
|
.pmt__option-text {
|
||||||
color: #0d9488;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:not(.pmt__option--active):nth-child(4) .pmt__option-ico {
|
|
||||||
color: #d97706;
|
|
||||||
}
|
|
||||||
|
|
||||||
&-text {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
gap: 2px;
|
||||||
flex: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&-title {
|
.pmt__option-title {
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #0f172a;
|
color: $navy;
|
||||||
line-height: 1.3;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&-desc {
|
.pmt__option-desc {
|
||||||
font-size: 12px;
|
font-size: 11px;
|
||||||
color: #64748b;
|
color: $muted;
|
||||||
line-height: 1.4;
|
line-height: 1.35;
|
||||||
}
|
}
|
||||||
|
|
||||||
&-check {
|
.pmt__option:focus-visible {
|
||||||
color: #2563eb;
|
outline: 2px solid $blue;
|
||||||
font-size: 17px;
|
outline-offset: 2px;
|
||||||
position: absolute;
|
|
||||||
top: 14px;
|
|
||||||
right: 14px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pmt__option-check {
|
||||||
|
margin-left: auto;
|
||||||
|
color: $blue;
|
||||||
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pmt__option-error {
|
.pmt__option-error {
|
||||||
color: #b91c1c;
|
color: #b91c1c;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
margin: 4px 0 14px;
|
||||||
margin: -10px 0 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pmt__split {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 16px;
|
|
||||||
background: #f8fafc;
|
|
||||||
border: 1px dashed #cbd5e1;
|
|
||||||
border-radius: 14px;
|
|
||||||
padding: 18px;
|
|
||||||
margin: 10px 0 18px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
|
|
||||||
&-input {
|
|
||||||
flex: 1 1 220px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&-arrow {
|
|
||||||
color: #94a3b8;
|
|
||||||
font-size: 17px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&-remainder {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 3px;
|
|
||||||
background: #f0fdf4;
|
|
||||||
border: 1px solid #bbf7d0;
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 12px 16px;
|
|
||||||
flex: 1 1 220px;
|
|
||||||
|
|
||||||
&-label {
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
color: #166534;
|
|
||||||
}
|
|
||||||
|
|
||||||
&-value {
|
|
||||||
font-family: 'JetBrains Mono', 'DM Sans', monospace;
|
|
||||||
font-size: 19px;
|
|
||||||
font-weight: 800;
|
|
||||||
color: #166534;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.pmt__hint {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 10px;
|
|
||||||
background: #f0f9ff;
|
|
||||||
border: 1px solid #bae6fd;
|
|
||||||
border-left: 4px solid #0284c7;
|
|
||||||
border-radius: 10px;
|
|
||||||
color: #0369a1;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.5;
|
|
||||||
padding: 12px 16px;
|
|
||||||
margin: 0 0 18px;
|
|
||||||
|
|
||||||
&--warn {
|
|
||||||
background: #fffbeb;
|
|
||||||
border-color: #fde68a;
|
|
||||||
border-left-color: #d97706;
|
|
||||||
color: #b45309;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.pmt__error {
|
.pmt__error {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 10px;
|
gap: 4px;
|
||||||
background: #fef2f2;
|
background: #fef2f2;
|
||||||
border: 1px solid #fecaca;
|
border: 1px solid #fecaca;
|
||||||
border-left: 4px solid #ef4444;
|
border-radius: 8px;
|
||||||
border-radius: 10px;
|
|
||||||
color: #b91c1c;
|
color: #b91c1c;
|
||||||
font-size: 13px;
|
font-size: 12.5px;
|
||||||
padding: 12px 16px;
|
line-height: 1.45;
|
||||||
margin: 10px 0;
|
padding: 10px 14px;
|
||||||
|
margin: 4px 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pmt__hint {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 4px;
|
||||||
|
background: $soft;
|
||||||
|
border: 1px solid $border;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: $slate;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
padding: 9px 13px;
|
||||||
|
margin: 0 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pmt__hint--warn {
|
||||||
|
background: #fffbeb;
|
||||||
|
border-color: #fde68a;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pmt__split {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
background: $soft;
|
||||||
|
border: 1px dashed #cbd5e1;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 14px 16px 0;
|
||||||
|
margin: 4px 0 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pmt__split-input {
|
||||||
|
flex: 1 1 180px;
|
||||||
|
min-width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pmt__split-arrow {
|
||||||
|
color: $muted;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pmt__split-remainder {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
background: $green-soft;
|
||||||
|
border: 1px solid #bbf7d0;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
flex: 1 1 180px;
|
||||||
|
min-width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pmt__split-remainder-label {
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: $green;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pmt__split-remainder-value {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: $green;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pmt__content mat-form-field {
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pmt__actions {
|
.pmt__actions {
|
||||||
padding: 18px 30px !important;
|
padding: 14px 28px 20px !important;
|
||||||
border-top: 1px solid #e2e8f0;
|
border-top: 1px solid $border;
|
||||||
background: #fff;
|
flex: 0 0 auto;
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 14px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
|
|
||||||
.pmt__btn-cancel {
|
|
||||||
border-radius: 10px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #475569;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pmt__btn-submit {
|
|
||||||
border-radius: 10px;
|
|
||||||
font-weight: 700;
|
|
||||||
padding: 0 26px;
|
|
||||||
height: 42px;
|
|
||||||
background: linear-gradient(135deg, #1d4ed8 0%, #2563eb 100%);
|
|
||||||
color: #fff;
|
|
||||||
box-shadow: 0 8px 20px rgb(37 99 235 / 30%);
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
|
||||||
box-shadow: 0 10px 24px rgb(37 99 235 / 40%);
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:disabled {
|
|
||||||
background: #cbd5e1;
|
|
||||||
box-shadow: none;
|
|
||||||
color: #64748b;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-77
@@ -27,22 +27,12 @@ import { SettingsService } from 'app/settings/settings.service';
|
|||||||
import { Dates } from 'app/core/utils/dates';
|
import { Dates } from 'app/core/utils/dates';
|
||||||
import { FormatNumberPipe } from '../../../../../pipes/format-number.pipe';
|
import { FormatNumberPipe } from '../../../../../pipes/format-number.pipe';
|
||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
import { FdMaturityRow } from 'app/fd-dashboard/fd-dashboard.compute';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* "Payment" dialog -- an always-available alternative to Premature Close (Active-only, no reinvest) and
|
* "Payment" dialog -- an always-available alternative to Premature Close (Active-only, no reinvest) and
|
||||||
* Close (Matured-only, no partial option). Works on an FD in either state and offers a fourth choice,
|
* Close (Matured-only, no partial option). Works on an FD in either state and offers a fourth choice,
|
||||||
* Partial Reinvest, alongside the ones the backend's `DepositAccountOnClosureType` already had: the
|
* Partial Reinvest, alongside the ones the backend's `DepositAccountOnClosureType` already had: the
|
||||||
* entered amount renews on this same account, the rest transfers to a savings account picked here.
|
* entered amount renews on this same account, the rest transfers to a savings account picked here.
|
||||||
*
|
|
||||||
* Doubles as the FD Dashboard's Renew action when opened with `data.dashboardRenewal` set: same form,
|
|
||||||
* same full set of closure types (nothing removed -- Transfer to Savings and Partial Reinvest work from
|
|
||||||
* the Dashboard too, not just the two Reinvest options). The only two differences in that mode: the
|
|
||||||
* Closed On Date is computed from the FD's grace window instead of picked freely (backdated to the
|
|
||||||
* original maturity date within grace, today once it's passed -- the picker is hidden, not just
|
|
||||||
* defaulted, so it can't accidentally be moved off that date), and submit() first calls
|
|
||||||
* `creditGapInterestIfPastGrace` when past grace, crediting the gap-period interest (at the linked
|
|
||||||
* account's rate) onto the FD before the normal `payment` command runs and reads its balance.
|
|
||||||
*/
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'mifosx-payment-dialog',
|
selector: 'mifosx-payment-dialog',
|
||||||
@@ -86,13 +76,7 @@ export class PaymentDialogComponent implements OnInit {
|
|||||||
};
|
};
|
||||||
|
|
||||||
dialogRef = inject<MatDialogRef<PaymentDialogComponent>>(MatDialogRef);
|
dialogRef = inject<MatDialogRef<PaymentDialogComponent>>(MatDialogRef);
|
||||||
data: {
|
data: { accountId: number | string; accountBalance?: number; maturityDate?: any } = inject(MAT_DIALOG_DATA);
|
||||||
accountId: number | string;
|
|
||||||
accountBalance?: number;
|
|
||||||
maturityDate?: any;
|
|
||||||
/** Present only when opened from the FD Dashboard's Renew action. */
|
|
||||||
dashboardRenewal?: { row: FdMaturityRow };
|
|
||||||
} = inject(MAT_DIALOG_DATA);
|
|
||||||
private formBuilder = inject(UntypedFormBuilder);
|
private formBuilder = inject(UntypedFormBuilder);
|
||||||
private fixedDepositsService = inject(FixedDepositsService);
|
private fixedDepositsService = inject(FixedDepositsService);
|
||||||
private settingsService = inject(SettingsService);
|
private settingsService = inject(SettingsService);
|
||||||
@@ -109,10 +93,6 @@ export class PaymentDialogComponent implements OnInit {
|
|||||||
submitError: string = null;
|
submitError: string = null;
|
||||||
maxDate: Date;
|
maxDate: Date;
|
||||||
|
|
||||||
/** Whether gap-interest still needs crediting before this submits -- only ever true once, right
|
|
||||||
* after open; cleared as soon as `submit()` has credited it, so a retried submit doesn't double-post. */
|
|
||||||
private gapInterestPending = false;
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.maxDate = this.settingsService.businessDate;
|
this.maxDate = this.settingsService.businessDate;
|
||||||
this.paymentForm = this.formBuilder.group({
|
this.paymentForm = this.formBuilder.group({
|
||||||
@@ -126,18 +106,6 @@ export class PaymentDialogComponent implements OnInit {
|
|||||||
],
|
],
|
||||||
note: ['']
|
note: ['']
|
||||||
});
|
});
|
||||||
|
|
||||||
const dashboardRow = this.dashboardRenewalRow;
|
|
||||||
if (dashboardRow) {
|
|
||||||
// Computed from the grace window, never picked -- the date field stays in the form (the generic
|
|
||||||
// submit path and reinvestCap/processingOnOrAfterMaturity both key off it) but its picker is
|
|
||||||
// hidden in this mode (see the template), so it can't be moved off this value.
|
|
||||||
this.paymentForm
|
|
||||||
.get('closedOnDate')
|
|
||||||
.setValue(dashboardRow.withinGraceWindow ? new Date(dashboardRow.maturityDate) : this.maxDate);
|
|
||||||
this.gapInterestPending = !dashboardRow.withinGraceWindow;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.paymentForm.get('onAccountClosureId').valueChanges.subscribe((id: number) => this.onClosureTypeChange(id));
|
this.paymentForm.get('onAccountClosureId').valueChanges.subscribe((id: number) => this.onClosureTypeChange(id));
|
||||||
// The cap depends on whether the chosen date is on or after maturity, so a date change can make an
|
// The cap depends on whether the chosen date is on or after maturity, so a date change can make an
|
||||||
// already-entered amount valid or invalid. Without this the amount keeps its verdict from the old date.
|
// already-entered amount valid or invalid. Without this the amount keeps its verdict from the old date.
|
||||||
@@ -168,25 +136,6 @@ export class PaymentDialogComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
get isDashboardRenewalMode(): boolean {
|
|
||||||
return !!this.data.dashboardRenewal;
|
|
||||||
}
|
|
||||||
|
|
||||||
get dashboardRenewalRow(): FdMaturityRow | null {
|
|
||||||
return this.data.dashboardRenewal?.row ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** What clicking Confirm is actually about to do -- shown above the (hidden-picker) date in this
|
|
||||||
* mode, since the date itself is no longer visible to explain it. */
|
|
||||||
get dashboardRenewalTimingText(): string {
|
|
||||||
const row = this.dashboardRenewalRow;
|
|
||||||
if (!row) return '';
|
|
||||||
return row.withinGraceWindow
|
|
||||||
? `Backdated to its original maturity date (${row.maturityDate}), at the currently active interest rate.`
|
|
||||||
: `This FD's grace period has passed -- gap-period interest (for the days since maturity) will be ` +
|
|
||||||
`credited at the linked account's rate first, then this renews from today.`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The amount being split, and the bound the entered partial amount is checked against.
|
* The amount being split, and the bound the entered partial amount is checked against.
|
||||||
* <p>
|
* <p>
|
||||||
@@ -310,31 +259,6 @@ export class PaymentDialogComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
submit(): void {
|
submit(): void {
|
||||||
if (this.gapInterestPending) {
|
|
||||||
this.submitting = true;
|
|
||||||
this.submitError = null;
|
|
||||||
this.fixedDepositsService.creditGapInterestIfPastGrace(this.data.accountId).subscribe({
|
|
||||||
next: () => {
|
|
||||||
this.gapInterestPending = false;
|
|
||||||
this.submitGenericPayment();
|
|
||||||
},
|
|
||||||
error: (error: any) => {
|
|
||||||
this.submitting = false;
|
|
||||||
this.submitError =
|
|
||||||
error?.error?.defaultUserMessage ??
|
|
||||||
error?.error?.errors?.[0]?.defaultUserMessage ??
|
|
||||||
'Could not credit gap-period interest. Please review the details and try again.';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.submitGenericPayment();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The same `command=payment` submission every non-Dashboard call to this dialog already used --
|
|
||||||
* Dashboard-renewal mode reaches it too, once any gap-interest pre-step above has cleared, so every
|
|
||||||
* closure type behaves identically regardless of where the dialog was opened from. */
|
|
||||||
private submitGenericPayment(): void {
|
|
||||||
const formValue = { ...this.paymentForm.value };
|
const formValue = { ...this.paymentForm.value };
|
||||||
const locale = this.settingsService.language.code;
|
const locale = this.settingsService.language.code;
|
||||||
const dateFormat = this.settingsService.dateFormat;
|
const dateFormat = this.settingsService.dateFormat;
|
||||||
|
|||||||
+1
-1
@@ -276,7 +276,7 @@ export class FixedDepositAccountViewComponent implements OnInit {
|
|||||||
*/
|
*/
|
||||||
private openPaymentDialog(): void {
|
private openPaymentDialog(): void {
|
||||||
const paymentDialogRef = this.dialog.open(PaymentDialogComponent, {
|
const paymentDialogRef = this.dialog.open(PaymentDialogComponent, {
|
||||||
width: '720px',
|
width: '640px',
|
||||||
maxWidth: '95vw',
|
maxWidth: '95vw',
|
||||||
data: {
|
data: {
|
||||||
accountId: this.fixedDepositsAccountData.id,
|
accountId: this.fixedDepositsAccountData.id,
|
||||||
|
|||||||
+4
-5
@@ -57,12 +57,11 @@ export class FixedDepositsButtonsConfiguration {
|
|||||||
];
|
];
|
||||||
break;
|
break;
|
||||||
case 'Matured':
|
case 'Matured':
|
||||||
// 'Close' (the generic Fineract apply-maturity-instructions action) is deliberately left out
|
|
||||||
// here -- what happens to a matured FD is now handled either automatically by the nightly job
|
|
||||||
// (an account opened with an explicit instruction) or manually via the FD Dashboard's Renew
|
|
||||||
// action (an account left without one, backdated within grace / gap-interest after). Closing
|
|
||||||
// from here would go around both of those paths.
|
|
||||||
this.buttonsArray = [
|
this.buttonsArray = [
|
||||||
|
{
|
||||||
|
name: 'Close',
|
||||||
|
icon: 'fa fa-arrow-right'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'Calculate Interest',
|
name: 'Calculate Interest',
|
||||||
icon: 'fa fa-table'
|
icon: 'fa fa-table'
|
||||||
|
|||||||
@@ -133,8 +133,7 @@ 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
|
||||||
@@ -154,26 +153,6 @@ export class FixedDepositsService {
|
|||||||
return this.http.get(`/fixeddepositaccounts/calculate-fd-interest`, { params: httpParams });
|
return this.http.get(`/fixeddepositaccounts/calculate-fd-interest`, { params: httpParams });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* FD Dashboard's "Renew" action on a matured FD whose product has a renewal grace period
|
|
||||||
* configured: backdated to the original maturity date within the grace window, or from today
|
|
||||||
* with a gap-period interest credit (at the linked Savings account's rate) once it has passed.
|
|
||||||
* @param accountId Fixed Deposit Account Id
|
|
||||||
* @returns {Observable<any>} { renewedAccountId, maturityDate, maturityAmount, backdated, gapInterestAmount? }
|
|
||||||
*/
|
|
||||||
/** `reinvestType` is the staff's choice made at renewal time: 300 = Reinvest Principal + Interest,
|
|
||||||
* 400 = Reinvest Principal Only (rest of the balance goes to the FD's linked Savings account). */
|
|
||||||
renewFixedDepositAccount(accountId: string | number, reinvestType: 300 | 400): Observable<any> {
|
|
||||||
return this.http.post(`/fixeddepositaccounts/${accountId}/renew`, { reinvestType });
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Pre-step the Payment dialog calls, from the FD Dashboard, before submitting the generic `payment`
|
|
||||||
* command -- credits gap-period interest (at the linked account's rate) if the product's renewal
|
|
||||||
* grace window has passed; a no-op (`gapInterestAmount: 0`) if still within it. */
|
|
||||||
creditGapInterestIfPastGrace(accountId: string | number): Observable<any> {
|
|
||||||
return this.http.post(`/fixeddepositaccounts/${accountId}/credit-gap-interest`, {});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param fixedDepositAccount Fixed Deposit Account
|
* @param fixedDepositAccount Fixed Deposit Account
|
||||||
* @returns {Observable<any>}
|
* @returns {Observable<any>}
|
||||||
|
|||||||
@@ -1,29 +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 { NgModule } from '@angular/core';
|
|
||||||
import { Routes, RouterModule } from '@angular/router';
|
|
||||||
|
|
||||||
import { Route } from '../core/route/route.service';
|
|
||||||
import { FdDashboardComponent } from './fd-dashboard.component';
|
|
||||||
|
|
||||||
const routes: Routes = [
|
|
||||||
Route.withShell([
|
|
||||||
{
|
|
||||||
path: 'fd-dashboard',
|
|
||||||
component: FdDashboardComponent,
|
|
||||||
data: { title: 'FD Dashboard', breadcrumb: 'FD Dashboard' }
|
|
||||||
}
|
|
||||||
])
|
|
||||||
];
|
|
||||||
|
|
||||||
@NgModule({
|
|
||||||
imports: [RouterModule.forChild(routes)],
|
|
||||||
exports: [RouterModule]
|
|
||||||
})
|
|
||||||
export class FdDashboardRoutingModule {}
|
|
||||||
@@ -1,240 +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="fdd-container">
|
|
||||||
<!-- Compact Light Header -->
|
|
||||||
<div class="fdd-header">
|
|
||||||
<div class="fdd-header__left">
|
|
||||||
<div class="fdd-header__icon">
|
|
||||||
<fa-icon icon="piggy-bank"></fa-icon>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="fdd-header__title-row">
|
|
||||||
<h2 class="fdd-header__title">FD Dashboard & Maturity Ladder</h2>
|
|
||||||
<span class="fdd-header__badge">Core Banking</span>
|
|
||||||
</div>
|
|
||||||
<p class="fdd-header__sub">
|
|
||||||
Active Fixed Deposits positioned against maturity dates. Press <kbd>ESC</kbd> to toggle header & footer.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="fdd-header__right">
|
|
||||||
<button class="fdd-btn fdd-btn--slim" (click)="load()" [disabled]="loading" title="Refresh maturity ladder data">
|
|
||||||
<fa-icon icon="sync-alt" [class.fa-spin]="loading"></fa-icon>
|
|
||||||
<span>{{ loading ? 'Syncing...' : 'Reload' }}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Alerts -->
|
|
||||||
@if (errorMessage) {
|
|
||||||
<div class="fdd-alert fdd-alert--danger">
|
|
||||||
<fa-icon icon="exclamation-triangle"></fa-icon>
|
|
||||||
<span>{{ errorMessage }}</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- Compact Stat Cards Grid -->
|
|
||||||
<div class="fdd-stats-grid">
|
|
||||||
@for (key of stageKeys; track key) {
|
|
||||||
<button
|
|
||||||
[class]="'fdd-stat-card fdd-stat-card--' + stageMeta[key].tone"
|
|
||||||
[class.fdd-stat-card--active]="stageFilter === key"
|
|
||||||
(click)="toggleStage(key)"
|
|
||||||
>
|
|
||||||
<div class="fdd-stat-card__header">
|
|
||||||
<span class="fdd-stat-card__label">{{ stageMeta[key].label }}</span>
|
|
||||||
<div class="fdd-stat-card__icon">
|
|
||||||
@switch (key) {
|
|
||||||
@case ('MATURED') {
|
|
||||||
<fa-icon icon="exclamation-circle"></fa-icon>
|
|
||||||
}
|
|
||||||
@case ('DUE_SOON') {
|
|
||||||
<fa-icon icon="clock"></fa-icon>
|
|
||||||
}
|
|
||||||
@case ('UPCOMING') {
|
|
||||||
<fa-icon icon="calendar-alt"></fa-icon>
|
|
||||||
}
|
|
||||||
@default {
|
|
||||||
<fa-icon icon="check-circle"></fa-icon>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="fdd-stat-card__val">
|
|
||||||
@switch (key) {
|
|
||||||
@case ('MATURED') {
|
|
||||||
{{ summary.matured }}
|
|
||||||
}
|
|
||||||
@case ('DUE_SOON') {
|
|
||||||
{{ summary.dueSoon }}
|
|
||||||
}
|
|
||||||
@case ('UPCOMING') {
|
|
||||||
{{ summary.upcoming }}
|
|
||||||
}
|
|
||||||
@default {
|
|
||||||
{{ summary.onTrack }}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<div class="fdd-stat-card__sub">{{ stageMeta[key].summary }}</div>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Compact Filter & Search Toolbar -->
|
|
||||||
<div class="fdd-toolbar">
|
|
||||||
<div class="fdd-search-box">
|
|
||||||
<fa-icon icon="search" class="fdd-search-box__icon"></fa-icon>
|
|
||||||
<input
|
|
||||||
class="fdd-search-box__input"
|
|
||||||
type="text"
|
|
||||||
placeholder="Search account no. or client name..."
|
|
||||||
[(ngModel)]="search"
|
|
||||||
/>
|
|
||||||
@if (search) {
|
|
||||||
<button class="fdd-search-box__clear" (click)="search = ''" title="Clear search">
|
|
||||||
<fa-icon icon="times"></fa-icon>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (stageFilter || search) {
|
|
||||||
<button class="fdd-btn fdd-btn--reset fdd-btn--slim" (click)="resetFilters()">
|
|
||||||
<fa-icon icon="undo"></fa-icon> Reset
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="fdd-toolbar__spacer"></div>
|
|
||||||
|
|
||||||
<div class="fdd-count-chip">
|
|
||||||
<fa-icon icon="layer-group"></fa-icon>
|
|
||||||
<span
|
|
||||||
><strong>{{ visibleRows.length }}</strong> of {{ summary.total }} active FDs</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Worklist Table / Empty State -->
|
|
||||||
@if (loading) {
|
|
||||||
<div class="fdd-card fdd-state-card">
|
|
||||||
<fa-icon icon="spinner" [class.fa-spin]="true" class="fdd-spinner"></fa-icon>
|
|
||||||
<p>Loading FD maturity list...</p>
|
|
||||||
</div>
|
|
||||||
} @else if (visibleRows.length === 0) {
|
|
||||||
<div class="fdd-card fdd-state-card">
|
|
||||||
<fa-icon icon="inbox" class="fdd-state-icon"></fa-icon>
|
|
||||||
<p class="fdd-state-msg">
|
|
||||||
@if (!stageFilter && summary.total > 0) {
|
|
||||||
No FDs are matured or maturing within {{ policy.upcomingDays }} days. Click "On Track" to see all active FDs.
|
|
||||||
} @else {
|
|
||||||
No FDs match these filters.
|
|
||||||
}
|
|
||||||
</p>
|
|
||||||
@if (stageFilter || search) {
|
|
||||||
<button class="fdd-btn fdd-btn--primary fdd-btn--slim" (click)="resetFilters()">
|
|
||||||
<fa-icon icon="undo"></fa-icon> Clear Filters
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
} @else {
|
|
||||||
<div class="fdd-table-card">
|
|
||||||
<div class="fdd-table-wrapper">
|
|
||||||
<table class="fdd-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Account</th>
|
|
||||||
<th>Client</th>
|
|
||||||
<th>Product</th>
|
|
||||||
<th>Maturity Date</th>
|
|
||||||
<th class="text-right">Deposit Amount</th>
|
|
||||||
<th class="text-right">Maturity Amount</th>
|
|
||||||
<th>Status</th>
|
|
||||||
<th class="text-center">Action</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@for (r of visibleRows; track r.id) {
|
|
||||||
<tr class="fdd-row" (click)="openAccount(r)">
|
|
||||||
<td>
|
|
||||||
<span class="fdd-acc-num">{{ r.accountNo || '—' }}</span>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td>
|
|
||||||
<div class="fdd-client-cell">
|
|
||||||
<span class="fdd-client-name">{{ r.clientName }}</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td>
|
|
||||||
<span class="fdd-product-tag">{{ r.productName }}</span>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td>
|
|
||||||
<div class="fdd-date-cell">
|
|
||||||
<span class="fdd-date-val">{{ r.maturityDate }}</span>
|
|
||||||
<span class="fdd-date-sub" [class.fdd-date-sub--overdue]="r.daysOverdue > 0">
|
|
||||||
{{ maturityText(r) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td class="text-right">
|
|
||||||
<span class="fdd-amount fdd-amount--deposit">
|
|
||||||
{{ r.depositAmount != null ? '₹' + (r.depositAmount | number: '1.2-2') : '—' }}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td class="text-right">
|
|
||||||
<span class="fdd-amount fdd-amount--maturity">
|
|
||||||
{{ r.maturityAmount != null ? '₹' + (r.maturityAmount | number: '1.2-2') : '—' }}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td>
|
|
||||||
<div class="fdd-status-cell">
|
|
||||||
<span [class]="stageClass(r.stage)">
|
|
||||||
{{ r.meta.label }}
|
|
||||||
</span>
|
|
||||||
@if (r.renewable) {
|
|
||||||
<div
|
|
||||||
class="fdd-grace-sub"
|
|
||||||
[class.fdd-grace-sub--warning]="r.renewalGracePeriodDays > 0 && !r.withinGraceWindow"
|
|
||||||
>
|
|
||||||
{{ graceStatusText(r) }}
|
|
||||||
</div>
|
|
||||||
} @else if (r.configuredInstructionLabel) {
|
|
||||||
<div class="fdd-grace-sub">Configured: {{ r.configuredInstructionLabel }} (auto-processes)</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td class="fdd-act-cell text-center" (click)="$event.stopPropagation()">
|
|
||||||
@if (r.renewable) {
|
|
||||||
<button class="fdd-btn fdd-btn--action" [disabled]="renewing === r.id" (click)="openRenewDialog(r)">
|
|
||||||
@if (renewing === r.id) {
|
|
||||||
<fa-icon icon="spinner" [class.fa-spin]="true"></fa-icon> Renewing...
|
|
||||||
} @else {
|
|
||||||
<fa-icon icon="sync-alt"></fa-icon> Renew
|
|
||||||
}
|
|
||||||
</button>
|
|
||||||
} @else {
|
|
||||||
<button class="fdd-btn-icon" (click)="openAccount(r)" title="View Account Details">
|
|
||||||
<fa-icon icon="chevron-right"></fa-icon>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
@@ -1,578 +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/.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ultra-Compact Light FD Dashboard & Maturity Ladder Styles
|
|
||||||
* CBSNexora Core Banking Design System
|
|
||||||
*/
|
|
||||||
|
|
||||||
:host {
|
|
||||||
display: block;
|
|
||||||
font-family:
|
|
||||||
'DM Sans',
|
|
||||||
system-ui,
|
|
||||||
-apple-system,
|
|
||||||
sans-serif;
|
|
||||||
color: #0f172a;
|
|
||||||
background: #f8fafc;
|
|
||||||
min-height: 100vh;
|
|
||||||
padding: 12px 16px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fdd-container {
|
|
||||||
max-width: 1400px;
|
|
||||||
margin: 0 auto;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Light Compact Header ────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.fdd-header {
|
|
||||||
background: #fff;
|
|
||||||
border: 1px solid #e2e8f0;
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 10px 16px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 16px;
|
|
||||||
box-shadow: 0 1px 3px rgb(0 0 0 / 4%);
|
|
||||||
|
|
||||||
&__left {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__icon {
|
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: #eff6ff;
|
|
||||||
color: #2563eb;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 18px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__title-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__title {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #0f172a;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__badge {
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: 4px;
|
|
||||||
background: #e0f2fe;
|
|
||||||
color: #0369a1;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__sub {
|
|
||||||
margin: 2px 0 0;
|
|
||||||
font-size: 11.5px;
|
|
||||||
color: #64748b;
|
|
||||||
|
|
||||||
kbd {
|
|
||||||
background: #f1f5f9;
|
|
||||||
border: 1px solid #cbd5e1;
|
|
||||||
border-radius: 3px;
|
|
||||||
padding: 1px 4px;
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 10.5px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #334155;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Alerts ─────────────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.fdd-alert {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
|
|
||||||
&--danger {
|
|
||||||
background: #fef2f2;
|
|
||||||
border: 1px solid #fca5a5;
|
|
||||||
color: #dc2626;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--warning {
|
|
||||||
background: #fffbeb;
|
|
||||||
border: 1px solid #fcd34d;
|
|
||||||
color: #d97706;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Compact Stat Cards Grid ────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.fdd-stats-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fdd-stat-card {
|
|
||||||
background: #fff;
|
|
||||||
border: 1px solid #e2e8f0;
|
|
||||||
border-left: 4px solid #cbd5e1;
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 10px 14px;
|
|
||||||
text-align: left;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.15s ease;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
border-color: #cbd5e1;
|
|
||||||
box-shadow: 0 4px 12px rgb(15 23 42 / 6%);
|
|
||||||
}
|
|
||||||
|
|
||||||
&__header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__label {
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #334155;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__icon {
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__val {
|
|
||||||
font-size: 20px;
|
|
||||||
font-weight: 800;
|
|
||||||
line-height: 1.1;
|
|
||||||
color: #0f172a;
|
|
||||||
margin-bottom: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__sub {
|
|
||||||
font-size: 10.5px;
|
|
||||||
color: #64748b;
|
|
||||||
line-height: 1.3;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Tone styling */
|
|
||||||
&--danger {
|
|
||||||
border-left-color: #ef4444;
|
|
||||||
|
|
||||||
.fdd-stat-card__icon {
|
|
||||||
color: #ef4444;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.fdd-stat-card--active {
|
|
||||||
background: #fef2f2;
|
|
||||||
border-color: #fca5a5;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&--warn {
|
|
||||||
border-left-color: #f59e0b;
|
|
||||||
|
|
||||||
.fdd-stat-card__icon {
|
|
||||||
color: #f59e0b;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.fdd-stat-card--active {
|
|
||||||
background: #fffbeb;
|
|
||||||
border-color: #fcd34d;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&--info {
|
|
||||||
border-left-color: #3b82f6;
|
|
||||||
|
|
||||||
.fdd-stat-card__icon {
|
|
||||||
color: #3b82f6;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.fdd-stat-card--active {
|
|
||||||
background: #eff6ff;
|
|
||||||
border-color: #93c5fd;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&--neutral {
|
|
||||||
border-left-color: #64748b;
|
|
||||||
|
|
||||||
.fdd-stat-card__icon {
|
|
||||||
color: #64748b;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.fdd-stat-card--active {
|
|
||||||
background: #f1f5f9;
|
|
||||||
border-color: #cbd5e1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Compact Filter Toolbar ─────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.fdd-toolbar {
|
|
||||||
background: #fff;
|
|
||||||
border: 1px solid #e2e8f0;
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 6px 10px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
box-shadow: 0 1px 2px rgb(0 0 0 / 2%);
|
|
||||||
|
|
||||||
&__spacer {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.fdd-search-box {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
width: 300px;
|
|
||||||
|
|
||||||
&__icon {
|
|
||||||
position: absolute;
|
|
||||||
left: 10px;
|
|
||||||
color: #94a3b8;
|
|
||||||
font-size: 12px;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__input {
|
|
||||||
width: 100%;
|
|
||||||
padding: 5px 30px 5px 28px;
|
|
||||||
border: 1px solid #cbd5e1;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-family: inherit;
|
|
||||||
color: #0f172a;
|
|
||||||
background: #fff;
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: #2563eb;
|
|
||||||
box-shadow: 0 0 0 2px rgb(37 99 235 / 15%);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__clear {
|
|
||||||
position: absolute;
|
|
||||||
right: 6px;
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
color: #94a3b8;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 2px 4px;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: #334155;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.fdd-count-chip {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
font-size: 11.5px;
|
|
||||||
color: #64748b;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Buttons ────────────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.fdd-btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 6px 12px;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
border: 1px solid #cbd5e1;
|
|
||||||
background: #fff;
|
|
||||||
color: #334155;
|
|
||||||
transition: all 0.15s ease;
|
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
|
||||||
border-color: #2563eb;
|
|
||||||
color: #2563eb;
|
|
||||||
background: #f8fafc;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--slim {
|
|
||||||
padding: 4px 10px;
|
|
||||||
font-size: 11.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--primary {
|
|
||||||
background: #2563eb;
|
|
||||||
color: #fff;
|
|
||||||
border-color: #2563eb;
|
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
|
||||||
background: #1d4ed8;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&--action {
|
|
||||||
background: #2563eb;
|
|
||||||
color: #fff;
|
|
||||||
border-color: #2563eb;
|
|
||||||
padding: 4px 10px;
|
|
||||||
font-size: 11px;
|
|
||||||
border-radius: 5px;
|
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
|
||||||
background: #1d4ed8;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&:disabled {
|
|
||||||
opacity: 0.55;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.fdd-btn-icon {
|
|
||||||
width: 26px;
|
|
||||||
height: 26px;
|
|
||||||
border-radius: 5px;
|
|
||||||
border: 1px solid #e2e8f0;
|
|
||||||
background: #fff;
|
|
||||||
color: #64748b;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.15s ease;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: #eff6ff;
|
|
||||||
color: #2563eb;
|
|
||||||
border-color: #93c5fd;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Compact Table ──────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.fdd-table-card {
|
|
||||||
background: #fff;
|
|
||||||
border: 1px solid #e2e8f0;
|
|
||||||
border-radius: 10px;
|
|
||||||
overflow: hidden;
|
|
||||||
box-shadow: 0 1px 3px rgb(0 0 0 / 3%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fdd-table-wrapper {
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fdd-table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
text-align: left;
|
|
||||||
font-size: 12px;
|
|
||||||
|
|
||||||
th {
|
|
||||||
padding: 8px 10px;
|
|
||||||
background: #f1f5f9;
|
|
||||||
color: #475569;
|
|
||||||
font-size: 10.5px;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
border-bottom: 1px solid #e2e8f0;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
td {
|
|
||||||
padding: 7px 10px;
|
|
||||||
border-bottom: 1px solid #f1f5f9;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-right {
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-center {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.fdd-row {
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: #f8fafc;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:last-child td {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.fdd-acc-num {
|
|
||||||
font-family: 'JetBrains Mono', monospace;
|
|
||||||
font-size: 11.5px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #0f172a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fdd-client-name {
|
|
||||||
font-weight: 600;
|
|
||||||
color: #0f172a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fdd-product-tag {
|
|
||||||
font-size: 11px;
|
|
||||||
color: #475569;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fdd-date-cell {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1px;
|
|
||||||
|
|
||||||
.fdd-date-val {
|
|
||||||
font-family: 'JetBrains Mono', monospace;
|
|
||||||
font-size: 11.5px;
|
|
||||||
color: #0f172a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fdd-date-sub {
|
|
||||||
font-size: 10.5px;
|
|
||||||
color: #64748b;
|
|
||||||
|
|
||||||
&--overdue {
|
|
||||||
color: #dc2626;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.fdd-amount {
|
|
||||||
font-family: 'JetBrains Mono', monospace;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
|
|
||||||
&--deposit {
|
|
||||||
color: #334155;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--maturity {
|
|
||||||
color: #2563eb;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.fdd-status-cell {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fdd-stage {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 12px;
|
|
||||||
font-size: 10.5px;
|
|
||||||
font-weight: 700;
|
|
||||||
width: fit-content;
|
|
||||||
|
|
||||||
&--info {
|
|
||||||
background: #eff6ff;
|
|
||||||
color: #2563eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--warn {
|
|
||||||
background: #fffbeb;
|
|
||||||
color: #d97706;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--danger {
|
|
||||||
background: #fef2f2;
|
|
||||||
color: #dc2626;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--neutral {
|
|
||||||
background: #f1f5f9;
|
|
||||||
color: #475569;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.fdd-grace-sub {
|
|
||||||
font-size: 10px;
|
|
||||||
color: #64748b;
|
|
||||||
|
|
||||||
&--warning {
|
|
||||||
color: #dc2626;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.fdd-card.fdd-state-card {
|
|
||||||
background: #fff;
|
|
||||||
border: 1px solid #e2e8f0;
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 24px;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #64748b;
|
|
||||||
|
|
||||||
.fdd-state-icon {
|
|
||||||
font-size: 24px;
|
|
||||||
color: #94a3b8;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fdd-spinner {
|
|
||||||
font-size: 20px;
|
|
||||||
color: #2563eb;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fdd-state-msg {
|
|
||||||
margin: 0 0 12px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,273 +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/.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** Angular Imports */
|
|
||||||
import { Component, OnInit, inject } from '@angular/core';
|
|
||||||
import { FormsModule } from '@angular/forms';
|
|
||||||
import { Router } from '@angular/router';
|
|
||||||
import { MatDialog } from '@angular/material/dialog';
|
|
||||||
|
|
||||||
/** Custom Modules */
|
|
||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
|
||||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
|
||||||
|
|
||||||
/** Custom Services */
|
|
||||||
import { AccountsService } from '../accounts/accounts.service';
|
|
||||||
import { FixedDepositsService } from '../deposits/fixed-deposits/fixed-deposits.service';
|
|
||||||
import { toIsoDate } from 'app/core/utils/api-date';
|
|
||||||
|
|
||||||
/** Dashboard data + computation */
|
|
||||||
import {
|
|
||||||
FD_MATURITY_POLICY,
|
|
||||||
FD_MATURITY_STAGES,
|
|
||||||
FdMaturityRow,
|
|
||||||
FdMaturityStage,
|
|
||||||
FdMaturitySummary,
|
|
||||||
buildMaturityRow,
|
|
||||||
maturitySummary,
|
|
||||||
sortMaturityRows
|
|
||||||
} from './fd-dashboard.compute';
|
|
||||||
import { PaymentDialogComponent } from '../deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* FD Dashboard: every active Fixed Deposit positioned against its own maturity date.
|
|
||||||
*
|
|
||||||
* Read-only and computed, the same way the re-KYC expiry worklist is: the ladder (Matured / Due
|
|
||||||
* Soon / Upcoming / On Track) is worked out here from `maturityDate`, not stored anywhere, so this
|
|
||||||
* screen can never drift out of step with the account -- it always says what today's date implies.
|
|
||||||
* Data comes from the same `/fixeddepositaccounts` list Accounts > Fixed Deposit already uses;
|
|
||||||
* nothing new was added on the backend for this.
|
|
||||||
*/
|
|
||||||
@Component({
|
|
||||||
selector: 'mifosx-fd-dashboard',
|
|
||||||
templateUrl: './fd-dashboard.component.html',
|
|
||||||
styleUrls: ['./fd-dashboard.component.scss'],
|
|
||||||
imports: [
|
|
||||||
...STANDALONE_SHARED_IMPORTS,
|
|
||||||
FormsModule,
|
|
||||||
FaIconComponent
|
|
||||||
]
|
|
||||||
})
|
|
||||||
export class FdDashboardComponent implements OnInit {
|
|
||||||
private accountsService = inject(AccountsService);
|
|
||||||
private fixedDepositsService = inject(FixedDepositsService);
|
|
||||||
private router = inject(Router);
|
|
||||||
private dialog = inject(MatDialog);
|
|
||||||
|
|
||||||
loading = false;
|
|
||||||
errorMessage = '';
|
|
||||||
|
|
||||||
rows: FdMaturityRow[] = [];
|
|
||||||
|
|
||||||
/** Renew action state -- the dialog itself owns submit-in-progress/error display; this just guards
|
|
||||||
* against opening a second dialog for the same row while one is already up. */
|
|
||||||
renewing: number | null = null;
|
|
||||||
|
|
||||||
/** Filters */
|
|
||||||
stageFilter: FdMaturityStage | '' = '';
|
|
||||||
search = '';
|
|
||||||
|
|
||||||
readonly policy = FD_MATURITY_POLICY;
|
|
||||||
readonly stageMeta = FD_MATURITY_STAGES;
|
|
||||||
/** ON_TRACK is reachable only by clicking its own tile -- the default view stays on what needs
|
|
||||||
* attention (matured or maturing soon), matching why this screen exists. */
|
|
||||||
readonly stageKeys: FdMaturityStage[] = [
|
|
||||||
'MATURED',
|
|
||||||
'DUE_SOON',
|
|
||||||
'UPCOMING',
|
|
||||||
'ON_TRACK'
|
|
||||||
];
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
|
||||||
this.load();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Loads every active FD and re-derives the maturity ladder for each.
|
|
||||||
*
|
|
||||||
* Calls the dedicated maturity-dashboard endpoint (backend: FixedDepositMaturityDashboardApiResource),
|
|
||||||
* not the general Accounts > Fixed Deposit list endpoint -- that one's query is wide (six extra
|
|
||||||
* table joins for audit/currency/tax fields this screen never shows) and, until this feature
|
|
||||||
* added them, ran over unindexed columns. The dashboard endpoint already filters to ACTIVE FDs
|
|
||||||
* (deposit_type_enum/status_enum are fixed in its SQL, not a request parameter), so there's
|
|
||||||
* nothing to re-check client-side here.
|
|
||||||
*/
|
|
||||||
load(): void {
|
|
||||||
this.loading = true;
|
|
||||||
this.errorMessage = '';
|
|
||||||
const today = toIsoDate(new Date().toISOString());
|
|
||||||
|
|
||||||
this.accountsService.getFixedDepositMaturityDashboard().subscribe({
|
|
||||||
next: (res: any) => {
|
|
||||||
const items: any[] = Array.isArray(res) ? res : [];
|
|
||||||
|
|
||||||
const mapped = items
|
|
||||||
.map((a: any) => this.mapAccount(a))
|
|
||||||
.map((a) => buildMaturityRow(a, today))
|
|
||||||
.filter((r): r is FdMaturityRow => !!r);
|
|
||||||
|
|
||||||
this.rows = sortMaturityRows(mapped);
|
|
||||||
this.loading = false;
|
|
||||||
},
|
|
||||||
error: (err: any) => {
|
|
||||||
this.rows = [];
|
|
||||||
this.loading = false;
|
|
||||||
this.errorMessage =
|
|
||||||
err?.error?.defaultUserMessage || err?.error?.developerMessage || 'Could not load the FD maturity list.';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Maps one raw `/fixeddepositaccounts/maturity-dashboard` list item onto what the ladder needs. */
|
|
||||||
private mapAccount(a: any): {
|
|
||||||
id: number;
|
|
||||||
accountNo: string;
|
|
||||||
clientId?: number;
|
|
||||||
clientName?: string;
|
|
||||||
productName?: string;
|
|
||||||
savingsProductName?: string;
|
|
||||||
fdType?: string;
|
|
||||||
maturityDate: string;
|
|
||||||
depositAmount?: number;
|
|
||||||
maturityAmount?: number;
|
|
||||||
onClosureType?: number;
|
|
||||||
linkedAccountId?: number;
|
|
||||||
renewalGracePeriodDays?: number;
|
|
||||||
} {
|
|
||||||
return {
|
|
||||||
id: a?.id,
|
|
||||||
accountNo: a?.accountNo || '',
|
|
||||||
clientId: a?.clientId,
|
|
||||||
clientName: a?.clientName,
|
|
||||||
productName: a?.productName,
|
|
||||||
savingsProductName: a?.savingsProductName,
|
|
||||||
fdType: a?.fdType,
|
|
||||||
maturityDate: toIsoDate(a?.maturityDate),
|
|
||||||
depositAmount: a?.depositAmount,
|
|
||||||
maturityAmount: a?.maturityAmount,
|
|
||||||
onClosureType: a?.onClosureType,
|
|
||||||
linkedAccountId: a?.linkedAccountId,
|
|
||||||
renewalGracePeriodDays: a?.renewalGracePeriodDays
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Filtering ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
get visibleRows(): FdMaturityRow[] {
|
|
||||||
const needle = this.search.trim().toLowerCase();
|
|
||||||
const stage = this.stageFilter;
|
|
||||||
return this.rows.filter((r) => {
|
|
||||||
if (stage) {
|
|
||||||
if (r.stage !== stage) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
} else if (r.stage === 'ON_TRACK') {
|
|
||||||
// No tile selected -- the default view is the worklist, not every active FD.
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (needle) {
|
|
||||||
const hay = `${r.accountNo} ${r.clientName}`.toLowerCase();
|
|
||||||
if (!hay.includes(needle)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
get summary(): FdMaturitySummary {
|
|
||||||
return maturitySummary(this.rows);
|
|
||||||
}
|
|
||||||
|
|
||||||
resetFilters(): void {
|
|
||||||
this.stageFilter = '';
|
|
||||||
this.search = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleStage(stage: FdMaturityStage): void {
|
|
||||||
this.stageFilter = this.stageFilter === stage ? '' : stage;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Navigation ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
openAccount(row: FdMaturityRow): void {
|
|
||||||
if (!row.clientId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.router.navigate([
|
|
||||||
'/clients',
|
|
||||||
row.clientId,
|
|
||||||
'fixed-deposits-accounts',
|
|
||||||
row.id,
|
|
||||||
'general'
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Renewal ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Renew is a single action on a matured, renewable row -- opens the same Payment dialog an account
|
|
||||||
* page's own Account Actions menu uses, scoped to just the Reinvest choices, with the closure date
|
|
||||||
* and gap-interest crediting decided from the grace window rather than picked here (see
|
|
||||||
* PaymentDialogComponent's `dashboardRenewal` mode). */
|
|
||||||
openRenewDialog(row: FdMaturityRow): void {
|
|
||||||
if (!row.renewable || this.renewing != null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.renewing = row.id;
|
|
||||||
this.dialog
|
|
||||||
.open(PaymentDialogComponent, {
|
|
||||||
width: '720px',
|
|
||||||
maxWidth: '95vw',
|
|
||||||
data: {
|
|
||||||
accountId: row.id,
|
|
||||||
accountBalance: row.maturityAmount,
|
|
||||||
maturityDate: row.maturityDate,
|
|
||||||
dashboardRenewal: { row }
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.afterClosed()
|
|
||||||
.subscribe((result: { confirm: boolean } | undefined) => {
|
|
||||||
this.renewing = null;
|
|
||||||
if (result?.confirm) {
|
|
||||||
this.load();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Presentation ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
stageClass(stage: FdMaturityStage): string {
|
|
||||||
return `fdd-stage fdd-stage--${FD_MATURITY_STAGES[stage].tone}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** "12 days overdue" / "in 5 days" / "Matures today", as a branch officer would say it. */
|
|
||||||
maturityText(row: FdMaturityRow): string {
|
|
||||||
if (row.daysOverdue > 0) {
|
|
||||||
return `${row.daysOverdue} day${row.daysOverdue === 1 ? '' : 's'} overdue`;
|
|
||||||
}
|
|
||||||
if (row.daysRemaining === 0) {
|
|
||||||
return 'Matures today';
|
|
||||||
}
|
|
||||||
return `In ${row.daysRemaining} day${row.daysRemaining === 1 ? '' : 's'}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** "Renewable — 6 of 9 grace days left" / "Grace period (9d) passed 3 days ago" / "Renewable" (no
|
|
||||||
* grace configured) -- the sub-label under a matured, renewable row's stage badge. */
|
|
||||||
graceStatusText(row: FdMaturityRow): string {
|
|
||||||
if (!row.renewalGracePeriodDays) {
|
|
||||||
return 'Renewable';
|
|
||||||
}
|
|
||||||
const grace = row.renewalGracePeriodDays;
|
|
||||||
if (row.withinGraceWindow) {
|
|
||||||
const daysLeft = grace - row.daysOverdue;
|
|
||||||
return `Renewable — ${daysLeft} of ${grace} grace day${grace === 1 ? '' : 's'} left`;
|
|
||||||
}
|
|
||||||
const daysPastGrace = row.daysOverdue - grace;
|
|
||||||
return `Grace period (${grace}d) passed ${daysPastGrace} day${daysPastGrace === 1 ? '' : 's'} ago`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,240 +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/.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Where an active Fixed Deposit sits relative to its own maturity date.
|
|
||||||
*
|
|
||||||
* MATURED accounts are the ones staff actually need to act on -- the FD has run its course but is
|
|
||||||
* still ACTIVE in the core, meaning nobody has applied the maturity instruction yet (or the
|
|
||||||
* scheduled job hasn't reached it). DUE_SOON and UPCOMING exist so a branch can see what's coming
|
|
||||||
* before it lands, not just after.
|
|
||||||
*/
|
|
||||||
export type FdMaturityStage = 'MATURED' | 'DUE_SOON' | 'UPCOMING' | 'ON_TRACK';
|
|
||||||
|
|
||||||
/** Day-count thresholds, measured forward from today toward the maturity date. */
|
|
||||||
export const FD_MATURITY_POLICY = {
|
|
||||||
/** Maturing within this many days counts as DUE_SOON. */
|
|
||||||
dueSoonDays: 7,
|
|
||||||
/** Maturing within this many days (and not already DUE_SOON) counts as UPCOMING. */
|
|
||||||
upcomingDays: 30
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface FdMaturityStageMeta {
|
|
||||||
stage: FdMaturityStage;
|
|
||||||
label: string;
|
|
||||||
/** Colour key the template maps to a badge/tile class. */
|
|
||||||
tone: 'info' | 'warn' | 'danger' | 'neutral';
|
|
||||||
summary: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const FD_MATURITY_STAGES: Record<FdMaturityStage, FdMaturityStageMeta> = {
|
|
||||||
MATURED: {
|
|
||||||
stage: 'MATURED',
|
|
||||||
label: 'Matured',
|
|
||||||
tone: 'danger',
|
|
||||||
summary: 'Past maturity date and still active -- the maturity instruction has not been applied yet.'
|
|
||||||
},
|
|
||||||
DUE_SOON: {
|
|
||||||
stage: 'DUE_SOON',
|
|
||||||
label: `Maturing ≤ ${FD_MATURITY_POLICY.dueSoonDays} Days`,
|
|
||||||
tone: 'warn',
|
|
||||||
summary: 'Matures within the week.'
|
|
||||||
},
|
|
||||||
UPCOMING: {
|
|
||||||
stage: 'UPCOMING',
|
|
||||||
label: `Maturing ≤ ${FD_MATURITY_POLICY.upcomingDays} Days`,
|
|
||||||
tone: 'info',
|
|
||||||
summary: 'Matures within the month.'
|
|
||||||
},
|
|
||||||
ON_TRACK: {
|
|
||||||
stage: 'ON_TRACK',
|
|
||||||
label: 'On Track',
|
|
||||||
tone: 'neutral',
|
|
||||||
summary: 'Maturity date is further out.'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/** FD types the manual-renewal action applies to. SPECIAL is architecturally forced to Transfer to
|
|
||||||
* Savings server-side and can never be Reinvest-configured, so it's never renewable here. */
|
|
||||||
const RENEWABLE_FD_TYPES = [
|
|
||||||
'SINGLE',
|
|
||||||
'DOUBLE'
|
|
||||||
];
|
|
||||||
|
|
||||||
/** DepositAccountOnClosureType labels, for the "what will happen automatically" sub-label shown on a
|
|
||||||
* matured row that already carries an explicit instruction from account-opening. */
|
|
||||||
const ON_CLOSURE_TYPE_LABELS: Record<number, string> = {
|
|
||||||
200: 'Transfer to Savings',
|
|
||||||
300: 'Reinvest Principal + Interest',
|
|
||||||
400: 'Reinvest Principal Only'
|
|
||||||
};
|
|
||||||
|
|
||||||
/** One active Fixed Deposit account, positioned on the maturity ladder. */
|
|
||||||
export interface FdMaturityRow {
|
|
||||||
id: number;
|
|
||||||
accountNo: string;
|
|
||||||
clientId: number | null;
|
|
||||||
clientName: string;
|
|
||||||
productName: string;
|
|
||||||
maturityDate: string;
|
|
||||||
depositAmount: number | null;
|
|
||||||
maturityAmount: number | null;
|
|
||||||
/** Negative once the maturity date has passed. */
|
|
||||||
daysRemaining: number;
|
|
||||||
/** 0 while not yet matured; how many days past maturity otherwise. */
|
|
||||||
daysOverdue: number;
|
|
||||||
stage: FdMaturityStage;
|
|
||||||
meta: FdMaturityStageMeta;
|
|
||||||
/** Null/0 -- no buffer configured; renewing after the maturity date itself pays gap interest. */
|
|
||||||
renewalGracePeriodDays: number | null;
|
|
||||||
linkedAccountId: number | null;
|
|
||||||
/** The account's own stored DepositAccountOnClosureType, raw -- null when none was chosen at
|
|
||||||
* account-opening. On a renewable row that DOES carry one (grace-period case: the account picked
|
|
||||||
* Reinvest, and is only waiting because the product also has a grace window), the Renew dialog
|
|
||||||
* pre-selects this rather than presenting a neutral, undecided choice -- the maker already decided
|
|
||||||
* what this FD should do, grace only changed when that gets carried out. */
|
|
||||||
onClosureType: number | null;
|
|
||||||
/** True only when the account was opened with NO maturity instruction at all (the officer left
|
|
||||||
* Maturity Instructions blank at account-opening) -- that's the one case nothing else will ever
|
|
||||||
* act on this FD, so the Dashboard's manual Renew action is meaningful. An account that WAS given
|
|
||||||
* an explicit instruction (Transfer to Savings or either Reinvest option) auto-processes on its
|
|
||||||
* own via the nightly job, exactly as it always has -- the Dashboard only reports on those, it
|
|
||||||
* doesn't also offer to renew them. */
|
|
||||||
isReinvestEligible: boolean;
|
|
||||||
/** True once matured AND eligible -- the only case where nothing has auto-processed it, so a
|
|
||||||
* manual action from the Dashboard is meaningful. */
|
|
||||||
renewable: boolean;
|
|
||||||
/** Only meaningful when renewable -- true if still inside the grace window (backdated renewal,
|
|
||||||
* no gap interest), false if the window has passed (renews from today, gap interest applies). */
|
|
||||||
withinGraceWindow: boolean;
|
|
||||||
/** Set on a matured row that is NOT renewable because it already carries an explicit instruction --
|
|
||||||
* what it was configured to do at account-opening, shown so staff can see at a glance what tonight's
|
|
||||||
* job will do without opening the account. Null for a renewable row (nothing decided yet) or a row
|
|
||||||
* that isn't matured. */
|
|
||||||
configuredInstructionLabel: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Days between two ISO dates, positive when `later` is after `earlier`. */
|
|
||||||
function daysBetween(earlier: string, later: string): number {
|
|
||||||
const a = new Date(`${earlier}T00:00:00Z`).getTime();
|
|
||||||
const b = new Date(`${later}T00:00:00Z`).getTime();
|
|
||||||
return Math.round((b - a) / 86400000);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Which rung of the ladder an account is on, from how many days remain until maturity. */
|
|
||||||
export function maturityStage(daysRemaining: number): FdMaturityStage {
|
|
||||||
if (daysRemaining <= 0) {
|
|
||||||
return 'MATURED';
|
|
||||||
}
|
|
||||||
if (daysRemaining <= FD_MATURITY_POLICY.dueSoonDays) {
|
|
||||||
return 'DUE_SOON';
|
|
||||||
}
|
|
||||||
if (daysRemaining <= FD_MATURITY_POLICY.upcomingDays) {
|
|
||||||
return 'UPCOMING';
|
|
||||||
}
|
|
||||||
return 'ON_TRACK';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Builds one dashboard row from a raw `/fixeddepositaccounts` list item and today's ISO date. */
|
|
||||||
export function buildMaturityRow(
|
|
||||||
fd: {
|
|
||||||
id: number;
|
|
||||||
accountNo: string;
|
|
||||||
clientId?: number;
|
|
||||||
clientName?: string;
|
|
||||||
savingsProductName?: string;
|
|
||||||
productName?: string;
|
|
||||||
fdType?: string;
|
|
||||||
maturityDate: string;
|
|
||||||
depositAmount?: number;
|
|
||||||
maturityAmount?: number;
|
|
||||||
onClosureType?: number;
|
|
||||||
linkedAccountId?: number;
|
|
||||||
renewalGracePeriodDays?: number;
|
|
||||||
},
|
|
||||||
today: string
|
|
||||||
): FdMaturityRow | null {
|
|
||||||
if (!fd.maturityDate) {
|
|
||||||
// No maturity date means the account isn't fully assembled yet (e.g. a chart not applied) --
|
|
||||||
// nothing sound to bucket it by, so it's left off the worklist rather than shown wrong.
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const daysRemaining = daysBetween(today, fd.maturityDate);
|
|
||||||
const stage = maturityStage(daysRemaining);
|
|
||||||
// No grace configured reads as a 0-day buffer, not "not renewable" -- it only shrinks the backdate
|
|
||||||
// window, matching the backend's default (renewMaturedFixedDeposit treats a missing value as 0).
|
|
||||||
const gracePeriodDays = fd.renewalGracePeriodDays ?? 0;
|
|
||||||
// Mirrors updateMaturityDetails()'s own "will I auto-process this?" check exactly, inverted: the
|
|
||||||
// nightly job leaves an account alone (never touches it) only when it was opened with NO maturity
|
|
||||||
// instruction at all -- that's the one case the Dashboard's Renew action exists for. An account
|
|
||||||
// opened WITH an explicit instruction auto-processes tonight regardless of the product's renewal
|
|
||||||
// grace period (the grace window never delays it), so it's never renewable here either way.
|
|
||||||
const isReinvestEligible =
|
|
||||||
!!fd.fdType && RENEWABLE_FD_TYPES.includes(fd.fdType as string) && fd.onClosureType == null;
|
|
||||||
const renewable = stage === 'MATURED' && isReinvestEligible;
|
|
||||||
const withinGraceWindow = renewable && daysOverdueFor(daysRemaining) <= gracePeriodDays;
|
|
||||||
const configuredInstructionLabel =
|
|
||||||
stage === 'MATURED' && !renewable && fd.onClosureType != null
|
|
||||||
? (ON_CLOSURE_TYPE_LABELS[fd.onClosureType] ?? null)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: fd.id,
|
|
||||||
accountNo: fd.accountNo || '',
|
|
||||||
clientId: fd.clientId ?? null,
|
|
||||||
clientName: fd.clientName || '—',
|
|
||||||
productName: fd.productName || fd.savingsProductName || '—',
|
|
||||||
maturityDate: fd.maturityDate,
|
|
||||||
depositAmount: fd.depositAmount ?? null,
|
|
||||||
maturityAmount: fd.maturityAmount ?? null,
|
|
||||||
daysRemaining,
|
|
||||||
daysOverdue: daysOverdueFor(daysRemaining),
|
|
||||||
stage,
|
|
||||||
meta: FD_MATURITY_STAGES[stage],
|
|
||||||
renewalGracePeriodDays: gracePeriodDays,
|
|
||||||
linkedAccountId: fd.linkedAccountId ?? null,
|
|
||||||
onClosureType: fd.onClosureType ?? null,
|
|
||||||
isReinvestEligible,
|
|
||||||
renewable,
|
|
||||||
withinGraceWindow,
|
|
||||||
configuredInstructionLabel
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function daysOverdueFor(daysRemaining: number): number {
|
|
||||||
return daysRemaining < 0 ? -daysRemaining : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FdMaturitySummary {
|
|
||||||
total: number;
|
|
||||||
matured: number;
|
|
||||||
dueSoon: number;
|
|
||||||
upcoming: number;
|
|
||||||
onTrack: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function maturitySummary(rows: FdMaturityRow[]): FdMaturitySummary {
|
|
||||||
return {
|
|
||||||
total: rows.length,
|
|
||||||
matured: rows.filter((r) => r.stage === 'MATURED').length,
|
|
||||||
dueSoon: rows.filter((r) => r.stage === 'DUE_SOON').length,
|
|
||||||
upcoming: rows.filter((r) => r.stage === 'UPCOMING').length,
|
|
||||||
onTrack: rows.filter((r) => r.stage === 'ON_TRACK').length
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Sorts the worklist so the most overdue, then the soonest-to-mature, rows come first. */
|
|
||||||
export function sortMaturityRows(rows: FdMaturityRow[]): FdMaturityRow[] {
|
|
||||||
const rank: Record<FdMaturityStage, number> = {
|
|
||||||
MATURED: 0,
|
|
||||||
DUE_SOON: 1,
|
|
||||||
UPCOMING: 2,
|
|
||||||
ON_TRACK: 3
|
|
||||||
};
|
|
||||||
return [...rows].sort((a, b) => rank[a.stage] - rank[b.stage] || a.daysRemaining - b.daysRemaining);
|
|
||||||
}
|
|
||||||
@@ -1,22 +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 { NgModule } from '@angular/core';
|
|
||||||
|
|
||||||
import { SharedModule } from '../shared/shared.module';
|
|
||||||
import { FdDashboardRoutingModule } from './fd-dashboard-routing.module';
|
|
||||||
import { FdDashboardComponent } from './fd-dashboard.component';
|
|
||||||
|
|
||||||
@NgModule({
|
|
||||||
imports: [
|
|
||||||
SharedModule,
|
|
||||||
FdDashboardRoutingModule,
|
|
||||||
FdDashboardComponent
|
|
||||||
]
|
|
||||||
})
|
|
||||||
export class FdDashboardModule {}
|
|
||||||
@@ -167,9 +167,7 @@
|
|||||||
</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">
|
<div class="dc-meta">Due: <strong>{{ ctrDueDateLabel }}</strong></div>
|
||||||
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">
|
||||||
@@ -222,7 +220,9 @@
|
|||||||
@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"><fa-icon icon="exclamation-triangle"></fa-icon> {{ ctrLinesError }}</div>
|
<div class="fiu-note fiu-note--warn">
|
||||||
|
<fa-icon icon="exclamation-triangle"></fa-icon> {{ ctrLinesError }}
|
||||||
|
</div>
|
||||||
} @else if (!ctrTxns.length) {
|
} @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,17 +432,87 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
<div class="fiu-section">
|
<div class="fiu-section">
|
||||||
<!-- The triage queue that used to head this tab has gone. It listed the same open alerts as the AML Rules
|
<!-- LIVE: suspicious AML alerts awaiting AML Officer review (Structuring, PEP, etc.) -->
|
||||||
Master, and listed them worse: without the severity bands, the per-rule findings or the batch decision that
|
<div class="fiu-form-meta">
|
||||||
screen carries. Two places to work the same queue is how an alert gets cleared in one and escalated in the
|
<div class="fiu-form-title">
|
||||||
other. This tab keeps what is about the filing itself - what was cleared, and what became an STR case. -->
|
<h3>Live AML Alerts (Awaiting Triage)</h3>
|
||||||
<div class="fiu-note">
|
<p>Flagged by the monitoring engine for suspicious behavioral patterns — triage and whitelist or escalate</p>
|
||||||
<fa-icon icon="info-circle"></fa-icon>
|
|
||||||
<span>
|
|
||||||
Alerts are triaged in <strong>RBI › AML Rules Master › Flagged Transactions</strong>. What an
|
|
||||||
officer escalates there arrives here as an STR case.
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Whitelisted is deliberately absent: cleared alerts are not STR business and have
|
||||||
|
their own register below. -->
|
||||||
|
<select class="fiu-inp" [(ngModel)]="amlAlertStatusFilter">
|
||||||
|
<option value="">All statuses</option>
|
||||||
|
<option value="RAISED">Raised</option>
|
||||||
|
<option value="UNDER_REVIEW">Under Review</option>
|
||||||
|
<option value="ESCALATED">Escalated</option>
|
||||||
|
</select>
|
||||||
|
</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. -->
|
||||||
@@ -520,7 +590,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@for (sc of liveStrCases; track sc.id) {
|
@for (sc of amlStrCases; 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>
|
||||||
@@ -537,50 +607,13 @@
|
|||||||
</tr>
|
</tr>
|
||||||
} @empty {
|
} @empty {
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="8" class="fiu-empty">No STR cases outstanding.</td>
|
<td colspan="8" class="fiu-empty">No STR cases yet.</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,7 +7,6 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
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';
|
||||||
@@ -26,7 +25,8 @@ 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,7 +130,6 @@ 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';
|
||||||
|
|
||||||
@@ -155,16 +154,7 @@ 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.
|
||||||
*
|
*
|
||||||
@@ -220,29 +210,13 @@ 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 = '';
|
||||||
@@ -424,14 +398,6 @@ 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.
|
||||||
@@ -649,9 +615,10 @@ export class FiuComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Always fetches every status. This tab shows what was cleared and what became an STR case, which are two views of
|
* Always fetches every status. The triage table and the cleared register are two views of the
|
||||||
* the same set, and the CTR tab reads the list as well — so narrowing it server-side for one view would empty
|
* same set, so filtering server-side would empty one whenever the other was narrowed — and the
|
||||||
* another.
|
* CTR tab reads this list too, which a status filter chosen on the STR tab should not touch.
|
||||||
|
* amlAlertStatusFilter is applied client-side in {@link amlSuspiciousAlerts} instead.
|
||||||
*/
|
*/
|
||||||
loadAmlAlerts(): void {
|
loadAmlAlerts(): void {
|
||||||
this.amlService.getAlerts().subscribe((data) => (this.amlAlerts = data || []));
|
this.amlService.getAlerts().subscribe((data) => (this.amlAlerts = data || []));
|
||||||
@@ -694,8 +661,7 @@ export class FiuComponent implements OnInit {
|
|||||||
this.ctrLinesLoading = false;
|
this.ctrLinesLoading = false;
|
||||||
this.ctrTxns = [];
|
this.ctrTxns = [];
|
||||||
this.ctrLinesError =
|
this.ctrLinesError =
|
||||||
err?.error?.defaultUserMessage ||
|
err?.error?.defaultUserMessage || 'Could not load the generated CTR lines. Generate a report for the month first.';
|
||||||
'Could not load the generated CTR lines. Generate a report for the month first.';
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -795,6 +761,48 @@ 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');
|
||||||
@@ -982,16 +990,9 @@ 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.amlStrCases.filter((c) => c?.status !== 'FILED' && c?.status !== 'CONCLUDED_CLEAR').length;
|
return this.liveStrOpen + this.liveStrAwaitingFiling;
|
||||||
}
|
}
|
||||||
get totalCustomers(): number {
|
get totalCustomers(): number {
|
||||||
return this.kc1.length + this.kc2.length;
|
return this.kc1.length + this.kc2.length;
|
||||||
@@ -1020,6 +1021,12 @@ 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');
|
||||||
@@ -1682,9 +1689,7 @@ 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(
|
xml.push(`<STR xmlns="http://fiuind.gov.in/finnet2/str" reportingMonth="${this.escapeXml(this.reportingMonthLabel)}">`);
|
||||||
`<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)}" />`);
|
||||||
|
|||||||
+10
-6
@@ -576,11 +576,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ───────── Print — only the page, no toolbar / dialog chrome ─────────
|
/* ───────── Print — only the page, no toolbar / dialog chrome ─────────
|
||||||
The document-level clip (`html, body { overflow: hidden }`) is lifted globally in main.scss's
|
This dialog is opened with `maxHeight: '92vh'`, which Angular Material carries onto the
|
||||||
print block; what remains here is this dialog's own clip — it opens with `maxHeight: '92vh'`,
|
overlay pane / .mat-mdc-dialog-container as an inline max-height + overflow:auto clip.
|
||||||
which Angular Material carries onto the overlay pane / .mat-mdc-dialog-container as an inline
|
`position: absolute` still lives inside that clip and gets cut off — only the on-screen
|
||||||
max-height plus overflow:auto. Freeing those lets the whole certificate flow onto the page in
|
scroll position prints. `position: fixed` escapes it: a fixed element's containing block is
|
||||||
normal static order (a fixed/absolute hack would pin it to the first page instead). */
|
the viewport itself, not the clipped ancestor (the classic "fixed child ignores an
|
||||||
|
overflow:hidden parent" behaviour), as long as nothing between here and the viewport sets a
|
||||||
|
transform/filter — the CDK overlay chrome doesn't. */
|
||||||
@media print {
|
@media print {
|
||||||
body > :not(.cdk-overlay-container) {
|
body > :not(.cdk-overlay-container) {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
@@ -609,7 +611,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.cpd-root {
|
.cpd-root {
|
||||||
position: static;
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
height: auto !important;
|
height: auto !important;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
|
|||||||
-29
@@ -34,35 +34,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Renewal -->
|
|
||||||
<div class="fdpt-section">
|
|
||||||
<div class="fdpt-section__header">
|
|
||||||
<span class="fdpt-section__badge"><i class="fas fa-sync-alt"></i></span>
|
|
||||||
<span>Renewal</span>
|
|
||||||
</div>
|
|
||||||
<div class="fdpt-body">
|
|
||||||
<div class="fdpt-banner">
|
|
||||||
<span class="fdpt-banner__icon">ℹ️</span>
|
|
||||||
<span>
|
|
||||||
Leave blank for an FD on this product to keep renewing instantly, the same night it matures, as before. Set a
|
|
||||||
number of days and a matured "auto renew" FD instead waits on the FD Dashboard for that many days for staff to
|
|
||||||
renew it manually -- backdated to the original maturity date within that window, or from the day it is
|
|
||||||
actually renewed (with gap-period interest at the linked Savings account's rate) once the window has passed.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="fdpt-grid fdpt-grid--three">
|
|
||||||
<mat-form-field appearance="outline" class="fdpt-field">
|
|
||||||
<mat-label>Renewal Grace Period</mat-label>
|
|
||||||
<input type="number" matInput formControlName="renewalGracePeriodDays" min="0" />
|
|
||||||
<mat-hint>Days</mat-hint>
|
|
||||||
@if (fixedDepositProductTermsForm.get('renewalGracePeriodDays').hasError('min')) {
|
|
||||||
<mat-error>Renewal Grace Period must be zero or greater</mat-error>
|
|
||||||
}
|
|
||||||
</mat-form-field>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Interest Rate & Posting Terms -->
|
<!-- Interest Rate & Posting Terms -->
|
||||||
<div class="fdpt-section">
|
<div class="fdpt-section">
|
||||||
<div class="fdpt-section__header">
|
<div class="fdpt-section__header">
|
||||||
|
|||||||
+3
-14
@@ -74,6 +74,7 @@ export class FixedDepositProductTermsStepComponent implements OnInit, OnChanges
|
|||||||
this.periodFrequencyData = this.fixedDepositProductsTemplate.periodFrequencyTypeOptions || [];
|
this.periodFrequencyData = this.fixedDepositProductsTemplate.periodFrequencyTypeOptions || [];
|
||||||
|
|
||||||
const isEditing = !!this.fixedDepositProductsTemplate.id;
|
const isEditing = !!this.fixedDepositProductsTemplate.id;
|
||||||
|
const defaultPeriodTypeId = this.periodFrequencyData[0]?.id ?? null;
|
||||||
const defaultAveragePeriod = this.averagePeriodData[0]?.value || 'Monthly';
|
const defaultAveragePeriod = this.averagePeriodData[0]?.value || 'Monthly';
|
||||||
const savedEnumTypeId =
|
const savedEnumTypeId =
|
||||||
this.fixedDepositProductsTemplate.interestCalculationType?.id || this.interestCalculationTypeData?.[0]?.id || 1;
|
this.fixedDepositProductsTemplate.interestCalculationType?.id || this.interestCalculationTypeData?.[0]?.id || 1;
|
||||||
@@ -84,17 +85,12 @@ export class FixedDepositProductTermsStepComponent implements OnInit, OnChanges
|
|||||||
: (this.interestCalculationTypeOptionData?.[0]?.id ?? null);
|
: (this.interestCalculationTypeOptionData?.[0]?.id ?? null);
|
||||||
|
|
||||||
this.fixedDepositProductTermsForm.patchValue({
|
this.fixedDepositProductTermsForm.patchValue({
|
||||||
// Hidden, unedited-by-anyone fields -- left blank (like their max-/in-multiples-of- siblings
|
minDepositTerm: this.fixedDepositProductsTemplate.minDepositTerm ?? 1,
|
||||||
// already correctly are) rather than forced to a real default, or every single product edit
|
minDepositTermTypeId: this.fixedDepositProductsTemplate.minDepositTermType?.id ?? defaultPeriodTypeId,
|
||||||
// submitted a "real" value against a product that had none, and a checker's "Proposed changes"
|
|
||||||
// review showed these two as "changed" on every unrelated edit, never anything a maker touched.
|
|
||||||
minDepositTerm: this.fixedDepositProductsTemplate.minDepositTerm ?? '',
|
|
||||||
minDepositTermTypeId: this.fixedDepositProductsTemplate.minDepositTermType?.id ?? '',
|
|
||||||
maxDepositTerm: this.fixedDepositProductsTemplate.maxDepositTerm ?? '',
|
maxDepositTerm: this.fixedDepositProductsTemplate.maxDepositTerm ?? '',
|
||||||
maxDepositTermTypeId: this.fixedDepositProductsTemplate.maxDepositTermType?.id ?? '',
|
maxDepositTermTypeId: this.fixedDepositProductsTemplate.maxDepositTermType?.id ?? '',
|
||||||
inMultiplesOfDepositTerm: this.fixedDepositProductsTemplate.inMultiplesOfDepositTerm ?? '',
|
inMultiplesOfDepositTerm: this.fixedDepositProductsTemplate.inMultiplesOfDepositTerm ?? '',
|
||||||
inMultiplesOfDepositTermTypeId: this.fixedDepositProductsTemplate.inMultiplesOfDepositTermType?.id ?? '',
|
inMultiplesOfDepositTermTypeId: this.fixedDepositProductsTemplate.inMultiplesOfDepositTermType?.id ?? '',
|
||||||
renewalGracePeriodDays: this.fixedDepositProductsTemplate.renewalGracePeriodDays ?? '',
|
|
||||||
minDepositAmount: this.fixedDepositProductsTemplate.minDepositAmount,
|
minDepositAmount: this.fixedDepositProductsTemplate.minDepositAmount,
|
||||||
maxDepositAmount: this.fixedDepositProductsTemplate.maxDepositAmount,
|
maxDepositAmount: this.fixedDepositProductsTemplate.maxDepositAmount,
|
||||||
nominalAnnualInterestRate: this.fixedDepositProductsTemplate.nominalAnnualInterestRate ?? 0,
|
nominalAnnualInterestRate: this.fixedDepositProductsTemplate.nominalAnnualInterestRate ?? 0,
|
||||||
@@ -175,13 +171,6 @@ export class FixedDepositProductTermsStepComponent implements OnInit, OnChanges
|
|||||||
maxDepositTermTypeId: [''],
|
maxDepositTermTypeId: [''],
|
||||||
inMultiplesOfDepositTerm: [''],
|
inMultiplesOfDepositTerm: [''],
|
||||||
inMultiplesOfDepositTermTypeId: [''],
|
inMultiplesOfDepositTermTypeId: [''],
|
||||||
// Days after maturity a depositor can still walk in and have their FD renewed backdated to the
|
|
||||||
// original maturity date, at the currently active rate -- blank/0 keeps today's instant,
|
|
||||||
// same-night auto-renewal (see FD Dashboard for the manual renewal this enables).
|
|
||||||
renewalGracePeriodDays: [
|
|
||||||
'',
|
|
||||||
Validators.min(0)
|
|
||||||
],
|
|
||||||
minDepositAmount: [
|
minDepositAmount: [
|
||||||
'',
|
'',
|
||||||
Validators.min(0)
|
Validators.min(0)
|
||||||
|
|||||||
+5
-63
@@ -21,31 +21,14 @@
|
|||||||
<!-- 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
|
<fa-icon icon="check-circle" class="dd-result-ic"></fa-icon>
|
||||||
[icon]="hasSkipped ? 'exclamation-triangle' : 'check-circle'"
|
|
||||||
class="dd-result-ic"
|
|
||||||
[class.dd-result-ic--warn]="hasSkipped"
|
|
||||||
></fa-icon>
|
|
||||||
<div>
|
<div>
|
||||||
<h3>
|
<h3>Dividend declared for {{ result.financialYear }}</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>
|
||||||
@@ -145,22 +128,7 @@
|
|||||||
<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 (exceedsMaximumRate) {
|
@if (isUnderfunded) {
|
||||||
<!-- 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.
|
||||||
@@ -178,42 +146,16 @@
|
|||||||
{{ 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>
|
<span>{{ product.productName }}</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>
|
<span>Total to declare</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,14 +148,6 @@ 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;
|
||||||
@@ -202,11 +194,6 @@ 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 {
|
||||||
|
|||||||
+4
-47
@@ -73,17 +73,6 @@ 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: [
|
||||||
@@ -107,13 +96,9 @@ 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 year in progress is listed but cannot be declared — the server rejects it, and the screen
|
* The current year is included because a society may declare an interim dividend part-way through,
|
||||||
* says so as soon as it is picked. It stays on the list so an officer looking for it finds it with
|
* and five back covers a declaration delayed by an AGM that ran late. Built from the business date
|
||||||
* the reason attached, rather than wondering why the year they are in is missing. Five back covers
|
* rather than the browser's clock, so the list matches the year the branch is operating in.
|
||||||
* 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();
|
||||||
@@ -186,36 +171,8 @@ 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 (
|
return this.declareForm.valid && !this.submitting && this.eligibleProducts.length > 0 && !this.isUnderfunded;
|
||||||
this.declareForm.valid &&
|
|
||||||
!this.submitting &&
|
|
||||||
this.eligibleProducts.length > 0 &&
|
|
||||||
!this.isUnderfunded &&
|
|
||||||
!this.isPeriodNotFinished &&
|
|
||||||
!this.exceedsMaximumRate
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
submit(): void {
|
submit(): void {
|
||||||
|
|||||||
+34
-1
@@ -70,9 +70,16 @@ 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();
|
||||||
@@ -80,15 +87,18 @@ 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 || ''
|
||||||
@@ -99,6 +109,10 @@ 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
|
||||||
@@ -111,6 +125,7 @@ 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([])
|
||||||
@@ -119,6 +134,24 @@ 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',
|
||||||
@@ -206,7 +239,7 @@ export class ShareProductAccountingStepComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get shareProductAccounting() {
|
get shareProductAccounting() {
|
||||||
const { liabilitiesOtherGlMappings, incomeOtherGlMappings, equityOtherGlMappings, ...rest } =
|
const { assetsOtherGlMappings, liabilitiesOtherGlMappings, incomeOtherGlMappings, equityOtherGlMappings, ...rest } =
|
||||||
this.shareProductAccountingForm.value;
|
this.shareProductAccountingForm.value;
|
||||||
return rest;
|
return rest;
|
||||||
}
|
}
|
||||||
|
|||||||
+8
@@ -213,6 +213,14 @@
|
|||||||
</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,6 +208,16 @@
|
|||||||
|
|
||||||
@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
@@ -1,311 +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, 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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+15
-384
@@ -36,17 +36,6 @@
|
|||||||
<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>
|
||||||
|
|
||||||
@@ -55,52 +44,6 @@
|
|||||||
<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>
|
||||||
@@ -117,143 +60,23 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Narrow to the rules firing most often. A rule tripping repeatedly is either a real pattern in the book or a rule
|
<!-- Severity mix of what the table is showing -->
|
||||||
set too tight and filling the queue with noise — either way it is what an officer reads first, and the long tail
|
|
||||||
below it can wait. Ranked over the period and status already chosen. -->
|
|
||||||
<div class="status-filters">
|
|
||||||
<span class="status-filters-label">Top rules</span>
|
|
||||||
@for (n of topRuleOptions; track n) {
|
|
||||||
<button type="button" class="status-chip" [class.active]="topRules === n" (click)="setTopRules(n)">
|
|
||||||
{{ n === null ? 'All rules' : 'Top ' + n }}
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
@if (topRules !== null) {
|
|
||||||
<span class="period-resolved">{{ distinctRuleCount }} rule{{ distinctRuleCount === 1 ? '' : 's' }} in view</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 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">
|
<div class="risk-summary">
|
||||||
<button
|
<div *ngFor="let tier of riskSummary" class="risk-tile" [ngClass]="tier.cls">
|
||||||
*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-n">{{ tier.count }}</span>
|
||||||
<span class="risk-tile-l">{{ tier.label }}</span>
|
<span class="risk-tile-l">{{ tier.label }}</span>
|
||||||
</button>
|
</div>
|
||||||
<button
|
<div *ngIf="unclassifiedCount > 0" class="risk-tile risk-none" matTooltip="Raised by a user, not by a catalog rule">
|
||||||
*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-n">{{ unclassifiedCount }}</span>
|
||||||
<span class="risk-tile-l">Unclassified</span>
|
<span class="risk-tile-l">Unclassified</span>
|
||||||
</button>
|
</div>
|
||||||
<button
|
<div class="risk-tile risk-total">
|
||||||
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-n">{{ summaryTotal }}</span>
|
||||||
<span class="risk-tile-l">Total Flagged</span>
|
<span class="risk-tile-l">Total Flagged</span>
|
||||||
</button>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Appears only once something is ticked, so the table does not carry a permanently empty bar. -->
|
<table mat-table [dataSource]="alerts" class="flagged-table mat-elevation-z1">
|
||||||
@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>
|
||||||
@@ -290,162 +113,6 @@
|
|||||||
<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>
|
||||||
@@ -465,7 +132,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" (click)="$event.stopPropagation()">
|
<td mat-cell *matCellDef="let alert" class="col-center actions-cell">
|
||||||
<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. -->
|
||||||
@@ -491,19 +158,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
<ng-template #reviewed>
|
<ng-template #reviewed>
|
||||||
<!-- Held by somebody else: not a verdict, so it does not read like one. The endpoint refuses a disposition
|
|
||||||
from anyone but the officer who has it, and this says who to ask. -->
|
|
||||||
@if (heldByAnother(alert)) {
|
|
||||||
<div class="reviewed-cell">
|
|
||||||
<mat-icon
|
|
||||||
class="held-icon"
|
|
||||||
[matTooltip]="'Open with ' + heldBy(alert) + ' — they decide it or hand it back'"
|
|
||||||
>
|
|
||||||
lock
|
|
||||||
</mat-icon>
|
|
||||||
<span class="held-by">with {{ heldBy(alert) }}</span>
|
|
||||||
</div>
|
|
||||||
} @else {
|
|
||||||
<div class="reviewed-cell">
|
<div class="reviewed-cell">
|
||||||
<span class="reviewed-by">
|
<span class="reviewed-by">
|
||||||
{{ alert.reviewedByUsername ? 'by ' + alert.reviewedByUsername : 'Reviewed' }}
|
{{ alert.reviewedByUsername ? 'by ' + alert.reviewedByUsername : 'Reviewed' }}
|
||||||
@@ -517,47 +171,24 @@
|
|||||||
>
|
>
|
||||||
description
|
description
|
||||||
</mat-icon>
|
</mat-icon>
|
||||||
<span
|
<span *ngIf="alert.status === 'ESCALATED'" class="str-routed" matTooltip="An STR case is open for this alert">
|
||||||
*ngIf="alert.status === 'ESCALATED'"
|
|
||||||
class="str-routed"
|
|
||||||
matTooltip="An STR case is open for this alert"
|
|
||||||
>
|
|
||||||
→ STR
|
→ STR
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
||||||
<!-- Clicking anywhere on the row opens its detail. The checkbox and the action buttons stop the
|
<tr mat-row *matRowDef="let row; columns: displayedColumns" class="alert-row" [class.row-new]="isNew(row)"></tr>
|
||||||
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>
|
||||||
|
|
||||||
<!-- pageSize stated explicitly: without it the paginator starts at 0, and anything that touches the
|
<mat-paginator [pageSizeOptions]="[10, 25, 50, 100]" showFirstLastButtons class="flagged-paginator"></mat-paginator>
|
||||||
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 || activeSeverity !== null ? 'filter_alt_off' : 'verified_user' }}</mat-icon>
|
<mat-icon>{{ activeStatus ? 'filter_alt_off' : 'verified_user' }}</mat-icon>
|
||||||
<span *ngIf="!activeStatus && activeSeverity === null"
|
<span *ngIf="!activeStatus"
|
||||||
>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 || activeSeverity !== null">No alerts match the filters in force.</span>
|
<span *ngIf="activeStatus">No alerts in this status.</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+1
-475
@@ -196,43 +196,15 @@
|
|||||||
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 {
|
||||||
@@ -321,22 +293,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.alert-row {
|
.alert-row:hover {
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: #f8fafc;
|
background: #f8fafc;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/* The open row and its pane read as one block. */
|
|
||||||
.alert-row.row-expanded {
|
|
||||||
background: #f8fafc;
|
|
||||||
|
|
||||||
td {
|
|
||||||
border-bottom-color: transparent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* A newly arrived alert stays tinted until acknowledged. */
|
/* A newly arrived alert stays tinted until acknowledged. */
|
||||||
.row-new {
|
.row-new {
|
||||||
@@ -421,285 +380,6 @@
|
|||||||
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;
|
||||||
}
|
}
|
||||||
@@ -857,157 +537,3 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
|
|||||||
+25
-806
@@ -7,7 +7,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/** Angular Imports */
|
/** Angular Imports */
|
||||||
import { Component, OnInit, AfterViewInit, OnDestroy, inject, ViewChild } from '@angular/core';
|
import { Component, OnInit, 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,23 +15,15 @@ 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.
|
||||||
@@ -49,7 +41,6 @@ type PeriodMode = 'all' | 'day' | 'week' | 'month' | 'year';
|
|||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
FormsModule,
|
|
||||||
MatTableModule,
|
MatTableModule,
|
||||||
MatPaginatorModule,
|
MatPaginatorModule,
|
||||||
MatIconModule,
|
MatIconModule,
|
||||||
@@ -57,18 +48,13 @@ type PeriodMode = 'all' | 'day' | 'week' | 'month' | 'year';
|
|||||||
MatTooltipModule,
|
MatTooltipModule,
|
||||||
MatProgressSpinnerModule,
|
MatProgressSpinnerModule,
|
||||||
MatDialogModule,
|
MatDialogModule,
|
||||||
MatCheckboxModule,
|
|
||||||
HasPermissionDirective
|
HasPermissionDirective
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class AmlFlaggedTransactionsComponent implements OnInit, AfterViewInit, OnDestroy {
|
export class AmlFlaggedTransactionsComponent implements OnInit, 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. */
|
||||||
@@ -76,7 +62,6 @@ export class AmlFlaggedTransactionsComponent implements OnInit, AfterViewInit, O
|
|||||||
|
|
||||||
alerts = new MatTableDataSource<any>([]);
|
alerts = new MatTableDataSource<any>([]);
|
||||||
displayedColumns: string[] = [
|
displayedColumns: string[] = [
|
||||||
'select',
|
|
||||||
'raisedOn',
|
'raisedOn',
|
||||||
'transaction',
|
'transaction',
|
||||||
'client',
|
'client',
|
||||||
@@ -86,532 +71,25 @@ export class AmlFlaggedTransactionsComponent implements OnInit, AfterViewInit, O
|
|||||||
'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' },
|
||||||
// No "Under review" chip: nothing sets that status now that reading a row no longer records anything, so the
|
{ key: 'UNDER_REVIEW', label: 'Under review' },
|
||||||
// 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: { key: number; label: string; count: number; cls: string }[] = [];
|
riskSummary: { 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;
|
||||||
|
|
||||||
@@ -628,18 +106,6 @@ export class AmlFlaggedTransactionsComponent implements OnInit, AfterViewInit, O
|
|||||||
|
|
||||||
@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)
|
||||||
@@ -692,22 +158,7 @@ export class AmlFlaggedTransactionsComponent implements OnInit, AfterViewInit, O
|
|||||||
|
|
||||||
setStatus(key: string): void {
|
setStatus(key: string): void {
|
||||||
this.activeStatus = key;
|
this.activeStatus = key;
|
||||||
this.applyStatusFilter(true);
|
this.applyStatusFilter();
|
||||||
}
|
|
||||||
|
|
||||||
/** 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 {
|
||||||
@@ -752,202 +203,58 @@ export class AmlFlaggedTransactionsComponent implements OnInit, AfterViewInit, O
|
|||||||
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 {
|
||||||
* @param resetPage
|
this.alerts.data = this.activeStatus
|
||||||
* true only when the user changed a filter. A poll must not reset the page: the feed
|
? this.allAlerts.filter((a) => a.status === this.activeStatus)
|
||||||
* refreshes every few seconds, and an officer reading page 3 would be thrown back to
|
: this.allAlerts;
|
||||||
* 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;
|
||||||
}
|
}
|
||||||
if (resetPage && this.alerts.paginator) {
|
this.recomputeRiskSummary();
|
||||||
// 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(pool: any[]): void {
|
private recomputeRiskSummary(): 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) => this.severityBand(a) === t.key).length
|
count: pool.filter((a) => Number(a.riskCategory) === t.key).length
|
||||||
}));
|
}));
|
||||||
// Band 0 is everything ungraded, so the tiles always reconcile to the total.
|
// Anything outside 1-3, including null, so the tiles always reconcile to the total.
|
||||||
this.unclassifiedCount = pool.filter((a) => this.severityBand(a) === 0).length;
|
this.unclassifiedCount = pool.filter(
|
||||||
}
|
(a) => ![
|
||||||
|
1,
|
||||||
/**
|
2,
|
||||||
* Everything the status filter leaves, whatever severity tile is in force — so the tiles still
|
3
|
||||||
* add up to the total while one of them is narrowing the table, and the total reads as the way
|
].includes(Number(a.riskCategory))
|
||||||
* back to all of them.
|
).length;
|
||||||
*/
|
|
||||||
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 {
|
||||||
const pool = this.allAlerts.filter((a) => this.inActivePeriod(a));
|
return this.alerts.data.length;
|
||||||
return this.activeStatus ? pool.filter((a) => this.matchesStatus(a, this.activeStatus)).length : pool.length;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private recomputeStatusCounts(): void {
|
private recomputeStatusCounts(): void {
|
||||||
// Counted within the chosen day, so "Escalated 1" means one escalated that day rather than one in the whole feed.
|
this.statusCounts = { '': this.allAlerts.length };
|
||||||
// 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] = pool.filter((a) => this.matchesStatus(a, s.key)).length;
|
this.statusCounts[s.key] = this.allAlerts.filter((a) => a.status === 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();
|
||||||
}
|
}
|
||||||
@@ -995,26 +302,7 @@ export class AmlFlaggedTransactionsComponent implements OnInit, AfterViewInit, O
|
|||||||
* 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 {
|
||||||
if (alert?.status !== 'RAISED' && alert?.status !== 'UNDER_REVIEW') {
|
return 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 {
|
||||||
@@ -1042,73 +330,4 @@ export class AmlFlaggedTransactionsComponent implements OnInit, AfterViewInit, O
|
|||||||
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();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-23
@@ -31,11 +31,6 @@ 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[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -167,13 +162,6 @@ 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';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,17 +181,7 @@ export class AmlReviewAlertDialogComponent {
|
|||||||
|
|
||||||
this.submitting = true;
|
this.submitting = true;
|
||||||
this.errorMessage = '';
|
this.errorMessage = '';
|
||||||
// Deciding named rules within the alert rather than the whole alert, when the caller passed some. The evidence
|
this.amlService.reviewAlert(this.data.alert.id, this.data.disposition, this.composedNotes).subscribe({
|
||||||
// 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,11 +93,6 @@ 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' },
|
||||||
|
|||||||
@@ -1,401 +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, 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 });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,222 +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, 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,29 +372,13 @@ 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_RULE_PREFIXES = [
|
export const NON_MITIGABLE_RULES = new Set([
|
||||||
'SANCTIONS',
|
'SANCTIONS_HIT',
|
||||||
'PEP',
|
'PEP',
|
||||||
'STRUCTURING',
|
'STRUCTURING',
|
||||||
'STRUCTURED',
|
'HIGH_RISK_COUNTRY'
|
||||||
'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,9 +304,7 @@
|
|||||||
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">
|
||||||
<!-- An unrated customer is its own state: no rating exists, so "same" would be claiming the
|
<span class="rr-move" [class]="'rr-move--' + c.direction">
|
||||||
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>
|
||||||
@@ -325,32 +323,15 @@
|
|||||||
<!-- 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">
|
||||||
<!-- Raises the change for approval rather than sending the reviewer to the
|
|
||||||
KYC screen to make it themselves. A case with the Principal Officer is
|
|
||||||
held where it is until the STR concludes, so it only offers the record. -->
|
|
||||||
@if (canChangeRisk(c)) {
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="rr-kyc-link"
|
class="rr-kyc-link"
|
||||||
[class.rr-kyc-link--act]="c.direction !== 'same'"
|
[class.rr-kyc-link--act]="c.direction !== 'same' && !c.escalated"
|
||||||
(click)="changeRisk(c, $event)"
|
(click)="openKyc(c, $event)"
|
||||||
[attr.aria-label]="'Change risk category for ' + c.customerName"
|
[attr.aria-label]="kycActionText(c) + ' for ' + c.customerName"
|
||||||
>
|
>
|
||||||
<fa-icon icon="shield-alt"></fa-icon> Change Risk
|
<fa-icon icon="id-card"></fa-icon> {{ kycActionText(c) }}
|
||||||
</button>
|
</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,13 +937,6 @@
|
|||||||
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,7 +10,6 @@
|
|||||||
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';
|
||||||
|
|
||||||
@@ -39,14 +38,12 @@ 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,
|
||||||
@@ -56,7 +53,6 @@ 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';
|
||||||
@@ -113,7 +109,6 @@ 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;
|
||||||
@@ -186,28 +181,12 @@ 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;
|
||||||
// Both in one pass: the windows decide what each row recommends, so composing before they arrive would show a
|
this.aml
|
||||||
// recommendation the screen then silently replaced.
|
.getAlerts()
|
||||||
forkJoin({
|
.pipe(catchError(() => of([] as LiveAmlAlert[])))
|
||||||
alerts: this.aml.getAlerts().pipe(catchError(() => of([] as LiveAmlAlert[]))),
|
.subscribe((live) => {
|
||||||
windows: this.aml.getBehaviourWindows().pipe(catchError(() => of([] as any[])))
|
|
||||||
}).subscribe(({ alerts: live, windows }) => {
|
|
||||||
this.behaviourWindows = {};
|
|
||||||
for (const w of windows || []) {
|
|
||||||
if (w?.clientId != null) {
|
|
||||||
this.behaviourWindows[w.clientId] = w;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const rows = (live || []).map((a) => mapLiveAlert(a as unknown as LiveAmlAlert));
|
const rows = (live || []).map((a) => mapLiveAlert(a as unknown as LiveAmlAlert));
|
||||||
this.liveAlerts = rows;
|
this.liveAlerts = rows;
|
||||||
this.compose();
|
this.compose();
|
||||||
@@ -264,12 +243,7 @@ export class RiskReviewReportComponent implements OnInit {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
).subscribe((results) => {
|
).subscribe((results) => {
|
||||||
const byClient = new Map(
|
const byClient = new Map(results.map((r) => [r.id, r]));
|
||||||
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 {
|
||||||
@@ -362,11 +336,9 @@ 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 {
|
||||||
// The window count belongs in the key: the windows decide what each row recommends, and they arrive in the same
|
const key = JSON.stringify(this.filter) + `|${this.alerts.length}`;
|
||||||
// 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.behaviourWindows);
|
this.memo = buildRiskReview(this.alerts, this.filter);
|
||||||
this.memoKey = key;
|
this.memoKey = key;
|
||||||
}
|
}
|
||||||
return this.memo;
|
return this.memo;
|
||||||
@@ -392,63 +364,10 @@ 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;
|
||||||
// An escalated customer whose rating still has to move belongs with the work, not with the cases that are waiting
|
const escalated = 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
|
const rest = rows.filter((c) => !c.escalated);
|
||||||
// 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[] = [
|
||||||
{
|
{
|
||||||
@@ -459,42 +378,25 @@ export class RiskReviewReportComponent implements OnInit {
|
|||||||
rows: escalated
|
rows: escalated
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// Raising and lowering are one section, not two. The heading answers "is there anything to do about this
|
key: 'up',
|
||||||
// customer", and which way the rating moves is already on every row - as a verdict, an arrow and a reason.
|
title: 'Rating needs raising',
|
||||||
// Splitting them put the same answer in two places and left an officer reading two lists to find one day's
|
hint: 'The rules point higher than the rating on file. These are under-rated customers — the real gap.',
|
||||||
// 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: [
|
rows: rest.filter((c) => c.direction === 'up')
|
||||||
...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')
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// Its own section, and above the watching list, because it is the opposite of that. An unrated customer whose
|
key: 'down',
|
||||||
// money is moving has no rating for the behaviour to be compared against, so nothing here can say whether it
|
title: 'Rating can be lowered',
|
||||||
// is in keeping - the work is to give them one, and grouping them with the customers nothing need be done
|
hint: 'Activity no longer supports the rating on file.',
|
||||||
// about buried the one action the report was asking for.
|
tone: 'down',
|
||||||
key: 'unrated',
|
rows: rest.filter((c) => c.direction === 'down')
|
||||||
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: 'Watching — not enough to act on',
|
title: 'No change needed',
|
||||||
hint: 'In view, but the period does not yet support moving the rating. The reason on each row says what is missing.',
|
hint: 'The rating on file already matches the behaviour. Listed for the record.',
|
||||||
tone: 'hold',
|
tone: 'hold',
|
||||||
rows: rest.filter((c) => c.currentRisk && c.direction === 'same')
|
rows: rest.filter((c) => c.direction === 'same')
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -673,67 +575,6 @@ 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.
|
||||||
*
|
*
|
||||||
@@ -748,41 +589,25 @@ 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 {
|
||||||
// Escalation is only the whole story when the rating already matches. An STR holds a customer at High, so one
|
if (c.escalated) {
|
||||||
// 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';
|
||||||
}
|
}
|
||||||
// Both ends of the move, not just the destination. "Lower to Medium" reads as a verdict on its own and leaves the
|
if (c.direction === 'up') {
|
||||||
// officer joining it to the rating column to see what is actually changing; "High → Medium" is the whole fact in
|
return `Raise to ${RISK_LABEL[c.recommendedRisk]}`;
|
||||||
// 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]}`;
|
|
||||||
}
|
}
|
||||||
// An unrated customer is told what to do, not what was concluded. "No rating on record" states the gap and stops
|
if (c.direction === 'down') {
|
||||||
// there, which on a worklist reads as though the row needs nothing - and it is in fact the row that needs the most.
|
return `Lower to ${RISK_LABEL[c.recommendedRisk]}`;
|
||||||
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 {
|
||||||
// Same test as directionText: a padlock says the case is out of the officer's hands, which is untrue while the
|
if (c.escalated) {
|
||||||
// rating still has to be moved.
|
|
||||||
if (c.escalated && c.direction === 'same') {
|
|
||||||
return 'lock';
|
return 'lock';
|
||||||
}
|
}
|
||||||
if (c.direction === 'up') {
|
// 'check' rather than an equals sign: faEquals is not in the shared library, and an
|
||||||
return 'arrow-up';
|
// unregistered icon throws at render and takes the whole row down with it.
|
||||||
}
|
return c.direction === 'up' ? 'arrow-up' : c.direction === 'down' ? 'arrow-down' : 'check';
|
||||||
if (c.direction === '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,
|
||||||
isNonMitigableRule,
|
NON_MITIGABLE_RULES,
|
||||||
REVIEW_STATUS_META,
|
REVIEW_STATUS_META,
|
||||||
RISK_LABEL,
|
RISK_LABEL,
|
||||||
RiskAlertRow,
|
RiskAlertRow,
|
||||||
@@ -26,17 +26,6 @@ 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;
|
||||||
@@ -120,8 +109,6 @@ 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 {
|
||||||
@@ -168,26 +155,17 @@ 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) => isNonMitigableRule(c));
|
const grave = ruleCodes.find((c) => NON_MITIGABLE_RULES.has(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' | 'thin';
|
let basis: 'escalated' | 'grave' | 'material' | 'small' | 'moderate';
|
||||||
if (escalated) {
|
if (escalated) {
|
||||||
risk = 'HIGH';
|
risk = 'HIGH';
|
||||||
basis = 'escalated';
|
basis = 'escalated';
|
||||||
@@ -197,12 +175,6 @@ 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';
|
||||||
@@ -246,27 +218,6 @@ 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 {
|
||||||
@@ -320,36 +271,7 @@ 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);
|
||||||
@@ -375,59 +297,19 @@ export function riskCustomerRows(views: RiskAlertView[], windows: BehaviourWindo
|
|||||||
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');
|
||||||
|
|
||||||
// The server's own reckoning, where the nightly job has produced one.
|
const recommendation = recommendRating(
|
||||||
//
|
|
||||||
// It is the better answer, and not because it is newer: it is drawn from a review period rather than from the
|
|
||||||
// alerts on this screen. A customer appears here only once a rule has fired, so judging them on those alerts
|
|
||||||
// alone describes the exceptions and never the ordinary months around them - which is exactly what a rating is
|
|
||||||
// supposed to reflect. The job also tests the things this screen cannot see at all: how many separate months the
|
|
||||||
// activity falls in, how long the customer has held the rating, whether an STR inside the block window forbids
|
|
||||||
// lowering it.
|
|
||||||
//
|
|
||||||
// Without a window - a tenant where the job has not run, or a customer it has not reached - the per-alert
|
|
||||||
// reckoning below still stands, so the report never renders blank while the two are in step.
|
|
||||||
// A window is only good for as long as the rating it was computed against. The job takes a snapshot each night
|
|
||||||
// and records the rating the customer held at the time; once an officer moves that rating the snapshot describes
|
|
||||||
// a customer who no longer exists, and the screen would go on asking for a change that has already been applied
|
|
||||||
// and approved. Dropping it here falls back to the per-alert reckoning until tonight's job catches up.
|
|
||||||
const raw = windows[clientId];
|
|
||||||
const staleWindow =
|
|
||||||
raw?.riskCategoryAtReview != null &&
|
|
||||||
currentRisk != null &&
|
|
||||||
RANK_TO_LEVEL[raw.riskCategoryAtReview] !== currentRisk;
|
|
||||||
const window = staleWindow ? undefined : raw;
|
|
||||||
const recommendation = window?.suggestedDirection
|
|
||||||
? {
|
|
||||||
risk: RANK_TO_LEVEL[window.suggestedCategory ?? 0] ?? currentRisk ?? 'LOW',
|
|
||||||
reason: window.suggestionReason ?? ''
|
|
||||||
}
|
|
||||||
: recommendRating(
|
|
||||||
proposedRank,
|
proposedRank,
|
||||||
currentRisk,
|
currentRisk,
|
||||||
largestAmount,
|
largestAmount,
|
||||||
totalAmount,
|
totalAmount,
|
||||||
escalated,
|
escalated,
|
||||||
alerts.map((a) => a.ruleCode),
|
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 = window?.suggestedDirection
|
const direction: RatingDirection = delta > 0 ? 'up' : delta < 0 ? 'down' : 'same';
|
||||||
? window.suggestedDirection === 'UP'
|
|
||||||
? 'up'
|
|
||||||
: window.suggestedDirection === 'DOWN'
|
|
||||||
? 'down'
|
|
||||||
: 'same'
|
|
||||||
: delta > 0
|
|
||||||
? 'up'
|
|
||||||
: delta < 0
|
|
||||||
? 'down'
|
|
||||||
: 'same';
|
|
||||||
|
|
||||||
rows.push({
|
rows.push({
|
||||||
clientId,
|
clientId,
|
||||||
@@ -450,10 +332,7 @@ export function riskCustomerRows(views: RiskAlertView[], windows: BehaviourWindo
|
|||||||
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
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -582,13 +461,9 @@ 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(
|
export function buildRiskReview(alerts: RiskAlertRow[], filter: RiskReviewFilter): RiskReviewModel {
|
||||||
alerts: RiskAlertRow[],
|
|
||||||
filter: RiskReviewFilter,
|
|
||||||
windows: BehaviourWindowByClient = {}
|
|
||||||
): RiskReviewModel {
|
|
||||||
const base = riskAlertViews(alerts, filter);
|
const base = riskAlertViews(alerts, filter);
|
||||||
const narrowed = applyDirectionFilter(base, riskCustomerRows(base, windows), filter.direction);
|
const narrowed = applyDirectionFilter(base, riskCustomerRows(base), filter.direction);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
alerts: narrowed.views,
|
alerts: narrowed.views,
|
||||||
|
|||||||
@@ -56,7 +56,6 @@ import {
|
|||||||
faGavel,
|
faGavel,
|
||||||
faGlobe,
|
faGlobe,
|
||||||
faHandHoldingUsd,
|
faHandHoldingUsd,
|
||||||
faHandPaper,
|
|
||||||
faHome,
|
faHome,
|
||||||
faKey,
|
faKey,
|
||||||
faLink,
|
faLink,
|
||||||
@@ -304,7 +303,6 @@ export class IconsModule {
|
|||||||
faHandHoldingHand,
|
faHandHoldingHand,
|
||||||
faHandHoldingHeart,
|
faHandHoldingHeart,
|
||||||
faHandHoldingUsd,
|
faHandHoldingUsd,
|
||||||
faHandPaper,
|
|
||||||
faHeart,
|
faHeart,
|
||||||
faHome,
|
faHome,
|
||||||
faKey,
|
faKey,
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ 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
@@ -1,209 +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="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>
|
|
||||||
@@ -1,238 +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 { 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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,174 +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="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>
|
|
||||||
@@ -1,188 +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 { 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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,192 +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="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>
|
|
||||||
@@ -1,293 +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 { 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
@@ -1,152 +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="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
@@ -1,206 +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 { 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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-171
@@ -1,171 +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="cert-root">
|
|
||||||
<div class="cert-head">
|
|
||||||
<div class="cert-head-l">
|
|
||||||
<fa-icon icon="dollar-sign" class="cert-head-ic"></fa-icon>
|
|
||||||
<div>
|
|
||||||
<h2 class="cert-head-t">Dividend Statement</h2>
|
|
||||||
<div class="cert-head-s">
|
|
||||||
Every dividend declared to a member, year by year, with what has been paid and what is still owed.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="cert-head-r">
|
|
||||||
<button class="cert-cta-btn" (click)="exportCsv()" [disabled]="!member || !rows.length">
|
|
||||||
<fa-icon icon="file-alt"></fa-icon> Export CSV
|
|
||||||
</button>
|
|
||||||
<button class="cert-cta-btn" (click)="print()" [disabled]="!member">
|
|
||||||
<fa-icon icon="print"></fa-icon> Print
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="cert-filters">
|
|
||||||
<div class="cert-filter" style="flex: 2 1 340px">
|
|
||||||
<label>Member name, membership number or share account number</label>
|
|
||||||
<div class="cert-input-ic">
|
|
||||||
<fa-icon icon="search"></fa-icon>
|
|
||||||
<input type="text" [(ngModel)]="search" (keyup.enter)="runSearch()" placeholder="e.g. member name" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="cert-filter">
|
|
||||||
<label> </label>
|
|
||||||
<button class="cert-cta-btn" (click)="runSearch()" [disabled]="!search.trim() || searching">
|
|
||||||
{{ searching ? 'Searching…' : 'Show statement' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (loadError) {
|
|
||||||
<div class="srep-state srep-state--error"><fa-icon icon="exclamation-circle"></fa-icon> {{ loadError }}</div>
|
|
||||||
} @else if (searching) {
|
|
||||||
<div class="srep-state">Loading the statement…</div>
|
|
||||||
} @else if (matches.length) {
|
|
||||||
<div class="srep-state">{{ matches.length }} members match. Choose one:</div>
|
|
||||||
<div class="srep-table-wrap">
|
|
||||||
<table class="srep-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Member</th>
|
|
||||||
<th>Membership No</th>
|
|
||||||
<th></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@for (m of matches; track m.clientId) {
|
|
||||||
<tr class="srep-row--click" (click)="loadByClient(m.clientId)">
|
|
||||||
<td>{{ m.clientName }}</td>
|
|
||||||
<td class="srep-mono">{{ m.cifNumber || '—' }}</td>
|
|
||||||
<td><span class="srep-pill srep-pill--blue">Open</span></td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
} @else if (searched && !member) {
|
|
||||||
<div class="srep-state">No member with a share account matches "{{ search }}".</div>
|
|
||||||
} @else if (member) {
|
|
||||||
<div class="srep-stmt-head">
|
|
||||||
<div class="srep-stmt-grid">
|
|
||||||
<div>
|
|
||||||
<div class="srep-stmt-l">Member</div>
|
|
||||||
<div class="srep-stmt-v">{{ member.clientName }}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="srep-stmt-l">Membership No</div>
|
|
||||||
<div class="srep-stmt-v srep-mono">{{ member.cifNumber || '—' }}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="srep-stmt-l">Branch</div>
|
|
||||||
<div class="srep-stmt-v">{{ member.officeName || '—' }}</div>
|
|
||||||
</div>
|
|
||||||
</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.declarationCount | number }}</div>
|
|
||||||
<div class="cert-kpi-l">Declarations</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="cert-kpi cert-kpi--teal">
|
|
||||||
<fa-icon icon="hand-holding-usd" class="cert-kpi-ic"></fa-icon>
|
|
||||||
<div>
|
|
||||||
<div class="cert-kpi-n">₹{{ totals.declared | number: '1.2-2' }}</div>
|
|
||||||
<div class="cert-kpi-l">Total Declared</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="cert-kpi cert-kpi--green">
|
|
||||||
<fa-icon icon="check-circle" class="cert-kpi-ic"></fa-icon>
|
|
||||||
<div>
|
|
||||||
<div class="cert-kpi-n">₹{{ totals.paid | number: '1.2-2' }}</div>
|
|
||||||
<div class="cert-kpi-l">Paid</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="cert-kpi" [ngClass]="totals.outstanding ? 'cert-kpi--amber' : 'cert-kpi--blue'">
|
|
||||||
<fa-icon icon="sync" class="cert-kpi-ic"></fa-icon>
|
|
||||||
<div>
|
|
||||||
<div class="cert-kpi-n">₹{{ totals.outstanding | number: '1.2-2' }}</div>
|
|
||||||
<div class="cert-kpi-l">Still Owed</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (!rows.length) {
|
|
||||||
<div class="srep-state">No dividend has been declared to this member yet.</div>
|
|
||||||
} @else {
|
|
||||||
<div class="srep-table-wrap">
|
|
||||||
<table class="srep-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Dividend Period</th>
|
|
||||||
<th>Share A/c No</th>
|
|
||||||
<th>Product</th>
|
|
||||||
<th class="srep-num">Rate</th>
|
|
||||||
<th class="srep-num">Shares Held Now</th>
|
|
||||||
<th class="srep-num">Amount</th>
|
|
||||||
<th>Status</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@for (row of rows; track row.dividendDetailId) {
|
|
||||||
<tr>
|
|
||||||
<td>{{ row.periodStart }} — {{ row.periodEnd }}</td>
|
|
||||||
<td class="srep-mono">{{ row.accountNo }}</td>
|
|
||||||
<td>{{ row.productName }}</td>
|
|
||||||
<td class="srep-num">{{ row.ratePercentage ? (row.ratePercentage | number: '1.2-2') + '%' : '—' }}</td>
|
|
||||||
<td class="srep-num">{{ row.sharesHeldNow | number }}</td>
|
|
||||||
<td class="srep-num">₹{{ row.amount | number: '1.2-2' }}</td>
|
|
||||||
<td>
|
|
||||||
<span class="srep-pill" [ngClass]="statusClass(row.statusEnum)">{{
|
|
||||||
statusLabel(row.statusEnum)
|
|
||||||
}}</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
<tfoot>
|
|
||||||
<tr>
|
|
||||||
<td colspan="5">Total declared</td>
|
|
||||||
<td class="srep-num">₹{{ totals?.declared | number: '1.2-2' }}</td>
|
|
||||||
<td></td>
|
|
||||||
</tr>
|
|
||||||
</tfoot>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
} @else {
|
|
||||||
<div class="srep-state">Search for a member above to see their dividend statement.</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
-151
@@ -1,151 +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 { ActivatedRoute } 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_DIVIDEND_STATUS, shareReportCsv, shareReportPrint } from '../share-report.util';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Dividend Statement — every dividend declared to one member, with what has been paid and what is
|
|
||||||
* still owed.
|
|
||||||
*
|
|
||||||
* <p>
|
|
||||||
* Handed to a member as their record, so declared and paid stay separate columns: "declared" and
|
|
||||||
* "paid" are different answers to "have I had my dividend", and a statement showing one amount
|
|
||||||
* cannot tell them apart.
|
|
||||||
*/
|
|
||||||
@Component({
|
|
||||||
selector: 'mifosx-share-dividend-statement',
|
|
||||||
templateUrl: './share-dividend-statement.component.html',
|
|
||||||
styleUrls: [
|
|
||||||
'../../../letter-mgmt/certificates/certificates.shared.scss',
|
|
||||||
'../share-report.shared.scss'
|
|
||||||
],
|
|
||||||
imports: [
|
|
||||||
CommonModule,
|
|
||||||
FormsModule,
|
|
||||||
FaIconComponent
|
|
||||||
]
|
|
||||||
})
|
|
||||||
export class ShareDividendStatementComponent implements OnInit {
|
|
||||||
private sharesService = inject(SharesService);
|
|
||||||
private route = inject(ActivatedRoute);
|
|
||||||
|
|
||||||
search = '';
|
|
||||||
searching = false;
|
|
||||||
searched = false;
|
|
||||||
loadError = '';
|
|
||||||
|
|
||||||
matches: any[] = [];
|
|
||||||
member: any = null;
|
|
||||||
rows: any[] = [];
|
|
||||||
totals: any = null;
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
|
||||||
const clientId = this.route.snapshot.queryParamMap.get('clientId');
|
|
||||||
if (clientId) {
|
|
||||||
this.loadByClient(Number(clientId));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
runSearch(): void {
|
|
||||||
const term = this.search.trim();
|
|
||||||
if (!term) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.fetch({ search: term });
|
|
||||||
}
|
|
||||||
|
|
||||||
loadByClient(clientId: number): void {
|
|
||||||
this.fetch({ clientId });
|
|
||||||
}
|
|
||||||
|
|
||||||
private fetch(params: { search?: string; clientId?: number }): void {
|
|
||||||
this.searching = true;
|
|
||||||
this.loadError = '';
|
|
||||||
this.reset();
|
|
||||||
this.sharesService
|
|
||||||
.getShareDividendStatement(params)
|
|
||||||
.pipe(
|
|
||||||
catchError((err) => {
|
|
||||||
this.loadError = this.readError(err, 'The dividend statement could not be loaded.');
|
|
||||||
return of(null);
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.subscribe((res: any) => {
|
|
||||||
this.matches = res?.matches || [];
|
|
||||||
this.member = res?.member || null;
|
|
||||||
this.rows = res?.rows || [];
|
|
||||||
this.totals = res?.totals || null;
|
|
||||||
this.searching = false;
|
|
||||||
this.searched = true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private reset(): void {
|
|
||||||
this.matches = [];
|
|
||||||
this.member = null;
|
|
||||||
this.rows = [];
|
|
||||||
this.totals = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
statusLabel(status: number): string {
|
|
||||||
return SHARE_DIVIDEND_STATUS[status]?.label || String(status ?? '');
|
|
||||||
}
|
|
||||||
|
|
||||||
statusClass(status: number): string {
|
|
||||||
return SHARE_DIVIDEND_STATUS[status]?.cls || 'srep-pill--grey';
|
|
||||||
}
|
|
||||||
|
|
||||||
exportCsv(): void {
|
|
||||||
if (!this.member) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
shareReportCsv(
|
|
||||||
`dividend-statement-${this.member.cifNumber || this.member.clientId}`,
|
|
||||||
[
|
|
||||||
'Period From',
|
|
||||||
'Period To',
|
|
||||||
'Share A/c No',
|
|
||||||
'Product',
|
|
||||||
'Rate %',
|
|
||||||
'Amount',
|
|
||||||
'Status'
|
|
||||||
],
|
|
||||||
this.rows.map((r) => [
|
|
||||||
r.periodStart,
|
|
||||||
r.periodEnd,
|
|
||||||
r.accountNo,
|
|
||||||
r.productName,
|
|
||||||
r.ratePercentage,
|
|
||||||
r.amount,
|
|
||||||
this.statusLabel(r.statusEnum)
|
|
||||||
])
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
print(): void {
|
|
||||||
shareReportPrint();
|
|
||||||
}
|
|
||||||
|
|
||||||
private readError(err: any, fallback: string): string {
|
|
||||||
return (
|
|
||||||
err?.error?.errors?.[0]?.defaultUserMessage ||
|
|
||||||
err?.error?.defaultUserMessage ||
|
|
||||||
err?.error?.developerMessage ||
|
|
||||||
fallback
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,291 +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="cert-root">
|
|
||||||
<div class="cert-head">
|
|
||||||
<div class="cert-head-l">
|
|
||||||
<fa-icon icon="search" class="cert-head-ic"></fa-icon>
|
|
||||||
<div>
|
|
||||||
<h2 class="cert-head-t">Share Enquiry</h2>
|
|
||||||
<div class="cert-head-s">
|
|
||||||
One member's whole share position — accounts, movements, dividends and linked savings, in one answer.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="cert-head-r">
|
|
||||||
<button class="cert-cta-btn" (click)="exportCsv()" [disabled]="!member || !movements.length">
|
|
||||||
<fa-icon icon="file-alt"></fa-icon> Export CSV
|
|
||||||
</button>
|
|
||||||
<button class="cert-cta-btn" (click)="print()" [disabled]="!member">
|
|
||||||
<fa-icon icon="print"></fa-icon> Print
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="cert-filters">
|
|
||||||
<div class="cert-filter" style="flex: 2 1 340px">
|
|
||||||
<label>Member name, membership number or share account number</label>
|
|
||||||
<div class="cert-input-ic">
|
|
||||||
<fa-icon icon="search"></fa-icon>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
[(ngModel)]="search"
|
|
||||||
(keyup.enter)="runSearch()"
|
|
||||||
placeholder="e.g. Deepak Chouhan, 000000105 or 000000004"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="cert-filter">
|
|
||||||
<label> </label>
|
|
||||||
<button class="cert-cta-btn" (click)="runSearch()" [disabled]="!search.trim() || searching">
|
|
||||||
{{ searching ? 'Searching…' : 'Enquire' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (loadError) {
|
|
||||||
<div class="srep-state srep-state--error"><fa-icon icon="exclamation-circle"></fa-icon> {{ loadError }}</div>
|
|
||||||
} @else if (searching) {
|
|
||||||
<div class="srep-state">Looking up the member…</div>
|
|
||||||
} @else if (matches.length) {
|
|
||||||
<!-- Several members answer the search — the clerk picks, rather than this guessing between them -->
|
|
||||||
<div class="srep-state">{{ matches.length }} members match. Choose one:</div>
|
|
||||||
<div class="srep-table-wrap">
|
|
||||||
<table class="srep-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Member</th>
|
|
||||||
<th>Membership No</th>
|
|
||||||
<th></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@for (m of matches; track m.clientId) {
|
|
||||||
<tr class="srep-row--click" (click)="loadByClient(m.clientId)">
|
|
||||||
<td>{{ m.clientName }}</td>
|
|
||||||
<td class="srep-mono">{{ m.cifNumber || '—' }}</td>
|
|
||||||
<td><span class="srep-pill srep-pill--blue">Open</span></td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
} @else if (searched && !member) {
|
|
||||||
<div class="srep-state">No member with a share account matches "{{ search }}".</div>
|
|
||||||
} @else if (member) {
|
|
||||||
<!-- Member identity -->
|
|
||||||
<div class="srep-stmt-head">
|
|
||||||
<div class="srep-stmt-grid">
|
|
||||||
<div>
|
|
||||||
<div class="srep-stmt-l">Member</div>
|
|
||||||
<div class="srep-stmt-v">{{ member.clientName }}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="srep-stmt-l">Membership No</div>
|
|
||||||
<div class="srep-stmt-v srep-mono">{{ member.cifNumber || '—' }}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="srep-stmt-l">Branch</div>
|
|
||||||
<div class="srep-stmt-v">{{ member.officeName || '—' }}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="srep-stmt-l">Mobile</div>
|
|
||||||
<div class="srep-stmt-v">{{ member.mobileNo || '—' }}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="srep-stmt-l">Member Since</div>
|
|
||||||
<div class="srep-stmt-v">{{ member.memberSince || '—' }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</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.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 Value</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<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--amber">
|
|
||||||
<fa-icon icon="hand-holding-usd" class="cert-kpi-ic"></fa-icon>
|
|
||||||
<div>
|
|
||||||
<div class="cert-kpi-n">₹{{ totals.dividendTotal | number: '1.2-2' }}</div>
|
|
||||||
<div class="cert-kpi-l">Dividend Declared</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- Accounts -->
|
|
||||||
<div class="cert-filters">
|
|
||||||
<div class="cert-filter" style="flex: 1 1 100%"><label>Share accounts</label></div>
|
|
||||||
</div>
|
|
||||||
<div class="srep-table-wrap">
|
|
||||||
<table class="srep-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Share A/c No</th>
|
|
||||||
<th>Product</th>
|
|
||||||
<th>State</th>
|
|
||||||
<th class="srep-num">Shares Held</th>
|
|
||||||
<th class="srep-num">Pending</th>
|
|
||||||
<th class="srep-num">Share Value</th>
|
|
||||||
<th>Linked Savings</th>
|
|
||||||
<th>Activated</th>
|
|
||||||
<th class="srep-no-print"></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@for (a of accounts; track a.shareAccountId) {
|
|
||||||
<tr [ngClass]="stateRowClass(a.holdingState)">
|
|
||||||
<td class="srep-mono">{{ a.accountNo }}</td>
|
|
||||||
<td>{{ a.productName }}</td>
|
|
||||||
<td>
|
|
||||||
<span class="srep-pill" [ngClass]="stateClass(a.holdingState)" [title]="stateNote(a.holdingState)">{{
|
|
||||||
stateLabel(a.holdingState)
|
|
||||||
}}</span>
|
|
||||||
</td>
|
|
||||||
<td class="srep-num">
|
|
||||||
{{ a.sharesHeld | number }}
|
|
||||||
@if (a.holdingState === 'UNPAID') {
|
|
||||||
<div class="srep-sub">{{ a.unpaidShares | number }} unpaid</div>
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
<td class="srep-num">{{ a.pendingShares | number }}</td>
|
|
||||||
<td class="srep-num">₹{{ a.shareValue | number: '1.2-2' }}</td>
|
|
||||||
<td class="srep-mono">
|
|
||||||
{{ a.linkedSavingsAccountNo || 'Not linked' }}
|
|
||||||
@if (a.linkedSavingsAccountNo) {
|
|
||||||
<div class="srep-scope-note" style="margin: 0">
|
|
||||||
Balance ₹{{ a.linkedSavingsBalance | number: '1.2-2' }}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
<td>{{ a.activatedDate || '—' }}</td>
|
|
||||||
<td class="srep-no-print">
|
|
||||||
<button class="cert-cta-btn" (click)="openStatement(a)">Statement</button>
|
|
||||||
<button class="cert-cta-btn" (click)="openAccount(a)">Open</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Movements -->
|
|
||||||
<div class="cert-filters" style="margin-top: 0.85rem">
|
|
||||||
<div class="cert-filter" style="flex: 1 1 100%"><label>Share movements</label></div>
|
|
||||||
</div>
|
|
||||||
@if (!movements.length) {
|
|
||||||
<div class="srep-state">No share movement recorded for this member.</div>
|
|
||||||
} @else {
|
|
||||||
<div class="srep-table-wrap">
|
|
||||||
<table class="srep-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Date</th>
|
|
||||||
<th>Share A/c No</th>
|
|
||||||
<th>Particulars</th>
|
|
||||||
<th class="srep-num">Shares</th>
|
|
||||||
<th class="srep-num">Unit Price</th>
|
|
||||||
<th class="srep-num">Amount</th>
|
|
||||||
<th>Funded By</th>
|
|
||||||
<th>Board Resolution</th>
|
|
||||||
<th>Status</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@for (m of movements; track m.transactionId) {
|
|
||||||
<tr>
|
|
||||||
<td>{{ m.transactionDate }}</td>
|
|
||||||
<td class="srep-mono">{{ m.accountNo }}</td>
|
|
||||||
<td>
|
|
||||||
<span class="srep-pill" [ngClass]="typeClass(m.typeEnum)">{{ typeLabel(m.typeEnum) }}</span>
|
|
||||||
</td>
|
|
||||||
<td class="srep-num">{{ m.shares | number }}</td>
|
|
||||||
<td class="srep-num">{{ m.unitPrice | number: '1.2-2' }}</td>
|
|
||||||
<td class="srep-num">₹{{ m.amount | number: '1.2-2' }}</td>
|
|
||||||
<td>{{ funding(m.fundingSource) }}</td>
|
|
||||||
<td class="srep-mono">{{ m.resolutionNumber || '—' }}</td>
|
|
||||||
<td>
|
|
||||||
<span class="srep-pill" [ngClass]="txnStatusClass(m.statusEnum)">{{
|
|
||||||
txnStatusLabel(m.statusEnum)
|
|
||||||
}}</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- Dividends -->
|
|
||||||
<div class="cert-filters" style="margin-top: 0.85rem">
|
|
||||||
<div class="cert-filter" style="flex: 1 1 100%"><label>Dividends</label></div>
|
|
||||||
</div>
|
|
||||||
@if (!dividends.length) {
|
|
||||||
<div class="srep-state">No dividend has been declared to this member yet.</div>
|
|
||||||
} @else {
|
|
||||||
<div class="srep-table-wrap">
|
|
||||||
<table class="srep-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Period</th>
|
|
||||||
<th>Share A/c No</th>
|
|
||||||
<th>Product</th>
|
|
||||||
<th class="srep-num">Rate</th>
|
|
||||||
<th class="srep-num">Amount</th>
|
|
||||||
<th>Status</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@for (d of dividends; track d.dividendDetailId) {
|
|
||||||
<tr>
|
|
||||||
<td>{{ d.periodStart }} — {{ d.periodEnd }}</td>
|
|
||||||
<td class="srep-mono">{{ d.accountNo }}</td>
|
|
||||||
<td>{{ d.productName }}</td>
|
|
||||||
<td class="srep-num">{{ d.ratePercentage ? (d.ratePercentage | number: '1.2-2') + '%' : '—' }}</td>
|
|
||||||
<td class="srep-num">₹{{ d.amount | number: '1.2-2' }}</td>
|
|
||||||
<td>
|
|
||||||
<span class="srep-pill" [ngClass]="dividendStatusClass(d.statusEnum)">{{
|
|
||||||
dividendStatusLabel(d.statusEnum)
|
|
||||||
}}</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
<tfoot>
|
|
||||||
<tr>
|
|
||||||
<td colspan="4">Total declared</td>
|
|
||||||
<td class="srep-num">₹{{ totals?.dividendTotal | number: '1.2-2' }}</td>
|
|
||||||
<td></td>
|
|
||||||
</tr>
|
|
||||||
</tfoot>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
} @else {
|
|
||||||
<div class="srep-state">Search for a member above to see their full share position.</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
@@ -1,246 +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 { ActivatedRoute, 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_TXN_STATUS,
|
|
||||||
SHARE_TXN_TYPE,
|
|
||||||
fundingLabel,
|
|
||||||
holdingStateClass,
|
|
||||||
holdingStateLabel,
|
|
||||||
holdingStateNote,
|
|
||||||
holdingStateRowClass,
|
|
||||||
shareReportCsv,
|
|
||||||
shareReportPrint
|
|
||||||
} from '../share-report.util';
|
|
||||||
|
|
||||||
/** m_share_account_dividend_details.status — where a member's dividend has got to. */
|
|
||||||
const DIVIDEND_STATUS: { [key: number]: { label: string; cls: string } } = {
|
|
||||||
100: { label: 'Declared', cls: 'srep-pill--amber' },
|
|
||||||
300: { label: 'Approved', cls: 'srep-pill--blue' },
|
|
||||||
600: { label: 'Paid', cls: 'srep-pill--green' }
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Share Enquiry — everything about one member's share position in a single answer: their accounts,
|
|
||||||
* every movement, the dividends declared to them, and the savings account their money moves through.
|
|
||||||
*
|
|
||||||
* <p>
|
|
||||||
* Built for the counter. Someone is standing in front of the clerk asking "how many shares do I
|
|
||||||
* hold, when did I buy them, and what dividend am I owed" — three screens is three chances to read
|
|
||||||
* the wrong member's row out loud, so this is one search and one page.
|
|
||||||
*
|
|
||||||
* <p>
|
|
||||||
* A search matching several members returns the list rather than picking one: guessing between two
|
|
||||||
* members with similar names is precisely the mistake this screen must not make.
|
|
||||||
*/
|
|
||||||
@Component({
|
|
||||||
selector: 'mifosx-share-enquiry',
|
|
||||||
templateUrl: './share-enquiry.component.html',
|
|
||||||
styleUrls: [
|
|
||||||
'../../../letter-mgmt/certificates/certificates.shared.scss',
|
|
||||||
'../share-report.shared.scss'
|
|
||||||
],
|
|
||||||
imports: [
|
|
||||||
CommonModule,
|
|
||||||
FormsModule,
|
|
||||||
FaIconComponent
|
|
||||||
]
|
|
||||||
})
|
|
||||||
export class ShareEnquiryComponent implements OnInit {
|
|
||||||
private sharesService = inject(SharesService);
|
|
||||||
private route = inject(ActivatedRoute);
|
|
||||||
private router = inject(Router);
|
|
||||||
|
|
||||||
search = '';
|
|
||||||
searching = false;
|
|
||||||
searched = false;
|
|
||||||
loadError = '';
|
|
||||||
|
|
||||||
/** Populated when the search matched more than one member — the caller picks. */
|
|
||||||
matches: any[] = [];
|
|
||||||
|
|
||||||
member: any = null;
|
|
||||||
accounts: any[] = [];
|
|
||||||
movements: any[] = [];
|
|
||||||
dividends: any[] = [];
|
|
||||||
totals: any = null;
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
|
||||||
const clientId = this.route.snapshot.queryParamMap.get('clientId');
|
|
||||||
if (clientId) {
|
|
||||||
this.loadByClient(Number(clientId));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
runSearch(): void {
|
|
||||||
const term = this.search.trim();
|
|
||||||
if (!term) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.searching = true;
|
|
||||||
this.loadError = '';
|
|
||||||
this.reset();
|
|
||||||
this.sharesService
|
|
||||||
.getShareEnquiry({ search: term })
|
|
||||||
.pipe(
|
|
||||||
catchError((err) => {
|
|
||||||
this.loadError = this.readError(err, 'The enquiry could not be run.');
|
|
||||||
return of(null);
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.subscribe((res: any) => this.apply(res));
|
|
||||||
}
|
|
||||||
|
|
||||||
loadByClient(clientId: number): void {
|
|
||||||
this.searching = true;
|
|
||||||
this.loadError = '';
|
|
||||||
this.reset();
|
|
||||||
this.sharesService
|
|
||||||
.getShareEnquiry({ clientId })
|
|
||||||
.pipe(
|
|
||||||
catchError((err) => {
|
|
||||||
this.loadError = this.readError(err, 'The member could not be loaded.');
|
|
||||||
return of(null);
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.subscribe((res: any) => this.apply(res));
|
|
||||||
}
|
|
||||||
|
|
||||||
private apply(res: any): void {
|
|
||||||
this.matches = res?.matches || [];
|
|
||||||
this.member = res?.member || null;
|
|
||||||
this.accounts = res?.accounts || [];
|
|
||||||
this.movements = res?.movements || [];
|
|
||||||
this.dividends = res?.dividends || [];
|
|
||||||
this.totals = res?.totals || null;
|
|
||||||
this.searching = false;
|
|
||||||
this.searched = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private reset(): void {
|
|
||||||
this.matches = [];
|
|
||||||
this.member = null;
|
|
||||||
this.accounts = [];
|
|
||||||
this.movements = [];
|
|
||||||
this.dividends = [];
|
|
||||||
this.totals = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
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';
|
|
||||||
}
|
|
||||||
|
|
||||||
txnStatusLabel(statusEnum: number): string {
|
|
||||||
return SHARE_TXN_STATUS[statusEnum]?.label || String(statusEnum ?? '');
|
|
||||||
}
|
|
||||||
|
|
||||||
txnStatusClass(statusEnum: number): string {
|
|
||||||
return SHARE_TXN_STATUS[statusEnum]?.cls || 'srep-pill--grey';
|
|
||||||
}
|
|
||||||
|
|
||||||
dividendStatusLabel(status: number): string {
|
|
||||||
return DIVIDEND_STATUS[status]?.label || String(status ?? '');
|
|
||||||
}
|
|
||||||
|
|
||||||
dividendStatusClass(status: number): string {
|
|
||||||
return DIVIDEND_STATUS[status]?.cls || 'srep-pill--grey';
|
|
||||||
}
|
|
||||||
|
|
||||||
funding(source: string): string {
|
|
||||||
return fundingLabel(source);
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
openAccount(account: any): void {
|
|
||||||
this.router.navigate([
|
|
||||||
'/clients',
|
|
||||||
this.member.clientId,
|
|
||||||
'shares-accounts',
|
|
||||||
account.shareAccountId,
|
|
||||||
'general'
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
openStatement(account: any): void {
|
|
||||||
this.router.navigate(['/shares/reports/account-statement'], {
|
|
||||||
queryParams: { shareAccountId: account.shareAccountId }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
exportCsv(): void {
|
|
||||||
if (!this.member) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
shareReportCsv(
|
|
||||||
`share-enquiry-${this.member.cifNumber || this.member.clientId}`,
|
|
||||||
[
|
|
||||||
'Date',
|
|
||||||
'Share A/c No',
|
|
||||||
'Particulars',
|
|
||||||
'Shares',
|
|
||||||
'Unit Price',
|
|
||||||
'Amount',
|
|
||||||
'Funded By',
|
|
||||||
'Board Resolution',
|
|
||||||
'Status'
|
|
||||||
],
|
|
||||||
this.movements.map((m) => [
|
|
||||||
m.transactionDate,
|
|
||||||
m.accountNo,
|
|
||||||
this.typeLabel(m.typeEnum),
|
|
||||||
m.shares,
|
|
||||||
m.unitPrice,
|
|
||||||
m.amount,
|
|
||||||
this.funding(m.fundingSource),
|
|
||||||
m.resolutionNumber,
|
|
||||||
this.txnStatusLabel(m.statusEnum)
|
|
||||||
])
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
print(): void {
|
|
||||||
shareReportPrint();
|
|
||||||
}
|
|
||||||
|
|
||||||
private readError(err: any, fallback: string): string {
|
|
||||||
return (
|
|
||||||
err?.error?.errors?.[0]?.defaultUserMessage ||
|
|
||||||
err?.error?.defaultUserMessage ||
|
|
||||||
err?.error?.developerMessage ||
|
|
||||||
fallback
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,208 +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="cert-root">
|
|
||||||
<div class="cert-head">
|
|
||||||
<div class="cert-head-l">
|
|
||||||
<fa-icon icon="chart-line" class="cert-head-ic"></fa-icon>
|
|
||||||
<div>
|
|
||||||
<h2 class="cert-head-t">Share Final Balance</h2>
|
|
||||||
<div class="cert-head-s">
|
|
||||||
Share capital held by each member as on a date — purchases less redemptions, settled only.
|
|
||||||
</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>
|
|
||||||
|
|
||||||
<!-- KPI strip — always the society-wide figures the server returned, never the filtered view -->
|
|
||||||
@if (totals) {
|
|
||||||
<div class="cert-kpis">
|
|
||||||
<div class="cert-kpi cert-kpi--blue">
|
|
||||||
<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 Holding</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 cert-kpi--amber">
|
|
||||||
<fa-icon icon="undo" class="cert-kpi-ic"></fa-icon>
|
|
||||||
<div>
|
|
||||||
<div class="cert-kpi-n">₹{{ totals.amountRedeemed | number: '1.2-2' }}</div>
|
|
||||||
<div class="cert-kpi-l">Redeemed To Date</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- Allotted but unpaid — excluded from the capital above, said out loud so the exclusion is not
|
|
||||||
a silent one, and because an allotment sitting unpaid is itself worth chasing. -->
|
|
||||||
@if (hasUnpaidAllotments) {
|
|
||||||
<div class="srep-warn">
|
|
||||||
<fa-icon icon="exclamation-circle"></fa-icon>
|
|
||||||
<span>
|
|
||||||
<b>{{ unpaidAllotments.accountCount | number }}</b>
|
|
||||||
{{ unpaidAllotments.accountCount === 1 ? 'account has' : 'accounts have' }} shares allotted but not yet paid for
|
|
||||||
— <b>{{ unpaidAllotments.shares | number }}</b> shares, <b>₹{{ unpaidAllotments.amount | number: '1.2-2' }}</b
|
|
||||||
>. These are excluded above: share capital is what the society has received, not what it has agreed to issue.
|
|
||||||
Apply the member's collection to bring them in.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- Filters -->
|
|
||||||
<div class="cert-filters">
|
|
||||||
<div class="cert-filter">
|
|
||||||
<label>As on date</label>
|
|
||||||
<input type="date" [(ngModel)]="asOnDate" (change)="load()" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="cert-filter">
|
|
||||||
<label>Member / Membership No / A/c No</label>
|
|
||||||
<div class="cert-input-ic">
|
|
||||||
<fa-icon icon="search"></fa-icon>
|
|
||||||
<input type="text" [(ngModel)]="search" placeholder="e.g. member name or 000000004" />
|
|
||||||
</div>
|
|
||||||
</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>Nil holdings</label>
|
|
||||||
<select [(ngModel)]="includeZero" (change)="load()">
|
|
||||||
<option [ngValue]="false">Hide fully redeemed</option>
|
|
||||||
<option [ngValue]="true">Include fully redeemed</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 share balances…</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 member held share capital as on {{ asOnDate }}.</div>
|
|
||||||
} @else if (!filteredRows.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>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 Held</th>
|
|
||||||
<th class="srep-num">Unit Price</th>
|
|
||||||
<th class="srep-num">Share Value</th>
|
|
||||||
<th class="srep-num">Purchased</th>
|
|
||||||
<th class="srep-num">Redeemed</th>
|
|
||||||
<th>Last Movement</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 }}</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.unitPrice | number: '1.2-2' }}</td>
|
|
||||||
<td class="srep-num">{{ row.shareValue | number: '1.2-2' }}</td>
|
|
||||||
<td class="srep-num">{{ row.sharesPurchased | number }}</td>
|
|
||||||
<td class="srep-num">{{ row.sharesRedeemed | number }}</td>
|
|
||||||
<td>{{ row.lastMovementDate || '—' }}</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
<tfoot>
|
|
||||||
<tr>
|
|
||||||
<td colspan="6">
|
|
||||||
{{ isFiltered ? 'Total (filtered)' : 'Total' }} — {{ filteredRows.length | number }} members
|
|
||||||
</td>
|
|
||||||
<td class="srep-num">{{ shownShares | number }}</td>
|
|
||||||
<td class="srep-num"></td>
|
|
||||||
<td class="srep-num">₹{{ shownValue | number: '1.2-2' }}</td>
|
|
||||||
<td class="srep-num"></td>
|
|
||||||
<td class="srep-num"></td>
|
|
||||||
<td></td>
|
|
||||||
</tr>
|
|
||||||
</tfoot>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (isFiltered && totals) {
|
|
||||||
<div class="srep-scope-note">
|
|
||||||
Showing a filtered view. Society-wide as on {{ asOnDate }}: {{ totals.sharesHeld | number }} shares held by
|
|
||||||
{{ totals.memberCount | number }} members, ₹{{ totals.shareValue | number: '1.2-2' }} share capital.
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
@@ -1,279 +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 { 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 { SettingsService } from 'app/settings/settings.service';
|
|
||||||
import {
|
|
||||||
SHARE_ACCOUNT_STATUS,
|
|
||||||
holdingStateClass,
|
|
||||||
holdingStateLabel,
|
|
||||||
holdingStateNote,
|
|
||||||
holdingStateRowClass,
|
|
||||||
shareReportCsv,
|
|
||||||
shareReportPrint
|
|
||||||
} from '../share-report.util';
|
|
||||||
|
|
||||||
/** One member's closing shareholding. Shapes the row the server sends; nothing is derived here. */
|
|
||||||
interface FinalBalanceRow {
|
|
||||||
shareAccountId: number;
|
|
||||||
accountNo: string;
|
|
||||||
clientId: number;
|
|
||||||
cifNumber: string;
|
|
||||||
clientName: string;
|
|
||||||
officeId: number;
|
|
||||||
officeName: string;
|
|
||||||
productId: number;
|
|
||||||
productName: string;
|
|
||||||
currencyCode: string;
|
|
||||||
statusEnum: number;
|
|
||||||
holdingState: string;
|
|
||||||
unpaidShares: number;
|
|
||||||
unitPrice: number;
|
|
||||||
sharesHeld: number;
|
|
||||||
shareValue: number;
|
|
||||||
sharesPurchased: number;
|
|
||||||
sharesRedeemed: number;
|
|
||||||
amountSubscribed: number;
|
|
||||||
amountRedeemed: number;
|
|
||||||
firstMovementDate: string;
|
|
||||||
lastMovementDate: string;
|
|
||||||
activatedDate: string;
|
|
||||||
closedDate: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Final Balance — what each member's share capital stands at on a given date, and what the society's
|
|
||||||
* Share Capital account should therefore read.
|
|
||||||
*
|
|
||||||
* <p>
|
|
||||||
* The date is the whole point of the report, so it is asked of the server rather than filtered in the
|
|
||||||
* browser: a holding "as on 31 March" means walking that member's purchases and redemptions up to
|
|
||||||
* that date, which the rows already on screen cannot answer. Changing the date re-queries.
|
|
||||||
*
|
|
||||||
* <p>
|
|
||||||
* Branch, product and search are client-side, because they only ever narrow rows already fetched and
|
|
||||||
* a round trip for each keystroke would be worse than useless.
|
|
||||||
*/
|
|
||||||
@Component({
|
|
||||||
selector: 'mifosx-share-final-balance',
|
|
||||||
templateUrl: './share-final-balance.component.html',
|
|
||||||
styleUrls: [
|
|
||||||
'../../../letter-mgmt/certificates/certificates.shared.scss',
|
|
||||||
'../share-report.shared.scss'
|
|
||||||
],
|
|
||||||
imports: [
|
|
||||||
CommonModule,
|
|
||||||
FormsModule,
|
|
||||||
FaIconComponent
|
|
||||||
]
|
|
||||||
})
|
|
||||||
export class ShareFinalBalanceComponent implements OnInit {
|
|
||||||
private sharesService = inject(SharesService);
|
|
||||||
private settingsService = inject(SettingsService);
|
|
||||||
private router = inject(Router);
|
|
||||||
|
|
||||||
loading = true;
|
|
||||||
loadError = '';
|
|
||||||
rows: FinalBalanceRow[] = [];
|
|
||||||
totals: any = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shares allotted to a member whose money never arrived — the account was activated but its
|
|
||||||
* collection was never applied.
|
|
||||||
*
|
|
||||||
* Deliberately kept out of the balance (share capital is what the society received, not what it
|
|
||||||
* promised to issue) and deliberately shown anyway: a total that silently drops accounts leaves a
|
|
||||||
* reader wondering which, and an allotment sitting unpaid is the thing worth chasing.
|
|
||||||
*/
|
|
||||||
unpaidAllotments: { accountCount: number; shares: number; amount: number } | null = null;
|
|
||||||
|
|
||||||
/** Server-side filter — changing it re-queries, since the balance itself depends on it. */
|
|
||||||
asOnDate = '';
|
|
||||||
/** Closed and fully-redeemed members, off by default: a closing-balance report lists holders. */
|
|
||||||
includeZero = false;
|
|
||||||
|
|
||||||
/** Client-side filters. */
|
|
||||||
search = '';
|
|
||||||
branchFilter = '';
|
|
||||||
productFilter = '';
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
|
||||||
const businessDate: Date = this.settingsService.businessDate || new Date();
|
|
||||||
this.asOnDate = this.toIso(businessDate);
|
|
||||||
this.load();
|
|
||||||
}
|
|
||||||
|
|
||||||
load(): void {
|
|
||||||
this.loading = true;
|
|
||||||
this.loadError = '';
|
|
||||||
this.sharesService
|
|
||||||
.getShareFinalBalance({ asOnDate: this.asOnDate, includeZero: this.includeZero })
|
|
||||||
.pipe(
|
|
||||||
catchError((err) => {
|
|
||||||
this.loadError = this.readError(err, 'The share balance report could not be loaded.');
|
|
||||||
return of(null);
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.subscribe((res: any) => {
|
|
||||||
this.rows = res?.rows || [];
|
|
||||||
this.totals = res?.totals || null;
|
|
||||||
this.unpaidAllotments = res?.unpaidAllotments || null;
|
|
||||||
this.loading = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
get hasUnpaidAllotments(): boolean {
|
|
||||||
return !!(this.unpaidAllotments && Number(this.unpaidAllotments.accountCount) > 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 filteredRows(): FinalBalanceRow[] {
|
|
||||||
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 (!term) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return [
|
|
||||||
r.clientName,
|
|
||||||
r.accountNo,
|
|
||||||
r.cifNumber,
|
|
||||||
r.productName
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.some((field) => String(field).toLowerCase().includes(term));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Totals for what is on screen. The server's own totals cover the unfiltered report and are shown
|
|
||||||
* beside these, so a narrowed view can never be mistaken for the society's whole share capital.
|
|
||||||
*/
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
statusLabel(statusEnum: number): string {
|
|
||||||
return SHARE_ACCOUNT_STATUS[statusEnum]?.label || String(statusEnum ?? '');
|
|
||||||
}
|
|
||||||
|
|
||||||
clearFilters(): void {
|
|
||||||
this.search = '';
|
|
||||||
this.branchFilter = '';
|
|
||||||
this.productFilter = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
openAccount(row: FinalBalanceRow): void {
|
|
||||||
this.router.navigate([
|
|
||||||
'/clients',
|
|
||||||
row.clientId,
|
|
||||||
'shares-accounts',
|
|
||||||
row.shareAccountId,
|
|
||||||
'general'
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
exportCsv(): void {
|
|
||||||
shareReportCsv(
|
|
||||||
`share-final-balance-${this.asOnDate || 'today'}`,
|
|
||||||
[
|
|
||||||
'Member',
|
|
||||||
'Membership No',
|
|
||||||
'Share A/c No',
|
|
||||||
'Branch',
|
|
||||||
'Product',
|
|
||||||
'Status',
|
|
||||||
'Holding State',
|
|
||||||
'Allotted Unpaid',
|
|
||||||
'Shares Held',
|
|
||||||
'Unit Price',
|
|
||||||
'Share Value',
|
|
||||||
'Shares Purchased',
|
|
||||||
'Shares Redeemed',
|
|
||||||
'Last Movement'
|
|
||||||
],
|
|
||||||
this.filteredRows.map((r) => [
|
|
||||||
r.clientName,
|
|
||||||
r.cifNumber,
|
|
||||||
r.accountNo,
|
|
||||||
r.officeName,
|
|
||||||
r.productName,
|
|
||||||
this.statusLabel(r.statusEnum),
|
|
||||||
this.stateLabel(r.holdingState),
|
|
||||||
r.unpaidShares,
|
|
||||||
r.sharesHeld,
|
|
||||||
r.unitPrice,
|
|
||||||
r.shareValue,
|
|
||||||
r.sharesPurchased,
|
|
||||||
r.sharesRedeemed,
|
|
||||||
r.lastMovementDate
|
|
||||||
])
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
print(): void {
|
|
||||||
shareReportPrint();
|
|
||||||
}
|
|
||||||
|
|
||||||
private toIso(date: Date): string {
|
|
||||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private readError(err: any, fallback: string): string {
|
|
||||||
return (
|
|
||||||
err?.error?.errors?.[0]?.defaultUserMessage ||
|
|
||||||
err?.error?.defaultUserMessage ||
|
|
||||||
err?.error?.developerMessage ||
|
|
||||||
fallback
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,250 +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="cert-root">
|
|
||||||
<div class="cert-head">
|
|
||||||
<div class="cert-head-l">
|
|
||||||
<fa-icon icon="file-alt" class="cert-head-ic"></fa-icon>
|
|
||||||
<div>
|
|
||||||
<h2 class="cert-head-t">Folio Statement</h2>
|
|
||||||
<div class="cert-head-s">
|
|
||||||
A member's folio in the share register — every account they hold, with the movements running across all of
|
|
||||||
them.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="cert-head-r">
|
|
||||||
<button class="cert-cta-btn" (click)="exportCsv()" [disabled]="!folio || !lines.length">
|
|
||||||
<fa-icon icon="file-alt"></fa-icon> Export CSV
|
|
||||||
</button>
|
|
||||||
<button class="cert-cta-btn" (click)="print()" [disabled]="!folio"><fa-icon icon="print"></fa-icon> Print</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="cert-filters">
|
|
||||||
<div class="cert-filter" style="flex: 2 1 320px">
|
|
||||||
<label>Member name, membership number or share account number</label>
|
|
||||||
<div class="cert-input-ic">
|
|
||||||
<fa-icon icon="search"></fa-icon>
|
|
||||||
<input type="text" [(ngModel)]="search" (keyup.enter)="runSearch()" placeholder="e.g. member name" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="cert-filter">
|
|
||||||
<label> </label>
|
|
||||||
<button class="cert-cta-btn" (click)="runSearch()" [disabled]="!search.trim() || searching">
|
|
||||||
{{ searching ? 'Searching…' : 'Open folio' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="cert-filter">
|
|
||||||
<label>From</label>
|
|
||||||
<input type="date" [(ngModel)]="fromDate" (change)="reloadPeriod()" [disabled]="!folio" />
|
|
||||||
</div>
|
|
||||||
<div class="cert-filter">
|
|
||||||
<label>To</label>
|
|
||||||
<input type="date" [(ngModel)]="toDate" (change)="reloadPeriod()" [disabled]="!folio" />
|
|
||||||
</div>
|
|
||||||
<div class="cert-filter">
|
|
||||||
<label> </label>
|
|
||||||
<button class="cert-cta-btn" (click)="clearPeriod()" [disabled]="!folio">Full history</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (loadError) {
|
|
||||||
<div class="srep-state srep-state--error"><fa-icon icon="exclamation-circle"></fa-icon> {{ loadError }}</div>
|
|
||||||
} @else if (searching) {
|
|
||||||
<div class="srep-state">Loading the folio…</div>
|
|
||||||
} @else if (matches.length) {
|
|
||||||
<div class="srep-state">{{ matches.length }} members match. Choose one:</div>
|
|
||||||
<div class="srep-table-wrap">
|
|
||||||
<table class="srep-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Member</th>
|
|
||||||
<th>Folio No</th>
|
|
||||||
<th></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@for (m of matches; track m.clientId) {
|
|
||||||
<tr class="srep-row--click" (click)="loadByClient(m.clientId)">
|
|
||||||
<td>{{ m.clientName }}</td>
|
|
||||||
<td class="srep-mono">{{ m.cifNumber || '—' }}</td>
|
|
||||||
<td><span class="srep-pill srep-pill--blue">Open</span></td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
} @else if (searched && !folio) {
|
|
||||||
<div class="srep-state">No member with a share account matches "{{ search }}".</div>
|
|
||||||
} @else if (folio) {
|
|
||||||
<div class="srep-stmt-head">
|
|
||||||
<div class="srep-stmt-grid">
|
|
||||||
<div>
|
|
||||||
<div class="srep-stmt-l">Folio No</div>
|
|
||||||
<div class="srep-stmt-v srep-mono">{{ folio.folioNumber || '—' }}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="srep-stmt-l">Member</div>
|
|
||||||
<div class="srep-stmt-v">{{ folio.clientName }}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="srep-stmt-l">Branch</div>
|
|
||||||
<div class="srep-stmt-v">{{ folio.officeName || '—' }}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="srep-stmt-l">Mobile</div>
|
|
||||||
<div class="srep-stmt-v">{{ folio.mobileNo || '—' }}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="srep-stmt-l">Member Since</div>
|
|
||||||
<div class="srep-stmt-v">{{ folio.memberSince || '—' }}</div>
|
|
||||||
</div>
|
|
||||||
</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">Accounts In Folio</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">Folio Value</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="cert-kpi cert-kpi--amber">
|
|
||||||
<fa-icon icon="exchange" class="cert-kpi-ic"></fa-icon>
|
|
||||||
<div>
|
|
||||||
<div class="cert-kpi-n">{{ totals.movementCount | number }}</div>
|
|
||||||
<div class="cert-kpi-l">Movements Shown</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- Accounts in this folio -->
|
|
||||||
<div class="cert-filters">
|
|
||||||
<div class="cert-filter" style="flex: 1 1 100%"><label>Accounts in this folio</label></div>
|
|
||||||
</div>
|
|
||||||
<div class="srep-table-wrap">
|
|
||||||
<table class="srep-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Share A/c No</th>
|
|
||||||
<th>Product</th>
|
|
||||||
<th>State</th>
|
|
||||||
<th class="srep-num">Shares Held</th>
|
|
||||||
<th class="srep-num">Unit Price</th>
|
|
||||||
<th class="srep-num">Value</th>
|
|
||||||
<th>Activated</th>
|
|
||||||
<th>Closed</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@for (a of accounts; track a.shareAccountId) {
|
|
||||||
<tr class="srep-row--click" [ngClass]="stateRowClass(a.holdingState)" (click)="openAccount(a)">
|
|
||||||
<td class="srep-mono">{{ a.accountNo }}</td>
|
|
||||||
<td>{{ a.productName }}</td>
|
|
||||||
<td>
|
|
||||||
<span class="srep-pill" [ngClass]="stateClass(a.holdingState)" [title]="stateNote(a.holdingState)">{{
|
|
||||||
stateLabel(a.holdingState)
|
|
||||||
}}</span>
|
|
||||||
</td>
|
|
||||||
<td class="srep-num">
|
|
||||||
{{ a.sharesHeld | number }}
|
|
||||||
@if (a.holdingState === 'UNPAID') {
|
|
||||||
<div class="srep-sub">{{ a.unpaidShares | number }} unpaid</div>
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
<td class="srep-num">{{ a.unitPrice | number: '1.2-2' }}</td>
|
|
||||||
<td class="srep-num">₹{{ a.shareValue | number: '1.2-2' }}</td>
|
|
||||||
<td>{{ a.activatedDate || '—' }}</td>
|
|
||||||
<td>{{ a.closedDate || '—' }}</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
<tfoot>
|
|
||||||
<tr>
|
|
||||||
<td colspan="3">Folio total</td>
|
|
||||||
<td class="srep-num">{{ totals?.sharesHeld | number }}</td>
|
|
||||||
<td class="srep-num"></td>
|
|
||||||
<td class="srep-num">₹{{ totals?.shareValue | number: '1.2-2' }}</td>
|
|
||||||
<td colspan="2"></td>
|
|
||||||
</tr>
|
|
||||||
</tfoot>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Movements across the whole folio -->
|
|
||||||
<div class="cert-filters" style="margin-top: 0.85rem">
|
|
||||||
<div class="cert-filter" style="flex: 1 1 100%"><label>Folio movements</label></div>
|
|
||||||
</div>
|
|
||||||
@if (!lines.length) {
|
|
||||||
<div class="srep-state">
|
|
||||||
No settled movement in this folio{{ fromDate || toDate ? ' in the selected period' : '' }}.
|
|
||||||
</div>
|
|
||||||
} @else {
|
|
||||||
<div class="srep-table-wrap">
|
|
||||||
<table class="srep-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Date</th>
|
|
||||||
<th>Share A/c No</th>
|
|
||||||
<th>Product</th>
|
|
||||||
<th>Particulars</th>
|
|
||||||
<th class="srep-num">Shares In</th>
|
|
||||||
<th class="srep-num">Shares Out</th>
|
|
||||||
<th class="srep-num">Amount</th>
|
|
||||||
<th>Funded By</th>
|
|
||||||
<th class="srep-num">Folio Balance</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@for (line of lines; track line.transactionId) {
|
|
||||||
<tr>
|
|
||||||
<td>{{ line.transactionDate }}</td>
|
|
||||||
<td class="srep-mono">{{ line.accountNo }}</td>
|
|
||||||
<td>{{ line.productName }}</td>
|
|
||||||
<td>
|
|
||||||
<span class="srep-pill" [ngClass]="typeClass(line.typeEnum)">{{ typeLabel(line.typeEnum) }}</span>
|
|
||||||
</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.amount | number: '1.2-2' }}</td>
|
|
||||||
<td>{{ funding(line.fundingSource) }}</td>
|
|
||||||
<td class="srep-num">{{ line.folioBalance | number }}</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
<tfoot>
|
|
||||||
<tr>
|
|
||||||
<td colspan="8">Closing folio balance</td>
|
|
||||||
<td class="srep-num">{{ totals?.closingFolioBalance | number }}</td>
|
|
||||||
</tr>
|
|
||||||
</tfoot>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
} @else {
|
|
||||||
<div class="srep-state">Search for a member above to open their folio.</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
@@ -1,217 +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 { ActivatedRoute, 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_TXN_TYPE,
|
|
||||||
fundingLabel,
|
|
||||||
holdingStateClass,
|
|
||||||
holdingStateLabel,
|
|
||||||
holdingStateNote,
|
|
||||||
holdingStateRowClass,
|
|
||||||
shareReportCsv,
|
|
||||||
shareReportPrint
|
|
||||||
} from '../share-report.util';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Folio Statement — one member's folio in the share register: every account they hold and every
|
|
||||||
* movement across all of them, in one running sequence.
|
|
||||||
*
|
|
||||||
* <p>
|
|
||||||
* Where the Account Statement is one account, a folio is the member. Someone holding two share
|
|
||||||
* classes has two accounts but one folio, and the register is kept by folio — so the running balance
|
|
||||||
* here is across accounts, which is the figure the register itself carries.
|
|
||||||
*/
|
|
||||||
@Component({
|
|
||||||
selector: 'mifosx-share-folio-statement',
|
|
||||||
templateUrl: './share-folio-statement.component.html',
|
|
||||||
styleUrls: [
|
|
||||||
'../../../letter-mgmt/certificates/certificates.shared.scss',
|
|
||||||
'../share-report.shared.scss'
|
|
||||||
],
|
|
||||||
imports: [
|
|
||||||
CommonModule,
|
|
||||||
FormsModule,
|
|
||||||
FaIconComponent
|
|
||||||
]
|
|
||||||
})
|
|
||||||
export class ShareFolioStatementComponent implements OnInit {
|
|
||||||
private sharesService = inject(SharesService);
|
|
||||||
private route = inject(ActivatedRoute);
|
|
||||||
private router = inject(Router);
|
|
||||||
|
|
||||||
search = '';
|
|
||||||
searching = false;
|
|
||||||
searched = false;
|
|
||||||
loadError = '';
|
|
||||||
|
|
||||||
matches: any[] = [];
|
|
||||||
folio: any = null;
|
|
||||||
accounts: any[] = [];
|
|
||||||
lines: any[] = [];
|
|
||||||
totals: any = null;
|
|
||||||
|
|
||||||
fromDate = '';
|
|
||||||
toDate = '';
|
|
||||||
|
|
||||||
/** Held once a folio is open, so changing the period re-queries the same member. */
|
|
||||||
private resolvedClientId: number | null = null;
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
|
||||||
const clientId = this.route.snapshot.queryParamMap.get('clientId');
|
|
||||||
if (clientId) {
|
|
||||||
this.resolvedClientId = Number(clientId);
|
|
||||||
this.fetch({ clientId: this.resolvedClientId });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
runSearch(): void {
|
|
||||||
const term = this.search.trim();
|
|
||||||
if (!term) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.resolvedClientId = null;
|
|
||||||
this.fetch({ search: term });
|
|
||||||
}
|
|
||||||
|
|
||||||
loadByClient(clientId: number): void {
|
|
||||||
this.resolvedClientId = clientId;
|
|
||||||
this.fetch({ clientId });
|
|
||||||
}
|
|
||||||
|
|
||||||
reloadPeriod(): void {
|
|
||||||
if (this.resolvedClientId) {
|
|
||||||
this.fetch({ clientId: this.resolvedClientId });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
clearPeriod(): void {
|
|
||||||
this.fromDate = '';
|
|
||||||
this.toDate = '';
|
|
||||||
this.reloadPeriod();
|
|
||||||
}
|
|
||||||
|
|
||||||
private fetch(params: { search?: string; clientId?: number }): void {
|
|
||||||
this.searching = true;
|
|
||||||
this.loadError = '';
|
|
||||||
this.sharesService
|
|
||||||
.getShareFolioStatement({ ...params, fromDate: this.fromDate, toDate: this.toDate })
|
|
||||||
.pipe(
|
|
||||||
catchError((err) => {
|
|
||||||
this.loadError = this.readError(err, 'The folio statement could not be loaded.');
|
|
||||||
return of(null);
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.subscribe((res: any) => {
|
|
||||||
this.matches = res?.matches || [];
|
|
||||||
this.folio = res?.folio || null;
|
|
||||||
this.accounts = res?.accounts || [];
|
|
||||||
this.lines = res?.lines || [];
|
|
||||||
this.totals = res?.totals || null;
|
|
||||||
if (this.folio) {
|
|
||||||
this.resolvedClientId = this.folio.clientId;
|
|
||||||
}
|
|
||||||
this.searching = false;
|
|
||||||
this.searched = true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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';
|
|
||||||
}
|
|
||||||
|
|
||||||
funding(source: string): string {
|
|
||||||
return fundingLabel(source);
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
openAccount(account: any): void {
|
|
||||||
this.router.navigate([
|
|
||||||
'/clients',
|
|
||||||
this.folio.clientId,
|
|
||||||
'shares-accounts',
|
|
||||||
account.shareAccountId,
|
|
||||||
'general'
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
exportCsv(): void {
|
|
||||||
if (!this.folio) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
shareReportCsv(
|
|
||||||
`folio-statement-${this.folio.folioNumber || this.folio.clientId}`,
|
|
||||||
[
|
|
||||||
'Date',
|
|
||||||
'Share A/c No',
|
|
||||||
'Product',
|
|
||||||
'Particulars',
|
|
||||||
'Shares In',
|
|
||||||
'Shares Out',
|
|
||||||
'Unit Price',
|
|
||||||
'Amount',
|
|
||||||
'Funded By',
|
|
||||||
'Board Resolution',
|
|
||||||
'Folio Balance'
|
|
||||||
],
|
|
||||||
this.lines.map((l) => [
|
|
||||||
l.transactionDate,
|
|
||||||
l.accountNo,
|
|
||||||
l.productName,
|
|
||||||
this.typeLabel(l.typeEnum),
|
|
||||||
l.sharesIn || '',
|
|
||||||
l.sharesOut || '',
|
|
||||||
l.unitPrice,
|
|
||||||
l.amount,
|
|
||||||
this.funding(l.fundingSource),
|
|
||||||
l.resolutionNumber,
|
|
||||||
l.folioBalance
|
|
||||||
])
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
print(): void {
|
|
||||||
shareReportPrint();
|
|
||||||
}
|
|
||||||
|
|
||||||
private readError(err: any, fallback: string): string {
|
|
||||||
return (
|
|
||||||
err?.error?.errors?.[0]?.defaultUserMessage ||
|
|
||||||
err?.error?.defaultUserMessage ||
|
|
||||||
err?.error?.developerMessage ||
|
|
||||||
fallback
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,162 +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="cert-root">
|
|
||||||
<div class="cert-head">
|
|
||||||
<div class="cert-head-l">
|
|
||||||
<fa-icon icon="user-shield" class="cert-head-ic"></fa-icon>
|
|
||||||
<div>
|
|
||||||
<h2 class="cert-head-t">Guarantor Detail</h2>
|
|
||||||
<div class="cert-head-s">
|
|
||||||
Everyone standing surety on a live loan, with the shareholding they hold in the society.
|
|
||||||
</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="user-shield" class="cert-kpi-ic"></fa-icon>
|
|
||||||
<div>
|
|
||||||
<div class="cert-kpi-n">{{ totals.guarantorCount | number }}</div>
|
|
||||||
<div class="cert-kpi-l">Active Guarantors</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.guaranteedOutstanding | number: '1.2-2' }}</div>
|
|
||||||
<div class="cert-kpi-l">Guaranteed Outstanding</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.guarantorShareValue | number: '1.2-2' }}</div>
|
|
||||||
<div class="cert-kpi-l">Guarantors' Share Value</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="cert-kpi" [ngClass]="totals.membersWithoutShares ? 'cert-kpi--amber' : 'cert-kpi--blue'">
|
|
||||||
<fa-icon icon="exclamation-circle" class="cert-kpi-ic"></fa-icon>
|
|
||||||
<div>
|
|
||||||
<div class="cert-kpi-n">{{ totals.membersWithoutShares | number }}</div>
|
|
||||||
<div class="cert-kpi-l">Members Without Shares</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="cert-filters">
|
|
||||||
<div class="cert-filter">
|
|
||||||
<label>Guarantor / Borrower / Loan A/c</label>
|
|
||||||
<div class="cert-input-ic">
|
|
||||||
<fa-icon icon="search"></fa-icon>
|
|
||||||
<input type="text" [(ngModel)]="search" placeholder="e.g. guarantor or borrower name" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="cert-filter">
|
|
||||||
<label>Guarantor type</label>
|
|
||||||
<select [(ngModel)]="typeFilter">
|
|
||||||
<option value="">All</option>
|
|
||||||
<option value="MEMBER">Member</option>
|
|
||||||
<option value="EXTERNAL">External</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="cert-filter">
|
|
||||||
<label>Shareholding</label>
|
|
||||||
<select [(ngModel)]="onlyWithoutShares">
|
|
||||||
<option [ngValue]="false">All guarantors</option>
|
|
||||||
<option [ngValue]="true">Members holding no shares</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 guarantors…</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 guarantor stands on a loan at present.</div>
|
|
||||||
} @else if (!filteredRows.length) {
|
|
||||||
<div class="srep-state">No guarantor matches the filters applied.</div>
|
|
||||||
} @else {
|
|
||||||
<div class="srep-table-wrap">
|
|
||||||
<table class="srep-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Guarantor</th>
|
|
||||||
<th>Type</th>
|
|
||||||
<th>Membership No</th>
|
|
||||||
<th>Borrower</th>
|
|
||||||
<th>Loan A/c No</th>
|
|
||||||
<th class="srep-num">Loan Outstanding</th>
|
|
||||||
<th>Share A/c No</th>
|
|
||||||
<th class="srep-num">Shares Held</th>
|
|
||||||
<th class="srep-num">Share Value</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@for (row of filteredRows; track row.guarantorId) {
|
|
||||||
<tr [class.srep-row--click]="row.shareAccountId" (click)="openGuarantorShares(row)">
|
|
||||||
<td>
|
|
||||||
{{ row.guarantorName || '—' }}
|
|
||||||
@if (row.guarantorMobile) {
|
|
||||||
<div class="srep-scope-note" style="margin: 0">{{ row.guarantorMobile }}</div>
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span
|
|
||||||
class="srep-pill"
|
|
||||||
[ngClass]="row.guarantorType === 'MEMBER' ? 'srep-pill--green' : 'srep-pill--grey'"
|
|
||||||
>{{ row.guarantorType === 'MEMBER' ? 'Member' : 'External' }}</span
|
|
||||||
>
|
|
||||||
</td>
|
|
||||||
<td class="srep-mono">{{ row.guarantorCif || '—' }}</td>
|
|
||||||
<td>{{ row.borrowerName }}</td>
|
|
||||||
<td class="srep-mono">{{ row.loanAccountNo }}</td>
|
|
||||||
<td class="srep-num">₹{{ row.totalOutstanding | number: '1.2-2' }}</td>
|
|
||||||
<td class="srep-mono">{{ row.shareAccountNo || '—' }}</td>
|
|
||||||
<td class="srep-num">
|
|
||||||
@if (isUncovered(row)) {
|
|
||||||
<span class="srep-pill srep-pill--amber">No shares</span>
|
|
||||||
} @else {
|
|
||||||
{{ row.sharesHeld | number }}
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
<td class="srep-num">₹{{ row.shareValue | number: '1.2-2' }}</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
<tfoot>
|
|
||||||
<tr>
|
|
||||||
<td colspan="5">
|
|
||||||
{{ isFiltered ? 'Total (filtered)' : 'Total' }} — {{ filteredRows.length | number }} guarantors
|
|
||||||
</td>
|
|
||||||
<td class="srep-num">₹{{ shownOutstanding | number: '1.2-2' }}</td>
|
|
||||||
<td></td>
|
|
||||||
<td class="srep-num"></td>
|
|
||||||
<td class="srep-num">₹{{ shownShareValue | number: '1.2-2' }}</td>
|
|
||||||
</tr>
|
|
||||||
</tfoot>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
@@ -1,208 +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 { 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 GuarantorRow {
|
|
||||||
guarantorId: number;
|
|
||||||
loanId: number;
|
|
||||||
loanAccountNo: string;
|
|
||||||
loanStatusId: number;
|
|
||||||
loanPrincipal: number;
|
|
||||||
principalOutstanding: number;
|
|
||||||
totalOutstanding: number;
|
|
||||||
borrowerName: string;
|
|
||||||
borrowerCif: string;
|
|
||||||
guarantorTypeEnum: number;
|
|
||||||
guarantorType: 'MEMBER' | 'EXTERNAL';
|
|
||||||
guarantorName: string;
|
|
||||||
guarantorCif: string;
|
|
||||||
guarantorClientId: number;
|
|
||||||
guarantorOfficeName: string;
|
|
||||||
guarantorMobile: string;
|
|
||||||
shareAccountId: number;
|
|
||||||
shareAccountNo: string;
|
|
||||||
shareProductName: string;
|
|
||||||
sharesHeld: number;
|
|
||||||
shareValue: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Guarantor Detail — everyone standing surety on a live loan, with the shareholding they hold in the
|
|
||||||
* society.
|
|
||||||
*
|
|
||||||
* <p>
|
|
||||||
* A co-operative accepts a member as surety on the strength of their stake in it, so the exposure
|
|
||||||
* and the stake belong on one line. The case this report exists for is the member who stood surety
|
|
||||||
* and has since redeemed their shares: nothing on the loan screen or the share screen shows it, and
|
|
||||||
* it is flagged here rather than left to be noticed.
|
|
||||||
*/
|
|
||||||
@Component({
|
|
||||||
selector: 'mifosx-share-guarantor-report',
|
|
||||||
templateUrl: './share-guarantor-report.component.html',
|
|
||||||
styleUrls: [
|
|
||||||
'../../../letter-mgmt/certificates/certificates.shared.scss',
|
|
||||||
'../share-report.shared.scss'
|
|
||||||
],
|
|
||||||
imports: [
|
|
||||||
CommonModule,
|
|
||||||
FormsModule,
|
|
||||||
FaIconComponent
|
|
||||||
]
|
|
||||||
})
|
|
||||||
export class ShareGuarantorReportComponent implements OnInit {
|
|
||||||
private sharesService = inject(SharesService);
|
|
||||||
private router = inject(Router);
|
|
||||||
|
|
||||||
loading = true;
|
|
||||||
loadError = '';
|
|
||||||
rows: GuarantorRow[] = [];
|
|
||||||
totals: any = null;
|
|
||||||
|
|
||||||
search = '';
|
|
||||||
typeFilter = '';
|
|
||||||
/** Members standing surety with no shareholding left — the exposure worth isolating. */
|
|
||||||
onlyWithoutShares = false;
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
|
||||||
this.load();
|
|
||||||
}
|
|
||||||
|
|
||||||
load(): void {
|
|
||||||
this.loading = true;
|
|
||||||
this.loadError = '';
|
|
||||||
this.sharesService
|
|
||||||
.getShareGuarantorDetail({})
|
|
||||||
.pipe(
|
|
||||||
catchError((err) => {
|
|
||||||
this.loadError = this.readError(err, 'The guarantor report could not be loaded.');
|
|
||||||
return of(null);
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.subscribe((res: any) => {
|
|
||||||
this.rows = res?.rows || [];
|
|
||||||
this.totals = res?.totals || null;
|
|
||||||
this.loading = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
get filteredRows(): GuarantorRow[] {
|
|
||||||
const term = this.search.trim().toLowerCase();
|
|
||||||
return this.rows.filter((r) => {
|
|
||||||
if (this.typeFilter && r.guarantorType !== this.typeFilter) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (this.onlyWithoutShares && !(r.guarantorType === 'MEMBER' && !Number(r.sharesHeld))) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!term) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return [
|
|
||||||
r.guarantorName,
|
|
||||||
r.guarantorCif,
|
|
||||||
r.borrowerName,
|
|
||||||
r.loanAccountNo,
|
|
||||||
r.shareAccountNo
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.some((field) => String(field).toLowerCase().includes(term));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
get shownOutstanding(): number {
|
|
||||||
return this.filteredRows.reduce((sum, r) => sum + Number(r.totalOutstanding || 0), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
get shownShareValue(): number {
|
|
||||||
return this.filteredRows.reduce((sum, r) => sum + Number(r.shareValue || 0), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
get isFiltered(): boolean {
|
|
||||||
return !!(this.search.trim() || this.typeFilter || this.onlyWithoutShares);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A member guarantor holding nothing is the exposure; an external one never held any. */
|
|
||||||
isUncovered(row: GuarantorRow): boolean {
|
|
||||||
return row.guarantorType === 'MEMBER' && !Number(row.sharesHeld);
|
|
||||||
}
|
|
||||||
|
|
||||||
openGuarantorShares(row: GuarantorRow): void {
|
|
||||||
if (row.guarantorClientId && row.shareAccountId) {
|
|
||||||
this.router.navigate([
|
|
||||||
'/clients',
|
|
||||||
row.guarantorClientId,
|
|
||||||
'shares-accounts',
|
|
||||||
row.shareAccountId,
|
|
||||||
'general'
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
clearFilters(): void {
|
|
||||||
this.search = '';
|
|
||||||
this.typeFilter = '';
|
|
||||||
this.onlyWithoutShares = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
exportCsv(): void {
|
|
||||||
shareReportCsv(
|
|
||||||
'share-guarantor-detail',
|
|
||||||
[
|
|
||||||
'Guarantor',
|
|
||||||
'Type',
|
|
||||||
'Membership No',
|
|
||||||
'Branch',
|
|
||||||
'Mobile',
|
|
||||||
'Borrower',
|
|
||||||
'Loan A/c No',
|
|
||||||
'Loan Outstanding',
|
|
||||||
'Share A/c No',
|
|
||||||
'Share Product',
|
|
||||||
'Shares Held',
|
|
||||||
'Share Value'
|
|
||||||
],
|
|
||||||
this.filteredRows.map((r) => [
|
|
||||||
r.guarantorName,
|
|
||||||
r.guarantorType,
|
|
||||||
r.guarantorCif,
|
|
||||||
r.guarantorOfficeName,
|
|
||||||
r.guarantorMobile,
|
|
||||||
r.borrowerName,
|
|
||||||
r.loanAccountNo,
|
|
||||||
r.totalOutstanding,
|
|
||||||
r.shareAccountNo,
|
|
||||||
r.shareProductName,
|
|
||||||
r.sharesHeld,
|
|
||||||
r.shareValue
|
|
||||||
])
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
print(): void {
|
|
||||||
shareReportPrint();
|
|
||||||
}
|
|
||||||
|
|
||||||
private readError(err: any, fallback: string): string {
|
|
||||||
return (
|
|
||||||
err?.error?.errors?.[0]?.defaultUserMessage ||
|
|
||||||
err?.error?.defaultUserMessage ||
|
|
||||||
err?.error?.developerMessage ||
|
|
||||||
fallback
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,204 +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="cert-root">
|
|
||||||
<div class="cert-head">
|
|
||||||
<div class="cert-head-l">
|
|
||||||
<fa-icon icon="check-circle" class="cert-head-ic"></fa-icon>
|
|
||||||
<div>
|
|
||||||
<h2 class="cert-head-t">Share Issued Register</h2>
|
|
||||||
<div class="cert-head-s">
|
|
||||||
Every tranche of shares allotted in the period, with the board resolution behind it and how it was paid for.
|
|
||||||
</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.trancheCount | number }}</div>
|
|
||||||
<div class="cert-kpi-l">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.shares | number }}</div>
|
|
||||||
<div class="cert-kpi-l">Shares Issued</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.amount | number: '1.2-2' }}</div>
|
|
||||||
<div class="cert-kpi-l">Capital Raised</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="cert-kpi" [ngClass]="missingResolutionCount ? 'cert-kpi--amber' : 'cert-kpi--blue'">
|
|
||||||
<fa-icon icon="exclamation-circle" class="cert-kpi-ic"></fa-icon>
|
|
||||||
<div>
|
|
||||||
<div class="cert-kpi-n">{{ missingResolutionCount | number }}</div>
|
|
||||||
<div class="cert-kpi-l">Without Resolution</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="cert-filters">
|
|
||||||
<div class="cert-filter">
|
|
||||||
<label>Issued from</label>
|
|
||||||
<input type="date" [(ngModel)]="fromDate" (change)="load()" />
|
|
||||||
</div>
|
|
||||||
<div class="cert-filter">
|
|
||||||
<label>Issued to</label>
|
|
||||||
<input type="date" [(ngModel)]="toDate" (change)="load()" />
|
|
||||||
</div>
|
|
||||||
<div class="cert-filter">
|
|
||||||
<label>Allotment status</label>
|
|
||||||
<select [(ngModel)]="statusFilter" (change)="load()">
|
|
||||||
<option [ngValue]="''">Settled allotments</option>
|
|
||||||
<option [ngValue]="100">Pending approval</option>
|
|
||||||
<option [ngValue]="400">Rejected</option>
|
|
||||||
</select>
|
|
||||||
</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 or BR-2026-14" />
|
|
||||||
</div>
|
|
||||||
</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>Funded by</label>
|
|
||||||
<select [(ngModel)]="fundingFilter">
|
|
||||||
<option value="">All routes</option>
|
|
||||||
@for (f of fundingOptions; track f) {
|
|
||||||
<option [value]="f">{{ f }}</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 issue 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 shares were allotted between {{ fromDate }} and {{ toDate }}.</div>
|
|
||||||
} @else if (!filteredRows.length) {
|
|
||||||
<div class="srep-state">No allotment matches the filters applied.</div>
|
|
||||||
} @else {
|
|
||||||
<div class="srep-table-wrap">
|
|
||||||
<table class="srep-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Issue Date</th>
|
|
||||||
<th>Member</th>
|
|
||||||
<th>Membership No</th>
|
|
||||||
<th>Share A/c No</th>
|
|
||||||
<th>Branch</th>
|
|
||||||
<th>Product</th>
|
|
||||||
<th class="srep-num">Shares</th>
|
|
||||||
<th class="srep-num">Unit Price</th>
|
|
||||||
<th class="srep-num">Amount</th>
|
|
||||||
<th class="srep-num">Entry Fee</th>
|
|
||||||
<th>Funded By</th>
|
|
||||||
<th>Board Resolution</th>
|
|
||||||
<th>Status</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@for (row of filteredRows; track row.transactionId) {
|
|
||||||
<tr class="srep-row--click" (click)="openAccount(row)">
|
|
||||||
<td>{{ row.issueDate }}</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.productName }}</td>
|
|
||||||
<td class="srep-num">{{ row.shares | number }}</td>
|
|
||||||
<td class="srep-num">{{ row.unitPrice | number: '1.2-2' }}</td>
|
|
||||||
<td class="srep-num">{{ row.amount | number: '1.2-2' }}</td>
|
|
||||||
<td class="srep-num">{{ row.chargeAmount ? (row.chargeAmount | number: '1.2-2') : '—' }}</td>
|
|
||||||
<td>{{ funding(row.fundingSource) }}</td>
|
|
||||||
<td>
|
|
||||||
@if (row.resolutionNumber) {
|
|
||||||
<span class="srep-mono">{{ row.resolutionNumber }}</span>
|
|
||||||
@if (row.meetingDate) {
|
|
||||||
<div class="srep-scope-note" style="margin: 0">{{ row.meetingDate }}</div>
|
|
||||||
}
|
|
||||||
} @else {
|
|
||||||
<span class="srep-pill srep-pill--amber">Not recorded</span>
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span class="srep-pill" [ngClass]="txnStatusClass(row.statusEnum)">{{
|
|
||||||
txnStatusLabel(row.statusEnum)
|
|
||||||
}}</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
<tfoot>
|
|
||||||
<tr>
|
|
||||||
<td colspan="6">
|
|
||||||
{{ isFiltered ? 'Total (filtered)' : 'Total' }} — {{ filteredRows.length | number }} allotments
|
|
||||||
</td>
|
|
||||||
<td class="srep-num">{{ shownShares | number }}</td>
|
|
||||||
<td class="srep-num"></td>
|
|
||||||
<td class="srep-num">₹{{ shownAmount | number: '1.2-2' }}</td>
|
|
||||||
<td class="srep-num"></td>
|
|
||||||
<td colspan="3"></td>
|
|
||||||
</tr>
|
|
||||||
</tfoot>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (isFiltered && totals) {
|
|
||||||
<div class="srep-scope-note">
|
|
||||||
Showing a filtered view. Whole period {{ fromDate }} to {{ toDate }}: {{ totals.shares | number }} shares issued
|
|
||||||
to {{ totals.memberCount | number }} members across {{ totals.trancheCount | number }} allotments, ₹{{
|
|
||||||
totals.amount | number: '1.2-2'
|
|
||||||
}}.
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
@@ -1,264 +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 { 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 { SettingsService } from 'app/settings/settings.service';
|
|
||||||
import { SHARE_TXN_STATUS, fundingLabel, shareReportCsv, shareReportPrint } from '../share-report.util';
|
|
||||||
|
|
||||||
interface IssuedRow {
|
|
||||||
transactionId: number;
|
|
||||||
issueDate: string;
|
|
||||||
shareAccountId: number;
|
|
||||||
accountNo: string;
|
|
||||||
clientId: number;
|
|
||||||
cifNumber: string;
|
|
||||||
clientName: string;
|
|
||||||
officeId: number;
|
|
||||||
officeName: string;
|
|
||||||
productId: number;
|
|
||||||
productName: string;
|
|
||||||
currencyCode: string;
|
|
||||||
shares: number;
|
|
||||||
unitPrice: number;
|
|
||||||
amount: number;
|
|
||||||
chargeAmount: number;
|
|
||||||
statusEnum: number;
|
|
||||||
resolutionNumber: string;
|
|
||||||
meetingDate: string;
|
|
||||||
fundingSource: string;
|
|
||||||
accountStatusEnum: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Share Issued — the allotment register: every tranche of shares issued in a period, with the board
|
|
||||||
* resolution that authorised it and how the member paid.
|
|
||||||
*
|
|
||||||
* <p>
|
|
||||||
* One row per tranche, not per member: a member who bought three times was allotted three times, and
|
|
||||||
* the register records allotments. Rolling them up per member would lose the resolution each one was
|
|
||||||
* made under, which is the column an auditor actually reads.
|
|
||||||
*
|
|
||||||
* <p>
|
|
||||||
* Period and status are server-side (the register is a period document, and "what is pending" is a
|
|
||||||
* different query, not a different view of the same rows); branch, product and search are local.
|
|
||||||
*/
|
|
||||||
@Component({
|
|
||||||
selector: 'mifosx-share-issued-report',
|
|
||||||
templateUrl: './share-issued-report.component.html',
|
|
||||||
styleUrls: [
|
|
||||||
'../../../letter-mgmt/certificates/certificates.shared.scss',
|
|
||||||
'../share-report.shared.scss'
|
|
||||||
],
|
|
||||||
imports: [
|
|
||||||
CommonModule,
|
|
||||||
FormsModule,
|
|
||||||
FaIconComponent
|
|
||||||
]
|
|
||||||
})
|
|
||||||
export class ShareIssuedReportComponent implements OnInit {
|
|
||||||
private sharesService = inject(SharesService);
|
|
||||||
private settingsService = inject(SettingsService);
|
|
||||||
private router = inject(Router);
|
|
||||||
|
|
||||||
loading = true;
|
|
||||||
loadError = '';
|
|
||||||
rows: IssuedRow[] = [];
|
|
||||||
totals: any = null;
|
|
||||||
|
|
||||||
// ── Server-side ──
|
|
||||||
fromDate = '';
|
|
||||||
toDate = '';
|
|
||||||
/** '' = settled allotments only (the register's default); 100 = pending; 400 = rejected. */
|
|
||||||
statusFilter: number | '' = '';
|
|
||||||
|
|
||||||
// ── Client-side ──
|
|
||||||
search = '';
|
|
||||||
branchFilter = '';
|
|
||||||
productFilter = '';
|
|
||||||
fundingFilter = '';
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
|
||||||
const businessDate: Date = this.settingsService.businessDate || new Date();
|
|
||||||
// Opens on the current financial year, which is the period this register is almost always run
|
|
||||||
// for. April opens the FY in this system, matching the Share Capital Return.
|
|
||||||
const fyStart = new Date(businessDate.getFullYear(), businessDate.getMonth() >= 3 ? 3 : -9, 1);
|
|
||||||
this.fromDate = this.toIso(fyStart);
|
|
||||||
this.toDate = this.toIso(businessDate);
|
|
||||||
this.load();
|
|
||||||
}
|
|
||||||
|
|
||||||
load(): void {
|
|
||||||
this.loading = true;
|
|
||||||
this.loadError = '';
|
|
||||||
this.sharesService
|
|
||||||
.getShareIssuedReport({
|
|
||||||
fromDate: this.fromDate,
|
|
||||||
toDate: this.toDate,
|
|
||||||
status: this.statusFilter === '' ? undefined : this.statusFilter
|
|
||||||
})
|
|
||||||
.pipe(
|
|
||||||
catchError((err) => {
|
|
||||||
this.loadError = this.readError(err, 'The share issue 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 fundingOptions(): string[] {
|
|
||||||
return [...new Set(this.rows.map((r) => fundingLabel(r.fundingSource)))].sort();
|
|
||||||
}
|
|
||||||
|
|
||||||
get filteredRows(): IssuedRow[] {
|
|
||||||
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.fundingFilter && fundingLabel(r.fundingSource) !== this.fundingFilter) {
|
|
||||||
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 shownAmount(): number {
|
|
||||||
return this.filteredRows.reduce((sum, r) => sum + Number(r.amount || 0), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
get isFiltered(): boolean {
|
|
||||||
return !!(this.search.trim() || this.branchFilter || this.productFilter || this.fundingFilter);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Tranches with no resolution recorded — the one thing an auditor opens this register to find. */
|
|
||||||
get missingResolutionCount(): number {
|
|
||||||
return this.filteredRows.filter((r) => !r.resolutionNumber).length;
|
|
||||||
}
|
|
||||||
|
|
||||||
txnStatusLabel(statusEnum: number): string {
|
|
||||||
return SHARE_TXN_STATUS[statusEnum]?.label || String(statusEnum ?? '');
|
|
||||||
}
|
|
||||||
|
|
||||||
txnStatusClass(statusEnum: number): string {
|
|
||||||
return SHARE_TXN_STATUS[statusEnum]?.cls || 'srep-pill--grey';
|
|
||||||
}
|
|
||||||
|
|
||||||
funding(source: string): string {
|
|
||||||
return fundingLabel(source);
|
|
||||||
}
|
|
||||||
|
|
||||||
clearFilters(): void {
|
|
||||||
this.search = '';
|
|
||||||
this.branchFilter = '';
|
|
||||||
this.productFilter = '';
|
|
||||||
this.fundingFilter = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
openAccount(row: IssuedRow): void {
|
|
||||||
this.router.navigate([
|
|
||||||
'/clients',
|
|
||||||
row.clientId,
|
|
||||||
'shares-accounts',
|
|
||||||
row.shareAccountId,
|
|
||||||
'general'
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
exportCsv(): void {
|
|
||||||
shareReportCsv(
|
|
||||||
`share-issued-${this.fromDate}-to-${this.toDate}`,
|
|
||||||
[
|
|
||||||
'Issue Date',
|
|
||||||
'Member',
|
|
||||||
'Membership No',
|
|
||||||
'Share A/c No',
|
|
||||||
'Branch',
|
|
||||||
'Product',
|
|
||||||
'Shares',
|
|
||||||
'Unit Price',
|
|
||||||
'Amount',
|
|
||||||
'Entry Fee',
|
|
||||||
'Funded By',
|
|
||||||
'Board Resolution',
|
|
||||||
'Meeting Date',
|
|
||||||
'Status'
|
|
||||||
],
|
|
||||||
this.filteredRows.map((r) => [
|
|
||||||
r.issueDate,
|
|
||||||
r.clientName,
|
|
||||||
r.cifNumber,
|
|
||||||
r.accountNo,
|
|
||||||
r.officeName,
|
|
||||||
r.productName,
|
|
||||||
r.shares,
|
|
||||||
r.unitPrice,
|
|
||||||
r.amount,
|
|
||||||
r.chargeAmount,
|
|
||||||
this.funding(r.fundingSource),
|
|
||||||
r.resolutionNumber,
|
|
||||||
r.meetingDate,
|
|
||||||
this.txnStatusLabel(r.statusEnum)
|
|
||||||
])
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
print(): void {
|
|
||||||
shareReportPrint();
|
|
||||||
}
|
|
||||||
|
|
||||||
private toIso(date: Date): string {
|
|
||||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private readError(err: any, fallback: string): string {
|
|
||||||
return (
|
|
||||||
err?.error?.errors?.[0]?.defaultUserMessage ||
|
|
||||||
err?.error?.defaultUserMessage ||
|
|
||||||
err?.error?.developerMessage ||
|
|
||||||
fallback
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,124 +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="cert-root">
|
|
||||||
<div class="cert-head srep-no-print">
|
|
||||||
<div class="cert-head-l">
|
|
||||||
<fa-icon icon="tag" class="cert-head-ic"></fa-icon>
|
|
||||||
<div>
|
|
||||||
<h2 class="cert-head-t">Share Labels</h2>
|
|
||||||
<div class="cert-head-s">
|
|
||||||
Printable address labels for shareholding members — ready for an ordinary label sheet.
|
|
||||||
</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 || !printableRows.length">
|
|
||||||
<fa-icon icon="print"></fa-icon> Print Labels
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="cert-filters srep-no-print">
|
|
||||||
<div class="cert-filter">
|
|
||||||
<label>Member / Folio / 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 areaOptions; track a) {
|
|
||||||
<option [value]="a">{{ a }}</option>
|
|
||||||
}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="cert-filter">
|
|
||||||
<label>Labels per row</label>
|
|
||||||
<select [(ngModel)]="columns">
|
|
||||||
<option [ngValue]="2">2 across</option>
|
|
||||||
<option [ngValue]="3">3 across</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="cert-filter">
|
|
||||||
<label>Show folio no.</label>
|
|
||||||
<select [(ngModel)]="showFolio">
|
|
||||||
<option [ngValue]="true">Yes</option>
|
|
||||||
<option [ngValue]="false">No</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="cert-filter">
|
|
||||||
<label>Show holding</label>
|
|
||||||
<select [(ngModel)]="showHolding">
|
|
||||||
<option [ngValue]="false">No</option>
|
|
||||||
<option [ngValue]="true">Yes</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 mailing list…</div>
|
|
||||||
} @else if (loadError) {
|
|
||||||
<div class="srep-state srep-state--error"><fa-icon icon="exclamation-circle"></fa-icon> {{ loadError }}</div>
|
|
||||||
} @else if (!filteredRows.length) {
|
|
||||||
<div class="srep-state">No shareholding member matches the filters applied.</div>
|
|
||||||
} @else {
|
|
||||||
<div class="srep-state srep-no-print">
|
|
||||||
{{ printableRows.length | number }} labels ready to print
|
|
||||||
@if (unaddressableRows.length) {
|
|
||||||
· {{ unaddressableRows.length | number }} member{{ unaddressableRows.length === 1 ? '' : 's' }} cannot be posted
|
|
||||||
to
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="slab-sheet" [style.grid-template-columns]="'repeat(' + columns + ', 1fr)'">
|
|
||||||
@for (row of printableRows; track row.clientId) {
|
|
||||||
<div class="slab">
|
|
||||||
<div class="slab-name">{{ row.clientName }}</div>
|
|
||||||
@for (line of lines(row); track line) {
|
|
||||||
<div class="slab-line">{{ line }}</div>
|
|
||||||
}
|
|
||||||
@if (showFolio && row.folioNumber) {
|
|
||||||
<div class="slab-meta">Folio {{ row.folioNumber }}</div>
|
|
||||||
}
|
|
||||||
@if (showHolding) {
|
|
||||||
<div class="slab-meta">{{ row.sharesHeld | number }} shares · ₹{{ row.shareValue | number: '1.0-0' }}</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Who cannot be posted to. Kept off the sheet but never out of sight: a blank label is a
|
|
||||||
wasted sheet, and a member with no address is one the society silently stops reaching. -->
|
|
||||||
@if (unaddressableRows.length) {
|
|
||||||
<div class="srep-warn srep-no-print">
|
|
||||||
<fa-icon icon="exclamation-circle"></fa-icon>
|
|
||||||
<span>
|
|
||||||
<b>{{ unaddressableRows.length | number }}</b> shareholding member{{
|
|
||||||
unaddressableRows.length === 1 ? ' has' : 's have'
|
|
||||||
}}
|
|
||||||
no address on file, so no label is printed for them: {{ unaddressableRows[0].clientName }}
|
|
||||||
@if (unaddressableRows.length > 1) {
|
|
||||||
and {{ unaddressableRows.length - 1 | number }} other{{ unaddressableRows.length > 2 ? 's' : '' }}
|
|
||||||
}
|
|
||||||
. Record an address against the member to include them in the next run.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
@@ -1,197 +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 { 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';
|
|
||||||
|
|
||||||
export interface MailingRow {
|
|
||||||
clientId: number;
|
|
||||||
folioNumber: string;
|
|
||||||
clientName: string;
|
|
||||||
mobileNo: string;
|
|
||||||
officeName: string;
|
|
||||||
addressLine1: string;
|
|
||||||
addressLine2: string;
|
|
||||||
street: string;
|
|
||||||
city: string;
|
|
||||||
district: string;
|
|
||||||
postalCode: string;
|
|
||||||
area: string;
|
|
||||||
sharesHeld: number;
|
|
||||||
shareValue: number;
|
|
||||||
shareAccountNos: string;
|
|
||||||
addressable: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Assembles one member's address into the lines an envelope actually carries. */
|
|
||||||
export function addressLines(row: MailingRow): string[] {
|
|
||||||
return [
|
|
||||||
row.addressLine1,
|
|
||||||
row.addressLine2,
|
|
||||||
row.street,
|
|
||||||
[
|
|
||||||
row.city,
|
|
||||||
row.district
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(', '),
|
|
||||||
row.postalCode ? `PIN ${row.postalCode}` : ''
|
|
||||||
]
|
|
||||||
.map((line) => (line || '').trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Share Label — printable address labels for shareholding members.
|
|
||||||
*
|
|
||||||
* <p>
|
|
||||||
* Laid out as a grid sized to ordinary label sheets and printed straight from the browser, so a
|
|
||||||
* mailing run needs no separate mail-merge tool. Members with no usable address are listed
|
|
||||||
* separately rather than printed as a blank label: a blank label is a wasted sheet and a member who
|
|
||||||
* silently never hears from the society.
|
|
||||||
*/
|
|
||||||
@Component({
|
|
||||||
selector: 'mifosx-share-label',
|
|
||||||
templateUrl: './share-label.component.html',
|
|
||||||
styleUrls: [
|
|
||||||
'../../../letter-mgmt/certificates/certificates.shared.scss',
|
|
||||||
'../share-report.shared.scss'
|
|
||||||
],
|
|
||||||
imports: [
|
|
||||||
CommonModule,
|
|
||||||
FormsModule,
|
|
||||||
FaIconComponent
|
|
||||||
]
|
|
||||||
})
|
|
||||||
export class ShareLabelComponent implements OnInit {
|
|
||||||
private sharesService = inject(SharesService);
|
|
||||||
|
|
||||||
loading = true;
|
|
||||||
loadError = '';
|
|
||||||
rows: MailingRow[] = [];
|
|
||||||
totals: any = null;
|
|
||||||
|
|
||||||
search = '';
|
|
||||||
areaFilter = '';
|
|
||||||
/** Labels per row on the sheet — 2 and 3 cover the sheets a branch actually buys. */
|
|
||||||
columns = 3;
|
|
||||||
showFolio = true;
|
|
||||||
showHolding = false;
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
|
||||||
this.load();
|
|
||||||
}
|
|
||||||
|
|
||||||
load(): void {
|
|
||||||
this.loading = true;
|
|
||||||
this.loadError = '';
|
|
||||||
this.sharesService
|
|
||||||
.getShareMailingList({})
|
|
||||||
.pipe(
|
|
||||||
catchError((err) => {
|
|
||||||
this.loadError = this.readError(err, 'The mailing list could not be loaded.');
|
|
||||||
return of(null);
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.subscribe((res: any) => {
|
|
||||||
this.rows = res?.rows || [];
|
|
||||||
this.totals = res?.totals || null;
|
|
||||||
this.loading = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
get areaOptions(): string[] {
|
|
||||||
return [...new Set(this.rows.map((r) => r.area).filter(Boolean))].sort();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Only members who can actually be posted to — the rest are listed below, not printed. */
|
|
||||||
get printableRows(): MailingRow[] {
|
|
||||||
return this.filteredRows.filter((r) => r.addressable);
|
|
||||||
}
|
|
||||||
|
|
||||||
get unaddressableRows(): MailingRow[] {
|
|
||||||
return this.filteredRows.filter((r) => !r.addressable);
|
|
||||||
}
|
|
||||||
|
|
||||||
get filteredRows(): MailingRow[] {
|
|
||||||
const term = this.search.trim().toLowerCase();
|
|
||||||
return this.rows.filter((r) => {
|
|
||||||
if (this.areaFilter && r.area !== this.areaFilter) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!term) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return [
|
|
||||||
r.clientName,
|
|
||||||
r.folioNumber,
|
|
||||||
r.area,
|
|
||||||
r.postalCode
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.some((field) => String(field).toLowerCase().includes(term));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
lines(row: MailingRow): string[] {
|
|
||||||
return addressLines(row);
|
|
||||||
}
|
|
||||||
|
|
||||||
clearFilters(): void {
|
|
||||||
this.search = '';
|
|
||||||
this.areaFilter = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
exportCsv(): void {
|
|
||||||
shareReportCsv(
|
|
||||||
'share-mailing-labels',
|
|
||||||
[
|
|
||||||
'Member',
|
|
||||||
'Folio No',
|
|
||||||
'Address',
|
|
||||||
'City',
|
|
||||||
'District',
|
|
||||||
'Postal Code',
|
|
||||||
'Mobile',
|
|
||||||
'Shares Held',
|
|
||||||
'Addressable'
|
|
||||||
],
|
|
||||||
this.filteredRows.map((r) => [
|
|
||||||
r.clientName,
|
|
||||||
r.folioNumber,
|
|
||||||
this.lines(r).join(' / '),
|
|
||||||
r.city,
|
|
||||||
r.district,
|
|
||||||
r.postalCode,
|
|
||||||
r.mobileNo,
|
|
||||||
r.sharesHeld,
|
|
||||||
r.addressable ? 'Yes' : 'No'
|
|
||||||
])
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
print(): void {
|
|
||||||
shareReportPrint();
|
|
||||||
}
|
|
||||||
|
|
||||||
private readError(err: any, fallback: string): string {
|
|
||||||
return (
|
|
||||||
err?.error?.errors?.[0]?.defaultUserMessage ||
|
|
||||||
err?.error?.defaultUserMessage ||
|
|
||||||
err?.error?.developerMessage ||
|
|
||||||
fallback
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,171 +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="cert-root">
|
|
||||||
<div class="cert-head srep-no-print">
|
|
||||||
<div class="cert-head-l">
|
|
||||||
<fa-icon icon="envelope" class="cert-head-ic"></fa-icon>
|
|
||||||
<div>
|
|
||||||
<h2 class="cert-head-t">Share Letter</h2>
|
|
||||||
<div class="cert-head-s">
|
|
||||||
A letter to shareholding members, merged with their own details and printed — one page each.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="cert-head-r">
|
|
||||||
<button class="cert-cta-btn" (click)="print()" [disabled]="loading || !recipients.length || !body.trim()">
|
|
||||||
<fa-icon icon="print"></fa-icon> Print {{ recipients.length | number }} letters
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Compose -->
|
|
||||||
<div class="srep-stmt-head srep-no-print" style="display: block">
|
|
||||||
<div class="cert-filters" style="margin: 0">
|
|
||||||
<div class="cert-filter">
|
|
||||||
<label>Template</label>
|
|
||||||
<select [(ngModel)]="templateKey" (change)="applyTemplate()">
|
|
||||||
@for (t of templates; track t.key) {
|
|
||||||
<option [value]="t.key">{{ t.label }}</option>
|
|
||||||
}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="cert-filter" style="flex: 2 1 320px">
|
|
||||||
<label>Subject</label>
|
|
||||||
<input type="text" [(ngModel)]="subject" placeholder="Subject line of the letter" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="margin-top: 0.7rem">
|
|
||||||
<label class="srep-stmt-l">Letter body</label>
|
|
||||||
<textarea class="slet-body-input" rows="7" [(ngModel)]="body" placeholder="Write the letter…"></textarea>
|
|
||||||
<div class="srep-scope-note">
|
|
||||||
Merged per member:
|
|
||||||
@for (p of placeholders; track p) {
|
|
||||||
<code>{{ p }}</code
|
|
||||||
>{{ $last ? '.' : ', ' }}
|
|
||||||
}
|
|
||||||
Anything else you write is sent as it stands.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Recipients -->
|
|
||||||
<div class="cert-filters srep-no-print">
|
|
||||||
<div class="cert-filter">
|
|
||||||
<label>Member / Folio / Area</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>Area</label>
|
|
||||||
<select [(ngModel)]="areaFilter">
|
|
||||||
<option value="">All areas</option>
|
|
||||||
@for (a of areaOptions; track a) {
|
|
||||||
<option [value]="a">{{ a }}</option>
|
|
||||||
}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="cert-filter">
|
|
||||||
<label> </label>
|
|
||||||
<button class="cert-cta-btn" (click)="selectAll()">Write to all shown</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (loading) {
|
|
||||||
<div class="srep-state">Loading the mailing list…</div>
|
|
||||||
} @else if (loadError) {
|
|
||||||
<div class="srep-state srep-state--error"><fa-icon icon="exclamation-circle"></fa-icon> {{ loadError }}</div>
|
|
||||||
} @else if (!filteredRows.length) {
|
|
||||||
<div class="srep-state">No shareholding member matches the filters applied.</div>
|
|
||||||
} @else {
|
|
||||||
<div class="srep-table-wrap srep-no-print">
|
|
||||||
<table class="srep-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th class="col-checkbox"></th>
|
|
||||||
<th>Member</th>
|
|
||||||
<th>Folio No</th>
|
|
||||||
<th>Area</th>
|
|
||||||
<th class="srep-num">Shares</th>
|
|
||||||
<th>Postable</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@for (row of filteredRows; track row.clientId) {
|
|
||||||
<tr class="srep-row--click" [class.selected]="isSelected(row.clientId)" (click)="toggle(row.clientId)">
|
|
||||||
<td class="ta-center" (click)="$event.stopPropagation()">
|
|
||||||
<input type="checkbox" [checked]="isSelected(row.clientId)" (change)="toggle(row.clientId)" />
|
|
||||||
</td>
|
|
||||||
<td>{{ row.clientName }}</td>
|
|
||||||
<td class="srep-mono">{{ row.folioNumber || '—' }}</td>
|
|
||||||
<td>{{ row.area }}</td>
|
|
||||||
<td class="srep-num">{{ row.sharesHeld | number }}</td>
|
|
||||||
<td>
|
|
||||||
@if (row.addressable) {
|
|
||||||
<span class="srep-pill srep-pill--green">Yes</span>
|
|
||||||
} @else {
|
|
||||||
<span class="srep-pill srep-pill--amber">No address</span>
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="srep-state srep-no-print">
|
|
||||||
{{ recipients.length | number }} letter{{ recipients.length === 1 ? '' : 's' }} will print
|
|
||||||
@if (!selectedIds.length) {
|
|
||||||
(everyone shown — tick rows to narrow it)
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- The letters. One per member, each its own printed page. -->
|
|
||||||
@for (row of recipients; track row.clientId) {
|
|
||||||
<div class="slet-page">
|
|
||||||
<div class="spc-letterhead">
|
|
||||||
<div class="spc-org">{{ institution?.name || institution?.institutionName || 'Co-operative Bank' }}</div>
|
|
||||||
@if (institution?.addressLine1 || institution?.address) {
|
|
||||||
<div class="spc-org-sub">{{ institution?.addressLine1 || institution?.address }}</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="slet-meta">
|
|
||||||
<div>
|
|
||||||
<div>{{ row.clientName }}</div>
|
|
||||||
@for (line of lines(row); track line) {
|
|
||||||
<div>{{ line }}</div>
|
|
||||||
}
|
|
||||||
@if (row.folioNumber) {
|
|
||||||
<div class="srep-scope-note" style="margin: 0">Folio {{ row.folioNumber }}</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<div class="slet-date">Date: {{ today }}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (subject.trim()) {
|
|
||||||
<div class="slet-subject">Subject: {{ subject }}</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="slet-salutation">Dear {{ row.clientName }},</div>
|
|
||||||
<div class="slet-body">{{ merged(row) }}</div>
|
|
||||||
|
|
||||||
<div class="spc-foot">
|
|
||||||
<div class="spc-foot-l">Yours faithfully,</div>
|
|
||||||
<div class="spc-sign">
|
|
||||||
<div class="spc-sign-line"></div>
|
|
||||||
<div>Authorised Signatory</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
@@ -1,227 +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 { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
|
||||||
import { of } from 'rxjs';
|
|
||||||
import { catchError } from 'rxjs/operators';
|
|
||||||
|
|
||||||
import { SharesService } from '../../shares.service';
|
|
||||||
import { OrganizationService } from 'app/organization/organization.service';
|
|
||||||
import { MailingRow, addressLines } from '../share-label/share-label.component';
|
|
||||||
import { shareReportPrint } from '../share-report.util';
|
|
||||||
|
|
||||||
/** The letters a society actually sends its shareholders, as starting text. */
|
|
||||||
const TEMPLATES: { key: string; label: string; subject: string; body: string }[] = [
|
|
||||||
{
|
|
||||||
key: 'AGM',
|
|
||||||
label: 'Notice of Annual General Meeting',
|
|
||||||
subject: 'Notice of the Annual General Meeting',
|
|
||||||
body:
|
|
||||||
'You are hereby informed that the Annual General Meeting of the society will be held on {{MEETING_DATE}} at {{VENUE}}.\n\n' +
|
|
||||||
'As a shareholding member holding {{SHARES}} shares (Folio {{FOLIO}}), you are entitled to attend and vote.\n\n' +
|
|
||||||
'Your presence is requested.'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'DIVIDEND',
|
|
||||||
label: 'Dividend intimation',
|
|
||||||
subject: 'Dividend declared on your shareholding',
|
|
||||||
body:
|
|
||||||
'We are pleased to inform you that the society has declared a dividend for the year {{YEAR}}.\n\n' +
|
|
||||||
'Against your holding of {{SHARES}} shares (Folio {{FOLIO}}), the dividend due to you will be credited to your ' +
|
|
||||||
'linked savings account. Please contact your branch if it is not received within fifteen days.'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'SHARE_CALL',
|
|
||||||
label: 'Call to complete shareholding',
|
|
||||||
subject: 'Completion of your share subscription',
|
|
||||||
body:
|
|
||||||
'Our records show that your share account stands at {{SHARES}} shares (Folio {{FOLIO}}), which is below the ' +
|
|
||||||
'minimum subscription your share class requires.\n\n' +
|
|
||||||
'You are requested to complete your subscription at your branch at the earliest so that your membership rights ' +
|
|
||||||
'remain unaffected.'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'BLANK',
|
|
||||||
label: 'Blank letter',
|
|
||||||
subject: '',
|
|
||||||
body: ''
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Share Letter — a letter to shareholding members, merged with their own details and printed.
|
|
||||||
*
|
|
||||||
* <p>
|
|
||||||
* Runs off the same mailing list the labels do, so the letter and the envelope always agree on who
|
|
||||||
* is being written to. Templates are starting text rather than fixed forms: the body is editable
|
|
||||||
* before printing, because the letter a society actually sends is never quite the one a system
|
|
||||||
* imagined.
|
|
||||||
*
|
|
||||||
* <p>
|
|
||||||
* Placeholders are merged per member — {{NAME}}, {{FOLIO}}, {{SHARES}} and the rest — so one edit
|
|
||||||
* produces every letter, each addressed to the member it is going to.
|
|
||||||
*/
|
|
||||||
@Component({
|
|
||||||
selector: 'mifosx-share-letter',
|
|
||||||
templateUrl: './share-letter.component.html',
|
|
||||||
styleUrls: [
|
|
||||||
'../../../letter-mgmt/certificates/certificates.shared.scss',
|
|
||||||
'../share-report.shared.scss'
|
|
||||||
],
|
|
||||||
imports: [
|
|
||||||
CommonModule,
|
|
||||||
FormsModule,
|
|
||||||
FaIconComponent
|
|
||||||
]
|
|
||||||
})
|
|
||||||
export class ShareLetterComponent implements OnInit {
|
|
||||||
private sharesService = inject(SharesService);
|
|
||||||
private organizationService = inject(OrganizationService);
|
|
||||||
|
|
||||||
loading = true;
|
|
||||||
loadError = '';
|
|
||||||
rows: MailingRow[] = [];
|
|
||||||
institution: any = null;
|
|
||||||
|
|
||||||
readonly templates = TEMPLATES;
|
|
||||||
templateKey = 'AGM';
|
|
||||||
subject = TEMPLATES[0].subject;
|
|
||||||
body = TEMPLATES[0].body;
|
|
||||||
|
|
||||||
search = '';
|
|
||||||
areaFilter = '';
|
|
||||||
/** Which members this run is for; empty means everyone shown. */
|
|
||||||
selectedIds: number[] = [];
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
|
||||||
this.sharesService
|
|
||||||
.getShareMailingList({})
|
|
||||||
.pipe(
|
|
||||||
catchError((err) => {
|
|
||||||
this.loadError = this.readError(err, 'The mailing list could not be loaded.');
|
|
||||||
return of(null);
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.subscribe((res: any) => {
|
|
||||||
this.rows = res?.rows || [];
|
|
||||||
this.loading = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
this.organizationService
|
|
||||||
.getInstitutionProfile()
|
|
||||||
.pipe(catchError(() => of(null)))
|
|
||||||
.subscribe((res: any) => (this.institution = res));
|
|
||||||
}
|
|
||||||
|
|
||||||
applyTemplate(): void {
|
|
||||||
const template = TEMPLATES.find((t) => t.key === this.templateKey);
|
|
||||||
if (template) {
|
|
||||||
this.subject = template.subject;
|
|
||||||
this.body = template.body;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
get areaOptions(): string[] {
|
|
||||||
return [...new Set(this.rows.map((r) => r.area).filter(Boolean))].sort();
|
|
||||||
}
|
|
||||||
|
|
||||||
get filteredRows(): MailingRow[] {
|
|
||||||
const term = this.search.trim().toLowerCase();
|
|
||||||
return this.rows.filter((r) => {
|
|
||||||
if (this.areaFilter && r.area !== this.areaFilter) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!term) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return [
|
|
||||||
r.clientName,
|
|
||||||
r.folioNumber,
|
|
||||||
r.area
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.some((field) => String(field).toLowerCase().includes(term));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Nothing ticked means the whole filtered list — the common case is "write to all of these". */
|
|
||||||
get recipients(): MailingRow[] {
|
|
||||||
if (!this.selectedIds.length) {
|
|
||||||
return this.filteredRows;
|
|
||||||
}
|
|
||||||
return this.filteredRows.filter((r) => this.selectedIds.includes(r.clientId));
|
|
||||||
}
|
|
||||||
|
|
||||||
isSelected(clientId: number): boolean {
|
|
||||||
return this.selectedIds.includes(clientId);
|
|
||||||
}
|
|
||||||
|
|
||||||
toggle(clientId: number): void {
|
|
||||||
this.selectedIds = this.isSelected(clientId) ? this.selectedIds.filter((id) => id !== clientId) : [
|
|
||||||
...this.selectedIds,
|
|
||||||
clientId
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
selectAll(): void {
|
|
||||||
this.selectedIds = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
lines(row: MailingRow): string[] {
|
|
||||||
return addressLines(row);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** What one member's letter substitutes, keyed by the token that stands for it in the body. */
|
|
||||||
private mergeFields(row: MailingRow): Record<string, string> {
|
|
||||||
return {
|
|
||||||
NAME: row.clientName || '',
|
|
||||||
FOLIO: row.folioNumber || '',
|
|
||||||
SHARES: Number(row.sharesHeld || 0).toLocaleString('en-IN'),
|
|
||||||
SHARE_VALUE: Number(row.shareValue || 0).toLocaleString('en-IN'),
|
|
||||||
SHARE_ACCOUNTS: row.shareAccountNos || '',
|
|
||||||
BRANCH: row.officeName || '',
|
|
||||||
AREA: row.area || ''
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The tokens the editor's help text offers, derived from the merge itself so the two can never
|
|
||||||
* drift apart — and assembled here rather than written in the template, because a literal
|
|
||||||
* `{{NAME}}` in an Angular template is an expression, not the text a letter-writer needs to see.
|
|
||||||
*/
|
|
||||||
readonly placeholders = Object.keys(this.mergeFields({} as MailingRow)).map((key) => `{{${key}}}`);
|
|
||||||
|
|
||||||
/** The member's own details merged into the body, one letter at a time. */
|
|
||||||
merged(row: MailingRow): string {
|
|
||||||
const fields = this.mergeFields(row);
|
|
||||||
// An unrecognised token is left standing rather than blanked: a letter that visibly still says
|
|
||||||
// {{VENUE}} tells the writer to fill it in; one silently emptied goes out with a hole in it.
|
|
||||||
return (this.body || '').replace(/\{\{([A-Z_]+)\}\}/g, (token, key: string) => fields[key] ?? token);
|
|
||||||
}
|
|
||||||
|
|
||||||
get today(): string {
|
|
||||||
const now = new Date();
|
|
||||||
return `${String(now.getDate()).padStart(2, '0')}-${String(now.getMonth() + 1).padStart(2, '0')}-${now.getFullYear()}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
print(): void {
|
|
||||||
shareReportPrint();
|
|
||||||
}
|
|
||||||
|
|
||||||
private readError(err: any, fallback: string): string {
|
|
||||||
return (
|
|
||||||
err?.error?.errors?.[0]?.defaultUserMessage ||
|
|
||||||
err?.error?.defaultUserMessage ||
|
|
||||||
err?.error?.developerMessage ||
|
|
||||||
fallback
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-166
@@ -1,166 +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="cert-root">
|
|
||||||
<div class="cert-head">
|
|
||||||
<div class="cert-head-l">
|
|
||||||
<fa-icon icon="calculator" class="cert-head-ic"></fa-icon>
|
|
||||||
<div>
|
|
||||||
<h2 class="cert-head-t">Total Interest Received & Receivable</h2>
|
|
||||||
<div class="cert-head-s">
|
|
||||||
Interest earned from the borrowings of the members who hold the society's share capital.
|
|
||||||
</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--green">
|
|
||||||
<fa-icon icon="hand-holding-usd" class="cert-kpi-ic"></fa-icon>
|
|
||||||
<div>
|
|
||||||
<div class="cert-kpi-n">₹{{ totals.interestReceived | number: '1.2-2' }}</div>
|
|
||||||
<div class="cert-kpi-l">Interest Received</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="cert-kpi cert-kpi--amber">
|
|
||||||
<fa-icon icon="sync" class="cert-kpi-ic"></fa-icon>
|
|
||||||
<div>
|
|
||||||
<div class="cert-kpi-n">₹{{ totals.interestReceivable | number: '1.2-2' }}</div>
|
|
||||||
<div class="cert-kpi-l">Interest Receivable</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="cert-kpi cert-kpi--blue">
|
|
||||||
<fa-icon icon="users" class="cert-kpi-ic"></fa-icon>
|
|
||||||
<div>
|
|
||||||
<div class="cert-kpi-n">{{ totals.borrowingMembers | number }} / {{ totals.memberCount | number }}</div>
|
|
||||||
<div class="cert-kpi-l">Borrowing Members</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="cert-kpi cert-kpi--teal">
|
|
||||||
<fa-icon icon="coins" class="cert-kpi-ic"></fa-icon>
|
|
||||||
<div>
|
|
||||||
<div class="cert-kpi-n">₹{{ totals.shareValue | number: '1.2-2' }}</div>
|
|
||||||
<div class="cert-kpi-l">Their Share Capital</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="cert-filters">
|
|
||||||
<div class="cert-filter">
|
|
||||||
<label>Member / Membership No</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>Loans counted</label>
|
|
||||||
<select [(ngModel)]="includeClosedLoans" (change)="load()">
|
|
||||||
<option [ngValue]="false">Live loans only</option>
|
|
||||||
<option [ngValue]="true">Include closed loans</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="cert-filter">
|
|
||||||
<label>Members shown</label>
|
|
||||||
<select [(ngModel)]="onlyBorrowers">
|
|
||||||
<option [ngValue]="false">All shareholding members</option>
|
|
||||||
<option [ngValue]="true">Borrowers only</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> </label>
|
|
||||||
<button class="cert-cta-btn" (click)="clearFilters()">Clear filters</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (loading) {
|
|
||||||
<div class="srep-state">Loading interest figures…</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 member holds share capital yet.</div>
|
|
||||||
} @else if (!filteredRows.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>Member</th>
|
|
||||||
<th>Membership No</th>
|
|
||||||
<th>Branch</th>
|
|
||||||
<th class="srep-num">Shares</th>
|
|
||||||
<th class="srep-num">Share Value</th>
|
|
||||||
<th class="srep-num">Loans</th>
|
|
||||||
<th class="srep-num">Principal O/S</th>
|
|
||||||
<th class="srep-num">Interest Charged</th>
|
|
||||||
<th class="srep-num">Interest Received</th>
|
|
||||||
<th class="srep-num">Interest Receivable</th>
|
|
||||||
<th class="srep-num">Waived</th>
|
|
||||||
<th class="srep-num">Written Off</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@for (row of filteredRows; track row.clientId) {
|
|
||||||
<tr class="srep-row--click" (click)="openMember(row)">
|
|
||||||
<td>{{ row.clientName }}</td>
|
|
||||||
<td class="srep-mono">{{ row.cifNumber || '—' }}</td>
|
|
||||||
<td>{{ row.officeName || '—' }}</td>
|
|
||||||
<td class="srep-num">{{ row.sharesHeld | number }}</td>
|
|
||||||
<td class="srep-num">₹{{ row.shareValue | number: '1.2-2' }}</td>
|
|
||||||
<td class="srep-num">{{ row.loanCount | number }}</td>
|
|
||||||
<td class="srep-num">₹{{ row.principalOutstanding | number: '1.2-2' }}</td>
|
|
||||||
<td class="srep-num">₹{{ row.interestCharged | number: '1.2-2' }}</td>
|
|
||||||
<td class="srep-num">₹{{ row.interestReceived | number: '1.2-2' }}</td>
|
|
||||||
<td class="srep-num">₹{{ row.interestReceivable | number: '1.2-2' }}</td>
|
|
||||||
<td class="srep-num">{{ row.interestWaived ? '₹' + (row.interestWaived | number: '1.2-2') : '—' }}</td>
|
|
||||||
<td class="srep-num">
|
|
||||||
{{ row.interestWrittenOff ? '₹' + (row.interestWrittenOff | number: '1.2-2') : '—' }}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
<tfoot>
|
|
||||||
<tr>
|
|
||||||
<td colspan="3">
|
|
||||||
{{ isFiltered ? 'Total (filtered)' : 'Total' }} — {{ filteredRows.length | number }} members
|
|
||||||
</td>
|
|
||||||
<td class="srep-num">{{ sum('sharesHeld') | number }}</td>
|
|
||||||
<td class="srep-num">₹{{ sum('shareValue') | number: '1.2-2' }}</td>
|
|
||||||
<td class="srep-num">{{ sum('loanCount') | number }}</td>
|
|
||||||
<td class="srep-num">₹{{ sum('principalOutstanding') | number: '1.2-2' }}</td>
|
|
||||||
<td class="srep-num">₹{{ sum('interestCharged') | number: '1.2-2' }}</td>
|
|
||||||
<td class="srep-num">₹{{ sum('interestReceived') | number: '1.2-2' }}</td>
|
|
||||||
<td class="srep-num">₹{{ sum('interestReceivable') | number: '1.2-2' }}</td>
|
|
||||||
<td class="srep-num">₹{{ sum('interestWaived') | number: '1.2-2' }}</td>
|
|
||||||
<td class="srep-num">₹{{ sum('interestWrittenOff') | number: '1.2-2' }}</td>
|
|
||||||
</tr>
|
|
||||||
</tfoot>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
-197
@@ -1,197 +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 { 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 MemberInterestRow {
|
|
||||||
clientId: number;
|
|
||||||
cifNumber: string;
|
|
||||||
clientName: string;
|
|
||||||
officeName: string;
|
|
||||||
sharesHeld: number;
|
|
||||||
shareValue: number;
|
|
||||||
loanCount: number;
|
|
||||||
principalLent: number;
|
|
||||||
principalOutstanding: number;
|
|
||||||
interestCharged: number;
|
|
||||||
interestReceived: number;
|
|
||||||
interestReceivable: number;
|
|
||||||
interestWaived: number;
|
|
||||||
interestWrittenOff: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Total Interest Received & Receivable — what the society has earned in interest from the borrowings
|
|
||||||
* of the members who hold its share capital.
|
|
||||||
*
|
|
||||||
* <p>
|
|
||||||
* Shares earn dividend, not interest; this is the other side of the member relationship. A member's
|
|
||||||
* stake and what their lending returns is the case for admitting and keeping them, and a
|
|
||||||
* co-operative reads the two together.
|
|
||||||
*
|
|
||||||
* <p>
|
|
||||||
* Received and receivable stay in separate columns rather than being summed — interest charged but
|
|
||||||
* not yet collected is not income in hand, and a single "interest" figure hides exactly that.
|
|
||||||
*/
|
|
||||||
@Component({
|
|
||||||
selector: 'mifosx-share-member-interest-report',
|
|
||||||
templateUrl: './share-member-interest-report.component.html',
|
|
||||||
styleUrls: [
|
|
||||||
'../../../letter-mgmt/certificates/certificates.shared.scss',
|
|
||||||
'../share-report.shared.scss'
|
|
||||||
],
|
|
||||||
imports: [
|
|
||||||
CommonModule,
|
|
||||||
FormsModule,
|
|
||||||
FaIconComponent
|
|
||||||
]
|
|
||||||
})
|
|
||||||
export class ShareMemberInterestReportComponent implements OnInit {
|
|
||||||
private sharesService = inject(SharesService);
|
|
||||||
private router = inject(Router);
|
|
||||||
|
|
||||||
loading = true;
|
|
||||||
loadError = '';
|
|
||||||
rows: MemberInterestRow[] = [];
|
|
||||||
totals: any = null;
|
|
||||||
|
|
||||||
/** Server-side: a closed loan's interest is history, not a position. */
|
|
||||||
includeClosedLoans = false;
|
|
||||||
|
|
||||||
search = '';
|
|
||||||
branchFilter = '';
|
|
||||||
/** Members who hold shares but have never borrowed are noise when the question is interest. */
|
|
||||||
onlyBorrowers = false;
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
|
||||||
this.load();
|
|
||||||
}
|
|
||||||
|
|
||||||
load(): void {
|
|
||||||
this.loading = true;
|
|
||||||
this.loadError = '';
|
|
||||||
this.sharesService
|
|
||||||
.getShareMemberLoanInterest({ includeClosedLoans: this.includeClosedLoans })
|
|
||||||
.pipe(
|
|
||||||
catchError((err) => {
|
|
||||||
this.loadError = this.readError(err, 'The interest report 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 filteredRows(): MemberInterestRow[] {
|
|
||||||
const term = this.search.trim().toLowerCase();
|
|
||||||
return this.rows.filter((r) => {
|
|
||||||
if (this.branchFilter && r.officeName !== this.branchFilter) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (this.onlyBorrowers && !Number(r.loanCount)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!term) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return [
|
|
||||||
r.clientName,
|
|
||||||
r.cifNumber
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.some((field) => String(field).toLowerCase().includes(term));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
sum(field: keyof MemberInterestRow): number {
|
|
||||||
return this.filteredRows.reduce((total, row) => total + Number(row[field] || 0), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
get isFiltered(): boolean {
|
|
||||||
return !!(this.search.trim() || this.branchFilter || this.onlyBorrowers);
|
|
||||||
}
|
|
||||||
|
|
||||||
openMember(row: MemberInterestRow): void {
|
|
||||||
this.router.navigate([
|
|
||||||
'/clients',
|
|
||||||
row.clientId,
|
|
||||||
'general'
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
clearFilters(): void {
|
|
||||||
this.search = '';
|
|
||||||
this.branchFilter = '';
|
|
||||||
this.onlyBorrowers = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
exportCsv(): void {
|
|
||||||
shareReportCsv(
|
|
||||||
'share-member-interest',
|
|
||||||
[
|
|
||||||
'Member',
|
|
||||||
'Membership No',
|
|
||||||
'Branch',
|
|
||||||
'Shares Held',
|
|
||||||
'Share Value',
|
|
||||||
'Loans',
|
|
||||||
'Principal Lent',
|
|
||||||
'Principal Outstanding',
|
|
||||||
'Interest Charged',
|
|
||||||
'Interest Received',
|
|
||||||
'Interest Receivable',
|
|
||||||
'Interest Waived',
|
|
||||||
'Interest Written Off'
|
|
||||||
],
|
|
||||||
this.filteredRows.map((r) => [
|
|
||||||
r.clientName,
|
|
||||||
r.cifNumber,
|
|
||||||
r.officeName,
|
|
||||||
r.sharesHeld,
|
|
||||||
r.shareValue,
|
|
||||||
r.loanCount,
|
|
||||||
r.principalLent,
|
|
||||||
r.principalOutstanding,
|
|
||||||
r.interestCharged,
|
|
||||||
r.interestReceived,
|
|
||||||
r.interestReceivable,
|
|
||||||
r.interestWaived,
|
|
||||||
r.interestWrittenOff
|
|
||||||
])
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
print(): void {
|
|
||||||
shareReportPrint();
|
|
||||||
}
|
|
||||||
|
|
||||||
private readError(err: any, fallback: string): string {
|
|
||||||
return (
|
|
||||||
err?.error?.errors?.[0]?.defaultUserMessage ||
|
|
||||||
err?.error?.defaultUserMessage ||
|
|
||||||
err?.error?.developerMessage ||
|
|
||||||
fallback
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,244 +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="cert-root">
|
|
||||||
<div class="cert-head">
|
|
||||||
<div class="cert-head-l">
|
|
||||||
<fa-icon icon="coins" class="cert-head-ic"></fa-icon>
|
|
||||||
<div>
|
|
||||||
<h2 class="cert-head-t">Nominal Share Report</h2>
|
|
||||||
<div class="cert-head-s">
|
|
||||||
Each member's holding at face value, measured against the share class's nominal, minimum and maximum.
|
|
||||||
</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--teal">
|
|
||||||
<fa-icon icon="balance-scale" class="cert-kpi-ic"></fa-icon>
|
|
||||||
<div>
|
|
||||||
<div class="cert-kpi-n">₹{{ totals.nominalValue | number: '1.2-2' }}</div>
|
|
||||||
<div class="cert-kpi-l">Nominal Capital</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.regularMembers | number }}</div>
|
|
||||||
<div class="cert-kpi-l">Regular Members</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="cert-kpi cert-kpi--amber">
|
|
||||||
<fa-icon icon="user-shield" class="cert-kpi-ic"></fa-icon>
|
|
||||||
<div>
|
|
||||||
<div class="cert-kpi-n">{{ totals.nominalMembers | number }}</div>
|
|
||||||
<div class="cert-kpi-l">Nominal Members</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="cert-kpi"
|
|
||||||
[ngClass]="totals.belowMinimum + totals.aboveMaximum ? 'cert-kpi--amber' : 'cert-kpi--blue'"
|
|
||||||
>
|
|
||||||
<fa-icon icon="exclamation-circle" class="cert-kpi-ic"></fa-icon>
|
|
||||||
<div>
|
|
||||||
<div class="cert-kpi-n">{{ totals.belowMinimum + totals.aboveMaximum | number }}</div>
|
|
||||||
<div class="cert-kpi-l">Outside Limits</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- Allotted but never collected. Kept in front of the reader rather than left to be spotted as an
|
|
||||||
amber row halfway down: these are live accounts whose money is still owed. -->
|
|
||||||
@if (unpaidRows.length) {
|
|
||||||
<div class="srep-warn">
|
|
||||||
<fa-icon icon="exclamation-circle"></fa-icon>
|
|
||||||
<span>
|
|
||||||
<b>{{ unpaidRows.length | number }}</b> {{ unpaidRows.length === 1 ? 'account has' : 'accounts have' }} shares
|
|
||||||
allotted but never collected — <b>{{ unpaidShares | number }}</b> shares in all. They are active, so they are
|
|
||||||
listed below and flagged in amber, but they hold nothing: the shares exist on paper only until the member's
|
|
||||||
collection is applied.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="cert-filters">
|
|
||||||
<div class="cert-filter">
|
|
||||||
<label>Account state</label>
|
|
||||||
<select [(ngModel)]="stateFilter">
|
|
||||||
<option value="LIVE">Live accounts</option>
|
|
||||||
<option value="">All states</option>
|
|
||||||
@for (s of stateOrder; track s) {
|
|
||||||
<option [value]="s">{{ stateLabel(s) }}</option>
|
|
||||||
}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="cert-filter">
|
|
||||||
<label>As on date</label>
|
|
||||||
<input type="date" [(ngModel)]="asOnDate" (change)="load()" />
|
|
||||||
</div>
|
|
||||||
<div class="cert-filter">
|
|
||||||
<label>Member / Membership No / A/c No</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>Compliance</label>
|
|
||||||
<select [(ngModel)]="complianceFilter">
|
|
||||||
<option value="">All</option>
|
|
||||||
<option value="WITHIN_LIMITS">Within limits</option>
|
|
||||||
<option value="BELOW_MINIMUM">Below minimum</option>
|
|
||||||
<option value="ABOVE_MAXIMUM">Above maximum</option>
|
|
||||||
<option value="NO_HOLDING">No holding</option>
|
|
||||||
<option value="NOT_APPLICABLE">Not applicable</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="cert-filter">
|
|
||||||
<label>Membership class</label>
|
|
||||||
<select [(ngModel)]="membershipFilter">
|
|
||||||
<option value="">All</option>
|
|
||||||
<option value="REGULAR">Regular</option>
|
|
||||||
<option value="NOMINAL">Nominal</option>
|
|
||||||
<option value="NONE">No holding</option>
|
|
||||||
<option value="NOT_APPLICABLE">Not applicable</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 nominal share positions…</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 existed as on {{ asOnDate }}.</div>
|
|
||||||
} @else if (!filteredRows.length) {
|
|
||||||
<div class="srep-state">No member matches the filters applied.</div>
|
|
||||||
} @else {
|
|
||||||
@if (hiddenByStateCount) {
|
|
||||||
<div class="srep-scope-note">
|
|
||||||
{{ hiddenByStateCount | number }} closed, rejected or not-yet-active
|
|
||||||
{{ hiddenByStateCount === 1 ? 'account is' : 'accounts are' }} not shown. Switch <b>Account state</b> to see
|
|
||||||
them.
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<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>Product</th>
|
|
||||||
<th>State</th>
|
|
||||||
<th class="srep-num">Held</th>
|
|
||||||
<th class="srep-num">Nominal</th>
|
|
||||||
<th class="srep-num">vs Nominal</th>
|
|
||||||
<th class="srep-num">Min</th>
|
|
||||||
<th class="srep-num">Max</th>
|
|
||||||
<th class="srep-num">Face Value</th>
|
|
||||||
<th class="srep-num">Nominal Value</th>
|
|
||||||
<th>Class</th>
|
|
||||||
<th>Compliance</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 }}</td>
|
|
||||||
<td class="srep-mono">{{ row.cifNumber || '—' }}</td>
|
|
||||||
<td class="srep-mono">{{ row.accountNo }}</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.nominalShares | number }}</td>
|
|
||||||
<td class="srep-num">{{ row.againstNominal > 0 ? '+' : '' }}{{ row.againstNominal | number }}</td>
|
|
||||||
<td class="srep-num">{{ row.minimumShares | number }}</td>
|
|
||||||
<td class="srep-num">{{ row.maximumShares | number }}</td>
|
|
||||||
<td class="srep-num">{{ row.faceValue | number: '1.2-2' }}</td>
|
|
||||||
<td class="srep-num">₹{{ row.nominalValue | number: '1.2-2' }}</td>
|
|
||||||
<td>
|
|
||||||
<span class="srep-pill" [ngClass]="membershipClass(row.membershipClass)">{{
|
|
||||||
membershipLabel(row.membershipClass)
|
|
||||||
}}</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span class="srep-pill" [ngClass]="complianceClass(row.complianceStatus)">{{
|
|
||||||
complianceLabel(row.complianceStatus)
|
|
||||||
}}</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
<tfoot>
|
|
||||||
<tr>
|
|
||||||
<td colspan="11">
|
|
||||||
{{ isFiltered ? 'Total (filtered)' : 'Total' }} — {{ filteredRows.length | number }} accounts
|
|
||||||
</td>
|
|
||||||
<td class="srep-num">₹{{ shownNominalValue | number: '1.2-2' }}</td>
|
|
||||||
<td colspan="2"></td>
|
|
||||||
</tr>
|
|
||||||
</tfoot>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (isFiltered && totals) {
|
|
||||||
<div class="srep-scope-note">
|
|
||||||
Showing a filtered view. Society-wide: ₹{{ totals.nominalValue | number: '1.2-2' }} nominal capital,
|
|
||||||
{{ totals.regularMembers | number }} regular and {{ totals.nominalMembers | number }} nominal members,
|
|
||||||
{{ totals.belowMinimum + totals.aboveMaximum | number }} outside the class's limits.
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
@@ -1,337 +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 { 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 { SettingsService } from 'app/settings/settings.service';
|
|
||||||
import {
|
|
||||||
SHARE_ACCOUNT_STATUS,
|
|
||||||
SHARE_HOLDING_STATE_ORDER,
|
|
||||||
holdingStateClass,
|
|
||||||
holdingStateLabel,
|
|
||||||
holdingStateNote,
|
|
||||||
holdingStateRowClass,
|
|
||||||
shareReportCsv,
|
|
||||||
shareReportPrint
|
|
||||||
} from '../share-report.util';
|
|
||||||
|
|
||||||
interface NominalRow {
|
|
||||||
shareAccountId: number;
|
|
||||||
accountNo: string;
|
|
||||||
clientId: number;
|
|
||||||
cifNumber: string;
|
|
||||||
clientName: string;
|
|
||||||
officeName: string;
|
|
||||||
productId: number;
|
|
||||||
productName: string;
|
|
||||||
statusEnum: number;
|
|
||||||
faceValue: number;
|
|
||||||
nominalShares: number;
|
|
||||||
minimumShares: number;
|
|
||||||
maximumShares: number;
|
|
||||||
sharesHeld: number;
|
|
||||||
nominalValue: number;
|
|
||||||
againstNominal: number;
|
|
||||||
complianceStatus: 'NO_HOLDING' | 'BELOW_MINIMUM' | 'ABOVE_MAXIMUM' | 'WITHIN_LIMITS' | 'NOT_APPLICABLE';
|
|
||||||
membershipClass: 'NONE' | 'NOMINAL' | 'REGULAR' | 'NOT_APPLICABLE';
|
|
||||||
holdingState: string;
|
|
||||||
unpaidShares: number;
|
|
||||||
activatedDate: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** What a compliance state is called and how it is flagged. */
|
|
||||||
const COMPLIANCE: { [key: string]: { label: string; cls: string } } = {
|
|
||||||
WITHIN_LIMITS: { label: 'Within limits', cls: 'srep-pill--green' },
|
|
||||||
BELOW_MINIMUM: { label: 'Below minimum', cls: 'srep-pill--red' },
|
|
||||||
ABOVE_MAXIMUM: { label: 'Above maximum', cls: 'srep-pill--red' },
|
|
||||||
NO_HOLDING: { label: 'No holding', cls: 'srep-pill--grey' },
|
|
||||||
// A closed or rejected account is not subject to the class's minimum any more, so it is excused
|
|
||||||
// rather than reported as breaching it.
|
|
||||||
NOT_APPLICABLE: { label: 'Not applicable', cls: 'srep-pill--grey' }
|
|
||||||
};
|
|
||||||
|
|
||||||
const MEMBERSHIP: { [key: string]: { label: string; cls: string } } = {
|
|
||||||
REGULAR: { label: 'Regular', cls: 'srep-pill--green' },
|
|
||||||
NOMINAL: { label: 'Nominal', cls: 'srep-pill--amber' },
|
|
||||||
NONE: { label: '—', cls: 'srep-pill--grey' },
|
|
||||||
NOT_APPLICABLE: { label: '—', cls: 'srep-pill--grey' }
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Nominal Share Report — each member's holding read against the three numbers the share class
|
|
||||||
* itself sets: its nominal subscription, its minimum and its maximum.
|
|
||||||
*
|
|
||||||
* <p>
|
|
||||||
* The report exists to find breaches, so the default view is every member and the breaches are
|
|
||||||
* flagged in place; the compliance filter is there for when the answer needed is only "who is
|
|
||||||
* short".
|
|
||||||
*/
|
|
||||||
@Component({
|
|
||||||
selector: 'mifosx-share-nominal-report',
|
|
||||||
templateUrl: './share-nominal-report.component.html',
|
|
||||||
styleUrls: [
|
|
||||||
'../../../letter-mgmt/certificates/certificates.shared.scss',
|
|
||||||
'../share-report.shared.scss'
|
|
||||||
],
|
|
||||||
imports: [
|
|
||||||
CommonModule,
|
|
||||||
FormsModule,
|
|
||||||
FaIconComponent
|
|
||||||
]
|
|
||||||
})
|
|
||||||
export class ShareNominalReportComponent implements OnInit {
|
|
||||||
private sharesService = inject(SharesService);
|
|
||||||
private settingsService = inject(SettingsService);
|
|
||||||
private router = inject(Router);
|
|
||||||
|
|
||||||
loading = true;
|
|
||||||
loadError = '';
|
|
||||||
rows: NominalRow[] = [];
|
|
||||||
totals: any = null;
|
|
||||||
stateCounts: any = null;
|
|
||||||
|
|
||||||
asOnDate = '';
|
|
||||||
search = '';
|
|
||||||
branchFilter = '';
|
|
||||||
productFilter = '';
|
|
||||||
complianceFilter = '';
|
|
||||||
membershipFilter = '';
|
|
||||||
/**
|
|
||||||
* Which accounts to show. Defaults to the live ones — a compliance report is read as "who is
|
|
||||||
* short today", and a closed member cannot be short of anything. Closed and rejected accounts are
|
|
||||||
* one dropdown away rather than gone, and the count of what the default hides is shown above the
|
|
||||||
* table so the narrowing is never silent.
|
|
||||||
*/
|
|
||||||
stateFilter = 'LIVE';
|
|
||||||
readonly stateOrder = SHARE_HOLDING_STATE_ORDER;
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
|
||||||
const businessDate: Date = this.settingsService.businessDate || new Date();
|
|
||||||
this.asOnDate = this.toIso(businessDate);
|
|
||||||
this.load();
|
|
||||||
}
|
|
||||||
|
|
||||||
load(): void {
|
|
||||||
this.loading = true;
|
|
||||||
this.loadError = '';
|
|
||||||
this.sharesService
|
|
||||||
.getShareNominalReport({ asOnDate: this.asOnDate })
|
|
||||||
.pipe(
|
|
||||||
catchError((err) => {
|
|
||||||
this.loadError = this.readError(err, 'The nominal share report could not be loaded.');
|
|
||||||
return of(null);
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.subscribe((res: any) => {
|
|
||||||
this.rows = res?.rows || [];
|
|
||||||
this.totals = res?.totals || null;
|
|
||||||
this.stateCounts = res?.stateCounts || null;
|
|
||||||
this.loading = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Live means the account is still a going concern, whether or not it currently holds anything. */
|
|
||||||
private isLive(row: NominalRow): boolean {
|
|
||||||
return row.holdingState === 'HOLDING' || row.holdingState === 'UNPAID' || row.holdingState === 'NIL';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** How many accounts the default "live only" view is leaving out, so it can say so. */
|
|
||||||
get hiddenByStateCount(): number {
|
|
||||||
return this.stateFilter === 'LIVE' ? this.rows.filter((r) => !this.isLive(r)).length : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
get unpaidRows(): NominalRow[] {
|
|
||||||
return this.rows.filter((r) => r.holdingState === 'UNPAID');
|
|
||||||
}
|
|
||||||
|
|
||||||
get unpaidShares(): number {
|
|
||||||
return this.unpaidRows.reduce((sum, r) => sum + Number(r.unpaidShares || 0), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 filteredRows(): NominalRow[] {
|
|
||||||
const term = this.search.trim().toLowerCase();
|
|
||||||
return this.rows.filter((r) => {
|
|
||||||
if (this.stateFilter === 'LIVE' ? !this.isLive(r) : this.stateFilter && r.holdingState !== this.stateFilter) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (this.branchFilter && r.officeName !== this.branchFilter) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (this.productFilter && r.productName !== this.productFilter) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (this.complianceFilter && r.complianceStatus !== this.complianceFilter) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (this.membershipFilter && r.membershipClass !== this.membershipFilter) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!term) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return [
|
|
||||||
r.clientName,
|
|
||||||
r.accountNo,
|
|
||||||
r.cifNumber,
|
|
||||||
r.productName
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.some((field) => String(field).toLowerCase().includes(term));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
get shownNominalValue(): number {
|
|
||||||
return this.filteredRows.reduce((sum, r) => sum + Number(r.nominalValue || 0), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
get isFiltered(): boolean {
|
|
||||||
return !!(
|
|
||||||
this.search.trim() ||
|
|
||||||
this.branchFilter ||
|
|
||||||
this.productFilter ||
|
|
||||||
this.complianceFilter ||
|
|
||||||
this.membershipFilter ||
|
|
||||||
this.stateFilter !== 'LIVE'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
complianceLabel(status: string): string {
|
|
||||||
return COMPLIANCE[status]?.label || status;
|
|
||||||
}
|
|
||||||
|
|
||||||
complianceClass(status: string): string {
|
|
||||||
return COMPLIANCE[status]?.cls || 'srep-pill--grey';
|
|
||||||
}
|
|
||||||
|
|
||||||
membershipLabel(membership: string): string {
|
|
||||||
return MEMBERSHIP[membership]?.label || membership;
|
|
||||||
}
|
|
||||||
|
|
||||||
membershipClass(membership: string): string {
|
|
||||||
return MEMBERSHIP[membership]?.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';
|
|
||||||
}
|
|
||||||
|
|
||||||
clearFilters(): void {
|
|
||||||
this.search = '';
|
|
||||||
this.branchFilter = '';
|
|
||||||
this.productFilter = '';
|
|
||||||
this.complianceFilter = '';
|
|
||||||
this.membershipFilter = '';
|
|
||||||
this.stateFilter = 'LIVE';
|
|
||||||
}
|
|
||||||
|
|
||||||
openAccount(row: NominalRow): void {
|
|
||||||
this.router.navigate([
|
|
||||||
'/clients',
|
|
||||||
row.clientId,
|
|
||||||
'shares-accounts',
|
|
||||||
row.shareAccountId,
|
|
||||||
'general'
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
exportCsv(): void {
|
|
||||||
shareReportCsv(
|
|
||||||
`share-nominal-${this.asOnDate || 'today'}`,
|
|
||||||
[
|
|
||||||
'Member',
|
|
||||||
'Membership No',
|
|
||||||
'Share A/c No',
|
|
||||||
'Branch',
|
|
||||||
'Product',
|
|
||||||
'Account Status',
|
|
||||||
'Holding State',
|
|
||||||
'Shares Held',
|
|
||||||
'Allotted Unpaid',
|
|
||||||
'Nominal Required',
|
|
||||||
'Against Nominal',
|
|
||||||
'Minimum',
|
|
||||||
'Maximum',
|
|
||||||
'Face Value',
|
|
||||||
'Nominal Value',
|
|
||||||
'Membership Class',
|
|
||||||
'Compliance'
|
|
||||||
],
|
|
||||||
this.filteredRows.map((r) => [
|
|
||||||
r.clientName,
|
|
||||||
r.cifNumber,
|
|
||||||
r.accountNo,
|
|
||||||
r.officeName,
|
|
||||||
r.productName,
|
|
||||||
this.statusLabel(r.statusEnum),
|
|
||||||
this.stateLabel(r.holdingState),
|
|
||||||
r.sharesHeld,
|
|
||||||
r.unpaidShares,
|
|
||||||
r.nominalShares,
|
|
||||||
r.againstNominal,
|
|
||||||
r.minimumShares,
|
|
||||||
r.maximumShares,
|
|
||||||
r.faceValue,
|
|
||||||
r.nominalValue,
|
|
||||||
this.membershipLabel(r.membershipClass),
|
|
||||||
this.complianceLabel(r.complianceStatus)
|
|
||||||
])
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
print(): void {
|
|
||||||
shareReportPrint();
|
|
||||||
}
|
|
||||||
|
|
||||||
private toIso(date: Date): string {
|
|
||||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
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