feat: implement cashier dashboard, dividend dashboards, share accounts views, and checker tasks

This commit is contained in:
2026-08-25 11:20:42 +05:30
parent 7ef75c902d
commit 0b79b0b0d3
77 changed files with 7396 additions and 426 deletions
+24 -17
View File
@@ -47,11 +47,11 @@ import { ViewTaxComponentComponent } from './manage-tax-components/view-tax-comp
import { CreateTaxComponentComponent } from './manage-tax-components/create-tax-component/create-tax-component.component';
import { EditTaxComponentComponent } from './manage-tax-components/edit-tax-component/edit-tax-component.component';
import { ViewTaxGroupComponent } from './manage-tax-groups/view-tax-group/view-tax-group.component';
import { ShareProductsDividendsComponent } from './share-products/dividends-share-product/dividends.components';
import { CreateRecurringDepositProductComponent } from './recurring-deposit-products/create-recurring-deposit-product/create-recurring-deposit-product.component';
import { CreateDividendComponent } from './share-products/create-dividend/create-dividend.component';
import { ViewFixedDepositProductComponent } from './fixed-deposit-products/view-fixed-deposit-product/view-fixed-deposit-product.component';
import { ViewDividendComponent } from './share-products/view-dividend/view-dividend.component';
import { AllProductsDividendsComponent } from './share-products/all-products-dividends/all-products-dividends.component';
import { DeclareDividendComponent } from './share-products/all-products-dividends/declare-dividend/declare-dividend.component';
import { CreateTaxGroupComponent } from './manage-tax-groups/create-tax-group/create-tax-group.component';
import { EditTaxGroupComponent } from './manage-tax-groups/edit-tax-group/edit-tax-group.component';
import { CreateProductMixComponent } from './products-mix/create-product-mix/create-product-mix.component';
@@ -370,6 +370,23 @@ const routes: Routes = [
shareProductsTemplate: ShareProductsTemplateResolver
}
},
// Must stay ahead of ':productId', which would otherwise swallow 'dividends' as a
// product id and route to a product that does not exist.
{
path: 'dividends',
data: { title: 'Dividends', breadcrumb: 'Dividends' },
children: [
{
path: '',
component: AllProductsDividendsComponent
},
{
path: 'declare',
component: DeclareDividendComponent,
data: { title: 'Declare Dividend', breadcrumb: 'Declare', routeParamBreadcrumb: false }
}
]
},
{
path: ':productId',
data: { title: 'View Share Product', breadcrumb: 'productId', routeParamBreadcrumb: 'productId' },
@@ -417,25 +434,15 @@ const routes: Routes = [
shareProductAndTemplate: ShareProductAndTemplateResolver
}
},
// A dividend is now declared for the whole society at once, at
// share-products/dividends, because the board resolves one rate for the year rather
// than one per product. What remains here is the per-product declaration view: the
// member-by-member allocation and the approve action still belong to the product,
// since share capital sits in a separate GL per product.
{
path: 'dividends',
data: { title: 'Share Products Dividends', breadcrumb: 'Dividends', routeParamBreadcrumb: false },
children: [
{
path: '',
component: ShareProductsDividendsComponent,
resolve: {
dividends: DividendsResolver
}
},
{
path: 'create',
component: CreateDividendComponent,
data: { title: 'Create Dividend', breadcrumb: 'Create', routeParamBreadcrumb: false },
resolve: {
shareProduct: ShareProductResolver
}
},
{
path: ':dividendId',
component: ViewDividendComponent,
+104 -3
View File
@@ -190,6 +190,58 @@ export class ProductsService {
return this.http.get(`/shareproduct/${shareProductId}/dividend`);
}
/**
* Every dividend declaration, across all share products.
*
* The board resolves one rate for the society, not one per share product, so the register that
* matters is the society-wide one. Each product still carries its own declaration row underneath,
* because share capital sits in a separate GL per product.
* @returns {Observable<any>} Declarations, newest period first.
*/
getAllProductsDividends(): Observable<any> {
return this.http.get('/shareproducts/dividends');
}
/**
* What a rate would cost, broken down per share product, before anything is declared.
*
* A rate is a promise about each member's own paid-up capital, so what it costs depends on how
* much capital each product's members held and for how long. Showing that split first is what
* makes one cross-product decision safe to take.
* @param fromDate Period start, yyyy-MM-dd.
* @param toDate Period end, yyyy-MM-dd.
* @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> {
const params: any = { fromDate, toDate };
if (ratePercentage != null) {
params.ratePercentage = ratePercentage;
}
if (dividendBasis) {
params.dividendBasis = dividendBasis;
}
return this.http.get('/shareproducts/dividends/preview', { params });
}
/**
* Declares one dividend across every eligible share product at once.
*
* Products with no eligible members, or one already declared for this period, are skipped and
* reported rather than failing the run — one ineligible product is not a reason to leave the
* rest undeclared.
* @param dividendData `{ dividendPeriodStartDate, dividendPeriodEndDate, ratePercentage }`.
* @returns {Observable<any>} `{ declarationsCreated, created, skipped }`.
*/
createAllProductsDividend(dividendData: any): Observable<any> {
return this.http.post('/shareproducts/dividends', dividendData);
}
/**
* Create Dividend.
* @param shareProductId Share Product Id.
@@ -200,15 +252,64 @@ export class ProductsService {
return this.http.post(`/shareproduct/${shareProductId}/dividend`, dividendData);
}
/**
* Weighted paid-up share capital for a period, and what a given rate per annum on it costs.
*
* This is what makes a declared "9%" mean 9% on each member's own capital — the promise an AGM
* resolution actually makes — rather than 9% of the society's profit split across however many
* members happen to hold shares that year.
* @param shareProductId Share Product Id.
* @param fromDate Period start, yyyy-MM-dd.
* @param toDate Period end, yyyy-MM-dd.
* @param ratePercentage Rate per annum; omit to get the weighted capital alone.
* @returns {Observable<any>} `{ weightedCapital, requiredAmount, eligibleAccounts,
* minimumActivePeriodDays }`.
*/
getDividendCapitalPreview(
shareProductId: string,
fromDate: string,
toDate: string,
ratePercentage?: number
): Observable<any> {
let httpParams = new HttpParams().set('fromDate', fromDate).set('toDate', toDate);
if (ratePercentage != null) {
httpParams = httpParams.set('ratePercentage', String(ratePercentage));
}
return this.http.get(`/shareproduct/${shareProductId}/dividend-capital-preview`, { params: httpParams });
}
/**
* Every member's share of one dividend declaration.
*
* Unpaginated on purpose. The screen this feeds totals the rows it receives and shows that as the
* declaration's amount, so a page-sized slice made the total wrong — a 22-member declaration of
* ₹22,190 read as ₹0.00 because the first ten members all happened to fall below the minimum. An
* approval decision cannot be taken against a figure that reflects only part of the register.
*/
getDividendData(shareProductId: any, dividendId: any): Observable<any> {
const httpParams = new HttpParams()
.set('dateFormat', this.settingsService.dateFormat)
.set('limit', '10')
.set('locale', this.settingsService.language.code)
.set('offset', '0');
.set('locale', this.settingsService.language.code);
return this.http.get(`/shareproduct/${shareProductId}/dividend/${dividendId}`, { params: httpParams });
}
/**
* What a declaration did NOT pay out to members: paise shaved off by rounding each entitlement
* down to a whole rupee, plus entitlements dropped for falling under the minimum worth drawing a
* Demand Draft for. Both are swept to Bank Profit at declaration time.
* @returns {Observable<any>} `{ declaredAmount, allocatedAmount, residueAmount, memberCount,
* belowMinimumCount, belowMinimumAmount, sweptToProfitDate, glTransactionId }`, or null for
* declarations made before this was tracked.
*/
getDividendRoundingResidue(shareProductId: any, dividendId: any): Observable<any> {
return this.http.get(`/shareproduct/${shareProductId}/dividend/${dividendId}/rounding-residue`);
}
/** 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`);
}
approveDividend(shareProductId: any, dividendId: any, data: any): Observable<any> {
const httpParams = new HttpParams().set('command', 'approve');
return this.http.put(`/shareproduct/${shareProductId}/dividend/${dividendId}`, data, { params: httpParams });
@@ -0,0 +1,182 @@
<!--
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="pl-root">
<div class="pl-header">
<div class="pl-header-left">
<div class="pl-header-ic"><fa-icon icon="hand-holding-usd"></fa-icon></div>
<div class="pl-header-text">
<h2>Dividends</h2>
<p>One rate per financial year, applied across every share product.</p>
</div>
</div>
<div class="pl-header-right">
<div class="pl-count-chip" matTooltip="Declarations on record">
<fa-icon icon="layer-group"></fa-icon>
<strong>{{ dividends.length }}</strong>
<span>{{ dividends.length === 1 ? 'declaration' : 'declarations' }}</span>
</div>
<button
class="pl-create-btn"
type="button"
[routerLink]="['declare']"
*mifosxHasPermission="'CREATE_SHAREDIVIDEND'"
>
<fa-icon icon="plus"></fa-icon>
<span>Declare Dividend</span>
</button>
</div>
</div>
<div class="pl-kpis">
<div class="pl-kpi pl-kpi--blue">
<div class="pl-kpi-ic"><fa-icon icon="rupee-sign"></fa-icon></div>
<div>
<div class="pl-kpi-n">{{ totalDeclared | number: '1.2-2' }}</div>
<div class="pl-kpi-l">
Total Declared
@if (pendingCount > 0) {
<span class="apd-pending">· {{ pendingCount }} awaiting approval</span>
}
</div>
</div>
</div>
<div class="pl-kpi pl-kpi--teal">
<div class="pl-kpi-ic"><fa-icon icon="users"></fa-icon></div>
<div>
<div class="pl-kpi-n">{{ memberCount | number }}</div>
<div class="pl-kpi-l">Members Receiving</div>
</div>
</div>
<div class="pl-kpi pl-kpi--purple">
<div class="pl-kpi-ic"><fa-icon icon="coins"></fa-icon></div>
<div>
<div class="pl-kpi-n">{{ productCount | number }}</div>
<div class="pl-kpi-l">Share Products Covered</div>
</div>
</div>
</div>
@if (financialYears.length > 1) {
<div class="apd-years">
<span class="apd-years-label">Financial Year</span>
@for (year of financialYears; track year) {
<button
type="button"
class="apd-year-chip"
[class.apd-year-chip--on]="selectedYear === year"
(click)="selectYear(year)"
>
{{ year }}
</button>
}
</div>
}
<div class="pl-card">
<div class="pl-toolbar">
<div class="pl-search">
<span class="pl-search-ic"><fa-icon icon="search"></fa-icon></span>
<input type="text" placeholder="Search by product or year…" (keyup)="applyFilter($any($event.target).value)" />
</div>
</div>
@if (loading) {
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
}
@if (loadFailed) {
<div class="apd-empty apd-empty--error">
<fa-icon icon="exclamation-triangle"></fa-icon>
<p>Could not load dividends. This is a connection problem, not an empty register.</p>
<button type="button" class="pl-secondary-btn" (click)="loadDividends()">Retry</button>
</div>
} @else if (!loading && dataSource.data.length === 0) {
<div class="apd-empty">
<fa-icon icon="hand-holding-usd"></fa-icon>
<p>No dividend has been declared yet.</p>
</div>
}
<!-- @if, not [hidden]: the wrapper sets overflow-x, which gives it a display value that wins
over the hidden attribute, so [hidden] left the table in the layout without showing it. -->
@if (!loadFailed && dataSource.data.length > 0) {
<div class="pl-table-wrap">
<table mat-table class="pl-table" [dataSource]="dataSource" matSort>
<ng-container matColumnDef="financialYear">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Financial Year</th>
<td mat-cell *matCellDef="let row">
<span class="pl-tag">{{ row.financialYear }}</span>
</td>
</ng-container>
<ng-container matColumnDef="productName">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Share Product</th>
<td mat-cell *matCellDef="let row">
<div class="pl-product-cell">
<div class="pl-product-icon"><fa-icon icon="coins"></fa-icon></div>
<div class="pl-product-meta">
<div class="pl-product-name">{{ row.productName }}</div>
<div class="pl-product-sub">Declaration #{{ row.dividendId }}</div>
</div>
</div>
</td>
</ng-container>
<ng-container matColumnDef="period">
<th mat-header-cell *matHeaderCellDef>Period</th>
<td mat-cell *matCellDef="let row">
{{ row.periodStartDate | dateFormat }} – {{ row.periodEndDate | dateFormat }}
</td>
</ng-container>
<ng-container matColumnDef="amount">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Amount</th>
<td mat-cell *matCellDef="let row">
<strong>{{ row.amount | number: '1.2-2' }}</strong>
</td>
</ng-container>
<ng-container matColumnDef="memberCount">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Members</th>
<td mat-cell *matCellDef="let row">{{ row.memberCount | number }}</td>
</ng-container>
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef>Status</th>
<td mat-cell *matCellDef="let row">
<span class="pl-status-pill" [class.apd-status--pending]="!row.approved">
{{ row.approved ? 'Approved' : 'Awaiting Approval' }}
</span>
</td>
</ng-container>
<!-- The approve action lives on the declaration's own page, alongside the member-by-member
allocation. Naming it here rather than relying on the row click makes it findable —
an officer should not have to guess that a row opens somewhere they can approve. -->
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *matCellDef="let row">
<button type="button" class="apd-action" (click)="viewDividend(row); $event.stopPropagation()">
{{ row.approved ? 'View' : 'Review & Approve' }}
<fa-icon icon="chevron-right"></fa-icon>
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row class="pl-row" *matRowDef="let row; columns: displayedColumns" (click)="viewDividend(row)"></tr>
</table>
</div>
}
<!-- pageSize is required, not optional: without it the paginator starts at zero and pages every
row out, leaving a table that looks empty while the data source is full. -->
<mat-paginator [pageSizeOptions]="[10, 25, 50, 100]" [pageSize]="10" showFirstLastButtons></mat-paginator>
</div>
</div>
@@ -0,0 +1,121 @@
/**
* 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/.
*/
@use 'assets/styles/product-list' as pl;
@include pl.product-list-styles;
/* Same pink accent as Share Products — this page is part of that area, not a separate one. */
:host {
--pl-accent: #be185d;
--pl-accent-l: #fce7f3;
}
/* Financial-year filter chips. A dividend decision belongs to a year, so the year is the primary
way an officer narrows this register. */
.apd-years {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 16px;
}
.apd-years-label {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #64748b;
margin-right: 4px;
}
.apd-year-chip {
border: 1px solid #e2e8f0;
background: #fff;
color: #334155;
border-radius: 999px;
padding: 5px 14px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
&:hover {
border-color: var(--pl-accent);
color: var(--pl-accent);
}
&--on {
background: var(--pl-accent);
border-color: var(--pl-accent);
color: #fff;
}
}
/* Amber rather than the default pill colour: a declaration awaiting approval has not reached any
member yet, and should not read the same as one that has. */
.apd-status--pending {
background: #fef3c7;
color: #b45309;
}
.apd-pending {
color: #b45309;
font-weight: 500;
}
/* Right-aligned so it reads as the row's action rather than another data column. */
.apd-action {
display: inline-flex;
align-items: center;
gap: 6px;
border: 1px solid #e2e8f0;
background: #fff;
color: var(--pl-accent);
border-radius: 6px;
padding: 5px 12px;
font-size: 13px;
font-weight: 600;
white-space: nowrap;
cursor: pointer;
transition: all 0.15s ease;
&:hover {
border-color: var(--pl-accent);
background: var(--pl-accent-l);
}
fa-icon {
font-size: 11px;
}
}
.apd-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 48px 20px;
color: #64748b;
text-align: center;
fa-icon {
font-size: 32px;
color: #cbd5e1;
}
p {
margin: 0;
font-size: 14px;
}
&--error fa-icon {
color: #f59e0b;
}
}
@@ -0,0 +1,200 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/** Angular Imports */
import { Component, OnInit, ViewChild, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router, RouterLink } from '@angular/router';
import { MatPaginator, MatPaginatorModule } from '@angular/material/paginator';
import { MatSort, MatSortHeader, MatSortModule } from '@angular/material/sort';
import {
MatTableDataSource,
MatTable,
MatColumnDef,
MatHeaderCellDef,
MatHeaderCell,
MatCellDef,
MatCell,
MatHeaderRowDef,
MatHeaderRow,
MatRowDef,
MatRow
} from '@angular/material/table';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
/** Custom Imports */
import { ProductsService } from '../../products.service';
import { DateFormatPipe } from '../../../pipes/date-format.pipe';
import { HasPermissionDirective } from '../../../directives/has-permission/has-permission.directive';
/**
* The society's dividend register, across every share product.
*
* A co-operative's board resolves one dividend rate for a financial year — not one rate per share
* product — so this is the level the decision is actually taken and recorded at. Each product still
* carries its own declaration row underneath, because share capital sits in a separate GL per
* product and the per-member allocation runs per product; what is shared is the rate and the period.
*
* Declarations for the same financial year are grouped, so a year that touched two products reads
* as one decision with a split rather than as two unrelated entries.
*/
@Component({
selector: 'mifosx-all-products-dividends',
templateUrl: './all-products-dividends.component.html',
styleUrls: ['./all-products-dividends.component.scss'],
imports: [
CommonModule,
FormsModule,
RouterLink,
FaIconComponent,
// Individual table directives rather than MatTableModule, matching the Share Products list this
// page sits beside — the module form leaves the header/row defs unresolved here and the table
// renders nothing at all, not even headers.
MatTable,
MatColumnDef,
MatHeaderCellDef,
MatHeaderCell,
MatCellDef,
MatCell,
MatHeaderRowDef,
MatHeaderRow,
MatRowDef,
MatRow,
MatSort,
MatSortHeader,
MatSortModule,
MatPaginatorModule,
MatTooltipModule,
MatProgressBarModule,
DateFormatPipe,
HasPermissionDirective
]
})
export class AllProductsDividendsComponent implements OnInit {
private productsService = inject(ProductsService);
private router = inject(Router);
/** Every declaration across all products, newest period first. */
dividends: any[] = [];
dataSource = new MatTableDataSource<any>([]);
displayedColumns: string[] = [
'financialYear',
'productName',
'period',
'amount',
'memberCount',
'status',
'actions'
];
loading = true;
loadFailed = false;
/** Distinct financial years present, for the filter chips. */
financialYears: string[] = [];
selectedYear = '';
// The paginator sits outside the table's @if so it resolves statically; the sort lives on the
// table inside it, so it only exists once there are rows to show — applyYearFilter re-attaches
// both every time the rows change, which is when the sort first becomes available.
@ViewChild(MatPaginator, { static: true }) paginator: MatPaginator;
@ViewChild(MatSort) sort: MatSort;
ngOnInit(): void {
this.loadDividends();
}
loadDividends(): void {
this.loading = true;
this.loadFailed = false;
this.productsService.getAllProductsDividends().subscribe({
next: (response: any[]) => {
this.dividends = response || [];
this.financialYears = Array.from(new Set(this.dividends.map((d) => d.financialYear)))
.sort()
.reverse();
this.applyYearFilter();
this.loading = false;
},
error: () => {
// Left empty with a retry rather than showing zeroes, so an unreachable server is never
// mistaken for a year in which no dividend was declared.
this.dividends = [];
this.loadFailed = true;
this.loading = false;
}
});
}
/** Total declared across the rows currently in view. */
get totalDeclared(): number {
return this.visibleRows.reduce((sum, d) => sum + Number(d.amount || 0), 0);
}
/** Declarations still awaiting approval — these have not reached members yet. */
get pendingCount(): number {
return this.visibleRows.filter((d) => !d.approved).length;
}
/** Members receiving a dividend across the rows in view. */
get memberCount(): number {
return this.visibleRows.reduce((sum, d) => sum + Number(d.memberCount || 0), 0);
}
/** Products covered by the rows in view. */
get productCount(): number {
return new Set(this.visibleRows.map((d) => d.productId)).size;
}
private get visibleRows(): any[] {
return this.selectedYear ? this.dividends.filter((d) => d.financialYear === this.selectedYear) : this.dividends;
}
selectYear(year: string): void {
this.selectedYear = this.selectedYear === year ? '' : year;
this.applyYearFilter();
}
/**
* Swaps the rows in place rather than rebuilding the data source, so the paginator and sort stay
* attached — a fresh MatTableDataSource loses both unless they are re-wired every time.
*/
private applyYearFilter(): void {
this.dataSource.data = this.visibleRows;
this.dataSource.paginator = this.paginator;
// Deferred a tick: the sort directive is inside the table's @if, so it does not exist until
// this change detection pass has rendered the rows we just set.
setTimeout(() => {
this.dataSource.sort = this.sort;
});
}
applyFilter(filterValue: string): void {
this.dataSource.filter = (filterValue || '').trim().toLowerCase();
}
/**
* Opens a declaration in the existing per-product view, which holds the member-by-member
* allocation and the approve action.
*/
viewDividend(row: any): void {
this.router.navigate(
[
'/products',
'share-products',
row.productId,
'dividends',
row.dividendId
],
{ queryParams: { status: row.approved ? 'Approved' : 'Initiated' } }
);
}
}
@@ -0,0 +1,217 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="pl-root">
<div class="pl-header">
<div class="pl-header-left">
<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>
</div>
</div>
</div>
@if (result) {
<!-- The outcome, product by product. Which products were skipped and why matters as much as
which succeeded, so both are shown rather than just a success count. -->
<div class="pl-card dd-panel">
<div class="dd-result-head">
<fa-icon icon="check-circle" class="dd-result-ic"></fa-icon>
<div>
<h3>Dividend declared for {{ result.financialYear }}</h3>
<p>
{{ result.declarationsCreated }}
{{ result.declarationsCreated === 1 ? 'declaration' : 'declarations' }} created. Each still needs approval
before members can claim.
</p>
</div>
</div>
<table class="dd-table">
<tbody>
@for (row of result.created; track row.productId) {
<tr>
<td>{{ row.productName }}</td>
<td class="dd-amount">{{ row.amount | number: '1.2-2' }}</td>
<td class="dd-ok">Declared</td>
</tr>
}
@for (row of result.skipped; track row.productId) {
<tr class="dd-skipped">
<td>{{ row.productName }}</td>
<td class="dd-amount">—</td>
<td class="dd-reason">{{ row.reason }}</td>
</tr>
}
</tbody>
</table>
<div class="dd-actions">
<button mat-raised-button color="primary" type="button" (click)="done()">View Dividends</button>
</div>
</div>
} @else {
<form [formGroup]="declareForm" (ngSubmit)="submit()">
<div class="pl-card dd-panel">
<div class="dd-fields">
<mat-form-field appearance="outline">
<mat-label>Financial Year</mat-label>
<mat-select formControlName="financialYear">
@for (year of financialYears; track year.label) {
<mat-option [value]="year.label">{{ year.label }}</mat-option>
}
</mat-select>
</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>
<input matInput type="number" step="0.01" min="0.01" max="100" formControlName="ratePercentage" />
<span matSuffix>&nbsp;%&nbsp;</span>
</mat-form-field>
</div>
@if (loadingPreview) {
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
}
@if (previewError) {
<div class="dd-note dd-note--error">{{ previewError }}</div>
}
@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. -->
<div class="dd-preview">
<div class="dd-preview-head">Where the money comes from</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>
</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>
<div class="dd-preview-row dd-preview-row--subtotal">
<span>Distributable profit</span>
<span class="dd-amount">{{ preview.distributableProfit | number: '1.2-2' }}</span>
</div>
@if (declareForm.value.dividendBasis === 'RATE_ON_CAPITAL') {
<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>
</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'
}}
</div>
@for (product of eligibleProducts; track product.productId) {
<div class="dd-preview-row">
<span>{{ product.productName }}</span>
<span class="dd-amount">{{ product.amount | number: '1.2-2' }}</span>
</div>
}
@if (eligibleProducts.length > 0) {
<div class="dd-preview-row dd-preview-row--total">
<span>Total to declare</span>
<span 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) {
<div class="dd-preview-row dd-preview-row--skipped">
<span>{{ product.productName }}</span>
<span class="dd-reason">
{{ product.alreadyDeclared ? 'Already declared for this year' : product.reason }}
</span>
</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.
</div>
}
</div>
}
@if (submitError) {
<div class="dd-note dd-note--error">{{ submitError }}</div>
}
<div class="dd-actions">
<button mat-stroked-button type="button" (click)="cancel()">Cancel</button>
<button mat-raised-button color="primary" type="submit" [disabled]="!canSubmit">
{{ submitting ? 'Declaring…' : 'Declare Dividend' }}
</button>
</div>
</div>
</form>
}
</div>
@@ -0,0 +1,250 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
@use 'assets/styles/product-list' as pl;
@include pl.product-list-styles;
:host {
--pl-accent: #be185d;
--pl-accent-l: #fce7f3;
}
/* A single-column form reads better centred than pinned to the left edge of a 1400px page — the
eye has nowhere else to go, and a wide empty right margin makes the form look unfinished. */
.dd-panel {
max-width: 720px;
margin: 0 auto;
padding: 22px 24px;
}
/* The header belongs over the form, not over the full page width, or it detaches from it. */
.pl-header {
max-width: 720px;
margin-left: auto;
margin-right: auto;
}
/* The panel is wrapped in a <form>, so centring it needs the form to be the full-width block that
does the centring — margin:auto on the panel alone has no effect inside a default-width form. */
form {
display: block;
width: 100%;
}
.dd-fields {
display: flex;
flex-wrap: wrap;
gap: 16px;
mat-form-field {
flex: 1 1 240px;
}
}
.dd-hint {
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 {
margin-top: 8px;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 14px 16px;
}
.dd-preview-head {
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #64748b;
margin-bottom: 10px;
}
.dd-preview-row {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 16px;
padding: 5px 0;
font-size: 14px;
/* Red, and signed, so an appropriation reads as money leaving the pool rather than another
figure to add up. */
&--deduction {
color: #b42318;
}
/* The line the deductions arrive at — separated from them, but lighter than the final total,
since it is a step in the arithmetic rather than the answer. */
&--subtotal {
margin-top: 6px;
padding-top: 8px;
border-top: 1px solid #cbd5e1;
font-weight: 600;
color: #0f172a;
}
/* Context rather than part of the sum. */
&--muted {
color: #64748b;
font-size: 13px;
}
&--total {
margin-top: 6px;
padding-top: 10px;
border-top: 2px solid #cbd5e1;
font-weight: 700;
color: var(--pl-accent);
}
/* Muted, not hidden: a product that is being skipped still needs to be visibly accounted for. */
&--skipped {
color: #94a3b8;
font-size: 13px;
}
}
.dd-amount {
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
/* Names the account the profit is actually read from, so the figure can be traced to the ledger
rather than taken on trust. */
.dd-gl {
display: inline-block;
margin-left: 6px;
padding: 1px 6px;
border-radius: 4px;
background: #f1f5f9;
color: #64748b;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.02em;
}
/* Two stacked panels: where the money comes from, then how it is split. */
.dd-preview + .dd-preview {
margin-top: 12px;
}
.dd-reason {
font-size: 12.5px;
color: #94a3b8;
text-align: right;
}
.dd-note {
margin-top: 12px;
padding: 10px 14px;
border-radius: 6px;
font-size: 13px;
background: #f1f5f9;
color: #334155;
&--error {
background: #fef2f2;
color: #b42318;
}
}
.dd-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 20px;
}
/* Outcome view */
.dd-result-head {
display: flex;
align-items: flex-start;
gap: 14px;
margin-bottom: 18px;
h3 {
margin: 0 0 4px;
font-size: 17px;
font-weight: 700;
color: #0f172a;
}
p {
margin: 0;
font-size: 13.5px;
color: #64748b;
}
}
.dd-result-ic {
font-size: 26px;
color: #16a34a;
margin-top: 2px;
}
.dd-table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
td {
padding: 9px 8px;
border-bottom: 1px solid #f1f5f9;
}
td:nth-child(2) {
text-align: right;
width: 140px;
}
td:nth-child(3) {
text-align: right;
width: 220px;
}
}
.dd-ok {
color: #16a34a;
font-weight: 600;
font-size: 13px;
}
.dd-skipped {
color: #94a3b8;
}
@@ -0,0 +1,208 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/** Angular Imports */
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { MatOptionModule } from '@angular/material/core';
import { MatRadioModule } from '@angular/material/radio';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
/** Custom Imports */
import { ProductsService } from '../../../products.service';
import { SettingsService } from '../../../../settings/settings.service';
/**
* Declares one dividend across every share product for a financial year.
*
* The board resolves a rate, not an amount — "9% for 2025-26" — and what that costs depends on how
* 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.
*/
@Component({
selector: 'mifosx-declare-dividend',
templateUrl: './declare-dividend.component.html',
styleUrls: ['./declare-dividend.component.scss'],
imports: [
CommonModule,
ReactiveFormsModule,
MatButtonModule,
MatFormFieldModule,
MatInputModule,
MatSelectModule,
MatOptionModule,
MatRadioModule,
MatProgressBarModule,
FaIconComponent
]
})
export class DeclareDividendComponent implements OnInit {
private formBuilder = inject(FormBuilder);
private productsService = inject(ProductsService);
private settingsService = inject(SettingsService);
private router = inject(Router);
declareForm: FormGroup;
/** The financial years a dividend may be declared for. */
financialYears: { label: string; start: string; end: string }[] = [];
/** What the entered rate works out to, per product. Null until a rate and year are both chosen. */
preview: any = null;
loadingPreview = false;
previewError: string | null = null;
submitting = false;
submitError: string | null = null;
/** Set once the declaration has gone through, so the outcome per product can be shown. */
result: any = null;
ngOnInit(): void {
this.declareForm = this.formBuilder.group({
financialYear: [
'',
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: [
'',
[
Validators.required,
Validators.min(0.01),
Validators.max(100)
]
]
});
this.buildFinancialYears();
this.declareForm.valueChanges.subscribe(() => this.loadPreview());
}
/**
* The financial years a dividend can be declared for: the current one and the five before it.
*
* The current year is included because a society may declare an interim dividend part-way through,
* and five back covers a declaration delayed by an AGM that ran late. Built from the business date
* rather than the browser's clock, so the list matches the year the branch is operating in.
*/
private buildFinancialYears(): void {
const businessDate: Date = this.settingsService.businessDate ?? new Date();
// April starts the Indian financial year, so anything before it still belongs to the prior year.
const currentStartYear = businessDate.getMonth() >= 3 ? businessDate.getFullYear() : businessDate.getFullYear() - 1;
this.financialYears = [];
for (let startYear = currentStartYear; startYear > currentStartYear - 6; startYear--) {
this.financialYears.push({
label: `${startYear}-${String((startYear + 1) % 100).padStart(2, '0')}`,
start: `${startYear}-04-01`,
end: `${startYear + 1}-03-31`
});
}
}
private selectedYear(): { label: string; start: string; end: string } | undefined {
return this.financialYears.find((y) => y.label === this.declareForm.value.financialYear);
}
/** What this rate costs, per product, before anything is created. */
loadPreview(): void {
const year = this.selectedYear();
const rate = Number(this.declareForm.value.ratePercentage);
if (!year || !rate || rate <= 0) {
this.preview = null;
return;
}
this.loadingPreview = true;
this.previewError = null;
this.productsService
.previewAllProductsDividend(year.start, year.end, rate, this.declareForm.value.dividendBasis)
.subscribe({
next: (response: any) => {
this.preview = response;
this.loadingPreview = false;
},
error: (error: any) => {
// Cleared rather than left stale, so a failed refresh can never be read as the cost of the
// rate now on screen.
this.preview = null;
this.previewError = error?.error?.defaultUserMessage ?? 'Could not work out what this rate would cost.';
this.loadingPreview = false;
}
});
}
/** Products that will actually receive a declaration. */
get eligibleProducts(): any[] {
return (this.preview?.products ?? []).filter((p: any) => p.eligible && !p.alreadyDeclared);
}
/** Products that will be skipped, with the reason shown alongside. */
get skippedProducts(): any[] {
return (this.preview?.products ?? []).filter((p: any) => !p.eligible || p.alreadyDeclared);
}
/** Total across only the products that will actually be declared. */
get payableTotal(): number {
return this.eligibleProducts.reduce((sum: number, p: any) => sum + Number(p.amount || 0), 0);
}
get canSubmit(): boolean {
return this.declareForm.valid && !this.submitting && this.eligibleProducts.length > 0;
}
submit(): void {
const year = this.selectedYear();
if (!year || !this.canSubmit) {
return;
}
this.submitting = true;
this.submitError = null;
this.productsService
.createAllProductsDividend({
dividendPeriodStartDate: year.start,
dividendPeriodEndDate: year.end,
dividendBasis: this.declareForm.value.dividendBasis,
ratePercentage: String(this.declareForm.value.ratePercentage)
})
.subscribe({
next: (response: any) => {
this.result = response;
this.submitting = false;
},
error: (error: any) => {
this.submitError = error?.error?.defaultUserMessage ?? 'The dividend could not be declared.';
this.submitting = false;
}
});
}
done(): void {
this.router.navigate([
'/products',
'share-products',
'dividends'
]);
}
cancel(): void {
this.done();
}
}
@@ -11,44 +11,23 @@
<form [formGroup]="createDividendForm" (ngSubmit)="submit()">
<mat-card-content>
<div class="layout-column">
<mat-form-field (click)="startDatePicker.open()">
<mat-label>{{ 'labels.inputs.Dividend Period Start Date' | translate }}</mat-label>
<input
matInput
[min]="minDate"
[max]="maxDate"
[matDatepicker]="startDatePicker"
<!-- A dividend is declared for a financial year, and the whole flow depends on that: the
profit it is measured against is the year's closed profit, and the residue and
undistributed remainder are filed under the year the period ends in. Picking the year
sets both dates, so a part-year period cannot be entered by mistake. -->
<mat-form-field>
<mat-label>{{ 'labels.inputs.Financial Year' | translate }}</mat-label>
<mat-select
[value]="selectedFinancialYear"
(selectionChange)="onFinancialYearChange($event.value)"
required
formControlName="dividendPeriodStartDate"
/>
<mat-datepicker-toggle matSuffix [for]="startDatePicker"></mat-datepicker-toggle>
<mat-datepicker #startDatePicker></mat-datepicker>
@if (createDividendForm.controls.dividendPeriodStartDate.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Starting Date' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
<mat-form-field (click)="endDatePicker.open()">
<mat-label>{{ 'labels.inputs.Dividend Period End Date' | translate }}</mat-label>
<input
matInput
[min]="minDate"
[max]="maxDate"
[matDatepicker]="endDatePicker"
required
formControlName="dividendPeriodEndDate"
/>
<mat-datepicker-toggle matSuffix [for]="endDatePicker"></mat-datepicker-toggle>
<mat-datepicker #endDatePicker></mat-datepicker>
@if (createDividendForm.controls.dividendPeriodEndDate.hasError('required')) {
<mat-error>
{{ 'labels.inputs.End Date' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
>
@for (fy of financialYears; track fy.label) {
<mat-option [value]="fy.label"
>FY {{ fy.label }} ({{ fy.startLabel }} &ndash; {{ fy.endLabel }})</mat-option
>
}
</mat-select>
</mat-form-field>
@if (overlapError) {
@@ -63,15 +42,125 @@
<p class="dividend-profit-note dividend-profit-note--error">
{{ 'labels.inputs.Could not fetch bank profit for this period' | translate }}.
</p>
} @else if (netProfit !== null) {
<p class="dividend-profit-note">
{{ 'labels.inputs.Bank Profit for this Period' | translate }}:
<strong>{{ netProfit | number: '1.2-2' }}</strong>
</p>
} @else if (profitBreakdown !== null) {
<!-- The statutory appropriations a co-operative society must make before any dividend
may be declared, then what other products have already committed, leaving the
ceiling this declaration has to stay within. -->
<div class="dividend-waterfall">
<div class="dividend-waterfall__row">
@if (profitBreakdown.yearClosed) {
<span
>{{ 'labels.inputs.Net Profit' | translate }} &mdash; FY {{ profitBreakdown.financialYear }} ({{
'labels.inputs.closed books' | translate
}})</span
>
} @else {
<span>{{ 'labels.inputs.Net Profit for this Period' | translate }}</span>
}
<span>{{ profitBreakdown.netProfit | number: '1.2-2' }}</span>
</div>
<!-- Where the profit figure came from. A year that has been closed gives the audited
number out of the Bank Profit GL; an open year can only offer a P&L that is still
moving, which makes any declaration against it an interim one. -->
<div class="dividend-waterfall__row dividend-waterfall__row--detail">
@if (profitBreakdown.yearClosed) {
<span>{{ 'labels.inputs.From the Bank Profit GL after year-end closing' | translate }}</span>
} @else if (profitBreakdown.coversWholeFinancialYear) {
<span
>{{ 'labels.inputs.FY' | translate }} {{ profitBreakdown.financialYear }}
{{
'labels.inputs.has not been closed yet — this is the open profit and loss, not an audited figure'
| translate
}}</span
>
} @else {
<span>{{
'labels.inputs.Interim period — from the open profit and loss, not an audited figure' | translate
}}</span>
}
<span></span>
</div>
@if (profitBreakdown.statutoryReservePercentage > 0) {
<div class="dividend-waterfall__row dividend-waterfall__row--deduction">
<span
>&minus; {{ '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
>&minus; {{ '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>&minus; {{ 'labels.inputs.Already declared for other products' | translate }}</span>
<span>{{ profitBreakdown.alreadyDeclared | number: '1.2-2' }}</span>
</div>
@for (declaration of profitBreakdown.existingDeclarations; track declaration.declarationId) {
<div class="dividend-waterfall__row dividend-waterfall__row--detail">
<span
>{{ declaration.productName }} ({{ declaration.periodStartDate }} &ndash;
{{ declaration.periodEndDate }})</span
>
<span>{{ declaration.amount | number: '1.2-2' }}</span>
</div>
}
}
<div class="dividend-waterfall__row dividend-waterfall__row--total">
<span>{{ 'labels.inputs.Available for this declaration' | translate }}</span>
<span>{{ profitBreakdown.availableForDeclaration | number: '1.2-2' }}</span>
</div>
</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>{{ 'labels.inputs.Dividend Percentage' | translate }} (%)</mat-label>
<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')) {
<mat-error>
@@ -81,6 +170,43 @@
}
</mat-form-field>
@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>
} @else if (capitalLookupError) {
<p class="dividend-profit-note dividend-profit-note--error">
{{ 'labels.inputs.Could not calculate eligible share capital for this period' | translate }}.
</p>
} @else if (capitalPreview !== null) {
<div class="dividend-waterfall">
<div class="dividend-waterfall__row">
<span>{{ 'labels.inputs.Eligible share capital (weighted)' | translate }}</span>
<span>{{ capitalPreview.weightedCapital | number: '1.2-2' }}</span>
</div>
<div class="dividend-waterfall__row dividend-waterfall__row--detail">
<span>{{ 'labels.inputs.Eligible accounts' | translate }}</span>
<span>{{ capitalPreview.eligibleAccounts }}</span>
</div>
@if (capitalPreview.eligibleAccounts === 0 && capitalPreview.minimumActivePeriodDays > 0) {
<div class="dividend-waterfall__row dividend-waterfall__row--detail">
<span
>{{ 'labels.inputs.No member has held shares for the minimum active period of' | translate }}
{{ capitalPreview.minimumActivePeriodDays }}
{{ 'labels.inputs.days' | translate }}</span
>
<span></span>
</div>
}
</div>
}
}
<mat-form-field>
<mat-label
>{{ 'labels.inputs.Dividend Amount' | translate }} ({{
@@ -95,6 +221,10 @@
</mat-error>
}
</mat-form-field>
@if (ceilingError) {
<p class="dividend-profit-note dividend-profit-note--error">{{ ceilingError }}</p>
}
</div>
</mat-card-content>
@@ -102,7 +232,7 @@
<button type="button" mat-raised-button [routerLink]="['../']">
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button mat-raised-button color="primary" [disabled]="!createDividendForm.valid || !!overlapError">
<button mat-raised-button color="primary" [disabled]="!createDividendForm.valid || hasBlockingError">
{{ 'labels.buttons.Submit' | translate }}
</button>
</mat-card-actions>
@@ -10,6 +10,77 @@
max-width: 37rem;
}
/* The appropriation waterfall: net profit reduced step by step to the ceiling this declaration
must stay within. Laid out as label/amount pairs so the arithmetic reads down the column. */
.dividend-waterfall {
margin: 0 0 12px;
padding: 10px 12px;
border: 1px solid #d8dee4;
border-radius: 6px;
font-size: 13px;
&__row {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 3px 0;
> span:last-child {
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
&--deduction {
color: #b42318;
}
&--detail {
padding-left: 14px;
color: #667085;
font-size: 12px;
}
&--subtotal {
margin-top: 4px;
padding-top: 6px;
border-top: 1px solid #d8dee4;
font-weight: 500;
}
&--total {
margin-top: 4px;
padding-top: 6px;
border-top: 2px solid #98a2b3;
font-weight: 600;
color: #1a7f37;
}
}
}
.dividend-mode {
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;
padding: 8px 12px;
@@ -10,6 +10,8 @@
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';
/** Custom Services. */
@@ -26,7 +28,10 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
templateUrl: './create-dividend.component.html',
styleUrls: ['./create-dividend.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS
...STANDALONE_SHARED_IMPORTS,
MatRadioGroup,
MatRadioButton,
DecimalPipe
]
})
export class CreateDividendComponent implements OnInit {
@@ -47,13 +52,76 @@ export class CreateDividendComponent implements OnInit {
/** Maximum Date allowed. */
maxDate = new Date();
/** Real bank profit for the selected period (fetched from posted GL journal entries). */
netProfit: number | null = null;
/**
* 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.
*/
profitBreakdown: {
financialYear: string;
coversWholeFinancialYear: boolean;
/** True once the year's P&L has been closed into the Bank Profit GL — the audited figure. */
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;
maximumRatePercentage: number;
existingDeclarations: {
productName: string;
amount: number;
periodStartDate: string;
periodEndDate: string;
}[];
} | null = null;
/** True while the profit figure for the selected period is being fetched. */
loadingProfit = false;
/** Set if the profit lookup fails, so the officer knows the amount below isn't auto-computed. */
profitLookupError = false;
/**
* Weighted paid-up capital for the period, for RATE_ON_CAPITAL mode. `requiredAmount` is what the
* entered rate actually costs to honour.
*/
capitalPreview: {
weightedCapital: number;
requiredAmount: number | null;
eligibleAccounts: number;
minimumActivePeriodDays: number;
} | null = null;
loadingCapital = false;
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.
*/
dividendMode: 'RATE_ON_CAPITAL' | 'PERCENT_OF_PROFIT' = 'RATE_ON_CAPITAL';
/**
* Financial years a dividend can be declared for, newest first.
*
* A dividend belongs to a financial year, and everything downstream depends on that: the profit it is measured
* against is the year's closed profit, and both the rounding residue and the undistributed remainder are filed
* under the year the period ends in. Choosing the year rather than two loose dates makes a part-year period —
* which none of that arithmetic would hold for — impossible to enter.
*/
financialYears: { label: string; startLabel: string; endLabel: string; start: Date; end: Date }[] = [];
selectedFinancialYear: string | null = null;
/** Set when the computed amount exceeds what may still be distributed for this period. */
ceilingError: string | null = null;
/** Set when the entered rate exceeds the ceiling in the governing Co-operative Societies Act. */
rateCeilingError: string | null = null;
/** Every dividend already declared for this product, used to block overlapping periods. */
private existingDividendPeriods: { start: Date; end: Date }[] = [];
/** Set when the currently selected period overlaps an existing declaration for this product. */
@@ -77,17 +145,57 @@ export class CreateDividendComponent implements OnInit {
ngOnInit() {
this.maxDate = this.settingsService.businessDate;
this.setDividendForm();
this.buildFinancialYears();
this.loadExistingDividendPeriods();
const onPeriodChange = () => {
// Both dates now move together as one financial year is chosen, so watching the end date alone
// is enough — subscribing to both would fire the profit and capital lookups twice per change.
this.createDividendForm.get('dividendPeriodEndDate').valueChanges.subscribe(() => {
this.fetchProfitForSelectedPeriod();
this.checkOverlap();
};
this.createDividendForm.get('dividendPeriodStartDate').valueChanges.subscribe(onPeriodChange);
this.createDividendForm.get('dividendPeriodEndDate').valueChanges.subscribe(onPeriodChange);
});
this.createDividendForm.get('dividendPercentage').valueChanges.subscribe(() => this.recomputeDividendAmount());
}
/**
* The financial years a dividend can be declared for: the current one and the five before it.
*
* The current year is included because a society may declare an interim dividend part-way through, and five back
* covers a declaration delayed by an AGM that ran late. Built from the business date rather than the browser's
* clock, so the list matches the year the branch is actually operating in.
*/
private buildFinancialYears(): void {
const businessDate: Date = this.settingsService.businessDate ?? new Date();
// April starts the Indian financial year, so anything before it still belongs to the prior year.
const currentStartYear = businessDate.getMonth() >= 3 ? businessDate.getFullYear() : businessDate.getFullYear() - 1;
this.financialYears = [];
for (let startYear = currentStartYear; startYear > currentStartYear - 6; startYear--) {
const start = new Date(startYear, 3, 1);
const end = new Date(startYear + 1, 2, 31);
this.financialYears.push({
label: `${startYear}-${String((startYear + 1) % 100).padStart(2, '0')}`,
startLabel: this.toIsoDate(start),
endLabel: this.toIsoDate(end),
start,
end
});
}
}
/** Sets both period dates from the chosen year, which is what drives the profit lookup. */
onFinancialYearChange(label: string): void {
const fy = this.financialYears.find((y) => y.label === label);
if (!fy) {
return;
}
this.selectedFinancialYear = label;
this.createDividendForm.patchValue({
dividendPeriodStartDate: fy.start,
dividendPeriodEndDate: fy.end
});
}
/** Parses a Fineract date field, which may arrive as `[yyyy, MM, dd]` or an ISO string. */
private parseFineractDate(value: any): Date | null {
if (!value) {
@@ -172,8 +280,9 @@ export class CreateDividendComponent implements OnInit {
}
/**
* Looks up the bank's real net profit for the selected dividend period, once both dates are
* set, so the officer can base the dividend on actual profit instead of guessing an amount.
* Looks up how much profit is actually available to distribute for the selected period, once both
* dates are set — net profit from posted GL entries, less the statutory appropriations that
* legally precede a dividend, less what other products have already been declared.
*/
fetchProfitForSelectedPeriod(): void {
const start: Date = this.createDividendForm.get('dividendPeriodStartDate').value;
@@ -183,36 +292,145 @@ export class CreateDividendComponent implements OnInit {
}
this.loadingProfit = true;
this.profitLookupError = false;
this.accountingService.getProfitAndLoss(this.toIsoDate(start), this.toIsoDate(end)).subscribe({
next: (response: any) => {
this.netProfit = Number(response.netProfit) || 0;
this.loadingProfit = false;
this.recomputeDividendAmount();
},
error: () => {
this.netProfit = null;
this.loadingProfit = false;
this.profitLookupError = true;
}
});
this.accountingService
.getDistributableProfit(this.toIsoDate(start), this.toIsoDate(end), this.shareProductData.id)
.subscribe({
next: (response: any) => {
this.profitBreakdown = {
financialYear: response.financialYear,
coversWholeFinancialYear: !!response.coversWholeFinancialYear,
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,
maximumRatePercentage: Number(response.maximumRatePercentage) || 0,
existingDeclarations: response.existingDeclarations || []
};
this.loadingProfit = false;
this.recomputeDividendAmount();
},
error: () => {
this.profitBreakdown = null;
this.loadingProfit = false;
this.profitLookupError = true;
}
});
this.fetchCapitalPreview();
}
/** Dividend Amount = Net Profit x Dividend Percentage / 100 — no manual entry needed. */
recomputeDividendAmount(): void {
const percentage = Number(this.createDividendForm.get('dividendPercentage').value);
if (this.netProfit === null || !percentage || percentage <= 0) {
this.createDividendForm.get('dividendAmount').setValue('');
/**
* Weighted paid-up capital for the period. Fetched independently of the profit lookup because it
* answers a different question — what the members actually hold, rather than what the society
* earned — and RATE_ON_CAPITAL needs both.
*/
fetchCapitalPreview(): void {
const start: Date = this.createDividendForm.get('dividendPeriodStartDate').value;
const end: Date = this.createDividendForm.get('dividendPeriodEndDate').value;
if (!(start instanceof Date) || !(end instanceof Date)) {
return;
}
const amount = (this.netProfit * percentage) / 100;
this.createDividendForm.get('dividendAmount').setValue(Math.round(amount * 100) / 100);
this.loadingCapital = true;
this.capitalLookupError = false;
this.productService
.getDividendCapitalPreview(this.shareProductData.id, this.toIsoDate(start), this.toIsoDate(end))
.subscribe({
next: (response: any) => {
this.capitalPreview = {
weightedCapital: Number(response.weightedCapital) || 0,
requiredAmount: null,
eligibleAccounts: Number(response.eligibleAccounts) || 0,
minimumActivePeriodDays: Number(response.minimumActivePeriodDays) || 0
};
this.loadingCapital = false;
this.recomputeDividendAmount();
},
error: () => {
this.capitalPreview = null;
this.loadingCapital = false;
this.capitalLookupError = true;
}
});
}
/** 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.
*
* 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.
*/
recomputeDividendAmount(): void {
const percentage = Number(this.createDividendForm.get('dividendPercentage').value);
this.ceilingError = null;
this.rateCeilingError = null;
if (!percentage || percentage <= 0) {
this.createDividendForm.get('dividendAmount').setValue('');
if (this.capitalPreview) {
this.capitalPreview.requiredAmount = null;
}
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.
const maximumRate = this.profitBreakdown?.maximumRatePercentage ?? 0;
if (this.dividendMode === 'RATE_ON_CAPITAL' && 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 rounded = Math.round(amount * 100) / 100;
this.createDividendForm.get('dividendAmount').setValue(rounded);
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.`;
}
}
/** True when any ceiling or overlap rule blocks submission. */
get hasBlockingError(): boolean {
return !!this.overlapError || !!this.ceilingError || !!this.rateCeilingError;
}
/**
* Submits Create Dividend form.
*/
submit() {
if (this.overlapError) {
if (this.hasBlockingError) {
return;
}
const createDividendFormData = this.createDividendForm.value;
@@ -21,6 +21,10 @@
<strong>{{ totalCount }}</strong>
<span>{{ totalCount === 1 ? 'product' : 'products' }}</span>
</div>
<button class="pl-secondary-btn" type="button" [routerLink]="['/products', 'share-products', 'dividends']">
<fa-icon icon="hand-holding-usd"></fa-icon>
<span>Dividends</span>
</button>
<button
#buttonCreateShareProduct
class="pl-create-btn"
@@ -56,6 +60,26 @@
<div class="pl-kpi-l">Showing after filter</div>
</div>
</div>
<!-- Hidden rather than zeroed when the figure is unavailable, so a failed fetch is never read
as "no dividend has been declared". -->
@if (dividendSummary) {
<div
class="pl-kpi sp-kpi--dividend"
[routerLink]="['/products', 'share-products', 'dividends']"
matTooltip="View all dividend declarations"
>
<div class="pl-kpi-ic"><fa-icon icon="hand-holding-usd"></fa-icon></div>
<div>
<div class="pl-kpi-n">{{ dividendSummary.totalDeclared | number: '1.2-2' }}</div>
<div class="pl-kpi-l">
Dividends Declared
@if (dividendSummary.pendingCount > 0) {
<span class="sp-kpi-pending">· {{ dividendSummary.pendingCount }} awaiting approval</span>
}
</div>
</div>
</div>
}
</div>
<div class="pl-card">
@@ -15,3 +15,31 @@
--pl-accent: #be185d;
--pl-accent-l: #fce7f3;
}
/* Dividend card. Pink to match the share accent rather than reusing one of the three colours
already carrying a meaning in this row. */
.sp-kpi--dividend {
cursor: pointer;
.pl-kpi-ic {
background: #fce7f3;
color: #be185d;
}
&:hover {
border-color: #be185d;
}
}
/* Only appears when something is actually waiting, so it reads as a nudge rather than a permanent
part of the label. */
.sp-kpi-pending {
color: #b45309;
font-weight: 500;
}
/* The shared grid is three columns; this page has a fourth card once the dividend figure loads.
Auto-fit keeps them even at any count instead of leaving the fourth stranded on its own row. */
.pl-kpis {
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
@@ -74,6 +74,14 @@ export class ShareProductsComponent implements OnInit, AfterViewInit {
filteredCount = 0;
/**
* Dividends declared across all share products.
*
* Left null when the figure cannot be fetched, so the card hides rather than showing a 0.00 that
* would read as "no dividend was declared" when the truth is "we could not find out".
*/
dividendSummary: { totalDeclared: number; declarationCount: number; pendingCount: number } | null = null;
get totalCount(): number {
return this.shareProductsData?.length || 0;
}
@@ -123,6 +131,7 @@ export class ShareProductsComponent implements OnInit, AfterViewInit {
this.dataSource.paginator = this.paginator;
this.dataSource.sort = this.sort;
this.filteredCount = this.totalCount;
this.loadDividendSummary();
this.dataSource.connect().subscribe((rows) => (this.filteredCount = rows.length));
}
@@ -130,6 +139,30 @@ export class ShareProductsComponent implements OnInit, AfterViewInit {
this.dataSource.filter = (filterValue || '').trim().toLowerCase();
}
/**
* What has been declared in dividends across every share product.
*
* Summarised here rather than per row because a dividend is one society-wide decision — the board
* resolves a rate for the year, and it lands on whichever products have members holding shares.
*/
private loadDividendSummary(): void {
this.productsService.getAllProductsDividends().subscribe({
next: (declarations: any[]) => {
const rows = declarations || [];
this.dividendSummary = {
totalDeclared: rows.reduce((sum, d) => sum + Number(d.amount || 0), 0),
declarationCount: rows.length,
pendingCount: rows.filter((d) => !d.approved).length
};
},
// Deliberately silent: this is a summary card beside the product list, not the reason the
// page exists. A failure hides the card instead of blocking the products the officer came for.
error: () => {
this.dividendSummary = null;
}
});
}
/**
* To show popover.
*/
@@ -56,6 +56,45 @@
</div>
</div>
<!-- Why the member rows below do not sum to the declared amount: entitlements are rounded down
to whole rupees so each can be drawn as a Demand Draft, and any left under the minimum worth
drawing one for is dropped. Both go to Bank Profit at declaration time. Hidden when nothing
was held back, and for declarations made before this was recorded. -->
@if (residue && residue.residueAmount > 0) {
<div class="vd-residue">
<div class="vd-residue__title">{{ 'labels.inputs.Not paid out to members' | translate }}</div>
<div class="vd-residue__rows">
<div class="vd-residue__row">
<span>{{ 'labels.inputs.Declared amount' | translate }}</span>
<span>{{ residue.declaredAmount | number: '1.2-2' }}</span>
</div>
<div class="vd-residue__row">
<span>{{ 'labels.inputs.Allocated to members' | translate }}</span>
<span>{{ residue.allocatedAmount | number: '1.2-2' }}</span>
</div>
<div class="vd-residue__row vd-residue__row--total">
<span>{{ 'labels.inputs.Swept to Bank Profit' | translate }}</span>
<span>{{ residue.residueAmount | number: '1.2-2' }}</span>
</div>
@if (residue.belowMinimumCount > 0) {
<div class="vd-residue__row vd-residue__row--detail">
<span>
{{ 'labels.inputs.of which' | translate }} {{ residue.belowMinimumCount }}
{{ 'labels.inputs.member(s) below the minimum payable amount' | translate }}
</span>
<span>{{ residue.belowMinimumAmount | number: '1.2-2' }}</span>
</div>
}
@if (residue.glTransactionId) {
<div class="vd-residue__row vd-residue__row--detail">
<span>{{ 'labels.inputs.Journal entry' | translate }} {{ residue.glTransactionId }}</span>
<span>{{ residue.sweptToProfitDate }}</span>
</div>
}
</div>
</div>
}
<div class="vd-filter">
<div class="vd-search">
<fa-icon icon="search" class="vd-search__icon"></fa-icon>
@@ -225,3 +225,56 @@ table {
text-align: right;
}
}
/* Why the member rows do not sum to the declared amount — rounding down to whole rupees, plus
entitlements dropped under the minimum worth drawing a draft for. Sits between the summary tiles
and the member table because that is where the question arises. */
.vd-residue {
margin: 0 0 16px;
padding: 12px 16px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #f8fafc;
&__title {
font-size: 12px;
font-weight: 600;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.03em;
margin-bottom: 8px;
}
&__rows {
display: flex;
flex-direction: column;
gap: 3px;
}
&__row {
display: flex;
justify-content: space-between;
gap: 16px;
font-size: 13px;
color: #334155;
> span:last-child {
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
&--total {
margin-top: 4px;
padding-top: 6px;
border-top: 1px solid #cbd5e1;
font-weight: 600;
color: #0f172a;
}
&--detail {
padding-left: 12px;
font-size: 12px;
color: #64748b;
}
}
}
@@ -7,6 +7,7 @@
*/
import { Component, OnInit, Input, Output, EventEmitter, ViewChild, inject } from '@angular/core';
import { catchError, of } from 'rxjs';
import { ActivatedRoute, Router } from '@angular/router';
import { MatPaginator } from '@angular/material/paginator';
import { MatSort, MatSortHeader } from '@angular/material/sort';
@@ -67,6 +68,22 @@ export class ViewDividendComponent implements OnInit {
isdividendPosted = false;
totalAmount = 0;
/**
* What the declaration did not pay out to members. Entitlements are rounded down to whole rupees
* (a branch cannot draw a Demand Draft for 421.32) and dropped entirely below the minimum worth
* drawing one for, so the member rows below never sum to the declared figure. This explains the
* gap rather than leaving it looking like a discrepancy.
*/
residue: {
declaredAmount: number;
allocatedAmount: number;
residueAmount: number;
belowMinimumCount: number;
belowMinimumAmount: number;
sweptToProfitDate: string | null;
glTransactionId: string | null;
} | null = null;
@ViewChild(MatPaginator, { static: true }) paginator: MatPaginator;
@ViewChild(MatSort, { static: true }) sort: MatSort;
displayedColumns: string[] = [
@@ -82,24 +99,56 @@ export class ViewDividendComponent implements OnInit {
this.productsService
.getDividendData(this.overrideProductId, this.overrideDividendId)
.subscribe((dividendData: any) => this.setDividendData(dividendData));
this.loadResidue(this.overrideProductId, this.overrideDividendId);
} else {
this.status = this.route.snapshot.queryParams['status'];
if (this.status && (this.status === 'Dividend Approved' || this.status === 'Dividend Posted')) {
this.isdividendPosted = true;
}
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')
);
}
}
/**
* 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.
*/
private loadResidue(productId: any, dividendId: any): void {
if (!productId || !dividendId) {
return;
}
this.productsService
.getDividendRoundingResidue(productId, dividendId)
.pipe(catchError(() => of(null)))
.subscribe((res: any) => {
this.residue = res
? {
declaredAmount: Number(res.declaredAmount) || 0,
allocatedAmount: Number(res.allocatedAmount) || 0,
residueAmount: Number(res.residueAmount) || 0,
belowMinimumCount: Number(res.belowMinimumCount) || 0,
belowMinimumAmount: Number(res.belowMinimumAmount) || 0,
sweptToProfitDate: res.sweptToProfitDate ?? null,
glTransactionId: res.glTransactionId ?? null
}
: null;
});
}
private setDividendData(dividendData: any) {
this.dividendData = dividendData;
this.dataSource = new MatTableDataSource(dividendData.pageItems);
const rows = dividendData.pageItems ?? [];
this.dataSource = new MatTableDataSource(rows);
this.dataSource.paginator = this.paginator;
this.dataSource.sort = this.sort;
this.totalAmount = (dividendData.pageItems ?? []).reduce(
(sum: number, item: any) => sum + Number(item.amount ?? 0),
0
);
this.totalAmount = rows.reduce((sum: number, item: any) => sum + Number(item.amount ?? 0), 0);
// Taken from the members' own rows rather than the ?status= query parameter, which is set by
// whichever screen linked here and can be edited in the URL. A stale or wrong parameter used to
// leave the Approve button on an already-approved declaration — an approval that would then be
// rejected by the server, after the officer had been told it was theirs to make.
this.isdividendPosted = rows.length > 0 && rows.every((item: any) => item.status?.value === 'Dividend Posted');
}
statusChipClass(status: string): string {
@@ -123,7 +172,14 @@ export class ViewDividendComponent implements OnInit {
if (this.embedded) {
this.actionCompleted.emit();
} else {
this.router.navigate(['../'], { relativeTo: this.route });
// 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'
]);
}
});
}
@@ -28,15 +28,9 @@
}
</div>
</div>
<!-- Dividends are declared for the whole society at once, from Share Products → Dividends,
because the board resolves one rate for the year rather than one per product. -->
<div style="display: flex; gap: 10px; flex-shrink: 0">
<button
class="pv-edit-btn"
type="button"
[routerLink]="['../dividends']"
*mifosxHasPermission="'UPDATE_SHAREPRODUCT'"
>
<fa-icon icon="hand-holding-usd"></fa-icon><span>{{ 'labels.buttons.Dividends' | translate }}</span>
</button>
<button class="pv-edit-btn" type="button" [routerLink]="['../edit']" *mifosxHasPermission="'UPDATE_SHAREPRODUCT'">
<fa-icon icon="edit"></fa-icon><span>{{ 'labels.buttons.Edit' | translate }}</span>
</button>