fix(cashier,savings): real bank/branch letterhead on statements, print-preview flow, WITHDRAWN status fixes
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s

Account Statement is no longer a jsPDF file that downloads the moment the
dialog opens. It now shows a live, on-screen preview styled the same way
the working Passbook screen already is (bank header, branch line,
account-details band, balance strip, transaction table), with its own
date-range filter, and only prints via the browser's print dialog on an
explicit click -- exactly the same clone-into-a-popup-window mechanism
Passbook already uses, so "Save as PDF" is the reader's own choice rather
than something forced on open.

Both Passbook and the new Statement preview pull the bank name, license
number, and branch name/address/IFSC/MICR from Institution Profile and
Branch Profile instead of a hardcoded bank name -- a printed passbook or
statement now actually identifies which bank and branch issued it. Cheque
Book's own issuance detail screen gets a matching "Print" action, laying
out a one-page issuance-slip PDF from data already on that screen (which
book/series, leaf range, issued-to account, issue date).

Cash Hold and Clearing Hold on the Transaction Entry balance strip were
both hardcoded 0.00 placeholders; they now read the account's real
hold-category breakdown, and a stray "Freeze Amount" placeholder a few
rows up is wired to the working getter that was already sitting right
next to it.

A clerk withdrawal's own terminal status is WITHDRAWN, not APPROVED --
the Day Book screen had the same status-string gap the backend GL report
did: neither its running CR/DR totals nor its status badge recognized a
completed withdrawal, so real money that had gone out never moved the
totals off zero. The Closing Cash Report's GL Entries panel also never
refreshed for a newly picked date once it had already been opened once --
"Load" hid the panel instead of refreshing it, leaving a stale, wrong-date
table under a header that had already relabelled itself correctly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-30 10:38:39 +05:30
co-authored by Claude Sonnet 5
parent fe81d159f1
commit 07a4c8b8e3
11 changed files with 1006 additions and 15 deletions
@@ -84,7 +84,6 @@ export class ClosingCashReportComponent implements OnInit {
}
load(): void {
this.showGl = false;
this.loading = true;
this.service.closingCashReport(this.date).subscribe({
next: (res: any) => {
@@ -106,6 +105,19 @@ export class ClosingCashReportComponent implements OnInit {
this.loaded = true;
}
});
// GL Entries is scoped to `this.date` too, but was never re-fetched here -- changing the date and
// clicking Load left glLoaded=true from whatever date was open before, so a stale, wrong-date GL
// table stayed on screen under a header that had already relabelled itself to the new date. If
// the panel was open, refresh it for the date just loaded; otherwise just invalidate it so the
// next time it's opened it fetches fresh instead of showing leftover rows.
this.glOffset = 0;
this.glLoaded = false;
if (this.showGl) {
this.loadGl();
} else {
this.glRows = [];
}
}
toggleGl(): void {
@@ -200,16 +200,20 @@ export class DayBookComponent implements OnInit {
.reduce((s, r) => s + Number(r.amount ?? 0), 0);
}
/** Only approved money is real money — a pending or rejected voucher must not move the totals. */
/** Only approved money is real money — a pending or rejected voucher must not move the totals.
* The three source registers don't share one terminal status string: deposits and disbursements
* land on APPROVED, but a clerk withdrawal's own finished state is WITHDRAWN — missing it here
* meant a completed withdrawal moved real money out yet the DR total stayed at zero. */
private countsAsMoney(r: DayBookRow): boolean {
const s = (r.status ?? '').toUpperCase();
return s === 'APPROVED' || s === 'COMPLETED';
return s === 'APPROVED' || s === 'COMPLETED' || s === 'WITHDRAWN';
}
statusClass(status: string): string {
switch ((status ?? '').toUpperCase()) {
case 'APPROVED':
case 'COMPLETED':
case 'WITHDRAWN':
return 'db-badge db-badge--ok';
case 'PENDING':
return 'db-badge db-badge--pending';
@@ -804,7 +804,7 @@
</div>
<div class="te-balance-row">
<span class="te-bal-num">4.</span><span class="te-bal-label">Freeze Amount</span>
<span class="te-bal-value">{{ currencySymbol }}0.00</span>
<span class="te-bal-value">{{ currencySymbol }}{{ freezeAmount | number: '1.2-2' }}</span>
</div>
<div class="te-balance-row">
<span class="te-bal-num">5.</span><span class="te-bal-label">Withdrawable Amt.</span>
@@ -865,7 +865,7 @@
</div>
<div class="te-bal-item">
<span class="te-bal-item__label">Cash Hold</span>
<span class="te-bal-item__val">0.00</span>
<span class="te-bal-item__val">{{ cashHold | number: '1.2-2' }}</span>
</div>
<div class="te-bal-item">
<span class="te-bal-item__label">Transfer Hold</span>
@@ -873,7 +873,7 @@
</div>
<div class="te-bal-item">
<span class="te-bal-item__label">Clearing Hold</span>
<span class="te-bal-item__val">0.00</span>
<span class="te-bal-item__val">{{ clearingHold | number: '1.2-2' }}</span>
</div>
<div class="te-bal-item">
<span class="te-bal-item__label">After Trans.</span>
@@ -889,6 +889,11 @@
<span class="te-bal-item__label">Currency</span>
<span class="te-bal-item__val">{{ accountDetails.currency?.code ?? 'INR' }}</span>
</div>
@if (canPrintStatement) {
<button type="button" class="te-btn" (click)="printStatement()">
<mat-icon>print</mat-icon> Statement
</button>
}
</div>
} @else {
<!-- Cashier: all account info in one compact strip -->
@@ -989,7 +994,7 @@
</div>
<div class="te-acs-b">
<span class="te-acs-b-l">Cash Hold</span>
<span class="te-acs-b-v">0.00</span>
<span class="te-acs-b-v">{{ cashHold | number: '1.2-2' }}</span>
</div>
<div class="te-acs-b">
<span class="te-acs-b-l">Tr. Hold</span>
@@ -997,13 +1002,19 @@
</div>
<div class="te-acs-b">
<span class="te-acs-b-l">Clr. Hold</span>
<span class="te-acs-b-v">0.00</span>
<span class="te-acs-b-v">{{ clearingHold | number: '1.2-2' }}</span>
</div>
<div class="te-acs-b te-acs-b--after">
<span class="te-acs-b-l">After Trans.</span>
<span class="te-acs-b-v te-acs-b-v--cr">{{ currencySymbol }}{{ clearBalance | number: '1.2-2' }}</span>
</div>
</div>
@if (canPrintStatement) {
<div class="te-acs-sep"></div>
<button type="button" class="te-btn" (click)="printStatement()">
<mat-icon>print</mat-icon> Statement
</button>
}
<!-- Alerts -->
@if (accountAlerts.length > 0) {
<div class="te-acs-sep"></div>
@@ -6,7 +6,7 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, OnDestroy, inject, Input } from '@angular/core';
import { Component, OnInit, OnDestroy, inject, Input, ElementRef, ViewChild } from '@angular/core';
import { CommonModule } from '@angular/common';
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
import { FormsModule } from '@angular/forms';
@@ -30,6 +30,7 @@ import {
CustomerSearchResult
} from '../../shared/customer-search-dialog/customer-search-dialog.component';
import { SponsorDDService } from '../../sponsor-dd/sponsor-dd.service';
import { OrganizationService } from '../../organization/organization.service';
interface TxnRow {
typ: string;
@@ -62,6 +63,709 @@ interface Denomination {
label: string;
}
// ── Account Statement Dialog ───────────────────────────────────────────────────
//
// Deliberately the same layout and print mechanism as
// savings/savings-account-view/passbook-tab.component.ts (bank-header/branch-line/
// account-details-band/balance-summary-strip/statement-table/statement-footer, cloned into a popup
// window for window.print()) rather than a generated PDF: a UCB's own passbook screen is the
// reference for what a statement should look like, and jsPDF's own layout would inevitably drift
// from it. Nothing downloads on open — the statement is only ever shown as a preview here; printing
// (and "Save as PDF" from the browser's own print dialog, if a file is wanted) is a deliberate,
// separate click.
@Component({
selector: 'mifosx-account-statement-dialog',
template: `
<div class="asd-shell">
<div class="asd-filterbar no-print">
<label class="asd-field">
<span>From</span>
<input type="date" [(ngModel)]="fromDate" [max]="toDate" (change)="applyFilter()" />
</label>
<label class="asd-field">
<span>To</span>
<input type="date" [(ngModel)]="toDate" [min]="fromDate" [max]="today" (change)="applyFilter()" />
</label>
<span class="asd-spacer"></span>
<button type="button" class="asd-btn asd-btn--primary" (click)="printStatement()">🖨 Print</button>
<button type="button" class="asd-btn" (click)="dialogRef.close()">Close</button>
</div>
<div class="asd-scroll">
<div class="statement-wrapper" #statementContent>
<div class="bank-header">
<div class="bank-logo-area">
<div class="bank-logo-circle">
<mat-icon>account_balance</mat-icon>
</div>
<div class="bank-name-block">
<div class="bank-title">{{ bankName }}</div>
<div class="bank-subtitle">{{ bankSubtitle }}</div>
</div>
</div>
<div class="statement-label">
<div class="stmt-label-text">STATEMENT OF ACCOUNT</div>
<div class="stmt-period">{{ fromDate || '—' }} to {{ toDate || '—' }}</div>
</div>
</div>
@if (branchName || ifscCode || micrCode) {
<div class="branch-line">
@if (branchName) {
<span>{{ branchName }} Branch</span>
}
@if (branchAddress) {
<span>{{ branchAddress }}</span>
}
@if (ifscCode) {
<span>IFSC: {{ ifscCode }}</span>
}
@if (micrCode) {
<span>MICR: {{ micrCode }}</span>
}
</div>
}
<div class="account-details-band">
<div class="account-details-grid">
<div class="detail-block">
<div class="detail-label">Account Holder</div>
<div class="detail-value bold">{{ accountHolder || '—' | uppercase }}</div>
</div>
<div class="detail-block">
<div class="detail-label">Account Number</div>
<div class="detail-value mono bold">{{ accountNo }}</div>
</div>
<div class="detail-block">
<div class="detail-label">Product</div>
<div class="detail-value">{{ productName || '—' }}</div>
</div>
</div>
</div>
<div class="balance-summary-strip">
<div class="balance-box">
<div class="balance-box-label">Current Balance</div>
<div class="balance-box-value">{{ currencySym }}{{ currentBalance | number: '1.2-2' }}</div>
</div>
<div class="balance-divider"></div>
<div class="balance-box">
<div class="balance-box-label">Available Balance</div>
<div class="balance-box-value">{{ currencySym }}{{ availableBalance | number: '1.2-2' }}</div>
</div>
<div class="balance-divider"></div>
<div class="balance-box">
<div class="balance-box-label">Total Transactions</div>
<div class="balance-box-value">{{ filteredRows.length }}</div>
</div>
</div>
@if (filteredRows.length > 0) {
<table class="statement-table">
<thead>
<tr>
<th class="col-sr">#</th>
<th class="col-date">Date</th>
<th class="col-desc">Description</th>
<th class="col-ref">Ref No.</th>
<th class="col-amount text-right">Debit (Dr)</th>
<th class="col-amount text-right">Credit (Cr)</th>
<th class="col-balance text-right">Balance</th>
</tr>
</thead>
<tbody>
@for (row of filteredRows; track row.id; let i = $index) {
<tr [class.debit-row]="row.debit > 0" [class.credit-row]="row.credit > 0">
<td class="col-sr text-center">{{ i + 1 }}</td>
<td class="col-date">{{ row.dateLabel }}</td>
<td class="col-desc">
<span class="txn-desc">{{ row.description }}</span>
</td>
<td class="col-ref text-muted mono">{{ row.id }}</td>
<td class="col-amount text-right debit-amount">
@if (row.debit > 0) {
{{ currencySym }}{{ row.debit | number: '1.2-2' }}
}
</td>
<td class="col-amount text-right credit-amount">
@if (row.credit > 0) {
{{ currencySym }}{{ row.credit | number: '1.2-2' }}
}
</td>
<td class="col-balance text-right balance-amount">
{{ currencySym }}{{ row.runningBalance | number: '1.2-2' }}
</td>
</tr>
}
</tbody>
<tfoot>
<tr class="total-row">
<td colspan="4" class="text-right total-label">Closing Balance</td>
<td class="text-right total-label"></td>
<td class="text-right total-label"></td>
<td class="text-right closing-balance">{{ currencySym }}{{ currentBalance | number: '1.2-2' }}</td>
</tr>
</tfoot>
</table>
} @else {
<div class="no-data-block">
<mat-icon>inbox</mat-icon>
<p>No transactions in this period.</p>
</div>
}
<div class="statement-footer">
<div class="footer-note">
This is a computer-generated statement and does not require a signature. For queries, contact your branch
or call customer care.
</div>
<div class="footer-meta">Generated on: {{ today }} &nbsp;|&nbsp; Account No: {{ accountNo }}</div>
</div>
</div>
</div>
</div>
`,
styles: [
`
.asd-shell {
display: flex;
flex-direction: column;
height: 82vh;
width: 780px;
max-width: 90vw;
}
.asd-filterbar {
display: flex;
align-items: flex-end;
gap: 12px;
padding: 12px 16px;
border-bottom: 1px solid #e2e8f0;
}
.asd-field {
display: flex;
flex-direction: column;
gap: 3px;
font-size: 11px;
color: #64748b;
input {
padding: 6px 8px;
border: 1px solid #e2e8f0;
border-radius: 6px;
font-size: 12px;
}
}
.asd-spacer {
flex: 1;
}
.asd-btn {
padding: 7px 14px;
border-radius: 6px;
border: 1px solid #e2e8f0;
background: #fff;
cursor: pointer;
font-size: 13px;
&--primary {
background: #1e40af;
border-color: #1e40af;
color: #fff;
}
}
.asd-scroll {
flex: 1;
overflow: auto;
padding: 16px;
background: #eef1f6;
}
/* Statement look — mirrors passbook-tab.component's PRINT_STYLES exactly, so this preview and
a real passbook read the same way. */
.statement-wrapper {
background: #fff;
border: 1px solid #d0d7e3;
border-radius: 6px;
overflow: hidden;
font-family: 'Segoe UI', Arial, sans-serif;
color: #333;
}
.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;
mat-icon {
color: #1a3a6b;
}
}
.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: rgb(255 255 255 / 75%);
font-size: 10px;
margin-top: 3px;
}
.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 {
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;
}
.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: rgb(255 255 255 / 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: rgb(255 255 255 / 15%);
margin: 6px 0;
}
.statement-table {
width: 100%;
border-collapse: collapse;
font-size: 11px;
}
.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 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;
}
.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;
}
.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;
}
.txn-desc {
font-weight: 500;
}
.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-block {
padding: 40px;
text-align: center;
color: #aaa;
font-size: 13px;
}
.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;
}
`
],
standalone: true,
imports: [
CommonModule,
FormsModule,
MatIconModule
]
})
export class AccountStatementDialogComponent implements OnInit {
dialogRef = inject<MatDialogRef<AccountStatementDialogComponent>>(MatDialogRef);
data = inject<{ account: any }>(MAT_DIALOG_DATA);
private organizationService = inject(OrganizationService);
@ViewChild('statementContent') statementContent!: ElementRef;
today = new Date().toISOString().slice(0, 10);
fromDate = '';
toDate = this.today;
bankName = 'Bank';
bankSubtitle = 'Account Statement';
branchName = '';
branchAddress = '';
ifscCode = '';
micrCode = '';
accountNo = '';
accountHolder = '';
productName = '';
currencySym = '₹';
currentBalance = 0;
availableBalance = 0;
private allRows: any[] = [];
filteredRows: any[] = [];
ngOnInit(): void {
const acc = this.data.account ?? {};
this.accountNo = acc.accountNo ?? '';
this.accountHolder = acc.clientName ?? acc.groupName ?? '';
this.productName = acc.savingsProductName ?? '';
this.currencySym = acc.currency?.displaySymbol ?? acc.currency?.code ?? '₹';
this.currentBalance = Number(acc.summary?.accountBalance ?? 0);
this.availableBalance = Number(acc.summary?.availableBalance ?? this.currentBalance);
this.allRows = this.buildRows(acc.transactions ?? []);
this.filteredRows = [...this.allRows];
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}` : '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 transactionDateAsTime(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();
}
private isDebit(transactionType: any): boolean {
return !!(
transactionType?.withdrawal ||
transactionType?.feeDeduction ||
transactionType?.overdraftInterest ||
transactionType?.withholdTax
);
}
/** Mirrors passbook-tab.component's recalculateRunningBalances()/isDebit() -- same account, same
* ledger, so the two screens must never disagree on what a transaction is worth. */
private buildRows(transactions: any[]): any[] {
const nonReversed = (transactions ?? []).filter((t: any) => !t.reversed);
const sorted = [...nonReversed].sort((a, b) => {
const da = this.transactionDateAsTime(a.date);
const db = this.transactionDateAsTime(b.date);
return da !== db ? da - db : (a.id ?? 0) - (b.id ?? 0);
});
let balance = 0;
return sorted.map((txn) => {
const affectsBalance =
!txn.transactionType?.accrual && !txn.transactionType?.amountHold && !txn.transactionType?.amountRelease;
const debit = this.isDebit(txn.transactionType);
if (affectsBalance) {
balance += debit ? -txn.amount : txn.amount;
}
const time = this.transactionDateAsTime(txn.date);
return {
id: txn.id,
time,
dateLabel: isNaN(time)
? '—'
: new Date(time).toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' }),
description: txn.transactionType?.value ?? '—',
debit: debit ? Number(txn.amount) : 0,
credit: !debit ? Number(txn.amount) : 0,
runningBalance: Math.round(balance * 100) / 100
};
});
}
applyFilter(): void {
if (!this.fromDate || !this.toDate) {
this.filteredRows = [...this.allRows];
return;
}
const from = new Date(this.fromDate).getTime();
const to = new Date(this.toDate).getTime();
this.filteredRows = this.allRows.filter((r) => r.time >= from && r.time <= to);
}
/** Same clone-into-popup-window approach as passbook-tab.component.printPassbook() -- the on-screen
* preview is never itself printed directly, since the dialog chrome around it must not appear. */
printStatement(): void {
const source = this.statementContent?.nativeElement as HTMLElement;
if (!source) return;
const clone = source.cloneNode(true) as HTMLElement;
clone.querySelectorAll('mat-icon, .mat-icon').forEach((el: Element) => {
const insideLogo = !!el.closest('.bank-logo-circle');
if (insideLogo) {
el.outerHTML =
'<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" fill="#1a3a6b" /></svg>';
} else {
el.remove();
}
});
const printWindow = window.open('', 'Statement', 'height=800,width=1100');
if (!printWindow) return;
printWindow.document.write(`
<html>
<head>
<meta charset="utf-8" />
<title>Account Statement - ${this.accountNo}</title>
<style>${AccountStatementDialogComponent.PRINT_STYLES}</style>
</head>
<body>
${clone.outerHTML}
<script>
window.onafterprint = function () { window.close(); };
window.addEventListener('load', function () { window.focus(); window.print(); });
</script>
</body>
</html>
`);
printWindow.document.close();
printWindow.focus();
}
/**
* A popup window opened via window.open() doesn't inherit this component's own scoped Angular
* styles, so the exact same statement CSS has to be re-supplied here as plain text -- this is a
* duplicate of the `styles: [...]` block above by necessity, not an oversight; keep the two in sync
* if either changes. Mirrors passbook-tab.component.ts's own PRINT_STYLES constant for the same
* reason.
*/
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-wrapper { background: #fff; border: 1px solid #d0d7e3; border-radius: 6px; overflow: hidden; }
.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-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 { 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; }
.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; }
.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 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; }
.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; }
.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; }
.txn-desc { font-weight: 500; }
.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-block { padding: 40px; text-align: center; color: #aaa; font-size: 13px; }
.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; }
`;
}
// ── Joint Holder Detail Dialog ────────────────────────────────────────────────
@Component({
@@ -841,6 +1545,10 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
// Extended cashier data
clientPhotoUrl: string | null = null;
signatureUrl: string | null = null;
/** Cash Hold (a manual/staff-placed lien) vs Clearing Hold (a cheque/DD deposit not yet realized,
* or a presented cheque not yet settled) — fetched separately from the account's own detail
* response, since only plain savings accounts (m_savings_account) carry this breakdown today. */
holdBreakdown: { cashHold: number; clearingHold: number } | null = null;
accountNotes: any[] = [];
standingInstructions: any[] = [];
todayTransactions: any[] = [];
@@ -1986,6 +2694,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
this.clientPhotoUrl = null;
if (this.signatureUrl?.startsWith('blob:')) URL.revokeObjectURL(this.signatureUrl);
this.signatureUrl = null;
this.holdBreakdown = null;
this.accountNotes = [];
this.standingInstructions = [];
this.todayTransactions = [];
@@ -2253,6 +2962,19 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}
this.accountDetails = acc;
this.jointHolders = acc.jointHolders ?? [];
// Cash Hold / Clearing Hold breakdown only exists for plain savings accounts (m_savings_account)
// — FD/RD/Current/Share/GL have no such row, so there's nothing to ask for.
if (acTyp?.toUpperCase() === 'SD' && acc.id) {
this.http
.get<any>(`/savingsaccounts/${acc.id}/holdbreakdown`)
.pipe(catchError(() => of(null)))
.subscribe((hb: any) => {
if (token !== this.searchToken) return;
this.holdBreakdown = hb
? { cashHold: Number(hb.cashHold) || 0, clearingHold: Number(hb.clearingHold) || 0 }
: null;
});
}
// Track per-row account id/name for multi-row disbursement
if (this.selectedRow?.acNo) {
this.rowAccountIds[this.selectedRow.acNo] = acc.id;
@@ -2566,6 +3288,19 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return this.accountDetails?.savingsAmountOnHold ?? 0;
}
/** A manual, staff-placed lien — the same underlying figure "Transfer Hold" already shows
* ({@link freezeAmount}); Fineract has one bucket for this, not two, so both fields legitimately
* read the same number rather than one of them being invented data. */
get cashHold(): number {
return this.holdBreakdown?.cashHold ?? this.freezeAmount;
}
/** Money still moving through the clearing process — a cheque/DD deposit not yet realized, or a
* presented cheque not yet settled. Only populated for a plain savings account; 0 elsewhere. */
get clearingHold(): number {
return this.holdBreakdown?.clearingHold ?? 0;
}
get withdrawableAmount(): number {
const avail = this.accountDetails?.summary?.availableBalance ?? this.currentBalance;
const acTyp = this.selectedRow?.acTyp;
@@ -2872,6 +3607,23 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
});
}
/** Only meaningful for a plain savings account — the transactions array a Statement is built from
* is embedded on that account type's own detail response (?associations=all), never on
* FD/RD/Current/Share/GL. */
get canPrintStatement(): boolean {
return this.selectedRow?.acTyp === 'SD' && !!this.accountDetails?.id;
}
printStatement(): void {
if (!this.canPrintStatement) return;
this.dialog.open(AccountStatementDialogComponent, {
data: { account: this.accountDetails },
width: '820px',
maxWidth: '95vw',
autoFocus: false
});
}
// ── Same-day compliance indicators (copied from SavingsAccountTransactionsComponent) ──
private computeSameDayNeftRtgs(transactions: any[] | undefined): void {
@@ -9,7 +9,12 @@
<div class="container">
<div class="page-header">
<h2>{{ 'labels.inputs.Cheque Book' | translate }} #{{ book.id }}</h2>
<button mat-raised-button [routerLink]="['/chequebook', 'books']">{{ 'labels.buttons.Back' | translate }}</button>
<div>
<button mat-stroked-button (click)="printIssuanceSlip()">
<fa-icon icon="print"></fa-icon> {{ 'labels.buttons.Print' | translate }}
</button>
<button mat-raised-button [routerLink]="['/chequebook', 'books']">{{ 'labels.buttons.Back' | translate }}</button>
</div>
</div>
<mat-card class="info-card">
@@ -20,6 +20,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Custom Services */
import { ChequeBookService } from '../chequebook.service';
import { LeafDetailDialogComponent } from './leaf-detail-dialog/leaf-detail-dialog.component';
import { buildChequeIssuanceSlipPdf } from './cheque-issuance-slip-pdf';
type LeafAction = 'stoppayment' | 'revokestoppayment' | 'reportlost' | 'cancelleaf' | 'markused' | 'return';
@@ -85,6 +86,11 @@ export class ChequeBookDetailComponent implements OnInit {
this.dialog.open(LeafDetailDialogComponent, { data: leaf, width: '420px' });
}
printIssuanceSlip(): void {
const doc = buildChequeIssuanceSlipPdf(this.book);
doc.save(`cheque-book-issuance-${this.book.id}.pdf`);
}
startAction(action: LeafAction): void {
this.activeAction = this.activeAction === action ? null : action;
this.actionForm.reset();
@@ -0,0 +1,120 @@
/**
* 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/.
*/
/**
* A one-page issuance-slip PDF for a cheque book already issued to an account — the "Cheque Book"
* print action was previously a dead menu stub. This is a record of the issuance itself (which
* book/series, leaf range, issued-to account, issue date, branch), not a physical cheque leaf.
*/
import { jsPDF } from 'jspdf';
const BANK_NAME = 'Nexora Co-operative Bank Ltd.';
const C_TEXT: [number, number, number] = [
15,
23,
42
];
const C_MUTED: [number, number, number] = [
100,
116,
139
];
const C_BORDER: [number, number, number] = [
226,
232,
240
];
const ACCENT: [number, number, number] = [
30,
64,
175
];
function formatDate(iso: string | null | undefined): string {
if (!iso) return '—';
const d = new Date(iso);
if (isNaN(d.getTime())) return String(iso);
return d.toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' });
}
export interface ChequeIssuanceSlipBook {
id: number | string;
accountNo?: string;
clientName?: string;
accountType?: string;
officeName?: string;
seriesCode?: string;
rangeStart?: string | number;
rangeEnd?: string | number;
noOfBooks?: number;
status?: string;
issueDate?: string;
}
export function buildChequeIssuanceSlipPdf(book: ChequeIssuanceSlipBook): jsPDF {
const doc = new jsPDF({ unit: 'mm', format: 'a5' });
const pageWidth = doc.internal.pageSize.getWidth();
const margin = 12;
let y = 16;
doc.setFillColor(...ACCENT);
doc.rect(0, 0, pageWidth, 4, 'F');
doc.setFont('helvetica', 'bold');
doc.setFontSize(14);
doc.setTextColor(...C_TEXT);
doc.text(BANK_NAME, margin, y);
y += 6;
doc.setFont('helvetica', 'normal');
doc.setFontSize(10);
doc.setTextColor(...C_MUTED);
doc.text('Cheque Book Issuance Slip', margin, y);
y += 8;
doc.setDrawColor(...C_BORDER);
doc.line(margin, y, pageWidth - margin, y);
y += 8;
const row = (label: string, value: string) => {
doc.setFont('helvetica', 'bold');
doc.setFontSize(9);
doc.setTextColor(...C_MUTED);
doc.text(label, margin, y);
doc.setFont('helvetica', 'normal');
doc.setFontSize(10.5);
doc.setTextColor(...C_TEXT);
doc.text(value, margin + 45, y);
y += 7;
};
row('Issuance ID', String(book.id ?? '—'));
row('Account No.', book.accountNo ?? '—');
row('Account Holder', book.clientName ?? '—');
row('Account Type', book.accountType ?? '—');
row('Branch', book.officeName ?? '—');
row('Series', book.seriesCode ?? '—');
row('Leaf Range', book.rangeStart != null && book.rangeEnd != null ? `${book.rangeStart} – ${book.rangeEnd}` : '—');
row('No. of Books', String(book.noOfBooks ?? '—'));
row('Status', book.status ?? '—');
row('Issue Date', formatDate(book.issueDate));
y += 6;
doc.setDrawColor(...C_BORDER);
doc.line(margin, y, pageWidth - margin, y);
y += 14;
doc.setFont('helvetica', 'normal');
doc.setFontSize(9);
doc.setTextColor(...C_MUTED);
doc.text('Issued by: ______________________', margin, y);
doc.text('Received by: ______________________', margin, y + 16);
return doc;
}
@@ -60,8 +60,8 @@
<mat-icon>account_balance</mat-icon>
</div>
<div class="bank-name-block">
<div class="bank-title">LincPay Co-operative Bank Ltd.</div>
<div class="bank-subtitle">Account Statement</div>
<div class="bank-title">{{ bankName }}</div>
<div class="bank-subtitle">{{ bankSubtitle }}</div>
</div>
</div>
<div class="statement-label">
@@ -70,6 +70,25 @@
</div>
</div>
<!-- Branch line — IFSC/MICR are how a UCB customer identifies which branch issued this, and are
needed for NEFT/RTGS/cheque printing, so a real passbook always carries them. -->
@if (branchName || ifscCode || micrCode) {
<div class="branch-line">
@if (branchName) {
<span>{{ branchName }} Branch</span>
}
@if (branchAddress) {
<span>{{ branchAddress }}</span>
}
@if (ifscCode) {
<span>IFSC: {{ ifscCode }}</span>
}
@if (micrCode) {
<span>MICR: {{ micrCode }}</span>
}
</div>
}
<!-- Account Details -->
<div class="account-details-band">
<div class="account-details-grid">
@@ -127,6 +127,18 @@ $header-text: #fff;
}
}
// ─── Branch Identification Line ──────────────────────────────────────────────
.branch-line {
background: #e8edf5;
border-bottom: 1px solid $border-color;
padding: 6px 24px;
display: flex;
flex-wrap: wrap;
gap: 4px 18px;
font-size: 11px;
color: #4a5a7a;
}
// ─── Account Details Band ────────────────────────────────────────────────────
.account-details-band {
background: #f0f3f9;
@@ -11,11 +11,12 @@ import { Component, OnInit, OnDestroy, inject, ElementRef, ViewChild } from '@an
import { ActivatedRoute } from '@angular/router';
import { UntypedFormBuilder, Validators } from '@angular/forms';
import { CurrencyPipe } from '@angular/common';
import { Subject, takeUntil } from 'rxjs';
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 {
SavingsAccountTransaction,
@@ -44,6 +45,7 @@ export class PassbookTabComponent implements OnInit, OnDestroy {
private formBuilder = inject(UntypedFormBuilder);
private settingsService = inject(SettingsService);
private dateUtils = inject(Dates);
private organizationService = inject(OrganizationService);
private destroy$ = new Subject<void>();
@@ -60,6 +62,17 @@ export class PassbookTabComponent implements OnInit, OnDestroy {
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 = 'Account Statement';
branchName = '';
branchAddress = '';
ifscCode = '';
micrCode = '';
/** Transactions */
allTransactions: SavingsAccountTransaction[] = [];
filteredTransactions: SavingsAccountTransaction[] = [];
@@ -101,6 +114,31 @@ export class PassbookTabComponent implements OnInit, OnDestroy {
Validators.required
]
});
this.loadLetterhead();
}
/** Institution Profile is the bank's own single record; Branch Profile is per-office. There is no
* office id on the savings 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}` : '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 || '';
}
});
}
isDebit(transactionType: SavingsAccountTransactionType): boolean {
@@ -269,6 +307,18 @@ export class PassbookTabComponent implements OnInit, OnDestroy {
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;
+2 -2
View File
@@ -9,8 +9,8 @@
/* tslint:disable */
export default {
'mifos_x': {
'version': '260828',
'hash': 'ce840d73'
'version': '260829',
'hash': 'fe81d159'
},
'allow_switching_backend_instance': true
};