/** * 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'; import { isSettledShareTransaction } from 'app/shares/share-transactions.util'; /** * 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 = ''; resolutionDate: any = null; 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.resolutionDate = account.resolutionDate || null; this.savingsAccountNumber = account.savingsAccountNumber || ''; this.totalApprovedShares = account.summary?.totalApprovedShares || 0; this.currentMarketPrice = account.currentMarketPrice || 0; const transactions = (account.purchasedShares || []).filter(isSettledShareTransaction); 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 source = this.passbookContent?.nativeElement as HTMLElement; if (!source) return; /** Work on a copy so the on-screen passbook 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 = SharesPassbookTabComponent.BANK_LOGO_SVG; } else { el.remove(); } }); const printWindow = window.open('', 'SharePassbook', 'height=800,width=1200'); if (!printWindow) return; printWindow.document.write(` Share Account Passbook - ${this.accountNo}
${clone.outerHTML}
`); printWindow.document.close(); printWindow.focus(); } /** Material "account_balance" glyph, inlined so the print window needs no icon font */ private static readonly BANK_LOGO_SVG = ` `; /** * Mirrors passbook-tab.component.scss so the printed passbook looks like the * on-screen one. Kept in sync manually - component styles are scoped and are * not reachable from the popup window. Landscape, because the share ledger * carries nine columns including the Resolution No. / Meeting column. */ private static readonly PRINT_STYLES = ` @page { size: A4 landscape; margin: 8mm; } * { 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; } /* 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(4, 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; } /* Share 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; } /* Ledger table - no horizontal scroll on paper, the landscape page holds every column */ .statement-table-scroll { width: 100%; overflow: visible; } .statement-table { width: 100%; border-collapse: collapse; font-size: 10.5px; } .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: 9.5px; font-weight: 700; color: #1a3a6b; text-transform: uppercase; letter-spacing: 0.4px; white-space: nowrap; } .statement-table tbody tr { border-bottom: 1px solid #e8ecf3; } .statement-table tbody tr:nth-child(even) { background: #f4f7fb; } .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: 32px; } .col-date { width: 96px; white-space: nowrap; } .col-desc { width: auto; min-width: 140px; } .col-ref { width: 70px; white-space: nowrap; } .col-amount { width: 108px; white-space: nowrap; } .col-balance { width: 96px; white-space: nowrap; } .col-resolution { width: 170px; white-space: nowrap; padding-left: 14px; } /* Share movement 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: 10.5px; font-weight: 700; color: #1a3a6b; text-transform: uppercase; letter-spacing: 0.5px; } .closing-balance { font-size: 12.5px; font-weight: 700; color: #1a3a6b; font-family: 'Courier New', monospace; } /* Misc */ .txn-desc { font-weight: 500; } .text-right { text-align: right; } .text-center { text-align: center; } .text-left { text-align: left; } .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; } `; 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); }); } }