Compare commits

..
2 Commits
Author SHA1 Message Date
satyamsingh_8888 6eaf9b959f Add Harbor login to deploy step so cross-node pull auth propagates to dev2
continuous-integration/drone/push Build is passing
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
2026-09-14 18:41:34 +05:30
satyamsingh_8888 b184a0909c Deploy cbsnexora UI to dev2 node (environment==dev2 constraint)
continuous-integration/drone/push Build was killed
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s
2026-09-14 18:22:00 +05:30
65 changed files with 3096 additions and 8643 deletions
+302
View File
@@ -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
@@ -16,7 +16,6 @@ import { AccountingService } from '../../accounting.service';
import { GLAccount } from 'app/shared/models/general.model';
import { GlAccountSelectorComponent } from '../../../shared/accounting/gl-account-selector/gl-account-selector.component';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { accountOptionsForActivity } from '../financial-activity-account-options';
/**
* Create financial activity mapping component.
@@ -88,11 +87,21 @@ export class CreateFinancialActivityMappingComponent implements OnInit {
*/
setGlAccountData() {
this.financialActivityMappingForm.get('financialActivityId').valueChanges.subscribe((financialActivityId) => {
this.glAccountData = accountOptionsForActivity(
this.financialActivityData,
financialActivityId,
this.glAccountOptions
);
switch (financialActivityId) {
case 100:
case 101:
case 102:
case 103:
this.glAccountData = this.glAccountOptions.assetAccountOptions;
break;
case 200:
case 201:
this.glAccountData = this.glAccountOptions.liabilityAccountOptions;
break;
case 300:
this.glAccountData = this.glAccountOptions.equityAccountOptions;
break;
}
});
}
@@ -16,7 +16,6 @@ import { AccountingService } from '../../accounting.service';
import { GLAccount } from 'app/shared/models/general.model';
import { GlAccountSelectorComponent } from '../../../shared/accounting/gl-account-selector/gl-account-selector.component';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { accountOptionsForActivity } from '../financial-activity-account-options';
/**
* Edit financial activity mapping component.
@@ -99,11 +98,21 @@ export class EditFinancialActivityMappingComponent implements OnInit {
*/
setGLAccountData() {
this.financialActivityMappingForm.get('financialActivityId').valueChanges.subscribe((financialActivityId) => {
this.glAccountData = accountOptionsForActivity(
this.financialActivityData,
financialActivityId,
this.glAccountOptions
);
switch (financialActivityId) {
case 100:
case 101:
case 102:
case 103:
this.glAccountData = this.glAccountOptions.assetAccountOptions;
break;
case 200:
case 201:
this.glAccountData = this.glAccountOptions.liabilityAccountOptions;
break;
case 300:
this.glAccountData = this.glAccountOptions.equityAccountOptions;
break;
}
});
}
@@ -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`] ?? [];
}
-45
View File
@@ -34,17 +34,6 @@ export class AmlService {
return this.http.post(`/aml/alerts/${alertId}/review`, { disposition, notes });
}
/**
* Decides named rules within one alert, leaving its other rules open.
*
* <p>
* The alert's own status follows from what is left undecided, so it is not sent: whitelisting five of six rules
* leaves the alert open on the sixth.
*/
reviewAlertRules(alertId: number, ruleCodes: string[], disposition: string, notes: string): Observable<any> {
return this.http.post(`/aml/alerts/${alertId}/review`, { disposition, notes, ruleCodes });
}
// ═══ Recurring-alert review queue ═══
/**
@@ -152,38 +141,4 @@ export class AmlService {
getRiskCategoryHistory(clientId: number): Observable<any[]> {
return this.http.get<any[]>(`/clients/${clientId}/risk-category-history`);
}
// ═══ Risk-category changes (maker-checker) ═══
//
// The rating decides how often a customer is re-KYC'd and what AML thresholds apply to them, so
// it moves only once a checker agrees rather than on one officer's say-so.
/** @param requestedCategory 1 Low, 2 Medium, 3 High */
requestRiskChange(clientId: number, requestedCategory: number, reason: string): Observable<any> {
return this.http.post('/aml/risk-change-requests', { clientId, requestedCategory, reason });
}
getRiskChangeRequests(status?: string): Observable<any[]> {
const params = status ? new HttpParams().set('status', status) : undefined;
return this.http.get<any[]>('/aml/risk-change-requests', { params });
}
approveRiskChange(requestId: number): Observable<any> {
return this.http.post(`/aml/risk-change-requests/${requestId}/approve`, {});
}
rejectRiskChange(requestId: number, rejectionReason: string): Observable<any> {
return this.http.post(`/aml/risk-change-requests/${requestId}/reject`, { rejectionReason });
}
/**
* The latest behaviour window for each customer, as the nightly job computed it.
*
* One row per customer carrying the review period's figures and the rating they suggest. The Risk Review report
* prefers these over its own per-alert reckoning, because a rating describes a period rather than the handful of
* transactions that happened to trip a rule.
*/
getBehaviourWindows(): Observable<any[]> {
return this.http.get<any[]>('/risk-review/behaviour-windows');
}
}
@@ -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">&#8377;{{ totalValue | number: '1.0-0' }}</div>
<div class="bor-kpi-l">Share Value</div>
</div>
</div>
<div class="bor-filters">
<div class="bor-filter">
<label>Resolution No.</label>
<input class="bor-inp" type="text" [(ngModel)]="filterResolution" placeholder="e.g. BOR/2026-27/001" />
</div>
<div class="bor-filter">
<label>Branch</label>
<select class="bor-inp" [(ngModel)]="filterBranch">
<option value="">All branches</option>
@for (b of branches; track b) {
<option [value]="b">{{ b }}</option>
}
</select>
</div>
<div class="bor-filter">
<label>Status</label>
<select class="bor-inp" [(ngModel)]="filterStatus">
<option [ngValue]="''">Any status</option>
@for (s of statuses; track s) {
<option [ngValue]="s">{{ statusText(s) }}</option>
}
</select>
</div>
<div class="bor-filter bor-filter--grow">
<label>Search</label>
<input class="bor-inp" type="text" [(ngModel)]="search" placeholder="Member name, share A/c or CIF…" />
</div>
<label class="bor-check">
<input type="checkbox" [(ngModel)]="missingOnly" />
No resolution only
</label>
<button class="bor-btn" type="button" (click)="resetFilters()"><fa-icon icon="undo"></fa-icon> Reset</button>
</div>
<div class="bor-table-wrap">
<table class="bor-table">
<thead>
<tr>
<th>Resolution No.</th>
<th>Resolution Date</th>
<th>Share A/c No.</th>
<th>CIF No.</th>
<th>Member Name</th>
<th>Branch</th>
<th>Product</th>
<th class="num">Shares</th>
<th class="num">Share Value</th>
<th class="num">Tranches</th>
<th>Status</th>
<th>Activated On</th>
</tr>
</thead>
<tbody>
@for (r of filtered; track r.shareAccountId) {
<tr [class.bor-row--missing]="!r.resolutionNumber">
<td>
@if (r.resolutionNumber) {
<strong class="bor-res">{{ r.resolutionNumber }}</strong>
} @else {
<span class="bor-missing">Not recorded</span>
}
</td>
<td class="mono">{{ r.resolutionDate || '—' }}</td>
<td class="mono">{{ r.accountNo }}</td>
<td class="mono">{{ r.cifNumber || '—' }}</td>
<td>{{ r.clientName }}</td>
<td>{{ r.officeName || '—' }}</td>
<td>{{ r.productName || '—' }}</td>
<td class="num">{{ r.approvedShares | number }}</td>
<td class="num strong">{{ r.shareValue | number: '1.2-2' }}</td>
<td class="num">{{ r.trancheCount }}</td>
<td><span [class]="statusClass(r.statusEnum)">{{ statusText(r.statusEnum) }}</span></td>
<td class="mono">{{ r.activatedDate || '—' }}</td>
</tr>
} @empty {
<tr>
<td colspan="12" class="bor-empty">No share account matches the filters.</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
@@ -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 { ClosingCashReportComponent } from './closing-cash-report/closing-cash-report.component';
import { ExcessTransactionReportComponent } from './excess-transaction-report/excess-transaction-report.component';
import { ShareCollectionComponent } from './share-collection/share-collection.component';
const routes: Routes = [
Route.withShell([
@@ -344,6 +345,11 @@ const routes: Routes = [
component: ExcessTransactionReportComponent,
data: { title: 'Excess Transaction Report', breadcrumb: 'Excess Transaction Report' }
},
{
path: 'share-collection',
component: ShareCollectionComponent,
data: { title: 'Share Collection', breadcrumb: 'Share Collection' }
},
{
path: 'transaction-entry',
canActivate: [CashierOnlyGuard],
+3 -1
View File
@@ -20,6 +20,7 @@ import { ReconciliationComponent } from './reconciliation/reconciliation.compone
import { CashHistoryComponent } from './cash-history/cash-history.component';
import { ClosingCashReportComponent } from './closing-cash-report/closing-cash-report.component';
import { ExcessTransactionReportComponent } from './excess-transaction-report/excess-transaction-report.component';
import { ShareCollectionComponent } from './share-collection/share-collection.component';
@NgModule({
imports: [
@@ -34,7 +35,8 @@ import { ExcessTransactionReportComponent } from './excess-transaction-report/ex
ReconciliationComponent,
CashHistoryComponent,
ClosingCashReportComponent,
ExcessTransactionReportComponent
ExcessTransactionReportComponent,
ShareCollectionComponent
]
})
export class CashierModule {}
+13 -23
View File
@@ -178,13 +178,6 @@ export class CashierService {
return this.http.get('/vouchers/my-work', { params });
}
/** One voucher and every debit/credit line it is made of — the full slip behind a summarised
* row (a "+2 more" badge in a register only ever shows the first side of each). */
voucherDetail(voucherNo: string): Observable<any> {
const params = new HttpParams().set('voucherNo', voucherNo);
return this.http.get('/vouchers/detail', { params });
}
/**
* Account register for one date: what opened and closed that day, each account's balance as it
* stood then, and what it holds now.
@@ -364,6 +357,19 @@ export class CashierService {
return this.http.get<any[]>('/paymenttypes');
}
// ── Share Collection ──
getShareAccountForCollection(accountId: string): Observable<any> {
return this.http.get(`/accounts/share/${accountId}`);
}
collectSharePayment(data: any): Observable<any> {
return this.http.post('/cashier/sharecollection', data);
}
getShareCollectionStatus(shareAccountId: number): Observable<any> {
return this.http.get(`/cashier/sharecollection/${shareAccountId}`);
}
/** One unapplied Share Sundry receipt, found by the voucher number the member quotes. */
getShareAdvanceByVoucher(voucherNo: string, glAccountNo: string, clientId?: number): Observable<any> {
// The voucher travels as a query parameter, not a path segment: numbers are formatted
@@ -401,12 +407,6 @@ export class CashierService {
return this.http.post(`/shareadvancecollection/${collectionId}/apply`, data);
}
/** The receipt's own real Dr/Cr GL legs — never the GL account currently picked in a search box,
* which is unrelated screen state that can name a different account than this receipt sits in. */
getShareAdvanceGlEntries(collectionId: number): Observable<any> {
return this.http.get(`/shareadvancecollection/${collectionId}/gl-entries`);
}
/** Every additional-share tranche awaiting approval, across every share account. */
getShareAdditionRequests(): Observable<any> {
return this.http.get('/shareadditionrequests');
@@ -419,14 +419,4 @@ export class CashierService {
getShareAdditionRequest(transactionId: number): Observable<any> {
return this.http.get(`/shareadditionrequests/${transactionId}`);
}
/**
* Entry Type options for a GL credit (General / Share Collection / DD Issue / …) — the
* "GLEntryType" code under Admin > System > Manage Codes, so an admin can add another type later
* without a deployment. Empty on failure rather than erroring, since a row missing this dropdown's
* options should still be usable for every other field.
*/
getEntryTypeOptions(): Observable<any[]> {
return this.http.get<any[]>('/codes/name/GLEntryType/codevalues').pipe(catchError(() => of([])));
}
}
@@ -174,24 +174,19 @@ export class ClosingCashReportComponent implements OnInit {
return this.noteRows.reduce((s, r) => s + Number(r.settledOutAmount ?? 0), 0);
}
/**
* Summed from each row's own `netCount`, not re-derived from today's legs — a row's `netCount`
* already carries forward what a cashier was holding from before today (`openingCount`), for the
* force-closed-day/late-settlement case. Recomputing here from only today's vaultIn/deposit/
* withdrawal/settledOut dropped that carry-forward and put the Total row back into negative
* numbers a cashier had already reconciled.
*/
get noteNetCount(): number {
return this.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 —
* 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.
* What is left in the drawer in notes: everything that came in, less everything that went out.
*
* Was deposits minus withdrawals, which is the counter's movement rather than the drawer's
* contents — it left out the cash drawn from the vault, so a teller holding a ₹500 note saw a net
* of zero and had nothing to count against.
*/
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[] {
return this.rows.filter((r) => r.kind === 'coin');
+4 -16
View File
@@ -152,21 +152,9 @@
<tbody>
@for (r of g.rows; track r.voucherNo) {
<!-- The left edge repeats the section's own colour, so a row still says which part it
is when the heading has scrolled away. Clicking anywhere on the row opens the full
slip -- every line, not just the first debit and first credit this table has room
to show. -->
<tr
[class]="'mw-row mw-row--' + r.myRole"
class="mw-row--clickable"
(click)="openDetail(r.voucherNo)"
[attr.aria-busy]="loadingDetailFor === r.voucherNo"
>
<td class="mw-vno">
{{ r.voucherNo }}
@if (loadingDetailFor === r.voucherNo) {
<mat-icon class="mw-vno__spinner" inline>hourglass_empty</mat-icon>
}
</td>
is when the heading has scrolled away. -->
<tr [class]="'mw-row mw-row--' + r.myRole">
<td class="mw-vno">{{ r.voucherNo }}</td>
<td>
<span class="mw-badge" [class]="'mw-badge--' + (r.side || 'JV')">{{ sideLabel(r.side) }}</span>
</td>
@@ -183,7 +171,7 @@
<span class="mw-acct">{{ r.creditAccount }}</span>
}
@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 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
tally and a checker's are each readable without adding rows up. */
.mw-group {
+2 -35
View File
@@ -10,10 +10,8 @@ import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { catchError, finalize, of } from 'rxjs';
import { catchError, of } from 'rxjs';
import { CashierService } from '../cashier.service';
import { VoucherDetailDialogComponent } from './voucher-detail-dialog.component';
/** What the signed-in user's part in a voucher was. */
export type MyRole = 'RAISED' | 'VERIFIED' | 'RAISED_AND_VERIFIED';
@@ -74,17 +72,11 @@ interface MyWorkReport {
imports: [
CommonModule,
FormsModule,
MatIconModule,
MatDialogModule
MatIconModule
]
})
export class MyWorkComponent implements OnInit {
private service = inject(CashierService);
private dialog = inject(MatDialog);
/** Which voucher's detail is currently being fetched, so the row that was clicked can show its
* own spinner instead of a page-wide one for what is a small, single-row lookup. */
loadingDetailFor: string | null = null;
/** Day is the working default; a month is what a close or an audit asks for. */
period: 'DAY' | 'MONTH' = 'DAY';
@@ -331,31 +323,6 @@ export class MyWorkComponent implements OnInit {
return { from: first, to: lastIso };
}
/** Opens the full slip for one voucher — every debit and credit line, not just the first side of
* each a table row has room to show. */
openDetail(voucherNo: string): void {
if (this.loadingDetailFor) {
return;
}
this.loadingDetailFor = voucherNo;
this.service
.voucherDetail(voucherNo)
.pipe(
catchError(() => of(null)),
finalize(() => (this.loadingDetailFor = null))
)
.subscribe((detail: any) => {
if (!detail) {
return;
}
this.dialog.open(VoucherDetailDialogComponent, {
data: { detail },
width: '600px',
autoFocus: false
});
});
}
private todayIso(): string {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
@@ -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' }} &middot; {{ detail.branchName || 'Head Office' }}</p>
</div>
</div>
<button mat-icon-button mat-dialog-close aria-label="Close">
<mat-icon>close</mat-icon>
</button>
</div>
<div class="vd-meta">
<span
>Raised by <strong>{{ detail.createdBy || '—' }}</strong></span
>
@if (detail.createdAt) {
<span>{{ detail.createdAt | date: 'dd MMM yyyy, h:mm a' }}</span>
}
@if (!detail.balanced) {
<span class="vd-unbalanced">
<mat-icon inline>warning</mat-icon>
Debit and credit do not balance
</span>
}
</div>
<table class="vd-table">
<thead>
<tr>
<th>Side</th>
<th>Party</th>
<th>Account No.</th>
<th class="num">Amount</th>
</tr>
</thead>
<tbody>
@for (line of detail.lines; track line.lineNo) {
<tr>
<td>
<span
class="vd-side"
[class.vd-side--dr]="line.entryType === 'DR'"
[class.vd-side--cr]="line.entryType === 'CR'"
>
{{ line.entryType === 'DR' ? 'Debit' : 'Credit' }}
</span>
</td>
<td>{{ line.partyName || '—' }}</td>
<td class="vd-acct">{{ line.accountNo || '—' }}</td>
<td class="num">₹{{ line.amount | number: '1.2-2' }}</td>
</tr>
@if (line.description) {
<tr class="vd-desc-row">
<td></td>
<td colspan="3" class="vd-desc">{{ line.description }}</td>
</tr>
}
} @empty {
<tr>
<td colspan="4" class="vd-empty">This voucher's individual lines were not recorded.</td>
</tr>
}
</tbody>
@if (detail.lines.length > 0) {
<tfoot>
<tr>
<td colspan="3">Total</td>
<td class="num">
₹{{ detail.totalDebit | number: '1.2-2' }} / ₹{{ detail.totalCredit | number: '1.2-2' }}
</td>
</tr>
</tfoot>
}
</table>
@if (detail.relatedLines.length > 0) {
<div class="vd-related">
<div class="vd-related__title">
<mat-icon inline>link</mat-icon>
Also posted for this transaction — not part of this voucher
</div>
<table class="vd-table vd-table--related">
<tbody>
@for (line of detail.relatedLines; track $index) {
<tr>
<td>
<span
class="vd-side"
[class.vd-side--dr]="line.entryType === 'DR'"
[class.vd-side--cr]="line.entryType === 'CR'"
>
{{ line.entryType === 'DR' ? 'Debit' : 'Credit' }}
</span>
</td>
<td>{{ line.partyName || '—' }}</td>
<td class="vd-acct">{{ line.accountNo || '—' }}</td>
<td class="num">₹{{ line.amount | number: '1.2-2' }}</td>
</tr>
}
</tbody>
</table>
<p class="vd-related__hint">
{{ detail.relatedLines[0].stage }} — this and the lines above are the same transaction's full accounting.
</p>
</div>
}
<div class="vd-actions">
<button mat-flat-button color="primary" mat-dialog-close>Close</button>
</div>
</div>
`,
styles: [
`
.vd-shell {
display: flex;
flex-direction: column;
gap: 16px;
padding: 20px 24px 16px;
min-width: 420px;
max-width: 560px;
}
.vd-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
}
.vd-header__title {
display: flex;
align-items: center;
gap: 12px;
}
.vd-header__title h2 {
margin: 0;
font-size: 18px;
font-weight: 600;
}
.vd-header__title p {
margin: 2px 0 0;
font-size: 13px;
color: rgba(0, 0, 0, 0.6);
}
.vd-icon {
color: #1976d2;
}
.vd-meta {
display: flex;
flex-wrap: wrap;
gap: 6px 16px;
font-size: 13px;
color: rgba(0, 0, 0, 0.65);
}
.vd-unbalanced {
display: inline-flex;
align-items: center;
gap: 4px;
color: #b91c1c;
font-weight: 600;
}
.vd-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.vd-table th {
text-align: left;
text-transform: uppercase;
font-size: 11px;
letter-spacing: 0.03em;
color: rgba(0, 0, 0, 0.5);
padding: 6px 8px;
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
}
.vd-table td {
padding: 8px;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
.vd-table .num {
text-align: right;
font-variant-numeric: tabular-nums;
}
.vd-acct {
color: rgba(0, 0, 0, 0.55);
}
.vd-side {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
}
.vd-side--dr {
background: #fee2e2;
color: #b91c1c;
}
.vd-side--cr {
background: #dcfce7;
color: #15803d;
}
.vd-desc-row td {
padding-top: 0;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
.vd-desc {
font-size: 12px;
color: rgba(0, 0, 0, 0.5);
font-style: italic;
}
.vd-empty {
text-align: center;
color: rgba(0, 0, 0, 0.5);
padding: 16px 8px;
}
.vd-table tfoot td {
font-weight: 600;
border-bottom: none;
border-top: 1px solid rgba(0, 0, 0, 0.1);
}
.vd-related {
border-top: 1px dashed rgba(0, 0, 0, 0.15);
padding-top: 12px;
}
.vd-related__title {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
color: #6d28d9;
margin-bottom: 8px;
}
.vd-table--related td {
background: #faf5ff;
}
.vd-related__hint {
margin: 6px 0 0;
font-size: 12px;
color: rgba(0, 0, 0, 0.5);
}
.vd-actions {
display: flex;
justify-content: flex-end;
}
`
]
})
export class VoucherDetailDialogComponent {
dialogRef = inject(MatDialogRef<VoucherDetailDialogComponent>);
private data = inject<VoucherDetailDialogData>(MAT_DIALOG_DATA);
detail = this.data.detail;
}
@@ -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 &amp; Income GL when account is <strong>activated</strong>.
</div>
</div>
</div>
<!-- ── DENOMINATION (Cash Only) ── -->
@if (isCashPayment && allDenominations.length > 0) {
<div class="pg-card sc-denom">
<div class="sc-breakdown__header">
<mat-icon>payments</mat-icon>
<span>Denomination Entry</span>
@if (denominationTotal > 0) {
<span
class="sc-denom-badge"
[class.sc-denom-badge--match]="denomMatchesTotal"
[class.sc-denom-badge--mismatch]="!denomMatchesTotal"
>
₹{{ denominationTotal | number: '1.2-2' }}
{{ denomMatchesTotal ? '✓ Matches' : '≠ Mismatch' }}
</span>
}
@if (denominationTotal === 0) {
<span class="sc-denom-required">⚠ Required for Cash</span>
}
</div>
<div class="sc-denom-body">
<table class="pg-table sc-denom-table">
<thead>
<tr>
<th>Denomination</th>
<th class="num">Count</th>
<th class="num">Amount</th>
</tr>
</thead>
<tbody>
@for (d of allDenominations; track d.id) {
<tr>
<td>
<span class="sc-denom-chip" [class.sc-denom-chip--coin]="d.kind === 'coin'">{{ d.label }}</span>
</td>
<td class="num">
<div class="sc-denom-stepper">
<button type="button" (click)="bumpDenom(d, -1)" [disabled]="countFor(d) === 0">−</button>
<input
type="number"
min="0"
[value]="countFor(d) || ''"
placeholder="0"
(input)="onDenomChange(d, $any($event.target).value)"
/>
<button type="button" (click)="bumpDenom(d, 1)">+</button>
</div>
</td>
<td class="num">{{ subtotalFor(d) > 0 ? (subtotalFor(d) | number: '1.2-2') : '—' }}</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td><strong>Total</strong></td>
<td></td>
<td class="num">
<strong>₹{{ denominationTotal | number: '1.2-2' }}</strong>
</td>
</tr>
</tfoot>
</table>
@if (!denomMatchesTotal && denominationTotal > 0) {
<div class="sc-denom-mismatch">
<mat-icon>warning</mat-icon>
Denomination ₹{{ denominationTotal | number: '1.2-2' }} must equal Total Payable ₹{{
totalAmount | number: '1.2-2'
}}.
</div>
}
@if (denominationTotal > 0) {
<div class="sc-denom-actions">
<button type="button" class="pg-btn pg-btn--ghost" (click)="clearDenominations()">
<mat-icon>clear</mat-icon> Clear
</button>
</div>
}
</div>
</div>
}
<!-- Transaction Details -->
<div class="pg-toolbar pg-toolbar--mat">
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Transaction Date *</mat-label>
<input matInput type="date" [(ngModel)]="transactionDate" />
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Payment Mode</mat-label>
<mat-select [(ngModel)]="paymentTypeId">
<mat-option [value]="null">— Cash (Default) —</mat-option>
@for (pt of paymentTypes; track pt.id) {
<mat-option [value]="pt.id">{{ pt.name }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="pg-note-field">
<mat-label>Note (Optional)</mat-label>
<input matInput type="text" [(ngModel)]="note" placeholder="e.g. Share purchase for 100 shares" />
</mat-form-field>
</div>
<!-- Footer Submit Bar -->
<div class="pg-total-bar sc-footer">
<div class="sc-footer__summary">
<span class="sc-footer__label">Total to Collect</span>
<span class="sc-footer__amount">{{ currencySymbol }}{{ totalAmount | number: '1.2-2' }}</span>
</div>
<div class="sc-footer__actions">
<button type="button" class="pg-btn pg-btn--ghost" (click)="reset()"><mat-icon>close</mat-icon> Cancel</button>
<button type="button" class="pg-btn pg-btn--primary" [disabled]="!isValid || saving" (click)="submit()">
<mat-icon>payments</mat-icon>
{{ saving ? 'Posting…' : 'Collect & Post' }}
</button>
</div>
</div>
}
<!-- ── SUCCESS ── -->
@if (pageState === 'success' && collectionResult) {
<div class="sc-success">
<div class="sc-success__icon-wrap">
<mat-icon>check_circle</mat-icon>
</div>
<h2 class="sc-success__title">Collection Posted Successfully</h2>
<p class="sc-success__sub">
Cash of
<strong>{{ collectionResult.currencyCode }} {{ collectionResult.totalAmount | number: '1.2-2' }}</strong>
collected from <strong>{{ collectionResult.clientName }}</strong> on
<strong>{{ collectionResult.collectionDate }}</strong> by <strong>{{ collectionResult.collectedBy }}</strong
>.
</p>
<!-- Transaction reference -->
<div class="sc-success__ref">
<span class="sc-success__ref-label">Transaction ID</span>
<span class="sc-success__ref-val">{{ collectionResult.transactionId }}</span>
</div>
<!-- GL entries from backend -->
<div class="sc-success__gl">
@for (entry of collectionResult.glEntries; track entry.account) {
<div
class="sc-success__gl-row"
[class.sc-success__gl-row--dr]="entry.type === 'Dr'"
[class.sc-success__gl-row--cr]="entry.type === 'Cr'"
>
<span>{{ entry.type }}. {{ entry.account }}</span>
<span>{{ entry.currency }} {{ entry.amount | number: '1.2-2' }}</span>
</div>
}
</div>
<p class="sc-success__note">
Funds are held in Sundry GL. They will move to Share Equity GL when the account is activated.
</p>
<button type="button" class="pg-btn pg-btn--primary" (click)="reset()">
<mat-icon>add</mat-icon> New Collection
</button>
</div>
}
</div>
@@ -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();
}
}
@@ -166,50 +166,23 @@
</div>
<div class="ws-f ws-f--actyp">
<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
class="ws-f__ctl"
[(ngModel)]="row.acTyp"
(click)="$event.stopPropagation()"
(change)="
row.acTyp === 'GL' &&
row.typ !== 'Disbursement' &&
row.typ !== 'Fund Transfer' &&
row.typ !== 'Enter Bank Transfer' &&
(row.dbCr = 'Cr')
"
>
@for (a of acTypOptionsFor(row); track a) {
<option [value]="a">{{ a }}</option>
}
</select>
}
<select
class="ws-f__ctl"
[(ngModel)]="row.acTyp"
(click)="$event.stopPropagation()"
(change)="
row.acTyp === 'GL' &&
row.typ !== 'Disbursement' &&
row.typ !== 'Fund Transfer' &&
row.typ !== 'Enter Bank Transfer' &&
(row.dbCr = 'Cr')
"
>
@for (a of acTypOptionsFor(row); track a) {
<option [value]="a">{{ a }}</option>
}
</select>
</div>
@if (showsEntryType(row)) {
<div class="ws-f ws-f--paytype">
<label
class="ws-f__lbl ws-f__lbl--accent"
title="What this money was actually collected for — checked before a share application or DD issuance can adopt this voucher"
>ENTRY TYPE</label
>
<select
class="ws-f__ctl ws-f__ctl--accent"
[(ngModel)]="row.entryTypeCvId"
(click)="$event.stopPropagation()"
>
<option [ngValue]="null">Select…</option>
@for (t of entryTypeOptions; track t.id) {
<option [ngValue]="t.id">{{ t.name }}</option>
}
</select>
</div>
}
</div>
</div>
@@ -233,20 +206,14 @@
? ddGlMapping?.ddPayableGlCode || 'Auto GL'
: 'Account number'
"
[readonly]="
(row.typ === 'DD' && !isDdAccountDebitRow(row)) || (acTypLockedByToken && row === selectedRow)
"
[class.ws-f__ctl--readonly]="
(row.typ === 'DD' && !isDdAccountDebitRow(row)) || (acTypLockedByToken && row === selectedRow)
"
[readonly]="row.typ === 'DD' && !isDdAccountDebitRow(row)"
[class.ws-f__ctl--readonly]="row.typ === 'DD' && !isDdAccountDebitRow(row)"
[title]="
acTypLockedByToken && row === selectedRow
? 'Locked — filled from a token-driven request, not a normal account entry'
: isDdAccountDebitRow(row)
? 'Member account the draft is funded from'
: row.typ === 'DD'
? 'Configured Sponsor DD Payable GL (Auto-selected)'
: 'Account number'
isDdAccountDebitRow(row)
? 'Member account the draft is funded from'
: row.typ === 'DD'
? 'Configured Sponsor DD Payable GL (Auto-selected)'
: 'Account number'
"
/>
</div>
@@ -1556,50 +1523,23 @@
</div>
<div class="ws-f ws-f--actyp">
<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
class="ws-f__ctl"
[(ngModel)]="row.acTyp"
(click)="$event.stopPropagation()"
(change)="
row.acTyp === 'GL' &&
row.typ !== 'Disbursement' &&
row.typ !== 'Fund Transfer' &&
row.typ !== 'Enter Bank Transfer' &&
(row.dbCr = 'Cr')
"
>
@for (a of acTypOptionsFor(row); track a) {
<option [value]="a">{{ a }}</option>
}
</select>
}
<select
class="ws-f__ctl"
[(ngModel)]="row.acTyp"
(click)="$event.stopPropagation()"
(change)="
row.acTyp === 'GL' &&
row.typ !== 'Disbursement' &&
row.typ !== 'Fund Transfer' &&
row.typ !== 'Enter Bank Transfer' &&
(row.dbCr = 'Cr')
"
>
@for (a of acTypOptionsFor(row); track a) {
<option [value]="a">{{ a }}</option>
}
</select>
</div>
@if (showsEntryType(row)) {
<div class="ws-f ws-f--paytype">
<label
class="ws-f__lbl ws-f__lbl--accent"
title="What this money was actually collected for — checked before a share application or DD issuance can adopt this voucher"
>ENTRY TYPE</label
>
<select
class="ws-f__ctl ws-f__ctl--accent"
[(ngModel)]="row.entryTypeCvId"
(click)="$event.stopPropagation()"
>
<option [ngValue]="null">Select…</option>
@for (t of entryTypeOptions; track t.id) {
<option [ngValue]="t.id">{{ t.name }}</option>
}
</select>
</div>
}
</div>
</div>
<div class="ws-vdivider"></div>
@@ -1616,16 +1556,10 @@
[placeholder]="
row.typ === 'DD' ? ddGlMapping?.ddPayableGlCode || 'Auto GL' : 'Account number'
"
[readonly]="row.typ === 'DD' || (acTypLockedByToken && row === selectedRow)"
[class.ws-f__ctl--readonly]="
row.typ === 'DD' || (acTypLockedByToken && row === selectedRow)
"
[readonly]="row.typ === 'DD'"
[class.ws-f__ctl--readonly]="row.typ === 'DD'"
[title]="
acTypLockedByToken && row === selectedRow
? 'Locked — filled from a token-driven request, not a normal account entry'
: row.typ === 'DD'
? 'Configured Sponsor DD Payable GL (Auto-selected)'
: 'Account number'
row.typ === 'DD' ? 'Configured Sponsor DD Payable GL (Auto-selected)' : 'Account number'
"
/>
</div>
@@ -916,20 +916,6 @@ input.ws-f__ctl--readonly {
font-size: 11px;
}
/* AC.TP shown as a locked label instead of the normal editable select, while the row is filled
from a token-driven request whose real account type row.acTyp cannot show (see
acTypLockedByToken in the component). */
span.ws-f__ctl--locked {
display: inline-flex;
align-items: center;
background: #f1f5f9;
color: #475569;
cursor: not-allowed;
user-select: none;
font-size: 11px;
font-weight: 600;
}
input.ws-amt-input--locked {
background: #fef9c3;
color: #713f12;
@@ -53,19 +53,6 @@ interface TxnRow {
/** DD issuance charges/commission — only used when TYPE=DD, TXN TP=Cash, AC.TP=GL */
ddCharges: number | null;
sponsorBankId?: number | null;
/**
* What a GL credit is actually for — the id of a code value under "GLEntryType" (General, Share
* Collection, DD Issue, or whatever an admin has added under Admin > System > Manage Codes).
*
* A cashier Receipt (or a clerk Collection) into a GL account is the counter picking, by hand,
* any account to post cash into — deliberately, so a broken product mapping can never block a
* genuine voucher. That freedom is also the hole: two unrelated flows can land in the same GL
* account with nothing on the row itself saying which one the money was collected for, and a
* manager applying a share request can pick up a voucher that was really meant to fund a DD. This
* tag is what a day-end reconciliation groups by, and what Share Advance Collection / Sponsor DD
* issuance check before adopting a row — only shown/sent for AC.TP=GL, DR./CR.=Cr rows.
*/
entryTypeCvId?: number | null;
/** Only used when this row is AC.TP=SHR inside an Enter Bank Transfer to its linked savings
* account — governs the share redemption (see submitShareRedemptionTransfer()). */
resolutionNumber?: string;
@@ -1716,18 +1703,15 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
/**
* Account types a clerk may fund a demand draft from.
*
* A clerk raises a draft against a member's own balance (SD/CA) or, now, against a GL account
* directly for an institutional/holding-account draft (see isDdAccountDebitRow()) — both are
* governed, maker-checker submissions. GL used to be offered here too in a different sense: picking
* it quietly switched the row onto the CASHIER's walk-in cash path instead (the one that asks for a
* purchaser's name and a note-and-coin count), which isDdAccountDebitRow() now disambiguates by
* role — GL means "debit this GL account" for a clerk, and still means "cash/GL purchase" for a
* cashier, who never reaches this list at all (isDdAccountDebitRow() is clerk-only).
* A clerk raises a draft against a member's own balance, so the only sensible sources are the
* accounts a member actually holds money in. GL was offered here too, and picking it quietly
* switched the row onto the cash path — the one that asks for a walk-in purchaser's name and a
* note-and-coin count, neither of which a clerk debiting an account has. The option is removed
* rather than validated afterwards, so the wrong path cannot be reached at all.
*/
private static readonly DD_CLERK_ACTYPS = [
'SD',
'CA',
'GL'
'CA'
];
/**
@@ -1742,33 +1726,6 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}
return this.acTypOptions;
}
/** True while the selected row is filled from a token-driven request whose row.acTyp is a
* technical stand-in rather than the account's real type — a dividend claim or an account closure
* both set row.acTyp = 'SD' purely to route past the SHR-specific guards elsewhere on this screen
* (see onTokNoBlur()), not because the counter is paying against a savings account. The AC.TP
* dropdown must not be left editable there: it already shows the wrong value, and a cashier
* changing it further would desync the row from the request it was auto-filled from. A plain
* withdrawal-slip token is deliberately excluded — its row.acTyp is resolved to the account's real
* type there, so the normal editable control already shows it correctly and there's nothing to
* lock. */
get acTypLockedByToken(): boolean {
return !!(this.activeDividendClaimRequest || this.activeClosureRequest);
}
/** What to show in place of the AC.TP dropdown while it's locked — the same label the account
* summary strip above already uses for each of these token pools, so the two never contradict
* each other on screen. */
get acTypLockedLabel(): string {
if (this.activeClosureRequest) {
return 'Closure';
}
if (this.activeDividendClaimRequest) {
return 'Dividend';
}
return '';
}
dbCrOptions = [
'Dr',
'Cr'
@@ -1877,12 +1834,6 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
this.loadCashVaultGl();
this.loadDdGstPercentage();
this.loadSponsorBanks();
this.cashierService
.getEntryTypeOptions()
.pipe(catchError(() => of([])))
.subscribe((options: any[]) => {
this.entryTypeOptions = options ?? [];
});
if (this.reviewMode) {
this.loadRequestIntoForm(this.reviewRequestId!, 'review');
@@ -2370,21 +2321,14 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}
/**
* TYPE=DD funded by debiting a member's savings/current account, or a GL account directly, rather
* than by cash.
* TYPE=DD funded by debiting a member's savings/current account rather than by cash.
*
* Clerk-only. A cashier's counter takes cash and nothing else, so offering them an account/GL debit
* would let a draft be raised with no cash to reconcile it against — and the clerk flow is the one
* that carries a maker-checker step over the money moved. AC.TP = GL means something different for
* a cashier's own DD row (a walk-in cash/GL purchase, see isDdCashGlRow()) — this method is checked
* first there specifically so a clerk's GL row is claimed here instead.
* Clerk-only. A cashier's counter takes cash and nothing else, so offering them an account debit
* would let a draft be raised against an account with no cash to reconcile it against — and the
* clerk flow is the one that carries a maker-checker step over the member's balance.
*/
isDdAccountDebitRow(row: TxnRow): boolean {
return (
this.isClerk &&
row.typ === 'DD' &&
(row.acTyp === 'SD' || row.acTyp === 'CA' || row.acTyp === 'CB' || row.acTyp === 'GL')
);
return this.isClerk && row.typ === 'DD' && (row.acTyp === 'SD' || row.acTyp === 'CA' || row.acTyp === 'CB');
}
/**
@@ -2398,23 +2342,6 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return this.isDdCashGlRow(row) || this.isDdAccountDebitRow(row);
}
/**
* Whether this row needs an Entry Type tag — a credit into a GL account, which is exactly the
* case where the money's real purpose isn't otherwise recorded anywhere (see TxnRow.entryTypeCvId).
* A debit out of a GL account, or any entry against a savings/current/share account, carries no
* such ambiguity — the account itself says what it's for.
*/
showsEntryType(row: TxnRow): boolean {
return row.acTyp === 'GL' && row.dbCr === 'Cr';
}
/**
* Entry Type choices for a GL credit — the "GLEntryType" code (Admin > System > Manage Codes),
* fetched once in ngOnInit(). A code value, not a free-text field, so the day-end "group by type"
* this exists for stays reliable; an admin extends the list there, not in this file.
*/
entryTypeOptions: { id: number; name: string }[] = [];
/** GST on DD charges only (never on the DD face value), at the admin-configured rate. */
ddGstAmountFor(row: TxnRow): number {
const charges = Number(row.ddCharges) || 0;
@@ -2481,24 +2408,19 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
// An account-funded draft needs the account resolved to a Fineract id before it can be sent:
// the server takes a savings/current account id and a client id, not the typed number. Both are
// filled in by the A/C NO. lookup, so an unresolved account means the number was never found.
// A GL-funded draft is the exception: a GL account is never tied to a client at all (see
// fetchAllDetails() — GL rows never populate rowClientIds), so only the account id is required.
const isGlDebit = accountDebit && row.acTyp === 'GL';
let fundingAccountId: number | null = null;
let fundingClientId: number | null = null;
if (accountDebit) {
const acNo = (row.acNo || '').trim();
if (!acNo) {
this.showAlert('error', 'Account Required', [
isGlDebit
? 'Enter the GL account the Demand Draft is funded from.'
: 'Enter the account number the Demand Draft is funded from.'
'Enter the account number the Demand Draft is funded from.'
]);
return;
}
fundingAccountId = this.rowAccountIds[acNo] ?? null;
fundingClientId = isGlDebit ? null : (this.rowClientIds[acNo] ?? null);
if (!fundingAccountId || (!isGlDebit && !fundingClientId)) {
fundingClientId = this.rowClientIds[acNo] ?? null;
if (!fundingAccountId || !fundingClientId) {
this.showAlert('error', 'Account Not Found', [
`Account ${acNo} could not be resolved.`,
'Check the account number and wait for its details to load before raising the draft.'
@@ -2588,32 +2510,29 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}
/**
* Raises a Demand Draft funded by debiting a member's savings/current account, or a GL account
* directly.
* Raises a Demand Draft funded by debiting a member's savings or current account.
*
* Clerk-only, and a maker-checker request like the cash flow: nothing is debited here. The
* account/GL is only touched when a checker approves, which for a member account is also when the
* balance is re-checked — so a draft raised against a sufficient balance can still be refused if
* the balance has since gone (a GL account carries no such balance check). No denominations are
* sent, because no cash is taken.
* Clerk-only, and a maker-checker request like the cash flow: nothing is debited here. The member's
* account is only touched when a checker approves, which is also when the balance is re-checked —
* so a draft raised against a sufficient balance can still be refused if the balance has since
* gone. No denominations are sent, because no cash is taken.
*
* The funding account goes in exactly one of savingsAccountId, currentAccountId or glAccountId: the
* server insists on exactly one, since which one decides what gets debited on approval.
* The account goes in savingsAccountId or currentAccountId, never both: the server insists on
* exactly one, since which table the account lives in decides which control GL is debited.
*/
private submitAccountDebitDd(
row: TxnRow,
leaflet: any,
beneficiaryData: any,
fundingAccountId: number,
fundingClientId: number | null
fundingClientId: number
): void {
const d = new Date();
const issuedDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
const isCurrent = row.acTyp === 'CA' || row.acTyp === 'CB';
const isGl = row.acTyp === 'GL';
const payload = {
clientId: isGl ? undefined : (fundingClientId ?? undefined),
clientId: fundingClientId,
beneficiaryName: beneficiaryData.beneficiaryName,
payableAtBranch: beneficiaryData.payableAtBranch,
payableAtCity: beneficiaryData.payableAtCity,
@@ -2621,10 +2540,9 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
chargesAmount: Number(row.ddCharges) || 0,
issuingBranchId: row.br,
issuedDate,
paymentMethod: (isGl ? 'GL' : 'ACCOUNT_DEBIT') as 'GL' | 'ACCOUNT_DEBIT',
savingsAccountId: isGl || isCurrent ? undefined : fundingAccountId,
currentAccountId: isGl || !isCurrent ? undefined : fundingAccountId,
glAccountId: isGl ? fundingAccountId : undefined
paymentMethod: 'ACCOUNT_DEBIT' as const,
savingsAccountId: isCurrent ? undefined : fundingAccountId,
currentAccountId: isCurrent ? fundingAccountId : undefined
};
this.saving = true;
@@ -2884,10 +2802,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
// Without this the backend defaults every request from this screen to DISBURSEMENT — a
// Collection or Fund Transfer submitted here was being saved (and shown in the checker's
// inbox) as a plain Disbursement.
requestType: isCollectionMode ? 'COLLECTION' : isFundTransfer ? 'FUND_TRANSFER' : 'DISBURSEMENT',
// Same ambiguity as a cashier Receipt — a Collection credits a GL account picked by hand.
// See TxnRow.entryTypeCvId.
...(isCollectionMode ? { entryTypeCvId: crRows[0]?.entryTypeCvId || null } : {})
requestType: isCollectionMode ? 'COLLECTION' : isFundTransfer ? 'FUND_TRANSFER' : 'DISBURSEMENT'
};
this.saving = true;
@@ -3129,168 +3044,6 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
);
}
/**
* Share redemption settled to a GL account the clerk picks on the CR row, instead of the
* member's linked savings account — mirrors submitShareRedemptionTransfer() above, but the
* credit side is any GL account (e.g. a "DD Payable" holding account), not a fixed linked
* account, so there is no "must match the linked account" check here.
*/
private submitShareGlSettlementTransfer(shareRow: TxnRow, glRow: TxnRow): void {
// Belt-and-braces: the TXN TP. dropdown already hides cash payment types for AC.TP = SHR (see
// paymentTypesForRow()), but re-check here too in case the payment type was set before that
// filter applied.
const settlementPaymentType = this.paymentTypes.find((p: any) => p.id === glRow.paymentTypeId);
if (!glRow.paymentTypeId) {
this.showAlert('error', 'Settlement Payment Type Required', [
'Select a Payment Type on the CR (GL account) row — it governs how the settlement credit posts.'
]);
return;
}
if (settlementPaymentType?.isCashPayment) {
this.showAlert('error', 'Cash Not Allowed for Share Transactions', [
'Share redemption settlement is always a book-transfer, never cash.',
'Select Transfer as the Payment Type on the CR row.'
]);
return;
}
const shareAccountId = this.rowAccountIds[shareRow.acNo];
if (!shareAccountId) {
this.showAlert('error', 'Share Account Not Loaded', [`Please load account details for: ${shareRow.acNo}`]);
return;
}
const settlementGlAccountId = this.rowAccountIds[glRow.acNo];
if (!settlementGlAccountId) {
this.showAlert('error', 'GL Account Not Loaded', [`Please load account details for: ${glRow.acNo}`]);
return;
}
const shares = Number(shareRow.amount) || 0;
if (shares <= 0) {
this.showAlert('error', 'Share No. Required', ['Enter the number of shares to withdraw (greater than zero).']);
return;
}
if (!shareRow.resolutionNumber?.trim()) {
this.showAlert('error', 'Board Resolution Number Required', [
'A share withdrawal must be authorised by a Board Resolution.'
]);
return;
}
if (!shareRow.meetingDate) {
this.showAlert('error', 'Date of Meeting Required', ['Enter the Board meeting date that passed the resolution.']);
return;
}
const today = new Date();
const requestedDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
// Full snapshot of the CR (settlement) row exactly as the clerk filled it in — saved verbatim
// for audit purposes, same as the linked-savings path above.
const settlementRowSnapshot = {
branch: glRow.br,
accountType: glRow.acTyp,
accountNo: glRow.acNo,
ecsAccountNo: glRow.ecsAcNo || null,
dbCr: glRow.dbCr,
amount: glRow.amount ?? null,
chequeNo: glRow.chequeNo || null,
chequeDate: glRow.cheqDate || null,
tokenNo: glRow.tokNo || null,
description: glRow.description || null,
bankName: glRow.bankName || null,
routingCode: glRow.routingCode || null,
paymentTypeId: glRow.paymentTypeId,
paymentTypeName: settlementPaymentType?.name ?? null
};
const payload = {
shareAccountId,
accountNo: shareRow.acNo,
clientId: this.rowClientIds[shareRow.acNo] ?? null,
clientName: this.rowAccountNames[shareRow.acNo] ?? null,
requestedShares: shares,
requestedDate,
resolutionNumber: shareRow.resolutionNumber.trim(),
meetingDate: shareRow.meetingDate,
description: shareRow.description || null,
settlementPaymentTypeId: glRow.paymentTypeId,
settlementRowSnapshot,
settlementMode: 'GL',
settlementGlAccountId
};
const sharePrice = this.rowSharePrices[shareRow.acNo] ?? 0;
const currentHolding = this.rowShareHoldings[shareRow.acNo] ?? 0;
const remainingShares = currentHolding - shares;
const willCloseAccount = remainingShares <= 0;
const confirmLines = [
`Share Account: ${shareRow.acNo} · Client: ${this.rowAccountNames[shareRow.acNo] ?? '—'}`,
`Shares to Withdraw: ${shares} @ ₹${sharePrice.toLocaleString('en-IN', { minimumFractionDigits: 2 })} = ₹${(shares * sharePrice).toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
`Remaining Shares After Withdrawal: ${Math.max(remainingShares, 0)}`,
...(willCloseAccount
? ['⚠ All shares withdrawn — this account will be CLOSED automatically when the Manager approves.']
: []),
`Board Resolution No.: ${payload.resolutionNumber}`,
`Date of Meeting: ${payload.meetingDate}`,
`Settlement Credit To (GL): ${glRow.acNo}`,
`Settlement Payment Type: ${settlementPaymentType?.name ?? '—'}`,
...(settlementRowSnapshot.description ? [`CR Row Remarks: ${settlementRowSnapshot.description}`] : []),
'',
willCloseAccount
? 'Pending Manager approval — redemption, settlement and account closure all post together on approval.'
: 'Pending Manager approval — redemption and settlement both post together on approval.'
];
this.showConfirmAlert('info', 'Confirm Share Withdrawal Request', confirmLines, 'Submit for Approval', () =>
this.doSubmitShareGlSettlementTransfer(payload, shareRow, shares, glRow.acNo)
);
}
private doSubmitShareGlSettlementTransfer(
payload: Record<string, unknown>,
shareRow: TxnRow,
shares: number,
glAcNo: string
): void {
this.saving = true;
this.clerkShareWithdrawalService
.submit(payload)
.pipe(
catchError((err: any) => {
const msg =
err?.error?.errors?.[0]?.defaultUserMessage ??
err?.error?.developerMessage ??
err?.error?.defaultUserMessage ??
'Share withdrawal submission failed. Please try again.';
this.showAlert('error', 'Submission Failed', [msg]);
this.saving = false;
return of(null);
})
)
.subscribe((res: any) => {
this.saving = false;
if (res) {
const willCloseAccount = (this.rowShareHoldings[shareRow.acNo] ?? 0) - shares <= 0;
this.showAlert('success', 'Share Withdrawal Submitted', [
`${shares} share(s) from ${shareRow.acNo} → settlement to GL ${glAcNo} on approval.`,
willCloseAccount
? 'Pending Manager approval — this will close the share account, since all shares will be withdrawn. Track status in "My Share Requests".'
: 'Pending Manager approval. Track status in "My Share Requests".'
]);
this.rows = [];
this.rowAccountIds = {};
this.rowAccountNames = {};
this.rowSharePrices = {};
this.rowClientIds = {};
this.rowLinkedSavingsAcNo = {};
this.rowShareHoldings = {};
this.addRow();
this.selectedRow = this.rows[0];
this.accountDetails = null;
this.clientDetails = null;
}
});
}
private doSubmitShareRedemptionTransfer(
payload: Record<string, unknown>,
shareRow: TxnRow,
@@ -3350,23 +3103,37 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
.pipe(
catchError((err: any) => {
// Not a withdrawal-request token at all (as opposed to PENDING/WITHDRAWN/REJECTED,
// which are legitimate matches with their own specific error) — try the account-closure
// pool before giving up: both share the same TOK. NO. field at this counter. A dividend
// claim was once a third pool here, but a dividend never actually pays out in cash over
// this counter — it only ever settles through a Demand Draft, which is its own DD
// issuance request and its own token, so looking one up here could only ever 404.
// which are legitimate matches with their own specific error) — try the dividend
// cash-claim pool, then the account-closure pool, before giving up: all three share
// the same TOK. NO. field at this counter.
if (err?.status === 404) {
return this.closureRequestService.getByToken(token).pipe(
catchError((closeErr: any) => {
return this.dividendClaimRequestService.getByToken(token).pipe(
catchError((divErr: any) => {
if (divErr?.status === 404) {
return this.closureRequestService.getByToken(token).pipe(
catchError((closeErr: any) => {
this.loadingWithdrawal = false;
const msg: string =
closeErr?.error?.message ??
closeErr?.error?.errors?.[0]?.defaultUserMessage ??
`Token '${token}' could not be verified. Please try again.`;
this.showAlert('error', `Token '${token}'`, [msg]);
return of(null);
}),
map((closeReq: any) => (closeReq ? { __closureRequest: true, ...closeReq } : null))
);
}
this.loadingWithdrawal = false;
const msg: string =
closeErr?.error?.message ??
closeErr?.error?.errors?.[0]?.defaultUserMessage ??
divErr?.error?.message ??
divErr?.error?.errors?.[0]?.defaultUserMessage ??
`Token '${token}' could not be verified. Please try again.`;
this.showAlert('error', `Token '${token}'`, [msg]);
return of(null);
}),
map((closeReq: any) => (closeReq ? { __closureRequest: true, ...closeReq } : null))
// Only tag a result that actually came from the dividend pool — a closure hit
// recovered by the inner fallback above is already tagged and must stay that way.
map((divReq: any) => (divReq && !divReq.__closureRequest ? { __dividendClaim: true, ...divReq } : divReq))
);
}
this.loadingWithdrawal = false;
@@ -3392,16 +3159,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
this.clientDetails = null;
this.accountError = '';
row.acNo = req.client_name ?? 'Dividend Claim #' + req.id;
// Marks this acNo as already resolved, the same way loadAccountDetails() itself would —
// without it, the next click into this row (onRowSelect) sees an acNo that was never
// "loaded" and re-searches for a real account matching this stub string, which can land
// on an unrelated real account whose number happens to share a suffix with it.
this.lastLoadedAcNo = row.acNo;
row.acTyp = 'SD';
// Every token-driven row is cash leaving the branch to a member, which is what the
// Payment register is for — not what this row's TYPE happened to default to before the
// token was entered.
row.typ = 'Payment';
row.amount = Number(req.amount) || null;
row.dbCr = 'Dr';
this.selectedRow = row;
@@ -3423,10 +3181,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
this.clientDetails = null;
this.accountError = '';
row.acNo = req.account_no ?? '';
// See the same line in the dividend-claim branch above for why this is required.
this.lastLoadedAcNo = row.acNo;
row.acTyp = 'SD';
row.typ = 'Payment';
row.amount = Number(req.cashier_disbursed_amount) || null;
row.dbCr = 'Dr';
// A closure request stores payment_type_id as NULL, meaning "Cash (Default)" — resolve the
@@ -3439,23 +3194,15 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}
this.activeWithdrawalRequest = req;
// A plain withdrawal-slip request carries its own real account_type (SAVINGS, CURRENT,
// FIXED_DEPOSIT or RECURRING_DEPOSIT — see ClerkWithdrawalRequestApiResource.submit()), unlike
// the closure/dividend pools above, which are never anything but a savings-shaped stub.
// Hardcoding 'SD' here searched every non-savings account on the wrong endpoint (Current
// accounts live under /currentaccounts, not /savingsaccounts) and showed it mislabelled even
// when the lookup happened to succeed.
const acTyp = TransactionEntryComponent.SEARCH_ACCOUNT_TYPE_TO_AC_TYP[req.account_type || ''] || 'SD';
row.acNo = req.account_no ?? '';
row.acTyp = acTyp;
row.typ = 'Payment';
row.acTyp = 'SD';
row.amount = Number(req.amount) || null;
row.dbCr = 'Dr';
row.paymentTypeId = req.payment_type_id ?? null;
row.description = req.description ?? '';
this.selectedRow = row;
if (req.account_no) {
this.loadAccountDetails(req.account_no.trim(), acTyp);
this.loadAccountDetails(req.account_no.trim(), 'SD');
}
});
}
@@ -3482,24 +3229,10 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
onAcNoBlur(row: TxnRow, index: number): void {
if (!row.acNo.trim()) return;
this.selectedRow = row;
// A token-driven row's account fields are already authoritative (see onTokNoBlur) -- merely
// clicking into and out of the A/C NO. field here must not re-search for them. Doing so used
// row.acTyp's internal stand-in value (e.g. 'SD' for a dividend claim, which is never the
// account's real type) against the account's real number, either finding nothing ("Account
// not found") or, worse, matching a completely unrelated real account that happened to share a
// number suffix.
if (this.activeWithdrawalRequest || this.activeDividendClaimRequest || this.activeClosureRequest) {
return;
}
this.loadAccountDetails(row.acNo.trim(), row.acTyp);
}
onAcNoInput(row: TxnRow, index: number, value: string): void {
// Same reasoning as the guard at the top of onAcNoBlur() -- a token-driven row's account
// fields must not be edited or re-searched via this field at all.
if (this.activeWithdrawalRequest || this.activeDividendClaimRequest || this.activeClosureRequest) {
return;
}
row.acNo = value;
this.lastLoadedAcNo = '';
this.acNoInput$.next({ row, index, value });
@@ -3608,15 +3341,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
const match = exactMatch ?? partialMatch ?? idMatch ?? null;
if (match?.id) {
const matchedGlCode = match[f] ?? acNo;
// Any row resolved via the partial/endsWith/id fallback above (not an exact code match)
// loads and displays the right account, but the row itself still carries whatever shorter
// string the cashier actually typed to search with -- and that's what gets submitted and
// shown everywhere downstream (vouchers, My Work, credit_entries), not the account's real
// full number. Correct the row to the account's actual code whenever the match wasn't
// already exact, for every account type -- not just the DD/GL cases this was previously
// limited to.
if (this.selectedRow && !exactMatch && matchedGlCode) {
this.selectedRow.acNo = matchedGlCode;
if (this.selectedRow && this.selectedRow.typ === 'DD' && match.glCode) {
this.selectedRow.acNo = match.glCode;
}
const clientIdFromSearch = acTyp !== 'GL' && match.clientId != null ? String(match.clientId) : null;
this.fetchAllDetails(match.id, matchedGlCode, cfg, token, acTyp, clientIdFromSearch);
@@ -4793,26 +4519,6 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
// "Disbursement". 'Enter Bank Transfer' never carries a GL leg, so it always stays in this
// branch; only skip out to the Disbursement/Collection branch below when every row is 'Fund
// Transfer' and at least one of them is GL.
// Share redemption settled to a GL account instead of the linked savings account: DR=Share
// account, CR=any GL account the clerk picks (e.g. a "DD Payable" holding account, ahead of a
// second, independent manual entry that later moves the value out to an actual draft). Checked
// ahead of both branches below — the Enter Bank Transfer/Fund Transfer branch would otherwise
// treat a Fund Transfer carrying a GL leg as the Disbursement/Collection shape (see
// fundTransferRowsHaveGl below), and a Share account can never be debited by that path at all —
// no such Fineract command exists — so it would either be silently mis-posted or rejected late.
if (
this.isClerk &&
this.rows.length === 2 &&
this.rows.every((r) => r.typ === 'Fund Transfer' || r.typ === 'Enter Bank Transfer') &&
this.rows.some((r) => r.dbCr === 'Dr' && r.acTyp === 'SHR') &&
this.rows.some((r) => r.dbCr === 'Cr' && r.acTyp === 'GL')
) {
const shareRow = this.rows.find((r) => r.dbCr === 'Dr' && r.acTyp === 'SHR')!;
const glRow = this.rows.find((r) => r.dbCr === 'Cr' && r.acTyp === 'GL')!;
this.submitShareGlSettlementTransfer(shareRow, glRow);
return;
}
const fundTransferRowsHaveGl =
this.rows.every((r) => r.typ === 'Fund Transfer') && this.rows.some((r) => r.acTyp === 'GL');
if (
@@ -5944,10 +5650,6 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
chequeNo: row.chequeNo || null,
bankName: row.bankName || null,
denominationData: isCash && this.denominationTotal > 0 ? JSON.stringify(this.denominationCounts) : null,
// What this GL credit is actually for — see TxnRow.entryTypeCvId. Sent for every GL deposit
// (the backend defaults to "General" when the counter didn't pick one), so Share Advance
// Collection and Sponsor DD issuance can tell whose voucher this really is before adopting it.
...(isGl ? { entryTypeCvId: row.entryTypeCvId || null } : {}),
// DD + Cash + GL breakdown — persisted so the Sponsor DD issuance screen can read the face
// value/charges/GST back once this request is approved, instead of retyping them.
...(this.isDdCashGlRow(row)
@@ -1142,46 +1142,6 @@
</mat-select>
</mat-form-field>
<!-- Risk profile. These are determinations about the customer rather than about anything they
transacted, and the re-rating policy reads them directly: a PEP or a sanctions match holds the
rating at High however ordinary the period's activity looks, and the declared income is what
turnover is measured against. Until they are recorded the policy has nothing to test. -->
<div class="full-span kyc-doc-fields">
<div class="kyc-doc-fields__head">
<h4 class="kyc-doc-fields__title">Risk Profile</h4>
<span class="kyc-doc-fields__hint">Used by the periodic risk review</span>
</div>
<div class="kyc-risk-flags">
<mat-checkbox formControlName="isPep">
Politically Exposed Person
<span class="kyc-risk-hint">Holds the rating at High; never lowered on ordinary activity</span>
</mat-checkbox>
<mat-checkbox formControlName="sanctionsMatch">
Sanctions / watch-list match
<span class="kyc-risk-hint">UNSC, MHA or internal watch list</span>
</mat-checkbox>
<mat-checkbox formControlName="adverseMediaMatch">
Adverse media / regulatory action
<span class="kyc-risk-hint">FIR, court proceedings or published action</span>
</mat-checkbox>
</div>
<div class="kyc-doc-fields__grid">
<mat-form-field appearance="outline">
<mat-label>Declared annual income (₹)</mat-label>
<input matInput type="number" min="0" formControlName="declaredAnnualIncome" />
<mat-hint>As stated by the customer</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Declared annual turnover (₹)</mat-label>
<input matInput type="number" min="0" formControlName="declaredAnnualTurnover" />
<mat-hint>For a business customer</mat-hint>
</mat-form-field>
</div>
</div>
<!-- Officially Valid Document. Shown when one is on file or the alert is asking for
it, so re-collecting an expired document is done on this form rather than
sending the officer somewhere else. -->
@@ -1807,28 +1807,3 @@
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,
[Validators.required]
],
// Risk profile. Determinations about the customer, which the periodic risk review reads directly - a PEP or a
// sanctions match holds the rating at High whatever the period's activity looks like.
isPep: [false],
sanctionsMatch: [false],
adverseMediaMatch: [false],
declaredAnnualIncome: [null],
declaredAnnualTurnover: [null],
// The officially valid document, so a lapsed one can be re-collected on this form. The
// backend has always accepted these four; only the fields were missing.
kycDocumentName: [''],
@@ -228,11 +221,6 @@ export class KycTabComponent implements OnInit, OnDestroy {
this.kycForm.patchValue({
kycType: data.kycType || 1,
riskCategory: data.riskCategory || 1,
isPep: !!data.isPep,
sanctionsMatch: !!data.sanctionsMatch,
adverseMediaMatch: !!data.adverseMediaMatch,
declaredAnnualIncome: data.declaredAnnualIncome ?? null,
declaredAnnualTurnover: data.declaredAnnualTurnover ?? null,
panNumber: data.panNumber || '',
// Aadhaar is stored masked on the server; user must re-enter
// the full 12-digit number to re-verify, so don't pre-fill.
@@ -458,28 +446,6 @@ export class KycTabComponent implements OnInit, OnDestroy {
return new Date();
}
/**
* The customer's risk profile, as every save has to carry it.
*
* <p>
* Sent from every path that writes the KYC record rather than only the main Save, because the form holds one set of
* values: a save triggered from the PAN declaration or the Aadhaar receipt would otherwise write the record without
* these and silently clear a PEP flag the officer had just ticked.
*/
private buildRiskProfileFields() {
const v = this.kycForm.value;
return {
isPep: !!v.isPep,
sanctionsMatch: !!v.sanctionsMatch,
adverseMediaMatch: !!v.adverseMediaMatch,
// Blank stays null rather than becoming zero: an income nobody asked for is unknown, and a zero would read as a
// customer who declared no income at all.
declaredAnnualIncome: v.declaredAnnualIncome === '' || v.declaredAnnualIncome == null ? null : v.declaredAnnualIncome,
declaredAnnualTurnover:
v.declaredAnnualTurnover === '' || v.declaredAnnualTurnover == null ? null : v.declaredAnnualTurnover
};
}
private buildExceptionFields() {
const panVerified = this.verificationStatus.pan === 'VERIFIED' || this.kycData?.panVerified === true;
const aadhaarVerified = this.verificationStatus.aadhaar === 'VERIFIED' || this.kycData?.aadhaarVerified === true;
@@ -518,8 +484,7 @@ export class KycTabComponent implements OnInit, OnDestroy {
// dateFormat is required so the backend can parse exceptionResolvedOn when an exception closes.
dateFormat: 'dd MMMM yyyy',
...this.buildDocumentFields(),
...this.buildRiskProfileFields(),
...this.buildExceptionFields()
...this.buildExceptionFields()
};
if (!this.kycId) {
@@ -565,7 +530,6 @@ export class KycTabComponent implements OnInit, OnDestroy {
remarks: this.kycForm.value.remarks || null,
riskCategory: this.kycForm.value.riskCategory || 1,
dateFormat: 'dd MMMM yyyy',
...this.buildRiskProfileFields(),
...this.buildExceptionFields()
};
@@ -765,8 +729,7 @@ export class KycTabComponent implements OnInit, OnDestroy {
dateFormat,
riskCategory: this.kycForm.value.riskCategory || 1,
...declarationFields,
...this.buildRiskProfileFields(),
...this.buildExceptionFields()
...this.buildExceptionFields()
};
this.clientsService
@@ -926,8 +889,7 @@ export class KycTabComponent implements OnInit, OnDestroy {
dateFormat,
riskCategory: this.kycForm.value.riskCategory || 1,
...receiptFields,
...this.buildRiskProfileFields(),
...this.buildExceptionFields()
...this.buildExceptionFields()
};
this.clientsService
+47 -8
View File
@@ -310,6 +310,41 @@
</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
============================================================
@@ -588,11 +623,18 @@
</div>
</div>
<!-- The KPI ticker that sat here has been removed. Every figure in it was written into the template — cash on
hand, transactions today, CASA, CD ratio, gross NPA, a circular reference — and none came from the bank's
own books. Under a "LIVE" badge on every screen in the application, that is not decoration: a figure a
branch manager can read as the day's cash position, or an inspector as the NPA ratio, has to be the real
one or must not be shown. The band returns when there is a source behind it. -->
<!-- LIVE KPI TICKER (below the menu — pauses on hover) -->
<div class="kpi-ticker" role="marquee" aria-label="Live key-performance ticker">
<span class="kpi-live">
<span class="kpi-live-dot"></span>
LIVE
</span>
<div class="kpi-track">
<!-- Render the item block twice so the marquee loops seamlessly. -->
<ng-container *ngTemplateOutlet="kpiTickerItems"></ng-container>
<ng-container *ngTemplateOutlet="kpiTickerItems"></ng-container>
</div>
</div>
}
<!-- end @if (!hideShellLayout) -->
@@ -671,9 +713,6 @@
<button mat-menu-item routerLink="/reports/tds-remittance" tabindex="0">
<i class="fa fa-landmark menu-item-icon"></i> TDS Remitted to Government
</button>
<button mat-menu-item routerLink="/reports/bor" tabindex="0">
<i class="fa fa-gavel menu-item-icon"></i> BOR Register &mdash; Share Capital
</button>
</mat-menu>
<!-- Admin Mega Menu — 3-column rectangular panel grouping Setup,
+86 -53
View File
@@ -167,9 +167,7 @@
</div>
<!-- The 15th of the month after the one being reported, worked out rather than fixed at
15 Feb 2026 — a due date that never moves stops being a due date. -->
<div class="dc-meta">
Due: <strong>{{ ctrDueDateLabel }}</strong>
</div>
<div class="dc-meta">Due: <strong>{{ ctrDueDateLabel }}</strong></div>
<button class="dc-btn" (click)="showTab('ctr')"><fa-icon icon="external-link-alt"></fa-icon> Open</button>
</div>
<div class="dc-card">
@@ -222,7 +220,9 @@
@if (ctrLinesLoading) {
<div class="fiu-note">Loading the generated CTR lines…</div>
} @else if (ctrLinesError) {
<div class="fiu-note fiu-note--warn"><fa-icon icon="exclamation-triangle"></fa-icon> {{ ctrLinesError }}</div>
<div class="fiu-note fiu-note--warn">
<fa-icon icon="exclamation-triangle"></fa-icon> {{ ctrLinesError }}
</div>
} @else if (!ctrTxns.length) {
<div class="fiu-note fiu-note--warn">
<fa-icon icon="exclamation-triangle"></fa-icon>
@@ -432,17 +432,87 @@
}
<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
Master, and listed them worse: without the severity bands, the per-rule findings or the batch decision that
screen carries. Two places to work the same queue is how an alert gets cleared in one and escalated in the
other. This tab keeps what is about the filing itself - what was cleared, and what became an STR case. -->
<div class="fiu-note">
<fa-icon icon="info-circle"></fa-icon>
<span>
Alerts are triaged in <strong>RBI &rsaquo; AML Rules Master &rsaquo; Flagged Transactions</strong>. What an
officer escalates there arrives here as an STR case.
</span>
<!-- LIVE: suspicious AML alerts awaiting AML Officer review (Structuring, PEP, etc.) -->
<div class="fiu-form-meta">
<div class="fiu-form-title">
<h3>Live AML Alerts (Awaiting Triage)</h3>
<p>Flagged by the monitoring engine for suspicious behavioral patterns — triage and whitelist or escalate</p>
</div>
<!-- Whitelisted is deliberately absent: cleared alerts are not STR business and have
their own register below. -->
<select class="fiu-inp" [(ngModel)]="amlAlertStatusFilter">
<option value="">All statuses</option>
<option value="RAISED">Raised</option>
<option value="UNDER_REVIEW">Under Review</option>
<option value="ESCALATED">Escalated</option>
</select>
</div>
<div class="fiu-tbl-wrap">
<table class="fiu-tbl">
<thead>
<tr>
<th>ID</th>
<th>Client</th>
<th>Type</th>
<th>Rule</th>
<th class="ta-r">Amount</th>
<th>Raised On</th>
<th>Status</th>
<th class="ta-c">Routes To</th>
<th>Notes</th>
<th>Action</th>
</tr>
</thead>
<tbody>
@for (alert of amlSuspiciousAlerts; track alert.id) {
<tr>
<td>{{ alert.id }}</td>
<td>{{ alert.clientDisplayName }}</td>
<td>{{ alert.transactionType }}</td>
<td>{{ alert.ruleCode }} &mdash; {{ alert.ruleDescription }}</td>
<td class="ta-r mono">&#8377;{{ alert.amount | number: '1.0-0' }}</td>
<td>{{ asDate(alert.raisedOn) | date: 'medium' }}</td>
<td>
<span [class]="amlStatusClass(alert.status)">{{ alert.status }}</span>
</td>
<td class="ta-c">
<span [class]="alertRouteClass(alert.status, false)">{{ alertRouteLabel(alert.status, false) }}</span>
</td>
<td>
@if (alert.status === 'RAISED' || alert.status === 'UNDER_REVIEW') {
<input
type="text"
class="fiu-inp"
placeholder="Review notes"
[(ngModel)]="amlReviewNotes[alert.id]"
/>
} @else {
<span class="muted">{{ alert.reviewNotes }}</span>
}
</td>
<td>
@if (alert.status === 'RAISED' || alert.status === 'UNDER_REVIEW') {
<div class="fiu-action-group" *mifosxHasPermission="'REVIEW_AML_ALERT'">
<button class="fiu-btn fiu-btn-g" (click)="whitelistAlert(alert)">Whitelist</button>
<button class="fiu-btn fiu-btn-xml" (click)="escalateAlert(alert)">Escalate</button>
</div>
}
</td>
</tr>
} @empty {
<tr>
<td colspan="10" class="fiu-empty">No suspicious behavioral alerts awaiting triage.</td>
</tr>
}
</tbody>
</table>
</div>
<p class="fiu-tbl-legend">
<span class="report-pill report-pending">Pending</span> awaiting Whitelist/Escalate decision &middot;
<span class="report-pill report-str">→ STR</span> escalated to Principal Officer as an STR case
</p>
<hr style="border: 0; border-top: 1px solid var(--f-g200); margin: 8px 0" />
<!-- Cleared register: whitelisted alerts, kept out of the STR queue but retained with the
evidence recorded at review time. -->
@@ -520,7 +590,7 @@
</tr>
</thead>
<tbody>
@for (sc of liveStrCases; track sc.id) {
@for (sc of amlStrCases; track sc.id) {
<tr [class.selected-row]="selectedStrCase?.id === sc.id">
<td>{{ sc.id }}</td>
<td>{{ sc.clientDisplayName }}</td>
@@ -537,50 +607,13 @@
</tr>
} @empty {
<tr>
<td colspan="8" class="fiu-empty">No STR cases outstanding.</td>
<td colspan="8" class="fiu-empty">No STR cases yet.</td>
</tr>
}
</tbody>
</table>
</div>
<!-- Filed cases are finished work, not outstanding work, so they sit apart from the live queue — but they are
the bank's record of what went to FIU-IND and against which reference, so they stay visible. -->
@if (filedStrCases.length) {
<div class="fiu-form-meta" style="margin-top: 18px">
<div class="fiu-form-title">
<h3>Filed with FIU-IND</h3>
<p>Reported and closed — kept for the record, with the reference the filing was made under.</p>
</div>
</div>
<div class="fiu-tbl-wrap">
<table class="fiu-tbl">
<thead>
<tr>
<th>ID</th>
<th>Client</th>
<th>Conclusion</th>
<th>Filed On</th>
<th>FIU Reference</th>
<th>Action</th>
</tr>
</thead>
<tbody>
@for (sc of filedStrCases; track sc.id) {
<tr>
<td>{{ sc.id }}</td>
<td>{{ sc.clientDisplayName }}</td>
<td>{{ sc.conclusion || '—' }}</td>
<td>{{ asDate(sc.strFiledOn) | date: 'dd MMM yyyy' }}</td>
<td class="mono">{{ sc.strReferenceNo || '—' }}</td>
<td><button class="fiu-btn fiu-btn-g" (click)="selectStrCase(sc)">View</button></td>
</tr>
}
</tbody>
</table>
</div>
}
@if (selectedStrCase) {
<div class="str-case-detail">
<div class="fiu-form-meta">
+59 -47
View File
@@ -7,7 +7,6 @@
*/
import { Component, OnInit, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
@@ -26,7 +25,8 @@ import {
KycIndividual,
PoFinding,
StrStatus,
StrTransaction
StrTransaction,
STR_TXNS
} from './fiu-data';
/** 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 sanctionsLogsService = inject(SanctionsLogsService);
private authenticationService = inject(AuthenticationService);
private route = inject(ActivatedRoute);
/** Active tab */
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.
*/
tc1Txns: CardTransaction[] = [];
/**
* 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[] = [];
strTxns: StrTransaction[] = STR_TXNS;
/**
* KC1 / KC2 — the customer cards that travel with the CTR bundle.
*
@@ -220,29 +210,13 @@ export class FiuComponent implements OnInit {
*/
sanctionsStrCases: any[] = [];
sanctionsStrFilter = '';
amlAlertStatusFilter = '';
amlReviewNotes: Record<number, string> = {};
amlCtrReports: any[] = [];
ctrGenerateMonth = '';
amlStrCases: any[] = [];
/**
* The cases still carrying work, which is what "Live" means here.
*
* <p>
* A case that has been filed with FIU-IND is finished: the report is gone, the reference number is on it, and there
* is nothing left for the Principal Officer to do. Leaving it in the live queue is how a working list stops being
* one — every filed case stays on it for good, and an officer opening the tab has to tell the outstanding work from
* the closed by reading a status column. Filed cases are listed below, where they can still be looked up.
*/
get liveStrCases(): any[] {
return this.amlStrCases.filter((c) => c?.status !== 'FILED');
}
/** Cases already filed with FIU-IND, kept for the record rather than as work. */
get filedStrCases(): any[] {
return this.amlStrCases.filter((c) => c?.status === 'FILED');
}
selectedStrCase: any = null;
concludeConclusion: 'NOT_SUSPICIOUS' | 'SUSPICIOUS' = 'NOT_SUSPICIOUS';
concludeInvestigationNotes = '';
@@ -424,14 +398,6 @@ export class FiuComponent implements OnInit {
this.isHeadOffice = this.authenticationService.isHeadOfficeUser();
this.currentOfficeName = creds?.officeName || 'Branch Office';
// Opened on a particular tab when another screen sends the officer here for a reason - the Risk
// Review Report links a case that is with the Principal Officer straight to the STR queue, so
// landing on the dashboard would make them find it themselves.
const wantedTab = this.route.snapshot.queryParamMap.get('tab');
if (wantedTab === 'str' || wantedTab === 'ctr' || wantedTab === 'customers' || wantedTab === 'files') {
this.activeTab = wantedTab;
}
// The header named a hard-coded officer and a hard-coded month — "Karthik Kamath" and
// "January 2026" — on a screen whose whole purpose is to say which month is being filed and
// by whom. Both now come from the session and the clock.
@@ -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
* the same set, and the CTR tab reads the list as well — so narrowing it server-side for one view would empty
* another.
* Always fetches every status. The triage table and the cleared register are two views of the
* same set, so filtering server-side would empty one whenever the other was narrowed — and the
* CTR tab reads this list too, which a status filter chosen on the STR tab should not touch.
* amlAlertStatusFilter is applied client-side in {@link amlSuspiciousAlerts} instead.
*/
loadAmlAlerts(): void {
this.amlService.getAlerts().subscribe((data) => (this.amlAlerts = data || []));
@@ -694,8 +661,7 @@ export class FiuComponent implements OnInit {
this.ctrLinesLoading = false;
this.ctrTxns = [];
this.ctrLinesError =
err?.error?.defaultUserMessage ||
'Could not load the generated CTR lines. Generate a report for the month first.';
err?.error?.defaultUserMessage || 'Could not load the generated CTR lines. Generate a report for the month first.';
}
});
}
@@ -795,6 +761,48 @@ export class FiuComponent implements OnInit {
return (err: any) => this.toast(err?.error?.defaultUserMessage || `${action} failed — please retry`);
}
/**
* Clearing or escalating an alert has to carry a reason: review_notes is the only audit record of
* why a flagged transaction was disposed of, and an empty string leaves no trace of the decision.
*/
private reviewNotesOf(alert: any): string | null {
const notes = (this.amlReviewNotes[alert.id] || '').trim();
if (!notes) {
this.toast(`Enter review notes before disposing of alert #${alert.id}`);
return null;
}
return notes;
}
whitelistAlert(alert: any): void {
const notes = this.reviewNotesOf(alert);
if (!notes) {
return;
}
this.amlService.reviewAlert(alert.id, 'WHITELIST', notes).subscribe({
next: () => {
this.toast(`Alert #${alert.id} whitelisted — no STR raised`);
this.loadAmlAlerts();
},
error: this.apiError(`Whitelisting alert #${alert.id}`)
});
}
escalateAlert(alert: any): void {
const notes = this.reviewNotesOf(alert);
if (!notes) {
return;
}
this.amlService.reviewAlert(alert.id, 'ESCALATE', notes).subscribe({
next: () => {
this.toast(`Alert #${alert.id} escalated — now an STR case for the Principal Officer`);
this.loadAmlAlerts();
this.loadAmlStrCases();
},
error: this.apiError(`Escalating alert #${alert.id}`)
});
}
generateCtrReport(): void {
if (!this.ctrGenerateMonth) {
this.toast('Pick a reporting month first');
@@ -1013,6 +1021,12 @@ export class FiuComponent implements OnInit {
* The STR triage queue: still to decide, or already escalated. Whitelisted alerts are closed
* with no report, so they are not STR business — they live in {@link amlWhitelistedAlerts}.
*/
get amlSuspiciousAlerts(): any[] {
return this.behaviouralAlerts.filter(
(a) => a.status !== 'WHITELISTED' && (!this.amlAlertStatusFilter || a.status === this.amlAlertStatusFilter)
);
}
/** Cleared register: alerts closed as false positives, kept for audit with their evidence. */
get amlWhitelistedAlerts(): any[] {
return this.behaviouralAlerts.filter((a) => a.status === 'WHITELISTED');
@@ -1675,9 +1689,7 @@ export class FiuComponent implements OnInit {
private writeLiveStrXml(cases: any[], filename: string): void {
const xml: string[] = [];
xml.push('<?xml version="1.0" encoding="UTF-8"?>');
xml.push(
`<STR xmlns="http://fiuind.gov.in/finnet2/str" reportingMonth="${this.escapeXml(this.reportingMonthLabel)}">`
);
xml.push(`<STR xmlns="http://fiuind.gov.in/finnet2/str" reportingMonth="${this.escapeXml(this.reportingMonthLabel)}">`);
xml.push(` <REBank name="${this.escapeXml(this.bank.name)}" />`);
if (this.principalOfficerName) {
xml.push(` <PrincipalOfficer name="${this.escapeXml(this.principalOfficerName)}" />`);
@@ -576,11 +576,13 @@
}
/* ───────── Print — only the page, no toolbar / dialog chrome ─────────
The document-level clip (`html, body { overflow: hidden }`) is lifted globally in main.scss's
print block; what remains here is this dialog's own clip — it opens with `maxHeight: '92vh'`,
which Angular Material carries onto the overlay pane / .mat-mdc-dialog-container as an inline
max-height plus overflow:auto. Freeing those lets the whole certificate flow onto the page in
normal static order (a fixed/absolute hack would pin it to the first page instead). */
This dialog is opened with `maxHeight: '92vh'`, which Angular Material carries onto the
overlay pane / .mat-mdc-dialog-container as an inline max-height + overflow:auto clip.
`position: absolute` still lives inside that clip and gets cut off — only the on-screen
scroll position prints. `position: fixed` escapes it: a fixed element's containing block is
the viewport itself, not the clipped ancestor (the classic "fixed child ignores an
overflow:hidden parent" behaviour), as long as nothing between here and the viewport sets a
transform/filter — the CDK overlay chrome doesn't. */
@media print {
body > :not(.cdk-overlay-container) {
display: none !important;
@@ -609,7 +611,9 @@
}
.cpd-root {
position: static;
position: fixed;
left: 0;
top: 0;
width: 100% !important;
height: auto !important;
background: #fff;
@@ -70,9 +70,16 @@ export class ShareProductAccountingStepComponent implements OnInit {
shareProductAccountingForm: UntypedFormGroup;
accountingSectionConfigs: ShareAccountingSectionConfig[] = [];
assetAccountData: GLAccount[] = [];
liabilityAccountData: GLAccount[] = [];
incomeAccountData: GLAccount[] = [];
equityAccountData: GLAccount[] = [];
/** "Share Reference" is where a redemption's credit side lands pending settlement — some banks
* model that as a receivable-like Asset (e.g. "Cash Vault", this field's original default),
* others as a payable-like Liability suspense account (e.g. an existing "Sundry" account
* already used elsewhere as Shares Suspense). Both are valid conventions, so this field accepts
* either instead of Asset-only. */
referenceAccountData: GLAccount[] = [];
constructor() {
this.createShareProductAccountingForm();
@@ -80,15 +87,18 @@ export class ShareProductAccountingStepComponent implements OnInit {
ngOnInit() {
const opts = this.shareProductsTemplate.accountingMappingOptions || {};
this.assetAccountData = opts.assetAccountOptions || [];
this.liabilityAccountData = opts.liabilityAccountOptions || [];
this.incomeAccountData = opts.incomeAccountOptions || [];
this.equityAccountData = opts.equityAccountOptions || [];
this.referenceAccountData = this.assetAccountData.concat(this.liabilityAccountData);
this.buildAccountingSectionConfigs();
if (this.shareProductsTemplate.accountingMappings) {
const m = this.shareProductsTemplate.accountingMappings;
this.shareProductAccountingForm.patchValue({
shareReferenceId: m.shareReferenceId?.id || '',
shareSuspenseId: m.shareSuspenseId?.id || '',
incomeFromFeeAccountId: m.incomeFromFeeAccountId?.id || '',
shareEquityId: m.shareEquityId?.id || ''
@@ -99,6 +109,10 @@ export class ShareProductAccountingStepComponent implements OnInit {
createShareProductAccountingForm() {
this.shareProductAccountingForm = this.formBuilder.group({
accountingRule: [2],
shareReferenceId: [
'',
Validators.required
],
shareSuspenseId: [
'',
Validators.required
@@ -111,6 +125,7 @@ export class ShareProductAccountingStepComponent implements OnInit {
'',
Validators.required
],
assetsOtherGlMappings: this.formBuilder.array([]),
liabilitiesOtherGlMappings: this.formBuilder.array([]),
incomeOtherGlMappings: this.formBuilder.array([]),
equityOtherGlMappings: this.formBuilder.array([])
@@ -119,6 +134,24 @@ export class ShareProductAccountingStepComponent implements OnInit {
buildAccountingSectionConfigs() {
this.accountingSectionConfigs = [
{
key: 'assets',
title: 'Assets',
pillLabel: 'Asset',
pillClass: 'green',
rows: [
{
key: 'shareReferenceId',
label: 'Share Reference (Cash Vault or Suspense)',
inputLabel: 'Share reference',
controlName: 'shareReferenceId',
options: this.referenceAccountData,
required: true
}
],
otherMappingsControlName: 'assetsOtherGlMappings',
otherAccountOptions: this.assetAccountData
},
{
key: 'liabilities',
title: 'Liabilities',
@@ -206,7 +239,7 @@ export class ShareProductAccountingStepComponent implements OnInit {
}
get shareProductAccounting() {
const { liabilitiesOtherGlMappings, incomeOtherGlMappings, equityOtherGlMappings, ...rest } =
const { assetsOtherGlMappings, liabilitiesOtherGlMappings, incomeOtherGlMappings, equityOtherGlMappings, ...rest } =
this.shareProductAccountingForm.value;
return rest;
}
@@ -213,6 +213,14 @@
</div>
@if (shareProduct.accountingRule === 2) {
<div class="shpp-sub-header">{{ 'labels.heading.Assets' | translate }}</div>
<div class="shpp-row">
<span class="shpp-label">{{ 'labels.inputs.Share reference' | translate }}:</span>
<span class="shpp-value">{{
shareProduct.shareReferenceId
| find: shareProductsTemplate.accountingMappingOptions.assetAccountOptions : 'id' : 'name'
}}</span>
</div>
<div class="shpp-sub-header">{{ 'labels.heading.Liabilities' | translate }}</div>
<div class="shpp-row">
<span class="shpp-label">{{ 'labels.inputs.Share suspense control' | translate }}:</span>
@@ -208,6 +208,16 @@
@if (shareProduct.accountingRule.id === 2) {
<div class="layout-row-wrap responsive-column flex-fill">
<h4 class="mat-h4 flex-fill">{{ 'labels.heading.Assets' | translate }}</h4>
<div class="flex-fill">
<mifosx-gl-account-display
class="flex-100"
[accountTitle]="'Share reference'"
[glAccount]="shareProduct.accountingMappings.shareReferenceId"
[withTitle]="'40%'"
>
</mifosx-gl-account-display>
</div>
<h4 class="mat-h4 flex-fill">{{ 'labels.heading.Liabilities' | translate }}</h4>
<div class="flex-fill">
<mifosx-gl-account-display
@@ -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);
});
}
}
@@ -36,17 +36,6 @@
<button mat-icon-button type="button" matTooltip="Refresh now" [disabled]="loading" (click)="fetch()">
<mat-icon>refresh</mat-icon>
</button>
<!-- Exports what is on screen, not the whole feed: the workbook has to reconcile with the report it came from. -->
<button
type="button"
class="export-btn"
[disabled]="exporting || !alerts.data.length"
[matTooltip]="alerts.data.length ? 'Download the alerts currently in view as Excel' : 'Nothing in view to export'"
(click)="exportExcel()"
>
<mat-icon>file_download</mat-icon>
{{ exporting ? 'Preparing…' : 'Excel' }}
</button>
</div>
</div>
@@ -55,52 +44,6 @@
<span>Could not reach the alert feed. Retrying automatically.</span>
</div>
<!-- Narrow the feed to one day. The feed accumulates, so without this an officer asking "what did we decide today"
reads past a week of older alerts to find out. Composes with the status chips below: Today + Escalated answers
"what did we mark suspicious today". -->
<div class="status-filters">
<span class="status-filters-label">Period</span>
@for (m of periodModes; track m.key) {
<button type="button" class="status-chip" [class.active]="periodMode === m.key" (click)="setPeriodMode(m.key)">
{{ m.label }}
</button>
}
<!-- One control per mode, so the picker always matches the granularity chosen. A week is picked by naming any day
inside it; the resolved Monday-to-Sunday span is printed beside it so the officer sees what was selected. -->
@if (periodMode === 'day') {
<input type="date" class="day-input" [(ngModel)]="filterDay" (ngModelChange)="onPeriodChange()" aria-label="Day" />
}
@if (periodMode === 'week') {
<input
type="date"
class="day-input"
[(ngModel)]="filterWeek"
(ngModelChange)="onPeriodChange()"
aria-label="Any day in the week"
/>
}
@if (periodMode === 'month') {
<input
type="month"
class="day-input"
[(ngModel)]="filterMonth"
(ngModelChange)="onPeriodChange()"
aria-label="Month"
/>
}
@if (periodMode === 'year') {
<select class="day-input" [(ngModel)]="filterYear" (ngModelChange)="onPeriodChange()" aria-label="Year">
@for (y of availableYears; track y) {
<option [value]="y">{{ y }}</option>
}
</select>
}
@if (periodLabel) {
<span class="period-resolved">{{ periodLabel }}</span>
}
</div>
<!-- Narrow the feed by where each alert sits in the review workflow -->
<div class="status-filters">
<span class="status-filters-label">Status</span>
@@ -117,143 +60,23 @@
</button>
</div>
<!-- Narrow to the rules firing most often. A rule tripping repeatedly is either a real pattern in the book or a rule
set too tight and filling the queue with noise — either way it is what an officer reads first, and the long tail
below it can wait. Ranked over the period and status already chosen. -->
<div class="status-filters">
<span class="status-filters-label">Top rules</span>
@for (n of topRuleOptions; track n) {
<button type="button" class="status-chip" [class.active]="topRules === n" (click)="setTopRules(n)">
{{ n === null ? 'All rules' : 'Top ' + n }}
</button>
}
@if (topRules !== null) {
<span class="period-resolved">{{ distinctRuleCount }} rule{{ distinctRuleCount === 1 ? '' : 's' }} in view</span>
}
</div>
<!-- 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. -->
<!-- Severity mix of what the table is showing -->
<div class="risk-summary">
<button
*ngFor="let tier of riskSummary"
type="button"
class="risk-tile"
[ngClass]="tier.cls"
[class.tile-active]="isSeverityActive(tier.key)"
[disabled]="tier.count === 0 && !isSeverityActive(tier.key)"
[matTooltip]="isSeverityActive(tier.key) ? 'Show every severity again' : 'Show only ' + tier.label + ' alerts'"
(click)="setSeverity(tier.key)"
>
<div *ngFor="let tier of riskSummary" class="risk-tile" [ngClass]="tier.cls">
<span class="risk-tile-n">{{ tier.count }}</span>
<span class="risk-tile-l">{{ tier.label }}</span>
</button>
<button
*ngIf="unclassifiedCount > 0"
type="button"
class="risk-tile risk-none"
[class.tile-active]="isSeverityActive(0)"
matTooltip="Raised by a user, not by a catalog rule"
(click)="setSeverity(0)"
>
</div>
<div *ngIf="unclassifiedCount > 0" class="risk-tile risk-none" matTooltip="Raised by a user, not by a catalog rule">
<span class="risk-tile-n">{{ unclassifiedCount }}</span>
<span class="risk-tile-l">Unclassified</span>
</button>
<button
type="button"
class="risk-tile risk-total"
[class.tile-active]="activeSeverity === null"
matTooltip="Show every severity"
(click)="clearSeverity()"
>
</div>
<div class="risk-tile risk-total">
<span class="risk-tile-n">{{ summaryTotal }}</span>
<span class="risk-tile-l">Total Flagged</span>
</button>
</div>
</div>
<!-- Appears only once something is ticked, so the table does not carry a permanently empty bar. -->
@if (selectedCount > 0) {
<div class="bulk-bar">
<span class="bulk-count">{{ selectedCount }} selected</span>
<span class="bulk-note"
>Low / medium only — a transaction that tripped a high-severity rule is decided on its own.</span
>
<span class="bulk-spacer"></span>
<button mat-stroked-button type="button" (click)="selectedIds.clear()">Clear</button>
<button
mat-stroked-button
color="primary"
type="button"
*mifosxHasPermission="'REVIEW_AML_ALERT'"
(click)="reviewSelected('WHITELIST')"
>
Whitelist {{ selectedCount }}
</button>
<button
mat-flat-button
color="warn"
type="button"
*mifosxHasPermission="'REVIEW_AML_ALERT'"
(click)="reviewSelected('ESCALATE')"
>
Escalate {{ selectedCount }}
</button>
</div>
}
<!-- multiTemplateDataRows: each alert renders through both row definitions — the alert row and its
detail row beneath it. Without it MatTable renders one row per alert, the two definitions both
claim it, and the table throws rather than rendering anything. All three tabs share one tab
group, so that error blanked Repeat Customers and Rules Master as well. -->
<table mat-table [dataSource]="alerts" multiTemplateDataRows class="flagged-table mat-elevation-z1">
<!-- Ticked for a batch decision. Disabled where the transaction tripped a high-severity rule,
where nothing it matched carried a severity (the CTR rules), or where it is already decided. -->
<ng-container matColumnDef="select">
<th mat-header-cell *matHeaderCellDef class="select-cell">
<!-- Hidden outright when nothing here can be batched — filtered to high severity, say. -->
@if (selectableRows.length) {
<mat-checkbox
[checked]="allSelectableSelected"
(change)="toggleSelectAll()"
matTooltip="Select every low / medium alert on this page"
></mat-checkbox>
}
</th>
<td mat-cell *matCellDef="let alert" class="select-cell" (click)="$event.stopPropagation()">
<!-- No checkbox at all where the transaction cannot be part of a batch. A disabled box still
reads as "this could be ticked", and on a high-severity row that is the wrong thing to
suggest: the decision is never a batch one. A dash says the column does not apply. -->
@if (isSelectable(alert)) {
<mat-checkbox [checked]="isSelected(alert)" (change)="toggleSelected(alert)"></mat-checkbox>
} @else {
<span
class="select-na"
[matTooltip]="
isHighSeverity(alert)
? 'High severity — this transaction is decided on its own'
: !canReview(alert)
? 'Already decided'
: 'A CTR is a statutory return, not a graded suspicion — decided on its own'
"
>—</span
>
}
</td>
</ng-container>
<table mat-table [dataSource]="alerts" class="flagged-table mat-elevation-z1">
<!-- When the engine raised the alert -->
<ng-container matColumnDef="raisedOn">
<th mat-header-cell *matHeaderCellDef>Raised On</th>
@@ -290,162 +113,6 @@
<div class="stacked-cell">
<span class="rule-code">{{ alert.ruleCode }}</span>
<span class="cell-sub">{{ alert.ruleDescription }}</span>
<!-- The transaction is one case under one rule, but an analyst still needs to see
everything it tripped — a deposit can be both reportable and on a new account. The
detail row carries each one with its own severity; this is only the count. -->
@if (matchedRuleCount(alert) > 1) {
<button type="button" class="rule-more" (click)="toggleExpanded(alert); $event.stopPropagation()">
<mat-icon>{{ isExpanded(alert) ? 'expand_less' : 'expand_more' }}</mat-icon>
{{ matchedRuleCount(alert) }} rules matched
</button>
}
</div>
</td>
</ng-container>
<!-- The expanded pane. One cell spanning the table, collapsed to zero height until its row is
opened, so the detail sits directly under the row it belongs to. -->
<ng-container matColumnDef="expandedDetail">
<!-- No header cell: this column never appears in the header row, only in its own data row. -->
<td mat-cell *matCellDef="let alert" [attr.colspan]="displayedColumns.length" class="detail-cell">
<!-- The pane is inside a row that toggles on click, so anything interactive in here must keep its own
clicks from reaching it — otherwise ticking a rule closes the pane. -->
<div class="detail-pane" [class.detail-open]="isExpanded(alert)" (click)="$event.stopPropagation()">
@if (isExpanded(alert)) {
<div class="detail-inner">
<div class="detail-head">
<span
>Rules matched by {{ alert.transactionType | lowercase }} #{{ alert.transactionId }} —
{{ amount(alert) }}</span
>
@if (selectableRulesOf(alert).length > 1) {
<button type="button" class="rule-select-all" (click)="toggleAllRules(alert)">
{{ allRulesSelected(alert) ? 'Clear all' : 'Select all open' }}
</button>
}
</div>
<!-- Governing rule first: it is the one the alert was raised under, and the one the
disposition is recorded against. -->
@for (rule of matchedRules(alert); track rule.ruleCode) {
<div
class="detail-rule"
[class.detail-rule-governing]="rule.governing"
[class.detail-rule-decided]="rule.disposition"
>
<!-- One checkbox per rule: a transaction can trip six rules where five are explained by the
customer's business and the sixth is not, and those are different decisions. A rule already
decided shows its verdict instead. -->
@if (isRuleSelectable(rule)) {
<mat-checkbox
[checked]="isRuleSelected(rule)"
(change)="toggleRuleSelected(rule)"
*mifosxHasPermission="'REVIEW_AML_ALERT'"
></mat-checkbox>
} @else if (!isGradedRule(rule)) {
<!-- A statutory return, not a graded suspicion: filed on the amount whatever the officer decides
about the transaction, so there is nothing here to tick. -->
<mat-icon
class="rule-decided-icon decided-statutory"
matTooltip="A statutory return — filed on the amount, whatever is decided about this transaction. Nothing to review here."
matTooltipClass="evidence-tooltip"
>
gavel
</mat-icon>
} @else {
<mat-icon
class="rule-decided-icon"
[class.decided-escalated]="rule.disposition === 'ESCALATE' && rule.directlyDecided"
[class.decided-carried]="!rule.directlyDecided"
[matTooltip]="
(rule.directlyDecided ? ruleDispositionLabel(rule) : 'Closed with the case') +
(rule.reviewedByUsername ? ' by ' + rule.reviewedByUsername : '') +
(rule.reviewNotes ? ' — ' + rule.reviewNotes : '')
"
matTooltipClass="evidence-tooltip"
>
{{
!rule.directlyDecided
? 'remove_circle_outline'
: rule.disposition === 'ESCALATE'
? 'report'
: 'verified'
}}
</mat-icon>
}
<span class="sev-chip" [ngClass]="severityClass(rule.riskCategory)">{{
severityLabel(rule.riskCategory)
}}</span>
<div class="detail-rule-text">
<span class="detail-rule-code">
{{ rule.ruleCode }}
@if (rule.governing) {
<span class="governing-tag" matTooltip="The alert is raised and decided under this rule"
>governing</span
>
}
<!-- Says where a statutory rule actually goes, so the absence of a checkbox does not read as the
transaction being unreported. -->
@if (!isGradedRule(rule)) {
<span class="ctr-tag" matTooltip="Goes to the monthly CTR filing for FIU-IND, not to an STR">
→ CTR filing
</span>
}
<!-- The rule the officer named reads as their decision; a rule closed alongside it says so,
because an audit of the finding turns on which one they put their name to. -->
@if (rule.disposition) {
<span
class="rule-verdict"
[class.verdict-escalated]="rule.disposition === 'ESCALATE'"
[class.verdict-carried]="!rule.directlyDecided"
[matTooltip]="
rule.directlyDecided
? ruleDispositionLabel(rule) +
(rule.reviewedByUsername ? ' by ' + rule.reviewedByUsername : '')
: 'Closed with the transaction when another rule was marked suspicious — not decided on its own'
"
>
{{ rule.directlyDecided ? ruleDispositionLabel(rule) : 'Closed with case' }}
</span>
}
</span>
<span class="detail-rule-desc">{{ rule.ruleDescription }}</span>
</div>
</div>
}
<!-- Acts on the ticked rules only. The alert's own status follows from what is left undecided, so
clearing five of six rules leaves the case open on the sixth. -->
@if (selectedRuleCount > 0) {
<div class="rule-action-bar" *mifosxHasPermission="'REVIEW_AML_ALERT'">
<span class="rule-action-count"
>{{ selectedRuleCount }} rule{{ selectedRuleCount > 1 ? 's' : '' }} selected</span
>
<span class="bulk-spacer"></span>
<button mat-stroked-button type="button" (click)="selectedRuleCodes.clear()">Clear</button>
<button
mat-stroked-button
color="primary"
type="button"
(click)="reviewSelectedRules(alert, 'WHITELIST')"
>
Whitelist
</button>
<button mat-flat-button color="warn" type="button" (click)="reviewSelectedRules(alert, 'ESCALATE')">
Suspicious
</button>
</div>
}
<div class="detail-foot">
@if (isHighSeverity(alert)) {
A high-severity rule fired, so this transaction is decided on its own rather than in a batch.
} @else if (!alert.highestSeverity) {
A statutory return rather than a graded suspicion — decided on its own.
} @else {
Every rule here is low or medium, so this transaction can be decided together with others.
}
</div>
</div>
}
</div>
</td>
</ng-container>
@@ -465,7 +132,7 @@
<!-- Disposition. Hidden once an alert is already whitelisted or escalated. -->
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef class="col-center">Action</th>
<td mat-cell *matCellDef="let alert" class="col-center actions-cell" (click)="$event.stopPropagation()">
<td mat-cell *matCellDef="let alert" class="col-center actions-cell">
<ng-container *ngIf="canReview(alert); else reviewed">
<!-- Same gate the FIU-IND register uses; the endpoint enforces REVIEW_AML_ALERT server side,
so without this a user who cannot review still sees buttons that only fail on click. -->
@@ -491,73 +158,37 @@
</div>
</ng-container>
<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">
<span class="reviewed-by">
{{ alert.reviewedByUsername ? 'by ' + alert.reviewedByUsername : 'Reviewed' }}
</span>
<!-- The evidence recorded at review time, so the trail is readable without leaving the feed -->
<mat-icon
*ngIf="alert.reviewNotes"
class="reviewed-evidence-icon"
[matTooltip]="alert.reviewNotes"
matTooltipClass="evidence-tooltip"
>
description
</mat-icon>
<span
*ngIf="alert.status === 'ESCALATED'"
class="str-routed"
matTooltip="An STR case is open for this alert"
>
→ STR
</span>
</div>
}
<div class="reviewed-cell">
<span class="reviewed-by">
{{ alert.reviewedByUsername ? 'by ' + alert.reviewedByUsername : 'Reviewed' }}
</span>
<!-- The evidence recorded at review time, so the trail is readable without leaving the feed -->
<mat-icon
*ngIf="alert.reviewNotes"
class="reviewed-evidence-icon"
[matTooltip]="alert.reviewNotes"
matTooltipClass="evidence-tooltip"
>
description
</mat-icon>
<span *ngIf="alert.status === 'ESCALATED'" class="str-routed" matTooltip="An STR case is open for this alert">
→ STR
</span>
</div>
</ng-template>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<!-- Clicking anywhere on the row opens its detail. The checkbox and the action buttons stop the
event themselves, so ticking a row or disposing of it does not also expand it. -->
<tr
mat-row
*matRowDef="let row; columns: displayedColumns"
class="alert-row"
[class.row-new]="isNew(row)"
[class.row-expanded]="isExpanded(row)"
(click)="toggleExpanded(row)"
></tr>
<tr mat-row *matRowDef="let row; columns: ['expandedDetail']" class="detail-row"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns" class="alert-row" [class.row-new]="isNew(row)"></tr>
</table>
<!-- pageSize stated explicitly: without it the paginator starts at 0, and anything that touches the
paginator before the data source has bound to it leaves that 0 in force, which pages every row
out of the table. -->
<mat-paginator
[pageSize]="10"
[pageSizeOptions]="[10, 25, 50, 100]"
showFirstLastButtons
class="flagged-paginator"
></mat-paginator>
<mat-paginator [pageSizeOptions]="[10, 25, 50, 100]" showFirstLastButtons class="flagged-paginator"></mat-paginator>
<div *ngIf="alerts.data.length === 0 && !loading" class="no-alerts-message">
<mat-icon>{{ activeStatus || activeSeverity !== null ? 'filter_alt_off' : 'verified_user' }}</mat-icon>
<span *ngIf="!activeStatus && activeSeverity === null"
<mat-icon>{{ activeStatus ? 'filter_alt_off' : 'verified_user' }}</mat-icon>
<span *ngIf="!activeStatus"
>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>
@@ -196,43 +196,15 @@
padding: 0 0 16px;
}
/* A button, not a div: each tile narrows the table to its own severity band. */
.risk-tile {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
padding: 12px 14px;
border: 1px solid #e2e8f0;
border-left-width: 4px;
border-radius: 8px;
background: #fff;
font-family: inherit;
text-align: left;
cursor: pointer;
transition:
box-shadow 0.15s,
transform 0.1s;
&:hover:not(:disabled) {
box-shadow: 0 2px 8px rgb(15 23 42 / 12%);
}
&:active:not(:disabled) {
transform: translateY(1px);
}
/* A band with nothing in it would filter to an empty table. */
&:disabled {
opacity: 0.5;
cursor: default;
}
}
/* The band currently narrowing the table. The ring reads on every tile colour, where a
background change would fight the severity tint each tile already carries. */
.risk-tile.tile-active {
box-shadow: 0 0 0 2px #1a237e;
}
.risk-tile-n {
@@ -321,21 +293,8 @@
}
}
.alert-row {
cursor: pointer;
&:hover {
background: #f8fafc;
}
}
/* The open row and its pane read as one block. */
.alert-row.row-expanded {
.alert-row:hover {
background: #f8fafc;
td {
border-bottom-color: transparent;
}
}
/* A newly arrived alert stays tinted until acknowledged. */
@@ -421,285 +380,6 @@
font-size: 12px;
}
/* Quieter than the governing rule: it is context on one case, not a second case. */
.rule-also {
margin-top: 3px;
color: #92400e;
background: #fffbeb;
border: 1px solid #fde68a;
border-radius: 4px;
padding: 1px 6px;
font-size: 11px;
align-self: flex-start;
}
/* Says how many rules fired and opens the detail. Replaces the block of codes that used to sit in
this cell: the codes belong in the pane, where each can carry its own severity. */
.rule-more {
display: inline-flex;
align-items: center;
gap: 3px;
margin-top: 4px;
padding: 2px 8px 2px 4px;
align-self: flex-start;
border: 1px solid #fde68a;
border-radius: 999px;
background: #fffbeb;
color: #92400e;
font-family: inherit;
font-size: 11px;
font-weight: 700;
cursor: pointer;
mat-icon {
font-size: 15px;
width: 15px;
height: 15px;
}
&:hover {
background: #fef3c7;
}
}
/* ── Expanded detail ─────────────────────────────────────────────────────────────────────── */
.detail-row {
height: 0;
}
.detail-cell {
padding: 0 !important;
border-bottom: none !important;
}
/* Animating max-height rather than height: the pane's height depends on how many rules fired, and
a transition needs a figure to move between. */
.detail-pane {
max-height: 0;
overflow: hidden;
transition: max-height 0.2s ease;
background: #f8fafc;
/* Generous enough for a transaction that tripped every rule it could, plus the action bar. Too low
silently clips the last rule, which is the one an officer most needs to see. */
&.detail-open {
max-height: 1200px;
}
}
.detail-inner {
padding: 14px 16px 16px 60px;
border-bottom: 1px solid #e2e8f0;
}
.detail-head {
display: flex;
align-items: center;
gap: 12px;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #64748b;
margin-bottom: 10px;
}
.rule-select-all {
border: none;
background: none;
padding: 0;
font-family: inherit;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #1a237e;
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
/* A rule that already carries a verdict is context, not a choice. */
.detail-rule-decided {
opacity: 0.75;
}
.rule-decided-icon {
flex: none;
font-size: 18px;
width: 18px;
height: 18px;
color: #15803d;
cursor: help;
&.decided-escalated {
color: #b91c1c;
}
/* Closed alongside another rule rather than judged on its own — stated, but not as a verdict. */
&.decided-carried {
color: #94a3b8;
}
/* A statutory return: not the officer's decision at all. */
&.decided-statutory {
color: #475569;
}
}
/* Where a statutory rule goes, said next to the rule rather than left to be inferred from a
missing checkbox. */
.ctr-tag {
margin-left: 6px;
padding: 1px 6px;
border-radius: 4px;
background: #e2e8f0;
color: #334155;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.03em;
text-transform: uppercase;
white-space: nowrap;
}
.rule-verdict {
margin-left: 6px;
padding: 1px 6px;
border-radius: 4px;
background: #dcfce7;
color: #15803d;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.03em;
text-transform: uppercase;
&.verdict-escalated {
background: #fee2e2;
color: #b91c1c;
}
/* Wins over the disposition colours above: this rule was not itself the finding, and colouring it
like one would claim the officer condemned it. */
&.verdict-carried {
background: #f1f5f9;
color: #64748b;
}
}
/* Acts on the ticked rules only, and sits with them rather than at the top of the table. */
.rule-action-bar {
display: flex;
align-items: center;
gap: 10px;
margin-top: 12px;
padding: 10px 12px;
border: 1px solid #bfdbfe;
border-radius: 8px;
background: #eff6ff;
}
.rule-action-count {
font-size: 12.5px;
font-weight: 700;
color: #1e3a8a;
white-space: nowrap;
}
.detail-rule {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 7px 10px;
border-radius: 6px;
background: #fff;
border: 1px solid #e2e8f0;
& + & {
margin-top: 6px;
}
}
/* The rule the alert was raised under, and the one the disposition is recorded against. */
.detail-rule-governing {
border-color: #cbd5e1;
box-shadow: inset 3px 0 0 #1a237e;
}
.detail-rule-text {
display: flex;
flex-direction: column;
gap: 1px;
min-width: 0;
}
.detail-rule-code {
font-size: 12.5px;
font-weight: 700;
color: #1e293b;
}
.detail-rule-desc {
font-size: 12px;
color: #64748b;
}
.governing-tag {
margin-left: 6px;
padding: 1px 6px;
border-radius: 4px;
background: #e0e7ff;
color: #3730a3;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.03em;
text-transform: uppercase;
}
/* Severity of one rule inside the pane. Same colours as the summary tiles, so a band reads the
same wherever it appears. */
.sev-chip {
flex: none;
min-width: 62px;
text-align: center;
padding: 2px 8px;
border-radius: 999px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
&.sev-high {
background: #fee2e2;
color: #b91c1c;
}
&.sev-medium {
background: #fef3c7;
color: #b45309;
}
&.sev-low {
background: #dcfce7;
color: #15803d;
}
&.sev-none {
background: #e2e8f0;
color: #475569;
}
}
/* Why this transaction can or cannot be decided in a batch, said where the officer is deciding. */
.detail-foot {
margin-top: 10px;
font-size: 12px;
color: #475569;
font-style: italic;
}
.rule-code {
font-weight: 600;
}
@@ -857,157 +537,3 @@
background: #fff;
border-top: 1px solid #e2e8f0;
}
/* Batch disposition bar — only rendered while rows are ticked. */
.bulk-bar {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
margin-bottom: 10px;
border: 1px solid #bfdbfe;
border-radius: 10px;
background: #eff6ff;
}
.bulk-count {
font-size: 13px;
font-weight: 700;
color: #1e3a8a;
white-space: nowrap;
}
/* Says why some rows cannot be ticked, where the officer is looking rather than in a tooltip. */
.bulk-note {
font-size: 12px;
color: #475569;
}
.bulk-spacer {
flex: 1;
}
.select-cell {
width: 44px;
padding-right: 0;
}
/* Stands in for the checkbox on a row that cannot be batched, so the column still reads as a
column rather than leaving a hole the eye has to account for. */
.select-na {
display: inline-block;
width: 18px;
text-align: center;
color: #cbd5e1;
cursor: help;
user-select: none;
}
/* Day picker. Sized and bordered to sit level with the status chips beside it, so the two rows read
as one control strip rather than a form field dropped above a set of buttons. */
.day-input {
padding: 5px 11px;
border: 1px solid #cbd5e1;
border-radius: 999px;
background: #fff;
font-family: inherit;
font-size: 12.5px;
font-weight: 600;
color: #334155;
cursor: pointer;
&:hover {
border-color: #1a237e;
}
&:focus {
outline: none;
border-color: #1a237e;
}
}
/* What the chosen mode resolved to. A week picked by naming one day inside it is otherwise invisible — the officer
has no way to tell which Monday-to-Sunday span they are looking at. */
.period-resolved {
font-size: 12px;
color: #64748b;
font-variant-numeric: tabular-nums;
}
/* Sits with the pause and refresh controls, green because it produces something the officer takes away rather
than changing what is on screen. */
.export-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 13px;
border: 1px solid #bbf7d0;
border-radius: 999px;
background: #f0fdf4;
color: #15803d;
font-family: inherit;
font-size: 12.5px;
font-weight: 700;
cursor: pointer;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
&:hover:not(:disabled) {
background: #dcfce7;
border-color: #86efac;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
/* Held by another officer. Grey, not red: nobody has done anything wrong, the case is simply somebody else's. */
.held-icon {
font-size: 16px;
width: 16px;
height: 16px;
color: #94a3b8;
cursor: help;
}
.held-by {
color: #64748b;
font-size: 12px;
font-style: italic;
}
/* Rule frequency strip. Sits under the filters as a reading aid, not a control — clicking a rule would be a fourth
filter competing with the three above it. */
.rule-freq {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 0 0 14px;
}
.rule-freq-item {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 9px;
border: 1px solid #e2e8f0;
border-radius: 999px;
background: #f8fafc;
font-size: 11px;
font-weight: 600;
color: #475569;
}
.rule-freq-n {
padding: 0 6px;
border-radius: 999px;
background: #e2e8f0;
color: #1e293b;
font-variant-numeric: tabular-nums;
}
@@ -7,7 +7,7 @@
*/
/** Angular Imports */
import { Component, OnInit, AfterViewInit, OnDestroy, inject, ViewChild } from '@angular/core';
import { Component, OnInit, OnDestroy, inject, ViewChild } from '@angular/core';
import { MatTableModule, MatTableDataSource } from '@angular/material/table';
import { MatPaginatorModule, MatPaginator } from '@angular/material/paginator';
import { MatIconModule } from '@angular/material/icon';
@@ -15,23 +15,15 @@ import { MatButtonModule } from '@angular/material/button';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Subject, timer, of } from 'rxjs';
import { switchMap, takeUntil, catchError, filter } from 'rxjs/operators';
/** Custom Services */
import { AmlService } from '../../../aml/aml.service';
import { AlertService } from '../../../core/alert/alert.service';
import { AuthenticationService } from '../../../core/authentication/authentication.service';
import { HasPermissionDirective } from '../../../directives/has-permission/has-permission.directive';
import { AmlReviewAlertDialogComponent } from './aml-review-alert-dialog.component';
import { AmlBulkReviewDialogComponent } from './aml-bulk-review-dialog.component';
import { exportWorkbook } from '../../../cashier/cashier-export';
/** How the feed is narrowed by time. */
type PeriodMode = 'all' | 'day' | 'week' | 'month' | 'year';
/**
* Live feed of transactions the AML rule engine has flagged.
@@ -49,7 +41,6 @@ type PeriodMode = 'all' | 'day' | 'week' | 'month' | 'year';
standalone: true,
imports: [
CommonModule,
FormsModule,
MatTableModule,
MatPaginatorModule,
MatIconModule,
@@ -57,18 +48,13 @@ type PeriodMode = 'all' | 'day' | 'week' | 'month' | 'year';
MatTooltipModule,
MatProgressSpinnerModule,
MatDialogModule,
MatCheckboxModule,
HasPermissionDirective
]
})
export class AmlFlaggedTransactionsComponent implements OnInit, AfterViewInit, OnDestroy {
export class AmlFlaggedTransactionsComponent implements OnInit, OnDestroy {
private amlService = inject(AmlService);
private dialog = inject(MatDialog);
private alertService = inject(AlertService);
private auth = inject(AuthenticationService);
/** Who is signed in, so the screen can tell an alert this officer holds from one somebody else does. */
private readonly currentUsername = this.auth.getCredentials()?.username ?? '';
private destroy$ = new Subject<void>();
/** Short enough to feel live to an officer watching the desk, long enough not to hammer the API. */
@@ -76,7 +62,6 @@ export class AmlFlaggedTransactionsComponent implements OnInit, AfterViewInit, O
alerts = new MatTableDataSource<any>([]);
displayedColumns: string[] = [
'select',
'raisedOn',
'transaction',
'client',
@@ -86,532 +71,25 @@ export class AmlFlaggedTransactionsComponent implements OnInit, AfterViewInit, O
'actions'
];
/**
* Alerts ticked for a single disposition across several transactions.
*
* <p>
* Low and medium-severity alerts arrive in numbers and are mostly the same decision repeated -
* a run of ordinary cash deposits that each tripped a threshold. Dealing with them one dialog at
* a time is what stops an officer getting to the one alert that matters. High severity is
* deliberately excluded: an alert that reached the high band is a judgement about that customer's
* transaction, and it gets its own decision.
*/
selectedIds = new Set<number>();
/** m_client_risk_rule.risk_category — 3 is the high band. */
private static readonly HIGH_SEVERITY = 3;
/**
* Whether any rule this transaction matched reached the high band.
*
* <p>
* Read from highestSeverity, not from the governing rule's own riskCategory. One transaction
* raises one alert under whichever rule most warrants attention, and a statutory CTR outranks
* everything — so a high-severity rule can sit in "also matched" while the row itself reports the
* CTR, which carries no severity at all. Judging the row by the governing rule would offer such a
* transaction up for a batch decision.
*/
isHighSeverity(alert: any): boolean {
return Number(alert?.highestSeverity) === AmlFlaggedTransactionsComponent.HIGH_SEVERITY;
}
/**
* The band this alert counts as, for the summary tiles.
*
* <p>
* The same figure the checkbox is gated on, so the tiles and the table cannot disagree. Reading
* riskCategory here put a ₹12,00,000 cash deposit that had tripped a high-severity rule under
* "Unclassified", because the alert reports under the statutory CTR and a CTR carries no band of
* its own. Alerts raised before highest_severity existed fall back to the governing rule's band.
*/
private severityBand(alert: any): number {
const worst = Number(alert?.highestSeverity);
if (worst === 1 || worst === 2 || worst === 3) {
return worst;
}
const governing = Number(alert?.riskCategory);
if (governing === 1 || governing === 2 || governing === 3) {
return governing;
}
// 0, never NaN: the Unclassified tile filters on this value, and NaN === NaN is false, so a
// NaN band would leave that tile showing a count it could never select.
return 0;
}
/**
* Only an open alert whose every matched rule sits in the low or medium band can be swept up with
* others.
*
* <p>
* Two things are held back. A transaction that tripped any high-severity rule, because that is a
* judgement about the transaction itself. And one where no matched rule carried a severity at
* all, which is how the CTR rules arrive - they are not graded, being returns the bank owes
* rather than suspicions. Neither belongs in a batch disposed of on a single reason.
*/
isSelectable(alert: any): boolean {
const band = Number(alert?.highestSeverity);
const graded = band === 1 || band === 2;
return this.canReview(alert) && graded;
}
/** The alert whose detail row is open. One at a time: this is a reading pane, not a comparison. */
expandedId: number | null = null;
/**
* Opens or closes the detail pane. Nothing is recorded by reading.
*
* <p>
* Expanding a row used to mark the alert UNDER_REVIEW, on the reasoning that opening the pane is where the work
* starts. It is not: most expands are an officer glancing at what a transaction tripped, and a status that changes
* on a single click describes the click rather than any review. The queue filled with alerts nobody was working,
* and the one state the flag existed to make visible — this one is actually being worked — became meaningless.
*/
toggleExpanded(alert: any): void {
this.expandedId = this.expandedId === alert.id ? null : alert.id;
// Ticks belong to the alert they were made on, not to whichever pane is open next.
this.selectedRuleCodes.clear();
}
isExpanded(alert: any): boolean {
return this.expandedId === alert.id;
}
/**
* Every rule the transaction matched, governing one first.
*
* <p>
* Served by the API. The fallback rebuilds the list from the flat fields for an alert read before the API carried
* it, so an older row still expands to something rather than appearing to have matched nothing.
*/
matchedRules(
alert: any
): { ruleCode: string; ruleDescription: string; riskCategory: number | null; governing: boolean }[] {
if (Array.isArray(alert?.matchedRules) && alert.matchedRules.length) {
return alert.matchedRules;
}
const rules = [
{
ruleCode: alert?.ruleCode,
ruleDescription: alert?.ruleDescription,
riskCategory: alert?.riskCategory ?? null,
governing: true
}
];
for (const code of String(alert?.alsoMatchedRules || '')
.split(',')
.map((c: string) => c.trim())
.filter((c: string) => c)) {
rules.push({ ruleCode: code, ruleDescription: code, riskCategory: null, governing: false });
}
return rules;
}
/** How many rules the transaction matched, for the count shown on the collapsed row. */
matchedRuleCount(alert: any): number {
return this.matchedRules(alert).length;
}
severityLabel(band: number | null | undefined): string {
switch (Number(band)) {
case 1:
return 'Low';
case 2:
return 'Medium';
case 3:
return 'High';
default:
return 'Statutory';
}
}
/**
* Rule codes ticked inside the open detail pane.
*
* <p>
* Cleared whenever a different alert is opened: the codes are only meaningful against the alert they were ticked on,
* and carrying them across would apply one transaction's selection to another's rules.
*/
selectedRuleCodes = new Set<string>();
isRuleSelected(rule: any): boolean {
return this.selectedRuleCodes.has(rule.ruleCode);
}
/**
* Whether this rule is the officer's to decide.
*
* <p>
* A rule already decided keeps its verdict. A rule with no severity band is not a graded suspicion at all - the CTR
* rules are statutory returns the bank owes FIU-IND on any cash transaction at or above ten lakh, filed on the
* amount whether or not the customer is above suspicion. There is no "whitelist" for one: clearing the transaction
* does not stop the CTR, and escalating it does not describe what the rule found.
*/
isRuleSelectable(rule: any): boolean {
return !rule?.disposition && this.isGradedRule(rule);
}
/** A rule that carries a severity band, as against a statutory reporting rule that carries none. */
isGradedRule(rule: any): boolean {
const band = Number(rule?.riskCategory);
return band === 1 || band === 2 || band === 3;
}
toggleRuleSelected(rule: any): void {
if (!this.isRuleSelectable(rule)) {
return;
}
if (this.selectedRuleCodes.has(rule.ruleCode)) {
this.selectedRuleCodes.delete(rule.ruleCode);
} else {
this.selectedRuleCodes.add(rule.ruleCode);
}
}
selectableRulesOf(alert: any): any[] {
return this.matchedRules(alert).filter((r) => this.isRuleSelectable(r));
}
get selectedRuleCount(): number {
return this.selectedRuleCodes.size;
}
allRulesSelected(alert: any): boolean {
const open = this.selectableRulesOf(alert);
return open.length > 0 && open.every((r) => this.selectedRuleCodes.has(r.ruleCode));
}
toggleAllRules(alert: any): void {
if (this.allRulesSelected(alert)) {
this.selectedRuleCodes.clear();
return;
}
for (const rule of this.selectableRulesOf(alert)) {
this.selectedRuleCodes.add(rule.ruleCode);
}
}
ruleDispositionLabel(rule: any): string {
return rule?.disposition === 'ESCALATE' ? 'Escalated' : rule?.disposition === 'WHITELIST' ? 'Whitelisted' : '';
}
severityClass(band: number | null | undefined): string {
switch (Number(band)) {
case 1:
return 'sev-low';
case 2:
return 'sev-medium';
case 3:
return 'sev-high';
default:
return 'sev-none';
}
}
isSelected(alert: any): boolean {
return this.selectedIds.has(alert.id);
}
toggleSelected(alert: any): void {
if (!this.isSelectable(alert)) {
return;
}
if (this.selectedIds.has(alert.id)) {
this.selectedIds.delete(alert.id);
} else {
this.selectedIds.add(alert.id);
}
}
/** The rows a "select all" would act on: what is on screen, open, and below the high band. */
get selectableRows(): any[] {
return this.alerts.data.filter((a) => this.isSelectable(a));
}
get allSelectableSelected(): boolean {
const rows = this.selectableRows;
return rows.length > 0 && rows.every((a) => this.selectedIds.has(a.id));
}
toggleSelectAll(): void {
if (this.allSelectableSelected) {
this.selectedIds.clear();
return;
}
for (const a of this.selectableRows) {
this.selectedIds.add(a.id);
}
}
get selectedCount(): number {
return this.selectedIds.size;
}
private selectedAlerts(): any[] {
return this.alerts.data.filter((a) => this.selectedIds.has(a.id));
}
/**
* Drops ticks that no longer stand, after every poll.
*
* <p>
* The feed refreshes itself every few seconds while the officer works. Without this, an alert
* another officer disposed of in the meantime — or one the status filter has since hidden — stays
* in the set, and the bar goes on offering to decide it: the count reads higher than the ticks on
* screen, and the batch carries alerts nobody chose.
*/
private pruneSelection(): void {
const stillSelectable = new Set(this.selectableRows.map((a) => a.id));
for (const id of Array.from(this.selectedIds)) {
if (!stillSelectable.has(id)) {
this.selectedIds.delete(id);
}
}
// Same for the open detail pane: an alert filtered away or disposed of elsewhere should not
// leave the table holding a pane for a row that is no longer on it.
if (this.expandedId !== null && !this.alerts.data.some((a) => a.id === this.expandedId)) {
this.expandedId = null;
}
}
/** Every alert as returned, before the status filter narrows the table. */
private allAlerts: any[] = [];
readonly statusFilters: { key: string; label: string }[] = [
{ key: '', label: 'All alerts' },
{ key: 'RAISED', label: 'Raised' },
// No "Under review" chip: nothing sets that status now that reading a row no longer records anything, so the
// chip would sit at zero for good. The status itself is kept — alerts predating this change still carry it, and
// canReview still treats them as open — but it is not offered as a filter nobody can populate.
{ key: 'UNDER_REVIEW', label: 'Under review' },
{ key: 'ESCALATED', label: 'Escalated' },
{ key: 'WHITELISTED', label: 'Whitelisted' }
];
activeStatus = '';
private statusCounts: Record<string, number> = {};
/**
* The period being worked.
*
* A monitoring feed accumulates: last week's alerts sit under today's, and an officer asked "what did we decide
* today" has to read past them. The period filter is what turns the feed into a day's, a week's or a month's work
* — and it composes with the status chips, so Today + Escalated answers "what did we mark suspicious today".
*/
readonly periodModes: { key: PeriodMode; label: string }[] = [
{ key: 'all', label: 'All time' },
{ key: 'day', label: 'Day wise' },
{ key: 'week', label: 'Week wise' },
{ key: 'month', label: 'Month wise' },
{ key: 'year', label: 'Year wise' }
];
periodMode: PeriodMode = 'all';
/** The chosen day / any day inside the chosen week / the chosen month / the chosen year. */
filterDay = '';
filterWeek = '';
filterMonth = '';
filterYear = '';
/**
* Narrows the table to the rules firing most often in the chosen period.
*
* <p>
* A rule tripping repeatedly is a different question from one transaction tripping a rule: it is either a real
* pattern in the book, or a rule set too tight and filling the queue with noise. Either way the rules at the top of
* the count are the ones worth an officer's attention first, and the long tail below them can wait.
*
* <p>
* Null shows every rule. The options are deliberately small — a "top 10" that lists 40 rules answers nothing.
*/
readonly topRuleOptions: (number | null)[] = [
null,
10,
20,
50
];
topRules: number | null = null;
setTopRules(n: number | null): void {
this.topRules = n;
this.applyStatusFilter(true);
}
/**
* The rule codes to keep, most-triggered first, or null when every rule is in view.
*
* <p>
* Counted over the period and status already chosen rather than over the whole feed, so "top 10 this month" means
* the ten busiest rules of that month. Ties are broken on the rule code so the same set comes back each time
* instead of shifting between polls.
*/
private topRuleCodes(pool: any[]): Set<string> | null {
if (this.topRules === null) {
return null;
}
const counts = new Map<string, number>();
for (const a of pool) {
const code = a?.ruleCode;
if (code) {
counts.set(code, (counts.get(code) ?? 0) + 1);
}
}
if (counts.size <= this.topRules) {
return null;
}
const ranked = Array.from(counts.entries())
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
.slice(0, this.topRules)
.map(([code]) => code);
return new Set(ranked);
}
/** How many distinct rules the current view holds, so the chip can say what it is narrowing. */
get distinctRuleCount(): number {
return new Set(this.alerts.data.map((a) => a.ruleCode).filter((c) => c)).size;
}
/**
* The rules in view with how often each fired, busiest first.
*
* <p>
* The point of ranking rules is to see which ones are driving the queue, and a count per rule is that answer.
* Without it "Top 10" narrows the table and leaves the officer to work out what it kept.
*/
get ruleFrequency(): { code: string; count: number }[] {
const counts = new Map<string, number>();
for (const a of this.alerts.data) {
if (a?.ruleCode) {
counts.set(a.ruleCode, (counts.get(a.ruleCode) ?? 0) + 1);
}
}
return Array.from(counts.entries())
.map(
([
code,
count
]) => ({ code, count })
)
.sort((a, b) => b.count - a.count || a.code.localeCompare(b.code));
}
setPeriodMode(mode: PeriodMode): void {
this.periodMode = mode;
// Seeded with today the first time a mode is picked, so choosing "Month wise" shows this month rather than an
// empty field the officer has to fill before anything happens.
const now = new Date();
const iso = this.isoOf(now);
if (mode === 'day' && !this.filterDay) {
this.filterDay = iso;
}
if (mode === 'week' && !this.filterWeek) {
this.filterWeek = iso;
}
if (mode === 'month' && !this.filterMonth) {
this.filterMonth = iso.slice(0, 7);
}
if (mode === 'year' && !this.filterYear) {
this.filterYear = String(now.getFullYear());
}
this.applyStatusFilter(true);
}
onPeriodChange(): void {
this.applyStatusFilter(true);
}
/** The years the feed actually covers, newest first, so the picker offers nothing empty. */
get availableYears(): string[] {
const years = new Set<string>();
for (const a of this.allAlerts) {
if (a.raisedOnDate) {
years.add(String(a.raisedOnDate.getFullYear()));
}
}
const list = Array.from(years).sort().reverse();
return list.length ? list : [String(new Date().getFullYear())];
}
/** Reads back the resolved period, so the officer can see what "week wise" actually resolved to. */
get periodLabel(): string {
const range = this.periodRange();
if (!range) {
return '';
}
return range.from === range.to ? range.from : `${range.from} to ${range.to}`;
}
private isoOf(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
/**
* The chosen period as an inclusive from/to, or null for all time.
*
* Week runs Monday to Sunday. Built from local date parts rather than by parsing to UTC: an alert raised late in
* the evening is on that evening's date for the officer looking at it, and a UTC round-trip would move it.
*/
private periodRange(): { from: string; to: string } | null {
switch (this.periodMode) {
case 'day':
return this.filterDay ? { from: this.filterDay, to: this.filterDay } : null;
case 'week': {
if (!this.filterWeek) {
return null;
}
const [
y,
m,
d
] = this.filterWeek.split('-').map(Number);
const picked = new Date(y, m - 1, d);
const backToMonday = picked.getDay() === 0 ? 6 : picked.getDay() - 1;
const monday = new Date(y, m - 1, d - backToMonday);
const sunday = new Date(y, m - 1, d - backToMonday + 6);
return { from: this.isoOf(monday), to: this.isoOf(sunday) };
}
case 'month': {
if (!this.filterMonth) {
return null;
}
const [
y,
m
] = this.filterMonth.split('-').map(Number);
// Day 0 of the next month is the last day of this one, which handles February and leap years without a table.
return { from: `${this.filterMonth}-01`, to: this.isoOf(new Date(y, m, 0)) };
}
case 'year':
return this.filterYear ? { from: `${this.filterYear}-01-01`, to: `${this.filterYear}-12-31` } : null;
default:
return null;
}
}
/** Whether an alert was raised inside the chosen period. */
private inActivePeriod(alert: any): boolean {
const range = this.periodRange();
if (!range) {
return true;
}
const d: Date | null = alert?.raisedOnDate;
if (!d) {
return false;
}
const iso = this.isoOf(d);
return iso >= range.from && iso <= range.to;
}
/**
* The severity tile currently narrowing the table: 1 Low, 2 Medium, 3 High, 0 unclassified, or
* null for all. Independent of {@link activeStatus} — the two compose, so an officer can ask for
* the high-severity alerts that are still open.
*/
activeSeverity: number | null = null;
/**
* Severity mix of the flagged transactions currently in the table. riskCategory is the severity
* of the rule that fired, resolved server-side from the originating catalog entry — the same
* field the Rules Master tab shows in its Risk Category column.
*/
riskSummary: { 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. */
unclassifiedCount = 0;
@@ -628,18 +106,6 @@ export class AmlFlaggedTransactionsComponent implements OnInit, AfterViewInit, O
@ViewChild(MatPaginator) paginator!: MatPaginator;
/**
* Binds the paginator as soon as the view exists.
*
* <p>
* The first poll can return before the view has initialised, and until this the data source was
* only ever wired up as a side effect of a later filter pass — so the table could render every
* alert unpaginated, then jump once the next poll arrived.
*/
ngAfterViewInit(): void {
this.alerts.paginator = this.paginator;
}
ngOnInit(): void {
this.fetch();
timer(AmlFlaggedTransactionsComponent.POLL_INTERVAL_MS, AmlFlaggedTransactionsComponent.POLL_INTERVAL_MS)
@@ -692,22 +158,7 @@ export class AmlFlaggedTransactionsComponent implements OnInit, AfterViewInit, O
setStatus(key: string): void {
this.activeStatus = key;
this.applyStatusFilter(true);
}
/** Clicking the tile already in force clears it, so the same click both narrows and undoes. */
setSeverity(band: number): void {
this.activeSeverity = this.activeSeverity === band ? null : band;
this.applyStatusFilter(true);
}
isSeverityActive(band: number): boolean {
return this.activeSeverity === band;
}
clearSeverity(): void {
this.activeSeverity = null;
this.applyStatusFilter(true);
this.applyStatusFilter();
}
statusCount(key: string): number {
@@ -752,179 +203,54 @@ export class AmlFlaggedTransactionsComponent implements OnInit, AfterViewInit, O
this.allAlerts = list;
this.recomputeStatusCounts();
this.applyStatusFilter();
this.pruneSelection();
this.lastUpdated = new Date();
}
/**
* @param resetPage
* true only when the user changed a filter. A poll must not reset the page: the feed
* refreshes every few seconds, and an officer reading page 3 would be thrown back to
* page 1 each time. Nor may it touch the paginator before the data source has bound to
* it — doing that on the first pass left pageSize at 0 and emptied the table.
*/
private applyStatusFilter(resetPage = false): void {
// The day narrows everything else: the status chips and the severity tiles are asking "of what is in view", and
// an officer who has picked a day expects their counts to describe that day rather than the whole feed.
const byDay = this.allAlerts.filter((a) => this.inActivePeriod(a));
const byStatus = this.activeStatus ? byDay.filter((a) => a.status === 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;
private applyStatusFilter(): void {
this.alerts.data = this.activeStatus
? this.allAlerts.filter((a) => a.status === this.activeStatus)
: this.allAlerts;
if (!this.alerts.paginator && this.paginator) {
this.alerts.paginator = this.paginator;
}
if (resetPage && this.alerts.paginator) {
// Otherwise a filter applied while on page 2 leaves the table blank on a one-page result.
this.alerts.paginator.firstPage();
}
// Counted before the severity filter, so every tile keeps its real figure and stays clickable.
// Counting the filtered rows would zero the other tiles the moment one was picked, leaving no
// way back to them.
this.recomputeRiskSummary(byStatus);
this.recomputeRiskSummary();
}
/**
* Counted over the rows the status filter leaves visible, not the whole set — a summary that
* disagreed with the table underneath it would be worse than no summary.
*/
private recomputeRiskSummary(pool: any[]): void {
private recomputeRiskSummary(): void {
const pool = this.alerts.data;
const tiers = [
{ key: 3, label: 'High Risk', cls: 'risk-high' },
{ key: 2, label: 'Medium Risk', cls: 'risk-medium' },
{ key: 1, label: 'Low Risk', cls: 'risk-low' }
];
this.riskSummary = tiers.map((t) => ({
key: t.key,
label: t.label,
cls: t.cls,
count: pool.filter((a) => 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.
this.unclassifiedCount = pool.filter((a) => this.severityBand(a) === 0).length;
}
/**
* Everything the status filter leaves, whatever severity tile is in force — so the tiles still
* add up to the total while one of them is narrowing the table, and the total reads as the way
* back to all of them.
*/
exporting = false;
/**
* Exports exactly what the table is showing.
*
* <p>
* Built from `alerts.data` rather than from the full feed, so the period, status and severity an officer chose are
* the period, status and severity in the file. A workbook that quietly held more than the screen did would be
* worse than none: the figures in it would not reconcile with the report it came from, and an officer sending it
* on has no way to tell.
*
* <p>
* The filters are written into the subtitle and the file name for the same reason — a sheet that says "10 alerts"
* with nothing about which month it covers cannot be checked later by anyone, including the person who made it.
*/
async exportExcel(): Promise<void> {
const rows = this.alerts.data;
if (!rows.length || this.exporting) {
return;
}
this.exporting = true;
try {
const scope = [
this.periodLabel ? `Period: ${this.periodLabel}` : 'Period: all time',
`Status: ${this.activeStatus ? this.statusLabel(this.activeStatus) : 'all'}`,
`Severity: ${this.activeSeverity === null ? 'all' : this.severityLabel(this.activeSeverity)}`,
`Rules: ${this.topRules === null ? 'all' : `top ${this.topRules} by frequency`}`
].join(' · ');
await exportWorkbook(
[
{
sheetName: 'Flagged Transactions',
title: 'AML — Flagged Transactions',
subtitle: `${scope} · ${rows.length} alert${rows.length === 1 ? '' : 's'} · generated ${new Date().toLocaleString('en-IN')}`,
headers: [
'Alert ID',
'Raised On',
'Customer',
'Customer ID',
'Transaction',
'Txn ID',
'Governing Rule',
'Rule Description',
'All Rules Matched',
'Severity',
'Amount',
'Status',
'Reviewed By',
'Review Notes'
],
rows: rows.map((a) => [
a.id ?? null,
a.raisedOnDate ? a.raisedOnDate.toLocaleString('en-IN') : '',
a.clientDisplayName || '',
a.clientId ?? null,
a.transactionType || '',
a.transactionId ?? null,
a.ruleCode || '',
a.ruleDescription || '',
// Every rule the transaction tripped, not only the one it reports under: the governing rule alone can
// hide a high-severity finding that a reader of the sheet would need.
this.matchedRules(a)
.map((r) => r.ruleCode)
.join(', '),
this.severityLabel(this.severityBand(a)),
Number(a.amount) || 0,
a.status || '',
a.reviewedByUsername || '',
a.reviewNotes || ''
]),
widths: [
9,
20,
24,
12,
12,
10,
30,
38,
44,
11,
15,
14,
14,
50
]
}
],
`AML-Flagged-${this.periodLabel || 'all-time'}`
);
} finally {
this.exporting = false;
}
}
private statusLabel(key: string): string {
return this.statusFilters.find((s) => s.key === key)?.label ?? key;
// Anything outside 1-3, including null, so the tiles always reconcile to the total.
this.unclassifiedCount = pool.filter(
(a) => ![
1,
2,
3
].includes(Number(a.riskCategory))
).length;
}
get summaryTotal(): number {
const pool = this.allAlerts.filter((a) => this.inActivePeriod(a));
return this.activeStatus ? pool.filter((a) => a.status === this.activeStatus).length : pool.length;
return this.alerts.data.length;
}
private recomputeStatusCounts(): void {
// Counted within the chosen day, so "Escalated 1" means one escalated that day rather than one in the whole feed.
// A chip whose figure describes a different set from the table under it is worse than no figure.
const pool = this.allAlerts.filter((a) => this.inActivePeriod(a));
this.statusCounts = { '': pool.length };
this.statusCounts = { '': this.allAlerts.length };
for (const s of this.statusFilters) {
if (s.key) {
this.statusCounts[s.key] = pool.filter((a) => a.status === s.key).length;
this.statusCounts[s.key] = this.allAlerts.filter((a) => a.status === s.key).length;
}
}
}
@@ -976,26 +302,7 @@ export class AmlFlaggedTransactionsComponent implements OnInit, AfterViewInit, O
* opening a second STR case — this gate is what prevents it.
*/
canReview(alert: any): boolean {
if (alert?.status !== 'RAISED' && alert?.status !== 'UNDER_REVIEW') {
return false;
}
// An alert another officer has open is theirs until they decide it or hand it back. The endpoint refuses it
// anyway; hiding the buttons is what stops an officer working a case they will not be allowed to close.
return !this.heldByAnother(alert);
}
/** True when this alert is under review by somebody other than the signed-in officer. */
heldByAnother(alert: any): boolean {
return (
alert?.status === 'UNDER_REVIEW' &&
!!alert?.reviewedByUsername &&
alert.reviewedByUsername !== this.currentUsername
);
}
/** Who has the alert, for the message shown in place of the action buttons. */
heldBy(alert: any): string {
return alert?.reviewedByUsername || 'another officer';
return alert?.status === 'RAISED' || alert?.status === 'UNDER_REVIEW';
}
review(alert: any, disposition: 'WHITELIST' | 'ESCALATE'): void {
@@ -1023,73 +330,4 @@ export class AmlFlaggedTransactionsComponent implements OnInit, AfterViewInit, O
this.fetch();
});
}
/**
* Applies one disposition to every ticked alert.
*
* <p>
* Each still goes through the review dialog's own requirements - a reason, and for a whitelist
* the evidence behind it - asked once and recorded against every alert in the batch. That is the
* point of the batch: the officer is making one decision about one kind of activity, not the same
* decision twenty times.
*/
/**
* Applies one disposition to the rules ticked inside the open pane.
*
* <p>
* The alert's own status is left to the server, which derives it from what remains: whitelisting five of six rules
* leaves the case open on the sixth rather than closing it.
*/
reviewSelectedRules(alert: any, disposition: 'WHITELIST' | 'ESCALATE'): void {
const codes = Array.from(this.selectedRuleCodes);
if (!codes.length) {
return;
}
this.dialog
.open(AmlReviewAlertDialogComponent, {
width: '560px',
maxWidth: '95vw',
autoFocus: false,
disableClose: true,
data: { alert, disposition, ruleCodes: codes }
})
.afterClosed()
.subscribe((result: string | undefined) => {
if (!result) {
return;
}
this.alertService.alert({
type: disposition === 'WHITELIST' ? 'Rules Whitelisted' : 'Rules Escalated',
message: `${codes.length} rule${codes.length > 1 ? 's' : ''} on ${alert.transactionType} #${alert.transactionId} ${
disposition === 'WHITELIST' ? 'cleared' : 'escalated'
}.`
});
this.selectedRuleCodes.clear();
// The alert's status may have changed with the last rule decided, so re-read rather than patching locally.
this.fetch();
});
}
reviewSelected(disposition: 'WHITELIST' | 'ESCALATE'): void {
const batch = this.selectedAlerts();
if (!batch.length) {
return;
}
this.dialog
.open(AmlBulkReviewDialogComponent, {
width: '620px',
maxWidth: '95vw',
autoFocus: false,
disableClose: true,
data: { alerts: batch, disposition }
})
.afterClosed()
.subscribe((done: boolean | undefined) => {
if (!done) {
return;
}
this.selectedIds.clear();
this.fetch();
});
}
}
@@ -31,11 +31,6 @@ import { AmlService } from '../../../aml/aml.service';
export interface AmlReviewDialogData {
alert: any;
disposition: 'WHITELIST' | 'ESCALATE';
/**
* The rules being decided, when the officer picked some inside the expanded pane rather than acting on the whole
* transaction. Absent means the decision covers the alert and every rule under it.
*/
ruleCodes?: string[];
}
/**
@@ -167,13 +162,6 @@ export class AmlReviewAlertDialogComponent {
}
get title(): string {
// Named differently when only some of the transaction's rules are being decided, so the officer is not told they
// are clearing a transaction when five of its six rules stay open.
const n = this.data.ruleCodes?.length ?? 0;
if (n > 0) {
const subject = n === 1 ? 'Rule' : `${n} Rules`;
return this.isWhitelist ? `Whitelist ${subject}` : `Mark ${subject} as Suspicious`;
}
return this.isWhitelist ? 'Whitelist Transaction' : 'Mark as Suspicious';
}
@@ -193,17 +181,7 @@ export class AmlReviewAlertDialogComponent {
this.submitting = true;
this.errorMessage = '';
// Deciding named rules within the alert rather than the whole alert, when the caller passed some. The evidence
// requirements are the same either way, which is why this dialog serves both.
const request = this.data.ruleCodes?.length
? this.amlService.reviewAlertRules(
this.data.alert.id,
this.data.ruleCodes,
this.data.disposition,
this.composedNotes
)
: this.amlService.reviewAlert(this.data.alert.id, this.data.disposition, this.composedNotes);
request.subscribe({
this.amlService.reviewAlert(this.data.alert.id, this.data.disposition, this.composedNotes).subscribe({
next: () => {
this.submitting = false;
this.dialogRef.close(this.data.disposition);
@@ -93,11 +93,6 @@ const routes: Routes = [
loadComponent: () =>
import('../tds-reports/tds-remittance-report.component').then((m) => m.TdsRemittanceReportComponent)
},
{
path: 'bor',
data: { title: 'BOR Register — Share Capital', breadcrumb: 'BOR Register' },
loadComponent: () => import('../board-resolution/bor-report.component').then((m) => m.BorReportComponent)
},
{
path: ':filter',
data: { routeParamBreadcrumb: 'filter' },
@@ -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">&rarr;</span>
<strong>{{ labelOf(r.requestedCategory) }}</strong>
</span>
</td>
<td class="rca-reason">{{ r.reason }}</td>
<td>
<span class="rca-status" [ngClass]="'rca-status--' + (r.status || '').toLowerCase()">{{
r.status
}}</span>
</td>
<td>{{ r.makerUsername }}</td>
<td class="mono">{{ r.createdDate }}</td>
<td>
<div class="rca-actions" *ngIf="r.status === 'PENDING'">
<button mat-flat-button color="primary" type="button" (click)="approve(r)">Approve</button>
<button mat-stroked-button color="warn" type="button" (click)="reject(r)">Reject</button>
</div>
<span class="rca-done" *ngIf="r.status === 'APPROVED'">
by {{ r.checkerUsername }}, {{ r.checkerDate }}
</span>
<span class="rca-done" *ngIf="r.status === 'REJECTED'">{{ r.rejectionReason }}</span>
</td>
</tr>
<tr *ngIf="!filtered.length">
<td colspan="10" class="rca-empty">No risk category change matches this filter.</td>
</tr>
</tbody>
</table>
</div>
</div>
`,
styles: [
`
.rca-root {
padding: 20px 24px 40px;
background: #f8fafc;
min-height: 100%;
}
.rca-head {
margin-bottom: 16px;
}
.rca-title {
margin: 0 0 3px;
font-size: 20px;
font-weight: 700;
color: #1e293b;
}
.rca-sub {
margin: 0;
max-width: 800px;
font-size: 13px;
line-height: 1.5;
color: #64748b;
}
.rca-filters {
display: flex;
gap: 8px;
margin-bottom: 14px;
flex-wrap: wrap;
}
.rca-chip {
height: 32px;
padding: 0 14px;
border: 1px solid #cbd5e1;
border-radius: 999px;
background: #fff;
font: inherit;
font-size: 12.5px;
font-weight: 600;
color: #334155;
cursor: pointer;
}
.rca-chip--on {
border-color: #1e3a8a;
background: #eff6ff;
color: #1e3a8a;
}
.rca-count {
color: #94a3b8;
font-weight: 500;
}
.rca-table-wrap {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 11px;
overflow-x: auto;
}
.rca-table {
width: 100%;
border-collapse: collapse;
font-size: 12.5px;
}
.rca-table th {
text-align: left;
padding: 10px;
background: #f1f5f9;
font-size: 10.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #475569;
white-space: nowrap;
border-bottom: 1px solid #e2e8f0;
}
.rca-table td {
padding: 11px 10px;
border-bottom: 1px solid #f1f5f9;
vertical-align: middle;
color: #334155;
}
.mono {
font-family: 'DM Mono', monospace;
}
.rca-move {
white-space: nowrap;
}
.rca-arrow {
color: #94a3b8;
margin: 0 4px;
}
.rca-reason {
max-width: 280px;
}
.rca-status {
display: inline-block;
padding: 2px 9px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
background: #f1f5f9;
color: #475569;
}
.rca-status--pending {
background: #fef3c7;
color: #b45309;
}
.rca-status--approved {
background: #dcfce7;
color: #15803d;
}
.rca-status--rejected {
background: #fee2e2;
color: #b91c1c;
}
.rca-actions {
display: flex;
gap: 8px;
}
.rca-done {
font-size: 11.5px;
color: #64748b;
}
.rca-empty {
padding: 30px;
text-align: center;
color: #94a3b8;
}
`
]
})
export class RiskChangeApprovalsComponent implements OnInit {
private amlService = inject(AmlService);
private dialog = inject(MatDialog);
private snackBar = inject(MatSnackBar);
rows: any[] = [];
readonly statuses = [
'PENDING',
'APPROVED',
'REJECTED',
''
];
selectedStatus = 'PENDING';
ngOnInit(): void {
this.load();
}
load(): void {
this.amlService.getRiskChangeRequests().subscribe({
next: (rows) => (this.rows = rows || []),
error: (err: any) =>
this.snackBar.open('Could not load requests: ' + (err?.error?.defaultUserMessage || err.message), 'Close', {
duration: 5000
})
});
}
get filtered(): any[] {
return this.selectedStatus ? this.rows.filter((r) => r.status === this.selectedStatus) : this.rows;
}
countOf(status: string): number {
return status ? this.rows.filter((r) => r.status === status).length : this.rows.length;
}
filterBy(status: string): void {
this.selectedStatus = status;
}
labelOf(category: number | null): string {
return category == null ? 'Unrated' : CATEGORY_LABEL[category] || '—';
}
approve(r: any): void {
this.amlService.approveRiskChange(r.id).subscribe({
next: () => {
this.snackBar.open(
`${r.clientName} moved to ${this.labelOf(r.requestedCategory)} — re-KYC due date updated.`,
'Close',
{ duration: 6000 }
);
this.load();
},
error: (err: any) =>
this.snackBar.open('Error: ' + (err?.error?.defaultUserMessage || err.message), 'Close', { duration: 7000 })
});
}
reject(r: any): void {
this.dialog
.open(RejectRiskChangeDialogComponent, { width: '450px', data: { request: r }, disableClose: true })
.afterClosed()
.subscribe((done) => {
if (done) {
this.load();
}
});
}
}
// ── REJECT DIALOG ──
@Component({
selector: 'mifosx-reject-risk-change-dialog',
standalone: true,
imports: [
CommonModule,
ReactiveFormsModule,
MatDialogModule,
MatFormFieldModule,
MatInputModule,
MatButtonModule
],
template: `
<h2 mat-dialog-title>Reject Risk Category Change</h2>
<mat-dialog-content>
<p class="hint">
{{ data.request.clientName }} stays on their current rating. The reason is shown to the reviewer who raised it.
</p>
<form [formGroup]="form">
<mat-form-field appearance="outline" class="full">
<mat-label>Rejection reason</mat-label>
<textarea matInput formControlName="rejectionReason" rows="3"></textarea>
<mat-error *ngIf="form.get('rejectionReason')?.hasError('required')">A reason is required</mat-error>
</mat-form-field>
</form>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-stroked-button type="button" (click)="dialogRef.close(false)">Cancel</button>
<button mat-flat-button color="warn" type="button" [disabled]="form.invalid || busy" (click)="submit()">
Reject
</button>
</mat-dialog-actions>
`,
styles: [
`
.full {
width: 100%;
}
.hint {
font-size: 13px;
color: #64748b;
margin: 0 0 12px;
line-height: 1.5;
}
`
]
})
export class RejectRiskChangeDialogComponent {
private fb = inject(FormBuilder);
private amlService = inject(AmlService);
private snackBar = inject(MatSnackBar);
dialogRef = inject(MatDialogRef<RejectRiskChangeDialogComponent>);
data: { request: any } = inject(MAT_DIALOG_DATA);
form: FormGroup;
busy = false;
constructor() {
this.form = this.fb.group({ rejectionReason: [
'',
Validators.required
] });
}
submit(): void {
if (this.form.invalid) {
return;
}
this.busy = true;
this.amlService.rejectRiskChange(this.data.request.id, this.form.value.rejectionReason).subscribe({
next: () => {
this.busy = false;
this.snackBar.open('Risk category change rejected', 'Close', { duration: 3000 });
this.dialogRef.close(true);
},
error: (err: any) => {
this.busy = false;
this.snackBar.open('Error: ' + (err?.error?.defaultUserMessage || err.message), 'Close', { duration: 6000 });
}
});
}
}
@@ -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.';
}
});
}
}
+4 -20
View File
@@ -372,29 +372,13 @@ export const MATERIALITY = {
* Rules whose seriousness does not depend on the amount. A sanctions match or a
* deliberate structuring pattern is grave at any value, so activity being small
* is not a reason to bring the rating down.
*
* Matched as prefixes, not as whole codes. The rule set names these by variant —
* STRUCTURING_BELOW_50K, STRUCTURING_BELOW_10L, PEP_ACCOUNT_MONITORING — so an
* exact-match Set never matched any of them and the protection was dead: a
* customer caught structuring could be recommended for downgrade purely because
* the amounts involved were small, which is the one case structuring exists to
* catch.
*/
export const NON_MITIGABLE_RULE_PREFIXES = [
'SANCTIONS',
export const NON_MITIGABLE_RULES = new Set([
'SANCTIONS_HIT',
'PEP',
'STRUCTURING',
'STRUCTURED',
'HIGH_RISK_COUNTRY',
'ADVERSE_MEDIA',
'WILFUL_DEFAULTER'
];
/** Whether a rule code names activity that stays grave however small the amounts are. */
export function isNonMitigableRule(ruleCode: string): boolean {
const code = (ruleCode || '').toUpperCase();
return NON_MITIGABLE_RULE_PREFIXES.some((prefix) => code.startsWith(prefix));
}
'HIGH_RISK_COUNTRY'
]);
/** Shape of one element of GET /aml/alerts. */
export interface LiveAmlAlert {
@@ -304,9 +304,7 @@
reason sits under it rather than under the button, because it explains
this conclusion and not the act of opening the KYC screen. -->
<td class="rr-move-cell">
<!-- An unrated customer is its own state: no rating exists, so "same" would be claiming the
rating on file matches the behaviour when there is no rating on file. -->
<span class="rr-move" [class]="'rr-move--' + (c.currentRisk ? c.direction : 'unrated')">
<span class="rr-move" [class]="'rr-move--' + c.direction">
<fa-icon [icon]="directionIcon(c)"></fa-icon>
{{ directionText(c) }}
</span>
@@ -325,32 +323,15 @@
<!-- Only the control. The verdict and its reason moved to their own column,
so this no longer wraps to two lines under the button. -->
<td class="rr-action-cell">
<!-- Raises the change for approval rather than sending the reviewer to the
KYC screen to make it themselves. A case with the Principal Officer is
held where it is until the STR concludes, so it only offers the record. -->
@if (canChangeRisk(c)) {
<button
type="button"
class="rr-kyc-link"
[class.rr-kyc-link--act]="c.direction !== 'same'"
(click)="changeRisk(c, $event)"
[attr.aria-label]="'Change risk category for ' + c.customerName"
>
<fa-icon icon="shield-alt"></fa-icon> Change Risk
</button>
} @else {
<!-- The rating cannot move while the STR is open, so the case itself is
what this row can usefully offer. -->
<button
type="button"
class="rr-kyc-link"
(click)="openStrCase(c, $event)"
title="The rating is held until the Principal Officer concludes the STR — open the case"
[attr.aria-label]="'Open the STR case for ' + c.customerName"
>
<fa-icon icon="exclamation-triangle"></fa-icon> Open STR Case
</button>
}
<button
type="button"
class="rr-kyc-link"
[class.rr-kyc-link--act]="c.direction !== 'same' && !c.escalated"
(click)="openKyc(c, $event)"
[attr.aria-label]="kycActionText(c) + ' for ' + c.customerName"
>
<fa-icon icon="id-card"></fa-icon> {{ kycActionText(c) }}
</button>
</td>
</tr>
@@ -937,13 +937,6 @@
background: var(--r-g100);
color: var(--r-g500);
}
/* An unrated customer reads neither as settled nor as a movement: there is no rating to move. Amber says the row
is outstanding without claiming the customer is a concern — the gap is the bank's, not theirs. */
&--unrated {
background: var(--r-amber-l, #fef3c7);
color: var(--r-amber, #b45309);
}
}
.rr-move-why {
@@ -10,7 +10,6 @@
import { Component, OnInit, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { MatDialog } from '@angular/material/dialog';
import { forkJoin, of } from 'rxjs';
import { catchError, map } from 'rxjs/operators';
@@ -39,14 +38,12 @@ import {
RiskLevel,
channelLabel,
mapLiveAlert,
riskFromCategory,
riskRank
riskFromCategory
} from './risk-review-data';
import {
DateRange,
RatingDirection,
RiskAlertView,
BehaviourWindowByClient,
RiskCustomerRow,
RiskReviewFilter,
RiskReviewModel,
@@ -56,7 +53,6 @@ import {
monthRange,
weekRange
} from './risk-review.compute';
import { RiskChangeDialogComponent, RiskChangeDialogData } from './risk-change-dialog.component';
type PeriodMode = 'day' | 'week' | 'month' | 'range';
type ViewMode = 'customer' | 'alert' | 'txn';
@@ -113,7 +109,6 @@ export class RiskReviewReportComponent implements OnInit {
private clients = inject(ClientsService);
private scope = inject(BranchScopeService);
private router = inject(Router);
private dialog = inject(MatDialog);
readonly period = GL_PERIOD;
readonly branches = GL_BRANCHES;
@@ -186,37 +181,21 @@ export class RiskReviewReportComponent implements OnInit {
// ── Loading ────────────────────────────────────────────────────────────────
/**
* The nightly job's verdict per customer, keyed on client id.
*
* Empty until the first load, and stays empty on a tenant where the job has not run - the report then falls back to
* its own per-alert reckoning rather than showing nothing.
*/
private behaviourWindows: BehaviourWindowByClient = {};
load(): void {
this.loading = true;
// Both in one pass: the windows decide what each row recommends, so composing before they arrive would show a
// recommendation the screen then silently replaced.
forkJoin({
alerts: this.aml.getAlerts().pipe(catchError(() => of([] as LiveAmlAlert[]))),
windows: this.aml.getBehaviourWindows().pipe(catchError(() => of([] as any[])))
}).subscribe(({ alerts: live, windows }) => {
this.behaviourWindows = {};
for (const w of windows || []) {
if (w?.clientId != null) {
this.behaviourWindows[w.clientId] = w;
this.aml
.getAlerts()
.pipe(catchError(() => of([] as LiveAmlAlert[])))
.subscribe((live) => {
const rows = (live || []).map((a) => mapLiveAlert(a as unknown as LiveAmlAlert));
this.liveAlerts = rows;
this.compose();
if (!rows.length) {
this.loading = false;
return;
}
}
const rows = (live || []).map((a) => mapLiveAlert(a as unknown as LiveAmlAlert));
this.liveAlerts = rows;
this.compose();
if (!rows.length) {
this.loading = false;
return;
}
this.enrichCurrentRisk(rows);
});
this.enrichCurrentRisk(rows);
});
}
/**
@@ -264,12 +243,7 @@ export class RiskReviewReportComponent implements OnInit {
)
)
).subscribe((results) => {
const byClient = new Map(
results.map((r) => [
r.id,
r
])
);
const byClient = new Map(results.map((r) => [r.id, r]));
this.liveAlerts = rows.map((r) => {
const info = byClient.get(r.clientId);
return {
@@ -362,11 +336,9 @@ export class RiskReviewReportComponent implements OnInit {
private memo: RiskReviewModel = { alerts: [], customers: [], txns: [], summary: EMPTY_SUMMARY };
private get model(): RiskReviewModel {
// The window count belongs in the key: the windows decide what each row recommends, and they arrive in the same
// load as the alerts. Keying on the alerts alone would serve the memo computed before they landed.
const key = JSON.stringify(this.filter) + `|${this.alerts.length}|${Object.keys(this.behaviourWindows).length}`;
const key = JSON.stringify(this.filter) + `|${this.alerts.length}`;
if (key !== this.memoKey) {
this.memo = buildRiskReview(this.alerts, this.filter, this.behaviourWindows);
this.memo = buildRiskReview(this.alerts, this.filter);
this.memoKey = key;
}
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
* only a correction, and matching ratings need nothing at all.
*/
/**
* A report row for a customer the behaviour review named but no alert brought onto the report.
*
* <p>
* The alert-derived fields are empty because there are none in the window, and that is the point being shown: the
* case rests on the period, not on anything that fired. The figures the verdict was drawn from travel on
* behaviourWindow, so the row can still explain itself.
*/
private rowFromWindow(w: any): RiskCustomerRow {
const RANK: Record<number, RiskLevel> = { 1: 'LOW', 2: 'MEDIUM', 3: 'HIGH' };
const current = RANK[w.riskCategoryAtReview] ?? null;
const recommended = RANK[w.suggestedCategory] ?? current ?? 'LOW';
return {
clientId: w.clientId,
customerName: w.clientName || `Client ${w.clientId}`,
accountNo: w.accountNo || '',
branchCode: w.officeName || '',
currentRisk: current,
alerts: [],
alertCount: 0,
totalAmount: Number(w.creditTotal || 0) + Number(w.debitTotal || 0),
largestAmount: Number(w.largestTxn || 0),
proposedRisk: recommended,
recommendedRisk: recommended,
direction: w.suggestedDirection === 'UP' ? 'up' : w.suggestedDirection === 'DOWN' ? 'down' : 'same',
directionReason: w.suggestionReason || '',
ruleLabels: [],
firstAlertDate: '',
lastAlertDate: '',
openCount: 0,
escalated: false,
needsUpgrade: w.suggestedDirection === 'UP',
needsDowngrade: w.suggestedDirection === 'DOWN',
behaviourWindow: w
};
}
get customerGroups(): RiskCustomerGroup[] {
const rows = this.customerRows;
// An escalated customer whose rating still has to move belongs with the work, not with the cases that are waiting
// on someone else. An open STR holds a rating at High, and if the customer is not yet at High that is a change
// somebody has to apply - filing the STR does not make it. Only a customer whose rating already matches sits under
// the Principal Officer with nothing to do.
const escalated = rows.filter((c) => c.escalated && c.direction === 'same');
const rest = rows.filter((c) => !(c.escalated && c.direction === 'same'));
// Customers the nightly review says to move who raised no alert inside the filter window.
//
// The report is built from alerts, so a customer appears only once a rule has fired on them recently. That is the
// wrong test for a downgrade: the whole case for lowering a rating is that nothing has fired for a year, so the
// customer with the strongest case is precisely the one the alert feed has stopped mentioning. Without this they
// would sit in the behaviour table with a verdict nobody ever sees.
const onReport = new Set(rows.map((r) => r.clientId));
const offReport: RiskCustomerRow[] = Object.values(this.behaviourWindows)
.filter((w: any) => w?.clientId != null && !onReport.has(w.clientId))
.filter((w: any) => w.suggestedDirection === 'UP' || w.suggestedDirection === 'DOWN')
.map((w: any) => this.rowFromWindow(w));
const escalated = rows.filter((c) => c.escalated);
const rest = rows.filter((c) => !c.escalated);
const groups: RiskCustomerGroup[] = [
{
@@ -459,42 +378,25 @@ export class RiskReviewReportComponent implements OnInit {
rows: escalated
},
{
// Raising and lowering are one section, not two. The heading answers "is there anything to do about this
// customer", and which way the rating moves is already on every row - as a verdict, an arrow and a reason.
// Splitting them put the same answer in two places and left an officer reading two lists to find one day's
// work. Raises sort first within the section, because an under-rated customer is the more urgent gap.
key: 'change',
title: 'Change needed',
hint: 'Every condition for moving the rating is met. Apply the change; it goes to a checker for approval.',
key: 'up',
title: 'Rating needs raising',
hint: 'The rules point higher than the rating on file. These are under-rated customers — the real gap.',
tone: 'up',
rows: [
...rest.filter((c) => c.currentRisk && c.direction === 'up'),
...offReport.filter((c) => c.direction === 'up'),
...rest.filter((c) => c.currentRisk && c.direction === 'down'),
...offReport.filter((c) => c.direction === 'down')
]
rows: rest.filter((c) => c.direction === 'up')
},
{
// Its own section, and above the watching list, because it is the opposite of that. An unrated customer whose
// money is moving has no rating for the behaviour to be compared against, so nothing here can say whether it
// is in keeping - the work is to give them one, and grouping them with the customers nothing need be done
// about buried the one action the report was asking for.
key: 'unrated',
title: 'Update risk',
hint: 'These customers are transacting with no rating on file. Nothing can be judged against a rating that does not exist — rate them first.',
tone: 'up',
rows: rest.filter((c) => !c.currentRisk)
key: 'down',
title: 'Rating can be lowered',
hint: 'Activity no longer supports the rating on file.',
tone: 'down',
rows: rest.filter((c) => c.direction === 'down')
},
{
// Everything an alert put on the report that the policy will not move yet. "No change needed" read as though
// these customers were fine, which is not what it means: a customer with four open alerts sits here, and the
// reason is that the period does not yet carry enough to act on - not that there is nothing to look at.
// "Watching" says what the section is for, and the reason on each row says which condition fell short.
key: 'same',
title: 'Watching — not enough to act on',
hint: 'In view, but the period does not yet support moving the rating. The reason on each row says what is missing.',
title: 'No change needed',
hint: 'The rating on file already matches the behaviour. Listed for the record.',
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.
*
@@ -748,41 +589,25 @@ export class RiskReviewReportComponent implements OnInit {
* change nobody is authorised to make yet.
*/
directionText(c: RiskCustomerRow): string {
// Escalation is only the whole story when the rating already matches. An STR holds a customer at High, so one
// filed against a Medium-rated customer leaves a rating that still has to be moved - saying "held" there reports
// the case and hides the work, which is how this row came to sit under "Change needed" announcing that nothing
// needed doing.
if (c.escalated && c.direction === 'same') {
if (c.escalated) {
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
// officer joining it to the rating column to see what is actually changing; "High → Medium" is the whole fact in
// one glance, and on an exported sheet it still says what happened without the column beside it.
if (c.direction === 'up' || c.direction === 'down') {
const from = c.currentRisk ? RISK_LABEL[c.currentRisk] : 'Unrated';
return `${from} → ${RISK_LABEL[c.recommendedRisk]}`;
if (c.direction === 'up') {
return `Raise to ${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
// there, which on a worklist reads as though the row needs nothing - and it is in fact the row that needs the most.
return c.currentRisk ? `Keep at ${RISK_LABEL[c.currentRisk]}` : 'Rate this customer';
if (c.direction === 'down') {
return `Lower to ${RISK_LABEL[c.recommendedRisk]}`;
}
return c.currentRisk ? `Keep at ${RISK_LABEL[c.currentRisk]}` : 'No rating on record';
}
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
// rating still has to be moved.
if (c.escalated && c.direction === 'same') {
if (c.escalated) {
return 'lock';
}
if (c.direction === 'up') {
return 'arrow-up';
}
if (c.direction === 'down') {
return 'arrow-down';
}
// A tick claims the row is settled. An unrated customer is not settled, so it gets the mark that asks a question
// rather than the one that closes it. 'exclamation-circle' rather than plain 'exclamation': only the registered
// names resolve, and an unregistered icon throws at render and takes the whole row down with it.
return c.currentRisk ? 'check' : 'exclamation-circle';
// 'check' rather than an equals sign: faEquals is not in the shared library, and an
// unregistered icon throws at render and takes the whole row down with it.
return c.direction === 'up' ? 'arrow-up' : c.direction === 'down' ? 'arrow-down' : 'check';
}
/**
+18 -143
View File
@@ -10,7 +10,7 @@ import {
AmlRuleMeta,
CTR_THRESHOLD,
MATERIALITY,
isNonMitigableRule,
NON_MITIGABLE_RULES,
REVIEW_STATUS_META,
RISK_LABEL,
RiskAlertRow,
@@ -26,17 +26,6 @@ export type RatingDirection = 'up' | 'down' | 'same';
const RANK_TO_LEVEL: Record<number, RiskLevel> = { 1: 'LOW', 2: 'MEDIUM', 3: 'HIGH' };
/**
* What has to have been seen before this screen's own reckoning will argue for lowering a rating.
*
* The nightly behaviour review is the real test — it reads a 270-day window, counts transactions rather than alerts,
* and applies the blocks. These are the floor for the fallback used until that job has run: deliberately modest,
* because the fallback sees only the alerts inside the report's date filter, but not absent, because judging on
* amounts alone let three small deposits in one month argue for taking a customer off Medium.
*/
const MIN_ALERTS_TO_LOWER = 5;
const MIN_MONTHS_TO_LOWER = 3;
export interface DateRange {
from: string;
to: string;
@@ -120,8 +109,6 @@ export interface RiskCustomerRow {
needsUpgrade: boolean;
/** True when the rating on record is higher than the activity warrants. */
needsDowngrade: boolean;
/** The review period the recommendation was drawn from, where the nightly job has computed one. */
behaviourWindow?: BehaviourWindowByClient[number] | null;
}
export interface RiskReviewSummary {
@@ -168,26 +155,17 @@ function recommendRating(
largestAmount: number,
totalAmount: number,
escalated: boolean,
ruleCodes: string[],
alertCount: number,
distinctMonths: number
ruleCodes: string[]
): { risk: RiskLevel; reason: string } {
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;
// 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;
const small = largestAmount < MATERIALITY.smallTxn && totalAmount < MATERIALITY.smallTotal;
// What the rating should be. Amounts only ever pull the rules' bound down, and
// only where nothing weightier holds it up.
let risk: RiskLevel;
let basis: 'escalated' | 'grave' | 'material' | 'small' | 'moderate' | 'thin';
let basis: 'escalated' | 'grave' | 'material' | 'small' | 'moderate';
if (escalated) {
risk = 'HIGH';
basis = 'escalated';
@@ -197,12 +175,6 @@ function recommendRating(
} else if (material) {
risk = implied;
basis = 'material';
} else if (!enoughActivity) {
// Too little observed to say anything about the rating either way, so it stands. Stating this rather than
// quietly leaving the rating alone is the point: an officer reading the row learns the period was too thin,
// not that the customer was examined and found unremarkable.
risk = currentRisk ?? implied;
basis = 'thin';
} else {
risk = RANK_TO_LEVEL[Math.min(impliedRank, small ? 1 : 2)] ?? 'LOW';
basis = small ? 'small' : 'moderate';
@@ -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
// read as "not assessed yet" on screen, which is the opposite of what the review concluded.
return {
@@ -320,36 +271,7 @@ export function riskAlertViews(alerts: RiskAlertRow[], filter: RiskReviewFilter)
* escalated, then rated below what the rules point at, then most alerts —
* come to the top of the worklist.
*/
/**
* 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[] {
export function riskCustomerRows(views: RiskAlertView[]): RiskCustomerRow[] {
const byClient = new Map<number, RiskAlertView[]>();
for (const v of views) {
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 escalated = alerts.some((a) => a.status === 'ESCALATED_STR' || a.status === 'STR_FILED');
// The server's own reckoning, where the nightly job has produced one.
//
// It is the better answer, and not because it is newer: it is drawn from a review period rather than from the
// alerts on this screen. A customer appears here only once a rule has fired, so judging them on those alerts
// alone describes the exceptions and never the ordinary months around them - which is exactly what a rating is
// supposed to reflect. The job also tests the things this screen cannot see at all: how many separate months the
// activity falls in, how long the customer has held the rating, whether an STR inside the block window forbids
// lowering it.
//
// Without a window - a tenant where the job has not run, or a customer it has not reached - the per-alert
// reckoning below still stands, so the report never renders blank while the two are in step.
// A window is only good for as long as the rating it was computed against. The job takes a snapshot each night
// and records the rating the customer held at the time; once an officer moves that rating the snapshot describes
// a customer who no longer exists, and the screen would go on asking for a change that has already been applied
// and approved. Dropping it here falls back to the per-alert reckoning until tonight's job catches up.
const raw = windows[clientId];
const staleWindow =
raw?.riskCategoryAtReview != null &&
currentRisk != null &&
RANK_TO_LEVEL[raw.riskCategoryAtReview] !== currentRisk;
const window = staleWindow ? undefined : raw;
const recommendation = window?.suggestedDirection
? {
risk: RANK_TO_LEVEL[window.suggestedCategory ?? 0] ?? currentRisk ?? 'LOW',
reason: window.suggestionReason ?? ''
}
: recommendRating(
proposedRank,
currentRisk,
largestAmount,
totalAmount,
escalated,
alerts.map((a) => a.ruleCode),
alerts.length,
// Distinct calendar months the alerts fall in. Count alone does not establish a period: five alerts in one
// week is a busy fortnight, not five months of steady conduct.
new Set(alerts.map((a) => a.alertDate.slice(0, 7))).size
);
const recommendation = recommendRating(
proposedRank,
currentRisk,
largestAmount,
totalAmount,
escalated,
alerts.map((a) => a.ruleCode)
);
// Where the customer carries no rating there is nothing to move, so an
// unrated customer is never counted as an upgrade or a downgrade.
const delta = currentRisk ? riskRank(recommendation.risk) - riskRank(currentRisk) : 0;
const direction: RatingDirection = window?.suggestedDirection
? window.suggestedDirection === 'UP'
? 'up'
: window.suggestedDirection === 'DOWN'
? 'down'
: 'same'
: delta > 0
? 'up'
: delta < 0
? 'down'
: 'same';
const direction: RatingDirection = delta > 0 ? 'up' : delta < 0 ? 'down' : 'same';
rows.push({
clientId,
@@ -450,10 +332,7 @@ export function riskCustomerRows(views: RiskAlertView[], windows: BehaviourWindo
openCount: alerts.filter((a) => REVIEW_STATUS_META[a.status].open).length,
escalated,
needsUpgrade: direction === 'up',
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
needsDowngrade: direction === 'down'
});
}
@@ -582,13 +461,9 @@ export interface RiskReviewModel {
* the summary all run once per filter change rather than once per template
* binding, which matters because every view reads from the same result.
*/
export function buildRiskReview(
alerts: RiskAlertRow[],
filter: RiskReviewFilter,
windows: BehaviourWindowByClient = {}
): RiskReviewModel {
export function buildRiskReview(alerts: RiskAlertRow[], filter: RiskReviewFilter): RiskReviewModel {
const base = riskAlertViews(alerts, filter);
const narrowed = applyDirectionFilter(base, riskCustomerRows(base, windows), filter.direction);
const narrowed = applyDirectionFilter(base, riskCustomerRows(base), filter.direction);
return {
alerts: narrowed.views,
-2
View File
@@ -56,7 +56,6 @@ import {
faGavel,
faGlobe,
faHandHoldingUsd,
faHandPaper,
faHome,
faKey,
faLink,
@@ -304,7 +303,6 @@ export class IconsModule {
faHandHoldingHand,
faHandHoldingHeart,
faHandHoldingUsd,
faHandPaper,
faHeart,
faHome,
faKey,
@@ -6,189 +6,112 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="container action-container">
<mat-card class="action-card shadow-sm">
<!-- Compact Header -->
<div class="card-header-banner">
<div class="header-icon-box">
<mat-icon>add_card</mat-icon>
</div>
<div class="header-text font-accent">
<h2>{{ 'Apply Additional Shares' | translate }}</h2>
<p>Purchase additional share tranches via cash receipts or savings debit</p>
</div>
</div>
<div class="container">
<mat-card>
<form [formGroup]="applySharesForm" (ngSubmit)="submit()">
<mat-card-content class="card-body">
<!-- SECTION 1: Application & Resolution Details -->
<div class="form-section">
<div class="section-title">
<mat-icon class="sec-icon">assignment</mat-icon>
<span>Application & Resolution Details</span>
</div>
<mat-card-content>
<div class="layout-column">
<mat-form-field (click)="requestedDatePicker.open()">
<mat-label>{{ 'labels.inputs.Request Date' | translate }}</mat-label>
<input
matInput
[min]="minDate"
[max]="maxDate"
[matDatepicker]="requestedDatePicker"
required
formControlName="requestedDate"
/>
<mat-datepicker-toggle matSuffix [for]="requestedDatePicker"></mat-datepicker-toggle>
<mat-datepicker #requestedDatePicker></mat-datepicker>
@if (applySharesForm.controls.requestedDate.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Request Date' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
<div class="form-grid form-grid-3">
<!-- Request Date -->
<mat-form-field appearance="outline" density="compact" (click)="requestedDatePicker.open()">
<mat-label>{{ 'labels.inputs.Request Date' | translate }}</mat-label>
<input
matInput
[min]="minDate"
[max]="maxDate"
[matDatepicker]="requestedDatePicker"
required
formControlName="requestedDate"
/>
<mat-datepicker-toggle matSuffix [for]="requestedDatePicker"></mat-datepicker-toggle>
<mat-datepicker #requestedDatePicker></mat-datepicker>
@if (applySharesForm.controls.requestedDate.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Request Date' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
<!-- The board resolution authorising this tranche. Its own meeting and number, not the
one the account was opened under, so it is captured per purchase. -->
<mat-form-field>
<mat-label>{{ 'labels.inputs.Resolution Number' | translate }}</mat-label>
<input matInput required formControlName="resolutionNumber" />
@if (applySharesForm.controls.resolutionNumber.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Resolution Number' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
<!-- Resolution Number -->
<mat-form-field appearance="outline" density="compact">
<mat-label>{{ 'labels.inputs.Resolution Number' | translate }}</mat-label>
<input matInput required formControlName="resolutionNumber" placeholder="e.g. RES-2026-08" />
@if (applySharesForm.controls.resolutionNumber.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Resolution Number' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
<mat-form-field (click)="resolutionDatePicker.open()">
<mat-label>{{ 'labels.inputs.Resolution Date' | translate }}</mat-label>
<input
matInput
[min]="minDate"
[max]="maxDate"
[matDatepicker]="resolutionDatePicker"
required
formControlName="resolutionDate"
/>
<mat-datepicker-toggle matSuffix [for]="resolutionDatePicker"></mat-datepicker-toggle>
<mat-datepicker #resolutionDatePicker></mat-datepicker>
@if (applySharesForm.controls.resolutionDate.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Resolution Date' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
<!-- Resolution Date -->
<mat-form-field appearance="outline" density="compact" (click)="resolutionDatePicker.open()">
<mat-label>{{ 'labels.inputs.Resolution Date' | translate }}</mat-label>
<input
matInput
[min]="minDate"
[max]="maxDate"
[matDatepicker]="resolutionDatePicker"
required
formControlName="resolutionDate"
/>
<mat-datepicker-toggle matSuffix [for]="resolutionDatePicker"></mat-datepicker-toggle>
<mat-datepicker #resolutionDatePicker></mat-datepicker>
@if (applySharesForm.controls.resolutionDate.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Resolution Date' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
</div>
</div>
<mat-form-field>
<mat-label>{{ 'labels.inputs.Total No. of Shares' | translate }}</mat-label>
<input matInput type="number" min="1" required formControlName="requestedShares" />
@if (applySharesForm.controls.requestedShares.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Requested Shares' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
<!-- SECTION 2: Share Purchase Calculation -->
<div class="form-section financial-section">
<div class="section-title">
<mat-icon class="sec-icon">calculate</mat-icon>
<span>Share Purchase Calculation</span>
</div>
<mat-form-field>
<mat-label>{{ 'labels.inputs.Current Price' | translate }}</mat-label>
<input matInput formControlName="unitPrice" />
</mat-form-field>
<div class="form-grid form-grid-3 align-items-center">
<!-- Total No. of Shares -->
<mat-form-field appearance="outline" density="compact">
<mat-label>{{ 'labels.inputs.Total No. of Shares' | translate }}</mat-label>
<input
matInput
type="number"
min="1"
required
formControlName="requestedShares"
placeholder="e.g. 100"
class="shares-count-input"
/>
@if (applySharesForm.controls.requestedShares.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Requested Shares' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
<!-- Current Price -->
<mat-form-field appearance="outline" density="compact" class="price-field">
<mat-label>{{ 'labels.inputs.Current Price' | translate }}</mat-label>
<input matInput formControlName="unitPrice" class="price-input" />
<span matSuffix class="currency-label">per share</span>
</mat-form-field>
<!-- Computed Amount Card -->
<div class="amount-display-card">
<span class="amount-label">{{ 'labels.inputs.Amount' | translate }}</span>
<span class="amount-value">{{ amount | number: '1.2-2' }}</span>
<span class="amount-subtext"
>{{ applySharesForm.get('requestedShares').value || 0 }} shares &times;
{{ sharesAccountData?.currentMarketPrice || 0 }}</span
>
</div>
</div>
</div>
<!-- SECTION 3: Payment / Funding Option -->
<div class="form-section">
<div class="section-title">
<mat-icon class="sec-icon">account_balance_wallet</mat-icon>
<span>{{ 'labels.inputs.Payment Option' | translate }}</span>
</div>
<div class="payment-card-group">
<div class="payment-card-option" [class.active]="paymentMode === 'CASH'" (click)="setPaymentMode('CASH')">
<div class="option-icon-box">
<mat-icon>payments</mat-icon>
</div>
<div class="option-content">
<span class="option-title">Apply Collection (Cash)</span>
<span class="option-desc">Link receipts paid at counter sitting in Share Sundry</span>
</div>
@if (paymentMode === 'CASH') {
<mat-icon class="selected-check font-success">check_circle</mat-icon>
}
</div>
<div
class="payment-card-option"
[class.active]="paymentMode === 'SAVINGS'"
(click)="setPaymentMode('SAVINGS')"
<!-- Shares x price, computed rather than entered, so the value posted to the GL is the
value the clerk read back to the member. -->
<mat-form-field>
<mat-label>{{ 'labels.inputs.Amount' | translate }}</mat-label>
<input matInput formControlName="amount" />
<mat-hint
>{{ 'labels.inputs.Total No. of Shares' | translate }} &times;
{{ 'labels.inputs.Current Price' | translate }}</mat-hint
>
<div class="option-icon-box">
<mat-icon>account_balance</mat-icon>
</div>
<div class="option-content">
<span class="option-title">Debit from Savings</span>
<span class="option-desc">Direct debit from member's linked savings account</span>
</div>
@if (paymentMode === 'SAVINGS') {
<mat-icon class="selected-check font-success">check_circle</mat-icon>
}
</div>
</mat-form-field>
<!-- Where the money comes from. Both routes land in Share Sundry, and approval is what
moves it out to share capital. -->
<div class="payment-mode">
<div class="payment-mode-label">{{ 'labels.inputs.Payment Option' | translate }}</div>
<mat-radio-group formControlName="paymentMode" class="payment-mode-options">
<mat-radio-button value="CASH">Apply Collection (Cash)</mat-radio-button>
<mat-radio-button value="SAVINGS">Debit from Savings</mat-radio-button>
</mat-radio-group>
</div>
</div>
<!-- CASH FUNDING DETAILS -->
@if (paymentMode === 'CASH') {
<div class="funding-panel cash-panel animate-fade-in">
<div class="funding-intro-banner">
<mat-icon class="banner-icon">info</mat-icon>
<span>
Select the one receipt that covers this payment, or enter a voucher number to search. Click
<mat-icon class="inline-icon">visibility</mat-icon> on any row to view full details popup.
</span>
</div>
@if (paymentMode === 'CASH') {
<div class="funding-panel">
<p class="funding-intro">
The member has already paid at the counter. Tick the receipts that make up this payment, or enter a
voucher number to find one — tick more than one where they paid in instalments. The cash is sitting in
Share Sundry against them, and approving this request moves it into share capital.
</p>
<!-- Toolbar: GL Account Select & Voucher Lookup -->
<div class="lookup-toolbar">
<div class="gl-select-box">
<label class="toolbar-label"> <mat-icon class="mini-icon">account_tree</mat-icon> GL Account </label>
<div class="gl-select-field">
<select
class="custom-select"
class="gl-select"
[(ngModel)]="glAccountNo"
[ngModelOptions]="{ standalone: true }"
[disabled]="lookingUpVoucher || submitting || loadingGlAccounts"
@@ -202,394 +125,173 @@
</select>
</div>
<div class="voucher-lookup-box">
<label class="toolbar-label">
<mat-icon class="mini-icon">search</mat-icon> Find Specific Voucher
</label>
<div class="lookup-input-group">
<input
#voucherInput
type="text"
class="lookup-input font-mono"
placeholder="Voucher no. (e.g. 000123)"
[disabled]="lookingUpVoucher || submitting"
(keyup.enter)="lookupVoucher(voucherInput.value)"
/>
<button
mat-flat-button
color="primary"
type="button"
class="find-btn"
[disabled]="!voucherInput.value.trim() || !glAccountNo.trim() || lookingUpVoucher || submitting"
(click)="lookupVoucher(voucherInput.value)"
>
@if (lookingUpVoucher) {
<mat-spinner diameter="14"></mat-spinner>
} @else {
<mat-icon>search</mat-icon>
<span>Find</span>
}
</button>
<div class="voucher-lookup">
<input
#voucherInput
type="text"
class="voucher-input"
placeholder="Voucher no. (e.g. 000123)"
[disabled]="lookingUpVoucher || submitting"
(keyup.enter)="lookupVoucher(voucherInput.value)"
/>
<button
mat-stroked-button
type="button"
[disabled]="!voucherInput.value.trim() || !glAccountNo.trim() || lookingUpVoucher || submitting"
(click)="lookupVoucher(voucherInput.value)"
>
{{ lookingUpVoucher ? 'Finding…' : 'Find' }}
</button>
</div>
@if (voucherError) {
<div class="alert-error">
<mat-icon>error_outline</mat-icon>
<span>{{ voucherError }}</span>
</div>
</div>
</div>
}
@if (voucherError) {
<div class="alert-box alert-error">
<mat-icon>error_outline</mat-icon>
<span>{{ voucherError }}</span>
</div>
}
<!-- Vouchers Array List / Table -->
<div class="vouchers-array-container">
@if (loadingVouchers) {
<div class="funding-loading">
<mat-spinner diameter="24"></mat-spinner>
<span>Loading member receipts…</span>
<span>Loading the member's receipts…</span>
</div>
} @else if (vouchers.length === 0) {
<div class="funding-empty">
<mat-icon class="empty-icon">receipt_long</mat-icon>
<div class="empty-text">
<strong>No unapplied receipts found for this member.</strong>
<p>
Collect cash at <em>Cashier &rsaquo; Transaction Entry</em> as a deposit to Share Sundry GL. Once
posted, it will appear here.
</p>
<mat-icon>info_outline</mat-icon>
<div>
<b>No unapplied receipt for this member.</b>
<div class="funding-empty-hint">
Collect the cash first from Cashier &rsaquo; Transaction Entry as a deposit to the Share Sundry
GL. Once it is approved, the voucher appears here.
</div>
</div>
</div>
} @else {
<div class="vouchers-card">
<!-- Array Header Toolbar -->
<div class="vouchers-header">
<div class="vouchers-title font-accent">
<mat-icon class="v-icon">receipt</mat-icon>
<span>Vouchers / Receipts</span>
<span class="badge-count">{{ vouchers.length }}</span>
</div>
</div>
<!-- Compact Table View -->
<div class="table-responsive">
<table class="receipt-table">
<thead>
<tr>
<th class="col-checkbox ta-center">#</th>
<th class="ta-left">Voucher / Receipt No</th>
<th class="ta-left">Date</th>
<th class="ta-left">Description</th>
<th class="ta-left">Collected By</th>
<th class="ta-right">Amount</th>
<th class="col-actions ta-center">Detail</th>
</tr>
</thead>
<tbody>
<!-- One receipt funds one tranche — no combining instalments here. -->
@for (v of vouchers; track v.id) {
<tr
class="receipt-row"
[class.selected]="isVoucherSelected(v.id)"
(click)="toggleVoucher(v.id)"
>
<td class="col-checkbox ta-center" (click)="$event.stopPropagation()">
<input
type="radio"
name="voucherSelect"
class="custom-checkbox"
[checked]="isVoucherSelected(v.id)"
(change)="toggleVoucher(v.id)"
/>
</td>
<td class="ta-left">
<span class="voucher-badge font-mono">{{ v.voucher_no || v.receipt_no }}</span>
</td>
<td class="ta-left date-cell">{{ v.collection_date }}</td>
<td class="ta-left desc-cell" [title]="v.note">{{ v.note || 'Share Collection' }}</td>
<td class="ta-left user-cell">
<span class="user-chip">
<mat-icon class="chip-icon">person_outline</mat-icon>
{{ v.collected_by_name || 'System' }}
</span>
</td>
<td class="ta-right amount-cell">
<span class="val font-semibold">{{ v.amount | number: '1.2-2' }}</span>
</td>
<td class="col-actions ta-center" (click)="$event.stopPropagation()">
<button
type="button"
mat-icon-button
class="view-detail-btn"
title="View Full Details Popup"
(click)="openVoucherDetail(v, $event)"
>
<mat-icon class="eye-icon">visibility</mat-icon>
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
<!-- Summary Footer Bar -->
<div class="vouchers-footer">
<div class="selected-summary">
<span class="summary-text font-accent">
<strong>{{ selectedVoucherIds.length }}</strong> of
<strong>{{ vouchers.length }}</strong> receipts selected
</span>
<div class="summary-amounts">
<span class="total-label">Selected Total:</span>
<span class="total-value font-semibold">{{ selectedVoucherTotal | number: '1.2-2' }}</span>
<span class="target-divider">/</span>
<span class="target-value">Target {{ amount | number: '1.2-2' }}</span>
</div>
</div>
<!-- Coverage Status Indicator Pill -->
<div class="coverage-status">
@if (selectedVoucherIds.length === 0) {
<span class="status-badge badge-neutral">
<mat-icon>info</mat-icon> Tick at least 1 receipt
</span>
} @else if (voucherShortfall > 0) {
<span class="status-badge badge-warning">
<mat-icon>warning</mat-icon> Shortfall {{ voucherShortfall | number: '1.2-2' }}
</span>
} @else {
<span class="status-badge badge-success"> <mat-icon>check_circle</mat-icon> Fully Funded </span>
}
</div>
</div>
</div>
<!-- Checkboxes, not radios: a member paying in instalments has several receipts
against one request, and they fund it together. -->
<table class="receipt-table">
<thead>
<tr>
<th></th>
<th class="ta-left">Voucher / Receipt</th>
<th class="ta-left">Date</th>
<th class="ta-right">Amount</th>
</tr>
</thead>
<tbody>
@for (v of vouchers; track v.id) {
<tr [class.selected]="isVoucherSelected(v.id)" (click)="toggleVoucher(v.id)">
<td class="ta-center">
<input
type="checkbox"
[checked]="isVoucherSelected(v.id)"
(change)="toggleVoucher(v.id)"
(click)="$event.stopPropagation()"
/>
</td>
<td class="ta-left mono">{{ v.voucher_no || v.receipt_no }}</td>
<td class="ta-left">{{ v.collection_date }}</td>
<td class="ta-right">
<b>{{ v.amount | number: '1.2-2' }}</b>
</td>
</tr>
}
</tbody>
@if (selectedVoucherIds.length > 0) {
<tfoot>
<tr class="total-row">
<td></td>
<td class="ta-left" colspan="2">
{{ selectedVoucherIds.length }} selected, against {{ amount | number: '1.2-2' }}
</td>
<td class="ta-right">
<b>{{ selectedVoucherTotal | number: '1.2-2' }}</b>
</td>
</tr>
</tfoot>
}
</table>
<!-- Says how much is still missing rather than only that it is short, so the clerk
can tell the member what to pay without working it out. -->
@if (selectedVoucherIds.length > 0 && voucherShortfall > 0) {
<div class="alert-box alert-warning mt-8">
<div class="alert-error">
<mat-icon>error_outline</mat-icon>
<span>
Selected total {{ selectedVoucherTotal | number: '1.2-2' }} is
<strong>{{ voucherShortfall | number: '1.2-2' }} short</strong> of tranche target
{{ amount | number: '1.2-2' }}. Tick another receipt or collect balance.
The selected receipts are {{ voucherShortfall | number: '1.2-2' }} short of
{{ amount | number: '1.2-2' }}. Tick another receipt, or collect the balance at the counter first.
</span>
</div>
}
}
</div>
</div>
}
}
<!-- SAVINGS FUNDING DETAILS -->
@if (paymentMode === 'SAVINGS') {
<div class="funding-panel savings-panel animate-fade-in">
<div class="funding-intro-banner">
<mat-icon class="banner-icon">info</mat-icon>
<span>
The tranche amount will be automatically debited from the member's linked savings account into Share
Sundry upon submission.
</span>
</div>
@if (paymentMode === 'SAVINGS') {
<div class="funding-panel">
<p class="funding-intro">
The amount is debited from the member's linked savings into Share Sundry when this is submitted, so
their balance drops now. Approving the request then moves it into share capital.
</p>
@if (checkingSavings) {
<div class="funding-loading">
<mat-spinner diameter="24"></mat-spinner>
<span>Checking linked savings account eligibility…</span>
</div>
} @else if (savingsEligibility) {
<div class="savings-card">
@if (savingsEligibility.savingsAccountNo) {
<div class="savings-row">
<span class="lbl">Savings Account No.</span>
<span class="val font-mono">{{ savingsEligibility.savingsAccountNo }}</span>
@if (checkingSavings) {
<div class="funding-loading">
<mat-spinner diameter="24"></mat-spinner>
<span>Checking the linked savings account…</span>
</div>
} @else if (savingsEligibility) {
<div class="funding-summary">
@if (savingsEligibility.savingsAccountNo) {
<div class="funding-row">
<span>Savings account</span>
<span class="mono">{{ savingsEligibility.savingsAccountNo }}</span>
</div>
<div class="funding-row">
<span>Available balance</span>
<span>{{ savingsEligibility.savingsBalance | number: '1.2-2' }}</span>
</div>
}
<div class="funding-row funding-row--total">
<span>To debit</span>
<span>{{ amount | number: '1.2-2' }}</span>
</div>
<div class="savings-row">
<span class="lbl">Available Balance</span>
<span class="val font-semibold">{{ savingsEligibility.savingsBalance | number: '1.2-2' }}</span>
</div>
@if (!savingsEligibility.eligible) {
<div class="alert-error">
<mat-icon>error_outline</mat-icon>
<span>{{ savingsEligibility.reason }}</span>
</div>
}
<div class="savings-row savings-row-total">
<span class="lbl">Amount to Debit</span>
<span class="val font-semibold text-primary">{{ amount | number: '1.2-2' }}</span>
</div>
</div>
@if (!savingsEligibility.eligible) {
<div class="alert-box alert-error mt-8">
<mat-icon>error_outline</mat-icon>
<span>{{ savingsEligibility.reason }}</span>
</div>
}
}
</div>
}
</div>
}
@if (errorMsg) {
<div class="alert-box alert-error mt-12">
<mat-icon>error_outline</mat-icon>
<span>{{ errorMsg }}</span>
</div>
}
@if (errorMsg) {
<div class="alert-error">
<mat-icon>error_outline</mat-icon>
<span>{{ errorMsg }}</span>
</div>
}
<!-- PENDING APPROVAL NOTICE -->
<div class="pending-notice mt-10">
<mat-icon class="notice-icon">schedule</mat-icon>
<span>
Submitting records a request for approval. Shares are issued and funds moved out of Share Sundry only upon
manager approval.
</span>
<!-- Said plainly, so a clerk does not tell the member their shares are issued while a
manager still has to approve the request. -->
<p class="pending-hint">
Submitting records a request for approval. The shares are issued, and the money moved out of Share Sundry,
only once it is approved — until then it appears under pending, not in the account's transactions.
</p>
</div>
</mat-card-content>
<!-- CARD ACTIONS -->
<mat-card-actions class="card-actions-bar">
<button type="button" mat-stroked-button class="action-btn cancel-btn" [routerLink]="['../../']">
<mat-card-actions class="layout-row align-center gap-5px responsive-column">
<button type="button" mat-raised-button [routerLink]="['../../']">
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button mat-flat-button color="primary" class="action-btn submit-btn" [disabled]="!canSubmit">
@if (submitting) {
<mat-spinner diameter="16"></mat-spinner>
<span>Submitting…</span>
} @else {
<mat-icon>send</mat-icon>
<span>{{ 'labels.buttons.Submit' | translate }}</span>
}
<button mat-raised-button color="primary" [disabled]="!canSubmit">
{{ submitting ? 'Submitting…' : ('labels.buttons.Submit' | translate) }}
</button>
</mat-card-actions>
</form>
</mat-card>
</div>
<!-- VOUCHER FULL DETAILS POPUP MODAL -->
@if (viewingVoucherDetail) {
<div class="modal-backdrop animate-fade-in" (click)="closeVoucherDetail()">
<div class="voucher-modal-dialog" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="header-title font-accent">
<mat-icon class="m-icon">receipt_long</mat-icon>
<span>Voucher Details</span>
<span class="modal-badge font-mono">{{
viewingVoucherDetail.voucher_no || viewingVoucherDetail.receipt_no
}}</span>
</div>
<button type="button" mat-icon-button class="close-btn" (click)="closeVoucherDetail()">
<mat-icon>close</mat-icon>
</button>
</div>
<div class="modal-body">
<div class="detail-grid">
<div class="detail-item">
<span class="detail-label">Voucher / Receipt No</span>
<span class="detail-val font-mono font-semibold">{{
viewingVoucherDetail.voucher_no || viewingVoucherDetail.receipt_no || '—'
}}</span>
</div>
<div class="detail-item">
<span class="detail-label">Receipt Reference</span>
<span class="detail-val font-mono">{{ viewingVoucherDetail.receipt_no || '—' }}</span>
</div>
<div class="detail-item">
<span class="detail-label">Client Name</span>
<span class="detail-val">{{
viewingVoucherDetail.client_name || sharesAccountData?.clientName || 'Member'
}}</span>
</div>
<div class="detail-item">
<span class="detail-label">Client ID</span>
<span class="detail-val font-mono">{{
viewingVoucherDetail.client_id || sharesAccountData?.clientId || '—'
}}</span>
</div>
<div class="detail-item">
<span class="detail-label">Collection Date</span>
<span class="detail-val">{{ viewingVoucherDetail.collection_date || '—' }}</span>
</div>
<div class="detail-item">
<span class="detail-label">Status</span>
<span class="status-pill status-unapplied">{{ viewingVoucherDetail.status || 'UNAPPLIED' }}</span>
</div>
<div class="detail-item">
<span class="detail-label">Collected By</span>
<span class="detail-val">{{ viewingVoucherDetail.collected_by_name || 'System Administrator' }}</span>
</div>
<div class="detail-item full-width highlight-amount-box">
<span class="detail-label">Receipt Amount</span>
<span class="amount-val font-mono">{{ viewingVoucherDetail.amount | number: '1.2-2' }}</span>
</div>
<div class="detail-item full-width">
<span class="detail-label">Full Note / Description</span>
<div class="note-box font-accent">
{{ viewingVoucherDetail.note || 'Share collection deposit to Share Sundry GL' }}
</div>
</div>
<!-- The receipt's own actual GL posting — never the GL Account dropdown above the
voucher search box, which is unrelated screen state and can name a completely
different account than the one this specific receipt sits in. -->
<div class="detail-item full-width">
<span class="detail-label">GL Entry (this receipt's actual posting)</span>
@if (loadingGlEntries) {
<div class="gl-entries-loading">Loading…</div>
} @else if (voucherGlEntries.length === 0) {
<div class="gl-entries-empty">No GL posting recorded for this receipt.</div>
} @else {
<table class="gl-entries-table">
<thead>
<tr>
<th class="ta-left">GL Account</th>
<th class="ta-center">Dr / Cr</th>
<th class="ta-right">Amount</th>
</tr>
</thead>
<tbody>
@for (leg of voucherGlEntries; track leg.glCode + leg.drCr) {
<tr>
<td class="ta-left font-mono">{{ leg.glCode }} — {{ leg.accountName }}</td>
<td class="ta-center">
<span
class="drcr-pill"
[class.drcr-pill--dr]="leg.drCr === 'Dr'"
[class.drcr-pill--cr]="leg.drCr === 'Cr'"
>
{{ leg.drCr }}
</span>
</td>
<td class="ta-right font-mono">{{ leg.amount | number: '1.2-2' }}</td>
</tr>
}
</tbody>
</table>
}
</div>
</div>
</div>
<div class="modal-footer">
<button
type="button"
mat-flat-button
[color]="isVoucherSelected(viewingVoucherDetail.id) ? 'warn' : 'primary'"
class="modal-action-btn"
(click)="toggleVoucher(viewingVoucherDetail.id); closeVoucherDetail()"
>
<mat-icon>{{
isVoucherSelected(viewingVoucherDetail.id) ? 'remove_circle_outline' : 'check_circle_outline'
}}</mat-icon>
<span>{{ isVoucherSelected(viewingVoucherDetail.id) ? 'Unselect Receipt' : 'Select Receipt' }}</span>
</button>
<button type="button" mat-stroked-button (click)="closeVoucherDetail()">Close</button>
</div>
</div>
</div>
}
File diff suppressed because it is too large Load Diff
@@ -107,15 +107,6 @@ export class ApplySharesComponent implements OnInit {
glAccounts: any[] = [];
loadingGlAccounts = true;
/** Currently selected voucher for viewing in the detail popup. */
viewingVoucherDetail: any = null;
/** The receipt's own real Dr/Cr GL legs, fetched fresh per voucher — never the GL Account
* dropdown above (that is just what the by-voucher search box is currently set to, which can
* name a completely different account than the one this specific receipt actually sits in). */
voucherGlEntries: { glCode: string; accountName: string; drCr: string; amount: number }[] = [];
loadingGlEntries = false;
/** What the member's savings can cover, for the savings route. */
savingsEligibility: {
eligible: boolean;
@@ -143,43 +134,10 @@ export class ApplySharesComponent implements OnInit {
.pipe(catchError(() => of(null)))
.subscribe((res: any) => {
this.glAccounts = res?.glAccounts || [];
// Auto-picked only when there is exactly one live candidate -- the backend already narrowed
// this list to accounts that actually hold an eligible receipt today, so a single option is
// unambiguous. Two or more still means the counter chooses by hand: picking "whichever
// sorts first" among several real candidates is the same blind guess that used to land on
// Cash at Vault, just with a shorter, filtered list to guess from.
if (this.glAccounts.length === 1 && !this.glAccountNo) {
this.glAccountNo = this.glAccounts[0].gl_code;
}
this.loadingGlAccounts = false;
});
}
setPaymentMode(mode: string): void {
this.applySharesForm.get('paymentMode')?.setValue(mode);
}
openVoucherDetail(voucher: any, event?: Event): void {
if (event) {
event.stopPropagation();
}
this.viewingVoucherDetail = voucher;
this.voucherGlEntries = [];
this.loadingGlEntries = true;
this.cashierService
.getShareAdvanceGlEntries(voucher.id)
.pipe(catchError(() => of(null)))
.subscribe((res: any) => {
this.voucherGlEntries = res?.entries || [];
this.loadingGlEntries = false;
});
}
closeVoucherDetail(): void {
this.viewingVoucherDetail = null;
this.voucherGlEntries = [];
}
/**
* Creates the apply shares form.
*
@@ -296,24 +254,12 @@ export class ApplySharesComponent implements OnInit {
return this.selectedVoucherIds.includes(id);
}
/**
* One receipt funds one tranche — no combining instalments here (unlike the first-application
* Apply Collection dialog, which still allows several). Picking a different receipt replaces the
* current one rather than adding to it, so {@link selectedVoucherIds} never holds more than one id.
*/
toggleVoucher(id: number): void {
const wasSelected = this.isVoucherSelected(id);
this.selectedVoucherIds = wasSelected ? [] : [id];
this.selectedVoucherIds = this.isVoucherSelected(id) ? this.selectedVoucherIds.filter((x) => x !== id) : [
...this.selectedVoucherIds,
id
];
this.voucherError = '';
// The GL Account picker above follows whichever receipt is ticked — its own actual GL account
// (see gl_code on the row), not left showing an unrelated account or "Select…" once a receipt
// that plainly does have one is already chosen.
if (!wasSelected) {
const voucher = this.vouchers.find((v) => v.id === id);
if (voucher?.gl_code) {
this.glAccountNo = voucher.gl_code;
}
}
}
/**
@@ -361,13 +307,19 @@ export class ApplySharesComponent implements OnInit {
this.voucherError = `Voucher ${trimmed} belongs to ${res.client_name}, not the holder of this share account.`;
return;
}
// Replaces the selection rather than adding to it — one receipt funds one tranche here, so
// finding a voucher by number is a different way to pick THE receipt, not a second one.
// Added to the selection rather than replacing it — a looked-up voucher is usually the
// second half of a part-payment, not a change of mind about the first. No amount check
// here: one instalment is not meant to cover the tranche on its own.
this.vouchers = [
res,
...this.vouchers.filter((v) => v.id !== res.id)
];
this.selectedVoucherIds = [res.id];
if (!this.isVoucherSelected(res.id)) {
this.selectedVoucherIds = [
...this.selectedVoucherIds,
res.id
];
}
});
}
@@ -168,8 +168,6 @@
<th class="col-tick"></th>
<th class="ta-left">Voucher / Receipt</th>
<th class="ta-left">Date</th>
<th class="ta-left">Description</th>
<th class="ta-left">Collected By</th>
<th class="ta-right">Amount</th>
</tr>
</thead>
@@ -188,8 +186,6 @@
</td>
<td class="ta-left mono">{{ r.voucher_no || r.receipt_no }}</td>
<td class="ta-left">{{ r.collection_date }}</td>
<td class="ta-left" [title]="r.note">{{ r.note || '—' }}</td>
<td class="ta-left">{{ r.collected_by_name || '—' }}</td>
<td class="ta-right amount-cell">{{ r.amount | number: '1.2-2' }}</td>
</tr>
}
@@ -201,7 +197,7 @@
<tfoot>
<tr class="total-row">
<td class="col-tick"></td>
<td class="ta-left" colspan="4">
<td class="ta-left" colspan="2">
{{ selectedIds.length }} {{ selectedIds.length === 1 ? 'receipt' : 'receipts' }} selected
</td>
<td class="ta-right amount-cell">{{ selectedTotal | number: '1.2-2' }}</td>
@@ -135,12 +135,6 @@ export class ApplyCollectionDialogComponent implements OnInit {
)
.subscribe((res: any) => {
this.glAccounts = res?.glAccounts || [];
// Auto-picked only when there is exactly one live candidate -- the backend already narrowed
// this list to accounts that actually hold an eligible receipt today, so a single option is
// unambiguous. Two or more still means the counter chooses by hand.
if (this.glAccounts.length === 1 && !this.glAccountNo) {
this.glAccountNo = this.glAccounts[0].gl_code;
}
this.loadingGlAccounts = false;
});
@@ -384,59 +384,18 @@
/* Print CSS */
@media print {
::ng-deep html,
::ng-deep body {
height: auto !important;
overflow: visible !important;
}
/* Hide non-overlay contents during print. Deliberately NOT `::ng-deep` here: under Angular's
emulated encapsulation a bare `body` selector never carries this component's scoping
attribute, so a scoped rule can never match the real <body> and is safely inert — whereas
`::ng-deep` emits it fully unscoped into the GLOBAL stylesheet for the rest of the session
(Angular never removes a component's injected styles when it's destroyed), silently hiding
every other page's print output app-wide the moment this dialog is opened once. The app
shell (nav, toolbar, sidebar) is already hidden for every print via the global rules in
main.scss, so this dialog doesn't need its own global override to get the same effect. */
/* Hide non-overlay contents during print */
body > :not(.cdk-overlay-container) {
display: none !important;
}
/* The dialog's own scroll region clips anything past 80vh -- printing only captures what a
scrollable/max-height box shows on screen, so both constraints must be lifted. */
.dialog-content-container {
max-height: none !important;
overflow: visible !important;
}
/* CDK's overlay wrapper/backdrop/pane are all position: fixed. Unset them so the full certificate prints statically. */
::ng-deep .cdk-overlay-backdrop {
display: none !important;
}
::ng-deep .cdk-overlay-container,
::ng-deep .cdk-global-overlay-wrapper,
::ng-deep .cdk-overlay-pane,
::ng-deep .mat-mdc-dialog-container,
::ng-deep .mdc-dialog__container,
::ng-deep .mdc-dialog__surface {
position: static !important;
display: block !important;
max-height: none !important;
max-width: none !important;
height: auto !important;
width: auto !important;
overflow: visible !important;
transform: none !important;
box-shadow: none !important;
background: transparent !important;
}
/* Make certificate fill page */
#share-certificate-print {
position: static !important;
position: absolute;
left: 0;
top: 0;
width: 100% !important;
height: auto !important;
height: 100% !important;
padding: 0 !important;
margin: 0 !important;
background-color: #fff !important;
@@ -452,135 +411,7 @@
display: none !important;
}
::ng-deep .mat-mdc-dialog-container {
.mat-mdc-dialog-container {
padding: 0 !important;
}
/* Compact every spacing/font value so the whole certificate fits a single printed page */
.certificate-container {
padding: 6px !important;
}
.certificate-border-inner {
padding: 14px !important;
}
.certificate-header {
margin-bottom: 10px !important;
padding-bottom: 10px !important;
.bank-name {
font-size: 19px !important;
margin-bottom: 2px !important;
}
.bank-sub {
font-size: 10px !important;
margin-bottom: 8px !important;
}
.cert-title {
font-size: 19px !important;
letter-spacing: 2px !important;
margin-bottom: 2px !important;
}
.cert-subtitle {
font-size: 9px !important;
}
}
.cert-meta-row {
margin-bottom: 10px !important;
padding-bottom: 8px !important;
.meta-label {
font-size: 9px !important;
margin-bottom: 1px !important;
}
.meta-value {
font-size: 12px !important;
}
}
.cert-body-section {
margin-bottom: 10px !important;
.cert-main-text {
font-size: 11px !important;
line-height: 1.4 !important;
margin-bottom: 10px !important;
}
}
.issued-to-details {
padding: 8px 14px !important;
margin: 8px auto !important;
.client-name-details {
font-size: 14px !important;
}
.client-ids,
.client-address {
font-size: 10px !important;
}
}
.certificate-details-table {
margin-bottom: 12px !important;
td {
padding: 5px 10px !important;
font-size: 10px !important;
}
.lbl-col-highlight {
font-size: 12px !important;
}
.val-col-highlight {
font-size: 13px !important;
}
}
.allotment-table {
margin-bottom: 12px !important;
th {
padding: 4px 8px !important;
font-size: 8px !important;
}
td {
padding: 4px 8px !important;
font-size: 9px !important;
}
}
.cert-footer {
margin-top: 8px !important;
.seal-ring {
width: 60px !important;
height: 60px !important;
}
.seal-text {
font-size: 8px !important;
}
.closing-text {
font-size: 10px !important;
}
.signature-line {
height: 22px !important;
}
.signatory-title {
font-size: 10px !important;
}
}
}
@@ -5,244 +5,124 @@
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/.
-->
<!--
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="inv-container">
<!-- Hero header -->
<div class="inv-hero">
<div class="inv-hero__left">
<div class="inv-hero__icon">
<fa-icon icon="university"></fa-icon>
</div>
<div>
<h1 class="inv-hero__title">Sponsor Bank DD Inventory</h1>
<p class="inv-hero__sub">
Manage allocated ranges of physical Demand Draft leaflets received from Sponsor Banks.
</p>
</div>
<div class="inventory-container">
<div class="header-row">
<div class="title-area">
<h2>Sponsor Bank DD Inventory</h2>
<p>Manage allocated ranges of physical Demand Draft leaflets received from Sponsor Banks.</p>
</div>
<button class="inv-btn primary" (click)="openAddInventoryDialog()">
<fa-icon icon="plus"></fa-icon>
Register DD Series
</button>
<button mat-flat-button color="primary" (click)="openAddInventoryDialog()">Register DD Series</button>
</div>
<!-- Dividend Context Banner -->
<!-- Says why the clerk is here when they arrived from a dividend claim rather than from the menu.
Without it, a page listing every DD series gives no clue which member is waiting at the
counter or how much the draft has to be drawn for. -->
@if (dividendContext) {
<div class="inv-dividend-banner">
<div class="inv-dividend-banner__ic">
<fa-icon icon="hand-holding-usd"></fa-icon>
</div>
<div class="inv-dividend-banner__text">
<strong>Issuing Dividend Demand Draft</strong>
<div class="dividend-banner">
<div class="dividend-banner__text">
<strong>Issuing a dividend Demand Draft</strong>
<span>
Beneficiary: <strong>{{ dividendContext.beneficiary }}</strong> · Amount:
<strong>₹{{ dividendContext.amount | number: '1.2-2' }}</strong>
{{ dividendContext.beneficiary }} ·
{{ dividendContext.amount | number: '1.2-2' }}
@if (dividendContext.financialYear) {
· FY {{ dividendContext.financialYear }}
}
</span>
<span class="inv-dividend-banner__hint">Select a series below to pick an available leaflet.</span>
<span class="dividend-banner__hint">Open a series below and choose an available leaflet.</span>
</div>
<button class="inv-btn ghost small" (click)="cancelDividendClaim()">
<fa-icon icon="times-circle"></fa-icon>
Cancel Claim
</button>
<button mat-button class="action-btn" (click)="cancelDividendClaim()">Cancel</button>
</div>
}
<!-- KPI Metrics Bar -->
<div class="inv-kpi-grid">
<div class="inv-kpi-card">
<div class="inv-kpi-card__icon blue">
<fa-icon icon="layer-group"></fa-icon>
</div>
<div class="inv-kpi-card__info">
<span class="inv-kpi-card__label">Total Series Batches</span>
<span class="inv-kpi-card__val">{{ totalBatches }}</span>
</div>
<mat-card>
<table mat-table [dataSource]="dataSource">
<!-- ID Column -->
<ng-container matColumnDef="id">
<th mat-header-cell *matHeaderCellDef>Batch ID</th>
<td mat-cell *matCellDef="let element">{{ element.id }}</td>
</ng-container>
<!-- Sponsor Bank Column -->
<ng-container matColumnDef="sponsorBankName">
<th mat-header-cell *matHeaderCellDef>Sponsor Bank</th>
<td mat-cell *matCellDef="let element">{{ element.sponsorBankName }}</td>
</ng-container>
<!-- Branch Column -->
<ng-container matColumnDef="branchName">
<th mat-header-cell *matHeaderCellDef>Branch</th>
<td mat-cell *matCellDef="let element">{{ element.branchName }}</td>
</ng-container>
<!-- Series Start Column -->
<ng-container matColumnDef="seriesStart">
<th mat-header-cell *matHeaderCellDef>Start No.</th>
<td mat-cell *matCellDef="let element">{{ element.seriesStart }}</td>
</ng-container>
<!-- Series End Column -->
<ng-container matColumnDef="seriesEnd">
<th mat-header-cell *matHeaderCellDef>End No.</th>
<td mat-cell *matCellDef="let element">{{ element.seriesEnd }}</td>
</ng-container>
<!-- Total Leaves Column -->
<ng-container matColumnDef="totalLeaves">
<th mat-header-cell *matHeaderCellDef>Total Leaves</th>
<td mat-cell *matCellDef="let element">{{ element.totalLeaves }}</td>
</ng-container>
<!-- Available Leaves Column -->
<ng-container matColumnDef="availableLeaves">
<th mat-header-cell *matHeaderCellDef>Available Leaves</th>
<td mat-cell *matCellDef="let element">
<span [class.leaves-low]="element.availableLeaves === 0">{{ element.availableLeaves }}</span>
</td>
</ng-container>
<!-- Received Date Column -->
<ng-container matColumnDef="receivedDate">
<th mat-header-cell *matHeaderCellDef>Received Date</th>
<td mat-cell *matCellDef="let element">{{ element.receivedDate }}</td>
</ng-container>
<!-- Status Column -->
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef>Status</th>
<td mat-cell *matCellDef="let element">
<span class="status-badge" [ngClass]="element.status.toLowerCase()">
{{ element.status }}
</span>
</td>
</ng-container>
<!-- Actions Column -->
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef>Actions</th>
<td mat-cell *matCellDef="let element">
<!-- The dividend context travels on, so the leaflets page knows this is a dividend
draft and not an ordinary counter sale. -->
<button
mat-button
class="action-btn"
[routerLink]="['/sponsor-dd/leaflets', element.id]"
[queryParams]="dividendQueryParams"
>
{{ dividendContext ? 'Choose Leaflet' : 'View Leaflets' }}
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
</table>
<div
*ngIf="dataSource.length === 0"
class="no-records-row"
style="text-align: center; padding: 32px; color: #64748b"
>
No DD series registered yet. Click "Register DD Series" to add stock.
</div>
<div class="inv-kpi-card">
<div class="inv-kpi-card__icon green">
<fa-icon icon="check-circle"></fa-icon>
</div>
<div class="inv-kpi-card__info">
<span class="inv-kpi-card__label">Active Stock Series</span>
<span class="inv-kpi-card__val">{{ activeBatchesCount }}</span>
</div>
</div>
<div class="inv-kpi-card">
<div class="inv-kpi-card__icon purple">
<fa-icon icon="money-check"></fa-icon>
</div>
<div class="inv-kpi-card__info">
<span class="inv-kpi-card__label">Total Allocated Leaves</span>
<span class="inv-kpi-card__val">{{ totalLeavesCount | number }}</span>
</div>
</div>
<div class="inv-kpi-card">
<div class="inv-kpi-card__icon emerald">
<fa-icon icon="list-ul"></fa-icon>
</div>
<div class="inv-kpi-card__info">
<span class="inv-kpi-card__label">Available Leaves</span>
<span class="inv-kpi-card__val">{{ totalAvailableLeaves | number }}</span>
</div>
</div>
</div>
<!-- Search & Filter Controls -->
<div class="inv-toolbar">
<div class="inv-search">
<fa-icon icon="search" class="inv-search__ic"></fa-icon>
<input
type="text"
[(ngModel)]="searchText"
placeholder="Search by Sponsor Bank, Branch, Batch ID, or Series No..."
/>
<button class="inv-search__clear" *ngIf="searchText" (click)="searchText = ''" matTooltip="Clear">
<fa-icon icon="times"></fa-icon>
</button>
</div>
<div class="inv-filter-chips">
<button class="inv-chip" [class.active]="selectedStatusFilter === 'ALL'" (click)="selectedStatusFilter = 'ALL'">
All Batches
<span class="inv-chip__count">{{ dataSource.length }}</span>
</button>
<button
class="inv-chip active-chip"
[class.active]="selectedStatusFilter === 'ACTIVE'"
(click)="selectedStatusFilter = 'ACTIVE'"
>
<span class="inv-chip__dot green"></span>
Active
<span class="inv-chip__count">{{ activeBatchesCount }}</span>
</button>
<button
class="inv-chip exhausted-chip"
[class.active]="selectedStatusFilter === 'EXHAUSTED'"
(click)="selectedStatusFilter = 'EXHAUSTED'"
>
<span class="inv-chip__dot grey"></span>
Exhausted
<span class="inv-chip__count">{{ dataSource.length - activeBatchesCount }}</span>
</button>
</div>
</div>
<!-- Main Card & Data Table -->
<div class="inv-card">
<div class="inv-card__head">
<div class="inv-card__title">
<fa-icon icon="table" class="inv-card__title-ic"></fa-icon>
Registered Series
<span class="inv-card__count">{{ filteredData.length }} of {{ dataSource.length }}</span>
</div>
</div>
<div class="inv-table-wrap">
<table mat-table [dataSource]="filteredData" class="inv-table">
<!-- ID Column -->
<ng-container matColumnDef="id">
<th mat-header-cell *matHeaderCellDef>Batch ID</th>
<td mat-cell *matCellDef="let element" class="inv-mono inv-strong">#{{ element.id }}</td>
</ng-container>
<!-- Sponsor Bank Column -->
<ng-container matColumnDef="sponsorBankName">
<th mat-header-cell *matHeaderCellDef>Sponsor Bank</th>
<td mat-cell *matCellDef="let element" class="inv-cell-bank">
<fa-icon icon="building" class="bank-icon"></fa-icon>
<span class="inv-strong">{{ element.sponsorBankName }}</span>
</td>
</ng-container>
<!-- Branch Column -->
<ng-container matColumnDef="branchName">
<th mat-header-cell *matHeaderCellDef>Branch</th>
<td mat-cell *matCellDef="let element">{{ element.branchName }}</td>
</ng-container>
<!-- Series Start Column -->
<ng-container matColumnDef="seriesStart">
<th mat-header-cell *matHeaderCellDef>Start No.</th>
<td mat-cell *matCellDef="let element" class="inv-mono">{{ element.seriesStart }}</td>
</ng-container>
<!-- Series End Column -->
<ng-container matColumnDef="seriesEnd">
<th mat-header-cell *matHeaderCellDef>End No.</th>
<td mat-cell *matCellDef="let element" class="inv-mono">{{ element.seriesEnd }}</td>
</ng-container>
<!-- Total Leaves Column -->
<ng-container matColumnDef="totalLeaves">
<th mat-header-cell *matHeaderCellDef class="inv-num">Total Leaves</th>
<td mat-cell *matCellDef="let element" class="inv-num inv-mono">{{ element.totalLeaves }}</td>
</ng-container>
<!-- Available Leaves Column -->
<ng-container matColumnDef="availableLeaves">
<th mat-header-cell *matHeaderCellDef class="inv-num">Available Leaves</th>
<td mat-cell *matCellDef="let element" class="inv-num">
<span class="inv-leaves-badge" [class.low]="element.availableLeaves === 0">
{{ element.availableLeaves }}
</span>
</td>
</ng-container>
<!-- Received Date Column -->
<ng-container matColumnDef="receivedDate">
<th mat-header-cell *matHeaderCellDef>Received Date</th>
<td mat-cell *matCellDef="let element" class="inv-muted inv-mono">{{ element.receivedDate }}</td>
</ng-container>
<!-- Status Column -->
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef>Status</th>
<td mat-cell *matCellDef="let element">
<span class="inv-badge" [ngClass]="element.status.toLowerCase()">
<span class="inv-badge__dot"></span>
{{ element.status }}
</span>
</td>
</ng-container>
<!-- Actions Column -->
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef class="inv-actions-col">Actions</th>
<td mat-cell *matCellDef="let element" class="inv-actions-col">
<button
class="inv-btn primary small"
[routerLink]="['/sponsor-dd/leaflets', element.id]"
[queryParams]="dividendQueryParams"
>
<fa-icon [icon]="dividendContext ? 'hand-holding-usd' : 'eye'"></fa-icon>
{{ dividendContext ? 'Choose Leaflet' : 'View Leaflets' }}
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
</table>
@if (filteredData.length === 0) {
<div class="inv-empty">
<fa-icon icon="inbox" class="inv-empty__ic"></fa-icon>
<p class="inv-empty__title">No DD Series Found</p>
<p class="inv-empty__sub">No registered DD stock series match your filter criteria.</p>
</div>
}
</div>
</div>
</mat-card>
</div>
@@ -22,8 +22,6 @@ import { MatNativeDateModule } from '@angular/material/core';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { HttpClient } from '@angular/common/http';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
import { SponsorDDService } from '../sponsor-dd.service';
@Component({
@@ -32,482 +30,106 @@ import { SponsorDDService } from '../sponsor-dd.service';
imports: [
CommonModule,
RouterModule,
FormsModule,
ReactiveFormsModule,
MatCardModule,
MatTableModule,
MatButtonModule,
MatDialogModule,
MatSnackBarModule,
MatFormFieldModule,
MatInputModule,
MatIconModule,
MatTooltipModule,
FaIconComponent
MatSnackBarModule
],
templateUrl: './dd-inventory-list.component.html',
styles: [
`
.inv-container {
.inventory-container {
padding: 24px;
max-width: 1360px;
max-width: 1200px;
margin: 0 auto;
}
/* Hero Header */
.inv-hero {
.header-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
gap: 16px;
flex-wrap: wrap;
}
.inv-hero__left {
display: flex;
align-items: center;
gap: 16px;
.title-area h2 {
margin: 0 0 4px 0;
font-size: 24px;
font-weight: 600;
color: #1e293b;
}
.inv-hero__icon {
width: 52px;
height: 52px;
border-radius: 14px;
background: linear-gradient(135deg, #2563eb 0%, #4f46e5 100%);
color: #ffffff;
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
box-shadow: 0 4px 14px rgba(37, 99, 235, 0.25);
}
.inv-hero__title {
.title-area p {
margin: 0;
font-size: 22px;
font-weight: 800;
color: #0f172a;
letter-spacing: -0.02em;
}
.inv-hero__sub {
margin: 2px 0 0 0;
font-size: 13.5px;
color: #64748b;
font-size: 14px;
}
/* Dividend Banner */
.inv-dividend-banner {
.mat-mdc-card {
border-radius: 12px;
border: 1px solid #e2e8f0;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
overflow: hidden;
}
table {
width: 100%;
}
th {
font-weight: 600;
color: #475569;
background-color: #f8fafc;
}
td {
color: #334155;
}
.status-badge {
display: inline-flex;
align-items: center;
padding: 4px 8px;
border-radius: 9999px;
font-size: 12px;
font-weight: 500;
}
.status-badge.active {
background-color: #dcfce7;
color: #15803d;
}
.status-badge.exhausted {
background-color: #f1f5f9;
color: #475569;
}
.action-btn {
color: #7c3aed;
font-weight: 500;
}
.leaves-low {
color: #b91c1c;
font-weight: 600;
}
.dividend-banner {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 24px;
padding: 16px 20px;
border-radius: 12px;
margin-bottom: 20px;
padding: 12px 16px;
border: 1px solid #c7d2fe;
background: linear-gradient(135deg, #eef2ff 0%, #f5f3ff 100%);
box-shadow: 0 2px 8px rgba(79, 70, 229, 0.08);
border-left: 4px solid #4f46e5;
border-radius: 8px;
background-color: #eef2ff;
}
.inv-dividend-banner__ic {
width: 40px;
height: 40px;
border-radius: 10px;
background: #4f46e5;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
flex-shrink: 0;
}
.inv-dividend-banner__text {
.dividend-banner__text {
display: flex;
flex-direction: column;
gap: 2px;
font-size: 13.5px;
font-size: 13px;
color: #312e81;
}
.inv-dividend-banner__hint {
font-size: 12px;
.dividend-banner__text strong {
font-size: 13.5px;
}
.dividend-banner__hint {
color: #4338ca;
font-size: 12px;
}
.inv-dividend-banner button {
.dividend-banner button {
margin-left: auto;
}
/* KPI Cards */
.inv-kpi-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.inv-kpi-card {
background: #ffffff;
border-radius: 12px;
padding: 18px 20px;
border: 1px solid #e2e8f0;
display: flex;
align-items: center;
gap: 16px;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.04);
transition:
transform 0.15s ease,
box-shadow 0.15s ease;
}
.inv-kpi-card:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(15, 23, 42, 0.08);
}
.inv-kpi-card__icon {
width: 46px;
height: 46px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
flex-shrink: 0;
}
.inv-kpi-card__icon.blue {
background: #eff6ff;
color: #2563eb;
}
.inv-kpi-card__icon.green {
background: #f0fdf4;
color: #16a34a;
}
.inv-kpi-card__icon.purple {
background: #faf5ff;
color: #9333ea;
}
.inv-kpi-card__icon.emerald {
background: #ecfdf5;
color: #059669;
}
.inv-kpi-card__info {
display: flex;
flex-direction: column;
}
.inv-kpi-card__label {
font-size: 12px;
font-weight: 600;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.inv-kpi-card__val {
font-size: 24px;
font-weight: 800;
color: #0f172a;
margin-top: 2px;
line-height: 1;
}
/* Toolbar */
.inv-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.inv-search {
position: relative;
flex: 1;
min-width: 280px;
max-width: 460px;
}
.inv-search input {
width: 100%;
padding: 10px 14px 10px 38px;
border: 1px solid #cbd5e1;
border-radius: 10px;
font-size: 13.5px;
background: #ffffff;
color: #0f172a;
outline: none;
box-sizing: border-box;
transition:
border-color 0.15s,
box-shadow 0.15s;
}
.inv-search input:focus {
border-color: #2563eb;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12);
}
.inv-search__ic {
position: absolute;
left: 13px;
top: 50%;
transform: translateY(-50%);
color: #94a3b8;
font-size: 13px;
pointer-events: none;
}
.inv-search__clear {
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
border: none;
background: transparent;
color: #94a3b8;
cursor: pointer;
padding: 4px;
}
.inv-search__clear:hover {
color: #ef4444;
}
.inv-filter-chips {
display: flex;
align-items: center;
gap: 8px;
}
.inv-chip {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
border-radius: 999px;
border: 1px solid #e2e8f0;
background: #ffffff;
color: #475569;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s ease;
}
.inv-chip:hover {
background: #f8fafc;
border-color: #cbd5e1;
}
.inv-chip.active {
background: #1e40af;
border-color: #1e40af;
color: #ffffff !important;
}
.inv-chip.active-chip.active {
background: #16a34a;
border-color: #16a34a;
color: #ffffff !important;
}
.inv-chip.exhausted-chip.active {
background: #475569;
border-color: #475569;
color: #ffffff !important;
}
.inv-chip__dot {
width: 7px;
height: 7px;
border-radius: 50%;
}
.inv-chip__dot.green {
background: #16a34a;
}
.inv-chip__dot.grey {
background: #64748b;
}
.inv-chip.active .inv-chip__dot {
background: #ffffff;
}
.inv-chip__count {
padding: 2px 7px;
border-radius: 999px;
background: rgba(0, 0, 0, 0.07);
font-size: 11px;
font-weight: 700;
}
.inv-chip.active .inv-chip__count {
background: rgba(255, 255, 255, 0.25);
color: #ffffff;
}
/* Card & Table */
.inv-card {
background: #ffffff;
border-radius: 14px;
border: 1px solid #e2e8f0;
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.05);
overflow: hidden;
}
.inv-card__head {
padding: 16px 20px;
border-bottom: 1px solid #e2e8f0;
background: linear-gradient(180deg, #f8fafc 0%, #ffffff 100%);
}
.inv-card__title {
display: flex;
align-items: center;
gap: 8px;
font-size: 15px;
font-weight: 700;
color: #0f172a;
}
.inv-card__title-ic {
color: #2563eb;
}
.inv-card__count {
margin-left: 6px;
padding: 2px 10px;
border-radius: 999px;
background: #eff6ff;
color: #1e40af;
font-size: 11px;
font-weight: 700;
}
.inv-table-wrap {
overflow-x: auto;
}
.inv-table {
width: 100%;
border-collapse: collapse;
}
.inv-table th {
background: #f1f5f9;
color: #475569;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 12px 16px;
border-bottom: 1px solid #e2e8f0;
white-space: nowrap;
}
.inv-table td {
padding: 12px 16px;
color: #334155;
border-bottom: 1px solid #f1f5f9;
font-size: 13.5px;
vertical-align: middle;
}
.inv-table tr:hover {
background: #f8fafc;
}
.inv-cell-bank {
display: flex;
align-items: center;
gap: 8px;
}
.bank-icon {
color: #2563eb;
font-size: 14px;
}
.inv-mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.inv-strong {
font-weight: 700;
color: #0f172a;
}
.inv-muted {
color: #64748b;
}
.inv-num {
text-align: right;
}
.inv-leaves-badge {
display: inline-block;
padding: 4px 10px;
border-radius: 6px;
background: #f0fdf4;
color: #16a34a;
font-weight: 700;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.inv-leaves-badge.low {
background: #fef2f2;
color: #dc2626;
}
.inv-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
}
.inv-badge__dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}
.inv-badge.active {
background: #dcfce7;
color: #15803d;
}
.inv-badge.exhausted {
background: #f1f5f9;
color: #64748b;
}
.inv-actions-col {
text-align: right;
}
/* Buttons */
.inv-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 9px 18px;
border-radius: 10px;
font-size: 13.5px;
font-weight: 600;
cursor: pointer;
border: 1px solid transparent;
transition: all 0.15s ease;
}
.inv-btn.primary {
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
color: #ffffff;
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.3);
}
.inv-btn.primary:hover {
transform: translateY(-1px);
box-shadow: 0 4px 14px rgba(37, 99, 235, 0.4);
}
.inv-btn.ghost {
background: #ffffff;
border-color: #cbd5e1;
color: #475569;
}
.inv-btn.ghost:hover {
background: #f8fafc;
color: #0f172a;
}
.inv-btn.small {
padding: 6px 12px;
font-size: 12px;
border-radius: 8px;
}
.inv-empty {
text-align: center;
padding: 48px 20px;
color: #64748b;
}
.inv-empty__ic {
font-size: 36px;
color: #cbd5e1;
margin-bottom: 8px;
}
.inv-empty__title {
font-size: 16px;
font-weight: 700;
color: #334155;
margin: 0 0 4px 0;
}
.inv-empty__sub {
font-size: 13px;
margin: 0;
}
`
]
})
@@ -518,9 +140,14 @@ export class DdInventoryListComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
/**
* The dividend this visit is drawing a draft for, when the clerk arrived from a dividend claim
* rather than from the menu.
*
* Null on an ordinary visit, which is what keeps this page's normal behaviour untouched — the
* banner, the relabelled action and the forwarded query params all hang off this being set.
*/
dividendContext: { lockId: number; amount: number; beneficiary: string; financialYear: string } | null = null;
searchText = '';
selectedStatusFilter = 'ALL';
displayedColumns: string[] = [
'id',
@@ -541,6 +168,13 @@ export class DdInventoryListComponent implements OnInit {
this.loadInventories();
}
/**
* Picks up the dividend a claim sent us here to pay out.
*
* Read from the URL rather than handed over in a service so a refreshed or bookmarked page still
* knows which lock it is drawing against — losing it silently would issue an ordinary counter
* draft against the member instead of settling their dividend.
*/
private readDividendContext(): void {
const params = this.route.snapshot.queryParams;
const lockId = Number(params['dividendLockId']);
@@ -555,6 +189,7 @@ export class DdInventoryListComponent implements OnInit {
};
}
/** The dividend context to carry onto the leaflets page; empty on an ordinary visit. */
get dividendQueryParams(): Record<string, string | number> {
return this.dividendContext
? {
@@ -566,6 +201,7 @@ export class DdInventoryListComponent implements OnInit {
: {};
}
/** Abandons the claim and returns to the dividend dashboard, leaving the lock untouched. */
cancelDividendClaim(): void {
this.router.navigate([
'/clerk',
@@ -576,7 +212,7 @@ export class DdInventoryListComponent implements OnInit {
loadInventories(): void {
this.sponsorDDService.getInventories().subscribe({
next: (data) => {
this.dataSource = data || [];
this.dataSource = data;
},
error: (err) => {
this.snackBar.open('Error loading inventories: ' + (err.error?.message || err.message), 'Close', {
@@ -586,44 +222,9 @@ export class DdInventoryListComponent implements OnInit {
});
}
get totalBatches(): number {
return this.dataSource.length;
}
get activeBatchesCount(): number {
return this.dataSource.filter((x) => (x.status || '').toUpperCase() === 'ACTIVE').length;
}
get totalLeavesCount(): number {
return this.dataSource.reduce((acc, curr) => acc + (Number(curr.totalLeaves) || 0), 0);
}
get totalAvailableLeaves(): number {
return this.dataSource.reduce((acc, curr) => acc + (Number(curr.availableLeaves) || 0), 0);
}
get filteredData(): any[] {
let list = this.dataSource;
if (this.selectedStatusFilter !== 'ALL') {
list = list.filter((item) => (item.status || '').toUpperCase() === this.selectedStatusFilter);
}
if (this.searchText.trim()) {
const q = this.searchText.trim().toLowerCase();
list = list.filter(
(item) =>
String(item.id).toLowerCase().includes(q) ||
(item.sponsorBankName || '').toLowerCase().includes(q) ||
(item.branchName || '').toLowerCase().includes(q) ||
String(item.seriesStart).includes(q) ||
String(item.seriesEnd).includes(q)
);
}
return list;
}
openAddInventoryDialog(): void {
const dialogRef = this.dialog.open(AddInventoryDialogComponent, {
width: '520px',
width: '500px',
disableClose: true
});
+214 -289
View File
@@ -5,326 +5,251 @@
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="ddreg-container">
<!-- Hero header -->
<div class="ddreg-header">
<div class="ddreg-header__left">
<button class="ddreg-back" routerLink="/sponsor-dd/inventory" matTooltip="Back to Inventories">
<fa-icon icon="arrow-left"></fa-icon>
<div class="leaflet-container">
<div class="header-row">
<div class="title-area">
<button mat-button routerLink="/sponsor-dd/inventory" style="margin-bottom: 8px; color: #64748b; padding-left: 0">
&larr; Back to Inventories
</button>
<fa-icon icon="money-check-alt" class="ddreg-header__icon"></fa-icon>
<div>
<h2 class="ddreg-header__title">DD Leaflet Registry</h2>
<div class="ddreg-header__sub" *ngIf="inventoryDetails">
Sponsor Bank <strong>{{ inventoryDetails.sponsorBankName }}</strong> · Series
<strong>{{ inventoryDetails.seriesStart }}–{{ inventoryDetails.seriesEnd }}</strong>
</div>
</div>
</div>
</div>
<!-- KPI Metrics Row -->
<div class="ddreg-kpi-grid">
<div class="ddreg-kpi-card">
<div class="ddreg-kpi-card__icon blue">
<fa-icon icon="money-check-alt"></fa-icon>
</div>
<div class="ddreg-kpi-card__info">
<span class="ddreg-kpi-card__label">Total Stock</span>
<span class="ddreg-kpi-card__val">{{ dataSource.data.length }}</span>
</div>
</div>
<div class="ddreg-kpi-card">
<div class="ddreg-kpi-card__icon green">
<fa-icon icon="check-circle"></fa-icon>
</div>
<div class="ddreg-kpi-card__info">
<span class="ddreg-kpi-card__label">Available</span>
<span class="ddreg-kpi-card__val">{{ statusCount('AVAILABLE') }}</span>
</div>
</div>
<div class="ddreg-kpi-card">
<div class="ddreg-kpi-card__icon indigo">
<fa-icon icon="file-alt"></fa-icon>
</div>
<div class="ddreg-kpi-card__info">
<span class="ddreg-kpi-card__label">Issued</span>
<span class="ddreg-kpi-card__val">{{ statusCount('ISSUED') }}</span>
</div>
</div>
<div class="ddreg-kpi-card">
<div class="ddreg-kpi-card__icon amber">
<fa-icon icon="ban"></fa-icon>
</div>
<div class="ddreg-kpi-card__info">
<span class="ddreg-kpi-card__label">Cancelled / Stopped</span>
<span class="ddreg-kpi-card__val">{{ statusCount('CANCELLED') + statusCount('STOPPED') }}</span>
</div>
</div>
<div class="ddreg-kpi-card">
<div class="ddreg-kpi-card__icon slate">
<fa-icon icon="times-circle"></fa-icon>
</div>
<div class="ddreg-kpi-card__info">
<span class="ddreg-kpi-card__label">Revoked / Spoiled</span>
<span class="ddreg-kpi-card__val">{{ statusCount('REVOKED') }}</span>
</div>
<h2>DD Leaflet Registry</h2>
<p *ngIf="inventoryDetails">
Sponsor Bank: <strong>{{ inventoryDetails.sponsorBankName }}</strong> | Series Range:
<strong>{{ inventoryDetails.seriesStart }} - {{ inventoryDetails.seriesEnd }}</strong>
</p>
</div>
</div>
<!-- Says why the clerk is here when they arrived from a dividend claim, and which member is
waiting. Without it a registry of DD numbers gives no clue what the next click commits to. -->
<div class="ddreg-dividend" *ngIf="dividendContext">
<fa-icon icon="hand-holding-usd" class="ddreg-dividend__ic"></fa-icon>
<div class="ddreg-dividend__text">
<div class="dividend-banner" *ngIf="dividendContext">
<div class="dividend-banner__text">
<strong>Issuing a dividend Demand Draft</strong>
<span>
{{ dividendContext.beneficiary }} · ₹{{ dividendContext.amount | number: '1.2-2' }}
{{ dividendContext.beneficiary }} · {{ dividendContext.amount | number: '1.2-2' }}
<ng-container *ngIf="dividendContext.financialYear"> · FY {{ dividendContext.financialYear }}</ng-container>
</span>
<span class="ddreg-dividend__hint">Pick an available leaflet below and press "Draw for Dividend".</span>
<span class="dividend-banner__hint">Pick an available leaflet below and press Issue.</span>
</div>
<button class="ddreg-btn ghost small" (click)="cancelDividendClaim()">Cancel</button>
<button mat-button class="action-btn" (click)="cancelDividendClaim()">Cancel</button>
</div>
<!-- Toolbar: search + date scope -->
<div class="ddreg-toolbar">
<div class="ddreg-toolbar__search">
<fa-icon icon="search" class="ddreg-toolbar__search-ic"></fa-icon>
<input
type="text"
[value]="searchText"
(input)="onSearchChange($event.target.value)"
placeholder="Search by DD Leaflet No., e.g. 3005"
/>
<button class="ddreg-toolbar__clear" *ngIf="searchText" (click)="onSearchChange('')" matTooltip="Clear">
<fa-icon icon="times"></fa-icon>
</button>
</div>
<button class="ddreg-btn ghost" [class.active]="todayOnly" (click)="toggleTodayOnly()">
<fa-icon icon="calendar-day"></fa-icon>
<mat-form-field appearance="outline" class="search-field">
<mat-label>Search by DD Leaflet No.</mat-label>
<input matInput [value]="searchText" (input)="onSearchChange($event.target.value)" placeholder="e.g. 3005" />
</mat-form-field>
<div class="status-filter-row">
<button mat-stroked-button color="accent" [class.filter-active]="todayOnly" (click)="toggleTodayOnly()">
{{ todayOnly ? "Today's Issued DDs Only" : 'Showing All Dates' }}
</button>
</div>
<!-- Status filter chips — doubles as an at-a-glance count of every leaflet state -->
<div class="ddreg-chips">
<button
mat-stroked-button
*ngFor="let status of statusOptions"
class="ddreg-chip"
[ngClass]="'s-' + status.toLowerCase()"
[class.active]="selectedStatus === status"
[color]="selectedStatus === status ? 'primary' : undefined"
[class.filter-active]="selectedStatus === status"
(click)="filterByStatus(status)"
>
<span class="ddreg-chip__dot"></span>
{{ status === 'ALL' ? 'All' : (status | titlecase) }}
<span class="ddreg-chip__count">{{ statusCount(status) }}</span>
{{ status === 'ALL' ? 'All' : status }}
<span class="filter-count">({{ statusCount(status) }})</span>
</button>
</div>
<p class="ddreg-hint" *ngIf="todayOnly">
<fa-icon icon="info-circle"></fa-icon>
Only DDs actually ISSUED today are shown — Available, Cancelled, Stopped and Cleared are hidden. Click "Showing All
<p class="hint-text" *ngIf="todayOnly">
Only DDs actually ISSUED today are shown - AVAILABLE, CANCELLED, STOPPED and CLEARED are hidden. Click "Showing All
Dates" above to see everything.
</p>
<!-- Leaflets table -->
<div class="ddreg-card">
<div class="ddreg-card__head">
<div class="ddreg-card__title">
<fa-icon icon="list" class="ddreg-card__title-ic"></fa-icon>
Leaflets
<span class="ddreg-card__count">{{ dataSource.filteredData.length }} of {{ dataSource.data.length }}</span>
</div>
</div>
<div class="ddreg-table-wrap">
<table mat-table [dataSource]="dataSource" class="ddreg-table">
<!-- DD Number Column -->
<ng-container matColumnDef="ddNumber">
<th mat-header-cell *matHeaderCellDef>DD Leaflet No.</th>
<td mat-cell *matCellDef="let element" class="ddreg-mono ddreg-strong">{{ element.ddNumber }}</td>
</ng-container>
<mat-card>
<table mat-table [dataSource]="dataSource">
<!-- DD Number Column -->
<ng-container matColumnDef="ddNumber">
<th mat-header-cell *matHeaderCellDef>DD Leaflet No.</th>
<td mat-cell *matCellDef="let element">
<strong>{{ element.ddNumber }}</strong>
</td>
</ng-container>
<!-- Status Column -->
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef>Status</th>
<td mat-cell *matCellDef="let element">
<span class="ddreg-badge" [ngClass]="element.status.toLowerCase()">
<span class="ddreg-badge__dot"></span>
{{ element.status | titlecase }}
<!-- Status Column -->
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef>Status</th>
<td mat-cell *matCellDef="let element">
<span class="status-badge" [ngClass]="element.status.toLowerCase()">
{{ element.status }}
</span>
</td>
</ng-container>
<!-- Face Value Column -->
<ng-container matColumnDef="faceValue">
<th mat-header-cell *matHeaderCellDef>Face Value</th>
<td mat-cell *matCellDef="let element">
{{ element.faceValue > 0 ? (element.faceValue | number: '1.2-2') : '-' }}
</td>
</ng-container>
<!-- Client Name Column -->
<ng-container matColumnDef="issuedToClientName">
<th mat-header-cell *matHeaderCellDef>Purchaser / Client</th>
<!-- A dividend payout has no purchasing client: the society raised the draft out of its own
Dividend Payable to pay the member. Falling back to the walk-in purchaser name shows
that, instead of repeating the beneficiary and reading as a counter sale. -->
<td mat-cell *matCellDef="let element">
{{ element.issuedToClientName || element.walkinPurchaserName || '-' }}
</td>
</ng-container>
<!-- Beneficiary Column -->
<ng-container matColumnDef="beneficiaryName">
<th mat-header-cell *matHeaderCellDef>Beneficiary</th>
<td mat-cell *matCellDef="let element">{{ element.beneficiaryName || '-' }}</td>
</ng-container>
<!-- Issued Date Column -->
<ng-container matColumnDef="issuedDate">
<th mat-header-cell *matHeaderCellDef>Issued Date</th>
<td mat-cell *matCellDef="let element">{{ element.issuedDate || '-' }}</td>
</ng-container>
<!-- Advice Status Column -->
<ng-container matColumnDef="adviceStatus">
<th mat-header-cell *matHeaderCellDef>Advice Status</th>
<td mat-cell *matCellDef="let element">
{{ element.status === 'ISSUED' ? element.adviceStatus : '-' }}
</td>
</ng-container>
<!-- Actions Column -->
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef>Actions</th>
<td mat-cell *matCellDef="let element">
<div class="action-btn-group">
<!-- An ordinary draft is raised from the counter, not from this registry: Transaction
Entry carries the purchaser, the funding and the denomination entry a DD needs,
while this screen is the leaflet register — what each leaflet is and what has since
happened to it.
The button stays for a dividend claim, which arrives here from the dividend
dashboard with the beneficiary and amount already settled and only needs a leaflet
picked; there is no payment to collect, so there is nothing for the counter to do. -->
<button
mat-flat-button
color="primary"
size="small"
*ngIf="dividendContext && element.status === 'AVAILABLE' && !element.pendingIssueRequestId"
(click)="openIssueDialog(element)"
>
Draw for Dividend
</button>
<span class="pending-badge" *ngIf="element.status === 'AVAILABLE' && element.pendingIssueRequestId">
Pending Approval (Req #{{ element.pendingIssueRequestId }})
</span>
<!-- Corrects the beneficiary, or the branch and city the draft is payable at. The
draft itself is unchanged until a manager approves, so a second request is not
offered while one is still pending. -->
<button
mat-stroked-button
color="primary"
size="small"
*ngIf="element.status === 'ISSUED' && !element.pendingAmendmentRequestId"
(click)="openAmendDialog(element)"
>
Edit
</button>
<span class="pending-badge" *ngIf="element.status === 'ISSUED' && element.pendingAmendmentRequestId">
Amendment Pending (Req #{{ element.pendingAmendmentRequestId }})
</span>
<button
mat-stroked-button
color="warn"
size="small"
*ngIf="element.status === 'ISSUED'"
(click)="openCancelDialog(element)"
>
Cancel Draft
</button>
<button
mat-button
color="warn"
size="small"
*ngIf="element.status === 'ISSUED'"
(click)="onStopPayment(element)"
>
Stop Payment
</button>
<button
mat-stroked-button
size="small"
*ngIf="element.status === 'ISSUED' && !element.printCount"
(click)="openPrintDialog(element)"
>
Print
</button>
<button
mat-stroked-button
color="primary"
size="small"
*ngIf="
element.status === 'ISSUED' && element.printCount > 0 && element.approvedUnconsumedReprintRequestId
"
(click)="openPrintDialog(element)"
>
Print (Reprint Approved)
</button>
<button
mat-stroked-button
size="small"
*ngIf="
element.status === 'ISSUED' &&
element.printCount > 0 &&
!element.approvedUnconsumedReprintRequestId &&
!element.pendingReprintRequestId
"
(click)="openRequestReprintDialog(element)"
>
Request Reprint
</button>
<span class="pending-badge" *ngIf="element.status === 'ISSUED' && element.pendingReprintRequestId">
Reprint Pending Approval (Req #{{ element.pendingReprintRequestId }})
</span>
</td>
</ng-container>
<!-- Face Value Column -->
<ng-container matColumnDef="faceValue">
<th mat-header-cell *matHeaderCellDef class="ddreg-num">Face Value</th>
<td mat-cell *matCellDef="let element" class="ddreg-num ddreg-mono">
{{ element.faceValue > 0 ? '₹' + (element.faceValue | number: '1.2-2') : '—' }}
</td>
</ng-container>
<!-- The leaflet is ruined and cannot be handed over. Offered beside Request Reprint
because the two look alike from the counter: reprint when the paper survived, revoke
when it did not. -->
<button
mat-stroked-button
color="warn"
size="small"
*ngIf="element.status === 'ISSUED' && !element.pendingRevokeRequestId"
(click)="openRevokeDialog(element)"
>
Revoke
</button>
<span class="pending-badge" *ngIf="element.status === 'ISSUED' && element.pendingRevokeRequestId">
Revoke Pending Approval (Req #{{ element.pendingRevokeRequestId }})
</span>
<!-- Client Name Column -->
<ng-container matColumnDef="issuedToClientName">
<th mat-header-cell *matHeaderCellDef>Purchaser / Client</th>
<!-- A dividend payout has no purchasing client: the society raised the draft out of its own
Dividend Payable to pay the member. Falling back to the walk-in purchaser name shows
that, instead of repeating the beneficiary and reading as a counter sale. -->
<td mat-cell *matCellDef="let element">
{{ element.issuedToClientName || element.walkinPurchaserName || '—' }}
</td>
</ng-container>
<!-- Both ends of a replacement, so the pad reconciles: a revoked leaflet says where its
draft went, and the blank that took it says what it stands in for. -->
<span class="pending-badge" *ngIf="element.revokedReplacedByDdNumber">
Revoked → replaced by DD {{ element.revokedReplacedByDdNumber }}
</span>
<span class="pending-badge" *ngIf="element.revokedReplacementOfDdNumber">
Replaces spoiled DD {{ element.revokedReplacementOfDdNumber }}
</span>
</div>
</td>
</ng-container>
<!-- Beneficiary Column -->
<ng-container matColumnDef="beneficiaryName">
<th mat-header-cell *matHeaderCellDef>Beneficiary</th>
<td mat-cell *matCellDef="let element">{{ element.beneficiaryName || '—' }}</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
</table>
<!-- Issued Date Column -->
<ng-container matColumnDef="issuedDate">
<th mat-header-cell *matHeaderCellDef>Issued Date</th>
<td mat-cell *matCellDef="let element" class="ddreg-mono">{{ element.issuedDate || '—' }}</td>
</ng-container>
<!-- Advice Status Column -->
<ng-container matColumnDef="adviceStatus">
<th mat-header-cell *matHeaderCellDef>Advice Status</th>
<td mat-cell *matCellDef="let element" class="ddreg-muted">
{{ element.status === 'ISSUED' ? (element.adviceStatus | titlecase) : '—' }}
</td>
</ng-container>
<!-- Actions Column -->
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef class="ddreg-actions-col">Actions</th>
<td mat-cell *matCellDef="let element" class="ddreg-actions-col">
<div class="ddreg-actions">
<!-- An ordinary draft is raised from the counter, not from this registry: Transaction
Entry carries the purchaser, the funding and the denomination entry a DD needs,
while this screen is the leaflet register — what each leaflet is and what has
since happened to it.
The button stays for a dividend claim, which arrives here from the dividend
dashboard with the beneficiary and amount already settled and only needs a
leaflet picked; there is no payment to collect, so there is nothing for the
counter to do. -->
<button
class="ddreg-btn primary small"
*ngIf="dividendContext && element.status === 'AVAILABLE' && !element.pendingIssueRequestId"
(click)="openIssueDialog(element)"
>
<fa-icon icon="hand-holding-usd"></fa-icon>
Draw for Dividend
</button>
<span class="ddreg-pending" *ngIf="element.status === 'AVAILABLE' && element.pendingIssueRequestId">
<fa-icon icon="clock"></fa-icon>
Pending Approval (Req #{{ element.pendingIssueRequestId }})
</span>
<!-- Print is the action a clerk reaches for almost every time an issued draft is
opened, so it stays a visible button; everything rarer (edit, reprint, revoke,
stop, cancel) is one tap away behind the ⋮ menu instead of competing for space. -->
<button
class="ddreg-btn primary small"
*ngIf="element.status === 'ISSUED' && !element.printCount"
(click)="openPrintDialog(element)"
>
<fa-icon icon="print"></fa-icon>
Print
</button>
<button
class="ddreg-btn primary small"
*ngIf="
element.status === 'ISSUED' && element.printCount > 0 && element.approvedUnconsumedReprintRequestId
"
(click)="openPrintDialog(element)"
>
<fa-icon icon="print"></fa-icon>
Print (Reprint Approved)
</button>
<ng-container *ngIf="element.status === 'ISSUED'">
<button class="ddreg-act more" [matMenuTriggerFor]="rowMenu" matTooltip="More actions">
<fa-icon icon="ellipsis-v"></fa-icon>
</button>
<mat-menu #rowMenu="matMenu" xPosition="before" class="ddreg-menu">
<!-- Corrects the beneficiary, or the branch and city the draft is payable at. The
draft itself is unchanged until a manager approves, so a second request is
not offered while one is still pending. -->
<button mat-menu-item *ngIf="!element.pendingAmendmentRequestId" (click)="openAmendDialog(element)">
<fa-icon icon="pencil-alt" class="ddreg-menu__ic"></fa-icon>
Edit
</button>
<button
mat-menu-item
*ngIf="
element.printCount > 0 &&
!element.approvedUnconsumedReprintRequestId &&
!element.pendingReprintRequestId
"
(click)="openRequestReprintDialog(element)"
>
<fa-icon icon="redo" class="ddreg-menu__ic"></fa-icon>
Request Reprint
</button>
<!-- The leaflet is ruined and cannot be handed over. Grouped beside Request Reprint
because the two look alike from the counter: reprint when the paper survived,
revoke when it did not. -->
<button mat-menu-item *ngIf="!element.pendingRevokeRequestId" (click)="openRevokeDialog(element)">
<fa-icon icon="ban" class="ddreg-menu__ic warn"></fa-icon>
Revoke
</button>
<button mat-menu-item (click)="onStopPayment(element)">
<fa-icon icon="hand-paper" class="ddreg-menu__ic warn"></fa-icon>
Stop Payment
</button>
<button mat-menu-item (click)="openCancelDialog(element)">
<fa-icon icon="times-circle" class="ddreg-menu__ic warn"></fa-icon>
Cancel Draft
</button>
</mat-menu>
</ng-container>
<span class="ddreg-pending" *ngIf="element.status === 'ISSUED' && element.pendingAmendmentRequestId">
<fa-icon icon="clock"></fa-icon>
Amendment Pending (Req #{{ element.pendingAmendmentRequestId }})
</span>
<span class="ddreg-pending" *ngIf="element.status === 'ISSUED' && element.pendingReprintRequestId">
<fa-icon icon="clock"></fa-icon>
Reprint Pending (Req #{{ element.pendingReprintRequestId }})
</span>
<span class="ddreg-pending" *ngIf="element.status === 'ISSUED' && element.pendingRevokeRequestId">
<fa-icon icon="clock"></fa-icon>
Revoke Pending (Req #{{ element.pendingRevokeRequestId }})
</span>
<!-- Both ends of a replacement, so the pad reconciles: a revoked leaflet says where
its draft went, and the blank that took it says what it stands in for. -->
<span class="ddreg-info" *ngIf="element.revokedReplacedByDdNumber">
<fa-icon icon="arrow-right"></fa-icon>
Replaced by DD {{ element.revokedReplacedByDdNumber }}
</span>
<span class="ddreg-info" *ngIf="element.revokedReplacementOfDdNumber">
<fa-icon icon="undo"></fa-icon>
Replaces spoiled DD {{ element.revokedReplacementOfDdNumber }}
</span>
</div>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
</table>
<div class="ddreg-empty" *ngIf="dataSource.filteredData.length === 0">
<fa-icon [icon]="dataSource.data.length === 0 ? 'spinner' : 'inbox'"></fa-icon>
{{ dataSource.data.length === 0 ? 'Loading leaflets…' : 'No leaflets match the selected filter.' }}
</div>
<div
*ngIf="dataSource.filteredData.length === 0"
class="no-records-row"
style="text-align: center; padding: 32px; color: #64748b"
>
{{ dataSource.data.length === 0 ? 'Loading leaflets...' : 'No leaflets match the selected filter.' }}
</div>
<mat-paginator [pageSizeOptions]="[25, 50, 100, 250]" [pageSize]="25" showFirstLastButtons></mat-paginator>
</div>
</mat-card>
</div>
+71 -553
View File
@@ -21,9 +21,6 @@ import { MatSelectModule } from '@angular/material/select';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { MatNativeDateModule } from '@angular/material/core';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { MatMenuModule } from '@angular/material/menu';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { HttpClient } from '@angular/common/http';
import { debounceTime } from 'rxjs/operators';
@@ -43,624 +40,145 @@ import { DividendClaimRequestService } from '../../clerk/dividend-claim-request.
MatDialogModule,
MatSnackBarModule,
MatFormFieldModule,
MatInputModule,
MatMenuModule,
MatIconModule,
MatTooltipModule,
FaIconComponent
MatInputModule
],
templateUrl: './dd-list.component.html',
styles: [
`
/* Same visual language as users.component.scss (gradient header icon, KPI-style chips, card
table with sticky head + row-hover, icon-button/overflow-menu actions) so this registry
reads as part of the same app rather than a one-off style. */
.ddreg-container {
padding: 20px 24px;
max-width: 1320px;
.leaflet-container {
padding: 24px;
max-width: 1200px;
margin: 0 auto;
}
/* ─── Header ─── */
.ddreg-header {
.dividend-banner {
display: flex;
align-items: center;
margin-bottom: 16px;
}
.ddreg-header__left {
display: flex;
align-items: center;
gap: 12px;
}
.ddreg-back {
width: 36px;
height: 36px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 9px;
border: 1px solid #d0d5dd;
background: #fff;
color: #475569;
cursor: pointer;
transition: all 0.15s;
}
.ddreg-back:hover {
background: #f8fafc;
border-color: #94a3b8;
color: #1e293b;
}
.ddreg-header__icon {
width: 44px;
height: 44px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 12px;
background: linear-gradient(135deg, #1e88e5 0%, #7c3aed 100%);
color: #fff;
font-size: 18px;
box-shadow: 0 2px 8px rgb(29 78 216 / 25%);
}
.ddreg-header__title {
margin: 0;
font-size: 20px;
font-weight: 800;
color: #0d1b2a;
line-height: 1.1;
}
.ddreg-header__sub {
margin-top: 2px;
font-size: 12.5px;
color: #64748b;
}
/* ─── Dividend context banner ─── */
.ddreg-dividend {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 16px;
gap: 16px;
margin-bottom: 20px;
padding: 12px 16px;
border: 1px solid #c7d2fe;
border-left: 4px solid #4f46e5;
border-radius: 10px;
background: linear-gradient(180deg, #eef2ff 0%, #f5f3ff 100%);
border-radius: 8px;
background-color: #eef2ff;
}
.ddreg-dividend__ic {
width: 34px;
height: 34px;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 9px;
background: #4f46e5;
color: #fff;
font-size: 14px;
}
.ddreg-dividend__text {
.dividend-banner__text {
display: flex;
flex-direction: column;
gap: 2px;
font-size: 13px;
color: #312e81;
}
.ddreg-dividend__hint {
.dividend-banner__hint {
color: #4338ca;
font-size: 12px;
}
.ddreg-dividend .ddreg-btn {
.dividend-banner button {
margin-left: auto;
}
/* ─── Buttons ─── */
.ddreg-btn {
display: inline-flex;
.header-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
padding: 8px 16px;
border-radius: 9px;
font-size: 13px;
margin-bottom: 24px;
}
.title-area h2 {
margin: 0 0 4px 0;
font-size: 24px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
border: 1px solid transparent;
background: #fff;
color: #1e293b;
}
.ddreg-btn fa-icon {
font-size: 12px;
}
.ddreg-btn.ghost {
border-color: #d0d5dd;
color: #475569;
}
.ddreg-btn.ghost:hover {
background: #f8fafc;
border-color: #94a3b8;
color: #0d1b2a;
}
.ddreg-btn.ghost.active {
background: #ede9fe;
border-color: #c4b5fd;
color: #6d28d9;
}
.ddreg-btn.primary {
background: linear-gradient(135deg, #1e88e5 0%, #1e40af 100%);
border-color: #1e40af;
color: #fff;
box-shadow: 0 2px 6px -2px rgb(30 64 175 / 35%);
}
.ddreg-btn.primary:hover {
box-shadow: 0 4px 12px -3px rgb(30 64 175 / 45%);
transform: translateY(-1px);
}
.ddreg-btn.small {
padding: 6px 12px;
font-size: 12px;
}
/* ─── Toolbar ─── */
.ddreg-toolbar {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.ddreg-toolbar__search {
position: relative;
flex: 1;
min-width: 260px;
max-width: 420px;
}
.ddreg-toolbar__search input {
width: 100%;
padding: 9px 34px;
border: 1px solid #d0d5dd;
border-radius: 9px;
font-size: 13px;
background: #fff;
color: #0d1b2a;
transition: all 0.15s;
box-sizing: border-box;
}
.ddreg-toolbar__search input:focus {
outline: none;
border-color: #1e88e5;
box-shadow: 0 0 0 3px rgb(30 136 229 / 12%);
}
.ddreg-toolbar__search-ic {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: #94a3b8;
font-size: 12px;
pointer-events: none;
}
.ddreg-toolbar__clear {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
width: 22px;
height: 22px;
border-radius: 50%;
border: 1px solid #e2e8f0;
background: #fff;
color: #94a3b8;
font-size: 10px;
cursor: pointer;
}
.ddreg-toolbar__clear:hover {
background: #fef2f2;
color: #ef4444;
border-color: #fecaca;
}
/* ─── KPI Cards ─── */
.ddreg-kpi-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 14px;
margin-bottom: 20px;
}
.ddreg-kpi-card {
background: #ffffff;
border-radius: 12px;
padding: 16px 18px;
border: 1px solid #e2e8f0;
display: flex;
align-items: center;
gap: 14px;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.04);
transition:
transform 0.15s ease,
box-shadow 0.15s ease;
}
.ddreg-kpi-card:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(15, 23, 42, 0.08);
}
.ddreg-kpi-card__icon {
width: 42px;
height: 42px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
flex-shrink: 0;
}
.ddreg-kpi-card__icon.blue {
background: #eff6ff;
color: #2563eb;
}
.ddreg-kpi-card__icon.green {
background: #f0fdf4;
color: #16a34a;
}
.ddreg-kpi-card__icon.indigo {
background: #eef2ff;
color: #4f46e5;
}
.ddreg-kpi-card__icon.amber {
background: #fffbeb;
color: #d97706;
}
.ddreg-kpi-card__icon.slate {
background: #f8fafc;
.title-area p {
margin: 0;
color: #64748b;
font-size: 14px;
}
.ddreg-kpi-card__info {
display: flex;
flex-direction: column;
}
.ddreg-kpi-card__label {
font-size: 11.5px;
font-weight: 600;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.ddreg-kpi-card__val {
font-size: 22px;
font-weight: 800;
color: #0f172a;
margin-top: 1px;
line-height: 1;
}
/* ─── Status filter chips (doubles as the count-at-a-glance) ─── */
.ddreg-chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
.search-field {
width: 320px;
display: block;
margin-bottom: 8px;
}
.ddreg-chip {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 7px 12px;
border-radius: 999px;
border: 1px solid #e2e8f0;
background: #fff;
color: #475569;
font-size: 12.5px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
}
.ddreg-chip:hover {
border-color: #cbd5e1;
background: #f8fafc;
}
.ddreg-chip__dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: currentColor;
opacity: 0.6;
}
.ddreg-chip__count {
padding: 1px 7px;
border-radius: 999px;
background: rgb(0 0 0 / 6%);
font-size: 11px;
font-weight: 700;
}
.ddreg-chip.s-available {
color: #15803d;
}
.ddreg-chip.s-issued {
color: #1d4ed8;
}
.ddreg-chip.s-cancelled {
color: #b91c1c;
}
.ddreg-chip.s-stopped {
color: #b45309;
}
.ddreg-chip.s-cleared,
.ddreg-chip.s-revoked {
color: #475569;
}
.ddreg-chip.s-all {
color: #4338ca;
}
.ddreg-chip.active {
background: #1e40af;
border-color: #1e40af;
color: #ffffff !important;
}
.ddreg-chip.active.s-available {
background: #16a34a;
border-color: #16a34a;
}
.ddreg-chip.active.s-issued {
background: #2563eb;
border-color: #2563eb;
}
.ddreg-chip.active.s-cancelled {
background: #dc2626;
border-color: #dc2626;
}
.ddreg-chip.active.s-stopped {
background: #d97706;
border-color: #d97706;
}
.ddreg-chip.active.s-cleared,
.ddreg-chip.active.s-revoked {
background: #475569;
border-color: #475569;
}
.ddreg-chip.active.s-all {
background: #4f46e5;
border-color: #4f46e5;
}
.ddreg-chip.active .ddreg-chip__dot {
background: #fff;
opacity: 0.9;
}
.ddreg-chip.active .ddreg-chip__count {
background: rgb(255 255 255 / 25%);
color: #fff;
}
.ddreg-hint {
display: flex;
align-items: center;
gap: 6px;
color: #64748b;
font-size: 12.5px;
margin: 0 0 16px;
}
/* ─── Card wrapper ─── */
.ddreg-card {
background: #fff;
border: 1px solid #e2e8f0;
.mat-mdc-card {
border-radius: 12px;
box-shadow: 0 1px 3px rgb(15 23 42 / 6%);
border: 1px solid #e2e8f0;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
overflow: hidden;
}
.ddreg-card__head {
display: flex;
align-items: center;
padding: 14px 18px;
border-bottom: 1px solid #e2e8f0;
background: linear-gradient(180deg, #f8fafc 0%, #fff 100%);
}
.ddreg-card__title {
display: flex;
align-items: center;
gap: 8px;
font-size: 15px;
font-weight: 700;
color: #0d1b2a;
}
.ddreg-card__title-ic {
color: #1e88e5;
}
.ddreg-card__count {
margin-left: 8px;
padding: 2px 10px;
background: #eff6ff;
color: #1e40af;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
}
/* ─── Table ─── */
.ddreg-table-wrap {
overflow: auto;
}
.ddreg-table {
table {
width: 100%;
}
.ddreg-table th {
position: sticky;
top: 0;
z-index: 1;
background: #f1f5f9;
th {
font-weight: 600;
color: #475569;
text-align: left;
padding: 11px 14px !important;
font-size: 10.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
border-bottom: 1px solid #e2e8f0;
background-color: #f8fafc;
}
.ddreg-table td {
padding: 10px 14px !important;
td {
color: #334155;
border-bottom: 1px solid #f1f5f9;
}
.ddreg-table tr.mat-mdc-row:hover {
background: #f8fafc;
}
.ddreg-num {
text-align: right;
}
.ddreg-mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12.5px;
}
.ddreg-strong {
font-weight: 700;
color: #0d1b2a;
}
.ddreg-muted {
color: #94a3b8;
}
/* ─── Status badge (per-row, same palette as the filter chips) ─── */
.ddreg-badge {
.status-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
padding: 4px 8px;
border-radius: 9999px;
font-size: 11.5px;
font-weight: 600;
font-size: 12px;
font-weight: 500;
}
.ddreg-badge__dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}
.ddreg-badge.available {
background: #dcfce7;
.status-badge.available {
background-color: #dcfce7;
color: #15803d;
}
.ddreg-badge.issued {
background: #dbeafe;
.status-badge.issued {
background-color: #dbeafe;
color: #1d4ed8;
}
.ddreg-badge.cleared {
background: #f1f5f9;
.status-badge.cleared {
background-color: #f1f5f9;
color: #475569;
}
.ddreg-badge.cancelled {
background: #fee2e2;
.status-badge.cancelled {
background-color: #fee2e2;
color: #b91c1c;
}
.ddreg-badge.stopped {
background: #fef3c7;
.status-badge.stopped {
background-color: #fef3c7;
color: #b45309;
}
/* Spoiled stock. Grey rather than red: nothing went wrong with the customer's draft, only
with the paper it was printed on, and the draft itself is alive on another leaflet. */
.ddreg-badge.revoked {
background: #e2e8f0;
.status-badge.revoked {
background-color: #e2e8f0;
color: #475569;
}
/* ─── Actions ─── */
.ddreg-actions-col {
min-width: 190px;
}
.ddreg-actions {
.action-btn-group {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
gap: 8px;
}
.ddreg-act {
width: 30px;
height: 30px;
border: 1px solid #e2e8f0;
background: #fff;
border-radius: 7px;
cursor: pointer;
.pending-badge {
display: inline-flex;
align-items: center;
justify-content: center;
color: #64748b;
font-size: 12px;
transition: all 0.12s;
}
.ddreg-act:hover {
transform: translateY(-1px);
}
.ddreg-act.more:hover {
background: #f5f3ff;
border-color: #ddd6fe;
color: #7c3aed;
}
.ddreg-menu__ic {
width: 18px;
margin-right: 10px;
color: #64748b;
font-size: 13px;
text-align: center;
}
.ddreg-menu__ic.warn {
color: #ef4444;
}
.ddreg-pending {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 10px;
border-radius: 9999px;
font-size: 11px;
font-weight: 600;
background: #fef3c7;
font-size: 12px;
font-weight: 500;
background-color: #fef3c7;
color: #b45309;
}
.ddreg-pending fa-icon {
font-size: 10px;
}
.ddreg-info {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 11.5px;
color: #64748b;
}
.ddreg-info fa-icon {
font-size: 10px;
}
.ddreg-empty {
.status-filter-row {
display: flex;
flex-direction: column;
align-items: center;
flex-wrap: wrap;
gap: 8px;
padding: 48px 16px;
color: #94a3b8;
margin-bottom: 16px;
}
.status-filter-row button.filter-active {
background-color: #ede9fe;
}
.filter-count {
color: #64748b;
margin-left: 4px;
}
.hint-text {
color: #64748b;
font-size: 13px;
}
.ddreg-empty fa-icon {
font-size: 22px;
opacity: 0.6;
}
/* ─── Responsive ─── */
@media (max-width: 900px) {
.ddreg-toolbar {
flex-direction: column;
align-items: stretch;
}
.ddreg-toolbar__search {
max-width: none;
}
}
@media (max-width: 600px) {
.ddreg-table th,
.ddreg-table td {
padding: 8px 10px !important;
font-size: 12px;
}
margin: -8px 0 16px 0;
}
`
]
+1 -2
View File
@@ -108,14 +108,13 @@ export class SponsorDDService {
chargesAmount?: number;
issuingBranchId: number;
issuedDate: string; // yyyy-MM-dd
paymentMethod: 'ACCOUNT_DEBIT' | 'CHEQUE' | 'CASH' | 'GL';
paymentMethod: 'ACCOUNT_DEBIT' | 'CHEQUE' | 'CASH';
savingsAccountId?: number;
currentAccountId?: number;
chequeNumber?: string;
chequeBankName?: string;
cashierDepositRequestId?: number;
denominationData?: string;
glAccountId?: number;
}
): Observable<any> {
return this.http.post<any>(`/sponsordd/leaflets/${leafletId}/issue-request`, data);
@@ -68,70 +68,18 @@
<tr>
<th class="ta-left">Voucher / Receipt</th>
<th class="ta-left">Date</th>
<th class="ta-left">Description</th>
<th class="ta-left">Collected by</th>
<th class="ta-right">Amount</th>
<th class="ta-center">Detail</th>
</tr>
</thead>
<tbody>
@for (v of detail.vouchers; track v.id) {
<tr class="sard-voucher-row" (click)="toggleVoucherGlEntries(v.id)">
<tr>
<td class="ta-left mono">{{ v.voucherNo || v.receiptNo }}</td>
<td class="ta-left">{{ v.collectionDate }}</td>
<td class="ta-left sard-desc-cell" [title]="v.note">{{ v.note || '—' }}</td>
<td class="ta-left">{{ v.collectedByName || '—' }}</td>
<td class="ta-right amount-cell">{{ v.amount | number: '1.2-2' }}</td>
<td class="ta-center">
<button
type="button"
mat-icon-button
class="sard-detail-btn"
(click)="$event.stopPropagation(); toggleVoucherGlEntries(v.id)"
title="View this receipt's actual GL posting"
>
<mat-icon>visibility</mat-icon>
</button>
</td>
</tr>
@if (expandedVoucherId === v.id) {
<tr class="sard-gl-row">
<td colspan="6">
@if (loadingGlEntriesId === v.id) {
<div class="sard-gl-loading">Loading GL entry…</div>
} @else if (!glEntriesByVoucherId[v.id]?.length) {
<div class="sard-gl-empty">No GL posting recorded for this receipt.</div>
} @else {
<table class="sard-gl-table">
<thead>
<tr>
<th class="ta-left">GL Account</th>
<th class="ta-center">Dr / Cr</th>
<th class="ta-right">Amount</th>
</tr>
</thead>
<tbody>
@for (leg of glEntriesByVoucherId[v.id]; track leg.glCode + leg.drCr) {
<tr>
<td class="ta-left mono">{{ leg.glCode }} — {{ leg.accountName }}</td>
<td class="ta-center">
<span
class="sard-drcr-pill"
[class.sard-drcr-pill--dr]="leg.drCr === 'Dr'"
[class.sard-drcr-pill--cr]="leg.drCr === 'Cr'"
>
{{ leg.drCr }}
</span>
</td>
<td class="ta-right amount-cell">{{ leg.amount | number: '1.2-2' }}</td>
</tr>
}
</tbody>
</table>
}
</td>
</tr>
}
}
</tbody>
</table>
@@ -121,93 +121,3 @@ $danger: #b3261e;
font-size: 13px;
margin-top: 12px;
}
.ta-center {
text-align: center;
}
.sard-voucher-row {
cursor: pointer;
&:hover {
background: #f4f7fb;
}
}
.sard-desc-cell {
max-width: 160px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sard-detail-btn {
width: 30px;
height: 30px;
line-height: 30px;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
color: $primary;
}
}
.sard-gl-row td {
background: #fbfcfe;
border-top: none;
padding: 4px 8px 10px;
}
.sard-gl-loading,
.sard-gl-empty {
font-size: 12px;
color: $muted;
padding: 4px 0;
}
.sard-gl-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
background: #fff;
border: 1px solid $border;
border-radius: 6px;
overflow: hidden;
th {
color: $muted;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.4px;
padding: 5px 8px;
background: #f4f7fb;
border-bottom: 1px solid $border;
}
td {
padding: 5px 8px;
border-top: 1px solid #eceff4;
}
}
.sard-drcr-pill {
display: inline-block;
padding: 1px 8px;
border-radius: 999px;
font-size: 10px;
font-weight: 700;
&.sard-drcr-pill--dr {
background: #fef2f2;
color: #dc2626;
border: 1px solid #fecaca;
}
&.sard-drcr-pill--cr {
background: #ecfdf5;
color: #059669;
border: 1px solid #a7f3d0;
}
}
@@ -50,12 +50,6 @@ export class ShareAdditionRequestDetailDialogComponent implements OnInit {
actioning = false;
detail: any = null;
/** Which voucher's row is currently expanded to show its real Dr/Cr GL legs, or null if none. */
expandedVoucherId: number | null = null;
loadingGlEntriesId: number | null = null;
/** Fetched once per voucher and kept — a checker toggling the same row twice shouldn't re-fetch. */
glEntriesByVoucherId: { [id: number]: { glCode: string; accountName: string; drCr: string; amount: number }[] } = {};
public dialogRef = inject(MatDialogRef<ShareAdditionRequestDetailDialogComponent>);
public data = inject<{ transactionId: number }>(MAT_DIALOG_DATA);
@@ -82,26 +76,6 @@ export class ShareAdditionRequestDetailDialogComponent implements OnInit {
return this.detail?.vouchers?.length || 0;
}
/** Shows or hides a voucher's real GL posting, fetching it the first time it is opened. */
toggleVoucherGlEntries(voucherId: number): void {
if (this.expandedVoucherId === voucherId) {
this.expandedVoucherId = null;
return;
}
this.expandedVoucherId = voucherId;
if (this.glEntriesByVoucherId[voucherId]) {
return;
}
this.loadingGlEntriesId = voucherId;
this.cashierService
.getShareAdvanceGlEntries(voucherId)
.pipe(catchError(() => of(null)))
.subscribe((res: any) => {
this.glEntriesByVoucherId[voucherId] = res?.entries || [];
this.loadingGlEntriesId = null;
});
}
approve(): void {
this.act('approveadditionalshares');
}
@@ -232,12 +232,6 @@
<fa-icon icon="ban" class="ac-tab__ic"></fa-icon>
<span>DD Revoke Requests</span>
</a>
<!-- Raised from the Risk Review Report. Approving moves the customer's KYC rating and their
re-KYC due date together, so it belongs with the other maker-checker queues. -->
<a class="ac-tab" routerLink="./risk-change-approvals" routerLinkActive="ac-tab--active">
<fa-icon icon="shield-alt" class="ac-tab__ic"></fa-icon>
<span>Risk Category Changes</span>
</a>
</nav>
<div class="ac-tab-panel">
-6
View File
@@ -243,12 +243,6 @@ const routes: Routes = [
(m) => m.DdRevokeApprovalsComponent
),
data: { title: 'DD Revoke Approvals' }
},
{
path: 'risk-change-approvals',
loadComponent: () =>
import('../risk-review/risk-change-approvals.component').then((m) => m.RiskChangeApprovalsComponent),
data: { title: 'Risk Category Approvals' }
}
]
},
+2 -2
View File
@@ -9,8 +9,8 @@
/* tslint:disable */
export default {
'mifos_x': {
'version': '260918',
'hash': '0cef1b5a'
'version': '260914',
'hash': 'ee5a430b'
},
'allow_switching_backend_instance': true
};
+11 -102
View File
@@ -982,16 +982,6 @@ mat-card {
.mat-mdc-tab-header,
.mat-stepper-vertical-line,
.mat-step-header,
/* Account-view pages (Shares, Savings, Current, Deposits…) use `<nav mat-tab-nav-bar
class="navigation-tabs">` for their General/Passbook/Charges/… tab links — a DIFFERENT
Material component from mat-tab-group, so it never carries `.mat-mdc-tab-header` and slipped
past the rule above. Its own style (theme/_content.scss) gives it a grey background and
`overflow: auto`, which is exactly the grey box with a baked-in scrollbar seen in print/PDF
output — printing it is as pointless as printing the tab-group header already excluded above. */
.navigation-tabs,
[mat-tab-nav-bar],
.mat-mdc-tab-nav-bar,
.mat-mdc-tab-link-container,
/* The real 2-row nav menu (Client Master / Approvals / Cashier … and the row below it) and the
live KPI ticker live directly in shell.component.html as .main-header / .kpi-ticker, not
inside <mifosx-toolbar> — hiding only the toolbar left this menu printing on every page. */
@@ -1007,57 +997,17 @@ mat-card {
margin-left: 0 !important;
}
/* THE root clip: assets/styles/_overflow.scss sets `html, body { overflow: hidden }` and
index.html puts `h-full` (height: 100%) on both, so the document itself is locked to exactly
one viewport and everything past it is cut away. Every inner container fix below is useless
while this stands — it is why printing any page produced a blank sheet with only a sliver of
whatever happened to sit at the fold. */
html,
body {
height: auto !important;
overflow: visible !important;
width: 100% !important;
background: #fff !important;
/* Keep backgrounds/colours (certificate rules, table shading) instead of dropping them. */
print-color-adjust: exact;
}
/* The shell then scrolls inside CDK's own clipped viewport —
#mifosx-shell-container { height: 100vh }, .mat-drawer-content { height: 100%; overflow: auto }
— so these have to be freed as well. */
/* The whole app shell scrolls inside CDK's own clipped viewport —
.mat-drawer-container { overflow: hidden } and .mat-drawer-content { overflow: auto; height: 100% }
— so without freeing these too, print is still capped to one screen's worth of the page
regardless of the tab-body fix below. */
#mifosx-shell-container,
mat-sidenav-container,
mat-drawer-container,
mat-sidenav-content,
mat-drawer-content,
.mat-drawer-container,
.mat-drawer-content,
.mat-sidenav-content {
mat-sidenav-content.sidenav {
position: static !important;
overflow: visible !important;
height: auto !important;
max-height: none !important;
/* Material's own theme paints a background-color on .mat-drawer-container — without
overriding it here, that grey shows behind every printed page even though html/body are
already forced white above. */
background: #fff !important;
}
/* shell.component.scss:247-271 scopes `mifosx-content { overflow-x: hidden; max-width: 100% }`
specifically to `.sidenav mifosx-content` so tables/cards shrink to fit whenever the sidebar
is open on screen — this rule has nothing to do with the sidebar being hidden for print, and
without overriding it too, the routed page content keeps clipping/scrolling horizontally to
whatever width was last measured on screen, which is exactly the narrow box with a baked-in
scrollbar seen in print/PDF output. */
mifosx-content,
.sidenav mifosx-content,
mat-tab-nav-panel,
.mat-mdc-tab-nav-panel {
overflow: visible !important;
width: auto !important;
max-width: 100% !important;
height: auto !important;
}
/* Angular Material clips a tab's content to a JS-measured on-screen height via
@@ -1073,6 +1023,11 @@ mat-card {
height: auto !important;
}
body,
html {
background: #fff !important;
}
@page {
/* stylelint-disable-next-line unit-allowed-list */
margin: 1.5cm;
@@ -1914,49 +1869,3 @@ mifosx-client-datatable-step * {
--mdc-checkbox-selected-pressed-icon-color: #059669;
}
/* stylelint-enable selector-max-universal */
/* ==========================================================================
Global Print Styles for Material Dialogs & Certificates
========================================================================== */
@media print {
html,
body {
height: auto !important;
overflow: visible !important;
background: #fff !important;
}
/* Hide main application shell during dialog printing */
body > :not(.cdk-overlay-container) {
display: none !important;
}
/* Remove fixed positioning on CDK overlay elements so content flows naturally */
.cdk-overlay-backdrop {
display: none !important;
}
.cdk-overlay-container,
.cdk-global-overlay-wrapper,
.cdk-overlay-pane,
.mat-mdc-dialog-container,
.mdc-dialog__container,
.mdc-dialog__surface {
position: static !important;
display: block !important;
max-height: none !important;
max-width: none !important;
height: auto !important;
width: auto !important;
overflow: visible !important;
transform: none !important;
box-shadow: none !important;
background: transparent !important;
padding: 0 !important;
}
.no-print,
.mat-mdc-dialog-actions {
display: none !important;
}
}