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 });
|
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.
|
* @param {boolean} useHttpParams True if http params should be used.
|
||||||
* @returns {Observable<any>} Accounting rules data.
|
* @returns {Observable<any>} Accounting rules data.
|
||||||
|
|||||||
@@ -117,6 +117,14 @@ const routes: Routes = [
|
|||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('../clerk/today-activity/today-activity.component').then((m) => m.TodayActivityComponent),
|
import('../clerk/today-activity/today-activity.component').then((m) => m.TodayActivityComponent),
|
||||||
data: { title: "Today's Activity", breadcrumb: "Today's Activity" }
|
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
|
<fa-icon icon="id-card" class="menu-item-icon"></fa-icon> Share Member Details
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</mat-menu>
|
</mat-menu>
|
||||||
|
|
||||||
|
|||||||
@@ -51,9 +51,43 @@
|
|||||||
}
|
}
|
||||||
</mat-form-field>
|
</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-form-field>
|
||||||
<mat-label>{{ 'labels.inputs.Dividend Amount' | translate }}</mat-label>
|
<mat-label>{{ 'labels.inputs.Dividend Percentage' | translate }} (%)</mat-label>
|
||||||
<input matInput required formControlName="dividendAmount" />
|
<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')) {
|
@if (createDividendForm.controls.dividendAmount.hasError('required')) {
|
||||||
<mat-error>
|
<mat-error>
|
||||||
{{ 'labels.inputs.Amount' | translate }} {{ 'labels.commons.is' | translate }}
|
{{ 'labels.inputs.Amount' | translate }} {{ 'labels.commons.is' | translate }}
|
||||||
@@ -68,7 +102,7 @@
|
|||||||
<button type="button" mat-raised-button [routerLink]="['../']">
|
<button type="button" mat-raised-button [routerLink]="['../']">
|
||||||
{{ 'labels.buttons.Cancel' | translate }}
|
{{ 'labels.buttons.Cancel' | translate }}
|
||||||
</button>
|
</button>
|
||||||
<button mat-raised-button color="primary" [disabled]="!createDividendForm.valid">
|
<button mat-raised-button color="primary" [disabled]="!createDividendForm.valid || !!overlapError">
|
||||||
{{ 'labels.buttons.Submit' | translate }}
|
{{ 'labels.buttons.Submit' | translate }}
|
||||||
</button>
|
</button>
|
||||||
</mat-card-actions>
|
</mat-card-actions>
|
||||||
|
|||||||
@@ -9,3 +9,17 @@
|
|||||||
.container {
|
.container {
|
||||||
max-width: 37rem;
|
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. */
|
/** Custom Services. */
|
||||||
import { ProductsService } from 'app/products/products.service';
|
import { ProductsService } from 'app/products/products.service';
|
||||||
import { SettingsService } from 'app/settings/settings.service';
|
import { SettingsService } from 'app/settings/settings.service';
|
||||||
|
import { AccountingService } from 'app/accounting/accounting.service';
|
||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -33,6 +34,7 @@ export class CreateDividendComponent implements OnInit {
|
|||||||
private route = inject(ActivatedRoute);
|
private route = inject(ActivatedRoute);
|
||||||
private dateUtils = inject(Dates);
|
private dateUtils = inject(Dates);
|
||||||
private productService = inject(ProductsService);
|
private productService = inject(ProductsService);
|
||||||
|
private accountingService = inject(AccountingService);
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
private settingsService = inject(SettingsService);
|
private settingsService = inject(SettingsService);
|
||||||
|
|
||||||
@@ -45,6 +47,18 @@ export class CreateDividendComponent implements OnInit {
|
|||||||
/** Maximum Date allowed. */
|
/** Maximum Date allowed. */
|
||||||
maxDate = new Date();
|
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`.
|
* Get Share Product data from `Resolver`.
|
||||||
* @param {FormBuilder} formBuilder Form Builder.
|
* @param {FormBuilder} formBuilder Form Builder.
|
||||||
@@ -63,6 +77,64 @@ export class CreateDividendComponent implements OnInit {
|
|||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
this.maxDate = this.settingsService.businessDate;
|
this.maxDate = this.settingsService.businessDate;
|
||||||
this.setDividendForm();
|
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
|
Validators.required
|
||||||
],
|
],
|
||||||
|
dividendPercentage: [
|
||||||
|
'',
|
||||||
|
[
|
||||||
|
Validators.required,
|
||||||
|
Validators.min(0),
|
||||||
|
Validators.max(100)
|
||||||
|
]
|
||||||
|
],
|
||||||
dividendAmount: [
|
dividendAmount: [
|
||||||
'',
|
'',
|
||||||
Validators.required
|
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.
|
* Submits Create Dividend form.
|
||||||
*/
|
*/
|
||||||
submit() {
|
submit() {
|
||||||
|
if (this.overlapError) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const createDividendFormData = this.createDividendForm.value;
|
const createDividendFormData = this.createDividendForm.value;
|
||||||
const locale = this.settingsService.language.code;
|
const locale = this.settingsService.language.code;
|
||||||
const dateFormat = this.settingsService.dateFormat;
|
const dateFormat = this.settingsService.dateFormat;
|
||||||
@@ -105,6 +231,9 @@ export class CreateDividendComponent implements OnInit {
|
|||||||
dateFormat,
|
dateFormat,
|
||||||
locale
|
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.productService.createDividend(this.shareProductData.id, data).subscribe((response: any) => {
|
||||||
this.router.navigate(['../'], { relativeTo: this.route });
|
this.router.navigate(['../'], { relativeTo: this.route });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,6 +14,34 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="container">
|
<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">
|
<div class="layout-row">
|
||||||
<mat-form-field appearance="outline" class="flex-fill">
|
<mat-form-field appearance="outline" class="flex-fill">
|
||||||
<mat-label>{{ 'labels.inputs.Filter' | translate }}</mat-label>
|
<mat-label>{{ 'labels.inputs.Filter' | translate }}</mat-label>
|
||||||
|
|||||||
@@ -13,3 +13,41 @@ table {
|
|||||||
cursor: pointer;
|
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 { MatInput } from '@angular/material/input';
|
||||||
import { TranslatePipe as NgxTranslatePipe } from '@ngx-translate/core';
|
import { TranslatePipe as NgxTranslatePipe } from '@ngx-translate/core';
|
||||||
import { DateFormatPipe } from '../../../pipes/date-format.pipe';
|
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.
|
* Dividends component.
|
||||||
@@ -40,6 +43,8 @@ import { DateFormatPipe } from '../../../pipes/date-format.pipe';
|
|||||||
templateUrl: './dividends.component.html',
|
templateUrl: './dividends.component.html',
|
||||||
styleUrls: ['./dividends.component.scss'],
|
styleUrls: ['./dividends.component.scss'],
|
||||||
imports: [
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
HasPermissionDirective,
|
HasPermissionDirective,
|
||||||
MatButton,
|
MatButton,
|
||||||
RouterLink,
|
RouterLink,
|
||||||
@@ -67,6 +72,13 @@ import { DateFormatPipe } from '../../../pipes/date-format.pipe';
|
|||||||
export class ShareProductsDividendsComponent implements OnInit {
|
export class ShareProductsDividendsComponent implements OnInit {
|
||||||
private route = inject(ActivatedRoute);
|
private route = inject(ActivatedRoute);
|
||||||
private router = inject(Router);
|
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. */
|
/** Dividends data. */
|
||||||
dividendData: any;
|
dividendData: any;
|
||||||
@@ -101,6 +113,39 @@ export class ShareProductsDividendsComponent implements OnInit {
|
|||||||
*/
|
*/
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
this.setDividends();
|
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/.
|
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
@if (!isdividendPosted) {
|
<div class="vd-container">
|
||||||
<div class="container m-b-20 layout-row layout-align-end gap-20px">
|
<div class="pg-card">
|
||||||
<button mat-raised-button color="primary" *mifosxHasPermission="'POST_DIVIDENDS'" (click)="postDividends()">
|
<div class="vd-header">
|
||||||
<fa-icon icon="plus" class="m-r-10"></fa-icon>
|
<div class="vd-header__title">
|
||||||
{{ 'labels.buttons.buttons' | translate }}
|
<fa-icon icon="hand-holding-usd" class="vd-header__icon"></fa-icon>
|
||||||
</button>
|
<span
|
||||||
</div>
|
>{{ '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="vd-summary">
|
||||||
<div class="layout-row">
|
<div class="vd-summary__stat">
|
||||||
<mat-form-field appearance="outline" class="flex-fill">
|
<span class="vd-summary__label">{{ 'labels.inputs.Total Amount' | translate }}</span>
|
||||||
<mat-label>{{ 'labels.inputs.Filter' | translate }}</mat-label>
|
<span class="vd-summary__value">{{ totalAmount | number: '1.2-2' }}</span>
|
||||||
<i matPrefix class="fas fa-search"></i>
|
</div>
|
||||||
<input matInput (keyup)="applyFilter($event.target.value)" />
|
<div class="vd-summary__stat">
|
||||||
</mat-form-field>
|
<span class="vd-summary__label">{{ 'labels.inputs.Members' | translate }}</span>
|
||||||
</div>
|
<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">
|
<div class="vd-filter">
|
||||||
<table mat-table [dataSource]="dataSource" matSort>
|
<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">
|
<ng-container matColumnDef="clientName">
|
||||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'labels.inputs.Client Name' | translate }}</th>
|
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'labels.inputs.Client Name' | translate }}</th>
|
||||||
<td mat-cell *matCellDef="let dividend">{{ dividend.accountData.clientName }}</td>
|
<td mat-cell *matCellDef="let dividend">{{ dividend.accountData.clientName }}</td>
|
||||||
@@ -33,17 +76,23 @@
|
|||||||
|
|
||||||
<ng-container matColumnDef="shareAccount">
|
<ng-container matColumnDef="shareAccount">
|
||||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'labels.inputs.Share Account' | translate }}</th>
|
<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>
|
||||||
|
|
||||||
<ng-container matColumnDef="dividendAmount">
|
<ng-container matColumnDef="dividendAmount">
|
||||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'labels.inputs.Dividend Amount' | translate }}</th>
|
<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>
|
||||||
|
|
||||||
<ng-container matColumnDef="status">
|
<ng-container matColumnDef="status">
|
||||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'labels.inputs.Status' | translate }}</th>
|
<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>
|
</ng-container>
|
||||||
|
|
||||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||||
|
|||||||
@@ -6,6 +6,222 @@
|
|||||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
* 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 {
|
table {
|
||||||
width: 100%;
|
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/.
|
* 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 { ActivatedRoute, Router } from '@angular/router';
|
||||||
import { MatPaginator } from '@angular/material/paginator';
|
import { MatPaginator } from '@angular/material/paginator';
|
||||||
import { MatSort, MatSortHeader } from '@angular/material/sort';
|
import { MatSort, MatSortHeader } from '@angular/material/sort';
|
||||||
@@ -54,9 +54,18 @@ export class ViewDividendComponent implements OnInit {
|
|||||||
private productsService = inject(ProductsService);
|
private productsService = inject(ProductsService);
|
||||||
private router = inject(Router);
|
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;
|
dividendData: any;
|
||||||
status: any;
|
status: any;
|
||||||
isdividendPosted = false;
|
isdividendPosted = false;
|
||||||
|
totalAmount = 0;
|
||||||
|
|
||||||
@ViewChild(MatPaginator, { static: true }) paginator: MatPaginator;
|
@ViewChild(MatPaginator, { static: true }) paginator: MatPaginator;
|
||||||
@ViewChild(MatSort, { static: true }) sort: MatSort;
|
@ViewChild(MatSort, { static: true }) sort: MatSort;
|
||||||
@@ -68,32 +77,61 @@ export class ViewDividendComponent implements OnInit {
|
|||||||
];
|
];
|
||||||
dataSource: MatTableDataSource<any>;
|
dataSource: MatTableDataSource<any>;
|
||||||
|
|
||||||
constructor() {
|
ngOnInit() {
|
||||||
this.route.data.subscribe((data: { dividendData: any }) => {
|
if (this.embedded) {
|
||||||
this.dividendData = data.dividendData;
|
this.productsService
|
||||||
});
|
.getDividendData(this.overrideProductId, this.overrideDividendId)
|
||||||
this.status = this.route.snapshot.queryParams['status'];
|
.subscribe((dividendData: any) => this.setDividendData(dividendData));
|
||||||
if (this.status && (this.status === 'Dividend Approved' || this.status === 'Dividend Posted')) {
|
} else {
|
||||||
this.isdividendPosted = true;
|
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() {
|
private setDividendData(dividendData: any) {
|
||||||
this.dataSource = new MatTableDataSource(this.dividendData.pageItems);
|
this.dividendData = dividendData;
|
||||||
|
this.dataSource = new MatTableDataSource(dividendData.pageItems);
|
||||||
this.dataSource.paginator = this.paginator;
|
this.dataSource.paginator = this.paginator;
|
||||||
this.dataSource.sort = this.sort;
|
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() {
|
postDividends() {
|
||||||
const shareProductId = this.route.parent.parent.snapshot.paramMap.get('productId');
|
const shareProductId = this.embedded
|
||||||
const dividendId = this.route.snapshot.paramMap.get('dividendId');
|
? this.overrideProductId
|
||||||
|
: this.route.parent.parent.snapshot.paramMap.get('productId');
|
||||||
|
const dividendId = this.embedded ? this.overrideDividendId : this.route.snapshot.paramMap.get('dividendId');
|
||||||
this.productsService
|
this.productsService
|
||||||
.approveDividend(shareProductId, dividendId, { productId: shareProductId, dividendId: dividendId })
|
.approveDividend(shareProductId, dividendId, { productId: shareProductId, dividendId: dividendId })
|
||||||
.subscribe(() => {
|
.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) {
|
applyFilter(filterValue: string) {
|
||||||
this.dataSource.filter = filterValue.trim().toLowerCase();
|
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>
|
<fa-icon icon="hand-holding-usd" class="ac-tab__ic"></fa-icon>
|
||||||
<span>Share Withdrawal Requests</span>
|
<span>Share Withdrawal Requests</span>
|
||||||
</a>
|
</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
|
<a
|
||||||
class="ac-tab"
|
class="ac-tab"
|
||||||
routerLink="./deposit-requests"
|
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 { 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 { 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 { 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 { 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 { 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';
|
import { ClientApprovalComponent } from './checker-inbox-and-tasks-tabs/client-approval/client-approval.component';
|
||||||
@@ -165,6 +166,11 @@ const routes: Routes = [
|
|||||||
component: ShareWithdrawalRequestsComponent,
|
component: ShareWithdrawalRequestsComponent,
|
||||||
data: { title: 'Share Withdrawal Requests' }
|
data: { title: 'Share Withdrawal Requests' }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'dividend-requests',
|
||||||
|
component: DividendRequestsComponent,
|
||||||
|
data: { title: 'Dividend Requests' }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'deposit-requests',
|
path: 'deposit-requests',
|
||||||
component: DepositRequestsComponent,
|
component: DepositRequestsComponent,
|
||||||
|
|||||||
@@ -9,8 +9,8 @@
|
|||||||
/* tslint:disable */
|
/* tslint:disable */
|
||||||
export default {
|
export default {
|
||||||
'mifos_x': {
|
'mifos_x': {
|
||||||
'version': '260806',
|
'version': '260811',
|
||||||
'hash': 'ddb215de'
|
'hash': '01e86173'
|
||||||
},
|
},
|
||||||
'allow_switching_backend_instance': true
|
'allow_switching_backend_instance': true
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user