feat(shares): real-profit driven dividend UI, Dividend Dashboard, and member claim workflow

Frontend counterpart to the backend dividend rework:
- Create Dividend screen auto-computes the amount from real bank profit x officer
  percentage, blocks overlapping declaration periods, and adds a Total Dividend Paid
  summary card.
- View Dividend page modernized and reusable as an embedded popup from the new
  cross-product "Dividend Requests" approval-center tab.
- New consolidated Dividend Dashboard: summary stats, every declaration and its
  status, locked-dividend-vs-overdue-EMI matching with a proactive apply-to-loan
  action, full 3-year lock history, and a maker-checker claim panel letting a clerk
  split a member's unclaimed dividend across an overdue loan, savings credit, or
  cash for a manager to approve.
- Menu/routing consolidated under Share > Dividend Management; removed the
  now-unreachable Failed Dividend Payouts page and its home-dashboard alert once the
  backend stopped needing a retry path for stuck payouts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 12:43:18 +05:30
co-authored by Claude Sonnet 5
parent 01e86173c1
commit c34f6a0131
24 changed files with 2004 additions and 39 deletions
+22
View File
@@ -32,6 +32,28 @@ export class AccountingService {
return this.http.get('/offices', { params: httpParams });
}
/**
* Real net profit for a date range, computed live from posted GL journal entries (not the
* demo data shown by the GL Reports module).
* @param {string} fromDate Period start, yyyy-MM-dd.
* @param {string} toDate Period end, yyyy-MM-dd.
* @returns {Observable<any>} `{ totalIncome, totalExpense, netProfit }`.
*/
getProfitAndLoss(fromDate: string, toDate: string): Observable<any> {
const httpParams = new HttpParams().set('fromDate', fromDate).set('toDate', toDate);
return this.http.get('/accounting/profitandloss', { params: httpParams });
}
/**
* Total share dividend actually paid to members in a date range — derived from the Dividend
* Payable GL account's own ledger (no separate "Dividend Paid" account needed).
* @returns {Observable<any>} `{ totalPaid, payoutCount, entries[], glAccountConfigured }`.
*/
getDividendPaidReport(fromDate: string, toDate: string): Observable<any> {
const httpParams = new HttpParams().set('fromDate', fromDate).set('toDate', toDate);
return this.http.get('/accounting/dividend-paid-report', { params: httpParams });
}
/**
* @param {boolean} useHttpParams True if http params should be used.
* @returns {Observable<any>} Accounting rules data.
@@ -117,6 +117,14 @@ const routes: Routes = [
loadComponent: () =>
import('../clerk/today-activity/today-activity.component').then((m) => m.TodayActivityComponent),
data: { title: "Today's Activity", breadcrumb: "Today's Activity" }
},
{
path: 'dividend-dashboard',
loadComponent: () =>
import('../clerk/dividend-dashboard/dividend-dashboard.component').then(
(m) => m.DividendDashboardComponent
),
data: { title: 'Dividend Dashboard', breadcrumb: 'Dividend Dashboard' }
}
]
},
@@ -0,0 +1,66 @@
/**
* 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 } from '@angular/common/http';
import { Observable } from 'rxjs';
/**
* HTTP service for the maker-checker "claim my dividend early" workflow — a clerk submits a claim
* against a still-locked dividend on the member's behalf, a Bank Manager approves (only possible
* when the client has no overdue loan) or rejects it.
*/
@Injectable({ providedIn: 'root' })
export class DividendClaimRequestService {
private http = inject(HttpClient);
submit(
lockId: number,
breakdown: {
loanId?: number;
loanApplyAmount?: number;
savingsAccountId?: number;
savingsApplyAmount?: number;
cashApplyAmount?: number;
}
): Observable<any> {
const body: any = { lockId };
if (breakdown.loanId != null) {
body.loanId = breakdown.loanId;
}
if (breakdown.loanApplyAmount != null) {
body.loanApplyAmount = String(breakdown.loanApplyAmount);
}
if (breakdown.savingsAccountId != null) {
body.savingsAccountId = breakdown.savingsAccountId;
}
if (breakdown.savingsApplyAmount != null) {
body.savingsApplyAmount = String(breakdown.savingsApplyAmount);
}
if (breakdown.cashApplyAmount != null) {
body.cashApplyAmount = String(breakdown.cashApplyAmount);
}
return this.http.post('/clerk/dividend-claim-requests', body);
}
getOverdueLoans(clientId: number): Observable<any> {
return this.http.get(`/clerk/dividend-claim-requests/overdue-loans/${clientId}`);
}
list(status = 'PENDING'): Observable<any> {
return this.http.get('/clerk/dividend-claim-requests', { params: { status } });
}
approve(id: number): Observable<any> {
return this.http.post(`/clerk/dividend-claim-requests/${id}/approve`, {});
}
reject(id: number, reason: string): Observable<any> {
return this.http.post(`/clerk/dividend-claim-requests/${id}/reject`, { reason });
}
}
@@ -0,0 +1,42 @@
/**
* 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 } from '@angular/common/http';
import { Observable } from 'rxjs';
/**
* HTTP service for the Bank Manager's Dividend Dashboard — members with a locked (3-year)
* share dividend who also have an overdue loan EMI, with an action to apply the locked dividend
* towards that EMI.
*/
@Injectable({ providedIn: 'root' })
export class DividendDashboardService {
private http = inject(HttpClient);
getDashboard(): Observable<any> {
return this.http.get('/clerk/dividend-dashboard');
}
applyToLoan(lockId: number, loanId: number, amount: number): Observable<any> {
return this.http.post(`/clerk/dividend-dashboard/${lockId}/apply-to-loan`, { loanId, amount: String(amount) });
}
getSummary(): Observable<any> {
return this.http.get('/clerk/dividend-dashboard/summary');
}
getDeclarations(): Observable<any> {
return this.http.get('/clerk/dividend-dashboard/declarations');
}
getLocks(status?: string): Observable<any> {
const params = status && status !== 'ALL' ? { status } : {};
return this.http.get('/clerk/dividend-dashboard/locks', { params });
}
}
@@ -0,0 +1,405 @@
<!--
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/.
-->
@if (summary) {
<div class="dd-summary-grid">
<div class="dd-stat-card">
<div class="dd-stat-label">Declarations</div>
<div class="dd-stat-value">{{ summary.totalDeclarations }}</div>
<div class="dd-stat-sub">{{ summary.pendingApprovalDeclarations }} pending approval</div>
</div>
<div class="dd-stat-card">
<div class="dd-stat-label">Members Affected</div>
<div class="dd-stat-value">{{ summary.membersAffected }}</div>
<div class="dd-stat-sub">{{ summary.totalMemberDividends }} member-dividends total</div>
</div>
<div class="dd-stat-card">
<div class="dd-stat-label">Total Amount</div>
<div class="dd-stat-value">{{ summary.totalAmount | number: '1.2-2' }}</div>
<div class="dd-stat-sub">across all declared dividends</div>
</div>
<div class="dd-stat-card">
<div class="dd-stat-label">Currently Locked</div>
<div class="dd-stat-value">{{ summary.lockedCount }}</div>
<div class="dd-stat-sub">{{ summary.lockedAmount | number: '1.2-2' }}</div>
</div>
<div class="dd-stat-card">
<div class="dd-stat-label">Applied to Loans</div>
<div class="dd-stat-value">{{ summary.appliedToLoanCount }}</div>
<div class="dd-stat-sub">{{ summary.appliedToLoanAmount | number: '1.2-2' }}</div>
</div>
<div class="dd-stat-card">
<div class="dd-stat-label">Claimed by Member</div>
<div class="dd-stat-value">{{ summary.claimedCount }}</div>
<div class="dd-stat-sub">{{ summary.claimedAmount | number: '1.2-2' }}</div>
</div>
<div class="dd-stat-card dd-stat-card--forfeited">
<div class="dd-stat-label">Forfeited to Profit</div>
<div class="dd-stat-value">{{ summary.forfeitedCount }}</div>
<div class="dd-stat-sub">{{ summary.forfeitedAmount | number: '1.2-2' }}</div>
</div>
</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
</button>
</label>
</div>
<table class="pg-table">
<thead>
<tr>
<th>Product</th>
<th>Period</th>
<th class="num">Declared Amount</th>
<th class="num">Members</th>
<th>Status</th>
<th>Approved On</th>
</tr>
</thead>
<tbody>
@if (declarationsLoading) {
<tr>
<td colspan="6" class="pg-empty">Loading…</td>
</tr>
} @else if (declarationsLoaded && declarations.length === 0) {
<tr>
<td colspan="6" class="pg-empty">No dividends have been declared yet.</td>
</tr>
} @else {
@for (d of declarations; track d.declarationId) {
<tr>
<td>{{ d.productName }}</td>
<td>{{ d.periodStartDate }} → {{ d.periodEndDate }}</td>
<td class="num">{{ d.declaredAmount | number: '1.2-2' }}</td>
<td class="num">{{ d.totalMembers }}</td>
<td>
<span class="dd-badge" [ngClass]="declarationStatusClass(d.status)">{{
declarationStatusLabel(d.status)
}}</span>
</td>
<td>{{ d.approvedOn ? (d.approvedOn | date: 'medium') : '—' }}</td>
</tr>
}
}
</tbody>
</table>
</div>
<div class="pg-card" style="margin-top: 16px">
<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">
<button type="button" class="pg-btn" [disabled]="loading" (click)="load()">Refresh</button>
</label>
</div>
<table class="pg-table">
<thead>
<tr>
<th>Client</th>
<th class="num">Locked Dividend</th>
<th>Locked Date</th>
<th>Release Due</th>
<th class="num">Total Overdue EMI</th>
<th></th>
</tr>
</thead>
<tbody>
@if (loading) {
<tr>
<td colspan="6" class="pg-empty">Loading…</td>
</tr>
} @else if (loaded && rows.length === 0) {
<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) {
<tr>
<td>{{ row.clientName }}</td>
<td class="num">{{ row.lockedAmount | number: '1.2-2' }}</td>
<td>{{ row.lockedDate }}</td>
<td>{{ row.releaseDueDate }}</td>
<td class="num">{{ totalOverdue(row) | number: '1.2-2' }}</td>
<td>
<button type="button" class="pg-btn" (click)="toggleApply(row)">
{{ expandedLockId === row.lockId ? 'Cancel' : 'Apply to Loan' }}
</button>
</td>
</tr>
@if (expandedLockId === row.lockId) {
<tr class="dd-apply-row">
<td colspan="6">
<div class="dd-apply-panel">
<label class="inv-form-field">
<span>Loan</span>
<select [(ngModel)]="selectedLoanId[row.lockId]" (change)="onLoanChange(row)">
@for (loan of row.overdueLoans; track loan.loanId) {
<option [ngValue]="loan.loanId">
{{ loan.accountNo }} — Overdue {{ loan.overdueAmount | number: '1.2-2' }}
</option>
}
</select>
</label>
<label class="inv-form-field">
<span>Amount to Apply (max {{ maxAmountFor(row) | number: '1.2-2' }})</span>
<input
type="number"
[(ngModel)]="applyAmount[row.lockId]"
[max]="maxAmountFor(row)"
min="0.01"
step="0.01"
/>
</label>
@if (applyError) {
<div class="dd-apply-error">{{ applyError }}</div>
}
<button
type="button"
class="pg-btn dd-apply-confirm"
[disabled]="applying"
(click)="submitApply(row)"
>
{{ applying ? 'Applying…' : 'Confirm' }}
</button>
</div>
</td>
</tr>
}
}
}
</tbody>
</table>
</div>
<div class="pg-card" style="margin-top: 16px">
<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">
@for (opt of ['ALL', 'UNCLAIMED', 'CLAIMED', 'FORFEITED']; track opt) {
<button
type="button"
class="dd-filter-btn"
[class.dd-filter-btn--active]="lockStatusFilter === opt"
(click)="setLockStatusFilter(opt)"
>
{{ opt === 'ALL' ? 'All' : (opt | titlecase) }}
</button>
}
</div>
<label class="inv-form-field" style="align-self: end; margin-left: auto">
<button type="button" class="pg-btn" [disabled]="locksLoading" (click)="loadLocks()">Refresh</button>
</label>
</div>
<table class="pg-table">
<thead>
<tr>
<th>Client</th>
<th>Savings Account</th>
<th class="num">Amount</th>
<th>Locked Date</th>
<th>Release Due</th>
<th>Status</th>
<th>Applied Loan</th>
<th></th>
</tr>
</thead>
<tbody>
@if (locksLoading) {
<tr>
<td colspan="8" class="pg-empty">Loading…</td>
</tr>
} @else if (locksLoaded && locks.length === 0) {
<tr>
<td colspan="8" class="pg-empty">No dividend locks match this filter.</td>
</tr>
} @else {
@for (l of locks; track l.lockId) {
<tr>
<td>{{ l.clientName }}</td>
<td>
<span class="pg-chip">{{ l.savingsAccountNo }}</span>
</td>
<td class="num">{{ l.amount | number: '1.2-2' }}</td>
<td>{{ l.lockedDate }}</td>
<td>{{ l.releaseDueDate }}</td>
<td>
<span class="dd-badge" [ngClass]="lockStatusClass(l.status)">{{ l.status | titlecase }}</span>
</td>
<td>{{ l.appliedLoanAccountNo || '—' }}</td>
<td>
@if (l.status === 'UNCLAIMED') {
<button type="button" class="pg-btn" (click)="toggleClaim(l)">
{{ expandedClaimLockId === l.lockId ? 'Cancel' : 'Claim' }}
</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' }} — split it below (loan, savings, cash)
</div>
@if (claimOverdueLoans[l.lockId] && claimOverdueLoans[l.lockId].length > 0) {
<label class="inv-form-field">
<span>Apply to Overdue Loan</span>
<select [(ngModel)]="claimLoanId[l.lockId]">
@for (loan of claimOverdueLoans[l.lockId]; track loan.loanId) {
<option [ngValue]="loan.loanId">
{{ loan.accountNo }} — Overdue {{ loan.overdueAmount | number: '1.2-2' }}
</option>
}
</select>
</label>
<label class="inv-form-field">
<span>Loan Amount</span>
<input type="number" [(ngModel)]="claimLoanAmount[l.lockId]" min="0" step="0.01" />
</label>
} @else {
<div class="pg-empty-inline">No overdue loan for this client.</div>
}
<label class="inv-form-field">
<span>Credit to Savings</span>
<input type="number" [(ngModel)]="claimSavingsAmount[l.lockId]" min="0" step="0.01" />
</label>
@if (!l.savingsAccountId && claimSavingsAmount[l.lockId] > 0) {
<label class="inv-form-field">
<span>Savings Account ID (no linked account — enter one to credit)</span>
<input type="number" [(ngModel)]="claimSavingsAccountOverride[l.lockId]" min="1" step="1" />
</label>
}
<label class="inv-form-field">
<span>Cash</span>
<input type="number" [(ngModel)]="claimCashAmount[l.lockId]" min="0" step="0.01" />
</label>
<div class="dd-apply-panel-total">
Total: {{ claimTotal(l) | 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>
<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>
<th>Savings Account</th>
<th class="num">To Loan</th>
<th class="num">To Savings</th>
<th class="num">Cash</th>
<th class="num">Unclaimed Balance</th>
<th>Client Loan Status</th>
<th></th>
</tr>
</thead>
<tbody>
@if (claimRequestsLoading) {
<tr>
<td colspan="8" class="pg-empty">Loading…</td>
</tr>
} @else if (claimRequestsLoaded && claimRequests.length === 0) {
<tr>
<td colspan="8" 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>
<span class="pg-chip">{{ req.savings_account_no }}</span>
</td>
<td class="num">
{{
req.loan_apply_amount > 0
? (req.loan_apply_amount | number: '1.2-2') + ' (' + req.loan_account_no + ')'
: '—'
}}
</td>
<td class="num">{{ req.savings_apply_amount > 0 ? (req.savings_apply_amount | number: '1.2-2') : '—' }}</td>
<td class="num">{{ req.cash_apply_amount > 0 ? (req.cash_apply_amount | number: '1.2-2') : '—' }}</td>
<td class="num">{{ req.locked_amount | number: '1.2-2' }}</td>
<td>
@if (req.has_overdue_loan) {
<span class="dd-badge dd-badge--failed">Has Overdue EMI</span>
} @else {
<span class="dd-badge dd-badge--posted">No Overdue Loan</span>
}
</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="8">
<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>
</table>
</div>
@@ -0,0 +1,171 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
.dd-apply-row td {
background: #f8fafc;
padding: 0;
}
.dd-apply-panel {
display: flex;
align-items: flex-end;
gap: 16px;
flex-wrap: wrap;
padding: 14px 16px;
.inv-form-field {
min-width: 220px;
}
}
.dd-apply-error {
color: #b42318;
font-size: 12.5px;
font-weight: 600;
}
.dd-apply-panel--wrap {
align-items: flex-end;
}
.dd-apply-panel-title {
width: 100%;
font-size: 12.5px;
font-weight: 700;
color: #475569;
}
.dd-apply-panel-total {
font-size: 13px;
font-weight: 700;
color: #0f172a;
font-variant-numeric: tabular-nums;
}
.dd-apply-confirm {
background: #2563eb;
color: #fff;
border-color: #2563eb;
&:disabled {
opacity: 0.6;
}
}
.dd-retry-error {
margin-top: 4px;
color: #b42318;
font-size: 12px;
font-weight: 600;
max-width: 240px;
}
.dd-badge {
display: inline-block;
padding: 3px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
}
.dd-badge--pending {
background: #fef3c7;
color: #92400e;
}
.pg-empty-inline {
color: #94a3b8;
font-size: 12.5px;
font-style: italic;
}
.dd-badge--failed {
background: #fee2e2;
color: #b42318;
}
.dd-badge--posted {
background: #dcfce7;
color: #166534;
}
.dd-badge--partial {
background: #dbeafe;
color: #1e40af;
}
/* ─── Summary stat cards ─── */
.dd-summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.dd-stat-card {
background: #fff;
border: 1px solid #e8ecf0;
border-radius: 10px;
padding: 12px 14px;
border-left: 3px solid #94a3b8;
&--forfeited {
border-left-color: #7c3aed;
}
}
.dd-stat-label {
font-size: 11.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.4px;
color: #64748b;
}
.dd-stat-value {
font-size: 22px;
font-weight: 700;
color: #0f172a;
margin-top: 4px;
font-variant-numeric: tabular-nums;
}
.dd-stat-sub {
font-size: 11.5px;
color: #94a3b8;
margin-top: 2px;
}
/* ─── Lock status filter buttons ─── */
.dd-filter-group {
display: flex;
gap: 6px;
}
.dd-filter-btn {
border: 1px solid #e2e8f0;
background: #fff;
color: #475569;
font-size: 12.5px;
font-weight: 600;
padding: 6px 12px;
border-radius: 999px;
cursor: pointer;
transition: all 0.15s ease;
&:hover {
border-color: #94a3b8;
}
&--active {
background: #1e293b;
border-color: #1e293b;
color: #fff;
}
}
@@ -0,0 +1,348 @@
/**
* 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 { 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).
*/
@Component({
selector: 'mifosx-dividend-dashboard',
templateUrl: './dividend-dashboard.component.html',
styleUrls: [
'../../cashier/cashier-shared.scss',
'../clerk-shared.scss',
'./dividend-dashboard.component.scss'
],
standalone: true,
imports: [
CommonModule,
FormsModule
]
})
export class DividendDashboardComponent implements OnInit {
private service = inject(DividendDashboardService);
private claimRequestService = inject(DividendClaimRequestService);
rows: any[] = [];
loading = false;
loaded = false;
expandedLockId: number | null = null;
selectedLoanId: Record<number, number | null> = {};
applyAmount: Record<number, number | null> = {};
applying = false;
applyError: string | null = null;
summary: any = null;
summaryLoading = false;
declarations: any[] = [];
declarationsLoading = false;
declarationsLoaded = false;
locks: any[] = [];
locksLoading = false;
locksLoaded = false;
lockStatusFilter = 'ALL';
expandedClaimLockId: number | null = null;
claimOverdueLoans: Record<number, any[]> = {};
claimLoanId: Record<number, number | null> = {};
claimLoanAmount: Record<number, number | null> = {};
claimSavingsAmount: Record<number, number | null> = {};
claimSavingsAccountOverride: Record<number, number | null> = {};
claimCashAmount: Record<number, number | null> = {};
claimSubmitting = false;
claimError: string | null = null;
claimRequests: any[] = [];
claimRequestsLoading = false;
claimRequestsLoaded = false;
claimActioningId: number | null = null;
claimActionError: Record<number, string> = {};
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;
}
this.expandedClaimLockId = lock.lockId;
this.claimError = null;
this.claimLoanId[lock.lockId] = null;
this.claimLoanAmount[lock.lockId] = null;
this.claimSavingsAmount[lock.lockId] = Number(lock.amount);
this.claimSavingsAccountOverride[lock.lockId] = null;
this.claimCashAmount[lock.lockId] = null;
this.claimRequestService
.getOverdueLoans(lock.clientId)
.pipe(catchError(() => of([])))
.subscribe((loans: any[]) => {
this.claimOverdueLoans[lock.lockId] = loans ?? [];
if (loans && loans.length > 0) {
this.claimLoanId[lock.lockId] = loans[0].loanId;
const loanAmt = Math.min(Number(lock.amount), Number(loans[0].overdueAmount));
this.claimLoanAmount[lock.lockId] = loanAmt;
this.claimSavingsAmount[lock.lockId] = Number((Number(lock.amount) - loanAmt).toFixed(2));
}
});
}
claimTotal(lock: any): number {
return (
Number(this.claimLoanAmount[lock.lockId] || 0) +
Number(this.claimSavingsAmount[lock.lockId] || 0) +
Number(this.claimCashAmount[lock.lockId] || 0)
);
}
submitClaim(lock: any): void {
const loanAmount = Number(this.claimLoanAmount[lock.lockId] || 0);
const savingsAmount = Number(this.claimSavingsAmount[lock.lockId] || 0);
const cashAmount = Number(this.claimCashAmount[lock.lockId] || 0);
const total = loanAmount + savingsAmount + cashAmount;
if (total <= 0) {
this.claimError = 'Enter at least one amount (loan, savings, or cash), greater than zero.';
return;
}
if (total > Number(lock.amount)) {
this.claimError = `Total (${total}) cannot exceed the unclaimed amount of ${lock.amount}.`;
return;
}
if (loanAmount > 0 && !this.claimLoanId[lock.lockId]) {
this.claimError = 'Select a loan to apply the loan amount towards.';
return;
}
if (savingsAmount > 0 && !lock.savingsAccountId && !this.claimSavingsAccountOverride[lock.lockId]) {
this.claimError = 'This share account has no linked savings account — enter a Savings Account ID to credit.';
return;
}
this.claimSubmitting = true;
this.claimError = null;
this.claimRequestService
.submit(lock.lockId, {
loanId: loanAmount > 0 ? this.claimLoanId[lock.lockId] : undefined,
loanApplyAmount: loanAmount > 0 ? loanAmount : undefined,
savingsAccountId:
savingsAmount > 0 && !lock.savingsAccountId ? this.claimSavingsAccountOverride[lock.lockId] : undefined,
savingsApplyAmount: savingsAmount > 0 ? savingsAmount : undefined,
cashApplyAmount: cashAmount > 0 ? cashAmount : undefined
})
.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: () => {
this.claimActioningId = null;
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 {
this.summaryLoading = true;
this.service
.getSummary()
.pipe(catchError(() => of(null)))
.subscribe((res: any) => {
this.summary = res;
this.summaryLoading = false;
});
}
loadDeclarations(): void {
this.declarationsLoading = true;
this.service
.getDeclarations()
.pipe(catchError(() => of([])))
.subscribe((res: any[]) => {
this.declarations = res ?? [];
this.declarationsLoading = false;
this.declarationsLoaded = true;
});
}
declarationStatusLabel(status: string): string {
return status === 'PENDING_APPROVAL' ? 'Pending Approval' : 'Approved';
}
declarationStatusClass(status: string): string {
return status === 'PENDING_APPROVAL' ? 'dd-badge--pending' : 'dd-badge--posted';
}
loadLocks(): void {
this.locksLoading = true;
this.service
.getLocks(this.lockStatusFilter)
.pipe(catchError(() => of([])))
.subscribe((res: any[]) => {
this.locks = res ?? [];
this.locksLoading = false;
this.locksLoaded = true;
});
}
setLockStatusFilter(status: string): void {
this.lockStatusFilter = status;
this.loadLocks();
}
lockStatusClass(status: string): string {
switch (status) {
case 'UNCLAIMED':
return 'dd-badge--pending';
case 'CLAIMED':
return 'dd-badge--posted';
case 'FORFEITED':
return 'dd-badge--failed';
default:
return 'dd-badge--pending';
}
}
load(): void {
this.loading = true;
this.expandedLockId = null;
this.service
.getDashboard()
.pipe(catchError(() => of([])))
.subscribe((res: any[]) => {
this.rows = res ?? [];
this.loading = false;
this.loaded = true;
});
}
totalOverdue(row: any): number {
return (row.overdueLoans || []).reduce((sum: number, l: any) => sum + Number(l.overdueAmount || 0), 0);
}
toggleApply(row: any): void {
if (this.expandedLockId === row.lockId) {
this.expandedLockId = null;
return;
}
this.expandedLockId = row.lockId;
this.applyError = null;
const firstLoan = (row.overdueLoans || [])[0];
this.selectedLoanId[row.lockId] = firstLoan ? firstLoan.loanId : null;
this.applyAmount[row.lockId] = firstLoan
? Math.min(Number(row.lockedAmount), Number(firstLoan.overdueAmount))
: Number(row.lockedAmount);
}
maxAmountFor(row: any): number {
const loanId = this.selectedLoanId[row.lockId];
const loan = (row.overdueLoans || []).find((l: any) => l.loanId === loanId);
const overdue = loan ? Number(loan.overdueAmount) : Number(row.lockedAmount);
return Math.min(Number(row.lockedAmount), overdue);
}
onLoanChange(row: any): void {
this.applyAmount[row.lockId] = this.maxAmountFor(row);
}
submitApply(row: any): void {
const loanId = this.selectedLoanId[row.lockId];
const amount = this.applyAmount[row.lockId];
if (!loanId || !amount || amount <= 0) {
this.applyError = 'Select a loan and enter a valid amount.';
return;
}
this.applying = true;
this.applyError = null;
this.service.applyToLoan(row.lockId, loanId, amount).subscribe({
next: () => {
this.applying = false;
this.load();
this.loadLocks();
this.loadSummary();
},
error: (err: any) => {
this.applying = false;
this.applyError = err?.error?.message || 'Failed to apply dividend to loan.';
}
});
}
}
+7
View File
@@ -1235,6 +1235,13 @@
<fa-icon icon="id-card" class="menu-item-icon"></fa-icon> Share Member Details
</button>
</div>
<!-- Dividend Management -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Dividend Management</div>
<button mat-menu-item routerLink="/clerk/dividend-dashboard" tabindex="0">
<fa-icon icon="hand-holding-usd" class="menu-item-icon"></fa-icon> Dividend Dashboard
</button>
</div>
</div>
</mat-menu>
@@ -51,9 +51,43 @@
}
</mat-form-field>
@if (overlapError) {
<p class="dividend-profit-note dividend-profit-note--error">{{ overlapError }}</p>
}
@if (loadingProfit) {
<p class="dividend-profit-note">
{{ 'labels.inputs.Fetching bank profit for this period' | translate }}...
</p>
} @else if (profitLookupError) {
<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>
}
<mat-form-field>
<mat-label>{{ 'labels.inputs.Dividend Amount' | translate }}</mat-label>
<input matInput required formControlName="dividendAmount" />
<mat-label>{{ '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>
{{ 'labels.inputs.Dividend Percentage' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
<mat-form-field>
<mat-label
>{{ 'labels.inputs.Dividend Amount' | translate }} ({{
'labels.inputs.auto-computed' | translate
}})</mat-label
>
<input matInput required readonly formControlName="dividendAmount" />
@if (createDividendForm.controls.dividendAmount.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Amount' | translate }} {{ 'labels.commons.is' | translate }}
@@ -68,7 +102,7 @@
<button type="button" mat-raised-button [routerLink]="['../']">
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button mat-raised-button color="primary" [disabled]="!createDividendForm.valid">
<button mat-raised-button color="primary" [disabled]="!createDividendForm.valid || !!overlapError">
{{ 'labels.buttons.Submit' | translate }}
</button>
</mat-card-actions>
@@ -9,3 +9,17 @@
.container {
max-width: 37rem;
}
.dividend-profit-note {
margin: 0 0 12px;
padding: 8px 12px;
border-radius: 6px;
background: #eafaf0;
color: #1a7f37;
font-size: 13px;
&--error {
background: #fdecea;
color: #b42318;
}
}
@@ -15,6 +15,7 @@ import { Dates } from 'app/core/utils/dates';
/** Custom Services. */
import { ProductsService } from 'app/products/products.service';
import { SettingsService } from 'app/settings/settings.service';
import { AccountingService } from 'app/accounting/accounting.service';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
@@ -33,6 +34,7 @@ export class CreateDividendComponent implements OnInit {
private route = inject(ActivatedRoute);
private dateUtils = inject(Dates);
private productService = inject(ProductsService);
private accountingService = inject(AccountingService);
private router = inject(Router);
private settingsService = inject(SettingsService);
@@ -45,6 +47,18 @@ 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;
/** 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;
/** 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. */
overlapError: string | null = null;
/**
* Get Share Product data from `Resolver`.
* @param {FormBuilder} formBuilder Form Builder.
@@ -63,6 +77,64 @@ export class CreateDividendComponent implements OnInit {
ngOnInit() {
this.maxDate = this.settingsService.businessDate;
this.setDividendForm();
this.loadExistingDividendPeriods();
const onPeriodChange = () => {
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());
}
/** Parses a Fineract date field, which may arrive as `[yyyy, MM, dd]` or an ISO string. */
private parseFineractDate(value: any): Date | null {
if (!value) {
return null;
}
if (Array.isArray(value)) {
const [
year,
month,
day
] = value;
return new Date(year, month - 1, day);
}
const parsed = new Date(value);
return isNaN(parsed.getTime()) ? null : parsed;
}
/**
* Loads every dividend already declared for this product (any status -- this product has no
* "rejected" state, see backend `ShareProductDividendStatusType`) so a new period that overlaps
* one of them can be flagged before the officer even submits.
*/
private loadExistingDividendPeriods(): void {
this.productService.getDividends(this.shareProductData.id).subscribe((response: any) => {
const items = response?.pageItems || response || [];
this.existingDividendPeriods = items
.map((item: any) => ({
start: this.parseFineractDate(item.dividendPeriodStartDate),
end: this.parseFineractDate(item.dividendPeriodEndDate)
}))
.filter((period: any) => period.start && period.end);
this.checkOverlap();
});
}
/** Blocks a new period that overlaps any existing declaration for this product. */
checkOverlap(): void {
const start: Date = this.createDividendForm.get('dividendPeriodStartDate').value;
const end: Date = this.createDividendForm.get('dividendPeriodEndDate').value;
if (!(start instanceof Date) || !(end instanceof Date)) {
this.overlapError = null;
return;
}
const overlapping = this.existingDividendPeriods.find((period) => period.start <= end && period.end >= start);
this.overlapError = overlapping
? `This product already has a dividend declared from ${overlapping.start.toLocaleDateString()} to ${overlapping.end.toLocaleDateString()}. Choose a period that doesn't overlap.`
: null;
}
/**
@@ -78,6 +150,14 @@ export class CreateDividendComponent implements OnInit {
'',
Validators.required
],
dividendPercentage: [
'',
[
Validators.required,
Validators.min(0),
Validators.max(100)
]
],
dividendAmount: [
'',
Validators.required
@@ -85,10 +165,56 @@ export class CreateDividendComponent implements OnInit {
});
}
/** yyyy-MM-dd, independent of the tenant's display date format — this is what the backend expects. */
private toIsoDate(date: Date): string {
const pad = (n: number) => String(n).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
}
/**
* 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.
*/
fetchProfitForSelectedPeriod(): 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;
}
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;
}
});
}
/** 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('');
return;
}
const amount = (this.netProfit * percentage) / 100;
this.createDividendForm.get('dividendAmount').setValue(Math.round(amount * 100) / 100);
}
/**
* Submits Create Dividend form.
*/
submit() {
if (this.overlapError) {
return;
}
const createDividendFormData = this.createDividendForm.value;
const locale = this.settingsService.language.code;
const dateFormat = this.settingsService.dateFormat;
@@ -105,6 +231,9 @@ export class CreateDividendComponent implements OnInit {
dateFormat,
locale
};
// dividendPercentage only drives the auto-computed dividendAmount above — Fineract's
// create-dividend command doesn't recognise it.
delete data.dividendPercentage;
this.productService.createDividend(this.shareProductData.id, data).subscribe((response: any) => {
this.router.navigate(['../'], { relativeTo: this.route });
});
@@ -14,6 +14,34 @@
</div>
<div class="container">
<div class="dpd-summary-card">
<div class="dpd-summary-card__header">{{ 'labels.inputs.Total Dividend Paid' | translate }}</div>
<div class="layout-row gap-10px align-center" style="flex-wrap: wrap">
<mat-form-field appearance="outline">
<mat-label>{{ 'labels.inputs.From Date' | translate }}</mat-label>
<input matInput type="date" [(ngModel)]="dividendPaidFromDate" (change)="loadDividendPaidReport()" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>{{ 'labels.inputs.To Date' | translate }}</mat-label>
<input matInput type="date" [(ngModel)]="dividendPaidToDate" (change)="loadDividendPaidReport()" />
</mat-form-field>
@if (loadingDividendPaidReport) {
<span class="dpd-summary-card__value">{{ 'labels.inputs.Loading' | translate }}…</span>
} @else if (dividendPaidReport) {
<span class="dpd-summary-card__value">{{ dividendPaidReport.totalPaid | number: '1.2-2' }}</span>
<span class="dpd-summary-card__meta">
({{ dividendPaidReport.payoutCount }} {{ 'labels.inputs.payouts' | translate }})
</span>
@if (dividendPaidReport.glAccountConfigured === false) {
<span class="dpd-summary-card__warn">
{{ 'labels.inputs.Dividend Payable GL account not mapped yet' | translate }} (Accounting → Financial
Activity Mappings)
</span>
}
}
</div>
</div>
<div class="layout-row">
<mat-form-field appearance="outline" class="flex-fill">
<mat-label>{{ 'labels.inputs.Filter' | translate }}</mat-label>
@@ -13,3 +13,41 @@ table {
cursor: pointer;
}
}
.dpd-summary-card {
background: #eafaf0;
border: 1px solid rgb(26 127 55 / 25%);
border-radius: 10px;
padding: 14px 18px;
margin-bottom: 16px;
&__header {
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.02em;
color: #1a7f37;
margin-bottom: 8px;
}
&__value {
font-size: 22px;
font-weight: 700;
color: #1a7f37;
}
&__meta {
font-size: 13px;
color: #475569;
}
&__warn {
font-size: 12.5px;
color: #b42318;
font-weight: 600;
}
mat-form-field {
width: 160px;
}
}
@@ -31,6 +31,9 @@ import { MatFormField, MatLabel } from '@angular/material/form-field';
import { MatInput } from '@angular/material/input';
import { TranslatePipe as NgxTranslatePipe } from '@ngx-translate/core';
import { DateFormatPipe } from '../../../pipes/date-format.pipe';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { AccountingService } from '../../../accounting/accounting.service';
/**
* Dividends component.
@@ -40,6 +43,8 @@ import { DateFormatPipe } from '../../../pipes/date-format.pipe';
templateUrl: './dividends.component.html',
styleUrls: ['./dividends.component.scss'],
imports: [
CommonModule,
FormsModule,
HasPermissionDirective,
MatButton,
RouterLink,
@@ -67,6 +72,13 @@ import { DateFormatPipe } from '../../../pipes/date-format.pipe';
export class ShareProductsDividendsComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private accountingService = inject(AccountingService);
/** "Total Dividend Paid" report state — derived from the Dividend Payable GL ledger. */
dividendPaidFromDate: string;
dividendPaidToDate: string;
dividendPaidReport: any = null;
loadingDividendPaidReport = false;
/** Dividends data. */
dividendData: any;
@@ -101,6 +113,39 @@ export class ShareProductsDividendsComponent implements OnInit {
*/
ngOnInit() {
this.setDividends();
const today = new Date();
const threeYearsAgo = new Date(today);
threeYearsAgo.setFullYear(threeYearsAgo.getFullYear() - 3);
this.dividendPaidToDate = this.toIsoDate(today);
this.dividendPaidFromDate = this.toIsoDate(threeYearsAgo);
this.loadDividendPaidReport();
}
private toIsoDate(date: Date): string {
const pad = (n: number) => String(n).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
}
/**
* "Total Dividend Paid" for the selected period — a report over the existing Dividend Payable
* GL account's ledger, not a separate account (real-world CBS practice).
*/
loadDividendPaidReport() {
if (!this.dividendPaidFromDate || !this.dividendPaidToDate) {
return;
}
this.loadingDividendPaidReport = true;
this.accountingService.getDividendPaidReport(this.dividendPaidFromDate, this.dividendPaidToDate).subscribe({
next: (response: any) => {
this.dividendPaidReport = response;
this.loadingDividendPaidReport = false;
},
error: () => {
this.dividendPaidReport = null;
this.loadingDividendPaidReport = false;
}
});
}
/**
@@ -6,26 +6,69 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
@if (!isdividendPosted) {
<div class="container m-b-20 layout-row layout-align-end gap-20px">
<button mat-raised-button color="primary" *mifosxHasPermission="'POST_DIVIDENDS'" (click)="postDividends()">
<fa-icon icon="plus" class="m-r-10"></fa-icon>
{{ 'labels.buttons.buttons' | translate }}
</button>
</div>
}
<div class="vd-container">
<div class="pg-card">
<div class="vd-header">
<div class="vd-header__title">
<fa-icon icon="hand-holding-usd" class="vd-header__icon"></fa-icon>
<span
>{{ 'labels.inputs.Dividend Approval' | translate }} —
{{ 'labels.inputs.Per-Member Breakdown' | translate }}</span
>
</div>
<div class="vd-header__actions">
@if (!isdividendPosted) {
<button
mat-raised-button
color="primary"
class="vd-approve-btn"
*mifosxHasPermission="'POST_DIVIDENDS'"
(click)="postDividends()"
>
<fa-icon icon="check" class="m-r-10"></fa-icon>
{{ 'labels.buttons.Approve' | translate }}
</button>
}
@if (embedded) {
<button type="button" class="vd-close-btn" (click)="close()" aria-label="Close">
<fa-icon icon="times"></fa-icon>
</button>
}
</div>
</div>
<div class="container">
<div class="layout-row">
<mat-form-field appearance="outline" class="flex-fill">
<mat-label>{{ 'labels.inputs.Filter' | translate }}</mat-label>
<i matPrefix class="fas fa-search"></i>
<input matInput (keyup)="applyFilter($event.target.value)" />
</mat-form-field>
</div>
<div class="vd-summary">
<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>
</div>
<div class="vd-summary__stat">
<span class="vd-summary__label">{{ 'labels.inputs.Members' | translate }}</span>
<span class="vd-summary__value">{{ dividendData.pageItems.length }}</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'">
{{
isdividendPosted ? ('labels.inputs.Approved' | translate) : ('labels.inputs.Awaiting Approval' | translate)
}}
</span>
</div>
</div>
<div class="mat-elevation-z8">
<table mat-table [dataSource]="dataSource" matSort>
<div class="vd-filter">
<div class="vd-search">
<fa-icon icon="search" class="vd-search__icon"></fa-icon>
<input
class="vd-search__input"
type="text"
placeholder="{{ 'labels.inputs.Filter' | translate }}"
(keyup)="applyFilter($event.target.value)"
/>
</div>
</div>
<table mat-table [dataSource]="dataSource" matSort class="pg-table">
<ng-container matColumnDef="clientName">
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'labels.inputs.Client Name' | translate }}</th>
<td mat-cell *matCellDef="let dividend">{{ dividend.accountData.clientName }}</td>
@@ -33,17 +76,23 @@
<ng-container matColumnDef="shareAccount">
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'labels.inputs.Share Account' | translate }}</th>
<td mat-cell *matCellDef="let dividend">{{ dividend.accountData.accountNo }}</td>
<td mat-cell *matCellDef="let dividend">
<span class="pg-chip">{{ dividend.accountData.accountNo }}</span>
</td>
</ng-container>
<ng-container matColumnDef="dividendAmount">
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'labels.inputs.Dividend Amount' | translate }}</th>
<td mat-cell *matCellDef="let dividend">{{ dividend.amount }}</td>
<td mat-cell *matCellDef="let dividend" class="num" [class.vd-amount--zero]="dividend.amount === 0">
{{ dividend.amount | number: '1.2-2' }}
</td>
</ng-container>
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'labels.inputs.Status' | translate }}</th>
<td mat-cell *matCellDef="let dividend">{{ dividend.status.value }}</td>
<td mat-cell *matCellDef="let dividend">
<span [class]="statusChipClass(dividend.status.value)">{{ dividend.status.value }}</span>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
@@ -6,6 +6,222 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
$primary: #2563eb;
$surface: #fff;
$surface-alt: #f8fafc;
$border: #e2e8f0;
$text-primary: #1e293b;
$text-secondary: #64748b;
.vd-container {
max-width: 100%;
}
.pg-card {
background: $surface;
border: 1px solid $border;
border-radius: 12px;
box-shadow:
0 1px 3px rgb(0 0 0 / 7%),
0 1px 2px rgb(0 0 0 / 4%);
padding: 20px 24px;
}
.vd-header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 18px;
&__title {
display: flex;
align-items: center;
gap: 10px;
font-size: 15px;
font-weight: 700;
color: $text-primary;
}
&__icon {
color: $primary;
font-size: 16px;
}
}
.vd-header__actions {
display: flex;
align-items: center;
gap: 4px;
}
.vd-approve-btn {
font-weight: 600;
}
.vd-close-btn {
width: 34px;
height: 34px;
border-radius: 50%;
border: 1px solid $border;
background: $surface;
color: $text-secondary;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 13px;
padding: 0;
transition:
background 0.15s ease,
border-color 0.15s ease,
color 0.15s ease;
&:hover {
background: #fdecea;
border-color: #f3a79a;
color: #b42318;
}
&:focus-visible {
outline: 2px solid $primary;
outline-offset: 2px;
}
}
.vd-summary {
display: flex;
gap: 28px;
flex-wrap: wrap;
background: $surface-alt;
border: 1px solid $border;
border-radius: 10px;
padding: 14px 20px;
margin-bottom: 18px;
&__stat {
display: flex;
flex-direction: column;
gap: 4px;
}
&__label {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.03em;
color: $text-secondary;
}
&__value {
font-size: 18px;
font-weight: 700;
color: $text-primary;
}
}
.vd-filter {
margin-bottom: 16px;
}
.vd-search {
display: flex;
align-items: center;
gap: 10px;
max-width: 320px;
background: $surface;
border: 1px solid $border;
border-radius: 999px;
padding: 9px 16px;
transition:
border-color 0.15s ease,
box-shadow 0.15s ease;
&:focus-within {
border-color: $primary;
box-shadow: 0 0 0 3px rgb(37 99 235 / 12%);
}
&__icon {
color: $text-secondary;
font-size: 12px;
flex-shrink: 0;
}
&__input {
border: none;
outline: none;
background: transparent;
font-size: 13.5px;
color: $text-primary;
flex: 1;
min-width: 0;
&::placeholder {
color: #94a3b8;
}
}
}
.vd-badge {
display: inline-flex;
align-items: center;
width: fit-content;
padding: 4px 12px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
&--pending {
background: #fffbeb;
color: #92400e;
}
&--approved {
background: #eff6ff;
color: #1d4ed8;
}
&--posted {
background: #eafaf0;
color: #1a7f37;
}
}
.pg-chip {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
background: #f1f5f9;
color: $text-primary;
}
.vd-amount--zero {
color: $text-secondary;
}
table {
width: 100%;
}
.pg-table {
th {
font-size: 11.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.02em;
color: $text-secondary;
}
td {
font-size: 13.5px;
}
.num {
text-align: right;
}
}
@@ -6,7 +6,7 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, ViewChild, inject } from '@angular/core';
import { Component, OnInit, Input, Output, EventEmitter, ViewChild, inject } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { MatPaginator } from '@angular/material/paginator';
import { MatSort, MatSortHeader } from '@angular/material/sort';
@@ -54,9 +54,18 @@ export class ViewDividendComponent implements OnInit {
private productsService = inject(ProductsService);
private router = inject(Router);
/** Set to render standalone (e.g. inside a popup from the Dividend Requests tab) instead of
* resolving productId/dividendId/data from the Angular route. */
@Input() embedded = false;
@Input() overrideProductId: string | number | null = null;
@Input() overrideDividendId: string | number | null = null;
@Output() closeRequested = new EventEmitter<void>();
@Output() actionCompleted = new EventEmitter<void>();
dividendData: any;
status: any;
isdividendPosted = false;
totalAmount = 0;
@ViewChild(MatPaginator, { static: true }) paginator: MatPaginator;
@ViewChild(MatSort, { static: true }) sort: MatSort;
@@ -68,32 +77,61 @@ export class ViewDividendComponent implements OnInit {
];
dataSource: MatTableDataSource<any>;
constructor() {
this.route.data.subscribe((data: { dividendData: any }) => {
this.dividendData = data.dividendData;
});
this.status = this.route.snapshot.queryParams['status'];
if (this.status && (this.status === 'Dividend Approved' || this.status === 'Dividend Posted')) {
this.isdividendPosted = true;
ngOnInit() {
if (this.embedded) {
this.productsService
.getDividendData(this.overrideProductId, this.overrideDividendId)
.subscribe((dividendData: any) => this.setDividendData(dividendData));
} 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));
}
}
ngOnInit() {
this.dataSource = new MatTableDataSource(this.dividendData.pageItems);
private setDividendData(dividendData: any) {
this.dividendData = dividendData;
this.dataSource = new MatTableDataSource(dividendData.pageItems);
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
);
}
statusChipClass(status: string): string {
if (status === 'Dividend Posted') {
return 'vd-badge vd-badge--posted';
}
if (status === 'Dividend Approved') {
return 'vd-badge vd-badge--approved';
}
return 'vd-badge vd-badge--pending';
}
postDividends() {
const shareProductId = this.route.parent.parent.snapshot.paramMap.get('productId');
const dividendId = this.route.snapshot.paramMap.get('dividendId');
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');
this.productsService
.approveDividend(shareProductId, dividendId, { productId: shareProductId, dividendId: dividendId })
.subscribe(() => {
this.router.navigate(['../'], { relativeTo: this.route });
if (this.embedded) {
this.actionCompleted.emit();
} else {
this.router.navigate(['../'], { relativeTo: this.route });
}
});
}
close(): void {
this.closeRequested.emit();
}
applyFilter(filterValue: string) {
this.dataSource.filter = filterValue.trim().toLowerCase();
}
@@ -0,0 +1,77 @@
<!--
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-card">
<div class="pos-section-label">Dividend Requests — Declared, Awaiting Approval</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]="loading" (click)="load()">Refresh</button>
</label>
</div>
<table class="pg-table">
<thead>
<tr>
<th>Share Product</th>
<th>Dividend Period</th>
<th class="num">Total Amount</th>
<th class="num">Members</th>
<th>Declared On</th>
<th></th>
</tr>
</thead>
<tbody>
@if (loading) {
<tr>
<td colspan="6" class="pg-empty">Loading…</td>
</tr>
} @else if (loaded && rows.length === 0) {
<tr>
<td colspan="6" class="pg-empty">No dividend declarations are waiting for approval.</td>
</tr>
} @else {
@for (row of rows; track row.dividendId) {
<tr>
<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 dr-approve-btn"
[disabled]="approvingId === row.dividendId"
(click)="approve(row)"
>
{{ approvingId === row.dividendId ? 'Approving…' : 'Approve' }}
</button>
@if (approveError[row.dividendId]) {
<div class="dr-approve-error">{{ approveError[row.dividendId] }}</div>
}
</td>
</tr>
}
}
</tbody>
</table>
</div>
@if (showViewModal && viewProductId && viewDividendId) {
<div class="dr-popup-backdrop" (click)="closeViewModal()">
<div class="dr-popup-panel" (click)="$event.stopPropagation()">
<mifosx-view-dividend
[embedded]="true"
[overrideProductId]="viewProductId"
[overrideDividendId]="viewDividendId"
(closeRequested)="closeViewModal()"
(actionCompleted)="onViewActionCompleted()"
></mifosx-view-dividend>
</div>
</div>
}
@@ -0,0 +1,85 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
.dr-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.dr-approve-btn {
background: #2563eb;
color: #fff;
border-color: #2563eb;
&:disabled {
opacity: 0.6;
}
}
.dr-approve-error {
width: 100%;
color: #b42318;
font-size: 12px;
font-weight: 600;
}
@keyframes dr-popup-fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes dr-popup-zoom {
from {
opacity: 0;
transform: scale(0.97) translateY(10px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.dr-popup-backdrop {
position: fixed;
inset: 0;
// No backdrop-filter here: it would make this element a containing block for any
// position:fixed descendant inside the embedded ViewDividendComponent.
background: rgb(15 25 45 / 65%);
z-index: 1100;
display: flex;
align-items: flex-start;
justify-content: center;
padding: 32px 20px;
overflow-y: auto;
animation: dr-popup-fade 0.2s ease-out;
}
.dr-popup-panel {
background: #f7f9fc;
border-radius: 16px;
width: 100%;
max-width: 1000px;
box-shadow: 0 24px 60px rgb(0 0 0 / 25%);
animation: dr-popup-zoom 0.25s ease-out;
padding: 12px;
}
@media (prefers-reduced-motion: reduce) {
.dr-popup-backdrop,
.dr-popup-panel {
animation: none;
}
}
@@ -0,0 +1,103 @@
/**
* 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 { catchError } from 'rxjs/operators';
import { of } from 'rxjs';
import { PendingDividendsService } from './pending-dividends.service';
import { ProductsService } from '../../../products/products.service';
import { ViewDividendComponent } from '../../../products/share-products/view-dividend/view-dividend.component';
/**
* Approval Center tab — every Share Product dividend declaration still awaiting approval, across
* all products (Fineract's own screens only show one product's dividends at a time). Approving
* directly from here calls the same native Fineract "approve dividend" command used by the
* per-product View Dividend page.
*/
@Component({
selector: 'mifosx-dividend-requests',
templateUrl: './dividend-requests.component.html',
styleUrls: [
'../../../cashier/cashier-shared.scss',
'../../../clerk/clerk-shared.scss',
'./dividend-requests.component.scss'
],
standalone: true,
imports: [
CommonModule,
ViewDividendComponent
]
})
export class DividendRequestsComponent implements OnInit {
private service = inject(PendingDividendsService);
private productsService = inject(ProductsService);
rows: any[] = [];
loading = false;
loaded = false;
approvingId: number | null = null;
approveError: Record<number, string> = {};
showViewModal = false;
viewProductId: number | null = null;
viewDividendId: number | null = null;
ngOnInit(): void {
this.load();
}
openView(row: any): void {
this.viewProductId = row.productId;
this.viewDividendId = row.dividendId;
this.showViewModal = true;
}
closeViewModal(): void {
this.showViewModal = false;
this.viewProductId = null;
this.viewDividendId = null;
}
onViewActionCompleted(): void {
this.closeViewModal();
this.load();
}
load(): void {
this.loading = true;
this.service
.list()
.pipe(catchError(() => of([])))
.subscribe((res: any[]) => {
this.rows = res ?? [];
this.loading = false;
this.loaded = true;
});
}
approve(row: any): void {
this.approvingId = row.dividendId;
delete this.approveError[row.dividendId];
this.productsService
.approveDividend(row.productId, row.dividendId, { productId: row.productId, dividendId: row.dividendId })
.subscribe({
next: () => {
this.approvingId = null;
this.load();
},
error: (err: any) => {
this.approvingId = null;
this.approveError[row.dividendId] =
err?.error?.defaultUserMessage || err?.error?.message || 'Approve failed.';
}
});
}
}
@@ -0,0 +1,25 @@
/**
* 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 } from '@angular/common/http';
import { Observable } from 'rxjs';
/**
* HTTP service listing every Share Product dividend declaration still awaiting approval, across
* all products — Fineract's own dividend endpoints are scoped to one product at a time, so this
* is what feeds the "Dividend Requests" tab on the Approval Center (Checker Inbox).
*/
@Injectable({ providedIn: 'root' })
export class PendingDividendsService {
private http = inject(HttpClient);
list(): Observable<any> {
return this.http.get('/clerk/pending-dividends');
}
}
@@ -144,6 +144,15 @@
<fa-icon icon="hand-holding-usd" class="ac-tab__ic"></fa-icon>
<span>Share Withdrawal Requests</span>
</a>
<a
class="ac-tab"
routerLink="./dividend-requests"
routerLinkActive="ac-tab--active"
*mifosxHasPermission="'ALL_FUNCTIONS_READ'"
>
<fa-icon icon="coins" class="ac-tab__ic"></fa-icon>
<span>Dividend Requests</span>
</a>
<a
class="ac-tab"
routerLink="./deposit-requests"
+6
View File
@@ -18,6 +18,7 @@ import { CheckerInboxAndTasksComponent } from './checker-inbox-and-tasks/checker
import { CheckerInboxComponent } from './checker-inbox-and-tasks-tabs/checker-inbox/checker-inbox.component';
import { WithdrawalRequestsComponent } from './checker-inbox-and-tasks-tabs/withdrawal-requests/withdrawal-requests.component';
import { ShareWithdrawalRequestsComponent } from './checker-inbox-and-tasks-tabs/share-withdrawal-requests/share-withdrawal-requests.component';
import { DividendRequestsComponent } from './checker-inbox-and-tasks-tabs/dividend-requests/dividend-requests.component';
import { DepositRequestsComponent } from './checker-inbox-and-tasks-tabs/deposit-requests/deposit-requests.component';
import { DisbursementRequestsComponent } from './checker-inbox-and-tasks-tabs/disbursement-requests/disbursement-requests.component';
import { ClientApprovalComponent } from './checker-inbox-and-tasks-tabs/client-approval/client-approval.component';
@@ -165,6 +166,11 @@ const routes: Routes = [
component: ShareWithdrawalRequestsComponent,
data: { title: 'Share Withdrawal Requests' }
},
{
path: 'dividend-requests',
component: DividendRequestsComponent,
data: { title: 'Dividend Requests' }
},
{
path: 'deposit-requests',
component: DepositRequestsComponent,
+2 -2
View File
@@ -9,8 +9,8 @@
/* tslint:disable */
export default {
'mifos_x': {
'version': '260806',
'hash': 'ddb215de'
'version': '260811',
'hash': '01e86173'
},
'allow_switching_backend_instance': true
};