A
This commit is contained in:
+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
|
||||
@@ -396,10 +390,18 @@ export class CashierService {
|
||||
return this.http.get('/shareadvancecollection/requirement', { params: { shareAccountId: String(shareAccountId) } });
|
||||
}
|
||||
|
||||
/** Receipts still sitting in Share Sundry, optionally for a single member. */
|
||||
getUnappliedShareAdvances(clientId?: number): Observable<any> {
|
||||
const suffix = clientId ? `?clientId=${clientId}` : '';
|
||||
return this.http.get(`/shareadvancecollection${suffix}`);
|
||||
/**
|
||||
* Everything an Apply screen needs, in one call: `own` is this member's own unapplied receipts,
|
||||
* ready to tick; `rows` is every other Share Collection receipt still available — unclaimed GL
|
||||
* deposits and receipts linked to another member — which attach by voucher instead.
|
||||
*
|
||||
* One request rather than two: both lists come from the same table under the same filters, and
|
||||
* splitting them across endpoints only meant the same rows were fetched twice and, where they
|
||||
* overlapped, drawn twice on the same screen.
|
||||
*/
|
||||
getShareCollectionReceipts(clientId?: number): Observable<any> {
|
||||
const params = clientId ? new HttpParams().set('clientId', String(clientId)) : undefined;
|
||||
return this.http.get('/shareadvancecollection/unattributed', { params });
|
||||
}
|
||||
|
||||
/** Moves a receipt out of Sundry into share capital for an activated account. */
|
||||
@@ -407,6 +409,12 @@ export class CashierService {
|
||||
return this.http.post(`/shareadvancecollection/${collectionId}/apply`, data);
|
||||
}
|
||||
|
||||
/** The receipt's own real Dr/Cr GL legs — never the GL account currently picked in a search box,
|
||||
* which is unrelated screen state that can name a different account than this receipt sits in. */
|
||||
getShareAdvanceGlEntries(collectionId: number): Observable<any> {
|
||||
return this.http.get(`/shareadvancecollection/${collectionId}/gl-entries`);
|
||||
}
|
||||
|
||||
/** Every additional-share tranche awaiting approval, across every share account. */
|
||||
getShareAdditionRequests(): Observable<any> {
|
||||
return this.http.get('/shareadditionrequests');
|
||||
@@ -419,4 +427,14 @@ export class CashierService {
|
||||
getShareAdditionRequest(transactionId: number): Observable<any> {
|
||||
return this.http.get(`/shareadditionrequests/${transactionId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Entry Type options for a GL credit (General / Share Collection / DD Issue / …) — the
|
||||
* "GLEntryType" code under Admin > System > Manage Codes, so an admin can add another type later
|
||||
* without a deployment. Empty on failure rather than erroring, since a row missing this dropdown's
|
||||
* options should still be usable for every other field.
|
||||
*/
|
||||
getEntryTypeOptions(): Observable<any[]> {
|
||||
return this.http.get<any[]>('/codes/name/GLEntryType/codevalues').pipe(catchError(() => of([])));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,19 +174,24 @@ export class ClosingCashReportComponent implements OnInit {
|
||||
return this.noteRows.reduce((s, r) => s + Number(r.settledOutAmount ?? 0), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Summed from each row's own `netCount`, not re-derived from today's legs — a row's `netCount`
|
||||
* already carries forward what a cashier was holding from before today (`openingCount`), for the
|
||||
* force-closed-day/late-settlement case. Recomputing here from only today's vaultIn/deposit/
|
||||
* withdrawal/settledOut dropped that carry-forward and put the Total row back into negative
|
||||
* numbers a cashier had already reconciled.
|
||||
*/
|
||||
get noteNetCount(): number {
|
||||
return this.noteVaultInCount + this.noteDepositCount - this.noteWithdrawalCount - this.noteSettledOutCount;
|
||||
return this.noteRows.reduce((s, r) => s + Number(r.netCount ?? 0), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* What is left in the drawer in notes: everything that came in, less everything that went out.
|
||||
*
|
||||
* Was deposits minus withdrawals, which is the counter's movement rather than the drawer's
|
||||
* contents — it left out the cash drawn from the vault, so a teller holding a ₹500 note saw a net
|
||||
* of zero and had nothing to count against.
|
||||
* What is left in the drawer in notes: everything that came in, less everything that went out —
|
||||
* including cash carried forward from before today. See `noteNetCount` for why this sums each
|
||||
* row's own `netAmount` rather than re-deriving it from today's legs.
|
||||
*/
|
||||
get noteNetAmount(): number {
|
||||
return this.noteVaultInAmount + this.noteDepositAmount - this.noteWithdrawalAmount - this.noteSettledOutAmount;
|
||||
return this.noteRows.reduce((s, r) => s + Number(r.netAmount ?? 0), 0);
|
||||
}
|
||||
get coinRows(): any[] {
|
||||
return this.rows.filter((r) => r.kind === 'coin');
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, FormArray, Validators } from '@angular/forms';
|
||||
import { MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
@@ -35,6 +35,35 @@ export interface CashierIssueDdDialogData {
|
||||
accountNo?: string;
|
||||
/** The account holder, for the same reason. */
|
||||
accountHolderName?: string;
|
||||
/**
|
||||
* How many drafts this slip raises. Absent or 1 is the ordinary single draft and the dialog is
|
||||
* unchanged; 2 or more turns it into a slip, asking for a face value, charge and beneficiary per
|
||||
* draft and reporting the combined total the account will be debited for.
|
||||
*/
|
||||
ddCount?: number;
|
||||
/**
|
||||
* Whether the funding account is a GL account rather than a member's. Only affects wording: a GL
|
||||
* account has no passbook, so it is not promised one.
|
||||
*/
|
||||
glFunded?: boolean;
|
||||
/**
|
||||
* The leaflets provisionally shown against each line of a slip.
|
||||
*
|
||||
* Only ever indicative. The server allocates the real numbers inside the locked transaction that
|
||||
* submits the slip, so what is shown here is the next-available run at the moment the dialog was
|
||||
* opened — accurate unless another clerk submits first, which is precisely why the numbers cannot
|
||||
* be promised until submit returns them.
|
||||
*/
|
||||
previewDdNumbers?: string[];
|
||||
}
|
||||
|
||||
/** One captured draft on a multi-DD slip. */
|
||||
export interface DdSlipLineResult {
|
||||
beneficiaryName: string;
|
||||
payableAtBranch: string;
|
||||
payableAtCity: string;
|
||||
faceValue: number;
|
||||
chargesAmount: number;
|
||||
}
|
||||
|
||||
@Component({
|
||||
@@ -56,10 +85,20 @@ export interface CashierIssueDdDialogData {
|
||||
<div class="dd-dialog-header__title">
|
||||
<mat-icon class="dd-icon">receipt_long</mat-icon>
|
||||
<div>
|
||||
<h2>{{ isAccountDebit ? 'Account-Debit Demand Draft (DD)' : 'Walk-in Cash Demand Draft (DD)' }}</h2>
|
||||
<h2>
|
||||
@if (isSlip) {
|
||||
Multi-DD Slip · {{ lineCount }} Demand Drafts
|
||||
} @else {
|
||||
{{ isAccountDebit ? 'Account-Debit Demand Draft (DD)' : 'Walk-in Cash Demand Draft (DD)' }}
|
||||
}
|
||||
</h2>
|
||||
<p>
|
||||
Next available DD leaflet auto-selected ·
|
||||
{{ isAccountDebit ? 'Enter beneficiary details' : 'Enter purchaser & beneficiary details' }}
|
||||
@if (isSlip) {
|
||||
One debit funds all {{ lineCount }} drafts · Enter each draft's beneficiary and amount
|
||||
} @else {
|
||||
Next available DD leaflet auto-selected ·
|
||||
{{ isAccountDebit ? 'Enter beneficiary details' : 'Enter purchaser & beneficiary details' }}
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -69,41 +108,91 @@ export interface CashierIssueDdDialogData {
|
||||
</div>
|
||||
|
||||
<mat-dialog-content class="dd-dialog-body">
|
||||
<!-- Draft & Financials Summary Card -->
|
||||
<div class="dd-summary-card">
|
||||
<!-- The number the customer will actually see printed on the instrument. "Auto-Selected
|
||||
<!--
|
||||
Slip running total.
|
||||
|
||||
A slip's figures are not known until every line is typed — different drafts carry different
|
||||
face values and different charges — so the total the account will be debited for is built
|
||||
up here as the clerk works, rather than shown as a fixed figure up front. Answering "what
|
||||
is this going to cost my member" without it would mean adding the lines by hand.
|
||||
-->
|
||||
@if (isSlip) {
|
||||
<div class="dd-summary-card">
|
||||
<div class="summary-col">
|
||||
<span class="label">Drafts On This Slip</span>
|
||||
<strong class="dd-number-highlight">{{ lineCount }}</strong>
|
||||
<span class="sublabel">{{ data.sponsorBankName || 'Sponsor Bank' }} · {{ data.branchName }}</span>
|
||||
</div>
|
||||
<div class="summary-divider"></div>
|
||||
<div class="summary-col">
|
||||
<span class="label">Total Face Value</span>
|
||||
<strong class="amount">₹{{ slipFaceValue | number: '1.2-2' }}</strong>
|
||||
<span class="sublabel">+ Charges ₹{{ slipCharges | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
<div class="summary-divider"></div>
|
||||
<div class="summary-col">
|
||||
<span class="label">GST ({{ data.gstPercentage }}%)</span>
|
||||
<strong class="amount">₹{{ slipGst | number: '1.2-2' }}</strong>
|
||||
<span class="sublabel">on charges only</span>
|
||||
</div>
|
||||
<div class="summary-divider"></div>
|
||||
<div class="summary-col highlight-col">
|
||||
<span class="label">Total To Debit</span>
|
||||
<strong class="total-amount">₹{{ slipTotal | number: '1.2-2' }}</strong>
|
||||
<span class="sublabel">{{ data.accountNo || 'Account' }} · one debit</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
Said plainly because the numbers on screen are the one thing in this dialog the clerk
|
||||
cannot rely on. They are the next-available run as it stood when the dialog opened; the
|
||||
binding allocation happens inside the submit transaction, so a slip filed after another
|
||||
clerk's gets the following numbers instead.
|
||||
-->
|
||||
<div class="dd-preview-note">
|
||||
<mat-icon>info</mat-icon>
|
||||
<div>
|
||||
DD numbers below are <strong>provisional</strong>. The final numbers are allocated when the slip is
|
||||
submitted and will be confirmed on the receipt.
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<!-- Draft & Financials Summary Card -->
|
||||
<div class="dd-summary-card">
|
||||
<!-- The number the customer will actually see printed on the instrument. "Auto-Selected
|
||||
Leaflet" named the stock item rather than the draft, so it was not obvious that this
|
||||
is the DD number being issued. -->
|
||||
<div class="summary-col">
|
||||
<span class="label">DD Number Being Issued</span>
|
||||
<strong class="dd-number-highlight">{{ data.leaflet.ddNumber }}</strong>
|
||||
<span class="sublabel">{{ data.sponsorBankName || 'Sponsor Bank' }} · {{ data.branchName }}</span>
|
||||
<div class="summary-col">
|
||||
<span class="label">DD Number Being Issued</span>
|
||||
<strong class="dd-number-highlight">{{ data.leaflet.ddNumber }}</strong>
|
||||
<span class="sublabel">{{ data.sponsorBankName || 'Sponsor Bank' }} · {{ data.branchName }}</span>
|
||||
</div>
|
||||
<div class="summary-divider"></div>
|
||||
<div class="summary-col">
|
||||
<span class="label">Face Value</span>
|
||||
<strong class="amount">₹{{ data.faceValue | number: '1.2-2' }}</strong>
|
||||
<span class="sublabel">+ Charges ₹{{ data.chargesAmount | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
<div class="summary-divider"></div>
|
||||
<div class="summary-col">
|
||||
<span class="label">GST ({{ data.gstPercentage }}%)</span>
|
||||
<strong class="amount">₹{{ data.gstAmount | number: '1.2-2' }}</strong>
|
||||
<span class="sublabel">on charges only</span>
|
||||
</div>
|
||||
<div class="summary-divider"></div>
|
||||
<div class="summary-col highlight-col">
|
||||
<span class="label">{{ isAccountDebit ? 'Total Debited' : 'Total Cash Collected' }}</span>
|
||||
<strong class="total-amount">₹{{ data.totalCashPayable | number: '1.2-2' }}</strong>
|
||||
<span class="sublabel">
|
||||
@if (isAccountDebit) {
|
||||
{{ data.accountNo || 'Account' }}
|
||||
} @else {
|
||||
Denominations verified
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="summary-divider"></div>
|
||||
<div class="summary-col">
|
||||
<span class="label">Face Value</span>
|
||||
<strong class="amount">₹{{ data.faceValue | number: '1.2-2' }}</strong>
|
||||
<span class="sublabel">+ Charges ₹{{ data.chargesAmount | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
<div class="summary-divider"></div>
|
||||
<div class="summary-col">
|
||||
<span class="label">GST ({{ data.gstPercentage }}%)</span>
|
||||
<strong class="amount">₹{{ data.gstAmount | number: '1.2-2' }}</strong>
|
||||
<span class="sublabel">on charges only</span>
|
||||
</div>
|
||||
<div class="summary-divider"></div>
|
||||
<div class="summary-col highlight-col">
|
||||
<span class="label">{{ isAccountDebit ? 'Total Debited' : 'Total Cash Collected' }}</span>
|
||||
<strong class="total-amount">₹{{ data.totalCashPayable | number: '1.2-2' }}</strong>
|
||||
<span class="sublabel">
|
||||
@if (isAccountDebit) {
|
||||
{{ data.accountNo || 'Account' }}
|
||||
} @else {
|
||||
Denominations verified
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!--
|
||||
The account funding the draft, stated before the form.
|
||||
@@ -121,122 +210,238 @@ export interface CashierIssueDdDialogData {
|
||||
· {{ data.accountHolderName }}
|
||||
}
|
||||
<div class="dd-funding-sub">
|
||||
₹{{ data.totalCashPayable | number: '1.2-2' }} will be debited from this account when a checker
|
||||
approves. No cash is taken at the counter.
|
||||
@if (isSlip) {
|
||||
₹{{ slipTotal | number: '1.2-2' }} will be debited from this account as a
|
||||
<strong>single transaction</strong> covering all {{ lineCount }} drafts, when a checker approves the
|
||||
slip —
|
||||
<!-- A GL account has no passbook, so the reassurance a member gets is phrased for
|
||||
the ledger instead of promising a statement line that does not exist. -->
|
||||
@if (isGlFunded) {
|
||||
one journal entry against this GL account, not {{ lineCount }}.
|
||||
} @else {
|
||||
the member's passbook shows one entry, not {{ lineCount }}.
|
||||
}
|
||||
} @else {
|
||||
₹{{ data.totalCashPayable | number: '1.2-2' }} will be debited from this account when a checker
|
||||
approves. No cash is taken at the counter.
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<form [formGroup]="form" class="dd-form">
|
||||
<!--
|
||||
<!--
|
||||
A slip's drafts, one card each.
|
||||
|
||||
Face value and charge sit on the line rather than on the slip because that is how the bank
|
||||
actually uses this: three drafts to three institutions are three different amounts, and the
|
||||
charge is levied per draft. The server stores and posts them per line for the same reason.
|
||||
-->
|
||||
@if (isSlip) {
|
||||
<form [formGroup]="slipForm" class="dd-form">
|
||||
<div formArrayName="lines">
|
||||
@for (lineCtrl of slipLines.controls; track $index) {
|
||||
<div class="dd-line-card" [formGroupName]="$index">
|
||||
<div class="dd-line-head">
|
||||
<span class="dd-line-badge">DD {{ $index + 1 }} of {{ lineCount }}</span>
|
||||
@if (previewNumberAt($index)) {
|
||||
<span class="dd-line-number">No. {{ previewNumberAt($index) }} <em>(provisional)</em></span>
|
||||
}
|
||||
<span class="dd-line-total"> Debit ₹{{ lineTotalAt($index) | number: '1.2-2' }} </span>
|
||||
</div>
|
||||
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Beneficiary Name (In Favour Of) *</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="beneficiaryName"
|
||||
placeholder="e.g. UNIVERSITY OF MUMBAI"
|
||||
maxlength="120"
|
||||
style="text-transform: uppercase;"
|
||||
/>
|
||||
<mat-error *ngIf="lineCtrl.get('beneficiaryName')?.hasError('required')"
|
||||
>Beneficiary name is required</mat-error
|
||||
>
|
||||
<mat-error *ngIf="lineCtrl.get('beneficiaryName')?.hasError('maxlength')"
|
||||
>Max 120 characters</mat-error
|
||||
>
|
||||
</mat-form-field>
|
||||
|
||||
<div class="form-row">
|
||||
<mat-form-field appearance="outline" class="form-field-flex">
|
||||
<mat-label>Payable At - Branch *</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="payableAtBranch"
|
||||
placeholder="e.g. FORT BRANCH"
|
||||
maxlength="150"
|
||||
style="text-transform: uppercase;"
|
||||
/>
|
||||
<mat-error *ngIf="lineCtrl.get('payableAtBranch')?.hasError('required')"
|
||||
>Payable Branch is required</mat-error
|
||||
>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="form-field-flex">
|
||||
<mat-label>Payable At - City *</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="payableAtCity"
|
||||
placeholder="e.g. MUMBAI"
|
||||
maxlength="100"
|
||||
style="text-transform: uppercase;"
|
||||
/>
|
||||
<mat-error *ngIf="lineCtrl.get('payableAtCity')?.hasError('required')"
|
||||
>Payable City is required</mat-error
|
||||
>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<mat-form-field appearance="outline" class="form-field-flex">
|
||||
<mat-label>Face Value *</mat-label>
|
||||
<input matInput type="number" formControlName="faceValue" placeholder="0.00" />
|
||||
<span matPrefix>₹ </span>
|
||||
<mat-error *ngIf="lineCtrl.get('faceValue')?.hasError('required')"
|
||||
>Face value is required</mat-error
|
||||
>
|
||||
<mat-error *ngIf="lineCtrl.get('faceValue')?.hasError('min')"
|
||||
>Face value must be greater than zero</mat-error
|
||||
>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="form-field-flex">
|
||||
<mat-label>Charges</mat-label>
|
||||
<input matInput type="number" formControlName="chargesAmount" placeholder="0.00" />
|
||||
<span matPrefix>₹ </span>
|
||||
<mat-hint>GST ₹{{ lineGstAt($index) | number: '1.2-2' }} at {{ data.gstPercentage }}%</mat-hint>
|
||||
<mat-error *ngIf="lineCtrl.get('chargesAmount')?.hasError('min')"
|
||||
>Charges cannot be negative</mat-error
|
||||
>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</form>
|
||||
} @else {
|
||||
<form [formGroup]="form" class="dd-form">
|
||||
<!--
|
||||
Purchaser identity is asked for only when the bank has none: a walk-in paying cash. On an
|
||||
account debit the purchaser IS the account holder, already identified and KYC'd, and
|
||||
re-capturing a name and Aadhaar here would invite a mismatch against the account on record.
|
||||
-->
|
||||
@if (!isAccountDebit) {
|
||||
<div class="form-section-title">Walk-in Purchaser Details</div>
|
||||
@if (!isAccountDebit) {
|
||||
<div class="form-section-title">Walk-in Purchaser Details</div>
|
||||
|
||||
<div class="form-row">
|
||||
<mat-form-field appearance="outline" class="form-field-flex">
|
||||
<mat-label>Walk-in Purchaser Name *</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="walkinPurchaserName"
|
||||
placeholder="e.g. RAMESH CHANDRA"
|
||||
style="text-transform: uppercase;"
|
||||
/>
|
||||
<mat-error *ngIf="form.get('walkinPurchaserName')?.hasError('required')"
|
||||
>Purchaser name is required</mat-error
|
||||
>
|
||||
<mat-error *ngIf="form.get('walkinPurchaserName')?.hasError('minlength')"
|
||||
>Must be at least 3 characters</mat-error
|
||||
>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="form-field-flex">
|
||||
<mat-label>12-Digit Aadhaar Number *</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="walkinPurchaserAadhaar"
|
||||
placeholder="12-digit Aadhaar"
|
||||
maxlength="12"
|
||||
/>
|
||||
<mat-hint>Masked to last 4 digits before storage</mat-hint>
|
||||
<mat-error *ngIf="form.get('walkinPurchaserAadhaar')?.hasError('required')"
|
||||
>Aadhaar number is required</mat-error
|
||||
>
|
||||
<mat-error *ngIf="form.get('walkinPurchaserAadhaar')?.hasError('pattern')"
|
||||
>Must be exactly 12 numeric digits</mat-error
|
||||
>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Purchaser Address / Location *</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="walkinPurchaserAddress"
|
||||
placeholder="e.g. 14, SECTOR 5, MAIN ROAD, MUMBAI"
|
||||
style="text-transform: uppercase;"
|
||||
/>
|
||||
<mat-error *ngIf="form.get('walkinPurchaserAddress')?.hasError('required')"
|
||||
>Address is required</mat-error
|
||||
>
|
||||
</mat-form-field>
|
||||
}
|
||||
|
||||
<!-- Beneficiary Section -->
|
||||
<div class="form-section-title">Beneficiary & Payment Details</div>
|
||||
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Beneficiary Name (In Favour Of) *</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="beneficiaryName"
|
||||
placeholder="e.g. UNIVERSITY OF MUMBAI"
|
||||
maxlength="120"
|
||||
style="text-transform: uppercase;"
|
||||
/>
|
||||
<mat-error *ngIf="form.get('beneficiaryName')?.hasError('required')"
|
||||
>Beneficiary name is required</mat-error
|
||||
>
|
||||
<mat-error *ngIf="form.get('beneficiaryName')?.hasError('maxlength')">Max 120 characters</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<div class="form-row">
|
||||
<mat-form-field appearance="outline" class="form-field-flex">
|
||||
<mat-label>Walk-in Purchaser Name *</mat-label>
|
||||
<mat-label>Payable At - Branch *</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="walkinPurchaserName"
|
||||
placeholder="e.g. RAMESH CHANDRA"
|
||||
formControlName="payableAtBranch"
|
||||
placeholder="e.g. FORT BRANCH"
|
||||
maxlength="150"
|
||||
style="text-transform: uppercase;"
|
||||
/>
|
||||
<mat-error *ngIf="form.get('walkinPurchaserName')?.hasError('required')"
|
||||
>Purchaser name is required</mat-error
|
||||
>
|
||||
<mat-error *ngIf="form.get('walkinPurchaserName')?.hasError('minlength')"
|
||||
>Must be at least 3 characters</mat-error
|
||||
<mat-error *ngIf="form.get('payableAtBranch')?.hasError('required')"
|
||||
>Payable Branch is required</mat-error
|
||||
>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="form-field-flex">
|
||||
<mat-label>12-Digit Aadhaar Number *</mat-label>
|
||||
<mat-label>Payable At - City *</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="walkinPurchaserAadhaar"
|
||||
placeholder="12-digit Aadhaar"
|
||||
maxlength="12"
|
||||
formControlName="payableAtCity"
|
||||
placeholder="e.g. MUMBAI"
|
||||
maxlength="100"
|
||||
style="text-transform: uppercase;"
|
||||
/>
|
||||
<mat-hint>Masked to last 4 digits before storage</mat-hint>
|
||||
<mat-error *ngIf="form.get('walkinPurchaserAadhaar')?.hasError('required')"
|
||||
>Aadhaar number is required</mat-error
|
||||
>
|
||||
<mat-error *ngIf="form.get('walkinPurchaserAadhaar')?.hasError('pattern')"
|
||||
>Must be exactly 12 numeric digits</mat-error
|
||||
>
|
||||
<mat-error *ngIf="form.get('payableAtCity')?.hasError('required')">Payable City is required</mat-error>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Purchaser Address / Location *</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="walkinPurchaserAddress"
|
||||
placeholder="e.g. 14, SECTOR 5, MAIN ROAD, MUMBAI"
|
||||
style="text-transform: uppercase;"
|
||||
/>
|
||||
<mat-error *ngIf="form.get('walkinPurchaserAddress')?.hasError('required')"
|
||||
>Address is required</mat-error
|
||||
>
|
||||
</mat-form-field>
|
||||
}
|
||||
|
||||
<!-- Beneficiary Section -->
|
||||
<div class="form-section-title">Beneficiary & Payment Details</div>
|
||||
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Beneficiary Name (In Favour Of) *</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="beneficiaryName"
|
||||
placeholder="e.g. UNIVERSITY OF MUMBAI"
|
||||
maxlength="120"
|
||||
style="text-transform: uppercase;"
|
||||
/>
|
||||
<mat-error *ngIf="form.get('beneficiaryName')?.hasError('required')"
|
||||
>Beneficiary name is required</mat-error
|
||||
>
|
||||
<mat-error *ngIf="form.get('beneficiaryName')?.hasError('maxlength')">Max 120 characters</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<div class="form-row">
|
||||
<mat-form-field appearance="outline" class="form-field-flex">
|
||||
<mat-label>Payable At - Branch *</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="payableAtBranch"
|
||||
placeholder="e.g. FORT BRANCH"
|
||||
maxlength="150"
|
||||
style="text-transform: uppercase;"
|
||||
/>
|
||||
<mat-error *ngIf="form.get('payableAtBranch')?.hasError('required')"
|
||||
>Payable Branch is required</mat-error
|
||||
>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="form-field-flex">
|
||||
<mat-label>Payable At - City *</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="payableAtCity"
|
||||
placeholder="e.g. MUMBAI"
|
||||
maxlength="100"
|
||||
style="text-transform: uppercase;"
|
||||
/>
|
||||
<mat-error *ngIf="form.get('payableAtCity')?.hasError('required')">Payable City is required</mat-error>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</form>
|
||||
</form>
|
||||
}
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions class="dd-dialog-actions" align="end">
|
||||
@if (isSlip) {
|
||||
<span class="dd-actions-total">
|
||||
{{ lineCount }} drafts · <strong>₹{{ slipTotal | number: '1.2-2' }}</strong> to debit
|
||||
</span>
|
||||
}
|
||||
<button mat-button type="button" (click)="onClose()">Cancel</button>
|
||||
<button mat-flat-button color="primary" [disabled]="form.invalid" (click)="onSubmit()">Save DD Details</button>
|
||||
<button mat-flat-button color="primary" [disabled]="activeForm.invalid" (click)="onSubmit()">
|
||||
{{ isSlip ? 'Save Slip Details' : 'Save DD Details' }}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
</div>
|
||||
`,
|
||||
@@ -393,6 +598,81 @@ export interface CashierIssueDdDialogData {
|
||||
line-height: 1.5;
|
||||
color: #64748b;
|
||||
}
|
||||
/* One card per draft on a slip — the boundary is what stops a clerk typing line 2's
|
||||
beneficiary against line 1's amount. */
|
||||
.dd-line-card {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-left: 3px solid #0369a1;
|
||||
border-radius: 8px;
|
||||
padding: 14px 16px 4px;
|
||||
margin-bottom: 14px;
|
||||
background: #fcfdff;
|
||||
}
|
||||
.dd-line-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.dd-line-badge {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
color: #ffffff;
|
||||
background: #0369a1;
|
||||
padding: 3px 9px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.dd-line-number {
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
color: #475569;
|
||||
}
|
||||
.dd-line-number em {
|
||||
font-style: normal;
|
||||
color: #94a3b8;
|
||||
font-size: 11px;
|
||||
}
|
||||
.dd-line-total {
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #065f46;
|
||||
background: #ecfdf5;
|
||||
border: 1px solid #a7f3d0;
|
||||
padding: 3px 10px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.dd-preview-note {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
margin: 0 0 16px;
|
||||
padding: 9px 12px;
|
||||
background: #fffbeb;
|
||||
border: 1px solid #fde68a;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: #78350f;
|
||||
}
|
||||
.dd-preview-note mat-icon {
|
||||
color: #b45309;
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dd-actions-total {
|
||||
margin-right: auto;
|
||||
font-size: 13px;
|
||||
color: #475569;
|
||||
}
|
||||
.dd-actions-total strong {
|
||||
color: #065f46;
|
||||
}
|
||||
`
|
||||
]
|
||||
})
|
||||
@@ -402,13 +682,127 @@ export class CashierIssueDdDialogComponent implements OnInit {
|
||||
public data: CashierIssueDdDialogData = inject(MAT_DIALOG_DATA);
|
||||
|
||||
form!: FormGroup;
|
||||
/** Built only in slip mode; `form` stays untouched so the single-draft path is unchanged. */
|
||||
slipForm!: FormGroup;
|
||||
|
||||
/** Whether the draft is funded by debiting a member's account rather than by cash at the counter. */
|
||||
get isAccountDebit(): boolean {
|
||||
return this.data.paymentMethod === 'ACCOUNT_DEBIT';
|
||||
}
|
||||
|
||||
/** How many drafts this dialog is capturing — 1 unless the clerk asked for a slip. */
|
||||
get lineCount(): number {
|
||||
const n = Number(this.data.ddCount) || 1;
|
||||
return n > 1 ? n : 1;
|
||||
}
|
||||
|
||||
/** Whether this dialog is capturing a multi-DD slip rather than a single draft. */
|
||||
get isSlip(): boolean {
|
||||
return this.lineCount > 1;
|
||||
}
|
||||
|
||||
/** Whether the debit lands on a GL account rather than a member's — wording only. */
|
||||
get isGlFunded(): boolean {
|
||||
return !!this.data.glFunded;
|
||||
}
|
||||
|
||||
/** The form the Save button validates against — whichever of the two was built. */
|
||||
get activeForm(): FormGroup {
|
||||
return this.isSlip ? this.slipForm : this.form;
|
||||
}
|
||||
|
||||
get slipLines(): FormArray {
|
||||
return this.slipForm.get('lines') as FormArray;
|
||||
}
|
||||
|
||||
/** The provisional leaflet number shown against a line, if the caller supplied a run of them. */
|
||||
previewNumberAt(index: number): string | null {
|
||||
return this.data.previewDdNumbers?.[index] ?? null;
|
||||
}
|
||||
|
||||
private lineValueAt(index: number, field: string): number {
|
||||
return Number(this.slipLines.at(index)?.get(field)?.value) || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* GST on one line's charge.
|
||||
*
|
||||
* Rounded per line, matching the server, which computes and stores GST against each draft rather
|
||||
* than once for the slip. Summing rounded lines and rounding a summed charge can differ by a
|
||||
* paisa, and it is the per-line figure that ends up on the draft.
|
||||
*/
|
||||
lineGstAt(index: number): number {
|
||||
const charges = this.lineValueAt(index, 'chargesAmount');
|
||||
if (charges <= 0) return 0;
|
||||
return Math.round(charges * (Number(this.data.gstPercentage) || 0)) / 100;
|
||||
}
|
||||
|
||||
/** What one line adds to the debit — its face value, its charge and the GST on that charge. */
|
||||
lineTotalAt(index: number): number {
|
||||
return this.lineValueAt(index, 'faceValue') + this.lineValueAt(index, 'chargesAmount') + this.lineGstAt(index);
|
||||
}
|
||||
|
||||
get slipFaceValue(): number {
|
||||
return this.slipLines.controls.reduce((sum, _c, i) => sum + this.lineValueAt(i, 'faceValue'), 0);
|
||||
}
|
||||
|
||||
get slipCharges(): number {
|
||||
return this.slipLines.controls.reduce((sum, _c, i) => sum + this.lineValueAt(i, 'chargesAmount'), 0);
|
||||
}
|
||||
|
||||
get slipGst(): number {
|
||||
return this.slipLines.controls.reduce((sum, _c, i) => sum + this.lineGstAt(i), 0);
|
||||
}
|
||||
|
||||
/** The figure the account is debited for — rounded up to the whole rupee, as the single flow does. */
|
||||
get slipTotal(): number {
|
||||
return Math.ceil(this.slipFaceValue + this.slipCharges + this.slipGst - 1e-9);
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
if (this.isSlip) {
|
||||
this.slipForm = this.fb.group({
|
||||
lines: this.fb.array(
|
||||
Array.from({ length: this.lineCount }, () =>
|
||||
this.fb.group({
|
||||
beneficiaryName: [
|
||||
'',
|
||||
[
|
||||
Validators.required,
|
||||
Validators.maxLength(120)
|
||||
]
|
||||
],
|
||||
payableAtBranch: [
|
||||
'',
|
||||
[Validators.required]
|
||||
],
|
||||
payableAtCity: [
|
||||
'',
|
||||
[Validators.required]
|
||||
],
|
||||
// Zero is rejected rather than merely warned about: the server refuses a line with a
|
||||
// face value at or below zero, and a slip that fails there has already cost the clerk
|
||||
// every other line they typed.
|
||||
faceValue: [
|
||||
null as
|
||||
| number
|
||||
| null,
|
||||
[
|
||||
Validators.required,
|
||||
Validators.min(0.01)
|
||||
]
|
||||
],
|
||||
chargesAmount: [
|
||||
0,
|
||||
[Validators.min(0)]
|
||||
]
|
||||
})
|
||||
)
|
||||
)
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Walk-in identity is required for cash and rejected outright for an account debit — the server
|
||||
// refuses a walk-in name alongside an account — so the controls are only built when they apply.
|
||||
// Registering them unconditionally and merely hiding them would hold the form invalid on a
|
||||
@@ -475,7 +869,25 @@ export class CashierIssueDdDialogComponent implements OnInit {
|
||||
}
|
||||
|
||||
onSubmit(): void {
|
||||
if (this.form.invalid) return;
|
||||
if (this.activeForm.invalid) return;
|
||||
|
||||
// A slip closes with its lines. There is no walk-in branch here: a slip is only ever offered on
|
||||
// a member-account debit, where the purchaser is the account holder already on record.
|
||||
if (this.isSlip) {
|
||||
const lines: DdSlipLineResult[] = this.slipLines.controls.map((c) => {
|
||||
const v = c.value;
|
||||
return {
|
||||
beneficiaryName: (v.beneficiaryName || '').trim().toUpperCase(),
|
||||
payableAtBranch: (v.payableAtBranch || '').trim().toUpperCase(),
|
||||
payableAtCity: (v.payableAtCity || '').trim().toUpperCase(),
|
||||
faceValue: Number(v.faceValue) || 0,
|
||||
chargesAmount: Number(v.chargesAmount) || 0
|
||||
};
|
||||
});
|
||||
this.dialogRef.close({ lines });
|
||||
return;
|
||||
}
|
||||
|
||||
const val = this.form.value;
|
||||
const beneficiary = {
|
||||
beneficiaryName: (val.beneficiaryName || '').trim().toUpperCase(),
|
||||
|
||||
@@ -166,23 +166,50 @@
|
||||
</div>
|
||||
<div class="ws-f ws-f--actyp">
|
||||
<label class="ws-f__lbl" title="Account Type">AC. TP.</label>
|
||||
<select
|
||||
class="ws-f__ctl"
|
||||
[(ngModel)]="row.acTyp"
|
||||
(click)="$event.stopPropagation()"
|
||||
(change)="
|
||||
row.acTyp === 'GL' &&
|
||||
row.typ !== 'Disbursement' &&
|
||||
row.typ !== 'Fund Transfer' &&
|
||||
row.typ !== 'Enter Bank Transfer' &&
|
||||
(row.dbCr = 'Cr')
|
||||
"
|
||||
>
|
||||
@for (a of acTypOptionsFor(row); track a) {
|
||||
<option [value]="a">{{ a }}</option>
|
||||
}
|
||||
</select>
|
||||
@if (acTypLockedByToken && row === selectedRow) {
|
||||
<span
|
||||
class="ws-f__ctl ws-f__ctl--locked"
|
||||
title="Locked — filled from a token-driven request, not a normal account entry"
|
||||
>{{ acTypLockedLabel }}</span
|
||||
>
|
||||
} @else {
|
||||
<select
|
||||
class="ws-f__ctl"
|
||||
[(ngModel)]="row.acTyp"
|
||||
(click)="$event.stopPropagation()"
|
||||
(change)="
|
||||
row.acTyp === 'GL' &&
|
||||
row.typ !== 'Disbursement' &&
|
||||
row.typ !== 'Fund Transfer' &&
|
||||
row.typ !== 'Enter Bank Transfer' &&
|
||||
(row.dbCr = 'Cr')
|
||||
"
|
||||
>
|
||||
@for (a of acTypOptionsFor(row); track a) {
|
||||
<option [value]="a">{{ a }}</option>
|
||||
}
|
||||
</select>
|
||||
}
|
||||
</div>
|
||||
@if (showsEntryType(row)) {
|
||||
<div class="ws-f ws-f--paytype">
|
||||
<label
|
||||
class="ws-f__lbl ws-f__lbl--accent"
|
||||
title="What this money was actually collected for — checked before a share application or DD issuance can adopt this voucher"
|
||||
>ENTRY TYPE</label
|
||||
>
|
||||
<select
|
||||
class="ws-f__ctl ws-f__ctl--accent"
|
||||
[(ngModel)]="row.entryTypeCvId"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
<option [ngValue]="null">Select…</option>
|
||||
@for (t of entryTypeOptions; track t.id) {
|
||||
<option [ngValue]="t.id">{{ t.name }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -206,14 +233,20 @@
|
||||
? ddGlMapping?.ddPayableGlCode || 'Auto GL'
|
||||
: 'Account number'
|
||||
"
|
||||
[readonly]="row.typ === 'DD' && !isDdAccountDebitRow(row)"
|
||||
[class.ws-f__ctl--readonly]="row.typ === 'DD' && !isDdAccountDebitRow(row)"
|
||||
[readonly]="
|
||||
(row.typ === 'DD' && !isDdAccountDebitRow(row)) || (acTypLockedByToken && row === selectedRow)
|
||||
"
|
||||
[class.ws-f__ctl--readonly]="
|
||||
(row.typ === 'DD' && !isDdAccountDebitRow(row)) || (acTypLockedByToken && row === selectedRow)
|
||||
"
|
||||
[title]="
|
||||
isDdAccountDebitRow(row)
|
||||
? 'Member account the draft is funded from'
|
||||
: row.typ === 'DD'
|
||||
? 'Configured Sponsor DD Payable GL (Auto-selected)'
|
||||
: 'Account number'
|
||||
acTypLockedByToken && row === selectedRow
|
||||
? 'Locked — filled from a token-driven request, not a normal account entry'
|
||||
: isDdAccountDebitRow(row)
|
||||
? 'Member account the draft is funded from'
|
||||
: row.typ === 'DD'
|
||||
? 'Configured Sponsor DD Payable GL (Auto-selected)'
|
||||
: 'Account number'
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
@@ -286,9 +319,13 @@
|
||||
[value]="row.amount ?? ''"
|
||||
(input)="row.amount = +$any($event.target).value; syncShareLinkedAmounts()"
|
||||
(click)="$event.stopPropagation()"
|
||||
[placeholder]="row.acTyp === 'SHR' ? '0' : '0.00'"
|
||||
[readonly]="!isClerk && !!(activeWithdrawalRequest || activeClosureRequest)"
|
||||
[class.ws-amt-input--locked]="!isClerk && !!(activeWithdrawalRequest || activeClosureRequest)"
|
||||
[placeholder]="isMultiDdRow(row) ? 'Per draft' : row.acTyp === 'SHR' ? '0' : '0.00'"
|
||||
[readonly]="
|
||||
isMultiDdRow(row) || (!isClerk && !!(activeWithdrawalRequest || activeClosureRequest))
|
||||
"
|
||||
[class.ws-amt-input--locked]="
|
||||
isMultiDdRow(row) || (!isClerk && !!(activeWithdrawalRequest || activeClosureRequest))
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
@if (row.acTyp === 'SHR' && rowSharePrices[row.acNo]) {
|
||||
@@ -298,6 +335,19 @@
|
||||
}}/share</span
|
||||
>
|
||||
}
|
||||
<!-- On a slip the face value belongs to each draft, so the row total is the sum of
|
||||
them and is filled in by the dialog rather than typed here. A clerk-typed
|
||||
figure could disagree with the drafts, and the account would be debited for
|
||||
a total the drafts do not add up to. -->
|
||||
@if (isMultiDdRow(row)) {
|
||||
<span class="ws-f__hint">
|
||||
{{
|
||||
row.ddLines?.length
|
||||
? 'Total of ' + row.ddLines?.length + ' drafts'
|
||||
: 'Set per draft in Fill DD Details'
|
||||
}}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
@if (row.acTyp === 'SHR' && (row.typ === 'Enter Bank Transfer' || row.typ === 'Fund Transfer')) {
|
||||
<div class="ws-f ws-f--cheqno">
|
||||
@@ -377,9 +427,36 @@
|
||||
<div class="ws-group ws-group--flex">
|
||||
<span class="ws-caption">DD Charges & GST — Admin Rate {{ ddGstPercentage }}%</span>
|
||||
<div class="ws-group__fields">
|
||||
<!-- How many drafts this one debit funds. Typed before the details are filled in,
|
||||
because it is what decides how many beneficiary forms the dialog presents.
|
||||
Offered only where the server can honour it: a member savings/current
|
||||
account debit, never a GL or cash draft. -->
|
||||
@if (canRowBeMultiDd(row)) {
|
||||
<div class="ws-f ws-f--amt">
|
||||
<label class="ws-f__lbl" title="Number of demand drafts to raise against this single debit"
|
||||
>NO. OF DD</label
|
||||
>
|
||||
<div class="ws-amt-wrap">
|
||||
<input
|
||||
class="ws-f__ctl ws-amt-input"
|
||||
type="number"
|
||||
min="1"
|
||||
[max]="maxDdSlipLines"
|
||||
step="1"
|
||||
[value]="ddCountFor(row)"
|
||||
(input)="onDdCountChange(row, +$any($event.target).value)"
|
||||
(click)="$event.stopPropagation()"
|
||||
placeholder="1"
|
||||
/>
|
||||
</div>
|
||||
@if (isMultiDdRow(row)) {
|
||||
<span class="ws-f__hint">One debit · {{ ddCountFor(row) }} drafts</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<div class="ws-f ws-f--amt">
|
||||
<label class="ws-f__lbl" title="DD issuance charges / commission">CHARGES</label>
|
||||
<div class="ws-amt-wrap">
|
||||
<div class="ws-amt-wrap" [class.ws-amt-wrap--locked]="isMultiDdRow(row)">
|
||||
<span class="ws-amt-prefix">₹</span>
|
||||
<input
|
||||
class="ws-f__ctl ws-amt-input"
|
||||
@@ -388,8 +465,15 @@
|
||||
(input)="row.ddCharges = +$any($event.target).value"
|
||||
(click)="$event.stopPropagation()"
|
||||
placeholder="0.00"
|
||||
[readonly]="isMultiDdRow(row)"
|
||||
[class.ws-amt-input--locked]="isMultiDdRow(row)"
|
||||
/>
|
||||
</div>
|
||||
<!-- Charges are levied per draft and can differ between them, so on a slip they
|
||||
are entered against each draft and totalled here. -->
|
||||
@if (isMultiDdRow(row)) {
|
||||
<span class="ws-f__hint">Set per draft</span>
|
||||
}
|
||||
</div>
|
||||
<div class="ws-f ws-f--amt">
|
||||
<label class="ws-f__lbl" title="GST on charges, at the admin-configured rate">
|
||||
@@ -400,7 +484,7 @@
|
||||
<input
|
||||
class="ws-f__ctl ws-amt-input ws-amt-input--locked"
|
||||
type="text"
|
||||
[value]="ddGstAmountFor(row) | number: '1.2-2'"
|
||||
[value]="ddGstDisplayFor(row)"
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
@@ -414,7 +498,7 @@
|
||||
<input
|
||||
class="ws-f__ctl ws-amt-input ws-amt-input--locked"
|
||||
type="text"
|
||||
[value]="ddTotalPayableFor(row)"
|
||||
[value]="ddTotalDisplayFor(row)"
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
@@ -427,18 +511,27 @@
|
||||
[disabled]="saving"
|
||||
(click)="openIssueWalkinDd(row); $event.stopPropagation()"
|
||||
[title]="
|
||||
isDdAccountDebitRow(row)
|
||||
? 'Auto-selects next available leaflet and opens the beneficiary form'
|
||||
: 'Auto-selects next available leaflet and opens walk-in purchaser form'
|
||||
isMultiDdRow(row)
|
||||
? 'Opens one beneficiary form per draft; DD numbers are allocated on submission'
|
||||
: isDdAccountDebitRow(row)
|
||||
? 'Auto-selects next available leaflet and opens the beneficiary form'
|
||||
: 'Auto-selects next available leaflet and opens walk-in purchaser form'
|
||||
"
|
||||
>
|
||||
<mat-icon style="font-size: 18px; width: 18px; height: 18px">{{
|
||||
row.ddDetails ? 'edit_note' : 'post_add'
|
||||
row.ddDetails || row.ddLines?.length ? 'edit_note' : 'post_add'
|
||||
}}</mat-icon>
|
||||
<!-- Names the leaflet once details are captured, so the clerk can see at a
|
||||
glance which rows are ready before pressing Save. -->
|
||||
glance which rows are ready before pressing Save. A slip names the count
|
||||
instead: its numbers are not allocated until it is submitted. -->
|
||||
<span>{{
|
||||
row.ddDetails ? 'DD ' + row.ddDetails.ddNumber + ' · Edit Details' : 'Fill DD Details'
|
||||
row.ddLines?.length
|
||||
? row.ddLines?.length + ' Drafts Captured · Edit Slip'
|
||||
: row.ddDetails
|
||||
? 'DD ' + row.ddDetails.ddNumber + ' · Edit Details'
|
||||
: isMultiDdRow(row)
|
||||
? 'Fill ' + ddCountFor(row) + ' DD Details'
|
||||
: 'Fill DD Details'
|
||||
}}</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -1523,23 +1616,50 @@
|
||||
</div>
|
||||
<div class="ws-f ws-f--actyp">
|
||||
<label class="ws-f__lbl">AC. TP.</label>
|
||||
<select
|
||||
class="ws-f__ctl"
|
||||
[(ngModel)]="row.acTyp"
|
||||
(click)="$event.stopPropagation()"
|
||||
(change)="
|
||||
row.acTyp === 'GL' &&
|
||||
row.typ !== 'Disbursement' &&
|
||||
row.typ !== 'Fund Transfer' &&
|
||||
row.typ !== 'Enter Bank Transfer' &&
|
||||
(row.dbCr = 'Cr')
|
||||
"
|
||||
>
|
||||
@for (a of acTypOptionsFor(row); track a) {
|
||||
<option [value]="a">{{ a }}</option>
|
||||
}
|
||||
</select>
|
||||
@if (acTypLockedByToken && row === selectedRow) {
|
||||
<span
|
||||
class="ws-f__ctl ws-f__ctl--locked"
|
||||
title="Locked — filled from a token-driven request, not a normal account entry"
|
||||
>{{ acTypLockedLabel }}</span
|
||||
>
|
||||
} @else {
|
||||
<select
|
||||
class="ws-f__ctl"
|
||||
[(ngModel)]="row.acTyp"
|
||||
(click)="$event.stopPropagation()"
|
||||
(change)="
|
||||
row.acTyp === 'GL' &&
|
||||
row.typ !== 'Disbursement' &&
|
||||
row.typ !== 'Fund Transfer' &&
|
||||
row.typ !== 'Enter Bank Transfer' &&
|
||||
(row.dbCr = 'Cr')
|
||||
"
|
||||
>
|
||||
@for (a of acTypOptionsFor(row); track a) {
|
||||
<option [value]="a">{{ a }}</option>
|
||||
}
|
||||
</select>
|
||||
}
|
||||
</div>
|
||||
@if (showsEntryType(row)) {
|
||||
<div class="ws-f ws-f--paytype">
|
||||
<label
|
||||
class="ws-f__lbl ws-f__lbl--accent"
|
||||
title="What this money was actually collected for — checked before a share application or DD issuance can adopt this voucher"
|
||||
>ENTRY TYPE</label
|
||||
>
|
||||
<select
|
||||
class="ws-f__ctl ws-f__ctl--accent"
|
||||
[(ngModel)]="row.entryTypeCvId"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
<option [ngValue]="null">Select…</option>
|
||||
@for (t of entryTypeOptions; track t.id) {
|
||||
<option [ngValue]="t.id">{{ t.name }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="ws-vdivider"></div>
|
||||
@@ -1556,10 +1676,16 @@
|
||||
[placeholder]="
|
||||
row.typ === 'DD' ? ddGlMapping?.ddPayableGlCode || 'Auto GL' : 'Account number'
|
||||
"
|
||||
[readonly]="row.typ === 'DD'"
|
||||
[class.ws-f__ctl--readonly]="row.typ === 'DD'"
|
||||
[readonly]="row.typ === 'DD' || (acTypLockedByToken && row === selectedRow)"
|
||||
[class.ws-f__ctl--readonly]="
|
||||
row.typ === 'DD' || (acTypLockedByToken && row === selectedRow)
|
||||
"
|
||||
[title]="
|
||||
row.typ === 'DD' ? 'Configured Sponsor DD Payable GL (Auto-selected)' : 'Account number'
|
||||
acTypLockedByToken && row === selectedRow
|
||||
? 'Locked — filled from a token-driven request, not a normal account entry'
|
||||
: row.typ === 'DD'
|
||||
? 'Configured Sponsor DD Payable GL (Auto-selected)'
|
||||
: 'Account number'
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
@@ -1628,10 +1754,12 @@
|
||||
[value]="row.amount ?? ''"
|
||||
(input)="row.amount = +$any($event.target).value; syncShareLinkedAmounts()"
|
||||
(click)="$event.stopPropagation()"
|
||||
[placeholder]="row.acTyp === 'SHR' ? '0' : '0.00'"
|
||||
[readonly]="!isClerk && !!(activeWithdrawalRequest || activeClosureRequest)"
|
||||
[placeholder]="isMultiDdRow(row) ? 'Per draft' : row.acTyp === 'SHR' ? '0' : '0.00'"
|
||||
[readonly]="
|
||||
isMultiDdRow(row) || (!isClerk && !!(activeWithdrawalRequest || activeClosureRequest))
|
||||
"
|
||||
[class.ws-amt-input--locked]="
|
||||
!isClerk && !!(activeWithdrawalRequest || activeClosureRequest)
|
||||
isMultiDdRow(row) || (!isClerk && !!(activeWithdrawalRequest || activeClosureRequest))
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
@@ -1642,6 +1770,19 @@
|
||||
}}/share</span
|
||||
>
|
||||
}
|
||||
<!-- On a slip the face value belongs to each draft, so the row total is the
|
||||
sum of them and is filled in by the dialog rather than typed here. A
|
||||
clerk-typed figure could disagree with the drafts, and the account would
|
||||
be debited for a total the drafts do not add up to. -->
|
||||
@if (isMultiDdRow(row)) {
|
||||
<span class="ws-f__hint">
|
||||
{{
|
||||
row.ddLines?.length
|
||||
? 'Total of ' + row.ddLines?.length + ' drafts'
|
||||
: 'Set per draft in Fill DD Details'
|
||||
}}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
@if (
|
||||
row.acTyp === 'SHR' && (row.typ === 'Enter Bank Transfer' || row.typ === 'Fund Transfer')
|
||||
@@ -1706,9 +1847,38 @@
|
||||
<div class="ws-group ws-group--flex">
|
||||
<span class="ws-caption">DD Charges & GST — Admin Rate {{ ddGstPercentage }}%</span>
|
||||
<div class="ws-group__fields">
|
||||
<!-- How many drafts this one debit funds. Typed before the details are filled
|
||||
in, because it is what decides how many beneficiary forms the dialog
|
||||
presents. Offered only where the server can honour it: a member
|
||||
savings/current account debit, never a GL or cash draft. -->
|
||||
@if (canRowBeMultiDd(row)) {
|
||||
<div class="ws-f ws-f--amt">
|
||||
<label
|
||||
class="ws-f__lbl"
|
||||
title="Number of demand drafts to raise against this single debit"
|
||||
>NO. OF DD</label
|
||||
>
|
||||
<div class="ws-amt-wrap">
|
||||
<input
|
||||
class="ws-f__ctl ws-amt-input"
|
||||
type="number"
|
||||
min="1"
|
||||
[max]="maxDdSlipLines"
|
||||
step="1"
|
||||
[value]="ddCountFor(row)"
|
||||
(input)="onDdCountChange(row, +$any($event.target).value)"
|
||||
(click)="$event.stopPropagation()"
|
||||
placeholder="1"
|
||||
/>
|
||||
</div>
|
||||
@if (isMultiDdRow(row)) {
|
||||
<span class="ws-f__hint">One debit · {{ ddCountFor(row) }} drafts</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<div class="ws-f ws-f--amt">
|
||||
<label class="ws-f__lbl" title="DD issuance charges / commission">CHARGES</label>
|
||||
<div class="ws-amt-wrap">
|
||||
<div class="ws-amt-wrap" [class.ws-amt-wrap--locked]="isMultiDdRow(row)">
|
||||
<span class="ws-amt-prefix">₹</span>
|
||||
<input
|
||||
class="ws-f__ctl ws-amt-input"
|
||||
@@ -1717,8 +1887,15 @@
|
||||
(input)="row.ddCharges = +$any($event.target).value"
|
||||
(click)="$event.stopPropagation()"
|
||||
placeholder="0.00"
|
||||
[readonly]="isMultiDdRow(row)"
|
||||
[class.ws-amt-input--locked]="isMultiDdRow(row)"
|
||||
/>
|
||||
</div>
|
||||
<!-- Charges are levied per draft and can differ between them, so on a slip
|
||||
they are entered against each draft and totalled here. -->
|
||||
@if (isMultiDdRow(row)) {
|
||||
<span class="ws-f__hint">Set per draft</span>
|
||||
}
|
||||
</div>
|
||||
<div class="ws-f ws-f--amt">
|
||||
<label class="ws-f__lbl" title="GST on charges, at the admin-configured rate">
|
||||
@@ -1729,7 +1906,7 @@
|
||||
<input
|
||||
class="ws-f__ctl ws-amt-input ws-amt-input--locked"
|
||||
type="text"
|
||||
[value]="ddGstAmountFor(row) | number: '1.2-2'"
|
||||
[value]="ddGstDisplayFor(row)"
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
@@ -1743,7 +1920,7 @@
|
||||
<input
|
||||
class="ws-f__ctl ws-amt-input ws-amt-input--locked"
|
||||
type="text"
|
||||
[value]="ddTotalPayableFor(row)"
|
||||
[value]="ddTotalDisplayFor(row)"
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
@@ -1756,13 +1933,28 @@
|
||||
[disabled]="saving"
|
||||
(click)="openIssueWalkinDd(row); $event.stopPropagation()"
|
||||
[title]="
|
||||
isDdAccountDebitRow(row)
|
||||
? 'Auto-selects next available leaflet and opens the beneficiary form'
|
||||
: 'Auto-selects next available leaflet and opens walk-in purchaser form'
|
||||
isMultiDdRow(row)
|
||||
? 'Opens one beneficiary form per draft; DD numbers are allocated on submission'
|
||||
: isDdAccountDebitRow(row)
|
||||
? 'Auto-selects next available leaflet and opens the beneficiary form'
|
||||
: 'Auto-selects next available leaflet and opens walk-in purchaser form'
|
||||
"
|
||||
>
|
||||
<mat-icon style="font-size: 18px; width: 18px; height: 18px">post_add</mat-icon>
|
||||
<span>Fill DD Details</span>
|
||||
<mat-icon style="font-size: 18px; width: 18px; height: 18px">{{
|
||||
row.ddDetails || row.ddLines?.length ? 'edit_note' : 'post_add'
|
||||
}}</mat-icon>
|
||||
<!-- Names what has been captured, so the clerk can see at a glance which
|
||||
rows are ready before pressing Save. A slip names its count instead:
|
||||
its numbers are not allocated until it is submitted. -->
|
||||
<span>{{
|
||||
row.ddLines?.length
|
||||
? row.ddLines?.length + ' Drafts Captured · Edit Slip'
|
||||
: row.ddDetails
|
||||
? 'DD ' + row.ddDetails.ddNumber + ' · Edit Details'
|
||||
: isMultiDdRow(row)
|
||||
? 'Fill ' + ddCountFor(row) + ' DD Details'
|
||||
: 'Fill DD Details'
|
||||
}}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -916,6 +916,20 @@ input.ws-f__ctl--readonly {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* AC.TP shown as a locked label instead of the normal editable select, while the row is filled
|
||||
from a token-driven request whose real account type row.acTyp cannot show (see
|
||||
acTypLockedByToken in the component). */
|
||||
span.ws-f__ctl--locked {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
cursor: not-allowed;
|
||||
user-select: none;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
input.ws-amt-input--locked {
|
||||
background: #fef9c3;
|
||||
color: #713f12;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1142,6 +1142,46 @@
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Risk profile. These are determinations about the customer rather than about anything they
|
||||
transacted, and the re-rating policy reads them directly: a PEP or a sanctions match holds the
|
||||
rating at High however ordinary the period's activity looks, and the declared income is what
|
||||
turnover is measured against. Until they are recorded the policy has nothing to test. -->
|
||||
<div class="full-span kyc-doc-fields">
|
||||
<div class="kyc-doc-fields__head">
|
||||
<h4 class="kyc-doc-fields__title">Risk Profile</h4>
|
||||
<span class="kyc-doc-fields__hint">Used by the periodic risk review</span>
|
||||
</div>
|
||||
|
||||
<div class="kyc-risk-flags">
|
||||
<mat-checkbox formControlName="isPep">
|
||||
Politically Exposed Person
|
||||
<span class="kyc-risk-hint">Holds the rating at High; never lowered on ordinary activity</span>
|
||||
</mat-checkbox>
|
||||
<mat-checkbox formControlName="sanctionsMatch">
|
||||
Sanctions / watch-list match
|
||||
<span class="kyc-risk-hint">UNSC, MHA or internal watch list</span>
|
||||
</mat-checkbox>
|
||||
<mat-checkbox formControlName="adverseMediaMatch">
|
||||
Adverse media / regulatory action
|
||||
<span class="kyc-risk-hint">FIR, court proceedings or published action</span>
|
||||
</mat-checkbox>
|
||||
</div>
|
||||
|
||||
<div class="kyc-doc-fields__grid">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Declared annual income (₹)</mat-label>
|
||||
<input matInput type="number" min="0" formControlName="declaredAnnualIncome" />
|
||||
<mat-hint>As stated by the customer</mat-hint>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Declared annual turnover (₹)</mat-label>
|
||||
<input matInput type="number" min="0" formControlName="declaredAnnualTurnover" />
|
||||
<mat-hint>For a business customer</mat-hint>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Officially Valid Document. Shown when one is on file or the alert is asking for
|
||||
it, so re-collecting an expired document is done on this form rather than
|
||||
sending the officer somewhere else. -->
|
||||
|
||||
@@ -1807,3 +1807,28 @@
|
||||
color: #15803d;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Risk profile ────────────────────────────────────────────────────────────
|
||||
Determinations about the customer, which the periodic risk review reads directly. Grouped with
|
||||
the document block so the form reads as "what we know about them" rather than scattering a PEP
|
||||
flag among the identifier fields. */
|
||||
|
||||
.kyc-doc-fields__hint {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.kyc-risk-flags {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 4px 0 14px;
|
||||
}
|
||||
|
||||
/* Sits under its checkbox label: the consequence of ticking the box matters more than the box. */
|
||||
.kyc-risk-hint {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
@@ -140,6 +140,13 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
||||
1,
|
||||
[Validators.required]
|
||||
],
|
||||
// Risk profile. Determinations about the customer, which the periodic risk review reads directly - a PEP or a
|
||||
// sanctions match holds the rating at High whatever the period's activity looks like.
|
||||
isPep: [false],
|
||||
sanctionsMatch: [false],
|
||||
adverseMediaMatch: [false],
|
||||
declaredAnnualIncome: [null],
|
||||
declaredAnnualTurnover: [null],
|
||||
// The officially valid document, so a lapsed one can be re-collected on this form. The
|
||||
// backend has always accepted these four; only the fields were missing.
|
||||
kycDocumentName: [''],
|
||||
@@ -224,6 +231,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.
|
||||
@@ -449,6 +461,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;
|
||||
@@ -487,7 +521,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) {
|
||||
@@ -533,6 +568,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()
|
||||
};
|
||||
|
||||
@@ -732,7 +768,8 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
||||
dateFormat,
|
||||
riskCategory: this.kycForm.value.riskCategory || 1,
|
||||
...declarationFields,
|
||||
...this.buildExceptionFields()
|
||||
...this.buildRiskProfileFields(),
|
||||
...this.buildExceptionFields()
|
||||
};
|
||||
|
||||
this.clientsService
|
||||
@@ -892,7 +929,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,
|
||||
@@ -1339,72 +1300,79 @@
|
||||
<!-- Share Operations -->
|
||||
<div class="deposit-menu-group">
|
||||
<div class="deposit-group-title">Share Operations</div>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/nominal-shares']">
|
||||
<fa-icon icon="coins" class="menu-item-icon"></fa-icon> Nominal Share Report
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/share-wise']">
|
||||
<fa-icon icon="file-alt" class="menu-item-icon"></fa-icon> Share Wise Report
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/guarantor-detail']">
|
||||
<fa-icon icon="user-shield" class="menu-item-icon"></fa-icon> Guarantor Detail
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/member-interest']">
|
||||
<fa-icon icon="calculator" class="menu-item-icon"></fa-icon> Total Int Rec
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/enquiry']">
|
||||
<fa-icon icon="search" class="menu-item-icon"></fa-icon> Share Enquiry
|
||||
</button>
|
||||
</div>
|
||||
<!-- Share Documents -->
|
||||
<div class="deposit-menu-group">
|
||||
<div class="deposit-group-title">Share Documents</div>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/payment-certificate']">
|
||||
<fa-icon icon="file-signature" class="menu-item-icon"></fa-icon> Share Payment Certificate
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/share-detail']">
|
||||
<fa-icon icon="list-alt" class="menu-item-icon"></fa-icon> Share Detail
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/dividend-statement']">
|
||||
<fa-icon icon="dollar-sign" class="menu-item-icon"></fa-icon> Dividend Statement
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/shares/transfers']">
|
||||
<fa-icon icon="exchange" class="menu-item-icon"></fa-icon> Share Transfer
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/folio-statement']">
|
||||
<fa-icon icon="file-alt" class="menu-item-icon"></fa-icon> Folio Statement
|
||||
</button>
|
||||
</div>
|
||||
<!-- Share Reports -->
|
||||
<div class="deposit-menu-group">
|
||||
<div class="deposit-group-title">Share Reports</div>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/final-balance']">
|
||||
<fa-icon icon="chart-line" class="menu-item-icon"></fa-icon> Final Balance
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/account-statement']">
|
||||
<fa-icon icon="file-invoice" class="menu-item-icon"></fa-icon> Account Statement
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/share-issued']">
|
||||
<fa-icon icon="check-circle" class="menu-item-icon"></fa-icon> Share Issued
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/shares/share-holders']">
|
||||
<fa-icon icon="users" class="menu-item-icon"></fa-icon> Share Holder
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/share-status']">
|
||||
<fa-icon icon="info-circle" class="menu-item-icon"></fa-icon> Share Status
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/product-summary']">
|
||||
<fa-icon icon="layer-group" class="menu-item-icon"></fa-icon> Product Summary
|
||||
</button>
|
||||
</div>
|
||||
<!-- Share Admin -->
|
||||
<div class="deposit-menu-group">
|
||||
<div class="deposit-group-title">Share Admin</div>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/distinctive-numbers']">
|
||||
<fa-icon icon="tag" class="menu-item-icon"></fa-icon> Share Devede No Detail
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0"><fa-icon icon="map" class="menu-item-icon"></fa-icon> Area Wise</button>
|
||||
<button mat-menu-item tabindex="0"><fa-icon icon="tag" class="menu-item-icon"></fa-icon> Share Label</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/area-wise']">
|
||||
<fa-icon icon="map" class="menu-item-icon"></fa-icon> Area Wise
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/labels']">
|
||||
<fa-icon icon="tag" class="menu-item-icon"></fa-icon> Share Label
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/letters']">
|
||||
<fa-icon icon="envelope" class="menu-item-icon"></fa-icon> Share Letter
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/letter-mgmt/share-member']">
|
||||
<fa-icon icon="id-card" class="menu-item-icon"></fa-icon> Share Member Details
|
||||
</button>
|
||||
</div>
|
||||
|
||||
+10
-4
@@ -334,14 +334,18 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
|
||||
const frequencyId = this.resolveFrequencyId(this.tenurePeriodUnit);
|
||||
|
||||
if (this.principalAmount && this.principalAmount > 0) {
|
||||
// Convert tenure to months for backend API
|
||||
let tenureInMonths = 0;
|
||||
// Convert tenure to months for backend API. A Days tenure under 30 days rounds down to 0
|
||||
// months, which the backend rejects -- send it as tenureInDays instead in that case.
|
||||
let tenureInMonths: number | undefined;
|
||||
let tenureInDays: number | undefined;
|
||||
if (this.tenurePeriodUnit === 'Years') {
|
||||
tenureInMonths = this.tenureValue * 12;
|
||||
} else if (this.tenurePeriodUnit === 'Months') {
|
||||
tenureInMonths = this.tenureValue;
|
||||
} else {
|
||||
} else if (enteredDays >= 30) {
|
||||
tenureInMonths = Math.floor(enteredDays / 30);
|
||||
} else {
|
||||
tenureInDays = enteredDays;
|
||||
}
|
||||
|
||||
// Map compounding period type id → months (0=Daily, 1=Daily, 4=Monthly, 5=Quarterly, 6=SemiAnnual, 7=Annual)
|
||||
@@ -395,6 +399,7 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
|
||||
principalAmount: this.principalAmount,
|
||||
annualInterestRate: rate,
|
||||
tenureInMonths,
|
||||
tenureInDays,
|
||||
interestCompoundingPeriodInMonths: compMonths, // 0 = daily, now supported by backend
|
||||
interestPostingPeriodInMonths: postMonths,
|
||||
submittedOnDate: submittedOnDateStr,
|
||||
@@ -417,7 +422,8 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
|
||||
Math.round(this.principalAmount * Math.pow(1 + r / daysInYearVal, enteredDays) * 100) / 100;
|
||||
} else {
|
||||
const n = 12 / compMonths;
|
||||
const totalCompoundings = Math.floor(tenureInMonths / compMonths);
|
||||
const effectiveMonths = tenureInMonths ?? enteredDays / 30;
|
||||
const totalCompoundings = Math.floor(effectiveMonths / compMonths);
|
||||
const rPerCompounding = rate / (n * 100);
|
||||
result.maturityAmount =
|
||||
Math.round(this.principalAmount * Math.pow(1 + rPerCompounding, totalCompoundings) * 100) / 100;
|
||||
|
||||
@@ -133,7 +133,8 @@ export class FixedDepositsService {
|
||||
calculateFixedDepositInterest(params: {
|
||||
principalAmount: number;
|
||||
annualInterestRate: number;
|
||||
tenureInMonths: number;
|
||||
tenureInMonths?: number;
|
||||
tenureInDays?: number; // alternative to tenureInMonths, for sub-month FD Single tenures
|
||||
interestCompoundingPeriodInMonths: number;
|
||||
interestPostingPeriodInMonths: number;
|
||||
submittedOnDate?: string; // yyyy-MM-dd — enables exact calendar calculation
|
||||
|
||||
@@ -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 || []));
|
||||
@@ -762,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');
|
||||
@@ -991,9 +982,16 @@ export class FiuComponent implements OnInit {
|
||||
get liveStrFiled(): number {
|
||||
return this.amlStrCases.filter((c) => c.status === 'FILED').length;
|
||||
}
|
||||
/** Still needs PO action: untouched, or concluded suspicious but not yet filed with FIU-IND. */
|
||||
/**
|
||||
* Still needs PO action: anything neither filed with FIU-IND nor concluded clear.
|
||||
*
|
||||
* Counted by what is unfinished rather than by naming the statuses that are. Adding up OPEN and CONCLUDED_SUSPICIOUS
|
||||
* gives the same figure today, but it is the shape that fails quietly: a case in any status those two do not name
|
||||
* drops out of the KPI while still sitting in the queue below it, and the dashboard then under-reports the work.
|
||||
* That is exactly how five undecided alerts hid behind a pending count of 1 on the flagged-transactions screen.
|
||||
*/
|
||||
get liveStrPending(): number {
|
||||
return this.liveStrOpen + this.liveStrAwaitingFiling;
|
||||
return this.amlStrCases.filter((c) => c?.status !== 'FILED' && c?.status !== 'CONCLUDED_CLEAR').length;
|
||||
}
|
||||
get totalCustomers(): number {
|
||||
return this.kc1.length + this.kc2.length;
|
||||
@@ -1022,12 +1020,6 @@ export class FiuComponent implements OnInit {
|
||||
* The STR triage queue: still to decide, or already escalated. Whitelisted alerts are closed
|
||||
* with no report, so they are not STR business — they live in {@link amlWhitelistedAlerts}.
|
||||
*/
|
||||
get amlSuspiciousAlerts(): any[] {
|
||||
return this.behaviouralAlerts.filter(
|
||||
(a) => a.status !== 'WHITELISTED' && (!this.amlAlertStatusFilter || a.status === this.amlAlertStatusFilter)
|
||||
);
|
||||
}
|
||||
|
||||
/** Cleared register: alerts closed as false positives, kept for audit with their evidence. */
|
||||
get amlWhitelistedAlerts(): any[] {
|
||||
return this.behaviouralAlerts.filter((a) => a.status === 'WHITELISTED');
|
||||
|
||||
+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;
|
||||
|
||||
+63
-5
@@ -21,14 +21,31 @@
|
||||
<!-- The outcome, product by product. Which products were skipped and why matters as much as
|
||||
which succeeded, so both are shown rather than just a success count. -->
|
||||
<div class="pl-card dd-panel">
|
||||
<!-- A run that skipped a product is not a clean success: those members get nothing, and saying
|
||||
only "declared" is how that goes unnoticed. The heading states it either way. -->
|
||||
<div class="dd-result-head">
|
||||
<fa-icon icon="check-circle" class="dd-result-ic"></fa-icon>
|
||||
<fa-icon
|
||||
[icon]="hasSkipped ? 'exclamation-triangle' : 'check-circle'"
|
||||
class="dd-result-ic"
|
||||
[class.dd-result-ic--warn]="hasSkipped"
|
||||
></fa-icon>
|
||||
<div>
|
||||
<h3>Dividend declared for {{ result.financialYear }}</h3>
|
||||
<h3>
|
||||
@if (hasSkipped) {
|
||||
Declared for {{ result.financialYear }} — {{ result.skipped.length }}
|
||||
{{ result.skipped.length === 1 ? 'product was' : 'products were' }} skipped
|
||||
} @else {
|
||||
Dividend declared for {{ result.financialYear }}
|
||||
}
|
||||
</h3>
|
||||
<p>
|
||||
{{ result.declarationsCreated }}
|
||||
{{ result.declarationsCreated === 1 ? 'declaration' : 'declarations' }} created. Each still needs approval
|
||||
before members can claim.
|
||||
@if (hasSkipped) {
|
||||
Members of the skipped {{ result.skipped.length === 1 ? 'product' : 'products' }} below will receive
|
||||
nothing for this year — check the reason and declare separately if that is not intended.
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -128,7 +145,22 @@
|
||||
<span class="dd-amount">− {{ preview.totalAmount | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
|
||||
@if (isUnderfunded) {
|
||||
@if (exceedsMaximumRate) {
|
||||
<!-- Checked before the year and funding notes: a rate over the cap is not payable at
|
||||
all, so what it would cost is beside the point until it is lowered. -->
|
||||
<div class="dd-note dd-note--error">
|
||||
{{ declareForm.value.ratePercentage }}% is above the society's maximum of
|
||||
{{ preview.maximumRatePercentage }}%. Lower the rate, or raise the limit in Configurations if the board
|
||||
has resolved a higher one.
|
||||
</div>
|
||||
} @else if (isPeriodNotFinished) {
|
||||
<!-- Shown ahead of the funding check: a year still running cannot be declared at any
|
||||
rate, so the shortfall below it would be advice on a choice that is not available. -->
|
||||
<div class="dd-note dd-note--error">
|
||||
{{ preview.financialYear }} has not finished yet, so its profit is not final. A dividend can only be
|
||||
declared once the financial year is over.
|
||||
</div>
|
||||
} @else if (isUnderfunded) {
|
||||
<div class="dd-note dd-note--error">
|
||||
Short by {{ preview.fundingShortfall | number: '1.2-2' }}. There is not enough in
|
||||
{{ preview.profitGlCode }} to fund this dividend — lower the rate, or close the year's profit first.
|
||||
@@ -146,16 +178,42 @@
|
||||
{{ declareForm.value.ratePercentage || 0 }}% on each member’s share investment
|
||||
</div>
|
||||
|
||||
<!-- The member count sits beside each amount because an amount alone does not say who it
|
||||
reaches. A product paying a plausible total to one member is the case worth catching
|
||||
here, while the rate can still be changed. -->
|
||||
@for (product of eligibleProducts; track product.productId) {
|
||||
<div class="dd-preview-row">
|
||||
<span>{{ product.productName }}</span>
|
||||
<span>
|
||||
{{ product.productName }}
|
||||
<span class="dd-members">
|
||||
{{ product.memberCount }} {{ product.memberCount === 1 ? 'member' : 'members' }}
|
||||
@if (product.accountCount !== product.memberCount) {
|
||||
· {{ product.accountCount }} accounts
|
||||
}
|
||||
</span>
|
||||
</span>
|
||||
<span class="dd-amount">{{ product.amount | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (eligibleProducts.length > 0) {
|
||||
<div class="dd-preview-row dd-preview-row--total">
|
||||
<span>Total to declare</span>
|
||||
<span>
|
||||
Total to declare
|
||||
@if (preview.totalMemberCount) {
|
||||
<!-- Members are distinct people, accounts are what actually gets paid: someone
|
||||
holding shares in two products is one member with two entitlements, each
|
||||
claimed on its own draft. Both are shown so the difference reads as the
|
||||
split it is rather than a miscount. -->
|
||||
<span class="dd-members">
|
||||
{{ preview.totalMemberCount }}
|
||||
{{ preview.totalMemberCount === 1 ? 'member' : 'members' }}
|
||||
@if (preview.totalAccountCount !== preview.totalMemberCount) {
|
||||
· {{ preview.totalAccountCount }} accounts
|
||||
}
|
||||
</span>
|
||||
}
|
||||
</span>
|
||||
<span class="dd-amount">{{ payableTotal | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
+13
@@ -148,6 +148,14 @@ form {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
// Who a product's amount reaches, set quietly beside the product name so it reads as a
|
||||
// qualifier on the amount rather than competing with it.
|
||||
.dd-members {
|
||||
margin-left: 8px;
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.dd-note {
|
||||
margin-top: 12px;
|
||||
padding: 10px 14px;
|
||||
@@ -194,6 +202,11 @@ form {
|
||||
font-size: 26px;
|
||||
color: #16a34a;
|
||||
margin-top: 2px;
|
||||
|
||||
// A run that left a product out is amber, not green: it succeeded, but not for everyone.
|
||||
&--warn {
|
||||
color: #d97706;
|
||||
}
|
||||
}
|
||||
|
||||
.dd-table {
|
||||
|
||||
+47
-4
@@ -73,6 +73,17 @@ export class DeclareDividendComponent implements OnInit {
|
||||
/** Set once the declaration has gone through, so the outcome per product can be shown. */
|
||||
result: any = null;
|
||||
|
||||
/**
|
||||
* Whether any product was left out of the declaration that just ran.
|
||||
*
|
||||
* Drives the outcome heading. A run that skipped a product still returns success — one product
|
||||
* being ineligible is not a reason to lose the others — but those members receive nothing for the
|
||||
* year, so the screen has to say so rather than report a plain "declared".
|
||||
*/
|
||||
get hasSkipped(): boolean {
|
||||
return (this.result?.skipped?.length ?? 0) > 0;
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.declareForm = this.formBuilder.group({
|
||||
financialYear: [
|
||||
@@ -96,9 +107,13 @@ export class DeclareDividendComponent implements OnInit {
|
||||
/**
|
||||
* The financial years a dividend can be declared for: the current one and the five before it.
|
||||
*
|
||||
* The current year is included because a society may declare an interim dividend part-way through,
|
||||
* and five back covers a declaration delayed by an AGM that ran late. Built from the business date
|
||||
* rather than the browser's clock, so the list matches the year the branch is operating in.
|
||||
* The year in progress is listed but cannot be declared — the server rejects it, and the screen
|
||||
* says so as soon as it is picked. It stays on the list so an officer looking for it finds it with
|
||||
* the reason attached, rather than wondering why the year they are in is missing. Five back covers
|
||||
* a declaration delayed by an AGM that ran late.
|
||||
*
|
||||
* Built from the business date rather than the browser's clock, so the list matches the year the
|
||||
* branch is operating in.
|
||||
*/
|
||||
private buildFinancialYears(): void {
|
||||
const businessDate: Date = this.settingsService.businessDate ?? new Date();
|
||||
@@ -171,8 +186,36 @@ export class DeclareDividendComponent implements OnInit {
|
||||
return !!this.preview?.exceedsDistributableProfit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the chosen year is still running.
|
||||
*
|
||||
* A dividend is a share of the year's profit, so it cannot be declared until the year is over and
|
||||
* that profit is final. The server enforces this; the flag simply lets the screen say so before the
|
||||
* officer commits to a rate.
|
||||
*/
|
||||
get isPeriodNotFinished(): boolean {
|
||||
return !!this.preview?.periodNotFinished;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the entered rate is above the society's configured maximum.
|
||||
*
|
||||
* The bye-laws cap what may be paid in a year, so a rate above it needs a board resolution rather
|
||||
* than a typed figure. The server enforces the same limit.
|
||||
*/
|
||||
get exceedsMaximumRate(): boolean {
|
||||
return !!this.preview?.exceedsMaximumRate;
|
||||
}
|
||||
|
||||
get canSubmit(): boolean {
|
||||
return this.declareForm.valid && !this.submitting && this.eligibleProducts.length > 0 && !this.isUnderfunded;
|
||||
return (
|
||||
this.declareForm.valid &&
|
||||
!this.submitting &&
|
||||
this.eligibleProducts.length > 0 &&
|
||||
!this.isUnderfunded &&
|
||||
!this.isPeriodNotFinished &&
|
||||
!this.exceedsMaximumRate
|
||||
);
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
|
||||
+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;
|
||||
}
|
||||
|
||||
+806
-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,58 +752,202 @@ export class AmlFlaggedTransactionsComponent implements OnInit, OnDestroy {
|
||||
this.allAlerts = list;
|
||||
this.recomputeStatusCounts();
|
||||
this.applyStatusFilter();
|
||||
this.pruneSelection();
|
||||
this.lastUpdated = new Date();
|
||||
}
|
||||
|
||||
private applyStatusFilter(): void {
|
||||
this.alerts.data = this.activeStatus
|
||||
? this.allAlerts.filter((a) => a.status === this.activeStatus)
|
||||
: this.allAlerts;
|
||||
/**
|
||||
* @param resetPage
|
||||
* true only when the user changed a filter. A poll must not reset the page: the feed
|
||||
* refreshes every few seconds, and an officer reading page 3 would be thrown back to
|
||||
* page 1 each time. Nor may it touch the paginator before the data source has bound to
|
||||
* it — doing that on the first pass left pageSize at 0 and emptied the table.
|
||||
*/
|
||||
private applyStatusFilter(resetPage = false): void {
|
||||
// The day narrows everything else: the status chips and the severity tiles are asking "of what is in view", and
|
||||
// an officer who has picked a day expects their counts to describe that day rather than the whole feed.
|
||||
const byDay = this.allAlerts.filter((a) => this.inActivePeriod(a));
|
||||
const byStatus = this.activeStatus ? byDay.filter((a) => this.matchesStatus(a, this.activeStatus)) : byDay;
|
||||
const bySeverity =
|
||||
this.activeSeverity === null ? byStatus : byStatus.filter((a) => this.severityBand(a) === this.activeSeverity);
|
||||
// Ranked last, over what the other filters left: "top 10 this month" is the ten busiest rules of that month, not
|
||||
// the ten busiest overall then filtered down to a handful.
|
||||
const keep = this.topRuleCodes(bySeverity);
|
||||
this.alerts.data = keep ? bySeverity.filter((a) => keep.has(a.ruleCode)) : bySeverity;
|
||||
if (!this.alerts.paginator && this.paginator) {
|
||||
this.alerts.paginator = this.paginator;
|
||||
}
|
||||
this.recomputeRiskSummary();
|
||||
if (resetPage && this.alerts.paginator) {
|
||||
// Otherwise a filter applied while on page 2 leaves the table blank on a one-page result.
|
||||
this.alerts.paginator.firstPage();
|
||||
}
|
||||
// Counted before the severity filter, so every tile keeps its real figure and stays clickable.
|
||||
// Counting the filtered rows would zero the other tiles the moment one was picked, leaving no
|
||||
// way back to them.
|
||||
this.recomputeRiskSummary(byStatus);
|
||||
}
|
||||
|
||||
/**
|
||||
* Counted over the rows the status filter leaves visible, not the whole set — a summary that
|
||||
* disagreed with the table underneath it would be worse than no summary.
|
||||
*/
|
||||
private recomputeRiskSummary(): void {
|
||||
const pool = this.alerts.data;
|
||||
private recomputeRiskSummary(pool: any[]): void {
|
||||
const tiers = [
|
||||
{ key: 3, label: 'High Risk', cls: 'risk-high' },
|
||||
{ key: 2, label: 'Medium Risk', cls: 'risk-medium' },
|
||||
{ key: 1, label: 'Low Risk', cls: 'risk-low' }
|
||||
];
|
||||
this.riskSummary = tiers.map((t) => ({
|
||||
key: t.key,
|
||||
label: t.label,
|
||||
cls: t.cls,
|
||||
count: pool.filter((a) => Number(a.riskCategory) === t.key).length
|
||||
count: pool.filter((a) => this.severityBand(a) === t.key).length
|
||||
}));
|
||||
// Anything outside 1-3, including null, so the tiles always reconcile to the total.
|
||||
this.unclassifiedCount = pool.filter(
|
||||
(a) => ![
|
||||
1,
|
||||
2,
|
||||
3
|
||||
].includes(Number(a.riskCategory))
|
||||
).length;
|
||||
// Band 0 is everything ungraded, so the tiles always reconcile to the total.
|
||||
this.unclassifiedCount = pool.filter((a) => this.severityBand(a) === 0).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the status filter leaves, whatever severity tile is in force — so the tiles still
|
||||
* add up to the total while one of them is narrowing the table, and the total reads as the way
|
||||
* back to all of them.
|
||||
*/
|
||||
exporting = false;
|
||||
|
||||
/**
|
||||
* Exports exactly what the table is showing.
|
||||
*
|
||||
* <p>
|
||||
* Built from `alerts.data` rather than from the full feed, so the period, status and severity an officer chose are
|
||||
* the period, status and severity in the file. A workbook that quietly held more than the screen did would be
|
||||
* worse than none: the figures in it would not reconcile with the report it came from, and an officer sending it
|
||||
* on has no way to tell.
|
||||
*
|
||||
* <p>
|
||||
* The filters are written into the subtitle and the file name for the same reason — a sheet that says "10 alerts"
|
||||
* with nothing about which month it covers cannot be checked later by anyone, including the person who made it.
|
||||
*/
|
||||
async exportExcel(): Promise<void> {
|
||||
const rows = this.alerts.data;
|
||||
if (!rows.length || this.exporting) {
|
||||
return;
|
||||
}
|
||||
this.exporting = true;
|
||||
try {
|
||||
const scope = [
|
||||
this.periodLabel ? `Period: ${this.periodLabel}` : 'Period: all time',
|
||||
`Status: ${this.activeStatus ? this.statusLabel(this.activeStatus) : 'all'}`,
|
||||
`Severity: ${this.activeSeverity === null ? 'all' : this.severityLabel(this.activeSeverity)}`,
|
||||
`Rules: ${this.topRules === null ? 'all' : `top ${this.topRules} by frequency`}`
|
||||
].join(' · ');
|
||||
|
||||
await exportWorkbook(
|
||||
[
|
||||
{
|
||||
sheetName: 'Flagged Transactions',
|
||||
title: 'AML — Flagged Transactions',
|
||||
subtitle: `${scope} · ${rows.length} alert${rows.length === 1 ? '' : 's'} · generated ${new Date().toLocaleString('en-IN')}`,
|
||||
headers: [
|
||||
'Alert ID',
|
||||
'Raised On',
|
||||
'Customer',
|
||||
'Customer ID',
|
||||
'Transaction',
|
||||
'Txn ID',
|
||||
'Governing Rule',
|
||||
'Rule Description',
|
||||
'All Rules Matched',
|
||||
'Severity',
|
||||
'Amount',
|
||||
'Status',
|
||||
'Reviewed By',
|
||||
'Review Notes'
|
||||
],
|
||||
rows: rows.map((a) => [
|
||||
a.id ?? null,
|
||||
a.raisedOnDate ? a.raisedOnDate.toLocaleString('en-IN') : '',
|
||||
a.clientDisplayName || '',
|
||||
a.clientId ?? null,
|
||||
a.transactionType || '',
|
||||
a.transactionId ?? null,
|
||||
a.ruleCode || '',
|
||||
a.ruleDescription || '',
|
||||
// Every rule the transaction tripped, not only the one it reports under: the governing rule alone can
|
||||
// hide a high-severity finding that a reader of the sheet would need.
|
||||
this.matchedRules(a)
|
||||
.map((r) => r.ruleCode)
|
||||
.join(', '),
|
||||
this.severityLabel(this.severityBand(a)),
|
||||
Number(a.amount) || 0,
|
||||
a.status || '',
|
||||
a.reviewedByUsername || '',
|
||||
a.reviewNotes || ''
|
||||
]),
|
||||
widths: [
|
||||
9,
|
||||
20,
|
||||
24,
|
||||
12,
|
||||
12,
|
||||
10,
|
||||
30,
|
||||
38,
|
||||
44,
|
||||
11,
|
||||
15,
|
||||
14,
|
||||
14,
|
||||
50
|
||||
]
|
||||
}
|
||||
],
|
||||
`AML-Flagged-${this.periodLabel || 'all-time'}`
|
||||
);
|
||||
} finally {
|
||||
this.exporting = false;
|
||||
}
|
||||
}
|
||||
|
||||
private statusLabel(key: string): string {
|
||||
return this.statusFilters.find((s) => s.key === key)?.label ?? key;
|
||||
}
|
||||
|
||||
get summaryTotal(): number {
|
||||
return this.alerts.data.length;
|
||||
const pool = this.allAlerts.filter((a) => this.inActivePeriod(a));
|
||||
return this.activeStatus ? pool.filter((a) => this.matchesStatus(a, this.activeStatus)).length : pool.length;
|
||||
}
|
||||
|
||||
private recomputeStatusCounts(): void {
|
||||
this.statusCounts = { '': this.allAlerts.length };
|
||||
// Counted within the chosen day, so "Escalated 1" means one escalated that day rather than one in the whole feed.
|
||||
// A chip whose figure describes a different set from the table under it is worse than no figure.
|
||||
const pool = this.allAlerts.filter((a) => this.inActivePeriod(a));
|
||||
this.statusCounts = { '': pool.length };
|
||||
for (const s of this.statusFilters) {
|
||||
if (s.key) {
|
||||
this.statusCounts[s.key] = this.allAlerts.filter((a) => a.status === s.key).length;
|
||||
this.statusCounts[s.key] = pool.filter((a) => this.matchesStatus(a, s.key)).length;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an alert belongs under a status chip.
|
||||
*
|
||||
* Not an equality test, deliberately. Matching the chip's key exactly meant any status the chip list did not name
|
||||
* belonged to no chip at all while still counting toward "All alerts" — so an alert could sit in the feed, need a
|
||||
* decision, and appear nowhere an officer looks for work. That is what UNDER_REVIEW did here: five undecided
|
||||
* alerts, the largest ₹18,00,000, behind a pending figure that read 1.
|
||||
*
|
||||
* Pending is therefore defined by what has not been decided rather than by one status name. Any status that is
|
||||
* neither whitelisted nor escalated surfaces under Raised on its own, which is the safe direction to fail — an
|
||||
* alert shown as work that turns out to be finished costs a glance; one hidden costs a filing.
|
||||
*/
|
||||
private matchesStatus(alert: any, key: string): boolean {
|
||||
if (key === 'RAISED') {
|
||||
return alert?.status !== 'WHITELISTED' && alert?.status !== 'ESCALATED';
|
||||
}
|
||||
return alert?.status === key;
|
||||
}
|
||||
|
||||
statusClass(status: string): string {
|
||||
return 'alert-status status-' + (status || 'raised').toLowerCase();
|
||||
}
|
||||
@@ -302,7 +995,26 @@ export class AmlFlaggedTransactionsComponent implements OnInit, OnDestroy {
|
||||
* opening a second STR case — this gate is what prevents it.
|
||||
*/
|
||||
canReview(alert: any): boolean {
|
||||
return alert?.status === 'RAISED' || alert?.status === 'UNDER_REVIEW';
|
||||
if (alert?.status !== 'RAISED' && alert?.status !== 'UNDER_REVIEW') {
|
||||
return false;
|
||||
}
|
||||
// An alert another officer has open is theirs until they decide it or hand it back. The endpoint refuses it
|
||||
// anyway; hiding the buttons is what stops an officer working a case they will not be allowed to close.
|
||||
return !this.heldByAnother(alert);
|
||||
}
|
||||
|
||||
/** True when this alert is under review by somebody other than the signed-in officer. */
|
||||
heldByAnother(alert: any): boolean {
|
||||
return (
|
||||
alert?.status === 'UNDER_REVIEW' &&
|
||||
!!alert?.reviewedByUsername &&
|
||||
alert.reviewedByUsername !== this.currentUsername
|
||||
);
|
||||
}
|
||||
|
||||
/** Who has the alert, for the message shown in place of the action buttons. */
|
||||
heldBy(alert: any): string {
|
||||
return alert?.reviewedByUsername || 'another officer';
|
||||
}
|
||||
|
||||
review(alert: any, disposition: 'WHITELIST' | 'ESCALATE'): void {
|
||||
@@ -330,4 +1042,73 @@ export class AmlFlaggedTransactionsComponent implements OnInit, OnDestroy {
|
||||
this.fetch();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies one disposition to every ticked alert.
|
||||
*
|
||||
* <p>
|
||||
* Each still goes through the review dialog's own requirements - a reason, and for a whitelist
|
||||
* the evidence behind it - asked once and recorded against every alert in the batch. That is the
|
||||
* point of the batch: the officer is making one decision about one kind of activity, not the same
|
||||
* decision twenty times.
|
||||
*/
|
||||
/**
|
||||
* Applies one disposition to the rules ticked inside the open pane.
|
||||
*
|
||||
* <p>
|
||||
* The alert's own status is left to the server, which derives it from what remains: whitelisting five of six rules
|
||||
* leaves the case open on the sixth rather than closing it.
|
||||
*/
|
||||
reviewSelectedRules(alert: any, disposition: 'WHITELIST' | 'ESCALATE'): void {
|
||||
const codes = Array.from(this.selectedRuleCodes);
|
||||
if (!codes.length) {
|
||||
return;
|
||||
}
|
||||
this.dialog
|
||||
.open(AmlReviewAlertDialogComponent, {
|
||||
width: '560px',
|
||||
maxWidth: '95vw',
|
||||
autoFocus: false,
|
||||
disableClose: true,
|
||||
data: { alert, disposition, ruleCodes: codes }
|
||||
})
|
||||
.afterClosed()
|
||||
.subscribe((result: string | undefined) => {
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
this.alertService.alert({
|
||||
type: disposition === 'WHITELIST' ? 'Rules Whitelisted' : 'Rules Escalated',
|
||||
message: `${codes.length} rule${codes.length > 1 ? 's' : ''} on ${alert.transactionType} #${alert.transactionId} ${
|
||||
disposition === 'WHITELIST' ? 'cleared' : 'escalated'
|
||||
}.`
|
||||
});
|
||||
this.selectedRuleCodes.clear();
|
||||
// The alert's status may have changed with the last rule decided, so re-read rather than patching locally.
|
||||
this.fetch();
|
||||
});
|
||||
}
|
||||
|
||||
reviewSelected(disposition: 'WHITELIST' | 'ESCALATE'): void {
|
||||
const batch = this.selectedAlerts();
|
||||
if (!batch.length) {
|
||||
return;
|
||||
}
|
||||
this.dialog
|
||||
.open(AmlBulkReviewDialogComponent, {
|
||||
width: '620px',
|
||||
maxWidth: '95vw',
|
||||
autoFocus: false,
|
||||
disableClose: true,
|
||||
data: { alerts: batch, disposition }
|
||||
})
|
||||
.afterClosed()
|
||||
.subscribe((done: boolean | undefined) => {
|
||||
if (!done) {
|
||||
return;
|
||||
}
|
||||
this.selectedIds.clear();
|
||||
this.fetch();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+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);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -341,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;
|
||||
@@ -369,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[] = [
|
||||
{
|
||||
@@ -383,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')
|
||||
}
|
||||
];
|
||||
|
||||
@@ -580,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.
|
||||
*
|
||||
@@ -594,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,
|
||||
|
||||
@@ -32,7 +32,6 @@ export class ShareAccountActionsResolver {
|
||||
const actionName = route.paramMap.get('name');
|
||||
const shareAccountId = route.paramMap.get('shareAccountId') || route.parent.parent.paramMap.get('shareAccountId');
|
||||
switch (actionName) {
|
||||
case 'Apply Additional Shares':
|
||||
case 'Redeem Shares':
|
||||
case 'Approve Additional Shares':
|
||||
case 'Reject Additional Shares':
|
||||
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="cert-root">
|
||||
<div class="cert-head">
|
||||
<div class="cert-head-l">
|
||||
<fa-icon icon="file-invoice" class="cert-head-ic"></fa-icon>
|
||||
<div>
|
||||
<h2 class="cert-head-t">Share Account Statement</h2>
|
||||
<div class="cert-head-s">
|
||||
One member's share account, movement by movement, with the holding each movement left behind.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-head-r">
|
||||
<button class="cert-cta-btn" (click)="exportCsv()" [disabled]="loading || !lines.length">
|
||||
<fa-icon icon="file-alt"></fa-icon> Export CSV
|
||||
</button>
|
||||
<button class="cert-cta-btn" (click)="print()" [disabled]="loading || !lines.length">
|
||||
<fa-icon icon="print"></fa-icon> Print
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Account picker -->
|
||||
<div class="cert-filters">
|
||||
<div class="cert-filter" style="flex: 2 1 320px; position: relative">
|
||||
<label>Share account</label>
|
||||
<div class="cert-input-ic">
|
||||
<fa-icon icon="search"></fa-icon>
|
||||
<input
|
||||
type="text"
|
||||
[ngModel]="accountSearch"
|
||||
(ngModelChange)="onAccountSearch($event)"
|
||||
placeholder="Search member name or share account number"
|
||||
/>
|
||||
</div>
|
||||
@if (accountOptions.length && !selectedAccountId) {
|
||||
<div class="srep-picker">
|
||||
@for (opt of accountOptions; track opt.id) {
|
||||
<button type="button" class="srep-picker-item" (click)="selectAccount(opt)">
|
||||
<span class="srep-picker-name">{{ opt.clientName }}</span>
|
||||
<span class="srep-picker-meta srep-mono">{{ opt.accountNo }}</span>
|
||||
<span class="srep-picker-meta">{{ opt.productName }}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="cert-filter">
|
||||
<label>From</label>
|
||||
<input type="date" [(ngModel)]="fromDate" (change)="load()" [disabled]="!selectedAccountId" />
|
||||
</div>
|
||||
<div class="cert-filter">
|
||||
<label>To</label>
|
||||
<input type="date" [(ngModel)]="toDate" (change)="load()" [disabled]="!selectedAccountId" />
|
||||
</div>
|
||||
<div class="cert-filter">
|
||||
<label> </label>
|
||||
<button class="cert-cta-btn" (click)="clearPeriod()" [disabled]="!selectedAccountId">Full history</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!selectedAccountId) {
|
||||
<div class="srep-state">Search for a member or share account number above to open its statement.</div>
|
||||
} @else if (loading) {
|
||||
<div class="srep-state">Loading statement…</div>
|
||||
} @else if (loadError) {
|
||||
<div class="srep-state srep-state--error"><fa-icon icon="exclamation-circle"></fa-icon> {{ loadError }}</div>
|
||||
} @else if (account) {
|
||||
<!-- Statement header -->
|
||||
<div class="srep-stmt-head">
|
||||
<div class="srep-stmt-grid">
|
||||
<div>
|
||||
<div class="srep-stmt-l">Member</div>
|
||||
<div class="srep-stmt-v">{{ account.clientName }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="srep-stmt-l">Membership No</div>
|
||||
<div class="srep-stmt-v srep-mono">{{ account.cifNumber || '—' }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="srep-stmt-l">Share A/c No</div>
|
||||
<div class="srep-stmt-v srep-mono">{{ account.accountNo }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="srep-stmt-l">Product</div>
|
||||
<div class="srep-stmt-v">{{ account.productName }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="srep-stmt-l">Branch</div>
|
||||
<div class="srep-stmt-v">{{ account.officeName || '—' }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="srep-stmt-l">Status</div>
|
||||
<div class="srep-stmt-v">
|
||||
<span class="srep-pill" [ngClass]="statusClass(account.statusEnum)">{{
|
||||
statusLabel(account.statusEnum)
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="srep-stmt-l">Linked Savings</div>
|
||||
<div class="srep-stmt-v srep-mono">{{ account.linkedSavingsAccountNo || 'Not linked' }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="srep-stmt-l">Activated</div>
|
||||
<div class="srep-stmt-v">{{ account.activatedDate || '—' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="cert-cta-btn srep-no-print" (click)="openAccount()">
|
||||
<fa-icon icon="external-link-alt"></fa-icon> Open account
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (totals) {
|
||||
<div class="cert-kpis">
|
||||
<div class="cert-kpi cert-kpi--green">
|
||||
<fa-icon icon="coins" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">{{ totals.closingShares | number }}</div>
|
||||
<div class="cert-kpi-l">Closing Shares</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-kpi cert-kpi--teal">
|
||||
<fa-icon icon="balance-scale" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">₹{{ totals.closingValue | number: '1.2-2' }}</div>
|
||||
<div class="cert-kpi-l">Closing Value</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-kpi cert-kpi--blue">
|
||||
<fa-icon icon="arrow-up" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">{{ totals.sharesIn | number }}</div>
|
||||
<div class="cert-kpi-l">Shares Purchased</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-kpi cert-kpi--amber">
|
||||
<fa-icon icon="arrow-down" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">{{ totals.sharesOut | number }}</div>
|
||||
<div class="cert-kpi-l">Shares Redeemed</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!lines.length) {
|
||||
<div class="srep-state">
|
||||
No settled movement on this account{{ fromDate || toDate ? ' in the selected period' : '' }}.
|
||||
</div>
|
||||
} @else {
|
||||
<div class="srep-table-wrap">
|
||||
<table class="srep-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Particulars</th>
|
||||
<th>Board Resolution</th>
|
||||
<th>Funded By</th>
|
||||
<th class="srep-num">Shares In</th>
|
||||
<th class="srep-num">Shares Out</th>
|
||||
<th class="srep-num">Unit Price</th>
|
||||
<th class="srep-num">Amount</th>
|
||||
<th class="srep-num">Balance Shares</th>
|
||||
<th class="srep-num">Balance Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (line of lines; track line.transactionId) {
|
||||
<tr>
|
||||
<td>{{ line.transactionDate }}</td>
|
||||
<td>
|
||||
<span class="srep-pill" [ngClass]="typeClass(line.typeEnum)">{{ typeLabel(line.typeEnum) }}</span>
|
||||
</td>
|
||||
<td class="srep-mono">{{ line.resolutionNumber || '—' }}</td>
|
||||
<td>{{ funding(line.fundingSource) }}</td>
|
||||
<td class="srep-num">{{ line.sharesIn ? (line.sharesIn | number) : '' }}</td>
|
||||
<td class="srep-num">{{ line.sharesOut ? (line.sharesOut | number) : '' }}</td>
|
||||
<td class="srep-num">{{ line.unitPrice | number: '1.2-2' }}</td>
|
||||
<td class="srep-num">{{ line.amount | number: '1.2-2' }}</td>
|
||||
<td class="srep-num">{{ line.balanceShares | number }}</td>
|
||||
<td class="srep-num">₹{{ line.balanceValue | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="4">Closing balance</td>
|
||||
<td class="srep-num">{{ totals?.sharesIn | number }}</td>
|
||||
<td class="srep-num">{{ totals?.sharesOut | number }}</td>
|
||||
<td class="srep-num"></td>
|
||||
<td class="srep-num"></td>
|
||||
<td class="srep-num">{{ totals?.closingShares | number }}</td>
|
||||
<td class="srep-num">₹{{ totals?.closingValue | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { Subject, of } from 'rxjs';
|
||||
import { catchError, debounceTime, distinctUntilChanged, switchMap } from 'rxjs/operators';
|
||||
|
||||
import { SharesService } from '../../shares.service';
|
||||
import {
|
||||
SHARE_ACCOUNT_STATUS,
|
||||
SHARE_TXN_TYPE,
|
||||
fundingLabel,
|
||||
shareReportCsv,
|
||||
shareReportPrint
|
||||
} from '../share-report.util';
|
||||
|
||||
interface StatementLine {
|
||||
transactionId: number;
|
||||
transactionDate: string;
|
||||
typeEnum: number;
|
||||
statusEnum: number;
|
||||
sharesIn: number;
|
||||
sharesOut: number;
|
||||
unitPrice: number;
|
||||
amount: number;
|
||||
chargeAmount: number;
|
||||
amountPaid: number;
|
||||
resolutionNumber: string;
|
||||
meetingDate: string;
|
||||
fundingSource: string;
|
||||
balanceShares: number;
|
||||
balanceValue: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Share Account Statement — one member's share account, movement by movement, with the holding each
|
||||
* movement left behind.
|
||||
*
|
||||
* <p>
|
||||
* The account is chosen first because a statement is always about one account; the search box is
|
||||
* server-side (the same summary endpoint the Share Holder report uses), so it finds a member across
|
||||
* every branch rather than only among accounts already downloaded.
|
||||
*
|
||||
* <p>
|
||||
* A period narrows which lines are shown but never how the running balance was reached — the server
|
||||
* walks the account's whole life and filters afterwards, so a mid-year statement opens at the
|
||||
* holding the member actually had, not at zero.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-share-account-statement',
|
||||
templateUrl: './share-account-statement.component.html',
|
||||
styleUrls: [
|
||||
'../../../letter-mgmt/certificates/certificates.shared.scss',
|
||||
'../share-report.shared.scss'
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
FaIconComponent
|
||||
]
|
||||
})
|
||||
export class ShareAccountStatementComponent implements OnInit {
|
||||
private sharesService = inject(SharesService);
|
||||
private route = inject(ActivatedRoute);
|
||||
private router = inject(Router);
|
||||
|
||||
/** Account picker. */
|
||||
accountSearch = '';
|
||||
accountOptions: any[] = [];
|
||||
searchingAccounts = false;
|
||||
selectedAccountId: number | null = null;
|
||||
|
||||
/** Statement. */
|
||||
loading = false;
|
||||
loadError = '';
|
||||
account: any = null;
|
||||
lines: StatementLine[] = [];
|
||||
totals: any = null;
|
||||
|
||||
fromDate = '';
|
||||
toDate = '';
|
||||
|
||||
private searchTerm$ = new Subject<string>();
|
||||
|
||||
ngOnInit(): void {
|
||||
this.searchTerm$
|
||||
.pipe(
|
||||
debounceTime(350),
|
||||
distinctUntilChanged(),
|
||||
switchMap((term) => {
|
||||
this.searchingAccounts = true;
|
||||
return this.sharesService.getShareAccountSummaries(term).pipe(catchError(() => of([])));
|
||||
})
|
||||
)
|
||||
.subscribe((res: any) => {
|
||||
this.accountOptions = Array.isArray(res) ? res : res?.pageItems || [];
|
||||
this.searchingAccounts = false;
|
||||
});
|
||||
|
||||
// Deep-linkable: the account view's own statement button, and any report that drills in here,
|
||||
// can hand over ?shareAccountId= and land on a loaded statement rather than an empty picker.
|
||||
const preselected = this.route.snapshot.queryParamMap.get('shareAccountId');
|
||||
if (preselected) {
|
||||
this.selectedAccountId = Number(preselected);
|
||||
this.load();
|
||||
}
|
||||
this.searchTerm$.next('');
|
||||
}
|
||||
|
||||
onAccountSearch(term: string): void {
|
||||
this.accountSearch = term;
|
||||
this.searchTerm$.next(term.trim());
|
||||
}
|
||||
|
||||
selectAccount(option: any): void {
|
||||
this.selectedAccountId = option.id;
|
||||
this.accountOptions = [];
|
||||
this.accountSearch = `${option.clientName} — ${option.accountNo}`;
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
if (!this.selectedAccountId) {
|
||||
return;
|
||||
}
|
||||
this.loading = true;
|
||||
this.loadError = '';
|
||||
this.sharesService
|
||||
.getShareAccountStatement(this.selectedAccountId, { fromDate: this.fromDate, toDate: this.toDate })
|
||||
.pipe(
|
||||
catchError((err) => {
|
||||
this.loadError = this.readError(err, 'The share account statement could not be loaded.');
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
.subscribe((res: any) => {
|
||||
this.account = res?.account || null;
|
||||
this.lines = res?.lines || [];
|
||||
this.totals = res?.totals || null;
|
||||
this.loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
clearPeriod(): void {
|
||||
this.fromDate = '';
|
||||
this.toDate = '';
|
||||
this.load();
|
||||
}
|
||||
|
||||
typeLabel(typeEnum: number): string {
|
||||
return SHARE_TXN_TYPE[typeEnum]?.label || String(typeEnum ?? '');
|
||||
}
|
||||
|
||||
typeClass(typeEnum: number): string {
|
||||
return SHARE_TXN_TYPE[typeEnum]?.cls || 'srep-pill--grey';
|
||||
}
|
||||
|
||||
statusLabel(statusEnum: number): string {
|
||||
return SHARE_ACCOUNT_STATUS[statusEnum]?.label || String(statusEnum ?? '');
|
||||
}
|
||||
|
||||
statusClass(statusEnum: number): string {
|
||||
return SHARE_ACCOUNT_STATUS[statusEnum]?.cls || 'srep-pill--grey';
|
||||
}
|
||||
|
||||
funding(source: string): string {
|
||||
return fundingLabel(source);
|
||||
}
|
||||
|
||||
openAccount(): void {
|
||||
if (this.account) {
|
||||
this.router.navigate([
|
||||
'/clients',
|
||||
this.account.clientId,
|
||||
'shares-accounts',
|
||||
this.account.shareAccountId,
|
||||
'general'
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
exportCsv(): void {
|
||||
if (!this.account) {
|
||||
return;
|
||||
}
|
||||
shareReportCsv(
|
||||
`share-statement-${this.account.accountNo}`,
|
||||
[
|
||||
'Date',
|
||||
'Particulars',
|
||||
'Board Resolution',
|
||||
'Funded By',
|
||||
'Shares In',
|
||||
'Shares Out',
|
||||
'Unit Price',
|
||||
'Amount',
|
||||
'Entry Fee',
|
||||
'Balance Shares',
|
||||
'Balance Value'
|
||||
],
|
||||
this.lines.map((l) => [
|
||||
l.transactionDate,
|
||||
this.typeLabel(l.typeEnum),
|
||||
l.resolutionNumber,
|
||||
this.funding(l.fundingSource),
|
||||
l.sharesIn || '',
|
||||
l.sharesOut || '',
|
||||
l.unitPrice,
|
||||
l.amount,
|
||||
l.chargeAmount,
|
||||
l.balanceShares,
|
||||
l.balanceValue
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
print(): void {
|
||||
shareReportPrint();
|
||||
}
|
||||
|
||||
private readError(err: any, fallback: string): string {
|
||||
return (
|
||||
err?.error?.errors?.[0]?.defaultUserMessage ||
|
||||
err?.error?.defaultUserMessage ||
|
||||
err?.error?.developerMessage ||
|
||||
fallback
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="cert-root">
|
||||
<div class="cert-head">
|
||||
<div class="cert-head-l">
|
||||
<fa-icon icon="map" class="cert-head-ic"></fa-icon>
|
||||
<div>
|
||||
<h2 class="cert-head-t">Area Wise Shareholding</h2>
|
||||
<div class="cert-head-s">
|
||||
Where the society's members and its share capital sit — grouped by the member's own recorded address.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-head-r">
|
||||
<button class="cert-cta-btn" (click)="exportCsv()" [disabled]="loading || !filteredMembers.length">
|
||||
<fa-icon icon="file-alt"></fa-icon> Export CSV
|
||||
</button>
|
||||
<button class="cert-cta-btn" (click)="print()" [disabled]="loading || !filteredMembers.length">
|
||||
<fa-icon icon="print"></fa-icon> Print
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (totals) {
|
||||
<div class="cert-kpis">
|
||||
<div class="cert-kpi cert-kpi--blue">
|
||||
<fa-icon icon="map" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">{{ totals.areaCount | number }}</div>
|
||||
<div class="cert-kpi-l">Areas</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-kpi cert-kpi--green">
|
||||
<fa-icon icon="users" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">{{ totals.memberCount | number }}</div>
|
||||
<div class="cert-kpi-l">Members</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-kpi cert-kpi--teal">
|
||||
<fa-icon icon="balance-scale" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">₹{{ totals.shareValue | number: '1.2-2' }}</div>
|
||||
<div class="cert-kpi-l">Share Capital</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-kpi cert-kpi--amber">
|
||||
<fa-icon icon="coins" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">{{ totals.shares | number }}</div>
|
||||
<div class="cert-kpi-l">Shares Held</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (loading) {
|
||||
<div class="srep-state">Loading area-wise shareholding…</div>
|
||||
} @else if (loadError) {
|
||||
<div class="srep-state srep-state--error"><fa-icon icon="exclamation-circle"></fa-icon> {{ loadError }}</div>
|
||||
} @else if (!members.length) {
|
||||
<div class="srep-state">No member holds share capital yet.</div>
|
||||
} @else {
|
||||
<!-- Area summary — click one to narrow the member list below -->
|
||||
<div class="srep-table-wrap">
|
||||
<table class="srep-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Area</th>
|
||||
<th class="srep-num">Members</th>
|
||||
<th style="width: 22%">Capital</th>
|
||||
<th class="srep-num">Shares</th>
|
||||
<th class="srep-num">Share Value</th>
|
||||
<th class="srep-num">% of Capital</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (a of areas; track a.area) {
|
||||
<tr class="srep-row--click" (click)="selectArea(a.area)">
|
||||
<td>
|
||||
{{ a.area }}
|
||||
@if (areaFilter === a.area) {
|
||||
<span class="srep-pill srep-pill--blue srep-no-print">filtered</span>
|
||||
}
|
||||
</td>
|
||||
<td class="srep-num">{{ a.memberCount | number }}</td>
|
||||
<td>
|
||||
<div class="srep-bar"><div class="srep-bar-fill" [style.width.%]="barWidth(a)"></div></div>
|
||||
</td>
|
||||
<td class="srep-num">{{ a.shares | number }}</td>
|
||||
<td class="srep-num">₹{{ a.shareValue | number: '1.2-2' }}</td>
|
||||
<td class="srep-num">{{ capitalShare(a) | number: '1.2-2' }}%</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td>All areas</td>
|
||||
<td class="srep-num">{{ totals?.memberCount | number }}</td>
|
||||
<td></td>
|
||||
<td class="srep-num">{{ totals?.shares | number }}</td>
|
||||
<td class="srep-num">₹{{ totals?.shareValue | number: '1.2-2' }}</td>
|
||||
<td class="srep-num">100.00%</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Members -->
|
||||
<div class="cert-filters" style="margin-top: 0.85rem">
|
||||
<div class="cert-filter">
|
||||
<label>Member / Membership No / Area</label>
|
||||
<div class="cert-input-ic">
|
||||
<fa-icon icon="search"></fa-icon>
|
||||
<input type="text" [(ngModel)]="search" placeholder="e.g. member name or Bhopal" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-filter">
|
||||
<label>Area</label>
|
||||
<select [(ngModel)]="areaFilter">
|
||||
<option value="">All areas</option>
|
||||
@for (a of areas; track a.area) {
|
||||
<option [value]="a.area">{{ a.area }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="cert-filter">
|
||||
<label> </label>
|
||||
<button class="cert-cta-btn" (click)="clearFilters()">Clear filters</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!filteredMembers.length) {
|
||||
<div class="srep-state">No member matches the filters applied.</div>
|
||||
} @else {
|
||||
<div class="srep-table-wrap">
|
||||
<table class="srep-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Area</th>
|
||||
<th>Member</th>
|
||||
<th>Membership No</th>
|
||||
<th>Mobile</th>
|
||||
<th>Branch</th>
|
||||
<th>Postal Code</th>
|
||||
<th class="srep-num">Shares Held</th>
|
||||
<th class="srep-num">Share Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (m of filteredMembers; track m.clientId) {
|
||||
<tr class="srep-row--click" (click)="openMember(m)">
|
||||
<td>{{ m.area }}</td>
|
||||
<td>{{ m.clientName }}</td>
|
||||
<td class="srep-mono">{{ m.cifNumber || '—' }}</td>
|
||||
<td>{{ m.mobileNo || '—' }}</td>
|
||||
<td>{{ m.officeName || '—' }}</td>
|
||||
<td class="srep-mono">{{ m.postalCode || '—' }}</td>
|
||||
<td class="srep-num">{{ m.sharesHeld | number }}</td>
|
||||
<td class="srep-num">₹{{ m.shareValue | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { of } from 'rxjs';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
|
||||
import { SharesService } from '../../shares.service';
|
||||
import { shareReportCsv, shareReportPrint } from '../share-report.util';
|
||||
|
||||
interface AreaRow {
|
||||
area: string;
|
||||
memberCount: number;
|
||||
shares: number;
|
||||
shareValue: number;
|
||||
}
|
||||
|
||||
interface AreaMemberRow {
|
||||
clientId: number;
|
||||
cifNumber: string;
|
||||
clientName: string;
|
||||
mobileNo: string;
|
||||
officeName: string;
|
||||
area: string;
|
||||
postalCode: string;
|
||||
sharesHeld: number;
|
||||
shareValue: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Area Wise — where the society's members and its share capital actually sit.
|
||||
*
|
||||
* <p>
|
||||
* A co-operative's area of operation is part of its licence, so "how much of our capital comes from
|
||||
* which area" is a question both the board and the Registrar ask. Grouped on the member's own
|
||||
* recorded address rather than the branch that serves them: the two answer different questions, and
|
||||
* a licence is written against the address.
|
||||
*
|
||||
* <p>
|
||||
* Members with no address are their own group rather than dropped — an unaddressable member is a gap
|
||||
* in the register worth seeing.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-share-area-wise',
|
||||
templateUrl: './share-area-wise.component.html',
|
||||
styleUrls: [
|
||||
'../../../letter-mgmt/certificates/certificates.shared.scss',
|
||||
'../share-report.shared.scss'
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
FaIconComponent
|
||||
]
|
||||
})
|
||||
export class ShareAreaWiseComponent implements OnInit {
|
||||
private sharesService = inject(SharesService);
|
||||
private router = inject(Router);
|
||||
|
||||
loading = true;
|
||||
loadError = '';
|
||||
areas: AreaRow[] = [];
|
||||
members: AreaMemberRow[] = [];
|
||||
totals: any = null;
|
||||
|
||||
search = '';
|
||||
/** Set by clicking an area row; '' means every area. */
|
||||
areaFilter = '';
|
||||
|
||||
ngOnInit(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading = true;
|
||||
this.loadError = '';
|
||||
this.sharesService
|
||||
.getShareAreaWise({})
|
||||
.pipe(
|
||||
catchError((err) => {
|
||||
this.loadError = this.readError(err, 'The area-wise report could not be loaded.');
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
.subscribe((res: any) => {
|
||||
this.areas = res?.areas || [];
|
||||
this.members = res?.members || [];
|
||||
this.totals = res?.totals || null;
|
||||
this.loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
get filteredMembers(): AreaMemberRow[] {
|
||||
const term = this.search.trim().toLowerCase();
|
||||
return this.members.filter((m) => {
|
||||
if (this.areaFilter && m.area !== this.areaFilter) {
|
||||
return false;
|
||||
}
|
||||
if (!term) {
|
||||
return true;
|
||||
}
|
||||
return [
|
||||
m.clientName,
|
||||
m.cifNumber,
|
||||
m.area,
|
||||
m.postalCode
|
||||
]
|
||||
.filter(Boolean)
|
||||
.some((field) => String(field).toLowerCase().includes(term));
|
||||
});
|
||||
}
|
||||
|
||||
/** An area's share of the society's capital — the reading this report exists for. */
|
||||
capitalShare(row: AreaRow): number {
|
||||
const total = Number(this.totals?.shareValue || 0);
|
||||
return total ? (Number(row.shareValue || 0) * 100) / total : 0;
|
||||
}
|
||||
|
||||
barWidth(row: AreaRow): number {
|
||||
const largest = Math.max(...this.areas.map((a) => Number(a.shareValue || 0)), 1);
|
||||
return (Number(row.shareValue || 0) * 100) / largest;
|
||||
}
|
||||
|
||||
selectArea(area: string): void {
|
||||
this.areaFilter = this.areaFilter === area ? '' : area;
|
||||
}
|
||||
|
||||
openMember(row: AreaMemberRow): void {
|
||||
this.router.navigate([
|
||||
'/clients',
|
||||
row.clientId,
|
||||
'general'
|
||||
]);
|
||||
}
|
||||
|
||||
clearFilters(): void {
|
||||
this.search = '';
|
||||
this.areaFilter = '';
|
||||
}
|
||||
|
||||
exportCsv(): void {
|
||||
shareReportCsv(
|
||||
'share-area-wise',
|
||||
[
|
||||
'Area',
|
||||
'Member',
|
||||
'Membership No',
|
||||
'Mobile',
|
||||
'Branch',
|
||||
'Postal Code',
|
||||
'Shares Held',
|
||||
'Share Value'
|
||||
],
|
||||
this.filteredMembers.map((m) => [
|
||||
m.area,
|
||||
m.clientName,
|
||||
m.cifNumber,
|
||||
m.mobileNo,
|
||||
m.officeName,
|
||||
m.postalCode,
|
||||
m.sharesHeld,
|
||||
m.shareValue
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
print(): void {
|
||||
shareReportPrint();
|
||||
}
|
||||
|
||||
private readError(err: any, fallback: string): string {
|
||||
return (
|
||||
err?.error?.errors?.[0]?.defaultUserMessage ||
|
||||
err?.error?.defaultUserMessage ||
|
||||
err?.error?.developerMessage ||
|
||||
fallback
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="cert-root">
|
||||
<div class="cert-head">
|
||||
<div class="cert-head-l">
|
||||
<fa-icon icon="list-alt" class="cert-head-ic"></fa-icon>
|
||||
<div>
|
||||
<h2 class="cert-head-t">Share Detail</h2>
|
||||
<div class="cert-head-s">
|
||||
The complete share register — every account with its member, class and the terms it was opened under.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-head-r">
|
||||
<button class="cert-cta-btn" (click)="exportCsv()" [disabled]="loading || !filteredRows.length">
|
||||
<fa-icon icon="file-alt"></fa-icon> Export CSV
|
||||
</button>
|
||||
<button class="cert-cta-btn" (click)="print()" [disabled]="loading || !filteredRows.length">
|
||||
<fa-icon icon="print"></fa-icon> Print
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (totals) {
|
||||
<div class="cert-kpis">
|
||||
<div class="cert-kpi cert-kpi--blue">
|
||||
<fa-icon icon="file-alt" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">{{ totals.accountCount | number }}</div>
|
||||
<div class="cert-kpi-l">Share Accounts</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-kpi cert-kpi--green">
|
||||
<fa-icon icon="coins" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">{{ totals.sharesHeld | number }}</div>
|
||||
<div class="cert-kpi-l">Shares Held</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-kpi cert-kpi--teal">
|
||||
<fa-icon icon="balance-scale" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">₹{{ totals.shareValue | number: '1.2-2' }}</div>
|
||||
<div class="cert-kpi-l">Share Capital</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-kpi" [ngClass]="totals.chargeOutstanding ? 'cert-kpi--amber' : 'cert-kpi--blue'">
|
||||
<fa-icon icon="exclamation-circle" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">₹{{ totals.chargeOutstanding | number: '1.2-2' }}</div>
|
||||
<div class="cert-kpi-l">Charges Outstanding</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="cert-filters">
|
||||
<div class="cert-filter">
|
||||
<label>Member / A/c No / Resolution</label>
|
||||
<div class="cert-input-ic">
|
||||
<fa-icon icon="search"></fa-icon>
|
||||
<input type="text" [(ngModel)]="search" placeholder="e.g. member name or BR-2026-14" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-filter">
|
||||
<label>Status</label>
|
||||
<select [(ngModel)]="statusFilter">
|
||||
<option [ngValue]="''">All statuses</option>
|
||||
@for (s of statusOptions; track s) {
|
||||
<option [ngValue]="s">{{ statusLabel(s) }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="cert-filter">
|
||||
<label>Branch</label>
|
||||
<select [(ngModel)]="branchFilter">
|
||||
<option value="">All branches</option>
|
||||
@for (b of branchOptions; track b) {
|
||||
<option [value]="b">{{ b }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="cert-filter">
|
||||
<label>Share product</label>
|
||||
<select [(ngModel)]="productFilter">
|
||||
<option value="">All products</option>
|
||||
@for (p of productOptions; track p) {
|
||||
<option [value]="p">{{ p }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="cert-filter">
|
||||
<label> </label>
|
||||
<button class="cert-cta-btn" (click)="clearFilters()">Clear filters</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (loading) {
|
||||
<div class="srep-state">Loading the share register…</div>
|
||||
} @else if (loadError) {
|
||||
<div class="srep-state srep-state--error"><fa-icon icon="exclamation-circle"></fa-icon> {{ loadError }}</div>
|
||||
} @else if (!rows.length) {
|
||||
<div class="srep-state">No share account exists yet.</div>
|
||||
} @else if (!filteredRows.length) {
|
||||
<div class="srep-state">No share account matches the filters applied.</div>
|
||||
} @else {
|
||||
<div class="srep-table-wrap">
|
||||
<table class="srep-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Member</th>
|
||||
<th>Membership No</th>
|
||||
<th>Share A/c No</th>
|
||||
<th>Branch</th>
|
||||
<th>Product</th>
|
||||
<th>State</th>
|
||||
<th class="srep-num">Shares</th>
|
||||
<th class="srep-num">Value</th>
|
||||
<th class="srep-num">Tranches</th>
|
||||
<th>Board Resolution</th>
|
||||
<th>Lock-in</th>
|
||||
<th>Min Active</th>
|
||||
<th>Linked Savings</th>
|
||||
<th class="srep-num">Charges O/S</th>
|
||||
<th>Activated</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of filteredRows; track row.shareAccountId) {
|
||||
<tr class="srep-row--click" [ngClass]="stateRowClass(row.holdingState)" (click)="openAccount(row)">
|
||||
<td>
|
||||
{{ row.clientName }}
|
||||
@if (row.mobileNo) {
|
||||
<div class="srep-scope-note" style="margin: 0">{{ row.mobileNo }}</div>
|
||||
}
|
||||
</td>
|
||||
<td class="srep-mono">{{ row.cifNumber || '—' }}</td>
|
||||
<td class="srep-mono">{{ row.accountNo }}</td>
|
||||
<td>{{ row.officeName || '—' }}</td>
|
||||
<td>{{ row.productName }}</td>
|
||||
<td>
|
||||
<span
|
||||
class="srep-pill"
|
||||
[ngClass]="stateClass(row.holdingState)"
|
||||
[title]="stateNote(row.holdingState)"
|
||||
>{{ stateLabel(row.holdingState) }}</span
|
||||
>
|
||||
</td>
|
||||
<td class="srep-num">
|
||||
{{ row.sharesHeld | number }}
|
||||
@if (row.holdingState === 'UNPAID') {
|
||||
<div class="srep-sub">{{ row.unpaidShares | number }} unpaid</div>
|
||||
}
|
||||
</td>
|
||||
<td class="srep-num">₹{{ row.shareValue | number: '1.2-2' }}</td>
|
||||
<td class="srep-num">{{ row.trancheCount | number }}</td>
|
||||
<td class="srep-mono">
|
||||
{{ row.resolutionNumber || '—' }}
|
||||
@if (row.resolutionDate) {
|
||||
<div class="srep-scope-note" style="margin: 0">{{ row.resolutionDate }}</div>
|
||||
}
|
||||
</td>
|
||||
<td>{{ lockin(row) }}</td>
|
||||
<td>{{ minimumActive(row) }}</td>
|
||||
<td class="srep-mono">{{ row.linkedSavingsAccountNo || '—' }}</td>
|
||||
<td class="srep-num">
|
||||
{{ row.chargeOutstanding ? '₹' + (row.chargeOutstanding | number: '1.2-2') : '—' }}
|
||||
</td>
|
||||
<td>{{ row.activatedDate || '—' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="6">
|
||||
{{ isFiltered ? 'Total (filtered)' : 'Total' }} — {{ filteredRows.length | number }} accounts
|
||||
</td>
|
||||
<td class="srep-num">{{ shownShares | number }}</td>
|
||||
<td class="srep-num">₹{{ shownValue | number: '1.2-2' }}</td>
|
||||
<td colspan="7"></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,293 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { of } from 'rxjs';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
|
||||
import { SharesService } from '../../shares.service';
|
||||
import {
|
||||
SHARE_ACCOUNT_STATUS,
|
||||
holdingStateClass,
|
||||
holdingStateLabel,
|
||||
holdingStateNote,
|
||||
holdingStateRowClass,
|
||||
periodLabel,
|
||||
shareReportCsv,
|
||||
shareReportPrint
|
||||
} from '../share-report.util';
|
||||
|
||||
interface DetailRow {
|
||||
shareAccountId: number;
|
||||
accountNo: string;
|
||||
externalId: string;
|
||||
clientId: number;
|
||||
cifNumber: string;
|
||||
clientName: string;
|
||||
mobileNo: string;
|
||||
officeName: string;
|
||||
productId: number;
|
||||
productName: string;
|
||||
productShortName: string;
|
||||
currencyCode: string;
|
||||
statusEnum: number;
|
||||
holdingState: string;
|
||||
unpaidShares: number;
|
||||
unitPrice: number;
|
||||
sharesHeld: number;
|
||||
shareValue: number;
|
||||
pendingShares: number;
|
||||
trancheCount: number;
|
||||
resolutionNumber: string;
|
||||
resolutionDate: string;
|
||||
lockinPeriod: number;
|
||||
lockinPeriodEnum: number;
|
||||
minimumActivePeriod: number;
|
||||
minimumActivePeriodEnum: number;
|
||||
allowDividendsInactive: boolean;
|
||||
linkedSavingsAccountNo: string;
|
||||
chargeTotal: number;
|
||||
chargeOutstanding: number;
|
||||
submittedDate: string;
|
||||
approvedDate: string;
|
||||
activatedDate: string;
|
||||
closedDate: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Share Detail — the complete register: every account with its member, share class, and the terms it
|
||||
* was opened under.
|
||||
*
|
||||
* <p>
|
||||
* Where the Share Holder list answers "who holds shares", this answers "what exactly does this
|
||||
* account say": lock-in, minimum active period, linked savings, charges, tranche count and governing
|
||||
* resolution, which are otherwise spread one tab at a time across each account.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-share-detail-report',
|
||||
templateUrl: './share-detail-report.component.html',
|
||||
styleUrls: [
|
||||
'../../../letter-mgmt/certificates/certificates.shared.scss',
|
||||
'../share-report.shared.scss'
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
FaIconComponent
|
||||
]
|
||||
})
|
||||
export class ShareDetailReportComponent implements OnInit {
|
||||
private sharesService = inject(SharesService);
|
||||
private router = inject(Router);
|
||||
|
||||
loading = true;
|
||||
loadError = '';
|
||||
rows: DetailRow[] = [];
|
||||
totals: any = null;
|
||||
|
||||
search = '';
|
||||
branchFilter = '';
|
||||
productFilter = '';
|
||||
statusFilter: number | '' = '';
|
||||
|
||||
ngOnInit(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading = true;
|
||||
this.loadError = '';
|
||||
this.sharesService
|
||||
.getShareDetailRegister({})
|
||||
.pipe(
|
||||
catchError((err) => {
|
||||
this.loadError = this.readError(err, 'The share register could not be loaded.');
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
.subscribe((res: any) => {
|
||||
this.rows = res?.rows || [];
|
||||
this.totals = res?.totals || null;
|
||||
this.loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
get branchOptions(): string[] {
|
||||
return [...new Set(this.rows.map((r) => r.officeName).filter(Boolean))].sort();
|
||||
}
|
||||
|
||||
get productOptions(): string[] {
|
||||
return [...new Set(this.rows.map((r) => r.productName).filter(Boolean))].sort();
|
||||
}
|
||||
|
||||
get statusOptions(): number[] {
|
||||
return [...new Set(this.rows.map((r) => r.statusEnum))].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
get filteredRows(): DetailRow[] {
|
||||
const term = this.search.trim().toLowerCase();
|
||||
return this.rows.filter((r) => {
|
||||
if (this.branchFilter && r.officeName !== this.branchFilter) {
|
||||
return false;
|
||||
}
|
||||
if (this.productFilter && r.productName !== this.productFilter) {
|
||||
return false;
|
||||
}
|
||||
if (this.statusFilter !== '' && r.statusEnum !== this.statusFilter) {
|
||||
return false;
|
||||
}
|
||||
if (!term) {
|
||||
return true;
|
||||
}
|
||||
return [
|
||||
r.clientName,
|
||||
r.accountNo,
|
||||
r.cifNumber,
|
||||
r.productName,
|
||||
r.resolutionNumber,
|
||||
r.externalId
|
||||
]
|
||||
.filter(Boolean)
|
||||
.some((field) => String(field).toLowerCase().includes(term));
|
||||
});
|
||||
}
|
||||
|
||||
get shownShares(): number {
|
||||
return this.filteredRows.reduce((sum, r) => sum + Number(r.sharesHeld || 0), 0);
|
||||
}
|
||||
|
||||
get shownValue(): number {
|
||||
return this.filteredRows.reduce((sum, r) => sum + Number(r.shareValue || 0), 0);
|
||||
}
|
||||
|
||||
get isFiltered(): boolean {
|
||||
return !!(this.search.trim() || this.branchFilter || this.productFilter || this.statusFilter !== '');
|
||||
}
|
||||
|
||||
statusLabel(statusEnum: number): string {
|
||||
return SHARE_ACCOUNT_STATUS[statusEnum]?.label || String(statusEnum ?? '');
|
||||
}
|
||||
|
||||
lockin(row: DetailRow): string {
|
||||
return periodLabel(row.lockinPeriod, row.lockinPeriodEnum);
|
||||
}
|
||||
|
||||
minimumActive(row: DetailRow): string {
|
||||
return periodLabel(row.minimumActivePeriod, row.minimumActivePeriodEnum);
|
||||
}
|
||||
|
||||
stateLabel(state: string): string {
|
||||
return holdingStateLabel(state);
|
||||
}
|
||||
|
||||
stateClass(state: string): string {
|
||||
return holdingStateClass(state);
|
||||
}
|
||||
|
||||
stateRowClass(state: string): string {
|
||||
return holdingStateRowClass(state);
|
||||
}
|
||||
|
||||
stateNote(state: string): string {
|
||||
return holdingStateNote(state);
|
||||
}
|
||||
|
||||
clearFilters(): void {
|
||||
this.search = '';
|
||||
this.branchFilter = '';
|
||||
this.productFilter = '';
|
||||
this.statusFilter = '';
|
||||
}
|
||||
|
||||
openAccount(row: DetailRow): void {
|
||||
this.router.navigate([
|
||||
'/clients',
|
||||
row.clientId,
|
||||
'shares-accounts',
|
||||
row.shareAccountId,
|
||||
'general'
|
||||
]);
|
||||
}
|
||||
|
||||
exportCsv(): void {
|
||||
shareReportCsv(
|
||||
'share-detail-register',
|
||||
[
|
||||
'Member',
|
||||
'Membership No',
|
||||
'Mobile',
|
||||
'Share A/c No',
|
||||
'External Id',
|
||||
'Branch',
|
||||
'Product',
|
||||
'Status',
|
||||
'Holding State',
|
||||
'Allotted Unpaid',
|
||||
'Shares Held',
|
||||
'Unit Price',
|
||||
'Share Value',
|
||||
'Pending Shares',
|
||||
'Tranches',
|
||||
'Board Resolution',
|
||||
'Lock-in',
|
||||
'Min Active Period',
|
||||
'Dividends If Inactive',
|
||||
'Linked Savings',
|
||||
'Charges',
|
||||
'Charges Outstanding',
|
||||
'Submitted',
|
||||
'Activated',
|
||||
'Closed'
|
||||
],
|
||||
this.filteredRows.map((r) => [
|
||||
r.clientName,
|
||||
r.cifNumber,
|
||||
r.mobileNo,
|
||||
r.accountNo,
|
||||
r.externalId,
|
||||
r.officeName,
|
||||
r.productName,
|
||||
this.statusLabel(r.statusEnum),
|
||||
this.stateLabel(r.holdingState),
|
||||
r.unpaidShares,
|
||||
r.sharesHeld,
|
||||
r.unitPrice,
|
||||
r.shareValue,
|
||||
r.pendingShares,
|
||||
r.trancheCount,
|
||||
r.resolutionNumber,
|
||||
this.lockin(r),
|
||||
this.minimumActive(r),
|
||||
r.allowDividendsInactive ? 'Yes' : 'No',
|
||||
r.linkedSavingsAccountNo,
|
||||
r.chargeTotal,
|
||||
r.chargeOutstanding,
|
||||
r.submittedDate,
|
||||
r.activatedDate,
|
||||
r.closedDate
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
print(): void {
|
||||
shareReportPrint();
|
||||
}
|
||||
|
||||
private readError(err: any, fallback: string): string {
|
||||
return (
|
||||
err?.error?.errors?.[0]?.defaultUserMessage ||
|
||||
err?.error?.defaultUserMessage ||
|
||||
err?.error?.developerMessage ||
|
||||
fallback
|
||||
);
|
||||
}
|
||||
}
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="cert-root">
|
||||
<div class="cert-head">
|
||||
<div class="cert-head-l">
|
||||
<fa-icon icon="tag" class="cert-head-ic"></fa-icon>
|
||||
<div>
|
||||
<h2 class="cert-head-t">Share Distinctive Number Detail</h2>
|
||||
<div class="cert-head-s">
|
||||
Which numbered shares each member holds — the range every allotment covers, per share class.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-head-r">
|
||||
<button class="cert-cta-btn" (click)="exportCsv()" [disabled]="loading || !filteredRows.length">
|
||||
<fa-icon icon="file-alt"></fa-icon> Export CSV
|
||||
</button>
|
||||
<button class="cert-cta-btn" (click)="print()" [disabled]="loading || !filteredRows.length">
|
||||
<fa-icon icon="print"></fa-icon> Print
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (totals) {
|
||||
<div class="cert-kpis">
|
||||
<div class="cert-kpi cert-kpi--blue">
|
||||
<fa-icon icon="file-alt" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">{{ totals.allotmentCount | number }}</div>
|
||||
<div class="cert-kpi-l">Numbered Allotments</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-kpi cert-kpi--green">
|
||||
<fa-icon icon="coins" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">{{ totals.sharesNumbered | number }}</div>
|
||||
<div class="cert-kpi-l">Shares Numbered</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- The numbers are derived, not recorded. Said plainly so nobody reads them as an independent
|
||||
register the society has been maintaining. -->
|
||||
<div class="srep-warn">
|
||||
<fa-icon icon="info-circle"></fa-icon>
|
||||
<span>
|
||||
Distinctive numbers are <b>derived from issue order</b> within each share class — no distinctive-number column
|
||||
exists in the system, so each paid-up allotment takes the next block in sequence. Redeemed shares leave a gap
|
||||
rather than renumbering everybody else, exactly as a physical register would. Only paid-up allotments are
|
||||
numbered.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="cert-filters">
|
||||
<div class="cert-filter">
|
||||
<label>Find share number</label>
|
||||
<div class="cert-input-ic">
|
||||
<fa-icon icon="search"></fa-icon>
|
||||
<input type="number" min="1" [(ngModel)]="shareNumberLookup" placeholder="e.g. 175 — who holds it?" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-filter">
|
||||
<label>Member / A/c No / Resolution</label>
|
||||
<div class="cert-input-ic">
|
||||
<fa-icon icon="search"></fa-icon>
|
||||
<input type="text" [(ngModel)]="search" placeholder="e.g. member name" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-filter">
|
||||
<label>Share class</label>
|
||||
<select [(ngModel)]="productFilter">
|
||||
<option value="">All classes</option>
|
||||
@for (p of productOptions; track p) {
|
||||
<option [value]="p">{{ p }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="cert-filter">
|
||||
<label> </label>
|
||||
<button class="cert-cta-btn" (click)="clearFilters()">Clear filters</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (loading) {
|
||||
<div class="srep-state">Loading the distinctive number register…</div>
|
||||
} @else if (loadError) {
|
||||
<div class="srep-state srep-state--error"><fa-icon icon="exclamation-circle"></fa-icon> {{ loadError }}</div>
|
||||
} @else if (!rows.length) {
|
||||
<div class="srep-state">No paid-up allotment has been made yet, so no share carries a number.</div>
|
||||
} @else if (!filteredRows.length) {
|
||||
<div class="srep-state">
|
||||
@if (shareNumberLookup) {
|
||||
Share number {{ shareNumberLookup | number }} has not been allotted in this share class.
|
||||
} @else {
|
||||
No allotment matches the filters applied.
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="srep-table-wrap">
|
||||
<table class="srep-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Share Class</th>
|
||||
<th>Distinctive Numbers</th>
|
||||
<th class="srep-num">Shares</th>
|
||||
<th>Member</th>
|
||||
<th>Membership No</th>
|
||||
<th>Share A/c No</th>
|
||||
<th>Branch</th>
|
||||
<th>Allotment Date</th>
|
||||
<th>Funded By</th>
|
||||
<th>Board Resolution</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of filteredRows; track row.transactionId) {
|
||||
<tr class="srep-row--click" (click)="openAccount(row)">
|
||||
<td>{{ row.productName }}</td>
|
||||
<td class="srep-mono">
|
||||
<b>{{ range(row) }}</b>
|
||||
</td>
|
||||
<td class="srep-num">{{ row.shares | number }}</td>
|
||||
<td>{{ row.clientName }}</td>
|
||||
<td class="srep-mono">{{ row.cifNumber || '—' }}</td>
|
||||
<td class="srep-mono">{{ row.accountNo }}</td>
|
||||
<td>{{ row.officeName || '—' }}</td>
|
||||
<td>{{ row.allotmentDate }}</td>
|
||||
<td>{{ funding(row.fundingSource) }}</td>
|
||||
<td class="srep-mono">{{ row.resolutionNumber || '—' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="2">
|
||||
{{ isFiltered ? 'Total (filtered)' : 'Total' }} — {{ filteredRows.length | number }} allotments
|
||||
</td>
|
||||
<td class="srep-num">{{ shownShares | number }}</td>
|
||||
<td colspan="7"></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { of } from 'rxjs';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
|
||||
import { SharesService } from '../../shares.service';
|
||||
import { fundingLabel, shareReportCsv, shareReportPrint } from '../share-report.util';
|
||||
|
||||
interface DistinctiveRow {
|
||||
transactionId: number;
|
||||
allotmentDate: string;
|
||||
shareAccountId: number;
|
||||
accountNo: string;
|
||||
clientId: number;
|
||||
cifNumber: string;
|
||||
clientName: string;
|
||||
officeName: string;
|
||||
productId: number;
|
||||
productName: string;
|
||||
shares: number;
|
||||
unitPrice: number;
|
||||
amount: number;
|
||||
resolutionNumber: string;
|
||||
fundingSource: string;
|
||||
distinctiveFrom: number;
|
||||
distinctiveTo: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Share Devede (Distinctive) Number Detail — which numbered shares each member actually holds.
|
||||
*
|
||||
* <p>
|
||||
* A share certificate names the distinctive numbers it covers, and the register has to be able to
|
||||
* say who holds which. Nothing in the schema records those numbers, so they are derived the way the
|
||||
* society would have allotted them: shares numbered in issue order within each share class, each
|
||||
* allotment taking the next block. That is stated plainly on the screen rather than presented as an
|
||||
* independently-kept number, because it is a derivation and a reader should know it.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-share-distinctive-numbers',
|
||||
templateUrl: './share-distinctive-numbers.component.html',
|
||||
styleUrls: [
|
||||
'../../../letter-mgmt/certificates/certificates.shared.scss',
|
||||
'../share-report.shared.scss'
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
FaIconComponent
|
||||
]
|
||||
})
|
||||
export class ShareDistinctiveNumbersComponent implements OnInit {
|
||||
private sharesService = inject(SharesService);
|
||||
private router = inject(Router);
|
||||
|
||||
loading = true;
|
||||
loadError = '';
|
||||
rows: DistinctiveRow[] = [];
|
||||
totals: any = null;
|
||||
|
||||
search = '';
|
||||
productFilter = '';
|
||||
/** Find who holds a given share number — the question a certificate query actually arrives as. */
|
||||
shareNumberLookup: number | null = null;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading = true;
|
||||
this.loadError = '';
|
||||
this.sharesService
|
||||
.getShareDistinctiveNumbers({})
|
||||
.pipe(
|
||||
catchError((err) => {
|
||||
this.loadError = this.readError(err, 'The distinctive number register could not be loaded.');
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
.subscribe((res: any) => {
|
||||
this.rows = res?.rows || [];
|
||||
this.totals = res?.totals || null;
|
||||
this.loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
get productOptions(): string[] {
|
||||
return [...new Set(this.rows.map((r) => r.productName).filter(Boolean))].sort();
|
||||
}
|
||||
|
||||
get filteredRows(): DistinctiveRow[] {
|
||||
const term = this.search.trim().toLowerCase();
|
||||
const lookup = Number(this.shareNumberLookup);
|
||||
return this.rows.filter((r) => {
|
||||
if (this.productFilter && r.productName !== this.productFilter) {
|
||||
return false;
|
||||
}
|
||||
// A share number belongs to exactly one block, so the lookup is a range test rather than a
|
||||
// match — which is the whole point of holding the register this way.
|
||||
if (lookup > 0 && !(lookup >= Number(r.distinctiveFrom) && lookup <= Number(r.distinctiveTo))) {
|
||||
return false;
|
||||
}
|
||||
if (!term) {
|
||||
return true;
|
||||
}
|
||||
return [
|
||||
r.clientName,
|
||||
r.accountNo,
|
||||
r.cifNumber,
|
||||
r.productName,
|
||||
r.resolutionNumber
|
||||
]
|
||||
.filter(Boolean)
|
||||
.some((field) => String(field).toLowerCase().includes(term));
|
||||
});
|
||||
}
|
||||
|
||||
get shownShares(): number {
|
||||
return this.filteredRows.reduce((sum, r) => sum + Number(r.shares || 0), 0);
|
||||
}
|
||||
|
||||
get isFiltered(): boolean {
|
||||
return !!(this.search.trim() || this.productFilter || Number(this.shareNumberLookup) > 0);
|
||||
}
|
||||
|
||||
funding(source: string): string {
|
||||
return fundingLabel(source);
|
||||
}
|
||||
|
||||
range(row: DistinctiveRow): string {
|
||||
return `${Number(row.distinctiveFrom).toLocaleString('en-IN')} – ${Number(row.distinctiveTo).toLocaleString('en-IN')}`;
|
||||
}
|
||||
|
||||
clearFilters(): void {
|
||||
this.search = '';
|
||||
this.productFilter = '';
|
||||
this.shareNumberLookup = null;
|
||||
}
|
||||
|
||||
openAccount(row: DistinctiveRow): void {
|
||||
this.router.navigate([
|
||||
'/clients',
|
||||
row.clientId,
|
||||
'shares-accounts',
|
||||
row.shareAccountId,
|
||||
'general'
|
||||
]);
|
||||
}
|
||||
|
||||
exportCsv(): void {
|
||||
shareReportCsv(
|
||||
'share-distinctive-numbers',
|
||||
[
|
||||
'Share Class',
|
||||
'Distinctive From',
|
||||
'Distinctive To',
|
||||
'Shares',
|
||||
'Member',
|
||||
'Membership No',
|
||||
'Share A/c No',
|
||||
'Branch',
|
||||
'Allotment Date',
|
||||
'Funded By',
|
||||
'Board Resolution'
|
||||
],
|
||||
this.filteredRows.map((r) => [
|
||||
r.productName,
|
||||
r.distinctiveFrom,
|
||||
r.distinctiveTo,
|
||||
r.shares,
|
||||
r.clientName,
|
||||
r.cifNumber,
|
||||
r.accountNo,
|
||||
r.officeName,
|
||||
r.allotmentDate,
|
||||
this.funding(r.fundingSource),
|
||||
r.resolutionNumber
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
print(): void {
|
||||
shareReportPrint();
|
||||
}
|
||||
|
||||
private readError(err: any, fallback: string): string {
|
||||
return (
|
||||
err?.error?.errors?.[0]?.defaultUserMessage ||
|
||||
err?.error?.defaultUserMessage ||
|
||||
err?.error?.developerMessage ||
|
||||
fallback
|
||||
);
|
||||
}
|
||||
}
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="cert-root">
|
||||
<div class="cert-head">
|
||||
<div class="cert-head-l">
|
||||
<fa-icon icon="dollar-sign" class="cert-head-ic"></fa-icon>
|
||||
<div>
|
||||
<h2 class="cert-head-t">Dividend Statement</h2>
|
||||
<div class="cert-head-s">
|
||||
Every dividend declared to a member, year by year, with what has been paid and what is still owed.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-head-r">
|
||||
<button class="cert-cta-btn" (click)="exportCsv()" [disabled]="!member || !rows.length">
|
||||
<fa-icon icon="file-alt"></fa-icon> Export CSV
|
||||
</button>
|
||||
<button class="cert-cta-btn" (click)="print()" [disabled]="!member">
|
||||
<fa-icon icon="print"></fa-icon> Print
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cert-filters">
|
||||
<div class="cert-filter" style="flex: 2 1 340px">
|
||||
<label>Member name, membership number or share account number</label>
|
||||
<div class="cert-input-ic">
|
||||
<fa-icon icon="search"></fa-icon>
|
||||
<input type="text" [(ngModel)]="search" (keyup.enter)="runSearch()" placeholder="e.g. member name" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-filter">
|
||||
<label> </label>
|
||||
<button class="cert-cta-btn" (click)="runSearch()" [disabled]="!search.trim() || searching">
|
||||
{{ searching ? 'Searching…' : 'Show statement' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (loadError) {
|
||||
<div class="srep-state srep-state--error"><fa-icon icon="exclamation-circle"></fa-icon> {{ loadError }}</div>
|
||||
} @else if (searching) {
|
||||
<div class="srep-state">Loading the statement…</div>
|
||||
} @else if (matches.length) {
|
||||
<div class="srep-state">{{ matches.length }} members match. Choose one:</div>
|
||||
<div class="srep-table-wrap">
|
||||
<table class="srep-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Member</th>
|
||||
<th>Membership No</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (m of matches; track m.clientId) {
|
||||
<tr class="srep-row--click" (click)="loadByClient(m.clientId)">
|
||||
<td>{{ m.clientName }}</td>
|
||||
<td class="srep-mono">{{ m.cifNumber || '—' }}</td>
|
||||
<td><span class="srep-pill srep-pill--blue">Open</span></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
} @else if (searched && !member) {
|
||||
<div class="srep-state">No member with a share account matches "{{ search }}".</div>
|
||||
} @else if (member) {
|
||||
<div class="srep-stmt-head">
|
||||
<div class="srep-stmt-grid">
|
||||
<div>
|
||||
<div class="srep-stmt-l">Member</div>
|
||||
<div class="srep-stmt-v">{{ member.clientName }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="srep-stmt-l">Membership No</div>
|
||||
<div class="srep-stmt-v srep-mono">{{ member.cifNumber || '—' }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="srep-stmt-l">Branch</div>
|
||||
<div class="srep-stmt-v">{{ member.officeName || '—' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (totals) {
|
||||
<div class="cert-kpis">
|
||||
<div class="cert-kpi cert-kpi--blue">
|
||||
<fa-icon icon="file-alt" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">{{ totals.declarationCount | number }}</div>
|
||||
<div class="cert-kpi-l">Declarations</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-kpi cert-kpi--teal">
|
||||
<fa-icon icon="hand-holding-usd" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">₹{{ totals.declared | number: '1.2-2' }}</div>
|
||||
<div class="cert-kpi-l">Total Declared</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-kpi cert-kpi--green">
|
||||
<fa-icon icon="check-circle" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">₹{{ totals.paid | number: '1.2-2' }}</div>
|
||||
<div class="cert-kpi-l">Paid</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-kpi" [ngClass]="totals.outstanding ? 'cert-kpi--amber' : 'cert-kpi--blue'">
|
||||
<fa-icon icon="sync" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">₹{{ totals.outstanding | number: '1.2-2' }}</div>
|
||||
<div class="cert-kpi-l">Still Owed</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!rows.length) {
|
||||
<div class="srep-state">No dividend has been declared to this member yet.</div>
|
||||
} @else {
|
||||
<div class="srep-table-wrap">
|
||||
<table class="srep-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Dividend Period</th>
|
||||
<th>Share A/c No</th>
|
||||
<th>Product</th>
|
||||
<th class="srep-num">Rate</th>
|
||||
<th class="srep-num">Shares Held Now</th>
|
||||
<th class="srep-num">Amount</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of rows; track row.dividendDetailId) {
|
||||
<tr>
|
||||
<td>{{ row.periodStart }} — {{ row.periodEnd }}</td>
|
||||
<td class="srep-mono">{{ row.accountNo }}</td>
|
||||
<td>{{ row.productName }}</td>
|
||||
<td class="srep-num">{{ row.ratePercentage ? (row.ratePercentage | number: '1.2-2') + '%' : '—' }}</td>
|
||||
<td class="srep-num">{{ row.sharesHeldNow | number }}</td>
|
||||
<td class="srep-num">₹{{ row.amount | number: '1.2-2' }}</td>
|
||||
<td>
|
||||
<span class="srep-pill" [ngClass]="statusClass(row.statusEnum)">{{
|
||||
statusLabel(row.statusEnum)
|
||||
}}</span>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="5">Total declared</td>
|
||||
<td class="srep-num">₹{{ totals?.declared | number: '1.2-2' }}</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
<div class="srep-state">Search for a member above to see their dividend statement.</div>
|
||||
}
|
||||
</div>
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { of } from 'rxjs';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
|
||||
import { SharesService } from '../../shares.service';
|
||||
import { SHARE_DIVIDEND_STATUS, shareReportCsv, shareReportPrint } from '../share-report.util';
|
||||
|
||||
/**
|
||||
* Dividend Statement — every dividend declared to one member, with what has been paid and what is
|
||||
* still owed.
|
||||
*
|
||||
* <p>
|
||||
* Handed to a member as their record, so declared and paid stay separate columns: "declared" and
|
||||
* "paid" are different answers to "have I had my dividend", and a statement showing one amount
|
||||
* cannot tell them apart.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-share-dividend-statement',
|
||||
templateUrl: './share-dividend-statement.component.html',
|
||||
styleUrls: [
|
||||
'../../../letter-mgmt/certificates/certificates.shared.scss',
|
||||
'../share-report.shared.scss'
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
FaIconComponent
|
||||
]
|
||||
})
|
||||
export class ShareDividendStatementComponent implements OnInit {
|
||||
private sharesService = inject(SharesService);
|
||||
private route = inject(ActivatedRoute);
|
||||
|
||||
search = '';
|
||||
searching = false;
|
||||
searched = false;
|
||||
loadError = '';
|
||||
|
||||
matches: any[] = [];
|
||||
member: any = null;
|
||||
rows: any[] = [];
|
||||
totals: any = null;
|
||||
|
||||
ngOnInit(): void {
|
||||
const clientId = this.route.snapshot.queryParamMap.get('clientId');
|
||||
if (clientId) {
|
||||
this.loadByClient(Number(clientId));
|
||||
}
|
||||
}
|
||||
|
||||
runSearch(): void {
|
||||
const term = this.search.trim();
|
||||
if (!term) {
|
||||
return;
|
||||
}
|
||||
this.fetch({ search: term });
|
||||
}
|
||||
|
||||
loadByClient(clientId: number): void {
|
||||
this.fetch({ clientId });
|
||||
}
|
||||
|
||||
private fetch(params: { search?: string; clientId?: number }): void {
|
||||
this.searching = true;
|
||||
this.loadError = '';
|
||||
this.reset();
|
||||
this.sharesService
|
||||
.getShareDividendStatement(params)
|
||||
.pipe(
|
||||
catchError((err) => {
|
||||
this.loadError = this.readError(err, 'The dividend statement could not be loaded.');
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
.subscribe((res: any) => {
|
||||
this.matches = res?.matches || [];
|
||||
this.member = res?.member || null;
|
||||
this.rows = res?.rows || [];
|
||||
this.totals = res?.totals || null;
|
||||
this.searching = false;
|
||||
this.searched = true;
|
||||
});
|
||||
}
|
||||
|
||||
private reset(): void {
|
||||
this.matches = [];
|
||||
this.member = null;
|
||||
this.rows = [];
|
||||
this.totals = null;
|
||||
}
|
||||
|
||||
statusLabel(status: number): string {
|
||||
return SHARE_DIVIDEND_STATUS[status]?.label || String(status ?? '');
|
||||
}
|
||||
|
||||
statusClass(status: number): string {
|
||||
return SHARE_DIVIDEND_STATUS[status]?.cls || 'srep-pill--grey';
|
||||
}
|
||||
|
||||
exportCsv(): void {
|
||||
if (!this.member) {
|
||||
return;
|
||||
}
|
||||
shareReportCsv(
|
||||
`dividend-statement-${this.member.cifNumber || this.member.clientId}`,
|
||||
[
|
||||
'Period From',
|
||||
'Period To',
|
||||
'Share A/c No',
|
||||
'Product',
|
||||
'Rate %',
|
||||
'Amount',
|
||||
'Status'
|
||||
],
|
||||
this.rows.map((r) => [
|
||||
r.periodStart,
|
||||
r.periodEnd,
|
||||
r.accountNo,
|
||||
r.productName,
|
||||
r.ratePercentage,
|
||||
r.amount,
|
||||
this.statusLabel(r.statusEnum)
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
print(): void {
|
||||
shareReportPrint();
|
||||
}
|
||||
|
||||
private readError(err: any, fallback: string): string {
|
||||
return (
|
||||
err?.error?.errors?.[0]?.defaultUserMessage ||
|
||||
err?.error?.defaultUserMessage ||
|
||||
err?.error?.developerMessage ||
|
||||
fallback
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="cert-root">
|
||||
<div class="cert-head">
|
||||
<div class="cert-head-l">
|
||||
<fa-icon icon="search" class="cert-head-ic"></fa-icon>
|
||||
<div>
|
||||
<h2 class="cert-head-t">Share Enquiry</h2>
|
||||
<div class="cert-head-s">
|
||||
One member's whole share position — accounts, movements, dividends and linked savings, in one answer.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-head-r">
|
||||
<button class="cert-cta-btn" (click)="exportCsv()" [disabled]="!member || !movements.length">
|
||||
<fa-icon icon="file-alt"></fa-icon> Export CSV
|
||||
</button>
|
||||
<button class="cert-cta-btn" (click)="print()" [disabled]="!member">
|
||||
<fa-icon icon="print"></fa-icon> Print
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cert-filters">
|
||||
<div class="cert-filter" style="flex: 2 1 340px">
|
||||
<label>Member name, membership number or share account number</label>
|
||||
<div class="cert-input-ic">
|
||||
<fa-icon icon="search"></fa-icon>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="search"
|
||||
(keyup.enter)="runSearch()"
|
||||
placeholder="e.g. Deepak Chouhan, 000000105 or 000000004"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-filter">
|
||||
<label> </label>
|
||||
<button class="cert-cta-btn" (click)="runSearch()" [disabled]="!search.trim() || searching">
|
||||
{{ searching ? 'Searching…' : 'Enquire' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (loadError) {
|
||||
<div class="srep-state srep-state--error"><fa-icon icon="exclamation-circle"></fa-icon> {{ loadError }}</div>
|
||||
} @else if (searching) {
|
||||
<div class="srep-state">Looking up the member…</div>
|
||||
} @else if (matches.length) {
|
||||
<!-- Several members answer the search — the clerk picks, rather than this guessing between them -->
|
||||
<div class="srep-state">{{ matches.length }} members match. Choose one:</div>
|
||||
<div class="srep-table-wrap">
|
||||
<table class="srep-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Member</th>
|
||||
<th>Membership No</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (m of matches; track m.clientId) {
|
||||
<tr class="srep-row--click" (click)="loadByClient(m.clientId)">
|
||||
<td>{{ m.clientName }}</td>
|
||||
<td class="srep-mono">{{ m.cifNumber || '—' }}</td>
|
||||
<td><span class="srep-pill srep-pill--blue">Open</span></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
} @else if (searched && !member) {
|
||||
<div class="srep-state">No member with a share account matches "{{ search }}".</div>
|
||||
} @else if (member) {
|
||||
<!-- Member identity -->
|
||||
<div class="srep-stmt-head">
|
||||
<div class="srep-stmt-grid">
|
||||
<div>
|
||||
<div class="srep-stmt-l">Member</div>
|
||||
<div class="srep-stmt-v">{{ member.clientName }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="srep-stmt-l">Membership No</div>
|
||||
<div class="srep-stmt-v srep-mono">{{ member.cifNumber || '—' }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="srep-stmt-l">Branch</div>
|
||||
<div class="srep-stmt-v">{{ member.officeName || '—' }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="srep-stmt-l">Mobile</div>
|
||||
<div class="srep-stmt-v">{{ member.mobileNo || '—' }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="srep-stmt-l">Member Since</div>
|
||||
<div class="srep-stmt-v">{{ member.memberSince || '—' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (totals) {
|
||||
<div class="cert-kpis">
|
||||
<div class="cert-kpi cert-kpi--green">
|
||||
<fa-icon icon="coins" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">{{ totals.sharesHeld | number }}</div>
|
||||
<div class="cert-kpi-l">Shares Held</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-kpi cert-kpi--teal">
|
||||
<fa-icon icon="balance-scale" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">₹{{ totals.shareValue | number: '1.2-2' }}</div>
|
||||
<div class="cert-kpi-l">Share Value</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-kpi cert-kpi--blue">
|
||||
<fa-icon icon="file-alt" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">{{ totals.accountCount | number }}</div>
|
||||
<div class="cert-kpi-l">Share Accounts</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-kpi cert-kpi--amber">
|
||||
<fa-icon icon="hand-holding-usd" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">₹{{ totals.dividendTotal | number: '1.2-2' }}</div>
|
||||
<div class="cert-kpi-l">Dividend Declared</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Accounts -->
|
||||
<div class="cert-filters">
|
||||
<div class="cert-filter" style="flex: 1 1 100%"><label>Share accounts</label></div>
|
||||
</div>
|
||||
<div class="srep-table-wrap">
|
||||
<table class="srep-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Share A/c No</th>
|
||||
<th>Product</th>
|
||||
<th>State</th>
|
||||
<th class="srep-num">Shares Held</th>
|
||||
<th class="srep-num">Pending</th>
|
||||
<th class="srep-num">Share Value</th>
|
||||
<th>Linked Savings</th>
|
||||
<th>Activated</th>
|
||||
<th class="srep-no-print"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (a of accounts; track a.shareAccountId) {
|
||||
<tr [ngClass]="stateRowClass(a.holdingState)">
|
||||
<td class="srep-mono">{{ a.accountNo }}</td>
|
||||
<td>{{ a.productName }}</td>
|
||||
<td>
|
||||
<span class="srep-pill" [ngClass]="stateClass(a.holdingState)" [title]="stateNote(a.holdingState)">{{
|
||||
stateLabel(a.holdingState)
|
||||
}}</span>
|
||||
</td>
|
||||
<td class="srep-num">
|
||||
{{ a.sharesHeld | number }}
|
||||
@if (a.holdingState === 'UNPAID') {
|
||||
<div class="srep-sub">{{ a.unpaidShares | number }} unpaid</div>
|
||||
}
|
||||
</td>
|
||||
<td class="srep-num">{{ a.pendingShares | number }}</td>
|
||||
<td class="srep-num">₹{{ a.shareValue | number: '1.2-2' }}</td>
|
||||
<td class="srep-mono">
|
||||
{{ a.linkedSavingsAccountNo || 'Not linked' }}
|
||||
@if (a.linkedSavingsAccountNo) {
|
||||
<div class="srep-scope-note" style="margin: 0">
|
||||
Balance ₹{{ a.linkedSavingsBalance | number: '1.2-2' }}
|
||||
</div>
|
||||
}
|
||||
</td>
|
||||
<td>{{ a.activatedDate || '—' }}</td>
|
||||
<td class="srep-no-print">
|
||||
<button class="cert-cta-btn" (click)="openStatement(a)">Statement</button>
|
||||
<button class="cert-cta-btn" (click)="openAccount(a)">Open</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Movements -->
|
||||
<div class="cert-filters" style="margin-top: 0.85rem">
|
||||
<div class="cert-filter" style="flex: 1 1 100%"><label>Share movements</label></div>
|
||||
</div>
|
||||
@if (!movements.length) {
|
||||
<div class="srep-state">No share movement recorded for this member.</div>
|
||||
} @else {
|
||||
<div class="srep-table-wrap">
|
||||
<table class="srep-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Share A/c No</th>
|
||||
<th>Particulars</th>
|
||||
<th class="srep-num">Shares</th>
|
||||
<th class="srep-num">Unit Price</th>
|
||||
<th class="srep-num">Amount</th>
|
||||
<th>Funded By</th>
|
||||
<th>Board Resolution</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (m of movements; track m.transactionId) {
|
||||
<tr>
|
||||
<td>{{ m.transactionDate }}</td>
|
||||
<td class="srep-mono">{{ m.accountNo }}</td>
|
||||
<td>
|
||||
<span class="srep-pill" [ngClass]="typeClass(m.typeEnum)">{{ typeLabel(m.typeEnum) }}</span>
|
||||
</td>
|
||||
<td class="srep-num">{{ m.shares | number }}</td>
|
||||
<td class="srep-num">{{ m.unitPrice | number: '1.2-2' }}</td>
|
||||
<td class="srep-num">₹{{ m.amount | number: '1.2-2' }}</td>
|
||||
<td>{{ funding(m.fundingSource) }}</td>
|
||||
<td class="srep-mono">{{ m.resolutionNumber || '—' }}</td>
|
||||
<td>
|
||||
<span class="srep-pill" [ngClass]="txnStatusClass(m.statusEnum)">{{
|
||||
txnStatusLabel(m.statusEnum)
|
||||
}}</span>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Dividends -->
|
||||
<div class="cert-filters" style="margin-top: 0.85rem">
|
||||
<div class="cert-filter" style="flex: 1 1 100%"><label>Dividends</label></div>
|
||||
</div>
|
||||
@if (!dividends.length) {
|
||||
<div class="srep-state">No dividend has been declared to this member yet.</div>
|
||||
} @else {
|
||||
<div class="srep-table-wrap">
|
||||
<table class="srep-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Period</th>
|
||||
<th>Share A/c No</th>
|
||||
<th>Product</th>
|
||||
<th class="srep-num">Rate</th>
|
||||
<th class="srep-num">Amount</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (d of dividends; track d.dividendDetailId) {
|
||||
<tr>
|
||||
<td>{{ d.periodStart }} — {{ d.periodEnd }}</td>
|
||||
<td class="srep-mono">{{ d.accountNo }}</td>
|
||||
<td>{{ d.productName }}</td>
|
||||
<td class="srep-num">{{ d.ratePercentage ? (d.ratePercentage | number: '1.2-2') + '%' : '—' }}</td>
|
||||
<td class="srep-num">₹{{ d.amount | number: '1.2-2' }}</td>
|
||||
<td>
|
||||
<span class="srep-pill" [ngClass]="dividendStatusClass(d.statusEnum)">{{
|
||||
dividendStatusLabel(d.statusEnum)
|
||||
}}</span>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="4">Total declared</td>
|
||||
<td class="srep-num">₹{{ totals?.dividendTotal | number: '1.2-2' }}</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
<div class="srep-state">Search for a member above to see their full share position.</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { of } from 'rxjs';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
|
||||
import { SharesService } from '../../shares.service';
|
||||
import {
|
||||
SHARE_TXN_STATUS,
|
||||
SHARE_TXN_TYPE,
|
||||
fundingLabel,
|
||||
holdingStateClass,
|
||||
holdingStateLabel,
|
||||
holdingStateNote,
|
||||
holdingStateRowClass,
|
||||
shareReportCsv,
|
||||
shareReportPrint
|
||||
} from '../share-report.util';
|
||||
|
||||
/** m_share_account_dividend_details.status — where a member's dividend has got to. */
|
||||
const DIVIDEND_STATUS: { [key: number]: { label: string; cls: string } } = {
|
||||
100: { label: 'Declared', cls: 'srep-pill--amber' },
|
||||
300: { label: 'Approved', cls: 'srep-pill--blue' },
|
||||
600: { label: 'Paid', cls: 'srep-pill--green' }
|
||||
};
|
||||
|
||||
/**
|
||||
* Share Enquiry — everything about one member's share position in a single answer: their accounts,
|
||||
* every movement, the dividends declared to them, and the savings account their money moves through.
|
||||
*
|
||||
* <p>
|
||||
* Built for the counter. Someone is standing in front of the clerk asking "how many shares do I
|
||||
* hold, when did I buy them, and what dividend am I owed" — three screens is three chances to read
|
||||
* the wrong member's row out loud, so this is one search and one page.
|
||||
*
|
||||
* <p>
|
||||
* A search matching several members returns the list rather than picking one: guessing between two
|
||||
* members with similar names is precisely the mistake this screen must not make.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-share-enquiry',
|
||||
templateUrl: './share-enquiry.component.html',
|
||||
styleUrls: [
|
||||
'../../../letter-mgmt/certificates/certificates.shared.scss',
|
||||
'../share-report.shared.scss'
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
FaIconComponent
|
||||
]
|
||||
})
|
||||
export class ShareEnquiryComponent implements OnInit {
|
||||
private sharesService = inject(SharesService);
|
||||
private route = inject(ActivatedRoute);
|
||||
private router = inject(Router);
|
||||
|
||||
search = '';
|
||||
searching = false;
|
||||
searched = false;
|
||||
loadError = '';
|
||||
|
||||
/** Populated when the search matched more than one member — the caller picks. */
|
||||
matches: any[] = [];
|
||||
|
||||
member: any = null;
|
||||
accounts: any[] = [];
|
||||
movements: any[] = [];
|
||||
dividends: any[] = [];
|
||||
totals: any = null;
|
||||
|
||||
ngOnInit(): void {
|
||||
const clientId = this.route.snapshot.queryParamMap.get('clientId');
|
||||
if (clientId) {
|
||||
this.loadByClient(Number(clientId));
|
||||
}
|
||||
}
|
||||
|
||||
runSearch(): void {
|
||||
const term = this.search.trim();
|
||||
if (!term) {
|
||||
return;
|
||||
}
|
||||
this.searching = true;
|
||||
this.loadError = '';
|
||||
this.reset();
|
||||
this.sharesService
|
||||
.getShareEnquiry({ search: term })
|
||||
.pipe(
|
||||
catchError((err) => {
|
||||
this.loadError = this.readError(err, 'The enquiry could not be run.');
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
.subscribe((res: any) => this.apply(res));
|
||||
}
|
||||
|
||||
loadByClient(clientId: number): void {
|
||||
this.searching = true;
|
||||
this.loadError = '';
|
||||
this.reset();
|
||||
this.sharesService
|
||||
.getShareEnquiry({ clientId })
|
||||
.pipe(
|
||||
catchError((err) => {
|
||||
this.loadError = this.readError(err, 'The member could not be loaded.');
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
.subscribe((res: any) => this.apply(res));
|
||||
}
|
||||
|
||||
private apply(res: any): void {
|
||||
this.matches = res?.matches || [];
|
||||
this.member = res?.member || null;
|
||||
this.accounts = res?.accounts || [];
|
||||
this.movements = res?.movements || [];
|
||||
this.dividends = res?.dividends || [];
|
||||
this.totals = res?.totals || null;
|
||||
this.searching = false;
|
||||
this.searched = true;
|
||||
}
|
||||
|
||||
private reset(): void {
|
||||
this.matches = [];
|
||||
this.member = null;
|
||||
this.accounts = [];
|
||||
this.movements = [];
|
||||
this.dividends = [];
|
||||
this.totals = null;
|
||||
}
|
||||
|
||||
typeLabel(typeEnum: number): string {
|
||||
return SHARE_TXN_TYPE[typeEnum]?.label || String(typeEnum ?? '');
|
||||
}
|
||||
|
||||
typeClass(typeEnum: number): string {
|
||||
return SHARE_TXN_TYPE[typeEnum]?.cls || 'srep-pill--grey';
|
||||
}
|
||||
|
||||
txnStatusLabel(statusEnum: number): string {
|
||||
return SHARE_TXN_STATUS[statusEnum]?.label || String(statusEnum ?? '');
|
||||
}
|
||||
|
||||
txnStatusClass(statusEnum: number): string {
|
||||
return SHARE_TXN_STATUS[statusEnum]?.cls || 'srep-pill--grey';
|
||||
}
|
||||
|
||||
dividendStatusLabel(status: number): string {
|
||||
return DIVIDEND_STATUS[status]?.label || String(status ?? '');
|
||||
}
|
||||
|
||||
dividendStatusClass(status: number): string {
|
||||
return DIVIDEND_STATUS[status]?.cls || 'srep-pill--grey';
|
||||
}
|
||||
|
||||
funding(source: string): string {
|
||||
return fundingLabel(source);
|
||||
}
|
||||
|
||||
stateLabel(state: string): string {
|
||||
return holdingStateLabel(state);
|
||||
}
|
||||
|
||||
stateClass(state: string): string {
|
||||
return holdingStateClass(state);
|
||||
}
|
||||
|
||||
stateRowClass(state: string): string {
|
||||
return holdingStateRowClass(state);
|
||||
}
|
||||
|
||||
stateNote(state: string): string {
|
||||
return holdingStateNote(state);
|
||||
}
|
||||
|
||||
openAccount(account: any): void {
|
||||
this.router.navigate([
|
||||
'/clients',
|
||||
this.member.clientId,
|
||||
'shares-accounts',
|
||||
account.shareAccountId,
|
||||
'general'
|
||||
]);
|
||||
}
|
||||
|
||||
openStatement(account: any): void {
|
||||
this.router.navigate(['/shares/reports/account-statement'], {
|
||||
queryParams: { shareAccountId: account.shareAccountId }
|
||||
});
|
||||
}
|
||||
|
||||
exportCsv(): void {
|
||||
if (!this.member) {
|
||||
return;
|
||||
}
|
||||
shareReportCsv(
|
||||
`share-enquiry-${this.member.cifNumber || this.member.clientId}`,
|
||||
[
|
||||
'Date',
|
||||
'Share A/c No',
|
||||
'Particulars',
|
||||
'Shares',
|
||||
'Unit Price',
|
||||
'Amount',
|
||||
'Funded By',
|
||||
'Board Resolution',
|
||||
'Status'
|
||||
],
|
||||
this.movements.map((m) => [
|
||||
m.transactionDate,
|
||||
m.accountNo,
|
||||
this.typeLabel(m.typeEnum),
|
||||
m.shares,
|
||||
m.unitPrice,
|
||||
m.amount,
|
||||
this.funding(m.fundingSource),
|
||||
m.resolutionNumber,
|
||||
this.txnStatusLabel(m.statusEnum)
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
print(): void {
|
||||
shareReportPrint();
|
||||
}
|
||||
|
||||
private readError(err: any, fallback: string): string {
|
||||
return (
|
||||
err?.error?.errors?.[0]?.defaultUserMessage ||
|
||||
err?.error?.defaultUserMessage ||
|
||||
err?.error?.developerMessage ||
|
||||
fallback
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="cert-root">
|
||||
<div class="cert-head">
|
||||
<div class="cert-head-l">
|
||||
<fa-icon icon="chart-line" class="cert-head-ic"></fa-icon>
|
||||
<div>
|
||||
<h2 class="cert-head-t">Share Final Balance</h2>
|
||||
<div class="cert-head-s">
|
||||
Share capital held by each member as on a date — purchases less redemptions, settled only.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-head-r">
|
||||
<button class="cert-cta-btn" (click)="exportCsv()" [disabled]="loading || !filteredRows.length">
|
||||
<fa-icon icon="file-alt"></fa-icon> Export CSV
|
||||
</button>
|
||||
<button class="cert-cta-btn" (click)="print()" [disabled]="loading || !filteredRows.length">
|
||||
<fa-icon icon="print"></fa-icon> Print
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- KPI strip — always the society-wide figures the server returned, never the filtered view -->
|
||||
@if (totals) {
|
||||
<div class="cert-kpis">
|
||||
<div class="cert-kpi cert-kpi--blue">
|
||||
<fa-icon icon="users" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">{{ totals.memberCount | number }}</div>
|
||||
<div class="cert-kpi-l">Members Holding</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-kpi cert-kpi--green">
|
||||
<fa-icon icon="coins" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">{{ totals.sharesHeld | number }}</div>
|
||||
<div class="cert-kpi-l">Shares Held</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-kpi cert-kpi--teal">
|
||||
<fa-icon icon="balance-scale" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">₹{{ totals.shareValue | number: '1.2-2' }}</div>
|
||||
<div class="cert-kpi-l">Share Capital</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-kpi cert-kpi--amber">
|
||||
<fa-icon icon="undo" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">₹{{ totals.amountRedeemed | number: '1.2-2' }}</div>
|
||||
<div class="cert-kpi-l">Redeemed To Date</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Allotted but unpaid — excluded from the capital above, said out loud so the exclusion is not
|
||||
a silent one, and because an allotment sitting unpaid is itself worth chasing. -->
|
||||
@if (hasUnpaidAllotments) {
|
||||
<div class="srep-warn">
|
||||
<fa-icon icon="exclamation-circle"></fa-icon>
|
||||
<span>
|
||||
<b>{{ unpaidAllotments.accountCount | number }}</b>
|
||||
{{ unpaidAllotments.accountCount === 1 ? 'account has' : 'accounts have' }} shares allotted but not yet paid for
|
||||
— <b>{{ unpaidAllotments.shares | number }}</b> shares, <b>₹{{ unpaidAllotments.amount | number: '1.2-2' }}</b
|
||||
>. These are excluded above: share capital is what the society has received, not what it has agreed to issue.
|
||||
Apply the member's collection to bring them in.
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="cert-filters">
|
||||
<div class="cert-filter">
|
||||
<label>As on date</label>
|
||||
<input type="date" [(ngModel)]="asOnDate" (change)="load()" />
|
||||
</div>
|
||||
|
||||
<div class="cert-filter">
|
||||
<label>Member / Membership No / A/c No</label>
|
||||
<div class="cert-input-ic">
|
||||
<fa-icon icon="search"></fa-icon>
|
||||
<input type="text" [(ngModel)]="search" placeholder="e.g. member name or 000000004" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cert-filter">
|
||||
<label>Branch</label>
|
||||
<select [(ngModel)]="branchFilter">
|
||||
<option value="">All branches</option>
|
||||
@for (b of branchOptions; track b) {
|
||||
<option [value]="b">{{ b }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="cert-filter">
|
||||
<label>Share product</label>
|
||||
<select [(ngModel)]="productFilter">
|
||||
<option value="">All products</option>
|
||||
@for (p of productOptions; track p) {
|
||||
<option [value]="p">{{ p }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="cert-filter">
|
||||
<label>Nil holdings</label>
|
||||
<select [(ngModel)]="includeZero" (change)="load()">
|
||||
<option [ngValue]="false">Hide fully redeemed</option>
|
||||
<option [ngValue]="true">Include fully redeemed</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="cert-filter">
|
||||
<label> </label>
|
||||
<button class="cert-cta-btn" (click)="clearFilters()">Clear filters</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (loading) {
|
||||
<div class="srep-state">Loading share balances…</div>
|
||||
} @else if (loadError) {
|
||||
<div class="srep-state srep-state--error"><fa-icon icon="exclamation-circle"></fa-icon> {{ loadError }}</div>
|
||||
} @else if (!rows.length) {
|
||||
<div class="srep-state">No member held share capital as on {{ asOnDate }}.</div>
|
||||
} @else if (!filteredRows.length) {
|
||||
<div class="srep-state">No member matches the filters applied.</div>
|
||||
} @else {
|
||||
<div class="srep-table-wrap">
|
||||
<table class="srep-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Member</th>
|
||||
<th>Membership No</th>
|
||||
<th>Share A/c No</th>
|
||||
<th>Branch</th>
|
||||
<th>Product</th>
|
||||
<th>State</th>
|
||||
<th class="srep-num">Shares Held</th>
|
||||
<th class="srep-num">Unit Price</th>
|
||||
<th class="srep-num">Share Value</th>
|
||||
<th class="srep-num">Purchased</th>
|
||||
<th class="srep-num">Redeemed</th>
|
||||
<th>Last Movement</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of filteredRows; track row.shareAccountId) {
|
||||
<tr class="srep-row--click" [ngClass]="stateRowClass(row.holdingState)" (click)="openAccount(row)">
|
||||
<td>{{ row.clientName }}</td>
|
||||
<td class="srep-mono">{{ row.cifNumber || '—' }}</td>
|
||||
<td class="srep-mono">{{ row.accountNo }}</td>
|
||||
<td>{{ row.officeName || '—' }}</td>
|
||||
<td>{{ row.productName }}</td>
|
||||
<td>
|
||||
<span
|
||||
class="srep-pill"
|
||||
[ngClass]="stateClass(row.holdingState)"
|
||||
[title]="stateNote(row.holdingState)"
|
||||
>{{ stateLabel(row.holdingState) }}</span
|
||||
>
|
||||
</td>
|
||||
<td class="srep-num">
|
||||
{{ row.sharesHeld | number }}
|
||||
@if (row.holdingState === 'UNPAID') {
|
||||
<div class="srep-sub">{{ row.unpaidShares | number }} unpaid</div>
|
||||
}
|
||||
</td>
|
||||
<td class="srep-num">{{ row.unitPrice | number: '1.2-2' }}</td>
|
||||
<td class="srep-num">{{ row.shareValue | number: '1.2-2' }}</td>
|
||||
<td class="srep-num">{{ row.sharesPurchased | number }}</td>
|
||||
<td class="srep-num">{{ row.sharesRedeemed | number }}</td>
|
||||
<td>{{ row.lastMovementDate || '—' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="6">
|
||||
{{ isFiltered ? 'Total (filtered)' : 'Total' }} — {{ filteredRows.length | number }} members
|
||||
</td>
|
||||
<td class="srep-num">{{ shownShares | number }}</td>
|
||||
<td class="srep-num"></td>
|
||||
<td class="srep-num">₹{{ shownValue | number: '1.2-2' }}</td>
|
||||
<td class="srep-num"></td>
|
||||
<td class="srep-num"></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@if (isFiltered && totals) {
|
||||
<div class="srep-scope-note">
|
||||
Showing a filtered view. Society-wide as on {{ asOnDate }}: {{ totals.sharesHeld | number }} shares held by
|
||||
{{ totals.memberCount | number }} members, ₹{{ totals.shareValue | number: '1.2-2' }} share capital.
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { of } from 'rxjs';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
|
||||
import { SharesService } from '../../shares.service';
|
||||
import { SettingsService } from 'app/settings/settings.service';
|
||||
import {
|
||||
SHARE_ACCOUNT_STATUS,
|
||||
holdingStateClass,
|
||||
holdingStateLabel,
|
||||
holdingStateNote,
|
||||
holdingStateRowClass,
|
||||
shareReportCsv,
|
||||
shareReportPrint
|
||||
} from '../share-report.util';
|
||||
|
||||
/** One member's closing shareholding. Shapes the row the server sends; nothing is derived here. */
|
||||
interface FinalBalanceRow {
|
||||
shareAccountId: number;
|
||||
accountNo: string;
|
||||
clientId: number;
|
||||
cifNumber: string;
|
||||
clientName: string;
|
||||
officeId: number;
|
||||
officeName: string;
|
||||
productId: number;
|
||||
productName: string;
|
||||
currencyCode: string;
|
||||
statusEnum: number;
|
||||
holdingState: string;
|
||||
unpaidShares: number;
|
||||
unitPrice: number;
|
||||
sharesHeld: number;
|
||||
shareValue: number;
|
||||
sharesPurchased: number;
|
||||
sharesRedeemed: number;
|
||||
amountSubscribed: number;
|
||||
amountRedeemed: number;
|
||||
firstMovementDate: string;
|
||||
lastMovementDate: string;
|
||||
activatedDate: string;
|
||||
closedDate: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Final Balance — what each member's share capital stands at on a given date, and what the society's
|
||||
* Share Capital account should therefore read.
|
||||
*
|
||||
* <p>
|
||||
* The date is the whole point of the report, so it is asked of the server rather than filtered in the
|
||||
* browser: a holding "as on 31 March" means walking that member's purchases and redemptions up to
|
||||
* that date, which the rows already on screen cannot answer. Changing the date re-queries.
|
||||
*
|
||||
* <p>
|
||||
* Branch, product and search are client-side, because they only ever narrow rows already fetched and
|
||||
* a round trip for each keystroke would be worse than useless.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-share-final-balance',
|
||||
templateUrl: './share-final-balance.component.html',
|
||||
styleUrls: [
|
||||
'../../../letter-mgmt/certificates/certificates.shared.scss',
|
||||
'../share-report.shared.scss'
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
FaIconComponent
|
||||
]
|
||||
})
|
||||
export class ShareFinalBalanceComponent implements OnInit {
|
||||
private sharesService = inject(SharesService);
|
||||
private settingsService = inject(SettingsService);
|
||||
private router = inject(Router);
|
||||
|
||||
loading = true;
|
||||
loadError = '';
|
||||
rows: FinalBalanceRow[] = [];
|
||||
totals: any = null;
|
||||
|
||||
/**
|
||||
* Shares allotted to a member whose money never arrived — the account was activated but its
|
||||
* collection was never applied.
|
||||
*
|
||||
* Deliberately kept out of the balance (share capital is what the society received, not what it
|
||||
* promised to issue) and deliberately shown anyway: a total that silently drops accounts leaves a
|
||||
* reader wondering which, and an allotment sitting unpaid is the thing worth chasing.
|
||||
*/
|
||||
unpaidAllotments: { accountCount: number; shares: number; amount: number } | null = null;
|
||||
|
||||
/** Server-side filter — changing it re-queries, since the balance itself depends on it. */
|
||||
asOnDate = '';
|
||||
/** Closed and fully-redeemed members, off by default: a closing-balance report lists holders. */
|
||||
includeZero = false;
|
||||
|
||||
/** Client-side filters. */
|
||||
search = '';
|
||||
branchFilter = '';
|
||||
productFilter = '';
|
||||
|
||||
ngOnInit(): void {
|
||||
const businessDate: Date = this.settingsService.businessDate || new Date();
|
||||
this.asOnDate = this.toIso(businessDate);
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading = true;
|
||||
this.loadError = '';
|
||||
this.sharesService
|
||||
.getShareFinalBalance({ asOnDate: this.asOnDate, includeZero: this.includeZero })
|
||||
.pipe(
|
||||
catchError((err) => {
|
||||
this.loadError = this.readError(err, 'The share balance report could not be loaded.');
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
.subscribe((res: any) => {
|
||||
this.rows = res?.rows || [];
|
||||
this.totals = res?.totals || null;
|
||||
this.unpaidAllotments = res?.unpaidAllotments || null;
|
||||
this.loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
get hasUnpaidAllotments(): boolean {
|
||||
return !!(this.unpaidAllotments && Number(this.unpaidAllotments.accountCount) > 0);
|
||||
}
|
||||
|
||||
stateLabel(state: string): string {
|
||||
return holdingStateLabel(state);
|
||||
}
|
||||
|
||||
stateClass(state: string): string {
|
||||
return holdingStateClass(state);
|
||||
}
|
||||
|
||||
stateRowClass(state: string): string {
|
||||
return holdingStateRowClass(state);
|
||||
}
|
||||
|
||||
stateNote(state: string): string {
|
||||
return holdingStateNote(state);
|
||||
}
|
||||
|
||||
get branchOptions(): string[] {
|
||||
return [...new Set(this.rows.map((r) => r.officeName).filter(Boolean))].sort();
|
||||
}
|
||||
|
||||
get productOptions(): string[] {
|
||||
return [...new Set(this.rows.map((r) => r.productName).filter(Boolean))].sort();
|
||||
}
|
||||
|
||||
get filteredRows(): FinalBalanceRow[] {
|
||||
const term = this.search.trim().toLowerCase();
|
||||
return this.rows.filter((r) => {
|
||||
if (this.branchFilter && r.officeName !== this.branchFilter) {
|
||||
return false;
|
||||
}
|
||||
if (this.productFilter && r.productName !== this.productFilter) {
|
||||
return false;
|
||||
}
|
||||
if (!term) {
|
||||
return true;
|
||||
}
|
||||
return [
|
||||
r.clientName,
|
||||
r.accountNo,
|
||||
r.cifNumber,
|
||||
r.productName
|
||||
]
|
||||
.filter(Boolean)
|
||||
.some((field) => String(field).toLowerCase().includes(term));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Totals for what is on screen. The server's own totals cover the unfiltered report and are shown
|
||||
* beside these, so a narrowed view can never be mistaken for the society's whole share capital.
|
||||
*/
|
||||
get shownShares(): number {
|
||||
return this.filteredRows.reduce((sum, r) => sum + Number(r.sharesHeld || 0), 0);
|
||||
}
|
||||
|
||||
get shownValue(): number {
|
||||
return this.filteredRows.reduce((sum, r) => sum + Number(r.shareValue || 0), 0);
|
||||
}
|
||||
|
||||
get isFiltered(): boolean {
|
||||
return !!(this.search.trim() || this.branchFilter || this.productFilter);
|
||||
}
|
||||
|
||||
statusLabel(statusEnum: number): string {
|
||||
return SHARE_ACCOUNT_STATUS[statusEnum]?.label || String(statusEnum ?? '');
|
||||
}
|
||||
|
||||
clearFilters(): void {
|
||||
this.search = '';
|
||||
this.branchFilter = '';
|
||||
this.productFilter = '';
|
||||
}
|
||||
|
||||
openAccount(row: FinalBalanceRow): void {
|
||||
this.router.navigate([
|
||||
'/clients',
|
||||
row.clientId,
|
||||
'shares-accounts',
|
||||
row.shareAccountId,
|
||||
'general'
|
||||
]);
|
||||
}
|
||||
|
||||
exportCsv(): void {
|
||||
shareReportCsv(
|
||||
`share-final-balance-${this.asOnDate || 'today'}`,
|
||||
[
|
||||
'Member',
|
||||
'Membership No',
|
||||
'Share A/c No',
|
||||
'Branch',
|
||||
'Product',
|
||||
'Status',
|
||||
'Holding State',
|
||||
'Allotted Unpaid',
|
||||
'Shares Held',
|
||||
'Unit Price',
|
||||
'Share Value',
|
||||
'Shares Purchased',
|
||||
'Shares Redeemed',
|
||||
'Last Movement'
|
||||
],
|
||||
this.filteredRows.map((r) => [
|
||||
r.clientName,
|
||||
r.cifNumber,
|
||||
r.accountNo,
|
||||
r.officeName,
|
||||
r.productName,
|
||||
this.statusLabel(r.statusEnum),
|
||||
this.stateLabel(r.holdingState),
|
||||
r.unpaidShares,
|
||||
r.sharesHeld,
|
||||
r.unitPrice,
|
||||
r.shareValue,
|
||||
r.sharesPurchased,
|
||||
r.sharesRedeemed,
|
||||
r.lastMovementDate
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
print(): void {
|
||||
shareReportPrint();
|
||||
}
|
||||
|
||||
private toIso(date: Date): string {
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
private readError(err: any, fallback: string): string {
|
||||
return (
|
||||
err?.error?.errors?.[0]?.defaultUserMessage ||
|
||||
err?.error?.defaultUserMessage ||
|
||||
err?.error?.developerMessage ||
|
||||
fallback
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="cert-root">
|
||||
<div class="cert-head">
|
||||
<div class="cert-head-l">
|
||||
<fa-icon icon="file-alt" class="cert-head-ic"></fa-icon>
|
||||
<div>
|
||||
<h2 class="cert-head-t">Folio Statement</h2>
|
||||
<div class="cert-head-s">
|
||||
A member's folio in the share register — every account they hold, with the movements running across all of
|
||||
them.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-head-r">
|
||||
<button class="cert-cta-btn" (click)="exportCsv()" [disabled]="!folio || !lines.length">
|
||||
<fa-icon icon="file-alt"></fa-icon> Export CSV
|
||||
</button>
|
||||
<button class="cert-cta-btn" (click)="print()" [disabled]="!folio"><fa-icon icon="print"></fa-icon> Print</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cert-filters">
|
||||
<div class="cert-filter" style="flex: 2 1 320px">
|
||||
<label>Member name, membership number or share account number</label>
|
||||
<div class="cert-input-ic">
|
||||
<fa-icon icon="search"></fa-icon>
|
||||
<input type="text" [(ngModel)]="search" (keyup.enter)="runSearch()" placeholder="e.g. member name" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-filter">
|
||||
<label> </label>
|
||||
<button class="cert-cta-btn" (click)="runSearch()" [disabled]="!search.trim() || searching">
|
||||
{{ searching ? 'Searching…' : 'Open folio' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="cert-filter">
|
||||
<label>From</label>
|
||||
<input type="date" [(ngModel)]="fromDate" (change)="reloadPeriod()" [disabled]="!folio" />
|
||||
</div>
|
||||
<div class="cert-filter">
|
||||
<label>To</label>
|
||||
<input type="date" [(ngModel)]="toDate" (change)="reloadPeriod()" [disabled]="!folio" />
|
||||
</div>
|
||||
<div class="cert-filter">
|
||||
<label> </label>
|
||||
<button class="cert-cta-btn" (click)="clearPeriod()" [disabled]="!folio">Full history</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (loadError) {
|
||||
<div class="srep-state srep-state--error"><fa-icon icon="exclamation-circle"></fa-icon> {{ loadError }}</div>
|
||||
} @else if (searching) {
|
||||
<div class="srep-state">Loading the folio…</div>
|
||||
} @else if (matches.length) {
|
||||
<div class="srep-state">{{ matches.length }} members match. Choose one:</div>
|
||||
<div class="srep-table-wrap">
|
||||
<table class="srep-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Member</th>
|
||||
<th>Folio No</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (m of matches; track m.clientId) {
|
||||
<tr class="srep-row--click" (click)="loadByClient(m.clientId)">
|
||||
<td>{{ m.clientName }}</td>
|
||||
<td class="srep-mono">{{ m.cifNumber || '—' }}</td>
|
||||
<td><span class="srep-pill srep-pill--blue">Open</span></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
} @else if (searched && !folio) {
|
||||
<div class="srep-state">No member with a share account matches "{{ search }}".</div>
|
||||
} @else if (folio) {
|
||||
<div class="srep-stmt-head">
|
||||
<div class="srep-stmt-grid">
|
||||
<div>
|
||||
<div class="srep-stmt-l">Folio No</div>
|
||||
<div class="srep-stmt-v srep-mono">{{ folio.folioNumber || '—' }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="srep-stmt-l">Member</div>
|
||||
<div class="srep-stmt-v">{{ folio.clientName }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="srep-stmt-l">Branch</div>
|
||||
<div class="srep-stmt-v">{{ folio.officeName || '—' }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="srep-stmt-l">Mobile</div>
|
||||
<div class="srep-stmt-v">{{ folio.mobileNo || '—' }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="srep-stmt-l">Member Since</div>
|
||||
<div class="srep-stmt-v">{{ folio.memberSince || '—' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (totals) {
|
||||
<div class="cert-kpis">
|
||||
<div class="cert-kpi cert-kpi--blue">
|
||||
<fa-icon icon="file-alt" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">{{ totals.accountCount | number }}</div>
|
||||
<div class="cert-kpi-l">Accounts In Folio</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-kpi cert-kpi--green">
|
||||
<fa-icon icon="coins" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">{{ totals.sharesHeld | number }}</div>
|
||||
<div class="cert-kpi-l">Shares Held</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-kpi cert-kpi--teal">
|
||||
<fa-icon icon="balance-scale" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">₹{{ totals.shareValue | number: '1.2-2' }}</div>
|
||||
<div class="cert-kpi-l">Folio Value</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-kpi cert-kpi--amber">
|
||||
<fa-icon icon="exchange" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">{{ totals.movementCount | number }}</div>
|
||||
<div class="cert-kpi-l">Movements Shown</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Accounts in this folio -->
|
||||
<div class="cert-filters">
|
||||
<div class="cert-filter" style="flex: 1 1 100%"><label>Accounts in this folio</label></div>
|
||||
</div>
|
||||
<div class="srep-table-wrap">
|
||||
<table class="srep-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Share A/c No</th>
|
||||
<th>Product</th>
|
||||
<th>State</th>
|
||||
<th class="srep-num">Shares Held</th>
|
||||
<th class="srep-num">Unit Price</th>
|
||||
<th class="srep-num">Value</th>
|
||||
<th>Activated</th>
|
||||
<th>Closed</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (a of accounts; track a.shareAccountId) {
|
||||
<tr class="srep-row--click" [ngClass]="stateRowClass(a.holdingState)" (click)="openAccount(a)">
|
||||
<td class="srep-mono">{{ a.accountNo }}</td>
|
||||
<td>{{ a.productName }}</td>
|
||||
<td>
|
||||
<span class="srep-pill" [ngClass]="stateClass(a.holdingState)" [title]="stateNote(a.holdingState)">{{
|
||||
stateLabel(a.holdingState)
|
||||
}}</span>
|
||||
</td>
|
||||
<td class="srep-num">
|
||||
{{ a.sharesHeld | number }}
|
||||
@if (a.holdingState === 'UNPAID') {
|
||||
<div class="srep-sub">{{ a.unpaidShares | number }} unpaid</div>
|
||||
}
|
||||
</td>
|
||||
<td class="srep-num">{{ a.unitPrice | number: '1.2-2' }}</td>
|
||||
<td class="srep-num">₹{{ a.shareValue | number: '1.2-2' }}</td>
|
||||
<td>{{ a.activatedDate || '—' }}</td>
|
||||
<td>{{ a.closedDate || '—' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="3">Folio total</td>
|
||||
<td class="srep-num">{{ totals?.sharesHeld | number }}</td>
|
||||
<td class="srep-num"></td>
|
||||
<td class="srep-num">₹{{ totals?.shareValue | number: '1.2-2' }}</td>
|
||||
<td colspan="2"></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Movements across the whole folio -->
|
||||
<div class="cert-filters" style="margin-top: 0.85rem">
|
||||
<div class="cert-filter" style="flex: 1 1 100%"><label>Folio movements</label></div>
|
||||
</div>
|
||||
@if (!lines.length) {
|
||||
<div class="srep-state">
|
||||
No settled movement in this folio{{ fromDate || toDate ? ' in the selected period' : '' }}.
|
||||
</div>
|
||||
} @else {
|
||||
<div class="srep-table-wrap">
|
||||
<table class="srep-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Share A/c No</th>
|
||||
<th>Product</th>
|
||||
<th>Particulars</th>
|
||||
<th class="srep-num">Shares In</th>
|
||||
<th class="srep-num">Shares Out</th>
|
||||
<th class="srep-num">Amount</th>
|
||||
<th>Funded By</th>
|
||||
<th class="srep-num">Folio Balance</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (line of lines; track line.transactionId) {
|
||||
<tr>
|
||||
<td>{{ line.transactionDate }}</td>
|
||||
<td class="srep-mono">{{ line.accountNo }}</td>
|
||||
<td>{{ line.productName }}</td>
|
||||
<td>
|
||||
<span class="srep-pill" [ngClass]="typeClass(line.typeEnum)">{{ typeLabel(line.typeEnum) }}</span>
|
||||
</td>
|
||||
<td class="srep-num">{{ line.sharesIn ? (line.sharesIn | number) : '' }}</td>
|
||||
<td class="srep-num">{{ line.sharesOut ? (line.sharesOut | number) : '' }}</td>
|
||||
<td class="srep-num">₹{{ line.amount | number: '1.2-2' }}</td>
|
||||
<td>{{ funding(line.fundingSource) }}</td>
|
||||
<td class="srep-num">{{ line.folioBalance | number }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="8">Closing folio balance</td>
|
||||
<td class="srep-num">{{ totals?.closingFolioBalance | number }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
<div class="srep-state">Search for a member above to open their folio.</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { of } from 'rxjs';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
|
||||
import { SharesService } from '../../shares.service';
|
||||
import {
|
||||
SHARE_TXN_TYPE,
|
||||
fundingLabel,
|
||||
holdingStateClass,
|
||||
holdingStateLabel,
|
||||
holdingStateNote,
|
||||
holdingStateRowClass,
|
||||
shareReportCsv,
|
||||
shareReportPrint
|
||||
} from '../share-report.util';
|
||||
|
||||
/**
|
||||
* Folio Statement — one member's folio in the share register: every account they hold and every
|
||||
* movement across all of them, in one running sequence.
|
||||
*
|
||||
* <p>
|
||||
* Where the Account Statement is one account, a folio is the member. Someone holding two share
|
||||
* classes has two accounts but one folio, and the register is kept by folio — so the running balance
|
||||
* here is across accounts, which is the figure the register itself carries.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-share-folio-statement',
|
||||
templateUrl: './share-folio-statement.component.html',
|
||||
styleUrls: [
|
||||
'../../../letter-mgmt/certificates/certificates.shared.scss',
|
||||
'../share-report.shared.scss'
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
FaIconComponent
|
||||
]
|
||||
})
|
||||
export class ShareFolioStatementComponent implements OnInit {
|
||||
private sharesService = inject(SharesService);
|
||||
private route = inject(ActivatedRoute);
|
||||
private router = inject(Router);
|
||||
|
||||
search = '';
|
||||
searching = false;
|
||||
searched = false;
|
||||
loadError = '';
|
||||
|
||||
matches: any[] = [];
|
||||
folio: any = null;
|
||||
accounts: any[] = [];
|
||||
lines: any[] = [];
|
||||
totals: any = null;
|
||||
|
||||
fromDate = '';
|
||||
toDate = '';
|
||||
|
||||
/** Held once a folio is open, so changing the period re-queries the same member. */
|
||||
private resolvedClientId: number | null = null;
|
||||
|
||||
ngOnInit(): void {
|
||||
const clientId = this.route.snapshot.queryParamMap.get('clientId');
|
||||
if (clientId) {
|
||||
this.resolvedClientId = Number(clientId);
|
||||
this.fetch({ clientId: this.resolvedClientId });
|
||||
}
|
||||
}
|
||||
|
||||
runSearch(): void {
|
||||
const term = this.search.trim();
|
||||
if (!term) {
|
||||
return;
|
||||
}
|
||||
this.resolvedClientId = null;
|
||||
this.fetch({ search: term });
|
||||
}
|
||||
|
||||
loadByClient(clientId: number): void {
|
||||
this.resolvedClientId = clientId;
|
||||
this.fetch({ clientId });
|
||||
}
|
||||
|
||||
reloadPeriod(): void {
|
||||
if (this.resolvedClientId) {
|
||||
this.fetch({ clientId: this.resolvedClientId });
|
||||
}
|
||||
}
|
||||
|
||||
clearPeriod(): void {
|
||||
this.fromDate = '';
|
||||
this.toDate = '';
|
||||
this.reloadPeriod();
|
||||
}
|
||||
|
||||
private fetch(params: { search?: string; clientId?: number }): void {
|
||||
this.searching = true;
|
||||
this.loadError = '';
|
||||
this.sharesService
|
||||
.getShareFolioStatement({ ...params, fromDate: this.fromDate, toDate: this.toDate })
|
||||
.pipe(
|
||||
catchError((err) => {
|
||||
this.loadError = this.readError(err, 'The folio statement could not be loaded.');
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
.subscribe((res: any) => {
|
||||
this.matches = res?.matches || [];
|
||||
this.folio = res?.folio || null;
|
||||
this.accounts = res?.accounts || [];
|
||||
this.lines = res?.lines || [];
|
||||
this.totals = res?.totals || null;
|
||||
if (this.folio) {
|
||||
this.resolvedClientId = this.folio.clientId;
|
||||
}
|
||||
this.searching = false;
|
||||
this.searched = true;
|
||||
});
|
||||
}
|
||||
|
||||
typeLabel(typeEnum: number): string {
|
||||
return SHARE_TXN_TYPE[typeEnum]?.label || String(typeEnum ?? '');
|
||||
}
|
||||
|
||||
typeClass(typeEnum: number): string {
|
||||
return SHARE_TXN_TYPE[typeEnum]?.cls || 'srep-pill--grey';
|
||||
}
|
||||
|
||||
funding(source: string): string {
|
||||
return fundingLabel(source);
|
||||
}
|
||||
|
||||
stateLabel(state: string): string {
|
||||
return holdingStateLabel(state);
|
||||
}
|
||||
|
||||
stateClass(state: string): string {
|
||||
return holdingStateClass(state);
|
||||
}
|
||||
|
||||
stateRowClass(state: string): string {
|
||||
return holdingStateRowClass(state);
|
||||
}
|
||||
|
||||
stateNote(state: string): string {
|
||||
return holdingStateNote(state);
|
||||
}
|
||||
|
||||
openAccount(account: any): void {
|
||||
this.router.navigate([
|
||||
'/clients',
|
||||
this.folio.clientId,
|
||||
'shares-accounts',
|
||||
account.shareAccountId,
|
||||
'general'
|
||||
]);
|
||||
}
|
||||
|
||||
exportCsv(): void {
|
||||
if (!this.folio) {
|
||||
return;
|
||||
}
|
||||
shareReportCsv(
|
||||
`folio-statement-${this.folio.folioNumber || this.folio.clientId}`,
|
||||
[
|
||||
'Date',
|
||||
'Share A/c No',
|
||||
'Product',
|
||||
'Particulars',
|
||||
'Shares In',
|
||||
'Shares Out',
|
||||
'Unit Price',
|
||||
'Amount',
|
||||
'Funded By',
|
||||
'Board Resolution',
|
||||
'Folio Balance'
|
||||
],
|
||||
this.lines.map((l) => [
|
||||
l.transactionDate,
|
||||
l.accountNo,
|
||||
l.productName,
|
||||
this.typeLabel(l.typeEnum),
|
||||
l.sharesIn || '',
|
||||
l.sharesOut || '',
|
||||
l.unitPrice,
|
||||
l.amount,
|
||||
this.funding(l.fundingSource),
|
||||
l.resolutionNumber,
|
||||
l.folioBalance
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
print(): void {
|
||||
shareReportPrint();
|
||||
}
|
||||
|
||||
private readError(err: any, fallback: string): string {
|
||||
return (
|
||||
err?.error?.errors?.[0]?.defaultUserMessage ||
|
||||
err?.error?.defaultUserMessage ||
|
||||
err?.error?.developerMessage ||
|
||||
fallback
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="cert-root">
|
||||
<div class="cert-head">
|
||||
<div class="cert-head-l">
|
||||
<fa-icon icon="user-shield" class="cert-head-ic"></fa-icon>
|
||||
<div>
|
||||
<h2 class="cert-head-t">Guarantor Detail</h2>
|
||||
<div class="cert-head-s">
|
||||
Everyone standing surety on a live loan, with the shareholding they hold in the society.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-head-r">
|
||||
<button class="cert-cta-btn" (click)="exportCsv()" [disabled]="loading || !filteredRows.length">
|
||||
<fa-icon icon="file-alt"></fa-icon> Export CSV
|
||||
</button>
|
||||
<button class="cert-cta-btn" (click)="print()" [disabled]="loading || !filteredRows.length">
|
||||
<fa-icon icon="print"></fa-icon> Print
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (totals) {
|
||||
<div class="cert-kpis">
|
||||
<div class="cert-kpi cert-kpi--blue">
|
||||
<fa-icon icon="user-shield" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">{{ totals.guarantorCount | number }}</div>
|
||||
<div class="cert-kpi-l">Active Guarantors</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-kpi cert-kpi--teal">
|
||||
<fa-icon icon="balance-scale" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">₹{{ totals.guaranteedOutstanding | number: '1.2-2' }}</div>
|
||||
<div class="cert-kpi-l">Guaranteed Outstanding</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-kpi cert-kpi--green">
|
||||
<fa-icon icon="coins" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">₹{{ totals.guarantorShareValue | number: '1.2-2' }}</div>
|
||||
<div class="cert-kpi-l">Guarantors' Share Value</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-kpi" [ngClass]="totals.membersWithoutShares ? 'cert-kpi--amber' : 'cert-kpi--blue'">
|
||||
<fa-icon icon="exclamation-circle" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">{{ totals.membersWithoutShares | number }}</div>
|
||||
<div class="cert-kpi-l">Members Without Shares</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="cert-filters">
|
||||
<div class="cert-filter">
|
||||
<label>Guarantor / Borrower / Loan A/c</label>
|
||||
<div class="cert-input-ic">
|
||||
<fa-icon icon="search"></fa-icon>
|
||||
<input type="text" [(ngModel)]="search" placeholder="e.g. guarantor or borrower name" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-filter">
|
||||
<label>Guarantor type</label>
|
||||
<select [(ngModel)]="typeFilter">
|
||||
<option value="">All</option>
|
||||
<option value="MEMBER">Member</option>
|
||||
<option value="EXTERNAL">External</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="cert-filter">
|
||||
<label>Shareholding</label>
|
||||
<select [(ngModel)]="onlyWithoutShares">
|
||||
<option [ngValue]="false">All guarantors</option>
|
||||
<option [ngValue]="true">Members holding no shares</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="cert-filter">
|
||||
<label> </label>
|
||||
<button class="cert-cta-btn" (click)="clearFilters()">Clear filters</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (loading) {
|
||||
<div class="srep-state">Loading guarantors…</div>
|
||||
} @else if (loadError) {
|
||||
<div class="srep-state srep-state--error"><fa-icon icon="exclamation-circle"></fa-icon> {{ loadError }}</div>
|
||||
} @else if (!rows.length) {
|
||||
<div class="srep-state">No guarantor stands on a loan at present.</div>
|
||||
} @else if (!filteredRows.length) {
|
||||
<div class="srep-state">No guarantor matches the filters applied.</div>
|
||||
} @else {
|
||||
<div class="srep-table-wrap">
|
||||
<table class="srep-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Guarantor</th>
|
||||
<th>Type</th>
|
||||
<th>Membership No</th>
|
||||
<th>Borrower</th>
|
||||
<th>Loan A/c No</th>
|
||||
<th class="srep-num">Loan Outstanding</th>
|
||||
<th>Share A/c No</th>
|
||||
<th class="srep-num">Shares Held</th>
|
||||
<th class="srep-num">Share Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of filteredRows; track row.guarantorId) {
|
||||
<tr [class.srep-row--click]="row.shareAccountId" (click)="openGuarantorShares(row)">
|
||||
<td>
|
||||
{{ row.guarantorName || '—' }}
|
||||
@if (row.guarantorMobile) {
|
||||
<div class="srep-scope-note" style="margin: 0">{{ row.guarantorMobile }}</div>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
class="srep-pill"
|
||||
[ngClass]="row.guarantorType === 'MEMBER' ? 'srep-pill--green' : 'srep-pill--grey'"
|
||||
>{{ row.guarantorType === 'MEMBER' ? 'Member' : 'External' }}</span
|
||||
>
|
||||
</td>
|
||||
<td class="srep-mono">{{ row.guarantorCif || '—' }}</td>
|
||||
<td>{{ row.borrowerName }}</td>
|
||||
<td class="srep-mono">{{ row.loanAccountNo }}</td>
|
||||
<td class="srep-num">₹{{ row.totalOutstanding | number: '1.2-2' }}</td>
|
||||
<td class="srep-mono">{{ row.shareAccountNo || '—' }}</td>
|
||||
<td class="srep-num">
|
||||
@if (isUncovered(row)) {
|
||||
<span class="srep-pill srep-pill--amber">No shares</span>
|
||||
} @else {
|
||||
{{ row.sharesHeld | number }}
|
||||
}
|
||||
</td>
|
||||
<td class="srep-num">₹{{ row.shareValue | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="5">
|
||||
{{ isFiltered ? 'Total (filtered)' : 'Total' }} — {{ filteredRows.length | number }} guarantors
|
||||
</td>
|
||||
<td class="srep-num">₹{{ shownOutstanding | number: '1.2-2' }}</td>
|
||||
<td></td>
|
||||
<td class="srep-num"></td>
|
||||
<td class="srep-num">₹{{ shownShareValue | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { of } from 'rxjs';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
|
||||
import { SharesService } from '../../shares.service';
|
||||
import { shareReportCsv, shareReportPrint } from '../share-report.util';
|
||||
|
||||
interface GuarantorRow {
|
||||
guarantorId: number;
|
||||
loanId: number;
|
||||
loanAccountNo: string;
|
||||
loanStatusId: number;
|
||||
loanPrincipal: number;
|
||||
principalOutstanding: number;
|
||||
totalOutstanding: number;
|
||||
borrowerName: string;
|
||||
borrowerCif: string;
|
||||
guarantorTypeEnum: number;
|
||||
guarantorType: 'MEMBER' | 'EXTERNAL';
|
||||
guarantorName: string;
|
||||
guarantorCif: string;
|
||||
guarantorClientId: number;
|
||||
guarantorOfficeName: string;
|
||||
guarantorMobile: string;
|
||||
shareAccountId: number;
|
||||
shareAccountNo: string;
|
||||
shareProductName: string;
|
||||
sharesHeld: number;
|
||||
shareValue: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Guarantor Detail — everyone standing surety on a live loan, with the shareholding they hold in the
|
||||
* society.
|
||||
*
|
||||
* <p>
|
||||
* A co-operative accepts a member as surety on the strength of their stake in it, so the exposure
|
||||
* and the stake belong on one line. The case this report exists for is the member who stood surety
|
||||
* and has since redeemed their shares: nothing on the loan screen or the share screen shows it, and
|
||||
* it is flagged here rather than left to be noticed.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-share-guarantor-report',
|
||||
templateUrl: './share-guarantor-report.component.html',
|
||||
styleUrls: [
|
||||
'../../../letter-mgmt/certificates/certificates.shared.scss',
|
||||
'../share-report.shared.scss'
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
FaIconComponent
|
||||
]
|
||||
})
|
||||
export class ShareGuarantorReportComponent implements OnInit {
|
||||
private sharesService = inject(SharesService);
|
||||
private router = inject(Router);
|
||||
|
||||
loading = true;
|
||||
loadError = '';
|
||||
rows: GuarantorRow[] = [];
|
||||
totals: any = null;
|
||||
|
||||
search = '';
|
||||
typeFilter = '';
|
||||
/** Members standing surety with no shareholding left — the exposure worth isolating. */
|
||||
onlyWithoutShares = false;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading = true;
|
||||
this.loadError = '';
|
||||
this.sharesService
|
||||
.getShareGuarantorDetail({})
|
||||
.pipe(
|
||||
catchError((err) => {
|
||||
this.loadError = this.readError(err, 'The guarantor report could not be loaded.');
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
.subscribe((res: any) => {
|
||||
this.rows = res?.rows || [];
|
||||
this.totals = res?.totals || null;
|
||||
this.loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
get filteredRows(): GuarantorRow[] {
|
||||
const term = this.search.trim().toLowerCase();
|
||||
return this.rows.filter((r) => {
|
||||
if (this.typeFilter && r.guarantorType !== this.typeFilter) {
|
||||
return false;
|
||||
}
|
||||
if (this.onlyWithoutShares && !(r.guarantorType === 'MEMBER' && !Number(r.sharesHeld))) {
|
||||
return false;
|
||||
}
|
||||
if (!term) {
|
||||
return true;
|
||||
}
|
||||
return [
|
||||
r.guarantorName,
|
||||
r.guarantorCif,
|
||||
r.borrowerName,
|
||||
r.loanAccountNo,
|
||||
r.shareAccountNo
|
||||
]
|
||||
.filter(Boolean)
|
||||
.some((field) => String(field).toLowerCase().includes(term));
|
||||
});
|
||||
}
|
||||
|
||||
get shownOutstanding(): number {
|
||||
return this.filteredRows.reduce((sum, r) => sum + Number(r.totalOutstanding || 0), 0);
|
||||
}
|
||||
|
||||
get shownShareValue(): number {
|
||||
return this.filteredRows.reduce((sum, r) => sum + Number(r.shareValue || 0), 0);
|
||||
}
|
||||
|
||||
get isFiltered(): boolean {
|
||||
return !!(this.search.trim() || this.typeFilter || this.onlyWithoutShares);
|
||||
}
|
||||
|
||||
/** A member guarantor holding nothing is the exposure; an external one never held any. */
|
||||
isUncovered(row: GuarantorRow): boolean {
|
||||
return row.guarantorType === 'MEMBER' && !Number(row.sharesHeld);
|
||||
}
|
||||
|
||||
openGuarantorShares(row: GuarantorRow): void {
|
||||
if (row.guarantorClientId && row.shareAccountId) {
|
||||
this.router.navigate([
|
||||
'/clients',
|
||||
row.guarantorClientId,
|
||||
'shares-accounts',
|
||||
row.shareAccountId,
|
||||
'general'
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
clearFilters(): void {
|
||||
this.search = '';
|
||||
this.typeFilter = '';
|
||||
this.onlyWithoutShares = false;
|
||||
}
|
||||
|
||||
exportCsv(): void {
|
||||
shareReportCsv(
|
||||
'share-guarantor-detail',
|
||||
[
|
||||
'Guarantor',
|
||||
'Type',
|
||||
'Membership No',
|
||||
'Branch',
|
||||
'Mobile',
|
||||
'Borrower',
|
||||
'Loan A/c No',
|
||||
'Loan Outstanding',
|
||||
'Share A/c No',
|
||||
'Share Product',
|
||||
'Shares Held',
|
||||
'Share Value'
|
||||
],
|
||||
this.filteredRows.map((r) => [
|
||||
r.guarantorName,
|
||||
r.guarantorType,
|
||||
r.guarantorCif,
|
||||
r.guarantorOfficeName,
|
||||
r.guarantorMobile,
|
||||
r.borrowerName,
|
||||
r.loanAccountNo,
|
||||
r.totalOutstanding,
|
||||
r.shareAccountNo,
|
||||
r.shareProductName,
|
||||
r.sharesHeld,
|
||||
r.shareValue
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
print(): void {
|
||||
shareReportPrint();
|
||||
}
|
||||
|
||||
private readError(err: any, fallback: string): string {
|
||||
return (
|
||||
err?.error?.errors?.[0]?.defaultUserMessage ||
|
||||
err?.error?.defaultUserMessage ||
|
||||
err?.error?.developerMessage ||
|
||||
fallback
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="cert-root">
|
||||
<div class="cert-head">
|
||||
<div class="cert-head-l">
|
||||
<fa-icon icon="check-circle" class="cert-head-ic"></fa-icon>
|
||||
<div>
|
||||
<h2 class="cert-head-t">Share Issued Register</h2>
|
||||
<div class="cert-head-s">
|
||||
Every tranche of shares allotted in the period, with the board resolution behind it and how it was paid for.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-head-r">
|
||||
<button class="cert-cta-btn" (click)="exportCsv()" [disabled]="loading || !filteredRows.length">
|
||||
<fa-icon icon="file-alt"></fa-icon> Export CSV
|
||||
</button>
|
||||
<button class="cert-cta-btn" (click)="print()" [disabled]="loading || !filteredRows.length">
|
||||
<fa-icon icon="print"></fa-icon> Print
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (totals) {
|
||||
<div class="cert-kpis">
|
||||
<div class="cert-kpi cert-kpi--blue">
|
||||
<fa-icon icon="file-alt" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">{{ totals.trancheCount | number }}</div>
|
||||
<div class="cert-kpi-l">Allotments</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-kpi cert-kpi--green">
|
||||
<fa-icon icon="coins" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">{{ totals.shares | number }}</div>
|
||||
<div class="cert-kpi-l">Shares Issued</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-kpi cert-kpi--teal">
|
||||
<fa-icon icon="balance-scale" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">₹{{ totals.amount | number: '1.2-2' }}</div>
|
||||
<div class="cert-kpi-l">Capital Raised</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-kpi" [ngClass]="missingResolutionCount ? 'cert-kpi--amber' : 'cert-kpi--blue'">
|
||||
<fa-icon icon="exclamation-circle" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">{{ missingResolutionCount | number }}</div>
|
||||
<div class="cert-kpi-l">Without Resolution</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="cert-filters">
|
||||
<div class="cert-filter">
|
||||
<label>Issued from</label>
|
||||
<input type="date" [(ngModel)]="fromDate" (change)="load()" />
|
||||
</div>
|
||||
<div class="cert-filter">
|
||||
<label>Issued to</label>
|
||||
<input type="date" [(ngModel)]="toDate" (change)="load()" />
|
||||
</div>
|
||||
<div class="cert-filter">
|
||||
<label>Allotment status</label>
|
||||
<select [(ngModel)]="statusFilter" (change)="load()">
|
||||
<option [ngValue]="''">Settled allotments</option>
|
||||
<option [ngValue]="100">Pending approval</option>
|
||||
<option [ngValue]="400">Rejected</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="cert-filter">
|
||||
<label>Member / A/c No / Resolution</label>
|
||||
<div class="cert-input-ic">
|
||||
<fa-icon icon="search"></fa-icon>
|
||||
<input type="text" [(ngModel)]="search" placeholder="e.g. member name or BR-2026-14" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-filter">
|
||||
<label>Branch</label>
|
||||
<select [(ngModel)]="branchFilter">
|
||||
<option value="">All branches</option>
|
||||
@for (b of branchOptions; track b) {
|
||||
<option [value]="b">{{ b }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="cert-filter">
|
||||
<label>Share product</label>
|
||||
<select [(ngModel)]="productFilter">
|
||||
<option value="">All products</option>
|
||||
@for (p of productOptions; track p) {
|
||||
<option [value]="p">{{ p }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="cert-filter">
|
||||
<label>Funded by</label>
|
||||
<select [(ngModel)]="fundingFilter">
|
||||
<option value="">All routes</option>
|
||||
@for (f of fundingOptions; track f) {
|
||||
<option [value]="f">{{ f }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="cert-filter">
|
||||
<label> </label>
|
||||
<button class="cert-cta-btn" (click)="clearFilters()">Clear filters</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (loading) {
|
||||
<div class="srep-state">Loading the issue register…</div>
|
||||
} @else if (loadError) {
|
||||
<div class="srep-state srep-state--error"><fa-icon icon="exclamation-circle"></fa-icon> {{ loadError }}</div>
|
||||
} @else if (!rows.length) {
|
||||
<div class="srep-state">No shares were allotted between {{ fromDate }} and {{ toDate }}.</div>
|
||||
} @else if (!filteredRows.length) {
|
||||
<div class="srep-state">No allotment matches the filters applied.</div>
|
||||
} @else {
|
||||
<div class="srep-table-wrap">
|
||||
<table class="srep-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Issue Date</th>
|
||||
<th>Member</th>
|
||||
<th>Membership No</th>
|
||||
<th>Share A/c No</th>
|
||||
<th>Branch</th>
|
||||
<th>Product</th>
|
||||
<th class="srep-num">Shares</th>
|
||||
<th class="srep-num">Unit Price</th>
|
||||
<th class="srep-num">Amount</th>
|
||||
<th class="srep-num">Entry Fee</th>
|
||||
<th>Funded By</th>
|
||||
<th>Board Resolution</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of filteredRows; track row.transactionId) {
|
||||
<tr class="srep-row--click" (click)="openAccount(row)">
|
||||
<td>{{ row.issueDate }}</td>
|
||||
<td>{{ row.clientName }}</td>
|
||||
<td class="srep-mono">{{ row.cifNumber || '—' }}</td>
|
||||
<td class="srep-mono">{{ row.accountNo }}</td>
|
||||
<td>{{ row.officeName || '—' }}</td>
|
||||
<td>{{ row.productName }}</td>
|
||||
<td class="srep-num">{{ row.shares | number }}</td>
|
||||
<td class="srep-num">{{ row.unitPrice | number: '1.2-2' }}</td>
|
||||
<td class="srep-num">{{ row.amount | number: '1.2-2' }}</td>
|
||||
<td class="srep-num">{{ row.chargeAmount ? (row.chargeAmount | number: '1.2-2') : '—' }}</td>
|
||||
<td>{{ funding(row.fundingSource) }}</td>
|
||||
<td>
|
||||
@if (row.resolutionNumber) {
|
||||
<span class="srep-mono">{{ row.resolutionNumber }}</span>
|
||||
@if (row.meetingDate) {
|
||||
<div class="srep-scope-note" style="margin: 0">{{ row.meetingDate }}</div>
|
||||
}
|
||||
} @else {
|
||||
<span class="srep-pill srep-pill--amber">Not recorded</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<span class="srep-pill" [ngClass]="txnStatusClass(row.statusEnum)">{{
|
||||
txnStatusLabel(row.statusEnum)
|
||||
}}</span>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="6">
|
||||
{{ isFiltered ? 'Total (filtered)' : 'Total' }} — {{ filteredRows.length | number }} allotments
|
||||
</td>
|
||||
<td class="srep-num">{{ shownShares | number }}</td>
|
||||
<td class="srep-num"></td>
|
||||
<td class="srep-num">₹{{ shownAmount | number: '1.2-2' }}</td>
|
||||
<td class="srep-num"></td>
|
||||
<td colspan="3"></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@if (isFiltered && totals) {
|
||||
<div class="srep-scope-note">
|
||||
Showing a filtered view. Whole period {{ fromDate }} to {{ toDate }}: {{ totals.shares | number }} shares issued
|
||||
to {{ totals.memberCount | number }} members across {{ totals.trancheCount | number }} allotments, ₹{{
|
||||
totals.amount | number: '1.2-2'
|
||||
}}.
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { of } from 'rxjs';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
|
||||
import { SharesService } from '../../shares.service';
|
||||
import { SettingsService } from 'app/settings/settings.service';
|
||||
import { SHARE_TXN_STATUS, fundingLabel, shareReportCsv, shareReportPrint } from '../share-report.util';
|
||||
|
||||
interface IssuedRow {
|
||||
transactionId: number;
|
||||
issueDate: string;
|
||||
shareAccountId: number;
|
||||
accountNo: string;
|
||||
clientId: number;
|
||||
cifNumber: string;
|
||||
clientName: string;
|
||||
officeId: number;
|
||||
officeName: string;
|
||||
productId: number;
|
||||
productName: string;
|
||||
currencyCode: string;
|
||||
shares: number;
|
||||
unitPrice: number;
|
||||
amount: number;
|
||||
chargeAmount: number;
|
||||
statusEnum: number;
|
||||
resolutionNumber: string;
|
||||
meetingDate: string;
|
||||
fundingSource: string;
|
||||
accountStatusEnum: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Share Issued — the allotment register: every tranche of shares issued in a period, with the board
|
||||
* resolution that authorised it and how the member paid.
|
||||
*
|
||||
* <p>
|
||||
* One row per tranche, not per member: a member who bought three times was allotted three times, and
|
||||
* the register records allotments. Rolling them up per member would lose the resolution each one was
|
||||
* made under, which is the column an auditor actually reads.
|
||||
*
|
||||
* <p>
|
||||
* Period and status are server-side (the register is a period document, and "what is pending" is a
|
||||
* different query, not a different view of the same rows); branch, product and search are local.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-share-issued-report',
|
||||
templateUrl: './share-issued-report.component.html',
|
||||
styleUrls: [
|
||||
'../../../letter-mgmt/certificates/certificates.shared.scss',
|
||||
'../share-report.shared.scss'
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
FaIconComponent
|
||||
]
|
||||
})
|
||||
export class ShareIssuedReportComponent implements OnInit {
|
||||
private sharesService = inject(SharesService);
|
||||
private settingsService = inject(SettingsService);
|
||||
private router = inject(Router);
|
||||
|
||||
loading = true;
|
||||
loadError = '';
|
||||
rows: IssuedRow[] = [];
|
||||
totals: any = null;
|
||||
|
||||
// ── Server-side ──
|
||||
fromDate = '';
|
||||
toDate = '';
|
||||
/** '' = settled allotments only (the register's default); 100 = pending; 400 = rejected. */
|
||||
statusFilter: number | '' = '';
|
||||
|
||||
// ── Client-side ──
|
||||
search = '';
|
||||
branchFilter = '';
|
||||
productFilter = '';
|
||||
fundingFilter = '';
|
||||
|
||||
ngOnInit(): void {
|
||||
const businessDate: Date = this.settingsService.businessDate || new Date();
|
||||
// Opens on the current financial year, which is the period this register is almost always run
|
||||
// for. April opens the FY in this system, matching the Share Capital Return.
|
||||
const fyStart = new Date(businessDate.getFullYear(), businessDate.getMonth() >= 3 ? 3 : -9, 1);
|
||||
this.fromDate = this.toIso(fyStart);
|
||||
this.toDate = this.toIso(businessDate);
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading = true;
|
||||
this.loadError = '';
|
||||
this.sharesService
|
||||
.getShareIssuedReport({
|
||||
fromDate: this.fromDate,
|
||||
toDate: this.toDate,
|
||||
status: this.statusFilter === '' ? undefined : this.statusFilter
|
||||
})
|
||||
.pipe(
|
||||
catchError((err) => {
|
||||
this.loadError = this.readError(err, 'The share issue register could not be loaded.');
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
.subscribe((res: any) => {
|
||||
this.rows = res?.rows || [];
|
||||
this.totals = res?.totals || null;
|
||||
this.loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
get branchOptions(): string[] {
|
||||
return [...new Set(this.rows.map((r) => r.officeName).filter(Boolean))].sort();
|
||||
}
|
||||
|
||||
get productOptions(): string[] {
|
||||
return [...new Set(this.rows.map((r) => r.productName).filter(Boolean))].sort();
|
||||
}
|
||||
|
||||
get fundingOptions(): string[] {
|
||||
return [...new Set(this.rows.map((r) => fundingLabel(r.fundingSource)))].sort();
|
||||
}
|
||||
|
||||
get filteredRows(): IssuedRow[] {
|
||||
const term = this.search.trim().toLowerCase();
|
||||
return this.rows.filter((r) => {
|
||||
if (this.branchFilter && r.officeName !== this.branchFilter) {
|
||||
return false;
|
||||
}
|
||||
if (this.productFilter && r.productName !== this.productFilter) {
|
||||
return false;
|
||||
}
|
||||
if (this.fundingFilter && fundingLabel(r.fundingSource) !== this.fundingFilter) {
|
||||
return false;
|
||||
}
|
||||
if (!term) {
|
||||
return true;
|
||||
}
|
||||
return [
|
||||
r.clientName,
|
||||
r.accountNo,
|
||||
r.cifNumber,
|
||||
r.productName,
|
||||
r.resolutionNumber
|
||||
]
|
||||
.filter(Boolean)
|
||||
.some((field) => String(field).toLowerCase().includes(term));
|
||||
});
|
||||
}
|
||||
|
||||
get shownShares(): number {
|
||||
return this.filteredRows.reduce((sum, r) => sum + Number(r.shares || 0), 0);
|
||||
}
|
||||
|
||||
get shownAmount(): number {
|
||||
return this.filteredRows.reduce((sum, r) => sum + Number(r.amount || 0), 0);
|
||||
}
|
||||
|
||||
get isFiltered(): boolean {
|
||||
return !!(this.search.trim() || this.branchFilter || this.productFilter || this.fundingFilter);
|
||||
}
|
||||
|
||||
/** Tranches with no resolution recorded — the one thing an auditor opens this register to find. */
|
||||
get missingResolutionCount(): number {
|
||||
return this.filteredRows.filter((r) => !r.resolutionNumber).length;
|
||||
}
|
||||
|
||||
txnStatusLabel(statusEnum: number): string {
|
||||
return SHARE_TXN_STATUS[statusEnum]?.label || String(statusEnum ?? '');
|
||||
}
|
||||
|
||||
txnStatusClass(statusEnum: number): string {
|
||||
return SHARE_TXN_STATUS[statusEnum]?.cls || 'srep-pill--grey';
|
||||
}
|
||||
|
||||
funding(source: string): string {
|
||||
return fundingLabel(source);
|
||||
}
|
||||
|
||||
clearFilters(): void {
|
||||
this.search = '';
|
||||
this.branchFilter = '';
|
||||
this.productFilter = '';
|
||||
this.fundingFilter = '';
|
||||
}
|
||||
|
||||
openAccount(row: IssuedRow): void {
|
||||
this.router.navigate([
|
||||
'/clients',
|
||||
row.clientId,
|
||||
'shares-accounts',
|
||||
row.shareAccountId,
|
||||
'general'
|
||||
]);
|
||||
}
|
||||
|
||||
exportCsv(): void {
|
||||
shareReportCsv(
|
||||
`share-issued-${this.fromDate}-to-${this.toDate}`,
|
||||
[
|
||||
'Issue Date',
|
||||
'Member',
|
||||
'Membership No',
|
||||
'Share A/c No',
|
||||
'Branch',
|
||||
'Product',
|
||||
'Shares',
|
||||
'Unit Price',
|
||||
'Amount',
|
||||
'Entry Fee',
|
||||
'Funded By',
|
||||
'Board Resolution',
|
||||
'Meeting Date',
|
||||
'Status'
|
||||
],
|
||||
this.filteredRows.map((r) => [
|
||||
r.issueDate,
|
||||
r.clientName,
|
||||
r.cifNumber,
|
||||
r.accountNo,
|
||||
r.officeName,
|
||||
r.productName,
|
||||
r.shares,
|
||||
r.unitPrice,
|
||||
r.amount,
|
||||
r.chargeAmount,
|
||||
this.funding(r.fundingSource),
|
||||
r.resolutionNumber,
|
||||
r.meetingDate,
|
||||
this.txnStatusLabel(r.statusEnum)
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
print(): void {
|
||||
shareReportPrint();
|
||||
}
|
||||
|
||||
private toIso(date: Date): string {
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
private readError(err: any, fallback: string): string {
|
||||
return (
|
||||
err?.error?.errors?.[0]?.defaultUserMessage ||
|
||||
err?.error?.defaultUserMessage ||
|
||||
err?.error?.developerMessage ||
|
||||
fallback
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="cert-root">
|
||||
<div class="cert-head srep-no-print">
|
||||
<div class="cert-head-l">
|
||||
<fa-icon icon="tag" class="cert-head-ic"></fa-icon>
|
||||
<div>
|
||||
<h2 class="cert-head-t">Share Labels</h2>
|
||||
<div class="cert-head-s">
|
||||
Printable address labels for shareholding members — ready for an ordinary label sheet.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-head-r">
|
||||
<button class="cert-cta-btn" (click)="exportCsv()" [disabled]="loading || !filteredRows.length">
|
||||
<fa-icon icon="file-alt"></fa-icon> Export CSV
|
||||
</button>
|
||||
<button class="cert-cta-btn" (click)="print()" [disabled]="loading || !printableRows.length">
|
||||
<fa-icon icon="print"></fa-icon> Print Labels
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cert-filters srep-no-print">
|
||||
<div class="cert-filter">
|
||||
<label>Member / Folio / Area</label>
|
||||
<div class="cert-input-ic">
|
||||
<fa-icon icon="search"></fa-icon>
|
||||
<input type="text" [(ngModel)]="search" placeholder="e.g. member name or Bhopal" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-filter">
|
||||
<label>Area</label>
|
||||
<select [(ngModel)]="areaFilter">
|
||||
<option value="">All areas</option>
|
||||
@for (a of areaOptions; track a) {
|
||||
<option [value]="a">{{ a }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="cert-filter">
|
||||
<label>Labels per row</label>
|
||||
<select [(ngModel)]="columns">
|
||||
<option [ngValue]="2">2 across</option>
|
||||
<option [ngValue]="3">3 across</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="cert-filter">
|
||||
<label>Show folio no.</label>
|
||||
<select [(ngModel)]="showFolio">
|
||||
<option [ngValue]="true">Yes</option>
|
||||
<option [ngValue]="false">No</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="cert-filter">
|
||||
<label>Show holding</label>
|
||||
<select [(ngModel)]="showHolding">
|
||||
<option [ngValue]="false">No</option>
|
||||
<option [ngValue]="true">Yes</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="cert-filter">
|
||||
<label> </label>
|
||||
<button class="cert-cta-btn" (click)="clearFilters()">Clear filters</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (loading) {
|
||||
<div class="srep-state">Loading the mailing list…</div>
|
||||
} @else if (loadError) {
|
||||
<div class="srep-state srep-state--error"><fa-icon icon="exclamation-circle"></fa-icon> {{ loadError }}</div>
|
||||
} @else if (!filteredRows.length) {
|
||||
<div class="srep-state">No shareholding member matches the filters applied.</div>
|
||||
} @else {
|
||||
<div class="srep-state srep-no-print">
|
||||
{{ printableRows.length | number }} labels ready to print
|
||||
@if (unaddressableRows.length) {
|
||||
· {{ unaddressableRows.length | number }} member{{ unaddressableRows.length === 1 ? '' : 's' }} cannot be posted
|
||||
to
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="slab-sheet" [style.grid-template-columns]="'repeat(' + columns + ', 1fr)'">
|
||||
@for (row of printableRows; track row.clientId) {
|
||||
<div class="slab">
|
||||
<div class="slab-name">{{ row.clientName }}</div>
|
||||
@for (line of lines(row); track line) {
|
||||
<div class="slab-line">{{ line }}</div>
|
||||
}
|
||||
@if (showFolio && row.folioNumber) {
|
||||
<div class="slab-meta">Folio {{ row.folioNumber }}</div>
|
||||
}
|
||||
@if (showHolding) {
|
||||
<div class="slab-meta">{{ row.sharesHeld | number }} shares · ₹{{ row.shareValue | number: '1.0-0' }}</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Who cannot be posted to. Kept off the sheet but never out of sight: a blank label is a
|
||||
wasted sheet, and a member with no address is one the society silently stops reaching. -->
|
||||
@if (unaddressableRows.length) {
|
||||
<div class="srep-warn srep-no-print">
|
||||
<fa-icon icon="exclamation-circle"></fa-icon>
|
||||
<span>
|
||||
<b>{{ unaddressableRows.length | number }}</b> shareholding member{{
|
||||
unaddressableRows.length === 1 ? ' has' : 's have'
|
||||
}}
|
||||
no address on file, so no label is printed for them: {{ unaddressableRows[0].clientName }}
|
||||
@if (unaddressableRows.length > 1) {
|
||||
and {{ unaddressableRows.length - 1 | number }} other{{ unaddressableRows.length > 2 ? 's' : '' }}
|
||||
}
|
||||
. Record an address against the member to include them in the next run.
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { of } from 'rxjs';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
|
||||
import { SharesService } from '../../shares.service';
|
||||
import { shareReportCsv, shareReportPrint } from '../share-report.util';
|
||||
|
||||
export interface MailingRow {
|
||||
clientId: number;
|
||||
folioNumber: string;
|
||||
clientName: string;
|
||||
mobileNo: string;
|
||||
officeName: string;
|
||||
addressLine1: string;
|
||||
addressLine2: string;
|
||||
street: string;
|
||||
city: string;
|
||||
district: string;
|
||||
postalCode: string;
|
||||
area: string;
|
||||
sharesHeld: number;
|
||||
shareValue: number;
|
||||
shareAccountNos: string;
|
||||
addressable: boolean;
|
||||
}
|
||||
|
||||
/** Assembles one member's address into the lines an envelope actually carries. */
|
||||
export function addressLines(row: MailingRow): string[] {
|
||||
return [
|
||||
row.addressLine1,
|
||||
row.addressLine2,
|
||||
row.street,
|
||||
[
|
||||
row.city,
|
||||
row.district
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(', '),
|
||||
row.postalCode ? `PIN ${row.postalCode}` : ''
|
||||
]
|
||||
.map((line) => (line || '').trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Share Label — printable address labels for shareholding members.
|
||||
*
|
||||
* <p>
|
||||
* Laid out as a grid sized to ordinary label sheets and printed straight from the browser, so a
|
||||
* mailing run needs no separate mail-merge tool. Members with no usable address are listed
|
||||
* separately rather than printed as a blank label: a blank label is a wasted sheet and a member who
|
||||
* silently never hears from the society.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-share-label',
|
||||
templateUrl: './share-label.component.html',
|
||||
styleUrls: [
|
||||
'../../../letter-mgmt/certificates/certificates.shared.scss',
|
||||
'../share-report.shared.scss'
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
FaIconComponent
|
||||
]
|
||||
})
|
||||
export class ShareLabelComponent implements OnInit {
|
||||
private sharesService = inject(SharesService);
|
||||
|
||||
loading = true;
|
||||
loadError = '';
|
||||
rows: MailingRow[] = [];
|
||||
totals: any = null;
|
||||
|
||||
search = '';
|
||||
areaFilter = '';
|
||||
/** Labels per row on the sheet — 2 and 3 cover the sheets a branch actually buys. */
|
||||
columns = 3;
|
||||
showFolio = true;
|
||||
showHolding = false;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading = true;
|
||||
this.loadError = '';
|
||||
this.sharesService
|
||||
.getShareMailingList({})
|
||||
.pipe(
|
||||
catchError((err) => {
|
||||
this.loadError = this.readError(err, 'The mailing list could not be loaded.');
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
.subscribe((res: any) => {
|
||||
this.rows = res?.rows || [];
|
||||
this.totals = res?.totals || null;
|
||||
this.loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
get areaOptions(): string[] {
|
||||
return [...new Set(this.rows.map((r) => r.area).filter(Boolean))].sort();
|
||||
}
|
||||
|
||||
/** Only members who can actually be posted to — the rest are listed below, not printed. */
|
||||
get printableRows(): MailingRow[] {
|
||||
return this.filteredRows.filter((r) => r.addressable);
|
||||
}
|
||||
|
||||
get unaddressableRows(): MailingRow[] {
|
||||
return this.filteredRows.filter((r) => !r.addressable);
|
||||
}
|
||||
|
||||
get filteredRows(): MailingRow[] {
|
||||
const term = this.search.trim().toLowerCase();
|
||||
return this.rows.filter((r) => {
|
||||
if (this.areaFilter && r.area !== this.areaFilter) {
|
||||
return false;
|
||||
}
|
||||
if (!term) {
|
||||
return true;
|
||||
}
|
||||
return [
|
||||
r.clientName,
|
||||
r.folioNumber,
|
||||
r.area,
|
||||
r.postalCode
|
||||
]
|
||||
.filter(Boolean)
|
||||
.some((field) => String(field).toLowerCase().includes(term));
|
||||
});
|
||||
}
|
||||
|
||||
lines(row: MailingRow): string[] {
|
||||
return addressLines(row);
|
||||
}
|
||||
|
||||
clearFilters(): void {
|
||||
this.search = '';
|
||||
this.areaFilter = '';
|
||||
}
|
||||
|
||||
exportCsv(): void {
|
||||
shareReportCsv(
|
||||
'share-mailing-labels',
|
||||
[
|
||||
'Member',
|
||||
'Folio No',
|
||||
'Address',
|
||||
'City',
|
||||
'District',
|
||||
'Postal Code',
|
||||
'Mobile',
|
||||
'Shares Held',
|
||||
'Addressable'
|
||||
],
|
||||
this.filteredRows.map((r) => [
|
||||
r.clientName,
|
||||
r.folioNumber,
|
||||
this.lines(r).join(' / '),
|
||||
r.city,
|
||||
r.district,
|
||||
r.postalCode,
|
||||
r.mobileNo,
|
||||
r.sharesHeld,
|
||||
r.addressable ? 'Yes' : 'No'
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
print(): void {
|
||||
shareReportPrint();
|
||||
}
|
||||
|
||||
private readError(err: any, fallback: string): string {
|
||||
return (
|
||||
err?.error?.errors?.[0]?.defaultUserMessage ||
|
||||
err?.error?.defaultUserMessage ||
|
||||
err?.error?.developerMessage ||
|
||||
fallback
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="cert-root">
|
||||
<div class="cert-head srep-no-print">
|
||||
<div class="cert-head-l">
|
||||
<fa-icon icon="envelope" class="cert-head-ic"></fa-icon>
|
||||
<div>
|
||||
<h2 class="cert-head-t">Share Letter</h2>
|
||||
<div class="cert-head-s">
|
||||
A letter to shareholding members, merged with their own details and printed — one page each.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-head-r">
|
||||
<button class="cert-cta-btn" (click)="print()" [disabled]="loading || !recipients.length || !body.trim()">
|
||||
<fa-icon icon="print"></fa-icon> Print {{ recipients.length | number }} letters
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Compose -->
|
||||
<div class="srep-stmt-head srep-no-print" style="display: block">
|
||||
<div class="cert-filters" style="margin: 0">
|
||||
<div class="cert-filter">
|
||||
<label>Template</label>
|
||||
<select [(ngModel)]="templateKey" (change)="applyTemplate()">
|
||||
@for (t of templates; track t.key) {
|
||||
<option [value]="t.key">{{ t.label }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="cert-filter" style="flex: 2 1 320px">
|
||||
<label>Subject</label>
|
||||
<input type="text" [(ngModel)]="subject" placeholder="Subject line of the letter" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 0.7rem">
|
||||
<label class="srep-stmt-l">Letter body</label>
|
||||
<textarea class="slet-body-input" rows="7" [(ngModel)]="body" placeholder="Write the letter…"></textarea>
|
||||
<div class="srep-scope-note">
|
||||
Merged per member:
|
||||
@for (p of placeholders; track p) {
|
||||
<code>{{ p }}</code
|
||||
>{{ $last ? '.' : ', ' }}
|
||||
}
|
||||
Anything else you write is sent as it stands.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recipients -->
|
||||
<div class="cert-filters srep-no-print">
|
||||
<div class="cert-filter">
|
||||
<label>Member / Folio / Area</label>
|
||||
<div class="cert-input-ic">
|
||||
<fa-icon icon="search"></fa-icon>
|
||||
<input type="text" [(ngModel)]="search" placeholder="e.g. member name" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-filter">
|
||||
<label>Area</label>
|
||||
<select [(ngModel)]="areaFilter">
|
||||
<option value="">All areas</option>
|
||||
@for (a of areaOptions; track a) {
|
||||
<option [value]="a">{{ a }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="cert-filter">
|
||||
<label> </label>
|
||||
<button class="cert-cta-btn" (click)="selectAll()">Write to all shown</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (loading) {
|
||||
<div class="srep-state">Loading the mailing list…</div>
|
||||
} @else if (loadError) {
|
||||
<div class="srep-state srep-state--error"><fa-icon icon="exclamation-circle"></fa-icon> {{ loadError }}</div>
|
||||
} @else if (!filteredRows.length) {
|
||||
<div class="srep-state">No shareholding member matches the filters applied.</div>
|
||||
} @else {
|
||||
<div class="srep-table-wrap srep-no-print">
|
||||
<table class="srep-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-checkbox"></th>
|
||||
<th>Member</th>
|
||||
<th>Folio No</th>
|
||||
<th>Area</th>
|
||||
<th class="srep-num">Shares</th>
|
||||
<th>Postable</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of filteredRows; track row.clientId) {
|
||||
<tr class="srep-row--click" [class.selected]="isSelected(row.clientId)" (click)="toggle(row.clientId)">
|
||||
<td class="ta-center" (click)="$event.stopPropagation()">
|
||||
<input type="checkbox" [checked]="isSelected(row.clientId)" (change)="toggle(row.clientId)" />
|
||||
</td>
|
||||
<td>{{ row.clientName }}</td>
|
||||
<td class="srep-mono">{{ row.folioNumber || '—' }}</td>
|
||||
<td>{{ row.area }}</td>
|
||||
<td class="srep-num">{{ row.sharesHeld | number }}</td>
|
||||
<td>
|
||||
@if (row.addressable) {
|
||||
<span class="srep-pill srep-pill--green">Yes</span>
|
||||
} @else {
|
||||
<span class="srep-pill srep-pill--amber">No address</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="srep-state srep-no-print">
|
||||
{{ recipients.length | number }} letter{{ recipients.length === 1 ? '' : 's' }} will print
|
||||
@if (!selectedIds.length) {
|
||||
(everyone shown — tick rows to narrow it)
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- The letters. One per member, each its own printed page. -->
|
||||
@for (row of recipients; track row.clientId) {
|
||||
<div class="slet-page">
|
||||
<div class="spc-letterhead">
|
||||
<div class="spc-org">{{ institution?.name || institution?.institutionName || 'Co-operative Bank' }}</div>
|
||||
@if (institution?.addressLine1 || institution?.address) {
|
||||
<div class="spc-org-sub">{{ institution?.addressLine1 || institution?.address }}</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="slet-meta">
|
||||
<div>
|
||||
<div>{{ row.clientName }}</div>
|
||||
@for (line of lines(row); track line) {
|
||||
<div>{{ line }}</div>
|
||||
}
|
||||
@if (row.folioNumber) {
|
||||
<div class="srep-scope-note" style="margin: 0">Folio {{ row.folioNumber }}</div>
|
||||
}
|
||||
</div>
|
||||
<div class="slet-date">Date: {{ today }}</div>
|
||||
</div>
|
||||
|
||||
@if (subject.trim()) {
|
||||
<div class="slet-subject">Subject: {{ subject }}</div>
|
||||
}
|
||||
|
||||
<div class="slet-salutation">Dear {{ row.clientName }},</div>
|
||||
<div class="slet-body">{{ merged(row) }}</div>
|
||||
|
||||
<div class="spc-foot">
|
||||
<div class="spc-foot-l">Yours faithfully,</div>
|
||||
<div class="spc-sign">
|
||||
<div class="spc-sign-line"></div>
|
||||
<div>Authorised Signatory</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { of } from 'rxjs';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
|
||||
import { SharesService } from '../../shares.service';
|
||||
import { OrganizationService } from 'app/organization/organization.service';
|
||||
import { MailingRow, addressLines } from '../share-label/share-label.component';
|
||||
import { shareReportPrint } from '../share-report.util';
|
||||
|
||||
/** The letters a society actually sends its shareholders, as starting text. */
|
||||
const TEMPLATES: { key: string; label: string; subject: string; body: string }[] = [
|
||||
{
|
||||
key: 'AGM',
|
||||
label: 'Notice of Annual General Meeting',
|
||||
subject: 'Notice of the Annual General Meeting',
|
||||
body:
|
||||
'You are hereby informed that the Annual General Meeting of the society will be held on {{MEETING_DATE}} at {{VENUE}}.\n\n' +
|
||||
'As a shareholding member holding {{SHARES}} shares (Folio {{FOLIO}}), you are entitled to attend and vote.\n\n' +
|
||||
'Your presence is requested.'
|
||||
},
|
||||
{
|
||||
key: 'DIVIDEND',
|
||||
label: 'Dividend intimation',
|
||||
subject: 'Dividend declared on your shareholding',
|
||||
body:
|
||||
'We are pleased to inform you that the society has declared a dividend for the year {{YEAR}}.\n\n' +
|
||||
'Against your holding of {{SHARES}} shares (Folio {{FOLIO}}), the dividend due to you will be credited to your ' +
|
||||
'linked savings account. Please contact your branch if it is not received within fifteen days.'
|
||||
},
|
||||
{
|
||||
key: 'SHARE_CALL',
|
||||
label: 'Call to complete shareholding',
|
||||
subject: 'Completion of your share subscription',
|
||||
body:
|
||||
'Our records show that your share account stands at {{SHARES}} shares (Folio {{FOLIO}}), which is below the ' +
|
||||
'minimum subscription your share class requires.\n\n' +
|
||||
'You are requested to complete your subscription at your branch at the earliest so that your membership rights ' +
|
||||
'remain unaffected.'
|
||||
},
|
||||
{
|
||||
key: 'BLANK',
|
||||
label: 'Blank letter',
|
||||
subject: '',
|
||||
body: ''
|
||||
}
|
||||
];
|
||||
|
||||
/**
|
||||
* Share Letter — a letter to shareholding members, merged with their own details and printed.
|
||||
*
|
||||
* <p>
|
||||
* Runs off the same mailing list the labels do, so the letter and the envelope always agree on who
|
||||
* is being written to. Templates are starting text rather than fixed forms: the body is editable
|
||||
* before printing, because the letter a society actually sends is never quite the one a system
|
||||
* imagined.
|
||||
*
|
||||
* <p>
|
||||
* Placeholders are merged per member — {{NAME}}, {{FOLIO}}, {{SHARES}} and the rest — so one edit
|
||||
* produces every letter, each addressed to the member it is going to.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-share-letter',
|
||||
templateUrl: './share-letter.component.html',
|
||||
styleUrls: [
|
||||
'../../../letter-mgmt/certificates/certificates.shared.scss',
|
||||
'../share-report.shared.scss'
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
FaIconComponent
|
||||
]
|
||||
})
|
||||
export class ShareLetterComponent implements OnInit {
|
||||
private sharesService = inject(SharesService);
|
||||
private organizationService = inject(OrganizationService);
|
||||
|
||||
loading = true;
|
||||
loadError = '';
|
||||
rows: MailingRow[] = [];
|
||||
institution: any = null;
|
||||
|
||||
readonly templates = TEMPLATES;
|
||||
templateKey = 'AGM';
|
||||
subject = TEMPLATES[0].subject;
|
||||
body = TEMPLATES[0].body;
|
||||
|
||||
search = '';
|
||||
areaFilter = '';
|
||||
/** Which members this run is for; empty means everyone shown. */
|
||||
selectedIds: number[] = [];
|
||||
|
||||
ngOnInit(): void {
|
||||
this.sharesService
|
||||
.getShareMailingList({})
|
||||
.pipe(
|
||||
catchError((err) => {
|
||||
this.loadError = this.readError(err, 'The mailing list could not be loaded.');
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
.subscribe((res: any) => {
|
||||
this.rows = res?.rows || [];
|
||||
this.loading = false;
|
||||
});
|
||||
|
||||
this.organizationService
|
||||
.getInstitutionProfile()
|
||||
.pipe(catchError(() => of(null)))
|
||||
.subscribe((res: any) => (this.institution = res));
|
||||
}
|
||||
|
||||
applyTemplate(): void {
|
||||
const template = TEMPLATES.find((t) => t.key === this.templateKey);
|
||||
if (template) {
|
||||
this.subject = template.subject;
|
||||
this.body = template.body;
|
||||
}
|
||||
}
|
||||
|
||||
get areaOptions(): string[] {
|
||||
return [...new Set(this.rows.map((r) => r.area).filter(Boolean))].sort();
|
||||
}
|
||||
|
||||
get filteredRows(): MailingRow[] {
|
||||
const term = this.search.trim().toLowerCase();
|
||||
return this.rows.filter((r) => {
|
||||
if (this.areaFilter && r.area !== this.areaFilter) {
|
||||
return false;
|
||||
}
|
||||
if (!term) {
|
||||
return true;
|
||||
}
|
||||
return [
|
||||
r.clientName,
|
||||
r.folioNumber,
|
||||
r.area
|
||||
]
|
||||
.filter(Boolean)
|
||||
.some((field) => String(field).toLowerCase().includes(term));
|
||||
});
|
||||
}
|
||||
|
||||
/** Nothing ticked means the whole filtered list — the common case is "write to all of these". */
|
||||
get recipients(): MailingRow[] {
|
||||
if (!this.selectedIds.length) {
|
||||
return this.filteredRows;
|
||||
}
|
||||
return this.filteredRows.filter((r) => this.selectedIds.includes(r.clientId));
|
||||
}
|
||||
|
||||
isSelected(clientId: number): boolean {
|
||||
return this.selectedIds.includes(clientId);
|
||||
}
|
||||
|
||||
toggle(clientId: number): void {
|
||||
this.selectedIds = this.isSelected(clientId) ? this.selectedIds.filter((id) => id !== clientId) : [
|
||||
...this.selectedIds,
|
||||
clientId
|
||||
];
|
||||
}
|
||||
|
||||
selectAll(): void {
|
||||
this.selectedIds = [];
|
||||
}
|
||||
|
||||
lines(row: MailingRow): string[] {
|
||||
return addressLines(row);
|
||||
}
|
||||
|
||||
/** What one member's letter substitutes, keyed by the token that stands for it in the body. */
|
||||
private mergeFields(row: MailingRow): Record<string, string> {
|
||||
return {
|
||||
NAME: row.clientName || '',
|
||||
FOLIO: row.folioNumber || '',
|
||||
SHARES: Number(row.sharesHeld || 0).toLocaleString('en-IN'),
|
||||
SHARE_VALUE: Number(row.shareValue || 0).toLocaleString('en-IN'),
|
||||
SHARE_ACCOUNTS: row.shareAccountNos || '',
|
||||
BRANCH: row.officeName || '',
|
||||
AREA: row.area || ''
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The tokens the editor's help text offers, derived from the merge itself so the two can never
|
||||
* drift apart — and assembled here rather than written in the template, because a literal
|
||||
* `{{NAME}}` in an Angular template is an expression, not the text a letter-writer needs to see.
|
||||
*/
|
||||
readonly placeholders = Object.keys(this.mergeFields({} as MailingRow)).map((key) => `{{${key}}}`);
|
||||
|
||||
/** The member's own details merged into the body, one letter at a time. */
|
||||
merged(row: MailingRow): string {
|
||||
const fields = this.mergeFields(row);
|
||||
// An unrecognised token is left standing rather than blanked: a letter that visibly still says
|
||||
// {{VENUE}} tells the writer to fill it in; one silently emptied goes out with a hole in it.
|
||||
return (this.body || '').replace(/\{\{([A-Z_]+)\}\}/g, (token, key: string) => fields[key] ?? token);
|
||||
}
|
||||
|
||||
get today(): string {
|
||||
const now = new Date();
|
||||
return `${String(now.getDate()).padStart(2, '0')}-${String(now.getMonth() + 1).padStart(2, '0')}-${now.getFullYear()}`;
|
||||
}
|
||||
|
||||
print(): void {
|
||||
shareReportPrint();
|
||||
}
|
||||
|
||||
private readError(err: any, fallback: string): string {
|
||||
return (
|
||||
err?.error?.errors?.[0]?.defaultUserMessage ||
|
||||
err?.error?.defaultUserMessage ||
|
||||
err?.error?.developerMessage ||
|
||||
fallback
|
||||
);
|
||||
}
|
||||
}
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="cert-root">
|
||||
<div class="cert-head">
|
||||
<div class="cert-head-l">
|
||||
<fa-icon icon="calculator" class="cert-head-ic"></fa-icon>
|
||||
<div>
|
||||
<h2 class="cert-head-t">Total Interest Received & Receivable</h2>
|
||||
<div class="cert-head-s">
|
||||
Interest earned from the borrowings of the members who hold the society's share capital.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-head-r">
|
||||
<button class="cert-cta-btn" (click)="exportCsv()" [disabled]="loading || !filteredRows.length">
|
||||
<fa-icon icon="file-alt"></fa-icon> Export CSV
|
||||
</button>
|
||||
<button class="cert-cta-btn" (click)="print()" [disabled]="loading || !filteredRows.length">
|
||||
<fa-icon icon="print"></fa-icon> Print
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (totals) {
|
||||
<div class="cert-kpis">
|
||||
<div class="cert-kpi cert-kpi--green">
|
||||
<fa-icon icon="hand-holding-usd" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">₹{{ totals.interestReceived | number: '1.2-2' }}</div>
|
||||
<div class="cert-kpi-l">Interest Received</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-kpi cert-kpi--amber">
|
||||
<fa-icon icon="sync" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">₹{{ totals.interestReceivable | number: '1.2-2' }}</div>
|
||||
<div class="cert-kpi-l">Interest Receivable</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-kpi cert-kpi--blue">
|
||||
<fa-icon icon="users" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">{{ totals.borrowingMembers | number }} / {{ totals.memberCount | number }}</div>
|
||||
<div class="cert-kpi-l">Borrowing Members</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-kpi cert-kpi--teal">
|
||||
<fa-icon icon="coins" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">₹{{ totals.shareValue | number: '1.2-2' }}</div>
|
||||
<div class="cert-kpi-l">Their Share Capital</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="cert-filters">
|
||||
<div class="cert-filter">
|
||||
<label>Member / Membership No</label>
|
||||
<div class="cert-input-ic">
|
||||
<fa-icon icon="search"></fa-icon>
|
||||
<input type="text" [(ngModel)]="search" placeholder="e.g. member name" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-filter">
|
||||
<label>Loans counted</label>
|
||||
<select [(ngModel)]="includeClosedLoans" (change)="load()">
|
||||
<option [ngValue]="false">Live loans only</option>
|
||||
<option [ngValue]="true">Include closed loans</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="cert-filter">
|
||||
<label>Members shown</label>
|
||||
<select [(ngModel)]="onlyBorrowers">
|
||||
<option [ngValue]="false">All shareholding members</option>
|
||||
<option [ngValue]="true">Borrowers only</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="cert-filter">
|
||||
<label>Branch</label>
|
||||
<select [(ngModel)]="branchFilter">
|
||||
<option value="">All branches</option>
|
||||
@for (b of branchOptions; track b) {
|
||||
<option [value]="b">{{ b }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="cert-filter">
|
||||
<label> </label>
|
||||
<button class="cert-cta-btn" (click)="clearFilters()">Clear filters</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (loading) {
|
||||
<div class="srep-state">Loading interest figures…</div>
|
||||
} @else if (loadError) {
|
||||
<div class="srep-state srep-state--error"><fa-icon icon="exclamation-circle"></fa-icon> {{ loadError }}</div>
|
||||
} @else if (!rows.length) {
|
||||
<div class="srep-state">No member holds share capital yet.</div>
|
||||
} @else if (!filteredRows.length) {
|
||||
<div class="srep-state">No member matches the filters applied.</div>
|
||||
} @else {
|
||||
<div class="srep-table-wrap">
|
||||
<table class="srep-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Member</th>
|
||||
<th>Membership No</th>
|
||||
<th>Branch</th>
|
||||
<th class="srep-num">Shares</th>
|
||||
<th class="srep-num">Share Value</th>
|
||||
<th class="srep-num">Loans</th>
|
||||
<th class="srep-num">Principal O/S</th>
|
||||
<th class="srep-num">Interest Charged</th>
|
||||
<th class="srep-num">Interest Received</th>
|
||||
<th class="srep-num">Interest Receivable</th>
|
||||
<th class="srep-num">Waived</th>
|
||||
<th class="srep-num">Written Off</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of filteredRows; track row.clientId) {
|
||||
<tr class="srep-row--click" (click)="openMember(row)">
|
||||
<td>{{ row.clientName }}</td>
|
||||
<td class="srep-mono">{{ row.cifNumber || '—' }}</td>
|
||||
<td>{{ row.officeName || '—' }}</td>
|
||||
<td class="srep-num">{{ row.sharesHeld | number }}</td>
|
||||
<td class="srep-num">₹{{ row.shareValue | number: '1.2-2' }}</td>
|
||||
<td class="srep-num">{{ row.loanCount | number }}</td>
|
||||
<td class="srep-num">₹{{ row.principalOutstanding | number: '1.2-2' }}</td>
|
||||
<td class="srep-num">₹{{ row.interestCharged | number: '1.2-2' }}</td>
|
||||
<td class="srep-num">₹{{ row.interestReceived | number: '1.2-2' }}</td>
|
||||
<td class="srep-num">₹{{ row.interestReceivable | number: '1.2-2' }}</td>
|
||||
<td class="srep-num">{{ row.interestWaived ? '₹' + (row.interestWaived | number: '1.2-2') : '—' }}</td>
|
||||
<td class="srep-num">
|
||||
{{ row.interestWrittenOff ? '₹' + (row.interestWrittenOff | number: '1.2-2') : '—' }}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="3">
|
||||
{{ isFiltered ? 'Total (filtered)' : 'Total' }} — {{ filteredRows.length | number }} members
|
||||
</td>
|
||||
<td class="srep-num">{{ sum('sharesHeld') | number }}</td>
|
||||
<td class="srep-num">₹{{ sum('shareValue') | number: '1.2-2' }}</td>
|
||||
<td class="srep-num">{{ sum('loanCount') | number }}</td>
|
||||
<td class="srep-num">₹{{ sum('principalOutstanding') | number: '1.2-2' }}</td>
|
||||
<td class="srep-num">₹{{ sum('interestCharged') | number: '1.2-2' }}</td>
|
||||
<td class="srep-num">₹{{ sum('interestReceived') | number: '1.2-2' }}</td>
|
||||
<td class="srep-num">₹{{ sum('interestReceivable') | number: '1.2-2' }}</td>
|
||||
<td class="srep-num">₹{{ sum('interestWaived') | number: '1.2-2' }}</td>
|
||||
<td class="srep-num">₹{{ sum('interestWrittenOff') | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
+197
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { of } from 'rxjs';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
|
||||
import { SharesService } from '../../shares.service';
|
||||
import { shareReportCsv, shareReportPrint } from '../share-report.util';
|
||||
|
||||
interface MemberInterestRow {
|
||||
clientId: number;
|
||||
cifNumber: string;
|
||||
clientName: string;
|
||||
officeName: string;
|
||||
sharesHeld: number;
|
||||
shareValue: number;
|
||||
loanCount: number;
|
||||
principalLent: number;
|
||||
principalOutstanding: number;
|
||||
interestCharged: number;
|
||||
interestReceived: number;
|
||||
interestReceivable: number;
|
||||
interestWaived: number;
|
||||
interestWrittenOff: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Total Interest Received & Receivable — what the society has earned in interest from the borrowings
|
||||
* of the members who hold its share capital.
|
||||
*
|
||||
* <p>
|
||||
* Shares earn dividend, not interest; this is the other side of the member relationship. A member's
|
||||
* stake and what their lending returns is the case for admitting and keeping them, and a
|
||||
* co-operative reads the two together.
|
||||
*
|
||||
* <p>
|
||||
* Received and receivable stay in separate columns rather than being summed — interest charged but
|
||||
* not yet collected is not income in hand, and a single "interest" figure hides exactly that.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-share-member-interest-report',
|
||||
templateUrl: './share-member-interest-report.component.html',
|
||||
styleUrls: [
|
||||
'../../../letter-mgmt/certificates/certificates.shared.scss',
|
||||
'../share-report.shared.scss'
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
FaIconComponent
|
||||
]
|
||||
})
|
||||
export class ShareMemberInterestReportComponent implements OnInit {
|
||||
private sharesService = inject(SharesService);
|
||||
private router = inject(Router);
|
||||
|
||||
loading = true;
|
||||
loadError = '';
|
||||
rows: MemberInterestRow[] = [];
|
||||
totals: any = null;
|
||||
|
||||
/** Server-side: a closed loan's interest is history, not a position. */
|
||||
includeClosedLoans = false;
|
||||
|
||||
search = '';
|
||||
branchFilter = '';
|
||||
/** Members who hold shares but have never borrowed are noise when the question is interest. */
|
||||
onlyBorrowers = false;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading = true;
|
||||
this.loadError = '';
|
||||
this.sharesService
|
||||
.getShareMemberLoanInterest({ includeClosedLoans: this.includeClosedLoans })
|
||||
.pipe(
|
||||
catchError((err) => {
|
||||
this.loadError = this.readError(err, 'The interest report could not be loaded.');
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
.subscribe((res: any) => {
|
||||
this.rows = res?.rows || [];
|
||||
this.totals = res?.totals || null;
|
||||
this.loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
get branchOptions(): string[] {
|
||||
return [...new Set(this.rows.map((r) => r.officeName).filter(Boolean))].sort();
|
||||
}
|
||||
|
||||
get filteredRows(): MemberInterestRow[] {
|
||||
const term = this.search.trim().toLowerCase();
|
||||
return this.rows.filter((r) => {
|
||||
if (this.branchFilter && r.officeName !== this.branchFilter) {
|
||||
return false;
|
||||
}
|
||||
if (this.onlyBorrowers && !Number(r.loanCount)) {
|
||||
return false;
|
||||
}
|
||||
if (!term) {
|
||||
return true;
|
||||
}
|
||||
return [
|
||||
r.clientName,
|
||||
r.cifNumber
|
||||
]
|
||||
.filter(Boolean)
|
||||
.some((field) => String(field).toLowerCase().includes(term));
|
||||
});
|
||||
}
|
||||
|
||||
sum(field: keyof MemberInterestRow): number {
|
||||
return this.filteredRows.reduce((total, row) => total + Number(row[field] || 0), 0);
|
||||
}
|
||||
|
||||
get isFiltered(): boolean {
|
||||
return !!(this.search.trim() || this.branchFilter || this.onlyBorrowers);
|
||||
}
|
||||
|
||||
openMember(row: MemberInterestRow): void {
|
||||
this.router.navigate([
|
||||
'/clients',
|
||||
row.clientId,
|
||||
'general'
|
||||
]);
|
||||
}
|
||||
|
||||
clearFilters(): void {
|
||||
this.search = '';
|
||||
this.branchFilter = '';
|
||||
this.onlyBorrowers = false;
|
||||
}
|
||||
|
||||
exportCsv(): void {
|
||||
shareReportCsv(
|
||||
'share-member-interest',
|
||||
[
|
||||
'Member',
|
||||
'Membership No',
|
||||
'Branch',
|
||||
'Shares Held',
|
||||
'Share Value',
|
||||
'Loans',
|
||||
'Principal Lent',
|
||||
'Principal Outstanding',
|
||||
'Interest Charged',
|
||||
'Interest Received',
|
||||
'Interest Receivable',
|
||||
'Interest Waived',
|
||||
'Interest Written Off'
|
||||
],
|
||||
this.filteredRows.map((r) => [
|
||||
r.clientName,
|
||||
r.cifNumber,
|
||||
r.officeName,
|
||||
r.sharesHeld,
|
||||
r.shareValue,
|
||||
r.loanCount,
|
||||
r.principalLent,
|
||||
r.principalOutstanding,
|
||||
r.interestCharged,
|
||||
r.interestReceived,
|
||||
r.interestReceivable,
|
||||
r.interestWaived,
|
||||
r.interestWrittenOff
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
print(): void {
|
||||
shareReportPrint();
|
||||
}
|
||||
|
||||
private readError(err: any, fallback: string): string {
|
||||
return (
|
||||
err?.error?.errors?.[0]?.defaultUserMessage ||
|
||||
err?.error?.defaultUserMessage ||
|
||||
err?.error?.developerMessage ||
|
||||
fallback
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="cert-root">
|
||||
<div class="cert-head">
|
||||
<div class="cert-head-l">
|
||||
<fa-icon icon="coins" class="cert-head-ic"></fa-icon>
|
||||
<div>
|
||||
<h2 class="cert-head-t">Nominal Share Report</h2>
|
||||
<div class="cert-head-s">
|
||||
Each member's holding at face value, measured against the share class's nominal, minimum and maximum.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-head-r">
|
||||
<button class="cert-cta-btn" (click)="exportCsv()" [disabled]="loading || !filteredRows.length">
|
||||
<fa-icon icon="file-alt"></fa-icon> Export CSV
|
||||
</button>
|
||||
<button class="cert-cta-btn" (click)="print()" [disabled]="loading || !filteredRows.length">
|
||||
<fa-icon icon="print"></fa-icon> Print
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (totals) {
|
||||
<div class="cert-kpis">
|
||||
<div class="cert-kpi cert-kpi--teal">
|
||||
<fa-icon icon="balance-scale" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">₹{{ totals.nominalValue | number: '1.2-2' }}</div>
|
||||
<div class="cert-kpi-l">Nominal Capital</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-kpi cert-kpi--green">
|
||||
<fa-icon icon="users" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">{{ totals.regularMembers | number }}</div>
|
||||
<div class="cert-kpi-l">Regular Members</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-kpi cert-kpi--amber">
|
||||
<fa-icon icon="user-shield" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">{{ totals.nominalMembers | number }}</div>
|
||||
<div class="cert-kpi-l">Nominal Members</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="cert-kpi"
|
||||
[ngClass]="totals.belowMinimum + totals.aboveMaximum ? 'cert-kpi--amber' : 'cert-kpi--blue'"
|
||||
>
|
||||
<fa-icon icon="exclamation-circle" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">{{ totals.belowMinimum + totals.aboveMaximum | number }}</div>
|
||||
<div class="cert-kpi-l">Outside Limits</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Allotted but never collected. Kept in front of the reader rather than left to be spotted as an
|
||||
amber row halfway down: these are live accounts whose money is still owed. -->
|
||||
@if (unpaidRows.length) {
|
||||
<div class="srep-warn">
|
||||
<fa-icon icon="exclamation-circle"></fa-icon>
|
||||
<span>
|
||||
<b>{{ unpaidRows.length | number }}</b> {{ unpaidRows.length === 1 ? 'account has' : 'accounts have' }} shares
|
||||
allotted but never collected — <b>{{ unpaidShares | number }}</b> shares in all. They are active, so they are
|
||||
listed below and flagged in amber, but they hold nothing: the shares exist on paper only until the member's
|
||||
collection is applied.
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="cert-filters">
|
||||
<div class="cert-filter">
|
||||
<label>Account state</label>
|
||||
<select [(ngModel)]="stateFilter">
|
||||
<option value="LIVE">Live accounts</option>
|
||||
<option value="">All states</option>
|
||||
@for (s of stateOrder; track s) {
|
||||
<option [value]="s">{{ stateLabel(s) }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="cert-filter">
|
||||
<label>As on date</label>
|
||||
<input type="date" [(ngModel)]="asOnDate" (change)="load()" />
|
||||
</div>
|
||||
<div class="cert-filter">
|
||||
<label>Member / Membership No / A/c No</label>
|
||||
<div class="cert-input-ic">
|
||||
<fa-icon icon="search"></fa-icon>
|
||||
<input type="text" [(ngModel)]="search" placeholder="e.g. member name" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-filter">
|
||||
<label>Compliance</label>
|
||||
<select [(ngModel)]="complianceFilter">
|
||||
<option value="">All</option>
|
||||
<option value="WITHIN_LIMITS">Within limits</option>
|
||||
<option value="BELOW_MINIMUM">Below minimum</option>
|
||||
<option value="ABOVE_MAXIMUM">Above maximum</option>
|
||||
<option value="NO_HOLDING">No holding</option>
|
||||
<option value="NOT_APPLICABLE">Not applicable</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="cert-filter">
|
||||
<label>Membership class</label>
|
||||
<select [(ngModel)]="membershipFilter">
|
||||
<option value="">All</option>
|
||||
<option value="REGULAR">Regular</option>
|
||||
<option value="NOMINAL">Nominal</option>
|
||||
<option value="NONE">No holding</option>
|
||||
<option value="NOT_APPLICABLE">Not applicable</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="cert-filter">
|
||||
<label>Branch</label>
|
||||
<select [(ngModel)]="branchFilter">
|
||||
<option value="">All branches</option>
|
||||
@for (b of branchOptions; track b) {
|
||||
<option [value]="b">{{ b }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="cert-filter">
|
||||
<label>Share product</label>
|
||||
<select [(ngModel)]="productFilter">
|
||||
<option value="">All products</option>
|
||||
@for (p of productOptions; track p) {
|
||||
<option [value]="p">{{ p }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="cert-filter">
|
||||
<label> </label>
|
||||
<button class="cert-cta-btn" (click)="clearFilters()">Clear filters</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (loading) {
|
||||
<div class="srep-state">Loading nominal share positions…</div>
|
||||
} @else if (loadError) {
|
||||
<div class="srep-state srep-state--error"><fa-icon icon="exclamation-circle"></fa-icon> {{ loadError }}</div>
|
||||
} @else if (!rows.length) {
|
||||
<div class="srep-state">No share account existed as on {{ asOnDate }}.</div>
|
||||
} @else if (!filteredRows.length) {
|
||||
<div class="srep-state">No member matches the filters applied.</div>
|
||||
} @else {
|
||||
@if (hiddenByStateCount) {
|
||||
<div class="srep-scope-note">
|
||||
{{ hiddenByStateCount | number }} closed, rejected or not-yet-active
|
||||
{{ hiddenByStateCount === 1 ? 'account is' : 'accounts are' }} not shown. Switch <b>Account state</b> to see
|
||||
them.
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="srep-table-wrap">
|
||||
<table class="srep-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Member</th>
|
||||
<th>Membership No</th>
|
||||
<th>Share A/c No</th>
|
||||
<th>Product</th>
|
||||
<th>State</th>
|
||||
<th class="srep-num">Held</th>
|
||||
<th class="srep-num">Nominal</th>
|
||||
<th class="srep-num">vs Nominal</th>
|
||||
<th class="srep-num">Min</th>
|
||||
<th class="srep-num">Max</th>
|
||||
<th class="srep-num">Face Value</th>
|
||||
<th class="srep-num">Nominal Value</th>
|
||||
<th>Class</th>
|
||||
<th>Compliance</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of filteredRows; track row.shareAccountId) {
|
||||
<tr class="srep-row--click" [ngClass]="stateRowClass(row.holdingState)" (click)="openAccount(row)">
|
||||
<td>{{ row.clientName }}</td>
|
||||
<td class="srep-mono">{{ row.cifNumber || '—' }}</td>
|
||||
<td class="srep-mono">{{ row.accountNo }}</td>
|
||||
<td>{{ row.productName }}</td>
|
||||
<td>
|
||||
<span
|
||||
class="srep-pill"
|
||||
[ngClass]="stateClass(row.holdingState)"
|
||||
[title]="stateNote(row.holdingState)"
|
||||
>{{ stateLabel(row.holdingState) }}</span
|
||||
>
|
||||
</td>
|
||||
<td class="srep-num">
|
||||
{{ row.sharesHeld | number }}
|
||||
@if (row.holdingState === 'UNPAID') {
|
||||
<div class="srep-sub">{{ row.unpaidShares | number }} unpaid</div>
|
||||
}
|
||||
</td>
|
||||
<td class="srep-num">{{ row.nominalShares | number }}</td>
|
||||
<td class="srep-num">{{ row.againstNominal > 0 ? '+' : '' }}{{ row.againstNominal | number }}</td>
|
||||
<td class="srep-num">{{ row.minimumShares | number }}</td>
|
||||
<td class="srep-num">{{ row.maximumShares | number }}</td>
|
||||
<td class="srep-num">{{ row.faceValue | number: '1.2-2' }}</td>
|
||||
<td class="srep-num">₹{{ row.nominalValue | number: '1.2-2' }}</td>
|
||||
<td>
|
||||
<span class="srep-pill" [ngClass]="membershipClass(row.membershipClass)">{{
|
||||
membershipLabel(row.membershipClass)
|
||||
}}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="srep-pill" [ngClass]="complianceClass(row.complianceStatus)">{{
|
||||
complianceLabel(row.complianceStatus)
|
||||
}}</span>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="11">
|
||||
{{ isFiltered ? 'Total (filtered)' : 'Total' }} — {{ filteredRows.length | number }} accounts
|
||||
</td>
|
||||
<td class="srep-num">₹{{ shownNominalValue | number: '1.2-2' }}</td>
|
||||
<td colspan="2"></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@if (isFiltered && totals) {
|
||||
<div class="srep-scope-note">
|
||||
Showing a filtered view. Society-wide: ₹{{ totals.nominalValue | number: '1.2-2' }} nominal capital,
|
||||
{{ totals.regularMembers | number }} regular and {{ totals.nominalMembers | number }} nominal members,
|
||||
{{ totals.belowMinimum + totals.aboveMaximum | number }} outside the class's limits.
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,337 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { of } from 'rxjs';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
|
||||
import { SharesService } from '../../shares.service';
|
||||
import { SettingsService } from 'app/settings/settings.service';
|
||||
import {
|
||||
SHARE_ACCOUNT_STATUS,
|
||||
SHARE_HOLDING_STATE_ORDER,
|
||||
holdingStateClass,
|
||||
holdingStateLabel,
|
||||
holdingStateNote,
|
||||
holdingStateRowClass,
|
||||
shareReportCsv,
|
||||
shareReportPrint
|
||||
} from '../share-report.util';
|
||||
|
||||
interface NominalRow {
|
||||
shareAccountId: number;
|
||||
accountNo: string;
|
||||
clientId: number;
|
||||
cifNumber: string;
|
||||
clientName: string;
|
||||
officeName: string;
|
||||
productId: number;
|
||||
productName: string;
|
||||
statusEnum: number;
|
||||
faceValue: number;
|
||||
nominalShares: number;
|
||||
minimumShares: number;
|
||||
maximumShares: number;
|
||||
sharesHeld: number;
|
||||
nominalValue: number;
|
||||
againstNominal: number;
|
||||
complianceStatus: 'NO_HOLDING' | 'BELOW_MINIMUM' | 'ABOVE_MAXIMUM' | 'WITHIN_LIMITS' | 'NOT_APPLICABLE';
|
||||
membershipClass: 'NONE' | 'NOMINAL' | 'REGULAR' | 'NOT_APPLICABLE';
|
||||
holdingState: string;
|
||||
unpaidShares: number;
|
||||
activatedDate: string;
|
||||
}
|
||||
|
||||
/** What a compliance state is called and how it is flagged. */
|
||||
const COMPLIANCE: { [key: string]: { label: string; cls: string } } = {
|
||||
WITHIN_LIMITS: { label: 'Within limits', cls: 'srep-pill--green' },
|
||||
BELOW_MINIMUM: { label: 'Below minimum', cls: 'srep-pill--red' },
|
||||
ABOVE_MAXIMUM: { label: 'Above maximum', cls: 'srep-pill--red' },
|
||||
NO_HOLDING: { label: 'No holding', cls: 'srep-pill--grey' },
|
||||
// A closed or rejected account is not subject to the class's minimum any more, so it is excused
|
||||
// rather than reported as breaching it.
|
||||
NOT_APPLICABLE: { label: 'Not applicable', cls: 'srep-pill--grey' }
|
||||
};
|
||||
|
||||
const MEMBERSHIP: { [key: string]: { label: string; cls: string } } = {
|
||||
REGULAR: { label: 'Regular', cls: 'srep-pill--green' },
|
||||
NOMINAL: { label: 'Nominal', cls: 'srep-pill--amber' },
|
||||
NONE: { label: '—', cls: 'srep-pill--grey' },
|
||||
NOT_APPLICABLE: { label: '—', cls: 'srep-pill--grey' }
|
||||
};
|
||||
|
||||
/**
|
||||
* Nominal Share Report — each member's holding read against the three numbers the share class
|
||||
* itself sets: its nominal subscription, its minimum and its maximum.
|
||||
*
|
||||
* <p>
|
||||
* The report exists to find breaches, so the default view is every member and the breaches are
|
||||
* flagged in place; the compliance filter is there for when the answer needed is only "who is
|
||||
* short".
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-share-nominal-report',
|
||||
templateUrl: './share-nominal-report.component.html',
|
||||
styleUrls: [
|
||||
'../../../letter-mgmt/certificates/certificates.shared.scss',
|
||||
'../share-report.shared.scss'
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
FaIconComponent
|
||||
]
|
||||
})
|
||||
export class ShareNominalReportComponent implements OnInit {
|
||||
private sharesService = inject(SharesService);
|
||||
private settingsService = inject(SettingsService);
|
||||
private router = inject(Router);
|
||||
|
||||
loading = true;
|
||||
loadError = '';
|
||||
rows: NominalRow[] = [];
|
||||
totals: any = null;
|
||||
stateCounts: any = null;
|
||||
|
||||
asOnDate = '';
|
||||
search = '';
|
||||
branchFilter = '';
|
||||
productFilter = '';
|
||||
complianceFilter = '';
|
||||
membershipFilter = '';
|
||||
/**
|
||||
* Which accounts to show. Defaults to the live ones — a compliance report is read as "who is
|
||||
* short today", and a closed member cannot be short of anything. Closed and rejected accounts are
|
||||
* one dropdown away rather than gone, and the count of what the default hides is shown above the
|
||||
* table so the narrowing is never silent.
|
||||
*/
|
||||
stateFilter = 'LIVE';
|
||||
readonly stateOrder = SHARE_HOLDING_STATE_ORDER;
|
||||
|
||||
ngOnInit(): void {
|
||||
const businessDate: Date = this.settingsService.businessDate || new Date();
|
||||
this.asOnDate = this.toIso(businessDate);
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading = true;
|
||||
this.loadError = '';
|
||||
this.sharesService
|
||||
.getShareNominalReport({ asOnDate: this.asOnDate })
|
||||
.pipe(
|
||||
catchError((err) => {
|
||||
this.loadError = this.readError(err, 'The nominal share report could not be loaded.');
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
.subscribe((res: any) => {
|
||||
this.rows = res?.rows || [];
|
||||
this.totals = res?.totals || null;
|
||||
this.stateCounts = res?.stateCounts || null;
|
||||
this.loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
/** Live means the account is still a going concern, whether or not it currently holds anything. */
|
||||
private isLive(row: NominalRow): boolean {
|
||||
return row.holdingState === 'HOLDING' || row.holdingState === 'UNPAID' || row.holdingState === 'NIL';
|
||||
}
|
||||
|
||||
/** How many accounts the default "live only" view is leaving out, so it can say so. */
|
||||
get hiddenByStateCount(): number {
|
||||
return this.stateFilter === 'LIVE' ? this.rows.filter((r) => !this.isLive(r)).length : 0;
|
||||
}
|
||||
|
||||
get unpaidRows(): NominalRow[] {
|
||||
return this.rows.filter((r) => r.holdingState === 'UNPAID');
|
||||
}
|
||||
|
||||
get unpaidShares(): number {
|
||||
return this.unpaidRows.reduce((sum, r) => sum + Number(r.unpaidShares || 0), 0);
|
||||
}
|
||||
|
||||
get branchOptions(): string[] {
|
||||
return [...new Set(this.rows.map((r) => r.officeName).filter(Boolean))].sort();
|
||||
}
|
||||
|
||||
get productOptions(): string[] {
|
||||
return [...new Set(this.rows.map((r) => r.productName).filter(Boolean))].sort();
|
||||
}
|
||||
|
||||
get filteredRows(): NominalRow[] {
|
||||
const term = this.search.trim().toLowerCase();
|
||||
return this.rows.filter((r) => {
|
||||
if (this.stateFilter === 'LIVE' ? !this.isLive(r) : this.stateFilter && r.holdingState !== this.stateFilter) {
|
||||
return false;
|
||||
}
|
||||
if (this.branchFilter && r.officeName !== this.branchFilter) {
|
||||
return false;
|
||||
}
|
||||
if (this.productFilter && r.productName !== this.productFilter) {
|
||||
return false;
|
||||
}
|
||||
if (this.complianceFilter && r.complianceStatus !== this.complianceFilter) {
|
||||
return false;
|
||||
}
|
||||
if (this.membershipFilter && r.membershipClass !== this.membershipFilter) {
|
||||
return false;
|
||||
}
|
||||
if (!term) {
|
||||
return true;
|
||||
}
|
||||
return [
|
||||
r.clientName,
|
||||
r.accountNo,
|
||||
r.cifNumber,
|
||||
r.productName
|
||||
]
|
||||
.filter(Boolean)
|
||||
.some((field) => String(field).toLowerCase().includes(term));
|
||||
});
|
||||
}
|
||||
|
||||
get shownNominalValue(): number {
|
||||
return this.filteredRows.reduce((sum, r) => sum + Number(r.nominalValue || 0), 0);
|
||||
}
|
||||
|
||||
get isFiltered(): boolean {
|
||||
return !!(
|
||||
this.search.trim() ||
|
||||
this.branchFilter ||
|
||||
this.productFilter ||
|
||||
this.complianceFilter ||
|
||||
this.membershipFilter ||
|
||||
this.stateFilter !== 'LIVE'
|
||||
);
|
||||
}
|
||||
|
||||
stateLabel(state: string): string {
|
||||
return holdingStateLabel(state);
|
||||
}
|
||||
|
||||
stateClass(state: string): string {
|
||||
return holdingStateClass(state);
|
||||
}
|
||||
|
||||
stateRowClass(state: string): string {
|
||||
return holdingStateRowClass(state);
|
||||
}
|
||||
|
||||
stateNote(state: string): string {
|
||||
return holdingStateNote(state);
|
||||
}
|
||||
|
||||
complianceLabel(status: string): string {
|
||||
return COMPLIANCE[status]?.label || status;
|
||||
}
|
||||
|
||||
complianceClass(status: string): string {
|
||||
return COMPLIANCE[status]?.cls || 'srep-pill--grey';
|
||||
}
|
||||
|
||||
membershipLabel(membership: string): string {
|
||||
return MEMBERSHIP[membership]?.label || membership;
|
||||
}
|
||||
|
||||
membershipClass(membership: string): string {
|
||||
return MEMBERSHIP[membership]?.cls || 'srep-pill--grey';
|
||||
}
|
||||
|
||||
statusLabel(statusEnum: number): string {
|
||||
return SHARE_ACCOUNT_STATUS[statusEnum]?.label || String(statusEnum ?? '');
|
||||
}
|
||||
|
||||
statusClass(statusEnum: number): string {
|
||||
return SHARE_ACCOUNT_STATUS[statusEnum]?.cls || 'srep-pill--grey';
|
||||
}
|
||||
|
||||
clearFilters(): void {
|
||||
this.search = '';
|
||||
this.branchFilter = '';
|
||||
this.productFilter = '';
|
||||
this.complianceFilter = '';
|
||||
this.membershipFilter = '';
|
||||
this.stateFilter = 'LIVE';
|
||||
}
|
||||
|
||||
openAccount(row: NominalRow): void {
|
||||
this.router.navigate([
|
||||
'/clients',
|
||||
row.clientId,
|
||||
'shares-accounts',
|
||||
row.shareAccountId,
|
||||
'general'
|
||||
]);
|
||||
}
|
||||
|
||||
exportCsv(): void {
|
||||
shareReportCsv(
|
||||
`share-nominal-${this.asOnDate || 'today'}`,
|
||||
[
|
||||
'Member',
|
||||
'Membership No',
|
||||
'Share A/c No',
|
||||
'Branch',
|
||||
'Product',
|
||||
'Account Status',
|
||||
'Holding State',
|
||||
'Shares Held',
|
||||
'Allotted Unpaid',
|
||||
'Nominal Required',
|
||||
'Against Nominal',
|
||||
'Minimum',
|
||||
'Maximum',
|
||||
'Face Value',
|
||||
'Nominal Value',
|
||||
'Membership Class',
|
||||
'Compliance'
|
||||
],
|
||||
this.filteredRows.map((r) => [
|
||||
r.clientName,
|
||||
r.cifNumber,
|
||||
r.accountNo,
|
||||
r.officeName,
|
||||
r.productName,
|
||||
this.statusLabel(r.statusEnum),
|
||||
this.stateLabel(r.holdingState),
|
||||
r.sharesHeld,
|
||||
r.unpaidShares,
|
||||
r.nominalShares,
|
||||
r.againstNominal,
|
||||
r.minimumShares,
|
||||
r.maximumShares,
|
||||
r.faceValue,
|
||||
r.nominalValue,
|
||||
this.membershipLabel(r.membershipClass),
|
||||
this.complianceLabel(r.complianceStatus)
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
print(): void {
|
||||
shareReportPrint();
|
||||
}
|
||||
|
||||
private toIso(date: Date): string {
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
private readError(err: any, fallback: string): string {
|
||||
return (
|
||||
err?.error?.errors?.[0]?.defaultUserMessage ||
|
||||
err?.error?.defaultUserMessage ||
|
||||
err?.error?.developerMessage ||
|
||||
fallback
|
||||
);
|
||||
}
|
||||
}
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="cert-root">
|
||||
<div class="cert-head srep-no-print">
|
||||
<div class="cert-head-l">
|
||||
<fa-icon icon="file-signature" class="cert-head-ic"></fa-icon>
|
||||
<div>
|
||||
<h2 class="cert-head-t">Share Payment Certificate</h2>
|
||||
<div class="cert-head-s">Proof of what a member paid for their shares, and against which receipt.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-head-r">
|
||||
<button class="cert-cta-btn" (click)="print()" [disabled]="!account">
|
||||
<fa-icon icon="print"></fa-icon> Print Certificate
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cert-filters srep-no-print">
|
||||
<div class="cert-filter" style="flex: 2 1 340px; position: relative">
|
||||
<label>Share account</label>
|
||||
<div class="cert-input-ic">
|
||||
<fa-icon icon="search"></fa-icon>
|
||||
<input
|
||||
type="text"
|
||||
[ngModel]="accountSearch"
|
||||
(ngModelChange)="onAccountSearch($event)"
|
||||
placeholder="Search member name or share account number"
|
||||
/>
|
||||
</div>
|
||||
@if (accountOptions.length && !selectedAccountId) {
|
||||
<div class="srep-picker">
|
||||
@for (opt of accountOptions; track opt.id) {
|
||||
<button type="button" class="srep-picker-item" (click)="selectAccount(opt)">
|
||||
<span class="srep-picker-name">{{ opt.clientName }}</span>
|
||||
<span class="srep-picker-meta srep-mono">{{ opt.accountNo }}</span>
|
||||
<span class="srep-picker-meta">{{ opt.productName }}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!selectedAccountId) {
|
||||
<div class="srep-state">Search for a member or share account above to issue a payment certificate.</div>
|
||||
} @else if (loading) {
|
||||
<div class="srep-state">Loading payment record…</div>
|
||||
} @else if (loadError) {
|
||||
<div class="srep-state srep-state--error"><fa-icon icon="exclamation-circle"></fa-icon> {{ loadError }}</div>
|
||||
} @else if (account) {
|
||||
<!-- The certificate itself. Everything above is the picker; this is what prints. -->
|
||||
<div class="spc-doc">
|
||||
<div class="spc-letterhead">
|
||||
<div class="spc-org">{{ institution?.name || institution?.institutionName || 'Co-operative Bank' }}</div>
|
||||
@if (institution?.addressLine1 || institution?.address) {
|
||||
<div class="spc-org-sub">{{ institution?.addressLine1 || institution?.address }}</div>
|
||||
}
|
||||
<div class="spc-title">Share Payment Certificate</div>
|
||||
</div>
|
||||
|
||||
<div class="spc-body">
|
||||
<p>
|
||||
This is to certify that <b>{{ account.clientName }}</b>
|
||||
@if (account.cifNumber) {
|
||||
(Membership No. <b class="srep-mono">{{ account.cifNumber }}</b
|
||||
>)
|
||||
}
|
||||
@if (account.officeName) {
|
||||
of our <b>{{ account.officeName }}</b> branch
|
||||
}
|
||||
has paid the amounts set out below towards
|
||||
<b>{{ totals?.sharesPaidFor | number }}</b> shares of <b>{{ account.productName }}</b> at ₹{{
|
||||
account.unitPrice | number: '1.2-2'
|
||||
}}
|
||||
each, held under share account <b class="srep-mono">{{ account.accountNo }}</b
|
||||
>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="srep-table-wrap">
|
||||
<table class="srep-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Payment Date</th>
|
||||
<th class="srep-num">Shares</th>
|
||||
<th class="srep-num">Unit Price</th>
|
||||
<th class="srep-num">Amount Paid</th>
|
||||
<th>Paid By</th>
|
||||
<th>Receipt / Voucher</th>
|
||||
<th>Board Resolution</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (p of payments; track p.transactionId) {
|
||||
<tr>
|
||||
<td>{{ p.paymentDate }}</td>
|
||||
<td class="srep-num">{{ p.shares | number }}</td>
|
||||
<td class="srep-num">{{ p.unitPrice | number: '1.2-2' }}</td>
|
||||
<td class="srep-num">₹{{ p.amount | number: '1.2-2' }}</td>
|
||||
<td>{{ funding(p.fundingSource) }}</td>
|
||||
<td class="srep-mono">
|
||||
{{ receiptRef(p) }}
|
||||
@if (p.receiptDate) {
|
||||
<div class="srep-scope-note" style="margin: 0">{{ p.receiptDate }}</div>
|
||||
}
|
||||
</td>
|
||||
<td class="srep-mono">{{ p.resolutionNumber || '—' }}</td>
|
||||
</tr>
|
||||
}
|
||||
@for (c of charges; track c.chargeName) {
|
||||
<tr>
|
||||
<td>—</td>
|
||||
<td class="srep-num">—</td>
|
||||
<td class="srep-num">—</td>
|
||||
<td class="srep-num">₹{{ c.amountPaid | number: '1.2-2' }}</td>
|
||||
<td colspan="3">{{ c.chargeName }} (entry fee)</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="3">Total paid</td>
|
||||
<td class="srep-num">₹{{ totals?.totalPaid | number: '1.2-2' }}</td>
|
||||
<td colspan="3"></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@if (amountInWords) {
|
||||
<div class="spc-words">
|
||||
Amount in words: <b>{{ amountInWords }}</b>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="spc-foot">
|
||||
<div class="spc-foot-l">
|
||||
<div>
|
||||
Date of issue: <b>{{ today }}</b>
|
||||
</div>
|
||||
<div class="srep-scope-note" style="margin-top: 4px">
|
||||
Account status: {{ statusLabel(account.statusEnum) }}. Shares currently held:
|
||||
{{ account.sharesHeld | number }}.
|
||||
</div>
|
||||
</div>
|
||||
<div class="spc-sign">
|
||||
<div class="spc-sign-line"></div>
|
||||
<div>Authorised Signatory</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
+236
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { Subject, of } from 'rxjs';
|
||||
import { catchError, debounceTime, distinctUntilChanged, switchMap } from 'rxjs/operators';
|
||||
|
||||
import { SharesService } from '../../shares.service';
|
||||
import { OrganizationService } from 'app/organization/organization.service';
|
||||
import { SHARE_ACCOUNT_STATUS, fundingLabel, shareReportPrint } from '../share-report.util';
|
||||
|
||||
/**
|
||||
* Share Payment Certificate — proof of what a member paid for their shares, and against which
|
||||
* receipt.
|
||||
*
|
||||
* <p>
|
||||
* Distinct from a share certificate, which certifies ownership: this certifies payment, so it leads
|
||||
* with amounts, dates and the voucher each one was received on. It is the document a member is asked
|
||||
* for when the money, not the holding, has to be proved.
|
||||
*
|
||||
* <p>
|
||||
* Every tranche is shown rather than one total — a member who paid in instalments paid on several
|
||||
* dates against several receipts, and a certificate carrying a single figure reconciles to none of
|
||||
* them.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-share-payment-certificate',
|
||||
templateUrl: './share-payment-certificate.component.html',
|
||||
styleUrls: [
|
||||
'../../../letter-mgmt/certificates/certificates.shared.scss',
|
||||
'../share-report.shared.scss'
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
FaIconComponent
|
||||
]
|
||||
})
|
||||
export class SharePaymentCertificateComponent implements OnInit {
|
||||
private sharesService = inject(SharesService);
|
||||
private organizationService = inject(OrganizationService);
|
||||
private route = inject(ActivatedRoute);
|
||||
|
||||
accountSearch = '';
|
||||
accountOptions: any[] = [];
|
||||
selectedAccountId: number | null = null;
|
||||
|
||||
loading = false;
|
||||
loadError = '';
|
||||
account: any = null;
|
||||
payments: any[] = [];
|
||||
charges: any[] = [];
|
||||
totals: any = null;
|
||||
|
||||
/** The society's own name and address, so the printed certificate is on its letterhead. */
|
||||
institution: any = null;
|
||||
|
||||
private searchTerm$ = new Subject<string>();
|
||||
|
||||
ngOnInit(): void {
|
||||
this.searchTerm$
|
||||
.pipe(
|
||||
debounceTime(350),
|
||||
distinctUntilChanged(),
|
||||
switchMap((term) => this.sharesService.getShareAccountSummaries(term).pipe(catchError(() => of([]))))
|
||||
)
|
||||
.subscribe((res: any) => {
|
||||
this.accountOptions = Array.isArray(res) ? res : res?.pageItems || [];
|
||||
});
|
||||
|
||||
this.organizationService
|
||||
.getInstitutionProfile()
|
||||
.pipe(catchError(() => of(null)))
|
||||
.subscribe((res: any) => (this.institution = res));
|
||||
|
||||
const preselected = this.route.snapshot.queryParamMap.get('shareAccountId');
|
||||
if (preselected) {
|
||||
this.selectedAccountId = Number(preselected);
|
||||
this.load();
|
||||
}
|
||||
this.searchTerm$.next('');
|
||||
}
|
||||
|
||||
onAccountSearch(term: string): void {
|
||||
this.accountSearch = term;
|
||||
this.searchTerm$.next(term.trim());
|
||||
}
|
||||
|
||||
selectAccount(option: any): void {
|
||||
this.selectedAccountId = option.id;
|
||||
this.accountOptions = [];
|
||||
this.accountSearch = `${option.clientName} — ${option.accountNo}`;
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
if (!this.selectedAccountId) {
|
||||
return;
|
||||
}
|
||||
this.loading = true;
|
||||
this.loadError = '';
|
||||
this.sharesService
|
||||
.getSharePaymentCertificate(this.selectedAccountId)
|
||||
.pipe(
|
||||
catchError((err) => {
|
||||
this.loadError = this.readError(err, 'The payment certificate could not be loaded.');
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
.subscribe((res: any) => {
|
||||
this.account = res?.account || null;
|
||||
this.payments = res?.payments || [];
|
||||
this.charges = res?.charges || [];
|
||||
this.totals = res?.totals || null;
|
||||
this.loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
statusLabel(statusEnum: number): string {
|
||||
return SHARE_ACCOUNT_STATUS[statusEnum]?.label || String(statusEnum ?? '');
|
||||
}
|
||||
|
||||
statusClass(statusEnum: number): string {
|
||||
return SHARE_ACCOUNT_STATUS[statusEnum]?.cls || 'srep-pill--grey';
|
||||
}
|
||||
|
||||
funding(source: string): string {
|
||||
return fundingLabel(source);
|
||||
}
|
||||
|
||||
/** Whatever identifies the money that came in: a voucher, else the receipt, else the route. */
|
||||
receiptRef(payment: any): string {
|
||||
return (
|
||||
payment.voucherNo || payment.receiptNo || (payment.fundingSource === 'SAVINGS' ? 'Debited from savings' : '—')
|
||||
);
|
||||
}
|
||||
|
||||
/** Amount in words, because a certificate carrying a figure alone is trivially altered. */
|
||||
get amountInWords(): string {
|
||||
const amount = Math.round(Number(this.totals?.totalPaid || 0));
|
||||
return amount ? `${this.toWords(amount)} rupees only` : '';
|
||||
}
|
||||
|
||||
private toWords(value: number): string {
|
||||
const ones = [
|
||||
'',
|
||||
'one',
|
||||
'two',
|
||||
'three',
|
||||
'four',
|
||||
'five',
|
||||
'six',
|
||||
'seven',
|
||||
'eight',
|
||||
'nine',
|
||||
'ten',
|
||||
'eleven',
|
||||
'twelve',
|
||||
'thirteen',
|
||||
'fourteen',
|
||||
'fifteen',
|
||||
'sixteen',
|
||||
'seventeen',
|
||||
'eighteen',
|
||||
'nineteen'
|
||||
];
|
||||
const tens = [
|
||||
'',
|
||||
'',
|
||||
'twenty',
|
||||
'thirty',
|
||||
'forty',
|
||||
'fifty',
|
||||
'sixty',
|
||||
'seventy',
|
||||
'eighty',
|
||||
'ninety'
|
||||
];
|
||||
|
||||
const twoDigits = (n: number): string =>
|
||||
n < 20 ? ones[n] : `${tens[Math.floor(n / 10)]}${n % 10 ? '-' + ones[n % 10] : ''}`;
|
||||
|
||||
// Indian grouping — a co-operative's certificates read in lakh and crore, not million.
|
||||
const parts: string[] = [];
|
||||
const crore = Math.floor(value / 10000000);
|
||||
const lakh = Math.floor((value % 10000000) / 100000);
|
||||
const thousand = Math.floor((value % 100000) / 1000);
|
||||
const hundred = Math.floor((value % 1000) / 100);
|
||||
const rest = value % 100;
|
||||
|
||||
if (crore) {
|
||||
parts.push(`${twoDigits(crore)} crore`);
|
||||
}
|
||||
if (lakh) {
|
||||
parts.push(`${twoDigits(lakh)} lakh`);
|
||||
}
|
||||
if (thousand) {
|
||||
parts.push(`${twoDigits(thousand)} thousand`);
|
||||
}
|
||||
if (hundred) {
|
||||
parts.push(`${ones[hundred]} hundred`);
|
||||
}
|
||||
if (rest) {
|
||||
parts.push(twoDigits(rest));
|
||||
}
|
||||
const words = parts.join(' ').trim();
|
||||
return words ? words.charAt(0).toUpperCase() + words.slice(1) : 'Zero';
|
||||
}
|
||||
|
||||
get today(): string {
|
||||
const now = new Date();
|
||||
return `${String(now.getDate()).padStart(2, '0')}-${String(now.getMonth() + 1).padStart(2, '0')}-${now.getFullYear()}`;
|
||||
}
|
||||
|
||||
print(): void {
|
||||
shareReportPrint();
|
||||
}
|
||||
|
||||
private readError(err: any, fallback: string): string {
|
||||
return (
|
||||
err?.error?.errors?.[0]?.defaultUserMessage ||
|
||||
err?.error?.defaultUserMessage ||
|
||||
err?.error?.developerMessage ||
|
||||
fallback
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="cert-root">
|
||||
<div class="cert-head">
|
||||
<div class="cert-head-l">
|
||||
<fa-icon icon="layer-group" class="cert-head-ic"></fa-icon>
|
||||
<div>
|
||||
<h2 class="cert-head-t">Share Product Summary</h2>
|
||||
<div class="cert-head-s">
|
||||
Every share class against its own authorised capital — shares issued, shares remaining, and the
|
||||
distinctive-number range issued so far. "Issued" means paid up, not merely allotted.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-head-r">
|
||||
<button class="cert-cta-btn" (click)="exportCsv()" [disabled]="loading || !rows.length">
|
||||
<fa-icon icon="file-alt"></fa-icon> Export CSV
|
||||
</button>
|
||||
<button class="cert-cta-btn" (click)="print()" [disabled]="loading || !rows.length">
|
||||
<fa-icon icon="print"></fa-icon> Print
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (rows.length) {
|
||||
<div class="cert-kpis">
|
||||
<div class="cert-kpi cert-kpi--blue">
|
||||
<fa-icon icon="layer-group" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">{{ totals.authorisedShares | number }}</div>
|
||||
<div class="cert-kpi-l">Authorised Shares (all classes)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-kpi cert-kpi--green">
|
||||
<fa-icon icon="coins" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">{{ totals.issuedShares | number }}</div>
|
||||
<div class="cert-kpi-l">Shares Issued (paid up)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-kpi cert-kpi--teal">
|
||||
<fa-icon icon="balance-scale" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">₹{{ totals.issuedCapital | number: '1.2-2' }}</div>
|
||||
<div class="cert-kpi-l">Share Capital Issued</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-kpi" [ngClass]="totals.unpaidShares ? 'cert-kpi--amber' : 'cert-kpi--blue'">
|
||||
<fa-icon icon="exclamation-circle" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">{{ totals.unpaidShares | number }}</div>
|
||||
<div class="cert-kpi-l">Allotted, Unpaid</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (loading) {
|
||||
<div class="srep-state">Loading product summary…</div>
|
||||
} @else if (loadError) {
|
||||
<div class="srep-state srep-state--error"><fa-icon icon="exclamation-circle"></fa-icon> {{ loadError }}</div>
|
||||
} @else if (!rows.length) {
|
||||
<div class="srep-state">No share product is configured.</div>
|
||||
} @else {
|
||||
<div class="srep-table-wrap">
|
||||
<table class="srep-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Share Class</th>
|
||||
<th class="srep-num">Unit Price</th>
|
||||
<th class="srep-num">Authorised</th>
|
||||
<th class="srep-num">Issued (Paid Up)</th>
|
||||
<th class="srep-num">Available</th>
|
||||
<th>Utilisation</th>
|
||||
<th class="srep-num">Allotted, Unpaid</th>
|
||||
<th>Distinctive Range</th>
|
||||
<th class="srep-num">Accounts</th>
|
||||
<th>Min / Nominal / Max</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of rows; track row.productId) {
|
||||
<tr>
|
||||
<td>
|
||||
{{ row.productName }}
|
||||
@if (row.productShortName) {
|
||||
<div class="srep-mono srep-sub" style="color: inherit; opacity: 0.65">{{ row.productShortName }}</div>
|
||||
}
|
||||
</td>
|
||||
<td class="srep-num">{{ row.unitPrice | number: '1.2-2' }}</td>
|
||||
<td class="srep-num">
|
||||
{{ row.authorisedShares | number }}
|
||||
<div class="srep-sub" style="color: inherit; opacity: 0.65">
|
||||
₹{{ row.authorisedCapital | number: '1.0-0' }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="srep-num">
|
||||
{{ row.issuedShares | number }}
|
||||
<div class="srep-sub" style="color: inherit; opacity: 0.65">
|
||||
₹{{ row.issuedCapital | number: '1.0-0' }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="srep-num">{{ row.availableShares | number }}</td>
|
||||
<td>
|
||||
<div class="product-utilisation-bar">
|
||||
<div class="product-utilisation-fill" [style.width.%]="utilisationPercent(row)"></div>
|
||||
</div>
|
||||
<div class="srep-mono" style="font-size: 0.72rem; color: #64748b">
|
||||
{{ utilisationPercent(row) | number: '1.0-1' }}%
|
||||
</div>
|
||||
</td>
|
||||
<td class="srep-num">
|
||||
@if (row.unpaidShares) {
|
||||
<span class="srep-pill srep-pill--amber">{{ row.unpaidShares | number }}</span>
|
||||
<div class="srep-sub" style="color: inherit; opacity: 0.65">
|
||||
₹{{ row.unpaidAmount | number: '1.0-0' }} · {{ row.unpaidAccounts }}
|
||||
{{ row.unpaidAccounts === 1 ? 'account' : 'accounts' }}
|
||||
</div>
|
||||
} @else {
|
||||
<span class="srep-pill srep-pill--green">0</span>
|
||||
}
|
||||
</td>
|
||||
<td class="srep-mono">{{ distinctiveRange(row) }}</td>
|
||||
<td class="srep-num">
|
||||
{{ row.accountCount | number }}
|
||||
<div class="srep-sub" style="color: inherit; opacity: 0.65">
|
||||
{{ row.memberCount | number }} {{ row.memberCount === 1 ? 'member' : 'members' }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="srep-mono">
|
||||
{{ row.minimumClientShares ?? '—' }} / {{ row.nominalClientShares ?? '—' }} /
|
||||
{{ row.maximumClientShares ?? '—' }}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td>Total — {{ rows.length | number }} share classes</td>
|
||||
<td class="srep-num"></td>
|
||||
<td class="srep-num">{{ totals.authorisedShares | number }}</td>
|
||||
<td class="srep-num">{{ totals.issuedShares | number }}</td>
|
||||
<td class="srep-num" colspan="2"></td>
|
||||
<td class="srep-num">{{ totals.unpaidShares | number }}</td>
|
||||
<td colspan="3"></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@if (totals.unpaidShares) {
|
||||
<div class="srep-warn">
|
||||
<fa-icon icon="exclamation-circle"></fa-icon>
|
||||
<span>
|
||||
<b>{{ totals.unpaidShares | number }}</b> shares across every class are allotted but not yet paid for —
|
||||
<b>₹{{ totals.unpaidAmount | number: '1.2-2' }}</b> in all. They are excluded from "Issued" above; see Final
|
||||
Balance for which members they belong to.
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* 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 { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { of } from 'rxjs';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
|
||||
import { SharesService } from '../../shares.service';
|
||||
import { shareReportCsv, shareReportPrint } from '../share-report.util';
|
||||
|
||||
interface ProductSummaryRow {
|
||||
productId: number;
|
||||
productName: string;
|
||||
productShortName: string;
|
||||
unitPrice: number;
|
||||
minimumClientShares: number;
|
||||
nominalClientShares: number;
|
||||
maximumClientShares: number;
|
||||
authorisedShares: number;
|
||||
authorisedCapital: number;
|
||||
issuedShares: number;
|
||||
issuedCapital: number;
|
||||
availableShares: number;
|
||||
unpaidAccounts: number;
|
||||
unpaidShares: number;
|
||||
unpaidAmount: number;
|
||||
distinctiveFrom: number | null;
|
||||
distinctiveTo: number | null;
|
||||
accountCount: number;
|
||||
memberCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Share Product Summary — every share class measured against its own authorised capital: how many
|
||||
* shares are issued, how many remain, and the distinctive-number range issued so far.
|
||||
*
|
||||
* <p>
|
||||
* "Issued" is paid-up shares — settled purchases less redemptions, computed the same way every other
|
||||
* report in this suite computes them — not {@code m_share_product.totalsubscribed_shares}. That
|
||||
* platform column counts an allotment the moment it is submitted, before any money has necessarily
|
||||
* arrived, so it runs ahead of what the society has actually received. A board reading this page is
|
||||
* reading the same figure the GL Share Capital account would show, not a subscription count.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-share-product-summary',
|
||||
templateUrl: './share-product-summary.component.html',
|
||||
styleUrls: [
|
||||
'../../../letter-mgmt/certificates/certificates.shared.scss',
|
||||
'../share-report.shared.scss'
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FaIconComponent
|
||||
]
|
||||
})
|
||||
export class ShareProductSummaryComponent implements OnInit {
|
||||
private sharesService = inject(SharesService);
|
||||
|
||||
loading = true;
|
||||
loadError = '';
|
||||
rows: ProductSummaryRow[] = [];
|
||||
|
||||
ngOnInit(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading = true;
|
||||
this.loadError = '';
|
||||
this.sharesService
|
||||
.getShareProductSummary()
|
||||
.pipe(
|
||||
catchError((err: any) => {
|
||||
this.loadError = this.readError(err, 'The product summary could not be loaded.');
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
.subscribe((res: any) => {
|
||||
this.rows = res?.rows || [];
|
||||
this.loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
/** How much of a product's authorised capital is actually issued, for the progress bar. */
|
||||
utilisationPercent(row: ProductSummaryRow): number {
|
||||
if (!row.authorisedShares) {
|
||||
return 0;
|
||||
}
|
||||
const pct = (row.issuedShares / row.authorisedShares) * 100;
|
||||
return Math.max(0, Math.min(100, pct));
|
||||
}
|
||||
|
||||
distinctiveRange(row: ProductSummaryRow): string {
|
||||
if (!row.distinctiveFrom || !row.distinctiveTo) {
|
||||
return 'None allotted yet';
|
||||
}
|
||||
return `${row.distinctiveFrom} – ${row.distinctiveTo}`;
|
||||
}
|
||||
|
||||
get totals() {
|
||||
return {
|
||||
authorisedShares: this.rows.reduce((sum, r) => sum + Number(r.authorisedShares || 0), 0),
|
||||
authorisedCapital: this.rows.reduce((sum, r) => sum + Number(r.authorisedCapital || 0), 0),
|
||||
issuedShares: this.rows.reduce((sum, r) => sum + Number(r.issuedShares || 0), 0),
|
||||
issuedCapital: this.rows.reduce((sum, r) => sum + Number(r.issuedCapital || 0), 0),
|
||||
unpaidShares: this.rows.reduce((sum, r) => sum + Number(r.unpaidShares || 0), 0),
|
||||
unpaidAmount: this.rows.reduce((sum, r) => sum + Number(r.unpaidAmount || 0), 0)
|
||||
};
|
||||
}
|
||||
|
||||
exportCsv(): void {
|
||||
shareReportCsv(
|
||||
'share-product-summary',
|
||||
[
|
||||
'Product',
|
||||
'Unit Price',
|
||||
'Authorised Shares',
|
||||
'Authorised Capital',
|
||||
'Issued Shares',
|
||||
'Issued Capital',
|
||||
'Available Shares',
|
||||
'Unpaid Accounts',
|
||||
'Unpaid Shares',
|
||||
'Unpaid Amount',
|
||||
'Distinctive Range',
|
||||
'Accounts',
|
||||
'Members',
|
||||
'Min / Nominal / Max Per Member'
|
||||
],
|
||||
this.rows.map((r) => [
|
||||
r.productName,
|
||||
r.unitPrice,
|
||||
r.authorisedShares,
|
||||
r.authorisedCapital,
|
||||
r.issuedShares,
|
||||
r.issuedCapital,
|
||||
r.availableShares,
|
||||
r.unpaidAccounts,
|
||||
r.unpaidShares,
|
||||
r.unpaidAmount,
|
||||
this.distinctiveRange(r),
|
||||
r.accountCount,
|
||||
r.memberCount,
|
||||
`${r.minimumClientShares ?? '—'} / ${r.nominalClientShares ?? '—'} / ${r.maximumClientShares ?? '—'}`
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
print(): void {
|
||||
shareReportPrint();
|
||||
}
|
||||
|
||||
private readError(err: any, fallback: string): string {
|
||||
return (
|
||||
err?.error?.errors?.[0]?.defaultUserMessage ||
|
||||
err?.error?.defaultUserMessage ||
|
||||
err?.error?.developerMessage ||
|
||||
fallback
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,511 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Shared table, pill and print styling for the share reports.
|
||||
*
|
||||
* Layered on top of certificates.shared.scss, which already supplies the page frame
|
||||
* (.cert-root / .cert-head / .cert-kpis / .cert-filters). Only what those do not cover lives here.
|
||||
*/
|
||||
|
||||
$srep-border: #e2e8f0;
|
||||
$srep-border-soft: #f1f5f9;
|
||||
$srep-head-bg: #f8fafc;
|
||||
$srep-text: #0f172a;
|
||||
$srep-muted: #64748b;
|
||||
|
||||
.srep-table-wrap {
|
||||
overflow-x: auto;
|
||||
border: 1px solid $srep-border;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.srep-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.82rem;
|
||||
|
||||
th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: $srep-head-bg;
|
||||
color: $srep-muted;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
text-align: left;
|
||||
padding: 0.55rem 0.7rem;
|
||||
border-bottom: 1px solid $srep-border;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 0.5rem 0.7rem;
|
||||
border-bottom: 1px solid $srep-border-soft;
|
||||
color: $srep-text;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
tbody tr.srep-row--click {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: #f4f7fb;
|
||||
}
|
||||
}
|
||||
|
||||
// A row that is over — closed, rejected, never activated — still belongs on the register, but it
|
||||
// must not read like a live holding. Dimmed and marked at the edge rather than hidden, because a
|
||||
// closed account that silently disappears is its own kind of confusion at a counter.
|
||||
tbody tr.srep-row--muted {
|
||||
background: #fafbfc;
|
||||
color: $srep-muted;
|
||||
|
||||
td {
|
||||
color: $srep-muted;
|
||||
}
|
||||
|
||||
td:first-child {
|
||||
box-shadow: inset 3px 0 0 #cbd5e1;
|
||||
}
|
||||
}
|
||||
|
||||
// Allotted but never collected. Flagged, not dimmed: this one is live and wants chasing.
|
||||
tbody tr.srep-row--flag {
|
||||
background: #fffdf5;
|
||||
|
||||
td:first-child {
|
||||
box-shadow: inset 3px 0 0 #f59e0b;
|
||||
}
|
||||
}
|
||||
|
||||
.srep-num {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.srep-mono {
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
// A second line under a figure, for the qualifier the figure alone cannot carry — "50 unpaid"
|
||||
// beneath a held count of nothing.
|
||||
.srep-sub {
|
||||
margin-top: 1px;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
tfoot td {
|
||||
background: $srep-head-bg;
|
||||
font-weight: 700;
|
||||
border-top: 2px solid $srep-border;
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.srep-pill {
|
||||
display: inline-block;
|
||||
padding: 1px 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
|
||||
&.srep-pill--green {
|
||||
background: #ecfdf5;
|
||||
color: #059669;
|
||||
border: 1px solid #a7f3d0;
|
||||
}
|
||||
|
||||
&.srep-pill--amber {
|
||||
background: #fffbeb;
|
||||
color: #d97706;
|
||||
border: 1px solid #fde68a;
|
||||
}
|
||||
|
||||
&.srep-pill--red {
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
|
||||
&.srep-pill--blue {
|
||||
background: #eff6ff;
|
||||
color: #2563eb;
|
||||
border: 1px solid #bfdbfe;
|
||||
}
|
||||
|
||||
&.srep-pill--grey {
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
border: 1px solid #cbd5e1;
|
||||
}
|
||||
}
|
||||
|
||||
.srep-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 2rem 1rem;
|
||||
color: $srep-muted;
|
||||
font-size: 0.85rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.srep-state--error {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
/* A narrowed view says so, right where the numbers are, so it is never read as the whole society. */
|
||||
.srep-scope-note {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: $srep-muted;
|
||||
}
|
||||
|
||||
/* Account picker — results drop under the search box rather than into a dialog, so choosing an
|
||||
account is one click from typing rather than a modal that has to be dismissed. */
|
||||
.srep-picker {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 20;
|
||||
margin-top: 2px;
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
background: #fff;
|
||||
border: 1px solid $srep-border;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 10px 24px -8px rgb(15 23 42 / 18%);
|
||||
}
|
||||
|
||||
.srep-picker-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
width: 100%;
|
||||
padding: 0.45rem 0.7rem;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 1px solid $srep-border-soft;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: #f4f7fb;
|
||||
}
|
||||
}
|
||||
|
||||
.srep-picker-name {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: $srep-text;
|
||||
}
|
||||
|
||||
.srep-picker-meta {
|
||||
font-size: 0.72rem;
|
||||
color: $srep-muted;
|
||||
}
|
||||
|
||||
/* Inline distribution bar — the shareholding pattern reads as a shape before it reads as numbers,
|
||||
and a chart library for one bar per row would be a dependency for nothing. */
|
||||
.srep-bar {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background: $srep-border-soft;
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.srep-bar-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #2563eb, #60a5fa);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
/* Statement header — the identity block a printed statement has to carry. */
|
||||
.srep-stmt-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.85rem 1rem;
|
||||
margin-bottom: 0.85rem;
|
||||
background: #f8fafc;
|
||||
border: 1px solid $srep-border;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.srep-stmt-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 0.6rem 1.2rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.srep-stmt-l {
|
||||
font-size: 0.66rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: $srep-muted;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.srep-stmt-v {
|
||||
font-size: 0.84rem;
|
||||
color: $srep-text;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* A share class's own authorised-vs-issued fraction, read at a glance rather than only in the two
|
||||
numbers beside it. */
|
||||
.product-utilisation-bar {
|
||||
width: 100%;
|
||||
min-width: 90px;
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: $srep-border-soft;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.product-utilisation-fill {
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(90deg, #2563eb, #059669);
|
||||
}
|
||||
|
||||
/* Something the reader has to know before trusting the number above it — an exclusion, a gap, a
|
||||
figure that will not reconcile until someone acts. Loud enough to be read, not an error. */
|
||||
.srep-warn {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.6rem;
|
||||
margin: 0.6rem 0;
|
||||
padding: 0.65rem 0.85rem;
|
||||
background: #fffbeb;
|
||||
border: 1px solid #fde68a;
|
||||
border-left: 4px solid #d97706;
|
||||
border-radius: 8px;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.55;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
/* The selected tab, in a screen that has two halves rather than two pages. */
|
||||
.srep-tab--active {
|
||||
background: #eff6ff !important;
|
||||
border-color: #2563eb !important;
|
||||
color: #1e40af !important;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Printed certificate — a document, not a screen: it carries the society's letterhead, a body in
|
||||
sentences and a signature block, because that is what a member is handed and files. */
|
||||
.spc-doc {
|
||||
border: 1px solid $srep-border;
|
||||
border-radius: 10px;
|
||||
padding: 1.5rem 1.75rem;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.spc-letterhead {
|
||||
text-align: center;
|
||||
border-bottom: 2px solid $srep-text;
|
||||
padding-bottom: 0.75rem;
|
||||
margin-bottom: 1.1rem;
|
||||
}
|
||||
|
||||
.spc-org {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
color: $srep-text;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.spc-org-sub {
|
||||
font-size: 0.78rem;
|
||||
color: $srep-muted;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.spc-title {
|
||||
margin-top: 0.7rem;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: $srep-text;
|
||||
}
|
||||
|
||||
.spc-body {
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.7;
|
||||
color: $srep-text;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.spc-words {
|
||||
margin-top: 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
color: $srep-text;
|
||||
}
|
||||
|
||||
.spc-foot {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 2rem;
|
||||
margin-top: 2.5rem;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.spc-sign {
|
||||
text-align: center;
|
||||
font-size: 0.8rem;
|
||||
color: $srep-text;
|
||||
}
|
||||
|
||||
.spc-sign-line {
|
||||
width: 170px;
|
||||
border-top: 1px solid $srep-text;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
/* Label sheet — a grid sized to the label stationery a branch buys, printed straight from here. */
|
||||
.slab-sheet {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.6rem;
|
||||
}
|
||||
|
||||
.slab {
|
||||
border: 1px dashed $srep-border;
|
||||
border-radius: 6px;
|
||||
padding: 0.65rem 0.8rem;
|
||||
min-height: 92px;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.45;
|
||||
color: $srep-text;
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
.slab-name {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.slab-line {
|
||||
color: $srep-text;
|
||||
}
|
||||
|
||||
.slab-meta {
|
||||
margin-top: 3px;
|
||||
font-size: 0.7rem;
|
||||
color: $srep-muted;
|
||||
}
|
||||
|
||||
/* One letter, one page. */
|
||||
.slet-page {
|
||||
border: 1px solid $srep-border;
|
||||
border-radius: 10px;
|
||||
padding: 1.5rem 1.75rem;
|
||||
margin-top: 0.85rem;
|
||||
background: #fff;
|
||||
break-after: page;
|
||||
}
|
||||
|
||||
.slet-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1.5rem;
|
||||
font-size: 0.84rem;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 1.1rem;
|
||||
}
|
||||
|
||||
.slet-date {
|
||||
white-space: nowrap;
|
||||
color: $srep-muted;
|
||||
}
|
||||
|
||||
.slet-subject {
|
||||
font-weight: 700;
|
||||
font-size: 0.86rem;
|
||||
margin-bottom: 0.9rem;
|
||||
}
|
||||
|
||||
.slet-salutation {
|
||||
font-size: 0.86rem;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
.slet-body {
|
||||
font-size: 0.86rem;
|
||||
line-height: 1.75;
|
||||
white-space: pre-wrap;
|
||||
color: $srep-text;
|
||||
}
|
||||
|
||||
.slet-body-input {
|
||||
width: 100%;
|
||||
padding: 0.55rem 0.7rem;
|
||||
border: 1px solid $srep-border;
|
||||
border-radius: 8px;
|
||||
font-family: inherit;
|
||||
font-size: 0.84rem;
|
||||
line-height: 1.6;
|
||||
color: $srep-text;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
@media print {
|
||||
.cert-head-r,
|
||||
.cert-filters,
|
||||
.srep-no-print {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.slab {
|
||||
border: 1px solid #999;
|
||||
}
|
||||
|
||||
.slet-page {
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.spc-doc {
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.srep-table-wrap {
|
||||
overflow: visible;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.srep-table th {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Shared vocabulary and plumbing for the share reports.
|
||||
*
|
||||
* Kept in one place because the reports are read side by side — a member shown as "Active" on the
|
||||
* balance report and "Approved" on the status report would read as two different things when it is
|
||||
* one status_enum, and a CSV that quotes its fields on one screen but not the next breaks whichever
|
||||
* spreadsheet opens both.
|
||||
*/
|
||||
|
||||
/** m_share_account.status_enum → what a reader calls it, and the pill it wears. */
|
||||
export const SHARE_ACCOUNT_STATUS: { [key: number]: { label: string; cls: string } } = {
|
||||
100: { label: 'Pending Approval', cls: 'srep-pill--amber' },
|
||||
200: { label: 'Approved', cls: 'srep-pill--blue' },
|
||||
300: { label: 'Active', cls: 'srep-pill--green' },
|
||||
500: { label: 'Rejected', cls: 'srep-pill--red' },
|
||||
600: { label: 'Closed', cls: 'srep-pill--grey' }
|
||||
};
|
||||
|
||||
/**
|
||||
* Why a share account's holding reads the way it does — the server's own derivation, so every
|
||||
* report tells the member the same story about the same account.
|
||||
*
|
||||
* <p>
|
||||
* A nil holding is the most over-loaded figure on a share register. A closed account, an active
|
||||
* account whose money was never collected, and an account redeemed down to nothing all print the
|
||||
* same "0", and a reader cannot tell them apart. Each state therefore carries its own pill, its own
|
||||
* row treatment, and a sentence saying what it means — closed and finished rows are dimmed, and the
|
||||
* one state that still wants chasing is flagged rather than greyed out.
|
||||
*/
|
||||
export const SHARE_HOLDING_STATE: {
|
||||
[key: string]: { label: string; cls: string; row: string; note: string };
|
||||
} = {
|
||||
HOLDING: {
|
||||
label: 'Holding',
|
||||
cls: 'srep-pill--green',
|
||||
row: '',
|
||||
note: 'Paid-up shares in hand.'
|
||||
},
|
||||
UNPAID: {
|
||||
label: 'Allotted · unpaid',
|
||||
cls: 'srep-pill--amber',
|
||||
row: 'srep-row--flag',
|
||||
note: 'Shares allotted, but the collection was never applied — so they are in no capital figure.'
|
||||
},
|
||||
NIL: {
|
||||
label: 'Nil holding',
|
||||
cls: 'srep-pill--grey',
|
||||
row: 'srep-row--muted',
|
||||
note: 'Active and paid for, but redeemed back down to nothing.'
|
||||
},
|
||||
CLOSED: {
|
||||
label: 'Closed',
|
||||
cls: 'srep-pill--grey',
|
||||
row: 'srep-row--muted',
|
||||
note: 'The account is closed — the nil holding is final and correct.'
|
||||
},
|
||||
NOT_ACTIVE: {
|
||||
label: 'Not active',
|
||||
cls: 'srep-pill--blue',
|
||||
row: 'srep-row--muted',
|
||||
note: 'Submitted or approved, not yet activated.'
|
||||
},
|
||||
REJECTED: {
|
||||
label: 'Rejected',
|
||||
cls: 'srep-pill--red',
|
||||
row: 'srep-row--muted',
|
||||
note: 'The application never became an account.'
|
||||
}
|
||||
};
|
||||
|
||||
/** The states in the order a reader thinks about them — live first, finished last. */
|
||||
export const SHARE_HOLDING_STATE_ORDER = [
|
||||
'HOLDING',
|
||||
'UNPAID',
|
||||
'NIL',
|
||||
'CLOSED',
|
||||
'NOT_ACTIVE',
|
||||
'REJECTED'
|
||||
];
|
||||
|
||||
export function holdingStateLabel(state: string | null | undefined): string {
|
||||
return SHARE_HOLDING_STATE[state || '']?.label || state || '—';
|
||||
}
|
||||
|
||||
export function holdingStateClass(state: string | null | undefined): string {
|
||||
return SHARE_HOLDING_STATE[state || '']?.cls || 'srep-pill--grey';
|
||||
}
|
||||
|
||||
/** The row treatment: dimmed when the account is over, flagged when it still wants chasing. */
|
||||
export function holdingStateRowClass(state: string | null | undefined): string {
|
||||
return SHARE_HOLDING_STATE[state || '']?.row || '';
|
||||
}
|
||||
|
||||
export function holdingStateNote(state: string | null | undefined): string {
|
||||
return SHARE_HOLDING_STATE[state || '']?.note || '';
|
||||
}
|
||||
|
||||
/** m_share_account_transactions.type_enum — only the two that move shares. */
|
||||
export const SHARE_TXN_TYPE: { [key: number]: { label: string; cls: string } } = {
|
||||
500: { label: 'Purchase', cls: 'srep-pill--green' },
|
||||
600: { label: 'Redemption', cls: 'srep-pill--red' },
|
||||
700: { label: 'Charge Payment', cls: 'srep-pill--grey' }
|
||||
};
|
||||
|
||||
/** m_share_account_transactions.status_enum — a tranche's own approval state. */
|
||||
export const SHARE_TXN_STATUS: { [key: number]: { label: string; cls: string } } = {
|
||||
100: { label: 'Pending', cls: 'srep-pill--amber' },
|
||||
300: { label: 'Approved', cls: 'srep-pill--green' },
|
||||
400: { label: 'Rejected', cls: 'srep-pill--red' }
|
||||
};
|
||||
|
||||
/** How a tranche was paid for; blank on the opening tranche, which the account itself carries. */
|
||||
export function fundingLabel(source: string | null | undefined): string {
|
||||
if (!source) {
|
||||
return 'Opening';
|
||||
}
|
||||
return source === 'CASH' ? 'Cash Receipt' : source === 'SAVINGS' ? 'Savings Debit' : source;
|
||||
}
|
||||
|
||||
/** m_share_account_dividend_details.status — where a member's dividend has got to. */
|
||||
export const SHARE_DIVIDEND_STATUS: { [key: number]: { label: string; cls: string } } = {
|
||||
100: { label: 'Declared', cls: 'srep-pill--amber' },
|
||||
300: { label: 'Approved', cls: 'srep-pill--blue' },
|
||||
600: { label: 'Paid', cls: 'srep-pill--green' }
|
||||
};
|
||||
|
||||
/** Where a transfer stands in maker-checker. */
|
||||
export const SHARE_TRANSFER_STATUS: { [key: string]: { label: string; cls: string } } = {
|
||||
PENDING: { label: 'Pending approval', cls: 'srep-pill--amber' },
|
||||
APPROVED: { label: 'Approved', cls: 'srep-pill--green' },
|
||||
REJECTED: { label: 'Rejected', cls: 'srep-pill--red' }
|
||||
};
|
||||
|
||||
/**
|
||||
* Fineract's period frequency enum, as a phrase.
|
||||
*
|
||||
* Reads "18 months" rather than "18 / 2", which is what the raw pair means to nobody outside the
|
||||
* schema. An unset period says "None" rather than showing a bare zero that looks like a real term.
|
||||
*/
|
||||
export function periodLabel(value: number | null | undefined, frequencyEnum: number | null | undefined): string {
|
||||
if (!value) {
|
||||
return 'None';
|
||||
}
|
||||
const unit = { 0: 'day', 1: 'week', 2: 'month', 3: 'year' }[frequencyEnum ?? -1] || 'period';
|
||||
return `${value} ${unit}${Number(value) === 1 ? '' : 's'}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads rows as CSV.
|
||||
*
|
||||
* Every field is quoted and its own quotes doubled, so a member name carrying a comma — which is
|
||||
* most of them, once an address or a title creeps in — cannot shift every later column by one.
|
||||
*/
|
||||
export function shareReportCsv(
|
||||
filename: string,
|
||||
headers: string[],
|
||||
rows: (string | number | null | undefined)[][]
|
||||
): void {
|
||||
const escape = (value: string | number | null | undefined): string => `"${String(value ?? '').replace(/"/g, '""')}"`;
|
||||
const csv = [
|
||||
headers.map(escape).join(','),
|
||||
...rows.map((row) => row.map(escape).join(','))
|
||||
].join('\n');
|
||||
|
||||
const blob = new Blob(['' + csv], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `${filename}.csv`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/** The browser's own print, against the component's print stylesheet. */
|
||||
export function shareReportPrint(): void {
|
||||
window.print();
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="cert-root">
|
||||
<div class="cert-head">
|
||||
<div class="cert-head-l">
|
||||
<fa-icon icon="info-circle" class="cert-head-ic"></fa-icon>
|
||||
<div>
|
||||
<h2 class="cert-head-t">Share Status Report</h2>
|
||||
<div class="cert-head-s">
|
||||
Where every share account stands — by status and share class, with the accounts behind each figure.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-head-r">
|
||||
<button class="cert-cta-btn" (click)="exportCsv()" [disabled]="loading || !filteredAccounts.length">
|
||||
<fa-icon icon="file-alt"></fa-icon> Export CSV
|
||||
</button>
|
||||
<button class="cert-cta-btn" (click)="print()" [disabled]="loading || !filteredAccounts.length">
|
||||
<fa-icon icon="print"></fa-icon> Print
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (totals) {
|
||||
<div class="cert-kpis">
|
||||
<div class="cert-kpi cert-kpi--blue">
|
||||
<fa-icon icon="file-alt" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">{{ totals.accountCount | number }}</div>
|
||||
<div class="cert-kpi-l">Share Accounts</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-kpi cert-kpi--green">
|
||||
<fa-icon icon="users" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">{{ totals.memberCount | number }}</div>
|
||||
<div class="cert-kpi-l">Members</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-kpi cert-kpi--teal">
|
||||
<fa-icon icon="coins" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">{{ totals.approvedShares | number }}</div>
|
||||
<div class="cert-kpi-l">Shares Allotted</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-kpi cert-kpi--amber">
|
||||
<fa-icon icon="balance-scale" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">₹{{ totals.shareValue | number: '1.2-2' }}</div>
|
||||
<div class="cert-kpi-l">Share Capital</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (loading) {
|
||||
<div class="srep-state">Loading share status…</div>
|
||||
} @else if (loadError) {
|
||||
<div class="srep-state srep-state--error"><fa-icon icon="exclamation-circle"></fa-icon> {{ loadError }}</div>
|
||||
} @else {
|
||||
<!-- Summary: click a status to narrow the detail below, click again to clear -->
|
||||
<div class="srep-table-wrap srep-no-print-filters">
|
||||
<table class="srep-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Status</th>
|
||||
<th class="srep-num">Accounts</th>
|
||||
<th class="srep-num">Members</th>
|
||||
<th class="srep-num">Shares Allotted</th>
|
||||
<th class="srep-num">Share Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (s of statusTotals; track s.statusEnum) {
|
||||
<tr class="srep-row--click" (click)="selectStatus(s.statusEnum)">
|
||||
<td>
|
||||
<span class="srep-pill" [ngClass]="statusClass(s.statusEnum)">{{ statusLabel(s.statusEnum) }}</span>
|
||||
@if (statusFilter === s.statusEnum) {
|
||||
<span class="srep-pill srep-pill--blue srep-no-print"> filtered </span>
|
||||
}
|
||||
</td>
|
||||
<td class="srep-num">{{ s.accountCount | number }}</td>
|
||||
<td class="srep-num">{{ s.memberCount | number }}</td>
|
||||
<td class="srep-num">{{ s.shares | number }}</td>
|
||||
<td class="srep-num">₹{{ s.value | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td>All statuses</td>
|
||||
<td class="srep-num">{{ totals?.accountCount | number }}</td>
|
||||
<td class="srep-num">{{ totals?.memberCount | number }}</td>
|
||||
<td class="srep-num">{{ totals?.approvedShares | number }}</td>
|
||||
<td class="srep-num">₹{{ totals?.shareValue | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Product breakup -->
|
||||
@if (groups.length > 1) {
|
||||
<div class="cert-filters" style="margin-top: 0.85rem">
|
||||
<div class="cert-filter" style="flex: 1 1 100%">
|
||||
<label>By share class</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="srep-table-wrap">
|
||||
<table class="srep-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Share Product</th>
|
||||
<th>Status</th>
|
||||
<th class="srep-num">Accounts</th>
|
||||
<th class="srep-num">Members</th>
|
||||
<th class="srep-num">Shares</th>
|
||||
<th class="srep-num">Pending Shares</th>
|
||||
<th class="srep-num">Share Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (g of groups; track g.productId + '-' + g.statusEnum) {
|
||||
<tr>
|
||||
<td>{{ g.productName }}</td>
|
||||
<td>
|
||||
<span class="srep-pill" [ngClass]="statusClass(g.statusEnum)">{{ statusLabel(g.statusEnum) }}</span>
|
||||
</td>
|
||||
<td class="srep-num">{{ g.accountCount | number }}</td>
|
||||
<td class="srep-num">{{ g.memberCount | number }}</td>
|
||||
<td class="srep-num">{{ g.approvedShares | number }}</td>
|
||||
<td class="srep-num">{{ g.pendingShares | number }}</td>
|
||||
<td class="srep-num">₹{{ g.shareValue | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Detail -->
|
||||
<div class="cert-filters" style="margin-top: 0.85rem">
|
||||
<div class="cert-filter">
|
||||
<label>Member / Membership No / A/c No</label>
|
||||
<div class="cert-input-ic">
|
||||
<fa-icon icon="search"></fa-icon>
|
||||
<input type="text" [(ngModel)]="search" placeholder="e.g. member name" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-filter">
|
||||
<label>Status</label>
|
||||
<select [(ngModel)]="statusFilter">
|
||||
<option [ngValue]="''">All statuses</option>
|
||||
@for (s of statusOptions; track s) {
|
||||
<option [ngValue]="s">{{ statusLabel(s) }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="cert-filter">
|
||||
<label>Branch</label>
|
||||
<select [(ngModel)]="branchFilter">
|
||||
<option value="">All branches</option>
|
||||
@for (b of branchOptions; track b) {
|
||||
<option [value]="b">{{ b }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="cert-filter">
|
||||
<label>Share product</label>
|
||||
<select [(ngModel)]="productFilter">
|
||||
<option value="">All products</option>
|
||||
@for (p of productOptions; track p) {
|
||||
<option [value]="p">{{ p }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="cert-filter">
|
||||
<label> </label>
|
||||
<button class="cert-cta-btn" (click)="clearFilters()">Clear filters</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!filteredAccounts.length) {
|
||||
<div class="srep-state">No share account matches the filters applied.</div>
|
||||
} @else {
|
||||
<div class="srep-table-wrap">
|
||||
<table class="srep-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Member</th>
|
||||
<th>Membership No</th>
|
||||
<th>Share A/c No</th>
|
||||
<th>Branch</th>
|
||||
<th>Product</th>
|
||||
<th>Status</th>
|
||||
<th>Status Date</th>
|
||||
<th class="srep-num">Shares</th>
|
||||
<th class="srep-num">Pending</th>
|
||||
<th class="srep-num">Share Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (a of filteredAccounts; track a.shareAccountId) {
|
||||
<tr class="srep-row--click" (click)="openAccount(a)">
|
||||
<td>{{ a.clientName }}</td>
|
||||
<td class="srep-mono">{{ a.cifNumber || '—' }}</td>
|
||||
<td class="srep-mono">{{ a.accountNo }}</td>
|
||||
<td>{{ a.officeName || '—' }}</td>
|
||||
<td>{{ a.productName }}</td>
|
||||
<td>
|
||||
<span class="srep-pill" [ngClass]="statusClass(a.statusEnum)">{{ statusLabel(a.statusEnum) }}</span>
|
||||
</td>
|
||||
<td>{{ effectiveDate(a) || '—' }}</td>
|
||||
<td class="srep-num">{{ a.approvedShares | number }}</td>
|
||||
<td class="srep-num">{{ a.pendingShares | number }}</td>
|
||||
<td class="srep-num">₹{{ a.shareValue | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,268 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { of } from 'rxjs';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
|
||||
import { SharesService } from '../../shares.service';
|
||||
import { SHARE_ACCOUNT_STATUS, shareReportCsv, shareReportPrint } from '../share-report.util';
|
||||
|
||||
interface StatusGroup {
|
||||
statusEnum: number;
|
||||
productId: number;
|
||||
productName: string;
|
||||
accountCount: number;
|
||||
memberCount: number;
|
||||
approvedShares: number;
|
||||
pendingShares: number;
|
||||
shareValue: number;
|
||||
}
|
||||
|
||||
interface StatusAccount {
|
||||
shareAccountId: number;
|
||||
accountNo: string;
|
||||
clientId: number;
|
||||
cifNumber: string;
|
||||
clientName: string;
|
||||
officeName: string;
|
||||
productId: number;
|
||||
productName: string;
|
||||
currencyCode: string;
|
||||
statusEnum: number;
|
||||
approvedShares: number;
|
||||
pendingShares: number;
|
||||
shareValue: number;
|
||||
submittedDate: string;
|
||||
approvedDate: string;
|
||||
activatedDate: string;
|
||||
rejectedDate: string;
|
||||
closedDate: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Share Status — where every share account stands, summarised by status and share class, with the
|
||||
* accounts behind each figure on the same screen.
|
||||
*
|
||||
* <p>
|
||||
* Summary and detail together on purpose: the question this report is opened with is almost always
|
||||
* "how many are pending, and which ones", and splitting that across two screens turns one answer
|
||||
* into two navigations. Clicking a summary row filters the detail below it rather than leaving.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-share-status-report',
|
||||
templateUrl: './share-status-report.component.html',
|
||||
styleUrls: [
|
||||
'../../../letter-mgmt/certificates/certificates.shared.scss',
|
||||
'../share-report.shared.scss'
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
FaIconComponent
|
||||
]
|
||||
})
|
||||
export class ShareStatusReportComponent implements OnInit {
|
||||
private sharesService = inject(SharesService);
|
||||
private router = inject(Router);
|
||||
|
||||
loading = true;
|
||||
loadError = '';
|
||||
groups: StatusGroup[] = [];
|
||||
accounts: StatusAccount[] = [];
|
||||
totals: any = null;
|
||||
|
||||
search = '';
|
||||
branchFilter = '';
|
||||
productFilter = '';
|
||||
/** Set by clicking a summary row; '' means every status. */
|
||||
statusFilter: number | '' = '';
|
||||
|
||||
ngOnInit(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading = true;
|
||||
this.loadError = '';
|
||||
this.sharesService
|
||||
.getShareStatusSummary({})
|
||||
.pipe(
|
||||
catchError((err) => {
|
||||
this.loadError = this.readError(err, 'The share status report could not be loaded.');
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
.subscribe((res: any) => {
|
||||
this.groups = res?.groups || [];
|
||||
this.accounts = res?.accounts || [];
|
||||
this.totals = res?.totals || null;
|
||||
this.loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
get branchOptions(): string[] {
|
||||
return [...new Set(this.accounts.map((a) => a.officeName).filter(Boolean))].sort();
|
||||
}
|
||||
|
||||
get productOptions(): string[] {
|
||||
return [...new Set(this.accounts.map((a) => a.productName).filter(Boolean))].sort();
|
||||
}
|
||||
|
||||
/** Distinct statuses present, in the lifecycle order a reader expects rather than by count. */
|
||||
get statusOptions(): number[] {
|
||||
return [...new Set(this.groups.map((g) => g.statusEnum))].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
/** One line per status, products folded together — the shape a board paper wants. */
|
||||
get statusTotals(): {
|
||||
statusEnum: number;
|
||||
accountCount: number;
|
||||
memberCount: number;
|
||||
shares: number;
|
||||
value: number;
|
||||
}[] {
|
||||
const byStatus = new Map<
|
||||
number,
|
||||
{ statusEnum: number; accountCount: number; memberCount: number; shares: number; value: number }
|
||||
>();
|
||||
this.groups.forEach((g) => {
|
||||
const entry = byStatus.get(g.statusEnum) || {
|
||||
statusEnum: g.statusEnum,
|
||||
accountCount: 0,
|
||||
memberCount: 0,
|
||||
shares: 0,
|
||||
value: 0
|
||||
};
|
||||
entry.accountCount += Number(g.accountCount || 0);
|
||||
entry.memberCount += Number(g.memberCount || 0);
|
||||
entry.shares += Number(g.approvedShares || 0);
|
||||
entry.value += Number(g.shareValue || 0);
|
||||
byStatus.set(g.statusEnum, entry);
|
||||
});
|
||||
return [...byStatus.values()].sort((a, b) => a.statusEnum - b.statusEnum);
|
||||
}
|
||||
|
||||
get filteredAccounts(): StatusAccount[] {
|
||||
const term = this.search.trim().toLowerCase();
|
||||
return this.accounts.filter((a) => {
|
||||
if (this.statusFilter !== '' && a.statusEnum !== this.statusFilter) {
|
||||
return false;
|
||||
}
|
||||
if (this.branchFilter && a.officeName !== this.branchFilter) {
|
||||
return false;
|
||||
}
|
||||
if (this.productFilter && a.productName !== this.productFilter) {
|
||||
return false;
|
||||
}
|
||||
if (!term) {
|
||||
return true;
|
||||
}
|
||||
return [
|
||||
a.clientName,
|
||||
a.accountNo,
|
||||
a.cifNumber,
|
||||
a.productName
|
||||
]
|
||||
.filter(Boolean)
|
||||
.some((field) => String(field).toLowerCase().includes(term));
|
||||
});
|
||||
}
|
||||
|
||||
selectStatus(statusEnum: number): void {
|
||||
this.statusFilter = this.statusFilter === statusEnum ? '' : statusEnum;
|
||||
}
|
||||
|
||||
statusLabel(statusEnum: number): string {
|
||||
return SHARE_ACCOUNT_STATUS[statusEnum]?.label || String(statusEnum ?? '');
|
||||
}
|
||||
|
||||
statusClass(statusEnum: number): string {
|
||||
return SHARE_ACCOUNT_STATUS[statusEnum]?.cls || 'srep-pill--grey';
|
||||
}
|
||||
|
||||
/** The date that put an account into the status it is in — otherwise every row shows a blank. */
|
||||
effectiveDate(account: StatusAccount): string {
|
||||
switch (account.statusEnum) {
|
||||
case 600:
|
||||
return account.closedDate;
|
||||
case 500:
|
||||
return account.rejectedDate;
|
||||
case 300:
|
||||
return account.activatedDate;
|
||||
case 200:
|
||||
return account.approvedDate;
|
||||
default:
|
||||
return account.submittedDate;
|
||||
}
|
||||
}
|
||||
|
||||
clearFilters(): void {
|
||||
this.search = '';
|
||||
this.branchFilter = '';
|
||||
this.productFilter = '';
|
||||
this.statusFilter = '';
|
||||
}
|
||||
|
||||
openAccount(account: StatusAccount): void {
|
||||
this.router.navigate([
|
||||
'/clients',
|
||||
account.clientId,
|
||||
'shares-accounts',
|
||||
account.shareAccountId,
|
||||
'general'
|
||||
]);
|
||||
}
|
||||
|
||||
exportCsv(): void {
|
||||
shareReportCsv(
|
||||
'share-status-report',
|
||||
[
|
||||
'Member',
|
||||
'Membership No',
|
||||
'Share A/c No',
|
||||
'Branch',
|
||||
'Product',
|
||||
'Status',
|
||||
'Status Date',
|
||||
'Approved Shares',
|
||||
'Pending Shares',
|
||||
'Share Value'
|
||||
],
|
||||
this.filteredAccounts.map((a) => [
|
||||
a.clientName,
|
||||
a.cifNumber,
|
||||
a.accountNo,
|
||||
a.officeName,
|
||||
a.productName,
|
||||
this.statusLabel(a.statusEnum),
|
||||
this.effectiveDate(a),
|
||||
a.approvedShares,
|
||||
a.pendingShares,
|
||||
a.shareValue
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
print(): void {
|
||||
shareReportPrint();
|
||||
}
|
||||
|
||||
private readError(err: any, fallback: string): string {
|
||||
return (
|
||||
err?.error?.errors?.[0]?.defaultUserMessage ||
|
||||
err?.error?.defaultUserMessage ||
|
||||
err?.error?.developerMessage ||
|
||||
fallback
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="cert-root">
|
||||
<div class="cert-head">
|
||||
<div class="cert-head-l">
|
||||
<fa-icon icon="exchange" class="cert-head-ic"></fa-icon>
|
||||
<div>
|
||||
<h2 class="cert-head-t">Share Transfer</h2>
|
||||
<div class="cert-head-s">
|
||||
Moving shares from one member to another, under board resolution — and the register of every transfer made.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-head-r">
|
||||
<button class="cert-cta-btn" (click)="exportCsv()" [disabled]="loading || !filteredRows.length">
|
||||
<fa-icon icon="file-alt"></fa-icon> Export CSV
|
||||
</button>
|
||||
<button class="cert-cta-btn" (click)="print()" [disabled]="loading || !filteredRows.length">
|
||||
<fa-icon icon="print"></fa-icon> Print
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (totals) {
|
||||
<div class="cert-kpis">
|
||||
<div class="cert-kpi cert-kpi--blue">
|
||||
<fa-icon icon="exchange" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">{{ totals.transferCount | number }}</div>
|
||||
<div class="cert-kpi-l">Transfers</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-kpi" [ngClass]="totals.pendingCount ? 'cert-kpi--amber' : 'cert-kpi--blue'">
|
||||
<fa-icon icon="sync" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">{{ totals.pendingCount | number }}</div>
|
||||
<div class="cert-kpi-l">Awaiting Approval</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-kpi cert-kpi--green">
|
||||
<fa-icon icon="coins" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">{{ totals.sharesTransferred | number }}</div>
|
||||
<div class="cert-kpi-l">Shares Transferred</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-kpi cert-kpi--teal">
|
||||
<fa-icon icon="balance-scale" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">₹{{ totals.valueTransferred | number: '1.2-2' }}</div>
|
||||
<div class="cert-kpi-l">Value Transferred</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="cert-filters srep-no-print">
|
||||
<div class="cert-filter">
|
||||
<label> </label>
|
||||
<button class="cert-cta-btn" [class.srep-tab--active]="tab === 'register'" (click)="tab = 'register'">
|
||||
Transfer register
|
||||
</button>
|
||||
</div>
|
||||
<div class="cert-filter">
|
||||
<label> </label>
|
||||
<button class="cert-cta-btn" [class.srep-tab--active]="tab === 'new'" (click)="tab = 'new'">New transfer</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (tab === 'new') {
|
||||
<!-- ── Raise a transfer ── -->
|
||||
<div class="srep-stmt-head" style="display: block">
|
||||
<div class="cert-filters" style="margin: 0">
|
||||
<div class="cert-filter" style="flex: 2 1 300px; position: relative">
|
||||
<label>Transfer FROM (member giving up shares)</label>
|
||||
<div class="cert-input-ic">
|
||||
<fa-icon icon="search"></fa-icon>
|
||||
<input
|
||||
type="text"
|
||||
[ngModel]="fromSearch"
|
||||
(ngModelChange)="onFromSearch($event)"
|
||||
placeholder="Search member or share account number"
|
||||
/>
|
||||
</div>
|
||||
@if (fromOptions.length && !fromAccount) {
|
||||
<div class="srep-picker">
|
||||
@for (opt of fromOptions; track opt.id) {
|
||||
<button type="button" class="srep-picker-item" (click)="pickFrom(opt)">
|
||||
<span class="srep-picker-name">{{ opt.clientName }}</span>
|
||||
<span class="srep-picker-meta srep-mono">{{ opt.accountNo }}</span>
|
||||
<span class="srep-picker-meta">
|
||||
{{ opt.productName }} · holds {{ opt.totalApprovedShares | number }}
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="cert-filter" style="flex: 2 1 300px; position: relative">
|
||||
<label>Transfer TO (member receiving shares)</label>
|
||||
<div class="cert-input-ic">
|
||||
<fa-icon icon="search"></fa-icon>
|
||||
<input
|
||||
type="text"
|
||||
[ngModel]="toSearch"
|
||||
(ngModelChange)="onToSearch($event)"
|
||||
placeholder="Search member or share account number"
|
||||
/>
|
||||
</div>
|
||||
@if (toOptions.length && !toAccount) {
|
||||
<div class="srep-picker">
|
||||
@for (opt of toOptions; track opt.id) {
|
||||
<button type="button" class="srep-picker-item" (click)="pickTo(opt)">
|
||||
<span class="srep-picker-name">{{ opt.clientName }}</span>
|
||||
<span class="srep-picker-meta srep-mono">{{ opt.accountNo }}</span>
|
||||
<span class="srep-picker-meta">
|
||||
{{ opt.productName }} · holds {{ opt.totalApprovedShares | number }}
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="cert-filter">
|
||||
<label>Shares to transfer</label>
|
||||
<input type="number" min="1" [(ngModel)]="shares" placeholder="e.g. 20" />
|
||||
</div>
|
||||
|
||||
<div class="cert-filter">
|
||||
<label>Transfer date</label>
|
||||
<input type="date" [(ngModel)]="transferDate" />
|
||||
</div>
|
||||
|
||||
<div class="cert-filter">
|
||||
<label>Board resolution no.</label>
|
||||
<input type="text" [(ngModel)]="resolutionNumber" placeholder="e.g. BR-2026-41" />
|
||||
</div>
|
||||
|
||||
<div class="cert-filter">
|
||||
<label>Board meeting date</label>
|
||||
<input type="date" [(ngModel)]="meetingDate" />
|
||||
</div>
|
||||
|
||||
<div class="cert-filter" style="flex: 2 1 300px">
|
||||
<label>Reason (optional)</label>
|
||||
<input type="text" [(ngModel)]="reason" placeholder="e.g. transfer to legal heir" />
|
||||
</div>
|
||||
|
||||
<div class="cert-filter">
|
||||
<label> </label>
|
||||
<button class="cert-cta-btn" (click)="submit()" [disabled]="!canSubmit">
|
||||
{{ submitting ? 'Submitting…' : 'Submit for approval' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="cert-filter">
|
||||
<label> </label>
|
||||
<button class="cert-cta-btn" (click)="resetForm()">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (fromAccount && toAccount && shares) {
|
||||
<div class="srep-scope-note">
|
||||
{{ shares | number }} shares of {{ fromAccount.productName }} move from
|
||||
<b>{{ fromAccount.clientName }}</b> ({{ fromAccount.accountNo }}) to <b>{{ toAccount.clientName }}</b> ({{
|
||||
toAccount.accountNo
|
||||
}}). Share capital does not change — only the register does. Nothing moves until a checker approves it.
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (submitError) {
|
||||
<div class="srep-state srep-state--error"><fa-icon icon="exclamation-circle"></fa-icon> {{ submitError }}</div>
|
||||
}
|
||||
@if (submitSuccess) {
|
||||
<div class="srep-state"><fa-icon icon="check-circle"></fa-icon> {{ submitSuccess }}</div>
|
||||
}
|
||||
}
|
||||
|
||||
<!-- ── Register ── -->
|
||||
<div class="cert-filters" [class.srep-no-print]="tab === 'new'">
|
||||
<div class="cert-filter">
|
||||
<label>Member / Reference / Resolution</label>
|
||||
<div class="cert-input-ic">
|
||||
<fa-icon icon="search"></fa-icon>
|
||||
<input type="text" [(ngModel)]="search" placeholder="e.g. member name or TRF-260917-0001" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-filter">
|
||||
<label>Status</label>
|
||||
<select [(ngModel)]="statusFilter" (change)="loadRegister()">
|
||||
<option value="ALL">All</option>
|
||||
<option value="PENDING">Pending approval</option>
|
||||
<option value="APPROVED">Approved</option>
|
||||
<option value="REJECTED">Rejected</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (loading) {
|
||||
<div class="srep-state">Loading the transfer register…</div>
|
||||
} @else if (loadError) {
|
||||
<div class="srep-state srep-state--error"><fa-icon icon="exclamation-circle"></fa-icon> {{ loadError }}</div>
|
||||
} @else if (!rows.length) {
|
||||
<div class="srep-state">No share transfer has been recorded yet.</div>
|
||||
} @else if (!filteredRows.length) {
|
||||
<div class="srep-state">No transfer matches the filters applied.</div>
|
||||
} @else {
|
||||
<div class="srep-table-wrap">
|
||||
<table class="srep-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Reference</th>
|
||||
<th>Date</th>
|
||||
<th>From</th>
|
||||
<th>To</th>
|
||||
<th>Product</th>
|
||||
<th class="srep-num">Shares</th>
|
||||
<th class="srep-num">Value</th>
|
||||
<th>Board Resolution</th>
|
||||
<th>Status</th>
|
||||
<th class="srep-no-print">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of filteredRows; track row.transferId) {
|
||||
<tr>
|
||||
<td class="srep-mono">{{ row.transferReference }}</td>
|
||||
<td>{{ row.transferDate }}</td>
|
||||
<td>
|
||||
{{ row.fromClientName }}
|
||||
<div class="srep-scope-note srep-mono" style="margin: 0">{{ row.fromAccountNo }}</div>
|
||||
</td>
|
||||
<td>
|
||||
{{ row.toClientName }}
|
||||
<div class="srep-scope-note srep-mono" style="margin: 0">{{ row.toAccountNo }}</div>
|
||||
</td>
|
||||
<td>{{ row.productName }}</td>
|
||||
<td class="srep-num">{{ row.shares | number }}</td>
|
||||
<td class="srep-num">₹{{ row.transferAmount | number: '1.2-2' }}</td>
|
||||
<td class="srep-mono">
|
||||
{{ row.resolutionNumber || '—' }}
|
||||
@if (row.meetingDate) {
|
||||
<div class="srep-scope-note" style="margin: 0">{{ row.meetingDate }}</div>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<span class="srep-pill" [ngClass]="statusClass(row.status)">{{ statusLabel(row.status) }}</span>
|
||||
@if (row.status === 'REJECTED' && row.rejectionReason) {
|
||||
<div class="srep-scope-note" style="margin: 0">{{ row.rejectionReason }}</div>
|
||||
}
|
||||
@if (row.status !== 'PENDING' && row.checkerName) {
|
||||
<div class="srep-scope-note" style="margin: 0">by {{ row.checkerName }}</div>
|
||||
}
|
||||
</td>
|
||||
<td class="srep-no-print">
|
||||
@if (row.status === 'PENDING') {
|
||||
<button class="cert-cta-btn" (click)="approve(row)" [disabled]="actioningId === row.transferId">
|
||||
{{ actioningId === row.transferId ? '…' : 'Approve' }}
|
||||
</button>
|
||||
<button class="cert-cta-btn" (click)="reject(row)" [disabled]="actioningId === row.transferId">
|
||||
Reject
|
||||
</button>
|
||||
} @else {
|
||||
<span class="srep-scope-note">{{ row.actionedOn || '—' }}</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,339 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { Subject, of } from 'rxjs';
|
||||
import { catchError, debounceTime, distinctUntilChanged, switchMap } from 'rxjs/operators';
|
||||
|
||||
import { SharesService } from '../../shares.service';
|
||||
import { SettingsService } from 'app/settings/settings.service';
|
||||
import { SHARE_TRANSFER_STATUS, shareReportCsv, shareReportPrint } from '../share-report.util';
|
||||
|
||||
/**
|
||||
* Share Transfer — moving shares from one member to another, and the register of every transfer
|
||||
* made.
|
||||
*
|
||||
* <p>
|
||||
* No money reaches the society and its share capital does not change: what changes is the register,
|
||||
* one member's holding down and another's up. The society's part is to approve it, so the screen is
|
||||
* a request form and a queue rather than an instant action — a transfer carries voting rights with
|
||||
* it and is not one clerk's decision.
|
||||
*
|
||||
* <p>
|
||||
* Every rule is enforced by the server at submission and again at approval. Nothing is validated
|
||||
* only here: holdings move while a request waits, and a browser that checked once at 10am would
|
||||
* happily submit something impossible by noon.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-share-transfer',
|
||||
templateUrl: './share-transfer.component.html',
|
||||
styleUrls: [
|
||||
'../../../letter-mgmt/certificates/certificates.shared.scss',
|
||||
'../share-report.shared.scss'
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
FaIconComponent
|
||||
]
|
||||
})
|
||||
export class ShareTransferComponent implements OnInit {
|
||||
private sharesService = inject(SharesService);
|
||||
private settingsService = inject(SettingsService);
|
||||
|
||||
/** Which half of the screen is showing: raise a transfer, or work the register. */
|
||||
tab: 'new' | 'register' = 'register';
|
||||
|
||||
// ── Register ──
|
||||
loading = true;
|
||||
loadError = '';
|
||||
rows: any[] = [];
|
||||
totals: any = null;
|
||||
statusFilter = 'ALL';
|
||||
search = '';
|
||||
actioningId: number | null = null;
|
||||
|
||||
// ── New transfer ──
|
||||
fromSearch = '';
|
||||
toSearch = '';
|
||||
fromOptions: any[] = [];
|
||||
toOptions: any[] = [];
|
||||
fromAccount: any = null;
|
||||
toAccount: any = null;
|
||||
shares: number | null = null;
|
||||
transferDate = '';
|
||||
resolutionNumber = '';
|
||||
meetingDate = '';
|
||||
reason = '';
|
||||
submitting = false;
|
||||
submitError = '';
|
||||
submitSuccess = '';
|
||||
|
||||
private fromTerm$ = new Subject<string>();
|
||||
private toTerm$ = new Subject<string>();
|
||||
|
||||
ngOnInit(): void {
|
||||
const businessDate: Date = this.settingsService.businessDate || new Date();
|
||||
this.transferDate = this.toIso(businessDate);
|
||||
|
||||
this.fromTerm$
|
||||
.pipe(
|
||||
debounceTime(350),
|
||||
distinctUntilChanged(),
|
||||
switchMap((term) => this.sharesService.getShareAccountSummaries(term).pipe(catchError(() => of([]))))
|
||||
)
|
||||
.subscribe((res: any) => (this.fromOptions = Array.isArray(res) ? res : res?.pageItems || []));
|
||||
|
||||
this.toTerm$
|
||||
.pipe(
|
||||
debounceTime(350),
|
||||
distinctUntilChanged(),
|
||||
switchMap((term) => this.sharesService.getShareAccountSummaries(term).pipe(catchError(() => of([]))))
|
||||
)
|
||||
.subscribe((res: any) => (this.toOptions = Array.isArray(res) ? res : res?.pageItems || []));
|
||||
|
||||
this.loadRegister();
|
||||
}
|
||||
|
||||
// ── Register ──────────────────────────────────────────────────────────
|
||||
|
||||
loadRegister(): void {
|
||||
this.loading = true;
|
||||
this.loadError = '';
|
||||
this.sharesService
|
||||
.getShareTransfers(this.statusFilter === 'ALL' ? undefined : this.statusFilter)
|
||||
.pipe(
|
||||
catchError((err) => {
|
||||
this.loadError = this.readError(err, 'The transfer register could not be loaded.');
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
.subscribe((res: any) => {
|
||||
this.rows = res?.rows || [];
|
||||
this.totals = res?.totals || null;
|
||||
this.loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
get filteredRows(): any[] {
|
||||
const term = this.search.trim().toLowerCase();
|
||||
if (!term) {
|
||||
return this.rows;
|
||||
}
|
||||
return this.rows.filter((r) =>
|
||||
[
|
||||
r.fromClientName,
|
||||
r.toClientName,
|
||||
r.fromAccountNo,
|
||||
r.toAccountNo,
|
||||
r.transferReference,
|
||||
r.resolutionNumber
|
||||
]
|
||||
.filter(Boolean)
|
||||
.some((field: any) => String(field).toLowerCase().includes(term))
|
||||
);
|
||||
}
|
||||
|
||||
approve(row: any): void {
|
||||
this.actioningId = row.transferId;
|
||||
this.loadError = '';
|
||||
this.sharesService
|
||||
.approveShareTransfer(row.transferId)
|
||||
.pipe(
|
||||
catchError((err) => {
|
||||
this.loadError = this.readError(err, 'The transfer could not be approved.');
|
||||
this.actioningId = null;
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
.subscribe((res: any) => {
|
||||
this.actioningId = null;
|
||||
if (res) {
|
||||
this.loadRegister();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
reject(row: any): void {
|
||||
this.actioningId = row.transferId;
|
||||
this.loadError = '';
|
||||
this.sharesService
|
||||
.rejectShareTransfer(row.transferId)
|
||||
.pipe(
|
||||
catchError((err) => {
|
||||
this.loadError = this.readError(err, 'The transfer could not be rejected.');
|
||||
this.actioningId = null;
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
.subscribe((res: any) => {
|
||||
this.actioningId = null;
|
||||
if (res) {
|
||||
this.loadRegister();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
statusLabel(status: string): string {
|
||||
return SHARE_TRANSFER_STATUS[status]?.label || status;
|
||||
}
|
||||
|
||||
statusClass(status: string): string {
|
||||
return SHARE_TRANSFER_STATUS[status]?.cls || 'srep-pill--grey';
|
||||
}
|
||||
|
||||
// ── New transfer ──────────────────────────────────────────────────────
|
||||
|
||||
onFromSearch(term: string): void {
|
||||
this.fromSearch = term;
|
||||
this.fromAccount = null;
|
||||
this.fromTerm$.next(term.trim());
|
||||
}
|
||||
|
||||
onToSearch(term: string): void {
|
||||
this.toSearch = term;
|
||||
this.toAccount = null;
|
||||
this.toTerm$.next(term.trim());
|
||||
}
|
||||
|
||||
pickFrom(option: any): void {
|
||||
this.fromAccount = option;
|
||||
this.fromOptions = [];
|
||||
this.fromSearch = `${option.clientName} — ${option.accountNo}`;
|
||||
}
|
||||
|
||||
pickTo(option: any): void {
|
||||
this.toAccount = option;
|
||||
this.toOptions = [];
|
||||
this.toSearch = `${option.clientName} — ${option.accountNo}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Only what the browser can honestly know: both sides chosen, a positive number of shares, and the
|
||||
* resolution captured. Everything about holdings and class limits is the server's to judge, since
|
||||
* only it knows what the register says right now.
|
||||
*/
|
||||
get canSubmit(): boolean {
|
||||
return !!(
|
||||
this.fromAccount &&
|
||||
this.toAccount &&
|
||||
this.shares &&
|
||||
this.shares > 0 &&
|
||||
this.transferDate &&
|
||||
this.resolutionNumber.trim() &&
|
||||
this.meetingDate &&
|
||||
!this.submitting
|
||||
);
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
if (!this.canSubmit) {
|
||||
return;
|
||||
}
|
||||
this.submitting = true;
|
||||
this.submitError = '';
|
||||
this.submitSuccess = '';
|
||||
this.sharesService
|
||||
.submitShareTransfer({
|
||||
fromShareAccountId: this.fromAccount.id,
|
||||
toShareAccountId: this.toAccount.id,
|
||||
shares: this.shares,
|
||||
transferDate: this.transferDate,
|
||||
resolutionNumber: this.resolutionNumber.trim(),
|
||||
meetingDate: this.meetingDate,
|
||||
reason: this.reason.trim() || null
|
||||
})
|
||||
.pipe(
|
||||
catchError((err) => {
|
||||
this.submitError = this.readError(err, 'The transfer could not be submitted.');
|
||||
this.submitting = false;
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
.subscribe((res: any) => {
|
||||
this.submitting = false;
|
||||
if (res) {
|
||||
this.submitSuccess = res.message || `Transfer ${res.transferReference} sent for approval.`;
|
||||
this.resetForm();
|
||||
this.loadRegister();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
resetForm(): void {
|
||||
this.fromSearch = '';
|
||||
this.toSearch = '';
|
||||
this.fromAccount = null;
|
||||
this.toAccount = null;
|
||||
this.shares = null;
|
||||
this.resolutionNumber = '';
|
||||
this.meetingDate = '';
|
||||
this.reason = '';
|
||||
this.submitError = '';
|
||||
}
|
||||
|
||||
exportCsv(): void {
|
||||
shareReportCsv(
|
||||
'share-transfer-register',
|
||||
[
|
||||
'Reference',
|
||||
'Date',
|
||||
'From Member',
|
||||
'From A/c No',
|
||||
'To Member',
|
||||
'To A/c No',
|
||||
'Product',
|
||||
'Shares',
|
||||
'Unit Price',
|
||||
'Value',
|
||||
'Board Resolution',
|
||||
'Meeting Date',
|
||||
'Status',
|
||||
'Raised By',
|
||||
'Actioned By'
|
||||
],
|
||||
this.filteredRows.map((r) => [
|
||||
r.transferReference,
|
||||
r.transferDate,
|
||||
r.fromClientName,
|
||||
r.fromAccountNo,
|
||||
r.toClientName,
|
||||
r.toAccountNo,
|
||||
r.productName,
|
||||
r.shares,
|
||||
r.unitPrice,
|
||||
r.transferAmount,
|
||||
r.resolutionNumber,
|
||||
r.meetingDate,
|
||||
this.statusLabel(r.status),
|
||||
r.makerName,
|
||||
r.checkerName
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
print(): void {
|
||||
shareReportPrint();
|
||||
}
|
||||
|
||||
private toIso(date: Date): string {
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
private readError(err: any, fallback: string): string {
|
||||
return (
|
||||
err?.error?.errors?.[0]?.defaultUserMessage ||
|
||||
err?.error?.defaultUserMessage ||
|
||||
err?.error?.developerMessage ||
|
||||
fallback
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="cert-root">
|
||||
<div class="cert-head">
|
||||
<div class="cert-head-l">
|
||||
<fa-icon icon="file-alt" class="cert-head-ic"></fa-icon>
|
||||
<div>
|
||||
<h2 class="cert-head-t">Share Wise Report</h2>
|
||||
<div class="cert-head-s">
|
||||
How the society's share capital is spread — members per holding band, and the split by share class.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-head-r">
|
||||
<button class="cert-cta-btn" (click)="exportCsv()" [disabled]="loading || !bands.length">
|
||||
<fa-icon icon="file-alt"></fa-icon> Export CSV
|
||||
</button>
|
||||
<button class="cert-cta-btn" (click)="print()" [disabled]="loading || !bands.length">
|
||||
<fa-icon icon="print"></fa-icon> Print
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (totals) {
|
||||
<div class="cert-kpis">
|
||||
<div class="cert-kpi cert-kpi--blue">
|
||||
<fa-icon icon="users" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">{{ totalMembers | number }}</div>
|
||||
<div class="cert-kpi-l">Shareholding Members</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-kpi cert-kpi--green">
|
||||
<fa-icon icon="coins" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">{{ totals.shares | number }}</div>
|
||||
<div class="cert-kpi-l">Shares Held</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-kpi cert-kpi--teal">
|
||||
<fa-icon icon="balance-scale" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">₹{{ totals.shareValue | number: '1.2-2' }}</div>
|
||||
<div class="cert-kpi-l">Share Capital</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-kpi cert-kpi--amber">
|
||||
<fa-icon icon="chart-pie" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">{{ byProduct.length | number }}</div>
|
||||
<div class="cert-kpi-l">Share Classes</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="cert-filters">
|
||||
<div class="cert-filter">
|
||||
<label>As on date</label>
|
||||
<input type="date" [(ngModel)]="asOnDate" (change)="load()" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (loading) {
|
||||
<div class="srep-state">Loading the shareholding pattern…</div>
|
||||
} @else if (loadError) {
|
||||
<div class="srep-state srep-state--error"><fa-icon icon="exclamation-circle"></fa-icon> {{ loadError }}</div>
|
||||
} @else if (!totals?.shares) {
|
||||
<div class="srep-state">No member held share capital as on {{ asOnDate }}.</div>
|
||||
} @else {
|
||||
<!-- Distribution by holding band -->
|
||||
<div class="srep-table-wrap">
|
||||
<table class="srep-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Holding Band (shares)</th>
|
||||
<th class="srep-num">Members</th>
|
||||
<th style="width: 22%">Distribution</th>
|
||||
<th class="srep-num">Accounts</th>
|
||||
<th class="srep-num">Shares</th>
|
||||
<th class="srep-num">Share Value</th>
|
||||
<th class="srep-num">% of Capital</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (band of bands; track band.band) {
|
||||
<tr>
|
||||
<td>{{ band.band }}</td>
|
||||
<td class="srep-num">{{ band.memberCount | number }}</td>
|
||||
<td>
|
||||
<div class="srep-bar">
|
||||
<div class="srep-bar-fill" [style.width.%]="barWidth(band)"></div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="srep-num">{{ band.accountCount | number }}</td>
|
||||
<td class="srep-num">{{ band.shares | number }}</td>
|
||||
<td class="srep-num">₹{{ band.shareValue | number: '1.2-2' }}</td>
|
||||
<td class="srep-num">{{ band.capitalShare | number: '1.2-2' }}%</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td>All bands</td>
|
||||
<td class="srep-num">{{ totalMembers | number }}</td>
|
||||
<td></td>
|
||||
<td class="srep-num"></td>
|
||||
<td class="srep-num">{{ totals.shares | number }}</td>
|
||||
<td class="srep-num">₹{{ totals.shareValue | number: '1.2-2' }}</td>
|
||||
<td class="srep-num">100.00%</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Split by share class -->
|
||||
<div class="cert-filters" style="margin-top: 0.85rem">
|
||||
<div class="cert-filter" style="flex: 1 1 100%">
|
||||
<label>By share class</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="srep-table-wrap">
|
||||
<table class="srep-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Share Product</th>
|
||||
<th class="srep-num">Members</th>
|
||||
<th class="srep-num">Accounts</th>
|
||||
<th class="srep-num">Shares</th>
|
||||
<th class="srep-num">Share Value</th>
|
||||
<th class="srep-num">% of Capital</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of byProduct; track row.productId) {
|
||||
<tr>
|
||||
<td>{{ row.productName }}</td>
|
||||
<td class="srep-num">{{ row.memberCount | number }}</td>
|
||||
<td class="srep-num">{{ row.accountCount | number }}</td>
|
||||
<td class="srep-num">{{ row.shares | number }}</td>
|
||||
<td class="srep-num">₹{{ row.shareValue | number: '1.2-2' }}</td>
|
||||
<td class="srep-num">{{ productShare(row) | number: '1.2-2' }}%</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td>All classes</td>
|
||||
<td class="srep-num"></td>
|
||||
<td class="srep-num"></td>
|
||||
<td class="srep-num">{{ totals.shares | number }}</td>
|
||||
<td class="srep-num">₹{{ totals.shareValue | number: '1.2-2' }}</td>
|
||||
<td class="srep-num">100.00%</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { of } from 'rxjs';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
|
||||
import { SharesService } from '../../shares.service';
|
||||
import { SettingsService } from 'app/settings/settings.service';
|
||||
import { shareReportCsv, shareReportPrint } from '../share-report.util';
|
||||
|
||||
interface BandRow {
|
||||
band: string;
|
||||
bandOrder: number;
|
||||
accountCount: number;
|
||||
memberCount: number;
|
||||
shares: number;
|
||||
shareValue: number;
|
||||
/** This band's percentage of total share capital, computed server-side against the true total. */
|
||||
capitalShare: number;
|
||||
}
|
||||
|
||||
interface ProductRow {
|
||||
productId: number;
|
||||
productName: string;
|
||||
accountCount: number;
|
||||
memberCount: number;
|
||||
shares: number;
|
||||
shareValue: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Share Wise Report — the shareholding pattern: how the society's capital is spread across its
|
||||
* members, and how it splits by share class.
|
||||
*
|
||||
* <p>
|
||||
* Concentration is what this answers. A total and a member count cannot show whether three members
|
||||
* hold half the capital, and that is the number a board and the Registrar both ask for.
|
||||
*
|
||||
* <p>
|
||||
* No client-side filters beyond the date and scope the server already applied: a distribution that
|
||||
* can be narrowed in the browser stops being a distribution — the percentages would still be of the
|
||||
* whole while the bands shown were of a part.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-share-wise-report',
|
||||
templateUrl: './share-wise-report.component.html',
|
||||
styleUrls: [
|
||||
'../../../letter-mgmt/certificates/certificates.shared.scss',
|
||||
'../share-report.shared.scss'
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
FaIconComponent
|
||||
]
|
||||
})
|
||||
export class ShareWiseReportComponent implements OnInit {
|
||||
private sharesService = inject(SharesService);
|
||||
private settingsService = inject(SettingsService);
|
||||
|
||||
loading = true;
|
||||
loadError = '';
|
||||
bands: BandRow[] = [];
|
||||
byProduct: ProductRow[] = [];
|
||||
totals: any = null;
|
||||
|
||||
asOnDate = '';
|
||||
|
||||
ngOnInit(): void {
|
||||
const businessDate: Date = this.settingsService.businessDate || new Date();
|
||||
this.asOnDate = this.toIso(businessDate);
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading = true;
|
||||
this.loadError = '';
|
||||
this.sharesService
|
||||
.getShareWiseReport({ asOnDate: this.asOnDate })
|
||||
.pipe(
|
||||
catchError((err) => {
|
||||
this.loadError = this.readError(err, 'The shareholding pattern could not be loaded.');
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
.subscribe((res: any) => {
|
||||
this.bands = res?.bands || [];
|
||||
this.byProduct = res?.byProduct || [];
|
||||
this.totals = res?.totals || null;
|
||||
this.loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
get totalMembers(): number {
|
||||
return this.bands.reduce((sum, b) => sum + Number(b.memberCount || 0), 0);
|
||||
}
|
||||
|
||||
/** A product's share of total capital — the same reading the bands give, by class instead. */
|
||||
productShare(row: ProductRow): number {
|
||||
const total = Number(this.totals?.shareValue || 0);
|
||||
return total ? (Number(row.shareValue || 0) * 100) / total : 0;
|
||||
}
|
||||
|
||||
/** Bar width for the inline distribution graphic; the largest band fills the cell. */
|
||||
barWidth(row: BandRow): number {
|
||||
const largest = Math.max(...this.bands.map((b) => Number(b.memberCount || 0)), 1);
|
||||
return (Number(row.memberCount || 0) * 100) / largest;
|
||||
}
|
||||
|
||||
exportCsv(): void {
|
||||
shareReportCsv(
|
||||
`share-wise-${this.asOnDate || 'today'}`,
|
||||
[
|
||||
'Holding Band',
|
||||
'Accounts',
|
||||
'Members',
|
||||
'Shares',
|
||||
'Share Value',
|
||||
'% of Capital'
|
||||
],
|
||||
[
|
||||
...this.bands.map((b) => [
|
||||
b.band,
|
||||
b.accountCount,
|
||||
b.memberCount,
|
||||
b.shares,
|
||||
b.shareValue,
|
||||
b.capitalShare
|
||||
]),
|
||||
[
|
||||
'TOTAL',
|
||||
'',
|
||||
this.totalMembers,
|
||||
this.totals?.shares ?? '',
|
||||
this.totals?.shareValue ?? '',
|
||||
100
|
||||
]
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
print(): void {
|
||||
shareReportPrint();
|
||||
}
|
||||
|
||||
private toIso(date: Date): string {
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
private readError(err: any, fallback: string): string {
|
||||
return (
|
||||
err?.error?.errors?.[0]?.defaultUserMessage ||
|
||||
err?.error?.defaultUserMessage ||
|
||||
err?.error?.developerMessage ||
|
||||
fallback
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,996 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
// Shared chrome for Apply Collection and Apply Additional Shares dialogs.
|
||||
// Each component @uses this, so every rule is emitted once per component
|
||||
// and stays scoped to it -- nothing here leaks into the global stylesheet.
|
||||
|
||||
/* ── Design tokens ─────────────────────────────────────────────────────── */
|
||||
|
||||
$primary: #1e3a8a;
|
||||
$primary-mid: #2448b0;
|
||||
$accent: #2563eb;
|
||||
$accent-soft: #eff6ff;
|
||||
$accent-border: #bfdbfe;
|
||||
|
||||
$text-main: #0f172a;
|
||||
$text-sub: #334155;
|
||||
$text-muted: #64748b;
|
||||
$border-color: #e2e8f0;
|
||||
$bg-page: #f8fafc;
|
||||
$bg-white: #fff;
|
||||
|
||||
$success: #059669;
|
||||
$success-bg: #ecfdf5;
|
||||
$success-border: #6ee7b7;
|
||||
|
||||
$danger: #dc2626;
|
||||
$danger-bg: #fef2f2;
|
||||
$danger-border: #fca5a5;
|
||||
|
||||
/* ── Utilities ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.mono {
|
||||
font-family: 'Roboto Mono', 'Courier New', monospace;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ── Dialog wrapper ────────────────────────────────────────────────────── */
|
||||
|
||||
.dialog-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 88vh;
|
||||
background: $bg-white;
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Header ────────────────────────────────────────────────────────────── */
|
||||
|
||||
.dialog-header {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
gap: 0.85rem;
|
||||
padding: 1.1rem 1.5rem;
|
||||
background: linear-gradient(135deg, $primary 0%, $primary-mid 60%, #3b5fc0 100%);
|
||||
color: #fff;
|
||||
|
||||
.header-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: none;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 9px;
|
||||
background: rgb(255 255 255 / 15%);
|
||||
border: 1px solid rgb(255 255 255 / 20%);
|
||||
|
||||
mat-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.header-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1.08rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0.18rem 0 0;
|
||||
font-size: 0.78rem;
|
||||
opacity: 0.78;
|
||||
}
|
||||
}
|
||||
|
||||
.header-close {
|
||||
color: rgb(255 255 255 / 80%);
|
||||
flex: none;
|
||||
|
||||
&:hover {
|
||||
color: #fff;
|
||||
background: rgb(255 255 255 / 12%) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Body ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
.dialog-body {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
gap: 1.1rem;
|
||||
min-height: 0;
|
||||
max-height: none !important;
|
||||
padding: 1.4rem 1.5rem !important;
|
||||
overflow-y: auto;
|
||||
background: $bg-page;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 5px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 99px;
|
||||
}
|
||||
}
|
||||
|
||||
.state-block {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
padding: 2rem 0;
|
||||
color: $text-muted;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════
|
||||
Collection Summary Card (sc-* prefix)
|
||||
═══════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
.sc-card {
|
||||
border: 1px solid $accent-border;
|
||||
border-radius: 10px;
|
||||
background: $bg-white;
|
||||
// No overflow:hidden — it clips row content in certain flex contexts
|
||||
box-shadow: 0 2px 8px rgb(37 99 235 / 8%);
|
||||
}
|
||||
|
||||
/* ── Each item row (Share / Entry Fee) ─────────────────────────────────── */
|
||||
|
||||
.sc-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.85rem 1.15rem;
|
||||
background: $bg-white;
|
||||
border-left: 4px solid transparent;
|
||||
transition:
|
||||
background 0.15s,
|
||||
border-color 0.2s;
|
||||
|
||||
&.sc-sep {
|
||||
border-top: 1px solid $border-color;
|
||||
}
|
||||
|
||||
&.sc-ok {
|
||||
background: $success-bg;
|
||||
border-left-color: $success;
|
||||
}
|
||||
|
||||
&.sc-bad {
|
||||
background: $danger-bg;
|
||||
border-left-color: $danger;
|
||||
}
|
||||
}
|
||||
|
||||
/* Left side: label + required amount */
|
||||
.sc-left {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.18rem;
|
||||
}
|
||||
|
||||
.sc-label {
|
||||
display: block;
|
||||
font-size: 0.86rem;
|
||||
font-weight: 700;
|
||||
color: $text-main;
|
||||
line-height: 1.3;
|
||||
min-height: 1em; // guarantees height even if content is empty
|
||||
}
|
||||
|
||||
.sc-sub {
|
||||
display: block;
|
||||
font-size: 0.72rem;
|
||||
color: $text-muted;
|
||||
line-height: 1.35;
|
||||
min-height: 1em;
|
||||
|
||||
b {
|
||||
font-weight: 600;
|
||||
color: $text-sub;
|
||||
}
|
||||
}
|
||||
|
||||
/* Right side: collected amount + badge */
|
||||
.sc-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.sc-val {
|
||||
font-family: 'Roboto Mono', 'Courier New', monospace;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 0.96rem;
|
||||
font-weight: 700;
|
||||
color: $text-muted;
|
||||
letter-spacing: -0.01em;
|
||||
transition: color 0.2s;
|
||||
|
||||
&.sc-val-ok {
|
||||
color: $success;
|
||||
}
|
||||
|
||||
&.sc-val-bad {
|
||||
color: $danger;
|
||||
}
|
||||
}
|
||||
|
||||
.sc-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 99px;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
|
||||
&.sc-tag-ok {
|
||||
background: $success-bg;
|
||||
color: $success;
|
||||
border: 1px solid $success-border;
|
||||
}
|
||||
|
||||
&.sc-tag-bad {
|
||||
background: $danger-bg;
|
||||
color: $danger;
|
||||
border: 1px solid $danger-border;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Total row ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.sc-total-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.8rem 1.15rem;
|
||||
border-top: 1px solid $border-color;
|
||||
background: $bg-page;
|
||||
transition: background 0.2s;
|
||||
|
||||
&.sc-total-ok {
|
||||
background: $success-bg;
|
||||
}
|
||||
}
|
||||
|
||||
.sc-total-lbl {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 800;
|
||||
color: $text-main;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.sc-total-nums {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.sc-total-req {
|
||||
font-family: 'Roboto Mono', 'Courier New', monospace;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
.sc-arrow {
|
||||
font-size: 0.8rem;
|
||||
color: #94a3b8;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.sc-total-got {
|
||||
font-family: 'Roboto Mono', 'Courier New', monospace;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 1rem;
|
||||
font-weight: 800;
|
||||
color: $text-muted;
|
||||
letter-spacing: -0.02em;
|
||||
transition: color 0.2s;
|
||||
|
||||
&.sc-total-got-ok {
|
||||
color: $success;
|
||||
}
|
||||
}
|
||||
|
||||
.sc-status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.2rem 0.65rem;
|
||||
border-radius: 99px;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.04em;
|
||||
white-space: nowrap;
|
||||
transition: all 0.2s;
|
||||
|
||||
&.sc-pill-ok {
|
||||
background: $success;
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 6px rgb(5 150 105 / 30%);
|
||||
}
|
||||
|
||||
&.sc-pill-pending {
|
||||
background: $border-color;
|
||||
color: $text-muted;
|
||||
}
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* ── Receipt step slots ────────────────────────────────────────────────── */
|
||||
|
||||
.slot {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding: 1.1rem 1.25rem;
|
||||
border: 1px solid $border-color;
|
||||
border-radius: 10px;
|
||||
background: $bg-white;
|
||||
box-shadow: 0 1px 4px rgb(15 23 42 / 4%);
|
||||
transition: box-shadow 0.2s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 3px 12px rgb(15 23 42 / 7%);
|
||||
}
|
||||
}
|
||||
|
||||
.slot-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.slot-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: none;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, $primary 0%, $accent 100%);
|
||||
color: #fff;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 800;
|
||||
box-shadow: 0 2px 6px rgb(37 99 235 / 30%);
|
||||
}
|
||||
|
||||
.slot-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
color: $text-main;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0.12rem 0 0;
|
||||
font-size: 0.72rem;
|
||||
color: $text-muted;
|
||||
}
|
||||
}
|
||||
|
||||
.slot-search {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
flex: none;
|
||||
padding: 0.38rem 0.72rem;
|
||||
border: 1.5px solid $accent-border;
|
||||
border-radius: 7px;
|
||||
background: $accent-soft;
|
||||
color: $accent;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
|
||||
mat-icon {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: $accent;
|
||||
border-color: $accent;
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 8px rgb(37 99 235 / 25%);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Pickable option list ───────────────────────────────────────────────── */
|
||||
|
||||
.options {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
max-height: 210px;
|
||||
overflow-y: auto;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 99px;
|
||||
}
|
||||
}
|
||||
|
||||
.option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
padding: 0.72rem 0.9rem;
|
||||
border: 1.5px solid $border-color;
|
||||
border-radius: 7px;
|
||||
cursor: pointer;
|
||||
background: $bg-white;
|
||||
transition: all 0.14s;
|
||||
|
||||
&:hover {
|
||||
border-color: $accent;
|
||||
background: $accent-soft;
|
||||
box-shadow: 0 2px 6px rgb(37 99 235 / 10%);
|
||||
}
|
||||
|
||||
&.option-busy {
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.option-radio {
|
||||
flex: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid #cbd5e1;
|
||||
border-radius: 50%;
|
||||
transition: border-color 0.14s;
|
||||
|
||||
.option:hover & {
|
||||
border-color: $accent;
|
||||
}
|
||||
}
|
||||
|
||||
.option-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
.option-voucher {
|
||||
font-size: 0.81rem;
|
||||
font-weight: 700;
|
||||
color: $text-main;
|
||||
}
|
||||
|
||||
.option-meta {
|
||||
font-size: 0.7rem;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
.option-desc {
|
||||
font-size: 0.7rem;
|
||||
color: $text-muted;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.option-amount {
|
||||
flex: none;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
color: $text-main;
|
||||
}
|
||||
|
||||
.option-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: none;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
padding: 0;
|
||||
border: 1px solid $border-color;
|
||||
border-radius: 5px;
|
||||
background: transparent;
|
||||
color: $text-muted;
|
||||
cursor: pointer;
|
||||
transition: all 0.14s;
|
||||
|
||||
mat-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: $accent;
|
||||
border-color: $accent;
|
||||
background: $accent-soft;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Chosen receipt ─────────────────────────────────────────────────────── */
|
||||
|
||||
.chosen {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 2px solid $success-border;
|
||||
border-radius: 9px;
|
||||
background: $success-bg;
|
||||
box-shadow: 0 2px 8px rgb(5 150 105 / 10%);
|
||||
|
||||
&.chosen-short {
|
||||
border-color: $danger-border;
|
||||
background: $danger-bg;
|
||||
box-shadow: 0 2px 8px rgb(220 38 38 / 8%);
|
||||
}
|
||||
}
|
||||
|
||||
.chosen-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: none;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 7px;
|
||||
background: $success;
|
||||
|
||||
.chosen-short & {
|
||||
background: $danger;
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 17px;
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.chosen-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
.chosen-voucher {
|
||||
font-size: 0.84rem;
|
||||
font-weight: 700;
|
||||
color: $text-main;
|
||||
}
|
||||
|
||||
.chosen-gl {
|
||||
font-size: 0.7rem;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
.chosen-amount {
|
||||
flex: none;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 800;
|
||||
color: $text-main;
|
||||
font-family: 'Roboto Mono', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.chosen-clear {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: none;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
padding: 0;
|
||||
border: 1px solid $danger-border;
|
||||
border-radius: 5px;
|
||||
background: transparent;
|
||||
color: #f87171;
|
||||
cursor: pointer;
|
||||
transition: all 0.14s;
|
||||
|
||||
mat-icon {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: $danger;
|
||||
background: $danger-bg;
|
||||
border-color: $danger;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Slot notes ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.slot-note {
|
||||
margin: 0;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 500;
|
||||
|
||||
&.note-good {
|
||||
color: $success;
|
||||
}
|
||||
|
||||
&.note-bad {
|
||||
color: $danger;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
&.note-muted {
|
||||
color: $text-muted;
|
||||
}
|
||||
}
|
||||
|
||||
.slot-empty {
|
||||
margin: 0;
|
||||
padding: 0.8rem 1rem;
|
||||
border: 1.5px dashed $border-color;
|
||||
border-radius: 7px;
|
||||
background: $bg-page;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.55;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
/* ── Error alert ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.alert {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
padding: 0.7rem 0.9rem;
|
||||
border: 1.5px solid $danger-border;
|
||||
border-radius: 7px;
|
||||
background: $danger-bg;
|
||||
color: $danger;
|
||||
font-size: 0.76rem;
|
||||
line-height: 1.45;
|
||||
|
||||
mat-icon {
|
||||
flex: none;
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
font-size: 17px;
|
||||
margin-top: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Footer ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
flex: none;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
min-height: 0;
|
||||
padding: 1rem 1.5rem !important;
|
||||
margin: 0 !important;
|
||||
border-top: 1px solid $border-color;
|
||||
background: $bg-white;
|
||||
box-shadow: 0 -2px 8px rgb(15 23 42 / 5%);
|
||||
|
||||
// Both buttons: same height, same border-radius, same font
|
||||
button {
|
||||
display: inline-flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
gap: 0.3rem !important;
|
||||
height: 40px !important;
|
||||
min-width: 110px !important;
|
||||
padding: 0 1.1rem !important;
|
||||
border-radius: 8px !important;
|
||||
font-weight: 600 !important;
|
||||
font-size: 0.85rem !important;
|
||||
line-height: 1 !important;
|
||||
letter-spacing: 0.01em !important;
|
||||
|
||||
// Icon inside button — vertically center it and size it correctly
|
||||
mat-icon {
|
||||
display: inline-flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
width: 18px !important;
|
||||
height: 18px !important;
|
||||
font-size: 18px !important;
|
||||
line-height: 1 !important;
|
||||
vertical-align: middle !important;
|
||||
margin: 0 !important;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
mat-spinner {
|
||||
flex: none;
|
||||
}
|
||||
}
|
||||
|
||||
// Stroked cancel button
|
||||
[mat-stroked-button] {
|
||||
border-color: $border-color !important;
|
||||
color: $text-sub !important;
|
||||
|
||||
&:hover {
|
||||
border-color: #94a3b8 !important;
|
||||
background: $bg-page !important;
|
||||
}
|
||||
}
|
||||
|
||||
// Primary action button (enabled)
|
||||
[mat-flat-button][color='primary']:not([disabled]) {
|
||||
background: linear-gradient(135deg, $accent 0%, #1d4ed8 100%) !important;
|
||||
box-shadow: 0 2px 8px rgb(37 99 235 / 28%) !important;
|
||||
color: #fff !important;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 14px rgb(37 99 235 / 40%) !important;
|
||||
transform: translateY(-1px) !important;
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0) !important;
|
||||
}
|
||||
}
|
||||
|
||||
// Primary action button (disabled)
|
||||
[mat-flat-button][color='primary'][disabled] {
|
||||
background: #e2e8f0 !important;
|
||||
color: #94a3b8 !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Form fields (in search popup) ─────────────────────────────────────── */
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
|
||||
> span {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
select,
|
||||
input {
|
||||
padding: 0.55rem 0.7rem;
|
||||
border: 1.5px solid $border-color;
|
||||
border-radius: 7px;
|
||||
font-size: 0.82rem;
|
||||
color: $text-main;
|
||||
background: #fff;
|
||||
outline: none;
|
||||
transition:
|
||||
border-color 0.14s,
|
||||
box-shadow 0.14s;
|
||||
|
||||
&:hover {
|
||||
border-color: #94a3b8;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: $accent;
|
||||
box-shadow: 0 0 0 3px rgb(37 99 235 / 12%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Sub-popups ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1.5rem;
|
||||
background: rgb(15 23 42 / 55%);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.popup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
max-width: 460px;
|
||||
max-height: 80vh;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 20px 50px -8px rgb(15 23 42 / 30%);
|
||||
animation: popup-in 0.15s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
@keyframes popup-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.96) translateY(8px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
.popup-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.85rem 0.85rem 0.85rem 1.2rem;
|
||||
border-bottom: 1px solid $border-color;
|
||||
background: $bg-page;
|
||||
|
||||
h3 {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 700;
|
||||
color: $text-main;
|
||||
}
|
||||
}
|
||||
|
||||
.popup-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.85rem;
|
||||
padding: 1.2rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.popup-foot {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.6rem;
|
||||
padding: 0.8rem 1.2rem;
|
||||
border-top: 1px solid $border-color;
|
||||
background: $bg-page;
|
||||
}
|
||||
|
||||
/* ── Receipt detail popup ───────────────────────────────────────────────── */
|
||||
|
||||
.detail-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
margin: 0;
|
||||
border: 1px solid $border-color;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
|
||||
> div {
|
||||
display: flex;
|
||||
border-bottom: 1px solid $border-color;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
dt {
|
||||
flex: none;
|
||||
width: 110px;
|
||||
padding: 0.6rem 0.9rem;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: $text-muted;
|
||||
background: $bg-page;
|
||||
border-right: 1px solid $border-color;
|
||||
}
|
||||
|
||||
dd {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 0.6rem 0.9rem;
|
||||
font-size: 0.8rem;
|
||||
color: $text-main;
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-heading {
|
||||
margin: 0.4rem 0 0.1rem;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
.gl-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.76rem;
|
||||
border: 1px solid $border-color;
|
||||
border-radius: 7px;
|
||||
overflow: hidden;
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 0.45rem 0.6rem;
|
||||
border-bottom: 1px solid $border-color;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
th {
|
||||
font-size: 0.64rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: $text-muted;
|
||||
background: $bg-page;
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background: $accent-soft;
|
||||
}
|
||||
|
||||
.num {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
/**
|
||||
* One Share Collection receipt the counter can choose, in a single shape whatever it came from.
|
||||
*
|
||||
* {@code GET /shareadvancecollection/unattributed} answers with two differently-shaped lists — the
|
||||
* member's own receipts under {@code own}, and every other receipt still going spare under
|
||||
* {@code rows} — and normalising both to this on arrival is what lets a screen show one plain list
|
||||
* of vouchers instead of two tables whose difference a counter has to understand before they can
|
||||
* pick anything.
|
||||
*
|
||||
* Shared by the two screens that settle a share purchase (the first application's Apply Collection
|
||||
* dialog and the Apply Additional Shares dialog) so a receipt reads identically on both.
|
||||
*/
|
||||
export interface ReceiptOption {
|
||||
/** Stable identity for tracking and comparison; unique across both source lists. */
|
||||
key: string;
|
||||
/**
|
||||
* The {@code m_share_advance_collection} row behind this receipt, or null while it is still only
|
||||
* a voucher number. A null one is attached to the member on first pick.
|
||||
*/
|
||||
collectionId: number | null;
|
||||
voucherNo: string;
|
||||
glAccountNo: string;
|
||||
glAccountName: string;
|
||||
date: string;
|
||||
amount: number;
|
||||
description: string;
|
||||
cashierName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A receipt that already has an {@code m_share_advance_collection} row — ready to settle as-is.
|
||||
*
|
||||
* Also the shape {@code GET /shareadvancecollection/by-voucher} answers with, so a receipt found by
|
||||
* number arrives here too, already attached.
|
||||
*/
|
||||
export function receiptFromOwn(row: any): ReceiptOption {
|
||||
return {
|
||||
key: `c${row.id}`,
|
||||
collectionId: Number(row.id),
|
||||
voucherNo: row.voucher_no || row.receipt_no || '',
|
||||
glAccountNo: row.gl_code || '',
|
||||
glAccountName: '',
|
||||
date: String(row.collection_date || '').slice(0, 10),
|
||||
amount: Number(row.amount || 0),
|
||||
description: row.note || '',
|
||||
cashierName: row.collected_by_name || ''
|
||||
};
|
||||
}
|
||||
|
||||
/** A receipt that is still only a voucher, attached to the member when it is picked. */
|
||||
export function receiptFromUnattached(row: any): ReceiptOption {
|
||||
return {
|
||||
key: `v${row.id}`,
|
||||
collectionId: null,
|
||||
voucherNo: row.voucherNo || '',
|
||||
glAccountNo: row.glAccountNo || '',
|
||||
glAccountName: row.glAccountName || '',
|
||||
date: String(row.txnDate || '').slice(0, 10),
|
||||
amount: Number(row.amount || 0),
|
||||
description: row.description || '',
|
||||
cashierName: row.cashierName || ''
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The GL account a receipt sits in, named where the name is known and coded where it is not.
|
||||
*
|
||||
* An attached receipt carries only its {@code gl_code}, so the name is looked up in whatever GL
|
||||
* account list the screen already loaded for its voucher search rather than fetched again.
|
||||
*/
|
||||
export function receiptGlLabel(option: ReceiptOption, glAccounts: any[]): string {
|
||||
const name = option.glAccountName || glAccounts.find((gl) => gl.gl_code === option.glAccountNo)?.name || '';
|
||||
if (!option.glAccountNo) {
|
||||
return name || '—';
|
||||
}
|
||||
return name ? `${option.glAccountNo} — ${name}` : option.glAccountNo;
|
||||
}
|
||||
@@ -6,292 +6,367 @@
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="container">
|
||||
<mat-card>
|
||||
<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>
|
||||
<div class="dialog-wrapper">
|
||||
<div class="dialog-header">
|
||||
<div class="header-icon">
|
||||
<mat-icon>trending_up</mat-icon>
|
||||
</div>
|
||||
<div class="header-text">
|
||||
<h2>Apply Additional Shares</h2>
|
||||
<p>
|
||||
Share account <strong class="mono">{{ data.accountNo }}</strong>
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" mat-icon-button class="header-close" [mat-dialog-close]="false" aria-label="Close">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
<!-- ── Shares / Price / Amount strip — outside scroll area so always visible ── -->
|
||||
<div class="shares-subheader">
|
||||
<div class="shares-strip">
|
||||
<div class="shares-cell shares-cell-input">
|
||||
<span class="shares-label">Shares</span>
|
||||
<input
|
||||
class="req-input mono"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
inputmode="numeric"
|
||||
placeholder="0"
|
||||
[(ngModel)]="requestedShares"
|
||||
(ngModelChange)="onSharesChange()"
|
||||
/>
|
||||
</div>
|
||||
<div class="shares-cell">
|
||||
<span class="shares-label">Unit Price</span>
|
||||
<span class="shares-value mono">{{ unitPrice | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
<div class="shares-cell shares-cell-total">
|
||||
<span class="shares-label">Total Amount</span>
|
||||
<span class="shares-value mono">{{ amount | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</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="gl-select-field">
|
||||
<select
|
||||
class="gl-select"
|
||||
[(ngModel)]="glAccountNo"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
[disabled]="lookingUpVoucher || submitting || loadingGlAccounts"
|
||||
>
|
||||
<option value="" disabled>
|
||||
{{ loadingGlAccounts ? 'Loading GL accounts…' : 'Select GL account…' }}
|
||||
</option>
|
||||
@for (gl of glAccounts; track gl.id) {
|
||||
<option [value]="gl.gl_code">{{ gl.gl_code }} — {{ gl.name }}</option>
|
||||
}
|
||||
</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>
|
||||
}
|
||||
|
||||
@if (loadingVouchers) {
|
||||
<div class="funding-loading">
|
||||
<mat-spinner diameter="24"></mat-spinner>
|
||||
<span>Loading the member's 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>
|
||||
</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>
|
||||
|
||||
<!-- 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">
|
||||
<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.
|
||||
</span>
|
||||
</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>
|
||||
|
||||
@if (checkingSavings) {
|
||||
<div class="funding-loading">
|
||||
<mat-spinner diameter="24"></mat-spinner>
|
||||
<span>Checking the linked savings account…</span>
|
||||
</div>
|
||||
} @else if (savingsEligibility) {
|
||||
<div class="funding-summary">
|
||||
@if (savingsEligibility.savingsAccountNo) {
|
||||
<div class="funding-row">
|
||||
<span>Savings account</span>
|
||||
<span class="mono">{{ savingsEligibility.savingsAccountNo }}</span>
|
||||
</div>
|
||||
<div class="funding-row">
|
||||
<span>Available balance</span>
|
||||
<span>{{ savingsEligibility.savingsBalance | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
}
|
||||
<div class="funding-row funding-row--total">
|
||||
<span>To debit</span>
|
||||
<span>{{ amount | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!savingsEligibility.eligible) {
|
||||
<div class="alert-error">
|
||||
<mat-icon>error_outline</mat-icon>
|
||||
<span>{{ savingsEligibility.reason }}</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (errorMsg) {
|
||||
<div class="alert-error">
|
||||
<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>
|
||||
<mat-dialog-content class="dialog-body">
|
||||
<!-- ── Step 1: Board Resolution ──────────────────────────────────────── -->
|
||||
<section class="slot">
|
||||
<header class="slot-head">
|
||||
<span class="slot-step">1</span>
|
||||
<div class="slot-title">
|
||||
<h3>Board Resolution</h3>
|
||||
<p>The meeting that authorised this tranche, not the one the account was opened under.</p>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</header>
|
||||
|
||||
<mat-card-actions class="layout-row align-center gap-5px responsive-column">
|
||||
<button type="button" mat-raised-button [routerLink]="['../../']">
|
||||
{{ 'labels.buttons.Cancel' | translate }}
|
||||
<div class="field-row">
|
||||
<label class="field">
|
||||
<span>Resolution No.</span>
|
||||
<input type="text" [(ngModel)]="resolutionNumber" placeholder="e.g. BR/2026/14" autocomplete="off" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Meeting Date</span>
|
||||
<input type="date" [(ngModel)]="resolutionDate" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Request Date</span>
|
||||
<input type="date" [(ngModel)]="requestedDate" />
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Step 2: Funding ───────────────────────────────────────────────── -->
|
||||
<section class="slot">
|
||||
<header class="slot-head">
|
||||
<span class="slot-step">2</span>
|
||||
<div class="slot-title">
|
||||
<h3>Funding</h3>
|
||||
<p>Approval posts Dr Sundry / Cr Share Capital either way.</p>
|
||||
</div>
|
||||
@if (funding === 'CASH') {
|
||||
<button type="button" class="slot-search" (click)="openSearch()">
|
||||
<mat-icon>search</mat-icon>
|
||||
<span>Find by voucher</span>
|
||||
</button>
|
||||
}
|
||||
</header>
|
||||
|
||||
<div class="funding-choice">
|
||||
<button
|
||||
type="button"
|
||||
class="funding-btn"
|
||||
[class.funding-on]="funding === 'CASH'"
|
||||
(click)="chooseFunding('CASH')"
|
||||
>
|
||||
<mat-icon>payments</mat-icon>
|
||||
<span class="funding-name">Cash</span>
|
||||
<span class="funding-hint">Already paid at the counter</span>
|
||||
</button>
|
||||
<button mat-raised-button color="primary" [disabled]="!canSubmit">
|
||||
{{ submitting ? 'Submitting…' : ('labels.buttons.Submit' | translate) }}
|
||||
<button
|
||||
type="button"
|
||||
class="funding-btn"
|
||||
[class.funding-on]="funding === 'SAVINGS'"
|
||||
(click)="chooseFunding('SAVINGS')"
|
||||
>
|
||||
<mat-icon>savings</mat-icon>
|
||||
<span class="funding-name">Savings</span>
|
||||
<span class="funding-hint">Debited on submit</span>
|
||||
</button>
|
||||
</mat-card-actions>
|
||||
</form>
|
||||
</mat-card>
|
||||
</div>
|
||||
|
||||
@if (funding === 'CASH') {
|
||||
@if (loadingReceipts) {
|
||||
<div class="state-block">
|
||||
<mat-spinner diameter="24"></mat-spinner>
|
||||
<span>Loading receipts…</span>
|
||||
</div>
|
||||
} @else if (receipt) {
|
||||
<div class="chosen" [class.chosen-short]="shortfall > 0">
|
||||
<div class="chosen-badge">
|
||||
<mat-icon>{{ shortfall > 0 ? 'warning' : 'check_circle' }}</mat-icon>
|
||||
</div>
|
||||
<div class="chosen-main">
|
||||
<span class="chosen-voucher mono">{{ receipt.voucherNo }}</span>
|
||||
<span class="chosen-gl">{{ glLabel(receipt) }}</span>
|
||||
</div>
|
||||
<span class="chosen-amount mono">{{ receipt.amount | number: '1.2-2' }}</span>
|
||||
<button type="button" class="chosen-clear" (click)="clearReceipt()" aria-label="Change receipt">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
@if (shortfall > 0) {
|
||||
<p class="slot-note note-bad">
|
||||
Short by {{ shortfall | number: '1.2-2' }} — this receipt does not cover {{ amount | number: '1.2-2' }}.
|
||||
</p>
|
||||
} @else {
|
||||
<p class="slot-note note-good">Covers the {{ amount | number: '1.2-2' }} tranche.</p>
|
||||
}
|
||||
} @else if (available.length) {
|
||||
<ul class="options">
|
||||
@for (option of available; track option.key) {
|
||||
<li class="option" [class.option-busy]="attaching === option.key" (click)="pick(option)">
|
||||
<span class="option-radio"></span>
|
||||
<div class="option-main">
|
||||
<span class="option-voucher mono">{{ option.voucherNo }}</span>
|
||||
<span class="option-meta">{{ option.date }} · {{ glLabel(option) }}</span>
|
||||
@if (option.description) {
|
||||
<span class="option-desc">{{ option.description }}</span>
|
||||
}
|
||||
</div>
|
||||
<span class="option-amount mono">{{ option.amount | number: '1.2-2' }}</span>
|
||||
@if (attaching === option.key) {
|
||||
<mat-spinner diameter="16"></mat-spinner>
|
||||
} @else if (option.collectionId !== null) {
|
||||
<button
|
||||
type="button"
|
||||
class="option-info"
|
||||
(click)="openDetail(option, $event)"
|
||||
aria-label="GL entries"
|
||||
>
|
||||
<mat-icon>receipt_long</mat-icon>
|
||||
</button>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
} @else {
|
||||
<p class="slot-empty">
|
||||
No Share Collection receipt is waiting. Collect the cash at <em>Cashier › Transaction Entry</em> with entry
|
||||
type <strong>Share Collection</strong>, or find an earlier voucher by number.
|
||||
</p>
|
||||
}
|
||||
}
|
||||
|
||||
@if (funding === 'SAVINGS') {
|
||||
@if (checkingSavings) {
|
||||
<div class="state-block">
|
||||
<mat-spinner diameter="24"></mat-spinner>
|
||||
<span>Checking the linked savings account…</span>
|
||||
</div>
|
||||
} @else if (savings) {
|
||||
<div class="chosen" [class.chosen-short]="!savings.eligible">
|
||||
<div class="chosen-badge">
|
||||
<mat-icon>{{ savings.eligible ? 'check_circle' : 'warning' }}</mat-icon>
|
||||
</div>
|
||||
<div class="chosen-main">
|
||||
<span class="chosen-voucher">
|
||||
@if (savings.savingsAccountNo) {
|
||||
Savings <span class="mono">{{ savings.savingsAccountNo }}</span>
|
||||
} @else {
|
||||
Linked savings account
|
||||
}
|
||||
</span>
|
||||
<span class="chosen-gl">
|
||||
{{ savings.eligible ? 'Debited into Share Sundry on submit.' : savings.reason }}
|
||||
</span>
|
||||
</div>
|
||||
@if (savings.savingsBalance !== null) {
|
||||
<span class="chosen-amount mono">{{ savings.savingsBalance | number: '1.2-2' }}</span>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<p class="slot-note note-muted">Enter the number of shares to check the member's balance.</p>
|
||||
}
|
||||
}
|
||||
</section>
|
||||
|
||||
@if (errorMsg) {
|
||||
<div class="alert">
|
||||
<mat-icon>error_outline</mat-icon>
|
||||
<span>{{ errorMsg }}</span>
|
||||
</div>
|
||||
}
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions class="dialog-actions">
|
||||
<button type="button" mat-stroked-button [mat-dialog-close]="false" [disabled]="submitting">Cancel</button>
|
||||
<button type="button" mat-flat-button color="primary" [disabled]="!canSubmit" (click)="submit()">
|
||||
@if (submitting) {
|
||||
<mat-spinner diameter="16"></mat-spinner>
|
||||
<span>Submitting…</span>
|
||||
} @else {
|
||||
<mat-icon>check</mat-icon>
|
||||
<span>Submit Request</span>
|
||||
}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
</div>
|
||||
|
||||
<!-- ── Find a voucher the list does not carry ───────────────────────────────── -->
|
||||
@if (searchOpen) {
|
||||
<div class="overlay" (click)="closeSearch()">
|
||||
<div class="popup" (click)="$event.stopPropagation()">
|
||||
<header class="popup-head">
|
||||
<h3>Find a receipt</h3>
|
||||
<button type="button" mat-icon-button (click)="closeSearch()" aria-label="Close">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="popup-body">
|
||||
<label class="field">
|
||||
<span>GL Account</span>
|
||||
<select [(ngModel)]="searchGlAccountNo" [disabled]="loadingGlAccounts">
|
||||
<option value="">{{ loadingGlAccounts ? 'Loading…' : 'Select the account it was paid into' }}</option>
|
||||
@for (gl of glAccounts; track gl.id) {
|
||||
<option [value]="gl.gl_code">{{ gl.gl_code }} — {{ gl.name }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>Voucher No.</span>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="searchVoucherNo"
|
||||
placeholder="CR/18092026/0004"
|
||||
(keyup.enter)="runSearch()"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</label>
|
||||
|
||||
@if (errorMsg) {
|
||||
<div class="alert">
|
||||
<mat-icon>error_outline</mat-icon>
|
||||
<span>{{ errorMsg }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<footer class="popup-foot">
|
||||
<button type="button" mat-stroked-button (click)="closeSearch()">Cancel</button>
|
||||
<button
|
||||
type="button"
|
||||
mat-flat-button
|
||||
color="primary"
|
||||
[disabled]="!searchVoucherNo.trim() || !searchGlAccountNo.trim() || searching"
|
||||
(click)="runSearch()"
|
||||
>
|
||||
@if (searching) {
|
||||
<mat-spinner diameter="16"></mat-spinner>
|
||||
} @else {
|
||||
<span>Find & Use</span>
|
||||
}
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ── A receipt's own GL entries ───────────────────────────────────────────── -->
|
||||
@if (viewing) {
|
||||
<div class="overlay" (click)="closeDetail()">
|
||||
<div class="popup" (click)="$event.stopPropagation()">
|
||||
<header class="popup-head">
|
||||
<h3>
|
||||
Voucher <span class="mono">{{ viewing.voucherNo }}</span>
|
||||
</h3>
|
||||
<button type="button" mat-icon-button (click)="closeDetail()" aria-label="Close">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="popup-body">
|
||||
<dl class="detail-list">
|
||||
<div>
|
||||
<dt>Date</dt>
|
||||
<dd class="mono">{{ viewing.date }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Amount</dt>
|
||||
<dd class="mono">{{ viewing.amount | number: '1.2-2' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>GL Account</dt>
|
||||
<dd>{{ glLabel(viewing) }}</dd>
|
||||
</div>
|
||||
@if (viewing.cashierName) {
|
||||
<div>
|
||||
<dt>Collected By</dt>
|
||||
<dd>{{ viewing.cashierName }}</dd>
|
||||
</div>
|
||||
}
|
||||
@if (viewing.description) {
|
||||
<div>
|
||||
<dt>Description</dt>
|
||||
<dd>{{ viewing.description }}</dd>
|
||||
</div>
|
||||
}
|
||||
</dl>
|
||||
|
||||
<h4 class="detail-heading">Journal Entries</h4>
|
||||
@if (loadingGlEntries) {
|
||||
<div class="state-block"><mat-spinner diameter="22"></mat-spinner></div>
|
||||
} @else if (viewingGlEntries?.length) {
|
||||
<table class="gl-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Account</th>
|
||||
<th class="num">Debit</th>
|
||||
<th class="num">Credit</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (entry of viewingGlEntries; track $index) {
|
||||
<tr>
|
||||
<td>
|
||||
<span class="mono">{{ entry.glCode }}</span> {{ entry.accountName }}
|
||||
</td>
|
||||
<td class="num mono">{{ entry.drCr === 'Dr' ? (entry.amount | number: '1.2-2') : '' }}</td>
|
||||
<td class="num mono">{{ entry.drCr === 'Cr' ? (entry.amount | number: '1.2-2') : '' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
} @else {
|
||||
<p class="slot-empty">No journal entries are recorded against this receipt.</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<footer class="popup-foot">
|
||||
<button type="button" mat-stroked-button (click)="closeDetail()">Close</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -6,231 +6,195 @@
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
$primary: #1a3a6b;
|
||||
$border: #d8e0ec;
|
||||
$muted: #6b7a90;
|
||||
$danger: #b3261e;
|
||||
// Dialog chrome, receipt list, chosen state and popups come from the shared
|
||||
// partial. Only what is unique to a tranche request lives below.
|
||||
@use '../../share-dialog.shared' as *;
|
||||
|
||||
.container {
|
||||
max-width: 37rem;
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.payment-mode {
|
||||
margin: 4px 0 16px;
|
||||
/* ── Shares sub-header (sits between dialog-header and dialog-body, never scrolls) ── */
|
||||
|
||||
.shares-subheader {
|
||||
display: flex;
|
||||
flex: none; // same as header/footer — doesn't participate in body flex growth
|
||||
padding: 0.85rem 1.5rem;
|
||||
background: #f1f5f9;
|
||||
border-bottom: 1px solid $border-color;
|
||||
}
|
||||
|
||||
.payment-mode-label {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
color: $muted;
|
||||
margin-bottom: 8px;
|
||||
/* ── Shares / Price / Amount strip ─────────────────────────────────────── */
|
||||
|
||||
.shares-strip {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
border: 1px solid $accent-border;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: $bg-white;
|
||||
box-shadow: 0 2px 8px rgb(37 99 235 / 7%);
|
||||
}
|
||||
|
||||
.payment-mode-options {
|
||||
.shares-cell {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
gap: 0.3rem;
|
||||
padding: 0.9rem 1.1rem;
|
||||
background: $bg-white;
|
||||
border-right: 1px solid $border-color;
|
||||
|
||||
&:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
&.shares-cell-input {
|
||||
// editable cell — slight highlight
|
||||
background: #fafcff;
|
||||
}
|
||||
|
||||
&.shares-cell-total {
|
||||
background: $accent-soft;
|
||||
|
||||
.shares-label {
|
||||
color: $accent;
|
||||
}
|
||||
|
||||
.shares-value {
|
||||
color: $primary;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* The funding route's own detail, boxed off from the share fields above it: which voucher, or
|
||||
which savings account, is what the clerk checks before submitting. */
|
||||
.funding-panel {
|
||||
border: 1px solid $border;
|
||||
border-radius: 6px;
|
||||
padding: 14px;
|
||||
margin-bottom: 16px;
|
||||
background: #fbfcfe;
|
||||
.shares-label {
|
||||
display: block;
|
||||
font-size: 0.63rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: $text-muted;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.funding-intro {
|
||||
font-size: 13px;
|
||||
color: $muted;
|
||||
margin: 0 0 12px;
|
||||
.shares-value {
|
||||
display: block;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
color: $text-main;
|
||||
letter-spacing: -0.01em;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.gl-select-field {
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
/* ── Inline number input inside the strip ──────────────────────────────── */
|
||||
|
||||
.gl-select {
|
||||
.req-input {
|
||||
width: 100%;
|
||||
padding: 9px 11px;
|
||||
border: 1px solid $border;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
color: $primary;
|
||||
background: #fff;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
color: $text-main;
|
||||
letter-spacing: -0.01em;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: $primary;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: #f4f7fb;
|
||||
color: $muted;
|
||||
&::placeholder {
|
||||
color: #cbd5e1;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
// Remove browser number arrows
|
||||
&::-webkit-outer-spin-button,
|
||||
&::-webkit-inner-spin-button {
|
||||
appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.voucher-lookup {
|
||||
/* ── Board resolution fields ────────────────────────────────────────────── */
|
||||
|
||||
.field-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.85rem;
|
||||
|
||||
@media (width <= 560px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Cash / Savings funding selector ───────────────────────────────────── */
|
||||
|
||||
.funding-choice {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.funding-btn {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.voucher-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 9px 11px;
|
||||
border: 1px solid $border;
|
||||
border-radius: 6px;
|
||||
font-family: 'Roboto Mono', monospace;
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.4px;
|
||||
color: $primary;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: $primary;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: #f4f7fb;
|
||||
color: $muted;
|
||||
}
|
||||
}
|
||||
|
||||
.funding-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 0;
|
||||
font-size: 13px;
|
||||
color: $muted;
|
||||
}
|
||||
|
||||
.funding-summary {
|
||||
border: 1px solid $border;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.funding-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 4px 0;
|
||||
color: $primary;
|
||||
|
||||
> span:first-child {
|
||||
color: $muted;
|
||||
}
|
||||
}
|
||||
|
||||
.funding-row--total {
|
||||
border-top: 1px solid #eceff4;
|
||||
margin-top: 4px;
|
||||
padding-top: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.funding-empty {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
background: #f4f7fb;
|
||||
border: 1px solid $border;
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.funding-empty-hint {
|
||||
color: $muted;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.receipt-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
background: #fff;
|
||||
border: 1px solid $border;
|
||||
border-radius: 6px;
|
||||
|
||||
th {
|
||||
background: #eef2f8;
|
||||
color: $primary;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid #eceff4;
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: #f4f7fb;
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background: #e7effb;
|
||||
}
|
||||
}
|
||||
|
||||
/* The running total against the tranche amount — what decides whether the request is accepted,
|
||||
and not something to be added up by eye once a second receipt is in play. */
|
||||
tfoot .total-row {
|
||||
border-top: 2px solid #cbd5e1;
|
||||
|
||||
td {
|
||||
padding-top: 10px;
|
||||
color: #1f3d7a;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ta-left {
|
||||
gap: 0.2rem;
|
||||
padding: 0.85rem 1rem;
|
||||
border: 2px solid $border-color;
|
||||
border-radius: 10px;
|
||||
background: $bg-white;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
box-shadow: 0 1px 3px rgb(15 23 42 / 4%);
|
||||
|
||||
mat-icon {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
font-size: 22px;
|
||||
color: $text-muted;
|
||||
transition: color 0.15s;
|
||||
margin-bottom: 0.15rem;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: $accent;
|
||||
background: $accent-soft;
|
||||
box-shadow: 0 2px 8px rgb(37 99 235 / 12%);
|
||||
transform: translateY(-1px);
|
||||
|
||||
mat-icon {
|
||||
color: $accent;
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
&.funding-on {
|
||||
border-color: $accent;
|
||||
background: linear-gradient(135deg, $accent-soft 0%, #e0eaff 100%);
|
||||
box-shadow: 0 2px 10px rgb(37 99 235 / 18%);
|
||||
|
||||
mat-icon,
|
||||
.funding-name {
|
||||
color: $primary;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ta-right {
|
||||
text-align: right;
|
||||
.funding-name {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
color: $text-main;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.ta-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: #fdecea;
|
||||
color: $danger;
|
||||
border: 1px solid #f5c6c2;
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.pending-hint {
|
||||
font-size: 12px;
|
||||
color: $muted;
|
||||
margin: 0;
|
||||
.funding-hint {
|
||||
font-size: 0.7rem;
|
||||
color: $text-muted;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
@@ -6,109 +6,116 @@
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
/** Angular Imports */
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { UntypedFormGroup, UntypedFormBuilder, Validators, FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { MatRadioModule } from '@angular/material/radio';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef, MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { Subject, of } from 'rxjs';
|
||||
import { catchError, debounceTime, distinctUntilChanged } from 'rxjs/operators';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
/** Custom Services */
|
||||
import { SharesService } from 'app/shares/shares.service';
|
||||
import { CashierService } from 'app/cashier/cashier.service';
|
||||
import { SettingsService } from 'app/settings/settings.service';
|
||||
import { Dates } from 'app/core/utils/dates';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { ReceiptOption, receiptFromOwn, receiptFromUnattached, receiptGlLabel } from 'app/shares/share-receipt-option';
|
||||
|
||||
/** Where the money for this tranche comes from. */
|
||||
export type FundingSource = 'CASH' | 'SAVINGS';
|
||||
|
||||
/**
|
||||
* Apply Additional Shares.
|
||||
*
|
||||
* A member buying a further tranche of shares, which — like the account's first application — is
|
||||
* only a request until it is approved. Two things have to be captured that the bare share count
|
||||
* does not carry:
|
||||
*
|
||||
* <ul>
|
||||
* <li>the board resolution authorising this particular tranche, which is its own meeting and its
|
||||
* own number, not the one the account was opened under;</li>
|
||||
* <li>where the money is coming from, because a tranche nobody has paid for cannot be approved.</li>
|
||||
* </ul>
|
||||
* A member buying a further tranche, which — like the account's first application — is only a
|
||||
* request until it is approved. Two things have to be captured that the bare share count does not
|
||||
* carry: the board resolution authorising this particular tranche (its own meeting, its own number,
|
||||
* not the one the account was opened under), and where the money is coming from, because a tranche
|
||||
* nobody has paid for cannot be approved.
|
||||
*
|
||||
* Both funding routes converge on the Share Sundry holding account, so approval posts the same
|
||||
* Dr Sundry / Cr Share Capital pair either way:
|
||||
*
|
||||
* <ul>
|
||||
* <li><b>Cash</b> — the member already paid at the counter; the cashier's receipts put the money
|
||||
* into Sundry at that point. Submitting only reserves those receipts against this tranche.
|
||||
* More than one can be ticked, because a member often pays in instalments and no single
|
||||
* receipt covers the tranche. They are checked here rather than at approval, while the member
|
||||
* is still at the counter to make up any shortfall.</li>
|
||||
* <li><b>Cash</b> — the member already paid at the counter and the cashier's receipt put the money
|
||||
* into Sundry then. Submitting only reserves that receipt against this tranche. One receipt
|
||||
* funds one tranche; it is checked here rather than at approval, while the member is still at
|
||||
* the counter to make up any shortfall.</li>
|
||||
* <li><b>Savings</b> — the member's savings are debited into Sundry on submit, so their balance
|
||||
* drops immediately. Approval then moves it out to share capital.</li>
|
||||
* </ul>
|
||||
*
|
||||
* Deliberately the same screen as the first application's Apply Collection dialog — same numbered
|
||||
* steps, same receipt list, same voucher search — because it settles the same receipts for the same
|
||||
* person, and two different designs for that were only ever two things to learn.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-apply-shares',
|
||||
templateUrl: './apply-shares.component.html',
|
||||
styleUrls: ['./apply-shares.component.scss'],
|
||||
standalone: true,
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatRadioModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatIconModule
|
||||
MatDialogModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatProgressSpinnerModule
|
||||
]
|
||||
})
|
||||
export class ApplySharesComponent implements OnInit {
|
||||
private formBuilder = inject(UntypedFormBuilder);
|
||||
private sharesService = inject(SharesService);
|
||||
private cashierService = inject(CashierService);
|
||||
private dateUtils = inject(Dates);
|
||||
private route = inject(ActivatedRoute);
|
||||
private router = inject(Router);
|
||||
private settingsService = inject(SettingsService);
|
||||
private dateUtils = inject(Dates);
|
||||
|
||||
/** Shares account data. */
|
||||
sharesAccountData: any;
|
||||
public dialogRef = inject(MatDialogRef<ApplySharesComponent>);
|
||||
public data = inject<{
|
||||
shareAccountId: number;
|
||||
clientId: number;
|
||||
accountNo: string;
|
||||
unitPrice: number;
|
||||
}>(MAT_DIALOG_DATA);
|
||||
|
||||
/** Minimum date allowed. */
|
||||
minDate = new Date(2000, 0, 1);
|
||||
/** Maximum date allowed. */
|
||||
maxDate = new Date();
|
||||
/** Apply Share Account form. */
|
||||
applySharesForm: UntypedFormGroup;
|
||||
/** Shares Account Id */
|
||||
accountId: any;
|
||||
|
||||
/** Submission in flight, so the form cannot be posted twice. */
|
||||
submitting = false;
|
||||
/** Anything that stopped the submission, shown above the buttons. */
|
||||
errorMsg = '';
|
||||
|
||||
/**
|
||||
* The member's unapplied Share Sundry receipts, and which of them fund this tranche.
|
||||
*
|
||||
* Several, because a member often pays one tranche in instalments — ₹600 today, the ₹400 balance
|
||||
* tomorrow — and neither receipt covers it alone. They are submitted together as one settlement
|
||||
* rather than sending the member back to the counter for a single combined receipt.
|
||||
*/
|
||||
vouchers: any[] = [];
|
||||
selectedVoucherIds: number[] = [];
|
||||
loadingVouchers = false;
|
||||
lookingUpVoucher = false;
|
||||
voucherError = '';
|
||||
// ── The tranche ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** GL account the deposit was posted to — required, and always picked by hand. Never resolved
|
||||
* from a share product's accounting setup, so a broken or missing product mapping can never
|
||||
* make a genuine voucher unreachable. */
|
||||
glAccountNo = '';
|
||||
glAccounts: any[] = [];
|
||||
loadingGlAccounts = true;
|
||||
/** How many shares this tranche is for. */
|
||||
requestedShares: number | null = null;
|
||||
|
||||
/** The product's current market price. Read-only: the tranche's value and its GL posting must
|
||||
* never be able to disagree because somebody typed over the price. */
|
||||
unitPrice = 0;
|
||||
|
||||
/** Both as yyyy-MM-dd, the value a native date input reads and writes. */
|
||||
requestedDate = '';
|
||||
resolutionDate = '';
|
||||
resolutionNumber = '';
|
||||
|
||||
/** Shares x unit price — what this tranche is worth, and what has to be funded. */
|
||||
get amount(): number {
|
||||
return (Number(this.requestedShares) || 0) * this.unitPrice;
|
||||
}
|
||||
|
||||
// ── Funding ─────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
funding: FundingSource | null = null;
|
||||
|
||||
/** Every Share Collection receipt that can fund this tranche, from both server lists, in one pool. */
|
||||
available: ReceiptOption[] = [];
|
||||
loadingReceipts = false;
|
||||
|
||||
/** The one receipt funding this tranche. One receipt, one tranche. */
|
||||
receipt: ReceiptOption | null = null;
|
||||
|
||||
/** Key of the receipt currently being attached to this member, so only its own row shows a spinner. */
|
||||
attaching: string | null = null;
|
||||
|
||||
/** What the member's savings can cover, for the savings route. */
|
||||
savingsEligibility: {
|
||||
savings: {
|
||||
eligible: boolean;
|
||||
reason: string | null;
|
||||
savingsAccountNo: string | null;
|
||||
@@ -116,19 +123,24 @@ export class ApplySharesComponent implements OnInit {
|
||||
} | null = null;
|
||||
checkingSavings = false;
|
||||
|
||||
constructor() {
|
||||
this.accountId = this.route.parent.snapshot.params['shareAccountId'];
|
||||
this.route.data.subscribe((data: { shareAccountActionData: any }) => {
|
||||
this.sharesAccountData = data.shareAccountActionData;
|
||||
/** Re-checks the savings verdict as the share count settles, rather than on every keystroke of
|
||||
* "1000" — which asks four times and lets the answers land out of order. */
|
||||
private sharesChanged = new Subject<number>();
|
||||
|
||||
ngOnInit(): void {
|
||||
this.unitPrice = Number(this.data.unitPrice) || 0;
|
||||
// Opens on the business date rather than blank: a tranche is recorded the day it is taken, and
|
||||
// asking a clerk to type that in is asking them to retype what the system already knows.
|
||||
this.requestedDate = this.isoOf(this.settingsService.businessDate);
|
||||
|
||||
this.sharesChanged.pipe(debounceTime(400), distinctUntilChanged()).subscribe(() => {
|
||||
if (this.funding === 'SAVINGS') {
|
||||
this.checkSavings();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.maxDate = this.settingsService.businessDate;
|
||||
this.createApplySharesAccountForm();
|
||||
this.applySharesForm.get('unitPrice').patchValue(this.sharesAccountData.currentMarketPrice || '');
|
||||
this.applySharesForm.get('amount').patchValue(this.amount || '');
|
||||
|
||||
// Needed only by the voucher search, which is the exception path -- loaded quietly in the
|
||||
// background so opening that popup never waits on a request.
|
||||
this.cashierService
|
||||
.getShareAdvanceGlAccounts()
|
||||
.pipe(catchError(() => of(null)))
|
||||
@@ -138,191 +150,109 @@ export class ApplySharesComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the apply shares form.
|
||||
*
|
||||
* The request date opens on the business date rather than blank: an additional-share request is
|
||||
* recorded the day it is taken, and a clerk who has to fill that in by hand is being asked to
|
||||
* retype what the system already knows.
|
||||
*/
|
||||
createApplySharesAccountForm() {
|
||||
this.applySharesForm = this.formBuilder.group({
|
||||
requestedDate: [
|
||||
this.settingsService.businessDate,
|
||||
Validators.required
|
||||
],
|
||||
requestedShares: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
unitPrice: [{ value: '', disabled: true }],
|
||||
// Disabled, not readonly: the amount is derived from shares x price and must never be a
|
||||
// figure somebody typed, or the tranche's value and its GL posting could disagree.
|
||||
amount: [{ value: '', disabled: true }],
|
||||
resolutionNumber: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
resolutionDate: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
paymentMode: [
|
||||
'',
|
||||
Validators.required
|
||||
]
|
||||
});
|
||||
|
||||
// Recompute as the share count is typed, so the clerk can read the amount back to the member
|
||||
// before committing to a funding route.
|
||||
this.applySharesForm.get('requestedShares').valueChanges.subscribe(() => {
|
||||
this.applySharesForm.get('amount').patchValue(this.amount || '');
|
||||
// A savings verdict is cleared the moment the amount changes rather than when the re-check
|
||||
// returns, so a stale "can cover it" is never on screen beside a larger amount. The ticked
|
||||
// vouchers stay: they are the receipts the member actually holds, and the running total below
|
||||
// shows against the new amount whether they still cover it.
|
||||
this.voucherError = '';
|
||||
this.savingsEligibility = null;
|
||||
});
|
||||
|
||||
// Debounced separately from the clearing above: the check is a server round-trip, and firing it
|
||||
// on every keystroke of "1000" asks four times and lets the answers land out of order.
|
||||
this.applySharesForm
|
||||
.get('requestedShares')
|
||||
.valueChanges.pipe(debounceTime(400), distinctUntilChanged())
|
||||
.subscribe(() => {
|
||||
if (this.paymentMode === 'SAVINGS') {
|
||||
this.checkSavings();
|
||||
}
|
||||
});
|
||||
|
||||
this.applySharesForm.get('paymentMode').valueChanges.subscribe((mode: string) => {
|
||||
this.vouchers = [];
|
||||
this.selectedVoucherIds = [];
|
||||
this.voucherError = '';
|
||||
this.errorMsg = '';
|
||||
this.savingsEligibility = null;
|
||||
if (mode === 'SAVINGS') {
|
||||
this.checkSavings();
|
||||
} else if (mode === 'CASH') {
|
||||
this.loadVouchers();
|
||||
}
|
||||
});
|
||||
onSharesChange(): void {
|
||||
// The verdict is cleared the moment the amount changes rather than when the re-check returns, so
|
||||
// a stale "can cover it" is never on screen beside a larger amount. The chosen receipt stays --
|
||||
// it is the receipt the member actually holds, and the shortfall line below re-reads against the
|
||||
// new amount on its own.
|
||||
this.savings = null;
|
||||
this.errorMsg = '';
|
||||
this.sharesChanged.next(Number(this.requestedShares) || 0);
|
||||
}
|
||||
|
||||
/** Shares x unit price — what this tranche is worth, and what has to be funded. */
|
||||
get amount(): number {
|
||||
const shares = Number(this.applySharesForm?.get('requestedShares')?.value) || 0;
|
||||
const price = Number(this.applySharesForm?.get('unitPrice')?.value) || 0;
|
||||
return shares * price;
|
||||
}
|
||||
|
||||
get paymentMode(): string {
|
||||
return this.applySharesForm?.get('paymentMode')?.value || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the member's unapplied Share Sundry receipts so they can be ticked off.
|
||||
*
|
||||
* A single receipt is ticked for them: with only one candidate there is nothing to choose, and
|
||||
* making the clerk tick it adds a step that can only be got wrong by forgetting it.
|
||||
*/
|
||||
private loadVouchers(): void {
|
||||
this.loadingVouchers = true;
|
||||
this.cashierService
|
||||
.getUnappliedShareAdvances(this.sharesAccountData.clientId)
|
||||
.pipe(
|
||||
catchError((err: any) => {
|
||||
this.voucherError = this.readError(err, 'Could not load the member’s receipts.');
|
||||
this.loadingVouchers = false;
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
.subscribe((res: any) => {
|
||||
this.loadingVouchers = false;
|
||||
if (!res) {
|
||||
return;
|
||||
}
|
||||
this.vouchers = res.unappliedCollections || [];
|
||||
if (this.vouchers.length === 1) {
|
||||
this.selectedVoucherIds = [this.vouchers[0].id];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
isVoucherSelected(id: number): boolean {
|
||||
return this.selectedVoucherIds.includes(id);
|
||||
}
|
||||
|
||||
toggleVoucher(id: number): void {
|
||||
this.selectedVoucherIds = this.isVoucherSelected(id) ? this.selectedVoucherIds.filter((x) => x !== id) : [
|
||||
...this.selectedVoucherIds,
|
||||
id
|
||||
];
|
||||
this.voucherError = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* What the ticked receipts come to.
|
||||
*
|
||||
* Shown live, because the submission only goes through when the total covers the tranche, and
|
||||
* adding the rows up by eye is exactly what leads to a rejected submission.
|
||||
*/
|
||||
get selectedVoucherTotal(): number {
|
||||
return this.vouchers.filter((v) => this.isVoucherSelected(v.id)).reduce((sum, v) => sum + Number(v.amount || 0), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a voucher number the member quotes and adds it to the list.
|
||||
*
|
||||
* Kept alongside the tick list rather than replacing it: the member usually quotes the number on
|
||||
* the slip in their hand, which is faster than finding it among their receipts. The lookup is
|
||||
* deliberately not restricted to this member, so a voucher belonging to somebody else is
|
||||
* surfaced here as a plain mismatch rather than silently ignored.
|
||||
*/
|
||||
lookupVoucher(voucherNo: string): void {
|
||||
const trimmed = (voucherNo || '').trim();
|
||||
const glAccountNo = this.glAccountNo.trim();
|
||||
if (!trimmed || !glAccountNo || this.lookingUpVoucher) {
|
||||
chooseFunding(source: FundingSource): void {
|
||||
if (this.funding === source) {
|
||||
return;
|
||||
}
|
||||
this.lookingUpVoucher = true;
|
||||
this.voucherError = '';
|
||||
this.funding = source;
|
||||
this.receipt = null;
|
||||
this.savings = null;
|
||||
this.errorMsg = '';
|
||||
if (source === 'SAVINGS') {
|
||||
this.checkSavings();
|
||||
} else if (!this.available.length) {
|
||||
this.loadReceipts();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads every Share Collection receipt that could fund this tranche — the member's own, and the
|
||||
* ones still belonging to nobody — as one list.
|
||||
*
|
||||
* Nothing is pre-selected, not even when a single receipt is listed: which receipt funds the
|
||||
* tranche is a choice the person applying it makes, not one they inherit from a pre-ticked row
|
||||
* they never looked at.
|
||||
*/
|
||||
private loadReceipts(): void {
|
||||
this.loadingReceipts = true;
|
||||
this.cashierService
|
||||
.getShareAdvanceByVoucher(trimmed, glAccountNo, this.sharesAccountData.clientId)
|
||||
.getShareCollectionReceipts(this.data.clientId)
|
||||
.pipe(
|
||||
catchError((err: any) => {
|
||||
this.voucherError = this.readError(err, `Voucher ${trimmed} could not be found.`);
|
||||
this.lookingUpVoucher = false;
|
||||
this.errorMsg = this.readError(err, 'Could not load the available receipts.');
|
||||
this.loadingReceipts = false;
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
.subscribe((res: any) => {
|
||||
this.lookingUpVoucher = false;
|
||||
this.loadingReceipts = false;
|
||||
if (!res) {
|
||||
return;
|
||||
}
|
||||
if (Number(res.client_id) !== Number(this.sharesAccountData.clientId)) {
|
||||
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.
|
||||
this.vouchers = [
|
||||
res,
|
||||
...this.vouchers.filter((v) => v.id !== res.id)
|
||||
this.available = [
|
||||
...(res.own || []).map((row: any) => receiptFromOwn(row)),
|
||||
...(res.rows || []).map((row: any) => receiptFromUnattached(row))
|
||||
];
|
||||
if (!this.isVoucherSelected(res.id)) {
|
||||
this.selectedVoucherIds = [
|
||||
...this.selectedVoucherIds,
|
||||
res.id
|
||||
];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Chooses the receipt funding this tranche, attaching it to this member first if it is still only
|
||||
* a voucher number.
|
||||
*
|
||||
* A receipt paid straight into a GL account names no member — a GL account is nobody's account —
|
||||
* so it has to be attributed before it can fund anything. That attribution is exactly the
|
||||
* by-voucher lookup a hand-typed number already runs; doing it on the pick makes it one deliberate
|
||||
* choice against a member on screen, rather than an Attach in one table and a tick in another.
|
||||
*/
|
||||
pick(option: ReceiptOption): void {
|
||||
if (this.attaching || this.submitting) {
|
||||
return;
|
||||
}
|
||||
if (this.receipt?.key === option.key) {
|
||||
this.receipt = null;
|
||||
return;
|
||||
}
|
||||
if (option.collectionId !== null) {
|
||||
this.receipt = option;
|
||||
return;
|
||||
}
|
||||
|
||||
this.attaching = option.key;
|
||||
this.errorMsg = '';
|
||||
this.findVoucher(option.voucherNo, option.glAccountNo).subscribe((res) => {
|
||||
this.attaching = null;
|
||||
if (!res) {
|
||||
return;
|
||||
}
|
||||
// Attached now, so it takes its real row's place in the pool -- picking it again must never
|
||||
// run the attach a second time.
|
||||
const attached = receiptFromOwn(res);
|
||||
this.available = this.available.map((entry) => (entry.key === option.key ? attached : entry));
|
||||
this.receipt = attached;
|
||||
});
|
||||
}
|
||||
|
||||
clearReceipt(): void {
|
||||
this.receipt = null;
|
||||
}
|
||||
|
||||
/** What the chosen receipt is still missing, zero once it covers the tranche. */
|
||||
get shortfall(): number {
|
||||
const short = this.amount - Number(this.receipt?.amount || 0);
|
||||
return this.receipt && short > 0 ? short : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the member's savings can cover this tranche.
|
||||
*
|
||||
@@ -331,104 +261,215 @@ export class ApplySharesComponent implements OnInit {
|
||||
* submission will be valued at. Deciding it in the browser would let the two drift apart.
|
||||
*/
|
||||
private checkSavings(): void {
|
||||
const shares = Number(this.applySharesForm.get('requestedShares').value) || 0;
|
||||
const shares = Number(this.requestedShares) || 0;
|
||||
this.checkingSavings = true;
|
||||
this.sharesService
|
||||
.getAdditionalSharesSavingsEligibility(this.accountId, shares)
|
||||
.getAdditionalSharesSavingsEligibility(String(this.data.shareAccountId), shares)
|
||||
.pipe(catchError(() => of(null)))
|
||||
.subscribe((res: any) => {
|
||||
this.checkingSavings = false;
|
||||
if (!res) {
|
||||
this.savingsEligibility = {
|
||||
eligible: false,
|
||||
reason: 'Could not check the savings account linked to this share account.',
|
||||
savingsAccountNo: null,
|
||||
savingsBalance: null
|
||||
};
|
||||
return;
|
||||
}
|
||||
this.savingsEligibility = {
|
||||
eligible: !!res.eligible,
|
||||
reason: res.reason ?? null,
|
||||
savingsAccountNo: res.savingsAccountNo ?? null,
|
||||
savingsBalance: res.savingsBalance == null ? null : Number(res.savingsBalance)
|
||||
};
|
||||
this.savings = res
|
||||
? {
|
||||
eligible: !!res.eligible,
|
||||
reason: res.reason ?? null,
|
||||
savingsAccountNo: res.savingsAccountNo ?? null,
|
||||
savingsBalance: res.savingsBalance == null ? null : Number(res.savingsBalance)
|
||||
}
|
||||
: {
|
||||
eligible: false,
|
||||
reason: 'Could not check the savings account linked to this share account.',
|
||||
savingsAccountNo: null,
|
||||
savingsBalance: null
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the chosen funding route is ready — receipts that between them cover the tranche, or
|
||||
* savings that can cover it.
|
||||
* Whether the chosen funding route is ready — a receipt that covers the tranche, or savings that
|
||||
* can.
|
||||
*
|
||||
* The total is checked here rather than only on submit, so a part-paid tranche is visibly short
|
||||
* at the counter instead of being refused after the member has left.
|
||||
* Checked here rather than only on submit, so a part-paid tranche is visibly short at the counter
|
||||
* instead of being refused after the member has left.
|
||||
*/
|
||||
get fundingReady(): boolean {
|
||||
if (this.paymentMode === 'CASH') {
|
||||
return this.selectedVoucherIds.length > 0 && this.selectedVoucherTotal >= this.amount;
|
||||
if (this.funding === 'CASH') {
|
||||
return !!this.receipt && this.shortfall === 0;
|
||||
}
|
||||
if (this.paymentMode === 'SAVINGS') {
|
||||
return !!this.savingsEligibility?.eligible;
|
||||
if (this.funding === 'SAVINGS') {
|
||||
return !!this.savings?.eligible;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** The shortfall to tell the member about, or zero when the ticked receipts cover the tranche. */
|
||||
get voucherShortfall(): number {
|
||||
const short = this.amount - this.selectedVoucherTotal;
|
||||
return short > 0 ? short : 0;
|
||||
get canSubmit(): boolean {
|
||||
return (
|
||||
this.amount > 0 &&
|
||||
!!this.requestedDate &&
|
||||
!!this.resolutionNumber.trim() &&
|
||||
!!this.resolutionDate &&
|
||||
this.fundingReady &&
|
||||
!this.submitting
|
||||
);
|
||||
}
|
||||
|
||||
get canSubmit(): boolean {
|
||||
return this.applySharesForm.valid && this.amount > 0 && this.fundingReady && !this.submitting;
|
||||
// ── Voucher search, for a receipt the list does not carry ───────────────────────────────────
|
||||
|
||||
searchOpen = false;
|
||||
searchVoucherNo = '';
|
||||
searchGlAccountNo = '';
|
||||
searching = false;
|
||||
|
||||
/** GL accounts, for the search only — a voucher is found by number *and* the account it sits in. */
|
||||
glAccounts: any[] = [];
|
||||
loadingGlAccounts = true;
|
||||
|
||||
openSearch(): void {
|
||||
this.searchOpen = true;
|
||||
this.searchVoucherNo = '';
|
||||
this.searchGlAccountNo = this.glAccounts.length === 1 ? this.glAccounts[0].gl_code : '';
|
||||
this.errorMsg = '';
|
||||
}
|
||||
|
||||
closeSearch(): void {
|
||||
this.searchOpen = false;
|
||||
this.errorMsg = '';
|
||||
}
|
||||
|
||||
runSearch(): void {
|
||||
const voucher = this.searchVoucherNo.trim();
|
||||
const glAccountNo = this.searchGlAccountNo.trim();
|
||||
// Both fields are genuinely needed: a blank GL account here means nothing has been chosen yet,
|
||||
// not that none is required.
|
||||
if (!voucher || !glAccountNo || this.searching) {
|
||||
return;
|
||||
}
|
||||
this.searching = true;
|
||||
this.errorMsg = '';
|
||||
this.findVoucher(voucher, glAccountNo).subscribe((res) => {
|
||||
this.searching = false;
|
||||
if (!res) {
|
||||
return;
|
||||
}
|
||||
const found = receiptFromOwn(res);
|
||||
this.available = [
|
||||
found,
|
||||
...this.available.filter((entry) => entry.key !== found.key && entry.voucherNo !== found.voucherNo)
|
||||
];
|
||||
this.receipt = found;
|
||||
this.searchOpen = false;
|
||||
});
|
||||
}
|
||||
|
||||
private findVoucher(voucher: string, glAccountNo: string) {
|
||||
// No client-match check: any receipt can fund any share account, regardless of which member it
|
||||
// was first recorded against.
|
||||
return this.cashierService.getShareAdvanceByVoucher(voucher, glAccountNo, this.data.clientId).pipe(
|
||||
catchError((err: any) => {
|
||||
this.errorMsg = this.readError(err, `Voucher ${voucher} could not be found in that GL account.`);
|
||||
return of(null);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// ── A receipt's own GL entries ──────────────────────────────────────────────────────────────
|
||||
|
||||
viewing: ReceiptOption | null = null;
|
||||
|
||||
/** The receipt's own real Dr/Cr legs, read under its own transaction id — never the GL account
|
||||
* picked in the search box, which is unrelated screen state naming a different account. */
|
||||
viewingGlEntries: any[] | null = null;
|
||||
loadingGlEntries = false;
|
||||
|
||||
openDetail(option: ReceiptOption, event: Event): void {
|
||||
event.stopPropagation();
|
||||
if (option.collectionId === null) {
|
||||
return;
|
||||
}
|
||||
this.viewing = option;
|
||||
this.viewingGlEntries = null;
|
||||
this.loadingGlEntries = true;
|
||||
this.cashierService
|
||||
.getShareAdvanceGlEntries(option.collectionId)
|
||||
.pipe(catchError(() => of(null)))
|
||||
.subscribe((res: any) => {
|
||||
this.viewingGlEntries = res?.entries || [];
|
||||
this.loadingGlEntries = false;
|
||||
});
|
||||
}
|
||||
|
||||
closeDetail(): void {
|
||||
this.viewing = null;
|
||||
this.viewingGlEntries = null;
|
||||
}
|
||||
|
||||
// ── Submission ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Records the request. Nothing is issued here — the tranche sits pending until it is approved,
|
||||
* which is what moves the money out of Sundry into share capital.
|
||||
*/
|
||||
submit() {
|
||||
submit(): void {
|
||||
if (!this.canSubmit) {
|
||||
return;
|
||||
}
|
||||
this.submitting = true;
|
||||
this.errorMsg = '';
|
||||
|
||||
const formValue = this.applySharesForm.value;
|
||||
const locale = this.settingsService.language.code;
|
||||
const dateFormat = this.settingsService.dateFormat;
|
||||
|
||||
const data: any = {
|
||||
requestedShares: formValue.requestedShares,
|
||||
requestedDate: this.dateUtils.formatDate(formValue.requestedDate, dateFormat),
|
||||
unitPrice: this.applySharesForm.get('unitPrice').value,
|
||||
resolutionNumber: formValue.resolutionNumber,
|
||||
meetingDate: this.dateUtils.formatDate(formValue.resolutionDate, dateFormat),
|
||||
fundingSource: formValue.paymentMode,
|
||||
const payload: any = {
|
||||
requestedShares: this.requestedShares,
|
||||
requestedDate: this.dateUtils.formatDate(this.dateOf(this.requestedDate), dateFormat),
|
||||
unitPrice: this.unitPrice,
|
||||
resolutionNumber: this.resolutionNumber.trim(),
|
||||
meetingDate: this.dateUtils.formatDate(this.dateOf(this.resolutionDate), dateFormat),
|
||||
fundingSource: this.funding,
|
||||
dateFormat,
|
||||
locale
|
||||
locale: this.settingsService.language.code
|
||||
};
|
||||
if (formValue.paymentMode === 'CASH') {
|
||||
// First receipt leads, the rest ride along; the server settles them as one funding.
|
||||
const [
|
||||
leadId,
|
||||
...additionalCollectionIds
|
||||
] = this.selectedVoucherIds;
|
||||
data.advanceCollectionId = leadId;
|
||||
if (additionalCollectionIds.length > 0) {
|
||||
data.additionalCollectionIds = additionalCollectionIds;
|
||||
}
|
||||
if (this.funding === 'CASH' && this.receipt) {
|
||||
payload.advanceCollectionId = this.receipt.collectionId;
|
||||
}
|
||||
|
||||
this.sharesService.executeSharesAccountCommand(this.accountId, 'applyadditionalshares', data).subscribe({
|
||||
next: () => {
|
||||
this.router.navigate(['../../'], { relativeTo: this.route });
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.submitting = false;
|
||||
this.errorMsg = this.readError(err, 'The additional-share request could not be submitted.');
|
||||
}
|
||||
});
|
||||
this.sharesService
|
||||
.executeSharesAccountCommand(String(this.data.shareAccountId), 'applyadditionalshares', payload)
|
||||
.subscribe({
|
||||
next: (res: any) => {
|
||||
this.dialogRef.close(res || true);
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.submitting = false;
|
||||
this.errorMsg = this.readError(err, 'The additional-share request could not be submitted.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** The GL account a receipt sits in, named from the list the voucher search already loaded. */
|
||||
glLabel(option: ReceiptOption): string {
|
||||
return receiptGlLabel(option, this.glAccounts);
|
||||
}
|
||||
|
||||
/** A Date as the yyyy-MM-dd a native date input reads, in local time. */
|
||||
private isoOf(date: Date): string {
|
||||
const local = new Date(date);
|
||||
return `${local.getFullYear()}-${String(local.getMonth() + 1).padStart(2, '0')}-${String(local.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A yyyy-MM-dd back into a local Date.
|
||||
*
|
||||
* Built from the parts rather than {@code new Date(iso)}, which reads the string as UTC midnight
|
||||
* and so lands on the previous day for anyone west of Greenwich — a request dated a day early is
|
||||
* not something a date picker should be able to produce.
|
||||
*/
|
||||
private dateOf(iso: string): Date {
|
||||
const [
|
||||
year,
|
||||
month,
|
||||
day
|
||||
] = iso.split('-').map(Number);
|
||||
return new Date(year, month - 1, day);
|
||||
}
|
||||
|
||||
private readError(err: any, fallback: string): string {
|
||||
|
||||
@@ -21,9 +21,6 @@
|
||||
@if (actions['Undo Approval']) {
|
||||
<mifosx-undo-approval-shares-account></mifosx-undo-approval-shares-account>
|
||||
}
|
||||
@if (actions['Apply Additional Shares']) {
|
||||
<mifosx-apply-shares></mifosx-apply-shares>
|
||||
}
|
||||
@if (actions['Redeem Shares']) {
|
||||
<mifosx-redeem-shares></mifosx-redeem-shares>
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ import { RejectSharesAccountComponent } from './reject-shares-account/reject-sha
|
||||
import { CloseSharesAccountComponent } from './close-shares-account/close-shares-account.component';
|
||||
import { ActivateSharesAccountComponent } from './activate-shares-account/activate-shares-account.component';
|
||||
import { UndoApprovalSharesAccountComponent } from './undo-approval-shares-account/undo-approval-shares-account.component';
|
||||
import { ApplySharesComponent } from './apply-shares/apply-shares.component';
|
||||
import { RedeemSharesComponent } from './redeem-shares/redeem-shares.component';
|
||||
import { ApproveSharesComponent } from './approve-shares/approve-shares.component';
|
||||
import { RejectSharesComponent } from './reject-shares/reject-shares.component';
|
||||
@@ -37,7 +36,6 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
CloseSharesAccountComponent,
|
||||
ActivateSharesAccountComponent,
|
||||
UndoApprovalSharesAccountComponent,
|
||||
ApplySharesComponent,
|
||||
RedeemSharesComponent,
|
||||
ApproveSharesComponent,
|
||||
RejectSharesComponent
|
||||
@@ -55,7 +53,6 @@ export class SharesAccountActionsComponent {
|
||||
Close: boolean;
|
||||
Activate: boolean;
|
||||
'Undo Approval': boolean;
|
||||
'Apply Additional Shares': boolean;
|
||||
'Redeem Shares': boolean;
|
||||
'Approve Additional Shares': boolean;
|
||||
'Reject Additional Shares': boolean;
|
||||
@@ -65,7 +62,6 @@ export class SharesAccountActionsComponent {
|
||||
Close: false,
|
||||
Activate: false,
|
||||
'Undo Approval': false,
|
||||
'Apply Additional Shares': false,
|
||||
'Redeem Shares': false,
|
||||
'Approve Additional Shares': false,
|
||||
'Reject Additional Shares': false
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user