feat(shares): add Share Withdrawal maker-checker workflow with GL settlement
Clerk submits a governed share redemption request (shares, Board Resolution No., Meeting Date, settlement Payment Type); Bank Manager reviews with a live backend-validated checklist (account/GL state, not just submit-time snapshot) before approving, which redeems the shares and settles the linked savings account in one transaction. Adds a Passbook tab to the Share Account view, a reusable search-select control, and a Savings Product "Map Payment Types to Specific Funds" fund-source override (only Loan Products had this before) so settlement can be GL-mapped correctly. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
/** Angular Imports */
|
||||
import { Component, OnInit, ElementRef, ViewChild, inject } from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { UntypedFormBuilder, Validators } from '@angular/forms';
|
||||
import { CurrencyPipe } from '@angular/common';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { DateFormatPipe } from '../../../pipes/date-format.pipe';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
/**
|
||||
* Share Account Passbook — a printable, chronological (oldest-first) ledger of every
|
||||
* Purchase/Redeem entry, mirroring PassbookTabComponent (Savings) but for share holdings:
|
||||
* shares purchased/redeemed instead of amount deposited/withdrawn, plus the Board Resolution
|
||||
* Number / Date of Meeting on redeem rows.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-shares-passbook-tab',
|
||||
templateUrl: './passbook-tab.component.html',
|
||||
styleUrls: ['./passbook-tab.component.scss'],
|
||||
standalone: true,
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
MatIcon,
|
||||
DateFormatPipe,
|
||||
CurrencyPipe
|
||||
]
|
||||
})
|
||||
export class SharesPassbookTabComponent implements OnInit {
|
||||
private route = inject(ActivatedRoute);
|
||||
private formBuilder = inject(UntypedFormBuilder);
|
||||
|
||||
@ViewChild('passbookContent') passbookContent: ElementRef;
|
||||
|
||||
/** Account data */
|
||||
accountNo = '';
|
||||
clientName = '';
|
||||
productName = '';
|
||||
currency: any = null;
|
||||
status = '';
|
||||
activatedOnDate: any = null;
|
||||
resolutionNumber = '';
|
||||
savingsAccountNumber = '';
|
||||
totalApprovedShares = 0;
|
||||
currentMarketPrice = 0;
|
||||
|
||||
/** Transactions */
|
||||
allTransactions: any[] = [];
|
||||
filteredTransactions: any[] = [];
|
||||
|
||||
/** Date range form */
|
||||
minDate = new Date(2000, 0, 1);
|
||||
maxDate = new Date();
|
||||
dateRangeForm: any;
|
||||
isFiltered = false;
|
||||
|
||||
get currentValue(): number {
|
||||
return this.totalApprovedShares * this.currentMarketPrice;
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.route.parent.data.subscribe((data: { sharesAccountData: any }) => {
|
||||
const account = data.sharesAccountData;
|
||||
this.accountNo = account.accountNo || '';
|
||||
this.clientName = account.clientName || '';
|
||||
this.productName = account.productName || '';
|
||||
this.currency = account.currency || null;
|
||||
this.status = account.status?.value || '';
|
||||
this.activatedOnDate = account.timeline?.activatedDate || null;
|
||||
this.resolutionNumber = account.resolutionNumber || '';
|
||||
this.savingsAccountNumber = account.savingsAccountNumber || '';
|
||||
this.totalApprovedShares = account.summary?.totalApprovedShares || 0;
|
||||
this.currentMarketPrice = account.currentMarketPrice || 0;
|
||||
|
||||
const transactions = (account.purchasedShares || []).filter(
|
||||
(transaction: any) => transaction.type.value !== 'Charge Payment'
|
||||
);
|
||||
this.allTransactions = this.recalculateRunningBalances(this.sortAscending(transactions));
|
||||
this.filteredTransactions = [...this.allTransactions];
|
||||
});
|
||||
|
||||
this.dateRangeForm = this.formBuilder.group({
|
||||
fromDate: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
toDate: [
|
||||
'',
|
||||
Validators.required
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
isRedeem(transaction: any): boolean {
|
||||
return transaction.type?.value === 'Redeem';
|
||||
}
|
||||
|
||||
filterTransactions(): void {
|
||||
const fromDate = this.dateRangeForm.value.fromDate;
|
||||
const toDate = this.dateRangeForm.value.toDate;
|
||||
|
||||
if (!fromDate || !toDate) return;
|
||||
|
||||
const from = new Date(fromDate).getTime();
|
||||
const to = new Date(toDate).getTime();
|
||||
|
||||
this.filteredTransactions = this.allTransactions.filter((txn: any) => {
|
||||
const txnDate = this.getTransactionDateAsTime(txn.purchasedDate);
|
||||
return txnDate >= from && txnDate <= to;
|
||||
});
|
||||
this.isFiltered = true;
|
||||
}
|
||||
|
||||
clearFilter(): void {
|
||||
this.dateRangeForm.reset();
|
||||
this.filteredTransactions = [...this.allTransactions];
|
||||
this.isFiltered = false;
|
||||
}
|
||||
|
||||
private getTransactionDateAsTime(date: any): number {
|
||||
if (Array.isArray(date) && date.length >= 3) {
|
||||
return new Date(date[0], date[1] - 1, date[2]).getTime();
|
||||
}
|
||||
return new Date(date).getTime();
|
||||
}
|
||||
|
||||
printPassbook(): void {
|
||||
const content = this.passbookContent?.nativeElement?.innerHTML;
|
||||
if (!content) return;
|
||||
|
||||
const printWindow = window.open('', 'SharePassbook', 'height=700,width=900');
|
||||
printWindow.document.write(`
|
||||
<html>
|
||||
<head>
|
||||
<title>Share Account Passbook - ${this.accountNo}</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: 'Segoe UI', Arial, sans-serif; padding: 20px; color: #333; }
|
||||
.passbook-header { text-align: center; margin-bottom: 20px; border-bottom: 2px solid #1a3a6b; padding-bottom: 12px; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 11px; }
|
||||
th { background: #1a3a6b; color: #fff; padding: 6px 8px; text-align: left; font-weight: 600; }
|
||||
td { padding: 5px 8px; border-bottom: 1px solid #e0e0e0; }
|
||||
tr:nth-child(even) { background: #fafafa; }
|
||||
.text-right { text-align: right; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
${content}
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
printWindow.document.close();
|
||||
printWindow.focus();
|
||||
printWindow.print();
|
||||
printWindow.close();
|
||||
}
|
||||
|
||||
private recalculateRunningBalances(transactions: any[]): any[] {
|
||||
let balance = 0;
|
||||
return transactions.map((txn) => {
|
||||
const shares = Number(txn.numberOfShares) || 0;
|
||||
balance += this.isRedeem(txn) ? -shares : shares;
|
||||
return { ...txn, runningBalance: balance };
|
||||
});
|
||||
}
|
||||
|
||||
private sortAscending(transactions: any[]): any[] {
|
||||
return [...transactions].sort((a, b) => {
|
||||
const dateA = this.getTransactionDateAsTime(a.purchasedDate);
|
||||
const dateB = this.getTransactionDateAsTime(b.purchasedDate);
|
||||
if (dateA !== dateB) return dateA - dateB;
|
||||
return (a.id as number) - (b.id as number);
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user