Current account passbook hardcoded "LincPay Co-operative Bank Ltd." and had no branch identification line at all, unlike the savings passbook which pulls the bank name and branch/IFSC/MICR details from the Institution and Branch Profile. Wire up the same OrganizationService lookup so the current account statement shows real bank/branch details too. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
500 lines
16 KiB
TypeScript
500 lines
16 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/.
|
|
*/
|
|
|
|
/** Angular Imports */
|
|
import { Component, OnInit, OnDestroy, inject, ElementRef, ViewChild } from '@angular/core';
|
|
import { ActivatedRoute } from '@angular/router';
|
|
import { UntypedFormBuilder } from '@angular/forms';
|
|
import { CurrencyPipe } from '@angular/common';
|
|
import { Subject, takeUntil, catchError, forkJoin, of } from 'rxjs';
|
|
|
|
/** Custom Services */
|
|
import { SettingsService } from 'app/settings/settings.service';
|
|
import { Dates } from 'app/core/utils/dates';
|
|
import { OrganizationService } from '../../../organization/organization.service';
|
|
import { Currency } from 'app/shared/models/general.model';
|
|
import {
|
|
CurrentAccountTransaction,
|
|
CurrentAccountTransactionType
|
|
} from 'app/current-accounts/models/current-account-transaction.model';
|
|
import { MatIcon } from '@angular/material/icon';
|
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
|
import { DateFormatPipe } from '../../../pipes/date-format.pipe';
|
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
|
|
|
@Component({
|
|
selector: 'mifosx-passbook-tab',
|
|
templateUrl: './passbook-tab.component.html',
|
|
styleUrls: ['./passbook-tab.component.scss'],
|
|
standalone: true,
|
|
imports: [
|
|
...STANDALONE_SHARED_IMPORTS,
|
|
MatIcon,
|
|
FaIconComponent,
|
|
DateFormatPipe,
|
|
CurrencyPipe
|
|
]
|
|
})
|
|
export class PassbookTabComponent implements OnInit, OnDestroy {
|
|
private route = inject(ActivatedRoute);
|
|
private formBuilder = inject(UntypedFormBuilder);
|
|
private settingsService = inject(SettingsService);
|
|
private dateUtils = inject(Dates);
|
|
private organizationService = inject(OrganizationService);
|
|
|
|
private destroy$ = new Subject<void>();
|
|
|
|
@ViewChild('passbookContent') passbookContent: ElementRef;
|
|
|
|
/** Account data */
|
|
accountNo: string = '';
|
|
clientName: string = '';
|
|
productName: string = '';
|
|
currency: Currency | null = null;
|
|
status: string = '';
|
|
interestRate: number = 0;
|
|
accountBalance: number = 0;
|
|
availableBalance: number = 0;
|
|
activatedOnDate: any = null;
|
|
|
|
/** Bank/branch letterhead — real values from Institution Profile + Branch Profile (Organization
|
|
* module) rather than a hardcoded name, so a printed passbook actually identifies which bank and
|
|
* branch issued it, the way a real UCB's core banking system does. Falls back to a generic label
|
|
* if the profile was never filled in, rather than showing a fabricated bank name. */
|
|
bankName = 'Bank';
|
|
bankSubtitle = 'Current Account Statement';
|
|
branchName = '';
|
|
branchAddress = '';
|
|
ifscCode = '';
|
|
micrCode = '';
|
|
|
|
/** Transactions */
|
|
allTransactions: CurrentAccountTransaction[] = [];
|
|
filteredTransactions: CurrentAccountTransaction[] = [];
|
|
|
|
/** Date range form */
|
|
minDate = new Date(2000, 0, 1);
|
|
maxDate = new Date();
|
|
dateRangeForm: any;
|
|
isFiltered = false;
|
|
|
|
ngOnInit(): void {
|
|
this.maxDate = this.settingsService.businessDate;
|
|
|
|
this.route.parent.data.pipe(takeUntil(this.destroy$)).subscribe((data: { currentAccountData: any }) => {
|
|
const account = data.currentAccountData;
|
|
this.accountNo = account.accountNo || '';
|
|
this.clientName = account.clientName || account.groupName || '';
|
|
this.productName = account.currentAccountProductName || '';
|
|
this.currency = account.currency || null;
|
|
this.status = account.status?.value || '';
|
|
this.interestRate = account.nominalAnnualInterestRate || 0;
|
|
this.accountBalance = account.summary?.accountBalance || 0;
|
|
this.availableBalance = account.summary?.availableBalance || 0;
|
|
this.activatedOnDate = account.timeline?.activatedOnDate || null;
|
|
this.allTransactions = this.computeRunningBalances(account.transactions || []);
|
|
this.filteredTransactions = [...this.allTransactions];
|
|
});
|
|
|
|
this.dateRangeForm = this.formBuilder.group({
|
|
fromDate: [''],
|
|
toDate: ['']
|
|
});
|
|
|
|
this.loadLetterhead();
|
|
}
|
|
|
|
/** Institution Profile is the bank's own single record; Branch Profile is per-office. There is no
|
|
* office id on the current account data reaching this tab, and this deployment runs a single
|
|
* branch, so the first branch profile on record is the correct one here — a genuinely
|
|
* multi-branch deployment would need the account's own office id threaded through instead. */
|
|
private loadLetterhead(): void {
|
|
forkJoin({
|
|
institution: this.organizationService.getInstitutionProfile().pipe(catchError(() => of(null))),
|
|
branches: this.organizationService.getBranchProfiles().pipe(catchError(() => of([])))
|
|
}).subscribe(({ institution, branches }: any) => {
|
|
if (institution) {
|
|
this.bankName = institution.passbookHeaderName || institution.bankName || institution.legalName || 'Bank';
|
|
this.bankSubtitle = institution.licenseNumber
|
|
? `Lic. No. ${institution.licenseNumber}`
|
|
: 'Current Account Statement';
|
|
}
|
|
const branch = Array.isArray(branches) ? branches[0] : null;
|
|
if (branch) {
|
|
this.branchName = branch.officeName || '';
|
|
this.branchAddress = branch.address || '';
|
|
this.ifscCode = branch.ifscCode || '';
|
|
this.micrCode = branch.micrCode || '';
|
|
}
|
|
});
|
|
}
|
|
|
|
private computeRunningBalances(transactions: CurrentAccountTransaction[]): CurrentAccountTransaction[] {
|
|
let balance = 0;
|
|
return transactions.map((txn) => {
|
|
if (txn.runningBalance != null) {
|
|
balance = txn.runningBalance;
|
|
} else {
|
|
const amount = txn.amount || 0;
|
|
if (this.isDebit(txn.transactionType)) {
|
|
balance = balance - amount;
|
|
} else {
|
|
balance = balance + amount;
|
|
}
|
|
txn = { ...txn, runningBalance: balance };
|
|
}
|
|
return txn;
|
|
});
|
|
}
|
|
|
|
isDebit(transactionType: CurrentAccountTransactionType): boolean {
|
|
return (
|
|
transactionType.withdrawal === true ||
|
|
transactionType.feeDeduction === true ||
|
|
transactionType.overdraftInterest === true ||
|
|
transactionType.withholdTax === true
|
|
);
|
|
}
|
|
|
|
filterTransactions(): void {
|
|
const fromDate = this.dateRangeForm.value.fromDate;
|
|
const toDate = this.dateRangeForm.value.toDate;
|
|
|
|
this.filteredTransactions = this.allTransactions.filter((txn: CurrentAccountTransaction) => {
|
|
const txnDate = this.getTransactionDateAsTime(txn.date);
|
|
if (fromDate && new Date(fromDate).getTime() > txnDate) return false;
|
|
if (toDate) {
|
|
const toEnd = new Date(toDate);
|
|
toEnd.setHours(23, 59, 59, 999);
|
|
if (toEnd.getTime() < txnDate) return false;
|
|
}
|
|
return true;
|
|
});
|
|
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 source = this.passbookContent?.nativeElement as HTMLElement;
|
|
if (!source) return;
|
|
|
|
/** Work on a copy so the on-screen statement is untouched */
|
|
const clone = source.cloneNode(true) as HTMLElement;
|
|
clone.querySelectorAll('.no-print').forEach((el: Element) => el.remove());
|
|
|
|
/** Material icon fonts are not available in the popup, so swap them for inline SVG */
|
|
clone.querySelectorAll('mat-icon, .mat-icon').forEach((el: Element) => {
|
|
const insideLogo = !!el.closest('.bank-logo-circle');
|
|
if (insideLogo) {
|
|
el.outerHTML = PassbookTabComponent.BANK_LOGO_SVG;
|
|
} else {
|
|
el.remove();
|
|
}
|
|
});
|
|
|
|
const printWindow = window.open('', 'Passbook', 'height=800,width=1100');
|
|
if (!printWindow) return;
|
|
|
|
printWindow.document.write(`
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<title>Current Account Passbook - ${this.accountNo}</title>
|
|
<style>${PassbookTabComponent.PRINT_STYLES}</style>
|
|
</head>
|
|
<body>
|
|
<div class="statement-print-page">${clone.outerHTML}</div>
|
|
<script>
|
|
window.onafterprint = function () { window.close(); };
|
|
window.addEventListener('load', function () { window.focus(); window.print(); });
|
|
</script>
|
|
</body>
|
|
</html>
|
|
`);
|
|
printWindow.document.close();
|
|
printWindow.focus();
|
|
}
|
|
|
|
/** Material "account_balance" glyph, inlined so the print window needs no icon font */
|
|
private static readonly BANK_LOGO_SVG = `
|
|
<svg class="bank-logo-svg" viewBox="0 0 24 24" width="26" height="26" aria-hidden="true">
|
|
<path d="M4 10h3v7H4zM10.5 10h3v7h-3zM2 19h20v3H2zM17 10h3v7h-3zM12 1L2 6v2h20V6z" />
|
|
</svg>`;
|
|
|
|
/**
|
|
* Mirrors passbook-tab.component.scss so the printed statement looks like the
|
|
* on-screen one. Kept in sync manually - component styles are scoped and are
|
|
* not reachable from the popup window.
|
|
*/
|
|
private static readonly PRINT_STYLES = `
|
|
@page { size: A4 portrait; margin: 10mm; }
|
|
|
|
* { margin: 0; padding: 0; box-sizing: border-box; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
|
|
|
body {
|
|
font-family: 'Segoe UI', Arial, sans-serif;
|
|
color: #333;
|
|
background: #fff;
|
|
}
|
|
|
|
.statement-print-page { padding: 0; }
|
|
|
|
.statement-wrapper {
|
|
background: #fff;
|
|
border: 1px solid #d0d7e3;
|
|
border-radius: 6px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
/* Bank header */
|
|
.bank-header {
|
|
background: #1a3a6b;
|
|
padding: 14px 20px;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
border-bottom: 4px solid #c8a951;
|
|
}
|
|
|
|
.bank-logo-area { display: flex; align-items: center; gap: 12px; }
|
|
|
|
.bank-logo-circle {
|
|
width: 44px;
|
|
height: 44px;
|
|
border-radius: 50%;
|
|
background: #c8a951;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.bank-logo-svg { fill: #1a3a6b; display: block; }
|
|
|
|
.bank-name-block .bank-title {
|
|
color: #fff;
|
|
font-size: 16px;
|
|
font-weight: 700;
|
|
letter-spacing: 0.3px;
|
|
}
|
|
|
|
.bank-name-block .bank-subtitle {
|
|
color: #c8a951;
|
|
font-size: 10px;
|
|
font-weight: 500;
|
|
letter-spacing: 1.5px;
|
|
text-transform: uppercase;
|
|
margin-top: 2px;
|
|
}
|
|
|
|
.statement-label { text-align: right; }
|
|
|
|
.statement-label .stmt-label-text {
|
|
color: #c8a951;
|
|
font-size: 12px;
|
|
font-weight: 700;
|
|
letter-spacing: 2px;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.statement-label .stmt-period {
|
|
color: rgba(255, 255, 255, 0.75);
|
|
font-size: 10px;
|
|
margin-top: 3px;
|
|
}
|
|
|
|
/* Branch identification line */
|
|
.branch-line {
|
|
background: #e8edf5;
|
|
border-bottom: 1px solid #d0d7e3;
|
|
padding: 5px 20px;
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 4px 16px;
|
|
font-size: 9.5px;
|
|
color: #4a5a7a;
|
|
}
|
|
|
|
/* Account details band */
|
|
.account-details-band {
|
|
background: #f0f3f9;
|
|
border-bottom: 1px solid #d0d7e3;
|
|
padding: 12px 20px;
|
|
}
|
|
|
|
.account-details-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(3, 1fr);
|
|
gap: 10px 24px;
|
|
}
|
|
|
|
.detail-block .detail-label {
|
|
font-size: 8.5px;
|
|
font-weight: 700;
|
|
color: #7a8aaa;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.8px;
|
|
margin-bottom: 2px;
|
|
}
|
|
|
|
.detail-block .detail-value { font-size: 12px; color: #222; }
|
|
.detail-block .detail-value.bold { font-weight: 600; }
|
|
.detail-block .detail-value.mono { font-family: 'Courier New', monospace; letter-spacing: 1px; }
|
|
|
|
.detail-block .detail-value.status-badge {
|
|
display: inline-block;
|
|
padding: 1px 8px;
|
|
border-radius: 10px;
|
|
font-size: 10px;
|
|
font-weight: 600;
|
|
background: #eee;
|
|
color: #555;
|
|
}
|
|
|
|
.detail-block .detail-value.status-badge.active { background: #d4edda; color: #155724; }
|
|
|
|
/* Balance summary strip */
|
|
.balance-summary-strip {
|
|
display: flex;
|
|
align-items: stretch;
|
|
background: #1a3a6b;
|
|
}
|
|
|
|
.balance-box { flex: 1; padding: 10px 18px; text-align: center; }
|
|
|
|
.balance-box .balance-box-label {
|
|
font-size: 9px;
|
|
font-weight: 600;
|
|
color: rgba(255, 255, 255, 0.65);
|
|
text-transform: uppercase;
|
|
letter-spacing: 1px;
|
|
margin-bottom: 3px;
|
|
}
|
|
|
|
.balance-box .balance-box-value { font-size: 15px; font-weight: 700; color: #c8a951; }
|
|
|
|
.balance-divider { width: 1px; background: rgba(255, 255, 255, 0.15); margin: 6px 0; }
|
|
|
|
/* Transactions table */
|
|
.statement-table { width: 100%; border-collapse: collapse; font-size: 11px; }
|
|
|
|
.statement-table thead { display: table-header-group; }
|
|
.statement-table tfoot { display: table-row-group; }
|
|
.statement-table tr { page-break-inside: avoid; break-inside: avoid; }
|
|
|
|
.statement-table thead tr {
|
|
background: #e8edf5;
|
|
border-top: 1px solid #d0d7e3;
|
|
border-bottom: 2px solid #1a3a6b;
|
|
}
|
|
|
|
.statement-table thead th {
|
|
padding: 8px 9px;
|
|
font-size: 10px;
|
|
font-weight: 700;
|
|
color: #1a3a6b;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.4px;
|
|
white-space: nowrap;
|
|
text-align: left;
|
|
}
|
|
|
|
.statement-table tbody tr { border-bottom: 1px solid #e8ecf3; }
|
|
.statement-table tbody tr:nth-child(even) { background: #f4f7fb; }
|
|
.statement-table tbody tr.reversed-row { opacity: 0.55; text-decoration: line-through; }
|
|
.statement-table tbody td { padding: 6px 9px; vertical-align: middle; color: #333; }
|
|
|
|
.statement-table tfoot .total-row { background: #e8edf5; border-top: 2px solid #1a3a6b; }
|
|
.statement-table tfoot .total-row td { padding: 8px 9px; }
|
|
|
|
/* Column widths */
|
|
.col-sr { width: 34px; }
|
|
.col-date { width: 100px; white-space: nowrap; }
|
|
.col-desc { min-width: 150px; }
|
|
.col-ref { width: 74px; }
|
|
.col-amount { width: 100px; }
|
|
.col-balance { width: 110px; }
|
|
|
|
/* Amount colors */
|
|
.debit-amount { color: #c0392b; font-weight: 500; }
|
|
.credit-amount { color: #1a7a3f; font-weight: 500; }
|
|
.balance-amount { color: #1a3a6b; font-weight: 600; font-family: 'Courier New', monospace; }
|
|
|
|
.total-label {
|
|
font-size: 11px;
|
|
font-weight: 700;
|
|
color: #1a3a6b;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
}
|
|
|
|
.closing-balance {
|
|
font-size: 13px;
|
|
font-weight: 700;
|
|
color: #1a3a6b;
|
|
font-family: 'Courier New', monospace;
|
|
}
|
|
|
|
/* Badges & misc */
|
|
.txn-desc { font-weight: 500; }
|
|
|
|
.reversed-badge {
|
|
display: inline-block;
|
|
margin-left: 6px;
|
|
font-size: 8px;
|
|
font-weight: 700;
|
|
color: #fff;
|
|
background: #e74c3c;
|
|
padding: 1px 5px;
|
|
border-radius: 3px;
|
|
letter-spacing: 0.5px;
|
|
}
|
|
|
|
.text-right { text-align: right; }
|
|
.text-center { text-align: center; }
|
|
.text-muted { color: #999; }
|
|
.mono { font-family: 'Courier New', monospace; font-size: 10px; }
|
|
|
|
/* No data */
|
|
.no-data-block { padding: 40px; text-align: center; color: #aaa; font-size: 13px; }
|
|
|
|
/* Footer */
|
|
.statement-footer {
|
|
background: #f0f3f9;
|
|
border-top: 1px solid #d0d7e3;
|
|
padding: 10px 20px;
|
|
text-align: center;
|
|
}
|
|
|
|
.statement-footer .footer-note {
|
|
font-size: 9px;
|
|
color: #888;
|
|
margin-bottom: 3px;
|
|
font-style: italic;
|
|
}
|
|
|
|
.statement-footer .footer-meta { font-size: 9px; color: #aaa; letter-spacing: 0.3px; }
|
|
`;
|
|
|
|
ngOnDestroy(): void {
|
|
this.destroy$.next();
|
|
this.destroy$.complete();
|
|
}
|
|
}
|