diff --git a/src/app/accounting/accounting.service.ts b/src/app/accounting/accounting.service.ts index dcd199783..9d2f8825d 100644 --- a/src/app/accounting/accounting.service.ts +++ b/src/app/accounting/accounting.service.ts @@ -32,6 +32,28 @@ export class AccountingService { return this.http.get('/offices', { params: httpParams }); } + /** + * Real net profit for a date range, computed live from posted GL journal entries (not the + * demo data shown by the GL Reports module). + * @param {string} fromDate Period start, yyyy-MM-dd. + * @param {string} toDate Period end, yyyy-MM-dd. + * @returns {Observable} `{ totalIncome, totalExpense, netProfit }`. + */ + getProfitAndLoss(fromDate: string, toDate: string): Observable { + 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} `{ totalPaid, payoutCount, entries[], glAccountConfigured }`. + */ + getDividendPaidReport(fromDate: string, toDate: string): Observable { + const httpParams = new HttpParams().set('fromDate', fromDate).set('toDate', toDate); + return this.http.get('/accounting/dividend-paid-report', { params: httpParams }); + } + /** * @param {boolean} useHttpParams True if http params should be used. * @returns {Observable} Accounting rules data. diff --git a/src/app/cashier/cashier-routing.module.ts b/src/app/cashier/cashier-routing.module.ts index e6fafaf2d..f5efc11bd 100644 --- a/src/app/cashier/cashier-routing.module.ts +++ b/src/app/cashier/cashier-routing.module.ts @@ -117,6 +117,14 @@ const routes: Routes = [ loadComponent: () => import('../clerk/today-activity/today-activity.component').then((m) => m.TodayActivityComponent), data: { title: "Today's Activity", breadcrumb: "Today's Activity" } + }, + { + path: 'dividend-dashboard', + loadComponent: () => + import('../clerk/dividend-dashboard/dividend-dashboard.component').then( + (m) => m.DividendDashboardComponent + ), + data: { title: 'Dividend Dashboard', breadcrumb: 'Dividend Dashboard' } } ] }, diff --git a/src/app/clerk/dividend-claim-request.service.ts b/src/app/clerk/dividend-claim-request.service.ts new file mode 100644 index 000000000..81c208966 --- /dev/null +++ b/src/app/clerk/dividend-claim-request.service.ts @@ -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 { + 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 { + return this.http.get(`/clerk/dividend-claim-requests/overdue-loans/${clientId}`); + } + + list(status = 'PENDING'): Observable { + return this.http.get('/clerk/dividend-claim-requests', { params: { status } }); + } + + approve(id: number): Observable { + return this.http.post(`/clerk/dividend-claim-requests/${id}/approve`, {}); + } + + reject(id: number, reason: string): Observable { + return this.http.post(`/clerk/dividend-claim-requests/${id}/reject`, { reason }); + } +} diff --git a/src/app/clerk/dividend-dashboard.service.ts b/src/app/clerk/dividend-dashboard.service.ts new file mode 100644 index 000000000..9493f5632 --- /dev/null +++ b/src/app/clerk/dividend-dashboard.service.ts @@ -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 { + return this.http.get('/clerk/dividend-dashboard'); + } + + applyToLoan(lockId: number, loanId: number, amount: number): Observable { + return this.http.post(`/clerk/dividend-dashboard/${lockId}/apply-to-loan`, { loanId, amount: String(amount) }); + } + + getSummary(): Observable { + return this.http.get('/clerk/dividend-dashboard/summary'); + } + + getDeclarations(): Observable { + return this.http.get('/clerk/dividend-dashboard/declarations'); + } + + getLocks(status?: string): Observable { + const params = status && status !== 'ALL' ? { status } : {}; + return this.http.get('/clerk/dividend-dashboard/locks', { params }); + } +} diff --git a/src/app/clerk/dividend-dashboard/dividend-dashboard.component.html b/src/app/clerk/dividend-dashboard/dividend-dashboard.component.html new file mode 100644 index 000000000..449483871 --- /dev/null +++ b/src/app/clerk/dividend-dashboard/dividend-dashboard.component.html @@ -0,0 +1,405 @@ + + +@if (summary) { +
+
+
Declarations
+
{{ summary.totalDeclarations }}
+
{{ summary.pendingApprovalDeclarations }} pending approval
+
+
+
Members Affected
+
{{ summary.membersAffected }}
+
{{ summary.totalMemberDividends }} member-dividends total
+
+
+
Total Amount
+
{{ summary.totalAmount | number: '1.2-2' }}
+
across all declared dividends
+
+
+
Currently Locked
+
{{ summary.lockedCount }}
+
{{ summary.lockedAmount | number: '1.2-2' }}
+
+
+
Applied to Loans
+
{{ summary.appliedToLoanCount }}
+
{{ summary.appliedToLoanAmount | number: '1.2-2' }}
+
+
+
Claimed by Member
+
{{ summary.claimedCount }}
+
{{ summary.claimedAmount | number: '1.2-2' }}
+
+
+
Forfeited to Profit
+
{{ summary.forfeitedCount }}
+
{{ summary.forfeitedAmount | number: '1.2-2' }}
+
+
+} + +
+ +
+ +
+ + + + + + + + + + + + + @if (declarationsLoading) { + + + + } @else if (declarationsLoaded && declarations.length === 0) { + + + + } @else { + @for (d of declarations; track d.declarationId) { + + + + + + + + + } + } + +
ProductPeriodDeclared AmountMembersStatusApproved On
Loading…
No dividends have been declared yet.
{{ d.productName }}{{ d.periodStartDate }} → {{ d.periodEndDate }}{{ d.declaredAmount | number: '1.2-2' }}{{ d.totalMembers }} + {{ + declarationStatusLabel(d.status) + }} + {{ d.approvedOn ? (d.approvedOn | date: 'medium') : '—' }}
+
+ +
+ +
+ +
+ + + + + + + + + + + + + @if (loading) { + + + + } @else if (loaded && rows.length === 0) { + + + + } @else { + @for (row of rows; track row.lockId) { + + + + + + + + + @if (expandedLockId === row.lockId) { + + + + } + } + } + +
ClientLocked DividendLocked DateRelease DueTotal Overdue EMI
Loading…
No members with a locked dividend and an overdue EMI right now.
{{ row.clientName }}{{ row.lockedAmount | number: '1.2-2' }}{{ row.lockedDate }}{{ row.releaseDueDate }}{{ totalOverdue(row) | number: '1.2-2' }} + +
+
+ + + @if (applyError) { +
{{ applyError }}
+ } + +
+
+
+ +
+ +
+
+ @for (opt of ['ALL', 'UNCLAIMED', 'CLAIMED', 'FORFEITED']; track opt) { + + } +
+ +
+ + + + + + + + + + + + + + + @if (locksLoading) { + + + + } @else if (locksLoaded && locks.length === 0) { + + + + } @else { + @for (l of locks; track l.lockId) { + + + + + + + + + + + @if (expandedClaimLockId === l.lockId) { + + + + } + } + } + +
ClientSavings AccountAmountLocked DateRelease DueStatusApplied Loan
Loading…
No dividend locks match this filter.
{{ l.clientName }} + {{ l.savingsAccountNo }} + {{ l.amount | number: '1.2-2' }}{{ l.lockedDate }}{{ l.releaseDueDate }} + {{ l.status | titlecase }} + {{ l.appliedLoanAccountNo || '—' }} + @if (l.status === 'UNCLAIMED') { + + } +
+
+
+ Unclaimed amount: {{ l.amount | number: '1.2-2' }} — split it below (loan, savings, cash) +
+ @if (claimOverdueLoans[l.lockId] && claimOverdueLoans[l.lockId].length > 0) { + + + } @else { +
No overdue loan for this client.
+ } + + @if (!l.savingsAccountId && claimSavingsAmount[l.lockId] > 0) { + + } + +
+ Total: {{ claimTotal(l) | number: '1.2-2' }} / {{ l.amount | number: '1.2-2' }} +
+ @if (claimError) { +
{{ claimError }}
+ } + +
+
+
+ +
+ +
+ +
+ + + + + + + + + + + + + + + @if (claimRequestsLoading) { + + + + } @else if (claimRequestsLoaded && claimRequests.length === 0) { + + + + } @else { + @for (req of claimRequests; track req.id) { + + + + + + + + + + + @if (expandedRejectClaimId === req.id) { + + + + } + } + } + +
ClientSavings AccountTo LoanTo SavingsCashUnclaimed BalanceClient Loan Status
Loading…
No dividend claim requests pending review.
{{ req.client_name }} + {{ req.savings_account_no }} + + {{ + req.loan_apply_amount > 0 + ? (req.loan_apply_amount | number: '1.2-2') + ' (' + req.loan_account_no + ')' + : '—' + }} + {{ req.savings_apply_amount > 0 ? (req.savings_apply_amount | number: '1.2-2') : '—' }}{{ req.cash_apply_amount > 0 ? (req.cash_apply_amount | number: '1.2-2') : '—' }}{{ req.locked_amount | number: '1.2-2' }} + @if (req.has_overdue_loan) { + Has Overdue EMI + } @else { + No Overdue Loan + } + + + + @if (claimActionError[req.id]) { +
{{ claimActionError[req.id] }}
+ } +
+
+ + +
+
+
diff --git a/src/app/clerk/dividend-dashboard/dividend-dashboard.component.scss b/src/app/clerk/dividend-dashboard/dividend-dashboard.component.scss new file mode 100644 index 000000000..e2a2e511a --- /dev/null +++ b/src/app/clerk/dividend-dashboard/dividend-dashboard.component.scss @@ -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; + } +} diff --git a/src/app/clerk/dividend-dashboard/dividend-dashboard.component.ts b/src/app/clerk/dividend-dashboard/dividend-dashboard.component.ts new file mode 100644 index 000000000..df1540926 --- /dev/null +++ b/src/app/clerk/dividend-dashboard/dividend-dashboard.component.ts @@ -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 = {}; + applyAmount: Record = {}; + 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 = {}; + claimLoanId: Record = {}; + claimLoanAmount: Record = {}; + claimSavingsAmount: Record = {}; + claimSavingsAccountOverride: Record = {}; + claimCashAmount: Record = {}; + claimSubmitting = false; + claimError: string | null = null; + + claimRequests: any[] = []; + claimRequestsLoading = false; + claimRequestsLoaded = false; + claimActioningId: number | null = null; + claimActionError: Record = {}; + expandedRejectClaimId: number | null = null; + rejectReason: Record = {}; + + 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.'; + } + }); + } +} diff --git a/src/app/core/shell/shell.component.html b/src/app/core/shell/shell.component.html index a64914b75..5b9a7603f 100644 --- a/src/app/core/shell/shell.component.html +++ b/src/app/core/shell/shell.component.html @@ -1235,6 +1235,13 @@ Share Member Details + +
+
Dividend Management
+ +
diff --git a/src/app/products/share-products/create-dividend/create-dividend.component.html b/src/app/products/share-products/create-dividend/create-dividend.component.html index 7870ed973..44d276f07 100644 --- a/src/app/products/share-products/create-dividend/create-dividend.component.html +++ b/src/app/products/share-products/create-dividend/create-dividend.component.html @@ -51,9 +51,43 @@ } + @if (overlapError) { +

{{ overlapError }}

+ } + + @if (loadingProfit) { +

+ {{ 'labels.inputs.Fetching bank profit for this period' | translate }}... +

+ } @else if (profitLookupError) { +

+ {{ 'labels.inputs.Could not fetch bank profit for this period' | translate }}. +

+ } @else if (netProfit !== null) { +

+ {{ 'labels.inputs.Bank Profit for this Period' | translate }}: + {{ netProfit | number: '1.2-2' }} +

+ } + - {{ 'labels.inputs.Dividend Amount' | translate }} - + {{ 'labels.inputs.Dividend Percentage' | translate }} (%) + + @if (createDividendForm.controls.dividendPercentage.hasError('required')) { + + {{ 'labels.inputs.Dividend Percentage' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + } + + + + {{ 'labels.inputs.Dividend Amount' | translate }} ({{ + 'labels.inputs.auto-computed' | translate + }}) + @if (createDividendForm.controls.dividendAmount.hasError('required')) { {{ 'labels.inputs.Amount' | translate }} {{ 'labels.commons.is' | translate }} @@ -68,7 +102,7 @@ - diff --git a/src/app/products/share-products/create-dividend/create-dividend.component.scss b/src/app/products/share-products/create-dividend/create-dividend.component.scss index 7c3f1555b..3a6edab91 100644 --- a/src/app/products/share-products/create-dividend/create-dividend.component.scss +++ b/src/app/products/share-products/create-dividend/create-dividend.component.scss @@ -9,3 +9,17 @@ .container { max-width: 37rem; } + +.dividend-profit-note { + margin: 0 0 12px; + padding: 8px 12px; + border-radius: 6px; + background: #eafaf0; + color: #1a7f37; + font-size: 13px; + + &--error { + background: #fdecea; + color: #b42318; + } +} diff --git a/src/app/products/share-products/create-dividend/create-dividend.component.ts b/src/app/products/share-products/create-dividend/create-dividend.component.ts index 922e63a09..59cb7dbaf 100644 --- a/src/app/products/share-products/create-dividend/create-dividend.component.ts +++ b/src/app/products/share-products/create-dividend/create-dividend.component.ts @@ -15,6 +15,7 @@ import { Dates } from 'app/core/utils/dates'; /** Custom Services. */ import { ProductsService } from 'app/products/products.service'; import { SettingsService } from 'app/settings/settings.service'; +import { AccountingService } from 'app/accounting/accounting.service'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; /** @@ -33,6 +34,7 @@ export class CreateDividendComponent implements OnInit { private route = inject(ActivatedRoute); private dateUtils = inject(Dates); private productService = inject(ProductsService); + private accountingService = inject(AccountingService); private router = inject(Router); private settingsService = inject(SettingsService); @@ -45,6 +47,18 @@ export class CreateDividendComponent implements OnInit { /** Maximum Date allowed. */ maxDate = new Date(); + /** Real bank profit for the selected period (fetched from posted GL journal entries). */ + netProfit: number | null = null; + /** True while the profit figure for the selected period is being fetched. */ + loadingProfit = false; + /** Set if the profit lookup fails, so the officer knows the amount below isn't auto-computed. */ + profitLookupError = false; + + /** Every dividend already declared for this product, used to block overlapping periods. */ + private existingDividendPeriods: { start: Date; end: Date }[] = []; + /** Set when the currently selected period overlaps an existing declaration for this product. */ + overlapError: string | null = null; + /** * Get Share Product data from `Resolver`. * @param {FormBuilder} formBuilder Form Builder. @@ -63,6 +77,64 @@ export class CreateDividendComponent implements OnInit { ngOnInit() { this.maxDate = this.settingsService.businessDate; this.setDividendForm(); + this.loadExistingDividendPeriods(); + + const onPeriodChange = () => { + this.fetchProfitForSelectedPeriod(); + this.checkOverlap(); + }; + this.createDividendForm.get('dividendPeriodStartDate').valueChanges.subscribe(onPeriodChange); + this.createDividendForm.get('dividendPeriodEndDate').valueChanges.subscribe(onPeriodChange); + this.createDividendForm.get('dividendPercentage').valueChanges.subscribe(() => this.recomputeDividendAmount()); + } + + /** Parses a Fineract date field, which may arrive as `[yyyy, MM, dd]` or an ISO string. */ + private parseFineractDate(value: any): Date | null { + if (!value) { + return null; + } + if (Array.isArray(value)) { + const [ + year, + month, + day + ] = value; + return new Date(year, month - 1, day); + } + const parsed = new Date(value); + return isNaN(parsed.getTime()) ? null : parsed; + } + + /** + * Loads every dividend already declared for this product (any status -- this product has no + * "rejected" state, see backend `ShareProductDividendStatusType`) so a new period that overlaps + * one of them can be flagged before the officer even submits. + */ + private loadExistingDividendPeriods(): void { + this.productService.getDividends(this.shareProductData.id).subscribe((response: any) => { + const items = response?.pageItems || response || []; + this.existingDividendPeriods = items + .map((item: any) => ({ + start: this.parseFineractDate(item.dividendPeriodStartDate), + end: this.parseFineractDate(item.dividendPeriodEndDate) + })) + .filter((period: any) => period.start && period.end); + this.checkOverlap(); + }); + } + + /** Blocks a new period that overlaps any existing declaration for this product. */ + checkOverlap(): void { + const start: Date = this.createDividendForm.get('dividendPeriodStartDate').value; + const end: Date = this.createDividendForm.get('dividendPeriodEndDate').value; + if (!(start instanceof Date) || !(end instanceof Date)) { + this.overlapError = null; + return; + } + const overlapping = this.existingDividendPeriods.find((period) => period.start <= end && period.end >= start); + this.overlapError = overlapping + ? `This product already has a dividend declared from ${overlapping.start.toLocaleDateString()} to ${overlapping.end.toLocaleDateString()}. Choose a period that doesn't overlap.` + : null; } /** @@ -78,6 +150,14 @@ export class CreateDividendComponent implements OnInit { '', Validators.required ], + dividendPercentage: [ + '', + [ + Validators.required, + Validators.min(0), + Validators.max(100) + ] + ], dividendAmount: [ '', Validators.required @@ -85,10 +165,56 @@ export class CreateDividendComponent implements OnInit { }); } + /** yyyy-MM-dd, independent of the tenant's display date format — this is what the backend expects. */ + private toIsoDate(date: Date): string { + const pad = (n: number) => String(n).padStart(2, '0'); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; + } + + /** + * Looks up the bank's real net profit for the selected dividend period, once both dates are + * set, so the officer can base the dividend on actual profit instead of guessing an amount. + */ + fetchProfitForSelectedPeriod(): void { + const start: Date = this.createDividendForm.get('dividendPeriodStartDate').value; + const end: Date = this.createDividendForm.get('dividendPeriodEndDate').value; + if (!(start instanceof Date) || !(end instanceof Date)) { + return; + } + this.loadingProfit = true; + this.profitLookupError = false; + this.accountingService.getProfitAndLoss(this.toIsoDate(start), this.toIsoDate(end)).subscribe({ + next: (response: any) => { + this.netProfit = Number(response.netProfit) || 0; + this.loadingProfit = false; + this.recomputeDividendAmount(); + }, + error: () => { + this.netProfit = null; + this.loadingProfit = false; + this.profitLookupError = true; + } + }); + } + + /** Dividend Amount = Net Profit x Dividend Percentage / 100 — no manual entry needed. */ + recomputeDividendAmount(): void { + const percentage = Number(this.createDividendForm.get('dividendPercentage').value); + if (this.netProfit === null || !percentage || percentage <= 0) { + this.createDividendForm.get('dividendAmount').setValue(''); + return; + } + const amount = (this.netProfit * percentage) / 100; + this.createDividendForm.get('dividendAmount').setValue(Math.round(amount * 100) / 100); + } + /** * Submits Create Dividend form. */ submit() { + if (this.overlapError) { + return; + } const createDividendFormData = this.createDividendForm.value; const locale = this.settingsService.language.code; const dateFormat = this.settingsService.dateFormat; @@ -105,6 +231,9 @@ export class CreateDividendComponent implements OnInit { dateFormat, locale }; + // dividendPercentage only drives the auto-computed dividendAmount above — Fineract's + // create-dividend command doesn't recognise it. + delete data.dividendPercentage; this.productService.createDividend(this.shareProductData.id, data).subscribe((response: any) => { this.router.navigate(['../'], { relativeTo: this.route }); }); diff --git a/src/app/products/share-products/dividends-share-product/dividends.component.html b/src/app/products/share-products/dividends-share-product/dividends.component.html index 22d706766..932587f6a 100644 --- a/src/app/products/share-products/dividends-share-product/dividends.component.html +++ b/src/app/products/share-products/dividends-share-product/dividends.component.html @@ -14,6 +14,34 @@
+
+
{{ 'labels.inputs.Total Dividend Paid' | translate }}
+
+ + {{ 'labels.inputs.From Date' | translate }} + + + + {{ 'labels.inputs.To Date' | translate }} + + + @if (loadingDividendPaidReport) { + {{ 'labels.inputs.Loading' | translate }}… + } @else if (dividendPaidReport) { + {{ dividendPaidReport.totalPaid | number: '1.2-2' }} + + ({{ dividendPaidReport.payoutCount }} {{ 'labels.inputs.payouts' | translate }}) + + @if (dividendPaidReport.glAccountConfigured === false) { + + {{ 'labels.inputs.Dividend Payable GL account not mapped yet' | translate }} (Accounting → Financial + Activity Mappings) + + } + } +
+
+
{{ 'labels.inputs.Filter' | translate }} diff --git a/src/app/products/share-products/dividends-share-product/dividends.component.scss b/src/app/products/share-products/dividends-share-product/dividends.component.scss index 567dcb2f8..fac39b86f 100644 --- a/src/app/products/share-products/dividends-share-product/dividends.component.scss +++ b/src/app/products/share-products/dividends-share-product/dividends.component.scss @@ -13,3 +13,41 @@ table { cursor: pointer; } } + +.dpd-summary-card { + background: #eafaf0; + border: 1px solid rgb(26 127 55 / 25%); + border-radius: 10px; + padding: 14px 18px; + margin-bottom: 16px; + + &__header { + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.02em; + color: #1a7f37; + margin-bottom: 8px; + } + + &__value { + font-size: 22px; + font-weight: 700; + color: #1a7f37; + } + + &__meta { + font-size: 13px; + color: #475569; + } + + &__warn { + font-size: 12.5px; + color: #b42318; + font-weight: 600; + } + + mat-form-field { + width: 160px; + } +} diff --git a/src/app/products/share-products/dividends-share-product/dividends.components.ts b/src/app/products/share-products/dividends-share-product/dividends.components.ts index acc4fa578..c8757097c 100644 --- a/src/app/products/share-products/dividends-share-product/dividends.components.ts +++ b/src/app/products/share-products/dividends-share-product/dividends.components.ts @@ -31,6 +31,9 @@ import { MatFormField, MatLabel } from '@angular/material/form-field'; import { MatInput } from '@angular/material/input'; import { TranslatePipe as NgxTranslatePipe } from '@ngx-translate/core'; import { DateFormatPipe } from '../../../pipes/date-format.pipe'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { AccountingService } from '../../../accounting/accounting.service'; /** * Dividends component. @@ -40,6 +43,8 @@ import { DateFormatPipe } from '../../../pipes/date-format.pipe'; templateUrl: './dividends.component.html', styleUrls: ['./dividends.component.scss'], imports: [ + CommonModule, + FormsModule, HasPermissionDirective, MatButton, RouterLink, @@ -67,6 +72,13 @@ import { DateFormatPipe } from '../../../pipes/date-format.pipe'; export class ShareProductsDividendsComponent implements OnInit { private route = inject(ActivatedRoute); private router = inject(Router); + private accountingService = inject(AccountingService); + + /** "Total Dividend Paid" report state — derived from the Dividend Payable GL ledger. */ + dividendPaidFromDate: string; + dividendPaidToDate: string; + dividendPaidReport: any = null; + loadingDividendPaidReport = false; /** Dividends data. */ dividendData: any; @@ -101,6 +113,39 @@ export class ShareProductsDividendsComponent implements OnInit { */ ngOnInit() { this.setDividends(); + + const today = new Date(); + const threeYearsAgo = new Date(today); + threeYearsAgo.setFullYear(threeYearsAgo.getFullYear() - 3); + this.dividendPaidToDate = this.toIsoDate(today); + this.dividendPaidFromDate = this.toIsoDate(threeYearsAgo); + this.loadDividendPaidReport(); + } + + private toIsoDate(date: Date): string { + const pad = (n: number) => String(n).padStart(2, '0'); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; + } + + /** + * "Total Dividend Paid" for the selected period — a report over the existing Dividend Payable + * GL account's ledger, not a separate account (real-world CBS practice). + */ + loadDividendPaidReport() { + if (!this.dividendPaidFromDate || !this.dividendPaidToDate) { + return; + } + this.loadingDividendPaidReport = true; + this.accountingService.getDividendPaidReport(this.dividendPaidFromDate, this.dividendPaidToDate).subscribe({ + next: (response: any) => { + this.dividendPaidReport = response; + this.loadingDividendPaidReport = false; + }, + error: () => { + this.dividendPaidReport = null; + this.loadingDividendPaidReport = false; + } + }); } /** diff --git a/src/app/products/share-products/view-dividend/view-dividend.component.html b/src/app/products/share-products/view-dividend/view-dividend.component.html index 9a97363b9..f055930f0 100644 --- a/src/app/products/share-products/view-dividend/view-dividend.component.html +++ b/src/app/products/share-products/view-dividend/view-dividend.component.html @@ -6,26 +6,69 @@ file, You can obtain one at http://mozilla.org/MPL/2.0/. --> -@if (!isdividendPosted) { -
- -
-} +
+
+
+
+ + {{ 'labels.inputs.Dividend Approval' | translate }} — + {{ 'labels.inputs.Per-Member Breakdown' | translate }} +
+
+ @if (!isdividendPosted) { + + } + @if (embedded) { + + } +
+
-
-
- - {{ 'labels.inputs.Filter' | translate }} - - - -
+
+
+ {{ 'labels.inputs.Total Amount' | translate }} + {{ totalAmount | number: '1.2-2' }} +
+
+ {{ 'labels.inputs.Members' | translate }} + {{ dividendData.pageItems.length }} +
+
+ {{ 'labels.inputs.Status' | translate }} + + {{ + isdividendPosted ? ('labels.inputs.Approved' | translate) : ('labels.inputs.Awaiting Approval' | translate) + }} + +
+
-
- +
+ +
+ +
@@ -33,17 +76,23 @@ - + - + - + diff --git a/src/app/products/share-products/view-dividend/view-dividend.component.scss b/src/app/products/share-products/view-dividend/view-dividend.component.scss index 4561ef8a6..35594e67e 100644 --- a/src/app/products/share-products/view-dividend/view-dividend.component.scss +++ b/src/app/products/share-products/view-dividend/view-dividend.component.scss @@ -6,6 +6,222 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +$primary: #2563eb; +$surface: #fff; +$surface-alt: #f8fafc; +$border: #e2e8f0; +$text-primary: #1e293b; +$text-secondary: #64748b; + +.vd-container { + max-width: 100%; +} + +.pg-card { + background: $surface; + border: 1px solid $border; + border-radius: 12px; + box-shadow: + 0 1px 3px rgb(0 0 0 / 7%), + 0 1px 2px rgb(0 0 0 / 4%); + padding: 20px 24px; +} + +.vd-header { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 12px; + margin-bottom: 18px; + + &__title { + display: flex; + align-items: center; + gap: 10px; + font-size: 15px; + font-weight: 700; + color: $text-primary; + } + + &__icon { + color: $primary; + font-size: 16px; + } +} + +.vd-header__actions { + display: flex; + align-items: center; + gap: 4px; +} + +.vd-approve-btn { + font-weight: 600; +} + +.vd-close-btn { + width: 34px; + height: 34px; + border-radius: 50%; + border: 1px solid $border; + background: $surface; + color: $text-secondary; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + font-size: 13px; + padding: 0; + transition: + background 0.15s ease, + border-color 0.15s ease, + color 0.15s ease; + + &:hover { + background: #fdecea; + border-color: #f3a79a; + color: #b42318; + } + + &:focus-visible { + outline: 2px solid $primary; + outline-offset: 2px; + } +} + +.vd-summary { + display: flex; + gap: 28px; + flex-wrap: wrap; + background: $surface-alt; + border: 1px solid $border; + border-radius: 10px; + padding: 14px 20px; + margin-bottom: 18px; + + &__stat { + display: flex; + flex-direction: column; + gap: 4px; + } + + &__label { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.03em; + color: $text-secondary; + } + + &__value { + font-size: 18px; + font-weight: 700; + color: $text-primary; + } +} + +.vd-filter { + margin-bottom: 16px; +} + +.vd-search { + display: flex; + align-items: center; + gap: 10px; + max-width: 320px; + background: $surface; + border: 1px solid $border; + border-radius: 999px; + padding: 9px 16px; + transition: + border-color 0.15s ease, + box-shadow 0.15s ease; + + &:focus-within { + border-color: $primary; + box-shadow: 0 0 0 3px rgb(37 99 235 / 12%); + } + + &__icon { + color: $text-secondary; + font-size: 12px; + flex-shrink: 0; + } + + &__input { + border: none; + outline: none; + background: transparent; + font-size: 13.5px; + color: $text-primary; + flex: 1; + min-width: 0; + + &::placeholder { + color: #94a3b8; + } + } +} + +.vd-badge { + display: inline-flex; + align-items: center; + width: fit-content; + padding: 4px 12px; + border-radius: 999px; + font-size: 12px; + font-weight: 700; + + &--pending { + background: #fffbeb; + color: #92400e; + } + + &--approved { + background: #eff6ff; + color: #1d4ed8; + } + + &--posted { + background: #eafaf0; + color: #1a7f37; + } +} + +.pg-chip { + display: inline-flex; + align-items: center; + padding: 3px 10px; + border-radius: 999px; + font-size: 12px; + font-weight: 600; + background: #f1f5f9; + color: $text-primary; +} + +.vd-amount--zero { + color: $text-secondary; +} + table { width: 100%; } + +.pg-table { + th { + font-size: 11.5px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.02em; + color: $text-secondary; + } + + td { + font-size: 13.5px; + } + + .num { + text-align: right; + } +} diff --git a/src/app/products/share-products/view-dividend/view-dividend.component.ts b/src/app/products/share-products/view-dividend/view-dividend.component.ts index 19ca4808b..7d95ff136 100644 --- a/src/app/products/share-products/view-dividend/view-dividend.component.ts +++ b/src/app/products/share-products/view-dividend/view-dividend.component.ts @@ -6,7 +6,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { Component, OnInit, ViewChild, inject } from '@angular/core'; +import { Component, OnInit, Input, Output, EventEmitter, ViewChild, inject } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { MatPaginator } from '@angular/material/paginator'; import { MatSort, MatSortHeader } from '@angular/material/sort'; @@ -54,9 +54,18 @@ export class ViewDividendComponent implements OnInit { private productsService = inject(ProductsService); private router = inject(Router); + /** Set to render standalone (e.g. inside a popup from the Dividend Requests tab) instead of + * resolving productId/dividendId/data from the Angular route. */ + @Input() embedded = false; + @Input() overrideProductId: string | number | null = null; + @Input() overrideDividendId: string | number | null = null; + @Output() closeRequested = new EventEmitter(); + @Output() actionCompleted = new EventEmitter(); + dividendData: any; status: any; isdividendPosted = false; + totalAmount = 0; @ViewChild(MatPaginator, { static: true }) paginator: MatPaginator; @ViewChild(MatSort, { static: true }) sort: MatSort; @@ -68,32 +77,61 @@ export class ViewDividendComponent implements OnInit { ]; dataSource: MatTableDataSource; - constructor() { - this.route.data.subscribe((data: { dividendData: any }) => { - this.dividendData = data.dividendData; - }); - this.status = this.route.snapshot.queryParams['status']; - if (this.status && (this.status === 'Dividend Approved' || this.status === 'Dividend Posted')) { - this.isdividendPosted = true; + ngOnInit() { + if (this.embedded) { + this.productsService + .getDividendData(this.overrideProductId, this.overrideDividendId) + .subscribe((dividendData: any) => this.setDividendData(dividendData)); + } else { + this.status = this.route.snapshot.queryParams['status']; + if (this.status && (this.status === 'Dividend Approved' || this.status === 'Dividend Posted')) { + this.isdividendPosted = true; + } + this.route.data.subscribe((data: { dividendData: any }) => this.setDividendData(data.dividendData)); } } - ngOnInit() { - this.dataSource = new MatTableDataSource(this.dividendData.pageItems); + private setDividendData(dividendData: any) { + this.dividendData = dividendData; + this.dataSource = new MatTableDataSource(dividendData.pageItems); this.dataSource.paginator = this.paginator; this.dataSource.sort = this.sort; + this.totalAmount = (dividendData.pageItems ?? []).reduce( + (sum: number, item: any) => sum + Number(item.amount ?? 0), + 0 + ); + } + + statusChipClass(status: string): string { + if (status === 'Dividend Posted') { + return 'vd-badge vd-badge--posted'; + } + if (status === 'Dividend Approved') { + return 'vd-badge vd-badge--approved'; + } + return 'vd-badge vd-badge--pending'; } postDividends() { - const shareProductId = this.route.parent.parent.snapshot.paramMap.get('productId'); - const dividendId = this.route.snapshot.paramMap.get('dividendId'); + const shareProductId = this.embedded + ? this.overrideProductId + : this.route.parent.parent.snapshot.paramMap.get('productId'); + const dividendId = this.embedded ? this.overrideDividendId : this.route.snapshot.paramMap.get('dividendId'); this.productsService .approveDividend(shareProductId, dividendId, { productId: shareProductId, dividendId: dividendId }) .subscribe(() => { - this.router.navigate(['../'], { relativeTo: this.route }); + if (this.embedded) { + this.actionCompleted.emit(); + } else { + this.router.navigate(['../'], { relativeTo: this.route }); + } }); } + close(): void { + this.closeRequested.emit(); + } + applyFilter(filterValue: string) { this.dataSource.filter = filterValue.trim().toLowerCase(); } diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/dividend-requests/dividend-requests.component.html b/src/app/tasks/checker-inbox-and-tasks-tabs/dividend-requests/dividend-requests.component.html new file mode 100644 index 000000000..bc22d9639 --- /dev/null +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/dividend-requests/dividend-requests.component.html @@ -0,0 +1,77 @@ + + +
+ +
+ +
+
{{ 'labels.inputs.Client Name' | translate }} {{ dividend.accountData.clientName }}{{ 'labels.inputs.Share Account' | translate }}{{ dividend.accountData.accountNo }} + {{ dividend.accountData.accountNo }} + {{ 'labels.inputs.Dividend Amount' | translate }}{{ dividend.amount }} + {{ dividend.amount | number: '1.2-2' }} + {{ 'labels.inputs.Status' | translate }}{{ dividend.status.value }} + {{ dividend.status.value }} +
+ + + + + + + + + + + + @if (loading) { + + + + } @else if (loaded && rows.length === 0) { + + + + } @else { + @for (row of rows; track row.dividendId) { + + + + + + + + + } + } + +
Share ProductDividend PeriodTotal AmountMembersDeclared On
Loading…
No dividend declarations are waiting for approval.
{{ row.productName }}{{ row.dividendPeriodStartDate }} → {{ row.dividendPeriodEndDate }}{{ row.amount | number: '1.2-2' }}{{ row.memberCount }}{{ row.declaredOn | slice: 0 : 10 }} + + + @if (approveError[row.dividendId]) { +
{{ approveError[row.dividendId] }}
+ } +
+
+ +@if (showViewModal && viewProductId && viewDividendId) { +
+
+ +
+
+} diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/dividend-requests/dividend-requests.component.scss b/src/app/tasks/checker-inbox-and-tasks-tabs/dividend-requests/dividend-requests.component.scss new file mode 100644 index 000000000..75905888b --- /dev/null +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/dividend-requests/dividend-requests.component.scss @@ -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; + } +} diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/dividend-requests/dividend-requests.component.ts b/src/app/tasks/checker-inbox-and-tasks-tabs/dividend-requests/dividend-requests.component.ts new file mode 100644 index 000000000..b7968b4e2 --- /dev/null +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/dividend-requests/dividend-requests.component.ts @@ -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 = {}; + + 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.'; + } + }); + } +} diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/dividend-requests/pending-dividends.service.ts b/src/app/tasks/checker-inbox-and-tasks-tabs/dividend-requests/pending-dividends.service.ts new file mode 100644 index 000000000..0ac0427f1 --- /dev/null +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/dividend-requests/pending-dividends.service.ts @@ -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 { + return this.http.get('/clerk/pending-dividends'); + } +} diff --git a/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html b/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html index 4210bba1d..e47b8257a 100644 --- a/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html +++ b/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html @@ -144,6 +144,15 @@ Share Withdrawal Requests + + + Dividend Requests +