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 }} + } +
+ } +