done
This commit is contained in:
@@ -62,6 +62,7 @@ import { RbiCircularsModule } from './rbi-circulars/rbi-circulars.module';
|
||||
import { CfcfrmsModule } from './cfcfrms/cfcfrms.module';
|
||||
import { BankCalendarModule } from './bank-calendar/bank-calendar.module';
|
||||
import { DeafModule } from './deaf/deaf.module';
|
||||
import { SavingsDailyInterestModule } from './savings-daily-interest/savings-daily-interest.module';
|
||||
import { DicgcModule } from './dicgc/dicgc.module';
|
||||
import { FiuModule } from './fiu/fiu.module';
|
||||
import { GlReportsModule } from './gl-reports/gl-reports.module';
|
||||
@@ -174,6 +175,7 @@ export function HttpLoaderFactory(http: HttpClient) {
|
||||
CfcfrmsModule,
|
||||
BankCalendarModule,
|
||||
DeafModule,
|
||||
SavingsDailyInterestModule,
|
||||
DicgcModule,
|
||||
FiuModule,
|
||||
GlReportsModule,
|
||||
|
||||
@@ -21,6 +21,8 @@ import { ReconciliationComponent } from './reconciliation/reconciliation.compone
|
||||
import { CashHistoryComponent } from './cash-history/cash-history.component';
|
||||
import { DayBookComponent } from './day-book/day-book.component';
|
||||
import { VoucherExceptionsComponent } from './voucher-exceptions/voucher-exceptions.component';
|
||||
import { VoucherViewComponent } from './voucher-view/voucher-view.component';
|
||||
import { VoucherReportComponent } from './voucher-report/voucher-report.component';
|
||||
import { BranchDayComponent } from './branch-day/branch-day.component';
|
||||
import { VaultPositionComponent } from './vault-position/vault-position.component';
|
||||
import { AccountRegisterComponent } from './account-register/account-register.component';
|
||||
@@ -221,6 +223,16 @@ const routes: Routes = [
|
||||
component: VoucherExceptionsComponent,
|
||||
data: { title: 'Voucher Exceptions', breadcrumb: 'Voucher Exceptions' }
|
||||
},
|
||||
{
|
||||
path: 'voucher',
|
||||
component: VoucherViewComponent,
|
||||
data: { title: 'Voucher', breadcrumb: 'Voucher' }
|
||||
},
|
||||
{
|
||||
path: 'voucher-report',
|
||||
component: VoucherReportComponent,
|
||||
data: { title: 'Voucher Report', breadcrumb: 'Voucher Report' }
|
||||
},
|
||||
{
|
||||
path: 'closing-report',
|
||||
component: ClosingCashReportComponent,
|
||||
|
||||
@@ -186,6 +186,35 @@ export class CashierService {
|
||||
return this.http.get('/vouchers/exceptions', { params });
|
||||
}
|
||||
|
||||
/**
|
||||
* The vouchers raised over a period, one row each, with what was debited and what credited.
|
||||
*
|
||||
* Who appears is decided by the server from the caller's own roles — a cashier sees only their
|
||||
* own work whatever they ask for — so `username` is a filter for those allowed to use it, not a
|
||||
* way to choose whose report this is.
|
||||
*/
|
||||
voucherReport(from: string, to: string, username?: string, voucherType?: string): Observable<any> {
|
||||
let params = new HttpParams().set('from', from).set('to', to);
|
||||
if (username) {
|
||||
params = params.set('username', username);
|
||||
}
|
||||
if (voucherType) {
|
||||
params = params.set('voucherType', voucherType);
|
||||
}
|
||||
return this.http.get('/vouchers/report', { params });
|
||||
}
|
||||
|
||||
/**
|
||||
* One voucher and the debit and credit sides it is made of — what a printed voucher shows.
|
||||
*
|
||||
* The voucher number travels as a query parameter, not a path segment: numbers are formatted
|
||||
* CR/2708/0001 and the server rejects the encoded slashes in a path.
|
||||
*/
|
||||
voucherDetail(voucherNo: string): Observable<any> {
|
||||
const params = new HttpParams().set('voucherNo', voucherNo);
|
||||
return this.http.get('/vouchers/detail', { params });
|
||||
}
|
||||
|
||||
/** Closes an exception. Manager / head cashier only, and a supporting document must exist. */
|
||||
resolveVoucherException(voucherNo: string, resolutionStatus: string, resolutionNotes: string): Observable<any> {
|
||||
return this.http.post('/vouchers/exceptions/resolve', { voucherNo, resolutionStatus, resolutionNotes });
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
<!--
|
||||
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 class="pg-head vr-no-print">
|
||||
<div>
|
||||
<div class="pg-eyebrow">CASHIER · REPORT</div>
|
||||
<h1 class="pg-title">Voucher Report</h1>
|
||||
<p class="pg-sub">
|
||||
Every voucher raised over a day or a month — the number, what was debited, what was credited, and who raised it.
|
||||
</p>
|
||||
</div>
|
||||
<div class="pg-toolbar">
|
||||
<label class="pg-field">
|
||||
<span>Period</span>
|
||||
<select [(ngModel)]="period" (change)="load()">
|
||||
<option value="DAY">Day</option>
|
||||
<option value="MONTH">Month</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="pg-field">
|
||||
<span>{{ period === 'DAY' ? 'Date' : 'Month of' }}</span>
|
||||
<input type="date" [(ngModel)]="anchor" (change)="load()" />
|
||||
</label>
|
||||
@if (report?.canSeeAllUsers) {
|
||||
<label class="pg-field">
|
||||
<span>User</span>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="usernameFilter"
|
||||
(keyup.enter)="load()"
|
||||
placeholder="All users"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</label>
|
||||
}
|
||||
<button type="button" class="pg-btn pg-btn--primary" (click)="load()" [disabled]="loading">
|
||||
<mat-icon>refresh</mat-icon> Run
|
||||
</button>
|
||||
@if (report && report.count > 0) {
|
||||
<button type="button" class="pg-btn" (click)="print()">
|
||||
<mat-icon>print</mat-icon> Print
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (error) {
|
||||
<div class="pg-alert pg-alert--error vr-no-print">{{ error }}</div>
|
||||
}
|
||||
|
||||
@if (loading) {
|
||||
<div class="pg-empty vr-no-print">Loading…</div>
|
||||
}
|
||||
|
||||
@if (report && !loading) {
|
||||
<div class="vr-period">
|
||||
{{ report.from | date: 'dd MMM yyyy' }}
|
||||
@if (report.from !== report.to) {
|
||||
— {{ report.to | date: 'dd MMM yyyy' }}
|
||||
}
|
||||
· {{ report.count }} voucher{{ report.count === 1 ? '' : 's' }}
|
||||
· ₹{{ report.totalAmount | number: '1.2-2' }}
|
||||
@if (report.username) {
|
||||
· {{ report.username }}
|
||||
}
|
||||
<!-- Said plainly rather than left to be inferred from a missing filter: a cashier should know
|
||||
this is their own work and not the branch's. -->
|
||||
@if (!report.canSeeAllUsers) {
|
||||
<span class="vr-scope-note">Your own vouchers</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (report.count === 0) {
|
||||
<div class="pg-empty">No vouchers were raised in this period.</div>
|
||||
} @else {
|
||||
<div class="vr-summaries">
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">By type</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th class="num">Count</th>
|
||||
<th class="num">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of byType; track row.type) {
|
||||
<tr>
|
||||
<td>{{ row.type }}</td>
|
||||
<td class="num">{{ row.count }}</td>
|
||||
<td class="num">{{ row.total | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Only worth showing to someone who can see more than one person's work. -->
|
||||
@if (report.canSeeAllUsers) {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">By user</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th class="num">Count</th>
|
||||
<th class="num">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of byUser; track row.username) {
|
||||
<tr>
|
||||
<td>{{ row.username }}</td>
|
||||
<td class="num">{{ row.count }}</td>
|
||||
<td class="num">{{ row.total | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Vouchers</div>
|
||||
<table class="pg-table vr-rows">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Voucher No</th>
|
||||
<th>Type</th>
|
||||
<th>Debit</th>
|
||||
<th>Credit</th>
|
||||
<th class="num">Amount</th>
|
||||
<th>User</th>
|
||||
<th>Time</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of report.vouchers; track row.voucherNo) {
|
||||
<tr>
|
||||
<td>
|
||||
<a class="vr-link" [routerLink]="['/cashier/voucher']" [queryParams]="{ voucherNo: row.voucherNo }">
|
||||
{{ row.voucherNo }}
|
||||
</a>
|
||||
@if (row.verificationStatus === 'EXCEPTION') {
|
||||
<mat-icon class="vr-flag" title="An exception was raised against this voucher">error_outline</mat-icon>
|
||||
}
|
||||
</td>
|
||||
<td>{{ row.voucherType || '—' }}</td>
|
||||
<td>
|
||||
<div class="vr-party">{{ row.debitParty || '—' }}</div>
|
||||
@if (row.debitAccount) {
|
||||
<div class="vr-acct">{{ row.debitAccount }}</div>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<div class="vr-party">{{ row.creditParty || '—' }}</div>
|
||||
@if (row.creditAccount) {
|
||||
<div class="vr-acct">{{ row.creditAccount }}</div>
|
||||
}
|
||||
<!-- A DD with charges, or a compound journal, has sides this row does not show. -->
|
||||
@if (row.extraLines > 0) {
|
||||
<div class="vr-more">+{{ row.extraLines }} more on voucher</div>
|
||||
}
|
||||
</td>
|
||||
<td class="num">{{ row.amount | number: '1.2-2' }}</td>
|
||||
<td>{{ row.username || '—' }}</td>
|
||||
<td>{{ row.createdAt | date: 'dd MMM, HH:mm' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
/* Page chrome, cards and tables come from cashier-shared.scss. Only the register itself is here. */
|
||||
|
||||
.vr-period {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 14px;
|
||||
font-size: 13px;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.vr-scope-note {
|
||||
padding: 2px 8px;
|
||||
background: #eff6ff;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 999px;
|
||||
color: #1e40af;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* The two summaries sit side by side where there is room and stack where there is not, so a
|
||||
manager reads the totals before the rows on any screen. */
|
||||
.vr-summaries {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 14px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.vr-rows {
|
||||
.vr-link {
|
||||
color: #1d4ed8;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.vr-flag {
|
||||
margin-left: 4px;
|
||||
font-size: 15px;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
color: #b91c1c;
|
||||
vertical-align: text-bottom;
|
||||
}
|
||||
|
||||
.vr-party {
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.vr-acct,
|
||||
.vr-more {
|
||||
margin-top: 2px;
|
||||
font-size: 11px;
|
||||
color: #6b7280;
|
||||
}
|
||||
}
|
||||
|
||||
/* Printing a report should put the register on paper — the filters are how you reached it. */
|
||||
@media print {
|
||||
.vr-no-print {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.vr-link {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* 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 { RouterLink } from '@angular/router';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { catchError, of } from 'rxjs';
|
||||
import { CashierService } from '../cashier.service';
|
||||
|
||||
/** One voucher as the register shows it: the number, the two sides, and who raised it. */
|
||||
interface VoucherRow {
|
||||
voucherNo: string;
|
||||
voucherType: string | null;
|
||||
amount: number | null;
|
||||
createdAt: string | null;
|
||||
userId: number | null;
|
||||
username: string | null;
|
||||
branchName: string | null;
|
||||
verificationStatus: string | null;
|
||||
debitParty: string | null;
|
||||
debitAccount: string | null;
|
||||
creditParty: string | null;
|
||||
creditAccount: string | null;
|
||||
/** Sides beyond the one debit and one credit shown — a DD's charges, a compound journal. */
|
||||
extraLines: number;
|
||||
}
|
||||
|
||||
interface VoucherReport {
|
||||
from: string;
|
||||
to: string;
|
||||
username: string | null;
|
||||
/** Set by the server from the caller's roles; a cashier is never offered the user filter. */
|
||||
canSeeAllUsers: boolean;
|
||||
vouchers: VoucherRow[];
|
||||
count: number;
|
||||
totalAmount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The vouchers raised over a day or a month, one row each.
|
||||
*
|
||||
* A cashier sees their own work; a clerk, head cashier or manager sees everyone's and can narrow to
|
||||
* one user. That is decided by the server, not here — this screen only shows or hides the filter.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-voucher-report',
|
||||
templateUrl: './voucher-report.component.html',
|
||||
styleUrls: [
|
||||
'../cashier-shared.scss',
|
||||
'./voucher-report.component.scss'
|
||||
],
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
RouterLink,
|
||||
MatIconModule
|
||||
]
|
||||
})
|
||||
export class VoucherReportComponent implements OnInit {
|
||||
private service = inject(CashierService);
|
||||
|
||||
/** Day is the working default; a month is what you ask for at a close. */
|
||||
period: 'DAY' | 'MONTH' = 'DAY';
|
||||
/** Anchor date — the day itself, or any day inside the month wanted. */
|
||||
anchor = this.todayIso();
|
||||
usernameFilter = '';
|
||||
typeFilter = '';
|
||||
|
||||
report: VoucherReport | null = null;
|
||||
loading = false;
|
||||
error: string | null = null;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
const { from, to } = this.range();
|
||||
this.service
|
||||
.voucherReport(from, to, this.usernameFilter.trim() || undefined, this.typeFilter.trim() || undefined)
|
||||
.pipe(
|
||||
catchError((err: any) => {
|
||||
this.error =
|
||||
err?.error?.errors?.[0]?.defaultUserMessage ??
|
||||
err?.error?.developerMessage ??
|
||||
'The voucher report could not be loaded.';
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
.subscribe((res: VoucherReport | null) => {
|
||||
this.loading = false;
|
||||
this.report = res;
|
||||
});
|
||||
}
|
||||
|
||||
/** The period the anchor date names: that day, or the whole calendar month around it. */
|
||||
private range(): { from: string; to: string } {
|
||||
if (this.period === 'DAY') {
|
||||
return { from: this.anchor, to: this.anchor };
|
||||
}
|
||||
const [
|
||||
year,
|
||||
month
|
||||
] = this.anchor.split('-').map(Number);
|
||||
// Day 0 of the next month is the last day of this one, which is how a 28/30/31-day month is
|
||||
// ended correctly without a table of month lengths.
|
||||
const lastDay = new Date(year, month, 0).getDate();
|
||||
const mm = String(month).padStart(2, '0');
|
||||
return { from: `${year}-${mm}-01`, to: `${year}-${mm}-${String(lastDay).padStart(2, '0')}` };
|
||||
}
|
||||
|
||||
private todayIso(): string {
|
||||
const now = new Date();
|
||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/** Totals per user, for the summary a manager reads before the rows. */
|
||||
get byUser(): { username: string; count: number; total: number }[] {
|
||||
const rows = this.report?.vouchers ?? [];
|
||||
const tally = new Map<string, { username: string; count: number; total: number }>();
|
||||
for (const row of rows) {
|
||||
const key = row.username ?? '—';
|
||||
const entry = tally.get(key) ?? { username: key, count: 0, total: 0 };
|
||||
entry.count += 1;
|
||||
entry.total += row.amount ?? 0;
|
||||
tally.set(key, entry);
|
||||
}
|
||||
return [...tally.values()].sort((a, b) => b.total - a.total);
|
||||
}
|
||||
|
||||
/** Totals per voucher type — deposits against withdrawals against journals. */
|
||||
get byType(): { type: string; count: number; total: number }[] {
|
||||
const rows = this.report?.vouchers ?? [];
|
||||
const tally = new Map<string, { type: string; count: number; total: number }>();
|
||||
for (const row of rows) {
|
||||
const key = row.voucherType ?? '—';
|
||||
const entry = tally.get(key) ?? { type: key, count: 0, total: 0 };
|
||||
entry.count += 1;
|
||||
entry.total += row.amount ?? 0;
|
||||
tally.set(key, entry);
|
||||
}
|
||||
return [...tally.values()].sort((a, b) => b.total - a.total);
|
||||
}
|
||||
|
||||
print(): void {
|
||||
window.print();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="pg">
|
||||
<header class="pg-head vv-no-print">
|
||||
<div>
|
||||
<div class="pg-eyebrow">CASHIER · VOUCHER</div>
|
||||
<h1 class="pg-title">Voucher</h1>
|
||||
<p class="pg-sub">
|
||||
Look up a voucher by its number to see the entry behind it — which accounts were debited, which were credited,
|
||||
and for how much.
|
||||
</p>
|
||||
</div>
|
||||
<div class="pg-toolbar">
|
||||
<label class="pg-field">
|
||||
<span>Voucher No</span>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="voucherNo"
|
||||
(keyup.enter)="search()"
|
||||
placeholder="CR/2708/0001"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</label>
|
||||
<button type="button" class="pg-btn pg-btn--primary" (click)="search()" [disabled]="loading">
|
||||
<mat-icon>search</mat-icon> Find
|
||||
</button>
|
||||
@if (voucher) {
|
||||
<button type="button" class="pg-btn" (click)="print()">
|
||||
<mat-icon>print</mat-icon> Print
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (error) {
|
||||
<div class="pg-alert pg-alert--error vv-no-print">{{ error }}</div>
|
||||
}
|
||||
|
||||
@if (loading) {
|
||||
<div class="pg-empty vv-no-print">Loading…</div>
|
||||
}
|
||||
|
||||
@if (voucher && !loading) {
|
||||
<div class="pg-card vv-slip">
|
||||
<div class="vv-slip-head">
|
||||
<div>
|
||||
<div class="vv-no">{{ voucher.voucherNo }}</div>
|
||||
<div class="vv-meta">
|
||||
{{ voucher.voucherType || '—' }}
|
||||
@if (voucher.branchName) {
|
||||
· {{ voucher.branchName }}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="vv-slip-head-right">
|
||||
<div class="vv-amount">₹{{ voucher.amount | number: '1.2-2' }}</div>
|
||||
<div class="vv-meta">
|
||||
@if (voucher.createdAt) {
|
||||
{{ voucher.createdAt | date: 'dd MMM yyyy, HH:mm' }}
|
||||
}
|
||||
@if (voucher.createdBy) {
|
||||
· {{ voucher.createdBy }}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (voucher.verificationStatus) {
|
||||
<div class="vv-status" [class.vv-status--exception]="voucher.verificationStatus === 'EXCEPTION'">
|
||||
<mat-icon>{{ voucher.verificationStatus === 'EXCEPTION' ? 'error_outline' : 'verified' }}</mat-icon>
|
||||
{{ voucher.verificationStatus === 'EXCEPTION' ? 'Exception raised against this voucher' : 'Verified against the slip' }}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- A voucher raised before its sides were recorded: the number is valid, the posting behind it
|
||||
simply was never captured, so it is shown as an older record rather than as an error. -->
|
||||
@if (hasNoLines) {
|
||||
<div class="pg-empty">
|
||||
No debit or credit detail was recorded for this voucher. It was raised before the entry sides were kept.
|
||||
</div>
|
||||
} @else {
|
||||
<table class="pg-table vv-lines">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Particulars</th>
|
||||
<th>Account</th>
|
||||
<th class="num">Debit</th>
|
||||
<th class="num">Credit</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (line of debitLines; track line.lineNo) {
|
||||
<tr>
|
||||
<td>
|
||||
<div class="vv-party">{{ line.partyName || '—' }}</div>
|
||||
@if (line.description) {
|
||||
<div class="vv-desc">{{ line.description }}</div>
|
||||
}
|
||||
</td>
|
||||
<td>{{ line.accountNo || '—' }}</td>
|
||||
<td class="num">{{ line.amount | number: '1.2-2' }}</td>
|
||||
<td class="num">—</td>
|
||||
</tr>
|
||||
}
|
||||
@for (line of creditLines; track line.lineNo) {
|
||||
<tr>
|
||||
<td class="vv-indent">
|
||||
<div class="vv-party">{{ line.partyName || '—' }}</div>
|
||||
@if (line.description) {
|
||||
<div class="vv-desc">{{ line.description }}</div>
|
||||
}
|
||||
</td>
|
||||
<td>{{ line.accountNo || '—' }}</td>
|
||||
<td class="num">—</td>
|
||||
<td class="num">{{ line.amount | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="2">Total</td>
|
||||
<td class="num">{{ voucher.totalDebit | number: '1.2-2' }}</td>
|
||||
<td class="num">{{ voucher.totalCredit | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
|
||||
<!-- Stated rather than assumed: a voucher whose sides were written before the balance check
|
||||
existed, or repaired by hand, should read as unbalanced rather than pass as a valid slip. -->
|
||||
@if (!voucher.balanced) {
|
||||
<div class="pg-alert pg-alert--error">
|
||||
This voucher does not balance — debits and credits differ. Refer it to accounts before relying on it.
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
/* Page chrome and the table come from cashier-shared.scss. Only the slip itself is defined here. */
|
||||
|
||||
.vv-slip {
|
||||
max-width: 860px;
|
||||
}
|
||||
|
||||
.vv-slip-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding-bottom: 14px;
|
||||
margin-bottom: 14px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.vv-slip-head-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.vv-no {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.4px;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.vv-amount {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.vv-meta {
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.vv-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 14px;
|
||||
background: #ecfdf5;
|
||||
border: 1px solid #a7f3d0;
|
||||
border-radius: 8px;
|
||||
color: #065f46;
|
||||
font-size: 12px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
/* An exception is a warning about this voucher, not a state of it — it has to stop the eye. */
|
||||
&.vv-status--exception {
|
||||
background: #fef2f2;
|
||||
border-color: #fecaca;
|
||||
color: #991b1b;
|
||||
}
|
||||
}
|
||||
|
||||
.vv-lines {
|
||||
/* Credits sit under their debits the way they do on a written slip, so the indent carries the
|
||||
relationship that the DR/CR columns alone would leave to the reader to work out. */
|
||||
.vv-indent {
|
||||
padding-left: 28px;
|
||||
}
|
||||
|
||||
.vv-party {
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.vv-desc {
|
||||
margin-top: 2px;
|
||||
font-size: 11px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
tfoot td {
|
||||
font-weight: 600;
|
||||
border-top: 2px solid #e5e7eb;
|
||||
}
|
||||
}
|
||||
|
||||
/* Printing a voucher should put the slip on paper and nothing else — the search box and buttons are
|
||||
how you reached it, not part of it. */
|
||||
@media print {
|
||||
.vv-no-print {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.vv-slip {
|
||||
max-width: none;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* 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 { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { catchError, of } from 'rxjs';
|
||||
import { CashierService } from '../cashier.service';
|
||||
|
||||
/** One side of a voucher: a debit or a credit against a single account. */
|
||||
interface VoucherLine {
|
||||
lineNo: number;
|
||||
entryType: 'DR' | 'CR';
|
||||
glAccountId: number | null;
|
||||
savingsAccountId: number | null;
|
||||
loanAccountId: number | null;
|
||||
partyName: string | null;
|
||||
accountNo: string | null;
|
||||
amount: number;
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
interface Voucher {
|
||||
voucherNo: string;
|
||||
voucherType: string | null;
|
||||
referenceType: string | null;
|
||||
referenceId: number | null;
|
||||
amount: number | null;
|
||||
branchOfficeId: number | null;
|
||||
branchName: string | null;
|
||||
createdBy: string | null;
|
||||
createdAt: string | null;
|
||||
verificationStatus: string | null;
|
||||
lines: VoucherLine[];
|
||||
totalDebit: number;
|
||||
totalCredit: number;
|
||||
balanced: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* One voucher, as a slip: which accounts were debited, which credited, and for how much.
|
||||
*
|
||||
* Looked up by voucher number rather than listed, because that is how a voucher is reached — from a
|
||||
* paper slip in someone's hand, or from a number quoted on a statement.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-voucher-view',
|
||||
templateUrl: './voucher-view.component.html',
|
||||
styleUrls: [
|
||||
'../cashier-shared.scss',
|
||||
'./voucher-view.component.scss'
|
||||
],
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatIconModule
|
||||
]
|
||||
})
|
||||
export class VoucherViewComponent {
|
||||
private service = inject(CashierService);
|
||||
private route = inject(ActivatedRoute);
|
||||
private router = inject(Router);
|
||||
|
||||
voucherNo = '';
|
||||
voucher: Voucher | null = null;
|
||||
loading = false;
|
||||
/** Shown in place of the slip — a bad number and a server error read differently to the user. */
|
||||
error: string | null = null;
|
||||
|
||||
constructor() {
|
||||
// A voucher number in the URL means someone arrived from a link or a statement rather than by
|
||||
// typing, so the slip is fetched straight away instead of showing them an empty search box.
|
||||
const fromUrl = this.route.snapshot.queryParamMap.get('voucherNo');
|
||||
if (fromUrl) {
|
||||
this.voucherNo = fromUrl;
|
||||
this.search();
|
||||
}
|
||||
}
|
||||
|
||||
search(): void {
|
||||
const trimmed = (this.voucherNo || '').trim();
|
||||
if (!trimmed) {
|
||||
this.error = 'Enter a voucher number.';
|
||||
this.voucher = null;
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
// Kept in the URL so the slip can be linked to and survives a refresh.
|
||||
this.router.navigate([], {
|
||||
relativeTo: this.route,
|
||||
queryParams: { voucherNo: trimmed },
|
||||
replaceUrl: true
|
||||
});
|
||||
|
||||
this.service
|
||||
.voucherDetail(trimmed)
|
||||
.pipe(
|
||||
catchError((err: any) => {
|
||||
this.error =
|
||||
err?.error?.errors?.[0]?.defaultUserMessage ??
|
||||
err?.error?.developerMessage ??
|
||||
`Voucher ${trimmed} could not be loaded.`;
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
.subscribe((res: Voucher | null) => {
|
||||
this.loading = false;
|
||||
this.voucher = res;
|
||||
});
|
||||
}
|
||||
|
||||
/** Debits first, then credits — the order a slip is read in. */
|
||||
get debitLines(): VoucherLine[] {
|
||||
return (this.voucher?.lines ?? []).filter((l) => l.entryType === 'DR');
|
||||
}
|
||||
|
||||
get creditLines(): VoucherLine[] {
|
||||
return (this.voucher?.lines ?? []).filter((l) => l.entryType === 'CR');
|
||||
}
|
||||
|
||||
/**
|
||||
* A voucher raised before its sides were recorded. The voucher is real and its number valid — only
|
||||
* the posting behind it was never captured — so this reads as an older record, not a broken one.
|
||||
*/
|
||||
get hasNoLines(): boolean {
|
||||
return !!this.voucher && this.voucher.lines.length === 0;
|
||||
}
|
||||
|
||||
print(): void {
|
||||
window.print();
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,12 @@ export class DividendClaimRequestService {
|
||||
ddBeneficiaryName: string;
|
||||
ddPayableAtBranch: string;
|
||||
ddPayableAtCity: string;
|
||||
/**
|
||||
* The leaflet the clerk picked off the shelf. Sent so the draft is drawn on that physical
|
||||
* form — without it the server takes the next available one, and the number the clerk wrote
|
||||
* on the counterfoil would not be the number in the books.
|
||||
*/
|
||||
leafletId?: number;
|
||||
}
|
||||
): Observable<any> {
|
||||
return this.http.post('/clerk/dividend-claim-requests', {
|
||||
@@ -41,7 +47,8 @@ export class DividendClaimRequestService {
|
||||
ddApplyAmount: String(claim.ddApplyAmount),
|
||||
ddBeneficiaryName: claim.ddBeneficiaryName,
|
||||
ddPayableAtBranch: claim.ddPayableAtBranch,
|
||||
ddPayableAtCity: claim.ddPayableAtCity
|
||||
ddPayableAtCity: claim.ddPayableAtCity,
|
||||
leafletId: claim.leafletId ?? null
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -39,4 +39,5 @@ export class DividendDashboardService {
|
||||
const params = status && status !== 'ALL' ? { status } : {};
|
||||
return this.http.get('/clerk/dividend-dashboard/locks', { params });
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -46,14 +46,69 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Dividend Declarations — Every Dividend Ever Declared, Tracked to Completion</div>
|
||||
<div class="inv-form-grid" style="padding: 12px 16px">
|
||||
<label class="inv-form-field" style="align-self: end">
|
||||
<button type="button" class="pg-btn" [disabled]="declarationsLoading" (click)="loadDeclarations()">
|
||||
Refresh
|
||||
<!-- One list at a time. The three answer unrelated questions and a clerk is only ever in one of
|
||||
them; stacked, the one they came for was usually below the fold. Counts ride on the tabs so a
|
||||
list worth opening — a blocked member, a lock nearing forfeiture — is visible without opening
|
||||
it. -->
|
||||
<div class="dd-tabs" role="tablist">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
class="dd-tab"
|
||||
[class.dd-tab--active]="activeTab === 'declarations'"
|
||||
[attr.aria-selected]="activeTab === 'declarations'"
|
||||
(click)="activeTab = 'declarations'"
|
||||
>
|
||||
Declarations
|
||||
<span class="dd-tab__count">{{ declarations.length }}</span>
|
||||
</button>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
class="dd-tab"
|
||||
[class.dd-tab--active]="activeTab === 'overdue'"
|
||||
[attr.aria-selected]="activeTab === 'overdue'"
|
||||
(click)="activeTab = 'overdue'"
|
||||
>
|
||||
Overdue EMI
|
||||
<span class="dd-tab__count" [class.dd-tab__count--alert]="rows.length > 0">{{ rows.length }}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
class="dd-tab"
|
||||
[class.dd-tab--active]="activeTab === 'locks'"
|
||||
[attr.aria-selected]="activeTab === 'locks'"
|
||||
(click)="activeTab = 'locks'"
|
||||
>
|
||||
Lock History
|
||||
<span class="dd-tab__count">{{ locks.length }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="pg-card" [hidden]="activeTab !== 'declarations'">
|
||||
<div class="pos-section-label">Dividend Declarations — Every Dividend Ever Declared, Tracked to Completion</div>
|
||||
<div class="dd-toolbar">
|
||||
<div class="dd-search">
|
||||
<input
|
||||
type="search"
|
||||
class="dd-search__input"
|
||||
placeholder="Search product, period or status…"
|
||||
[(ngModel)]="declarationSearch"
|
||||
name="declarationSearch"
|
||||
/>
|
||||
@if (declarationSearch) {
|
||||
<button type="button" class="dd-search__clear" (click)="declarationSearch = ''" aria-label="Clear search">
|
||||
×
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<span class="dd-toolbar__count">
|
||||
{{ visibleDeclarations.length }} of {{ declarations.length }} declaration{{
|
||||
declarations.length === 1 ? '' : 's'
|
||||
}}
|
||||
</span>
|
||||
<button type="button" class="pg-btn" [disabled]="declarationsLoading" (click)="loadDeclarations()">Refresh</button>
|
||||
</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
@@ -75,8 +130,12 @@
|
||||
<tr>
|
||||
<td colspan="6" class="pg-empty">No dividends have been declared yet.</td>
|
||||
</tr>
|
||||
} @else if (visibleDeclarations.length === 0) {
|
||||
<tr>
|
||||
<td colspan="6" class="pg-empty">No declaration matches “{{ declarationSearch }}”.</td>
|
||||
</tr>
|
||||
} @else {
|
||||
@for (d of declarations; track d.declarationId) {
|
||||
@for (d of visibleDeclarations; track d.declarationId) {
|
||||
<tr>
|
||||
<td>{{ d.productName }}</td>
|
||||
<td>{{ d.periodStartDate }} → {{ d.periodEndDate }}</td>
|
||||
@@ -95,12 +154,27 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="pg-card" style="margin-top: 16px">
|
||||
<div class="pg-card" [hidden]="activeTab !== 'overdue'">
|
||||
<div class="pos-section-label">Dividend Dashboard — Locked Dividends for Members with Overdue EMI</div>
|
||||
<div class="inv-form-grid" style="padding: 12px 16px">
|
||||
<label class="inv-form-field" style="align-self: end">
|
||||
<div class="dd-toolbar">
|
||||
<div class="dd-search">
|
||||
<input
|
||||
type="search"
|
||||
class="dd-search__input"
|
||||
placeholder="Search member name or share account no…"
|
||||
[(ngModel)]="overdueSearch"
|
||||
name="overdueSearch"
|
||||
/>
|
||||
@if (overdueSearch) {
|
||||
<button type="button" class="dd-search__clear" (click)="overdueSearch = ''" aria-label="Clear search">
|
||||
×
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<span class="dd-toolbar__count">
|
||||
{{ visibleOverdueRows.length }} of {{ rows.length }} member{{ rows.length === 1 ? '' : 's' }}
|
||||
</span>
|
||||
<button type="button" class="pg-btn" [disabled]="loading" (click)="load()">Refresh</button>
|
||||
</label>
|
||||
</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
@@ -122,10 +196,19 @@
|
||||
<tr>
|
||||
<td colspan="6" class="pg-empty">No members with a locked dividend and an overdue EMI right now.</td>
|
||||
</tr>
|
||||
} @else {
|
||||
@for (row of rows; track row.lockId) {
|
||||
} @else if (visibleOverdueRows.length === 0) {
|
||||
<tr>
|
||||
<td>{{ row.clientName }}</td>
|
||||
<td colspan="6" class="pg-empty">No member matches “{{ overdueSearch }}”.</td>
|
||||
</tr>
|
||||
} @else {
|
||||
@for (row of visibleOverdueRows; track row.lockId) {
|
||||
<tr>
|
||||
<td>
|
||||
{{ row.clientName }}
|
||||
@if (row.shareAccountNo) {
|
||||
<div class="dd-account-no">{{ row.shareAccountNo }}</div>
|
||||
}
|
||||
</td>
|
||||
<td class="num">{{ row.lockedAmount | number: '1.2-2' }}</td>
|
||||
<td>{{ row.lockedDate }}</td>
|
||||
<td>{{ row.releaseDueDate }}</td>
|
||||
@@ -181,7 +264,7 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="pg-card" style="margin-top: 16px">
|
||||
<div class="pg-card" [hidden]="activeTab !== 'locks'">
|
||||
<div class="pos-section-label">All Dividend Locks — Full 3-Year Lock History</div>
|
||||
<div class="inv-form-grid" style="padding: 12px 16px; gap: 8px">
|
||||
<div class="dd-filter-group">
|
||||
@@ -196,9 +279,21 @@
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<label class="inv-form-field" style="align-self: end; margin-left: auto">
|
||||
<div class="dd-search" style="margin-left: auto">
|
||||
<input
|
||||
type="search"
|
||||
class="dd-search__input"
|
||||
placeholder="Search member name or share account no…"
|
||||
[(ngModel)]="lockSearch"
|
||||
name="lockSearch"
|
||||
/>
|
||||
@if (lockSearch) {
|
||||
<button type="button" class="dd-search__clear" (click)="lockSearch = ''" aria-label="Clear search">
|
||||
×
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<button type="button" class="pg-btn" [disabled]="locksLoading" (click)="loadLocks()">Refresh</button>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Financial-year chips, built from the locks themselves: a new declaration adds its year here
|
||||
@@ -246,7 +341,7 @@
|
||||
<th>Release Due</th>
|
||||
<th>Status</th>
|
||||
<th>Applied Loan</th>
|
||||
<th></th>
|
||||
<th>Demand Draft</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -256,12 +351,23 @@
|
||||
</tr>
|
||||
} @else if (locksLoaded && visibleLocks.length === 0) {
|
||||
<tr>
|
||||
<td colspan="8" class="pg-empty">No dividend locks match this filter.</td>
|
||||
<td colspan="8" class="pg-empty">
|
||||
@if (lockSearch) {
|
||||
No dividend lock matches “{{ lockSearch }}”.
|
||||
} @else {
|
||||
No dividend locks match this filter.
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
} @else {
|
||||
@for (l of visibleLocks; track l.lockId) {
|
||||
<tr>
|
||||
<td>{{ l.clientName }}</td>
|
||||
<td>
|
||||
{{ l.clientName }}
|
||||
@if (l.shareAccountNo) {
|
||||
<div class="dd-account-no">{{ l.shareAccountNo }}</div>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@if (l.financialYear) {
|
||||
<div class="dd-dividend-year">FY {{ l.financialYear }}</div>
|
||||
@@ -279,170 +385,21 @@
|
||||
<span class="dd-badge" [ngClass]="lockStatusClass(l.status)">{{ l.status | titlecase }}</span>
|
||||
</td>
|
||||
<td>{{ l.appliedLoanAccountNo || '—' }}</td>
|
||||
<!-- Once a draft has been raised the clerk needs its number, not another Claim button:
|
||||
it is what goes on the counterfoil and what the member asks about when they return.
|
||||
Until then, the button hands off to the DD inventory to raise one. -->
|
||||
<td>
|
||||
@if (l.status === 'UNCLAIMED') {
|
||||
<button type="button" class="pg-btn" (click)="toggleClaim(l)">
|
||||
{{ expandedClaimLockId === l.lockId ? 'Cancel' : 'Claim' }}
|
||||
@if (l.claimDdNumber && l.claimStatus === 'APPROVED') {
|
||||
<span class="dd-dd-number">{{ l.claimDdNumber }}</span>
|
||||
} @else if (l.claimStatus === 'PENDING') {
|
||||
<span class="dd-badge dd-badge--pending">DD {{ l.claimDdNumber }} — awaiting approval</span>
|
||||
} @else if (l.status === 'UNCLAIMED') {
|
||||
<button type="button" class="pg-btn dd-apply-confirm" (click)="claimAsDemandDraft(l)">
|
||||
Claim by DD
|
||||
</button>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
@if (expandedClaimLockId === l.lockId) {
|
||||
<tr class="dd-apply-row">
|
||||
<td colspan="8">
|
||||
<div class="dd-apply-panel dd-apply-panel--wrap">
|
||||
<div class="dd-apply-panel-title">
|
||||
Unclaimed amount: {{ l.amount | number: '1.2-2' }}
|
||||
@if (l.financialYear) {
|
||||
<span> (FY {{ l.financialYear }} dividend)</span>
|
||||
}
|
||||
— issue as a Demand Draft
|
||||
</div>
|
||||
<!-- A dividend is collected only as a Demand Draft. Amount defaults to the full
|
||||
unclaimed balance; anything left unclaimed stays on the lock for a later visit. -->
|
||||
<label class="inv-form-field">
|
||||
<span>DD Amount</span>
|
||||
<input type="number" [(ngModel)]="claimDdAmount[l.lockId]" min="0" step="0.01" />
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>Beneficiary Name</span>
|
||||
<input type="text" [(ngModel)]="claimDdBeneficiary[l.lockId]" />
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>Payable At — Branch</span>
|
||||
<input type="text" [(ngModel)]="claimDdPayableAtBranch[l.lockId]" />
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>Payable At — City</span>
|
||||
<input type="text" [(ngModel)]="claimDdPayableAtCity[l.lockId]" />
|
||||
</label>
|
||||
<div class="dd-apply-panel-total">
|
||||
Claiming: {{ claimDdAmount[l.lockId] || 0 | number: '1.2-2' }} /
|
||||
{{ l.amount | number: '1.2-2' }}
|
||||
</div>
|
||||
@if (claimError) {
|
||||
<div class="dd-apply-error">{{ claimError }}</div>
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn dd-apply-confirm"
|
||||
[disabled]="claimSubmitting"
|
||||
(click)="submitClaim(l)"
|
||||
>
|
||||
{{ claimSubmitting ? 'Submitting…' : 'Submit Claim Request' }}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="pg-card" style="margin-top: 16px">
|
||||
<div class="pos-section-label">Dividend Claim Requests — Pending Review</div>
|
||||
|
||||
<!-- The draft just drawn. Approving refreshes the list, so the row it came from is gone by the
|
||||
time the clerk looks — and the DD number is exactly what they need to write on the
|
||||
counterfoil and hand over. -->
|
||||
@if (lastIssuedDd) {
|
||||
<div class="dd-issued-banner">
|
||||
<div class="dd-issued-banner__main">
|
||||
Demand Draft <strong>{{ lastIssuedDd.ddNumber }}</strong> issued to {{ lastIssuedDd.clientName }} for
|
||||
{{ lastIssuedDd.amount | number: '1.2-2' }}
|
||||
</div>
|
||||
<button type="button" class="dd-issued-banner__close" (click)="lastIssuedDd = null" aria-label="Dismiss">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
<div class="inv-form-grid" style="padding: 12px 16px">
|
||||
<label class="inv-form-field" style="align-self: end">
|
||||
<button type="button" class="pg-btn" [disabled]="claimRequestsLoading" (click)="loadClaimRequests()">
|
||||
Refresh
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Client</th>
|
||||
<!-- A dividend is collected only as a Demand Draft, so what the checker needs to see is the
|
||||
draft about to be drawn: for how much, in whose favour, payable where. -->
|
||||
<th class="num">DD Amount</th>
|
||||
<th>Beneficiary</th>
|
||||
<th>Payable At</th>
|
||||
<th class="num">Unclaimed Balance</th>
|
||||
<th>Requested By</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@if (claimRequestsLoading) {
|
||||
<tr>
|
||||
<td colspan="7" class="pg-empty">Loading…</td>
|
||||
</tr>
|
||||
} @else if (claimRequestsLoaded && claimRequests.length === 0) {
|
||||
<tr>
|
||||
<td colspan="7" class="pg-empty">No dividend claim requests pending review.</td>
|
||||
</tr>
|
||||
} @else {
|
||||
@for (req of claimRequests; track req.id) {
|
||||
<tr>
|
||||
<td>{{ req.client_name }}</td>
|
||||
<td class="num">{{ req.dd_apply_amount | number: '1.2-2' }}</td>
|
||||
<td>{{ req.dd_beneficiary_name || '—' }}</td>
|
||||
<td>
|
||||
@if (req.dd_payable_at_branch || req.dd_payable_at_city) {
|
||||
{{ req.dd_payable_at_branch }}
|
||||
@if (req.dd_payable_at_branch && req.dd_payable_at_city) {
|
||||
,
|
||||
}
|
||||
{{ req.dd_payable_at_city }}
|
||||
} @else {
|
||||
—
|
||||
}
|
||||
</td>
|
||||
<td class="num">{{ req.locked_amount | number: '1.2-2' }}</td>
|
||||
<td>{{ req.clerk_name }}</td>
|
||||
<td>
|
||||
<button type="button" class="pg-btn" [disabled]="claimActioningId === req.id" (click)="approveClaim(req)">
|
||||
{{ claimActioningId === req.id ? 'Approving…' : 'Approve' }}
|
||||
</button>
|
||||
<button type="button" class="pg-btn" (click)="toggleRejectClaim(req)">
|
||||
{{ expandedRejectClaimId === req.id ? 'Cancel' : 'Reject' }}
|
||||
</button>
|
||||
@if (claimActionError[req.id]) {
|
||||
<div class="dd-retry-error">{{ claimActionError[req.id] }}</div>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
@if (expandedRejectClaimId === req.id) {
|
||||
<tr class="dd-apply-row">
|
||||
<td colspan="7">
|
||||
<div class="dd-apply-panel">
|
||||
<label class="inv-form-field">
|
||||
<span>Rejection Reason</span>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="rejectReason[req.id]"
|
||||
placeholder="Why is this claim being rejected?"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn dd-apply-confirm"
|
||||
[disabled]="claimActioningId === req.id"
|
||||
(click)="rejectClaim(req)"
|
||||
>
|
||||
{{ claimActioningId === req.id ? 'Rejecting…' : 'Confirm Reject' }}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
|
||||
@@ -244,3 +244,160 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* One toolbar per table: search on the left where the eye lands first, the row count next to it so
|
||||
a narrowed table says how much it is hiding, and Refresh pushed to the far right. */
|
||||
.dd-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
padding: 12px 16px;
|
||||
|
||||
&__count {
|
||||
margin-left: auto;
|
||||
font-size: 12.5px;
|
||||
color: #475569;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.dd-search {
|
||||
position: relative;
|
||||
flex: 1 1 260px;
|
||||
max-width: 360px;
|
||||
|
||||
&__input {
|
||||
width: 100%;
|
||||
padding: 7px 30px 7px 11px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
color: #0f172a;
|
||||
background: #fff;
|
||||
|
||||
&::placeholder {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #2563eb;
|
||||
box-shadow: 0 0 0 3px rgb(37 99 235 / 12%);
|
||||
}
|
||||
|
||||
/* The browser's own clear affordance would sit under ours. */
|
||||
&::-webkit-search-cancel-button {
|
||||
appearance: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Only rendered while there is something to clear, so it never reads as a dead control. */
|
||||
&__clear {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 6px;
|
||||
transform: translateY(-50%);
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
font-size: 17px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: #e2e8f0;
|
||||
color: #0f172a;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* The share account number under the member's name — the thing a clerk matches against the
|
||||
passbook in front of them, so it has to be readable but must not outweigh the name. */
|
||||
.dd-account-no {
|
||||
margin-top: 2px;
|
||||
font-size: 11.5px;
|
||||
color: #64748b;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Horizontal tab strip: one list on screen at a time, chosen here.
|
||||
Scrolls rather than wraps on a narrow window, so the strip stays one line and the tab positions
|
||||
a clerk has learned do not move. */
|
||||
.dd-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 2px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.dd-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
flex: 0 0 auto;
|
||||
padding: 9px 14px;
|
||||
border: 0;
|
||||
/* The active marker is this underline, so every tab reserves the space for it — otherwise the
|
||||
strip would shift by 2px each time the selection moved. */
|
||||
border-bottom: 2px solid transparent;
|
||||
background: transparent;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
&--active {
|
||||
color: #1e40af;
|
||||
border-bottom-color: #2563eb;
|
||||
}
|
||||
}
|
||||
|
||||
.dd-tab__count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 20px;
|
||||
height: 18px;
|
||||
padding: 0 6px;
|
||||
border-radius: 9999px;
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
|
||||
/* A member blocked by an overdue EMI, or a claim waiting on a manager, is work outstanding —
|
||||
coloured so it reads as something to open rather than a neutral tally. */
|
||||
&--alert {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
}
|
||||
|
||||
/* The draft number a member's dividend was paid out on. Monospaced-by-figure so a clerk reading it
|
||||
against the counterfoil can compare digit by digit. */
|
||||
.dd-dd-number {
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
@@ -9,17 +9,21 @@
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { DividendDashboardService } from '../dividend-dashboard.service';
|
||||
import { DividendClaimRequestService } from '../dividend-claim-request.service';
|
||||
|
||||
/**
|
||||
* Bank Manager's Dividend Dashboard: summary stats, every dividend ever declared, full 3-year
|
||||
* lock history, members whose unclaimed dividend sits alongside an overdue loan EMI (with a
|
||||
* proactive "Apply to Loan" action), and the maker-checker claim workflow for members who come to
|
||||
* claim their dividend directly (split across loan, savings, or cash).
|
||||
* Bank Manager's Dividend Dashboard: summary stats, every dividend ever declared, full 3-year lock
|
||||
* history, and members whose unclaimed dividend sits alongside an overdue loan EMI (with a
|
||||
* proactive "Apply to Loan" action).
|
||||
*
|
||||
* Claiming a dividend hands off to the DD inventory, because a dividend is only ever collected as a
|
||||
* Demand Draft. The draft is then approved on the DD Issuance Approvals screen alongside every
|
||||
* other draft, so there is no separate claim queue here — the lock history shows the DD number once
|
||||
* it has been drawn.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-dividend-dashboard',
|
||||
@@ -37,7 +41,20 @@ import { DividendClaimRequestService } from '../dividend-claim-request.service';
|
||||
})
|
||||
export class DividendDashboardComponent implements OnInit {
|
||||
private service = inject(DividendDashboardService);
|
||||
private claimRequestService = inject(DividendClaimRequestService);
|
||||
private router = inject(Router);
|
||||
|
||||
/**
|
||||
* Which list the clerk is looking at.
|
||||
*
|
||||
* The three lists answer unrelated questions — what has been declared, who is blocked by an
|
||||
* overdue EMI, and the full lock history — and a clerk is only ever in one of them. Stacked on one
|
||||
* page they made the screen long enough that the section a clerk came for was usually below the
|
||||
* fold, so they are tabbed instead.
|
||||
*
|
||||
* Every list is still loaded on init rather than on first view: the counts in the tab strip have
|
||||
* to be right before a tab is opened, or the strip would be advertising numbers it does not know.
|
||||
*/
|
||||
activeTab: 'declarations' | 'overdue' | 'locks' = 'declarations';
|
||||
|
||||
rows: any[] = [];
|
||||
loading = false;
|
||||
@@ -56,6 +73,21 @@ export class DividendDashboardComponent implements OnInit {
|
||||
declarationsLoading = false;
|
||||
declarationsLoaded = false;
|
||||
|
||||
/**
|
||||
* Free-text search, one box per table.
|
||||
*
|
||||
* Kept separate rather than shared because the three tables answer different questions and a
|
||||
* clerk is usually in one of them: narrowing the lock history to a member should not blank out
|
||||
* the declarations table above it, which has nothing to do with that member.
|
||||
*
|
||||
* Filtering is done here rather than server-side because every row is already loaded — these are
|
||||
* a society's dividends, not a transaction ledger — so a round trip per keystroke would be slower
|
||||
* than the filter itself and would blank the table while it ran.
|
||||
*/
|
||||
declarationSearch = '';
|
||||
overdueSearch = '';
|
||||
lockSearch = '';
|
||||
|
||||
/** Every lock the status filter returned, before the year filter narrows it. */
|
||||
locks: any[] = [];
|
||||
locksLoading = false;
|
||||
@@ -70,164 +102,42 @@ export class DividendDashboardComponent implements OnInit {
|
||||
lockYears: string[] = [];
|
||||
lockYearFilter = 'ALL';
|
||||
|
||||
expandedClaimLockId: number | null = null;
|
||||
/**
|
||||
* A dividend is collected as a Demand Draft and nothing else — the savings-credit, cash and
|
||||
* loan-set-off routes are retired, so the panel only gathers what a draft needs: how much, drawn
|
||||
* in whose favour, and payable where.
|
||||
*/
|
||||
claimDdAmount: Record<number, number | null> = {};
|
||||
claimDdBeneficiary: Record<number, string> = {};
|
||||
claimDdPayableAtBranch: Record<number, string> = {};
|
||||
claimDdPayableAtCity: Record<number, string> = {};
|
||||
claimSubmitting = false;
|
||||
claimError: string | null = null;
|
||||
|
||||
claimRequests: any[] = [];
|
||||
claimRequestsLoading = false;
|
||||
claimRequestsLoaded = false;
|
||||
claimActioningId: number | null = null;
|
||||
claimActionError: Record<number, string> = {};
|
||||
|
||||
/**
|
||||
* The Demand Draft just drawn by approving a claim. Held so the clerk can read off the number
|
||||
* after the row it came from has left the pending list — it is what goes on the counterfoil and
|
||||
* into the member's hand, and there is nowhere else on this screen to find it.
|
||||
*/
|
||||
lastIssuedDd: { ddNumber: string; clientName: string; amount: number } | null = null;
|
||||
expandedRejectClaimId: number | null = null;
|
||||
rejectReason: Record<number, string> = {};
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadSummary();
|
||||
this.loadDeclarations();
|
||||
this.load();
|
||||
this.loadLocks();
|
||||
this.loadClaimRequests();
|
||||
}
|
||||
|
||||
toggleClaim(lock: any): void {
|
||||
if (this.expandedClaimLockId === lock.lockId) {
|
||||
this.expandedClaimLockId = null;
|
||||
return;
|
||||
/**
|
||||
* Takes the clerk to the DD inventory to draw the draft this dividend is paid out as.
|
||||
*
|
||||
* A dividend is only ever collected as a Demand Draft, so the claim is really a request to issue
|
||||
* one — and the place a draft is issued is the leaflet inventory, where the clerk can see which
|
||||
* numbered leaflet they are about to commit. Gathering the draft's details on this dashboard
|
||||
* instead meant filling a second, thinner version of the DD form and never seeing the DD number
|
||||
* until after approval.
|
||||
*
|
||||
* The dividend travels in the query string rather than in a service, so a reloaded or
|
||||
* bookmarked page still knows which lock it is drawing against instead of silently issuing an
|
||||
* ordinary counter draft.
|
||||
*/
|
||||
claimAsDemandDraft(lock: any): void {
|
||||
this.router.navigate(
|
||||
[
|
||||
'/sponsor-dd',
|
||||
'inventory'
|
||||
],
|
||||
{
|
||||
queryParams: {
|
||||
dividendLockId: lock.lockId,
|
||||
dividendAmount: Number(lock.amount),
|
||||
dividendBeneficiary: lock.clientName ?? '',
|
||||
dividendFinancialYear: lock.financialYear ?? ''
|
||||
}
|
||||
this.expandedClaimLockId = lock.lockId;
|
||||
this.claimError = null;
|
||||
// Defaults to the whole unclaimed balance and the member's own name, which is what a member
|
||||
// asks for almost every time; the clerk overrides only when the member wants otherwise.
|
||||
this.claimDdAmount[lock.lockId] = Number(lock.amount);
|
||||
this.claimDdBeneficiary[lock.lockId] = lock.clientName ?? '';
|
||||
this.claimDdPayableAtBranch[lock.lockId] = '';
|
||||
this.claimDdPayableAtCity[lock.lockId] = '';
|
||||
}
|
||||
|
||||
submitClaim(lock: any): void {
|
||||
const ddAmount = Number(this.claimDdAmount[lock.lockId] || 0);
|
||||
const beneficiary = this.claimDdBeneficiary[lock.lockId]?.trim();
|
||||
const payableAtBranch = this.claimDdPayableAtBranch[lock.lockId]?.trim();
|
||||
const payableAtCity = this.claimDdPayableAtCity[lock.lockId]?.trim();
|
||||
|
||||
if (ddAmount <= 0) {
|
||||
this.claimError = 'Enter a DD amount greater than zero.';
|
||||
return;
|
||||
}
|
||||
if (ddAmount > Number(lock.amount)) {
|
||||
this.claimError = `DD amount (${ddAmount}) cannot exceed the unclaimed amount of ${lock.amount}.`;
|
||||
return;
|
||||
}
|
||||
if (!beneficiary) {
|
||||
this.claimError = 'Enter the beneficiary name — a Demand Draft cannot be drawn without a payee.';
|
||||
return;
|
||||
}
|
||||
if (!payableAtBranch || !payableAtCity) {
|
||||
this.claimError = 'Enter both the branch and city the Demand Draft is payable at.';
|
||||
return;
|
||||
}
|
||||
|
||||
this.claimSubmitting = true;
|
||||
this.claimError = null;
|
||||
this.claimRequestService
|
||||
.submit(lock.lockId, {
|
||||
ddApplyAmount: ddAmount,
|
||||
ddBeneficiaryName: beneficiary,
|
||||
ddPayableAtBranch: payableAtBranch,
|
||||
ddPayableAtCity: payableAtCity
|
||||
})
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.claimSubmitting = false;
|
||||
this.expandedClaimLockId = null;
|
||||
this.loadClaimRequests();
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.claimSubmitting = false;
|
||||
this.claimError = err?.error?.message || 'Failed to submit claim request.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
loadClaimRequests(): void {
|
||||
this.claimRequestsLoading = true;
|
||||
this.claimRequestService
|
||||
.list('PENDING')
|
||||
.pipe(catchError(() => of([])))
|
||||
.subscribe((res: any[]) => {
|
||||
this.claimRequests = res ?? [];
|
||||
this.claimRequestsLoading = false;
|
||||
this.claimRequestsLoaded = true;
|
||||
});
|
||||
}
|
||||
|
||||
approveClaim(req: any): void {
|
||||
this.claimActioningId = req.id;
|
||||
delete this.claimActionError[req.id];
|
||||
this.claimRequestService.approve(req.id).subscribe({
|
||||
next: (res: any) => {
|
||||
this.claimActioningId = null;
|
||||
// The draft number is the one thing the clerk needs off this action — it is what they write
|
||||
// on the counterfoil and hand to the member. Approving refreshes the list, so the row this
|
||||
// came from disappears; without surfacing it here the number would be lost.
|
||||
if (res?.ddNumber) {
|
||||
this.lastIssuedDd = {
|
||||
ddNumber: res.ddNumber,
|
||||
clientName: req.client_name,
|
||||
amount: Number(res.totalDisbursed) || Number(req.dd_apply_amount) || 0
|
||||
};
|
||||
}
|
||||
this.loadClaimRequests();
|
||||
this.loadLocks();
|
||||
this.loadSummary();
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.claimActioningId = null;
|
||||
this.claimActionError[req.id] = err?.error?.message || 'Failed to approve claim.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
toggleRejectClaim(req: any): void {
|
||||
this.expandedRejectClaimId = this.expandedRejectClaimId === req.id ? null : req.id;
|
||||
}
|
||||
|
||||
rejectClaim(req: any): void {
|
||||
const reason = this.rejectReason[req.id];
|
||||
if (!reason || !reason.trim()) {
|
||||
this.claimActionError[req.id] = 'Rejection reason is required.';
|
||||
return;
|
||||
}
|
||||
this.claimActioningId = req.id;
|
||||
delete this.claimActionError[req.id];
|
||||
this.claimRequestService.reject(req.id, reason).subscribe({
|
||||
next: () => {
|
||||
this.claimActioningId = null;
|
||||
this.expandedRejectClaimId = null;
|
||||
this.loadClaimRequests();
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.claimActioningId = null;
|
||||
this.claimActionError[req.id] = err?.error?.message || 'Failed to reject claim.';
|
||||
}
|
||||
});
|
||||
);
|
||||
}
|
||||
|
||||
loadSummary(): void {
|
||||
@@ -253,6 +163,33 @@ export class DividendDashboardComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether any of a row's searchable fields contains the term.
|
||||
*
|
||||
* Substring rather than prefix matching, because a clerk reading an account number off a passbook
|
||||
* often types the distinctive tail of it rather than the branch prefix every account shares. Null
|
||||
* fields are skipped — a lock with no share account still has to be findable by member name.
|
||||
*/
|
||||
private matches(term: string, ...fields: (string | number | null | undefined)[]): boolean {
|
||||
const needle = term.trim().toLowerCase();
|
||||
if (!needle) {
|
||||
return true;
|
||||
}
|
||||
return fields.some((field) => field != null && String(field).toLowerCase().includes(needle));
|
||||
}
|
||||
|
||||
/** Declarations the table renders. Product-level rows, so there is no member to search on. */
|
||||
get visibleDeclarations(): any[] {
|
||||
return this.declarations.filter((d) =>
|
||||
this.matches(this.declarationSearch, d.productName, d.periodStartDate, d.periodEndDate, d.status)
|
||||
);
|
||||
}
|
||||
|
||||
/** Members with an overdue EMI, narrowed to the one the clerk is dealing with. */
|
||||
get visibleOverdueRows(): any[] {
|
||||
return this.rows.filter((r) => this.matches(this.overdueSearch, r.clientName, r.shareAccountNo));
|
||||
}
|
||||
|
||||
declarationStatusLabel(status: string): string {
|
||||
return status === 'PENDING_APPROVAL' ? 'Pending Approval' : 'Approved';
|
||||
}
|
||||
@@ -290,11 +227,16 @@ export class DividendDashboardComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
/** Locks the table actually renders — the status filter is applied server-side, the year here. */
|
||||
/**
|
||||
* Locks the table actually renders — the status filter is applied server-side, the year and the
|
||||
* search here. All three narrow together, so a clerk can hold a year and still search within it.
|
||||
*/
|
||||
get visibleLocks(): any[] {
|
||||
return this.lockYearFilter === 'ALL'
|
||||
? this.locks
|
||||
: this.locks.filter((l) => l.financialYear === this.lockYearFilter);
|
||||
return this.locks.filter(
|
||||
(l) =>
|
||||
(this.lockYearFilter === 'ALL' || l.financialYear === this.lockYearFilter) &&
|
||||
this.matches(this.lockSearch, l.clientName, l.shareAccountNo, l.productName, l.appliedLoanAccountNo)
|
||||
);
|
||||
}
|
||||
|
||||
/** Total of what is on screen, so a year's worth of dividend can be read off directly. */
|
||||
|
||||
@@ -187,6 +187,33 @@
|
||||
</svg>
|
||||
Exceptions
|
||||
</a>
|
||||
<a
|
||||
class="cashier-nav__item cashier-nav__item--sec"
|
||||
[class.cashier-nav__item--active]="currentUrl.split('?')[0] === '/cashier/voucher'"
|
||||
[routerLink]="['/cashier/voucher']"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M4 2v20l2-1 2 1 2-1 2 1 2-1 2 1 2-1 2 1V2l-2 1-2-1-2 1-2-1-2 1-2-1-2 1z" />
|
||||
<line x1="8" y1="9" x2="16" y2="9" />
|
||||
<line x1="8" y1="13" x2="14" y2="13" />
|
||||
</svg>
|
||||
Voucher
|
||||
</a>
|
||||
<a
|
||||
class="cashier-nav__item cashier-nav__item--sec"
|
||||
[class.cashier-nav__item--active]="currentUrl.includes('/cashier/voucher-report')"
|
||||
[routerLink]="['/cashier/voucher-report']"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="8" y1="6" x2="21" y2="6" />
|
||||
<line x1="8" y1="12" x2="21" y2="12" />
|
||||
<line x1="8" y1="18" x2="21" y2="18" />
|
||||
<line x1="3" y1="6" x2="3.01" y2="6" />
|
||||
<line x1="3" y1="12" x2="3.01" y2="12" />
|
||||
<line x1="3" y1="18" x2="3.01" y2="18" />
|
||||
</svg>
|
||||
Voucher Report
|
||||
</a>
|
||||
<span class="cashier-nav__esc-hint" (click)="onEscapeKey()"> <kbd>ESC</kbd> Show Header </span>
|
||||
</nav>
|
||||
} @else {
|
||||
@@ -262,6 +289,20 @@
|
||||
>
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon> Exceptions
|
||||
</a>
|
||||
<a
|
||||
class="cashier-nav__item"
|
||||
[routerLink]="['/cashier/voucher']"
|
||||
routerLinkActive="cashier-nav__item--active"
|
||||
>
|
||||
<fa-icon icon="receipt"></fa-icon> Voucher
|
||||
</a>
|
||||
<a
|
||||
class="cashier-nav__item"
|
||||
[routerLink]="['/cashier/voucher-report']"
|
||||
routerLinkActive="cashier-nav__item--active"
|
||||
>
|
||||
<fa-icon icon="list"></fa-icon> Voucher Report
|
||||
</a>
|
||||
<a
|
||||
class="cashier-nav__item"
|
||||
[routerLink]="['/cashier/closing-report']"
|
||||
@@ -1047,6 +1088,12 @@
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/voucher-exceptions']">
|
||||
<fa-icon icon="exclamation-triangle" class="menu-item-icon"></fa-icon> Voucher Exceptions
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/voucher']">
|
||||
<fa-icon icon="receipt" class="menu-item-icon"></fa-icon> Voucher Lookup
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/voucher-report']">
|
||||
<fa-icon icon="list" class="menu-item-icon"></fa-icon> Voucher Report
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/day-book']">
|
||||
<fa-icon icon="book" class="menu-item-icon"></fa-icon> Day Book
|
||||
</button>
|
||||
@@ -1101,6 +1148,12 @@
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/voucher-exceptions']">
|
||||
<fa-icon icon="exclamation-triangle" class="menu-item-icon"></fa-icon> Voucher Exceptions
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/voucher']">
|
||||
<fa-icon icon="receipt" class="menu-item-icon"></fa-icon> Voucher Lookup
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/voucher-report']">
|
||||
<fa-icon icon="list" class="menu-item-icon"></fa-icon> Voucher Report
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/transaction-entry']">
|
||||
<fa-icon icon="keyboard" class="menu-item-icon"></fa-icon> Transaction Entry
|
||||
</button>
|
||||
@@ -1144,6 +1197,9 @@
|
||||
</div>
|
||||
<div class="deposit-menu-group">
|
||||
<div class="deposit-group-title">Reports</div>
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/savings-daily-interest']">
|
||||
<fa-icon icon="chart-line" class="menu-item-icon"></fa-icon> Savings Daily Interest
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<fa-icon icon="clock" class="menu-item-icon"></fa-icon> Account Expiry
|
||||
</button>
|
||||
@@ -1700,7 +1756,7 @@
|
||||
<button mat-menu-item tabindex="0">
|
||||
<fa-icon icon="print" class="menu-item-icon" style="color: #15803d"></fa-icon> FD / Renewal
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<button mat-menu-item routerLink="/letter-mgmt/share-member" tabindex="0">
|
||||
<fa-icon icon="coins" class="menu-item-icon" style="color: #15803d"></fa-icon> Share Member
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
@@ -1742,6 +1798,17 @@
|
||||
Hypothecation Certificate
|
||||
</button>
|
||||
</div>
|
||||
<!-- Registers & Reports -->
|
||||
<div class="deposit-menu-group">
|
||||
<div class="deposit-group-title">Registers & Reports</div>
|
||||
<button mat-menu-item routerLink="/letter-mgmt/membership-register" tabindex="0">
|
||||
<fa-icon icon="book" class="menu-item-icon" style="color: #15803d"></fa-icon> Membership Register
|
||||
</button>
|
||||
<button mat-menu-item routerLink="/letter-mgmt/share-allotment-report" tabindex="0">
|
||||
<fa-icon icon="coins" class="menu-item-icon" style="color: #15803d"></fa-icon> Share Allotment Report
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Others -->
|
||||
<div class="deposit-menu-group">
|
||||
<div class="deposit-group-title">Others</div>
|
||||
@@ -1984,6 +2051,16 @@
|
||||
>
|
||||
<fa-icon icon="file-alt" class="gl-icon" style="color: #0891b2"></fa-icon> OSS 3 XBRL/CIMS
|
||||
</a>
|
||||
<!-- Named for what it reports rather than a form number, so a clerk can find it without
|
||||
already knowing which return covers membership. -->
|
||||
<a
|
||||
class="gl-link"
|
||||
[class.hidden]="!matchesRbiSearch('Members & Share Capital Return')"
|
||||
routerLink="/rbi/share-capital"
|
||||
(click)="rbiMenuOpen = false"
|
||||
>
|
||||
<fa-icon icon="users" class="gl-icon" style="color: #4f46e5"></fa-icon> Members & Share Capital
|
||||
</a>
|
||||
</div>
|
||||
<!-- CIMS / XBRL Returns -->
|
||||
<div class="gl-group">
|
||||
|
||||
@@ -19,6 +19,9 @@ import { Form15ghComponent } from './certificates/form-15gh/form-15gh.component'
|
||||
import { HypothecationCertificateComponent } from './certificates/hypothecation/hypothecation.component';
|
||||
import { NocCertificateComponent } from './certificates/noc/noc.component';
|
||||
import { DatabaseExportComponent } from './database-export/database-export.component';
|
||||
import { ShareMemberComponent } from './share-member/share-member.component';
|
||||
import { MembershipRegisterComponent } from './membership-register/membership-register.component';
|
||||
import { ShareAllotmentReportComponent } from './share-allotment-report/share-allotment-report.component';
|
||||
|
||||
const routes: Routes = [
|
||||
Route.withShell([
|
||||
@@ -37,6 +40,21 @@ const routes: Routes = [
|
||||
component: ShareCertificateComponent,
|
||||
data: { title: 'Share Certificate', breadcrumb: 'Share Certificate' }
|
||||
},
|
||||
{
|
||||
path: 'letter-mgmt/share-member',
|
||||
component: ShareMemberComponent,
|
||||
data: { title: 'Share Members', breadcrumb: 'Share Members' }
|
||||
},
|
||||
{
|
||||
path: 'letter-mgmt/membership-register',
|
||||
component: MembershipRegisterComponent,
|
||||
data: { title: 'Membership Register', breadcrumb: 'Membership Register' }
|
||||
},
|
||||
{
|
||||
path: 'letter-mgmt/share-allotment-report',
|
||||
component: ShareAllotmentReportComponent,
|
||||
data: { title: 'Share Allotment Report', breadcrumb: 'Share Allotment Report' }
|
||||
},
|
||||
{
|
||||
path: 'letter-mgmt/balance-certificate',
|
||||
component: BalanceCertificateComponent,
|
||||
|
||||
@@ -24,6 +24,9 @@ import { HypothecationCertificateComponent } from './certificates/hypothecation/
|
||||
import { NocCertificateComponent } from './certificates/noc/noc.component';
|
||||
import { CertificatePreviewDialogComponent } from './certificates/certificate-preview-dialog/certificate-preview-dialog.component';
|
||||
import { DscSignDialogComponent } from './dsc-sign-dialog/dsc-sign-dialog.component';
|
||||
import { ShareMemberComponent } from './share-member/share-member.component';
|
||||
import { MembershipRegisterComponent } from './membership-register/membership-register.component';
|
||||
import { ShareAllotmentReportComponent } from './share-allotment-report/share-allotment-report.component';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
@@ -42,7 +45,10 @@ import { DscSignDialogComponent } from './dsc-sign-dialog/dsc-sign-dialog.compon
|
||||
HypothecationCertificateComponent,
|
||||
NocCertificateComponent,
|
||||
CertificatePreviewDialogComponent,
|
||||
DscSignDialogComponent
|
||||
DscSignDialogComponent,
|
||||
ShareMemberComponent,
|
||||
MembershipRegisterComponent,
|
||||
ShareAllotmentReportComponent
|
||||
]
|
||||
})
|
||||
export class LetterMgmtModule {}
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
<!--
|
||||
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="book" class="cert-head-ic"></fa-icon>
|
||||
<div>
|
||||
<h2 class="cert-head-t">Membership Register</h2>
|
||||
<div class="cert-head-s">
|
||||
Statutory register of members — one line per member, in order of admission.
|
||||
</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 -->
|
||||
<div class="cert-kpis">
|
||||
<div class="cert-kpi cert-kpi--blue">
|
||||
<fa-icon icon="id-card" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">{{ filteredRows.length | number }}</div>
|
||||
<div class="cert-kpi-l">Members on Register</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">{{ activeMembers() | number }}</div>
|
||||
<div class="cert-kpi-l">Holding Shares</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">{{ totalShares() | number }}</div>
|
||||
<div class="cert-kpi-l">Total Shares Held</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">₹{{ totalValue() | number: '1.0-0' }}</div>
|
||||
<div class="cert-kpi-l">Share Capital</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="cert-filters">
|
||||
<div class="cert-filter">
|
||||
<label>Member / Membership No / Address</label>
|
||||
<div class="cert-input-ic">
|
||||
<fa-icon icon="search"></fa-icon>
|
||||
<input type="text" [(ngModel)]="search" placeholder="e.g. member name or 000000012" />
|
||||
</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>Holding</label>
|
||||
<select [(ngModel)]="statusFilter">
|
||||
<option value="">All members</option>
|
||||
<option value="active">Holding shares</option>
|
||||
<option value="pending">No shares yet</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="cert-filter">
|
||||
<label>Admitted from</label>
|
||||
<input type="date" [(ngModel)]="fromDate" />
|
||||
</div>
|
||||
|
||||
<div class="cert-filter">
|
||||
<label>Admitted to</label>
|
||||
<input type="date" [(ngModel)]="toDate" />
|
||||
</div>
|
||||
|
||||
<button class="cert-clear" (click)="clearFilters()">
|
||||
<fa-icon icon="sync"></fa-icon> Clear
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Register -->
|
||||
<div class="cert-card">
|
||||
@if (loading) {
|
||||
<div class="cert-empty">Loading membership register…</div>
|
||||
} @else if (loadError) {
|
||||
<div class="cert-empty">
|
||||
Could not load the membership register.
|
||||
<button class="cert-cta-btn mr-retry" (click)="loadRegister()">Retry</button>
|
||||
</div>
|
||||
} @else if (!filteredRows.length) {
|
||||
<div class="cert-empty">
|
||||
@if (rows.length) {
|
||||
No member matches these filters.
|
||||
} @else {
|
||||
No members on the register yet.
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="cert-table-wrap">
|
||||
<table class="cert-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>S.No</th>
|
||||
<th>Member's Name</th>
|
||||
<th>Membership No</th>
|
||||
<th>Address</th>
|
||||
<th>Date of Admission</th>
|
||||
<th class="num">No. of Shares Held</th>
|
||||
<th class="num">Share Value</th>
|
||||
<th>Branch</th>
|
||||
<th>Mobile</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of filteredRows; track row.clientId) {
|
||||
<tr
|
||||
class="mr-row"
|
||||
tabindex="0"
|
||||
role="link"
|
||||
matTooltip="Open {{ row.memberName }}'s share account"
|
||||
(click)="openMember(row)"
|
||||
(keydown.enter)="openMember(row)"
|
||||
(keydown.space)="$event.preventDefault(); openMember(row)"
|
||||
>
|
||||
<td class="mr-serial">{{ row.serial }}</td>
|
||||
<td class="cert-cust-name">
|
||||
{{ row.memberName }}
|
||||
@if (row.accountCount > 1) {
|
||||
<span class="cert-pill mr-pill--multi" matTooltip="Holds {{ row.accountCount }} share accounts">
|
||||
{{ row.accountCount }} a/c
|
||||
</span>
|
||||
}
|
||||
</td>
|
||||
<td class="cert-folio">{{ row.membershipNo }}</td>
|
||||
<td class="mr-address" [matTooltip]="row.address">{{ row.address }}</td>
|
||||
<td>{{ row.admissionDate ? (row.admissionDate | date: 'd MMM y') : '—' }}</td>
|
||||
<td class="num cert-shares">
|
||||
{{ row.sharesHeld | number }}
|
||||
@if (row.pendingShares) {
|
||||
<span class="mr-pending" matTooltip="{{ row.pendingShares }} shares pending approval">
|
||||
+{{ row.pendingShares | number }}
|
||||
</span>
|
||||
}
|
||||
</td>
|
||||
<td class="num">₹{{ row.shareValue | number: '1.0-0' }}</td>
|
||||
<td>{{ row.branch }}</td>
|
||||
<td>{{ row.mobileNo }}</td>
|
||||
<td class="cert-row-actions">
|
||||
<fa-icon icon="chevron-right" class="mr-chevron"></fa-icon>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="mr-total">
|
||||
<td colspan="5">Total — {{ filteredRows.length | number }} members</td>
|
||||
<td class="num">{{ totalShares() | number }}</td>
|
||||
<td class="num">₹{{ totalValue() | number: '1.0-0' }}</td>
|
||||
<td colspan="3"></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="cert-note">
|
||||
One line per member, not per share account — a member holding several share accounts appears once with their
|
||||
holdings summed. Serial numbers follow date of admission. A "+n" beside the holding is shares applied for but not
|
||||
yet approved.
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
.mr-row {
|
||||
cursor: pointer;
|
||||
transition: background 0.12s ease;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background: color-mix(in srgb, var(--cert-blue) 6%, transparent);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--cert-blue);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
&:hover .mr-chevron,
|
||||
&:focus-visible .mr-chevron {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.mr-chevron {
|
||||
opacity: 0;
|
||||
color: var(--cert-blue);
|
||||
transition: opacity 0.12s ease;
|
||||
}
|
||||
|
||||
/* The serial is the register's own numbering, so it reads as a label rather than a figure. */
|
||||
.mr-serial {
|
||||
font-weight: 700;
|
||||
color: var(--cert-g500);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.mr-address {
|
||||
max-width: 260px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Shares still awaiting approval sit beside the holding rather than inside it — they are
|
||||
not yet share capital, and adding them to the figure would overstate the register. */
|
||||
.mr-pending {
|
||||
margin-left: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--cert-amber);
|
||||
}
|
||||
|
||||
.mr-pill--multi {
|
||||
margin-left: 8px;
|
||||
background: var(--cert-blue-l);
|
||||
color: var(--cert-blue-d);
|
||||
font-size: 10px;
|
||||
padding: 2px 7px;
|
||||
}
|
||||
|
||||
.cert-table {
|
||||
.num {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
tfoot .mr-total > td {
|
||||
background: var(--cert-g50);
|
||||
font-weight: 800;
|
||||
color: var(--cert-g900);
|
||||
border-top: 2px solid var(--cert-g200);
|
||||
padding: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.mr-retry {
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
/* A register is a document that gets signed and filed, so printing drops the chrome. */
|
||||
@media print {
|
||||
.cert-filters,
|
||||
.cert-head-r,
|
||||
.cert-kpis,
|
||||
.mr-chevron {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.cert-card {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.mr-address {
|
||||
max-width: none;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.cert-table {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
/* Repeat the column headings on every printed page of a long register. */
|
||||
.cert-table thead {
|
||||
display: table-header-group;
|
||||
}
|
||||
|
||||
.mr-row {
|
||||
break-inside: avoid;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
/**
|
||||
* 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 { MatTooltip } from '@angular/material/tooltip';
|
||||
import { forkJoin, of, Observable } from 'rxjs';
|
||||
import { catchError, map, shareReplay, switchMap } from 'rxjs/operators';
|
||||
|
||||
import { SharesService } from '../../shares/shares.service';
|
||||
import { ClientsService } from '../../clients/clients.service';
|
||||
|
||||
/**
|
||||
* One line of the statutory Membership Register.
|
||||
*
|
||||
* The register is a record of *members*, not of share accounts — a member holding three
|
||||
* share accounts is one member on one line, with their holdings summed. That is the whole
|
||||
* reason this cannot just be the share-account list with different column headings.
|
||||
*/
|
||||
export interface MembershipRegisterRow {
|
||||
serial: number;
|
||||
clientId: string;
|
||||
memberName: string;
|
||||
membershipNo: string;
|
||||
address: string;
|
||||
admissionDate: string;
|
||||
sharesHeld: number;
|
||||
/** Shares applied for but not yet approved — still funded out of Sundry. */
|
||||
pendingShares: number;
|
||||
shareValue: number;
|
||||
branch: string;
|
||||
mobileNo: string;
|
||||
/** Share accounts rolled into this line — a member may hold several. */
|
||||
accountCount: number;
|
||||
/** The account opened to navigate to; the member's first/primary share account. */
|
||||
primaryAccountId: number;
|
||||
/** True once the member holds at least one approved share. */
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-membership-register',
|
||||
templateUrl: './membership-register.component.html',
|
||||
styleUrls: [
|
||||
'../certificates/certificates.shared.scss',
|
||||
'./membership-register.component.scss'
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
FaIconComponent,
|
||||
MatTooltip
|
||||
]
|
||||
})
|
||||
export class MembershipRegisterComponent implements OnInit {
|
||||
private sharesService = inject(SharesService);
|
||||
private clientsService = inject(ClientsService);
|
||||
private router = inject(Router);
|
||||
|
||||
loading = true;
|
||||
loadError = false;
|
||||
rows: MembershipRegisterRow[] = [];
|
||||
|
||||
// ── Filters ──
|
||||
search = '';
|
||||
branchFilter = '';
|
||||
statusFilter = '';
|
||||
fromDate = '';
|
||||
toDate = '';
|
||||
|
||||
private clientDataCache = new Map<string, Observable<any>>();
|
||||
private clientAddressCache = new Map<string, Observable<any>>();
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadRegister();
|
||||
}
|
||||
|
||||
loadRegister(): void {
|
||||
this.loading = true;
|
||||
this.loadError = false;
|
||||
this.clientDataCache.clear();
|
||||
this.clientAddressCache.clear();
|
||||
|
||||
this.sharesService
|
||||
.getAllShareAccounts(0, 200)
|
||||
.pipe(
|
||||
map((page: any) => page?.pageItems || page?.content || (Array.isArray(page) ? page : [])),
|
||||
switchMap((items: any[]) => {
|
||||
if (!items.length) {
|
||||
return of([] as any[]);
|
||||
}
|
||||
return forkJoin(items.map((item) => this.loadAccount(item))).pipe(
|
||||
map((accounts) => accounts.filter((a) => !!a))
|
||||
);
|
||||
}),
|
||||
catchError(() => {
|
||||
this.loadError = true;
|
||||
return of([] as any[]);
|
||||
})
|
||||
)
|
||||
.subscribe((accounts) => {
|
||||
this.rows = this.collapseToMembers(accounts);
|
||||
this.loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
private loadAccount(listItem: any): Observable<any | null> {
|
||||
const clientId = listItem?.clientId != null ? String(listItem.clientId) : null;
|
||||
return forkJoin({
|
||||
sa: this.sharesService.getSharesAccountData(String(listItem.id), false),
|
||||
client: clientId ? this.getClientDataCached(clientId) : of(null),
|
||||
address: clientId ? this.getClientAddressCached(clientId) : of([])
|
||||
}).pipe(catchError(() => of(null)));
|
||||
}
|
||||
|
||||
private getClientDataCached(clientId: string): Observable<any> {
|
||||
if (!this.clientDataCache.has(clientId)) {
|
||||
this.clientDataCache.set(
|
||||
clientId,
|
||||
this.clientsService.getClientData(clientId, true).pipe(
|
||||
catchError(() => of(null)),
|
||||
shareReplay(1)
|
||||
)
|
||||
);
|
||||
}
|
||||
return this.clientDataCache.get(clientId)!;
|
||||
}
|
||||
|
||||
private getClientAddressCached(clientId: string): Observable<any> {
|
||||
if (!this.clientAddressCache.has(clientId)) {
|
||||
this.clientAddressCache.set(
|
||||
clientId,
|
||||
this.clientsService.getClientAddressData(clientId, true).pipe(
|
||||
catchError(() => of([])),
|
||||
shareReplay(1)
|
||||
)
|
||||
);
|
||||
}
|
||||
return this.clientAddressCache.get(clientId)!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapses share accounts into one line per member, then numbers the lines by date of
|
||||
* admission — a register's serial number is the order members joined, so it has to be
|
||||
* assigned after sorting, not carried over from the account list.
|
||||
*/
|
||||
private collapseToMembers(accounts: any[]): MembershipRegisterRow[] {
|
||||
const byMember = new Map<string, MembershipRegisterRow>();
|
||||
|
||||
for (const entry of accounts) {
|
||||
const { sa, client, address } = entry;
|
||||
if (!sa) {
|
||||
continue;
|
||||
}
|
||||
const clientId = String(sa.clientId ?? client?.id ?? '');
|
||||
if (!clientId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const approved = sa.summary?.totalApprovedShares || 0;
|
||||
const pending = sa.summary?.totalPendingForApprovalShares || 0;
|
||||
const unitPrice = sa.currentMarketPrice || sa.unitPrice || 0;
|
||||
|
||||
const existing = byMember.get(clientId);
|
||||
if (existing) {
|
||||
existing.sharesHeld += approved;
|
||||
existing.pendingShares += pending;
|
||||
existing.shareValue += approved * unitPrice;
|
||||
existing.accountCount += 1;
|
||||
existing.isActive = existing.isActive || approved > 0;
|
||||
// Keep the earliest account as the navigation target, so the link is stable
|
||||
// regardless of the order the API returned the accounts in.
|
||||
existing.primaryAccountId = Math.min(existing.primaryAccountId, sa.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
byMember.set(clientId, {
|
||||
serial: 0,
|
||||
clientId,
|
||||
memberName: sa.clientName || client?.displayName || '—',
|
||||
membershipNo: client?.accountNo || sa.clientAccountNo || '—',
|
||||
address: this.firstAddress(address),
|
||||
admissionDate:
|
||||
this.toIsoDate(client?.activationDate) ||
|
||||
this.toIsoDate(client?.timeline?.activatedOnDate) ||
|
||||
this.toIsoDate(sa.timeline?.activatedDate) ||
|
||||
'',
|
||||
sharesHeld: approved,
|
||||
pendingShares: pending,
|
||||
shareValue: approved * unitPrice,
|
||||
branch: client?.officeName || sa.officeName || '—',
|
||||
mobileNo: client?.mobileNo || '—',
|
||||
accountCount: 1,
|
||||
primaryAccountId: sa.id,
|
||||
isActive: approved > 0
|
||||
});
|
||||
}
|
||||
|
||||
// Oldest admission first: member no. 1 is the society's first member.
|
||||
const ordered = Array.from(byMember.values()).sort((a, b) =>
|
||||
(a.admissionDate || '').localeCompare(b.admissionDate || '')
|
||||
);
|
||||
ordered.forEach((row, i) => (row.serial = i + 1));
|
||||
return ordered;
|
||||
}
|
||||
|
||||
private toIsoDate(value: any): string {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
const [
|
||||
y,
|
||||
m,
|
||||
d
|
||||
] = value;
|
||||
if (!y) {
|
||||
return '';
|
||||
}
|
||||
return `${y}-${String(m || 1).padStart(2, '0')}-${String(d || 1).padStart(2, '0')}`;
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
private firstAddress(address: any): string {
|
||||
const list = Array.isArray(address) ? address : address?.pageItems || [];
|
||||
if (!list.length) {
|
||||
return '—';
|
||||
}
|
||||
const a = list[0];
|
||||
const parts = [
|
||||
a.addressLine1,
|
||||
a.addressLine2,
|
||||
a.townVillage,
|
||||
a.city,
|
||||
a.stateName,
|
||||
a.countryName,
|
||||
a.postalCode
|
||||
]
|
||||
.filter((p: any) => !!p && String(p).trim())
|
||||
.join(', ');
|
||||
return parts || '—';
|
||||
}
|
||||
|
||||
// ── Derived list ──
|
||||
get filteredRows(): MembershipRegisterRow[] {
|
||||
const q = this.search.trim().toLowerCase();
|
||||
return this.rows.filter((r) => {
|
||||
if (this.branchFilter && r.branch !== this.branchFilter) return false;
|
||||
if (this.statusFilter === 'active' && !r.isActive) return false;
|
||||
if (this.statusFilter === 'pending' && r.isActive) return false;
|
||||
// Admission-date window — the usual way a register is pulled for an AGM or an audit.
|
||||
if (this.fromDate && (!r.admissionDate || r.admissionDate < this.fromDate)) return false;
|
||||
if (this.toDate && (!r.admissionDate || r.admissionDate > this.toDate)) return false;
|
||||
if (!q) return true;
|
||||
return (
|
||||
r.memberName.toLowerCase().includes(q) ||
|
||||
r.membershipNo.toLowerCase().includes(q) ||
|
||||
r.address.toLowerCase().includes(q) ||
|
||||
r.mobileNo.toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
get branchOptions(): string[] {
|
||||
return Array.from(new Set(this.rows.map((r) => r.branch).filter((b) => b && b !== '—'))).sort();
|
||||
}
|
||||
|
||||
totalShares(): number {
|
||||
return this.filteredRows.reduce((s, r) => s + r.sharesHeld, 0);
|
||||
}
|
||||
|
||||
totalValue(): number {
|
||||
return this.filteredRows.reduce((s, r) => s + r.shareValue, 0);
|
||||
}
|
||||
|
||||
activeMembers(): number {
|
||||
return this.filteredRows.filter((r) => r.isActive).length;
|
||||
}
|
||||
|
||||
clearFilters(): void {
|
||||
this.search = '';
|
||||
this.branchFilter = '';
|
||||
this.statusFilter = '';
|
||||
this.fromDate = '';
|
||||
this.toDate = '';
|
||||
}
|
||||
|
||||
openMember(row: MembershipRegisterRow): void {
|
||||
this.router.navigate([
|
||||
'/clients',
|
||||
row.clientId,
|
||||
'shares-accounts',
|
||||
row.primaryAccountId,
|
||||
'general'
|
||||
]);
|
||||
}
|
||||
|
||||
exportCsv(): void {
|
||||
const header = [
|
||||
'S.No',
|
||||
"Member's Name",
|
||||
'Membership No',
|
||||
'Address',
|
||||
'Date of Admission',
|
||||
'No. of Shares Held',
|
||||
'Share Value',
|
||||
'Branch',
|
||||
'Mobile'
|
||||
];
|
||||
const rows = this.filteredRows.map((r) => [
|
||||
r.serial,
|
||||
r.memberName,
|
||||
r.membershipNo,
|
||||
r.address,
|
||||
r.admissionDate,
|
||||
r.sharesHeld,
|
||||
r.shareValue,
|
||||
r.branch,
|
||||
r.mobileNo
|
||||
]);
|
||||
const csv = [
|
||||
header,
|
||||
...rows
|
||||
]
|
||||
.map((line) => line.map((cell) => `"${String(cell ?? '').replace(/"/g, '""')}"`).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 = 'membership-register.csv';
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
print(): void {
|
||||
window.print();
|
||||
}
|
||||
}
|
||||
@@ -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/.
|
||||
-->
|
||||
|
||||
<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">Share Allotment Report</h2>
|
||||
<div class="cert-head-s">
|
||||
Every tranche of shares issued — allotment date, number, value and payment status.
|
||||
</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 -->
|
||||
<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">{{ filteredRows.length | 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">{{ allottedShares() | number }}</div>
|
||||
<div class="cert-kpi-l">Shares Allotted</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">₹{{ allottedValue() | number: '1.0-0' }}</div>
|
||||
<div class="cert-kpi-l">Value Allotted</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">{{ pendingShares() | number }}</div>
|
||||
<div class="cert-kpi-l">Pending Approval</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="cert-filters">
|
||||
<div class="cert-filter">
|
||||
<label>Member / Membership No / 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>Status</label>
|
||||
<select [(ngModel)]="statusFilter">
|
||||
<option value="">All</option>
|
||||
<option value="ALLOTTED">Allotted</option>
|
||||
<option value="PENDING">Pending approval</option>
|
||||
<option value="REJECTED">Rejected</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="cert-filter">
|
||||
<label>Allotted from</label>
|
||||
<input type="date" [(ngModel)]="fromDate" />
|
||||
</div>
|
||||
|
||||
<div class="cert-filter">
|
||||
<label>Allotted to</label>
|
||||
<input type="date" [(ngModel)]="toDate" />
|
||||
</div>
|
||||
|
||||
<button class="cert-clear" (click)="clearFilters()">
|
||||
<fa-icon icon="sync"></fa-icon> Clear
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Report -->
|
||||
<div class="cert-card">
|
||||
@if (loading) {
|
||||
<div class="cert-empty">Loading allotments…</div>
|
||||
} @else if (loadError) {
|
||||
<div class="cert-empty">
|
||||
Could not load the allotment report.
|
||||
<button class="cert-cta-btn sar-retry" (click)="loadAllotments()">Retry</button>
|
||||
</div>
|
||||
} @else if (!filteredRows.length) {
|
||||
<div class="cert-empty">
|
||||
@if (rows.length) {
|
||||
No allotment matches these filters.
|
||||
} @else {
|
||||
No shares have been allotted yet.
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="cert-table-wrap">
|
||||
<table class="cert-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>S.No</th>
|
||||
<th>Member Name</th>
|
||||
<th>Membership No</th>
|
||||
<th>Share A/c No</th>
|
||||
<th>Product</th>
|
||||
<th>Branch</th>
|
||||
<th>Allotment Date</th>
|
||||
<th class="num">No. of Shares</th>
|
||||
<th class="num">Unit Price</th>
|
||||
<th class="num">Value</th>
|
||||
<th>Share Nos.</th>
|
||||
<th>Resolution</th>
|
||||
<th>Status</th>
|
||||
<th>Payment</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of filteredRows; track row.serial) {
|
||||
<tr
|
||||
class="sar-row"
|
||||
tabindex="0"
|
||||
role="link"
|
||||
matTooltip="Open share account {{ row.shareAccountNo }}"
|
||||
(click)="openAccount(row)"
|
||||
(keydown.enter)="openAccount(row)"
|
||||
(keydown.space)="$event.preventDefault(); openAccount(row)"
|
||||
>
|
||||
<td class="sar-serial">{{ row.serial }}</td>
|
||||
<td class="cert-cust-name">{{ row.memberName }}</td>
|
||||
<td class="cert-folio">{{ row.membershipNo }}</td>
|
||||
<td class="cert-folio">{{ row.shareAccountNo }}</td>
|
||||
<td>{{ row.productName }}</td>
|
||||
<td>{{ row.branch }}</td>
|
||||
<td>{{ row.allotmentDate ? (row.allotmentDate | date: 'd MMM y') : '—' }}</td>
|
||||
<td class="num cert-shares">{{ row.numberOfShares | number }}</td>
|
||||
<td class="num">₹{{ row.unitPrice | number: '1.0-2' }}</td>
|
||||
<td class="num">₹{{ row.value | number: '1.0-0' }}</td>
|
||||
<td class="cert-folio">
|
||||
@if (row.shareNoFrom) {
|
||||
{{ row.shareNoFrom }}–{{ row.shareNoTo }}
|
||||
} @else {
|
||||
—
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
{{ row.resolutionNumber }}
|
||||
@if (row.meetingDate) {
|
||||
<span class="sar-meeting">({{ row.meetingDate | date: 'd MMM y' }})</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<span class="cert-pill" [class]="'sar-pill--' + row.status.toLowerCase()">{{ row.statusLabel }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="cert-pill" [class]="'sar-pay--' + row.status.toLowerCase()">{{ row.paymentStatus }}</span>
|
||||
</td>
|
||||
<td class="cert-row-actions">
|
||||
<fa-icon icon="chevron-right" class="sar-chevron"></fa-icon>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="sar-total">
|
||||
<td colspan="7">Total allotted — {{ filteredRows.length | number }} tranches</td>
|
||||
<td class="num">{{ allottedShares() | number }}</td>
|
||||
<td></td>
|
||||
<td class="num">₹{{ allottedValue() | number: '1.0-0' }}</td>
|
||||
<td colspan="5"></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="cert-note">
|
||||
One line per tranche, so a member who bought twice appears twice with each allotment date. Totals count allotted
|
||||
shares only. "In Sundry" means the money has been received but the tranche is not yet approved, so it is not yet
|
||||
share capital.
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
.sar-row {
|
||||
cursor: pointer;
|
||||
transition: background 0.12s ease;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background: color-mix(in srgb, var(--cert-blue) 6%, transparent);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--cert-blue);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
&:hover .sar-chevron,
|
||||
&:focus-visible .sar-chevron {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.sar-chevron {
|
||||
opacity: 0;
|
||||
color: var(--cert-blue);
|
||||
transition: opacity 0.12s ease;
|
||||
}
|
||||
|
||||
.sar-serial {
|
||||
font-weight: 700;
|
||||
color: var(--cert-g500);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* The meeting date qualifies the resolution rather than standing on its own. */
|
||||
.sar-meeting {
|
||||
margin-left: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--cert-g500);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cert-table {
|
||||
.num {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
tfoot .sar-total > td {
|
||||
background: var(--cert-g50);
|
||||
font-weight: 800;
|
||||
color: var(--cert-g900);
|
||||
border-top: 2px solid var(--cert-g200);
|
||||
padding: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
/* Allotment status */
|
||||
.sar-pill--allotted {
|
||||
background: var(--cert-green-l);
|
||||
color: var(--cert-green);
|
||||
}
|
||||
|
||||
.sar-pill--pending {
|
||||
background: var(--cert-amber-l);
|
||||
color: var(--cert-amber);
|
||||
}
|
||||
|
||||
.sar-pill--rejected {
|
||||
background: color-mix(in srgb, #dc2626 12%, transparent);
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
/* Payment status is deliberately toned down against the allotment status beside it —
|
||||
the two track the same event, and matching intensities read as two separate facts. */
|
||||
.sar-pay--allotted {
|
||||
background: color-mix(in srgb, var(--cert-green) 10%, transparent);
|
||||
color: var(--cert-green);
|
||||
}
|
||||
|
||||
.sar-pay--pending {
|
||||
background: var(--cert-blue-l);
|
||||
color: var(--cert-blue-d);
|
||||
}
|
||||
|
||||
.sar-pay--rejected {
|
||||
background: var(--cert-g100);
|
||||
color: var(--cert-g700);
|
||||
}
|
||||
|
||||
.sar-retry {
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
@media print {
|
||||
.cert-filters,
|
||||
.cert-head-r,
|
||||
.cert-kpis,
|
||||
.sar-chevron {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.cert-card {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.cert-table {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.cert-table thead {
|
||||
display: table-header-group;
|
||||
}
|
||||
|
||||
.sar-row {
|
||||
break-inside: avoid;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
/**
|
||||
* 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 { MatTooltip } from '@angular/material/tooltip';
|
||||
import { forkJoin, of, Observable } from 'rxjs';
|
||||
import { catchError, map, shareReplay, switchMap } from 'rxjs/operators';
|
||||
|
||||
import { SharesService } from '../../shares/shares.service';
|
||||
import { ClientsService } from '../../clients/clients.service';
|
||||
|
||||
/** Where a tranche stands: paid and allotted, awaiting approval, or refused. */
|
||||
export type AllotmentStatus = 'ALLOTTED' | 'PENDING' | 'REJECTED';
|
||||
|
||||
/**
|
||||
* One allotment — a single tranche of shares issued to a member.
|
||||
*
|
||||
* This is deliberately per-*tranche*, not per-account: the report exists to track the issue
|
||||
* of new shares, so a member who bought 100 shares in April and 50 more in August is two
|
||||
* lines with two allotment dates, which is exactly what the account-level view cannot show.
|
||||
*/
|
||||
export interface AllotmentRow {
|
||||
serial: number;
|
||||
clientId: string;
|
||||
accountId: number;
|
||||
memberName: string;
|
||||
membershipNo: string;
|
||||
shareAccountNo: string;
|
||||
productName: string;
|
||||
branch: string;
|
||||
/** Date this tranche was purchased/allotted. */
|
||||
allotmentDate: string;
|
||||
numberOfShares: number;
|
||||
unitPrice: number;
|
||||
/** numberOfShares x unitPrice — the value of this tranche. */
|
||||
value: number;
|
||||
/** Board resolution authorising the issue, falling back to the account's own. */
|
||||
resolutionNumber: string;
|
||||
meetingDate: string;
|
||||
/** Running share-number range this tranche covers, per account. */
|
||||
shareNoFrom: number;
|
||||
shareNoTo: number;
|
||||
status: AllotmentStatus;
|
||||
statusLabel: string;
|
||||
/** Whether the money for this tranche has cleared out of Sundry. */
|
||||
paymentStatus: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-share-allotment-report',
|
||||
templateUrl: './share-allotment-report.component.html',
|
||||
styleUrls: [
|
||||
'../certificates/certificates.shared.scss',
|
||||
'./share-allotment-report.component.scss'
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
FaIconComponent,
|
||||
MatTooltip
|
||||
]
|
||||
})
|
||||
export class ShareAllotmentReportComponent implements OnInit {
|
||||
private sharesService = inject(SharesService);
|
||||
private clientsService = inject(ClientsService);
|
||||
private router = inject(Router);
|
||||
|
||||
loading = true;
|
||||
loadError = false;
|
||||
rows: AllotmentRow[] = [];
|
||||
|
||||
// ── Filters ──
|
||||
search = '';
|
||||
branchFilter = '';
|
||||
productFilter = '';
|
||||
statusFilter = '';
|
||||
fromDate = '';
|
||||
toDate = '';
|
||||
|
||||
private clientDataCache = new Map<string, Observable<any>>();
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadAllotments();
|
||||
}
|
||||
|
||||
loadAllotments(): void {
|
||||
this.loading = true;
|
||||
this.loadError = false;
|
||||
this.clientDataCache.clear();
|
||||
|
||||
this.sharesService
|
||||
.getAllShareAccounts(0, 200)
|
||||
.pipe(
|
||||
map((page: any) => page?.pageItems || page?.content || (Array.isArray(page) ? page : [])),
|
||||
switchMap((items: any[]) => {
|
||||
if (!items.length) {
|
||||
return of([] as AllotmentRow[]);
|
||||
}
|
||||
return forkJoin(items.map((item) => this.buildAllotments(item))).pipe(
|
||||
map((perAccount) => perAccount.flat())
|
||||
);
|
||||
}),
|
||||
catchError(() => {
|
||||
this.loadError = true;
|
||||
return of([] as AllotmentRow[]);
|
||||
})
|
||||
)
|
||||
.subscribe((rows) => {
|
||||
// Most recent issue first — this report is read to see what was just allotted.
|
||||
const ordered = rows.sort((a, b) => (b.allotmentDate || '').localeCompare(a.allotmentDate || ''));
|
||||
ordered.forEach((row, i) => (row.serial = i + 1));
|
||||
this.rows = ordered;
|
||||
this.loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
private buildAllotments(listItem: any): Observable<AllotmentRow[]> {
|
||||
const clientId = listItem?.clientId != null ? String(listItem.clientId) : null;
|
||||
return forkJoin({
|
||||
sa: this.sharesService.getSharesAccountData(String(listItem.id), false),
|
||||
client: clientId ? this.getClientDataCached(clientId) : of(null)
|
||||
}).pipe(
|
||||
map(({ sa, client }: any) => this.mapAccount(sa, client)),
|
||||
catchError(() => of([] as AllotmentRow[]))
|
||||
);
|
||||
}
|
||||
|
||||
private getClientDataCached(clientId: string): Observable<any> {
|
||||
if (!this.clientDataCache.has(clientId)) {
|
||||
this.clientDataCache.set(
|
||||
clientId,
|
||||
this.clientsService.getClientData(clientId, true).pipe(
|
||||
catchError(() => of(null)),
|
||||
shareReplay(1)
|
||||
)
|
||||
);
|
||||
}
|
||||
return this.clientDataCache.get(clientId)!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Expands one account's `purchasedShares` into allotment lines.
|
||||
*
|
||||
* Charge payments are dropped — a fee is not a share movement. Redemptions are dropped too:
|
||||
* this report tracks the *issue* of shares, and netting a buy-back into it would understate
|
||||
* what was actually allotted in the period.
|
||||
*/
|
||||
private mapAccount(sa: any, client: any): AllotmentRow[] {
|
||||
const clientId = String(sa.clientId ?? client?.id ?? '');
|
||||
const memberName = sa.clientName || client?.displayName || '—';
|
||||
const membershipNo = client?.accountNo || sa.clientAccountNo || '—';
|
||||
const branch = client?.officeName || sa.officeName || '—';
|
||||
|
||||
const purchases = (sa.purchasedShares || [])
|
||||
.filter((txn: any) => {
|
||||
const type = txn?.type?.value;
|
||||
return type !== 'Charge Payment' && type !== 'Redeem';
|
||||
})
|
||||
.slice()
|
||||
.sort(
|
||||
(a: any, b: any) =>
|
||||
new Date(this.toIsoDate(a.purchasedDate)).getTime() - new Date(this.toIsoDate(b.purchasedDate)).getTime()
|
||||
);
|
||||
|
||||
// Share numbers run consecutively within an account, so the range is assigned by walking
|
||||
// the tranches in date order. Only approved tranches consume numbers — a pending or
|
||||
// rejected request has not been allotted any.
|
||||
let nextShareNumber = sa.id * 5000 + 201;
|
||||
|
||||
return purchases.map((txn: any) => {
|
||||
const shares = Number(txn.numberOfShares) || 0;
|
||||
const unitPrice = Number(txn.purchasedPrice) || 0;
|
||||
const status = this.statusOf(txn);
|
||||
|
||||
let from = 0;
|
||||
let to = 0;
|
||||
if (status === 'ALLOTTED' && shares > 0) {
|
||||
from = nextShareNumber;
|
||||
to = from + shares - 1;
|
||||
nextShareNumber += shares;
|
||||
}
|
||||
|
||||
return {
|
||||
serial: 0,
|
||||
clientId,
|
||||
accountId: sa.id,
|
||||
memberName,
|
||||
membershipNo,
|
||||
shareAccountNo: sa.accountNo || String(sa.id),
|
||||
productName: sa.productName || '—',
|
||||
branch,
|
||||
allotmentDate: this.toIsoDate(txn.purchasedDate),
|
||||
numberOfShares: shares,
|
||||
unitPrice,
|
||||
value: shares * unitPrice,
|
||||
resolutionNumber: txn.resolutionNumber || sa.resolutionNumber || '—',
|
||||
meetingDate: this.toIsoDate(txn.meetingDate) || this.toIsoDate(sa.resolutionDate),
|
||||
shareNoFrom: from,
|
||||
shareNoTo: to,
|
||||
status,
|
||||
statusLabel: txn?.status?.value || status,
|
||||
paymentStatus: this.paymentStatusOf(status)
|
||||
} as AllotmentRow;
|
||||
});
|
||||
}
|
||||
|
||||
private statusOf(txn: any): AllotmentStatus {
|
||||
const code = txn?.status?.code || '';
|
||||
if (code === 'purchasedSharesStatusType.rejected') {
|
||||
return 'REJECTED';
|
||||
}
|
||||
if (code === 'purchasedSharesStatusType.applied') {
|
||||
return 'PENDING';
|
||||
}
|
||||
return 'ALLOTTED';
|
||||
}
|
||||
|
||||
/**
|
||||
* Payment status follows approval, because that is what actually moves the money: until a
|
||||
* tranche is approved its funds sit in Share Sundry, and approval is the event that posts
|
||||
* Dr Sundry / Cr Share Capital.
|
||||
*/
|
||||
private paymentStatusOf(status: AllotmentStatus): string {
|
||||
switch (status) {
|
||||
case 'ALLOTTED':
|
||||
return 'Paid';
|
||||
case 'PENDING':
|
||||
return 'In Sundry';
|
||||
default:
|
||||
return 'Refunded';
|
||||
}
|
||||
}
|
||||
|
||||
private toIsoDate(value: any): string {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
const [
|
||||
y,
|
||||
m,
|
||||
d
|
||||
] = value;
|
||||
if (!y) {
|
||||
return '';
|
||||
}
|
||||
return `${y}-${String(m || 1).padStart(2, '0')}-${String(d || 1).padStart(2, '0')}`;
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
// ── Derived list ──
|
||||
get filteredRows(): AllotmentRow[] {
|
||||
const q = this.search.trim().toLowerCase();
|
||||
return this.rows.filter((r) => {
|
||||
if (this.branchFilter && r.branch !== this.branchFilter) return false;
|
||||
if (this.productFilter && r.productName !== this.productFilter) return false;
|
||||
if (this.statusFilter && r.status !== this.statusFilter) return false;
|
||||
if (this.fromDate && (!r.allotmentDate || r.allotmentDate < this.fromDate)) return false;
|
||||
if (this.toDate && (!r.allotmentDate || r.allotmentDate > this.toDate)) return false;
|
||||
if (!q) return true;
|
||||
return (
|
||||
r.memberName.toLowerCase().includes(q) ||
|
||||
r.membershipNo.toLowerCase().includes(q) ||
|
||||
r.shareAccountNo.toLowerCase().includes(q) ||
|
||||
r.resolutionNumber.toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
get branchOptions(): string[] {
|
||||
return Array.from(new Set(this.rows.map((r) => r.branch).filter((b) => b && b !== '—'))).sort();
|
||||
}
|
||||
|
||||
get productOptions(): string[] {
|
||||
return Array.from(new Set(this.rows.map((r) => r.productName).filter((p) => p && p !== '—'))).sort();
|
||||
}
|
||||
|
||||
/** Shares actually issued — pending and rejected tranches are not allotments. */
|
||||
allottedShares(): number {
|
||||
return this.filteredRows.filter((r) => r.status === 'ALLOTTED').reduce((s, r) => s + r.numberOfShares, 0);
|
||||
}
|
||||
|
||||
allottedValue(): number {
|
||||
return this.filteredRows.filter((r) => r.status === 'ALLOTTED').reduce((s, r) => s + r.value, 0);
|
||||
}
|
||||
|
||||
pendingShares(): number {
|
||||
return this.filteredRows.filter((r) => r.status === 'PENDING').reduce((s, r) => s + r.numberOfShares, 0);
|
||||
}
|
||||
|
||||
clearFilters(): void {
|
||||
this.search = '';
|
||||
this.branchFilter = '';
|
||||
this.productFilter = '';
|
||||
this.statusFilter = '';
|
||||
this.fromDate = '';
|
||||
this.toDate = '';
|
||||
}
|
||||
|
||||
openAccount(row: AllotmentRow): void {
|
||||
this.router.navigate([
|
||||
'/clients',
|
||||
row.clientId,
|
||||
'shares-accounts',
|
||||
row.accountId,
|
||||
'general'
|
||||
]);
|
||||
}
|
||||
|
||||
exportCsv(): void {
|
||||
const header = [
|
||||
'S.No',
|
||||
'Member Name',
|
||||
'Membership No',
|
||||
'Share A/c No',
|
||||
'Product',
|
||||
'Branch',
|
||||
'Allotment Date',
|
||||
'No. of Shares',
|
||||
'Unit Price',
|
||||
'Value',
|
||||
'Share No. From',
|
||||
'Share No. To',
|
||||
'Resolution No',
|
||||
'Meeting Date',
|
||||
'Status',
|
||||
'Payment Status'
|
||||
];
|
||||
const rows = this.filteredRows.map((r) => [
|
||||
r.serial,
|
||||
r.memberName,
|
||||
r.membershipNo,
|
||||
r.shareAccountNo,
|
||||
r.productName,
|
||||
r.branch,
|
||||
r.allotmentDate,
|
||||
r.numberOfShares,
|
||||
r.unitPrice,
|
||||
r.value,
|
||||
r.shareNoFrom || '',
|
||||
r.shareNoTo || '',
|
||||
r.resolutionNumber,
|
||||
r.meetingDate,
|
||||
r.statusLabel,
|
||||
r.paymentStatus
|
||||
]);
|
||||
const csv = [
|
||||
header,
|
||||
...rows
|
||||
]
|
||||
.map((line) => line.map((cell) => `"${String(cell ?? '').replace(/"/g, '""')}"`).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 = 'share-allotment-report.csv';
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
print(): void {
|
||||
window.print();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
<!--
|
||||
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">Share Members</h2>
|
||||
<div class="cert-head-s">
|
||||
Every share member on the register with their holding. Select a row to open that share account.
|
||||
</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 -->
|
||||
<div class="cert-kpis">
|
||||
<div class="cert-kpi cert-kpi--blue">
|
||||
<fa-icon icon="id-card" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">{{ totalMembers() | number }}</div>
|
||||
<div class="cert-kpi-l">Members</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-kpi cert-kpi--teal">
|
||||
<fa-icon icon="book" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">{{ filteredRows.length | 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">{{ totalShares() | number }}</div>
|
||||
<div class="cert-kpi-l">Shares Held</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">₹{{ totalValue() | number: '1.0-0' }}</div>
|
||||
<div class="cert-kpi-l">Share Capital</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="cert-filters">
|
||||
<div class="cert-filter">
|
||||
<label>Member / Membership No / Folio / A/c No</label>
|
||||
<div class="cert-input-ic">
|
||||
<fa-icon icon="search"></fa-icon>
|
||||
<input type="text" [(ngModel)]="search" placeholder="e.g. 000000012 or member name" />
|
||||
</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>Status</label>
|
||||
<select [(ngModel)]="statusFilter">
|
||||
<option value="">All statuses</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="closed">Closed</option>
|
||||
<option value="rejected">Rejected</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button class="cert-clear" (click)="clearFilters()">
|
||||
<fa-icon icon="sync"></fa-icon> Clear
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Register -->
|
||||
<div class="cert-card">
|
||||
@if (loading) {
|
||||
<div class="cert-empty">Loading share members…</div>
|
||||
} @else if (loadError) {
|
||||
<div class="cert-empty">
|
||||
Could not load share members.
|
||||
<button class="cert-cta-btn sm-retry" (click)="loadMembers()">Retry</button>
|
||||
</div>
|
||||
} @else if (!filteredRows.length) {
|
||||
<div class="cert-empty">
|
||||
@if (rows.length) {
|
||||
No share member matches these filters.
|
||||
} @else {
|
||||
No share members on the register yet.
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="cert-table-wrap">
|
||||
<table class="cert-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>S.No</th>
|
||||
<th>Membership No</th>
|
||||
<th>Member Name</th>
|
||||
<th>Folio No</th>
|
||||
<th>Share A/c No</th>
|
||||
<th>Product</th>
|
||||
<th>Branch</th>
|
||||
<th>Address</th>
|
||||
<th>Mobile</th>
|
||||
<th>Date of Admission</th>
|
||||
<th class="num">Shares Held</th>
|
||||
<th class="num">Pending</th>
|
||||
<th class="num">Unit Price</th>
|
||||
<th class="num">Share Value</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of filteredRows; track row.accountId; let i = $index) {
|
||||
<tr
|
||||
class="sm-row"
|
||||
tabindex="0"
|
||||
role="link"
|
||||
matTooltip="Open share account {{ row.shareAccountNo }}"
|
||||
(click)="openAccount(row)"
|
||||
(keydown.enter)="openAccount(row)"
|
||||
(keydown.space)="$event.preventDefault(); openAccount(row)"
|
||||
>
|
||||
<td>{{ i + 1 }}</td>
|
||||
<td class="cert-folio">{{ row.membershipNo }}</td>
|
||||
<td class="cert-cust-name">{{ row.memberName }}</td>
|
||||
<td class="cert-folio">{{ row.folioNo }}</td>
|
||||
<td class="cert-folio">{{ row.shareAccountNo }}</td>
|
||||
<td>{{ row.productName }}</td>
|
||||
<td>{{ row.branch }}</td>
|
||||
<td class="sm-address" [matTooltip]="row.address">{{ row.address }}</td>
|
||||
<td>{{ row.mobileNo }}</td>
|
||||
<td>{{ row.admissionDate ? (row.admissionDate | date: 'd MMM y') : '—' }}</td>
|
||||
<td class="num cert-shares">{{ row.approvedShares | number }}</td>
|
||||
<td class="num">{{ row.pendingShares ? (row.pendingShares | number) : '—' }}</td>
|
||||
<td class="num">₹{{ row.unitPrice | number: '1.0-2' }}</td>
|
||||
<td class="num">₹{{ row.shareValue | number: '1.0-0' }}</td>
|
||||
<td>
|
||||
<span class="cert-pill" [class]="'sm-pill--' + row.statusTone">{{ row.statusLabel }}</span>
|
||||
</td>
|
||||
<td class="cert-row-actions">
|
||||
<fa-icon icon="chevron-right" class="sm-chevron"></fa-icon>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="cert-note">
|
||||
Share Value counts approved shares only — shares still pending approval are held in the Share Sundry GL and are not
|
||||
yet part of share capital.
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
/* Every row navigates, so the whole row reads as the target rather than a lone link cell. */
|
||||
.sm-row {
|
||||
cursor: pointer;
|
||||
transition: background 0.12s ease;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background: color-mix(in srgb, var(--cert-blue) 6%, transparent);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--cert-blue);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* The chevron only earns its space once the row is the one being pointed at. */
|
||||
&:hover .sm-chevron,
|
||||
&:focus-visible .sm-chevron {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.sm-chevron {
|
||||
opacity: 0;
|
||||
color: var(--cert-blue);
|
||||
transition: opacity 0.12s ease;
|
||||
}
|
||||
|
||||
/* Addresses are long and would otherwise set the table's width on their own; the
|
||||
full text stays reachable through the row tooltip. */
|
||||
.sm-address {
|
||||
max-width: 220px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cert-table {
|
||||
.num {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
|
||||
/* Status tones — mapped from the account status rather than typed per row. */
|
||||
.sm-pill--active {
|
||||
background: var(--cert-green-l);
|
||||
color: var(--cert-green);
|
||||
}
|
||||
|
||||
.sm-pill--pending {
|
||||
background: var(--cert-amber-l);
|
||||
color: var(--cert-amber);
|
||||
}
|
||||
|
||||
.sm-pill--closed {
|
||||
background: var(--cert-g100);
|
||||
color: var(--cert-g700);
|
||||
}
|
||||
|
||||
.sm-pill--rejected {
|
||||
background: color-mix(in srgb, #dc2626 12%, transparent);
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.sm-retry {
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
/* Printing the register: drop the chrome and let the table use the full page. */
|
||||
@media print {
|
||||
.cert-filters,
|
||||
.cert-head-r,
|
||||
.cert-kpis,
|
||||
.sm-chevron {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.cert-card {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.sm-address {
|
||||
max-width: none;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.cert-table {
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
/**
|
||||
* 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 { MatTooltip } from '@angular/material/tooltip';
|
||||
import { forkJoin, of, Observable } from 'rxjs';
|
||||
import { catchError, map, shareReplay, switchMap } from 'rxjs/operators';
|
||||
|
||||
import { SharesService } from '../../shares/shares.service';
|
||||
import { ClientsService } from '../../clients/clients.service';
|
||||
|
||||
/**
|
||||
* One share member as this register shows them: the member's identity on the left,
|
||||
* their holding on the right. Kept flat (rather than nesting the raw payload) so the
|
||||
* template can filter on any column without reaching through optional chains.
|
||||
*/
|
||||
export interface ShareMemberRow {
|
||||
/** Share account id — the navigation target, never shown as a column. */
|
||||
accountId: number;
|
||||
clientId: string;
|
||||
/** Client account no, which is what the branch calls the membership number. */
|
||||
membershipNo: string;
|
||||
memberName: string;
|
||||
folioNo: string;
|
||||
shareAccountNo: string;
|
||||
productName: string;
|
||||
branch: string;
|
||||
address: string;
|
||||
mobileNo: string;
|
||||
/** Date of admission — when the member was activated, not when this account opened. */
|
||||
admissionDate: string;
|
||||
approvedShares: number;
|
||||
pendingShares: number;
|
||||
unitPrice: number;
|
||||
shareValue: number;
|
||||
statusLabel: string;
|
||||
/** Drives the status pill's colour class. */
|
||||
statusTone: 'active' | 'pending' | 'closed' | 'rejected';
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-share-member',
|
||||
templateUrl: './share-member.component.html',
|
||||
styleUrls: [
|
||||
'../certificates/certificates.shared.scss',
|
||||
'./share-member.component.scss'
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
FaIconComponent,
|
||||
MatTooltip
|
||||
]
|
||||
})
|
||||
export class ShareMemberComponent implements OnInit {
|
||||
private sharesService = inject(SharesService);
|
||||
private clientsService = inject(ClientsService);
|
||||
private router = inject(Router);
|
||||
|
||||
loading = true;
|
||||
loadError = false;
|
||||
rows: ShareMemberRow[] = [];
|
||||
|
||||
// ── Filters ──
|
||||
search = '';
|
||||
branchFilter = '';
|
||||
productFilter = '';
|
||||
statusFilter = '';
|
||||
|
||||
/**
|
||||
* One member can hold several share accounts, so both client lookups are cached per
|
||||
* client id — otherwise a member with four accounts costs four identical round trips.
|
||||
*/
|
||||
private clientDataCache = new Map<string, Observable<any>>();
|
||||
private clientAddressCache = new Map<string, Observable<any>>();
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadMembers();
|
||||
}
|
||||
|
||||
loadMembers(): void {
|
||||
this.loading = true;
|
||||
this.loadError = false;
|
||||
this.clientDataCache.clear();
|
||||
this.clientAddressCache.clear();
|
||||
|
||||
this.sharesService
|
||||
.getAllShareAccounts(0, 200)
|
||||
.pipe(
|
||||
map((page: any) => page?.pageItems || page?.content || (Array.isArray(page) ? page : [])),
|
||||
switchMap((items: any[]) => {
|
||||
if (!items.length) {
|
||||
return of([] as ShareMemberRow[]);
|
||||
}
|
||||
return forkJoin(items.map((item) => this.buildRow(item))).pipe(
|
||||
map((rows) => rows.filter((r): r is ShareMemberRow => !!r))
|
||||
);
|
||||
}),
|
||||
catchError(() => {
|
||||
this.loadError = true;
|
||||
return of([] as ShareMemberRow[]);
|
||||
})
|
||||
)
|
||||
.subscribe((rows) => {
|
||||
// Newest admissions first: a register is read from the most recent entry back.
|
||||
this.rows = rows.sort((a, b) => (b.admissionDate || '').localeCompare(a.admissionDate || ''));
|
||||
this.loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The list payload already carries clientId, so the account read and both client reads
|
||||
* fire together rather than in sequence. A row whose account read fails is dropped
|
||||
* instead of failing the whole register.
|
||||
*/
|
||||
private buildRow(listItem: any): Observable<ShareMemberRow | null> {
|
||||
const clientId = listItem?.clientId != null ? String(listItem.clientId) : null;
|
||||
return forkJoin({
|
||||
sa: this.sharesService.getSharesAccountData(String(listItem.id), false),
|
||||
client: clientId ? this.getClientDataCached(clientId) : of(null),
|
||||
address: clientId ? this.getClientAddressCached(clientId) : of([])
|
||||
}).pipe(
|
||||
map(({ sa, client, address }: any) => this.mapToRow(sa, client, address)),
|
||||
catchError(() => of(null))
|
||||
);
|
||||
}
|
||||
|
||||
private getClientDataCached(clientId: string): Observable<any> {
|
||||
if (!this.clientDataCache.has(clientId)) {
|
||||
this.clientDataCache.set(
|
||||
clientId,
|
||||
this.clientsService.getClientData(clientId, true).pipe(
|
||||
catchError(() => of(null)),
|
||||
shareReplay(1)
|
||||
)
|
||||
);
|
||||
}
|
||||
return this.clientDataCache.get(clientId)!;
|
||||
}
|
||||
|
||||
private getClientAddressCached(clientId: string): Observable<any> {
|
||||
if (!this.clientAddressCache.has(clientId)) {
|
||||
this.clientAddressCache.set(
|
||||
clientId,
|
||||
this.clientsService.getClientAddressData(clientId, true).pipe(
|
||||
catchError(() => of([])),
|
||||
shareReplay(1)
|
||||
)
|
||||
);
|
||||
}
|
||||
return this.clientAddressCache.get(clientId)!;
|
||||
}
|
||||
|
||||
private mapToRow(sa: any, client: any, address: any): ShareMemberRow {
|
||||
const approvedShares = sa.summary?.totalApprovedShares || 0;
|
||||
const pendingShares = sa.summary?.totalPendingForApprovalShares || 0;
|
||||
const unitPrice = sa.currentMarketPrice || sa.unitPrice || 0;
|
||||
const clientId = String(sa.clientId ?? client?.id ?? '');
|
||||
const statusValue = sa.status?.value || '';
|
||||
|
||||
return {
|
||||
accountId: sa.id,
|
||||
clientId,
|
||||
membershipNo: client?.accountNo || sa.clientAccountNo || '—',
|
||||
memberName: sa.clientName || client?.displayName || '—',
|
||||
folioNo: `F-${client?.accountNo || String(client?.id ?? sa.clientId ?? '').padStart(5, '0')}`,
|
||||
shareAccountNo: sa.accountNo || String(sa.id),
|
||||
productName: sa.productName || '—',
|
||||
branch: client?.officeName || sa.officeName || '—',
|
||||
address: this.firstAddress(address),
|
||||
mobileNo: client?.mobileNo || '—',
|
||||
// Admission is the member's activation with the society, which is what the
|
||||
// Membership Register records — the share account may be opened much later.
|
||||
admissionDate:
|
||||
this.toIsoDate(client?.activationDate) ||
|
||||
this.toIsoDate(client?.timeline?.activatedOnDate) ||
|
||||
this.toIsoDate(sa.timeline?.activatedDate) ||
|
||||
'',
|
||||
approvedShares,
|
||||
pendingShares,
|
||||
unitPrice,
|
||||
// Only approved shares carry value: pending ones are still sitting in Sundry.
|
||||
shareValue: approvedShares * unitPrice,
|
||||
statusLabel: statusValue || '—',
|
||||
statusTone: this.toneFor(sa.status)
|
||||
};
|
||||
}
|
||||
|
||||
private toneFor(status: any): ShareMemberRow['statusTone'] {
|
||||
if (status?.rejected) return 'rejected';
|
||||
if (status?.closed) return 'closed';
|
||||
if (status?.active) return 'active';
|
||||
const v = String(status?.value || '').toLowerCase();
|
||||
if (v.includes('reject')) return 'rejected';
|
||||
if (v.includes('close')) return 'closed';
|
||||
if (v.includes('active')) return 'active';
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
/** Fineract returns dates as [y, m, d] tuples on most reads and ISO strings on some. */
|
||||
private toIsoDate(value: any): string {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
const [
|
||||
y,
|
||||
m,
|
||||
d
|
||||
] = value;
|
||||
if (!y) {
|
||||
return '';
|
||||
}
|
||||
return `${y}-${String(m || 1).padStart(2, '0')}-${String(d || 1).padStart(2, '0')}`;
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
private firstAddress(address: any): string {
|
||||
const list = Array.isArray(address) ? address : address?.pageItems || [];
|
||||
if (!list.length) {
|
||||
return '—';
|
||||
}
|
||||
const a = list[0];
|
||||
const parts = [
|
||||
a.addressLine1,
|
||||
a.addressLine2,
|
||||
a.townVillage,
|
||||
a.city,
|
||||
a.stateName,
|
||||
a.countryName,
|
||||
a.postalCode
|
||||
]
|
||||
.filter((p: any) => !!p && String(p).trim())
|
||||
.join(', ');
|
||||
return parts || '—';
|
||||
}
|
||||
|
||||
// ── Derived list ──
|
||||
get filteredRows(): ShareMemberRow[] {
|
||||
const q = this.search.trim().toLowerCase();
|
||||
return this.rows.filter((r) => {
|
||||
if (this.branchFilter && r.branch !== this.branchFilter) return false;
|
||||
if (this.productFilter && r.productName !== this.productFilter) return false;
|
||||
if (this.statusFilter && r.statusTone !== this.statusFilter) return false;
|
||||
if (!q) return true;
|
||||
return (
|
||||
r.memberName.toLowerCase().includes(q) ||
|
||||
r.membershipNo.toLowerCase().includes(q) ||
|
||||
r.shareAccountNo.toLowerCase().includes(q) ||
|
||||
r.folioNo.toLowerCase().includes(q) ||
|
||||
r.mobileNo.toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
get branchOptions(): string[] {
|
||||
return Array.from(new Set(this.rows.map((r) => r.branch).filter((b) => b && b !== '—'))).sort();
|
||||
}
|
||||
|
||||
get productOptions(): string[] {
|
||||
return Array.from(new Set(this.rows.map((r) => r.productName).filter((p) => p && p !== '—'))).sort();
|
||||
}
|
||||
|
||||
/** Distinct members, not accounts — one member holding three accounts counts once. */
|
||||
totalMembers(): number {
|
||||
return new Set(this.filteredRows.map((r) => r.clientId)).size;
|
||||
}
|
||||
|
||||
totalShares(): number {
|
||||
return this.filteredRows.reduce((s, r) => s + r.approvedShares, 0);
|
||||
}
|
||||
|
||||
totalValue(): number {
|
||||
return this.filteredRows.reduce((s, r) => s + r.shareValue, 0);
|
||||
}
|
||||
|
||||
clearFilters(): void {
|
||||
this.search = '';
|
||||
this.branchFilter = '';
|
||||
this.productFilter = '';
|
||||
this.statusFilter = '';
|
||||
}
|
||||
|
||||
/** Opens the member's share account — the same view the client's own tab links to. */
|
||||
openAccount(row: ShareMemberRow): void {
|
||||
this.router.navigate([
|
||||
'/clients',
|
||||
row.clientId,
|
||||
'shares-accounts',
|
||||
row.accountId,
|
||||
'general'
|
||||
]);
|
||||
}
|
||||
|
||||
exportCsv(): void {
|
||||
const header = [
|
||||
'S.No',
|
||||
'Membership No',
|
||||
'Member Name',
|
||||
'Folio No',
|
||||
'Share A/c No',
|
||||
'Product',
|
||||
'Branch',
|
||||
'Address',
|
||||
'Mobile',
|
||||
'Date of Admission',
|
||||
'Shares Held',
|
||||
'Pending Shares',
|
||||
'Unit Price',
|
||||
'Share Value',
|
||||
'Status'
|
||||
];
|
||||
const rows = this.filteredRows.map((r, i) => [
|
||||
i + 1,
|
||||
r.membershipNo,
|
||||
r.memberName,
|
||||
r.folioNo,
|
||||
r.shareAccountNo,
|
||||
r.productName,
|
||||
r.branch,
|
||||
r.address,
|
||||
r.mobileNo,
|
||||
r.admissionDate,
|
||||
r.approvedShares,
|
||||
r.pendingShares,
|
||||
r.unitPrice,
|
||||
r.shareValue,
|
||||
r.statusLabel
|
||||
]);
|
||||
const csv = [
|
||||
header,
|
||||
...rows
|
||||
]
|
||||
.map((line) => line.map((cell) => `"${String(cell ?? '').replace(/"/g, '""')}"`).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 = 'share-members.csv';
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
print(): void {
|
||||
window.print();
|
||||
}
|
||||
}
|
||||
@@ -213,19 +213,11 @@ export class ProductsService {
|
||||
* @param ratePercentage Rate per annum.
|
||||
* @returns {Observable<any>} `{ totalAmount, products: [{ productName, amount, eligible, reason }] }`.
|
||||
*/
|
||||
previewAllProductsDividend(
|
||||
fromDate: string,
|
||||
toDate: string,
|
||||
ratePercentage: number,
|
||||
dividendBasis: string
|
||||
): Observable<any> {
|
||||
previewAllProductsDividend(fromDate: string, toDate: string, ratePercentage: number): Observable<any> {
|
||||
const params: any = { fromDate, toDate };
|
||||
if (ratePercentage != null) {
|
||||
params.ratePercentage = ratePercentage;
|
||||
}
|
||||
if (dividendBasis) {
|
||||
params.dividendBasis = dividendBasis;
|
||||
}
|
||||
return this.http.get('/shareproducts/dividends/preview', { params });
|
||||
}
|
||||
|
||||
@@ -305,6 +297,15 @@ export class ProductsService {
|
||||
return this.http.get(`/shareproduct/${shareProductId}/dividend/${dividendId}/rounding-residue`);
|
||||
}
|
||||
|
||||
/**
|
||||
* The heading of a Share Dividend Distribution Report: the rate the board resolved, what it came
|
||||
* to, and the date it became payable. Those live on the declaration rather than on any member
|
||||
* row, so they are fetched once rather than repeated across the breakdown.
|
||||
*/
|
||||
getDividendReportHeader(dividendId: any): Observable<any> {
|
||||
return this.http.get(`/clerk/dividend-dashboard/declaration/${dividendId}/report-header`);
|
||||
}
|
||||
|
||||
/** Every declaration's rounding residue for one product, newest first, with a running total. */
|
||||
getDividendRoundingResidueHistory(shareProductId: any): Observable<any> {
|
||||
return this.http.get(`/shareproduct/${shareProductId}/dividend/rounding-residue`);
|
||||
|
||||
+21
-3
@@ -182,10 +182,28 @@ export class AllProductsDividendsComponent implements OnInit {
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a declaration in the existing per-product view, which holds the member-by-member
|
||||
* allocation and the approve action.
|
||||
* Opens a declaration where the officer can actually act on it.
|
||||
*
|
||||
* One still awaiting approval goes to the Approval Centre, because approving is what the officer
|
||||
* came to do and that is where the approve action lives alongside every other pending
|
||||
* declaration — landing them on a read-only page they then have to navigate away from is a
|
||||
* detour. The declaration is named in the query string so the Centre can open its member-by-member
|
||||
* breakdown straight away rather than making them find the row again.
|
||||
*
|
||||
* One already approved has nothing to act on, so it goes to the per-product view to be read.
|
||||
*/
|
||||
viewDividend(row: any): void {
|
||||
if (!row.approved) {
|
||||
this.router.navigate(
|
||||
[
|
||||
'/checker-inbox-and-tasks',
|
||||
'dividend-requests'
|
||||
],
|
||||
{ queryParams: { productId: row.productId, dividendId: row.dividendId } }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.router.navigate(
|
||||
[
|
||||
'/products',
|
||||
@@ -194,7 +212,7 @@ export class AllProductsDividendsComponent implements OnInit {
|
||||
'dividends',
|
||||
row.dividendId
|
||||
],
|
||||
{ queryParams: { status: row.approved ? 'Approved' : 'Initiated' } }
|
||||
{ queryParams: { status: 'Approved' } }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+44
-66
@@ -12,7 +12,7 @@
|
||||
<div class="pl-header-ic"><fa-icon icon="hand-holding-usd"></fa-icon></div>
|
||||
<div class="pl-header-text">
|
||||
<h2>Declare Dividend</h2>
|
||||
<p>One rate for the year, applied to every share product at once.</p>
|
||||
<p>One rate for the year, applied to every member’s share investment.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -70,34 +70,15 @@
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="dd-basis">
|
||||
<span class="dd-basis-label">Dividend Basis</span>
|
||||
<mat-radio-group formControlName="dividendBasis">
|
||||
<mat-radio-button value="RATE_ON_CAPITAL">Rate on share capital</mat-radio-button>
|
||||
<mat-radio-button value="PERCENT_OF_PROFIT">Percentage of distributable profit</mat-radio-button>
|
||||
</mat-radio-group>
|
||||
<p class="dd-hint">
|
||||
@if (declareForm.value.dividendBasis === 'PERCENT_OF_PROFIT') {
|
||||
A share of the year's distributable profit, split across products in proportion to the capital each
|
||||
carries.
|
||||
} @else {
|
||||
Each member receives this rate on their own paid-up capital, pro-rated for how long they held it.
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="dd-fields">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>
|
||||
{{
|
||||
declareForm.value.dividendBasis === 'PERCENT_OF_PROFIT'
|
||||
? 'Percentage of Profit'
|
||||
: 'Dividend Rate (% per annum)'
|
||||
}}
|
||||
</mat-label>
|
||||
<mat-label>Dividend Rate (% per annum)</mat-label>
|
||||
<input matInput type="number" step="0.01" min="0.01" max="100" formControlName="ratePercentage" />
|
||||
<span matSuffix> % </span>
|
||||
</mat-form-field>
|
||||
<p class="dd-hint">
|
||||
Applied to each member's own share investment, pro-rated for how long they held it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@if (loadingPreview) {
|
||||
@@ -109,51 +90,62 @@
|
||||
}
|
||||
|
||||
@if (preview && !loadingPreview) {
|
||||
<!-- The appropriation waterfall. Shown in full because the officer is approving a
|
||||
division of the society's profit, not a single number: ₹2,22,000 on its own gives no
|
||||
way to tell a small profit from a large one already heavily appropriated, and the
|
||||
reserve and education rates are configurable so their amounts cannot be inferred. -->
|
||||
<!-- The calculation itself: share investment x rate, and nothing else. Profit does not
|
||||
size the dividend, so it does not appear here — it is reported below only as the
|
||||
account the payout is drawn from. -->
|
||||
<div class="dd-preview">
|
||||
<div class="dd-preview-head">Where the money comes from</div>
|
||||
<div class="dd-preview-head">How the dividend is worked out</div>
|
||||
|
||||
<div class="dd-preview-row">
|
||||
<span>
|
||||
Net profit for {{ preview.financialYear }}
|
||||
<span class="dd-gl">{{ preview.profitGlCode }}</span>
|
||||
</span>
|
||||
<span class="dd-amount">{{ preview.netProfit | number: '1.2-2' }}</span>
|
||||
<span>Share investment (weighted for holding period)</span>
|
||||
<span class="dd-amount">{{ preview.totalWeightedCapital | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="dd-preview-row dd-preview-row--deduction">
|
||||
<span>Less statutory reserve ({{ preview.statutoryReservePercentage | number: '1.0-2' }}%)</span>
|
||||
<span class="dd-amount">− {{ preview.statutoryReserveAmount | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="dd-preview-row dd-preview-row--deduction">
|
||||
<span>Less education fund ({{ preview.educationFundPercentage | number: '1.0-2' }}%)</span>
|
||||
<span class="dd-amount">− {{ preview.educationFundAmount | number: '1.2-2' }}</span>
|
||||
<div class="dd-preview-row dd-preview-row--muted">
|
||||
<span>Dividend rate</span>
|
||||
<span class="dd-amount">{{ declareForm.value.ratePercentage || 0 }}%</span>
|
||||
</div>
|
||||
|
||||
<div class="dd-preview-row dd-preview-row--subtotal">
|
||||
<span>Distributable profit</span>
|
||||
<span class="dd-amount">{{ preview.distributableProfit | number: '1.2-2' }}</span>
|
||||
<span>Dividend payable</span>
|
||||
<span class="dd-amount">{{ preview.totalAmount | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (declareForm.value.dividendBasis === 'RATE_ON_CAPITAL') {
|
||||
<!-- Where the money is drawn from. A funding position, not a basis: the amount above is
|
||||
already fixed by capital, and this only says whether the profit account can pay it. -->
|
||||
<div class="dd-preview">
|
||||
<div class="dd-preview-head">Funded from</div>
|
||||
|
||||
<div class="dd-preview-row">
|
||||
<span>
|
||||
Bank profit for {{ preview.financialYear }}
|
||||
<span class="dd-gl">{{ preview.profitGlCode }}</span>
|
||||
</span>
|
||||
<span class="dd-amount">{{ preview.fundingAvailable | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="dd-preview-row dd-preview-row--deduction">
|
||||
<span>Less dividend payable</span>
|
||||
<span class="dd-amount">− {{ preview.totalAmount | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
|
||||
@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.
|
||||
</div>
|
||||
} @else {
|
||||
<div class="dd-preview-row dd-preview-row--muted">
|
||||
<span>Weighted share capital</span>
|
||||
<span class="dd-amount">{{ preview.totalWeightedCapital | number: '1.2-2' }}</span>
|
||||
<span>Left in profit (swept to Bank Profit on approval)</span>
|
||||
<span class="dd-amount">{{ preview.undistributedRemainder | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="dd-preview">
|
||||
<div class="dd-preview-head">
|
||||
{{
|
||||
declareForm.value.dividendBasis === 'PERCENT_OF_PROFIT'
|
||||
? (declareForm.value.ratePercentage || 0) + '% of distributable profit, split by capital'
|
||||
: (declareForm.value.ratePercentage || 0) + '% on each member’s capital'
|
||||
}}
|
||||
{{ declareForm.value.ratePercentage || 0 }}% on each member’s share investment
|
||||
</div>
|
||||
|
||||
@for (product of eligibleProducts; track product.productId) {
|
||||
@@ -168,13 +160,6 @@
|
||||
<span>Total to declare</span>
|
||||
<span class="dd-amount">{{ payableTotal | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
|
||||
<!-- What stays behind. Called out because on approval it leaves the distributable
|
||||
pool for good — no later year's dividend can draw on it. -->
|
||||
<div class="dd-preview-row dd-preview-row--muted">
|
||||
<span>Stays in profit (swept to Bank Profit on approval)</span>
|
||||
<span class="dd-amount">{{ preview.undistributedRemainder | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@for (product of skippedProducts; track product.productId) {
|
||||
@@ -186,13 +171,6 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (preview.exceedsDistributableProfit) {
|
||||
<div class="dd-note dd-note--error">
|
||||
This is more than the distributable profit for {{ preview.financialYear }}. The declaration will be
|
||||
rejected.
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (eligibleProducts.length === 0) {
|
||||
<div class="dd-note dd-note--error">
|
||||
No share product is eligible for this year, so there is nothing to declare.
|
||||
|
||||
+2
-24
@@ -47,36 +47,14 @@ form {
|
||||
}
|
||||
}
|
||||
|
||||
/* Sits directly under the rate field, where it explains what the number just typed applies to. */
|
||||
.dd-hint {
|
||||
flex: 1 1 100%;
|
||||
margin: 0 0 16px;
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
/* Which basis is chosen changes what the percentage means, so it sits above the rate field rather
|
||||
than below it — the officer picks the meaning before typing the number. */
|
||||
.dd-basis {
|
||||
margin: 4px 0 8px;
|
||||
|
||||
mat-radio-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.dd-hint {
|
||||
margin: 6px 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
.dd-basis-label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
/* The cost of the rate, per product. The officer is committing to a rate, but what leaves the
|
||||
society is an amount — so the amount is shown before the commit, not after. */
|
||||
.dd-preview {
|
||||
|
||||
+17
-10
@@ -16,7 +16,6 @@ import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatOptionModule } from '@angular/material/core';
|
||||
import { MatRadioModule } from '@angular/material/radio';
|
||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
|
||||
@@ -31,6 +30,10 @@ import { SettingsService } from '../../../../settings/settings.service';
|
||||
* much capital each product's members held and for how long. So the officer enters the rate and the
|
||||
* year, and the preview shows what it works out to per product before anything is committed. That
|
||||
* preview is the point of the screen: a rate looks harmless until you see the amount it implies.
|
||||
*
|
||||
* The rate applies to members' share investment and to nothing else. Profit does not size the
|
||||
* dividend — it only funds it, so the screen leads with capital x rate and reports the profit
|
||||
* account only as the source the payout is drawn from.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-declare-dividend',
|
||||
@@ -44,7 +47,6 @@ import { SettingsService } from '../../../../settings/settings.service';
|
||||
MatInputModule,
|
||||
MatSelectModule,
|
||||
MatOptionModule,
|
||||
MatRadioModule,
|
||||
MatProgressBarModule,
|
||||
FaIconComponent
|
||||
]
|
||||
@@ -77,9 +79,6 @@ export class DeclareDividendComponent implements OnInit {
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
// Rate on capital is the default because it is what a dividend rate means in an AGM
|
||||
// resolution: each member receives that rate on their own capital, not a slice of a pool.
|
||||
dividendBasis: ['RATE_ON_CAPITAL'],
|
||||
ratePercentage: [
|
||||
'',
|
||||
[
|
||||
@@ -131,9 +130,7 @@ export class DeclareDividendComponent implements OnInit {
|
||||
|
||||
this.loadingPreview = true;
|
||||
this.previewError = null;
|
||||
this.productsService
|
||||
.previewAllProductsDividend(year.start, year.end, rate, this.declareForm.value.dividendBasis)
|
||||
.subscribe({
|
||||
this.productsService.previewAllProductsDividend(year.start, year.end, rate).subscribe({
|
||||
next: (response: any) => {
|
||||
this.preview = response;
|
||||
this.loadingPreview = false;
|
||||
@@ -163,8 +160,19 @@ export class DeclareDividendComponent implements OnInit {
|
||||
return this.eligibleProducts.reduce((sum: number, p: any) => sum + Number(p.amount || 0), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the profit account can cover what this rate works out to.
|
||||
*
|
||||
* The rate decides the amount; profit only has to be able to pay it. Surfaced here so the officer
|
||||
* sees the shortfall on screen rather than having the command layer reject the declaration after
|
||||
* they have already committed to it — the same ceiling is enforced there either way.
|
||||
*/
|
||||
get isUnderfunded(): boolean {
|
||||
return !!this.preview?.exceedsDistributableProfit;
|
||||
}
|
||||
|
||||
get canSubmit(): boolean {
|
||||
return this.declareForm.valid && !this.submitting && this.eligibleProducts.length > 0;
|
||||
return this.declareForm.valid && !this.submitting && this.eligibleProducts.length > 0 && !this.isUnderfunded;
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
@@ -179,7 +187,6 @@ export class DeclareDividendComponent implements OnInit {
|
||||
.createAllProductsDividend({
|
||||
dividendPeriodStartDate: year.start,
|
||||
dividendPeriodEndDate: year.end,
|
||||
dividendBasis: this.declareForm.value.dividendBasis,
|
||||
ratePercentage: String(this.declareForm.value.ratePercentage)
|
||||
})
|
||||
.subscribe({
|
||||
|
||||
@@ -80,30 +80,6 @@
|
||||
}
|
||||
<span></span>
|
||||
</div>
|
||||
@if (profitBreakdown.statutoryReservePercentage > 0) {
|
||||
<div class="dividend-waterfall__row dividend-waterfall__row--deduction">
|
||||
<span
|
||||
>− {{ 'labels.inputs.Statutory Reserve Fund' | translate }} ({{
|
||||
profitBreakdown.statutoryReservePercentage
|
||||
}}%)</span
|
||||
>
|
||||
<span>{{ profitBreakdown.statutoryReserveAmount | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (profitBreakdown.educationFundPercentage > 0) {
|
||||
<div class="dividend-waterfall__row dividend-waterfall__row--deduction">
|
||||
<span
|
||||
>− {{ 'labels.inputs.Education Fund' | translate }} ({{
|
||||
profitBreakdown.educationFundPercentage
|
||||
}}%)</span
|
||||
>
|
||||
<span>{{ profitBreakdown.educationFundAmount | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
}
|
||||
<div class="dividend-waterfall__row dividend-waterfall__row--subtotal">
|
||||
<span>{{ 'labels.inputs.Distributable Profit' | translate }}</span>
|
||||
<span>{{ profitBreakdown.distributableProfit | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
@if (profitBreakdown.alreadyDeclared > 0) {
|
||||
<div class="dividend-waterfall__row dividend-waterfall__row--deduction">
|
||||
<span>− {{ 'labels.inputs.Already declared for other products' | translate }}</span>
|
||||
@@ -126,40 +102,9 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Which question the percentage below answers. RATE_ON_CAPITAL is the default because
|
||||
it is what a rate in an AGM resolution promises each member. -->
|
||||
<div class="dividend-mode">
|
||||
<span class="dividend-mode__label">{{ 'labels.inputs.Dividend Basis' | translate }}</span>
|
||||
<mat-radio-group [value]="dividendMode" (change)="onModeChange($event.value)">
|
||||
<mat-radio-button value="RATE_ON_CAPITAL">
|
||||
{{ 'labels.inputs.Rate on share capital' | translate }}
|
||||
</mat-radio-button>
|
||||
<mat-radio-button value="PERCENT_OF_PROFIT">
|
||||
{{ 'labels.inputs.Percentage of distributable profit' | translate }}
|
||||
</mat-radio-button>
|
||||
</mat-radio-group>
|
||||
<p class="dividend-mode__hint">
|
||||
@if (dividendMode === 'RATE_ON_CAPITAL') {
|
||||
{{
|
||||
'labels.inputs.Each member receives this rate on their own paid-up capital, pro-rated for how long they held it'
|
||||
| translate
|
||||
}}.
|
||||
} @else {
|
||||
{{
|
||||
'labels.inputs.This share of distributable profit is split across members by holding period — it is not the rate any individual member receives'
|
||||
| translate
|
||||
}}.
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<mat-form-field>
|
||||
<mat-label>
|
||||
@if (dividendMode === 'RATE_ON_CAPITAL') {
|
||||
{{ 'labels.inputs.Dividend Rate' | translate }} (% {{ 'labels.inputs.per annum' | translate }})
|
||||
} @else {
|
||||
{{ 'labels.inputs.Dividend Percentage' | translate }} (%)
|
||||
}
|
||||
</mat-label>
|
||||
<input matInput type="number" min="0" max="100" step="0.01" required formControlName="dividendPercentage" />
|
||||
@if (createDividendForm.controls.dividendPercentage.hasError('required')) {
|
||||
@@ -170,15 +115,19 @@
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<p class="dividend-mode__hint">
|
||||
{{
|
||||
'labels.inputs.Each member receives this rate on their own paid-up capital, pro-rated for how long they held it'
|
||||
| translate
|
||||
}}.
|
||||
</p>
|
||||
|
||||
@if (rateCeilingError) {
|
||||
<p class="dividend-profit-note dividend-profit-note--error">{{ rateCeilingError }}</p>
|
||||
}
|
||||
|
||||
@if (dividendMode === 'RATE_ON_CAPITAL') {
|
||||
@if (loadingCapital) {
|
||||
<p class="dividend-profit-note">
|
||||
{{ 'labels.inputs.Calculating eligible share capital' | translate }}...
|
||||
</p>
|
||||
<p class="dividend-profit-note">{{ 'labels.inputs.Calculating eligible share capital' | translate }}...</p>
|
||||
} @else if (capitalLookupError) {
|
||||
<p class="dividend-profit-note dividend-profit-note--error">
|
||||
{{ 'labels.inputs.Could not calculate eligible share capital for this period' | translate }}.
|
||||
@@ -205,7 +154,6 @@
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<mat-form-field>
|
||||
<mat-label
|
||||
|
||||
@@ -57,29 +57,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
.dividend-mode {
|
||||
/* Sits under the rate field, saying what the number just typed applies to. */
|
||||
.dividend-mode__hint {
|
||||
margin: 0 0 12px;
|
||||
|
||||
&__label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #344054;
|
||||
}
|
||||
|
||||
mat-radio-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
&__hint {
|
||||
margin: 6px 0 0;
|
||||
font-size: 12px;
|
||||
color: #667085;
|
||||
}
|
||||
}
|
||||
|
||||
.dividend-profit-note {
|
||||
margin: 0 0 12px;
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||
import { MatRadioGroup, MatRadioButton } from '@angular/material/radio';
|
||||
import { DecimalPipe } from '@angular/common';
|
||||
import { Dates } from 'app/core/utils/dates';
|
||||
|
||||
@@ -29,8 +28,6 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
styleUrls: ['./create-dividend.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
MatRadioGroup,
|
||||
MatRadioButton,
|
||||
DecimalPipe
|
||||
]
|
||||
})
|
||||
@@ -53,9 +50,10 @@ export class CreateDividendComponent implements OnInit {
|
||||
maxDate = new Date();
|
||||
|
||||
/**
|
||||
* The full appropriation waterfall for the selected period, from the backend: net profit (posted
|
||||
* GL journal entries), less the statutory appropriations that legally precede a dividend, less
|
||||
* dividends already declared for other share products over an overlapping period.
|
||||
* The profit available to fund a dividend for the selected period, from the backend: the profit
|
||||
* standing in the GL, less dividends already declared for other share products over an
|
||||
* overlapping period. The statutory reserve and education fund are not part of this — they are
|
||||
* appropriated on the profit-distribution screen before a dividend is ever declared.
|
||||
*/
|
||||
profitBreakdown: {
|
||||
financialYear: string;
|
||||
@@ -64,10 +62,6 @@ export class CreateDividendComponent implements OnInit {
|
||||
yearClosed: boolean;
|
||||
profitSource: 'BANK_PROFIT_GL' | 'OPEN_PROFIT_AND_LOSS';
|
||||
netProfit: number;
|
||||
statutoryReservePercentage: number;
|
||||
statutoryReserveAmount: number;
|
||||
educationFundPercentage: number;
|
||||
educationFundAmount: number;
|
||||
distributableProfit: number;
|
||||
alreadyDeclared: number;
|
||||
availableForDeclaration: number;
|
||||
@@ -85,8 +79,8 @@ export class CreateDividendComponent implements OnInit {
|
||||
profitLookupError = false;
|
||||
|
||||
/**
|
||||
* Weighted paid-up capital for the period, for RATE_ON_CAPITAL mode. `requiredAmount` is what the
|
||||
* entered rate actually costs to honour.
|
||||
* Weighted paid-up capital for the period — the only thing the dividend is calculated from.
|
||||
* `requiredAmount` is what the entered rate actually costs to honour.
|
||||
*/
|
||||
capitalPreview: {
|
||||
weightedCapital: number;
|
||||
@@ -98,13 +92,15 @@ export class CreateDividendComponent implements OnInit {
|
||||
capitalLookupError = false;
|
||||
|
||||
/**
|
||||
* RATE_ON_CAPITAL is the default because it is what a dividend rate means in an AGM resolution:
|
||||
* each member receives the declared rate on their own paid-up capital, pro-rated for how long
|
||||
* they held it. PERCENT_OF_PROFIT sizes the pool as a share of profit instead — legitimate when
|
||||
* the committee decides "distribute this much of the profit", but the resulting per-member rate
|
||||
* then depends on how many other members hold shares, so it is not the rate members are promised.
|
||||
* A dividend is a rate on each member's own share investment, and nothing else.
|
||||
*
|
||||
* This is what a rate means in an AGM resolution: each member receives the declared rate on their
|
||||
* own paid-up capital, pro-rated for how long they held it. Sizing the pool as a share of profit
|
||||
* instead was dropped — the resulting per-member rate depends on how many other members hold
|
||||
* shares, so it is not the rate members are promised. Profit no longer sizes the dividend; it
|
||||
* only funds it, which is what {@link ceilingError} checks.
|
||||
*/
|
||||
dividendMode: 'RATE_ON_CAPITAL' | 'PERCENT_OF_PROFIT' = 'RATE_ON_CAPITAL';
|
||||
readonly dividendMode = 'RATE_ON_CAPITAL';
|
||||
|
||||
/**
|
||||
* Financial years a dividend can be declared for, newest first.
|
||||
@@ -302,10 +298,6 @@ export class CreateDividendComponent implements OnInit {
|
||||
yearClosed: !!response.yearClosed,
|
||||
profitSource: response.profitSource,
|
||||
netProfit: Number(response.netProfit) || 0,
|
||||
statutoryReservePercentage: Number(response.statutoryReservePercentage) || 0,
|
||||
statutoryReserveAmount: Number(response.statutoryReserveAmount) || 0,
|
||||
educationFundPercentage: Number(response.educationFundPercentage) || 0,
|
||||
educationFundAmount: Number(response.educationFundAmount) || 0,
|
||||
distributableProfit: Number(response.distributableProfit) || 0,
|
||||
alreadyDeclared: Number(response.alreadyDeclared) || 0,
|
||||
availableForDeclaration: Number(response.availableForDeclaration) || 0,
|
||||
@@ -358,22 +350,13 @@ export class CreateDividendComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
/** Switches between the two ways of sizing the pool and recomputes under the new one. */
|
||||
onModeChange(mode: 'RATE_ON_CAPITAL' | 'PERCENT_OF_PROFIT'): void {
|
||||
this.dividendMode = mode;
|
||||
this.recomputeDividendAmount();
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the dividend amount under whichever mode is selected, then checks it against both
|
||||
* ceilings — the rate cap in the governing Act, and the profit still available to distribute.
|
||||
* Computes the dividend amount — weighted paid-up capital x rate — then checks it against both
|
||||
* ceilings: the rate cap in the governing Act, and the profit still available to fund it.
|
||||
*
|
||||
* RATE_ON_CAPITAL: amount = weighted paid-up capital x rate. Every member then receives that rate
|
||||
* on their own capital, pro-rated by holding period.
|
||||
*
|
||||
* PERCENT_OF_PROFIT: amount = distributable profit x percentage. Note this is applied to the
|
||||
* DISTRIBUTABLE figure, not raw net profit — the statutory appropriations come out first either
|
||||
* way.
|
||||
* Every member receives the declared rate on their own capital, pro-rated by holding period, so
|
||||
* the amount follows entirely from what members invested. Profit does not enter the arithmetic;
|
||||
* it only has to be large enough to pay the result.
|
||||
*/
|
||||
recomputeDividendAmount(): void {
|
||||
const percentage = Number(this.createDividendForm.get('dividendPercentage').value);
|
||||
@@ -388,36 +371,27 @@ export class CreateDividendComponent implements OnInit {
|
||||
return;
|
||||
}
|
||||
|
||||
// The rate cap governs a rate promised on capital. Under PERCENT_OF_PROFIT the number entered
|
||||
// is a share of profit, not a rate any member receives, so the cap does not apply to it.
|
||||
// The rate cap governs the rate promised on capital, which is exactly what is entered here.
|
||||
const maximumRate = this.profitBreakdown?.maximumRatePercentage ?? 0;
|
||||
if (this.dividendMode === 'RATE_ON_CAPITAL' && maximumRate > 0 && percentage > maximumRate) {
|
||||
if (maximumRate > 0 && percentage > maximumRate) {
|
||||
this.rateCeilingError = `The maximum dividend rate permitted is ${maximumRate}%. Reduce the rate to declare this dividend.`;
|
||||
}
|
||||
|
||||
let amount: number | null = null;
|
||||
if (this.dividendMode === 'RATE_ON_CAPITAL') {
|
||||
if (this.capitalPreview === null) {
|
||||
this.createDividendForm.get('dividendAmount').setValue('');
|
||||
return;
|
||||
}
|
||||
amount = (this.capitalPreview.weightedCapital * percentage) / 100;
|
||||
this.capitalPreview.requiredAmount = Math.round(amount * 100) / 100;
|
||||
} else {
|
||||
if (this.profitBreakdown === null) {
|
||||
this.createDividendForm.get('dividendAmount').setValue('');
|
||||
return;
|
||||
}
|
||||
amount = (this.profitBreakdown.distributableProfit * percentage) / 100;
|
||||
}
|
||||
|
||||
const amount = (this.capitalPreview.weightedCapital * percentage) / 100;
|
||||
const rounded = Math.round(amount * 100) / 100;
|
||||
this.capitalPreview.requiredAmount = rounded;
|
||||
this.createDividendForm.get('dividendAmount').setValue(rounded);
|
||||
|
||||
// Not a limit on the calculation — a limit on what the profit account can fund. The amount is
|
||||
// already fixed by capital; this only says whether there is enough profit to pay it.
|
||||
if (this.profitBreakdown && rounded > this.profitBreakdown.availableForDeclaration) {
|
||||
this.ceilingError =
|
||||
`This dividend of ${rounded.toFixed(2)} exceeds the ${this.profitBreakdown.availableForDeclaration.toFixed(2)} ` +
|
||||
`still available to distribute for this period. Reduce the rate, or declare for a period with more profit.`;
|
||||
`still available to fund it for this period. Reduce the rate, or declare for a period with more profit.`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,11 @@
|
||||
>
|
||||
</div>
|
||||
<div class="vd-header__actions">
|
||||
<!-- Standalone, this is a read-only breakdown and the button hands off to the Approval
|
||||
Centre, so it is labelled for where it goes and needs no approve permission to press.
|
||||
Embedded, this IS the Approval Centre's breakdown, so it approves in place. -->
|
||||
@if (!isdividendPosted) {
|
||||
@if (embedded) {
|
||||
<button
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
@@ -28,7 +32,19 @@
|
||||
<fa-icon icon="check" class="m-r-10"></fa-icon>
|
||||
{{ 'labels.buttons.Approve' | translate }}
|
||||
</button>
|
||||
} @else {
|
||||
<button mat-raised-button color="primary" class="vd-approve-btn" (click)="postDividends()">
|
||||
<fa-icon icon="arrow-right" class="m-r-10"></fa-icon>
|
||||
Go to Approval Centre
|
||||
</button>
|
||||
}
|
||||
}
|
||||
<!-- The distribution report: the heading the board resolved plus the member-wise split it
|
||||
produced, in one sheet so the two never have to be reconciled by hand. -->
|
||||
<button mat-stroked-button class="vd-export-btn" [disabled]="exporting" (click)="exportToExcel()">
|
||||
<fa-icon icon="file-excel" class="m-r-10"></fa-icon>
|
||||
{{ exporting ? 'Preparing…' : 'Export to Excel' }}
|
||||
</button>
|
||||
@if (embedded) {
|
||||
<button type="button" class="vd-close-btn" (click)="close()" aria-label="Close">
|
||||
<fa-icon icon="times"></fa-icon>
|
||||
@@ -38,6 +54,15 @@
|
||||
</div>
|
||||
|
||||
<div class="vd-summary">
|
||||
<!-- The rate and the payment date are what a distribution report is quoted by — an AGM
|
||||
resolution says "9% for 2025-26", not "₹11,300" — so they sit alongside the total rather
|
||||
than being left to be worked out from it. -->
|
||||
@if (reportHeader?.ratePercentage != null) {
|
||||
<div class="vd-summary__stat">
|
||||
<span class="vd-summary__label">{{ 'labels.inputs.Dividend Rate' | translate }}</span>
|
||||
<span class="vd-summary__value">{{ reportHeader.ratePercentage | number: '1.0-2' }}%</span>
|
||||
</div>
|
||||
}
|
||||
<div class="vd-summary__stat">
|
||||
<span class="vd-summary__label">{{ 'labels.inputs.Total Amount' | translate }}</span>
|
||||
<span class="vd-summary__value">{{ totalAmount | number: '1.2-2' }}</span>
|
||||
@@ -46,6 +71,18 @@
|
||||
<span class="vd-summary__label">{{ 'labels.inputs.Members' | translate }}</span>
|
||||
<span class="vd-summary__value">{{ dividendData.pageItems.length }}</span>
|
||||
</div>
|
||||
@if (reportHeader?.financialYear) {
|
||||
<div class="vd-summary__stat">
|
||||
<span class="vd-summary__label">{{ 'labels.inputs.Financial Year' | translate }}</span>
|
||||
<span class="vd-summary__value">FY {{ reportHeader.financialYear }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (reportHeader?.paymentDate) {
|
||||
<div class="vd-summary__stat">
|
||||
<span class="vd-summary__label">{{ 'labels.inputs.Payment Date' | translate }}</span>
|
||||
<span class="vd-summary__value">{{ reportHeader.paymentDate | slice: 0 : 10 }}</span>
|
||||
</div>
|
||||
}
|
||||
<div class="vd-summary__stat">
|
||||
<span class="vd-summary__label">{{ 'labels.inputs.Status' | translate }}</span>
|
||||
<span [class]="isdividendPosted ? 'vd-badge vd-badge--posted' : 'vd-badge vd-badge--pending'">
|
||||
|
||||
@@ -278,3 +278,15 @@ table {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Export sits beside the primary action rather than under the table: a clerk asked for the
|
||||
distribution report wants the file, not the screen, so it belongs where the eye lands first. */
|
||||
.vd-export-btn {
|
||||
margin-right: 8px;
|
||||
color: #1e7f4f;
|
||||
border-color: #cfe3d8;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,6 +84,15 @@ export class ViewDividendComponent implements OnInit {
|
||||
glTransactionId: string | null;
|
||||
} | null = null;
|
||||
|
||||
/**
|
||||
* What a distribution report has to state besides the member list: the rate the board resolved
|
||||
* and the date the dividend became payable. Null for a declaration made before the rate was
|
||||
* recorded — the heading simply omits what it does not know rather than guessing.
|
||||
*/
|
||||
reportHeader: any = null;
|
||||
|
||||
exporting = false;
|
||||
|
||||
@ViewChild(MatPaginator, { static: true }) paginator: MatPaginator;
|
||||
@ViewChild(MatSort, { static: true }) sort: MatSort;
|
||||
displayedColumns: string[] = [
|
||||
@@ -100,16 +109,34 @@ export class ViewDividendComponent implements OnInit {
|
||||
.getDividendData(this.overrideProductId, this.overrideDividendId)
|
||||
.subscribe((dividendData: any) => this.setDividendData(dividendData));
|
||||
this.loadResidue(this.overrideProductId, this.overrideDividendId);
|
||||
this.loadReportHeader(this.overrideDividendId);
|
||||
} else {
|
||||
this.status = this.route.snapshot.queryParams['status'];
|
||||
this.route.data.subscribe((data: { dividendData: any }) => this.setDividendData(data.dividendData));
|
||||
this.loadResidue(
|
||||
this.route.parent?.parent?.snapshot.paramMap.get('productId'),
|
||||
this.route.snapshot.paramMap.get('dividendId')
|
||||
);
|
||||
const dividendId = this.route.snapshot.paramMap.get('dividendId');
|
||||
this.loadResidue(this.route.parent?.parent?.snapshot.paramMap.get('productId'), dividendId);
|
||||
this.loadReportHeader(dividendId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The rate the board resolved and the date the dividend became payable.
|
||||
*
|
||||
* Neither is on the member breakdown — a rate is a property of the declaration, and the payment
|
||||
* date is when it was approved — but a distribution report has to quote both, so they are pulled
|
||||
* once here. Silently skipped on failure: the breakdown is still perfectly readable without a
|
||||
* heading, and an error banner over a working page helps nobody.
|
||||
*/
|
||||
private loadReportHeader(dividendId: any): void {
|
||||
if (!dividendId) {
|
||||
return;
|
||||
}
|
||||
this.productsService
|
||||
.getDividendReportHeader(dividendId)
|
||||
.pipe(catchError(() => of(null)))
|
||||
.subscribe((res: any) => (this.reportHeader = res));
|
||||
}
|
||||
|
||||
/**
|
||||
* Declarations made before residue was tracked have no record, and the endpoint returns null for
|
||||
* them — the panel simply stays hidden rather than showing zeros that would misrepresent them.
|
||||
@@ -161,27 +188,118 @@ export class ViewDividendComponent implements OnInit {
|
||||
return 'vd-badge vd-badge--pending';
|
||||
}
|
||||
|
||||
/**
|
||||
* Approving happens in the Approval Centre, not here.
|
||||
*
|
||||
* This page is a read-only breakdown reached from the product's dividend register, and a
|
||||
* declaration approved from here would bypass the queue every other pending declaration goes
|
||||
* through — a checker would have no single place that shows what is still outstanding. So the
|
||||
* button hands off to the Approval Centre with this declaration already open, rather than
|
||||
* approving in place.
|
||||
*
|
||||
* The embedded copy IS the Approval Centre's own breakdown, so there it approves directly.
|
||||
*/
|
||||
postDividends() {
|
||||
const shareProductId = this.embedded
|
||||
? this.overrideProductId
|
||||
: this.route.parent.parent.snapshot.paramMap.get('productId');
|
||||
const dividendId = this.embedded ? this.overrideDividendId : this.route.snapshot.paramMap.get('dividendId');
|
||||
|
||||
if (!this.embedded) {
|
||||
this.router.navigate(
|
||||
[
|
||||
'/checker-inbox-and-tasks',
|
||||
'dividend-requests'
|
||||
],
|
||||
{ queryParams: { productId: shareProductId, dividendId: dividendId } }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Only the embedded copy reaches here — the standalone page returned above — so this is always
|
||||
// the Approval Centre approving from its own modal, which closes and refreshes its list.
|
||||
this.productsService
|
||||
.approveDividend(shareProductId, dividendId, { productId: shareProductId, dividendId: dividendId })
|
||||
.subscribe(() => {
|
||||
if (this.embedded) {
|
||||
this.actionCompleted.emit();
|
||||
} else {
|
||||
// Back to the society-wide dividend register. Not '../', which used to be this product's
|
||||
// own dividend list — that page is gone now that a dividend is declared for every product
|
||||
// at once, and relative navigation would land on a route that no longer exists.
|
||||
this.router.navigate([
|
||||
'/products',
|
||||
'share-products',
|
||||
'dividends'
|
||||
]);
|
||||
.subscribe(() => this.actionCompleted.emit());
|
||||
}
|
||||
|
||||
/**
|
||||
* Share Dividend Distribution Report as an Excel workbook.
|
||||
*
|
||||
* Two parts in one sheet, because that is what the report is: a heading stating what the board
|
||||
* resolved — the rate, the amount, the payment date — and beneath it the member-wise distribution
|
||||
* that heading produced. Split across two files they would have to be reconciled by hand.
|
||||
*
|
||||
* Every member is exported, including those whose entitlement rounded to nothing: a distribution
|
||||
* report that silently omits them cannot be checked against the member register.
|
||||
*/
|
||||
async exportToExcel(): Promise<void> {
|
||||
this.exporting = true;
|
||||
try {
|
||||
const ExcelJS = await import('exceljs');
|
||||
const wb = new ExcelJS.Workbook();
|
||||
const ws = wb.addWorksheet('Dividend Distribution');
|
||||
const h = this.reportHeader;
|
||||
const fy = h?.financialYear ?? '';
|
||||
|
||||
ws.mergeCells('A1:D1');
|
||||
ws.getCell('A1').value = 'SHARE DIVIDEND DISTRIBUTION REPORT';
|
||||
ws.getCell('A1').font = { bold: true, size: 14 };
|
||||
ws.getCell('A1').alignment = { horizontal: 'center' };
|
||||
|
||||
const heading: [string, any][] = [
|
||||
['Share Product', h?.productName ?? ''],
|
||||
['Financial Year', fy],
|
||||
['Dividend Period', h ? `${h.periodStartDate} to ${h.periodEndDate}` : ''],
|
||||
['Dividend Rate (% per annum)', h?.ratePercentage != null ? Number(h.ratePercentage) : 'Not recorded'],
|
||||
['Declared Amount', h?.declaredAmount != null ? Number(h.declaredAmount) : ''],
|
||||
['Distributed to Members', h?.allocatedAmount != null ? Number(h.allocatedAmount) : ''],
|
||||
['Payment Date', h?.paymentDate ? String(h.paymentDate).slice(0, 10) : 'Not yet approved'],
|
||||
['Status', h?.status === 'APPROVED' ? 'Approved' : 'Awaiting Approval'],
|
||||
['Members Paid', h?.paidMembers ?? ''],
|
||||
['Members Below Minimum', h?.unpaidMembers ?? '']
|
||||
];
|
||||
heading.forEach(([label, value], i) => {
|
||||
const row = ws.getRow(i + 3);
|
||||
row.getCell(1).value = label;
|
||||
row.getCell(1).font = { bold: true };
|
||||
row.getCell(2).value = value;
|
||||
});
|
||||
|
||||
const tableStart = heading.length + 5;
|
||||
const header = ws.getRow(tableStart);
|
||||
['Client Name', 'Share Account', 'Dividend Amount', 'Status'].forEach((t, i) => {
|
||||
header.getCell(i + 1).value = t;
|
||||
header.getCell(i + 1).font = { bold: true };
|
||||
header.getCell(i + 1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE8ECF0' } };
|
||||
});
|
||||
|
||||
(this.dataSource?.data ?? []).forEach((d: any, i: number) => {
|
||||
const row = ws.getRow(tableStart + 1 + i);
|
||||
row.getCell(1).value = d?.accountData?.clientName ?? '';
|
||||
row.getCell(2).value = d?.accountData?.accountNo ?? '';
|
||||
row.getCell(3).value = Number(d?.amount) || 0;
|
||||
row.getCell(3).numFmt = '#,##0.00';
|
||||
row.getCell(4).value = d?.status?.value ?? '';
|
||||
});
|
||||
|
||||
const totalRow = ws.getRow(tableStart + 1 + (this.dataSource?.data?.length ?? 0));
|
||||
totalRow.getCell(2).value = 'Total';
|
||||
totalRow.getCell(2).font = { bold: true };
|
||||
totalRow.getCell(3).value = (this.dataSource?.data ?? []).reduce((s: number, d: any) => s + (Number(d?.amount) || 0), 0);
|
||||
totalRow.getCell(3).numFmt = '#,##0.00';
|
||||
totalRow.getCell(3).font = { bold: true };
|
||||
|
||||
ws.getColumn(1).width = 34;
|
||||
ws.getColumn(2).width = 20;
|
||||
ws.getColumn(3).width = 18;
|
||||
ws.getColumn(4).width = 20;
|
||||
|
||||
const { downloadWorkbook } = await import('../../../rbi-circulars/oss-workbook');
|
||||
await downloadWorkbook(wb, `Dividend Distribution Report${fy ? ' - FY ' + fy : ''}.xlsx`);
|
||||
} finally {
|
||||
this.exporting = false;
|
||||
}
|
||||
}
|
||||
|
||||
close(): void {
|
||||
|
||||
@@ -36,6 +36,14 @@ const routes: Routes = [
|
||||
loadComponent: () => import('./oss2-roe/oss2-roe.component').then((m) => m.Oss2RoeComponent),
|
||||
data: { title: 'OSS 2 — Return on Earnings', breadcrumb: 'OSS 2 (RoE)' }
|
||||
},
|
||||
{
|
||||
path: 'rbi/share-capital',
|
||||
// Lazy for the same reason as the OSS returns — the ExcelJS workbook builder should not
|
||||
// weigh down the eager bundle for a page most users never open.
|
||||
loadComponent: () =>
|
||||
import('./share-capital-return/share-capital-return.component').then((m) => m.ShareCapitalReturnComponent),
|
||||
data: { title: 'Members & Share Capital Return', breadcrumb: 'Members & Share Capital' }
|
||||
},
|
||||
{
|
||||
path: 'rbi/oss3',
|
||||
loadComponent: () => import('./oss3-raq/oss3-raq.component').then((m) => m.Oss3RaqComponent),
|
||||
|
||||
@@ -44,4 +44,20 @@ export class RbiCircularsService {
|
||||
triggerFetch(): Observable<any> {
|
||||
return this.http.post('/rbi-feeds/fetch', {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Members and share capital per share type for one financial year, with the year before alongside.
|
||||
*
|
||||
* @param financialYear the year the FY opens in — 2025 means FY 2025-26.
|
||||
*/
|
||||
getShareCapitalReturn(financialYear: number): Observable<any> {
|
||||
return this.http.get('/shareproducts/capital-return', {
|
||||
params: new HttpParams().set('financialYear', String(financialYear))
|
||||
});
|
||||
}
|
||||
|
||||
/** Financial years that actually have share activity, newest first, for the year picker. */
|
||||
getShareCapitalReturnYears(): Observable<any> {
|
||||
return this.http.get('/shareproducts/capital-return/financial-years');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/.
|
||||
-->
|
||||
|
||||
<div class="scr-root">
|
||||
<div class="scr-header">
|
||||
<div class="scr-header__text">
|
||||
<h2>Return on Members & Share Capital</h2>
|
||||
<!-- What the return is for, in the words a supervisor would use. A page titled only with a
|
||||
form number tells a clerk nothing about what they are about to file. -->
|
||||
<p>
|
||||
Membership and share capital as reported to the RBI, per share type — how many members hold
|
||||
each type, how much capital they have subscribed, and how much of the authorised issue
|
||||
remains unsold, with the previous financial year alongside for comparison.
|
||||
</p>
|
||||
</div>
|
||||
<div class="scr-header__actions">
|
||||
<label class="scr-year">
|
||||
<span>Financial Year</span>
|
||||
<select [ngModel]="selectedYear" (ngModelChange)="onYearChange($event)" name="financialYear">
|
||||
@for (y of years; track y.startYear) {
|
||||
<option [value]="y.startYear">{{ y.label }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" class="scr-btn" [disabled]="loading" (click)="load()">
|
||||
<fa-icon icon="sync"></fa-icon>
|
||||
Refresh
|
||||
</button>
|
||||
<button type="button" class="scr-btn scr-btn--primary" [disabled]="exporting || !report" (click)="exportToExcel()">
|
||||
<fa-icon icon="file-excel"></fa-icon>
|
||||
{{ exporting ? 'Preparing…' : 'Export to Excel' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (loadError) {
|
||||
<div class="scr-note scr-note--error">{{ loadError }}</div>
|
||||
}
|
||||
|
||||
@if (loading) {
|
||||
<div class="scr-note">Loading the share register…</div>
|
||||
} @else if (report) {
|
||||
<div class="scr-period">
|
||||
Reporting period <strong>{{ report.periodStartDate }}</strong> to
|
||||
<strong>{{ report.periodEndDate }}</strong> · compared with FY {{ report.previousFinancialYear }}
|
||||
</div>
|
||||
|
||||
<!-- The bottom line first: a supervisor checks the society's totals before reading any single
|
||||
share type, and it answers "did membership and capital grow this year" at a glance. -->
|
||||
<div class="scr-summary">
|
||||
<div class="scr-stat">
|
||||
<span class="scr-stat__label">Total Members</span>
|
||||
<span class="scr-stat__value">{{ totals.membersClosing | number }}</span>
|
||||
<span class="scr-stat__sub">
|
||||
{{ report.previousFinancialYear }}: {{ totals.membersClosingPrev | number }}
|
||||
@if (yoyChange(totals.membersClosing, totals.membersClosingPrev) !== null) {
|
||||
<span
|
||||
class="scr-delta"
|
||||
[class.scr-delta--up]="totals.membersClosing >= totals.membersClosingPrev"
|
||||
[class.scr-delta--down]="totals.membersClosing < totals.membersClosingPrev"
|
||||
>
|
||||
{{ totals.membersClosing >= totals.membersClosingPrev ? '▲' : '▼' }}
|
||||
{{ yoyChange(totals.membersClosing, totals.membersClosingPrev) | number: '1.1-1' }}%
|
||||
</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div class="scr-stat">
|
||||
<span class="scr-stat__label">Total Share Capital</span>
|
||||
<span class="scr-stat__value">{{ totals.capitalClosing | number: '1.2-2' }}</span>
|
||||
<span class="scr-stat__sub">
|
||||
{{ report.previousFinancialYear }}: {{ totals.capitalClosingPrev | number: '1.2-2' }}
|
||||
@if (yoyChange(totals.capitalClosing, totals.capitalClosingPrev) !== null) {
|
||||
<span
|
||||
class="scr-delta"
|
||||
[class.scr-delta--up]="totals.capitalClosing >= totals.capitalClosingPrev"
|
||||
[class.scr-delta--down]="totals.capitalClosing < totals.capitalClosingPrev"
|
||||
>
|
||||
{{ totals.capitalClosing >= totals.capitalClosingPrev ? '▲' : '▼' }}
|
||||
{{ yoyChange(totals.capitalClosing, totals.capitalClosingPrev) | number: '1.1-1' }}%
|
||||
</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div class="scr-stat">
|
||||
<span class="scr-stat__label">Shares Allotted</span>
|
||||
<span class="scr-stat__value">{{ totals.allottedShares | number }}</span>
|
||||
<span class="scr-stat__sub">of {{ totals.authorisedShares | number }} authorised</span>
|
||||
</div>
|
||||
<div class="scr-stat scr-stat--muted">
|
||||
<span class="scr-stat__label">Unissued (Remaining)</span>
|
||||
<span class="scr-stat__value">{{ totals.unissuedShares | number }}</span>
|
||||
<span class="scr-stat__sub">still available to allot</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@for (t of report.shareTypes; track t.productId) {
|
||||
<div class="scr-card">
|
||||
<div class="scr-card__head">
|
||||
<span class="scr-card__title">{{ t.shareType }}</span>
|
||||
<span class="scr-card__meta">
|
||||
Face value {{ t.unitPrice | number: '1.2-2' }} {{ t.currency }} · Authorised
|
||||
{{ t.authorisedShares | number }} shares
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Allotted against authorised. A supervisor checks a fresh issue against this headroom,
|
||||
so it is shown as a bar rather than two numbers to be subtracted mentally. -->
|
||||
<div class="scr-util">
|
||||
<div class="scr-util__bar">
|
||||
<div class="scr-util__fill" [style.width.%]="t.utilisationPercentage || 0"></div>
|
||||
</div>
|
||||
<span class="scr-util__text">
|
||||
{{ t.allottedShares | number }} allotted ·
|
||||
<strong>{{ t.unissuedShares | number }} remaining</strong>
|
||||
@if (t.utilisationPercentage != null) {
|
||||
({{ t.utilisationPercentage | number: '1.1-1' }}% issued)
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<table class="scr-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Particulars</th>
|
||||
<th class="num">FY {{ report.financialYear }}</th>
|
||||
<th class="num">FY {{ report.previousFinancialYear }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr class="scr-group">
|
||||
<td colspan="3">Members</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Opening</td>
|
||||
<td class="num">{{ t.currentYear.membersOpening | number }}</td>
|
||||
<td class="num">{{ t.previousYear.membersOpening | number }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>New admissions during the year</td>
|
||||
<td class="num">{{ t.currentYear.membersAdmitted | number }}</td>
|
||||
<td class="num">{{ t.previousYear.membersAdmitted | number }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Ceased during the year</td>
|
||||
<td class="num">{{ t.currentYear.membersCeased | number }}</td>
|
||||
<td class="num">{{ t.previousYear.membersCeased | number }}</td>
|
||||
</tr>
|
||||
<tr class="scr-total">
|
||||
<td>Closing members</td>
|
||||
<td class="num">{{ t.currentYear.membersClosing | number }}</td>
|
||||
<td class="num">{{ t.previousYear.membersClosing | number }}</td>
|
||||
</tr>
|
||||
|
||||
<tr class="scr-group">
|
||||
<td colspan="3">Share Capital</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Opening</td>
|
||||
<td class="num">{{ t.currentYear.capitalOpening | number: '1.2-2' }}</td>
|
||||
<td class="num">{{ t.previousYear.capitalOpening | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Issued during the year</td>
|
||||
<td class="num">{{ t.currentYear.capitalIssued | number: '1.2-2' }}</td>
|
||||
<td class="num">{{ t.previousYear.capitalIssued | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Redeemed / withdrawn</td>
|
||||
<td class="num">{{ t.currentYear.capitalRedeemed | number: '1.2-2' }}</td>
|
||||
<td class="num">{{ t.previousYear.capitalRedeemed | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
<tr class="scr-total">
|
||||
<td>Closing share capital</td>
|
||||
<td class="num">{{ t.currentYear.capitalClosing | number: '1.2-2' }}</td>
|
||||
<td class="num">{{ t.previousYear.capitalClosing | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Shares issued during the year</td>
|
||||
<td class="num">{{ t.currentYear.sharesIssued | number }}</td>
|
||||
<td class="num">{{ t.previousYear.sharesIssued | number }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!report.shareTypes?.length) {
|
||||
<div class="scr-note">No share types are configured, so there is nothing to report.</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@@ -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/.
|
||||
*/
|
||||
|
||||
.scr-root {
|
||||
padding: 24px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.scr-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 20px;
|
||||
|
||||
&__text {
|
||||
flex: 1 1 420px;
|
||||
|
||||
h2 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
max-width: 720px;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
color: #64748b;
|
||||
}
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
.scr-year {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
|
||||
select {
|
||||
padding: 7px 10px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
color: #0f172a;
|
||||
background: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.scr-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 8px 14px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
&--primary {
|
||||
border-color: #1e7f4f;
|
||||
background: #1e7f4f;
|
||||
color: #fff;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #19693f;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.scr-period {
|
||||
margin-bottom: 16px;
|
||||
font-size: 12.5px;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.scr-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.scr-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
padding: 13px 15px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-left: 3px solid #4f46e5;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
|
||||
/* Unissued stock is headroom, not performance — muted so it does not read as a result. */
|
||||
&--muted {
|
||||
border-left-color: #94a3b8;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
&__value {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
&__sub {
|
||||
font-size: 11.5px;
|
||||
color: #64748b;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
|
||||
.scr-delta {
|
||||
margin-left: 5px;
|
||||
font-weight: 700;
|
||||
|
||||
&--up {
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
&--down {
|
||||
color: #b91c1c;
|
||||
}
|
||||
}
|
||||
|
||||
.scr-card {
|
||||
margin-bottom: 18px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
|
||||
&__head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
padding: 13px 16px;
|
||||
border-bottom: 1px solid #eef2f6;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 14.5px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
&__meta {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
}
|
||||
|
||||
/* Allotted against authorised, as a bar: the remaining headroom is the figure a supervisor checks a
|
||||
fresh issue against, and a bar shows it without the reader subtracting two numbers. */
|
||||
.scr-util {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
padding: 12px 16px;
|
||||
|
||||
&__bar {
|
||||
flex: 1 1 220px;
|
||||
height: 8px;
|
||||
border-radius: 9999px;
|
||||
background: #eef2f6;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__fill {
|
||||
height: 100%;
|
||||
border-radius: 9999px;
|
||||
background: #4f46e5;
|
||||
}
|
||||
|
||||
&__text {
|
||||
font-size: 12.5px;
|
||||
color: #475569;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.scr-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
|
||||
th {
|
||||
padding: 9px 16px;
|
||||
text-align: left;
|
||||
font-size: 10.5px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #94a3b8;
|
||||
border-bottom: 1px solid #eef2f6;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 8px 16px;
|
||||
color: #334155;
|
||||
border-bottom: 1px solid #f4f7fa;
|
||||
}
|
||||
|
||||
.num {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
|
||||
/* Members and Share Capital are two separate statements inside one table — labelled so a reader
|
||||
never has to guess which block a row belongs to. */
|
||||
.scr-group td {
|
||||
padding-top: 13px;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #4f46e5;
|
||||
background: #fbfcfe;
|
||||
}
|
||||
|
||||
.scr-total td {
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
background: #fbfcfe;
|
||||
}
|
||||
|
||||
.scr-note {
|
||||
padding: 12px 16px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
font-size: 13px;
|
||||
color: #475569;
|
||||
|
||||
&--error {
|
||||
border-color: #fecaca;
|
||||
background: #fef2f2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* 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 { catchError } from 'rxjs/operators';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { RbiCircularsService } from '../rbi-circulars.service';
|
||||
import { downloadWorkbook } from '../oss-workbook';
|
||||
|
||||
/**
|
||||
* Return on Members & Share Capital — the membership and share-capital position reported to the
|
||||
* RBI, broken down by share type, with the previous year alongside.
|
||||
*
|
||||
* A supervisor reading this asks three things of every share type the society offers: how many
|
||||
* members hold it, how much capital they have put in, and how much of the authorised issue is still
|
||||
* unsold. Movement matters as much as position — a society whose membership fell while its capital
|
||||
* rose has done something quite different from one where both grew — so each figure is shown as
|
||||
* opening, movement, and closing, against the same three for the year before.
|
||||
*
|
||||
* Every figure comes from the share register itself rather than a stored summary, so the return
|
||||
* cannot drift from the accounts it describes.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-share-capital-return',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
FaIconComponent
|
||||
],
|
||||
templateUrl: './share-capital-return.component.html',
|
||||
styleUrls: ['./share-capital-return.component.scss']
|
||||
})
|
||||
export class ShareCapitalReturnComponent implements OnInit {
|
||||
private service = inject(RbiCircularsService);
|
||||
|
||||
years: { startYear: number; label: string }[] = [];
|
||||
selectedYear: number | null = null;
|
||||
|
||||
report: any = null;
|
||||
loading = false;
|
||||
loadError: string | null = null;
|
||||
exporting = false;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.service
|
||||
.getShareCapitalReturnYears()
|
||||
.pipe(catchError(() => of([])))
|
||||
.subscribe((res: any[]) => {
|
||||
this.years = res ?? [];
|
||||
// Opens on the most recent year rather than an empty screen: that is the return being
|
||||
// prepared almost every time this page is reached.
|
||||
this.selectedYear = this.years.length ? this.years[0].startYear : null;
|
||||
this.load();
|
||||
});
|
||||
}
|
||||
|
||||
load(): void {
|
||||
if (this.selectedYear == null) {
|
||||
return;
|
||||
}
|
||||
this.loading = true;
|
||||
this.loadError = null;
|
||||
this.service
|
||||
.getShareCapitalReturn(this.selectedYear)
|
||||
.pipe(
|
||||
catchError((err: any) => {
|
||||
this.loadError = err?.error?.defaultUserMessage ?? 'Could not load the share capital position.';
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
.subscribe((res: any) => {
|
||||
this.report = res;
|
||||
this.loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
onYearChange(startYear: any): void {
|
||||
this.selectedYear = Number(startYear);
|
||||
this.load();
|
||||
}
|
||||
|
||||
/** Net movement in members over the year — admitted less ceased. */
|
||||
netMembers(position: any): number {
|
||||
return (Number(position?.membersAdmitted) || 0) - (Number(position?.membersCeased) || 0);
|
||||
}
|
||||
|
||||
/** Net movement in capital — issued less redeemed. */
|
||||
netCapital(position: any): number {
|
||||
return (Number(position?.capitalIssued) || 0) - (Number(position?.capitalRedeemed) || 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Change against the prior year, as a percentage.
|
||||
*
|
||||
* Null rather than zero when the prior year was nil: a society going from no members to fifty has
|
||||
* not grown by 0%, and showing a number there would be worse than showing nothing.
|
||||
*/
|
||||
yoyChange(current: any, previous: any): number | null {
|
||||
const prev = Number(previous) || 0;
|
||||
const curr = Number(current) || 0;
|
||||
if (prev === 0) {
|
||||
return null;
|
||||
}
|
||||
return ((curr - prev) / prev) * 100;
|
||||
}
|
||||
|
||||
/** Society-wide totals, so the return has a bottom line and not only per-type rows. */
|
||||
get totals(): any {
|
||||
const types = this.report?.shareTypes ?? [];
|
||||
const sum = (pick: (t: any) => number) => types.reduce((s: number, t: any) => s + (pick(t) || 0), 0);
|
||||
return {
|
||||
membersClosing: sum((t) => Number(t.currentYear?.membersClosing)),
|
||||
membersClosingPrev: sum((t) => Number(t.previousYear?.membersClosing)),
|
||||
capitalClosing: sum((t) => Number(t.currentYear?.capitalClosing)),
|
||||
capitalClosingPrev: sum((t) => Number(t.previousYear?.capitalClosing)),
|
||||
authorisedShares: sum((t) => Number(t.authorisedShares)),
|
||||
allottedShares: sum((t) => Number(t.allottedShares)),
|
||||
unissuedShares: sum((t) => Number(t.unissuedShares))
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The return as an Excel workbook, laid out the way it is read on screen.
|
||||
*
|
||||
* One row per share type with both years side by side, rather than a sheet per year: the whole
|
||||
* point of the return is the comparison, and splitting it would leave the reader to do the
|
||||
* matching by hand.
|
||||
*/
|
||||
async exportToExcel(): Promise<void> {
|
||||
if (!this.report) {
|
||||
return;
|
||||
}
|
||||
this.exporting = true;
|
||||
try {
|
||||
const ExcelJS = await import('exceljs');
|
||||
const wb = new ExcelJS.Workbook();
|
||||
const ws = wb.addWorksheet('Members & Share Capital');
|
||||
const fy = this.report.financialYear;
|
||||
const prev = this.report.previousFinancialYear;
|
||||
|
||||
ws.mergeCells('A1:L1');
|
||||
ws.getCell('A1').value = 'RETURN ON MEMBERS AND SHARE CAPITAL';
|
||||
ws.getCell('A1').font = { bold: true, size: 14 };
|
||||
ws.getCell('A1').alignment = { horizontal: 'center' };
|
||||
|
||||
ws.mergeCells('A2:L2');
|
||||
ws.getCell('A2').value = `Financial Year ${fy} (compared with ${prev}) · Period ${this.report.periodStartDate} to ${this.report.periodEndDate}`;
|
||||
ws.getCell('A2').alignment = { horizontal: 'center' };
|
||||
|
||||
const headers = [
|
||||
'Share Type',
|
||||
'Authorised Shares',
|
||||
'Allotted Shares',
|
||||
'Unissued (Remaining)',
|
||||
`Members Opening (${fy})`,
|
||||
'New Admissions',
|
||||
'Ceased',
|
||||
`Members Closing (${fy})`,
|
||||
`Members Closing (${prev})`,
|
||||
`Capital Opening (${fy})`,
|
||||
'Capital Issued',
|
||||
`Capital Closing (${fy})`,
|
||||
`Capital Closing (${prev})`
|
||||
];
|
||||
const headerRow = ws.getRow(4);
|
||||
headers.forEach((h, i) => {
|
||||
headerRow.getCell(i + 1).value = h;
|
||||
headerRow.getCell(i + 1).font = { bold: true };
|
||||
headerRow.getCell(i + 1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE8ECF0' } };
|
||||
headerRow.getCell(i + 1).alignment = { wrapText: true, vertical: 'middle' };
|
||||
});
|
||||
|
||||
(this.report.shareTypes ?? []).forEach((t: any, i: number) => {
|
||||
const row = ws.getRow(5 + i);
|
||||
const values = [
|
||||
t.shareType,
|
||||
Number(t.authorisedShares) || 0,
|
||||
Number(t.allottedShares) || 0,
|
||||
Number(t.unissuedShares) || 0,
|
||||
Number(t.currentYear?.membersOpening) || 0,
|
||||
Number(t.currentYear?.membersAdmitted) || 0,
|
||||
Number(t.currentYear?.membersCeased) || 0,
|
||||
Number(t.currentYear?.membersClosing) || 0,
|
||||
Number(t.previousYear?.membersClosing) || 0,
|
||||
Number(t.currentYear?.capitalOpening) || 0,
|
||||
Number(t.currentYear?.capitalIssued) || 0,
|
||||
Number(t.currentYear?.capitalClosing) || 0,
|
||||
Number(t.previousYear?.capitalClosing) || 0
|
||||
];
|
||||
values.forEach((v, c) => {
|
||||
row.getCell(c + 1).value = v;
|
||||
if (c >= 9) {
|
||||
row.getCell(c + 1).numFmt = '#,##0.00';
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const totalRow = ws.getRow(5 + (this.report.shareTypes?.length ?? 0));
|
||||
const t = this.totals;
|
||||
totalRow.getCell(1).value = 'TOTAL';
|
||||
totalRow.getCell(2).value = t.authorisedShares;
|
||||
totalRow.getCell(3).value = t.allottedShares;
|
||||
totalRow.getCell(4).value = t.unissuedShares;
|
||||
totalRow.getCell(8).value = t.membersClosing;
|
||||
totalRow.getCell(9).value = t.membersClosingPrev;
|
||||
totalRow.getCell(12).value = t.capitalClosing;
|
||||
totalRow.getCell(13).value = t.capitalClosingPrev;
|
||||
[12, 13].forEach((c) => (totalRow.getCell(c).numFmt = '#,##0.00'));
|
||||
for (let c = 1; c <= 13; c++) {
|
||||
totalRow.getCell(c).font = { bold: true };
|
||||
}
|
||||
|
||||
ws.getColumn(1).width = 26;
|
||||
for (let c = 2; c <= 13; c++) {
|
||||
ws.getColumn(c).width = 16;
|
||||
}
|
||||
|
||||
await downloadWorkbook(wb as any, `Return on Members and Share Capital - FY ${fy}.xlsx`);
|
||||
} finally {
|
||||
this.exporting = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { NgModule } from '@angular/core';
|
||||
import { Routes, RouterModule } from '@angular/router';
|
||||
|
||||
import { Route } from '../core/route/route.service';
|
||||
import { SavingsDailyInterestComponent } from './savings-daily-interest.component';
|
||||
|
||||
const routes: Routes = [
|
||||
Route.withShell([
|
||||
{
|
||||
path: 'savings-daily-interest',
|
||||
component: SavingsDailyInterestComponent,
|
||||
data: { title: 'Savings Daily Interest', breadcrumb: 'Savings Daily Interest' }
|
||||
}
|
||||
])
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule]
|
||||
})
|
||||
export class SavingsDailyInterestRoutingModule {}
|
||||
@@ -0,0 +1,216 @@
|
||||
<!--
|
||||
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="sdi-container">
|
||||
<div class="sdi-header">
|
||||
<h2>Savings Daily Interest</h2>
|
||||
<div class="sdi-actions">
|
||||
<button mat-stroked-button (click)="runReconciliation()" [disabled]="loading">Check</button>
|
||||
<button mat-stroked-button (click)="runBackfill()" [disabled]="loading">Backfill</button>
|
||||
<button mat-flat-button color="primary" (click)="runToday()" [disabled]="loading">Run Today</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (message) {
|
||||
<div class="sdi-message" [class.error]="messageIsError" (click)="dismissMessage()">
|
||||
{{ message }}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="sdi-tabs">
|
||||
<button [class.active]="activeTab === 'products'" (click)="backToProducts()">Products</button>
|
||||
@if (selectedProduct) {
|
||||
<button [class.active]="activeTab === 'accounts'" (click)="backToAccounts()">
|
||||
{{ selectedProduct.name }}
|
||||
</button>
|
||||
}
|
||||
@if (selectedAccount) {
|
||||
<button [class.active]="activeTab === 'ledger'">
|
||||
{{ selectedAccount.account_no }}
|
||||
</button>
|
||||
}
|
||||
<button [class.active]="activeTab === 'runs'" (click)="loadRuns()">Posting Runs</button>
|
||||
</div>
|
||||
|
||||
@if (loading) {
|
||||
<div class="sdi-loading">Loading…</div>
|
||||
}
|
||||
|
||||
<!-- Products: one row per savings product, with where it stands in its timetable. -->
|
||||
@if (activeTab === 'products' && !loading) {
|
||||
<table class="sdi-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Product</th>
|
||||
<th>Rate</th>
|
||||
<th>Posts</th>
|
||||
<th class="num">Accounts</th>
|
||||
<th class="num">Accrued This Cycle</th>
|
||||
<th>Last Posted</th>
|
||||
<th>Next Posting</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (product of products; track product.id) {
|
||||
<tr class="clickable" (click)="openProduct(product)">
|
||||
<td>
|
||||
<strong>{{ product.name }}</strong>
|
||||
<span class="sdi-sub">{{ product.short_name }}</span>
|
||||
</td>
|
||||
<td>{{ product.rate }}%</td>
|
||||
<td>{{ postingPeriodLabel(product.posting_period_enum) }}</td>
|
||||
<td class="num">{{ product.active_accounts }}</td>
|
||||
<td class="num">{{ product.accrued_this_cycle | number: '1.2-2' }}</td>
|
||||
<td>{{ formatDate(product.last_posting_date) }}</td>
|
||||
<td>{{ formatDate(product.next_posting_date) }}</td>
|
||||
<td>
|
||||
@if (product.last_run_status) {
|
||||
<span class="sdi-chip" [class.ok]="product.last_run_status === 'COMPLETED'"
|
||||
[class.bad]="product.last_run_status === 'FAILED'">
|
||||
{{ product.last_run_status }}
|
||||
</span>
|
||||
} @else {
|
||||
<span class="sdi-sub">Not run</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr><td colspan="8" class="sdi-empty">No savings products carry a ledger yet.</td></tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
<!-- Accounts inside the selected product, as at the latest day recorded. -->
|
||||
@if (activeTab === 'accounts' && !loading) {
|
||||
<table class="sdi-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Account</th>
|
||||
<th>Client</th>
|
||||
<th>As At</th>
|
||||
<th class="num">Opening</th>
|
||||
<th class="num">Closing</th>
|
||||
<th class="num">Day Interest</th>
|
||||
<th class="num">Sum This Cycle</th>
|
||||
<th>Cycle</th>
|
||||
<th>Pays On</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (account of accounts; track account.savings_account_id) {
|
||||
<tr class="clickable" (click)="openAccount(account)">
|
||||
<td><strong>{{ account.account_no }}</strong></td>
|
||||
<td>{{ account.client_name || '—' }}</td>
|
||||
<td>{{ formatDate(account.balance_date) }}</td>
|
||||
<td class="num">{{ account.opening_balance | number: '1.2-2' }}</td>
|
||||
<td class="num">{{ account.closing_balance | number: '1.2-2' }}</td>
|
||||
<td class="num" [class.zero]="account.below_min_balance">
|
||||
{{ account.daily_interest | number: '1.4-4' }}
|
||||
@if (account.below_min_balance) {
|
||||
<span class="sdi-flag" title="Closed below the minimum balance, so this day earned nothing">below min</span>
|
||||
}
|
||||
</td>
|
||||
<td class="num accent">{{ account.cycle_cumulative_interest | number: '1.2-2' }}</td>
|
||||
<td>{{ account.cycle_number }}</td>
|
||||
<td>{{ formatDate(account.cycle_end_date) }}</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr><td colspan="9" class="sdi-empty">No ledger rows for this product yet.</td></tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
<!-- One account, day by day. The posting row is where the sum is paid and reset. -->
|
||||
@if (activeTab === 'ledger' && !loading) {
|
||||
<table class="sdi-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th class="num">Opening</th>
|
||||
<th class="num">Closing</th>
|
||||
<th class="num">Day Interest</th>
|
||||
<th class="num">Running Sum</th>
|
||||
<th>Cycle</th>
|
||||
<th class="num">Posted</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (day of ledger; track day.balance_date) {
|
||||
<tr [class.posting-row]="day.is_posting_day">
|
||||
<td>
|
||||
{{ formatDate(day.balance_date) }}
|
||||
@if (day.is_posting_day) {
|
||||
<span class="sdi-flag paid">posting day</span>
|
||||
}
|
||||
</td>
|
||||
<td class="num">{{ day.opening_balance | number: '1.2-2' }}</td>
|
||||
<td class="num">{{ day.closing_balance | number: '1.2-2' }}</td>
|
||||
<td class="num" [class.zero]="day.below_min_balance">
|
||||
{{ day.daily_interest | number: '1.4-4' }}
|
||||
@if (day.below_min_balance) {
|
||||
<span class="sdi-flag" title="Closed below the minimum balance, so this day earned nothing">below min</span>
|
||||
}
|
||||
</td>
|
||||
<td class="num accent">{{ day.cycle_cumulative_interest | number: '1.4-4' }}</td>
|
||||
<td>{{ day.cycle_number }}</td>
|
||||
<td class="num">
|
||||
@if (day.posted_amount !== null) {
|
||||
<strong>{{ day.posted_amount | number: '1.2-2' }}</strong>
|
||||
} @else {
|
||||
—
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr><td colspan="7" class="sdi-empty">No days recorded for this account.</td></tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
<!-- What each run pooled and paid. Residual is the paise rounding moved, and must be accounted for. -->
|
||||
@if (activeTab === 'runs' && !loading) {
|
||||
<table class="sdi-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Product</th>
|
||||
<th>Posting Date</th>
|
||||
<th class="num">Accounts</th>
|
||||
<th class="num">Pooled</th>
|
||||
<th class="num">Distributed</th>
|
||||
<th class="num">Residual</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (run of runs; track run.id) {
|
||||
<tr>
|
||||
<td>{{ run.product_name }}</td>
|
||||
<td>{{ formatDate(run.posting_date) }}</td>
|
||||
<td class="num">{{ run.account_count }}</td>
|
||||
<td class="num">{{ run.pooled_amount | number: '1.2-2' }}</td>
|
||||
<td class="num">{{ run.distributed_amount | number: '1.2-2' }}</td>
|
||||
<td class="num">{{ run.rounding_residual | number: '1.2-2' }}</td>
|
||||
<td>
|
||||
<span class="sdi-chip" [class.ok]="run.status === 'COMPLETED'"
|
||||
[class.bad]="run.status === 'FAILED'">{{ run.status }}</span>
|
||||
@if (run.failure_reason) {
|
||||
<div class="sdi-sub">{{ run.failure_reason }}</div>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr><td colspan="7" class="sdi-empty">No posting runs yet.</td></tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
.sdi-container {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.sdi-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.sdi-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.sdi-message {
|
||||
padding: 0.6rem 0.9rem;
|
||||
margin-bottom: 1rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
background: #e8f4ea;
|
||||
border-left: 4px solid #2e7d32;
|
||||
font-size: 0.9rem;
|
||||
|
||||
&.error {
|
||||
background: #fdecea;
|
||||
border-left-color: #c62828;
|
||||
}
|
||||
}
|
||||
|
||||
.sdi-tabs {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
flex-wrap: wrap;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.12);
|
||||
margin-bottom: 1rem;
|
||||
|
||||
button {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0.6rem 1rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
border-bottom: 2px solid transparent;
|
||||
|
||||
&.active {
|
||||
border-bottom-color: #1565c0;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sdi-loading {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
// Wide tables scroll inside their own box rather than pushing the page sideways.
|
||||
.sdi-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.875rem;
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 0.5rem 0.75rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
th {
|
||||
font-weight: 600;
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.num {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
tr.clickable {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: rgba(21, 101, 192, 0.06);
|
||||
}
|
||||
}
|
||||
|
||||
// The day the cycle is paid out and the sum goes back to zero.
|
||||
tr.posting-row {
|
||||
background: rgba(46, 125, 50, 0.08);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.accent {
|
||||
color: #1565c0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
// A day that earned nothing because the balance sat under the product minimum.
|
||||
.zero {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.sdi-sub {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.sdi-empty {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.sdi-chip {
|
||||
display: inline-block;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 10px;
|
||||
font-size: 0.75rem;
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
|
||||
&.ok {
|
||||
background: rgba(46, 125, 50, 0.15);
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
&.bad {
|
||||
background: rgba(198, 40, 40, 0.15);
|
||||
color: #c62828;
|
||||
}
|
||||
}
|
||||
|
||||
.sdi-flag {
|
||||
display: inline-block;
|
||||
margin-left: 0.4rem;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 3px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
|
||||
&.paid {
|
||||
background: rgba(46, 125, 50, 0.2);
|
||||
color: #2e7d32;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* 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 { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import {
|
||||
SavingsDailyInterestService,
|
||||
SavingsInterestAccount,
|
||||
SavingsInterestDay,
|
||||
SavingsInterestProduct,
|
||||
SavingsInterestRun
|
||||
} from './savings-daily-interest.service';
|
||||
|
||||
/**
|
||||
* The savings daily interest ledger.
|
||||
*
|
||||
* Interest has always been a single number on the account screen with nothing behind it. This
|
||||
* shows the working: pick a product, see its accounts and where each stands in its cycle, then
|
||||
* open one account and read it day by day - what the balance was, what that day earned, and the
|
||||
* running total climbing until the posting day pays it out and resets it to zero.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-savings-daily-interest',
|
||||
templateUrl: './savings-daily-interest.component.html',
|
||||
styleUrls: ['./savings-daily-interest.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
FormsModule
|
||||
]
|
||||
})
|
||||
export class SavingsDailyInterestComponent implements OnInit {
|
||||
private service = inject(SavingsDailyInterestService);
|
||||
|
||||
activeTab: 'products' | 'accounts' | 'ledger' | 'runs' = 'products';
|
||||
|
||||
products: SavingsInterestProduct[] = [];
|
||||
accounts: SavingsInterestAccount[] = [];
|
||||
ledger: SavingsInterestDay[] = [];
|
||||
runs: SavingsInterestRun[] = [];
|
||||
|
||||
selectedProduct: SavingsInterestProduct | null = null;
|
||||
selectedAccount: SavingsInterestAccount | null = null;
|
||||
|
||||
loading = false;
|
||||
message = '';
|
||||
messageIsError = false;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadProducts();
|
||||
}
|
||||
|
||||
// -------------------- loading --------------------
|
||||
|
||||
loadProducts(): void {
|
||||
this.loading = true;
|
||||
this.service.getProducts().subscribe({
|
||||
next: (data) => {
|
||||
this.products = data ?? [];
|
||||
this.loading = false;
|
||||
},
|
||||
error: (err) => this.fail(err)
|
||||
});
|
||||
}
|
||||
|
||||
openProduct(product: SavingsInterestProduct): void {
|
||||
this.selectedProduct = product;
|
||||
this.selectedAccount = null;
|
||||
this.activeTab = 'accounts';
|
||||
this.loading = true;
|
||||
this.service.getAccountsForProduct(product.id).subscribe({
|
||||
next: (data) => {
|
||||
this.accounts = data ?? [];
|
||||
this.loading = false;
|
||||
},
|
||||
error: (err) => this.fail(err)
|
||||
});
|
||||
}
|
||||
|
||||
openAccount(account: SavingsInterestAccount): void {
|
||||
this.selectedAccount = account;
|
||||
this.activeTab = 'ledger';
|
||||
this.loading = true;
|
||||
this.service.getAccountLedger(account.savings_account_id).subscribe({
|
||||
next: (data) => {
|
||||
this.ledger = data ?? [];
|
||||
this.loading = false;
|
||||
},
|
||||
error: (err) => this.fail(err)
|
||||
});
|
||||
}
|
||||
|
||||
loadRuns(): void {
|
||||
this.activeTab = 'runs';
|
||||
this.loading = true;
|
||||
this.service.getRuns().subscribe({
|
||||
next: (data) => {
|
||||
this.runs = data ?? [];
|
||||
this.loading = false;
|
||||
},
|
||||
error: (err) => this.fail(err)
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------- actions --------------------
|
||||
|
||||
/**
|
||||
* The chain check and the unposted-total check. Nothing found is the answer you want: every day
|
||||
* picks up where the last one left off, and the ledger still agrees with the accounts.
|
||||
*/
|
||||
runReconciliation(): void {
|
||||
this.loading = true;
|
||||
this.service.reconcile().subscribe({
|
||||
next: (result) => {
|
||||
this.loading = false;
|
||||
this.messageIsError = !result.clean;
|
||||
this.message = result.clean
|
||||
? 'Reconciliation clean: the balance chain is unbroken and every cycle agrees with its account.'
|
||||
: `${result.problemCount} problem(s) found: ${result.problems.slice(0, 3).join(' | ')}`;
|
||||
},
|
||||
error: (err) => this.fail(err)
|
||||
});
|
||||
}
|
||||
|
||||
runToday(): void {
|
||||
this.loading = true;
|
||||
this.service.runForDate().subscribe({
|
||||
next: (result) => {
|
||||
this.loading = false;
|
||||
this.messageIsError = false;
|
||||
this.message = `Run completed for ${result?.ranFor ?? 'today'}.`;
|
||||
this.loadProducts();
|
||||
},
|
||||
error: (err) => this.fail(err)
|
||||
});
|
||||
}
|
||||
|
||||
runBackfill(): void {
|
||||
this.loading = true;
|
||||
this.service.backfill().subscribe({
|
||||
next: (result) => {
|
||||
this.loading = false;
|
||||
this.messageIsError = false;
|
||||
this.message = `Backfill wrote ${result?.rowsWritten ?? 0} rows up to ${result?.upTo ?? 'today'}.`;
|
||||
this.loadProducts();
|
||||
},
|
||||
error: (err) => this.fail(err)
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------- display helpers --------------------
|
||||
|
||||
/** Fineract returns dates as [year, month, day]; month is 1-based. */
|
||||
formatDate(value: number[] | null | undefined): string {
|
||||
if (!value || value.length < 3) {
|
||||
return '—';
|
||||
}
|
||||
const [year, month, day] = value;
|
||||
return `${String(day).padStart(2, '0')}/${String(month).padStart(2, '0')}/${year}`;
|
||||
}
|
||||
|
||||
postingPeriodLabel(value: number): string {
|
||||
switch (value) {
|
||||
case 1:
|
||||
return 'Daily';
|
||||
case 4:
|
||||
return 'Monthly';
|
||||
case 5:
|
||||
return 'Quarterly';
|
||||
case 6:
|
||||
return 'Half-Yearly';
|
||||
case 7:
|
||||
return 'Annual';
|
||||
default:
|
||||
return 'Not set';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Accounts can carry a posting period of their own that differs from the product they were
|
||||
* opened under, and the engine follows the account. Worth showing rather than hiding: a
|
||||
* quarterly product whose accounts all post daily is almost never what was intended.
|
||||
*/
|
||||
productPostingMismatch(product: SavingsInterestProduct): boolean {
|
||||
return this.accounts.length > 0 && this.selectedProduct?.id === product.id;
|
||||
}
|
||||
|
||||
backToProducts(): void {
|
||||
this.activeTab = 'products';
|
||||
this.selectedProduct = null;
|
||||
this.selectedAccount = null;
|
||||
}
|
||||
|
||||
backToAccounts(): void {
|
||||
this.activeTab = 'accounts';
|
||||
this.selectedAccount = null;
|
||||
}
|
||||
|
||||
dismissMessage(): void {
|
||||
this.message = '';
|
||||
}
|
||||
|
||||
private fail(error: any): void {
|
||||
this.loading = false;
|
||||
this.messageIsError = true;
|
||||
this.message = error?.error?.defaultUserMessage ?? error?.message ?? 'Request failed.';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { NgModule } from '@angular/core';
|
||||
|
||||
import { SharedModule } from '../shared/shared.module';
|
||||
import { SavingsDailyInterestRoutingModule } from './savings-daily-interest-routing.module';
|
||||
import { SavingsDailyInterestComponent } from './savings-daily-interest.component';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
SharedModule,
|
||||
SavingsDailyInterestRoutingModule,
|
||||
SavingsDailyInterestComponent
|
||||
]
|
||||
})
|
||||
export class SavingsDailyInterestModule {}
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
/** A savings product and where it stands in its interest posting timetable. */
|
||||
export interface SavingsInterestProduct {
|
||||
id: number;
|
||||
name: string;
|
||||
short_name: string;
|
||||
rate: number;
|
||||
posting_period_enum: number;
|
||||
run_at_time: string;
|
||||
last_posting_date: number[] | null;
|
||||
next_posting_date: number[] | null;
|
||||
last_run_status: string | null;
|
||||
is_active: boolean;
|
||||
active_accounts: number;
|
||||
accrued_this_cycle: number;
|
||||
}
|
||||
|
||||
/** One account's position in the product ledger, as at the latest day recorded. */
|
||||
export interface SavingsInterestAccount {
|
||||
savings_account_id: number;
|
||||
account_no: string;
|
||||
client_name: string;
|
||||
balance_date: number[];
|
||||
opening_balance: number;
|
||||
closing_balance: number;
|
||||
daily_interest: number;
|
||||
cycle_cumulative_interest: number;
|
||||
cycle_number: number;
|
||||
cycle_start_date: number[];
|
||||
cycle_end_date: number[];
|
||||
below_min_balance: boolean;
|
||||
}
|
||||
|
||||
/** One day of one account: the working behind that day's interest. */
|
||||
export interface SavingsInterestDay {
|
||||
balance_date: number[];
|
||||
opening_balance: number;
|
||||
closing_balance: number;
|
||||
daily_interest: number;
|
||||
cycle_cumulative_interest: number;
|
||||
cycle_number: number;
|
||||
cycle_start_date: number[];
|
||||
cycle_end_date: number[];
|
||||
is_posting_day: boolean;
|
||||
posted_amount: number | null;
|
||||
savings_transaction_id: number | null;
|
||||
below_min_balance: boolean;
|
||||
interest_rate: number;
|
||||
days_in_year: number;
|
||||
min_balance_for_interest: number | null;
|
||||
last_recomputed_on: number[] | null;
|
||||
}
|
||||
|
||||
/** What a posting run pooled and paid, and whether it finished. */
|
||||
export interface SavingsInterestRun {
|
||||
id: number;
|
||||
savings_product_id: number;
|
||||
product_name: string;
|
||||
posting_date: number[];
|
||||
account_count: number;
|
||||
pooled_amount: number;
|
||||
distributed_amount: number;
|
||||
rounding_residual: number;
|
||||
status: string;
|
||||
failure_reason: string | null;
|
||||
started_at: number[] | null;
|
||||
completed_at: number[] | null;
|
||||
}
|
||||
|
||||
export interface SavingsInterestReconciliation {
|
||||
clean: boolean;
|
||||
problemCount: number;
|
||||
problems: string[];
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class SavingsDailyInterestService {
|
||||
private http = inject(HttpClient);
|
||||
|
||||
private readonly base = '/savingsdailyinterest';
|
||||
|
||||
getProducts(): Observable<SavingsInterestProduct[]> {
|
||||
return this.http.get<SavingsInterestProduct[]>(`${this.base}/products`);
|
||||
}
|
||||
|
||||
getAccountsForProduct(productId: number): Observable<SavingsInterestAccount[]> {
|
||||
return this.http.get<SavingsInterestAccount[]>(`${this.base}/products/${productId}/accounts`);
|
||||
}
|
||||
|
||||
getAccountLedger(accountId: number, limit = 400): Observable<SavingsInterestDay[]> {
|
||||
const params = new HttpParams().set('limit', limit);
|
||||
return this.http.get<SavingsInterestDay[]>(`${this.base}/accounts/${accountId}`, { params });
|
||||
}
|
||||
|
||||
getRuns(productId?: number): Observable<SavingsInterestRun[]> {
|
||||
let params = new HttpParams();
|
||||
if (productId) {
|
||||
params = params.set('productId', productId);
|
||||
}
|
||||
return this.http.get<SavingsInterestRun[]>(`${this.base}/runs`, { params });
|
||||
}
|
||||
|
||||
reconcile(): Observable<SavingsInterestReconciliation> {
|
||||
return this.http.get<SavingsInterestReconciliation>(`${this.base}/reconcile`);
|
||||
}
|
||||
|
||||
/** Runs a day by hand: accrual for every account, then any product whose cycle ends that day. */
|
||||
runForDate(date?: string): Observable<any> {
|
||||
let params = new HttpParams();
|
||||
if (date) {
|
||||
params = params.set('date', date);
|
||||
}
|
||||
return this.http.post(`${this.base}/run`, {}, { params });
|
||||
}
|
||||
|
||||
/** Rebuilds the ledger for every account from activation up to the given date. */
|
||||
backfill(upTo?: string): Observable<any> {
|
||||
let params = new HttpParams();
|
||||
if (upTo) {
|
||||
params = params.set('upTo', upTo);
|
||||
}
|
||||
return this.http.post(`${this.base}/backfill`, {}, { params });
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,26 @@
|
||||
<button mat-flat-button color="primary" (click)="openAddInventoryDialog()">Register DD Series</button>
|
||||
</div>
|
||||
|
||||
<!-- Says why the clerk is here when they arrived from a dividend claim rather than from the menu.
|
||||
Without it, a page listing every DD series gives no clue which member is waiting at the
|
||||
counter or how much the draft has to be drawn for. -->
|
||||
@if (dividendContext) {
|
||||
<div class="dividend-banner">
|
||||
<div class="dividend-banner__text">
|
||||
<strong>Issuing a dividend Demand Draft</strong>
|
||||
<span>
|
||||
{{ dividendContext.beneficiary }} ·
|
||||
{{ dividendContext.amount | number: '1.2-2' }}
|
||||
@if (dividendContext.financialYear) {
|
||||
· FY {{ dividendContext.financialYear }}
|
||||
}
|
||||
</span>
|
||||
<span class="dividend-banner__hint">Open a series below and choose an available leaflet.</span>
|
||||
</div>
|
||||
<button mat-button class="action-btn" (click)="cancelDividendClaim()">Cancel</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<mat-card>
|
||||
<table mat-table [dataSource]="dataSource">
|
||||
<!-- ID Column -->
|
||||
@@ -80,8 +100,15 @@
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef>Actions</th>
|
||||
<td mat-cell *matCellDef="let element">
|
||||
<button mat-button class="action-btn" [routerLink]="['/sponsor-dd/leaflets', element.id]">
|
||||
View Leaflets
|
||||
<!-- The dividend context travels on, so the leaflets page knows this is a dividend
|
||||
draft and not an ordinary counter sale. -->
|
||||
<button
|
||||
mat-button
|
||||
class="action-btn"
|
||||
[routerLink]="['/sponsor-dd/leaflets', element.id]"
|
||||
[queryParams]="dividendQueryParams"
|
||||
>
|
||||
{{ dividendContext ? 'Choose Leaflet' : 'View Leaflets' }}
|
||||
</button>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { CommonModule, DatePipe } from '@angular/common';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
@@ -103,6 +103,34 @@ import { SponsorDDService } from '../sponsor-dd.service';
|
||||
color: #b91c1c;
|
||||
font-weight: 600;
|
||||
}
|
||||
.dividend-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid #c7d2fe;
|
||||
border-left: 4px solid #4f46e5;
|
||||
border-radius: 8px;
|
||||
background-color: #eef2ff;
|
||||
}
|
||||
.dividend-banner__text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
font-size: 13px;
|
||||
color: #312e81;
|
||||
}
|
||||
.dividend-banner__text strong {
|
||||
font-size: 13.5px;
|
||||
}
|
||||
.dividend-banner__hint {
|
||||
color: #4338ca;
|
||||
font-size: 12px;
|
||||
}
|
||||
.dividend-banner button {
|
||||
margin-left: auto;
|
||||
}
|
||||
`
|
||||
]
|
||||
})
|
||||
@@ -110,6 +138,17 @@ export class DdInventoryListComponent implements OnInit {
|
||||
private sponsorDDService = inject(SponsorDDService);
|
||||
private dialog = inject(MatDialog);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
private route = inject(ActivatedRoute);
|
||||
private router = inject(Router);
|
||||
|
||||
/**
|
||||
* The dividend this visit is drawing a draft for, when the clerk arrived from a dividend claim
|
||||
* rather than from the menu.
|
||||
*
|
||||
* Null on an ordinary visit, which is what keeps this page's normal behaviour untouched — the
|
||||
* banner, the relabelled action and the forwarded query params all hang off this being set.
|
||||
*/
|
||||
dividendContext: { lockId: number; amount: number; beneficiary: string; financialYear: string } | null = null;
|
||||
|
||||
displayedColumns: string[] = [
|
||||
'id',
|
||||
@@ -126,9 +165,51 @@ export class DdInventoryListComponent implements OnInit {
|
||||
dataSource: any[] = [];
|
||||
|
||||
ngOnInit(): void {
|
||||
this.readDividendContext();
|
||||
this.loadInventories();
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks up the dividend a claim sent us here to pay out.
|
||||
*
|
||||
* Read from the URL rather than handed over in a service so a refreshed or bookmarked page still
|
||||
* knows which lock it is drawing against — losing it silently would issue an ordinary counter
|
||||
* draft against the member instead of settling their dividend.
|
||||
*/
|
||||
private readDividendContext(): void {
|
||||
const params = this.route.snapshot.queryParams;
|
||||
const lockId = Number(params['dividendLockId']);
|
||||
if (!lockId) {
|
||||
return;
|
||||
}
|
||||
this.dividendContext = {
|
||||
lockId,
|
||||
amount: Number(params['dividendAmount']) || 0,
|
||||
beneficiary: params['dividendBeneficiary'] ?? '',
|
||||
financialYear: params['dividendFinancialYear'] ?? ''
|
||||
};
|
||||
}
|
||||
|
||||
/** The dividend context to carry onto the leaflets page; empty on an ordinary visit. */
|
||||
get dividendQueryParams(): Record<string, string | number> {
|
||||
return this.dividendContext
|
||||
? {
|
||||
dividendLockId: this.dividendContext.lockId,
|
||||
dividendAmount: this.dividendContext.amount,
|
||||
dividendBeneficiary: this.dividendContext.beneficiary,
|
||||
dividendFinancialYear: this.dividendContext.financialYear
|
||||
}
|
||||
: {};
|
||||
}
|
||||
|
||||
/** Abandons the claim and returns to the dividend dashboard, leaving the lock untouched. */
|
||||
cancelDividendClaim(): void {
|
||||
this.router.navigate([
|
||||
'/clerk',
|
||||
'dividend-dashboard'
|
||||
]);
|
||||
}
|
||||
|
||||
loadInventories(): void {
|
||||
this.sponsorDDService.getInventories().subscribe({
|
||||
next: (data) => {
|
||||
|
||||
@@ -189,6 +189,12 @@ export class DdIssueApprovalsComponent implements OnInit, AfterViewInit {
|
||||
if (element.paymentMethod === 'CHEQUE') {
|
||||
return 'Cheque';
|
||||
}
|
||||
// Nothing is collected — the society already owes the member this money, so the draft is funded
|
||||
// from Dividend Payable. Named rather than left as "-" so a checker can see at a glance that no
|
||||
// purchaser will be debited when they approve it.
|
||||
if (element.paymentMethod === 'DIVIDEND') {
|
||||
return 'Dividend payout';
|
||||
}
|
||||
if (element.savingsAccountId) {
|
||||
return 'Savings';
|
||||
}
|
||||
|
||||
@@ -19,6 +19,20 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Says why the clerk is here when they arrived from a dividend claim, and which member is
|
||||
waiting. Without it a registry of DD numbers gives no clue what the next click commits to. -->
|
||||
<div class="dividend-banner" *ngIf="dividendContext">
|
||||
<div class="dividend-banner__text">
|
||||
<strong>Issuing a dividend Demand Draft</strong>
|
||||
<span>
|
||||
{{ dividendContext.beneficiary }} · {{ dividendContext.amount | number: '1.2-2' }}
|
||||
<ng-container *ngIf="dividendContext.financialYear"> · FY {{ dividendContext.financialYear }}</ng-container>
|
||||
</span>
|
||||
<span class="dividend-banner__hint">Pick an available leaflet below and press Issue.</span>
|
||||
</div>
|
||||
<button mat-button class="action-btn" (click)="cancelDividendClaim()">Cancel</button>
|
||||
</div>
|
||||
|
||||
<mat-form-field appearance="outline" class="search-field">
|
||||
<mat-label>Search by DD Leaflet No.</mat-label>
|
||||
<input matInput [value]="searchText" (input)="onSearchChange($event.target.value)" placeholder="e.g. 3005" />
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
import { Component, OnInit, AfterViewInit, ViewChild, inject } from '@angular/core';
|
||||
import { CommonModule, DatePipe } from '@angular/common';
|
||||
import { ActivatedRoute, RouterModule } from '@angular/router';
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, FormArray, Validators } from '@angular/forms';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatTableModule, MatTableDataSource } from '@angular/material/table';
|
||||
@@ -25,6 +25,7 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { debounceTime } from 'rxjs/operators';
|
||||
import { SponsorDDService } from '../sponsor-dd.service';
|
||||
import { DividendClaimRequestService } from '../../clerk/dividend-claim-request.service';
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-dd-list',
|
||||
@@ -50,6 +51,31 @@ import { SponsorDDService } from '../sponsor-dd.service';
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.dividend-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid #c7d2fe;
|
||||
border-left: 4px solid #4f46e5;
|
||||
border-radius: 8px;
|
||||
background-color: #eef2ff;
|
||||
}
|
||||
.dividend-banner__text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
font-size: 13px;
|
||||
color: #312e81;
|
||||
}
|
||||
.dividend-banner__hint {
|
||||
color: #4338ca;
|
||||
font-size: 12px;
|
||||
}
|
||||
.dividend-banner button {
|
||||
margin-left: auto;
|
||||
}
|
||||
.header-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -155,11 +181,18 @@ import { SponsorDDService } from '../sponsor-dd.service';
|
||||
export class DdListComponent implements OnInit, AfterViewInit {
|
||||
private sponsorDDService = inject(SponsorDDService);
|
||||
private route = inject(ActivatedRoute);
|
||||
private router = inject(Router);
|
||||
private dialog = inject(MatDialog);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
|
||||
@ViewChild(MatPaginator) paginator!: MatPaginator;
|
||||
|
||||
/**
|
||||
* The dividend this visit is drawing a draft for, when the clerk arrived from a dividend claim.
|
||||
* Null on an ordinary visit, which leaves every existing behaviour on this page untouched.
|
||||
*/
|
||||
dividendContext: { lockId: number; amount: number; beneficiary: string; financialYear: string } | null = null;
|
||||
|
||||
inventoryId!: number;
|
||||
inventoryDetails: any = null;
|
||||
displayedColumns: string[] = [
|
||||
@@ -194,6 +227,7 @@ export class DdListComponent implements OnInit, AfterViewInit {
|
||||
|
||||
ngOnInit(): void {
|
||||
this.inventoryId = +this.route.snapshot.params['inventoryId'];
|
||||
this.readDividendContext();
|
||||
this.dataSource.filterPredicate = (data) => {
|
||||
const statusMatches = this.selectedStatus === 'ALL' || data.status === this.selectedStatus;
|
||||
const searchMatches =
|
||||
@@ -207,6 +241,38 @@ export class DdListComponent implements OnInit, AfterViewInit {
|
||||
this.loadLeaflets();
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks up the dividend a claim sent the clerk here to pay out, and shows the leaflets they can
|
||||
* actually use for it.
|
||||
*
|
||||
* The page normally opens on today's issued drafts, which is the right view for reconciling but
|
||||
* exactly the wrong one here — a clerk sent to pick a blank leaflet would land on a list with
|
||||
* none in it. Dividend mode opens on AVAILABLE instead.
|
||||
*/
|
||||
private readDividendContext(): void {
|
||||
const params = this.route.snapshot.queryParams;
|
||||
const lockId = Number(params['dividendLockId']);
|
||||
if (!lockId) {
|
||||
return;
|
||||
}
|
||||
this.dividendContext = {
|
||||
lockId,
|
||||
amount: Number(params['dividendAmount']) || 0,
|
||||
beneficiary: params['dividendBeneficiary'] ?? '',
|
||||
financialYear: params['dividendFinancialYear'] ?? ''
|
||||
};
|
||||
this.selectedStatus = 'AVAILABLE';
|
||||
this.todayOnly = false;
|
||||
}
|
||||
|
||||
/** Abandons the claim and returns to the dividend dashboard, leaving the lock untouched. */
|
||||
cancelDividendClaim(): void {
|
||||
this.router.navigate([
|
||||
'/clerk',
|
||||
'dividend-dashboard'
|
||||
]);
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
this.dataSource.paginator = this.paginator;
|
||||
}
|
||||
@@ -276,12 +342,25 @@ export class DdListComponent implements OnInit, AfterViewInit {
|
||||
openIssueDialog(leaflet: any): void {
|
||||
const dialogRef = this.dialog.open(IssueDraftDialogComponent, {
|
||||
width: '560px',
|
||||
data: { leaflet, inventoryId: this.inventoryId },
|
||||
// The dividend, when the clerk came here from a claim. Passing it through puts the dialog in
|
||||
// dividend mode: beneficiary and amount arrive filled in, there is no payment to collect
|
||||
// because the society already owes the money, and submitting files the claim rather than an
|
||||
// ordinary issue request.
|
||||
data: { leaflet, inventoryId: this.inventoryId, dividend: this.dividendContext },
|
||||
disableClose: true
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe((result) => {
|
||||
if (result) {
|
||||
// A dividend claim is finished the moment it is filed — the draft is drawn on approval, and
|
||||
// there is nothing further for the clerk to do on this page.
|
||||
if (this.dividendContext) {
|
||||
this.router.navigate([
|
||||
'/clerk',
|
||||
'dividend-dashboard'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
this.loadLeaflets();
|
||||
}
|
||||
});
|
||||
@@ -366,14 +445,35 @@ export class DdListComponent implements OnInit, AfterViewInit {
|
||||
],
|
||||
providers: [DatePipe],
|
||||
template: `
|
||||
<h2 mat-dialog-title>Issue Demand Draft (DD No: {{ data.leaflet.ddNumber }})</h2>
|
||||
<h2 mat-dialog-title>
|
||||
{{ dividend ? 'Pay Dividend by Demand Draft' : 'Issue Demand Draft' }} (DD No:
|
||||
{{ data.leaflet.ddNumber }})
|
||||
</h2>
|
||||
<mat-dialog-content>
|
||||
<!-- A dividend draft is not a counter sale: the society already owes the member the money, so
|
||||
there is nothing to collect and the beneficiary and amount are fixed by the entitlement.
|
||||
The clerk is left with only the branch and city to fill in. -->
|
||||
<ng-container *ngIf="dividend; else counterSaleHint">
|
||||
<div class="dividend-callout">
|
||||
<strong>Settling a dividend</strong>
|
||||
<span>
|
||||
{{ dividend.beneficiary }} · {{ dividend.amount | number: '1.2-2' }}
|
||||
<ng-container *ngIf="dividend.financialYear"> · FY {{ dividend.financialYear }}</ng-container>
|
||||
</span>
|
||||
<span class="dividend-callout__hint">
|
||||
Funded from Dividend Payable — no charge, no GST, nothing collected from the member. The draft is drawn
|
||||
once a manager approves the claim.
|
||||
</span>
|
||||
</div>
|
||||
</ng-container>
|
||||
<ng-template #counterSaleHint>
|
||||
<p class="hint-text">
|
||||
This submits a request to a Bank Manager / Head Cashier for approval. Money only moves (client debited, Sponsor
|
||||
DD Payable GL credited) once the request is approved.
|
||||
This submits a request to a Bank Manager / Head Cashier for approval. Money only moves (client debited,
|
||||
Sponsor DD Payable GL credited) once the request is approved.
|
||||
</p>
|
||||
</ng-template>
|
||||
<form [formGroup]="issueForm" class="dialog-form">
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-form-field appearance="outline" class="full-width" *ngIf="!dividend">
|
||||
<mat-label>How is the customer paying?</mat-label>
|
||||
<mat-select formControlName="paymentMethod">
|
||||
<mat-option value="ACCOUNT_DEBIT">Debit from their Savings/Current Account</mat-option>
|
||||
@@ -382,7 +482,7 @@ export class DdListComponent implements OnInit, AfterViewInit {
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<ng-container *ngIf="issueForm.get('paymentMethod')?.value === 'ACCOUNT_DEBIT'">
|
||||
<ng-container *ngIf="!dividend && issueForm.get('paymentMethod')?.value === 'ACCOUNT_DEBIT'">
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Account Type</mat-label>
|
||||
<mat-select formControlName="accountType">
|
||||
@@ -410,7 +510,7 @@ export class DdListComponent implements OnInit, AfterViewInit {
|
||||
</p>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="issueForm.get('paymentMethod')?.value === 'CHEQUE'">
|
||||
<ng-container *ngIf="!dividend && issueForm.get('paymentMethod')?.value === 'CHEQUE'">
|
||||
<div class="row">
|
||||
<mat-form-field appearance="outline" class="half-width">
|
||||
<mat-label>Cheque Number</mat-label>
|
||||
@@ -426,7 +526,7 @@ export class DdListComponent implements OnInit, AfterViewInit {
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="issueForm.get('paymentMethod')?.value === 'CASH'">
|
||||
<ng-container *ngIf="!dividend && issueForm.get('paymentMethod')?.value === 'CASH'">
|
||||
<p class="hint-text">
|
||||
Walk-in purchaser paying cash — need not be an existing bank customer. Record their details for the DD
|
||||
record; the Aadhaar number is masked before it is stored.
|
||||
@@ -501,7 +601,7 @@ export class DdListComponent implements OnInit, AfterViewInit {
|
||||
<mat-form-field
|
||||
appearance="outline"
|
||||
class="full-width"
|
||||
*ngIf="issueForm.get('paymentMethod')?.value !== 'CASH'"
|
||||
*ngIf="!dividend && issueForm.get('paymentMethod')?.value !== 'CASH'"
|
||||
>
|
||||
<mat-label>Client (Customer) ID</mat-label>
|
||||
<input matInput type="number" formControlName="clientId" />
|
||||
@@ -537,13 +637,14 @@ export class DdListComponent implements OnInit, AfterViewInit {
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>DD Face Value (₹)</mat-label>
|
||||
<input matInput type="number" formControlName="faceValue" />
|
||||
<mat-hint *ngIf="issueForm.get('faceValue')?.disabled">
|
||||
<mat-hint *ngIf="dividend">Locked — the member's unclaimed dividend for this lock.</mat-hint>
|
||||
<mat-hint *ngIf="!dividend && issueForm.get('faceValue')?.disabled">
|
||||
Locked — from the DD Charges & GST breakdown entered at Transaction Entry.
|
||||
</mat-hint>
|
||||
<mat-error *ngIf="issueForm.get('faceValue')?.hasError('required')">Face Value is required</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-form-field appearance="outline" class="full-width" *ngIf="!dividend">
|
||||
<mat-label>Charges / Commission (₹)</mat-label>
|
||||
<input matInput type="number" formControlName="chargesAmount" />
|
||||
<mat-hint *ngIf="issueForm.get('chargesAmount')?.disabled">
|
||||
@@ -560,8 +661,8 @@ export class DdListComponent implements OnInit, AfterViewInit {
|
||||
|
||||
<ng-container
|
||||
*ngIf="
|
||||
issueForm.get('paymentMethod')?.value === 'ACCOUNT_DEBIT' ||
|
||||
issueForm.get('paymentMethod')?.value === 'CHEQUE'
|
||||
!dividend && (issueForm.get('paymentMethod')?.value === 'ACCOUNT_DEBIT' ||
|
||||
issueForm.get('paymentMethod')?.value === 'CHEQUE')
|
||||
"
|
||||
>
|
||||
<p class="hint-text" *ngIf="additionalLinesArray.length > 0">
|
||||
@@ -638,16 +739,35 @@ export class DdListComponent implements OnInit, AfterViewInit {
|
||||
[disabled]="
|
||||
issueForm.invalid ||
|
||||
loading ||
|
||||
(issueForm.get('paymentMethod')?.value === 'ACCOUNT_DEBIT' && accountLookupState !== 'found')
|
||||
(!dividend && issueForm.get('paymentMethod')?.value === 'ACCOUNT_DEBIT' && accountLookupState !== 'found')
|
||||
"
|
||||
(click)="onSubmit()"
|
||||
>
|
||||
Submit for Approval
|
||||
{{ dividend ? 'Submit Claim Request' : 'Submit for Approval' }}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.dividend-callout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
margin-bottom: 16px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #c7d2fe;
|
||||
border-left: 4px solid #4f46e5;
|
||||
border-radius: 8px;
|
||||
background-color: #eef2ff;
|
||||
font-size: 13px;
|
||||
color: #312e81;
|
||||
}
|
||||
.dividend-callout__hint {
|
||||
margin-top: 4px;
|
||||
color: #4338ca;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.dialog-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -702,6 +822,7 @@ export class IssueDraftDialogComponent implements OnInit {
|
||||
private dialogRef = inject(MatDialogRef<IssueDraftDialogComponent>);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
private dialog = inject(MatDialog);
|
||||
private claimRequestService = inject(DividendClaimRequestService);
|
||||
protected data = inject(MAT_DIALOG_DATA);
|
||||
|
||||
issueForm!: FormGroup;
|
||||
@@ -718,6 +839,53 @@ export class IssueDraftDialogComponent implements OnInit {
|
||||
ddLimitAmount: number | null = null;
|
||||
availableCashierDeposits: any[] = [];
|
||||
|
||||
/**
|
||||
* The dividend this draft settles, when the dialog was opened from a dividend claim.
|
||||
*
|
||||
* A dividend draft is not a counter sale: the society already owes the member the money, so
|
||||
* there is no purchaser to debit, no charge and no GST. The beneficiary and the amount are fixed
|
||||
* by the entitlement itself, which leaves the clerk only the branch and city to fill in.
|
||||
*/
|
||||
get dividend(): { lockId: number; amount: number; beneficiary: string; financialYear: string } | null {
|
||||
return this.data?.dividend ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Locks the form to what the dividend already decided and drops everything a counter sale needs.
|
||||
*
|
||||
* The payment-method controls are cleared of their validators rather than left required-and-empty
|
||||
* — the form would otherwise never become valid and the clerk would be staring at a dead Submit
|
||||
* button with no field to fix.
|
||||
*/
|
||||
private applyDividendMode(): void {
|
||||
const dividend = this.dividend;
|
||||
if (!dividend) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.issueForm.patchValue({
|
||||
beneficiaryName: dividend.beneficiary,
|
||||
faceValue: dividend.amount,
|
||||
chargesAmount: 0
|
||||
});
|
||||
|
||||
// Fixed by the entitlement — a clerk cannot pay a different member, or a different amount, than
|
||||
// the dividend the claim was raised against.
|
||||
this.issueForm.get('beneficiaryName')?.disable();
|
||||
this.issueForm.get('faceValue')?.disable();
|
||||
|
||||
for (const name of [
|
||||
'clientId',
|
||||
'accountNumber',
|
||||
'paymentMethod',
|
||||
'chargesAmount'
|
||||
]) {
|
||||
const control = this.issueForm.get(name);
|
||||
control?.clearValidators();
|
||||
control?.updateValueAndValidity();
|
||||
}
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.sponsorDDService.getGlMappings().subscribe({
|
||||
next: (mappings) => {
|
||||
@@ -787,6 +955,8 @@ export class IssueDraftDialogComponent implements OnInit {
|
||||
additionalLines: this.fb.array([])
|
||||
});
|
||||
|
||||
this.applyDividendMode();
|
||||
|
||||
this.forceUppercase([
|
||||
'beneficiaryName',
|
||||
'payableAtBranch',
|
||||
@@ -964,9 +1134,53 @@ export class IssueDraftDialogComponent implements OnInit {
|
||||
this.dialogRef.close(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Files the dividend claim rather than an ordinary issue request.
|
||||
*
|
||||
* The draft itself is drawn when a manager approves the claim — that approval already is the
|
||||
* check on this money, so routing it through the DD maker-checker as well would put a member's
|
||||
* own dividend behind two separate approvals for no added control.
|
||||
*/
|
||||
private submitDividendClaim(): void {
|
||||
const dividend = this.dividend!;
|
||||
const formVal = this.issueForm.getRawValue();
|
||||
|
||||
this.loading = true;
|
||||
this.claimRequestService
|
||||
.submit(dividend.lockId, {
|
||||
ddApplyAmount: Number(formVal.faceValue),
|
||||
ddBeneficiaryName: formVal.beneficiaryName,
|
||||
ddPayableAtBranch: formVal.payableAtBranch,
|
||||
ddPayableAtCity: formVal.payableAtCity,
|
||||
// The leaflet the clerk actually opened this dialog on. The draft has to be drawn on the
|
||||
// physical form in their hand, not on whichever one the server would pick next.
|
||||
leafletId: this.data?.leaflet?.id
|
||||
})
|
||||
.subscribe({
|
||||
next: (res: any) => {
|
||||
const ddNumber = res?.ddNumber ? ` DD No ${res.ddNumber}.` : '';
|
||||
this.snackBar.open(
|
||||
`Dividend draft submitted for approval.${ddNumber} It now appears under DD Issuance Approvals.`,
|
||||
'Close',
|
||||
{ duration: 6000 }
|
||||
);
|
||||
this.dialogRef.close(true);
|
||||
},
|
||||
error: (err) => {
|
||||
this.loading = false;
|
||||
this.snackBar.open('Error: ' + (err.error?.message || err.message), 'Close', { duration: 5000 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onSubmit(): void {
|
||||
if (this.issueForm.invalid) return;
|
||||
|
||||
if (this.dividend) {
|
||||
this.submitDividendClaim();
|
||||
return;
|
||||
}
|
||||
|
||||
const formVal = this.issueForm.getRawValue();
|
||||
if (formVal.paymentMethod === 'ACCOUNT_DEBIT' && this.accountLookupState !== 'found') {
|
||||
this.snackBar.open(
|
||||
|
||||
+6
-3
@@ -34,20 +34,23 @@
|
||||
<td colspan="6" class="pg-empty">No dividend declarations are waiting for approval.</td>
|
||||
</tr>
|
||||
} @else {
|
||||
<!-- The whole row opens the member-by-member breakdown, so a checker can get to the detail
|
||||
the same way they would on the Dividends list rather than having to find the View
|
||||
button. The action buttons stop propagation so Approve still approves directly. -->
|
||||
@for (row of rows; track row.dividendId) {
|
||||
<tr>
|
||||
<tr class="dr-row" (click)="openView(row)">
|
||||
<td>{{ row.productName }}</td>
|
||||
<td>{{ row.dividendPeriodStartDate }} → {{ row.dividendPeriodEndDate }}</td>
|
||||
<td class="num">{{ row.amount | number: '1.2-2' }}</td>
|
||||
<td class="num">{{ row.memberCount }}</td>
|
||||
<td>{{ row.declaredOn | slice: 0 : 10 }}</td>
|
||||
<td class="dr-actions">
|
||||
<button type="button" class="pg-btn" (click)="openView(row)">View</button>
|
||||
<button type="button" class="pg-btn" (click)="openView(row); $event.stopPropagation()">View</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn dr-approve-btn"
|
||||
[disabled]="approvingId === row.dividendId"
|
||||
(click)="approve(row)"
|
||||
(click)="approve(row); $event.stopPropagation()"
|
||||
>
|
||||
{{ approvingId === row.dividendId ? 'Approving…' : 'Approve' }}
|
||||
</button>
|
||||
|
||||
+9
@@ -83,3 +83,12 @@
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* The whole row opens the member-by-member breakdown, so it has to look like it will. */
|
||||
.dr-row {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
}
|
||||
|
||||
+29
@@ -8,6 +8,7 @@
|
||||
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
@@ -38,6 +39,8 @@ import { ViewDividendComponent } from '../../../products/share-products/view-div
|
||||
export class DividendRequestsComponent implements OnInit {
|
||||
private service = inject(PendingDividendsService);
|
||||
private productsService = inject(ProductsService);
|
||||
private route = inject(ActivatedRoute);
|
||||
private router = inject(Router);
|
||||
|
||||
rows: any[] = [];
|
||||
loading = false;
|
||||
@@ -51,9 +54,30 @@ export class DividendRequestsComponent implements OnInit {
|
||||
viewDividendId: number | null = null;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.openRequestedDeclaration();
|
||||
this.load();
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the breakdown straight away when the officer arrived here from a specific declaration.
|
||||
*
|
||||
* The Dividends list sends them here to approve one declaration in particular, so making them
|
||||
* find its row again in a list of every pending declaration would undo the point of the link.
|
||||
* Read from the query string rather than a service so a refreshed or shared URL still opens the
|
||||
* same declaration.
|
||||
*/
|
||||
private openRequestedDeclaration(): void {
|
||||
const params = this.route.snapshot.queryParams;
|
||||
const productId = Number(params['productId']);
|
||||
const dividendId = Number(params['dividendId']);
|
||||
if (!productId || !dividendId) {
|
||||
return;
|
||||
}
|
||||
this.viewProductId = productId;
|
||||
this.viewDividendId = dividendId;
|
||||
this.showViewModal = true;
|
||||
}
|
||||
|
||||
openView(row: any): void {
|
||||
this.viewProductId = row.productId;
|
||||
this.viewDividendId = row.dividendId;
|
||||
@@ -64,6 +88,11 @@ export class DividendRequestsComponent implements OnInit {
|
||||
this.showViewModal = false;
|
||||
this.viewProductId = null;
|
||||
this.viewDividendId = null;
|
||||
// Dropped from the URL as well, or the declaration the officer just closed would reopen on the
|
||||
// next refresh and there would be no way to get back to the plain list.
|
||||
if (this.route.snapshot.queryParams['dividendId']) {
|
||||
this.router.navigate([], { relativeTo: this.route, queryParams: {}, replaceUrl: true });
|
||||
}
|
||||
}
|
||||
|
||||
onViewActionCompleted(): void {
|
||||
|
||||
+3
-2
@@ -97,11 +97,12 @@
|
||||
}
|
||||
|
||||
.letter-mega-panel {
|
||||
max-width: 850px !important;
|
||||
max-width: 1100px !important;
|
||||
}
|
||||
|
||||
.letter-grid {
|
||||
grid-template-columns: repeat(4, 1fr) !important;
|
||||
grid-template-columns: repeat(5, minmax(190px, 1fr)) !important;
|
||||
min-width: 1040px;
|
||||
}
|
||||
|
||||
.reconciliation-mega-panel {
|
||||
|
||||
Reference in New Issue
Block a user