298 lines
10 KiB
TypeScript
298 lines
10 KiB
TypeScript
/**
|
|
* 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 { ActivatedRoute, Router } from '@angular/router';
|
|
import { FormControl, ReactiveFormsModule } from '@angular/forms';
|
|
import { NgClass } from '@angular/common';
|
|
import { MatTooltipModule } from '@angular/material/tooltip';
|
|
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
|
import { forkJoin, of } from 'rxjs';
|
|
import { catchError } from 'rxjs/operators';
|
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
|
import { FormatNumberPipe } from 'app/pipes/format-number.pipe';
|
|
import { OrganizationService } from 'app/organization/organization.service';
|
|
|
|
/** What each DDS transaction type is called on screen. */
|
|
const TRANSACTION_LABELS: Record<string, string> = {
|
|
PARTY_TRAN: 'Collection',
|
|
INTEREST_POST: 'Interest',
|
|
OD_INTEREST_POST: 'OD Interest',
|
|
MATURITY_PAYOUT: 'Maturity Payout',
|
|
PREMATURE_PAYOUT: 'Premature Payout',
|
|
PREMATURE_INT_RECOVERY: 'Interest Recovered (Premature)',
|
|
PREMATURE_AGENT_TOP_UP: 'Interest Funded by Agent',
|
|
WITHDRAW_DEPOSITE: 'Transfer (Withdraw Deposite)',
|
|
TRANSFER: 'Transfer'
|
|
};
|
|
|
|
/**
|
|
* One DDS account and everything that has moved on it, as the savings account page shows a savings
|
|
* account: the account's terms and where it stands, then its transactions with the balance after
|
|
* each, narrowed to a day or a range.
|
|
*
|
|
* Read-only. The account is changed from DDS Party Master; its money moves through collections, the
|
|
* cashier and the batch runs.
|
|
*/
|
|
@Component({
|
|
selector: 'mifosx-dds-account-view',
|
|
templateUrl: './dds-account-view.component.html',
|
|
styleUrls: ['./dds-account-view.component.scss'],
|
|
standalone: true,
|
|
imports: [
|
|
...STANDALONE_SHARED_IMPORTS,
|
|
ReactiveFormsModule,
|
|
NgClass,
|
|
FaIconComponent,
|
|
MatTooltipModule,
|
|
MatProgressSpinnerModule,
|
|
FormatNumberPipe
|
|
]
|
|
})
|
|
export class DdsAccountViewComponent implements OnInit {
|
|
private route = inject(ActivatedRoute);
|
|
private router = inject(Router);
|
|
private organizationService = inject(OrganizationService);
|
|
|
|
isLoading = true;
|
|
account: any = null;
|
|
private transactions: any[] = [];
|
|
|
|
/** The page's tabs, as the savings account page has them - only those that apply to DDS. */
|
|
tab: 'general' | 'transactions' | 'passbook' | 'nominees' | 'closure' = 'general';
|
|
|
|
/** How a closed account was closed - read only for closed accounts, for the Closure tab. */
|
|
closure: any = null;
|
|
|
|
/** What each kind of closure is called on screen. */
|
|
readonly closureLabels: Record<string, string> = {
|
|
PREMATURE: 'Premature Close',
|
|
MATURITY_PAYOUT_AND_CLOSE: 'Maturity Payout & Close',
|
|
MATURITY_PAYOUT_THEN_CLOSE: 'Maturity Payout, then Closed',
|
|
MATURITY_AUTO_CLOSE: 'Maturity Auto Close (night job)',
|
|
CLOSED_NIL_BALANCE: 'Closed with nil balance'
|
|
};
|
|
|
|
/** The passbook reads oldest first, as it is written up. */
|
|
get passbookEntries(): any[] {
|
|
return [...this.shownTransactions].reverse();
|
|
}
|
|
|
|
/** The balance after the last entry shown. */
|
|
get passbookClosingBalance(): number {
|
|
const entries = this.passbookEntries;
|
|
return entries.length > 0 ? Number(entries[entries.length - 1].balanceAfterTransaction || 0) : 0;
|
|
}
|
|
|
|
/**
|
|
* Prints the passbook alone. It is copied into a window of its own with only its own styles, so the
|
|
* page's menus, banner and tabs do not come out on the paper.
|
|
*/
|
|
printPassbook(): void {
|
|
const passbook = document.getElementById('dds-passbook');
|
|
const popup = window.open('', '_blank', 'width=900,height=700');
|
|
if (!passbook || !popup) {
|
|
return;
|
|
}
|
|
popup.document.write(`<html><head><title>${this.account?.accountNo || 'DDS'} Passbook</title><style>
|
|
body { font-family: Arial, sans-serif; margin: 24px; color: #111; }
|
|
.dav-pb-head { display: flex; justify-content: space-between; gap: 24px; margin-bottom: 16px; }
|
|
.dav-pb-title { font-size: 20px; font-weight: 700; }
|
|
.dav-pb-sub { color: #555; }
|
|
.dav-pb-grid { display: grid; grid-template-columns: auto auto; gap: 2px 12px; font-size: 13px; }
|
|
.dav-pb-grid span { color: #555; }
|
|
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
|
th, td { border: 1px solid #999; padding: 6px 8px; text-align: left; }
|
|
.dav-right { text-align: right; }
|
|
.dav-pb-note { display: block; font-size: 11px; color: #555; }
|
|
</style></head><body>${passbook.innerHTML}</body></html>`);
|
|
popup.document.close();
|
|
popup.focus();
|
|
popup.print();
|
|
}
|
|
|
|
fromDateControl = new FormControl('');
|
|
toDateControl = new FormControl('');
|
|
|
|
readonly today = new Date().toISOString().split('T')[0];
|
|
|
|
ngOnInit(): void {
|
|
const partyId = this.route.snapshot.paramMap.get('partyId');
|
|
if (!partyId) {
|
|
this.isLoading = false;
|
|
return;
|
|
}
|
|
forkJoin({
|
|
account: this.organizationService.getParty(partyId).pipe(catchError(() => of(null))),
|
|
transactions: this.organizationService.getPartyTransactionsOfParty(partyId).pipe(catchError(() => of([])))
|
|
}).subscribe(({ account, transactions }) => {
|
|
this.account = account;
|
|
if (account?.status === 'CLOSED') {
|
|
this.organizationService.getPartyClosure(partyId).subscribe({
|
|
next: (closure: any) => (this.closure = closure),
|
|
error: () => (this.closure = null)
|
|
});
|
|
}
|
|
// Newest first, and within a day the later entry first - the order a passbook is read back in.
|
|
this.transactions = (Array.isArray(transactions) ? transactions : []).sort(
|
|
(a: any, b: any) =>
|
|
String(b.transactionDate).localeCompare(String(a.transactionDate)) || Number(b.id) - Number(a.id)
|
|
);
|
|
this.isLoading = false;
|
|
});
|
|
}
|
|
|
|
/** The transactions in the chosen range - all of them when no range is set. */
|
|
get shownTransactions(): any[] {
|
|
const from = this.fromDateControl.value;
|
|
const to = this.toDateControl.value;
|
|
return this.transactions.filter((txn: any) => {
|
|
const day = String(txn.transactionDate || '');
|
|
if (from && day < String(from)) {
|
|
return false;
|
|
}
|
|
if (to && day > String(to)) {
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
}
|
|
|
|
get totalCredits(): number {
|
|
return this.sumOf('CREDIT');
|
|
}
|
|
|
|
get totalDebits(): number {
|
|
return this.sumOf('DEBIT');
|
|
}
|
|
|
|
private sumOf(direction: string): number {
|
|
return this.shownTransactions
|
|
.filter((txn: any) => txn.direction === direction)
|
|
.reduce((sum: number, txn: any) => sum + Number(txn.amount || 0), 0);
|
|
}
|
|
|
|
/** Everything of one type ever credited, whatever the date filter says - for the summary cards. */
|
|
totalOf(type: string): number {
|
|
return this.transactions
|
|
.filter((txn: any) => txn.transactionType === type && txn.direction === 'CREDIT')
|
|
.reduce((sum: number, txn: any) => sum + Number(txn.amount || 0), 0);
|
|
}
|
|
|
|
/** Interest credited, less any taken back on an early closure. */
|
|
get interestEarned(): number {
|
|
const recovered = this.transactions
|
|
.filter((txn: any) => txn.transactionType === 'PREMATURE_INT_RECOVERY')
|
|
.reduce((sum: number, txn: any) => sum + Number(txn.amount || 0), 0);
|
|
return (
|
|
this.totalOf('INTEREST_POST') +
|
|
this.totalOf('OD_INTEREST_POST') +
|
|
this.totalOf('PREMATURE_AGENT_TOP_UP') -
|
|
recovered
|
|
);
|
|
}
|
|
|
|
/** Money that has left the account - payouts, withdrawals, transfers out. */
|
|
get totalDebitsAll(): number {
|
|
return this.transactions
|
|
.filter((txn: any) => txn.direction === 'DEBIT' && txn.transactionType !== 'PREMATURE_INT_RECOVERY')
|
|
.reduce((sum: number, txn: any) => sum + Number(txn.amount || 0), 0);
|
|
}
|
|
|
|
get lastTransactionDate(): string | null {
|
|
return this.transactions.length > 0 ? this.transactions[0].transactionDate : null;
|
|
}
|
|
|
|
/** The rows on screen, as a spreadsheet the branch can keep or send on. */
|
|
exportCsv(): void {
|
|
const header = [
|
|
'#',
|
|
'ID',
|
|
'Transaction Date',
|
|
'Transaction Type',
|
|
'Voucher',
|
|
'Description',
|
|
'Account',
|
|
'Debit',
|
|
'Credit',
|
|
'Balance'
|
|
];
|
|
const quote = (value: any) => `"${String(value ?? '').replace(/"/g, '""')}"`;
|
|
const lines = this.shownTransactions.map((txn: any, index: number) =>
|
|
[
|
|
index + 1,
|
|
txn.id,
|
|
txn.transactionDate,
|
|
this.label(txn.transactionType),
|
|
txn.voucherNo,
|
|
txn.description,
|
|
txn.accountNo,
|
|
txn.direction === 'DEBIT' ? txn.amount : '',
|
|
txn.direction === 'CREDIT' ? txn.amount : '',
|
|
txn.balanceAfterTransaction
|
|
]
|
|
.map(quote)
|
|
.join(',')
|
|
);
|
|
const blob = new Blob(
|
|
[[
|
|
header.map(quote).join(','),
|
|
...lines
|
|
].join('\r\n')],
|
|
{ type: 'text/csv;charset=utf-8' }
|
|
);
|
|
const link = document.createElement('a');
|
|
link.href = URL.createObjectURL(blob);
|
|
link.download = `${this.account?.accountNo || 'dds-account'}-transactions.csv`;
|
|
link.click();
|
|
URL.revokeObjectURL(link.href);
|
|
}
|
|
|
|
get hasDateFilters(): boolean {
|
|
return !!(this.fromDateControl.value || this.toDateControl.value);
|
|
}
|
|
|
|
showToday(): void {
|
|
this.fromDateControl.setValue(this.today);
|
|
this.toDateControl.setValue(this.today);
|
|
}
|
|
|
|
clearDateFilters(): void {
|
|
this.fromDateControl.setValue('');
|
|
this.toDateControl.setValue('');
|
|
}
|
|
|
|
label(type: string): string {
|
|
return TRANSACTION_LABELS[type] || String(type || '').replace(/_/g, ' ');
|
|
}
|
|
|
|
statusClass(): string {
|
|
switch (this.account?.status) {
|
|
case 'ACTIVE':
|
|
return 'status--active';
|
|
case 'CLOSED':
|
|
return 'status--closed';
|
|
default:
|
|
return 'status--default';
|
|
}
|
|
}
|
|
|
|
back(): void {
|
|
this.router.navigate(['/accounts/dds-accounts']);
|
|
}
|
|
|
|
openInPartyMaster(): void {
|
|
this.router.navigate([
|
|
'/accounts/dds',
|
|
this.account.id,
|
|
'edit'
|
|
]);
|
|
}
|
|
}
|