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:
@@ -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.';
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
+77
@@ -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>
|
||||
}
|
||||
+85
@@ -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;
|
||||
}
|
||||
}
|
||||
+103
@@ -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.';
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
+25
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
/* tslint:disable */
|
||||
export default {
|
||||
'mifos_x': {
|
||||
'version': '260806',
|
||||
'hash': 'ddb215de'
|
||||
'version': '260811',
|
||||
'hash': '01e86173'
|
||||
},
|
||||
'allow_switching_backend_instance': true
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user