Compare commits
8
Commits
dev
...
17d97da380
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17d97da380 | ||
|
|
f9b73a4930 | ||
|
|
e53dfb5a66 | ||
|
|
41540ad24e | ||
|
|
b71619c402 | ||
|
|
d18bd9cf37 | ||
|
|
0cef1b5a50 | ||
|
|
0fbf6ec58c |
-288
@@ -1,288 +0,0 @@
|
||||
kind: pipeline
|
||||
type: docker
|
||||
name: auto-deploy
|
||||
|
||||
trigger:
|
||||
branch:
|
||||
- dev
|
||||
event:
|
||||
- push
|
||||
|
||||
steps:
|
||||
|
||||
# ============================================================
|
||||
# DOCKER BUILD & PUSH
|
||||
#
|
||||
# Angular build (npm ci + ng build) Dockerfile ke andar
|
||||
# multi-stage me hota hai, isliye alag build step nahi chahiye.
|
||||
# ============================================================
|
||||
- name: docker-build-push
|
||||
image: docker
|
||||
|
||||
volumes:
|
||||
- name: dockersock
|
||||
path: /var/run/docker.sock
|
||||
|
||||
commands:
|
||||
- |
|
||||
set -e
|
||||
|
||||
# --------------------------------------------------------
|
||||
# Repository name comes from Drone
|
||||
# --------------------------------------------------------
|
||||
REPO="$${DRONE_REPO_NAME}"
|
||||
REPO="$$(echo "$$REPO" | tr '[:upper:]' '[:lower:]')"
|
||||
|
||||
IMAGE="harbor.lincpay:8082/lincpay/$$REPO:$${DRONE_BUILD_NUMBER}"
|
||||
|
||||
echo "=========================================="
|
||||
echo "DOCKER BUILD & PUSH"
|
||||
echo "=========================================="
|
||||
echo "Repository : $$REPO"
|
||||
echo "Build : $${DRONE_BUILD_NUMBER}"
|
||||
echo "Image : $$IMAGE"
|
||||
echo "=========================================="
|
||||
|
||||
# --------------------------------------------------------
|
||||
# Harbor login
|
||||
# --------------------------------------------------------
|
||||
echo 'QVgW5QbqlKKtBU2dYApTY28Dd6J36GK3' | \
|
||||
docker login harbor.lincpay:8082 \
|
||||
-u 'robot$lincpay+drone' \
|
||||
--password-stdin
|
||||
|
||||
# --------------------------------------------------------
|
||||
# Build
|
||||
# --------------------------------------------------------
|
||||
echo "Building Docker image..."
|
||||
|
||||
docker build \
|
||||
-t "$$IMAGE" \
|
||||
.
|
||||
|
||||
# --------------------------------------------------------
|
||||
# Push
|
||||
# --------------------------------------------------------
|
||||
echo "Pushing Docker image..."
|
||||
|
||||
docker push "$$IMAGE"
|
||||
|
||||
echo "Docker image pushed successfully."
|
||||
|
||||
|
||||
# ============================================================
|
||||
# DEPLOY
|
||||
# ============================================================
|
||||
- name: deploy
|
||||
image: docker
|
||||
|
||||
volumes:
|
||||
- name: dockersock
|
||||
path: /var/run/docker.sock
|
||||
|
||||
- name: envfiles
|
||||
path: /platform-env
|
||||
|
||||
commands:
|
||||
- |
|
||||
set -e
|
||||
|
||||
# ========================================================
|
||||
# REPOSITORY / SERVICE / IMAGE
|
||||
# ========================================================
|
||||
|
||||
REPO="$${DRONE_REPO_NAME}"
|
||||
REPO="$$(echo "$$REPO" | tr '[:upper:]' '[:lower:]')"
|
||||
|
||||
SERVICE_NAME="$${REPO}_app"
|
||||
|
||||
IMAGE="harbor.lincpay:8082/lincpay/$$REPO:$${DRONE_BUILD_NUMBER}"
|
||||
|
||||
echo "=========================================="
|
||||
echo "DEPLOYMENT"
|
||||
echo "=========================================="
|
||||
echo "Repository : $$REPO"
|
||||
echo "Service : $$SERVICE_NAME"
|
||||
echo "Image : $$IMAGE"
|
||||
echo "Placement : node.labels.environment == dev"
|
||||
echo "=========================================="
|
||||
|
||||
|
||||
# ========================================================
|
||||
# VALIDATION
|
||||
# ========================================================
|
||||
|
||||
if [ -z "$$REPO" ]; then
|
||||
echo "ERROR: DRONE_REPO_NAME is empty."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$$SERVICE_NAME" ]; then
|
||||
echo "ERROR: Service name is empty."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$$IMAGE" ]; then
|
||||
echo "ERROR: Docker image is empty."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Repository verified : $$REPO"
|
||||
echo "Service verified : $$SERVICE_NAME"
|
||||
echo "Image verified : $$IMAGE"
|
||||
|
||||
|
||||
# ========================================================
|
||||
# EXISTING SERVICE
|
||||
# ========================================================
|
||||
|
||||
if docker service ls --format '{{.Name}}' | grep -Fxq "$$SERVICE_NAME"; then
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "EXISTING SERVICE"
|
||||
echo "=========================================="
|
||||
echo "Service: $$SERVICE_NAME"
|
||||
|
||||
|
||||
# ------------------------------------------------------
|
||||
# Check whether dev constraint already exists
|
||||
# ------------------------------------------------------
|
||||
|
||||
CONSTRAINT_EXISTS=$$(docker service inspect "$$SERVICE_NAME" \
|
||||
--format '{{range .Spec.TaskTemplate.Placement.Constraints}}{{println .}}{{end}}' \
|
||||
| grep -Fx 'node.labels.environment == dev' || true)
|
||||
|
||||
|
||||
# ------------------------------------------------------
|
||||
# Add constraint if missing
|
||||
# ------------------------------------------------------
|
||||
|
||||
if [ -z "$$CONSTRAINT_EXISTS" ]; then
|
||||
|
||||
echo "Constraint missing."
|
||||
echo "Adding: node.labels.environment == dev"
|
||||
|
||||
docker service update \
|
||||
--constraint-add 'node.labels.environment == dev' \
|
||||
"$$SERVICE_NAME"
|
||||
|
||||
else
|
||||
|
||||
echo "Constraint already exists."
|
||||
|
||||
fi
|
||||
|
||||
|
||||
# ------------------------------------------------------
|
||||
# Update image
|
||||
# ------------------------------------------------------
|
||||
|
||||
echo "Updating service image..."
|
||||
|
||||
docker service update \
|
||||
--image "$$IMAGE" \
|
||||
--replicas 1 \
|
||||
--with-registry-auth \
|
||||
"$$SERVICE_NAME"
|
||||
|
||||
|
||||
# ========================================================
|
||||
# NEW SERVICE
|
||||
# ========================================================
|
||||
|
||||
else
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "NEW SERVICE"
|
||||
echo "=========================================="
|
||||
echo "Creating: $$SERVICE_NAME"
|
||||
|
||||
|
||||
# ------------------------------------------------------
|
||||
# Traefik rule
|
||||
#
|
||||
# Construct it without allowing Drone to consume
|
||||
# the shell backticks.
|
||||
# ------------------------------------------------------
|
||||
|
||||
TRAEFIK_RULE='Host(`'$$REPO'.lincpay`)'
|
||||
|
||||
echo "Traefik rule: $$TRAEFIK_RULE"
|
||||
|
||||
|
||||
# ------------------------------------------------------
|
||||
# Create service
|
||||
# ------------------------------------------------------
|
||||
|
||||
docker service create \
|
||||
--name "$$SERVICE_NAME" \
|
||||
--network edge-network \
|
||||
--replicas 1 \
|
||||
--constraint 'node.labels.environment == dev' \
|
||||
--with-registry-auth \
|
||||
--env-file /platform-env/global.env \
|
||||
--env-file /platform-env/dev.env \
|
||||
--label traefik.enable=true \
|
||||
--label "traefik.docker.network=edge-network" \
|
||||
--label "traefik.http.routers.$$REPO.rule=$$TRAEFIK_RULE" \
|
||||
--label "traefik.http.services.$$REPO.loadbalancer.server.port=80" \
|
||||
"$$IMAGE"
|
||||
|
||||
fi
|
||||
|
||||
|
||||
# ========================================================
|
||||
# FINAL STATUS
|
||||
# ========================================================
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "FINAL SERVICE STATUS"
|
||||
echo "=========================================="
|
||||
|
||||
docker service ls \
|
||||
--filter "name=$$SERVICE_NAME"
|
||||
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "PLACEMENT CONSTRAINT"
|
||||
echo "=========================================="
|
||||
|
||||
docker service inspect "$$SERVICE_NAME" \
|
||||
--format '{{json .Spec.TaskTemplate.Placement}}'
|
||||
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "SERVICE TASKS"
|
||||
echo "=========================================="
|
||||
|
||||
docker service ps "$$SERVICE_NAME" --no-trunc
|
||||
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "DEPLOYMENT COMPLETE"
|
||||
echo "=========================================="
|
||||
|
||||
|
||||
volumes:
|
||||
|
||||
# ============================================================
|
||||
# DOCKER SOCKET
|
||||
# ============================================================
|
||||
- name: dockersock
|
||||
host:
|
||||
path: /var/run/docker.sock
|
||||
|
||||
|
||||
# ============================================================
|
||||
# ENVIRONMENT FILES
|
||||
# ============================================================
|
||||
- name: envfiles
|
||||
host:
|
||||
path: /opt/platform/env
|
||||
|
||||
+6
-15
@@ -16,6 +16,7 @@ import { AccountingService } from '../../accounting.service';
|
||||
import { GLAccount } from 'app/shared/models/general.model';
|
||||
import { GlAccountSelectorComponent } from '../../../shared/accounting/gl-account-selector/gl-account-selector.component';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { accountOptionsForActivity } from '../financial-activity-account-options';
|
||||
|
||||
/**
|
||||
* Create financial activity mapping component.
|
||||
@@ -87,21 +88,11 @@ export class CreateFinancialActivityMappingComponent implements OnInit {
|
||||
*/
|
||||
setGlAccountData() {
|
||||
this.financialActivityMappingForm.get('financialActivityId').valueChanges.subscribe((financialActivityId) => {
|
||||
switch (financialActivityId) {
|
||||
case 100:
|
||||
case 101:
|
||||
case 102:
|
||||
case 103:
|
||||
this.glAccountData = this.glAccountOptions.assetAccountOptions;
|
||||
break;
|
||||
case 200:
|
||||
case 201:
|
||||
this.glAccountData = this.glAccountOptions.liabilityAccountOptions;
|
||||
break;
|
||||
case 300:
|
||||
this.glAccountData = this.glAccountOptions.equityAccountOptions;
|
||||
break;
|
||||
}
|
||||
this.glAccountData = accountOptionsForActivity(
|
||||
this.financialActivityData,
|
||||
financialActivityId,
|
||||
this.glAccountOptions
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+6
-15
@@ -16,6 +16,7 @@ import { AccountingService } from '../../accounting.service';
|
||||
import { GLAccount } from 'app/shared/models/general.model';
|
||||
import { GlAccountSelectorComponent } from '../../../shared/accounting/gl-account-selector/gl-account-selector.component';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { accountOptionsForActivity } from '../financial-activity-account-options';
|
||||
|
||||
/**
|
||||
* Edit financial activity mapping component.
|
||||
@@ -98,21 +99,11 @@ export class EditFinancialActivityMappingComponent implements OnInit {
|
||||
*/
|
||||
setGLAccountData() {
|
||||
this.financialActivityMappingForm.get('financialActivityId').valueChanges.subscribe((financialActivityId) => {
|
||||
switch (financialActivityId) {
|
||||
case 100:
|
||||
case 101:
|
||||
case 102:
|
||||
case 103:
|
||||
this.glAccountData = this.glAccountOptions.assetAccountOptions;
|
||||
break;
|
||||
case 200:
|
||||
case 201:
|
||||
this.glAccountData = this.glAccountOptions.liabilityAccountOptions;
|
||||
break;
|
||||
case 300:
|
||||
this.glAccountData = this.glAccountOptions.equityAccountOptions;
|
||||
break;
|
||||
}
|
||||
this.glAccountData = accountOptionsForActivity(
|
||||
this.financialActivityData,
|
||||
financialActivityId,
|
||||
this.glAccountOptions
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The GL accounts a financial activity may be mapped to.
|
||||
*
|
||||
* The activity itself says which kind of account it needs — the template returns `mappedGLAccountType` on every
|
||||
* option — so the answer is read from the activity rather than worked out from its id. The screens used to switch on
|
||||
* the id instead, listing the ones they knew:
|
||||
*
|
||||
* case 100, 101, 102, 103 → asset
|
||||
* case 200, 201 → liability
|
||||
* case 300 → equity
|
||||
*
|
||||
* Every activity added since fell through that switch and left the account dropdown empty, which is how dividendPaid
|
||||
* (202), reserveForDividend (301), distributableProfit (302), retainedProfit (303) and dividendRoundOff (304) became
|
||||
* unmappable — the screen offered no accounts and gave no reason. A list keyed on ids has to be extended by hand for
|
||||
* each new activity, and is silently wrong until someone notices; asking the activity what it needs cannot fall out
|
||||
* of step with the backend.
|
||||
*
|
||||
* @param activities the template's financialActivityOptions
|
||||
* @param activityId the activity the user picked
|
||||
* @param glAccountOptions the template's glAccountOptions, keyed assetAccountOptions / liabilityAccountOptions / …
|
||||
*/
|
||||
export function accountOptionsForActivity(activities: any[], activityId: number, glAccountOptions: any): any[] {
|
||||
const activity = (activities || []).find((a) => a?.id === activityId);
|
||||
const accountType = activity?.mappedGLAccountType;
|
||||
if (!accountType || !glAccountOptions) {
|
||||
return [];
|
||||
}
|
||||
// ASSET → assetAccountOptions, and so on for LIABILITY, EQUITY, INCOME and EXPENSE.
|
||||
return glAccountOptions[`${accountType.toLowerCase()}AccountOptions`] ?? [];
|
||||
}
|
||||
@@ -34,6 +34,17 @@ export class AmlService {
|
||||
return this.http.post(`/aml/alerts/${alertId}/review`, { disposition, notes });
|
||||
}
|
||||
|
||||
/**
|
||||
* Decides named rules within one alert, leaving its other rules open.
|
||||
*
|
||||
* <p>
|
||||
* The alert's own status follows from what is left undecided, so it is not sent: whitelisting five of six rules
|
||||
* leaves the alert open on the sixth.
|
||||
*/
|
||||
reviewAlertRules(alertId: number, ruleCodes: string[], disposition: string, notes: string): Observable<any> {
|
||||
return this.http.post(`/aml/alerts/${alertId}/review`, { disposition, notes, ruleCodes });
|
||||
}
|
||||
|
||||
// ═══ Recurring-alert review queue ═══
|
||||
|
||||
/**
|
||||
@@ -141,4 +152,38 @@ export class AmlService {
|
||||
getRiskCategoryHistory(clientId: number): Observable<any[]> {
|
||||
return this.http.get<any[]>(`/clients/${clientId}/risk-category-history`);
|
||||
}
|
||||
|
||||
// ═══ Risk-category changes (maker-checker) ═══
|
||||
//
|
||||
// The rating decides how often a customer is re-KYC'd and what AML thresholds apply to them, so
|
||||
// it moves only once a checker agrees rather than on one officer's say-so.
|
||||
|
||||
/** @param requestedCategory 1 Low, 2 Medium, 3 High */
|
||||
requestRiskChange(clientId: number, requestedCategory: number, reason: string): Observable<any> {
|
||||
return this.http.post('/aml/risk-change-requests', { clientId, requestedCategory, reason });
|
||||
}
|
||||
|
||||
getRiskChangeRequests(status?: string): Observable<any[]> {
|
||||
const params = status ? new HttpParams().set('status', status) : undefined;
|
||||
return this.http.get<any[]>('/aml/risk-change-requests', { params });
|
||||
}
|
||||
|
||||
approveRiskChange(requestId: number): Observable<any> {
|
||||
return this.http.post(`/aml/risk-change-requests/${requestId}/approve`, {});
|
||||
}
|
||||
|
||||
rejectRiskChange(requestId: number, rejectionReason: string): Observable<any> {
|
||||
return this.http.post(`/aml/risk-change-requests/${requestId}/reject`, { rejectionReason });
|
||||
}
|
||||
|
||||
/**
|
||||
* The latest behaviour window for each customer, as the nightly job computed it.
|
||||
*
|
||||
* One row per customer carrying the review period's figures and the rating they suggest. The Risk Review report
|
||||
* prefers these over its own per-alert reckoning, because a rating describes a period rather than the handful of
|
||||
* transactions that happened to trip a rule.
|
||||
*/
|
||||
getBehaviourWindows(): Observable<any[]> {
|
||||
return this.http.get<any[]>('/risk-review/behaviour-windows');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
<div class="bor-root">
|
||||
<div class="bor-head">
|
||||
<div class="bor-head-left">
|
||||
<fa-icon icon="gavel" class="bor-head-ic"></fa-icon>
|
||||
<div>
|
||||
<h2 class="bor-head-t">Board of Resolution Register — Share Capital</h2>
|
||||
<p class="bor-head-s">
|
||||
Every share account with the board resolution that authorised the allotment. Accounts with no resolution on
|
||||
record sort to the top.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="bor-btn bor-btn--xls" type="button" [disabled]="exporting || !filtered.length" (click)="exportExcel()">
|
||||
<fa-icon icon="file-excel"></fa-icon>
|
||||
{{ exporting ? 'Exporting…' : 'Export' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (loading) {
|
||||
<div class="bor-note">Loading the register…</div>
|
||||
} @else if (loadError) {
|
||||
<div class="bor-note bor-note--warn"><fa-icon icon="exclamation-triangle"></fa-icon> {{ loadError }}</div>
|
||||
}
|
||||
|
||||
@if (!loading && !loadError) {
|
||||
<!-- Counted over the whole register, not the filtered view: it is a standing figure the
|
||||
secretary acts on, and hiding it behind a filter would hide the problem. -->
|
||||
@if (missingResolutionCount > 0) {
|
||||
<div class="bor-note bor-note--warn">
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||
{{ missingResolutionCount }} share account{{ missingResolutionCount === 1 ? '' : 's' }} carr{{
|
||||
missingResolutionCount === 1 ? 'ies' : 'y'
|
||||
}}
|
||||
no resolution number. Share capital allotted without a board resolution cannot be evidenced to the Registrar.
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="bor-kpis">
|
||||
<div class="bor-kpi">
|
||||
<div class="bor-kpi-n">{{ filtered.length | number }}</div>
|
||||
<div class="bor-kpi-l">Share Accounts</div>
|
||||
</div>
|
||||
<div class="bor-kpi bor-kpi--blue">
|
||||
<div class="bor-kpi-n">{{ distinctResolutions | number }}</div>
|
||||
<div class="bor-kpi-l">Resolutions</div>
|
||||
</div>
|
||||
<div class="bor-kpi bor-kpi--green">
|
||||
<div class="bor-kpi-n">{{ totalShares | number }}</div>
|
||||
<div class="bor-kpi-l">Shares Allotted</div>
|
||||
</div>
|
||||
<div class="bor-kpi bor-kpi--amber">
|
||||
<div class="bor-kpi-n">₹{{ totalValue | number: '1.0-0' }}</div>
|
||||
<div class="bor-kpi-l">Share Value</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bor-filters">
|
||||
<div class="bor-filter">
|
||||
<label>Resolution No.</label>
|
||||
<input class="bor-inp" type="text" [(ngModel)]="filterResolution" placeholder="e.g. BOR/2026-27/001" />
|
||||
</div>
|
||||
<div class="bor-filter">
|
||||
<label>Branch</label>
|
||||
<select class="bor-inp" [(ngModel)]="filterBranch">
|
||||
<option value="">All branches</option>
|
||||
@for (b of branches; track b) {
|
||||
<option [value]="b">{{ b }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="bor-filter">
|
||||
<label>Status</label>
|
||||
<select class="bor-inp" [(ngModel)]="filterStatus">
|
||||
<option [ngValue]="''">Any status</option>
|
||||
@for (s of statuses; track s) {
|
||||
<option [ngValue]="s">{{ statusText(s) }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="bor-filter bor-filter--grow">
|
||||
<label>Search</label>
|
||||
<input class="bor-inp" type="text" [(ngModel)]="search" placeholder="Member name, share A/c or CIF…" />
|
||||
</div>
|
||||
<label class="bor-check">
|
||||
<input type="checkbox" [(ngModel)]="missingOnly" />
|
||||
No resolution only
|
||||
</label>
|
||||
<button class="bor-btn" type="button" (click)="resetFilters()"><fa-icon icon="undo"></fa-icon> Reset</button>
|
||||
</div>
|
||||
|
||||
<div class="bor-table-wrap">
|
||||
<table class="bor-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Resolution No.</th>
|
||||
<th>Resolution Date</th>
|
||||
<th>Share A/c No.</th>
|
||||
<th>CIF No.</th>
|
||||
<th>Member Name</th>
|
||||
<th>Branch</th>
|
||||
<th>Product</th>
|
||||
<th class="num">Shares</th>
|
||||
<th class="num">Share Value</th>
|
||||
<th class="num">Tranches</th>
|
||||
<th>Status</th>
|
||||
<th>Activated On</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (r of filtered; track r.shareAccountId) {
|
||||
<tr [class.bor-row--missing]="!r.resolutionNumber">
|
||||
<td>
|
||||
@if (r.resolutionNumber) {
|
||||
<strong class="bor-res">{{ r.resolutionNumber }}</strong>
|
||||
} @else {
|
||||
<span class="bor-missing">Not recorded</span>
|
||||
}
|
||||
</td>
|
||||
<td class="mono">{{ r.resolutionDate || '—' }}</td>
|
||||
<td class="mono">{{ r.accountNo }}</td>
|
||||
<td class="mono">{{ r.cifNumber || '—' }}</td>
|
||||
<td>{{ r.clientName }}</td>
|
||||
<td>{{ r.officeName || '—' }}</td>
|
||||
<td>{{ r.productName || '—' }}</td>
|
||||
<td class="num">{{ r.approvedShares | number }}</td>
|
||||
<td class="num strong">{{ r.shareValue | number: '1.2-2' }}</td>
|
||||
<td class="num">{{ r.trancheCount }}</td>
|
||||
<td><span [class]="statusClass(r.statusEnum)">{{ statusText(r.statusEnum) }}</span></td>
|
||||
<td class="mono">{{ r.activatedDate || '—' }}</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="12" class="bor-empty">No share account matches the filters.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,313 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
.bor-root {
|
||||
padding: 20px 24px 40px;
|
||||
background: #f8fafc;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.bor-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.bor-head-left {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.bor-head-ic {
|
||||
font-size: 26px;
|
||||
color: #0f4f3c;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.bor-head-t {
|
||||
margin: 0 0 3px;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.bor-head-s {
|
||||
margin: 0;
|
||||
max-width: 780px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.bor-note {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 9px;
|
||||
padding: 11px 14px;
|
||||
margin-bottom: 14px;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 9px;
|
||||
background: #eff6ff;
|
||||
color: #1e3a8a;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.55;
|
||||
|
||||
fa-icon {
|
||||
flex: 0 0 auto;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
/* An allotment with no resolution behind it is the finding, not a detail. */
|
||||
&--warn {
|
||||
border-color: #fde68a;
|
||||
background: #fffbeb;
|
||||
color: #92400e;
|
||||
|
||||
fa-icon {
|
||||
color: #d97706;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bor-kpis {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.bor-kpi {
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-left: 4px solid #0f4f3c;
|
||||
border-radius: 11px;
|
||||
padding: 12px 16px;
|
||||
|
||||
&--blue {
|
||||
border-left-color: #2563eb;
|
||||
}
|
||||
|
||||
&--green {
|
||||
border-left-color: #15803d;
|
||||
}
|
||||
|
||||
&--amber {
|
||||
border-left-color: #d97706;
|
||||
}
|
||||
}
|
||||
|
||||
.bor-kpi-n {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.bor-kpi-l {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.bor-filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 14px;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 11px;
|
||||
}
|
||||
|
||||
.bor-filter {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
label {
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
&--grow {
|
||||
flex: 1 1 240px;
|
||||
}
|
||||
}
|
||||
|
||||
.bor-inp {
|
||||
height: 34px;
|
||||
min-width: 170px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 7px;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
color: #1e293b;
|
||||
background: #fff;
|
||||
|
||||
&:focus {
|
||||
outline: 2px solid #2563eb;
|
||||
outline-offset: -1px;
|
||||
}
|
||||
}
|
||||
|
||||
.bor-check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 34px;
|
||||
font-size: 12.5px;
|
||||
color: #334155;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.bor-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
height: 34px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 7px;
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
&--xls {
|
||||
border-color: #15803d;
|
||||
background: #16a34a;
|
||||
color: #fff;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #15803d;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bor-table-wrap {
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 11px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.bor-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12.5px;
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
padding: 10px;
|
||||
background: #f1f5f9;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: #475569;
|
||||
white-space: nowrap;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 11px 10px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
vertical-align: middle;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.num {
|
||||
text-align: right;
|
||||
font-family: 'DM Mono', monospace;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: 'DM Mono', monospace;
|
||||
}
|
||||
|
||||
.strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
}
|
||||
|
||||
/* The row an auditor is looking for, marked in the table as well as counted above it. */
|
||||
.bor-row--missing {
|
||||
background: #fffbeb;
|
||||
}
|
||||
|
||||
.bor-res {
|
||||
color: #0f4f3c;
|
||||
font-family: 'DM Mono', monospace;
|
||||
}
|
||||
|
||||
.bor-missing {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.bor-status {
|
||||
display: inline-block;
|
||||
padding: 2px 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
|
||||
&--active {
|
||||
background: #dcfce7;
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
&--submitted,
|
||||
&--approved {
|
||||
background: #fef3c7;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
&--rejected,
|
||||
&--closed {
|
||||
background: #fee2e2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
}
|
||||
|
||||
.bor-empty {
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
color: #94a3b8;
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
import { exportWorkbook } from '../cashier/cashier-export';
|
||||
import { BorRegisterRow, BorReportService } from './bor-report.service';
|
||||
|
||||
/** m_share_account.status_enum, as the register labels it. */
|
||||
const STATUS_LABEL: Record<number, string> = {
|
||||
100: 'Submitted',
|
||||
200: 'Approved',
|
||||
300: 'Active',
|
||||
400: 'Rejected',
|
||||
500: 'Closed',
|
||||
600: 'Inactive'
|
||||
};
|
||||
|
||||
/**
|
||||
* The Board of Resolution register for share capital.
|
||||
*
|
||||
* A co-operative allots shares only under a board resolution, and this is the list the Registrar or
|
||||
* an auditor asks for: every member's holding against the resolution that authorised it. The
|
||||
* account with no resolution recorded is the point of the register, so it sorts to the top and is
|
||||
* counted on its own rather than being left to be noticed.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-bor-report',
|
||||
templateUrl: './bor-report.component.html',
|
||||
styleUrls: ['./bor-report.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
FaIconComponent,
|
||||
FormsModule
|
||||
]
|
||||
})
|
||||
export class BorReportComponent implements OnInit {
|
||||
private borService = inject(BorReportService);
|
||||
|
||||
readonly statusLabel = STATUS_LABEL;
|
||||
|
||||
rows: BorRegisterRow[] = [];
|
||||
loading = false;
|
||||
loadError = '';
|
||||
|
||||
/** '' = every status. */
|
||||
filterStatus: number | '' = '';
|
||||
filterBranch = '';
|
||||
filterResolution = '';
|
||||
search = '';
|
||||
/** Only the accounts with no resolution on file — the ones that need chasing. */
|
||||
missingOnly = false;
|
||||
|
||||
exporting = false;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading = true;
|
||||
this.loadError = '';
|
||||
this.borService.getRegister().subscribe({
|
||||
next: (rows) => {
|
||||
this.loading = false;
|
||||
this.rows = rows;
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.loading = false;
|
||||
this.rows = [];
|
||||
this.loadError = err?.error?.defaultUserMessage || 'Could not load the BOR register.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Branches present in the data, so the filter offers only what can actually be selected. */
|
||||
get branches(): string[] {
|
||||
return [...new Set(this.rows.map((r) => r.officeName).filter(Boolean))].sort();
|
||||
}
|
||||
|
||||
get statuses(): number[] {
|
||||
return [...new Set(this.rows.map((r) => r.statusEnum))].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
get filtered(): BorRegisterRow[] {
|
||||
const needle = this.search.trim().toLowerCase();
|
||||
const resolutionNeedle = this.filterResolution.trim().toLowerCase();
|
||||
return this.rows.filter((r) => {
|
||||
if (this.filterStatus !== '' && r.statusEnum !== this.filterStatus) {
|
||||
return false;
|
||||
}
|
||||
if (this.filterBranch && r.officeName !== this.filterBranch) {
|
||||
return false;
|
||||
}
|
||||
if (this.missingOnly && r.resolutionNumber) {
|
||||
return false;
|
||||
}
|
||||
if (resolutionNeedle && !r.resolutionNumber.toLowerCase().includes(resolutionNeedle)) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
needle &&
|
||||
!(
|
||||
r.clientName.toLowerCase().includes(needle) ||
|
||||
r.accountNo.toLowerCase().includes(needle) ||
|
||||
r.cifNumber.toLowerCase().includes(needle)
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
get totalShares(): number {
|
||||
return this.filtered.reduce((s, r) => s + r.approvedShares, 0);
|
||||
}
|
||||
|
||||
get totalValue(): number {
|
||||
return this.filtered.reduce((s, r) => s + r.shareValue, 0);
|
||||
}
|
||||
|
||||
/** How many resolutions the filtered holdings were authorised under. */
|
||||
get distinctResolutions(): number {
|
||||
return new Set(this.filtered.map((r) => r.resolutionNumber).filter(Boolean)).size;
|
||||
}
|
||||
|
||||
get missingResolutionCount(): number {
|
||||
return this.rows.filter((r) => !r.resolutionNumber).length;
|
||||
}
|
||||
|
||||
statusText(statusEnum: number): string {
|
||||
return STATUS_LABEL[statusEnum] || '—';
|
||||
}
|
||||
|
||||
statusClass(statusEnum: number): string {
|
||||
return 'bor-status bor-status--' + (STATUS_LABEL[statusEnum] || 'unknown').toLowerCase();
|
||||
}
|
||||
|
||||
resetFilters(): void {
|
||||
this.filterStatus = '';
|
||||
this.filterBranch = '';
|
||||
this.filterResolution = '';
|
||||
this.search = '';
|
||||
this.missingOnly = false;
|
||||
}
|
||||
|
||||
async exportExcel(): Promise<void> {
|
||||
this.exporting = true;
|
||||
try {
|
||||
const rows = this.filtered;
|
||||
await exportWorkbook(
|
||||
[
|
||||
{
|
||||
sheetName: 'BOR Register',
|
||||
title: 'Board of Resolution Register — Share Capital',
|
||||
subtitle: `${rows.length} share account(s) · ${this.distinctResolutions} resolution(s) · ${this.totalShares} shares`,
|
||||
headers: [
|
||||
'Resolution No.',
|
||||
'Resolution Date',
|
||||
'Share A/c No.',
|
||||
'CIF No.',
|
||||
'Member Name',
|
||||
'Branch',
|
||||
'Product',
|
||||
'Shares',
|
||||
'Unit Price',
|
||||
'Share Value',
|
||||
'Tranches',
|
||||
'Status',
|
||||
'Activated On'
|
||||
],
|
||||
widths: [
|
||||
20,
|
||||
16,
|
||||
18,
|
||||
16,
|
||||
30,
|
||||
18,
|
||||
18,
|
||||
10,
|
||||
12,
|
||||
14,
|
||||
10,
|
||||
12,
|
||||
14
|
||||
],
|
||||
rows: rows.map((r) => [
|
||||
// Named rather than left blank, because an empty cell in a filing reads as an
|
||||
// oversight in the export rather than a gap in the register.
|
||||
r.resolutionNumber || 'NOT RECORDED',
|
||||
r.resolutionDate || '—',
|
||||
r.accountNo,
|
||||
r.cifNumber,
|
||||
r.clientName,
|
||||
r.officeName,
|
||||
r.productName,
|
||||
r.approvedShares,
|
||||
r.unitPrice,
|
||||
r.shareValue,
|
||||
r.trancheCount,
|
||||
this.statusText(r.statusEnum),
|
||||
r.activatedDate || '—'
|
||||
])
|
||||
}
|
||||
],
|
||||
'BOR-Register'
|
||||
);
|
||||
} finally {
|
||||
this.exporting = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
/** Angular Imports */
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable, map } from 'rxjs';
|
||||
|
||||
/** Custom Helpers */
|
||||
import { toIsoDate } from '../core/utils/api-date';
|
||||
|
||||
/** One share account and the board resolution that authorised it. */
|
||||
export interface BorRegisterRow {
|
||||
shareAccountId: number;
|
||||
accountNo: string;
|
||||
clientId: number;
|
||||
cifNumber: string;
|
||||
clientName: string;
|
||||
officeId: number | null;
|
||||
officeName: string;
|
||||
productName: string;
|
||||
currencyCode: string;
|
||||
statusEnum: number;
|
||||
approvedShares: number;
|
||||
pendingShares: number;
|
||||
unitPrice: number;
|
||||
shareValue: number;
|
||||
resolutionNumber: string;
|
||||
/** ISO date, or '' where no meeting date was ever recorded against the allotment. */
|
||||
resolutionDate: string;
|
||||
/** How many purchase tranches make up the holding. */
|
||||
trancheCount: number;
|
||||
submittedDate: string;
|
||||
approvedDate: string;
|
||||
activatedDate: string;
|
||||
closedDate: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The BOR register.
|
||||
*
|
||||
* One call returns every account with its resolution. The share allotment report next door fetches
|
||||
* each account separately and stops at two hundred, which is a limit on a register rather than on a
|
||||
* page of one.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class BorReportService {
|
||||
private http = inject(HttpClient);
|
||||
|
||||
getRegister(status?: number, officeId?: number): Observable<BorRegisterRow[]> {
|
||||
let params = new HttpParams();
|
||||
if (status != null) {
|
||||
params = params.set('status', String(status));
|
||||
}
|
||||
if (officeId != null) {
|
||||
params = params.set('officeId', String(officeId));
|
||||
}
|
||||
return this.http
|
||||
.get<any[]>('/shareaccounts/bor-register', { params })
|
||||
.pipe(map((rows) => (rows || []).map((r) => this.toRow(r))));
|
||||
}
|
||||
|
||||
/** Dates arrive as [y, M, d] arrays; flattened to ISO so they sort and filter as strings. */
|
||||
private toRow(r: any): BorRegisterRow {
|
||||
return {
|
||||
shareAccountId: r.shareAccountId,
|
||||
accountNo: r.accountNo || '',
|
||||
clientId: r.clientId,
|
||||
cifNumber: r.cifNumber || '',
|
||||
clientName: r.clientName || '',
|
||||
officeId: r.officeId ?? null,
|
||||
officeName: r.officeName || '',
|
||||
productName: r.productName || '',
|
||||
currencyCode: r.currencyCode || 'INR',
|
||||
statusEnum: Number(r.statusEnum) || 0,
|
||||
approvedShares: Number(r.approvedShares) || 0,
|
||||
pendingShares: Number(r.pendingShares) || 0,
|
||||
unitPrice: Number(r.unitPrice) || 0,
|
||||
shareValue: Number(r.shareValue) || 0,
|
||||
resolutionNumber: (r.resolutionNumber || '').trim(),
|
||||
resolutionDate: toIsoDate(r.resolutionDate),
|
||||
trancheCount: Number(r.trancheCount) || 0,
|
||||
submittedDate: toIsoDate(r.submittedDate),
|
||||
approvedDate: toIsoDate(r.approvedDate),
|
||||
activatedDate: toIsoDate(r.activatedDate),
|
||||
closedDate: toIsoDate(r.closedDate)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,6 @@ import { VaultPositionComponent } from './vault-position/vault-position.componen
|
||||
import { AccountRegisterComponent } from './account-register/account-register.component';
|
||||
import { ClosingCashReportComponent } from './closing-cash-report/closing-cash-report.component';
|
||||
import { ExcessTransactionReportComponent } from './excess-transaction-report/excess-transaction-report.component';
|
||||
import { ShareCollectionComponent } from './share-collection/share-collection.component';
|
||||
|
||||
const routes: Routes = [
|
||||
Route.withShell([
|
||||
@@ -345,11 +344,6 @@ const routes: Routes = [
|
||||
component: ExcessTransactionReportComponent,
|
||||
data: { title: 'Excess Transaction Report', breadcrumb: 'Excess Transaction Report' }
|
||||
},
|
||||
{
|
||||
path: 'share-collection',
|
||||
component: ShareCollectionComponent,
|
||||
data: { title: 'Share Collection', breadcrumb: 'Share Collection' }
|
||||
},
|
||||
{
|
||||
path: 'transaction-entry',
|
||||
canActivate: [CashierOnlyGuard],
|
||||
|
||||
@@ -20,7 +20,6 @@ import { ReconciliationComponent } from './reconciliation/reconciliation.compone
|
||||
import { CashHistoryComponent } from './cash-history/cash-history.component';
|
||||
import { ClosingCashReportComponent } from './closing-cash-report/closing-cash-report.component';
|
||||
import { ExcessTransactionReportComponent } from './excess-transaction-report/excess-transaction-report.component';
|
||||
import { ShareCollectionComponent } from './share-collection/share-collection.component';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
@@ -35,8 +34,7 @@ import { ShareCollectionComponent } from './share-collection/share-collection.co
|
||||
ReconciliationComponent,
|
||||
CashHistoryComponent,
|
||||
ClosingCashReportComponent,
|
||||
ExcessTransactionReportComponent,
|
||||
ShareCollectionComponent
|
||||
ExcessTransactionReportComponent
|
||||
]
|
||||
})
|
||||
export class CashierModule {}
|
||||
|
||||
@@ -178,6 +178,13 @@ export class CashierService {
|
||||
return this.http.get('/vouchers/my-work', { params });
|
||||
}
|
||||
|
||||
/** One voucher and every debit/credit line it is made of — the full slip behind a summarised
|
||||
* row (a "+2 more" badge in a register only ever shows the first side of each). */
|
||||
voucherDetail(voucherNo: string): Observable<any> {
|
||||
const params = new HttpParams().set('voucherNo', voucherNo);
|
||||
return this.http.get('/vouchers/detail', { params });
|
||||
}
|
||||
|
||||
/**
|
||||
* Account register for one date: what opened and closed that day, each account's balance as it
|
||||
* stood then, and what it holds now.
|
||||
@@ -357,19 +364,6 @@ export class CashierService {
|
||||
return this.http.get<any[]>('/paymenttypes');
|
||||
}
|
||||
|
||||
// ── Share Collection ──
|
||||
getShareAccountForCollection(accountId: string): Observable<any> {
|
||||
return this.http.get(`/accounts/share/${accountId}`);
|
||||
}
|
||||
|
||||
collectSharePayment(data: any): Observable<any> {
|
||||
return this.http.post('/cashier/sharecollection', data);
|
||||
}
|
||||
|
||||
getShareCollectionStatus(shareAccountId: number): Observable<any> {
|
||||
return this.http.get(`/cashier/sharecollection/${shareAccountId}`);
|
||||
}
|
||||
|
||||
/** One unapplied Share Sundry receipt, found by the voucher number the member quotes. */
|
||||
getShareAdvanceByVoucher(voucherNo: string, glAccountNo: string, clientId?: number): Observable<any> {
|
||||
// The voucher travels as a query parameter, not a path segment: numbers are formatted
|
||||
@@ -407,6 +401,12 @@ export class CashierService {
|
||||
return this.http.post(`/shareadvancecollection/${collectionId}/apply`, data);
|
||||
}
|
||||
|
||||
/** The receipt's own real Dr/Cr GL legs — never the GL account currently picked in a search box,
|
||||
* which is unrelated screen state that can name a different account than this receipt sits in. */
|
||||
getShareAdvanceGlEntries(collectionId: number): Observable<any> {
|
||||
return this.http.get(`/shareadvancecollection/${collectionId}/gl-entries`);
|
||||
}
|
||||
|
||||
/** Every additional-share tranche awaiting approval, across every share account. */
|
||||
getShareAdditionRequests(): Observable<any> {
|
||||
return this.http.get('/shareadditionrequests');
|
||||
@@ -419,4 +419,14 @@ export class CashierService {
|
||||
getShareAdditionRequest(transactionId: number): Observable<any> {
|
||||
return this.http.get(`/shareadditionrequests/${transactionId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Entry Type options for a GL credit (General / Share Collection / DD Issue / …) — the
|
||||
* "GLEntryType" code under Admin > System > Manage Codes, so an admin can add another type later
|
||||
* without a deployment. Empty on failure rather than erroring, since a row missing this dropdown's
|
||||
* options should still be usable for every other field.
|
||||
*/
|
||||
getEntryTypeOptions(): Observable<any[]> {
|
||||
return this.http.get<any[]>('/codes/name/GLEntryType/codevalues').pipe(catchError(() => of([])));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,19 +174,24 @@ export class ClosingCashReportComponent implements OnInit {
|
||||
return this.noteRows.reduce((s, r) => s + Number(r.settledOutAmount ?? 0), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Summed from each row's own `netCount`, not re-derived from today's legs — a row's `netCount`
|
||||
* already carries forward what a cashier was holding from before today (`openingCount`), for the
|
||||
* force-closed-day/late-settlement case. Recomputing here from only today's vaultIn/deposit/
|
||||
* withdrawal/settledOut dropped that carry-forward and put the Total row back into negative
|
||||
* numbers a cashier had already reconciled.
|
||||
*/
|
||||
get noteNetCount(): number {
|
||||
return this.noteVaultInCount + this.noteDepositCount - this.noteWithdrawalCount - this.noteSettledOutCount;
|
||||
return this.noteRows.reduce((s, r) => s + Number(r.netCount ?? 0), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* What is left in the drawer in notes: everything that came in, less everything that went out.
|
||||
*
|
||||
* Was deposits minus withdrawals, which is the counter's movement rather than the drawer's
|
||||
* contents — it left out the cash drawn from the vault, so a teller holding a ₹500 note saw a net
|
||||
* of zero and had nothing to count against.
|
||||
* What is left in the drawer in notes: everything that came in, less everything that went out —
|
||||
* including cash carried forward from before today. See `noteNetCount` for why this sums each
|
||||
* row's own `netAmount` rather than re-deriving it from today's legs.
|
||||
*/
|
||||
get noteNetAmount(): number {
|
||||
return this.noteVaultInAmount + this.noteDepositAmount - this.noteWithdrawalAmount - this.noteSettledOutAmount;
|
||||
return this.noteRows.reduce((s, r) => s + Number(r.netAmount ?? 0), 0);
|
||||
}
|
||||
get coinRows(): any[] {
|
||||
return this.rows.filter((r) => r.kind === 'coin');
|
||||
|
||||
@@ -152,9 +152,21 @@
|
||||
<tbody>
|
||||
@for (r of g.rows; track r.voucherNo) {
|
||||
<!-- The left edge repeats the section's own colour, so a row still says which part it
|
||||
is when the heading has scrolled away. -->
|
||||
<tr [class]="'mw-row mw-row--' + r.myRole">
|
||||
<td class="mw-vno">{{ r.voucherNo }}</td>
|
||||
is when the heading has scrolled away. Clicking anywhere on the row opens the full
|
||||
slip -- every line, not just the first debit and first credit this table has room
|
||||
to show. -->
|
||||
<tr
|
||||
[class]="'mw-row mw-row--' + r.myRole"
|
||||
class="mw-row--clickable"
|
||||
(click)="openDetail(r.voucherNo)"
|
||||
[attr.aria-busy]="loadingDetailFor === r.voucherNo"
|
||||
>
|
||||
<td class="mw-vno">
|
||||
{{ r.voucherNo }}
|
||||
@if (loadingDetailFor === r.voucherNo) {
|
||||
<mat-icon class="mw-vno__spinner" inline>hourglass_empty</mat-icon>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<span class="mw-badge" [class]="'mw-badge--' + (r.side || 'JV')">{{ sideLabel(r.side) }}</span>
|
||||
</td>
|
||||
@@ -171,7 +183,7 @@
|
||||
<span class="mw-acct">{{ r.creditAccount }}</span>
|
||||
}
|
||||
@if (r.extraLines > 0) {
|
||||
<span class="mw-more">+{{ r.extraLines }} more</span>
|
||||
<span class="mw-more" title="Click the row to see every line">+{{ r.extraLines }} more</span>
|
||||
}
|
||||
</td>
|
||||
<td class="num">₹{{ r.amount | number: '1.2-2' }}</td>
|
||||
|
||||
@@ -178,6 +178,21 @@
|
||||
}
|
||||
}
|
||||
|
||||
.mw-row--clickable {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
}
|
||||
|
||||
.mw-vno__spinner {
|
||||
margin-left: 4px;
|
||||
font-size: 14px;
|
||||
vertical-align: middle;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
/* One section per part played. The heading carries the section's own count and total, so a maker's
|
||||
tally and a checker's are each readable without adding rows up. */
|
||||
.mw-group {
|
||||
|
||||
@@ -10,8 +10,10 @@ import { Component, OnInit, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { catchError, of } from 'rxjs';
|
||||
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||
import { catchError, finalize, of } from 'rxjs';
|
||||
import { CashierService } from '../cashier.service';
|
||||
import { VoucherDetailDialogComponent } from './voucher-detail-dialog.component';
|
||||
|
||||
/** What the signed-in user's part in a voucher was. */
|
||||
export type MyRole = 'RAISED' | 'VERIFIED' | 'RAISED_AND_VERIFIED';
|
||||
@@ -72,11 +74,17 @@ interface MyWorkReport {
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatIconModule
|
||||
MatIconModule,
|
||||
MatDialogModule
|
||||
]
|
||||
})
|
||||
export class MyWorkComponent implements OnInit {
|
||||
private service = inject(CashierService);
|
||||
private dialog = inject(MatDialog);
|
||||
|
||||
/** Which voucher's detail is currently being fetched, so the row that was clicked can show its
|
||||
* own spinner instead of a page-wide one for what is a small, single-row lookup. */
|
||||
loadingDetailFor: string | null = null;
|
||||
|
||||
/** Day is the working default; a month is what a close or an audit asks for. */
|
||||
period: 'DAY' | 'MONTH' = 'DAY';
|
||||
@@ -323,6 +331,31 @@ export class MyWorkComponent implements OnInit {
|
||||
return { from: first, to: lastIso };
|
||||
}
|
||||
|
||||
/** Opens the full slip for one voucher — every debit and credit line, not just the first side of
|
||||
* each a table row has room to show. */
|
||||
openDetail(voucherNo: string): void {
|
||||
if (this.loadingDetailFor) {
|
||||
return;
|
||||
}
|
||||
this.loadingDetailFor = voucherNo;
|
||||
this.service
|
||||
.voucherDetail(voucherNo)
|
||||
.pipe(
|
||||
catchError(() => of(null)),
|
||||
finalize(() => (this.loadingDetailFor = null))
|
||||
)
|
||||
.subscribe((detail: any) => {
|
||||
if (!detail) {
|
||||
return;
|
||||
}
|
||||
this.dialog.open(VoucherDetailDialogComponent, {
|
||||
data: { detail },
|
||||
width: '600px',
|
||||
autoFocus: false
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private todayIso(): string {
|
||||
const d = new Date();
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
|
||||
export interface VoucherDetailDialogData {
|
||||
/** Already-loaded detail from CashierService.voucherDetail() — the caller fetches, this dialog
|
||||
* only renders, so one loading state lives in the row's own click handler rather than being
|
||||
* duplicated inside every dialog that could ever show a voucher. */
|
||||
detail: {
|
||||
voucherNo: string;
|
||||
voucherType: string | null;
|
||||
amount: number | null;
|
||||
branchName: string | null;
|
||||
createdBy: string | null;
|
||||
createdAt: string | null;
|
||||
totalDebit: number;
|
||||
totalCredit: number;
|
||||
balanced: boolean;
|
||||
lines: {
|
||||
lineNo: number;
|
||||
entryType: 'DR' | 'CR';
|
||||
partyName: string | null;
|
||||
accountNo: string | null;
|
||||
amount: number;
|
||||
description: string | null;
|
||||
}[];
|
||||
/** GL entries a native platform command posted for the same underlying event, outside this
|
||||
* voucher entirely — a share redemption's Dr Share Capital / Cr Share Sundry pair, posted at
|
||||
* manager approval, before this voucher's own Dr Sundry / Cr Cash at Vault settlement leg even
|
||||
* exists. Empty for every voucher that already is the whole transaction on its own. */
|
||||
relatedLines: {
|
||||
stage: string;
|
||||
entryType: 'DR' | 'CR';
|
||||
partyName: string | null;
|
||||
accountNo: string | null;
|
||||
amount: number;
|
||||
description: string | null;
|
||||
}[];
|
||||
};
|
||||
}
|
||||
|
||||
/** The full slip behind a summarised register row — every debit and credit line a voucher is
|
||||
* made of, not just the first side of each that a table row has room for. */
|
||||
@Component({
|
||||
selector: 'mifosx-voucher-detail-dialog',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatDialogModule,
|
||||
MatButtonModule,
|
||||
MatIconModule
|
||||
],
|
||||
template: `
|
||||
<div class="vd-shell">
|
||||
<div class="vd-header">
|
||||
<div class="vd-header__title">
|
||||
<mat-icon class="vd-icon">receipt_long</mat-icon>
|
||||
<div>
|
||||
<h2>{{ detail.voucherNo }}</h2>
|
||||
<p>{{ detail.voucherType || 'Voucher' }} · {{ detail.branchName || 'Head Office' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button mat-icon-button mat-dialog-close aria-label="Close">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="vd-meta">
|
||||
<span
|
||||
>Raised by <strong>{{ detail.createdBy || '—' }}</strong></span
|
||||
>
|
||||
@if (detail.createdAt) {
|
||||
<span>{{ detail.createdAt | date: 'dd MMM yyyy, h:mm a' }}</span>
|
||||
}
|
||||
@if (!detail.balanced) {
|
||||
<span class="vd-unbalanced">
|
||||
<mat-icon inline>warning</mat-icon>
|
||||
Debit and credit do not balance
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<table class="vd-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Side</th>
|
||||
<th>Party</th>
|
||||
<th>Account No.</th>
|
||||
<th class="num">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (line of detail.lines; track line.lineNo) {
|
||||
<tr>
|
||||
<td>
|
||||
<span
|
||||
class="vd-side"
|
||||
[class.vd-side--dr]="line.entryType === 'DR'"
|
||||
[class.vd-side--cr]="line.entryType === 'CR'"
|
||||
>
|
||||
{{ line.entryType === 'DR' ? 'Debit' : 'Credit' }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ line.partyName || '—' }}</td>
|
||||
<td class="vd-acct">{{ line.accountNo || '—' }}</td>
|
||||
<td class="num">₹{{ line.amount | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
@if (line.description) {
|
||||
<tr class="vd-desc-row">
|
||||
<td></td>
|
||||
<td colspan="3" class="vd-desc">{{ line.description }}</td>
|
||||
</tr>
|
||||
}
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="4" class="vd-empty">This voucher's individual lines were not recorded.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
@if (detail.lines.length > 0) {
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="3">Total</td>
|
||||
<td class="num">
|
||||
₹{{ detail.totalDebit | number: '1.2-2' }} / ₹{{ detail.totalCredit | number: '1.2-2' }}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
}
|
||||
</table>
|
||||
|
||||
@if (detail.relatedLines.length > 0) {
|
||||
<div class="vd-related">
|
||||
<div class="vd-related__title">
|
||||
<mat-icon inline>link</mat-icon>
|
||||
Also posted for this transaction — not part of this voucher
|
||||
</div>
|
||||
<table class="vd-table vd-table--related">
|
||||
<tbody>
|
||||
@for (line of detail.relatedLines; track $index) {
|
||||
<tr>
|
||||
<td>
|
||||
<span
|
||||
class="vd-side"
|
||||
[class.vd-side--dr]="line.entryType === 'DR'"
|
||||
[class.vd-side--cr]="line.entryType === 'CR'"
|
||||
>
|
||||
{{ line.entryType === 'DR' ? 'Debit' : 'Credit' }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ line.partyName || '—' }}</td>
|
||||
<td class="vd-acct">{{ line.accountNo || '—' }}</td>
|
||||
<td class="num">₹{{ line.amount | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="vd-related__hint">
|
||||
{{ detail.relatedLines[0].stage }} — this and the lines above are the same transaction's full accounting.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="vd-actions">
|
||||
<button mat-flat-button color="primary" mat-dialog-close>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.vd-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 20px 24px 16px;
|
||||
min-width: 420px;
|
||||
max-width: 560px;
|
||||
}
|
||||
.vd-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.vd-header__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.vd-header__title h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.vd-header__title p {
|
||||
margin: 2px 0 0;
|
||||
font-size: 13px;
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
.vd-icon {
|
||||
color: #1976d2;
|
||||
}
|
||||
.vd-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 16px;
|
||||
font-size: 13px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
.vd-unbalanced {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: #b91c1c;
|
||||
font-weight: 600;
|
||||
}
|
||||
.vd-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
.vd-table th {
|
||||
text-align: left;
|
||||
text-transform: uppercase;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.03em;
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.vd-table td {
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
.vd-table .num {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.vd-acct {
|
||||
color: rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
.vd-side {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.vd-side--dr {
|
||||
background: #fee2e2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
.vd-side--cr {
|
||||
background: #dcfce7;
|
||||
color: #15803d;
|
||||
}
|
||||
.vd-desc-row td {
|
||||
padding-top: 0;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
.vd-desc {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
font-style: italic;
|
||||
}
|
||||
.vd-empty {
|
||||
text-align: center;
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
padding: 16px 8px;
|
||||
}
|
||||
.vd-table tfoot td {
|
||||
font-weight: 600;
|
||||
border-bottom: none;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.vd-related {
|
||||
border-top: 1px dashed rgba(0, 0, 0, 0.15);
|
||||
padding-top: 12px;
|
||||
}
|
||||
.vd-related__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: #6d28d9;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.vd-table--related td {
|
||||
background: #faf5ff;
|
||||
}
|
||||
.vd-related__hint {
|
||||
margin: 6px 0 0;
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.vd-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
`
|
||||
]
|
||||
})
|
||||
export class VoucherDetailDialogComponent {
|
||||
dialogRef = inject(MatDialogRef<VoucherDetailDialogComponent>);
|
||||
private data = inject<VoucherDetailDialogData>(MAT_DIALOG_DATA);
|
||||
|
||||
detail = this.data.detail;
|
||||
}
|
||||
@@ -1,375 +0,0 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="pg">
|
||||
<!-- ── HEADER ── -->
|
||||
<header class="pg-head">
|
||||
<div>
|
||||
<div class="pg-eyebrow">CASHIER · SHARE COLLECTION</div>
|
||||
<h1 class="pg-title">Share Collection</h1>
|
||||
<p class="pg-sub">
|
||||
Collect cash from member for share purchase — amounts are held in Sundry GL until the account is activated.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ── SEARCH BAR ── -->
|
||||
@if (pageState !== 'success') {
|
||||
<div class="sc-search-bar">
|
||||
<div class="sc-search-bar__input-wrap">
|
||||
<mat-icon class="sc-search-bar__icon">search</mat-icon>
|
||||
<input
|
||||
class="sc-search-bar__input"
|
||||
type="text"
|
||||
placeholder="Enter Share Account ID or Account No."
|
||||
[(ngModel)]="accountInput"
|
||||
(keyup.enter)="searchAccount()"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--primary"
|
||||
[disabled]="pageState === 'searching' || !accountInput.trim()"
|
||||
(click)="searchAccount()"
|
||||
>
|
||||
<mat-icon>manage_search</mat-icon>
|
||||
{{ pageState === 'searching' ? 'Searching…' : 'Search' }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ── ERROR ── -->
|
||||
@if (errorMsg) {
|
||||
<div class="sc-alert sc-alert--error">
|
||||
<mat-icon>error_outline</mat-icon>
|
||||
<span>{{ errorMsg }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ── SEARCHING SPINNER ── -->
|
||||
@if (pageState === 'searching') {
|
||||
<div class="pg-empty">
|
||||
<mat-icon class="sc-spin">hourglass_empty</mat-icon>
|
||||
<p>Looking up account…</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ── ACCOUNT LOADED ── -->
|
||||
@if (pageState === 'loaded') {
|
||||
<!-- Member Card -->
|
||||
<div class="sc-member-card">
|
||||
<div class="sc-member-card__avatar">
|
||||
<mat-icon>person</mat-icon>
|
||||
</div>
|
||||
<div class="sc-member-card__info">
|
||||
<div class="sc-member-card__name">{{ account.clientName }}</div>
|
||||
<div class="sc-member-card__meta">
|
||||
<span class="sc-badge sc-badge--account">{{ account.accountNo }}</span>
|
||||
<span class="sc-badge sc-badge--product">{{ account.productName }}</span>
|
||||
<span
|
||||
class="sc-badge"
|
||||
[class.sc-badge--pending]="account.status?.code?.includes('pending')"
|
||||
[class.sc-badge--active]="account.status?.code?.includes('active')"
|
||||
>{{ account.status?.value }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Editable Shares + Price -->
|
||||
<div class="sc-member-card__shares">
|
||||
<div class="sc-shares-inputs">
|
||||
<div class="sc-shares-field">
|
||||
<label class="sc-shares-label">No. of Shares</label>
|
||||
<input
|
||||
class="sc-shares-input"
|
||||
type="number"
|
||||
min="0"
|
||||
[(ngModel)]="requestedSharesInput"
|
||||
(input)="recalc()"
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
<div class="sc-shares-sep">×</div>
|
||||
<div class="sc-shares-field">
|
||||
<label class="sc-shares-label">Price / Share (₹)</label>
|
||||
<input
|
||||
class="sc-shares-input"
|
||||
type="number"
|
||||
min="0"
|
||||
[(ngModel)]="pricePerShare"
|
||||
(input)="recalc()"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
<div class="sc-shares-sep">=</div>
|
||||
<div class="sc-shares-result">
|
||||
<div class="sc-shares-label">Share Amount</div>
|
||||
<div class="sc-shares-total">{{ currencySymbol }}{{ shareAmount | number: '1.2-2' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Amount Breakdown + GL Preview -->
|
||||
<div class="sc-split">
|
||||
<!-- LEFT: Amount Breakdown -->
|
||||
<div class="pg-card sc-breakdown">
|
||||
<div class="sc-breakdown__header">
|
||||
<mat-icon>receipt_long</mat-icon>
|
||||
<span>Amount Breakdown</span>
|
||||
</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Description</th>
|
||||
<th class="num">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
Share Amount
|
||||
<span class="sc-dim">
|
||||
({{ requestedSharesInput }} shares × {{ currencySymbol }}{{ pricePerShare | number: '1.2-2' }})
|
||||
</span>
|
||||
</td>
|
||||
<td class="num">{{ currencySymbol }}{{ shareAmount | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
@for (charge of charges; track charge.id) {
|
||||
<tr>
|
||||
<td>{{ charge.name }}</td>
|
||||
<td class="num sc-charge-amount">
|
||||
<input class="sc-charge-input" type="number" min="0" [(ngModel)]="charge.amount" (input)="recalc()" />
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@if (charges.length === 0) {
|
||||
<tr>
|
||||
<td colspan="2">
|
||||
<span class="sc-dim">No charges applicable</span>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="sc-total-row">
|
||||
<td><strong>Total Payable</strong></td>
|
||||
<td class="num sc-total-amount">
|
||||
<strong>{{ currencySymbol }}{{ totalAmount | number: '1.2-2' }}</strong>
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- RIGHT: GL Preview -->
|
||||
<div class="pg-card sc-gl-preview">
|
||||
<div class="sc-breakdown__header">
|
||||
<mat-icon>account_tree</mat-icon>
|
||||
<span>GL Entries Preview</span>
|
||||
</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Account</th>
|
||||
<th>Dr / Cr</th>
|
||||
<th class="num">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr class="gl-row--dr">
|
||||
<td class="gl-acname">Cash Vault GL</td>
|
||||
<td><span class="sc-dr-badge">Dr</span></td>
|
||||
<td class="num gl-amt--dr">{{ currencySymbol }}{{ totalAmount | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
<tr class="gl-row--cr">
|
||||
<td class="gl-acname">Share Sundry GL</td>
|
||||
<td><span class="sc-cr-badge">Cr</span></td>
|
||||
<td class="num gl-amt--cr">{{ currencySymbol }}{{ shareAmount | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
@if (totalChargeAmount > 0) {
|
||||
<tr class="gl-row--cr">
|
||||
<td class="gl-acname">Charge Receivable GL</td>
|
||||
<td><span class="sc-cr-badge">Cr</span></td>
|
||||
<td class="num gl-amt--cr">{{ currencySymbol }}{{ totalChargeAmount | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="sc-gl-note">
|
||||
<mat-icon>info_outline</mat-icon>
|
||||
Funds move to Share GL & Income GL when account is <strong>activated</strong>.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── DENOMINATION (Cash Only) ── -->
|
||||
@if (isCashPayment && allDenominations.length > 0) {
|
||||
<div class="pg-card sc-denom">
|
||||
<div class="sc-breakdown__header">
|
||||
<mat-icon>payments</mat-icon>
|
||||
<span>Denomination Entry</span>
|
||||
@if (denominationTotal > 0) {
|
||||
<span
|
||||
class="sc-denom-badge"
|
||||
[class.sc-denom-badge--match]="denomMatchesTotal"
|
||||
[class.sc-denom-badge--mismatch]="!denomMatchesTotal"
|
||||
>
|
||||
₹{{ denominationTotal | number: '1.2-2' }}
|
||||
{{ denomMatchesTotal ? '✓ Matches' : '≠ Mismatch' }}
|
||||
</span>
|
||||
}
|
||||
@if (denominationTotal === 0) {
|
||||
<span class="sc-denom-required">⚠ Required for Cash</span>
|
||||
}
|
||||
</div>
|
||||
<div class="sc-denom-body">
|
||||
<table class="pg-table sc-denom-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Denomination</th>
|
||||
<th class="num">Count</th>
|
||||
<th class="num">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (d of allDenominations; track d.id) {
|
||||
<tr>
|
||||
<td>
|
||||
<span class="sc-denom-chip" [class.sc-denom-chip--coin]="d.kind === 'coin'">{{ d.label }}</span>
|
||||
</td>
|
||||
<td class="num">
|
||||
<div class="sc-denom-stepper">
|
||||
<button type="button" (click)="bumpDenom(d, -1)" [disabled]="countFor(d) === 0">−</button>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
[value]="countFor(d) || ''"
|
||||
placeholder="0"
|
||||
(input)="onDenomChange(d, $any($event.target).value)"
|
||||
/>
|
||||
<button type="button" (click)="bumpDenom(d, 1)">+</button>
|
||||
</div>
|
||||
</td>
|
||||
<td class="num">{{ subtotalFor(d) > 0 ? (subtotalFor(d) | number: '1.2-2') : '—' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td><strong>Total</strong></td>
|
||||
<td></td>
|
||||
<td class="num">
|
||||
<strong>₹{{ denominationTotal | number: '1.2-2' }}</strong>
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
@if (!denomMatchesTotal && denominationTotal > 0) {
|
||||
<div class="sc-denom-mismatch">
|
||||
<mat-icon>warning</mat-icon>
|
||||
Denomination ₹{{ denominationTotal | number: '1.2-2' }} must equal Total Payable ₹{{
|
||||
totalAmount | number: '1.2-2'
|
||||
}}.
|
||||
</div>
|
||||
}
|
||||
@if (denominationTotal > 0) {
|
||||
<div class="sc-denom-actions">
|
||||
<button type="button" class="pg-btn pg-btn--ghost" (click)="clearDenominations()">
|
||||
<mat-icon>clear</mat-icon> Clear
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Transaction Details -->
|
||||
<div class="pg-toolbar pg-toolbar--mat">
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>Transaction Date *</mat-label>
|
||||
<input matInput type="date" [(ngModel)]="transactionDate" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>Payment Mode</mat-label>
|
||||
<mat-select [(ngModel)]="paymentTypeId">
|
||||
<mat-option [value]="null">— Cash (Default) —</mat-option>
|
||||
@for (pt of paymentTypes; track pt.id) {
|
||||
<mat-option [value]="pt.id">{{ pt.name }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="pg-note-field">
|
||||
<mat-label>Note (Optional)</mat-label>
|
||||
<input matInput type="text" [(ngModel)]="note" placeholder="e.g. Share purchase for 100 shares" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<!-- Footer Submit Bar -->
|
||||
<div class="pg-total-bar sc-footer">
|
||||
<div class="sc-footer__summary">
|
||||
<span class="sc-footer__label">Total to Collect</span>
|
||||
<span class="sc-footer__amount">{{ currencySymbol }}{{ totalAmount | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
<div class="sc-footer__actions">
|
||||
<button type="button" class="pg-btn pg-btn--ghost" (click)="reset()"><mat-icon>close</mat-icon> Cancel</button>
|
||||
<button type="button" class="pg-btn pg-btn--primary" [disabled]="!isValid || saving" (click)="submit()">
|
||||
<mat-icon>payments</mat-icon>
|
||||
{{ saving ? 'Posting…' : 'Collect & Post' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ── SUCCESS ── -->
|
||||
@if (pageState === 'success' && collectionResult) {
|
||||
<div class="sc-success">
|
||||
<div class="sc-success__icon-wrap">
|
||||
<mat-icon>check_circle</mat-icon>
|
||||
</div>
|
||||
<h2 class="sc-success__title">Collection Posted Successfully</h2>
|
||||
<p class="sc-success__sub">
|
||||
Cash of
|
||||
<strong>{{ collectionResult.currencyCode }} {{ collectionResult.totalAmount | number: '1.2-2' }}</strong>
|
||||
collected from <strong>{{ collectionResult.clientName }}</strong> on
|
||||
<strong>{{ collectionResult.collectionDate }}</strong> by <strong>{{ collectionResult.collectedBy }}</strong
|
||||
>.
|
||||
</p>
|
||||
|
||||
<!-- Transaction reference -->
|
||||
<div class="sc-success__ref">
|
||||
<span class="sc-success__ref-label">Transaction ID</span>
|
||||
<span class="sc-success__ref-val">{{ collectionResult.transactionId }}</span>
|
||||
</div>
|
||||
|
||||
<!-- GL entries from backend -->
|
||||
<div class="sc-success__gl">
|
||||
@for (entry of collectionResult.glEntries; track entry.account) {
|
||||
<div
|
||||
class="sc-success__gl-row"
|
||||
[class.sc-success__gl-row--dr]="entry.type === 'Dr'"
|
||||
[class.sc-success__gl-row--cr]="entry.type === 'Cr'"
|
||||
>
|
||||
<span>{{ entry.type }}. {{ entry.account }}</span>
|
||||
<span>{{ entry.currency }} {{ entry.amount | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<p class="sc-success__note">
|
||||
Funds are held in Sundry GL. They will move to Share Equity GL when the account is activated.
|
||||
</p>
|
||||
|
||||
<button type="button" class="pg-btn pg-btn--primary" (click)="reset()">
|
||||
<mat-icon>add</mat-icon> New Collection
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -1,701 +0,0 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
// ── Search Bar ───────────────────────────────────────────────────────────────
|
||||
|
||||
.sc-search-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
padding: 10px 14px;
|
||||
|
||||
&__input-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
color: #94a3b8;
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__input {
|
||||
flex: 1 1 auto;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
color: #0d1b2a;
|
||||
background: transparent;
|
||||
|
||||
&::placeholder {
|
||||
color: #94a3b8;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Alert ────────────────────────────────────────────────────────────────────
|
||||
|
||||
.sc-alert {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
|
||||
mat-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&--error {
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
color: #dc2626;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Spinner ──────────────────────────────────────────────────────────────────
|
||||
|
||||
.sc-spin {
|
||||
animation: sc-spin 1.2s linear infinite;
|
||||
display: block;
|
||||
margin: 0 auto 8px;
|
||||
font-size: 32px !important;
|
||||
width: 32px !important;
|
||||
height: 32px !important;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
@keyframes sc-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Member Card ──────────────────────────────────────────────────────────────
|
||||
|
||||
.sc-member-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
padding: 16px 20px;
|
||||
|
||||
&__avatar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
background: #eff6ff;
|
||||
flex-shrink: 0;
|
||||
|
||||
mat-icon {
|
||||
color: #2563eb;
|
||||
font-size: 24px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
&__info {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
&__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
&__shares {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Badges ───────────────────────────────────────────────────────────────────
|
||||
|
||||
.sc-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 22px;
|
||||
padding: 0 8px;
|
||||
border-radius: 5px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
border: 1px solid #e2e8f0;
|
||||
|
||||
&--account {
|
||||
background: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
border-color: #bfdbfe;
|
||||
font-family: 'Roboto Mono', monospace;
|
||||
}
|
||||
|
||||
&--product {
|
||||
background: #f0fdf4;
|
||||
color: #15803d;
|
||||
border-color: #bbf7d0;
|
||||
}
|
||||
|
||||
&--pending {
|
||||
background: #fffbeb;
|
||||
color: #d97706;
|
||||
border-color: #fde68a;
|
||||
}
|
||||
|
||||
&--active {
|
||||
background: #f0fdf4;
|
||||
color: #15803d;
|
||||
border-color: #bbf7d0;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Editable Share Inputs ────────────────────────────────────────────────────
|
||||
|
||||
.sc-shares-inputs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.sc-shares-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.sc-shares-label {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.sc-shares-input {
|
||||
width: 110px;
|
||||
height: 38px;
|
||||
border: 1.5px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 0 10px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
font-family: inherit;
|
||||
color: #1353a0;
|
||||
text-align: right;
|
||||
outline: none;
|
||||
background: #f8fafc;
|
||||
transition: border-color 0.15s;
|
||||
|
||||
&:focus {
|
||||
border-color: #1353a0;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
&::-webkit-outer-spin-button,
|
||||
&::-webkit-inner-spin-button {
|
||||
appearance: none;
|
||||
}
|
||||
}
|
||||
|
||||
.sc-shares-sep {
|
||||
font-size: 20px;
|
||||
font-weight: 300;
|
||||
color: #94a3b8;
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.sc-shares-result {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.sc-shares-total {
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
color: #0f4f3c;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
// ── Editable Charge Amount ───────────────────────────────────────────────────
|
||||
|
||||
.sc-charge-amount {
|
||||
padding: 4px 14px !important;
|
||||
}
|
||||
|
||||
.sc-charge-input {
|
||||
width: 100px;
|
||||
height: 32px;
|
||||
border: 1.5px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
padding: 0 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
font-family: inherit;
|
||||
color: #1353a0;
|
||||
text-align: right;
|
||||
outline: none;
|
||||
background: #f8fafc;
|
||||
transition: border-color 0.15s;
|
||||
|
||||
&:focus {
|
||||
border-color: #1353a0;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
&::-webkit-outer-spin-button,
|
||||
&::-webkit-inner-spin-button {
|
||||
appearance: none;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Split Layout ─────────────────────────────────────────────────────────────
|
||||
|
||||
.sc-split {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
|
||||
@media (width <= 900px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Breakdown Card ───────────────────────────────────────────────────────────
|
||||
|
||||
.sc-breakdown {
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
background: #f8fafc;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #334155;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: #0f4f3c;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sc-gl-preview {
|
||||
.sc-breakdown__header {
|
||||
mat-icon {
|
||||
color: #1d4ed8;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sc-gl-note {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
padding: 10px 14px;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
background: #f8fafc;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
|
||||
mat-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Table helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
.sc-dim {
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.sc-total-row td {
|
||||
background: #f8fafc;
|
||||
border-top: 2px solid #e2e8f0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.sc-total-amount {
|
||||
color: #0f4f3c;
|
||||
}
|
||||
|
||||
// ── Dr / Cr badges ───────────────────────────────────────────────────────────
|
||||
|
||||
.sc-dr-badge,
|
||||
.sc-cr-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 20px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.sc-dr-badge {
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
|
||||
.sc-cr-badge {
|
||||
background: #f0fdf4;
|
||||
color: #15803d;
|
||||
border: 1px solid #bbf7d0;
|
||||
}
|
||||
|
||||
// ── Footer ───────────────────────────────────────────────────────────────────
|
||||
|
||||
.sc-footer {
|
||||
justify-content: space-between !important;
|
||||
|
||||
&__summary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
&__amount {
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
color: #0f4f3c;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Denomination Panel ────────────────────────────────────────────────────────
|
||||
|
||||
.sc-denom {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sc-denom-body {
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.sc-denom-table {
|
||||
width: 100%;
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 7px 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
tfoot td {
|
||||
border-top: 2px solid #e2e8f0;
|
||||
background: #f8fafc;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.sc-denom-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
padding: 0 10px;
|
||||
border-radius: 5px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
background: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
border: 1px solid #bfdbfe;
|
||||
|
||||
&--coin {
|
||||
background: #fffbeb;
|
||||
color: #d97706;
|
||||
border-color: #fde68a;
|
||||
}
|
||||
}
|
||||
|
||||
.sc-denom-stepper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
justify-content: flex-end;
|
||||
|
||||
button {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border: 1.5px solid #e2e8f0;
|
||||
border-radius: 5px;
|
||||
background: #f8fafc;
|
||||
color: #475569;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #e0f2fe;
|
||||
border-color: #7dd3fc;
|
||||
color: #0369a1;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
input {
|
||||
width: 62px;
|
||||
height: 28px;
|
||||
border: 1.5px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
padding: 0 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
text-align: right;
|
||||
outline: none;
|
||||
background: #fff;
|
||||
|
||||
&:focus {
|
||||
border-color: #1353a0;
|
||||
}
|
||||
|
||||
&::-webkit-outer-spin-button,
|
||||
&::-webkit-inner-spin-button {
|
||||
appearance: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sc-denom-badge {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 22px;
|
||||
padding: 0 10px;
|
||||
border-radius: 5px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
|
||||
&--match {
|
||||
background: #f0fdf4;
|
||||
color: #15803d;
|
||||
border: 1px solid #bbf7d0;
|
||||
}
|
||||
|
||||
&--mismatch {
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
}
|
||||
|
||||
.sc-denom-required {
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
.sc-denom-mismatch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
padding: 9px 12px;
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #dc2626;
|
||||
|
||||
mat-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.sc-denom-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
// ── Success ──────────────────────────────────────────────────────────────────
|
||||
|
||||
.sc-success {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding: 40px 24px;
|
||||
gap: 16px;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 16px;
|
||||
|
||||
&__icon-wrap {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 50%;
|
||||
background: #f0fdf4;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
mat-icon {
|
||||
font-size: 40px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
color: #16a34a;
|
||||
}
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__sub {
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
max-width: 480px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__ref {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: #f1f5f9;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
&__ref-label {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
color: #94a3b8;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__ref-val {
|
||||
font-family: 'Roboto Mono', 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #1353a0;
|
||||
}
|
||||
|
||||
&__note {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
max-width: 420px;
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__gl {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
&__gl-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
padding: 4px 0;
|
||||
border-bottom: 1px dotted #e2e8f0;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&--dr {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
&--cr {
|
||||
color: #15803d;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,282 +0,0 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
import { of } from 'rxjs';
|
||||
import { CashierService } from '../cashier.service';
|
||||
|
||||
type PageState = 'idle' | 'searching' | 'loaded' | 'submitting' | 'success';
|
||||
|
||||
interface Denomination {
|
||||
id: number;
|
||||
value: number;
|
||||
kind: 'note' | 'coin';
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface ShareCharge {
|
||||
id: number;
|
||||
name: string;
|
||||
amount: number;
|
||||
currencySymbol: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-share-collection',
|
||||
templateUrl: './share-collection.component.html',
|
||||
styleUrls: [
|
||||
'../cashier-shared.scss',
|
||||
'./share-collection.component.scss'
|
||||
],
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
RouterModule,
|
||||
MatIconModule,
|
||||
MatSnackBarModule,
|
||||
MatFormFieldModule,
|
||||
MatSelectModule,
|
||||
MatInputModule
|
||||
]
|
||||
})
|
||||
export class ShareCollectionComponent implements OnInit {
|
||||
private service = inject(CashierService);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
|
||||
pageState: PageState = 'idle';
|
||||
accountInput = '';
|
||||
transactionDate = '';
|
||||
paymentTypeId: number | null = null;
|
||||
paymentTypes: any[] = [];
|
||||
note = '';
|
||||
errorMsg = '';
|
||||
saving = false;
|
||||
|
||||
account: any = null;
|
||||
charges: ShareCharge[] = [];
|
||||
collectionResult: any = null; // filled after successful POST
|
||||
|
||||
// Editable fields — cashier can override if API returns 0
|
||||
requestedSharesInput = 0;
|
||||
pricePerShare = 0;
|
||||
|
||||
// ── Denomination ────────────────────────────────────────────────────────────
|
||||
notes: Denomination[] = [];
|
||||
coins: Denomination[] = [];
|
||||
denominationCounts: Record<string, number> = {};
|
||||
|
||||
get shareAmount(): number {
|
||||
return this.requestedSharesInput * this.pricePerShare;
|
||||
}
|
||||
|
||||
get totalChargeAmount(): number {
|
||||
return this.charges.reduce((s, c) => s + c.amount, 0);
|
||||
}
|
||||
|
||||
get totalAmount(): number {
|
||||
return this.shareAmount + this.totalChargeAmount;
|
||||
}
|
||||
|
||||
get currencySymbol(): string {
|
||||
return this.account?.currency?.displaySymbol ?? '₹';
|
||||
}
|
||||
|
||||
get isCashPayment(): boolean {
|
||||
if (this.paymentTypeId === null) return true;
|
||||
const pt = this.paymentTypes.find((p: any) => p.id === this.paymentTypeId);
|
||||
return !!pt?.isCashPayment;
|
||||
}
|
||||
|
||||
get allDenominations(): Denomination[] {
|
||||
const desc = (a: Denomination, b: Denomination) => b.value - a.value;
|
||||
return [
|
||||
...[...this.notes].sort(desc),
|
||||
...[...this.coins].sort(desc)
|
||||
];
|
||||
}
|
||||
|
||||
get denominationTotal(): number {
|
||||
return this.allDenominations.reduce((s, d) => s + this.subtotalFor(d), 0);
|
||||
}
|
||||
|
||||
get denomMatchesTotal(): boolean {
|
||||
return this.denominationTotal === this.totalAmount;
|
||||
}
|
||||
|
||||
get isValid(): boolean {
|
||||
if (!this.account || this.totalAmount <= 0 || !this.transactionDate) return false;
|
||||
if (this.isCashPayment && this.allDenominations.length > 0) {
|
||||
return this.denomMatchesTotal;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
const d = new Date();
|
||||
this.transactionDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
this.service
|
||||
.getPaymentTypes()
|
||||
.pipe(catchError(() => of([])))
|
||||
.subscribe((types: any[]) => (this.paymentTypes = types || []));
|
||||
this.loadDenominations();
|
||||
}
|
||||
|
||||
private loadDenominations(): void {
|
||||
this.service
|
||||
.getActiveDenominations()
|
||||
.pipe(catchError(() => of([])))
|
||||
.subscribe((list: any[]) => {
|
||||
const rows: Denomination[] = (list || []).map((d: any) => ({
|
||||
id: d.id,
|
||||
value: d.denomination ?? d.value,
|
||||
kind: d.type === 'coin' ? 'coin' : 'note',
|
||||
label: '₹' + (d.denomination ?? d.value)
|
||||
}));
|
||||
const order = (a: Denomination, b: Denomination) => b.value - a.value;
|
||||
this.notes = rows.filter((r) => r.kind === 'note').sort(order);
|
||||
this.coins = rows.filter((r) => r.kind === 'coin').sort(order);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Denomination helpers ──────────────────────────────────────────────────
|
||||
|
||||
denomKey(d: Denomination): string {
|
||||
return `${d.kind}:${d.value}`;
|
||||
}
|
||||
|
||||
countFor(d: Denomination): number {
|
||||
return this.denominationCounts[this.denomKey(d)] || 0;
|
||||
}
|
||||
|
||||
subtotalFor(d: Denomination): number {
|
||||
return this.countFor(d) * d.value;
|
||||
}
|
||||
|
||||
onDenomChange(d: Denomination, raw: any): void {
|
||||
this.denominationCounts[this.denomKey(d)] = Math.max(0, Math.floor(Number(raw) || 0));
|
||||
}
|
||||
|
||||
bumpDenom(d: Denomination, delta: number): void {
|
||||
this.denominationCounts[this.denomKey(d)] = Math.max(0, this.countFor(d) + delta);
|
||||
}
|
||||
|
||||
clearDenominations(): void {
|
||||
this.denominationCounts = {};
|
||||
}
|
||||
|
||||
searchAccount(): void {
|
||||
const query = this.accountInput.trim();
|
||||
if (!query) return;
|
||||
this.pageState = 'searching';
|
||||
this.account = null;
|
||||
this.charges = [];
|
||||
this.errorMsg = '';
|
||||
|
||||
this.service
|
||||
.getShareAccountForCollection(query)
|
||||
.pipe(
|
||||
catchError((err) => {
|
||||
this.errorMsg =
|
||||
err?.error?.errors?.[0]?.defaultUserMessage ||
|
||||
err?.error?.developerMessage ||
|
||||
'Account not found. Please check the account ID and try again.';
|
||||
this.pageState = 'idle';
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
.subscribe((acc: any) => {
|
||||
if (!acc) return;
|
||||
this.account = acc;
|
||||
|
||||
// Auto-detect shares from summary or first pending purchasedShares entry
|
||||
const pendingTx = (acc.purchasedShares || []).find(
|
||||
(t: any) =>
|
||||
t.status?.code === 'purchasedSharesStatusType.applied' && t.type?.code === 'purchasedSharesType.purchased'
|
||||
);
|
||||
this.requestedSharesInput = Number(
|
||||
acc.summary?.totalPendingForApprovalShares || pendingTx?.numberOfShares || acc.requestedShares || 0
|
||||
);
|
||||
this.pricePerShare = Number(pendingTx?.purchasedPrice || acc.currentMarketPrice || acc.unitPrice || 0);
|
||||
|
||||
// Show ALL charges on the account — no type filtering
|
||||
this.charges = (acc.charges || []).map((c: any) => ({
|
||||
id: c.chargeId ?? c.id,
|
||||
name: c.name,
|
||||
amount: Number(c.amount ?? c.amountOutstanding ?? 0),
|
||||
currencySymbol: c.currency?.displaySymbol ?? '₹'
|
||||
}));
|
||||
|
||||
this.pageState = 'loaded';
|
||||
});
|
||||
}
|
||||
|
||||
recalc(): void {
|
||||
// triggered by input changes — nothing extra needed, getters auto-recalculate
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
if (!this.isValid || this.saving) return;
|
||||
this.saving = true;
|
||||
const payload = {
|
||||
shareAccountId: this.account.id,
|
||||
accountNo: this.account.accountNo,
|
||||
clientName: this.account.clientName,
|
||||
transactionDate: this.transactionDate,
|
||||
requestedShares: this.requestedSharesInput,
|
||||
pricePerShare: this.pricePerShare,
|
||||
shareAmount: this.shareAmount,
|
||||
chargeAmount: this.totalChargeAmount,
|
||||
totalAmount: this.totalAmount,
|
||||
paymentTypeId: this.paymentTypeId || null,
|
||||
note: this.note || null
|
||||
};
|
||||
|
||||
this.service.collectSharePayment(payload).subscribe({
|
||||
next: (result: any) => {
|
||||
this.collectionResult = result;
|
||||
this.snackBar.open('Share collection posted successfully.', 'Close', { duration: 3000 });
|
||||
this.pageState = 'success';
|
||||
this.saving = false;
|
||||
},
|
||||
error: (err: any) => {
|
||||
const msg =
|
||||
err?.error?.errors?.[0]?.defaultUserMessage ||
|
||||
err?.error?.developerMessage ||
|
||||
err?.error?.defaultUserMessage ||
|
||||
'Collection failed. Please try again.';
|
||||
this.snackBar.open(msg, 'Close', { duration: 8000 });
|
||||
this.saving = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.pageState = 'idle';
|
||||
this.accountInput = '';
|
||||
this.account = null;
|
||||
this.charges = [];
|
||||
this.errorMsg = '';
|
||||
this.note = '';
|
||||
this.paymentTypeId = null;
|
||||
this.requestedSharesInput = 0;
|
||||
this.pricePerShare = 0;
|
||||
this.collectionResult = null;
|
||||
this.saving = false;
|
||||
this.clearDenominations();
|
||||
}
|
||||
}
|
||||
@@ -166,23 +166,50 @@
|
||||
</div>
|
||||
<div class="ws-f ws-f--actyp">
|
||||
<label class="ws-f__lbl" title="Account Type">AC. TP.</label>
|
||||
<select
|
||||
class="ws-f__ctl"
|
||||
[(ngModel)]="row.acTyp"
|
||||
(click)="$event.stopPropagation()"
|
||||
(change)="
|
||||
row.acTyp === 'GL' &&
|
||||
row.typ !== 'Disbursement' &&
|
||||
row.typ !== 'Fund Transfer' &&
|
||||
row.typ !== 'Enter Bank Transfer' &&
|
||||
(row.dbCr = 'Cr')
|
||||
"
|
||||
>
|
||||
@for (a of acTypOptionsFor(row); track a) {
|
||||
<option [value]="a">{{ a }}</option>
|
||||
}
|
||||
</select>
|
||||
@if (acTypLockedByToken && row === selectedRow) {
|
||||
<span
|
||||
class="ws-f__ctl ws-f__ctl--locked"
|
||||
title="Locked — filled from a token-driven request, not a normal account entry"
|
||||
>{{ acTypLockedLabel }}</span
|
||||
>
|
||||
} @else {
|
||||
<select
|
||||
class="ws-f__ctl"
|
||||
[(ngModel)]="row.acTyp"
|
||||
(click)="$event.stopPropagation()"
|
||||
(change)="
|
||||
row.acTyp === 'GL' &&
|
||||
row.typ !== 'Disbursement' &&
|
||||
row.typ !== 'Fund Transfer' &&
|
||||
row.typ !== 'Enter Bank Transfer' &&
|
||||
(row.dbCr = 'Cr')
|
||||
"
|
||||
>
|
||||
@for (a of acTypOptionsFor(row); track a) {
|
||||
<option [value]="a">{{ a }}</option>
|
||||
}
|
||||
</select>
|
||||
}
|
||||
</div>
|
||||
@if (showsEntryType(row)) {
|
||||
<div class="ws-f ws-f--paytype">
|
||||
<label
|
||||
class="ws-f__lbl ws-f__lbl--accent"
|
||||
title="What this money was actually collected for — checked before a share application or DD issuance can adopt this voucher"
|
||||
>ENTRY TYPE</label
|
||||
>
|
||||
<select
|
||||
class="ws-f__ctl ws-f__ctl--accent"
|
||||
[(ngModel)]="row.entryTypeCvId"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
<option [ngValue]="null">Select…</option>
|
||||
@for (t of entryTypeOptions; track t.id) {
|
||||
<option [ngValue]="t.id">{{ t.name }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -206,14 +233,20 @@
|
||||
? ddGlMapping?.ddPayableGlCode || 'Auto GL'
|
||||
: 'Account number'
|
||||
"
|
||||
[readonly]="row.typ === 'DD' && !isDdAccountDebitRow(row)"
|
||||
[class.ws-f__ctl--readonly]="row.typ === 'DD' && !isDdAccountDebitRow(row)"
|
||||
[readonly]="
|
||||
(row.typ === 'DD' && !isDdAccountDebitRow(row)) || (acTypLockedByToken && row === selectedRow)
|
||||
"
|
||||
[class.ws-f__ctl--readonly]="
|
||||
(row.typ === 'DD' && !isDdAccountDebitRow(row)) || (acTypLockedByToken && row === selectedRow)
|
||||
"
|
||||
[title]="
|
||||
isDdAccountDebitRow(row)
|
||||
? 'Member account the draft is funded from'
|
||||
: row.typ === 'DD'
|
||||
? 'Configured Sponsor DD Payable GL (Auto-selected)'
|
||||
: 'Account number'
|
||||
acTypLockedByToken && row === selectedRow
|
||||
? 'Locked — filled from a token-driven request, not a normal account entry'
|
||||
: isDdAccountDebitRow(row)
|
||||
? 'Member account the draft is funded from'
|
||||
: row.typ === 'DD'
|
||||
? 'Configured Sponsor DD Payable GL (Auto-selected)'
|
||||
: 'Account number'
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
@@ -1523,23 +1556,50 @@
|
||||
</div>
|
||||
<div class="ws-f ws-f--actyp">
|
||||
<label class="ws-f__lbl">AC. TP.</label>
|
||||
<select
|
||||
class="ws-f__ctl"
|
||||
[(ngModel)]="row.acTyp"
|
||||
(click)="$event.stopPropagation()"
|
||||
(change)="
|
||||
row.acTyp === 'GL' &&
|
||||
row.typ !== 'Disbursement' &&
|
||||
row.typ !== 'Fund Transfer' &&
|
||||
row.typ !== 'Enter Bank Transfer' &&
|
||||
(row.dbCr = 'Cr')
|
||||
"
|
||||
>
|
||||
@for (a of acTypOptionsFor(row); track a) {
|
||||
<option [value]="a">{{ a }}</option>
|
||||
}
|
||||
</select>
|
||||
@if (acTypLockedByToken && row === selectedRow) {
|
||||
<span
|
||||
class="ws-f__ctl ws-f__ctl--locked"
|
||||
title="Locked — filled from a token-driven request, not a normal account entry"
|
||||
>{{ acTypLockedLabel }}</span
|
||||
>
|
||||
} @else {
|
||||
<select
|
||||
class="ws-f__ctl"
|
||||
[(ngModel)]="row.acTyp"
|
||||
(click)="$event.stopPropagation()"
|
||||
(change)="
|
||||
row.acTyp === 'GL' &&
|
||||
row.typ !== 'Disbursement' &&
|
||||
row.typ !== 'Fund Transfer' &&
|
||||
row.typ !== 'Enter Bank Transfer' &&
|
||||
(row.dbCr = 'Cr')
|
||||
"
|
||||
>
|
||||
@for (a of acTypOptionsFor(row); track a) {
|
||||
<option [value]="a">{{ a }}</option>
|
||||
}
|
||||
</select>
|
||||
}
|
||||
</div>
|
||||
@if (showsEntryType(row)) {
|
||||
<div class="ws-f ws-f--paytype">
|
||||
<label
|
||||
class="ws-f__lbl ws-f__lbl--accent"
|
||||
title="What this money was actually collected for — checked before a share application or DD issuance can adopt this voucher"
|
||||
>ENTRY TYPE</label
|
||||
>
|
||||
<select
|
||||
class="ws-f__ctl ws-f__ctl--accent"
|
||||
[(ngModel)]="row.entryTypeCvId"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
<option [ngValue]="null">Select…</option>
|
||||
@for (t of entryTypeOptions; track t.id) {
|
||||
<option [ngValue]="t.id">{{ t.name }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="ws-vdivider"></div>
|
||||
@@ -1556,10 +1616,16 @@
|
||||
[placeholder]="
|
||||
row.typ === 'DD' ? ddGlMapping?.ddPayableGlCode || 'Auto GL' : 'Account number'
|
||||
"
|
||||
[readonly]="row.typ === 'DD'"
|
||||
[class.ws-f__ctl--readonly]="row.typ === 'DD'"
|
||||
[readonly]="row.typ === 'DD' || (acTypLockedByToken && row === selectedRow)"
|
||||
[class.ws-f__ctl--readonly]="
|
||||
row.typ === 'DD' || (acTypLockedByToken && row === selectedRow)
|
||||
"
|
||||
[title]="
|
||||
row.typ === 'DD' ? 'Configured Sponsor DD Payable GL (Auto-selected)' : 'Account number'
|
||||
acTypLockedByToken && row === selectedRow
|
||||
? 'Locked — filled from a token-driven request, not a normal account entry'
|
||||
: row.typ === 'DD'
|
||||
? 'Configured Sponsor DD Payable GL (Auto-selected)'
|
||||
: 'Account number'
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -916,6 +916,20 @@ input.ws-f__ctl--readonly {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* AC.TP shown as a locked label instead of the normal editable select, while the row is filled
|
||||
from a token-driven request whose real account type row.acTyp cannot show (see
|
||||
acTypLockedByToken in the component). */
|
||||
span.ws-f__ctl--locked {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
cursor: not-allowed;
|
||||
user-select: none;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
input.ws-amt-input--locked {
|
||||
background: #fef9c3;
|
||||
color: #713f12;
|
||||
|
||||
@@ -53,6 +53,19 @@ 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;
|
||||
@@ -1703,15 +1716,18 @@ 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, 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.
|
||||
* 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).
|
||||
*/
|
||||
private static readonly DD_CLERK_ACTYPS = [
|
||||
'SD',
|
||||
'CA'
|
||||
'CA',
|
||||
'GL'
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -1726,6 +1742,33 @@ 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'
|
||||
@@ -1834,6 +1877,12 @@ 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');
|
||||
@@ -2321,14 +2370,21 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
/**
|
||||
* TYPE=DD funded by debiting a member's savings/current account rather than by cash.
|
||||
* TYPE=DD funded by debiting a member's savings/current account, or a GL account directly, rather
|
||||
* than by cash.
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
isDdAccountDebitRow(row: TxnRow): boolean {
|
||||
return this.isClerk && row.typ === 'DD' && (row.acTyp === 'SD' || row.acTyp === 'CA' || row.acTyp === 'CB');
|
||||
return (
|
||||
this.isClerk &&
|
||||
row.typ === 'DD' &&
|
||||
(row.acTyp === 'SD' || row.acTyp === 'CA' || row.acTyp === 'CB' || row.acTyp === 'GL')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2342,6 +2398,23 @@ 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;
|
||||
@@ -2408,19 +2481,24 @@ 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', [
|
||||
'Enter the account number the Demand Draft is funded from.'
|
||||
isGlDebit
|
||||
? 'Enter the GL account the Demand Draft is funded from.'
|
||||
: 'Enter the account number the Demand Draft is funded from.'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
fundingAccountId = this.rowAccountIds[acNo] ?? null;
|
||||
fundingClientId = this.rowClientIds[acNo] ?? null;
|
||||
if (!fundingAccountId || !fundingClientId) {
|
||||
fundingClientId = isGlDebit ? null : (this.rowClientIds[acNo] ?? null);
|
||||
if (!fundingAccountId || (!isGlDebit && !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.'
|
||||
@@ -2510,29 +2588,32 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
/**
|
||||
* Raises a Demand Draft funded by debiting a member's savings or current account.
|
||||
* Raises a Demand Draft funded by debiting a member's savings/current account, or a GL account
|
||||
* directly.
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
private submitAccountDebitDd(
|
||||
row: TxnRow,
|
||||
leaflet: any,
|
||||
beneficiaryData: any,
|
||||
fundingAccountId: number,
|
||||
fundingClientId: number
|
||||
fundingClientId: number | null
|
||||
): 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: fundingClientId,
|
||||
clientId: isGl ? undefined : (fundingClientId ?? undefined),
|
||||
beneficiaryName: beneficiaryData.beneficiaryName,
|
||||
payableAtBranch: beneficiaryData.payableAtBranch,
|
||||
payableAtCity: beneficiaryData.payableAtCity,
|
||||
@@ -2540,9 +2621,10 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
chargesAmount: Number(row.ddCharges) || 0,
|
||||
issuingBranchId: row.br,
|
||||
issuedDate,
|
||||
paymentMethod: 'ACCOUNT_DEBIT' as const,
|
||||
savingsAccountId: isCurrent ? undefined : fundingAccountId,
|
||||
currentAccountId: isCurrent ? fundingAccountId : undefined
|
||||
paymentMethod: (isGl ? 'GL' : 'ACCOUNT_DEBIT') as 'GL' | 'ACCOUNT_DEBIT',
|
||||
savingsAccountId: isGl || isCurrent ? undefined : fundingAccountId,
|
||||
currentAccountId: isGl || !isCurrent ? undefined : fundingAccountId,
|
||||
glAccountId: isGl ? fundingAccountId : undefined
|
||||
};
|
||||
|
||||
this.saving = true;
|
||||
@@ -2802,7 +2884,10 @@ 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'
|
||||
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 } : {})
|
||||
};
|
||||
|
||||
this.saving = true;
|
||||
@@ -3044,6 +3129,168 @@ 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,
|
||||
@@ -3103,37 +3350,23 @@ 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 dividend
|
||||
// cash-claim pool, then the account-closure pool, before giving up: all three share
|
||||
// the same TOK. NO. field at this counter.
|
||||
// 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.
|
||||
if (err?.status === 404) {
|
||||
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))
|
||||
);
|
||||
}
|
||||
return this.closureRequestService.getByToken(token).pipe(
|
||||
catchError((closeErr: any) => {
|
||||
this.loadingWithdrawal = false;
|
||||
const msg: string =
|
||||
divErr?.error?.message ??
|
||||
divErr?.error?.errors?.[0]?.defaultUserMessage ??
|
||||
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);
|
||||
}),
|
||||
// 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))
|
||||
map((closeReq: any) => (closeReq ? { __closureRequest: true, ...closeReq } : null))
|
||||
);
|
||||
}
|
||||
this.loadingWithdrawal = false;
|
||||
@@ -3159,7 +3392,16 @@ 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;
|
||||
@@ -3181,7 +3423,10 @@ 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
|
||||
@@ -3194,15 +3439,23 @@ 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 = 'SD';
|
||||
row.acTyp = acTyp;
|
||||
row.typ = 'Payment';
|
||||
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(), 'SD');
|
||||
this.loadAccountDetails(req.account_no.trim(), acTyp);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -3229,10 +3482,24 @@ 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 });
|
||||
@@ -3341,8 +3608,15 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
const match = exactMatch ?? partialMatch ?? idMatch ?? null;
|
||||
if (match?.id) {
|
||||
const matchedGlCode = match[f] ?? acNo;
|
||||
if (this.selectedRow && this.selectedRow.typ === 'DD' && match.glCode) {
|
||||
this.selectedRow.acNo = match.glCode;
|
||||
// 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;
|
||||
}
|
||||
const clientIdFromSearch = acTyp !== 'GL' && match.clientId != null ? String(match.clientId) : null;
|
||||
this.fetchAllDetails(match.id, matchedGlCode, cfg, token, acTyp, clientIdFromSearch);
|
||||
@@ -4519,6 +4793,26 @@ 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 (
|
||||
@@ -5650,6 +5944,10 @@ 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,6 +1142,46 @@
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Risk profile. These are determinations about the customer rather than about anything they
|
||||
transacted, and the re-rating policy reads them directly: a PEP or a sanctions match holds the
|
||||
rating at High however ordinary the period's activity looks, and the declared income is what
|
||||
turnover is measured against. Until they are recorded the policy has nothing to test. -->
|
||||
<div class="full-span kyc-doc-fields">
|
||||
<div class="kyc-doc-fields__head">
|
||||
<h4 class="kyc-doc-fields__title">Risk Profile</h4>
|
||||
<span class="kyc-doc-fields__hint">Used by the periodic risk review</span>
|
||||
</div>
|
||||
|
||||
<div class="kyc-risk-flags">
|
||||
<mat-checkbox formControlName="isPep">
|
||||
Politically Exposed Person
|
||||
<span class="kyc-risk-hint">Holds the rating at High; never lowered on ordinary activity</span>
|
||||
</mat-checkbox>
|
||||
<mat-checkbox formControlName="sanctionsMatch">
|
||||
Sanctions / watch-list match
|
||||
<span class="kyc-risk-hint">UNSC, MHA or internal watch list</span>
|
||||
</mat-checkbox>
|
||||
<mat-checkbox formControlName="adverseMediaMatch">
|
||||
Adverse media / regulatory action
|
||||
<span class="kyc-risk-hint">FIR, court proceedings or published action</span>
|
||||
</mat-checkbox>
|
||||
</div>
|
||||
|
||||
<div class="kyc-doc-fields__grid">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Declared annual income (₹)</mat-label>
|
||||
<input matInput type="number" min="0" formControlName="declaredAnnualIncome" />
|
||||
<mat-hint>As stated by the customer</mat-hint>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Declared annual turnover (₹)</mat-label>
|
||||
<input matInput type="number" min="0" formControlName="declaredAnnualTurnover" />
|
||||
<mat-hint>For a business customer</mat-hint>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Officially Valid Document. Shown when one is on file or the alert is asking for
|
||||
it, so re-collecting an expired document is done on this form rather than
|
||||
sending the officer somewhere else. -->
|
||||
|
||||
@@ -1807,3 +1807,28 @@
|
||||
color: #15803d;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Risk profile ────────────────────────────────────────────────────────────
|
||||
Determinations about the customer, which the periodic risk review reads directly. Grouped with
|
||||
the document block so the form reads as "what we know about them" rather than scattering a PEP
|
||||
flag among the identifier fields. */
|
||||
|
||||
.kyc-doc-fields__hint {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.kyc-risk-flags {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 4px 0 14px;
|
||||
}
|
||||
|
||||
/* Sits under its checkbox label: the consequence of ticking the box matters more than the box. */
|
||||
.kyc-risk-hint {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
@@ -140,6 +140,13 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
||||
1,
|
||||
[Validators.required]
|
||||
],
|
||||
// Risk profile. Determinations about the customer, which the periodic risk review reads directly - a PEP or a
|
||||
// sanctions match holds the rating at High whatever the period's activity looks like.
|
||||
isPep: [false],
|
||||
sanctionsMatch: [false],
|
||||
adverseMediaMatch: [false],
|
||||
declaredAnnualIncome: [null],
|
||||
declaredAnnualTurnover: [null],
|
||||
// The officially valid document, so a lapsed one can be re-collected on this form. The
|
||||
// backend has always accepted these four; only the fields were missing.
|
||||
kycDocumentName: [''],
|
||||
@@ -221,6 +228,11 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
||||
this.kycForm.patchValue({
|
||||
kycType: data.kycType || 1,
|
||||
riskCategory: data.riskCategory || 1,
|
||||
isPep: !!data.isPep,
|
||||
sanctionsMatch: !!data.sanctionsMatch,
|
||||
adverseMediaMatch: !!data.adverseMediaMatch,
|
||||
declaredAnnualIncome: data.declaredAnnualIncome ?? null,
|
||||
declaredAnnualTurnover: data.declaredAnnualTurnover ?? null,
|
||||
panNumber: data.panNumber || '',
|
||||
// Aadhaar is stored masked on the server; user must re-enter
|
||||
// the full 12-digit number to re-verify, so don't pre-fill.
|
||||
@@ -446,6 +458,28 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
||||
return new Date();
|
||||
}
|
||||
|
||||
/**
|
||||
* The customer's risk profile, as every save has to carry it.
|
||||
*
|
||||
* <p>
|
||||
* Sent from every path that writes the KYC record rather than only the main Save, because the form holds one set of
|
||||
* values: a save triggered from the PAN declaration or the Aadhaar receipt would otherwise write the record without
|
||||
* these and silently clear a PEP flag the officer had just ticked.
|
||||
*/
|
||||
private buildRiskProfileFields() {
|
||||
const v = this.kycForm.value;
|
||||
return {
|
||||
isPep: !!v.isPep,
|
||||
sanctionsMatch: !!v.sanctionsMatch,
|
||||
adverseMediaMatch: !!v.adverseMediaMatch,
|
||||
// Blank stays null rather than becoming zero: an income nobody asked for is unknown, and a zero would read as a
|
||||
// customer who declared no income at all.
|
||||
declaredAnnualIncome: v.declaredAnnualIncome === '' || v.declaredAnnualIncome == null ? null : v.declaredAnnualIncome,
|
||||
declaredAnnualTurnover:
|
||||
v.declaredAnnualTurnover === '' || v.declaredAnnualTurnover == null ? null : v.declaredAnnualTurnover
|
||||
};
|
||||
}
|
||||
|
||||
private buildExceptionFields() {
|
||||
const panVerified = this.verificationStatus.pan === 'VERIFIED' || this.kycData?.panVerified === true;
|
||||
const aadhaarVerified = this.verificationStatus.aadhaar === 'VERIFIED' || this.kycData?.aadhaarVerified === true;
|
||||
@@ -484,7 +518,8 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
||||
// dateFormat is required so the backend can parse exceptionResolvedOn when an exception closes.
|
||||
dateFormat: 'dd MMMM yyyy',
|
||||
...this.buildDocumentFields(),
|
||||
...this.buildExceptionFields()
|
||||
...this.buildRiskProfileFields(),
|
||||
...this.buildExceptionFields()
|
||||
};
|
||||
|
||||
if (!this.kycId) {
|
||||
@@ -530,6 +565,7 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
||||
remarks: this.kycForm.value.remarks || null,
|
||||
riskCategory: this.kycForm.value.riskCategory || 1,
|
||||
dateFormat: 'dd MMMM yyyy',
|
||||
...this.buildRiskProfileFields(),
|
||||
...this.buildExceptionFields()
|
||||
};
|
||||
|
||||
@@ -729,7 +765,8 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
||||
dateFormat,
|
||||
riskCategory: this.kycForm.value.riskCategory || 1,
|
||||
...declarationFields,
|
||||
...this.buildExceptionFields()
|
||||
...this.buildRiskProfileFields(),
|
||||
...this.buildExceptionFields()
|
||||
};
|
||||
|
||||
this.clientsService
|
||||
@@ -889,7 +926,8 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
||||
dateFormat,
|
||||
riskCategory: this.kycForm.value.riskCategory || 1,
|
||||
...receiptFields,
|
||||
...this.buildExceptionFields()
|
||||
...this.buildRiskProfileFields(),
|
||||
...this.buildExceptionFields()
|
||||
};
|
||||
|
||||
this.clientsService
|
||||
|
||||
@@ -310,41 +310,6 @@
|
||||
</nav>
|
||||
}
|
||||
}
|
||||
|
||||
<!-- Live KPI ticker items — referenced twice below for seamless marquee loop -->
|
||||
<ng-template #kpiTickerItems>
|
||||
<span class="ti"><span class="ti-k">CASH</span><span class="ti-v amber">₹14,82,500</span></span>
|
||||
<span class="ti-d">│</span>
|
||||
<span class="ti">
|
||||
<span class="ti-k">TXN TODAY</span>
|
||||
<span class="ti-v">1,247</span>
|
||||
<span class="ti-v up">↑ 8.2%</span>
|
||||
</span>
|
||||
<span class="ti-d">│</span>
|
||||
<span class="ti"><span class="ti-k">CLEARING IN</span><span class="ti-v">42</span></span>
|
||||
<span class="ti-d">│</span>
|
||||
<span class="ti"><span class="ti-k">CLEARING OUT</span><span class="ti-v">38</span></span>
|
||||
<span class="ti-d">│</span>
|
||||
<span class="ti">
|
||||
<span class="ti-k">NPA·GROSS</span>
|
||||
<span class="ti-v amber">3.47%</span>
|
||||
<span class="ti-v down">↑ 0.12</span>
|
||||
</span>
|
||||
<span class="ti-d">│</span>
|
||||
<span class="ti"><span class="ti-k">CASA</span><span class="ti-v">58.2%</span></span>
|
||||
<span class="ti-d">│</span>
|
||||
<span class="ti"><span class="ti-k">CD RATIO</span><span class="ti-v">71.8%</span></span>
|
||||
<span class="ti-d">│</span>
|
||||
<span class="ti">
|
||||
<span class="ti-k">RBI CIRC</span>
|
||||
<span class="ti-v">DBR.AML.NO.4/14.01.001</span>
|
||||
<span class="ti-v amber">NEW</span>
|
||||
</span>
|
||||
<span class="ti-d">│</span>
|
||||
<span class="ti"><span class="ti-k">REPO</span><span class="ti-v">6.50%</span></span>
|
||||
<span class="ti-d">│</span>
|
||||
</ng-template>
|
||||
|
||||
<!-- ============================================================
|
||||
Main Header — 2-Row Navigation
|
||||
============================================================
|
||||
@@ -623,18 +588,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- LIVE KPI TICKER (below the menu — pauses on hover) -->
|
||||
<div class="kpi-ticker" role="marquee" aria-label="Live key-performance ticker">
|
||||
<span class="kpi-live">
|
||||
<span class="kpi-live-dot"></span>
|
||||
LIVE
|
||||
</span>
|
||||
<div class="kpi-track">
|
||||
<!-- Render the item block twice so the marquee loops seamlessly. -->
|
||||
<ng-container *ngTemplateOutlet="kpiTickerItems"></ng-container>
|
||||
<ng-container *ngTemplateOutlet="kpiTickerItems"></ng-container>
|
||||
</div>
|
||||
</div>
|
||||
<!-- The KPI ticker that sat here has been removed. Every figure in it was written into the template — cash on
|
||||
hand, transactions today, CASA, CD ratio, gross NPA, a circular reference — and none came from the bank's
|
||||
own books. Under a "LIVE" badge on every screen in the application, that is not decoration: a figure a
|
||||
branch manager can read as the day's cash position, or an inspector as the NPA ratio, has to be the real
|
||||
one or must not be shown. The band returns when there is a source behind it. -->
|
||||
}
|
||||
<!-- end @if (!hideShellLayout) -->
|
||||
|
||||
@@ -713,6 +671,9 @@
|
||||
<button mat-menu-item routerLink="/reports/tds-remittance" tabindex="0">
|
||||
<i class="fa fa-landmark menu-item-icon"></i> TDS Remitted to Government
|
||||
</button>
|
||||
<button mat-menu-item routerLink="/reports/bor" tabindex="0">
|
||||
<i class="fa fa-gavel menu-item-icon"></i> BOR Register — Share Capital
|
||||
</button>
|
||||
</mat-menu>
|
||||
|
||||
<!-- Admin Mega Menu — 3-column rectangular panel grouping Setup,
|
||||
|
||||
@@ -167,7 +167,9 @@
|
||||
</div>
|
||||
<!-- The 15th of the month after the one being reported, worked out rather than fixed at
|
||||
15 Feb 2026 — a due date that never moves stops being a due date. -->
|
||||
<div class="dc-meta">Due: <strong>{{ ctrDueDateLabel }}</strong></div>
|
||||
<div class="dc-meta">
|
||||
Due: <strong>{{ ctrDueDateLabel }}</strong>
|
||||
</div>
|
||||
<button class="dc-btn" (click)="showTab('ctr')"><fa-icon icon="external-link-alt"></fa-icon> Open</button>
|
||||
</div>
|
||||
<div class="dc-card">
|
||||
@@ -220,9 +222,7 @@
|
||||
@if (ctrLinesLoading) {
|
||||
<div class="fiu-note">Loading the generated CTR lines…</div>
|
||||
} @else if (ctrLinesError) {
|
||||
<div class="fiu-note fiu-note--warn">
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon> {{ ctrLinesError }}
|
||||
</div>
|
||||
<div class="fiu-note fiu-note--warn"><fa-icon icon="exclamation-triangle"></fa-icon> {{ ctrLinesError }}</div>
|
||||
} @else if (!ctrTxns.length) {
|
||||
<div class="fiu-note fiu-note--warn">
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||
@@ -432,87 +432,17 @@
|
||||
}
|
||||
|
||||
<div class="fiu-section">
|
||||
<!-- LIVE: suspicious AML alerts awaiting AML Officer review (Structuring, PEP, etc.) -->
|
||||
<div class="fiu-form-meta">
|
||||
<div class="fiu-form-title">
|
||||
<h3>Live AML Alerts (Awaiting Triage)</h3>
|
||||
<p>Flagged by the monitoring engine for suspicious behavioral patterns — triage and whitelist or escalate</p>
|
||||
</div>
|
||||
<!-- Whitelisted is deliberately absent: cleared alerts are not STR business and have
|
||||
their own register below. -->
|
||||
<select class="fiu-inp" [(ngModel)]="amlAlertStatusFilter">
|
||||
<option value="">All statuses</option>
|
||||
<option value="RAISED">Raised</option>
|
||||
<option value="UNDER_REVIEW">Under Review</option>
|
||||
<option value="ESCALATED">Escalated</option>
|
||||
</select>
|
||||
<!-- The triage queue that used to head this tab has gone. It listed the same open alerts as the AML Rules
|
||||
Master, and listed them worse: without the severity bands, the per-rule findings or the batch decision that
|
||||
screen carries. Two places to work the same queue is how an alert gets cleared in one and escalated in the
|
||||
other. This tab keeps what is about the filing itself - what was cleared, and what became an STR case. -->
|
||||
<div class="fiu-note">
|
||||
<fa-icon icon="info-circle"></fa-icon>
|
||||
<span>
|
||||
Alerts are triaged in <strong>RBI › AML Rules Master › Flagged Transactions</strong>. What an
|
||||
officer escalates there arrives here as an STR case.
|
||||
</span>
|
||||
</div>
|
||||
<div class="fiu-tbl-wrap">
|
||||
<table class="fiu-tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Client</th>
|
||||
<th>Type</th>
|
||||
<th>Rule</th>
|
||||
<th class="ta-r">Amount</th>
|
||||
<th>Raised On</th>
|
||||
<th>Status</th>
|
||||
<th class="ta-c">Routes To</th>
|
||||
<th>Notes</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (alert of amlSuspiciousAlerts; track alert.id) {
|
||||
<tr>
|
||||
<td>{{ alert.id }}</td>
|
||||
<td>{{ alert.clientDisplayName }}</td>
|
||||
<td>{{ alert.transactionType }}</td>
|
||||
<td>{{ alert.ruleCode }} — {{ alert.ruleDescription }}</td>
|
||||
<td class="ta-r mono">₹{{ alert.amount | number: '1.0-0' }}</td>
|
||||
<td>{{ asDate(alert.raisedOn) | date: 'medium' }}</td>
|
||||
<td>
|
||||
<span [class]="amlStatusClass(alert.status)">{{ alert.status }}</span>
|
||||
</td>
|
||||
<td class="ta-c">
|
||||
<span [class]="alertRouteClass(alert.status, false)">{{ alertRouteLabel(alert.status, false) }}</span>
|
||||
</td>
|
||||
<td>
|
||||
@if (alert.status === 'RAISED' || alert.status === 'UNDER_REVIEW') {
|
||||
<input
|
||||
type="text"
|
||||
class="fiu-inp"
|
||||
placeholder="Review notes"
|
||||
[(ngModel)]="amlReviewNotes[alert.id]"
|
||||
/>
|
||||
} @else {
|
||||
<span class="muted">{{ alert.reviewNotes }}</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@if (alert.status === 'RAISED' || alert.status === 'UNDER_REVIEW') {
|
||||
<div class="fiu-action-group" *mifosxHasPermission="'REVIEW_AML_ALERT'">
|
||||
<button class="fiu-btn fiu-btn-g" (click)="whitelistAlert(alert)">Whitelist</button>
|
||||
<button class="fiu-btn fiu-btn-xml" (click)="escalateAlert(alert)">Escalate</button>
|
||||
</div>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="10" class="fiu-empty">No suspicious behavioral alerts awaiting triage.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="fiu-tbl-legend">
|
||||
<span class="report-pill report-pending">Pending</span> awaiting Whitelist/Escalate decision ·
|
||||
<span class="report-pill report-str">→ STR</span> escalated to Principal Officer as an STR case
|
||||
</p>
|
||||
|
||||
<hr style="border: 0; border-top: 1px solid var(--f-g200); margin: 8px 0" />
|
||||
|
||||
<!-- Cleared register: whitelisted alerts, kept out of the STR queue but retained with the
|
||||
evidence recorded at review time. -->
|
||||
@@ -590,7 +520,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (sc of amlStrCases; track sc.id) {
|
||||
@for (sc of liveStrCases; track sc.id) {
|
||||
<tr [class.selected-row]="selectedStrCase?.id === sc.id">
|
||||
<td>{{ sc.id }}</td>
|
||||
<td>{{ sc.clientDisplayName }}</td>
|
||||
@@ -607,13 +537,50 @@
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="8" class="fiu-empty">No STR cases yet.</td>
|
||||
<td colspan="8" class="fiu-empty">No STR cases outstanding.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Filed cases are finished work, not outstanding work, so they sit apart from the live queue — but they are
|
||||
the bank's record of what went to FIU-IND and against which reference, so they stay visible. -->
|
||||
@if (filedStrCases.length) {
|
||||
<div class="fiu-form-meta" style="margin-top: 18px">
|
||||
<div class="fiu-form-title">
|
||||
<h3>Filed with FIU-IND</h3>
|
||||
<p>Reported and closed — kept for the record, with the reference the filing was made under.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fiu-tbl-wrap">
|
||||
<table class="fiu-tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Client</th>
|
||||
<th>Conclusion</th>
|
||||
<th>Filed On</th>
|
||||
<th>FIU Reference</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (sc of filedStrCases; track sc.id) {
|
||||
<tr>
|
||||
<td>{{ sc.id }}</td>
|
||||
<td>{{ sc.clientDisplayName }}</td>
|
||||
<td>{{ sc.conclusion || '—' }}</td>
|
||||
<td>{{ asDate(sc.strFiledOn) | date: 'dd MMM yyyy' }}</td>
|
||||
<td class="mono">{{ sc.strReferenceNo || '—' }}</td>
|
||||
<td><button class="fiu-btn fiu-btn-g" (click)="selectStrCase(sc)">View</button></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (selectedStrCase) {
|
||||
<div class="str-case-detail">
|
||||
<div class="fiu-form-meta">
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
*/
|
||||
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
@@ -25,8 +26,7 @@ import {
|
||||
KycIndividual,
|
||||
PoFinding,
|
||||
StrStatus,
|
||||
StrTransaction,
|
||||
STR_TXNS
|
||||
StrTransaction
|
||||
} from './fiu-data';
|
||||
|
||||
/** A FINnet file rendered as a grid — the same rows that go into the CSV. */
|
||||
@@ -130,6 +130,7 @@ export class FiuComponent implements OnInit {
|
||||
private amlService = inject(AmlService);
|
||||
private sanctionsLogsService = inject(SanctionsLogsService);
|
||||
private authenticationService = inject(AuthenticationService);
|
||||
private route = inject(ActivatedRoute);
|
||||
/** Active tab */
|
||||
activeTab: 'dashboard' | 'ctr' | 'str' | 'customers' | 'files' = 'dashboard';
|
||||
|
||||
@@ -154,7 +155,16 @@ export class FiuComponent implements OnInit {
|
||||
* is honestly empty rather than populated with rows that cannot be filed.
|
||||
*/
|
||||
tc1Txns: CardTransaction[] = [];
|
||||
strTxns: StrTransaction[] = STR_TXNS;
|
||||
/**
|
||||
* The STR transaction file.
|
||||
*
|
||||
* Empty, for the same reason TC1 and the customer cards are. The four rows that sat here named customers this bank
|
||||
* has never onboarded, against account numbers it does not hold, for sums it never received — on the screen an
|
||||
* officer files suspicious-transaction reports from. A filing built on invented rows is not a draft; it is a false
|
||||
* report to FIU-IND. The live alerts the engine has actually escalated are listed on this tab already, and the
|
||||
* file fills from those when the STR export is wired to them.
|
||||
*/
|
||||
strTxns: StrTransaction[] = [];
|
||||
/**
|
||||
* KC1 / KC2 — the customer cards that travel with the CTR bundle.
|
||||
*
|
||||
@@ -210,13 +220,29 @@ export class FiuComponent implements OnInit {
|
||||
*/
|
||||
sanctionsStrCases: any[] = [];
|
||||
sanctionsStrFilter = '';
|
||||
amlAlertStatusFilter = '';
|
||||
amlReviewNotes: Record<number, string> = {};
|
||||
|
||||
amlCtrReports: any[] = [];
|
||||
ctrGenerateMonth = '';
|
||||
|
||||
amlStrCases: any[] = [];
|
||||
|
||||
/**
|
||||
* The cases still carrying work, which is what "Live" means here.
|
||||
*
|
||||
* <p>
|
||||
* A case that has been filed with FIU-IND is finished: the report is gone, the reference number is on it, and there
|
||||
* is nothing left for the Principal Officer to do. Leaving it in the live queue is how a working list stops being
|
||||
* one — every filed case stays on it for good, and an officer opening the tab has to tell the outstanding work from
|
||||
* the closed by reading a status column. Filed cases are listed below, where they can still be looked up.
|
||||
*/
|
||||
get liveStrCases(): any[] {
|
||||
return this.amlStrCases.filter((c) => c?.status !== 'FILED');
|
||||
}
|
||||
|
||||
/** Cases already filed with FIU-IND, kept for the record rather than as work. */
|
||||
get filedStrCases(): any[] {
|
||||
return this.amlStrCases.filter((c) => c?.status === 'FILED');
|
||||
}
|
||||
selectedStrCase: any = null;
|
||||
concludeConclusion: 'NOT_SUSPICIOUS' | 'SUSPICIOUS' = 'NOT_SUSPICIOUS';
|
||||
concludeInvestigationNotes = '';
|
||||
@@ -398,6 +424,14 @@ export class FiuComponent implements OnInit {
|
||||
this.isHeadOffice = this.authenticationService.isHeadOfficeUser();
|
||||
this.currentOfficeName = creds?.officeName || 'Branch Office';
|
||||
|
||||
// Opened on a particular tab when another screen sends the officer here for a reason - the Risk
|
||||
// Review Report links a case that is with the Principal Officer straight to the STR queue, so
|
||||
// landing on the dashboard would make them find it themselves.
|
||||
const wantedTab = this.route.snapshot.queryParamMap.get('tab');
|
||||
if (wantedTab === 'str' || wantedTab === 'ctr' || wantedTab === 'customers' || wantedTab === 'files') {
|
||||
this.activeTab = wantedTab;
|
||||
}
|
||||
|
||||
// The header named a hard-coded officer and a hard-coded month — "Karthik Kamath" and
|
||||
// "January 2026" — on a screen whose whole purpose is to say which month is being filed and
|
||||
// by whom. Both now come from the session and the clock.
|
||||
@@ -615,10 +649,9 @@ export class FiuComponent implements OnInit {
|
||||
}
|
||||
|
||||
/**
|
||||
* Always fetches every status. The triage table and the cleared register are two views of the
|
||||
* same set, so filtering server-side would empty one whenever the other was narrowed — and the
|
||||
* CTR tab reads this list too, which a status filter chosen on the STR tab should not touch.
|
||||
* amlAlertStatusFilter is applied client-side in {@link amlSuspiciousAlerts} instead.
|
||||
* Always fetches every status. This tab shows what was cleared and what became an STR case, which are two views of
|
||||
* the same set, and the CTR tab reads the list as well — so narrowing it server-side for one view would empty
|
||||
* another.
|
||||
*/
|
||||
loadAmlAlerts(): void {
|
||||
this.amlService.getAlerts().subscribe((data) => (this.amlAlerts = data || []));
|
||||
@@ -661,7 +694,8 @@ export class FiuComponent implements OnInit {
|
||||
this.ctrLinesLoading = false;
|
||||
this.ctrTxns = [];
|
||||
this.ctrLinesError =
|
||||
err?.error?.defaultUserMessage || 'Could not load the generated CTR lines. Generate a report for the month first.';
|
||||
err?.error?.defaultUserMessage ||
|
||||
'Could not load the generated CTR lines. Generate a report for the month first.';
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -761,48 +795,6 @@ export class FiuComponent implements OnInit {
|
||||
return (err: any) => this.toast(err?.error?.defaultUserMessage || `${action} failed — please retry`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clearing or escalating an alert has to carry a reason: review_notes is the only audit record of
|
||||
* why a flagged transaction was disposed of, and an empty string leaves no trace of the decision.
|
||||
*/
|
||||
private reviewNotesOf(alert: any): string | null {
|
||||
const notes = (this.amlReviewNotes[alert.id] || '').trim();
|
||||
if (!notes) {
|
||||
this.toast(`Enter review notes before disposing of alert #${alert.id}`);
|
||||
return null;
|
||||
}
|
||||
return notes;
|
||||
}
|
||||
|
||||
whitelistAlert(alert: any): void {
|
||||
const notes = this.reviewNotesOf(alert);
|
||||
if (!notes) {
|
||||
return;
|
||||
}
|
||||
this.amlService.reviewAlert(alert.id, 'WHITELIST', notes).subscribe({
|
||||
next: () => {
|
||||
this.toast(`Alert #${alert.id} whitelisted — no STR raised`);
|
||||
this.loadAmlAlerts();
|
||||
},
|
||||
error: this.apiError(`Whitelisting alert #${alert.id}`)
|
||||
});
|
||||
}
|
||||
|
||||
escalateAlert(alert: any): void {
|
||||
const notes = this.reviewNotesOf(alert);
|
||||
if (!notes) {
|
||||
return;
|
||||
}
|
||||
this.amlService.reviewAlert(alert.id, 'ESCALATE', notes).subscribe({
|
||||
next: () => {
|
||||
this.toast(`Alert #${alert.id} escalated — now an STR case for the Principal Officer`);
|
||||
this.loadAmlAlerts();
|
||||
this.loadAmlStrCases();
|
||||
},
|
||||
error: this.apiError(`Escalating alert #${alert.id}`)
|
||||
});
|
||||
}
|
||||
|
||||
generateCtrReport(): void {
|
||||
if (!this.ctrGenerateMonth) {
|
||||
this.toast('Pick a reporting month first');
|
||||
@@ -1021,12 +1013,6 @@ export class FiuComponent implements OnInit {
|
||||
* The STR triage queue: still to decide, or already escalated. Whitelisted alerts are closed
|
||||
* with no report, so they are not STR business — they live in {@link amlWhitelistedAlerts}.
|
||||
*/
|
||||
get amlSuspiciousAlerts(): any[] {
|
||||
return this.behaviouralAlerts.filter(
|
||||
(a) => a.status !== 'WHITELISTED' && (!this.amlAlertStatusFilter || a.status === this.amlAlertStatusFilter)
|
||||
);
|
||||
}
|
||||
|
||||
/** Cleared register: alerts closed as false positives, kept for audit with their evidence. */
|
||||
get amlWhitelistedAlerts(): any[] {
|
||||
return this.behaviouralAlerts.filter((a) => a.status === 'WHITELISTED');
|
||||
@@ -1689,7 +1675,9 @@ export class FiuComponent implements OnInit {
|
||||
private writeLiveStrXml(cases: any[], filename: string): void {
|
||||
const xml: string[] = [];
|
||||
xml.push('<?xml version="1.0" encoding="UTF-8"?>');
|
||||
xml.push(`<STR xmlns="http://fiuind.gov.in/finnet2/str" reportingMonth="${this.escapeXml(this.reportingMonthLabel)}">`);
|
||||
xml.push(
|
||||
`<STR xmlns="http://fiuind.gov.in/finnet2/str" reportingMonth="${this.escapeXml(this.reportingMonthLabel)}">`
|
||||
);
|
||||
xml.push(` <REBank name="${this.escapeXml(this.bank.name)}" />`);
|
||||
if (this.principalOfficerName) {
|
||||
xml.push(` <PrincipalOfficer name="${this.escapeXml(this.principalOfficerName)}" />`);
|
||||
|
||||
+6
-10
@@ -576,13 +576,11 @@
|
||||
}
|
||||
|
||||
/* ───────── Print — only the page, no toolbar / dialog chrome ─────────
|
||||
This dialog is opened with `maxHeight: '92vh'`, which Angular Material carries onto the
|
||||
overlay pane / .mat-mdc-dialog-container as an inline max-height + overflow:auto clip.
|
||||
`position: absolute` still lives inside that clip and gets cut off — only the on-screen
|
||||
scroll position prints. `position: fixed` escapes it: a fixed element's containing block is
|
||||
the viewport itself, not the clipped ancestor (the classic "fixed child ignores an
|
||||
overflow:hidden parent" behaviour), as long as nothing between here and the viewport sets a
|
||||
transform/filter — the CDK overlay chrome doesn't. */
|
||||
The document-level clip (`html, body { overflow: hidden }`) is lifted globally in main.scss's
|
||||
print block; what remains here is this dialog's own clip — it opens with `maxHeight: '92vh'`,
|
||||
which Angular Material carries onto the overlay pane / .mat-mdc-dialog-container as an inline
|
||||
max-height plus overflow:auto. Freeing those lets the whole certificate flow onto the page in
|
||||
normal static order (a fixed/absolute hack would pin it to the first page instead). */
|
||||
@media print {
|
||||
body > :not(.cdk-overlay-container) {
|
||||
display: none !important;
|
||||
@@ -611,9 +609,7 @@
|
||||
}
|
||||
|
||||
.cpd-root {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
position: static;
|
||||
width: 100% !important;
|
||||
height: auto !important;
|
||||
background: #fff;
|
||||
|
||||
+1
-34
@@ -70,16 +70,9 @@ export class ShareProductAccountingStepComponent implements OnInit {
|
||||
shareProductAccountingForm: UntypedFormGroup;
|
||||
accountingSectionConfigs: ShareAccountingSectionConfig[] = [];
|
||||
|
||||
assetAccountData: GLAccount[] = [];
|
||||
liabilityAccountData: GLAccount[] = [];
|
||||
incomeAccountData: GLAccount[] = [];
|
||||
equityAccountData: GLAccount[] = [];
|
||||
/** "Share Reference" is where a redemption's credit side lands pending settlement — some banks
|
||||
* model that as a receivable-like Asset (e.g. "Cash Vault", this field's original default),
|
||||
* others as a payable-like Liability suspense account (e.g. an existing "Sundry" account
|
||||
* already used elsewhere as Shares Suspense). Both are valid conventions, so this field accepts
|
||||
* either instead of Asset-only. */
|
||||
referenceAccountData: GLAccount[] = [];
|
||||
|
||||
constructor() {
|
||||
this.createShareProductAccountingForm();
|
||||
@@ -87,18 +80,15 @@ export class ShareProductAccountingStepComponent implements OnInit {
|
||||
|
||||
ngOnInit() {
|
||||
const opts = this.shareProductsTemplate.accountingMappingOptions || {};
|
||||
this.assetAccountData = opts.assetAccountOptions || [];
|
||||
this.liabilityAccountData = opts.liabilityAccountOptions || [];
|
||||
this.incomeAccountData = opts.incomeAccountOptions || [];
|
||||
this.equityAccountData = opts.equityAccountOptions || [];
|
||||
this.referenceAccountData = this.assetAccountData.concat(this.liabilityAccountData);
|
||||
|
||||
this.buildAccountingSectionConfigs();
|
||||
|
||||
if (this.shareProductsTemplate.accountingMappings) {
|
||||
const m = this.shareProductsTemplate.accountingMappings;
|
||||
this.shareProductAccountingForm.patchValue({
|
||||
shareReferenceId: m.shareReferenceId?.id || '',
|
||||
shareSuspenseId: m.shareSuspenseId?.id || '',
|
||||
incomeFromFeeAccountId: m.incomeFromFeeAccountId?.id || '',
|
||||
shareEquityId: m.shareEquityId?.id || ''
|
||||
@@ -109,10 +99,6 @@ export class ShareProductAccountingStepComponent implements OnInit {
|
||||
createShareProductAccountingForm() {
|
||||
this.shareProductAccountingForm = this.formBuilder.group({
|
||||
accountingRule: [2],
|
||||
shareReferenceId: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
shareSuspenseId: [
|
||||
'',
|
||||
Validators.required
|
||||
@@ -125,7 +111,6 @@ export class ShareProductAccountingStepComponent implements OnInit {
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
assetsOtherGlMappings: this.formBuilder.array([]),
|
||||
liabilitiesOtherGlMappings: this.formBuilder.array([]),
|
||||
incomeOtherGlMappings: this.formBuilder.array([]),
|
||||
equityOtherGlMappings: this.formBuilder.array([])
|
||||
@@ -134,24 +119,6 @@ export class ShareProductAccountingStepComponent implements OnInit {
|
||||
|
||||
buildAccountingSectionConfigs() {
|
||||
this.accountingSectionConfigs = [
|
||||
{
|
||||
key: 'assets',
|
||||
title: 'Assets',
|
||||
pillLabel: 'Asset',
|
||||
pillClass: 'green',
|
||||
rows: [
|
||||
{
|
||||
key: 'shareReferenceId',
|
||||
label: 'Share Reference (Cash Vault or Suspense)',
|
||||
inputLabel: 'Share reference',
|
||||
controlName: 'shareReferenceId',
|
||||
options: this.referenceAccountData,
|
||||
required: true
|
||||
}
|
||||
],
|
||||
otherMappingsControlName: 'assetsOtherGlMappings',
|
||||
otherAccountOptions: this.assetAccountData
|
||||
},
|
||||
{
|
||||
key: 'liabilities',
|
||||
title: 'Liabilities',
|
||||
@@ -239,7 +206,7 @@ export class ShareProductAccountingStepComponent implements OnInit {
|
||||
}
|
||||
|
||||
get shareProductAccounting() {
|
||||
const { assetsOtherGlMappings, liabilitiesOtherGlMappings, incomeOtherGlMappings, equityOtherGlMappings, ...rest } =
|
||||
const { liabilitiesOtherGlMappings, incomeOtherGlMappings, equityOtherGlMappings, ...rest } =
|
||||
this.shareProductAccountingForm.value;
|
||||
return rest;
|
||||
}
|
||||
|
||||
-8
@@ -213,14 +213,6 @@
|
||||
</div>
|
||||
|
||||
@if (shareProduct.accountingRule === 2) {
|
||||
<div class="shpp-sub-header">{{ 'labels.heading.Assets' | translate }}</div>
|
||||
<div class="shpp-row">
|
||||
<span class="shpp-label">{{ 'labels.inputs.Share reference' | translate }}:</span>
|
||||
<span class="shpp-value">{{
|
||||
shareProduct.shareReferenceId
|
||||
| find: shareProductsTemplate.accountingMappingOptions.assetAccountOptions : 'id' : 'name'
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="shpp-sub-header">{{ 'labels.heading.Liabilities' | translate }}</div>
|
||||
<div class="shpp-row">
|
||||
<span class="shpp-label">{{ 'labels.inputs.Share suspense control' | translate }}:</span>
|
||||
|
||||
-10
@@ -208,16 +208,6 @@
|
||||
|
||||
@if (shareProduct.accountingRule.id === 2) {
|
||||
<div class="layout-row-wrap responsive-column flex-fill">
|
||||
<h4 class="mat-h4 flex-fill">{{ 'labels.heading.Assets' | translate }}</h4>
|
||||
<div class="flex-fill">
|
||||
<mifosx-gl-account-display
|
||||
class="flex-100"
|
||||
[accountTitle]="'Share reference'"
|
||||
[glAccount]="shareProduct.accountingMappings.shareReferenceId"
|
||||
[withTitle]="'40%'"
|
||||
>
|
||||
</mifosx-gl-account-display>
|
||||
</div>
|
||||
<h4 class="mat-h4 flex-fill">{{ 'labels.heading.Liabilities' | translate }}</h4>
|
||||
<div class="flex-fill">
|
||||
<mifosx-gl-account-display
|
||||
|
||||
+311
@@ -0,0 +1,311 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, Inject, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormBuilder, FormArray, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { forkJoin, of } from 'rxjs';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
|
||||
import { AmlService } from 'app/aml/aml.service';
|
||||
|
||||
export interface AmlBulkReviewData {
|
||||
alerts: any[];
|
||||
disposition: 'WHITELIST' | 'ESCALATE';
|
||||
}
|
||||
|
||||
/**
|
||||
* One disposition applied to several alerts at once.
|
||||
*
|
||||
* <p>
|
||||
* Low and medium-severity alerts arrive as runs of the same thing - a customer whose ordinary cash
|
||||
* deposits each cross a threshold - and clearing them one dialog at a time is what keeps an officer
|
||||
* from reaching the alert that matters. The reason and evidence are asked once and written against
|
||||
* every alert in the batch, so each still carries its own record of why it was disposed of.
|
||||
*
|
||||
* <p>
|
||||
* High-severity alerts never reach here: the caller will not tick them. An alert in the high band is
|
||||
* a judgement about that particular transaction and gets its own decision.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-aml-bulk-review-dialog',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
MatDialogModule,
|
||||
MatButtonModule,
|
||||
MatCheckboxModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule
|
||||
],
|
||||
template: `
|
||||
<h2 mat-dialog-title>
|
||||
{{ isWhitelist ? 'Whitelist' : 'Escalate' }} {{ data.alerts.length }} Alert{{
|
||||
data.alerts.length === 1 ? '' : 's'
|
||||
}}
|
||||
</h2>
|
||||
<mat-dialog-content>
|
||||
<p class="bk-intro">
|
||||
The same decision, reason and evidence are recorded against each alert below. Every one stays individually
|
||||
auditable.
|
||||
</p>
|
||||
|
||||
<div class="bk-list">
|
||||
<div class="bk-row" *ngFor="let a of data.alerts">
|
||||
<span class="bk-txn">{{ a.transactionType }} #{{ a.transactionId }}</span>
|
||||
<span class="bk-client">{{ a.clientDisplayName }}</span>
|
||||
<span class="bk-rule">{{ a.ruleCode }}</span>
|
||||
<span class="bk-amt">₹{{ a.amount | number: '1.0-0' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form [formGroup]="form">
|
||||
<!-- Clearing an alert has to rest on something. Escalation does not: the officer is saying
|
||||
it needs investigating, which is the opposite of claiming it is explained. -->
|
||||
<div class="bk-evidence" *ngIf="isWhitelist">
|
||||
<div class="bk-label">Evidence relied on</div>
|
||||
<div formArrayName="evidence" class="bk-checks">
|
||||
<mat-checkbox *ngFor="let t of evidenceTypes; let i = index" [formControlName]="i">{{ t }}</mat-checkbox>
|
||||
</div>
|
||||
<p class="bk-err" *ngIf="evidenceMissing">Tick at least one piece of evidence</p>
|
||||
|
||||
<mat-form-field appearance="outline" class="bk-full">
|
||||
<mat-label>Reference (optional)</mat-label>
|
||||
<input matInput formControlName="evidenceRef" maxlength="100" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<mat-form-field appearance="outline" class="bk-full">
|
||||
<mat-label>{{ isWhitelist ? 'Evidence detail' : 'What makes these suspicious?' }}</mat-label>
|
||||
<textarea matInput formControlName="notes" rows="3" maxlength="800"></textarea>
|
||||
<mat-error *ngIf="form.get('notes')?.hasError('required')">A reason is required</mat-error>
|
||||
</mat-form-field>
|
||||
</form>
|
||||
|
||||
<p class="bk-effect" [class.bk-effect--warn]="!isWhitelist">
|
||||
{{
|
||||
isWhitelist
|
||||
? 'Cleared as false positives — no report is filed, and the evidence is retained against each alert.'
|
||||
: 'Each alert opens its own STR case for the Principal Officer.'
|
||||
}}
|
||||
</p>
|
||||
|
||||
<p class="bk-err" *ngIf="errorMessage">{{ errorMessage }}</p>
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-stroked-button type="button" (click)="dialogRef.close(false)">Cancel</button>
|
||||
<button
|
||||
mat-flat-button
|
||||
[color]="isWhitelist ? 'primary' : 'warn'"
|
||||
type="button"
|
||||
[disabled]="form.invalid || submitting"
|
||||
(click)="submit()"
|
||||
>
|
||||
{{ submitting ? 'Applying…' : isWhitelist ? 'Whitelist All' : 'Escalate All' }}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.bk-intro {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
margin: 0 0 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.bk-list {
|
||||
max-height: 190px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
.bk-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1.1fr 1.4fr 1.6fr 0.9fr;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
border-bottom: 1px solid #eef2f7;
|
||||
}
|
||||
.bk-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.bk-txn {
|
||||
font-family: 'DM Mono', monospace;
|
||||
color: #334155;
|
||||
}
|
||||
.bk-client {
|
||||
color: #1e293b;
|
||||
}
|
||||
.bk-rule {
|
||||
color: #7c3aed;
|
||||
font-size: 11px;
|
||||
}
|
||||
.bk-amt {
|
||||
text-align: right;
|
||||
font-family: 'DM Mono', monospace;
|
||||
color: #1e293b;
|
||||
}
|
||||
.bk-label {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: #64748b;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.bk-checks {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.bk-full {
|
||||
width: 100%;
|
||||
}
|
||||
.bk-effect {
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
color: #15803d;
|
||||
background: #f0fdf4;
|
||||
border: 1px solid #bbf7d0;
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
.bk-effect--warn {
|
||||
color: #b91c1c;
|
||||
background: #fef2f2;
|
||||
border-color: #fecaca;
|
||||
}
|
||||
.bk-err {
|
||||
margin: 10px 0 0;
|
||||
color: #b91c1c;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
`
|
||||
]
|
||||
})
|
||||
export class AmlBulkReviewDialogComponent {
|
||||
private fb = inject(FormBuilder);
|
||||
private amlService = inject(AmlService);
|
||||
dialogRef = inject(MatDialogRef<AmlBulkReviewDialogComponent>);
|
||||
|
||||
readonly evidenceTypes = [
|
||||
'Customer explanation obtained',
|
||||
'Source of funds documented',
|
||||
'KYC / identity re-verified',
|
||||
'Consistent with declared income or turnover',
|
||||
'Established business or salary pattern',
|
||||
'Duplicate or system-generated alert',
|
||||
'Other'
|
||||
];
|
||||
|
||||
form: FormGroup;
|
||||
submitting = false;
|
||||
errorMessage = '';
|
||||
|
||||
data: AmlBulkReviewData = inject(MAT_DIALOG_DATA);
|
||||
|
||||
constructor() {
|
||||
this.form = this.fb.group({
|
||||
evidence: this.fb.array(this.evidenceTypes.map(() => false)),
|
||||
evidenceRef: [
|
||||
'',
|
||||
Validators.maxLength(100)
|
||||
],
|
||||
notes: [
|
||||
'',
|
||||
[
|
||||
Validators.required,
|
||||
Validators.maxLength(800)
|
||||
]
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
get isWhitelist(): boolean {
|
||||
return this.data.disposition === 'WHITELIST';
|
||||
}
|
||||
|
||||
get evidenceArray(): FormArray {
|
||||
return this.form.get('evidence') as FormArray;
|
||||
}
|
||||
|
||||
get selectedEvidence(): string[] {
|
||||
return this.evidenceTypes.filter((_, i) => this.evidenceArray.at(i)?.value === true);
|
||||
}
|
||||
|
||||
get evidenceMissing(): boolean {
|
||||
return this.isWhitelist && this.evidenceArray.touched && this.selectedEvidence.length === 0;
|
||||
}
|
||||
|
||||
/** Exactly what is written to each alert, so the record reads the same as a single review. */
|
||||
private composedNotes(): string {
|
||||
const v = this.form.value;
|
||||
const detail = (v.notes || '').trim();
|
||||
if (!this.isWhitelist) {
|
||||
return detail ? `Grounds: ${detail}` : '';
|
||||
}
|
||||
const parts: string[] = [];
|
||||
if (this.selectedEvidence.length) {
|
||||
parts.push(`Evidence: ${this.selectedEvidence.join('; ')}`);
|
||||
}
|
||||
if (v.evidenceRef) {
|
||||
parts.push(`Ref: ${(v.evidenceRef || '').trim()}`);
|
||||
}
|
||||
const head = parts.join(' | ');
|
||||
const composed = head && detail ? `${head} — ${detail}` : head || detail;
|
||||
// Batch note, so each alert says it was disposed of alongside others rather than on its own.
|
||||
return `${composed} [batch of ${this.data.alerts.length}]`.slice(0, 1000);
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
this.evidenceArray.markAllAsTouched();
|
||||
if (this.form.invalid || (this.isWhitelist && this.selectedEvidence.length === 0)) {
|
||||
this.form.markAllAsTouched();
|
||||
return;
|
||||
}
|
||||
|
||||
this.submitting = true;
|
||||
this.errorMessage = '';
|
||||
const notes = this.composedNotes();
|
||||
|
||||
// Each alert is reviewed on its own call: one that fails - already disposed of by someone else,
|
||||
// say - must not take the rest of the batch with it. Failures are counted and reported.
|
||||
forkJoin(
|
||||
this.data.alerts.map((a) =>
|
||||
this.amlService
|
||||
.reviewAlert(a.id, this.data.disposition, notes)
|
||||
.pipe(catchError(() => of({ __failed: true, id: a.id })))
|
||||
)
|
||||
).subscribe((results: any[]) => {
|
||||
this.submitting = false;
|
||||
const failed = results.filter((r) => r?.__failed).length;
|
||||
if (failed === results.length) {
|
||||
this.errorMessage = 'None of the alerts could be updated. They may already have been reviewed.';
|
||||
return;
|
||||
}
|
||||
if (failed > 0) {
|
||||
this.errorMessage = `${results.length - failed} updated, ${failed} could not be — those may already have been reviewed.`;
|
||||
// Still closes as done: most of the batch landed, and the list behind must refresh.
|
||||
setTimeout(() => this.dialogRef.close(true), 2200);
|
||||
return;
|
||||
}
|
||||
this.dialogRef.close(true);
|
||||
});
|
||||
}
|
||||
}
|
||||
+407
-38
@@ -36,6 +36,17 @@
|
||||
<button mat-icon-button type="button" matTooltip="Refresh now" [disabled]="loading" (click)="fetch()">
|
||||
<mat-icon>refresh</mat-icon>
|
||||
</button>
|
||||
<!-- Exports what is on screen, not the whole feed: the workbook has to reconcile with the report it came from. -->
|
||||
<button
|
||||
type="button"
|
||||
class="export-btn"
|
||||
[disabled]="exporting || !alerts.data.length"
|
||||
[matTooltip]="alerts.data.length ? 'Download the alerts currently in view as Excel' : 'Nothing in view to export'"
|
||||
(click)="exportExcel()"
|
||||
>
|
||||
<mat-icon>file_download</mat-icon>
|
||||
{{ exporting ? 'Preparing…' : 'Excel' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -44,6 +55,52 @@
|
||||
<span>Could not reach the alert feed. Retrying automatically.</span>
|
||||
</div>
|
||||
|
||||
<!-- Narrow the feed to one day. The feed accumulates, so without this an officer asking "what did we decide today"
|
||||
reads past a week of older alerts to find out. Composes with the status chips below: Today + Escalated answers
|
||||
"what did we mark suspicious today". -->
|
||||
<div class="status-filters">
|
||||
<span class="status-filters-label">Period</span>
|
||||
@for (m of periodModes; track m.key) {
|
||||
<button type="button" class="status-chip" [class.active]="periodMode === m.key" (click)="setPeriodMode(m.key)">
|
||||
{{ m.label }}
|
||||
</button>
|
||||
}
|
||||
|
||||
<!-- One control per mode, so the picker always matches the granularity chosen. A week is picked by naming any day
|
||||
inside it; the resolved Monday-to-Sunday span is printed beside it so the officer sees what was selected. -->
|
||||
@if (periodMode === 'day') {
|
||||
<input type="date" class="day-input" [(ngModel)]="filterDay" (ngModelChange)="onPeriodChange()" aria-label="Day" />
|
||||
}
|
||||
@if (periodMode === 'week') {
|
||||
<input
|
||||
type="date"
|
||||
class="day-input"
|
||||
[(ngModel)]="filterWeek"
|
||||
(ngModelChange)="onPeriodChange()"
|
||||
aria-label="Any day in the week"
|
||||
/>
|
||||
}
|
||||
@if (periodMode === 'month') {
|
||||
<input
|
||||
type="month"
|
||||
class="day-input"
|
||||
[(ngModel)]="filterMonth"
|
||||
(ngModelChange)="onPeriodChange()"
|
||||
aria-label="Month"
|
||||
/>
|
||||
}
|
||||
@if (periodMode === 'year') {
|
||||
<select class="day-input" [(ngModel)]="filterYear" (ngModelChange)="onPeriodChange()" aria-label="Year">
|
||||
@for (y of availableYears; track y) {
|
||||
<option [value]="y">{{ y }}</option>
|
||||
}
|
||||
</select>
|
||||
}
|
||||
@if (periodLabel) {
|
||||
<span class="period-resolved">{{ periodLabel }}</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Narrow the feed by where each alert sits in the review workflow -->
|
||||
<div class="status-filters">
|
||||
<span class="status-filters-label">Status</span>
|
||||
@@ -60,23 +117,143 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Severity mix of what the table is showing -->
|
||||
<div class="risk-summary">
|
||||
<div *ngFor="let tier of riskSummary" class="risk-tile" [ngClass]="tier.cls">
|
||||
<span class="risk-tile-n">{{ tier.count }}</span>
|
||||
<span class="risk-tile-l">{{ tier.label }}</span>
|
||||
</div>
|
||||
<div *ngIf="unclassifiedCount > 0" class="risk-tile risk-none" matTooltip="Raised by a user, not by a catalog rule">
|
||||
<span class="risk-tile-n">{{ unclassifiedCount }}</span>
|
||||
<span class="risk-tile-l">Unclassified</span>
|
||||
</div>
|
||||
<div class="risk-tile risk-total">
|
||||
<span class="risk-tile-n">{{ summaryTotal }}</span>
|
||||
<span class="risk-tile-l">Total Flagged</span>
|
||||
</div>
|
||||
<!-- Narrow to the rules firing most often. A rule tripping repeatedly is either a real pattern in the book or a rule
|
||||
set too tight and filling the queue with noise — either way it is what an officer reads first, and the long tail
|
||||
below it can wait. Ranked over the period and status already chosen. -->
|
||||
<div class="status-filters">
|
||||
<span class="status-filters-label">Top rules</span>
|
||||
@for (n of topRuleOptions; track n) {
|
||||
<button type="button" class="status-chip" [class.active]="topRules === n" (click)="setTopRules(n)">
|
||||
{{ n === null ? 'All rules' : 'Top ' + n }}
|
||||
</button>
|
||||
}
|
||||
@if (topRules !== null) {
|
||||
<span class="period-resolved">{{ distinctRuleCount }} rule{{ distinctRuleCount === 1 ? '' : 's' }} in view</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<table mat-table [dataSource]="alerts" class="flagged-table mat-elevation-z1">
|
||||
<!-- Which rules are driving the queue, and by how much. Ranking without the counts narrows the table and leaves the
|
||||
officer to work out what it kept. -->
|
||||
@if (ruleFrequency.length > 1) {
|
||||
<div class="rule-freq">
|
||||
@for (r of ruleFrequency; track r.code) {
|
||||
<span class="rule-freq-item">
|
||||
{{ r.code }}
|
||||
<span class="rule-freq-n">{{ r.count }}</span>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Severity mix of what the table is showing. Each tile narrows the table to its own band;
|
||||
clicking the one already in force clears it, as does Total Flagged. -->
|
||||
<div class="risk-summary">
|
||||
<button
|
||||
*ngFor="let tier of riskSummary"
|
||||
type="button"
|
||||
class="risk-tile"
|
||||
[ngClass]="tier.cls"
|
||||
[class.tile-active]="isSeverityActive(tier.key)"
|
||||
[disabled]="tier.count === 0 && !isSeverityActive(tier.key)"
|
||||
[matTooltip]="isSeverityActive(tier.key) ? 'Show every severity again' : 'Show only ' + tier.label + ' alerts'"
|
||||
(click)="setSeverity(tier.key)"
|
||||
>
|
||||
<span class="risk-tile-n">{{ tier.count }}</span>
|
||||
<span class="risk-tile-l">{{ tier.label }}</span>
|
||||
</button>
|
||||
<button
|
||||
*ngIf="unclassifiedCount > 0"
|
||||
type="button"
|
||||
class="risk-tile risk-none"
|
||||
[class.tile-active]="isSeverityActive(0)"
|
||||
matTooltip="Raised by a user, not by a catalog rule"
|
||||
(click)="setSeverity(0)"
|
||||
>
|
||||
<span class="risk-tile-n">{{ unclassifiedCount }}</span>
|
||||
<span class="risk-tile-l">Unclassified</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="risk-tile risk-total"
|
||||
[class.tile-active]="activeSeverity === null"
|
||||
matTooltip="Show every severity"
|
||||
(click)="clearSeverity()"
|
||||
>
|
||||
<span class="risk-tile-n">{{ summaryTotal }}</span>
|
||||
<span class="risk-tile-l">Total Flagged</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Appears only once something is ticked, so the table does not carry a permanently empty bar. -->
|
||||
@if (selectedCount > 0) {
|
||||
<div class="bulk-bar">
|
||||
<span class="bulk-count">{{ selectedCount }} selected</span>
|
||||
<span class="bulk-note"
|
||||
>Low / medium only — a transaction that tripped a high-severity rule is decided on its own.</span
|
||||
>
|
||||
<span class="bulk-spacer"></span>
|
||||
<button mat-stroked-button type="button" (click)="selectedIds.clear()">Clear</button>
|
||||
<button
|
||||
mat-stroked-button
|
||||
color="primary"
|
||||
type="button"
|
||||
*mifosxHasPermission="'REVIEW_AML_ALERT'"
|
||||
(click)="reviewSelected('WHITELIST')"
|
||||
>
|
||||
Whitelist {{ selectedCount }}
|
||||
</button>
|
||||
<button
|
||||
mat-flat-button
|
||||
color="warn"
|
||||
type="button"
|
||||
*mifosxHasPermission="'REVIEW_AML_ALERT'"
|
||||
(click)="reviewSelected('ESCALATE')"
|
||||
>
|
||||
Escalate {{ selectedCount }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- multiTemplateDataRows: each alert renders through both row definitions — the alert row and its
|
||||
detail row beneath it. Without it MatTable renders one row per alert, the two definitions both
|
||||
claim it, and the table throws rather than rendering anything. All three tabs share one tab
|
||||
group, so that error blanked Repeat Customers and Rules Master as well. -->
|
||||
<table mat-table [dataSource]="alerts" multiTemplateDataRows class="flagged-table mat-elevation-z1">
|
||||
<!-- Ticked for a batch decision. Disabled where the transaction tripped a high-severity rule,
|
||||
where nothing it matched carried a severity (the CTR rules), or where it is already decided. -->
|
||||
<ng-container matColumnDef="select">
|
||||
<th mat-header-cell *matHeaderCellDef class="select-cell">
|
||||
<!-- Hidden outright when nothing here can be batched — filtered to high severity, say. -->
|
||||
@if (selectableRows.length) {
|
||||
<mat-checkbox
|
||||
[checked]="allSelectableSelected"
|
||||
(change)="toggleSelectAll()"
|
||||
matTooltip="Select every low / medium alert on this page"
|
||||
></mat-checkbox>
|
||||
}
|
||||
</th>
|
||||
<td mat-cell *matCellDef="let alert" class="select-cell" (click)="$event.stopPropagation()">
|
||||
<!-- No checkbox at all where the transaction cannot be part of a batch. A disabled box still
|
||||
reads as "this could be ticked", and on a high-severity row that is the wrong thing to
|
||||
suggest: the decision is never a batch one. A dash says the column does not apply. -->
|
||||
@if (isSelectable(alert)) {
|
||||
<mat-checkbox [checked]="isSelected(alert)" (change)="toggleSelected(alert)"></mat-checkbox>
|
||||
} @else {
|
||||
<span
|
||||
class="select-na"
|
||||
[matTooltip]="
|
||||
isHighSeverity(alert)
|
||||
? 'High severity — this transaction is decided on its own'
|
||||
: !canReview(alert)
|
||||
? 'Already decided'
|
||||
: 'A CTR is a statutory return, not a graded suspicion — decided on its own'
|
||||
"
|
||||
>—</span
|
||||
>
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- When the engine raised the alert -->
|
||||
<ng-container matColumnDef="raisedOn">
|
||||
<th mat-header-cell *matHeaderCellDef>Raised On</th>
|
||||
@@ -113,6 +290,162 @@
|
||||
<div class="stacked-cell">
|
||||
<span class="rule-code">{{ alert.ruleCode }}</span>
|
||||
<span class="cell-sub">{{ alert.ruleDescription }}</span>
|
||||
<!-- The transaction is one case under one rule, but an analyst still needs to see
|
||||
everything it tripped — a deposit can be both reportable and on a new account. The
|
||||
detail row carries each one with its own severity; this is only the count. -->
|
||||
@if (matchedRuleCount(alert) > 1) {
|
||||
<button type="button" class="rule-more" (click)="toggleExpanded(alert); $event.stopPropagation()">
|
||||
<mat-icon>{{ isExpanded(alert) ? 'expand_less' : 'expand_more' }}</mat-icon>
|
||||
{{ matchedRuleCount(alert) }} rules matched
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- The expanded pane. One cell spanning the table, collapsed to zero height until its row is
|
||||
opened, so the detail sits directly under the row it belongs to. -->
|
||||
<ng-container matColumnDef="expandedDetail">
|
||||
<!-- No header cell: this column never appears in the header row, only in its own data row. -->
|
||||
<td mat-cell *matCellDef="let alert" [attr.colspan]="displayedColumns.length" class="detail-cell">
|
||||
<!-- The pane is inside a row that toggles on click, so anything interactive in here must keep its own
|
||||
clicks from reaching it — otherwise ticking a rule closes the pane. -->
|
||||
<div class="detail-pane" [class.detail-open]="isExpanded(alert)" (click)="$event.stopPropagation()">
|
||||
@if (isExpanded(alert)) {
|
||||
<div class="detail-inner">
|
||||
<div class="detail-head">
|
||||
<span
|
||||
>Rules matched by {{ alert.transactionType | lowercase }} #{{ alert.transactionId }} —
|
||||
{{ amount(alert) }}</span
|
||||
>
|
||||
@if (selectableRulesOf(alert).length > 1) {
|
||||
<button type="button" class="rule-select-all" (click)="toggleAllRules(alert)">
|
||||
{{ allRulesSelected(alert) ? 'Clear all' : 'Select all open' }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<!-- Governing rule first: it is the one the alert was raised under, and the one the
|
||||
disposition is recorded against. -->
|
||||
@for (rule of matchedRules(alert); track rule.ruleCode) {
|
||||
<div
|
||||
class="detail-rule"
|
||||
[class.detail-rule-governing]="rule.governing"
|
||||
[class.detail-rule-decided]="rule.disposition"
|
||||
>
|
||||
<!-- One checkbox per rule: a transaction can trip six rules where five are explained by the
|
||||
customer's business and the sixth is not, and those are different decisions. A rule already
|
||||
decided shows its verdict instead. -->
|
||||
@if (isRuleSelectable(rule)) {
|
||||
<mat-checkbox
|
||||
[checked]="isRuleSelected(rule)"
|
||||
(change)="toggleRuleSelected(rule)"
|
||||
*mifosxHasPermission="'REVIEW_AML_ALERT'"
|
||||
></mat-checkbox>
|
||||
} @else if (!isGradedRule(rule)) {
|
||||
<!-- A statutory return, not a graded suspicion: filed on the amount whatever the officer decides
|
||||
about the transaction, so there is nothing here to tick. -->
|
||||
<mat-icon
|
||||
class="rule-decided-icon decided-statutory"
|
||||
matTooltip="A statutory return — filed on the amount, whatever is decided about this transaction. Nothing to review here."
|
||||
matTooltipClass="evidence-tooltip"
|
||||
>
|
||||
gavel
|
||||
</mat-icon>
|
||||
} @else {
|
||||
<mat-icon
|
||||
class="rule-decided-icon"
|
||||
[class.decided-escalated]="rule.disposition === 'ESCALATE' && rule.directlyDecided"
|
||||
[class.decided-carried]="!rule.directlyDecided"
|
||||
[matTooltip]="
|
||||
(rule.directlyDecided ? ruleDispositionLabel(rule) : 'Closed with the case') +
|
||||
(rule.reviewedByUsername ? ' by ' + rule.reviewedByUsername : '') +
|
||||
(rule.reviewNotes ? ' — ' + rule.reviewNotes : '')
|
||||
"
|
||||
matTooltipClass="evidence-tooltip"
|
||||
>
|
||||
{{
|
||||
!rule.directlyDecided
|
||||
? 'remove_circle_outline'
|
||||
: rule.disposition === 'ESCALATE'
|
||||
? 'report'
|
||||
: 'verified'
|
||||
}}
|
||||
</mat-icon>
|
||||
}
|
||||
<span class="sev-chip" [ngClass]="severityClass(rule.riskCategory)">{{
|
||||
severityLabel(rule.riskCategory)
|
||||
}}</span>
|
||||
<div class="detail-rule-text">
|
||||
<span class="detail-rule-code">
|
||||
{{ rule.ruleCode }}
|
||||
@if (rule.governing) {
|
||||
<span class="governing-tag" matTooltip="The alert is raised and decided under this rule"
|
||||
>governing</span
|
||||
>
|
||||
}
|
||||
<!-- Says where a statutory rule actually goes, so the absence of a checkbox does not read as the
|
||||
transaction being unreported. -->
|
||||
@if (!isGradedRule(rule)) {
|
||||
<span class="ctr-tag" matTooltip="Goes to the monthly CTR filing for FIU-IND, not to an STR">
|
||||
→ CTR filing
|
||||
</span>
|
||||
}
|
||||
<!-- The rule the officer named reads as their decision; a rule closed alongside it says so,
|
||||
because an audit of the finding turns on which one they put their name to. -->
|
||||
@if (rule.disposition) {
|
||||
<span
|
||||
class="rule-verdict"
|
||||
[class.verdict-escalated]="rule.disposition === 'ESCALATE'"
|
||||
[class.verdict-carried]="!rule.directlyDecided"
|
||||
[matTooltip]="
|
||||
rule.directlyDecided
|
||||
? ruleDispositionLabel(rule) +
|
||||
(rule.reviewedByUsername ? ' by ' + rule.reviewedByUsername : '')
|
||||
: 'Closed with the transaction when another rule was marked suspicious — not decided on its own'
|
||||
"
|
||||
>
|
||||
{{ rule.directlyDecided ? ruleDispositionLabel(rule) : 'Closed with case' }}
|
||||
</span>
|
||||
}
|
||||
</span>
|
||||
<span class="detail-rule-desc">{{ rule.ruleDescription }}</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Acts on the ticked rules only. The alert's own status follows from what is left undecided, so
|
||||
clearing five of six rules leaves the case open on the sixth. -->
|
||||
@if (selectedRuleCount > 0) {
|
||||
<div class="rule-action-bar" *mifosxHasPermission="'REVIEW_AML_ALERT'">
|
||||
<span class="rule-action-count"
|
||||
>{{ selectedRuleCount }} rule{{ selectedRuleCount > 1 ? 's' : '' }} selected</span
|
||||
>
|
||||
<span class="bulk-spacer"></span>
|
||||
<button mat-stroked-button type="button" (click)="selectedRuleCodes.clear()">Clear</button>
|
||||
<button
|
||||
mat-stroked-button
|
||||
color="primary"
|
||||
type="button"
|
||||
(click)="reviewSelectedRules(alert, 'WHITELIST')"
|
||||
>
|
||||
Whitelist
|
||||
</button>
|
||||
<button mat-flat-button color="warn" type="button" (click)="reviewSelectedRules(alert, 'ESCALATE')">
|
||||
Suspicious
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
<div class="detail-foot">
|
||||
@if (isHighSeverity(alert)) {
|
||||
A high-severity rule fired, so this transaction is decided on its own rather than in a batch.
|
||||
} @else if (!alert.highestSeverity) {
|
||||
A statutory return rather than a graded suspicion — decided on its own.
|
||||
} @else {
|
||||
Every rule here is low or medium, so this transaction can be decided together with others.
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
@@ -132,7 +465,7 @@
|
||||
<!-- Disposition. Hidden once an alert is already whitelisted or escalated. -->
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef class="col-center">Action</th>
|
||||
<td mat-cell *matCellDef="let alert" class="col-center actions-cell">
|
||||
<td mat-cell *matCellDef="let alert" class="col-center actions-cell" (click)="$event.stopPropagation()">
|
||||
<ng-container *ngIf="canReview(alert); else reviewed">
|
||||
<!-- Same gate the FIU-IND register uses; the endpoint enforces REVIEW_AML_ALERT server side,
|
||||
so without this a user who cannot review still sees buttons that only fail on click. -->
|
||||
@@ -158,37 +491,73 @@
|
||||
</div>
|
||||
</ng-container>
|
||||
<ng-template #reviewed>
|
||||
<div class="reviewed-cell">
|
||||
<span class="reviewed-by">
|
||||
{{ alert.reviewedByUsername ? 'by ' + alert.reviewedByUsername : 'Reviewed' }}
|
||||
</span>
|
||||
<!-- The evidence recorded at review time, so the trail is readable without leaving the feed -->
|
||||
<mat-icon
|
||||
*ngIf="alert.reviewNotes"
|
||||
class="reviewed-evidence-icon"
|
||||
[matTooltip]="alert.reviewNotes"
|
||||
matTooltipClass="evidence-tooltip"
|
||||
>
|
||||
description
|
||||
</mat-icon>
|
||||
<span *ngIf="alert.status === 'ESCALATED'" class="str-routed" matTooltip="An STR case is open for this alert">
|
||||
→ STR
|
||||
</span>
|
||||
</div>
|
||||
<!-- Held by somebody else: not a verdict, so it does not read like one. The endpoint refuses a disposition
|
||||
from anyone but the officer who has it, and this says who to ask. -->
|
||||
@if (heldByAnother(alert)) {
|
||||
<div class="reviewed-cell">
|
||||
<mat-icon
|
||||
class="held-icon"
|
||||
[matTooltip]="'Open with ' + heldBy(alert) + ' — they decide it or hand it back'"
|
||||
>
|
||||
lock
|
||||
</mat-icon>
|
||||
<span class="held-by">with {{ heldBy(alert) }}</span>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="reviewed-cell">
|
||||
<span class="reviewed-by">
|
||||
{{ alert.reviewedByUsername ? 'by ' + alert.reviewedByUsername : 'Reviewed' }}
|
||||
</span>
|
||||
<!-- The evidence recorded at review time, so the trail is readable without leaving the feed -->
|
||||
<mat-icon
|
||||
*ngIf="alert.reviewNotes"
|
||||
class="reviewed-evidence-icon"
|
||||
[matTooltip]="alert.reviewNotes"
|
||||
matTooltipClass="evidence-tooltip"
|
||||
>
|
||||
description
|
||||
</mat-icon>
|
||||
<span
|
||||
*ngIf="alert.status === 'ESCALATED'"
|
||||
class="str-routed"
|
||||
matTooltip="An STR case is open for this alert"
|
||||
>
|
||||
→ STR
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</ng-template>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns" class="alert-row" [class.row-new]="isNew(row)"></tr>
|
||||
<!-- Clicking anywhere on the row opens its detail. The checkbox and the action buttons stop the
|
||||
event themselves, so ticking a row or disposing of it does not also expand it. -->
|
||||
<tr
|
||||
mat-row
|
||||
*matRowDef="let row; columns: displayedColumns"
|
||||
class="alert-row"
|
||||
[class.row-new]="isNew(row)"
|
||||
[class.row-expanded]="isExpanded(row)"
|
||||
(click)="toggleExpanded(row)"
|
||||
></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: ['expandedDetail']" class="detail-row"></tr>
|
||||
</table>
|
||||
|
||||
<mat-paginator [pageSizeOptions]="[10, 25, 50, 100]" showFirstLastButtons class="flagged-paginator"></mat-paginator>
|
||||
<!-- pageSize stated explicitly: without it the paginator starts at 0, and anything that touches the
|
||||
paginator before the data source has bound to it leaves that 0 in force, which pages every row
|
||||
out of the table. -->
|
||||
<mat-paginator
|
||||
[pageSize]="10"
|
||||
[pageSizeOptions]="[10, 25, 50, 100]"
|
||||
showFirstLastButtons
|
||||
class="flagged-paginator"
|
||||
></mat-paginator>
|
||||
|
||||
<div *ngIf="alerts.data.length === 0 && !loading" class="no-alerts-message">
|
||||
<mat-icon>{{ activeStatus ? 'filter_alt_off' : 'verified_user' }}</mat-icon>
|
||||
<span *ngIf="!activeStatus"
|
||||
<mat-icon>{{ activeStatus || activeSeverity !== null ? 'filter_alt_off' : 'verified_user' }}</mat-icon>
|
||||
<span *ngIf="!activeStatus && activeSeverity === null"
|
||||
>No transactions flagged. The feed updates automatically as the engine raises alerts.</span
|
||||
>
|
||||
<span *ngIf="activeStatus">No alerts in this status.</span>
|
||||
<span *ngIf="activeStatus || activeSeverity !== null">No alerts match the filters in force.</span>
|
||||
</div>
|
||||
|
||||
+475
-1
@@ -196,15 +196,43 @@
|
||||
padding: 0 0 16px;
|
||||
}
|
||||
|
||||
/* A button, not a div: each tile narrows the table to its own severity band. */
|
||||
.risk-tile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-left-width: 4px;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
box-shadow 0.15s,
|
||||
transform 0.1s;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
box-shadow: 0 2px 8px rgb(15 23 42 / 12%);
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
/* A band with nothing in it would filter to an empty table. */
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
/* The band currently narrowing the table. The ring reads on every tile colour, where a
|
||||
background change would fight the severity tint each tile already carries. */
|
||||
.risk-tile.tile-active {
|
||||
box-shadow: 0 0 0 2px #1a237e;
|
||||
}
|
||||
|
||||
.risk-tile-n {
|
||||
@@ -293,8 +321,21 @@
|
||||
}
|
||||
}
|
||||
|
||||
.alert-row:hover {
|
||||
.alert-row {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
}
|
||||
|
||||
/* The open row and its pane read as one block. */
|
||||
.alert-row.row-expanded {
|
||||
background: #f8fafc;
|
||||
|
||||
td {
|
||||
border-bottom-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
/* A newly arrived alert stays tinted until acknowledged. */
|
||||
@@ -380,6 +421,285 @@
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Quieter than the governing rule: it is context on one case, not a second case. */
|
||||
.rule-also {
|
||||
margin-top: 3px;
|
||||
color: #92400e;
|
||||
background: #fffbeb;
|
||||
border: 1px solid #fde68a;
|
||||
border-radius: 4px;
|
||||
padding: 1px 6px;
|
||||
font-size: 11px;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
/* Says how many rules fired and opens the detail. Replaces the block of codes that used to sit in
|
||||
this cell: the codes belong in the pane, where each can carry its own severity. */
|
||||
.rule-more {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
margin-top: 4px;
|
||||
padding: 2px 8px 2px 4px;
|
||||
align-self: flex-start;
|
||||
border: 1px solid #fde68a;
|
||||
border-radius: 999px;
|
||||
background: #fffbeb;
|
||||
color: #92400e;
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
|
||||
mat-icon {
|
||||
font-size: 15px;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: #fef3c7;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Expanded detail ─────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
.detail-row {
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.detail-cell {
|
||||
padding: 0 !important;
|
||||
border-bottom: none !important;
|
||||
}
|
||||
|
||||
/* Animating max-height rather than height: the pane's height depends on how many rules fired, and
|
||||
a transition needs a figure to move between. */
|
||||
.detail-pane {
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.2s ease;
|
||||
background: #f8fafc;
|
||||
|
||||
/* Generous enough for a transaction that tripped every rule it could, plus the action bar. Too low
|
||||
silently clips the last rule, which is the one an officer most needs to see. */
|
||||
&.detail-open {
|
||||
max-height: 1200px;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-inner {
|
||||
padding: 14px 16px 16px 60px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.detail-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #64748b;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.rule-select-all {
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0;
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: #1a237e;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
/* A rule that already carries a verdict is context, not a choice. */
|
||||
.detail-rule-decided {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.rule-decided-icon {
|
||||
flex: none;
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: #15803d;
|
||||
cursor: help;
|
||||
|
||||
&.decided-escalated {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
/* Closed alongside another rule rather than judged on its own — stated, but not as a verdict. */
|
||||
&.decided-carried {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
/* A statutory return: not the officer's decision at all. */
|
||||
&.decided-statutory {
|
||||
color: #475569;
|
||||
}
|
||||
}
|
||||
|
||||
/* Where a statutory rule goes, said next to the rule rather than left to be inferred from a
|
||||
missing checkbox. */
|
||||
.ctr-tag {
|
||||
margin-left: 6px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
background: #e2e8f0;
|
||||
color: #334155;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rule-verdict {
|
||||
margin-left: 6px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
background: #dcfce7;
|
||||
color: #15803d;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
|
||||
&.verdict-escalated {
|
||||
background: #fee2e2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
/* Wins over the disposition colours above: this rule was not itself the finding, and colouring it
|
||||
like one would claim the officer condemned it. */
|
||||
&.verdict-carried {
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
}
|
||||
}
|
||||
|
||||
/* Acts on the ticked rules only, and sits with them rather than at the top of the table. */
|
||||
.rule-action-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 8px;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.rule-action-count {
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
color: #1e3a8a;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.detail-rule {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 7px 10px;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
|
||||
& + & {
|
||||
margin-top: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
/* The rule the alert was raised under, and the one the disposition is recorded against. */
|
||||
.detail-rule-governing {
|
||||
border-color: #cbd5e1;
|
||||
box-shadow: inset 3px 0 0 #1a237e;
|
||||
}
|
||||
|
||||
.detail-rule-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.detail-rule-code {
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.detail-rule-desc {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.governing-tag {
|
||||
margin-left: 6px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
background: #e0e7ff;
|
||||
color: #3730a3;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Severity of one rule inside the pane. Same colours as the summary tiles, so a band reads the
|
||||
same wherever it appears. */
|
||||
.sev-chip {
|
||||
flex: none;
|
||||
min-width: 62px;
|
||||
text-align: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
|
||||
&.sev-high {
|
||||
background: #fee2e2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
&.sev-medium {
|
||||
background: #fef3c7;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
&.sev-low {
|
||||
background: #dcfce7;
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
&.sev-none {
|
||||
background: #e2e8f0;
|
||||
color: #475569;
|
||||
}
|
||||
}
|
||||
|
||||
/* Why this transaction can or cannot be decided in a batch, said where the officer is deciding. */
|
||||
.detail-foot {
|
||||
margin-top: 10px;
|
||||
font-size: 12px;
|
||||
color: #475569;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.rule-code {
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -537,3 +857,157 @@
|
||||
background: #fff;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
/* Batch disposition bar — only rendered while rows are ticked. */
|
||||
.bulk-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 16px;
|
||||
margin-bottom: 10px;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 10px;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.bulk-count {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #1e3a8a;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Says why some rows cannot be ticked, where the officer is looking rather than in a tooltip. */
|
||||
.bulk-note {
|
||||
font-size: 12px;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.bulk-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.select-cell {
|
||||
width: 44px;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
/* Stands in for the checkbox on a row that cannot be batched, so the column still reads as a
|
||||
column rather than leaving a hole the eye has to account for. */
|
||||
.select-na {
|
||||
display: inline-block;
|
||||
width: 18px;
|
||||
text-align: center;
|
||||
color: #cbd5e1;
|
||||
cursor: help;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Day picker. Sized and bordered to sit level with the status chips beside it, so the two rows read
|
||||
as one control strip rather than a form field dropped above a set of buttons. */
|
||||
.day-input {
|
||||
padding: 5px 11px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
font-family: inherit;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
border-color: #1a237e;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #1a237e;
|
||||
}
|
||||
}
|
||||
|
||||
/* What the chosen mode resolved to. A week picked by naming one day inside it is otherwise invisible — the officer
|
||||
has no way to tell which Monday-to-Sunday span they are looking at. */
|
||||
.period-resolved {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Sits with the pause and refresh controls, green because it produces something the officer takes away rather
|
||||
than changing what is on screen. */
|
||||
.export-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 13px;
|
||||
border: 1px solid #bbf7d0;
|
||||
border-radius: 999px;
|
||||
background: #f0fdf4;
|
||||
color: #15803d;
|
||||
font-family: inherit;
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
|
||||
mat-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #dcfce7;
|
||||
border-color: #86efac;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
/* Held by another officer. Grey, not red: nobody has done anything wrong, the case is simply somebody else's. */
|
||||
.held-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: #94a3b8;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.held-by {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Rule frequency strip. Sits under the filters as a reading aid, not a control — clicking a rule would be a fourth
|
||||
filter competing with the three above it. */
|
||||
.rule-freq {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 0 0 14px;
|
||||
}
|
||||
|
||||
.rule-freq-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 9px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 999px;
|
||||
background: #f8fafc;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.rule-freq-n {
|
||||
padding: 0 6px;
|
||||
border-radius: 999px;
|
||||
background: #e2e8f0;
|
||||
color: #1e293b;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
+787
-25
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
/** Angular Imports */
|
||||
import { Component, OnInit, OnDestroy, inject, ViewChild } from '@angular/core';
|
||||
import { Component, OnInit, AfterViewInit, OnDestroy, inject, ViewChild } from '@angular/core';
|
||||
import { MatTableModule, MatTableDataSource } from '@angular/material/table';
|
||||
import { MatPaginatorModule, MatPaginator } from '@angular/material/paginator';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
@@ -15,15 +15,23 @@ import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Subject, timer, of } from 'rxjs';
|
||||
import { switchMap, takeUntil, catchError, filter } from 'rxjs/operators';
|
||||
|
||||
/** Custom Services */
|
||||
import { AmlService } from '../../../aml/aml.service';
|
||||
import { AlertService } from '../../../core/alert/alert.service';
|
||||
import { AuthenticationService } from '../../../core/authentication/authentication.service';
|
||||
import { HasPermissionDirective } from '../../../directives/has-permission/has-permission.directive';
|
||||
import { AmlReviewAlertDialogComponent } from './aml-review-alert-dialog.component';
|
||||
import { AmlBulkReviewDialogComponent } from './aml-bulk-review-dialog.component';
|
||||
import { exportWorkbook } from '../../../cashier/cashier-export';
|
||||
|
||||
/** How the feed is narrowed by time. */
|
||||
type PeriodMode = 'all' | 'day' | 'week' | 'month' | 'year';
|
||||
|
||||
/**
|
||||
* Live feed of transactions the AML rule engine has flagged.
|
||||
@@ -41,6 +49,7 @@ import { AmlReviewAlertDialogComponent } from './aml-review-alert-dialog.compone
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatTableModule,
|
||||
MatPaginatorModule,
|
||||
MatIconModule,
|
||||
@@ -48,13 +57,18 @@ import { AmlReviewAlertDialogComponent } from './aml-review-alert-dialog.compone
|
||||
MatTooltipModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatDialogModule,
|
||||
MatCheckboxModule,
|
||||
HasPermissionDirective
|
||||
]
|
||||
})
|
||||
export class AmlFlaggedTransactionsComponent implements OnInit, OnDestroy {
|
||||
export class AmlFlaggedTransactionsComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
private amlService = inject(AmlService);
|
||||
private dialog = inject(MatDialog);
|
||||
private alertService = inject(AlertService);
|
||||
private auth = inject(AuthenticationService);
|
||||
|
||||
/** Who is signed in, so the screen can tell an alert this officer holds from one somebody else does. */
|
||||
private readonly currentUsername = this.auth.getCredentials()?.username ?? '';
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
/** Short enough to feel live to an officer watching the desk, long enough not to hammer the API. */
|
||||
@@ -62,6 +76,7 @@ export class AmlFlaggedTransactionsComponent implements OnInit, OnDestroy {
|
||||
|
||||
alerts = new MatTableDataSource<any>([]);
|
||||
displayedColumns: string[] = [
|
||||
'select',
|
||||
'raisedOn',
|
||||
'transaction',
|
||||
'client',
|
||||
@@ -71,25 +86,532 @@ export class AmlFlaggedTransactionsComponent implements OnInit, OnDestroy {
|
||||
'actions'
|
||||
];
|
||||
|
||||
/**
|
||||
* Alerts ticked for a single disposition across several transactions.
|
||||
*
|
||||
* <p>
|
||||
* Low and medium-severity alerts arrive in numbers and are mostly the same decision repeated -
|
||||
* a run of ordinary cash deposits that each tripped a threshold. Dealing with them one dialog at
|
||||
* a time is what stops an officer getting to the one alert that matters. High severity is
|
||||
* deliberately excluded: an alert that reached the high band is a judgement about that customer's
|
||||
* transaction, and it gets its own decision.
|
||||
*/
|
||||
selectedIds = new Set<number>();
|
||||
|
||||
/** m_client_risk_rule.risk_category — 3 is the high band. */
|
||||
private static readonly HIGH_SEVERITY = 3;
|
||||
|
||||
/**
|
||||
* Whether any rule this transaction matched reached the high band.
|
||||
*
|
||||
* <p>
|
||||
* Read from highestSeverity, not from the governing rule's own riskCategory. One transaction
|
||||
* raises one alert under whichever rule most warrants attention, and a statutory CTR outranks
|
||||
* everything — so a high-severity rule can sit in "also matched" while the row itself reports the
|
||||
* CTR, which carries no severity at all. Judging the row by the governing rule would offer such a
|
||||
* transaction up for a batch decision.
|
||||
*/
|
||||
isHighSeverity(alert: any): boolean {
|
||||
return Number(alert?.highestSeverity) === AmlFlaggedTransactionsComponent.HIGH_SEVERITY;
|
||||
}
|
||||
|
||||
/**
|
||||
* The band this alert counts as, for the summary tiles.
|
||||
*
|
||||
* <p>
|
||||
* The same figure the checkbox is gated on, so the tiles and the table cannot disagree. Reading
|
||||
* riskCategory here put a ₹12,00,000 cash deposit that had tripped a high-severity rule under
|
||||
* "Unclassified", because the alert reports under the statutory CTR and a CTR carries no band of
|
||||
* its own. Alerts raised before highest_severity existed fall back to the governing rule's band.
|
||||
*/
|
||||
private severityBand(alert: any): number {
|
||||
const worst = Number(alert?.highestSeverity);
|
||||
if (worst === 1 || worst === 2 || worst === 3) {
|
||||
return worst;
|
||||
}
|
||||
const governing = Number(alert?.riskCategory);
|
||||
if (governing === 1 || governing === 2 || governing === 3) {
|
||||
return governing;
|
||||
}
|
||||
// 0, never NaN: the Unclassified tile filters on this value, and NaN === NaN is false, so a
|
||||
// NaN band would leave that tile showing a count it could never select.
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Only an open alert whose every matched rule sits in the low or medium band can be swept up with
|
||||
* others.
|
||||
*
|
||||
* <p>
|
||||
* Two things are held back. A transaction that tripped any high-severity rule, because that is a
|
||||
* judgement about the transaction itself. And one where no matched rule carried a severity at
|
||||
* all, which is how the CTR rules arrive - they are not graded, being returns the bank owes
|
||||
* rather than suspicions. Neither belongs in a batch disposed of on a single reason.
|
||||
*/
|
||||
isSelectable(alert: any): boolean {
|
||||
const band = Number(alert?.highestSeverity);
|
||||
const graded = band === 1 || band === 2;
|
||||
return this.canReview(alert) && graded;
|
||||
}
|
||||
|
||||
/** The alert whose detail row is open. One at a time: this is a reading pane, not a comparison. */
|
||||
expandedId: number | null = null;
|
||||
|
||||
/**
|
||||
* Opens or closes the detail pane. Nothing is recorded by reading.
|
||||
*
|
||||
* <p>
|
||||
* Expanding a row used to mark the alert UNDER_REVIEW, on the reasoning that opening the pane is where the work
|
||||
* starts. It is not: most expands are an officer glancing at what a transaction tripped, and a status that changes
|
||||
* on a single click describes the click rather than any review. The queue filled with alerts nobody was working,
|
||||
* and the one state the flag existed to make visible — this one is actually being worked — became meaningless.
|
||||
*/
|
||||
toggleExpanded(alert: any): void {
|
||||
this.expandedId = this.expandedId === alert.id ? null : alert.id;
|
||||
// Ticks belong to the alert they were made on, not to whichever pane is open next.
|
||||
this.selectedRuleCodes.clear();
|
||||
}
|
||||
|
||||
isExpanded(alert: any): boolean {
|
||||
return this.expandedId === alert.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every rule the transaction matched, governing one first.
|
||||
*
|
||||
* <p>
|
||||
* Served by the API. The fallback rebuilds the list from the flat fields for an alert read before the API carried
|
||||
* it, so an older row still expands to something rather than appearing to have matched nothing.
|
||||
*/
|
||||
matchedRules(
|
||||
alert: any
|
||||
): { ruleCode: string; ruleDescription: string; riskCategory: number | null; governing: boolean }[] {
|
||||
if (Array.isArray(alert?.matchedRules) && alert.matchedRules.length) {
|
||||
return alert.matchedRules;
|
||||
}
|
||||
const rules = [
|
||||
{
|
||||
ruleCode: alert?.ruleCode,
|
||||
ruleDescription: alert?.ruleDescription,
|
||||
riskCategory: alert?.riskCategory ?? null,
|
||||
governing: true
|
||||
}
|
||||
];
|
||||
for (const code of String(alert?.alsoMatchedRules || '')
|
||||
.split(',')
|
||||
.map((c: string) => c.trim())
|
||||
.filter((c: string) => c)) {
|
||||
rules.push({ ruleCode: code, ruleDescription: code, riskCategory: null, governing: false });
|
||||
}
|
||||
return rules;
|
||||
}
|
||||
|
||||
/** How many rules the transaction matched, for the count shown on the collapsed row. */
|
||||
matchedRuleCount(alert: any): number {
|
||||
return this.matchedRules(alert).length;
|
||||
}
|
||||
|
||||
severityLabel(band: number | null | undefined): string {
|
||||
switch (Number(band)) {
|
||||
case 1:
|
||||
return 'Low';
|
||||
case 2:
|
||||
return 'Medium';
|
||||
case 3:
|
||||
return 'High';
|
||||
default:
|
||||
return 'Statutory';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rule codes ticked inside the open detail pane.
|
||||
*
|
||||
* <p>
|
||||
* Cleared whenever a different alert is opened: the codes are only meaningful against the alert they were ticked on,
|
||||
* and carrying them across would apply one transaction's selection to another's rules.
|
||||
*/
|
||||
selectedRuleCodes = new Set<string>();
|
||||
|
||||
isRuleSelected(rule: any): boolean {
|
||||
return this.selectedRuleCodes.has(rule.ruleCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this rule is the officer's to decide.
|
||||
*
|
||||
* <p>
|
||||
* A rule already decided keeps its verdict. A rule with no severity band is not a graded suspicion at all - the CTR
|
||||
* rules are statutory returns the bank owes FIU-IND on any cash transaction at or above ten lakh, filed on the
|
||||
* amount whether or not the customer is above suspicion. There is no "whitelist" for one: clearing the transaction
|
||||
* does not stop the CTR, and escalating it does not describe what the rule found.
|
||||
*/
|
||||
isRuleSelectable(rule: any): boolean {
|
||||
return !rule?.disposition && this.isGradedRule(rule);
|
||||
}
|
||||
|
||||
/** A rule that carries a severity band, as against a statutory reporting rule that carries none. */
|
||||
isGradedRule(rule: any): boolean {
|
||||
const band = Number(rule?.riskCategory);
|
||||
return band === 1 || band === 2 || band === 3;
|
||||
}
|
||||
|
||||
toggleRuleSelected(rule: any): void {
|
||||
if (!this.isRuleSelectable(rule)) {
|
||||
return;
|
||||
}
|
||||
if (this.selectedRuleCodes.has(rule.ruleCode)) {
|
||||
this.selectedRuleCodes.delete(rule.ruleCode);
|
||||
} else {
|
||||
this.selectedRuleCodes.add(rule.ruleCode);
|
||||
}
|
||||
}
|
||||
|
||||
selectableRulesOf(alert: any): any[] {
|
||||
return this.matchedRules(alert).filter((r) => this.isRuleSelectable(r));
|
||||
}
|
||||
|
||||
get selectedRuleCount(): number {
|
||||
return this.selectedRuleCodes.size;
|
||||
}
|
||||
|
||||
allRulesSelected(alert: any): boolean {
|
||||
const open = this.selectableRulesOf(alert);
|
||||
return open.length > 0 && open.every((r) => this.selectedRuleCodes.has(r.ruleCode));
|
||||
}
|
||||
|
||||
toggleAllRules(alert: any): void {
|
||||
if (this.allRulesSelected(alert)) {
|
||||
this.selectedRuleCodes.clear();
|
||||
return;
|
||||
}
|
||||
for (const rule of this.selectableRulesOf(alert)) {
|
||||
this.selectedRuleCodes.add(rule.ruleCode);
|
||||
}
|
||||
}
|
||||
|
||||
ruleDispositionLabel(rule: any): string {
|
||||
return rule?.disposition === 'ESCALATE' ? 'Escalated' : rule?.disposition === 'WHITELIST' ? 'Whitelisted' : '';
|
||||
}
|
||||
|
||||
severityClass(band: number | null | undefined): string {
|
||||
switch (Number(band)) {
|
||||
case 1:
|
||||
return 'sev-low';
|
||||
case 2:
|
||||
return 'sev-medium';
|
||||
case 3:
|
||||
return 'sev-high';
|
||||
default:
|
||||
return 'sev-none';
|
||||
}
|
||||
}
|
||||
|
||||
isSelected(alert: any): boolean {
|
||||
return this.selectedIds.has(alert.id);
|
||||
}
|
||||
|
||||
toggleSelected(alert: any): void {
|
||||
if (!this.isSelectable(alert)) {
|
||||
return;
|
||||
}
|
||||
if (this.selectedIds.has(alert.id)) {
|
||||
this.selectedIds.delete(alert.id);
|
||||
} else {
|
||||
this.selectedIds.add(alert.id);
|
||||
}
|
||||
}
|
||||
|
||||
/** The rows a "select all" would act on: what is on screen, open, and below the high band. */
|
||||
get selectableRows(): any[] {
|
||||
return this.alerts.data.filter((a) => this.isSelectable(a));
|
||||
}
|
||||
|
||||
get allSelectableSelected(): boolean {
|
||||
const rows = this.selectableRows;
|
||||
return rows.length > 0 && rows.every((a) => this.selectedIds.has(a.id));
|
||||
}
|
||||
|
||||
toggleSelectAll(): void {
|
||||
if (this.allSelectableSelected) {
|
||||
this.selectedIds.clear();
|
||||
return;
|
||||
}
|
||||
for (const a of this.selectableRows) {
|
||||
this.selectedIds.add(a.id);
|
||||
}
|
||||
}
|
||||
|
||||
get selectedCount(): number {
|
||||
return this.selectedIds.size;
|
||||
}
|
||||
|
||||
private selectedAlerts(): any[] {
|
||||
return this.alerts.data.filter((a) => this.selectedIds.has(a.id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops ticks that no longer stand, after every poll.
|
||||
*
|
||||
* <p>
|
||||
* The feed refreshes itself every few seconds while the officer works. Without this, an alert
|
||||
* another officer disposed of in the meantime — or one the status filter has since hidden — stays
|
||||
* in the set, and the bar goes on offering to decide it: the count reads higher than the ticks on
|
||||
* screen, and the batch carries alerts nobody chose.
|
||||
*/
|
||||
private pruneSelection(): void {
|
||||
const stillSelectable = new Set(this.selectableRows.map((a) => a.id));
|
||||
for (const id of Array.from(this.selectedIds)) {
|
||||
if (!stillSelectable.has(id)) {
|
||||
this.selectedIds.delete(id);
|
||||
}
|
||||
}
|
||||
// Same for the open detail pane: an alert filtered away or disposed of elsewhere should not
|
||||
// leave the table holding a pane for a row that is no longer on it.
|
||||
if (this.expandedId !== null && !this.alerts.data.some((a) => a.id === this.expandedId)) {
|
||||
this.expandedId = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Every alert as returned, before the status filter narrows the table. */
|
||||
private allAlerts: any[] = [];
|
||||
|
||||
readonly statusFilters: { key: string; label: string }[] = [
|
||||
{ key: '', label: 'All alerts' },
|
||||
{ key: 'RAISED', label: 'Raised' },
|
||||
{ key: 'UNDER_REVIEW', label: 'Under review' },
|
||||
// No "Under review" chip: nothing sets that status now that reading a row no longer records anything, so the
|
||||
// chip would sit at zero for good. The status itself is kept — alerts predating this change still carry it, and
|
||||
// canReview still treats them as open — but it is not offered as a filter nobody can populate.
|
||||
{ key: 'ESCALATED', label: 'Escalated' },
|
||||
{ key: 'WHITELISTED', label: 'Whitelisted' }
|
||||
];
|
||||
activeStatus = '';
|
||||
private statusCounts: Record<string, number> = {};
|
||||
|
||||
/**
|
||||
* The period being worked.
|
||||
*
|
||||
* A monitoring feed accumulates: last week's alerts sit under today's, and an officer asked "what did we decide
|
||||
* today" has to read past them. The period filter is what turns the feed into a day's, a week's or a month's work
|
||||
* — and it composes with the status chips, so Today + Escalated answers "what did we mark suspicious today".
|
||||
*/
|
||||
readonly periodModes: { key: PeriodMode; label: string }[] = [
|
||||
{ key: 'all', label: 'All time' },
|
||||
{ key: 'day', label: 'Day wise' },
|
||||
{ key: 'week', label: 'Week wise' },
|
||||
{ key: 'month', label: 'Month wise' },
|
||||
{ key: 'year', label: 'Year wise' }
|
||||
];
|
||||
periodMode: PeriodMode = 'all';
|
||||
|
||||
/** The chosen day / any day inside the chosen week / the chosen month / the chosen year. */
|
||||
filterDay = '';
|
||||
filterWeek = '';
|
||||
filterMonth = '';
|
||||
filterYear = '';
|
||||
|
||||
/**
|
||||
* Narrows the table to the rules firing most often in the chosen period.
|
||||
*
|
||||
* <p>
|
||||
* A rule tripping repeatedly is a different question from one transaction tripping a rule: it is either a real
|
||||
* pattern in the book, or a rule set too tight and filling the queue with noise. Either way the rules at the top of
|
||||
* the count are the ones worth an officer's attention first, and the long tail below them can wait.
|
||||
*
|
||||
* <p>
|
||||
* Null shows every rule. The options are deliberately small — a "top 10" that lists 40 rules answers nothing.
|
||||
*/
|
||||
readonly topRuleOptions: (number | null)[] = [
|
||||
null,
|
||||
10,
|
||||
20,
|
||||
50
|
||||
];
|
||||
topRules: number | null = null;
|
||||
|
||||
setTopRules(n: number | null): void {
|
||||
this.topRules = n;
|
||||
this.applyStatusFilter(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* The rule codes to keep, most-triggered first, or null when every rule is in view.
|
||||
*
|
||||
* <p>
|
||||
* Counted over the period and status already chosen rather than over the whole feed, so "top 10 this month" means
|
||||
* the ten busiest rules of that month. Ties are broken on the rule code so the same set comes back each time
|
||||
* instead of shifting between polls.
|
||||
*/
|
||||
private topRuleCodes(pool: any[]): Set<string> | null {
|
||||
if (this.topRules === null) {
|
||||
return null;
|
||||
}
|
||||
const counts = new Map<string, number>();
|
||||
for (const a of pool) {
|
||||
const code = a?.ruleCode;
|
||||
if (code) {
|
||||
counts.set(code, (counts.get(code) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
if (counts.size <= this.topRules) {
|
||||
return null;
|
||||
}
|
||||
const ranked = Array.from(counts.entries())
|
||||
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
||||
.slice(0, this.topRules)
|
||||
.map(([code]) => code);
|
||||
return new Set(ranked);
|
||||
}
|
||||
|
||||
/** How many distinct rules the current view holds, so the chip can say what it is narrowing. */
|
||||
get distinctRuleCount(): number {
|
||||
return new Set(this.alerts.data.map((a) => a.ruleCode).filter((c) => c)).size;
|
||||
}
|
||||
|
||||
/**
|
||||
* The rules in view with how often each fired, busiest first.
|
||||
*
|
||||
* <p>
|
||||
* The point of ranking rules is to see which ones are driving the queue, and a count per rule is that answer.
|
||||
* Without it "Top 10" narrows the table and leaves the officer to work out what it kept.
|
||||
*/
|
||||
get ruleFrequency(): { code: string; count: number }[] {
|
||||
const counts = new Map<string, number>();
|
||||
for (const a of this.alerts.data) {
|
||||
if (a?.ruleCode) {
|
||||
counts.set(a.ruleCode, (counts.get(a.ruleCode) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
return Array.from(counts.entries())
|
||||
.map(
|
||||
([
|
||||
code,
|
||||
count
|
||||
]) => ({ code, count })
|
||||
)
|
||||
.sort((a, b) => b.count - a.count || a.code.localeCompare(b.code));
|
||||
}
|
||||
|
||||
setPeriodMode(mode: PeriodMode): void {
|
||||
this.periodMode = mode;
|
||||
// Seeded with today the first time a mode is picked, so choosing "Month wise" shows this month rather than an
|
||||
// empty field the officer has to fill before anything happens.
|
||||
const now = new Date();
|
||||
const iso = this.isoOf(now);
|
||||
if (mode === 'day' && !this.filterDay) {
|
||||
this.filterDay = iso;
|
||||
}
|
||||
if (mode === 'week' && !this.filterWeek) {
|
||||
this.filterWeek = iso;
|
||||
}
|
||||
if (mode === 'month' && !this.filterMonth) {
|
||||
this.filterMonth = iso.slice(0, 7);
|
||||
}
|
||||
if (mode === 'year' && !this.filterYear) {
|
||||
this.filterYear = String(now.getFullYear());
|
||||
}
|
||||
this.applyStatusFilter(true);
|
||||
}
|
||||
|
||||
onPeriodChange(): void {
|
||||
this.applyStatusFilter(true);
|
||||
}
|
||||
|
||||
/** The years the feed actually covers, newest first, so the picker offers nothing empty. */
|
||||
get availableYears(): string[] {
|
||||
const years = new Set<string>();
|
||||
for (const a of this.allAlerts) {
|
||||
if (a.raisedOnDate) {
|
||||
years.add(String(a.raisedOnDate.getFullYear()));
|
||||
}
|
||||
}
|
||||
const list = Array.from(years).sort().reverse();
|
||||
return list.length ? list : [String(new Date().getFullYear())];
|
||||
}
|
||||
|
||||
/** Reads back the resolved period, so the officer can see what "week wise" actually resolved to. */
|
||||
get periodLabel(): string {
|
||||
const range = this.periodRange();
|
||||
if (!range) {
|
||||
return '';
|
||||
}
|
||||
return range.from === range.to ? range.from : `${range.from} to ${range.to}`;
|
||||
}
|
||||
|
||||
private isoOf(d: Date): string {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The chosen period as an inclusive from/to, or null for all time.
|
||||
*
|
||||
* Week runs Monday to Sunday. Built from local date parts rather than by parsing to UTC: an alert raised late in
|
||||
* the evening is on that evening's date for the officer looking at it, and a UTC round-trip would move it.
|
||||
*/
|
||||
private periodRange(): { from: string; to: string } | null {
|
||||
switch (this.periodMode) {
|
||||
case 'day':
|
||||
return this.filterDay ? { from: this.filterDay, to: this.filterDay } : null;
|
||||
case 'week': {
|
||||
if (!this.filterWeek) {
|
||||
return null;
|
||||
}
|
||||
const [
|
||||
y,
|
||||
m,
|
||||
d
|
||||
] = this.filterWeek.split('-').map(Number);
|
||||
const picked = new Date(y, m - 1, d);
|
||||
const backToMonday = picked.getDay() === 0 ? 6 : picked.getDay() - 1;
|
||||
const monday = new Date(y, m - 1, d - backToMonday);
|
||||
const sunday = new Date(y, m - 1, d - backToMonday + 6);
|
||||
return { from: this.isoOf(monday), to: this.isoOf(sunday) };
|
||||
}
|
||||
case 'month': {
|
||||
if (!this.filterMonth) {
|
||||
return null;
|
||||
}
|
||||
const [
|
||||
y,
|
||||
m
|
||||
] = this.filterMonth.split('-').map(Number);
|
||||
// Day 0 of the next month is the last day of this one, which handles February and leap years without a table.
|
||||
return { from: `${this.filterMonth}-01`, to: this.isoOf(new Date(y, m, 0)) };
|
||||
}
|
||||
case 'year':
|
||||
return this.filterYear ? { from: `${this.filterYear}-01-01`, to: `${this.filterYear}-12-31` } : null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether an alert was raised inside the chosen period. */
|
||||
private inActivePeriod(alert: any): boolean {
|
||||
const range = this.periodRange();
|
||||
if (!range) {
|
||||
return true;
|
||||
}
|
||||
const d: Date | null = alert?.raisedOnDate;
|
||||
if (!d) {
|
||||
return false;
|
||||
}
|
||||
const iso = this.isoOf(d);
|
||||
return iso >= range.from && iso <= range.to;
|
||||
}
|
||||
|
||||
/**
|
||||
* The severity tile currently narrowing the table: 1 Low, 2 Medium, 3 High, 0 unclassified, or
|
||||
* null for all. Independent of {@link activeStatus} — the two compose, so an officer can ask for
|
||||
* the high-severity alerts that are still open.
|
||||
*/
|
||||
activeSeverity: number | null = null;
|
||||
|
||||
/**
|
||||
* Severity mix of the flagged transactions currently in the table. riskCategory is the severity
|
||||
* of the rule that fired, resolved server-side from the originating catalog entry — the same
|
||||
* field the Rules Master tab shows in its Risk Category column.
|
||||
*/
|
||||
riskSummary: { label: string; count: number; cls: string }[] = [];
|
||||
riskSummary: { key: number; label: string; count: number; cls: string }[] = [];
|
||||
/** Alerts whose rule has no catalog ancestor (MANUAL_REFERRAL), so no severity to report. */
|
||||
unclassifiedCount = 0;
|
||||
|
||||
@@ -106,6 +628,18 @@ export class AmlFlaggedTransactionsComponent implements OnInit, OnDestroy {
|
||||
|
||||
@ViewChild(MatPaginator) paginator!: MatPaginator;
|
||||
|
||||
/**
|
||||
* Binds the paginator as soon as the view exists.
|
||||
*
|
||||
* <p>
|
||||
* The first poll can return before the view has initialised, and until this the data source was
|
||||
* only ever wired up as a side effect of a later filter pass — so the table could render every
|
||||
* alert unpaginated, then jump once the next poll arrived.
|
||||
*/
|
||||
ngAfterViewInit(): void {
|
||||
this.alerts.paginator = this.paginator;
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.fetch();
|
||||
timer(AmlFlaggedTransactionsComponent.POLL_INTERVAL_MS, AmlFlaggedTransactionsComponent.POLL_INTERVAL_MS)
|
||||
@@ -158,7 +692,22 @@ export class AmlFlaggedTransactionsComponent implements OnInit, OnDestroy {
|
||||
|
||||
setStatus(key: string): void {
|
||||
this.activeStatus = key;
|
||||
this.applyStatusFilter();
|
||||
this.applyStatusFilter(true);
|
||||
}
|
||||
|
||||
/** Clicking the tile already in force clears it, so the same click both narrows and undoes. */
|
||||
setSeverity(band: number): void {
|
||||
this.activeSeverity = this.activeSeverity === band ? null : band;
|
||||
this.applyStatusFilter(true);
|
||||
}
|
||||
|
||||
isSeverityActive(band: number): boolean {
|
||||
return this.activeSeverity === band;
|
||||
}
|
||||
|
||||
clearSeverity(): void {
|
||||
this.activeSeverity = null;
|
||||
this.applyStatusFilter(true);
|
||||
}
|
||||
|
||||
statusCount(key: string): number {
|
||||
@@ -203,54 +752,179 @@ export class AmlFlaggedTransactionsComponent implements OnInit, OnDestroy {
|
||||
this.allAlerts = list;
|
||||
this.recomputeStatusCounts();
|
||||
this.applyStatusFilter();
|
||||
this.pruneSelection();
|
||||
this.lastUpdated = new Date();
|
||||
}
|
||||
|
||||
private applyStatusFilter(): void {
|
||||
this.alerts.data = this.activeStatus
|
||||
? this.allAlerts.filter((a) => a.status === this.activeStatus)
|
||||
: this.allAlerts;
|
||||
/**
|
||||
* @param resetPage
|
||||
* true only when the user changed a filter. A poll must not reset the page: the feed
|
||||
* refreshes every few seconds, and an officer reading page 3 would be thrown back to
|
||||
* page 1 each time. Nor may it touch the paginator before the data source has bound to
|
||||
* it — doing that on the first pass left pageSize at 0 and emptied the table.
|
||||
*/
|
||||
private applyStatusFilter(resetPage = false): void {
|
||||
// The day narrows everything else: the status chips and the severity tiles are asking "of what is in view", and
|
||||
// an officer who has picked a day expects their counts to describe that day rather than the whole feed.
|
||||
const byDay = this.allAlerts.filter((a) => this.inActivePeriod(a));
|
||||
const byStatus = this.activeStatus ? byDay.filter((a) => 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;
|
||||
if (!this.alerts.paginator && this.paginator) {
|
||||
this.alerts.paginator = this.paginator;
|
||||
}
|
||||
this.recomputeRiskSummary();
|
||||
if (resetPage && this.alerts.paginator) {
|
||||
// Otherwise a filter applied while on page 2 leaves the table blank on a one-page result.
|
||||
this.alerts.paginator.firstPage();
|
||||
}
|
||||
// Counted before the severity filter, so every tile keeps its real figure and stays clickable.
|
||||
// Counting the filtered rows would zero the other tiles the moment one was picked, leaving no
|
||||
// way back to them.
|
||||
this.recomputeRiskSummary(byStatus);
|
||||
}
|
||||
|
||||
/**
|
||||
* Counted over the rows the status filter leaves visible, not the whole set — a summary that
|
||||
* disagreed with the table underneath it would be worse than no summary.
|
||||
*/
|
||||
private recomputeRiskSummary(): void {
|
||||
const pool = this.alerts.data;
|
||||
private recomputeRiskSummary(pool: any[]): void {
|
||||
const tiers = [
|
||||
{ key: 3, label: 'High Risk', cls: 'risk-high' },
|
||||
{ key: 2, label: 'Medium Risk', cls: 'risk-medium' },
|
||||
{ key: 1, label: 'Low Risk', cls: 'risk-low' }
|
||||
];
|
||||
this.riskSummary = tiers.map((t) => ({
|
||||
key: t.key,
|
||||
label: t.label,
|
||||
cls: t.cls,
|
||||
count: pool.filter((a) => Number(a.riskCategory) === t.key).length
|
||||
count: pool.filter((a) => this.severityBand(a) === t.key).length
|
||||
}));
|
||||
// Anything outside 1-3, including null, so the tiles always reconcile to the total.
|
||||
this.unclassifiedCount = pool.filter(
|
||||
(a) => ![
|
||||
1,
|
||||
2,
|
||||
3
|
||||
].includes(Number(a.riskCategory))
|
||||
).length;
|
||||
// Band 0 is everything ungraded, so the tiles always reconcile to the total.
|
||||
this.unclassifiedCount = pool.filter((a) => this.severityBand(a) === 0).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the status filter leaves, whatever severity tile is in force — so the tiles still
|
||||
* add up to the total while one of them is narrowing the table, and the total reads as the way
|
||||
* back to all of them.
|
||||
*/
|
||||
exporting = false;
|
||||
|
||||
/**
|
||||
* Exports exactly what the table is showing.
|
||||
*
|
||||
* <p>
|
||||
* Built from `alerts.data` rather than from the full feed, so the period, status and severity an officer chose are
|
||||
* the period, status and severity in the file. A workbook that quietly held more than the screen did would be
|
||||
* worse than none: the figures in it would not reconcile with the report it came from, and an officer sending it
|
||||
* on has no way to tell.
|
||||
*
|
||||
* <p>
|
||||
* The filters are written into the subtitle and the file name for the same reason — a sheet that says "10 alerts"
|
||||
* with nothing about which month it covers cannot be checked later by anyone, including the person who made it.
|
||||
*/
|
||||
async exportExcel(): Promise<void> {
|
||||
const rows = this.alerts.data;
|
||||
if (!rows.length || this.exporting) {
|
||||
return;
|
||||
}
|
||||
this.exporting = true;
|
||||
try {
|
||||
const scope = [
|
||||
this.periodLabel ? `Period: ${this.periodLabel}` : 'Period: all time',
|
||||
`Status: ${this.activeStatus ? this.statusLabel(this.activeStatus) : 'all'}`,
|
||||
`Severity: ${this.activeSeverity === null ? 'all' : this.severityLabel(this.activeSeverity)}`,
|
||||
`Rules: ${this.topRules === null ? 'all' : `top ${this.topRules} by frequency`}`
|
||||
].join(' · ');
|
||||
|
||||
await exportWorkbook(
|
||||
[
|
||||
{
|
||||
sheetName: 'Flagged Transactions',
|
||||
title: 'AML — Flagged Transactions',
|
||||
subtitle: `${scope} · ${rows.length} alert${rows.length === 1 ? '' : 's'} · generated ${new Date().toLocaleString('en-IN')}`,
|
||||
headers: [
|
||||
'Alert ID',
|
||||
'Raised On',
|
||||
'Customer',
|
||||
'Customer ID',
|
||||
'Transaction',
|
||||
'Txn ID',
|
||||
'Governing Rule',
|
||||
'Rule Description',
|
||||
'All Rules Matched',
|
||||
'Severity',
|
||||
'Amount',
|
||||
'Status',
|
||||
'Reviewed By',
|
||||
'Review Notes'
|
||||
],
|
||||
rows: rows.map((a) => [
|
||||
a.id ?? null,
|
||||
a.raisedOnDate ? a.raisedOnDate.toLocaleString('en-IN') : '',
|
||||
a.clientDisplayName || '',
|
||||
a.clientId ?? null,
|
||||
a.transactionType || '',
|
||||
a.transactionId ?? null,
|
||||
a.ruleCode || '',
|
||||
a.ruleDescription || '',
|
||||
// Every rule the transaction tripped, not only the one it reports under: the governing rule alone can
|
||||
// hide a high-severity finding that a reader of the sheet would need.
|
||||
this.matchedRules(a)
|
||||
.map((r) => r.ruleCode)
|
||||
.join(', '),
|
||||
this.severityLabel(this.severityBand(a)),
|
||||
Number(a.amount) || 0,
|
||||
a.status || '',
|
||||
a.reviewedByUsername || '',
|
||||
a.reviewNotes || ''
|
||||
]),
|
||||
widths: [
|
||||
9,
|
||||
20,
|
||||
24,
|
||||
12,
|
||||
12,
|
||||
10,
|
||||
30,
|
||||
38,
|
||||
44,
|
||||
11,
|
||||
15,
|
||||
14,
|
||||
14,
|
||||
50
|
||||
]
|
||||
}
|
||||
],
|
||||
`AML-Flagged-${this.periodLabel || 'all-time'}`
|
||||
);
|
||||
} finally {
|
||||
this.exporting = false;
|
||||
}
|
||||
}
|
||||
|
||||
private statusLabel(key: string): string {
|
||||
return this.statusFilters.find((s) => s.key === key)?.label ?? key;
|
||||
}
|
||||
|
||||
get summaryTotal(): number {
|
||||
return this.alerts.data.length;
|
||||
const pool = this.allAlerts.filter((a) => this.inActivePeriod(a));
|
||||
return this.activeStatus ? pool.filter((a) => a.status === this.activeStatus).length : pool.length;
|
||||
}
|
||||
|
||||
private recomputeStatusCounts(): void {
|
||||
this.statusCounts = { '': this.allAlerts.length };
|
||||
// Counted within the chosen day, so "Escalated 1" means one escalated that day rather than one in the whole feed.
|
||||
// A chip whose figure describes a different set from the table under it is worse than no figure.
|
||||
const pool = this.allAlerts.filter((a) => this.inActivePeriod(a));
|
||||
this.statusCounts = { '': pool.length };
|
||||
for (const s of this.statusFilters) {
|
||||
if (s.key) {
|
||||
this.statusCounts[s.key] = this.allAlerts.filter((a) => a.status === s.key).length;
|
||||
this.statusCounts[s.key] = pool.filter((a) => a.status === s.key).length;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -302,7 +976,26 @@ export class AmlFlaggedTransactionsComponent implements OnInit, OnDestroy {
|
||||
* opening a second STR case — this gate is what prevents it.
|
||||
*/
|
||||
canReview(alert: any): boolean {
|
||||
return alert?.status === 'RAISED' || alert?.status === 'UNDER_REVIEW';
|
||||
if (alert?.status !== 'RAISED' && alert?.status !== 'UNDER_REVIEW') {
|
||||
return false;
|
||||
}
|
||||
// An alert another officer has open is theirs until they decide it or hand it back. The endpoint refuses it
|
||||
// anyway; hiding the buttons is what stops an officer working a case they will not be allowed to close.
|
||||
return !this.heldByAnother(alert);
|
||||
}
|
||||
|
||||
/** True when this alert is under review by somebody other than the signed-in officer. */
|
||||
heldByAnother(alert: any): boolean {
|
||||
return (
|
||||
alert?.status === 'UNDER_REVIEW' &&
|
||||
!!alert?.reviewedByUsername &&
|
||||
alert.reviewedByUsername !== this.currentUsername
|
||||
);
|
||||
}
|
||||
|
||||
/** Who has the alert, for the message shown in place of the action buttons. */
|
||||
heldBy(alert: any): string {
|
||||
return alert?.reviewedByUsername || 'another officer';
|
||||
}
|
||||
|
||||
review(alert: any, disposition: 'WHITELIST' | 'ESCALATE'): void {
|
||||
@@ -330,4 +1023,73 @@ export class AmlFlaggedTransactionsComponent implements OnInit, OnDestroy {
|
||||
this.fetch();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies one disposition to every ticked alert.
|
||||
*
|
||||
* <p>
|
||||
* Each still goes through the review dialog's own requirements - a reason, and for a whitelist
|
||||
* the evidence behind it - asked once and recorded against every alert in the batch. That is the
|
||||
* point of the batch: the officer is making one decision about one kind of activity, not the same
|
||||
* decision twenty times.
|
||||
*/
|
||||
/**
|
||||
* Applies one disposition to the rules ticked inside the open pane.
|
||||
*
|
||||
* <p>
|
||||
* The alert's own status is left to the server, which derives it from what remains: whitelisting five of six rules
|
||||
* leaves the case open on the sixth rather than closing it.
|
||||
*/
|
||||
reviewSelectedRules(alert: any, disposition: 'WHITELIST' | 'ESCALATE'): void {
|
||||
const codes = Array.from(this.selectedRuleCodes);
|
||||
if (!codes.length) {
|
||||
return;
|
||||
}
|
||||
this.dialog
|
||||
.open(AmlReviewAlertDialogComponent, {
|
||||
width: '560px',
|
||||
maxWidth: '95vw',
|
||||
autoFocus: false,
|
||||
disableClose: true,
|
||||
data: { alert, disposition, ruleCodes: codes }
|
||||
})
|
||||
.afterClosed()
|
||||
.subscribe((result: string | undefined) => {
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
this.alertService.alert({
|
||||
type: disposition === 'WHITELIST' ? 'Rules Whitelisted' : 'Rules Escalated',
|
||||
message: `${codes.length} rule${codes.length > 1 ? 's' : ''} on ${alert.transactionType} #${alert.transactionId} ${
|
||||
disposition === 'WHITELIST' ? 'cleared' : 'escalated'
|
||||
}.`
|
||||
});
|
||||
this.selectedRuleCodes.clear();
|
||||
// The alert's status may have changed with the last rule decided, so re-read rather than patching locally.
|
||||
this.fetch();
|
||||
});
|
||||
}
|
||||
|
||||
reviewSelected(disposition: 'WHITELIST' | 'ESCALATE'): void {
|
||||
const batch = this.selectedAlerts();
|
||||
if (!batch.length) {
|
||||
return;
|
||||
}
|
||||
this.dialog
|
||||
.open(AmlBulkReviewDialogComponent, {
|
||||
width: '620px',
|
||||
maxWidth: '95vw',
|
||||
autoFocus: false,
|
||||
disableClose: true,
|
||||
data: { alerts: batch, disposition }
|
||||
})
|
||||
.afterClosed()
|
||||
.subscribe((done: boolean | undefined) => {
|
||||
if (!done) {
|
||||
return;
|
||||
}
|
||||
this.selectedIds.clear();
|
||||
this.fetch();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+23
-1
@@ -31,6 +31,11 @@ import { AmlService } from '../../../aml/aml.service';
|
||||
export interface AmlReviewDialogData {
|
||||
alert: any;
|
||||
disposition: 'WHITELIST' | 'ESCALATE';
|
||||
/**
|
||||
* The rules being decided, when the officer picked some inside the expanded pane rather than acting on the whole
|
||||
* transaction. Absent means the decision covers the alert and every rule under it.
|
||||
*/
|
||||
ruleCodes?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -162,6 +167,13 @@ export class AmlReviewAlertDialogComponent {
|
||||
}
|
||||
|
||||
get title(): string {
|
||||
// Named differently when only some of the transaction's rules are being decided, so the officer is not told they
|
||||
// are clearing a transaction when five of its six rules stay open.
|
||||
const n = this.data.ruleCodes?.length ?? 0;
|
||||
if (n > 0) {
|
||||
const subject = n === 1 ? 'Rule' : `${n} Rules`;
|
||||
return this.isWhitelist ? `Whitelist ${subject}` : `Mark ${subject} as Suspicious`;
|
||||
}
|
||||
return this.isWhitelist ? 'Whitelist Transaction' : 'Mark as Suspicious';
|
||||
}
|
||||
|
||||
@@ -181,7 +193,17 @@ export class AmlReviewAlertDialogComponent {
|
||||
|
||||
this.submitting = true;
|
||||
this.errorMessage = '';
|
||||
this.amlService.reviewAlert(this.data.alert.id, this.data.disposition, this.composedNotes).subscribe({
|
||||
// Deciding named rules within the alert rather than the whole alert, when the caller passed some. The evidence
|
||||
// requirements are the same either way, which is why this dialog serves both.
|
||||
const request = this.data.ruleCodes?.length
|
||||
? this.amlService.reviewAlertRules(
|
||||
this.data.alert.id,
|
||||
this.data.ruleCodes,
|
||||
this.data.disposition,
|
||||
this.composedNotes
|
||||
)
|
||||
: this.amlService.reviewAlert(this.data.alert.id, this.data.disposition, this.composedNotes);
|
||||
request.subscribe({
|
||||
next: () => {
|
||||
this.submitting = false;
|
||||
this.dialogRef.close(this.data.disposition);
|
||||
|
||||
@@ -93,6 +93,11 @@ const routes: Routes = [
|
||||
loadComponent: () =>
|
||||
import('../tds-reports/tds-remittance-report.component').then((m) => m.TdsRemittanceReportComponent)
|
||||
},
|
||||
{
|
||||
path: 'bor',
|
||||
data: { title: 'BOR Register — Share Capital', breadcrumb: 'BOR Register' },
|
||||
loadComponent: () => import('../board-resolution/bor-report.component').then((m) => m.BorReportComponent)
|
||||
},
|
||||
{
|
||||
path: ':filter',
|
||||
data: { routeParamBreadcrumb: 'filter' },
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, Inject, OnInit, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MAT_DIALOG_DATA, MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
|
||||
import { AmlService } from 'app/aml/aml.service';
|
||||
|
||||
const CATEGORY_LABEL: Record<number, string> = { 1: 'Low', 2: 'Medium', 3: 'High' };
|
||||
|
||||
/**
|
||||
* The approval queue for risk-category changes raised from the Risk Review Report.
|
||||
*
|
||||
* <p>
|
||||
* Approving here moves the customer's KYC rating and their re-KYC due date together, so the row
|
||||
* shows where the rating is going from and to, and why the reviewer asked - the checker is deciding
|
||||
* how closely the bank watches that customer, not clearing a queue item.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-risk-change-approvals',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatButtonModule,
|
||||
MatDialogModule,
|
||||
MatSnackBarModule
|
||||
],
|
||||
template: `
|
||||
<div class="rca-root">
|
||||
<div class="rca-head">
|
||||
<div>
|
||||
<h2 class="rca-title">Risk Category Approvals</h2>
|
||||
<p class="rca-sub">
|
||||
Changes to a customer's KYC risk category, raised from the Risk Review Report. Approving moves the rating
|
||||
and the re-KYC due date together, and writes the reason into the customer's risk history.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rca-filters">
|
||||
<button
|
||||
*ngFor="let s of statuses"
|
||||
type="button"
|
||||
class="rca-chip"
|
||||
[class.rca-chip--on]="selectedStatus === s"
|
||||
(click)="filterBy(s)"
|
||||
>
|
||||
{{ s === '' ? 'All' : s }} <span class="rca-count">({{ countOf(s) }})</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="rca-table-wrap">
|
||||
<table class="rca-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Req. ID</th>
|
||||
<th>Customer</th>
|
||||
<th>CIF</th>
|
||||
<th>Branch</th>
|
||||
<th>Rating change</th>
|
||||
<th>Reason</th>
|
||||
<th>Status</th>
|
||||
<th>Requested By</th>
|
||||
<th>Requested On</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let r of filtered">
|
||||
<td class="mono">{{ r.id }}</td>
|
||||
<td>
|
||||
<strong>{{ r.clientName }}</strong>
|
||||
</td>
|
||||
<td class="mono">{{ r.cifNumber || '—' }}</td>
|
||||
<td>{{ r.officeName || '—' }}</td>
|
||||
<td>
|
||||
<span class="rca-move">
|
||||
{{ labelOf(r.currentCategory) }} <span class="rca-arrow">→</span>
|
||||
<strong>{{ labelOf(r.requestedCategory) }}</strong>
|
||||
</span>
|
||||
</td>
|
||||
<td class="rca-reason">{{ r.reason }}</td>
|
||||
<td>
|
||||
<span class="rca-status" [ngClass]="'rca-status--' + (r.status || '').toLowerCase()">{{
|
||||
r.status
|
||||
}}</span>
|
||||
</td>
|
||||
<td>{{ r.makerUsername }}</td>
|
||||
<td class="mono">{{ r.createdDate }}</td>
|
||||
<td>
|
||||
<div class="rca-actions" *ngIf="r.status === 'PENDING'">
|
||||
<button mat-flat-button color="primary" type="button" (click)="approve(r)">Approve</button>
|
||||
<button mat-stroked-button color="warn" type="button" (click)="reject(r)">Reject</button>
|
||||
</div>
|
||||
<span class="rca-done" *ngIf="r.status === 'APPROVED'">
|
||||
by {{ r.checkerUsername }}, {{ r.checkerDate }}
|
||||
</span>
|
||||
<span class="rca-done" *ngIf="r.status === 'REJECTED'">{{ r.rejectionReason }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr *ngIf="!filtered.length">
|
||||
<td colspan="10" class="rca-empty">No risk category change matches this filter.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.rca-root {
|
||||
padding: 20px 24px 40px;
|
||||
background: #f8fafc;
|
||||
min-height: 100%;
|
||||
}
|
||||
.rca-head {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.rca-title {
|
||||
margin: 0 0 3px;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
}
|
||||
.rca-sub {
|
||||
margin: 0;
|
||||
max-width: 800px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: #64748b;
|
||||
}
|
||||
.rca-filters {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 14px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.rca-chip {
|
||||
height: 32px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
cursor: pointer;
|
||||
}
|
||||
.rca-chip--on {
|
||||
border-color: #1e3a8a;
|
||||
background: #eff6ff;
|
||||
color: #1e3a8a;
|
||||
}
|
||||
.rca-count {
|
||||
color: #94a3b8;
|
||||
font-weight: 500;
|
||||
}
|
||||
.rca-table-wrap {
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 11px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.rca-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.rca-table th {
|
||||
text-align: left;
|
||||
padding: 10px;
|
||||
background: #f1f5f9;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: #475569;
|
||||
white-space: nowrap;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
.rca-table td {
|
||||
padding: 11px 10px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
vertical-align: middle;
|
||||
color: #334155;
|
||||
}
|
||||
.mono {
|
||||
font-family: 'DM Mono', monospace;
|
||||
}
|
||||
.rca-move {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.rca-arrow {
|
||||
color: #94a3b8;
|
||||
margin: 0 4px;
|
||||
}
|
||||
.rca-reason {
|
||||
max-width: 280px;
|
||||
}
|
||||
.rca-status {
|
||||
display: inline-block;
|
||||
padding: 2px 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
}
|
||||
.rca-status--pending {
|
||||
background: #fef3c7;
|
||||
color: #b45309;
|
||||
}
|
||||
.rca-status--approved {
|
||||
background: #dcfce7;
|
||||
color: #15803d;
|
||||
}
|
||||
.rca-status--rejected {
|
||||
background: #fee2e2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
.rca-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.rca-done {
|
||||
font-size: 11.5px;
|
||||
color: #64748b;
|
||||
}
|
||||
.rca-empty {
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
color: #94a3b8;
|
||||
}
|
||||
`
|
||||
]
|
||||
})
|
||||
export class RiskChangeApprovalsComponent implements OnInit {
|
||||
private amlService = inject(AmlService);
|
||||
private dialog = inject(MatDialog);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
|
||||
rows: any[] = [];
|
||||
readonly statuses = [
|
||||
'PENDING',
|
||||
'APPROVED',
|
||||
'REJECTED',
|
||||
''
|
||||
];
|
||||
selectedStatus = 'PENDING';
|
||||
|
||||
ngOnInit(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.amlService.getRiskChangeRequests().subscribe({
|
||||
next: (rows) => (this.rows = rows || []),
|
||||
error: (err: any) =>
|
||||
this.snackBar.open('Could not load requests: ' + (err?.error?.defaultUserMessage || err.message), 'Close', {
|
||||
duration: 5000
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
get filtered(): any[] {
|
||||
return this.selectedStatus ? this.rows.filter((r) => r.status === this.selectedStatus) : this.rows;
|
||||
}
|
||||
|
||||
countOf(status: string): number {
|
||||
return status ? this.rows.filter((r) => r.status === status).length : this.rows.length;
|
||||
}
|
||||
|
||||
filterBy(status: string): void {
|
||||
this.selectedStatus = status;
|
||||
}
|
||||
|
||||
labelOf(category: number | null): string {
|
||||
return category == null ? 'Unrated' : CATEGORY_LABEL[category] || '—';
|
||||
}
|
||||
|
||||
approve(r: any): void {
|
||||
this.amlService.approveRiskChange(r.id).subscribe({
|
||||
next: () => {
|
||||
this.snackBar.open(
|
||||
`${r.clientName} moved to ${this.labelOf(r.requestedCategory)} — re-KYC due date updated.`,
|
||||
'Close',
|
||||
{ duration: 6000 }
|
||||
);
|
||||
this.load();
|
||||
},
|
||||
error: (err: any) =>
|
||||
this.snackBar.open('Error: ' + (err?.error?.defaultUserMessage || err.message), 'Close', { duration: 7000 })
|
||||
});
|
||||
}
|
||||
|
||||
reject(r: any): void {
|
||||
this.dialog
|
||||
.open(RejectRiskChangeDialogComponent, { width: '450px', data: { request: r }, disableClose: true })
|
||||
.afterClosed()
|
||||
.subscribe((done) => {
|
||||
if (done) {
|
||||
this.load();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── REJECT DIALOG ──
|
||||
@Component({
|
||||
selector: 'mifosx-reject-risk-change-dialog',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
MatDialogModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatButtonModule
|
||||
],
|
||||
template: `
|
||||
<h2 mat-dialog-title>Reject Risk Category Change</h2>
|
||||
<mat-dialog-content>
|
||||
<p class="hint">
|
||||
{{ data.request.clientName }} stays on their current rating. The reason is shown to the reviewer who raised it.
|
||||
</p>
|
||||
<form [formGroup]="form">
|
||||
<mat-form-field appearance="outline" class="full">
|
||||
<mat-label>Rejection reason</mat-label>
|
||||
<textarea matInput formControlName="rejectionReason" rows="3"></textarea>
|
||||
<mat-error *ngIf="form.get('rejectionReason')?.hasError('required')">A reason is required</mat-error>
|
||||
</mat-form-field>
|
||||
</form>
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-stroked-button type="button" (click)="dialogRef.close(false)">Cancel</button>
|
||||
<button mat-flat-button color="warn" type="button" [disabled]="form.invalid || busy" (click)="submit()">
|
||||
Reject
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.full {
|
||||
width: 100%;
|
||||
}
|
||||
.hint {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
margin: 0 0 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
`
|
||||
]
|
||||
})
|
||||
export class RejectRiskChangeDialogComponent {
|
||||
private fb = inject(FormBuilder);
|
||||
private amlService = inject(AmlService);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
dialogRef = inject(MatDialogRef<RejectRiskChangeDialogComponent>);
|
||||
|
||||
data: { request: any } = inject(MAT_DIALOG_DATA);
|
||||
form: FormGroup;
|
||||
busy = false;
|
||||
|
||||
constructor() {
|
||||
this.form = this.fb.group({ rejectionReason: [
|
||||
'',
|
||||
Validators.required
|
||||
] });
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
if (this.form.invalid) {
|
||||
return;
|
||||
}
|
||||
this.busy = true;
|
||||
this.amlService.rejectRiskChange(this.data.request.id, this.form.value.rejectionReason).subscribe({
|
||||
next: () => {
|
||||
this.busy = false;
|
||||
this.snackBar.open('Risk category change rejected', 'Close', { duration: 3000 });
|
||||
this.dialogRef.close(true);
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.busy = false;
|
||||
this.snackBar.open('Error: ' + (err?.error?.defaultUserMessage || err.message), 'Close', { duration: 6000 });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, Inject, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
|
||||
import { AmlService } from 'app/aml/aml.service';
|
||||
|
||||
export interface RiskChangeDialogData {
|
||||
clientId: number;
|
||||
customerName: string;
|
||||
/** 1 Low, 2 Medium, 3 High; null where the customer carries no rating yet. */
|
||||
currentCategory: number | null;
|
||||
/** What the report's own computation says the rating should be, used as the default. */
|
||||
recommendedCategory: number | null;
|
||||
/** The one-line explanation the report already shows against the row. */
|
||||
recommendationReason: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Raises a change to a customer's KYC risk category for approval.
|
||||
*
|
||||
* <p>
|
||||
* The rating governs how often the customer is re-KYC'd and what AML thresholds they are measured
|
||||
* against, so it is not something one officer moves on their own. The dialog collects what it
|
||||
* should become and why; a checker applies it.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-risk-change-dialog',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
MatDialogModule,
|
||||
MatButtonModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatSelectModule
|
||||
],
|
||||
template: `
|
||||
<h2 mat-dialog-title>Change Risk Category</h2>
|
||||
<mat-dialog-content>
|
||||
<div class="rc-summary">
|
||||
<div class="rc-row">
|
||||
<span class="rc-label">Customer</span>
|
||||
<strong class="rc-value">{{ data.customerName }}</strong>
|
||||
</div>
|
||||
<div class="rc-row">
|
||||
<span class="rc-label">Rating on file</span>
|
||||
<strong class="rc-value">{{ labelOf(data.currentCategory) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- The report already worked this out; showing it saves the reviewer re-deriving it, and
|
||||
says plainly that the figure is a recommendation rather than a decision. -->
|
||||
<p class="rc-reason" *ngIf="data.recommendationReason">
|
||||
<span class="rc-label">Why this came up</span><br />{{ data.recommendationReason }}
|
||||
</p>
|
||||
|
||||
<form [formGroup]="form" class="rc-form">
|
||||
<mat-form-field appearance="outline" class="rc-full">
|
||||
<mat-label>New risk category</mat-label>
|
||||
<mat-select formControlName="requestedCategory">
|
||||
<mat-option [value]="1">Low</mat-option>
|
||||
<mat-option [value]="2">Medium</mat-option>
|
||||
<mat-option [value]="3">High</mat-option>
|
||||
</mat-select>
|
||||
<mat-error *ngIf="form.get('requestedCategory')?.hasError('required')">Pick the new category</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="rc-full">
|
||||
<mat-label>Reason</mat-label>
|
||||
<textarea
|
||||
matInput
|
||||
formControlName="reason"
|
||||
rows="3"
|
||||
placeholder="e.g. Repeated large cash deposits inconsistent with declared income"
|
||||
></textarea>
|
||||
<mat-hint>Recorded against the customer's risk history</mat-hint>
|
||||
<mat-error *ngIf="form.get('reason')?.hasError('required')">A reason is required</mat-error>
|
||||
</mat-form-field>
|
||||
</form>
|
||||
|
||||
<p class="rc-effect">
|
||||
The rating does not move now. It goes to a checker, and on approval the customer's KYC record and re-KYC due
|
||||
date are updated together.
|
||||
</p>
|
||||
|
||||
<p class="rc-error" *ngIf="errorMessage">{{ errorMessage }}</p>
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-stroked-button type="button" (click)="dialogRef.close(false)">Cancel</button>
|
||||
<button mat-flat-button color="primary" type="button" [disabled]="form.invalid || submitting" (click)="submit()">
|
||||
{{ submitting ? 'Sending…' : 'Send for Approval' }}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.rc-summary {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 14px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
.rc-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 16px;
|
||||
padding: 3px 0;
|
||||
}
|
||||
.rc-label {
|
||||
color: #64748b;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.rc-value {
|
||||
color: #1e293b;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
.rc-reason {
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
color: #334155;
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
.rc-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.rc-full {
|
||||
width: 100%;
|
||||
}
|
||||
.rc-effect {
|
||||
font-size: 12.5px;
|
||||
line-height: 1.55;
|
||||
color: #1e3a8a;
|
||||
background: #eff6ff;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
.rc-error {
|
||||
margin: 12px 0 0;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 6px;
|
||||
background: #fef2f2;
|
||||
color: #b91c1c;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
`
|
||||
]
|
||||
})
|
||||
export class RiskChangeDialogComponent {
|
||||
private fb = inject(FormBuilder);
|
||||
private amlService = inject(AmlService);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
dialogRef = inject(MatDialogRef<RiskChangeDialogComponent>);
|
||||
|
||||
form: FormGroup;
|
||||
submitting = false;
|
||||
errorMessage = '';
|
||||
|
||||
constructor(@Inject(MAT_DIALOG_DATA) public data: RiskChangeDialogData) {
|
||||
this.form = this.fb.group({
|
||||
// Defaulted to what the report recommends, but left editable: the reviewer has seen the
|
||||
// customer and the computation has not.
|
||||
requestedCategory: [
|
||||
data.recommendedCategory ?? null,
|
||||
Validators.required
|
||||
],
|
||||
reason: [
|
||||
'',
|
||||
Validators.required
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
labelOf(category: number | null): string {
|
||||
return category === 1 ? 'Low' : category === 2 ? 'Medium' : category === 3 ? 'High' : 'Unrated';
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
if (this.form.invalid) {
|
||||
this.form.markAllAsTouched();
|
||||
return;
|
||||
}
|
||||
this.submitting = true;
|
||||
this.errorMessage = '';
|
||||
const v = this.form.value;
|
||||
this.amlService.requestRiskChange(this.data.clientId, v.requestedCategory, (v.reason || '').trim()).subscribe({
|
||||
next: () => {
|
||||
this.submitting = false;
|
||||
this.snackBar.open(
|
||||
`Risk change to ${this.labelOf(v.requestedCategory)} sent for approval — ${this.data.customerName}`,
|
||||
'Close',
|
||||
{ duration: 5000 }
|
||||
);
|
||||
this.dialogRef.close(true);
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.submitting = false;
|
||||
this.errorMessage = err?.error?.defaultUserMessage || err?.error?.message || 'Could not send the request.';
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -372,13 +372,29 @@ export const MATERIALITY = {
|
||||
* Rules whose seriousness does not depend on the amount. A sanctions match or a
|
||||
* deliberate structuring pattern is grave at any value, so activity being small
|
||||
* is not a reason to bring the rating down.
|
||||
*
|
||||
* Matched as prefixes, not as whole codes. The rule set names these by variant —
|
||||
* STRUCTURING_BELOW_50K, STRUCTURING_BELOW_10L, PEP_ACCOUNT_MONITORING — so an
|
||||
* exact-match Set never matched any of them and the protection was dead: a
|
||||
* customer caught structuring could be recommended for downgrade purely because
|
||||
* the amounts involved were small, which is the one case structuring exists to
|
||||
* catch.
|
||||
*/
|
||||
export const NON_MITIGABLE_RULES = new Set([
|
||||
'SANCTIONS_HIT',
|
||||
export const NON_MITIGABLE_RULE_PREFIXES = [
|
||||
'SANCTIONS',
|
||||
'PEP',
|
||||
'STRUCTURING',
|
||||
'HIGH_RISK_COUNTRY'
|
||||
]);
|
||||
'STRUCTURED',
|
||||
'HIGH_RISK_COUNTRY',
|
||||
'ADVERSE_MEDIA',
|
||||
'WILFUL_DEFAULTER'
|
||||
];
|
||||
|
||||
/** Whether a rule code names activity that stays grave however small the amounts are. */
|
||||
export function isNonMitigableRule(ruleCode: string): boolean {
|
||||
const code = (ruleCode || '').toUpperCase();
|
||||
return NON_MITIGABLE_RULE_PREFIXES.some((prefix) => code.startsWith(prefix));
|
||||
}
|
||||
|
||||
/** Shape of one element of GET /aml/alerts. */
|
||||
export interface LiveAmlAlert {
|
||||
|
||||
@@ -304,7 +304,9 @@
|
||||
reason sits under it rather than under the button, because it explains
|
||||
this conclusion and not the act of opening the KYC screen. -->
|
||||
<td class="rr-move-cell">
|
||||
<span class="rr-move" [class]="'rr-move--' + c.direction">
|
||||
<!-- An unrated customer is its own state: no rating exists, so "same" would be claiming the
|
||||
rating on file matches the behaviour when there is no rating on file. -->
|
||||
<span class="rr-move" [class]="'rr-move--' + (c.currentRisk ? c.direction : 'unrated')">
|
||||
<fa-icon [icon]="directionIcon(c)"></fa-icon>
|
||||
{{ directionText(c) }}
|
||||
</span>
|
||||
@@ -323,15 +325,32 @@
|
||||
<!-- Only the control. The verdict and its reason moved to their own column,
|
||||
so this no longer wraps to two lines under the button. -->
|
||||
<td class="rr-action-cell">
|
||||
<button
|
||||
type="button"
|
||||
class="rr-kyc-link"
|
||||
[class.rr-kyc-link--act]="c.direction !== 'same' && !c.escalated"
|
||||
(click)="openKyc(c, $event)"
|
||||
[attr.aria-label]="kycActionText(c) + ' for ' + c.customerName"
|
||||
>
|
||||
<fa-icon icon="id-card"></fa-icon> {{ kycActionText(c) }}
|
||||
</button>
|
||||
<!-- Raises the change for approval rather than sending the reviewer to the
|
||||
KYC screen to make it themselves. A case with the Principal Officer is
|
||||
held where it is until the STR concludes, so it only offers the record. -->
|
||||
@if (canChangeRisk(c)) {
|
||||
<button
|
||||
type="button"
|
||||
class="rr-kyc-link"
|
||||
[class.rr-kyc-link--act]="c.direction !== 'same'"
|
||||
(click)="changeRisk(c, $event)"
|
||||
[attr.aria-label]="'Change risk category for ' + c.customerName"
|
||||
>
|
||||
<fa-icon icon="shield-alt"></fa-icon> Change Risk
|
||||
</button>
|
||||
} @else {
|
||||
<!-- The rating cannot move while the STR is open, so the case itself is
|
||||
what this row can usefully offer. -->
|
||||
<button
|
||||
type="button"
|
||||
class="rr-kyc-link"
|
||||
(click)="openStrCase(c, $event)"
|
||||
title="The rating is held until the Principal Officer concludes the STR — open the case"
|
||||
[attr.aria-label]="'Open the STR case for ' + c.customerName"
|
||||
>
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon> Open STR Case
|
||||
</button>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
|
||||
@@ -937,6 +937,13 @@
|
||||
background: var(--r-g100);
|
||||
color: var(--r-g500);
|
||||
}
|
||||
|
||||
/* An unrated customer reads neither as settled nor as a movement: there is no rating to move. Amber says the row
|
||||
is outstanding without claiming the customer is a concern — the gap is the bank's, not theirs. */
|
||||
&--unrated {
|
||||
background: var(--r-amber-l, #fef3c7);
|
||||
color: var(--r-amber, #b45309);
|
||||
}
|
||||
}
|
||||
|
||||
.rr-move-why {
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { forkJoin, of } from 'rxjs';
|
||||
import { catchError, map } from 'rxjs/operators';
|
||||
|
||||
@@ -38,12 +39,14 @@ import {
|
||||
RiskLevel,
|
||||
channelLabel,
|
||||
mapLiveAlert,
|
||||
riskFromCategory
|
||||
riskFromCategory,
|
||||
riskRank
|
||||
} from './risk-review-data';
|
||||
import {
|
||||
DateRange,
|
||||
RatingDirection,
|
||||
RiskAlertView,
|
||||
BehaviourWindowByClient,
|
||||
RiskCustomerRow,
|
||||
RiskReviewFilter,
|
||||
RiskReviewModel,
|
||||
@@ -53,6 +56,7 @@ import {
|
||||
monthRange,
|
||||
weekRange
|
||||
} from './risk-review.compute';
|
||||
import { RiskChangeDialogComponent, RiskChangeDialogData } from './risk-change-dialog.component';
|
||||
|
||||
type PeriodMode = 'day' | 'week' | 'month' | 'range';
|
||||
type ViewMode = 'customer' | 'alert' | 'txn';
|
||||
@@ -109,6 +113,7 @@ export class RiskReviewReportComponent implements OnInit {
|
||||
private clients = inject(ClientsService);
|
||||
private scope = inject(BranchScopeService);
|
||||
private router = inject(Router);
|
||||
private dialog = inject(MatDialog);
|
||||
|
||||
readonly period = GL_PERIOD;
|
||||
readonly branches = GL_BRANCHES;
|
||||
@@ -181,21 +186,37 @@ export class RiskReviewReportComponent implements OnInit {
|
||||
|
||||
// ── Loading ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The nightly job's verdict per customer, keyed on client id.
|
||||
*
|
||||
* Empty until the first load, and stays empty on a tenant where the job has not run - the report then falls back to
|
||||
* its own per-alert reckoning rather than showing nothing.
|
||||
*/
|
||||
private behaviourWindows: BehaviourWindowByClient = {};
|
||||
|
||||
load(): void {
|
||||
this.loading = true;
|
||||
this.aml
|
||||
.getAlerts()
|
||||
.pipe(catchError(() => of([] as LiveAmlAlert[])))
|
||||
.subscribe((live) => {
|
||||
const rows = (live || []).map((a) => mapLiveAlert(a as unknown as LiveAmlAlert));
|
||||
this.liveAlerts = rows;
|
||||
this.compose();
|
||||
if (!rows.length) {
|
||||
this.loading = false;
|
||||
return;
|
||||
// Both in one pass: the windows decide what each row recommends, so composing before they arrive would show a
|
||||
// recommendation the screen then silently replaced.
|
||||
forkJoin({
|
||||
alerts: this.aml.getAlerts().pipe(catchError(() => of([] as LiveAmlAlert[]))),
|
||||
windows: this.aml.getBehaviourWindows().pipe(catchError(() => of([] as any[])))
|
||||
}).subscribe(({ alerts: live, windows }) => {
|
||||
this.behaviourWindows = {};
|
||||
for (const w of windows || []) {
|
||||
if (w?.clientId != null) {
|
||||
this.behaviourWindows[w.clientId] = w;
|
||||
}
|
||||
this.enrichCurrentRisk(rows);
|
||||
});
|
||||
}
|
||||
const rows = (live || []).map((a) => mapLiveAlert(a as unknown as LiveAmlAlert));
|
||||
this.liveAlerts = rows;
|
||||
this.compose();
|
||||
if (!rows.length) {
|
||||
this.loading = false;
|
||||
return;
|
||||
}
|
||||
this.enrichCurrentRisk(rows);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -243,7 +264,12 @@ export class RiskReviewReportComponent implements OnInit {
|
||||
)
|
||||
)
|
||||
).subscribe((results) => {
|
||||
const byClient = new Map(results.map((r) => [r.id, r]));
|
||||
const byClient = new Map(
|
||||
results.map((r) => [
|
||||
r.id,
|
||||
r
|
||||
])
|
||||
);
|
||||
this.liveAlerts = rows.map((r) => {
|
||||
const info = byClient.get(r.clientId);
|
||||
return {
|
||||
@@ -336,9 +362,11 @@ export class RiskReviewReportComponent implements OnInit {
|
||||
private memo: RiskReviewModel = { alerts: [], customers: [], txns: [], summary: EMPTY_SUMMARY };
|
||||
|
||||
private get model(): RiskReviewModel {
|
||||
const key = JSON.stringify(this.filter) + `|${this.alerts.length}`;
|
||||
// The window count belongs in the key: the windows decide what each row recommends, and they arrive in the same
|
||||
// load as the alerts. Keying on the alerts alone would serve the memo computed before they landed.
|
||||
const key = JSON.stringify(this.filter) + `|${this.alerts.length}|${Object.keys(this.behaviourWindows).length}`;
|
||||
if (key !== this.memoKey) {
|
||||
this.memo = buildRiskReview(this.alerts, this.filter);
|
||||
this.memo = buildRiskReview(this.alerts, this.filter, this.behaviourWindows);
|
||||
this.memoKey = key;
|
||||
}
|
||||
return this.memo;
|
||||
@@ -364,10 +392,63 @@ export class RiskReviewReportComponent implements OnInit {
|
||||
* a regulatory clock, an under-rated customer is the real compliance gap, an over-rated one is
|
||||
* only a correction, and matching ratings need nothing at all.
|
||||
*/
|
||||
/**
|
||||
* A report row for a customer the behaviour review named but no alert brought onto the report.
|
||||
*
|
||||
* <p>
|
||||
* The alert-derived fields are empty because there are none in the window, and that is the point being shown: the
|
||||
* case rests on the period, not on anything that fired. The figures the verdict was drawn from travel on
|
||||
* behaviourWindow, so the row can still explain itself.
|
||||
*/
|
||||
private rowFromWindow(w: any): RiskCustomerRow {
|
||||
const RANK: Record<number, RiskLevel> = { 1: 'LOW', 2: 'MEDIUM', 3: 'HIGH' };
|
||||
const current = RANK[w.riskCategoryAtReview] ?? null;
|
||||
const recommended = RANK[w.suggestedCategory] ?? current ?? 'LOW';
|
||||
return {
|
||||
clientId: w.clientId,
|
||||
customerName: w.clientName || `Client ${w.clientId}`,
|
||||
accountNo: w.accountNo || '',
|
||||
branchCode: w.officeName || '',
|
||||
currentRisk: current,
|
||||
alerts: [],
|
||||
alertCount: 0,
|
||||
totalAmount: Number(w.creditTotal || 0) + Number(w.debitTotal || 0),
|
||||
largestAmount: Number(w.largestTxn || 0),
|
||||
proposedRisk: recommended,
|
||||
recommendedRisk: recommended,
|
||||
direction: w.suggestedDirection === 'UP' ? 'up' : w.suggestedDirection === 'DOWN' ? 'down' : 'same',
|
||||
directionReason: w.suggestionReason || '',
|
||||
ruleLabels: [],
|
||||
firstAlertDate: '',
|
||||
lastAlertDate: '',
|
||||
openCount: 0,
|
||||
escalated: false,
|
||||
needsUpgrade: w.suggestedDirection === 'UP',
|
||||
needsDowngrade: w.suggestedDirection === 'DOWN',
|
||||
behaviourWindow: w
|
||||
};
|
||||
}
|
||||
|
||||
get customerGroups(): RiskCustomerGroup[] {
|
||||
const rows = this.customerRows;
|
||||
const escalated = rows.filter((c) => c.escalated);
|
||||
const rest = rows.filter((c) => !c.escalated);
|
||||
// An escalated customer whose rating still has to move belongs with the work, not with the cases that are waiting
|
||||
// on someone else. An open STR holds a rating at High, and if the customer is not yet at High that is a change
|
||||
// somebody has to apply - filing the STR does not make it. Only a customer whose rating already matches sits under
|
||||
// the Principal Officer with nothing to do.
|
||||
const escalated = rows.filter((c) => c.escalated && c.direction === 'same');
|
||||
const rest = rows.filter((c) => !(c.escalated && c.direction === 'same'));
|
||||
|
||||
// Customers the nightly review says to move who raised no alert inside the filter window.
|
||||
//
|
||||
// The report is built from alerts, so a customer appears only once a rule has fired on them recently. That is the
|
||||
// wrong test for a downgrade: the whole case for lowering a rating is that nothing has fired for a year, so the
|
||||
// customer with the strongest case is precisely the one the alert feed has stopped mentioning. Without this they
|
||||
// would sit in the behaviour table with a verdict nobody ever sees.
|
||||
const onReport = new Set(rows.map((r) => r.clientId));
|
||||
const offReport: RiskCustomerRow[] = Object.values(this.behaviourWindows)
|
||||
.filter((w: any) => w?.clientId != null && !onReport.has(w.clientId))
|
||||
.filter((w: any) => w.suggestedDirection === 'UP' || w.suggestedDirection === 'DOWN')
|
||||
.map((w: any) => this.rowFromWindow(w));
|
||||
|
||||
const groups: RiskCustomerGroup[] = [
|
||||
{
|
||||
@@ -378,25 +459,42 @@ export class RiskReviewReportComponent implements OnInit {
|
||||
rows: escalated
|
||||
},
|
||||
{
|
||||
key: 'up',
|
||||
title: 'Rating needs raising',
|
||||
hint: 'The rules point higher than the rating on file. These are under-rated customers — the real gap.',
|
||||
// Raising and lowering are one section, not two. The heading answers "is there anything to do about this
|
||||
// customer", and which way the rating moves is already on every row - as a verdict, an arrow and a reason.
|
||||
// Splitting them put the same answer in two places and left an officer reading two lists to find one day's
|
||||
// work. Raises sort first within the section, because an under-rated customer is the more urgent gap.
|
||||
key: 'change',
|
||||
title: 'Change needed',
|
||||
hint: 'Every condition for moving the rating is met. Apply the change; it goes to a checker for approval.',
|
||||
tone: 'up',
|
||||
rows: rest.filter((c) => c.direction === 'up')
|
||||
rows: [
|
||||
...rest.filter((c) => c.currentRisk && c.direction === 'up'),
|
||||
...offReport.filter((c) => c.direction === 'up'),
|
||||
...rest.filter((c) => c.currentRisk && c.direction === 'down'),
|
||||
...offReport.filter((c) => c.direction === 'down')
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'down',
|
||||
title: 'Rating can be lowered',
|
||||
hint: 'Activity no longer supports the rating on file.',
|
||||
tone: 'down',
|
||||
rows: rest.filter((c) => c.direction === 'down')
|
||||
// Its own section, and above the watching list, because it is the opposite of that. An unrated customer whose
|
||||
// money is moving has no rating for the behaviour to be compared against, so nothing here can say whether it
|
||||
// is in keeping - the work is to give them one, and grouping them with the customers nothing need be done
|
||||
// about buried the one action the report was asking for.
|
||||
key: 'unrated',
|
||||
title: 'Update risk',
|
||||
hint: 'These customers are transacting with no rating on file. Nothing can be judged against a rating that does not exist — rate them first.',
|
||||
tone: 'up',
|
||||
rows: rest.filter((c) => !c.currentRisk)
|
||||
},
|
||||
{
|
||||
// Everything an alert put on the report that the policy will not move yet. "No change needed" read as though
|
||||
// these customers were fine, which is not what it means: a customer with four open alerts sits here, and the
|
||||
// reason is that the period does not yet carry enough to act on - not that there is nothing to look at.
|
||||
// "Watching" says what the section is for, and the reason on each row says which condition fell short.
|
||||
key: 'same',
|
||||
title: 'No change needed',
|
||||
hint: 'The rating on file already matches the behaviour. Listed for the record.',
|
||||
title: 'Watching — not enough to act on',
|
||||
hint: 'In view, but the period does not yet support moving the rating. The reason on each row says what is missing.',
|
||||
tone: 'hold',
|
||||
rows: rest.filter((c) => c.direction === 'same')
|
||||
rows: rest.filter((c) => c.currentRisk && c.direction === 'same')
|
||||
}
|
||||
];
|
||||
|
||||
@@ -575,6 +673,67 @@ export class RiskReviewReportComponent implements OnInit {
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a case that is with the Principal Officer to the STR queue.
|
||||
*
|
||||
* The rating cannot be moved while an STR is open, so the useful thing to offer is the case
|
||||
* itself. Sending the reviewer to the customer's KYC record instead made them land on a screen
|
||||
* where the one action they might take is the one thing they must not do yet.
|
||||
*/
|
||||
openStrCase(c: RiskCustomerRow, event: Event): void {
|
||||
event.stopPropagation();
|
||||
this.router.navigate(['/fiu'], { queryParams: { tab: 'str' } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this row's rating can be moved from here.
|
||||
*
|
||||
* A case with the Principal Officer is deliberately excluded: the rating is held where it is
|
||||
* until the STR concludes, which is what the section heading on that group says. Offering the
|
||||
* button there would invite a change nobody is in a position to make yet.
|
||||
*/
|
||||
canChangeRisk(c: RiskCustomerRow): boolean {
|
||||
return !c.escalated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Raises a rating change for approval, rather than sending the reviewer to the KYC screen to make
|
||||
* it themselves.
|
||||
*
|
||||
* The button used to navigate away: the reviewer left the report, found the customer, edited the
|
||||
* record, and the change took effect on their say-so alone with no second pair of eyes and no
|
||||
* record of why. The rating governs re-KYC frequency and AML thresholds, so it goes through a
|
||||
* checker like every other decision of that weight.
|
||||
*/
|
||||
changeRisk(c: RiskCustomerRow, event: Event): void {
|
||||
event.stopPropagation();
|
||||
if (!this.canChangeRisk(c)) {
|
||||
return;
|
||||
}
|
||||
this.dialog
|
||||
.open(RiskChangeDialogComponent, {
|
||||
width: '520px',
|
||||
maxWidth: '95vw',
|
||||
disableClose: true,
|
||||
data: {
|
||||
clientId: c.clientId,
|
||||
customerName: c.customerName,
|
||||
currentCategory: riskRank(c.currentRisk) || null,
|
||||
recommendedCategory: riskRank(c.recommendedRisk) || null,
|
||||
recommendationReason: c.directionReason
|
||||
} as RiskChangeDialogData
|
||||
})
|
||||
.afterClosed()
|
||||
.subscribe((sent) => {
|
||||
if (sent) {
|
||||
// The rating has not moved yet - only a request exists - so the figures on screen are
|
||||
// still correct. Reloaded anyway so a second click cannot raise a duplicate request the
|
||||
// server would refuse.
|
||||
this.load();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* What the reviewer is going to the KYC screen to do.
|
||||
*
|
||||
@@ -589,25 +748,41 @@ export class RiskReviewReportComponent implements OnInit {
|
||||
* change nobody is authorised to make yet.
|
||||
*/
|
||||
directionText(c: RiskCustomerRow): string {
|
||||
if (c.escalated) {
|
||||
// Escalation is only the whole story when the rating already matches. An STR holds a customer at High, so one
|
||||
// filed against a Medium-rated customer leaves a rating that still has to be moved - saying "held" there reports
|
||||
// the case and hides the work, which is how this row came to sit under "Change needed" announcing that nothing
|
||||
// needed doing.
|
||||
if (c.escalated && c.direction === 'same') {
|
||||
return 'Held — with Principal Officer';
|
||||
}
|
||||
if (c.direction === 'up') {
|
||||
return `Raise to ${RISK_LABEL[c.recommendedRisk]}`;
|
||||
// Both ends of the move, not just the destination. "Lower to Medium" reads as a verdict on its own and leaves the
|
||||
// officer joining it to the rating column to see what is actually changing; "High → Medium" is the whole fact in
|
||||
// one glance, and on an exported sheet it still says what happened without the column beside it.
|
||||
if (c.direction === 'up' || c.direction === 'down') {
|
||||
const from = c.currentRisk ? RISK_LABEL[c.currentRisk] : 'Unrated';
|
||||
return `${from} → ${RISK_LABEL[c.recommendedRisk]}`;
|
||||
}
|
||||
if (c.direction === 'down') {
|
||||
return `Lower to ${RISK_LABEL[c.recommendedRisk]}`;
|
||||
}
|
||||
return c.currentRisk ? `Keep at ${RISK_LABEL[c.currentRisk]}` : 'No rating on record';
|
||||
// An unrated customer is told what to do, not what was concluded. "No rating on record" states the gap and stops
|
||||
// there, which on a worklist reads as though the row needs nothing - and it is in fact the row that needs the most.
|
||||
return c.currentRisk ? `Keep at ${RISK_LABEL[c.currentRisk]}` : 'Rate this customer';
|
||||
}
|
||||
|
||||
directionIcon(c: RiskCustomerRow): string {
|
||||
if (c.escalated) {
|
||||
// Same test as directionText: a padlock says the case is out of the officer's hands, which is untrue while the
|
||||
// rating still has to be moved.
|
||||
if (c.escalated && c.direction === 'same') {
|
||||
return 'lock';
|
||||
}
|
||||
// 'check' rather than an equals sign: faEquals is not in the shared library, and an
|
||||
// unregistered icon throws at render and takes the whole row down with it.
|
||||
return c.direction === 'up' ? 'arrow-up' : c.direction === 'down' ? 'arrow-down' : 'check';
|
||||
if (c.direction === 'up') {
|
||||
return 'arrow-up';
|
||||
}
|
||||
if (c.direction === 'down') {
|
||||
return 'arrow-down';
|
||||
}
|
||||
// A tick claims the row is settled. An unrated customer is not settled, so it gets the mark that asks a question
|
||||
// rather than the one that closes it. 'exclamation-circle' rather than plain 'exclamation': only the registered
|
||||
// names resolve, and an unregistered icon throws at render and takes the whole row down with it.
|
||||
return c.currentRisk ? 'check' : 'exclamation-circle';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
AmlRuleMeta,
|
||||
CTR_THRESHOLD,
|
||||
MATERIALITY,
|
||||
NON_MITIGABLE_RULES,
|
||||
isNonMitigableRule,
|
||||
REVIEW_STATUS_META,
|
||||
RISK_LABEL,
|
||||
RiskAlertRow,
|
||||
@@ -26,6 +26,17 @@ export type RatingDirection = 'up' | 'down' | 'same';
|
||||
|
||||
const RANK_TO_LEVEL: Record<number, RiskLevel> = { 1: 'LOW', 2: 'MEDIUM', 3: 'HIGH' };
|
||||
|
||||
/**
|
||||
* What has to have been seen before this screen's own reckoning will argue for lowering a rating.
|
||||
*
|
||||
* The nightly behaviour review is the real test — it reads a 270-day window, counts transactions rather than alerts,
|
||||
* and applies the blocks. These are the floor for the fallback used until that job has run: deliberately modest,
|
||||
* because the fallback sees only the alerts inside the report's date filter, but not absent, because judging on
|
||||
* amounts alone let three small deposits in one month argue for taking a customer off Medium.
|
||||
*/
|
||||
const MIN_ALERTS_TO_LOWER = 5;
|
||||
const MIN_MONTHS_TO_LOWER = 3;
|
||||
|
||||
export interface DateRange {
|
||||
from: string;
|
||||
to: string;
|
||||
@@ -109,6 +120,8 @@ export interface RiskCustomerRow {
|
||||
needsUpgrade: boolean;
|
||||
/** True when the rating on record is higher than the activity warrants. */
|
||||
needsDowngrade: boolean;
|
||||
/** The review period the recommendation was drawn from, where the nightly job has computed one. */
|
||||
behaviourWindow?: BehaviourWindowByClient[number] | null;
|
||||
}
|
||||
|
||||
export interface RiskReviewSummary {
|
||||
@@ -155,17 +168,26 @@ function recommendRating(
|
||||
largestAmount: number,
|
||||
totalAmount: number,
|
||||
escalated: boolean,
|
||||
ruleCodes: string[]
|
||||
ruleCodes: string[],
|
||||
alertCount: number,
|
||||
distinctMonths: number
|
||||
): { risk: RiskLevel; reason: string } {
|
||||
const implied = RANK_TO_LEVEL[impliedRank] ?? 'LOW';
|
||||
const grave = ruleCodes.find((c) => NON_MITIGABLE_RULES.has(c));
|
||||
const grave = ruleCodes.find((c) => isNonMitigableRule(c));
|
||||
const material = largestAmount >= CTR_THRESHOLD || totalAmount >= MATERIALITY.highTotal;
|
||||
const small = largestAmount < MATERIALITY.smallTxn && totalAmount < MATERIALITY.smallTotal;
|
||||
|
||||
// Small amounts are not on their own a reason to lower a rating. A rating describes a period of behaviour, so
|
||||
// bringing one down needs a period to have been observed: enough transactions to be a pattern, and spread over
|
||||
// enough months that they are not one busy week. Judging on amounts alone let three small deposits in a single
|
||||
// month argue for taking a customer off Medium — which is the same mistake as re-rating on one transaction, only
|
||||
// three times over.
|
||||
const enoughActivity = alertCount >= MIN_ALERTS_TO_LOWER && distinctMonths >= MIN_MONTHS_TO_LOWER;
|
||||
const small = enoughActivity && largestAmount < MATERIALITY.smallTxn && totalAmount < MATERIALITY.smallTotal;
|
||||
|
||||
// What the rating should be. Amounts only ever pull the rules' bound down, and
|
||||
// only where nothing weightier holds it up.
|
||||
let risk: RiskLevel;
|
||||
let basis: 'escalated' | 'grave' | 'material' | 'small' | 'moderate';
|
||||
let basis: 'escalated' | 'grave' | 'material' | 'small' | 'moderate' | 'thin';
|
||||
if (escalated) {
|
||||
risk = 'HIGH';
|
||||
basis = 'escalated';
|
||||
@@ -175,6 +197,12 @@ function recommendRating(
|
||||
} else if (material) {
|
||||
risk = implied;
|
||||
basis = 'material';
|
||||
} else if (!enoughActivity) {
|
||||
// Too little observed to say anything about the rating either way, so it stands. Stating this rather than
|
||||
// quietly leaving the rating alone is the point: an officer reading the row learns the period was too thin,
|
||||
// not that the customer was examined and found unremarkable.
|
||||
risk = currentRisk ?? implied;
|
||||
basis = 'thin';
|
||||
} else {
|
||||
risk = RANK_TO_LEVEL[Math.min(impliedRank, small ? 1 : 2)] ?? 'LOW';
|
||||
basis = small ? 'small' : 'moderate';
|
||||
@@ -218,6 +246,27 @@ function recommendRating(
|
||||
};
|
||||
}
|
||||
|
||||
if (basis === 'thin') {
|
||||
// A customer with no rating is not waiting for more evidence — there is no rating for evidence to move. Telling
|
||||
// them the period is too thin, under a verdict that says "Rate this customer", asks for the work and argues
|
||||
// against it in the same row. The thin-period test is about lowering a rating that exists.
|
||||
if (!currentRisk) {
|
||||
return {
|
||||
risk,
|
||||
reason: `Transacting with no rating on file — ${inr(totalAmount)} across ${alertCount} alert${
|
||||
alertCount === 1 ? '' : 's'
|
||||
}. Rate the customer; behaviour can only be judged against a rating.`
|
||||
};
|
||||
}
|
||||
// Says which of the two tests fell short, because "not enough activity" leaves an officer unable to tell whether
|
||||
// the customer needs watching for another month or another five.
|
||||
const shortOn =
|
||||
alertCount < MIN_ALERTS_TO_LOWER
|
||||
? `only ${alertCount} alert${alertCount === 1 ? '' : 's'} in the period, ${MIN_ALERTS_TO_LOWER} needed`
|
||||
: `activity falls in ${distinctMonths} month${distinctMonths === 1 ? '' : 's'}, ${MIN_MONTHS_TO_LOWER} separate months needed`;
|
||||
return { risk, reason: `Too little to re-rate on — ${shortOn}. ${rated}; it stands until more is seen.` };
|
||||
}
|
||||
|
||||
// The rating on file is where the activity puts it. Saying so is the verdict — leaving it blank
|
||||
// read as "not assessed yet" on screen, which is the opposite of what the review concluded.
|
||||
return {
|
||||
@@ -271,7 +320,36 @@ export function riskAlertViews(alerts: RiskAlertRow[], filter: RiskReviewFilter)
|
||||
* escalated, then rated below what the rules point at, then most alerts —
|
||||
* come to the top of the worklist.
|
||||
*/
|
||||
export function riskCustomerRows(views: RiskAlertView[]): RiskCustomerRow[] {
|
||||
/**
|
||||
* The behaviour window the nightly job computed for one customer, where there is one.
|
||||
*
|
||||
* Keyed on client id. A customer with no window - one the job has not seen yet, or a tenant where the job has never
|
||||
* run - falls back to the per-alert reckoning below, so the report renders either way.
|
||||
*/
|
||||
export interface BehaviourWindowByClient {
|
||||
[clientId: number]: {
|
||||
clientId: number;
|
||||
clientName: string | null;
|
||||
accountNo: string | null;
|
||||
officeName: string | null;
|
||||
riskCategoryAtReview: number | null;
|
||||
txnCount: number | null;
|
||||
distinctMonths: number | null;
|
||||
windowFrom: any;
|
||||
windowTo: any;
|
||||
creditTotal: number | null;
|
||||
debitTotal: number | null;
|
||||
largestTxn: number | null;
|
||||
cashTotal: number | null;
|
||||
alertCount: number | null;
|
||||
strCaseCount: number | null;
|
||||
suggestedDirection: string | null;
|
||||
suggestedCategory: number | null;
|
||||
suggestionReason: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export function riskCustomerRows(views: RiskAlertView[], windows: BehaviourWindowByClient = {}): RiskCustomerRow[] {
|
||||
const byClient = new Map<number, RiskAlertView[]>();
|
||||
for (const v of views) {
|
||||
const list = byClient.get(v.clientId);
|
||||
@@ -297,19 +375,59 @@ export function riskCustomerRows(views: RiskAlertView[]): RiskCustomerRow[] {
|
||||
const largestAmount = Math.max(...alerts.map((a) => a.amount));
|
||||
const escalated = alerts.some((a) => a.status === 'ESCALATED_STR' || a.status === 'STR_FILED');
|
||||
|
||||
const recommendation = recommendRating(
|
||||
proposedRank,
|
||||
currentRisk,
|
||||
largestAmount,
|
||||
totalAmount,
|
||||
escalated,
|
||||
alerts.map((a) => a.ruleCode)
|
||||
);
|
||||
// The server's own reckoning, where the nightly job has produced one.
|
||||
//
|
||||
// It is the better answer, and not because it is newer: it is drawn from a review period rather than from the
|
||||
// alerts on this screen. A customer appears here only once a rule has fired, so judging them on those alerts
|
||||
// alone describes the exceptions and never the ordinary months around them - which is exactly what a rating is
|
||||
// supposed to reflect. The job also tests the things this screen cannot see at all: how many separate months the
|
||||
// activity falls in, how long the customer has held the rating, whether an STR inside the block window forbids
|
||||
// lowering it.
|
||||
//
|
||||
// Without a window - a tenant where the job has not run, or a customer it has not reached - the per-alert
|
||||
// reckoning below still stands, so the report never renders blank while the two are in step.
|
||||
// A window is only good for as long as the rating it was computed against. The job takes a snapshot each night
|
||||
// and records the rating the customer held at the time; once an officer moves that rating the snapshot describes
|
||||
// a customer who no longer exists, and the screen would go on asking for a change that has already been applied
|
||||
// and approved. Dropping it here falls back to the per-alert reckoning until tonight's job catches up.
|
||||
const raw = windows[clientId];
|
||||
const staleWindow =
|
||||
raw?.riskCategoryAtReview != null &&
|
||||
currentRisk != null &&
|
||||
RANK_TO_LEVEL[raw.riskCategoryAtReview] !== currentRisk;
|
||||
const window = staleWindow ? undefined : raw;
|
||||
const recommendation = window?.suggestedDirection
|
||||
? {
|
||||
risk: RANK_TO_LEVEL[window.suggestedCategory ?? 0] ?? currentRisk ?? 'LOW',
|
||||
reason: window.suggestionReason ?? ''
|
||||
}
|
||||
: recommendRating(
|
||||
proposedRank,
|
||||
currentRisk,
|
||||
largestAmount,
|
||||
totalAmount,
|
||||
escalated,
|
||||
alerts.map((a) => a.ruleCode),
|
||||
alerts.length,
|
||||
// Distinct calendar months the alerts fall in. Count alone does not establish a period: five alerts in one
|
||||
// week is a busy fortnight, not five months of steady conduct.
|
||||
new Set(alerts.map((a) => a.alertDate.slice(0, 7))).size
|
||||
);
|
||||
|
||||
// Where the customer carries no rating there is nothing to move, so an
|
||||
// unrated customer is never counted as an upgrade or a downgrade.
|
||||
const delta = currentRisk ? riskRank(recommendation.risk) - riskRank(currentRisk) : 0;
|
||||
const direction: RatingDirection = delta > 0 ? 'up' : delta < 0 ? 'down' : 'same';
|
||||
const direction: RatingDirection = window?.suggestedDirection
|
||||
? window.suggestedDirection === 'UP'
|
||||
? 'up'
|
||||
: window.suggestedDirection === 'DOWN'
|
||||
? 'down'
|
||||
: 'same'
|
||||
: delta > 0
|
||||
? 'up'
|
||||
: delta < 0
|
||||
? 'down'
|
||||
: 'same';
|
||||
|
||||
rows.push({
|
||||
clientId,
|
||||
@@ -332,7 +450,10 @@ export function riskCustomerRows(views: RiskAlertView[]): RiskCustomerRow[] {
|
||||
openCount: alerts.filter((a) => REVIEW_STATUS_META[a.status].open).length,
|
||||
escalated,
|
||||
needsUpgrade: direction === 'up',
|
||||
needsDowngrade: direction === 'down'
|
||||
needsDowngrade: direction === 'down',
|
||||
// Carried through so the screen can show the period a recommendation was drawn from. A rating an officer is
|
||||
// asked to approve has to show its working, and the alerts on this row are not it.
|
||||
behaviourWindow: window ?? null
|
||||
});
|
||||
}
|
||||
|
||||
@@ -461,9 +582,13 @@ export interface RiskReviewModel {
|
||||
* the summary all run once per filter change rather than once per template
|
||||
* binding, which matters because every view reads from the same result.
|
||||
*/
|
||||
export function buildRiskReview(alerts: RiskAlertRow[], filter: RiskReviewFilter): RiskReviewModel {
|
||||
export function buildRiskReview(
|
||||
alerts: RiskAlertRow[],
|
||||
filter: RiskReviewFilter,
|
||||
windows: BehaviourWindowByClient = {}
|
||||
): RiskReviewModel {
|
||||
const base = riskAlertViews(alerts, filter);
|
||||
const narrowed = applyDirectionFilter(base, riskCustomerRows(base), filter.direction);
|
||||
const narrowed = applyDirectionFilter(base, riskCustomerRows(base, windows), filter.direction);
|
||||
|
||||
return {
|
||||
alerts: narrowed.views,
|
||||
|
||||
@@ -56,6 +56,7 @@ import {
|
||||
faGavel,
|
||||
faGlobe,
|
||||
faHandHoldingUsd,
|
||||
faHandPaper,
|
||||
faHome,
|
||||
faKey,
|
||||
faLink,
|
||||
@@ -303,6 +304,7 @@ export class IconsModule {
|
||||
faHandHoldingHand,
|
||||
faHandHoldingHeart,
|
||||
faHandHoldingUsd,
|
||||
faHandPaper,
|
||||
faHeart,
|
||||
faHome,
|
||||
faKey,
|
||||
|
||||
@@ -6,112 +6,189 @@
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="container">
|
||||
<mat-card>
|
||||
<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>
|
||||
|
||||
<form [formGroup]="applySharesForm" (ngSubmit)="submit()">
|
||||
<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>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<mat-form-field>
|
||||
<mat-label>{{ 'labels.inputs.Current Price' | translate }}</mat-label>
|
||||
<input matInput formControlName="unitPrice" />
|
||||
</mat-form-field>
|
||||
|
||||
<!-- 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 }} ×
|
||||
{{ 'labels.inputs.Current Price' | translate }}</mat-hint
|
||||
>
|
||||
</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>
|
||||
<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>
|
||||
|
||||
@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>
|
||||
<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>
|
||||
|
||||
<div class="gl-select-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>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<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 ×
|
||||
{{ 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')"
|
||||
>
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<!-- 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>
|
||||
<select
|
||||
class="gl-select"
|
||||
class="custom-select"
|
||||
[(ngModel)]="glAccountNo"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
[disabled]="lookingUpVoucher || submitting || loadingGlAccounts"
|
||||
@@ -125,173 +202,394 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<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 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>
|
||||
}
|
||||
</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 the member's receipts…</span>
|
||||
<span>Loading member receipts…</span>
|
||||
</div>
|
||||
} @else if (vouchers.length === 0) {
|
||||
<div class="funding-empty">
|
||||
<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 › Transaction Entry as a deposit to the Share Sundry
|
||||
GL. Once it is approved, the voucher appears here.
|
||||
</div>
|
||||
<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 › Transaction Entry</em> as a deposit to Share Sundry GL. Once
|
||||
posted, it will appear here.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<!-- 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>
|
||||
<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>
|
||||
|
||||
<!-- 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-error">
|
||||
<div class="alert-box alert-warning mt-8">
|
||||
<mat-icon>error_outline</mat-icon>
|
||||
<span>
|
||||
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.
|
||||
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.
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</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>
|
||||
<!-- 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 (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>
|
||||
@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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!savingsEligibility.eligible) {
|
||||
<div class="alert-error">
|
||||
<mat-icon>error_outline</mat-icon>
|
||||
<span>{{ savingsEligibility.reason }}</span>
|
||||
<div class="savings-row">
|
||||
<span class="lbl">Available Balance</span>
|
||||
<span class="val font-semibold">{{ savingsEligibility.savingsBalance | number: '1.2-2' }}</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-error">
|
||||
<mat-icon>error_outline</mat-icon>
|
||||
<span>{{ errorMsg }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (errorMsg) {
|
||||
<div class="alert-box alert-error mt-12">
|
||||
<mat-icon>error_outline</mat-icon>
|
||||
<span>{{ errorMsg }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- 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>
|
||||
<!-- 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>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
|
||||
<mat-card-actions class="layout-row align-center gap-5px responsive-column">
|
||||
<button type="button" mat-raised-button [routerLink]="['../../']">
|
||||
<!-- CARD ACTIONS -->
|
||||
<mat-card-actions class="card-actions-bar">
|
||||
<button type="button" mat-stroked-button class="action-btn cancel-btn" [routerLink]="['../../']">
|
||||
{{ 'labels.buttons.Cancel' | translate }}
|
||||
</button>
|
||||
<button mat-raised-button color="primary" [disabled]="!canSubmit">
|
||||
{{ submitting ? 'Submitting…' : ('labels.buttons.Submit' | translate) }}
|
||||
|
||||
<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-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,6 +107,15 @@ 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;
|
||||
@@ -134,10 +143,43 @@ 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.
|
||||
*
|
||||
@@ -254,12 +296,24 @@ 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 {
|
||||
this.selectedVoucherIds = this.isVoucherSelected(id) ? this.selectedVoucherIds.filter((x) => x !== id) : [
|
||||
...this.selectedVoucherIds,
|
||||
id
|
||||
];
|
||||
const wasSelected = this.isVoucherSelected(id);
|
||||
this.selectedVoucherIds = wasSelected ? [] : [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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -307,19 +361,13 @@ export class ApplySharesComponent implements OnInit {
|
||||
this.voucherError = `Voucher ${trimmed} belongs to ${res.client_name}, not the holder of this share account.`;
|
||||
return;
|
||||
}
|
||||
// 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.
|
||||
// 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.
|
||||
this.vouchers = [
|
||||
res,
|
||||
...this.vouchers.filter((v) => v.id !== res.id)
|
||||
];
|
||||
if (!this.isVoucherSelected(res.id)) {
|
||||
this.selectedVoucherIds = [
|
||||
...this.selectedVoucherIds,
|
||||
res.id
|
||||
];
|
||||
}
|
||||
this.selectedVoucherIds = [res.id];
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+5
-1
@@ -168,6 +168,8 @@
|
||||
<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>
|
||||
@@ -186,6 +188,8 @@
|
||||
</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>
|
||||
}
|
||||
@@ -197,7 +201,7 @@
|
||||
<tfoot>
|
||||
<tr class="total-row">
|
||||
<td class="col-tick"></td>
|
||||
<td class="ta-left" colspan="2">
|
||||
<td class="ta-left" colspan="4">
|
||||
{{ selectedIds.length }} {{ selectedIds.length === 1 ? 'receipt' : 'receipts' }} selected
|
||||
</td>
|
||||
<td class="ta-right amount-cell">{{ selectedTotal | number: '1.2-2' }}</td>
|
||||
|
||||
+6
@@ -135,6 +135,12 @@ 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;
|
||||
});
|
||||
|
||||
|
||||
+175
-6
@@ -384,18 +384,59 @@
|
||||
|
||||
/* Print CSS */
|
||||
@media print {
|
||||
/* Hide non-overlay contents during 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. */
|
||||
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: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
position: static !important;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
height: auto !important;
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
background-color: #fff !important;
|
||||
@@ -411,7 +452,135 @@
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.mat-mdc-dialog-container {
|
||||
::ng-deep .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,124 +5,244 @@
|
||||
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="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>
|
||||
<!--
|
||||
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>
|
||||
<button mat-flat-button color="primary" (click)="openAddInventoryDialog()">Register DD Series</button>
|
||||
<button class="inv-btn primary" (click)="openAddInventoryDialog()">
|
||||
<fa-icon icon="plus"></fa-icon>
|
||||
Register DD Series
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 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. -->
|
||||
<!-- Dividend Context Banner -->
|
||||
@if (dividendContext) {
|
||||
<div class="dividend-banner">
|
||||
<div class="dividend-banner__text">
|
||||
<strong>Issuing a dividend Demand Draft</strong>
|
||||
<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>
|
||||
<span>
|
||||
{{ dividendContext.beneficiary }} ·
|
||||
{{ dividendContext.amount | number: '1.2-2' }}
|
||||
Beneficiary: <strong>{{ dividendContext.beneficiary }}</strong> · Amount:
|
||||
<strong>₹{{ dividendContext.amount | number: '1.2-2' }}</strong>
|
||||
@if (dividendContext.financialYear) {
|
||||
· FY {{ dividendContext.financialYear }}
|
||||
}
|
||||
</span>
|
||||
<span class="dividend-banner__hint">Open a series below and choose an available leaflet.</span>
|
||||
<span class="inv-dividend-banner__hint">Select a series below to pick an available leaflet.</span>
|
||||
</div>
|
||||
<button mat-button class="action-btn" (click)="cancelDividendClaim()">Cancel</button>
|
||||
<button class="inv-btn ghost small" (click)="cancelDividendClaim()">
|
||||
<fa-icon icon="times-circle"></fa-icon>
|
||||
Cancel Claim
|
||||
</button>
|
||||
</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.
|
||||
<!-- 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>
|
||||
</div>
|
||||
</mat-card>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -22,6 +22,8 @@ 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({
|
||||
@@ -30,106 +32,482 @@ import { SponsorDDService } from '../sponsor-dd.service';
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterModule,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
MatCardModule,
|
||||
MatTableModule,
|
||||
MatButtonModule,
|
||||
MatDialogModule,
|
||||
MatSnackBarModule
|
||||
MatSnackBarModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatIconModule,
|
||||
MatTooltipModule,
|
||||
FaIconComponent
|
||||
],
|
||||
templateUrl: './dd-inventory-list.component.html',
|
||||
styles: [
|
||||
`
|
||||
.inventory-container {
|
||||
.inv-container {
|
||||
padding: 24px;
|
||||
max-width: 1200px;
|
||||
max-width: 1360px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.header-row {
|
||||
|
||||
/* Hero Header */
|
||||
.inv-hero {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.title-area h2 {
|
||||
margin: 0 0 4px 0;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
.title-area p {
|
||||
margin: 0;
|
||||
color: #64748b;
|
||||
font-size: 14px;
|
||||
}
|
||||
.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 {
|
||||
.inv-hero__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid #c7d2fe;
|
||||
border-left: 4px solid #4f46e5;
|
||||
border-radius: 8px;
|
||||
background-color: #eef2ff;
|
||||
}
|
||||
.dividend-banner__text {
|
||||
.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 {
|
||||
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;
|
||||
}
|
||||
|
||||
/* Dividend Banner */
|
||||
.inv-dividend-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
padding: 16px 20px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #c7d2fe;
|
||||
background: linear-gradient(135deg, #eef2ff 0%, #f5f3ff 100%);
|
||||
box-shadow: 0 2px 8px rgba(79, 70, 229, 0.08);
|
||||
}
|
||||
.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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
font-size: 13px;
|
||||
font-size: 13.5px;
|
||||
color: #312e81;
|
||||
}
|
||||
.dividend-banner__text strong {
|
||||
font-size: 13.5px;
|
||||
}
|
||||
.dividend-banner__hint {
|
||||
color: #4338ca;
|
||||
.inv-dividend-banner__hint {
|
||||
font-size: 12px;
|
||||
color: #4338ca;
|
||||
}
|
||||
.dividend-banner button {
|
||||
.inv-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;
|
||||
}
|
||||
`
|
||||
]
|
||||
})
|
||||
@@ -140,14 +518,9 @@ 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',
|
||||
@@ -168,13 +541,6 @@ 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']);
|
||||
@@ -189,7 +555,6 @@ 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
|
||||
? {
|
||||
@@ -201,7 +566,6 @@ export class DdInventoryListComponent implements OnInit {
|
||||
: {};
|
||||
}
|
||||
|
||||
/** Abandons the claim and returns to the dividend dashboard, leaving the lock untouched. */
|
||||
cancelDividendClaim(): void {
|
||||
this.router.navigate([
|
||||
'/clerk',
|
||||
@@ -212,7 +576,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', {
|
||||
@@ -222,9 +586,44 @@ 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: '500px',
|
||||
width: '520px',
|
||||
disableClose: true
|
||||
});
|
||||
|
||||
|
||||
@@ -5,251 +5,326 @@
|
||||
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="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">
|
||||
← Back to Inventories
|
||||
<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>
|
||||
</button>
|
||||
<h2>DD Leaflet Registry</h2>
|
||||
<p *ngIf="inventoryDetails">
|
||||
Sponsor Bank: <strong>{{ inventoryDetails.sponsorBankName }}</strong> | Series Range:
|
||||
<strong>{{ inventoryDetails.seriesStart }} - {{ inventoryDetails.seriesEnd }}</strong>
|
||||
</p>
|
||||
<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>
|
||||
</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="dividend-banner" *ngIf="dividendContext">
|
||||
<div class="dividend-banner__text">
|
||||
<div class="ddreg-dividend" *ngIf="dividendContext">
|
||||
<fa-icon icon="hand-holding-usd" class="ddreg-dividend__ic"></fa-icon>
|
||||
<div class="ddreg-dividend__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="dividend-banner__hint">Pick an available leaflet below and press Issue.</span>
|
||||
<span class="ddreg-dividend__hint">Pick an available leaflet below and press "Draw for Dividend".</span>
|
||||
</div>
|
||||
<button mat-button class="action-btn" (click)="cancelDividendClaim()">Cancel</button>
|
||||
<button class="ddreg-btn ghost small" (click)="cancelDividendClaim()">Cancel</button>
|
||||
</div>
|
||||
|
||||
<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()">
|
||||
<!-- 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>
|
||||
{{ 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"
|
||||
[color]="selectedStatus === status ? 'primary' : undefined"
|
||||
[class.filter-active]="selectedStatus === status"
|
||||
class="ddreg-chip"
|
||||
[ngClass]="'s-' + status.toLowerCase()"
|
||||
[class.active]="selectedStatus === status"
|
||||
(click)="filterByStatus(status)"
|
||||
>
|
||||
{{ status === 'ALL' ? 'All' : status }}
|
||||
<span class="filter-count">({{ statusCount(status) }})</span>
|
||||
<span class="ddreg-chip__dot"></span>
|
||||
{{ status === 'ALL' ? 'All' : (status | titlecase) }}
|
||||
<span class="ddreg-chip__count">{{ statusCount(status) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="hint-text" *ngIf="todayOnly">
|
||||
Only DDs actually ISSUED today are shown - AVAILABLE, CANCELLED, STOPPED and CLEARED are hidden. Click "Showing All
|
||||
<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
|
||||
Dates" above to see everything.
|
||||
</p>
|
||||
|
||||
<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>
|
||||
<!-- 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>
|
||||
|
||||
<!-- 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 }})
|
||||
<!-- 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 }}
|
||||
</span>
|
||||
</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>
|
||||
<!-- 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>
|
||||
|
||||
<!-- 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>
|
||||
<!-- 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>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
|
||||
</table>
|
||||
<!-- Beneficiary Column -->
|
||||
<ng-container matColumnDef="beneficiaryName">
|
||||
<th mat-header-cell *matHeaderCellDef>Beneficiary</th>
|
||||
<td mat-cell *matCellDef="let element">{{ element.beneficiaryName || '—' }}</td>
|
||||
</ng-container>
|
||||
|
||||
<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.' }}
|
||||
<!-- 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>
|
||||
|
||||
<mat-paginator [pageSizeOptions]="[25, 50, 100, 250]" [pageSize]="25" showFirstLastButtons></mat-paginator>
|
||||
</mat-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -21,6 +21,9 @@ 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';
|
||||
@@ -40,145 +43,624 @@ import { DividendClaimRequestService } from '../../clerk/dividend-claim-request.
|
||||
MatDialogModule,
|
||||
MatSnackBarModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule
|
||||
MatInputModule,
|
||||
MatMenuModule,
|
||||
MatIconModule,
|
||||
MatTooltipModule,
|
||||
FaIconComponent
|
||||
],
|
||||
templateUrl: './dd-list.component.html',
|
||||
styles: [
|
||||
`
|
||||
.leaflet-container {
|
||||
padding: 24px;
|
||||
max-width: 1200px;
|
||||
/* 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;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.dividend-banner {
|
||||
|
||||
/* ─── Header ─── */
|
||||
.ddreg-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
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;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid #c7d2fe;
|
||||
border-left: 4px solid #4f46e5;
|
||||
border-radius: 8px;
|
||||
background-color: #eef2ff;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(180deg, #eef2ff 0%, #f5f3ff 100%);
|
||||
}
|
||||
.dividend-banner__text {
|
||||
.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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
font-size: 13px;
|
||||
color: #312e81;
|
||||
}
|
||||
.dividend-banner__hint {
|
||||
.ddreg-dividend__hint {
|
||||
color: #4338ca;
|
||||
font-size: 12px;
|
||||
}
|
||||
.dividend-banner button {
|
||||
.ddreg-dividend .ddreg-btn {
|
||||
margin-left: auto;
|
||||
}
|
||||
.header-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.title-area h2 {
|
||||
margin: 0 0 4px 0;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
.title-area p {
|
||||
margin: 0;
|
||||
color: #64748b;
|
||||
font-size: 14px;
|
||||
}
|
||||
.search-field {
|
||||
width: 320px;
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.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 {
|
||||
|
||||
/* ─── Buttons ─── */
|
||||
.ddreg-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 8px;
|
||||
border-radius: 9999px;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
border-radius: 9px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
white-space: nowrap;
|
||||
border: 1px solid transparent;
|
||||
background: #fff;
|
||||
}
|
||||
.ddreg-btn fa-icon {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.status-badge.available {
|
||||
background-color: #dcfce7;
|
||||
color: #15803d;
|
||||
}
|
||||
.status-badge.issued {
|
||||
background-color: #dbeafe;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
.status-badge.cleared {
|
||||
background-color: #f1f5f9;
|
||||
.ddreg-btn.ghost {
|
||||
border-color: #d0d5dd;
|
||||
color: #475569;
|
||||
}
|
||||
.status-badge.cancelled {
|
||||
background-color: #fee2e2;
|
||||
.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;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.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;
|
||||
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;
|
||||
}
|
||||
.status-badge.stopped {
|
||||
background-color: #fef3c7;
|
||||
.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;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgb(15 23 42 / 6%);
|
||||
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 {
|
||||
width: 100%;
|
||||
}
|
||||
.ddreg-table th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: #f1f5f9;
|
||||
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;
|
||||
}
|
||||
.ddreg-table td {
|
||||
padding: 10px 14px !important;
|
||||
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 {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 9999px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.ddreg-badge__dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
.ddreg-badge.available {
|
||||
background: #dcfce7;
|
||||
color: #15803d;
|
||||
}
|
||||
.ddreg-badge.issued {
|
||||
background: #dbeafe;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
.ddreg-badge.cleared {
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
}
|
||||
.ddreg-badge.cancelled {
|
||||
background: #fee2e2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
.ddreg-badge.stopped {
|
||||
background: #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. */
|
||||
.status-badge.revoked {
|
||||
background-color: #e2e8f0;
|
||||
.ddreg-badge.revoked {
|
||||
background: #e2e8f0;
|
||||
color: #475569;
|
||||
}
|
||||
.action-btn-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
/* ─── Actions ─── */
|
||||
.ddreg-actions-col {
|
||||
min-width: 190px;
|
||||
}
|
||||
.pending-badge {
|
||||
.ddreg-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.ddreg-act {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #fff;
|
||||
border-radius: 7px;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 10px;
|
||||
border-radius: 9999px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
background-color: #fef3c7;
|
||||
color: #b45309;
|
||||
}
|
||||
.status-filter-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.status-filter-row button.filter-active {
|
||||
background-color: #ede9fe;
|
||||
}
|
||||
.filter-count {
|
||||
justify-content: center;
|
||||
color: #64748b;
|
||||
margin-left: 4px;
|
||||
font-size: 12px;
|
||||
transition: all 0.12s;
|
||||
}
|
||||
.hint-text {
|
||||
.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;
|
||||
margin: -8px 0 16px 0;
|
||||
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;
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 48px 16px;
|
||||
color: #94a3b8;
|
||||
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;
|
||||
}
|
||||
}
|
||||
`
|
||||
]
|
||||
|
||||
@@ -108,13 +108,14 @@ export class SponsorDDService {
|
||||
chargesAmount?: number;
|
||||
issuingBranchId: number;
|
||||
issuedDate: string; // yyyy-MM-dd
|
||||
paymentMethod: 'ACCOUNT_DEBIT' | 'CHEQUE' | 'CASH';
|
||||
paymentMethod: 'ACCOUNT_DEBIT' | 'CHEQUE' | 'CASH' | 'GL';
|
||||
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);
|
||||
|
||||
+53
-1
@@ -68,18 +68,70 @@
|
||||
<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>
|
||||
<tr class="sard-voucher-row" (click)="toggleVoucherGlEntries(v.id)">
|
||||
<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>
|
||||
|
||||
+90
@@ -121,3 +121,93 @@ $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;
|
||||
}
|
||||
}
|
||||
|
||||
+26
@@ -50,6 +50,12 @@ 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);
|
||||
|
||||
@@ -76,6 +82,26 @@ 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,6 +232,12 @@
|
||||
<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">
|
||||
|
||||
@@ -243,6 +243,12 @@ 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' }
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
/* tslint:disable */
|
||||
export default {
|
||||
'mifos_x': {
|
||||
'version': '260914',
|
||||
'hash': 'ee5a430b'
|
||||
'version': '260918',
|
||||
'hash': '0cef1b5a'
|
||||
},
|
||||
'allow_switching_backend_instance': true
|
||||
};
|
||||
|
||||
+102
-11
@@ -982,6 +982,16 @@ 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. */
|
||||
@@ -997,17 +1007,57 @@ mat-card {
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
|
||||
/* 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. */
|
||||
/* 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. */
|
||||
#mifosx-shell-container,
|
||||
mat-sidenav-container,
|
||||
mat-drawer-container,
|
||||
mat-sidenav-content,
|
||||
mat-drawer-content,
|
||||
.mat-drawer-container,
|
||||
.mat-drawer-content,
|
||||
mat-sidenav-content.sidenav {
|
||||
position: static !important;
|
||||
.mat-sidenav-content {
|
||||
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
|
||||
@@ -1023,11 +1073,6 @@ mat-card {
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
body,
|
||||
html {
|
||||
background: #fff !important;
|
||||
}
|
||||
|
||||
@page {
|
||||
/* stylelint-disable-next-line unit-allowed-list */
|
||||
margin: 1.5cm;
|
||||
@@ -1869,3 +1914,49 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user