From 07a4c8b8e30f09aabd6ca5f4fe8a163225619992 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Sun, 30 Aug 2026 10:38:39 +0530 Subject: [PATCH] fix(cashier,savings): real bank/branch letterhead on statements, print-preview flow, WITHDRAWN status fixes 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 --- .../closing-cash-report.component.ts | 14 +- .../cashier/day-book/day-book.component.ts | 8 +- .../transaction-entry.component.html | 21 +- .../transaction-entry.component.ts | 754 +++++++++++++++++- .../cheque-book-detail.component.html | 7 +- .../cheque-book-detail.component.ts | 6 + .../cheque-issuance-slip-pdf.ts | 120 +++ .../passbook-tab/passbook-tab.component.html | 23 +- .../passbook-tab/passbook-tab.component.scss | 12 + .../passbook-tab/passbook-tab.component.ts | 52 +- src/environments/.env.ts | 4 +- 11 files changed, 1006 insertions(+), 15 deletions(-) create mode 100644 src/app/chequebook/cheque-book-detail/cheque-issuance-slip-pdf.ts diff --git a/src/app/cashier/closing-cash-report/closing-cash-report.component.ts b/src/app/cashier/closing-cash-report/closing-cash-report.component.ts index 2b0e0a183..81d6c3634 100644 --- a/src/app/cashier/closing-cash-report/closing-cash-report.component.ts +++ b/src/app/cashier/closing-cash-report/closing-cash-report.component.ts @@ -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 { diff --git a/src/app/cashier/day-book/day-book.component.ts b/src/app/cashier/day-book/day-book.component.ts index d6fe2dbd4..534f9d7ca 100644 --- a/src/app/cashier/day-book/day-book.component.ts +++ b/src/app/cashier/day-book/day-book.component.ts @@ -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'; diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.html b/src/app/cashier/transaction-entry/transaction-entry.component.html index 76de4e137..b260d1a8c 100644 --- a/src/app/cashier/transaction-entry/transaction-entry.component.html +++ b/src/app/cashier/transaction-entry/transaction-entry.component.html @@ -804,7 +804,7 @@
4.Freeze Amount - {{ currencySymbol }}0.00 + {{ currencySymbol }}{{ freezeAmount | number: '1.2-2' }}
5.Withdrawable Amt. @@ -865,7 +865,7 @@
Cash Hold - 0.00 + {{ cashHold | number: '1.2-2' }}
Transfer Hold @@ -873,7 +873,7 @@
Clearing Hold - 0.00 + {{ clearingHold | number: '1.2-2' }}
After Trans. @@ -889,6 +889,11 @@ Currency {{ accountDetails.currency?.code ?? 'INR' }}
+ @if (canPrintStatement) { + + } } @else { @@ -989,7 +994,7 @@
Cash Hold - 0.00 + {{ cashHold | number: '1.2-2' }}
Tr. Hold @@ -997,13 +1002,19 @@
Clr. Hold - 0.00 + {{ clearingHold | number: '1.2-2' }}
After Trans. {{ currencySymbol }}{{ clearBalance | number: '1.2-2' }}
+ @if (canPrintStatement) { +
+ + } @if (accountAlerts.length > 0) {
diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.ts b/src/app/cashier/transaction-entry/transaction-entry.component.ts index e1bfb4d24..60afe5ff5 100644 --- a/src/app/cashier/transaction-entry/transaction-entry.component.ts +++ b/src/app/cashier/transaction-entry/transaction-entry.component.ts @@ -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: ` +
+
+ + + + + +
+ +
+
+
+
+
+ account_balance +
+
+
{{ bankName }}
+
{{ bankSubtitle }}
+
+
+
+
STATEMENT OF ACCOUNT
+
{{ fromDate || '—' }} to {{ toDate || '—' }}
+
+
+ + @if (branchName || ifscCode || micrCode) { +
+ @if (branchName) { + {{ branchName }} Branch + } + @if (branchAddress) { + {{ branchAddress }} + } + @if (ifscCode) { + IFSC: {{ ifscCode }} + } + @if (micrCode) { + MICR: {{ micrCode }} + } +
+ } + + + +
+
+
Current Balance
+
{{ currencySym }}{{ currentBalance | number: '1.2-2' }}
+
+
+
+
Available Balance
+
{{ currencySym }}{{ availableBalance | number: '1.2-2' }}
+
+
+
+
Total Transactions
+
{{ filteredRows.length }}
+
+
+ + @if (filteredRows.length > 0) { + + + + + + + + + + + + + + @for (row of filteredRows; track row.id; let i = $index) { + + + + + + + + + + } + + + + + + + + + +
#DateDescriptionRef No.Debit (Dr)Credit (Cr)Balance
{{ i + 1 }}{{ row.dateLabel }} + {{ row.description }} + {{ row.id }} + @if (row.debit > 0) { + {{ currencySym }}{{ row.debit | number: '1.2-2' }} + } + + @if (row.credit > 0) { + {{ currencySym }}{{ row.credit | number: '1.2-2' }} + } + + {{ currencySym }}{{ row.runningBalance | number: '1.2-2' }} +
Closing Balance{{ currencySym }}{{ currentBalance | number: '1.2-2' }}
+ } @else { +
+ inbox +

No transactions in this period.

+
+ } + + +
+
+
+ `, + 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); + 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 = + ''; + } else { + el.remove(); + } + }); + + const printWindow = window.open('', 'Statement', 'height=800,width=1100'); + if (!printWindow) return; + + printWindow.document.write(` + + + + Account Statement - ${this.accountNo} + + + + ${clone.outerHTML} + + + + `); + 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(`/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 { diff --git a/src/app/chequebook/cheque-book-detail/cheque-book-detail.component.html b/src/app/chequebook/cheque-book-detail/cheque-book-detail.component.html index cf657e658..57a1fd6b3 100644 --- a/src/app/chequebook/cheque-book-detail/cheque-book-detail.component.html +++ b/src/app/chequebook/cheque-book-detail/cheque-book-detail.component.html @@ -9,7 +9,12 @@
diff --git a/src/app/chequebook/cheque-book-detail/cheque-book-detail.component.ts b/src/app/chequebook/cheque-book-detail/cheque-book-detail.component.ts index 2a95702db..684e01f2d 100644 --- a/src/app/chequebook/cheque-book-detail/cheque-book-detail.component.ts +++ b/src/app/chequebook/cheque-book-detail/cheque-book-detail.component.ts @@ -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(); diff --git a/src/app/chequebook/cheque-book-detail/cheque-issuance-slip-pdf.ts b/src/app/chequebook/cheque-book-detail/cheque-issuance-slip-pdf.ts new file mode 100644 index 000000000..ba1b5fc67 --- /dev/null +++ b/src/app/chequebook/cheque-book-detail/cheque-issuance-slip-pdf.ts @@ -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; +} diff --git a/src/app/savings/savings-account-view/passbook-tab/passbook-tab.component.html b/src/app/savings/savings-account-view/passbook-tab/passbook-tab.component.html index ee2fe2d98..0efb2fbcf 100644 --- a/src/app/savings/savings-account-view/passbook-tab/passbook-tab.component.html +++ b/src/app/savings/savings-account-view/passbook-tab/passbook-tab.component.html @@ -60,8 +60,8 @@ account_balance
-
LincPay Co-operative Bank Ltd.
-
Account Statement
+
{{ bankName }}
+
{{ bankSubtitle }}
@@ -70,6 +70,25 @@
+ + @if (branchName || ifscCode || micrCode) { +
+ @if (branchName) { + {{ branchName }} Branch + } + @if (branchAddress) { + {{ branchAddress }} + } + @if (ifscCode) { + IFSC: {{ ifscCode }} + } + @if (micrCode) { + MICR: {{ micrCode }} + } +
+ } +