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