6524 lines
262 KiB
TypeScript
6524 lines
262 KiB
TypeScript
/**
|
||
* Copyright since 2025 Mifos Initiative
|
||
*
|
||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||
*/
|
||
|
||
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';
|
||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||
import { catchError, debounceTime, distinctUntilChanged, map, timeout } from 'rxjs/operators';
|
||
import { forkJoin, of, Subject } from 'rxjs';
|
||
import { MatDialog, MatDialogModule, MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||
import { MatIconModule } from '@angular/material/icon';
|
||
import { Router } from '@angular/router';
|
||
import { CashierService } from '../cashier.service';
|
||
import { AuthenticationService } from '../../core/authentication/authentication.service';
|
||
import { ClerkUiStateService, ClerkSavedAccount } from '../clerk-ui-state.service';
|
||
import { ClerkWithdrawalService } from '../clerk-withdrawal.service';
|
||
import { ClerkClosureRequestService } from '../clerk-closure-request.service';
|
||
import { DividendClaimRequestService } from '../../clerk/dividend-claim-request.service';
|
||
import { ClerkDisbursementService } from '../clerk-disbursement.service';
|
||
import { ClerkShareWithdrawalService } from '../clerk-share-withdrawal.service';
|
||
import { CashierDepositService } from '../cashier-deposit.service';
|
||
import { SettingsService } from '../../settings/settings.service';
|
||
import {
|
||
CustomerSearchDialogComponent,
|
||
CustomerSearchResult
|
||
} from '../../shared/customer-search-dialog/customer-search-dialog.component';
|
||
import { SponsorDDService } from '../../sponsor-dd/sponsor-dd.service';
|
||
import { OrganizationService } from '../../organization/organization.service';
|
||
import { CashierIssueDdDialogComponent } from './cashier-issue-dd-dialog.component';
|
||
import { businessToday, businessTodayIso } from 'app/core/utils/business-today';
|
||
|
||
interface TxnRow {
|
||
typ: string;
|
||
br: number;
|
||
trTp: string;
|
||
acTyp: string;
|
||
acNo: string;
|
||
ecsAcNo: string;
|
||
dbCr: string;
|
||
chequeNo: string;
|
||
cheqDate: string;
|
||
amount: number | null;
|
||
description: string;
|
||
tokNo: string;
|
||
paymentTypeId: number | null;
|
||
bankName: string;
|
||
routingCode: string;
|
||
/** DD issuance charges/commission — only used when TYPE=DD, TXN TP=Cash, AC.TP=GL */
|
||
ddCharges: number | null;
|
||
sponsorBankId?: number | null;
|
||
/**
|
||
* What a GL credit is actually for — the id of a code value under "GLEntryType" (General, Share
|
||
* Collection, DD Issue, or whatever an admin has added under Admin > System > Manage Codes).
|
||
*
|
||
* A cashier Receipt (or a clerk Collection) into a GL account is the counter picking, by hand,
|
||
* any account to post cash into — deliberately, so a broken product mapping can never block a
|
||
* genuine voucher. That freedom is also the hole: two unrelated flows can land in the same GL
|
||
* account with nothing on the row itself saying which one the money was collected for, and a
|
||
* manager applying a share request can pick up a voucher that was really meant to fund a DD. This
|
||
* tag is what a day-end reconciliation groups by, and what Share Advance Collection / Sponsor DD
|
||
* issuance check before adopting a row — only shown/sent for AC.TP=GL, DR./CR.=Cr rows.
|
||
*/
|
||
entryTypeCvId?: number | null;
|
||
/** Only used when this row is AC.TP=SHR inside an Enter Bank Transfer to its linked savings
|
||
* account — governs the share redemption (see submitShareRedemptionTransfer()). */
|
||
resolutionNumber?: string;
|
||
meetingDate?: string;
|
||
/**
|
||
* Beneficiary and leaflet captured in the DD dialog, held on the row until Save.
|
||
*
|
||
* The dialog used to submit the request itself, which left the worksheet's own Save button doing
|
||
* nothing for a DD row — the clerk filled the dialog, the request was already gone, and pressing
|
||
* Save afterwards had no effect. Filling in details and committing them are two different
|
||
* actions, so the dialog now only records what was entered and Save is what sends it.
|
||
*/
|
||
ddDetails?: {
|
||
leafletId: number;
|
||
ddNumber: string;
|
||
beneficiaryName: string;
|
||
payableAtBranch: string;
|
||
payableAtCity: string;
|
||
/** Present only for a walk-in cash draft. */
|
||
purchaser?: { name: string; address: string; aadhaar: string };
|
||
/** Note-and-coin breakdown, cash drafts only. */
|
||
denominations?: { [key: string]: number };
|
||
fundingAccountId?: number;
|
||
fundingClientId?: number;
|
||
};
|
||
/**
|
||
* How many drafts this one row raises. 1 (or unset) is the ordinary single draft; 2 or more make
|
||
* the row a multi-DD slip — one debit funding several drafts, each to its own beneficiary.
|
||
*
|
||
* Typed by the clerk BEFORE opening the dialog, so the dialog knows how many beneficiary forms to
|
||
* present. Only a member-account debit can be a slip (see canRowBeMultiDd()).
|
||
*/
|
||
ddCount?: number | null;
|
||
/**
|
||
* The per-draft lines of a multi-DD slip, captured in the dialog and held here until Save — the
|
||
* same "fill now, commit on Save" contract as ddDetails above.
|
||
*
|
||
* Each line carries its OWN face value and charges: a slip's drafts are routinely for different
|
||
* amounts to different beneficiaries, and the charge is per draft rather than per slip. That is
|
||
* why the row's own AMOUNT and CHARGES fields are locked and derived from these lines while a
|
||
* slip is being built — a clerk-typed row total could disagree with the lines, and the account
|
||
* would then be debited for a figure the drafts don't add up to.
|
||
*/
|
||
ddLines?: DdSlipLine[];
|
||
/** The inventory the slip's leaflets are drawn from. The batch endpoint allocates by inventory
|
||
* rather than by leaflet, since the numbers are only picked inside its own locked transaction. */
|
||
ddInventoryId?: number;
|
||
/** Resolved funding account for a slip, captured alongside the lines for the same reason. */
|
||
ddFundingAccountId?: number;
|
||
ddFundingClientId?: number;
|
||
}
|
||
|
||
/** One draft on a multi-DD slip — its own beneficiary, its own face value, its own charges. */
|
||
interface DdSlipLine {
|
||
beneficiaryName: string;
|
||
payableAtBranch: string;
|
||
payableAtCity: string;
|
||
faceValue: number;
|
||
chargesAmount: number;
|
||
}
|
||
|
||
interface Denomination {
|
||
id: number;
|
||
value: number;
|
||
kind: 'note' | 'coin';
|
||
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 }} | 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 = businessTodayIso();
|
||
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({
|
||
selector: 'mifosx-joint-holder-dialog',
|
||
template: `
|
||
<div class="jhd-wrap">
|
||
<div class="jhd-header">
|
||
<div class="jhd-header-left">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
||
<circle cx="9" cy="7" r="4" />
|
||
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
|
||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||
</svg>
|
||
<span>Joint Holder Details</span>
|
||
</div>
|
||
<button class="jhd-close" (click)="close()">✕</button>
|
||
</div>
|
||
|
||
@if (loading) {
|
||
<div class="jhd-loading">Loading details…</div>
|
||
} @else {
|
||
<div class="jhd-body">
|
||
<!-- Identity section -->
|
||
<div class="jhd-identity">
|
||
<div class="jhd-media-col">
|
||
<div class="jhd-media-label">PHOTO</div>
|
||
@if (photo) {
|
||
<img
|
||
[src]="photo"
|
||
class="jhd-photo"
|
||
alt="Photo"
|
||
(click)="previewImg(photo, 'Joint Holder Photo')"
|
||
title="Click to enlarge"
|
||
/>
|
||
} @else {
|
||
<div class="jhd-no-media">
|
||
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="#cbd5e1" stroke-width="1.5">
|
||
<circle cx="12" cy="8" r="4" />
|
||
<path d="M4 20c0-4 3.6-7 8-7s8 3 8 7" />
|
||
</svg>
|
||
<span>No Photo</span>
|
||
</div>
|
||
}
|
||
</div>
|
||
|
||
<div class="jhd-media-col">
|
||
<div class="jhd-media-label">SIGNATURE</div>
|
||
@if (signature) {
|
||
<img
|
||
[src]="signature"
|
||
class="jhd-sig"
|
||
alt="Signature"
|
||
(click)="previewImg(signature, 'Joint Holder Signature')"
|
||
title="Click to enlarge"
|
||
/>
|
||
} @else {
|
||
<div class="jhd-no-media">
|
||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#cbd5e1" stroke-width="1.5">
|
||
<path d="M20 20H7L3 16l13.5-13.5a2 2 0 0 1 2.8 0L21 4.3a2 2 0 0 1 0 2.8L10.5 17" />
|
||
</svg>
|
||
<span>No Signature</span>
|
||
</div>
|
||
}
|
||
</div>
|
||
|
||
<div class="jhd-info-col">
|
||
<div class="jhd-name">{{ data.jh.displayName }}</div>
|
||
<div class="jhd-badges">
|
||
<span class="jhd-role-badge">{{ data.jh.role ?? 'Co-Holder' }}</span>
|
||
@if (data.jh.signingAuthority?.toLowerCase() === 'mandatory') {
|
||
<span class="jhd-sign-badge">✍ Mandatory Signing</span>
|
||
}
|
||
</div>
|
||
<div class="jhd-fields">
|
||
<div class="jhd-row">
|
||
<span class="jhd-lbl">CIF No.</span><span class="jhd-val mono">{{ client?.cifNumber ?? '—' }}</span>
|
||
</div>
|
||
<div class="jhd-row">
|
||
<span class="jhd-lbl">Mobile</span><span class="jhd-val">{{ client?.mobileNo ?? '—' }}</span>
|
||
</div>
|
||
<div class="jhd-row">
|
||
<span class="jhd-lbl">Email</span><span class="jhd-val">{{ client?.emailAddress ?? '—' }}</span>
|
||
</div>
|
||
<div class="jhd-row">
|
||
<span class="jhd-lbl">Share %</span><span class="jhd-val">{{ data.jh.sharePercentage }}%</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- KYC section -->
|
||
<div class="jhd-kyc-row">
|
||
<div class="jhd-kyc-card">
|
||
<span class="jhd-kyc-label">KYC Status</span>
|
||
<span class="jhd-kyc-val" [style.color]="kyc?.kycStatus === 300 ? '#059669' : '#f59e0b'">
|
||
{{ kyc?.kycStatus === 300 ? '✅ Verified' : kyc?.kycStatus === 100 ? '⏳ Pending' : '—' }}
|
||
</span>
|
||
</div>
|
||
<div class="jhd-kyc-card">
|
||
<span class="jhd-kyc-label">PAN</span>
|
||
<span class="jhd-kyc-val mono">{{ kyc?.panNumber ?? '—' }}</span>
|
||
</div>
|
||
<div class="jhd-kyc-card">
|
||
<span class="jhd-kyc-label">Aadhaar</span>
|
||
<span class="jhd-kyc-val mono">{{ kyc?.aadhaarNumberMasked ?? 'XXXX/XXXX/XXXX' }}</span>
|
||
</div>
|
||
<div class="jhd-kyc-card">
|
||
<span class="jhd-kyc-label">CKYC</span>
|
||
<span class="jhd-kyc-val mono">{{ kyc?.ckycNumber || '—' }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
}
|
||
</div>
|
||
`,
|
||
styles: [
|
||
`
|
||
.jhd-wrap {
|
||
display: flex;
|
||
flex-direction: column;
|
||
width: 600px;
|
||
max-width: 94vw;
|
||
border-radius: 12px;
|
||
overflow: hidden;
|
||
}
|
||
.jhd-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 12px 18px;
|
||
background: #1e293b;
|
||
color: #e2e8f0;
|
||
}
|
||
.jhd-header-left {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
letter-spacing: 0.05em;
|
||
text-transform: uppercase;
|
||
}
|
||
.jhd-close {
|
||
background: transparent;
|
||
border: none;
|
||
color: #94a3b8;
|
||
font-size: 18px;
|
||
cursor: pointer;
|
||
padding: 0 4px;
|
||
&:hover {
|
||
color: #fff;
|
||
}
|
||
}
|
||
.jhd-loading {
|
||
padding: 32px;
|
||
text-align: center;
|
||
color: #64748b;
|
||
font-size: 13px;
|
||
background: #f8fafc;
|
||
}
|
||
.jhd-body {
|
||
background: #f8fafc;
|
||
padding: 16px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 14px;
|
||
}
|
||
.jhd-identity {
|
||
display: grid;
|
||
grid-template-columns: 100px 160px 1fr;
|
||
gap: 16px;
|
||
background: #fff;
|
||
border-radius: 10px;
|
||
padding: 14px;
|
||
border: 1px solid #e2e8f0;
|
||
}
|
||
.jhd-media-col {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
.jhd-media-label {
|
||
font-size: 10px;
|
||
font-weight: 700;
|
||
color: #94a3b8;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.06em;
|
||
}
|
||
.jhd-photo {
|
||
width: 80px;
|
||
height: 96px;
|
||
object-fit: cover;
|
||
border-radius: 8px;
|
||
border: 1px solid #e2e8f0;
|
||
cursor: zoom-in;
|
||
transition: transform 0.15s;
|
||
&:hover {
|
||
transform: scale(1.05);
|
||
}
|
||
}
|
||
.jhd-sig {
|
||
width: 140px;
|
||
height: 60px;
|
||
object-fit: contain;
|
||
border: 1px solid #e2e8f0;
|
||
border-radius: 6px;
|
||
background: #fff;
|
||
cursor: zoom-in;
|
||
}
|
||
.jhd-no-media {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 4px;
|
||
color: #94a3b8;
|
||
font-size: 11px;
|
||
padding: 8px;
|
||
}
|
||
.jhd-info-col {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
.jhd-name {
|
||
font-size: 16px;
|
||
font-weight: 700;
|
||
color: #1e293b;
|
||
}
|
||
.jhd-badges {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 6px;
|
||
}
|
||
.jhd-role-badge {
|
||
padding: 3px 10px;
|
||
border-radius: 999px;
|
||
font-size: 11px;
|
||
font-weight: 700;
|
||
background: #dbeafe;
|
||
color: #1d4ed8;
|
||
border: 1px solid #bfdbfe;
|
||
}
|
||
.jhd-sign-badge {
|
||
padding: 3px 10px;
|
||
border-radius: 999px;
|
||
font-size: 11px;
|
||
font-weight: 700;
|
||
background: #fff7ed;
|
||
color: #c2410c;
|
||
border: 1px solid #fed7aa;
|
||
}
|
||
.jhd-fields {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
margin-top: 4px;
|
||
}
|
||
.jhd-row {
|
||
display: flex;
|
||
gap: 8px;
|
||
font-size: 12px;
|
||
border-bottom: 1px dotted #f1f5f9;
|
||
padding-bottom: 3px;
|
||
}
|
||
.jhd-lbl {
|
||
color: #64748b;
|
||
font-weight: 600;
|
||
min-width: 60px;
|
||
}
|
||
.jhd-val {
|
||
color: #1e293b;
|
||
font-weight: 500;
|
||
}
|
||
.jhd-val.mono {
|
||
font-family: monospace;
|
||
}
|
||
.jhd-kyc-row {
|
||
display: grid;
|
||
grid-template-columns: repeat(4, 1fr);
|
||
gap: 8px;
|
||
}
|
||
.jhd-kyc-card {
|
||
background: #fff;
|
||
border: 1px solid #e2e8f0;
|
||
border-radius: 8px;
|
||
padding: 10px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
}
|
||
.jhd-kyc-label {
|
||
font-size: 10px;
|
||
font-weight: 700;
|
||
color: #94a3b8;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.05em;
|
||
}
|
||
.jhd-kyc-val {
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
color: #1e293b;
|
||
}
|
||
.jhd-kyc-val.mono {
|
||
font-family: monospace;
|
||
font-size: 11px;
|
||
}
|
||
`
|
||
],
|
||
standalone: true,
|
||
imports: [CommonModule]
|
||
})
|
||
export class JointHolderDialogComponent implements OnInit {
|
||
dialogRef = inject<MatDialogRef<JointHolderDialogComponent>>(MatDialogRef);
|
||
data = inject<{ jh: any; clientId: number }>(MAT_DIALOG_DATA);
|
||
private http = inject(HttpClient);
|
||
private dialog = inject(MatDialog);
|
||
|
||
loading = true;
|
||
photo: string | null = null;
|
||
signature: string | null = null;
|
||
kyc: any = null;
|
||
client: any = null;
|
||
|
||
ngOnInit(): void {
|
||
const cid = this.data.clientId;
|
||
forkJoin({
|
||
client: this.http.get<any>(`/clients/${cid}`).pipe(catchError(() => of(null))),
|
||
kyc: this.http.get<any>(`/clients/${cid}/kyc`).pipe(catchError(() => of([]))),
|
||
photo: this.http.get(`/clients/${cid}/images`, { responseType: 'text' }).pipe(catchError(() => of(null))),
|
||
documents: this.http.get<any[]>(`/clients/${cid}/documents`).pipe(catchError(() => of([])))
|
||
}).subscribe(({ client, kyc, photo, documents }) => {
|
||
this.client = client;
|
||
const kycArr: any[] = Array.isArray(kyc) ? kyc : kyc ? [kyc] : [];
|
||
this.kyc = kycArr[0] ?? null;
|
||
this.photo = photo ?? null;
|
||
const docs: any[] = Array.isArray(documents) ? documents : [];
|
||
// Multiple 'clientSignature' documents can exist for clients who re-cropped/replaced their
|
||
// signature before uploads became single-slot, so pick the highest id (latest) rather than
|
||
// the first unordered match.
|
||
const sigDoc = docs
|
||
.filter((d: any) => d.name === 'clientSignature' || d.name?.toLowerCase().includes('signature'))
|
||
.sort((a: any, b: any) => b.id - a.id)[0];
|
||
this.loading = false;
|
||
if (sigDoc) {
|
||
this.http
|
||
.get(`/clients/${cid}/documents/${sigDoc.id}/attachment`, { responseType: 'blob' })
|
||
.pipe(catchError(() => of(null)))
|
||
.subscribe((blob: Blob | null) => {
|
||
if (blob) this.signature = URL.createObjectURL(blob);
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
previewImg(url: string | null, title: string): void {
|
||
if (!url) return;
|
||
this.dialog.open(ImagePreviewDialogComponent, {
|
||
data: { url, title },
|
||
maxWidth: '90vw',
|
||
maxHeight: '90vh'
|
||
});
|
||
}
|
||
|
||
close(): void {
|
||
this.dialogRef.close();
|
||
}
|
||
}
|
||
|
||
// ── Image Preview Dialog (declared before use) ────────────────────────────────
|
||
|
||
@Component({
|
||
selector: 'mifosx-image-preview-dialog',
|
||
template: `
|
||
<div class="ipd-wrap">
|
||
<div class="ipd-topbar">
|
||
<span class="ipd-title">{{ data.title }}</span>
|
||
<button class="ipd-close-btn" (click)="close()" title="Close">
|
||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
|
||
<path d="M1 1l10 10M11 1L1 11" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
|
||
<div
|
||
class="ipd-body"
|
||
(wheel)="onWheel($event)"
|
||
(mousedown)="onMouseDown($event)"
|
||
(mousemove)="onMouseMove($event)"
|
||
(mouseup)="onMouseUp()"
|
||
(mouseleave)="onMouseUp()"
|
||
[style.cursor]="dragging ? 'grabbing' : zoom > 1 ? 'grab' : 'default'"
|
||
>
|
||
<img
|
||
[src]="safeUrl"
|
||
[alt]="data.title"
|
||
class="ipd-img"
|
||
[style.transform]="imgTransform"
|
||
(dragstart)="$event.preventDefault()"
|
||
/>
|
||
</div>
|
||
|
||
<div class="ipd-footer">
|
||
<div class="ipd-toolbar">
|
||
<button class="ipd-tool" (click)="zoomOut()" [disabled]="zoom <= min" title="Zoom out">
|
||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||
<circle cx="6" cy="6" r="5" stroke="currentColor" stroke-width="1.5" />
|
||
<path d="M4 6h4M11 11l-2.5-2.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||
</svg>
|
||
</button>
|
||
<span class="ipd-zoom-val">{{ zoomPct }}%</span>
|
||
<button class="ipd-tool" (click)="zoomIn()" [disabled]="zoom >= max" title="Zoom in">
|
||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||
<circle cx="6" cy="6" r="5" stroke="currentColor" stroke-width="1.5" />
|
||
<path d="M6 4v4M4 6h4M11 11l-2.5-2.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||
</svg>
|
||
</button>
|
||
<div class="ipd-sep"></div>
|
||
<button class="ipd-tool" (click)="reset()" title="Reset zoom">
|
||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||
<path
|
||
d="M2 7a5 5 0 1 0 1.2-3.2M2 3.5V7h3.5"
|
||
stroke="currentColor"
|
||
stroke-width="1.5"
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
<span class="ipd-hint">Scroll to zoom · Drag to pan</span>
|
||
</div>
|
||
</div>
|
||
`,
|
||
styles: [
|
||
`
|
||
.ipd-wrap {
|
||
display: flex;
|
||
flex-direction: column;
|
||
width: min(88vw, 840px);
|
||
border-radius: 20px;
|
||
overflow: hidden;
|
||
background: #07080f;
|
||
box-shadow:
|
||
0 40px 100px rgba(0, 0, 0, 0.75),
|
||
0 0 0 1px rgba(255, 255, 255, 0.06);
|
||
}
|
||
|
||
/* ── Top bar ─────────────────────────── */
|
||
.ipd-topbar {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 14px 18px;
|
||
background: rgba(255, 255, 255, 0.03);
|
||
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
|
||
flex-shrink: 0;
|
||
}
|
||
.ipd-title {
|
||
font-size: 11px;
|
||
font-weight: 700;
|
||
letter-spacing: 0.1em;
|
||
text-transform: uppercase;
|
||
color: rgba(255, 255, 255, 0.55);
|
||
}
|
||
.ipd-close-btn {
|
||
width: 28px;
|
||
height: 28px;
|
||
border-radius: 50%;
|
||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||
background: rgba(255, 255, 255, 0.05);
|
||
color: rgba(255, 255, 255, 0.5);
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
transition: all 0.15s;
|
||
padding: 0;
|
||
}
|
||
.ipd-close-btn:hover {
|
||
background: rgba(239, 68, 68, 0.18);
|
||
border-color: rgba(239, 68, 68, 0.45);
|
||
color: #fca5a5;
|
||
}
|
||
|
||
/* ── Image area ──────────────────────── */
|
||
.ipd-body {
|
||
background: #040508;
|
||
overflow: hidden;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
height: 68vh;
|
||
user-select: none;
|
||
position: relative;
|
||
}
|
||
.ipd-img {
|
||
max-width: 82%;
|
||
max-height: 64vh;
|
||
object-fit: contain;
|
||
border-radius: 10px;
|
||
transform-origin: center center;
|
||
transition: transform 0.07s ease;
|
||
pointer-events: none;
|
||
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.55);
|
||
}
|
||
|
||
/* ── Footer ──────────────────────────── */
|
||
.ipd-footer {
|
||
background: rgba(255, 255, 255, 0.02);
|
||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||
padding: 12px 20px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
/* ── Toolbar pill ────────────────────── */
|
||
.ipd-toolbar {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 2px;
|
||
background: rgba(255, 255, 255, 0.06);
|
||
border: 1px solid rgba(255, 255, 255, 0.09);
|
||
border-radius: 50px;
|
||
padding: 4px 6px;
|
||
}
|
||
.ipd-tool {
|
||
width: 36px;
|
||
height: 36px;
|
||
border-radius: 50%;
|
||
border: none;
|
||
background: transparent;
|
||
color: rgba(255, 255, 255, 0.6);
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
transition: all 0.15s;
|
||
padding: 0;
|
||
flex-shrink: 0;
|
||
}
|
||
.ipd-tool:hover:not(:disabled) {
|
||
background: rgba(99, 102, 241, 0.22);
|
||
color: #a5b4fc;
|
||
}
|
||
.ipd-tool:disabled {
|
||
opacity: 0.2;
|
||
cursor: not-allowed;
|
||
}
|
||
.ipd-zoom-val {
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
color: rgba(255, 255, 255, 0.85);
|
||
min-width: 44px;
|
||
text-align: center;
|
||
font-variant-numeric: tabular-nums;
|
||
letter-spacing: -0.01em;
|
||
padding: 0 2px;
|
||
}
|
||
.ipd-sep {
|
||
width: 1px;
|
||
height: 18px;
|
||
background: rgba(255, 255, 255, 0.1);
|
||
margin: 0 4px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
/* ── Hint ────────────────────────────── */
|
||
.ipd-hint {
|
||
font-size: 10.5px;
|
||
color: rgba(255, 255, 255, 0.2);
|
||
letter-spacing: 0.03em;
|
||
}
|
||
`
|
||
],
|
||
standalone: true,
|
||
imports: [CommonModule]
|
||
})
|
||
export class ImagePreviewDialogComponent {
|
||
dialogRef = inject<MatDialogRef<ImagePreviewDialogComponent>>(MatDialogRef);
|
||
data = inject<{ url: string; title: string }>(MAT_DIALOG_DATA);
|
||
private sanitizer = inject(DomSanitizer);
|
||
|
||
get safeUrl(): SafeResourceUrl {
|
||
return this.sanitizer.bypassSecurityTrustResourceUrl(this.data.url);
|
||
}
|
||
|
||
zoom = 1;
|
||
tx = 0;
|
||
ty = 0;
|
||
dragging = false;
|
||
private lx = 0;
|
||
private ly = 0;
|
||
readonly step = 0.25;
|
||
readonly min = 0.25;
|
||
readonly max = 6;
|
||
|
||
get zoomPct(): number {
|
||
return Math.round(this.zoom * 100);
|
||
}
|
||
get imgTransform(): string {
|
||
return `scale(${this.zoom}) translate(${this.tx}px,${this.ty}px)`;
|
||
}
|
||
|
||
zoomIn(): void {
|
||
this.zoom = Math.min(this.max, +(this.zoom + this.step).toFixed(2));
|
||
}
|
||
zoomOut(): void {
|
||
this.zoom = Math.max(this.min, +(this.zoom - this.step).toFixed(2));
|
||
this.clamp();
|
||
}
|
||
reset(): void {
|
||
this.zoom = 1;
|
||
this.tx = 0;
|
||
this.ty = 0;
|
||
}
|
||
|
||
onWheel(e: WheelEvent): void {
|
||
e.preventDefault();
|
||
const d = e.deltaY < 0 ? this.step : -this.step;
|
||
this.zoom = Math.min(this.max, Math.max(this.min, +(this.zoom + d).toFixed(2)));
|
||
if (this.zoom === this.min) {
|
||
this.tx = 0;
|
||
this.ty = 0;
|
||
}
|
||
}
|
||
onMouseDown(e: MouseEvent): void {
|
||
if (this.zoom <= 1) return;
|
||
this.dragging = true;
|
||
this.lx = e.clientX;
|
||
this.ly = e.clientY;
|
||
e.preventDefault();
|
||
}
|
||
onMouseMove(e: MouseEvent): void {
|
||
if (!this.dragging) return;
|
||
this.tx += (e.clientX - this.lx) / this.zoom;
|
||
this.ty += (e.clientY - this.ly) / this.zoom;
|
||
this.lx = e.clientX;
|
||
this.ly = e.clientY;
|
||
this.clamp();
|
||
}
|
||
onMouseUp(): void {
|
||
this.dragging = false;
|
||
}
|
||
private clamp(): void {
|
||
const m = Math.max(0, (this.zoom - 1) * 350);
|
||
this.tx = Math.max(-m, Math.min(m, this.tx));
|
||
this.ty = Math.max(-m, Math.min(m, this.ty));
|
||
}
|
||
close(): void {
|
||
this.dialogRef.close();
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
@Component({
|
||
selector: 'mifosx-transaction-entry',
|
||
templateUrl: './transaction-entry.component.html',
|
||
styleUrls: ['./transaction-entry.component.scss'],
|
||
standalone: true,
|
||
imports: [
|
||
CommonModule,
|
||
FormsModule,
|
||
MatDialogModule,
|
||
MatIconModule
|
||
]
|
||
})
|
||
export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||
@Input() clerkMode = false;
|
||
/** When set, the component runs in manager-review mode for that withdrawal request id. */
|
||
@Input() reviewRequestId: number | null = null;
|
||
/** When set, the clerk edits & resubmits that (rejected) withdrawal request id. */
|
||
@Input() editRequestId: number | null = null;
|
||
|
||
// Manager review state
|
||
reviewRequest: any = null;
|
||
showRejectDialog = false;
|
||
rejectionReason = '';
|
||
|
||
// Clerk edit (rejected resubmit) state
|
||
editRequest: any = null;
|
||
|
||
// Active approved withdrawal (cashier disburse flow)
|
||
activeWithdrawalRequest: any = null;
|
||
activeDividendClaimRequest: any = null;
|
||
activeClosureRequest: any = null;
|
||
loadingWithdrawal = false;
|
||
|
||
// ── Account Actions (clerk) — "Close this account" ──────────────────────────
|
||
/** Account Actions popup, opened from the footer once an account is loaded. */
|
||
showAccountActions = false;
|
||
/** The one action offered today: close the account (Cash payout, maker-checker). */
|
||
closeAccountChecked = false;
|
||
/** Interest accrued to today, previewed the moment the checkbox is ticked so the clerk can see
|
||
* what the member will actually receive before submitting. Null until calculated.
|
||
* `upToDate` is false when the backend could not refresh the accrual (e.g. this user lacks the
|
||
* CALCULATEINTEREST permission) and the figure shown is the last stored one instead. */
|
||
closurePreview: {
|
||
balance: number;
|
||
interest: number;
|
||
total: number;
|
||
upToDate: boolean;
|
||
asOfDate: string | null;
|
||
} | null = null;
|
||
closurePreviewLoading = false;
|
||
closurePreviewError = '';
|
||
/** Closure applied to the slip but not yet submitted: the AMOUNT is filled and Save will raise a
|
||
* closure request instead of an ordinary withdrawal request. TOK. NO. and DESCRIPTION are taken
|
||
* from the slip's own fields, not from the popup. */
|
||
pendingClosure = false;
|
||
|
||
private http = inject(HttpClient);
|
||
private cashierService = inject(CashierService);
|
||
private authService = inject(AuthenticationService);
|
||
private dialog = inject(MatDialog);
|
||
private clerkUiState = inject(ClerkUiStateService);
|
||
private clerkWithdrawalService = inject(ClerkWithdrawalService);
|
||
private closureRequestService = inject(ClerkClosureRequestService);
|
||
private dividendClaimRequestService = inject(DividendClaimRequestService);
|
||
private clerkDisbursementService = inject(ClerkDisbursementService);
|
||
private clerkShareWithdrawalService = inject(ClerkShareWithdrawalService);
|
||
private cashierDepositService = inject(CashierDepositService);
|
||
readonly router = inject(Router);
|
||
private settingsService = inject(SettingsService);
|
||
private sponsorDDService = inject(SponsorDDService);
|
||
|
||
/** GST % on DD issuance charges — loaded from the admin GST/GL Settings (sponsor-dd module). */
|
||
ddGstPercentage = 18;
|
||
/** Admin-configured DD limit (sponsor-dd module) — caps a single DD's face value. Null = no limit. */
|
||
ddLimitAmount: number | null = null;
|
||
|
||
today = '';
|
||
rows: TxnRow[] = [];
|
||
selectedRow: TxnRow | null = null;
|
||
loadingAccount = false;
|
||
accountError = '';
|
||
|
||
/**
|
||
* A teller only ever picks Cash or Transfer — which specific internal settlement it actually is
|
||
* (a share redemption, a dividend payout, a GL-to-GL or GL-to-bank book entry) is business logic
|
||
* the backend already threads through from whichever payment type the clerk picked here (see
|
||
* ClerkShareWithdrawalRequestApiResource#creditLinkedSavingsAccount on the backend), not a separate
|
||
* teller-facing choice. System-defined payment types (Share Redemption Transfer, Dividend Claim
|
||
* Settlement, the loan repayment-adjustment types, ...) exist purely as internal audit labels and
|
||
* are never offered here.
|
||
*
|
||
* Share redemption settlement is always a book-transfer to the linked savings account — never a
|
||
* cash handover. A Share (SHR) row's own TXN TP. never offers a cash payment type, and neither
|
||
* does its paired CR (savings) row in "Enter Bank Transfer" mode — that CR row's payment type is
|
||
* the one that actually governs how the settlement credit posts (see submitShareRedemptionTransfer()).
|
||
*/
|
||
paymentTypesForRow(row: TxnRow): any[] {
|
||
// Sponsor-bank payment types are excluded. They were added for the Demand Draft module, which
|
||
// picks its bank from m_sponsor_bank on the DD row itself, and they are not counter instruments
|
||
// — a clerk choosing "SBI" as the TXN TP. of a withdrawal says nothing about how the money
|
||
// moves. They are recognised by their description rather than by name so that adding another
|
||
// sponsor bank does not put it back on this list.
|
||
const tellerFacing = this.paymentTypes.filter(
|
||
(pt: any) => !pt.isSystemDefined && !/sponsor bank/i.test(pt.description ?? '')
|
||
);
|
||
if (!this.isClerk) {
|
||
// Cashier counter takes cash only — cheque/transfer/DD instruments go through their own
|
||
// clerk/back-office approval flows, never straight through the cash drawer.
|
||
return tellerFacing.filter((pt: any) => !!pt.isCashPayment);
|
||
}
|
||
if (row.acTyp === 'SHR') {
|
||
return tellerFacing.filter((pt: any) => !pt.isCashPayment);
|
||
}
|
||
const isShareSettlementCredit =
|
||
(row.typ === 'Enter Bank Transfer' || row.typ === 'Fund Transfer') &&
|
||
row.dbCr === 'Cr' &&
|
||
this.rows.some(
|
||
(r) => (r.typ === 'Enter Bank Transfer' || r.typ === 'Fund Transfer') && r.dbCr === 'Dr' && r.acTyp === 'SHR'
|
||
);
|
||
if (isShareSettlementCredit) {
|
||
return tellerFacing.filter((pt: any) => !pt.isCashPayment);
|
||
}
|
||
// A withdrawal slip is a cash instruction and nothing else: the clerk raises it, the cashier
|
||
// counts the notes out against the token. There is no non-cash way to settle one, so cash is
|
||
// the only choice offered rather than the default among several.
|
||
if (row.typ === 'Withdrawal Slip') {
|
||
return tellerFacing.filter((pt: any) => !!pt.isCashPayment);
|
||
}
|
||
// A clerk's Demand Draft is never funded by cash they take themselves. Cash for a draft is
|
||
// counted at the counter, so it comes either from the cashier's own DD screen or from an
|
||
// approved cashier deposit request the clerk points the draft at; the clerk's own route is to
|
||
// debit the member's account. Offering cash here let a draft be raised against notes no drawer
|
||
// had counted, which is exactly how the six cash DDs with no denomination breakdown arose.
|
||
if (row.typ === 'DD') {
|
||
return tellerFacing.filter((pt: any) => !pt.isCashPayment);
|
||
}
|
||
// Everything else can go either way, so both are offered and the clerk says which. A cheque or
|
||
// a bank transfer normally settles through the instrument, a disbursement or fund transfer
|
||
// between accounts — the type alone does not fix it to one or the other.
|
||
return tellerFacing;
|
||
}
|
||
|
||
// Per-row account id/name map: acNo → Fineract id/name (used for multi-row disbursement)
|
||
rowAccountIds: Record<string, number> = {};
|
||
rowAccountNames: Record<string, string> = {};
|
||
// Per-row Share account market price, keyed by acNo — lets a Share row's "SHARE NO." field
|
||
// auto-fill the settlement amount on any other row added alongside it (calculator convenience
|
||
// only; a Share row still can never itself be saved from this screen — see accountTypeConfig).
|
||
rowSharePrices: Record<string, number> = {};
|
||
// Per-row client id, keyed by acNo — needed to submit a Share Withdrawal Request from a row
|
||
// that isn't necessarily the currently "selected" one.
|
||
rowClientIds: Record<string, number> = {};
|
||
// Per-row linked savings account number, keyed by the Share account's own acNo — used to make
|
||
// sure a Share-to-linked-account Bank Transfer actually credits the right account.
|
||
rowLinkedSavingsAcNo: Record<string, string> = {};
|
||
// Per-row shares currently held, keyed by acNo — lets submitShareRedemptionTransfer() warn the
|
||
// clerk when a withdrawal will bring the account's holding to zero (closing it on approval).
|
||
rowShareHoldings: Record<string, number> = {};
|
||
|
||
// Full account + client data
|
||
accountDetails: any = null;
|
||
clientDetails: any = null;
|
||
kycDetails: any = null;
|
||
nominees: any[] = [];
|
||
jointHolders: any[] = [];
|
||
clientAddresses: any[] = [];
|
||
nonPersonDetails: any = null;
|
||
|
||
// 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[] = [];
|
||
linkedAccounts: any = null;
|
||
|
||
// AML compliance indicators (same as withdrawal screen)
|
||
sameDayNeftRtgs = 0;
|
||
sameDayChequeClearing = 0;
|
||
|
||
// Search token — cancels stale async results
|
||
private searchToken = 0;
|
||
private lastLoadedAcNo = '';
|
||
private acNoInput$ = new Subject<{ row: TxnRow; index: number; value: string }>();
|
||
cashVaultGlId: number | null = null;
|
||
/** The Cash Vault GL account's own code and name — the denomination breakdown for a manual GL
|
||
* entry has to be filed against THIS account, not whichever account the teller typed in, since
|
||
* that account is never the one physical notes actually moved through (see submitGlJournalEntry).
|
||
* Both resolved dynamically off whatever this tenant's own financial-activity mapping names as
|
||
* its cash/vault account (see loadCashVaultGl) — never a literal account name or code. */
|
||
cashVaultGlCode: string | null = null;
|
||
cashVaultGlName: string | null = null;
|
||
|
||
// Joint holder expanded details (keyed by index)
|
||
expandedHolder: number | null = null;
|
||
holderDetails: Record<
|
||
number,
|
||
{ photo: string | null; signature: string | null; kyc: any; client: any; loading: boolean }
|
||
> = {};
|
||
|
||
paymentTypes: any[] = [];
|
||
showConfirmDialog = false;
|
||
confirmData: any = null;
|
||
|
||
// ── Custom alert/confirm modal ────────────────────────────────────────────
|
||
alertModal: {
|
||
type: 'error' | 'warning' | 'info' | 'success';
|
||
title: string;
|
||
lines: string[];
|
||
confirmLabel?: string;
|
||
onConfirm?: () => void;
|
||
} | null = null;
|
||
|
||
showAlert(type: 'error' | 'warning' | 'info' | 'success', title: string, lines: string[]): void {
|
||
this.alertModal = { type, title, lines };
|
||
}
|
||
|
||
showConfirmAlert(
|
||
type: 'warning' | 'info',
|
||
title: string,
|
||
lines: string[],
|
||
confirmLabel: string,
|
||
onConfirm: () => void
|
||
): void {
|
||
this.alertModal = { type, title, lines, confirmLabel, onConfirm };
|
||
}
|
||
|
||
closeAlert(): void {
|
||
this.alertModal = null;
|
||
}
|
||
|
||
confirmAlert(): void {
|
||
const cb = this.alertModal?.onConfirm;
|
||
this.alertModal = null;
|
||
if (cb) cb();
|
||
}
|
||
|
||
acTypOptions = [
|
||
'SD',
|
||
'CA',
|
||
'FD',
|
||
'RD',
|
||
'SHR',
|
||
'GL',
|
||
'LA'
|
||
];
|
||
|
||
/**
|
||
* Account types a clerk may fund a demand draft from.
|
||
*
|
||
* A clerk raises a draft against a member's own balance (SD/CA) or, now, against a GL account
|
||
* directly for an institutional/holding-account draft (see isDdAccountDebitRow()) — both are
|
||
* governed, maker-checker submissions. GL used to be offered here too in a different sense: picking
|
||
* it quietly switched the row onto the CASHIER's walk-in cash path instead (the one that asks for a
|
||
* purchaser's name and a note-and-coin count), which isDdAccountDebitRow() now disambiguates by
|
||
* role — GL means "debit this GL account" for a clerk, and still means "cash/GL purchase" for a
|
||
* cashier, who never reaches this list at all (isDdAccountDebitRow() is clerk-only).
|
||
*/
|
||
private static readonly DD_CLERK_ACTYPS = [
|
||
'SD',
|
||
'CA',
|
||
'GL'
|
||
];
|
||
|
||
/**
|
||
* The account types this particular row may use.
|
||
*
|
||
* Every other row keeps the full list; only a clerk's DD row is narrowed, because that is the
|
||
* only row where the account type silently decides which funding flow runs.
|
||
*/
|
||
acTypOptionsFor(row: TxnRow): string[] {
|
||
if (this.isClerk && row.typ === 'DD') {
|
||
return TransactionEntryComponent.DD_CLERK_ACTYPS;
|
||
}
|
||
return this.acTypOptions;
|
||
}
|
||
|
||
/** True while the selected row is filled from a token-driven request whose row.acTyp is a
|
||
* technical stand-in rather than the account's real type — a dividend claim or an account closure
|
||
* both set row.acTyp = 'SD' purely to route past the SHR-specific guards elsewhere on this screen
|
||
* (see onTokNoBlur()), not because the counter is paying against a savings account. The AC.TP
|
||
* dropdown must not be left editable there: it already shows the wrong value, and a cashier
|
||
* changing it further would desync the row from the request it was auto-filled from. A plain
|
||
* withdrawal-slip token is deliberately excluded — its row.acTyp is resolved to the account's real
|
||
* type there, so the normal editable control already shows it correctly and there's nothing to
|
||
* lock. */
|
||
get acTypLockedByToken(): boolean {
|
||
return !!(this.activeDividendClaimRequest || this.activeClosureRequest);
|
||
}
|
||
|
||
/** What to show in place of the AC.TP dropdown while it's locked — the same label the account
|
||
* summary strip above already uses for each of these token pools, so the two never contradict
|
||
* each other on screen. */
|
||
get acTypLockedLabel(): string {
|
||
if (this.activeClosureRequest) {
|
||
return 'Closure';
|
||
}
|
||
if (this.activeDividendClaimRequest) {
|
||
return 'Dividend';
|
||
}
|
||
return '';
|
||
}
|
||
|
||
dbCrOptions = [
|
||
'Dr',
|
||
'Cr'
|
||
];
|
||
trTpOptions = [
|
||
'C',
|
||
'D',
|
||
'T'
|
||
];
|
||
|
||
private readonly clerkTypOptions: { value: string; label: string }[] = [
|
||
{ value: 'Withdrawal Slip', label: 'Withdrawal Slip' },
|
||
{ value: 'Cheque', label: 'Cheque' },
|
||
{ value: 'Disbursement', label: 'Disbursement' },
|
||
// Mechanically identical to Disbursement (1 GL debit -> N account credits, same maker-checker
|
||
// double-entry flow) but labelled separately: "Disbursement" reads as a loan payout, which is
|
||
// wrong for crediting a GL amount into a savings/current account for any other reason (e.g.
|
||
// funding a newly-activated account's opening balance from a GL source).
|
||
{ value: 'Fund Transfer', label: 'Fund Transfer' },
|
||
{ value: 'Enter Bank Transfer', label: 'Enter Bank Transfer' },
|
||
{ value: 'DD', label: 'DD (Demand Draft)' }
|
||
];
|
||
|
||
/**
|
||
* A cashier takes cash in (Receipt), pays cash out (Payment), or collects the cash for a Demand
|
||
* Draft (DD). Cheque, Disbursement and Bank Transfer stay clerk/back-office instruments with their
|
||
* own approval flows and never appear at the cashier counter.
|
||
*
|
||
* DD belongs here because the whole DD-against-cash path is the cashier's: isDdCashGlRow() gates on
|
||
* a cash payment type against a GL account, onSave() makes the cashier count Face Value + Charges
|
||
* + GST exactly and enforces the admin DD limit at intake, and buildDepositPayload() persists that
|
||
* breakdown so the Sponsor DD issuance screen can read it back after approval. All of that was
|
||
* already written and reachable only by a cashier — it was just impossible to select.
|
||
*/
|
||
private readonly cashierTypOptions: { value: string; label: string }[] = [
|
||
{ value: 'Receipt', label: 'Receipt' },
|
||
{ value: 'Payment', label: 'Payment' },
|
||
{ value: 'DD', label: 'DD (Demand Draft)' }
|
||
];
|
||
|
||
get typOptions(): { value: string; label: string }[] {
|
||
return this.isClerk ? this.clerkTypOptions : this.cashierTypOptions;
|
||
}
|
||
|
||
sponsorBanks: any[] = [];
|
||
ddGlMapping: any = null;
|
||
|
||
// ── Denomination ──────────────────────────────────────────────────────────
|
||
notes: Denomination[] = [];
|
||
coins: Denomination[] = [];
|
||
denominationCounts: Record<string, number> = {};
|
||
|
||
// ── Opening / Closing position ────────────────────────────────────────────
|
||
openingDenoms: any[] = [];
|
||
openingTotal = 0;
|
||
openingLoaded = false;
|
||
|
||
// ── Cashier day status ────────────────────────────────────────────────────
|
||
previousDayUnsettled = false;
|
||
unsettledSince: string | null = null;
|
||
morningTransferReceived = true;
|
||
dayStatusLoaded = false;
|
||
|
||
// ── Transaction saving state ──────────────────────────────────────────────
|
||
saving = false;
|
||
|
||
ngOnInit(): void {
|
||
const d = businessToday();
|
||
this.today = `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}/${d.getFullYear()}`;
|
||
this.addRow();
|
||
this.loadDenominations();
|
||
this.loadOpeningPosition();
|
||
this.loadDayStatus();
|
||
this.cashierService
|
||
.getPaymentTypes()
|
||
.pipe(catchError(() => of([])))
|
||
.subscribe((types: any[]) => {
|
||
this.paymentTypes = types ?? [];
|
||
// addRow() ran before this resolved, so the very first row's default TXN TP. (Cash, for
|
||
// Withdrawal Slip / Receipt / Payment) couldn't be looked up yet — backfill it now that
|
||
// the payment types are actually loaded. Only ever touches a row still sitting on the
|
||
// null it was created with, so this never overwrites anything already picked on screen.
|
||
for (const row of this.rows) {
|
||
if (!row.paymentTypeId && [
|
||
'Withdrawal Slip',
|
||
'Receipt',
|
||
'Payment'
|
||
].includes(row.typ)) {
|
||
row.paymentTypeId = this.findCashPaymentTypeId();
|
||
}
|
||
}
|
||
});
|
||
|
||
this.acNoInput$
|
||
.pipe(
|
||
debounceTime(400),
|
||
distinctUntilChanged((a, b) => a.value === b.value && a.row === b.row)
|
||
)
|
||
.subscribe(({ row, index, value }) => {
|
||
if (value.trim().length >= 4) {
|
||
this.selectedRow = row;
|
||
this.loadAccountDetails(value.trim(), row.acTyp);
|
||
}
|
||
});
|
||
|
||
this.loadCashVaultGl();
|
||
this.loadDdGstPercentage();
|
||
this.loadSponsorBanks();
|
||
this.cashierService
|
||
.getEntryTypeOptions()
|
||
.pipe(catchError(() => of([])))
|
||
.subscribe((options: any[]) => {
|
||
this.entryTypeOptions = options ?? [];
|
||
});
|
||
|
||
if (this.reviewMode) {
|
||
this.loadRequestIntoForm(this.reviewRequestId!, 'review');
|
||
return;
|
||
}
|
||
|
||
if (this.editMode) {
|
||
this.loadRequestIntoForm(this.editRequestId!, 'edit');
|
||
return;
|
||
}
|
||
|
||
if (this.isClerk) {
|
||
const saved = this.clerkUiState.snapshot.savedAccount;
|
||
if (saved) {
|
||
this.accountDetails = saved.accountDetails;
|
||
this.clientDetails = saved.clientDetails;
|
||
this.kycDetails = saved.kycDetails;
|
||
this.nominees = saved.nominees;
|
||
this.jointHolders = saved.jointHolders;
|
||
this.clientAddresses = saved.clientAddresses;
|
||
this.nonPersonDetails = saved.nonPersonDetails;
|
||
this.clientPhotoUrl = saved.clientPhotoUrl;
|
||
this.signatureUrl = saved.signatureUrl;
|
||
this.accountNotes = saved.accountNotes;
|
||
this.standingInstructions = saved.standingInstructions;
|
||
this.todayTransactions = saved.todayTransactions;
|
||
this.linkedAccounts = saved.linkedAccounts;
|
||
this.sameDayNeftRtgs = saved.sameDayNeftRtgs;
|
||
this.sameDayChequeClearing = saved.sameDayChequeClearing;
|
||
this.selectedRow = saved.selectedRow;
|
||
if (saved.accountDetails) {
|
||
const acNo = saved.accountDetails.accountNo ?? '';
|
||
const acTyp = this.clerkUiState.snapshot.savedRow?.acTyp ?? '';
|
||
this.rows[0] = { ...this.rows[0], acNo, acTyp };
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
ngOnDestroy(): void {
|
||
this.persistClerkState();
|
||
}
|
||
|
||
/** Save the full loaded account into shared clerk state so the facility/photo/SI/activity views can read it. */
|
||
private persistClerkState(): void {
|
||
if (this.isClerk && this.accountDetails) {
|
||
this.clerkUiState.saveFullState({
|
||
accountDetails: this.accountDetails,
|
||
clientDetails: this.clientDetails,
|
||
kycDetails: this.kycDetails,
|
||
nominees: this.nominees,
|
||
jointHolders: this.jointHolders,
|
||
clientAddresses: this.clientAddresses,
|
||
nonPersonDetails: this.nonPersonDetails,
|
||
clientPhotoUrl: this.clientPhotoUrl,
|
||
signatureUrl: this.signatureUrl,
|
||
accountNotes: this.accountNotes,
|
||
standingInstructions: this.standingInstructions,
|
||
todayTransactions: this.todayTransactions,
|
||
linkedAccounts: this.linkedAccounts,
|
||
sameDayNeftRtgs: this.sameDayNeftRtgs,
|
||
sameDayChequeClearing: this.sameDayChequeClearing,
|
||
selectedRow: this.selectedRow
|
||
});
|
||
}
|
||
}
|
||
|
||
private loadDenominations(): void {
|
||
this.cashierService
|
||
.getActiveDenominations()
|
||
.pipe(catchError(() => of([])))
|
||
.subscribe((list: any[]) => {
|
||
const rows: Denomination[] = (list || []).map((d: any) => ({
|
||
id: d.id,
|
||
value: d.denomination ?? d.value,
|
||
kind: d.kind as 'note' | 'coin',
|
||
label: '₹' + (d.denomination ?? d.value)
|
||
}));
|
||
const order = (a: Denomination, b: Denomination) => b.value - a.value;
|
||
this.notes = rows.filter((r) => r.kind === 'note').sort(order);
|
||
this.coins = rows.filter((r) => r.kind === 'coin').sort(order);
|
||
});
|
||
}
|
||
|
||
/** Admin GST rate (set via GST / GL Settings in the sponsor-dd module) applied to DD issuance charges. */
|
||
private loadDdGstPercentage(): void {
|
||
this.sponsorDDService
|
||
.getGlMappings()
|
||
.pipe(catchError(() => of([])))
|
||
.subscribe((mappings: any[]) => {
|
||
this.ddGlMapping = mappings && mappings.length ? mappings[0] : null;
|
||
this.ddGstPercentage = Number(this.ddGlMapping?.gstPercentage ?? 18);
|
||
this.ddLimitAmount = this.ddGlMapping?.ddLimitAmount != null ? Number(this.ddGlMapping.ddLimitAmount) : null;
|
||
if (this.ddGlMapping?.ddPayableGlAccountId) {
|
||
if (this.ddGlMapping.ddPayableGlCode) {
|
||
this.applyDdGlMappingToDdRows();
|
||
} else {
|
||
this.http
|
||
.get<any>(`/glaccounts/${this.ddGlMapping.ddPayableGlAccountId}`)
|
||
.pipe(catchError(() => of(null)))
|
||
.subscribe((glAcc: any) => {
|
||
if (glAcc) {
|
||
this.ddGlMapping.ddPayableGlCode = glAcc.glCode || String(glAcc.id);
|
||
this.ddGlMapping.ddPayableGlAccountName = glAcc.name;
|
||
}
|
||
this.applyDdGlMappingToDdRows();
|
||
});
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
applyDdGlMappingToRow(row: TxnRow): void {
|
||
if (row.typ !== 'DD' || !this.ddGlMapping) return;
|
||
// Leaves an account-funded DD alone: its A/C NO. is the member's account the clerk typed, and
|
||
// overwriting it with the DD Payable GL would silently turn the row back into a cash draft.
|
||
if (this.isDdAccountDebitRow(row)) return;
|
||
row.acTyp = 'GL';
|
||
row.dbCr = 'Cr';
|
||
|
||
if (this.ddGlMapping.ddPayableGlCode) {
|
||
row.acNo = this.ddGlMapping.ddPayableGlCode;
|
||
this.selectedRow = row;
|
||
this.loadAccountDetails(row.acNo, 'GL');
|
||
return;
|
||
}
|
||
|
||
if (this.ddGlMapping.ddPayableGlAccountId) {
|
||
this.http
|
||
.get<any>(`/glaccounts/${this.ddGlMapping.ddPayableGlAccountId}`)
|
||
.pipe(catchError(() => of(null)))
|
||
.subscribe((glAcc: any) => {
|
||
if (glAcc) {
|
||
this.ddGlMapping.ddPayableGlCode = glAcc.glCode || String(glAcc.id);
|
||
this.ddGlMapping.ddPayableGlAccountName = glAcc.name;
|
||
row.acNo = this.ddGlMapping.ddPayableGlCode;
|
||
this.selectedRow = row;
|
||
this.loadAccountDetails(row.acNo, 'GL');
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
private applyDdGlMappingToDdRows(): void {
|
||
for (const row of this.rows) {
|
||
if (row.typ === 'DD') {
|
||
this.applyDdGlMappingToRow(row);
|
||
}
|
||
}
|
||
}
|
||
|
||
private loadSponsorBanks(): void {
|
||
this.sponsorDDService
|
||
.getSponsorBanks()
|
||
.pipe(catchError(() => of([])))
|
||
.subscribe((banks: any[]) => {
|
||
this.sponsorBanks = banks || [];
|
||
for (const row of this.rows) {
|
||
if (row.typ === 'DD' && !row.sponsorBankId && this.sponsorBanks.length > 0) {
|
||
row.sponsorBankId = this.sponsorBanks[0].id;
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
get isDdGlMappingConfigured(): boolean {
|
||
return !!(this.ddGlMapping && this.ddGlMapping.ddPayableGlAccountId && this.ddGlMapping.cashGlAccountId);
|
||
}
|
||
|
||
private loadDayStatus(): void {
|
||
this.cashierService
|
||
.getCashierDayStatus()
|
||
.pipe(catchError(() => of(null)))
|
||
.subscribe((res: any) => {
|
||
if (!res) {
|
||
this.dayStatusLoaded = true;
|
||
return;
|
||
}
|
||
this.previousDayUnsettled = !!res.previousDayUnsettled;
|
||
this.morningTransferReceived = res.morningTransferReceived !== false;
|
||
this.unsettledSince = res.unsettledSince ?? null;
|
||
this.dayStatusLoaded = true;
|
||
});
|
||
}
|
||
|
||
private loadOpeningPosition(): void {
|
||
const d = businessToday();
|
||
const asOfDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||
this.cashierService
|
||
.getCashierPositionForMe(asOfDate)
|
||
.pipe(catchError(() => of(null)))
|
||
.subscribe((res: any) => {
|
||
this.openingDenoms = res?.denominations ?? [];
|
||
this.openingTotal = Number(res?.totalAmount ?? 0);
|
||
this.openingLoaded = true;
|
||
});
|
||
}
|
||
|
||
// ── Denomination helpers ──────────────────────────────────────────────────
|
||
|
||
denomKey(d: Denomination): string {
|
||
return `${d.kind}:${d.value}`;
|
||
}
|
||
|
||
countFor(d: Denomination): number {
|
||
return this.denominationCounts[this.denomKey(d)] || 0;
|
||
}
|
||
|
||
subtotalFor(d: Denomination): number {
|
||
return this.countFor(d) * d.value;
|
||
}
|
||
|
||
onDenomChange(d: Denomination, raw: any): void {
|
||
this.denominationCounts[this.denomKey(d)] = Math.max(0, Math.floor(Number(raw) || 0));
|
||
}
|
||
|
||
bumpDenom(d: Denomination, delta: number): void {
|
||
this.denominationCounts[this.denomKey(d)] = Math.max(0, this.countFor(d) + delta);
|
||
}
|
||
|
||
get notesTotal(): number {
|
||
return this.notes.reduce((s, d) => s + this.subtotalFor(d), 0);
|
||
}
|
||
get coinsTotal(): number {
|
||
return this.coins.reduce((s, d) => s + this.subtotalFor(d), 0);
|
||
}
|
||
get denominationTotal(): number {
|
||
return this.notesTotal + this.coinsTotal;
|
||
}
|
||
|
||
/**
|
||
* Denomination Entry order: ALL notes (desc) first, then ALL coins (desc).
|
||
* This matches how cashiers physically count cash — notes first, coins after.
|
||
* Opening/Closing panels use value-based interleaving (reporting format).
|
||
*/
|
||
get allDenominations(): Denomination[] {
|
||
const desc = (a: Denomination, b: Denomination) => b.value - a.value;
|
||
return [
|
||
...[...this.notes].sort(desc),
|
||
...[...this.coins].sort(desc)
|
||
];
|
||
}
|
||
|
||
// ── Opening / Closing ─────────────────────────────────────────────────────
|
||
|
||
get openingNotes(): any[] {
|
||
return this.openingDenoms.filter((d) => d.kind !== 'coin');
|
||
}
|
||
get openingCoins(): any[] {
|
||
return this.openingDenoms.filter((d) => d.kind === 'coin');
|
||
}
|
||
|
||
/** Opening sorted: all notes (desc) then all coins (desc) */
|
||
get sortedOpeningDenoms(): any[] {
|
||
const desc = (a: any, b: any) => b.denominationValue - a.denominationValue;
|
||
const notes = this.openingDenoms.filter((d) => d.kind !== 'coin').sort(desc);
|
||
const coins = this.openingDenoms.filter((d) => d.kind === 'coin').sort(desc);
|
||
return [
|
||
...notes,
|
||
...coins
|
||
];
|
||
}
|
||
|
||
/** Closing sorted: all notes (desc) then all coins (desc) */
|
||
get sortedClosingDenoms(): any[] {
|
||
const desc = (a: any, b: any) => b.denominationValue - a.denominationValue;
|
||
const notes = this.closingDenoms.filter((d) => d.kind !== 'coin').sort(desc);
|
||
const coins = this.closingDenoms.filter((d) => d.kind === 'coin').sort(desc);
|
||
return [
|
||
...notes,
|
||
...coins
|
||
];
|
||
}
|
||
|
||
get closingTotal(): number {
|
||
const row = this.selectedRow;
|
||
const sign = row?.dbCr === 'Cr' ? 1 : -1; // Cr = Deposit (+), Dr = Withdrawal (-)
|
||
return this.openingTotal + this.denominationTotal * sign;
|
||
}
|
||
|
||
/** Denomination overdraft: check if requested denomination counts exceed opening stock */
|
||
get denominationOverdrafts(): { label: string; requested: number; available: number; shortfall: number }[] {
|
||
if (this.denominationTotal <= 0) return [];
|
||
// Build openMap same way closingDenoms does — key = "kind:denominationValue"
|
||
const openMap = new Map<string, number>();
|
||
for (const d of this.openingDenoms) {
|
||
openMap.set(`${d.kind}:${d.denominationValue}`, Number(d.count ?? 0));
|
||
}
|
||
const overdrafts: { label: string; requested: number; available: number; shortfall: number }[] = [];
|
||
for (const d of [
|
||
...this.notes,
|
||
...this.coins
|
||
]) {
|
||
const requested = this.countFor(d);
|
||
if (requested <= 0) continue;
|
||
const available = openMap.get(this.denomKey(d)) ?? 0;
|
||
if (requested > available) {
|
||
overdrafts.push({ label: d.label, requested, available, shortfall: requested - available });
|
||
}
|
||
}
|
||
return overdrafts;
|
||
}
|
||
|
||
get hasWithdrawalDenomOverdraft(): boolean {
|
||
return this.denominationOverdrafts.length > 0;
|
||
}
|
||
|
||
get closingDenoms(): any[] {
|
||
const openMap = new Map<string, any>();
|
||
for (const d of this.openingDenoms) {
|
||
openMap.set(`${d.kind}:${d.denominationValue}`, d);
|
||
}
|
||
const sign = this.selectedRow?.dbCr === 'Cr' ? 1 : -1;
|
||
const result: any[] = [];
|
||
for (const denom of [
|
||
...this.notes,
|
||
...this.coins
|
||
]) {
|
||
const key = this.denomKey(denom);
|
||
const open = openMap.get(key);
|
||
const openCount = open?.count ?? 0;
|
||
const entered = this.countFor(denom);
|
||
if (openCount === 0 && entered === 0) continue;
|
||
result.push({
|
||
denominationValue: denom.value,
|
||
kind: denom.kind,
|
||
openCount,
|
||
entered,
|
||
closingCount: openCount + entered * sign
|
||
});
|
||
}
|
||
return result.sort((a, b) => b.denominationValue - a.denominationValue);
|
||
}
|
||
|
||
applyDenominationTotal(): void {
|
||
// DD + Cash + GL: amount is derived from Face Value + Charges + GST, not from counted cash —
|
||
// overwriting it here would feed back into the Charges/GST sum and compound on every click.
|
||
if (this.selectedRow && !this.isDdCashGlRow(this.selectedRow)) {
|
||
this.selectedRow.amount = this.denominationTotal;
|
||
}
|
||
}
|
||
|
||
clearDenominations(): void {
|
||
this.denominationCounts = {};
|
||
}
|
||
|
||
addRow(): void {
|
||
const active = this.selectedRow;
|
||
// Disbursement/Fund Transfer/Bank Transfer: auto-add mirror CR row when active row is DR
|
||
if (
|
||
(active?.typ === 'Disbursement' || active?.typ === 'Fund Transfer' || active?.typ === 'Enter Bank Transfer') &&
|
||
active.dbCr === 'Dr'
|
||
) {
|
||
const mirror: TxnRow = {
|
||
typ: active.typ,
|
||
br: active.br ?? 1,
|
||
trTp: '',
|
||
acTyp: 'SD',
|
||
acNo: '',
|
||
ecsAcNo: '',
|
||
dbCr: 'Cr',
|
||
chequeNo: '',
|
||
cheqDate: '',
|
||
amount: active.amount,
|
||
description: active.description ?? '',
|
||
tokNo: '',
|
||
paymentTypeId: this.findTransferPaymentTypeId(),
|
||
bankName: '',
|
||
routingCode: '',
|
||
ddCharges: null
|
||
};
|
||
this.rows.push(mirror);
|
||
this.selectedRow = mirror;
|
||
if (active.acTyp === 'SHR') {
|
||
this.syncShareLinkedAmounts();
|
||
}
|
||
return;
|
||
}
|
||
// Neither TYPE nor TXN TP. ever offers a blank/placeholder option any more, so a fresh row
|
||
// can't start in an unselected state either — it needs a real, valid TYPE from the moment it
|
||
// exists. Cashier: Receipt (money in) is the far more common starting point than Payment
|
||
// (money out, which needs an approved token first). Clerk: Withdrawal Slip, the plainest of
|
||
// the five original types — same TYPE the old blank-then-choose flow most often ended on
|
||
// anyway, just without the extra click to get there.
|
||
const row: TxnRow = {
|
||
typ: this.isClerk ? 'Withdrawal Slip' : 'Receipt',
|
||
br: 1,
|
||
trTp: '',
|
||
acTyp: 'SD',
|
||
acNo: '',
|
||
ecsAcNo: '',
|
||
dbCr: this.isClerk ? 'Dr' : 'Cr',
|
||
chequeNo: '',
|
||
cheqDate: '',
|
||
amount: null,
|
||
description: '',
|
||
tokNo: '',
|
||
paymentTypeId: this.findCashPaymentTypeId(),
|
||
bankName: '',
|
||
routingCode: '',
|
||
ddCharges: null
|
||
};
|
||
this.rows.push(row);
|
||
}
|
||
|
||
/** Called when the TYPE dropdown changes — sets sensible defaults for TXN TP and DR/CR */
|
||
onTypChange(row: TxnRow): void {
|
||
switch (row.typ) {
|
||
case 'Withdrawal Slip':
|
||
row.dbCr = 'Dr';
|
||
row.paymentTypeId = this.findCashPaymentTypeId();
|
||
row.chequeNo = '';
|
||
row.cheqDate = '';
|
||
break;
|
||
// A cheque clears through the bank rather than the drawer, so it opens on the transfer
|
||
// instrument. Cash is still on its dropdown — a cheque can be cashed over the counter — but
|
||
// it is the exception, not what the field should start on.
|
||
case 'Cheque':
|
||
row.dbCr = 'Dr';
|
||
row.paymentTypeId = this.findTransferPaymentTypeId();
|
||
break;
|
||
case 'Disbursement':
|
||
case 'Fund Transfer':
|
||
if (!row.dbCr) row.dbCr = 'Dr';
|
||
row.paymentTypeId = this.findTransferPaymentTypeId();
|
||
break;
|
||
case 'Enter Bank Transfer':
|
||
if (!row.dbCr) row.dbCr = 'Dr';
|
||
row.paymentTypeId = this.findTransferPaymentTypeId();
|
||
break;
|
||
// Cashier-only: Receipt = cash coming in (Cr), Payment = cash going out (Dr). The cashier
|
||
// TXN TP. dropdown only ever offers Cash (see paymentTypesForRow()), so that's the only
|
||
// sensible default here.
|
||
case 'Receipt':
|
||
row.dbCr = 'Cr';
|
||
row.paymentTypeId = this.findCashPaymentTypeId();
|
||
row.chequeNo = '';
|
||
row.cheqDate = '';
|
||
break;
|
||
case 'Payment':
|
||
row.dbCr = 'Dr';
|
||
row.paymentTypeId = this.findCashPaymentTypeId();
|
||
row.chequeNo = '';
|
||
row.cheqDate = '';
|
||
break;
|
||
// A DD is bought with cash: the purchaser hands over Face Value + Charges + GST, so it is
|
||
// money coming IN (Cr) against the GL the draft is drawn on. All three of Cr, a cash payment
|
||
// type and AC.TP = GL are what isDdCashGlRow() looks for -- without them the DD Charges & GST
|
||
// panel never appears and the row would post as an ordinary entry with the charges silently
|
||
// dropped.
|
||
//
|
||
// A clerk funds a draft by debiting a member's own account, so their row opens on savings and
|
||
// is debited, and GL is not on their AC.TP list at all (see acTypOptionsFor). Opening them on
|
||
// GL put the row on the cash path, which asks for a walk-in purchaser and a note-and-coin
|
||
// count that a clerk debiting an account does not have.
|
||
case 'DD':
|
||
row.dbCr = this.isClerk ? 'Dr' : 'Cr';
|
||
row.acTyp = this.isClerk ? 'SD' : 'GL';
|
||
row.paymentTypeId = this.isClerk ? this.findTransferPaymentTypeId() : this.findCashPaymentTypeId();
|
||
row.chequeNo = '';
|
||
row.cheqDate = '';
|
||
if (this.sponsorBanks.length > 0 && !row.sponsorBankId) {
|
||
row.sponsorBankId = this.sponsorBanks[0].id;
|
||
}
|
||
this.applyDdGlMappingToRow(row);
|
||
break;
|
||
}
|
||
}
|
||
|
||
private findCashPaymentTypeId(): number | null {
|
||
const pt = this.paymentTypes.find((p: any) => !!p.isCashPayment);
|
||
return pt?.id ?? null;
|
||
}
|
||
|
||
private findTransferPaymentTypeId(): number | null {
|
||
const pt = this.paymentTypes.find((p: any) => /transfer|neft|rtgs|imps/i.test(p.name ?? ''));
|
||
return pt?.id ?? null;
|
||
}
|
||
|
||
/** TYPE=DD + TXN TP=Cash */
|
||
isDdCashGlRow(row: TxnRow): boolean {
|
||
if (this.isDdAccountDebitRow(row)) return false;
|
||
const pt = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId);
|
||
return row.typ === 'DD' && (row.acTyp === 'GL' || !row.acTyp || !!pt?.isCashPayment);
|
||
}
|
||
|
||
/**
|
||
* TYPE=DD funded by debiting a member's savings/current account, or a GL account directly, rather
|
||
* than by cash.
|
||
*
|
||
* Clerk-only. A cashier's counter takes cash and nothing else, so offering them an account/GL debit
|
||
* would let a draft be raised with no cash to reconcile it against — and the clerk flow is the one
|
||
* that carries a maker-checker step over the money moved. AC.TP = GL means something different for
|
||
* a cashier's own DD row (a walk-in cash/GL purchase, see isDdCashGlRow()) — this method is checked
|
||
* first there specifically so a clerk's GL row is claimed here instead.
|
||
*/
|
||
isDdAccountDebitRow(row: TxnRow): boolean {
|
||
return (
|
||
this.isClerk &&
|
||
row.typ === 'DD' &&
|
||
(row.acTyp === 'SD' || row.acTyp === 'CA' || row.acTyp === 'CB' || row.acTyp === 'GL')
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Any DD row the charges/GST panel applies to.
|
||
*
|
||
* Charges and GST come from the admin's DD configuration and are collected whichever way the
|
||
* draft is paid for — the difference is only where the money comes from, so the panel is the same
|
||
* for both and the label on the total changes rather than the arithmetic.
|
||
*/
|
||
isDdRow(row: TxnRow): boolean {
|
||
return this.isDdCashGlRow(row) || this.isDdAccountDebitRow(row);
|
||
}
|
||
|
||
/**
|
||
* Whether this row needs an Entry Type tag — a credit into a GL account, which is exactly the
|
||
* case where the money's real purpose isn't otherwise recorded anywhere (see TxnRow.entryTypeCvId).
|
||
* A debit out of a GL account, or any entry against a savings/current/share account, carries no
|
||
* such ambiguity — the account itself says what it's for.
|
||
*/
|
||
showsEntryType(row: TxnRow): boolean {
|
||
return row.acTyp === 'GL' && row.dbCr === 'Cr';
|
||
}
|
||
|
||
/**
|
||
* Entry Type choices for a GL credit — the "GLEntryType" code (Admin > System > Manage Codes),
|
||
* fetched once in ngOnInit(). A code value, not a free-text field, so the day-end "group by type"
|
||
* this exists for stays reliable; an admin extends the list there, not in this file.
|
||
*/
|
||
entryTypeOptions: { id: number; name: string }[] = [];
|
||
|
||
/**
|
||
* Whether this row may raise more than one draft off a single debit.
|
||
*
|
||
* Any funding source the clerk debits: a member's savings/current account, or a GL account for an
|
||
* institutional slip. Cash is the exception the batch endpoint still refuses, and for a real
|
||
* reason rather than an arbitrary one — a slip is funded by one payment, and one counted pile of
|
||
* notes cannot be apportioned across drafts without the denomination total ceasing to prove what
|
||
* was actually taken at the counter.
|
||
*/
|
||
canRowBeMultiDd(row: TxnRow): boolean {
|
||
return this.isDdAccountDebitRow(row);
|
||
}
|
||
|
||
/** Whether the clerk has asked this row for more than one draft. */
|
||
isMultiDdRow(row: TxnRow): boolean {
|
||
return this.canRowBeMultiDd(row) && (Number(row.ddCount) || 1) > 1;
|
||
}
|
||
|
||
/** How many drafts this row raises — always at least 1. */
|
||
ddCountFor(row: TxnRow): number {
|
||
const n = Number(row.ddCount) || 1;
|
||
return n > 1 ? n : 1;
|
||
}
|
||
|
||
/**
|
||
* Clamps a typed DD count into range and discards lines that no longer fit.
|
||
*
|
||
* Lowering the count after the dialog has been filled would otherwise leave lines beyond the new
|
||
* count still on the row: invisible to the clerk, but still sent at Save and still debited for.
|
||
*/
|
||
onDdCountChange(row: TxnRow, value: number): void {
|
||
const n = Math.floor(Number(value) || 1);
|
||
const previous = this.ddCountFor(row);
|
||
row.ddCount = n < 1 ? 1 : n > this.maxDdSlipLines ? this.maxDdSlipLines : n;
|
||
if (row.ddLines && row.ddLines.length !== row.ddCount) {
|
||
this.clearDdCapture(row);
|
||
}
|
||
// Moving onto a slip empties the row's own amount and charge, because on a slip they mean
|
||
// nothing: each draft carries its own face value and its own charge, entered in the dialog, and
|
||
// these two fields become the total of those. Leaving a previously typed figure sitting here
|
||
// would read as the slip's total while describing only one draft — which is exactly what showed
|
||
// ₹1,012 against four drafts of ₹1,000.
|
||
if (row.ddCount > 1 && previous <= 1) {
|
||
row.amount = null;
|
||
row.ddCharges = null;
|
||
}
|
||
}
|
||
|
||
/** Mirrors MAX_SLIP_LINES on the server, so an over-long slip is refused here with a clear message. */
|
||
readonly maxDdSlipLines = 20;
|
||
|
||
/**
|
||
* The run of leaflet numbers a slip will probably draw, for display only.
|
||
*
|
||
* Counts up from the next-available number, preserving its width so 000123 reads as 000124 rather
|
||
* than 124. Indicative by nature — the binding allocation happens inside the server's submit
|
||
* transaction, and a slip filed after another clerk's gets the numbers following theirs — which is
|
||
* why the dialog labels these provisional and the confirmed numbers come back on submission.
|
||
*
|
||
* A number that is not a plain run of digits (a prefixed or hyphenated series) cannot be counted
|
||
* on safely, so nothing is shown rather than something wrong.
|
||
*/
|
||
private previewDdNumbersFrom(firstDdNumber: string, count: number): string[] | undefined {
|
||
const first = (firstDdNumber || '').trim();
|
||
if (!/^\d+$/.test(first)) return undefined;
|
||
const width = first.length;
|
||
const start = Number(first);
|
||
if (!Number.isSafeInteger(start)) return undefined;
|
||
return Array.from({ length: count }, (_v, i) => String(start + i).padStart(width, '0'));
|
||
}
|
||
|
||
/** GST on a single charge figure, at the admin-configured rate. */
|
||
private ddGstOn(charges: number): number {
|
||
const c = Number(charges) || 0;
|
||
if (c <= 0) return 0;
|
||
return Math.round(c * this.ddGstPercentage) / 100;
|
||
}
|
||
|
||
/**
|
||
* GST on DD charges only (never on the DD face value), at the admin-configured rate.
|
||
*
|
||
* On a slip this is the sum of each line's own GST rather than GST on the summed charges. The two
|
||
* can differ by a paisa once rounding is applied per line, and it is the per-line figure that is
|
||
* stored against each draft and posted by the server — so the row must show that same total.
|
||
*/
|
||
ddGstAmountFor(row: TxnRow): number {
|
||
if (row.ddLines?.length) {
|
||
return row.ddLines.reduce((sum, l) => sum + this.ddGstOn(l.chargesAmount), 0);
|
||
}
|
||
return this.ddGstOn(row.ddCharges ?? 0);
|
||
}
|
||
|
||
/** Face value of the row — the sum of every line once a slip has been captured. */
|
||
ddFaceValueFor(row: TxnRow): number {
|
||
if (row.ddLines?.length) {
|
||
return row.ddLines.reduce((sum, l) => sum + (Number(l.faceValue) || 0), 0);
|
||
}
|
||
return Number(row.amount) || 0;
|
||
}
|
||
|
||
/** Charges on the row — the sum of every line's own charge once a slip has been captured. */
|
||
ddChargesFor(row: TxnRow): number {
|
||
if (row.ddLines?.length) {
|
||
return row.ddLines.reduce((sum, l) => sum + (Number(l.chargesAmount) || 0), 0);
|
||
}
|
||
return Number(row.ddCharges) || 0;
|
||
}
|
||
|
||
/** Amount + Charges + GST, rounded UP to the next whole rupee — the total collected from the customer. */
|
||
ddTotalPayableFor(row: TxnRow): number {
|
||
return Math.ceil(this.ddFaceValueFor(row) + this.ddChargesFor(row) + this.ddGstAmountFor(row) - 1e-9);
|
||
}
|
||
|
||
/**
|
||
* What the TOTAL field shows — a figure, or a dash while a slip has nothing to total yet.
|
||
*
|
||
* A slip whose drafts have not been entered has no total: its face values and charges live on the
|
||
* lines, and there are no lines. Showing ₹0 there would read as a real total of zero rather than
|
||
* as "not yet known", so the field says so instead.
|
||
*/
|
||
ddTotalDisplayFor(row: TxnRow): string {
|
||
if (this.isSlipAwaitingDetails(row)) {
|
||
return '—';
|
||
}
|
||
return String(this.ddTotalPayableFor(row));
|
||
}
|
||
|
||
/** GST field's text, dashed for the same reason as the total while a slip is still empty. */
|
||
ddGstDisplayFor(row: TxnRow): string {
|
||
if (this.isSlipAwaitingDetails(row)) {
|
||
return '—';
|
||
}
|
||
return this.ddGstAmountFor(row).toFixed(2);
|
||
}
|
||
|
||
/** A slip the clerk has asked for but not yet filled in — nothing to total from. */
|
||
isSlipAwaitingDetails(row: TxnRow): boolean {
|
||
return this.isMultiDdRow(row) && !row.ddLines?.length;
|
||
}
|
||
|
||
/**
|
||
* Forgets a row's captured draft details, leaving the row itself intact.
|
||
*
|
||
* Both shapes are cleared together on purpose: a row carrying a stale ddDetails alongside fresh
|
||
* ddLines (or the reverse) would be read by whichever of the two Save paths it reached first, and
|
||
* the clerk would have no way of seeing which one went.
|
||
*/
|
||
private clearDdCapture(row: TxnRow): void {
|
||
row.ddDetails = undefined;
|
||
row.ddLines = undefined;
|
||
row.ddInventoryId = undefined;
|
||
row.ddFundingAccountId = undefined;
|
||
row.ddFundingClientId = undefined;
|
||
}
|
||
|
||
get isSelectedRowDdCashGl(): boolean {
|
||
return !!this.selectedRow && this.isDdCashGlRow(this.selectedRow);
|
||
}
|
||
|
||
/** Cash the cashier must physically count for the selected DD row — Face Value + Charges + GST. */
|
||
get ddTargetTotal(): number {
|
||
return this.selectedRow ? this.ddTotalPayableFor(this.selectedRow) : 0;
|
||
}
|
||
|
||
ddDenomMatchesTarget(): boolean {
|
||
return !!this.selectedRow && Math.abs(this.denominationTotal - this.ddTargetTotal) < 0.005;
|
||
}
|
||
|
||
branchNameFor(branchId: number): string {
|
||
const map: Record<number, string> = {
|
||
1: '001 · Main',
|
||
2: '002 · M.G. Road',
|
||
14: '014 · Station Rd',
|
||
27: '027 · Industrial'
|
||
};
|
||
return map[branchId] || `Branch ${branchId}`;
|
||
}
|
||
|
||
/**
|
||
* Cashier Walk-in DD creation flow:
|
||
* 1. Validates one-time persistent GL configuration
|
||
* 2. Auto-selects next available leaflet
|
||
* 3. Opens dialog to capture purchaser/beneficiary details
|
||
* 4. Submits issue request directly with physical denominations for manager approval
|
||
*/
|
||
openIssueWalkinDd(row: TxnRow): void {
|
||
if (!this.isDdGlMappingConfigured) {
|
||
this.showAlert('error', 'DD GL Mapping Not Configured', [
|
||
'DD GL mapping is not configured.',
|
||
'Please ask an administrator to set up the GL accounts in Admin > Sponsor DD GST / GL Settings before creating Demand Drafts.'
|
||
]);
|
||
return;
|
||
}
|
||
// On a slip the face value lives on each line, typed in the dialog that is about to open — the
|
||
// row's own AMOUNT is the sum of those lines and cannot exist yet. Demanding it here would make
|
||
// a slip impossible to start.
|
||
const isSlip = this.isMultiDdRow(row);
|
||
const faceValue = Number(row.amount);
|
||
if (!isSlip && (!faceValue || faceValue <= 0)) {
|
||
this.showAlert('error', 'Invalid Amount', ['Please enter the DD Face Value amount before proceeding.']);
|
||
return;
|
||
}
|
||
if (isSlip && !this.isDdAccountDebitRow(row)) {
|
||
this.showAlert('error', 'Multiple DDs Not Available', [
|
||
'More than one draft can only be raised against a member savings or current account.',
|
||
'Set NO. OF DD back to 1 for this row.'
|
||
]);
|
||
return;
|
||
}
|
||
const chargesAmount = Number(row.ddCharges) || 0;
|
||
const gstAmount = this.ddGstAmountFor(row);
|
||
const totalCashPayable = this.ddTotalPayableFor(row);
|
||
const accountDebit = this.isDdAccountDebitRow(row);
|
||
|
||
// An account-funded draft needs the account resolved to a Fineract id before it can be sent:
|
||
// the server takes a savings/current account id and a client id, not the typed number. Both are
|
||
// filled in by the A/C NO. lookup, so an unresolved account means the number was never found.
|
||
// A GL-funded draft is the exception: a GL account is never tied to a client at all (see
|
||
// fetchAllDetails() — GL rows never populate rowClientIds), so only the account id is required.
|
||
const isGlDebit = accountDebit && row.acTyp === 'GL';
|
||
let fundingAccountId: number | null = null;
|
||
let fundingClientId: number | null = null;
|
||
if (accountDebit) {
|
||
const acNo = (row.acNo || '').trim();
|
||
if (!acNo) {
|
||
this.showAlert('error', 'Account Required', [
|
||
isGlDebit
|
||
? 'Enter the GL account the Demand Draft is funded from.'
|
||
: 'Enter the account number the Demand Draft is funded from.'
|
||
]);
|
||
return;
|
||
}
|
||
fundingAccountId = this.rowAccountIds[acNo] ?? null;
|
||
fundingClientId = isGlDebit ? null : (this.rowClientIds[acNo] ?? null);
|
||
if (!fundingAccountId || (!isGlDebit && !fundingClientId)) {
|
||
this.showAlert('error', 'Account Not Found', [
|
||
`Account ${acNo} could not be resolved.`,
|
||
'Check the account number and wait for its details to load before raising the draft.'
|
||
]);
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Only a cash draft has notes to reconcile. An account debit takes no cash at the counter, so a
|
||
// denomination count left over from an earlier row must not be checked against it. A slip is
|
||
// always an account debit, and its total is not known until the dialog is filled in either.
|
||
if (
|
||
!accountDebit &&
|
||
!isSlip &&
|
||
this.denominationTotal > 0 &&
|
||
Math.abs(this.denominationTotal - totalCashPayable) > 0.005
|
||
) {
|
||
this.showAlert('error', 'Denomination Mismatch', [
|
||
`Cash denomination counted (₹${this.denominationTotal}) does not match total payable cash (₹${totalCashPayable}).`,
|
||
'Please verify and match the physical cash denomination count.'
|
||
]);
|
||
return;
|
||
}
|
||
|
||
this.saving = true;
|
||
this.sponsorDDService.getNextAvailableLeaflet(row.br, row.sponsorBankId ?? undefined).subscribe({
|
||
next: (leaflet) => {
|
||
this.saving = false;
|
||
if (!leaflet) {
|
||
this.showAlert('error', 'No Available Leaflets', [
|
||
'No available Demand Draft leaflets found for this branch and sponsor bank.',
|
||
'Please register a DD inventory series first.'
|
||
]);
|
||
return;
|
||
}
|
||
|
||
const selectedBank = this.sponsorBanks.find((b: any) => b.id === row.sponsorBankId);
|
||
const branchName = this.branchNameFor(row.br) || 'Branch ' + row.br;
|
||
|
||
// A slip is allocated by inventory, not leaflet: the server picks the whole run inside its
|
||
// own locked transaction. Without the inventory behind this branch's next-available leaflet
|
||
// there is nothing to allocate from, so the slip is refused here rather than at submit.
|
||
if (isSlip && !leaflet.inventoryId) {
|
||
this.showAlert('error', 'DD Inventory Not Identified', [
|
||
'The DD inventory behind this branch and sponsor bank could not be identified, so multiple drafts cannot be allocated together.',
|
||
'Set NO. OF DD back to 1, or ask an administrator to check the DD inventory registration.'
|
||
]);
|
||
return;
|
||
}
|
||
|
||
const dialogRef = this.dialog.open(CashierIssueDdDialogComponent, {
|
||
width: isSlip ? '760px' : '600px',
|
||
disableClose: true,
|
||
data: {
|
||
leaflet,
|
||
branchName,
|
||
sponsorBankName: selectedBank?.name || 'Sponsor Bank',
|
||
faceValue,
|
||
chargesAmount,
|
||
gstPercentage: this.ddGstPercentage,
|
||
gstAmount,
|
||
totalCashPayable,
|
||
paymentMethod: accountDebit ? ('ACCOUNT_DEBIT' as const) : ('CASH' as const),
|
||
accountNo: accountDebit ? row.acNo : undefined,
|
||
accountHolderName: accountDebit ? this.rowAccountNames[(row.acNo || '').trim()] : undefined,
|
||
ddCount: isSlip ? this.ddCountFor(row) : undefined,
|
||
glFunded: isGlDebit,
|
||
previewDdNumbers: isSlip ? this.previewDdNumbersFrom(leaflet.ddNumber, this.ddCountFor(row)) : undefined
|
||
}
|
||
});
|
||
|
||
// Recorded on the row, not sent. Save is what commits the worksheet, and a DD row that
|
||
// submitted itself here left that button with nothing to do — so the clerk had no way to
|
||
// review the entered details, and no way to abandon them either.
|
||
dialogRef.afterClosed().subscribe((result) => {
|
||
if (!result) return;
|
||
|
||
// A slip records its lines instead of a single beneficiary, and takes the row's AMOUNT and
|
||
// CHARGES with it: from here on those two are the sum of the lines, shown read-only, so a
|
||
// clerk cannot leave a row total behind that disagrees with what the drafts add up to.
|
||
if (result.lines) {
|
||
this.clearDdCapture(row);
|
||
row.ddLines = result.lines;
|
||
row.ddInventoryId = leaflet.inventoryId;
|
||
row.ddFundingAccountId = fundingAccountId!;
|
||
// Null on a GL slip, which is raised against no member at all.
|
||
row.ddFundingClientId = fundingClientId ?? undefined;
|
||
row.amount = this.ddFaceValueFor(row);
|
||
row.ddCharges = this.ddChargesFor(row);
|
||
this.showAlert('success', 'Slip Details Captured', [
|
||
`${result.lines.length} demand drafts captured in favour of ${result.lines
|
||
.map((l: DdSlipLine) => l.beneficiaryName)
|
||
.join(', ')}.`,
|
||
`₹${this.ddTotalPayableFor(row)} will be debited from ${isGlDebit ? 'GL account ' : ''}${row.acNo} as a single ${
|
||
isGlDebit ? 'journal entry' : 'transaction'
|
||
} once a checker approves.`,
|
||
'DD numbers are allocated on submission. Press Save to send this slip for approval.'
|
||
]);
|
||
return;
|
||
}
|
||
|
||
row.ddDetails = {
|
||
leafletId: leaflet.id,
|
||
ddNumber: leaflet.ddNumber,
|
||
beneficiaryName: result.beneficiaryName,
|
||
payableAtBranch: result.payableAtBranch,
|
||
payableAtCity: result.payableAtCity,
|
||
purchaser: accountDebit
|
||
? undefined
|
||
: {
|
||
name: result.walkinPurchaserName,
|
||
address: result.walkinPurchaserAddress,
|
||
aadhaar: result.walkinPurchaserAadhaar
|
||
},
|
||
fundingAccountId: accountDebit ? fundingAccountId! : undefined,
|
||
fundingClientId: accountDebit ? fundingClientId! : undefined
|
||
};
|
||
this.showAlert('success', 'DD Details Captured', [
|
||
`DD No. ${leaflet.ddNumber} · in favour of ${result.beneficiaryName}.`,
|
||
accountDebit
|
||
? `₹${this.ddTotalPayableFor(row)} will be debited from ${row.acNo} once a checker approves.`
|
||
: `₹${this.ddTotalPayableFor(row)} to be collected at the counter.`,
|
||
'Press Save to send this draft for approval.'
|
||
]);
|
||
});
|
||
},
|
||
error: (err) => {
|
||
this.saving = false;
|
||
this.showAlert('error', 'Error Auto-Selecting DD Leaflet', [err.error?.message || err.message]);
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Raises a Demand Draft funded by debiting a member's savings/current account, or a GL account
|
||
* directly.
|
||
*
|
||
* Clerk-only, and a maker-checker request like the cash flow: nothing is debited here. The
|
||
* account/GL is only touched when a checker approves, which for a member account is also when the
|
||
* balance is re-checked — so a draft raised against a sufficient balance can still be refused if
|
||
* the balance has since gone (a GL account carries no such balance check). No denominations are
|
||
* sent, because no cash is taken.
|
||
*
|
||
* The funding account goes in exactly one of savingsAccountId, currentAccountId or glAccountId: the
|
||
* server insists on exactly one, since which one decides what gets debited on approval.
|
||
*/
|
||
private submitAccountDebitDd(
|
||
row: TxnRow,
|
||
leaflet: any,
|
||
beneficiaryData: any,
|
||
fundingAccountId: number,
|
||
fundingClientId: number | null
|
||
): void {
|
||
const d = businessToday();
|
||
const issuedDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||
const isCurrent = row.acTyp === 'CA' || row.acTyp === 'CB';
|
||
const isGl = row.acTyp === 'GL';
|
||
|
||
const payload = {
|
||
clientId: isGl ? undefined : (fundingClientId ?? undefined),
|
||
beneficiaryName: beneficiaryData.beneficiaryName,
|
||
payableAtBranch: beneficiaryData.payableAtBranch,
|
||
payableAtCity: beneficiaryData.payableAtCity,
|
||
faceValue: Number(row.amount),
|
||
chargesAmount: Number(row.ddCharges) || 0,
|
||
issuingBranchId: row.br,
|
||
issuedDate,
|
||
paymentMethod: (isGl ? 'GL' : 'ACCOUNT_DEBIT') as 'GL' | 'ACCOUNT_DEBIT',
|
||
savingsAccountId: isGl || isCurrent ? undefined : fundingAccountId,
|
||
currentAccountId: isGl || !isCurrent ? undefined : fundingAccountId,
|
||
glAccountId: isGl ? fundingAccountId : undefined
|
||
};
|
||
|
||
this.saving = true;
|
||
this.sponsorDDService.submitIssueRequest(leaflet.id, payload).subscribe({
|
||
next: () => {
|
||
this.saving = false;
|
||
this.showAlert('success', 'DD Issuance Request Submitted', [
|
||
`Demand Draft request for DD No. ${leaflet.ddNumber} (Face Value: ₹${row.amount}) submitted successfully!`,
|
||
`₹${this.ddTotalPayableFor(row)} will be debited from ${row.acNo} on approval.`,
|
||
'Sent to Manager / Head Cashier for maker-checker approval.'
|
||
]);
|
||
row.amount = null;
|
||
row.ddCharges = null;
|
||
row.description = '';
|
||
// Cleared with the row: leaving the captured details behind would let a second Save raise
|
||
// the same draft again against a leaflet that has already been used.
|
||
row.ddDetails = undefined;
|
||
},
|
||
error: (err) => {
|
||
this.saving = false;
|
||
this.showAlert('error', 'DD Submission Failed', [err.error?.message || err.message]);
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Sends the drafts on the worksheet for approval.
|
||
*
|
||
* Each row carries the beneficiary details the dialog captured; a row that was never filled in is
|
||
* refused by name rather than skipped, because a clerk who pressed Save expecting one draft and
|
||
* got none has no way of telling which row was missing.
|
||
*/
|
||
private saveDdRows(ddRows: TxnRow[]): void {
|
||
const unfilled = ddRows.filter((r) => !r.ddDetails && !r.ddLines?.length);
|
||
if (unfilled.length > 0) {
|
||
this.showAlert('error', 'DD Details Not Entered', [
|
||
unfilled.length === 1
|
||
? 'One DD row has no beneficiary details yet.'
|
||
: `${unfilled.length} DD rows have no beneficiary details yet.`,
|
||
'Use "Fill DD Details & Send for Approval" on each DD row first, then press Save.'
|
||
]);
|
||
return;
|
||
}
|
||
|
||
for (const row of ddRows) {
|
||
// A slip goes to the batch endpoint, which allocates its leaflets and posts one debit for the
|
||
// whole run. Sending its lines one at a time through the single-DD endpoint would debit the
|
||
// member once per draft — the very thing a slip exists to avoid.
|
||
if (row.ddLines?.length) {
|
||
this.submitDdSlip(row);
|
||
continue;
|
||
}
|
||
|
||
const details = row.ddDetails!;
|
||
const leaflet = { id: details.leafletId, ddNumber: details.ddNumber };
|
||
if (this.isDdAccountDebitRow(row)) {
|
||
this.submitAccountDebitDd(
|
||
row,
|
||
leaflet,
|
||
{
|
||
beneficiaryName: details.beneficiaryName,
|
||
payableAtBranch: details.payableAtBranch,
|
||
payableAtCity: details.payableAtCity
|
||
},
|
||
details.fundingAccountId!,
|
||
details.fundingClientId!
|
||
);
|
||
} else {
|
||
this.submitWalkinCashDd(row, leaflet, {
|
||
beneficiaryName: details.beneficiaryName,
|
||
payableAtBranch: details.payableAtBranch,
|
||
payableAtCity: details.payableAtCity,
|
||
walkinPurchaserName: details.purchaser?.name,
|
||
walkinPurchaserAddress: details.purchaser?.address,
|
||
walkinPurchaserAadhaar: details.purchaser?.aadhaar
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Submits a multi-DD slip: several drafts raised together against one debit of the member's
|
||
* account.
|
||
*
|
||
* Maker-checker like the single-draft flow, and nothing is debited here. On approval the server
|
||
* withdraws the combined face value, charges and GST of every line as ONE transaction and posts
|
||
* one journal entry against it, then issues each draft — so the member's passbook carries a single
|
||
* entry naming all the DD numbers rather than one entry per draft. The checker approves or rejects
|
||
* the slip whole; there is no approving four drafts out of five.
|
||
*
|
||
* The leaflets are not chosen here. The server allocates the run inside its own locked transaction
|
||
* and returns which number went to which line, which is the only point at which the numbers are
|
||
* real — two clerks submitting at the same moment can therefore never be handed the same one.
|
||
*/
|
||
private submitDdSlip(row: TxnRow): void {
|
||
const lines = row.ddLines ?? [];
|
||
const inventoryId = row.ddInventoryId;
|
||
const fundingAccountId = row.ddFundingAccountId;
|
||
const fundingClientId = row.ddFundingClientId;
|
||
|
||
const isGl = row.acTyp === 'GL';
|
||
|
||
// Re-checked at Save, not just when the details were captured: the row's TYPE, AC.TP or account
|
||
// number can all be edited afterwards, and a slip that has drifted onto a cash row is one the
|
||
// batch endpoint refuses outright. A GL slip is raised against no member, so it carries no
|
||
// client id and none is required of it — the same asymmetry as the single-draft GL path.
|
||
if (!inventoryId || !fundingAccountId || (!isGl && !fundingClientId) || !this.canRowBeMultiDd(row)) {
|
||
this.showAlert('error', 'Slip Cannot Be Submitted', [
|
||
'This row has changed since its drafts were captured, so the slip no longer matches it.',
|
||
'Open "Fill DD Details" again and re-enter the drafts.'
|
||
]);
|
||
return;
|
||
}
|
||
|
||
const d = businessToday();
|
||
const issuedDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||
const isCurrent = row.acTyp === 'CA' || row.acTyp === 'CB';
|
||
|
||
// The funding source goes in exactly one of savingsAccountId, currentAccountId or glAccountId,
|
||
// as the single-draft path does: which one is set is what decides the debit leg on approval.
|
||
const payload = {
|
||
clientId: isGl ? undefined : fundingClientId,
|
||
issuingBranchId: row.br,
|
||
issuedDate,
|
||
paymentMethod: (isGl ? 'GL' : 'ACCOUNT_DEBIT') as 'GL' | 'ACCOUNT_DEBIT',
|
||
savingsAccountId: isGl || isCurrent ? undefined : fundingAccountId,
|
||
currentAccountId: !isGl && isCurrent ? fundingAccountId : undefined,
|
||
glAccountId: isGl ? fundingAccountId : undefined,
|
||
lines: lines.map((l) => ({
|
||
beneficiaryName: l.beneficiaryName,
|
||
payableAtBranch: l.payableAtBranch,
|
||
payableAtCity: l.payableAtCity,
|
||
faceValue: Number(l.faceValue),
|
||
chargesAmount: Number(l.chargesAmount) || 0
|
||
}))
|
||
};
|
||
|
||
const totalDebited = this.ddTotalPayableFor(row);
|
||
const accountNo = row.acNo;
|
||
|
||
this.saving = true;
|
||
this.sponsorDDService.submitIssueRequestBatch(inventoryId, payload).subscribe({
|
||
next: (result: any) => {
|
||
this.saving = false;
|
||
// The allocated numbers, named back to the clerk. Until this response they were provisional,
|
||
// and these are what the customer's drafts will actually carry.
|
||
const allocated = (result?.lines ?? [])
|
||
.map((l: any) => `${l.ddNumber} · ${l.beneficiaryName} · ₹${l.faceValue}`)
|
||
.join('\n');
|
||
this.showAlert('success', 'DD Slip Submitted', [
|
||
`Slip of ${lines.length} demand drafts submitted successfully.`,
|
||
allocated || `${lines.length} drafts allocated.`,
|
||
`₹${totalDebited} will be debited from ${accountNo} as a single transaction on approval.`,
|
||
'Sent to Manager / Head Cashier for maker-checker approval of the whole slip.'
|
||
]);
|
||
row.amount = null;
|
||
row.ddCharges = null;
|
||
row.description = '';
|
||
row.ddCount = 1;
|
||
// Cleared with the row, as the single-draft paths do: leaving the lines behind would let a
|
||
// second Save raise the same slip again against leaflets already allocated.
|
||
this.clearDdCapture(row);
|
||
},
|
||
error: (err) => {
|
||
this.saving = false;
|
||
this.showAlert('error', 'DD Slip Submission Failed', [
|
||
err.error?.message || err.message,
|
||
'No drafts were issued and nothing has been debited — the whole slip was refused together.'
|
||
]);
|
||
}
|
||
});
|
||
}
|
||
|
||
private submitWalkinCashDd(row: TxnRow, leaflet: any, purchaserData: any): void {
|
||
// Read through countFor, which knows the map is keyed by kind:value. Indexing by d.id looked
|
||
// right but matched nothing, so every cash DD went in with no breakdown at all and its money
|
||
// vanished from the closing report -- the notes were counted on screen and then dropped.
|
||
const denominationMap: { [key: string]: number } = {};
|
||
for (const d of [
|
||
...this.notes,
|
||
...this.coins
|
||
]) {
|
||
const count = this.countFor(d);
|
||
if (count > 0) {
|
||
denominationMap[this.denomKey(d)] = count;
|
||
}
|
||
}
|
||
|
||
// The breakdown is the only record of which notes were taken, and the server insists it add up
|
||
// to the cash collected. Checked here so the cashier is told which figure is wrong while the
|
||
// counts are still on screen, rather than getting a rejection after the fact.
|
||
//
|
||
// ddTotalPayableFor rounds up to the whole rupee actually collected, which is what the notes
|
||
// can add to; the exact figure with paise is accepted too, for a draft that lands on a rupee.
|
||
const totalCollected = Number(this.ddTotalPayableFor(row));
|
||
const exactPayable = Number(row.amount || 0) + (Number(row.ddCharges) || 0) + this.ddGstAmountFor(row);
|
||
const countedTotal = Object.entries(denominationMap).reduce(
|
||
(sum, [
|
||
key,
|
||
count
|
||
]) => sum + Number(key.split(':')[1]) * count,
|
||
0
|
||
);
|
||
if (countedTotal !== totalCollected && countedTotal !== exactPayable) {
|
||
this.showAlert('error', 'Denomination Entry Does Not Match', [
|
||
countedTotal === 0
|
||
? 'Enter the notes and coins taken from the purchaser before submitting.'
|
||
: `The notes and coins entered come to ₹${countedTotal.toFixed(2)}, but ₹${totalCollected.toFixed(2)} is collected (face value + charges + GST).`,
|
||
'The breakdown must equal the cash taken — it is what the closing report and the drawer are checked against.'
|
||
]);
|
||
return;
|
||
}
|
||
|
||
const d = businessToday();
|
||
const issuedDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||
|
||
const payload = {
|
||
walkinPurchaserName: purchaserData.walkinPurchaserName,
|
||
walkinPurchaserAddress: purchaserData.walkinPurchaserAddress,
|
||
walkinPurchaserAadhaar: purchaserData.walkinPurchaserAadhaar,
|
||
beneficiaryName: purchaserData.beneficiaryName,
|
||
payableAtBranch: purchaserData.payableAtBranch,
|
||
payableAtCity: purchaserData.payableAtCity,
|
||
faceValue: Number(row.amount),
|
||
chargesAmount: Number(row.ddCharges) || 0,
|
||
issuingBranchId: row.br,
|
||
issuedDate,
|
||
paymentMethod: 'CASH' as const,
|
||
denominationData: Object.keys(denominationMap).length > 0 ? JSON.stringify(denominationMap) : undefined
|
||
};
|
||
|
||
this.saving = true;
|
||
this.sponsorDDService.submitIssueRequest(leaflet.id, payload).subscribe({
|
||
next: () => {
|
||
this.saving = false;
|
||
this.showAlert('success', 'DD Issuance Request Submitted', [
|
||
`Demand Draft request for DD No. ${leaflet.ddNumber} (Face Value: ₹${row.amount}, Total Cash: ₹${this.ddTotalPayableFor(row)}) submitted successfully!`,
|
||
'Sent to Manager / Head Cashier for maker-checker approval.'
|
||
]);
|
||
row.amount = null;
|
||
row.ddCharges = null;
|
||
row.description = '';
|
||
// See the account-debit path: a second Save must not re-raise a draft already issued.
|
||
row.ddDetails = undefined;
|
||
this.clearDenominations();
|
||
},
|
||
error: (err) => {
|
||
this.saving = false;
|
||
this.showAlert('error', 'DD Submission Failed', [err.error?.message || err.message]);
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Submits a clerk double-entry request (maker-checker, PENDING until manager approves).
|
||
* Disbursement: 1 DR [GL] source → N CR savings/current/GL destinations.
|
||
* Collection: N DR [Savings/CA] sources → 1 CR [GL] target.
|
||
*/
|
||
private submitDisbursementDoubleEntry(): void {
|
||
const drRows = this.rows.filter((r) => r.dbCr === 'Dr');
|
||
const crRows = this.rows.filter((r) => r.dbCr === 'Cr');
|
||
// Captured now, not re-read from this.rows inside the async success callback below — the rows
|
||
// array gets reset to a fresh blank row as soon as that callback runs.
|
||
const isFundTransfer = this.rows[0]?.typ === 'Fund Transfer';
|
||
|
||
const isCollectionMode = drRows.every((r) => r.acTyp !== 'GL') && crRows.length === 1 && crRows[0].acTyp === 'GL';
|
||
const isDisbursementMode = drRows.length === 1 && drRows[0].acTyp === 'GL';
|
||
|
||
// Validate each row has account, amount, and payment type (skip paymentTypeId for GL rows)
|
||
for (const r of [
|
||
...drRows,
|
||
...crRows
|
||
]) {
|
||
if (!r.acNo?.trim()) {
|
||
this.showAlert('error', 'Account Required', ['Please fill account number in all rows.']);
|
||
return;
|
||
}
|
||
if (!r.amount || r.amount <= 0) {
|
||
this.showAlert('error', 'Amount Required', [`Please enter amount for: ${r.acNo}`]);
|
||
return;
|
||
}
|
||
if (!r.paymentTypeId && r.acTyp !== 'GL') {
|
||
this.showAlert('error', 'TXN TP Required', [`Please select payment type for: ${r.acNo}`]);
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Verify all accounts have been loaded (accountId required for approval)
|
||
const unloaded = [
|
||
...drRows,
|
||
...crRows
|
||
].filter((r) => !this.rowAccountIds[r.acNo]);
|
||
if (unloaded.length > 0) {
|
||
this.showAlert('error', 'Account Not Loaded', [
|
||
`Please load account details for: ${unloaded.map((r) => r.acNo).join(', ')}`,
|
||
'Click on each row and wait for account details to load.'
|
||
]);
|
||
return;
|
||
}
|
||
|
||
const totalDr = drRows.reduce((s, r) => s + (r.amount ?? 0), 0);
|
||
|
||
// Build entries and main GL fields based on mode
|
||
let glAccountId: number | null = null;
|
||
let glAccountNo: string | null = null;
|
||
let glAccountName: string | null = null;
|
||
let entries: any[];
|
||
|
||
if (isDisbursementMode) {
|
||
const drRow = drRows[0];
|
||
glAccountId = this.rowAccountIds[drRow.acNo] ?? null;
|
||
glAccountNo = drRow.acNo;
|
||
glAccountName = this.rowAccountNames[drRow.acNo] ?? drRow.acNo;
|
||
entries = crRows.map((cr) => ({
|
||
dbCr: 'Cr',
|
||
accountNo: cr.acNo,
|
||
accountName: this.rowAccountNames[cr.acNo] ?? cr.acNo,
|
||
accountType: this.creditEntryAccountType(cr.acTyp),
|
||
savingsAccountId: cr.acTyp !== 'GL' ? (this.rowAccountIds[cr.acNo] ?? null) : null,
|
||
glAccountId: cr.acTyp === 'GL' ? (this.rowAccountIds[cr.acNo] ?? null) : null,
|
||
amount: String(cr.amount),
|
||
paymentTypeId: cr.paymentTypeId,
|
||
description: cr.description ?? null
|
||
}));
|
||
} else {
|
||
// Collection: store target GL in main fields; include all rows with dbCr field
|
||
const crGl = crRows[0];
|
||
glAccountId = this.rowAccountIds[crGl.acNo] ?? null;
|
||
glAccountNo = crGl.acNo;
|
||
glAccountName = this.rowAccountNames[crGl.acNo] ?? crGl.acNo;
|
||
entries = [
|
||
...drRows,
|
||
...crRows
|
||
].map((r) => ({
|
||
dbCr: r.dbCr,
|
||
accountNo: r.acNo,
|
||
accountName: this.rowAccountNames[r.acNo] ?? r.acNo,
|
||
accountType: this.creditEntryAccountType(r.acTyp),
|
||
savingsAccountId: r.acTyp !== 'GL' ? (this.rowAccountIds[r.acNo] ?? null) : null,
|
||
glAccountId: r.acTyp === 'GL' ? (this.rowAccountIds[r.acNo] ?? null) : null,
|
||
amount: String(r.amount),
|
||
paymentTypeId: r.paymentTypeId,
|
||
description: r.description ?? null
|
||
}));
|
||
}
|
||
|
||
const firstRow = drRows[0] ?? crRows[0];
|
||
const payload = {
|
||
glAccountId,
|
||
glAccountNo,
|
||
glAccountName,
|
||
officeId: firstRow?.br ?? 1,
|
||
currencyCode: 'INR',
|
||
paymentTypeId: firstRow?.paymentTypeId,
|
||
paymentTypeName: this.paymentTypes.find((p: any) => p.id === firstRow?.paymentTypeId)?.name ?? null,
|
||
description: firstRow?.description || null,
|
||
creditEntries: JSON.stringify(entries),
|
||
// Without this the backend defaults every request from this screen to DISBURSEMENT — a
|
||
// Collection or Fund Transfer submitted here was being saved (and shown in the checker's
|
||
// inbox) as a plain Disbursement.
|
||
requestType: isCollectionMode ? 'COLLECTION' : isFundTransfer ? 'FUND_TRANSFER' : 'DISBURSEMENT',
|
||
// Same ambiguity as a cashier Receipt — a Collection credits a GL account picked by hand.
|
||
// See TxnRow.entryTypeCvId.
|
||
...(isCollectionMode ? { entryTypeCvId: crRows[0]?.entryTypeCvId || null } : {})
|
||
};
|
||
|
||
this.saving = true;
|
||
this.clerkDisbursementService
|
||
.submit(payload)
|
||
.pipe(
|
||
catchError((err: any) => {
|
||
const msg =
|
||
err?.error?.errors?.[0]?.defaultUserMessage ??
|
||
err?.error?.message ??
|
||
'Request submission failed. Please try again.';
|
||
this.showAlert('error', 'Submission Failed', [msg]);
|
||
this.saving = false;
|
||
return of(null);
|
||
})
|
||
)
|
||
.subscribe((res: any) => {
|
||
this.saving = false;
|
||
if (res) {
|
||
const modeLabel = isCollectionMode ? 'Collection' : isFundTransfer ? 'Fund Transfer' : 'Disbursement';
|
||
const summaryLine = isCollectionMode
|
||
? `DR: ${drRows.length} account(s) → CR: GL ${crRows[0].acNo}`
|
||
: `DR: GL ${drRows[0].acNo} → CR: ${crRows.length} account(s)`;
|
||
this.showAlert('success', `${modeLabel} Request Submitted`, [
|
||
`Voucher No: ${res.voucherNo} · Total: ₹${totalDr.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
|
||
summaryLine,
|
||
'Pending manager approval.'
|
||
]);
|
||
this.rows = [];
|
||
this.rowAccountIds = {};
|
||
this.rowAccountNames = {};
|
||
this.addRow();
|
||
this.selectedRow = this.rows[0];
|
||
this.accountDetails = null;
|
||
this.clientDetails = null;
|
||
}
|
||
});
|
||
}
|
||
|
||
/** Submits an intra-bank book transfer for maker-checker approval. */
|
||
private submitBankTransfer(): void {
|
||
const drRows = this.rows.filter((r) => r.dbCr === 'Dr');
|
||
const crRows = this.rows.filter((r) => r.dbCr === 'Cr');
|
||
const firstRow = drRows[0] ?? crRows[0];
|
||
|
||
const toEntry = (r: TxnRow, dbCr: 'Dr' | 'Cr') => ({
|
||
dbCr,
|
||
accountNo: r.acNo,
|
||
accountName: this.rowAccountNames[r.acNo] ?? r.acNo,
|
||
accountType: this.creditEntryAccountType(r.acTyp),
|
||
savingsAccountId: this.rowAccountIds[r.acNo],
|
||
amount: String(r.amount),
|
||
description: r.description ?? null
|
||
});
|
||
|
||
const allEntries = [
|
||
...drRows.map((r) => toEntry(r, 'Dr')),
|
||
...crRows.map((r) => toEntry(r, 'Cr'))
|
||
];
|
||
|
||
const payload = {
|
||
requestType: 'BANK_TRANSFER',
|
||
officeId: firstRow?.br ?? 1,
|
||
currencyCode: 'INR',
|
||
description: firstRow?.description || undefined,
|
||
creditEntries: JSON.stringify(allEntries)
|
||
};
|
||
|
||
this.saving = true;
|
||
this.clerkDisbursementService
|
||
.submit(payload)
|
||
.pipe(
|
||
catchError((err: any) => {
|
||
const msg =
|
||
err?.error?.errors?.[0]?.defaultUserMessage ??
|
||
err?.error?.message ??
|
||
'Bank transfer submission failed. Please try again.';
|
||
this.showAlert('error', 'Submission Failed', [msg]);
|
||
this.saving = false;
|
||
return of(null);
|
||
})
|
||
)
|
||
.subscribe((res: any) => {
|
||
this.saving = false;
|
||
if (res) {
|
||
const totalDr = drRows.reduce((s, r) => s + (r.amount ?? 0), 0);
|
||
this.showAlert('success', 'Bank Transfer Submitted', [
|
||
`Voucher No: ${res.voucherNo}`,
|
||
`Total: ₹${totalDr.toLocaleString('en-IN', { minimumFractionDigits: 2 })} · DR: ${drRows.length} account(s) → CR: ${crRows.length} account(s)`,
|
||
'Pending Manager approval. Track status in "Disbursements & Transfer".'
|
||
]);
|
||
this.rows = [];
|
||
this.rowAccountIds = {};
|
||
this.rowAccountNames = {};
|
||
this.addRow();
|
||
this.selectedRow = this.rows[0];
|
||
this.accountDetails = null;
|
||
this.clientDetails = null;
|
||
}
|
||
});
|
||
}
|
||
|
||
/** One-step Share redemption from Bank Transfer mode: DR row is the Share account (its
|
||
* "SHARE NO." field), CR row must be that account's own linked savings account. Submits a
|
||
* governed Share Withdrawal Request (same maker-checker table/flow as the dedicated screen) —
|
||
* approval both redeems the shares and settles the linked account automatically. */
|
||
private submitShareRedemptionTransfer(shareRow: TxnRow, savingsRow: TxnRow): void {
|
||
// Belt-and-braces: the TXN TP. dropdown already hides cash payment types for the CR (savings)
|
||
// row (see paymentTypesForRow()), but re-check here too in case its payment type was set before
|
||
// that filter applied — settlement is always a book-transfer to the linked savings account, and
|
||
// it's the CR row's payment type that actually governs how that credit posts, never the DR
|
||
// (Share) row's, which has no real payment-type concept.
|
||
const settlementPaymentType = this.paymentTypes.find((p: any) => p.id === savingsRow.paymentTypeId);
|
||
if (!savingsRow.paymentTypeId) {
|
||
this.showAlert('error', 'Settlement Payment Type Required', [
|
||
'Select a Payment Type on the CR (savings account) row — it governs how the settlement credit posts.'
|
||
]);
|
||
return;
|
||
}
|
||
if (settlementPaymentType?.isCashPayment) {
|
||
this.showAlert('error', 'Cash Not Allowed for Share Transactions', [
|
||
'Share redemption settlement is always a book-transfer to the linked savings account, never cash.',
|
||
'Select Transfer as the Payment Type on the CR row.'
|
||
]);
|
||
return;
|
||
}
|
||
|
||
const shareAccountId = this.rowAccountIds[shareRow.acNo];
|
||
if (!shareAccountId) {
|
||
this.showAlert('error', 'Share Account Not Loaded', [`Please load account details for: ${shareRow.acNo}`]);
|
||
return;
|
||
}
|
||
if (!this.rowAccountIds[savingsRow.acNo]) {
|
||
this.showAlert('error', 'Savings Account Not Loaded', [`Please load account details for: ${savingsRow.acNo}`]);
|
||
return;
|
||
}
|
||
|
||
const linkedAcNo = this.rowLinkedSavingsAcNo[shareRow.acNo];
|
||
const enteredAcNo = savingsRow.acNo?.trim();
|
||
const matchesLinked =
|
||
!!linkedAcNo &&
|
||
(linkedAcNo === enteredAcNo || linkedAcNo.endsWith(enteredAcNo) || savingsRow.ecsAcNo === linkedAcNo);
|
||
if (linkedAcNo && !matchesLinked) {
|
||
this.showAlert('error', 'Wrong Credit Account', [
|
||
`The CR account must be this Share account's own linked savings account: ${linkedAcNo}`,
|
||
`You entered: ${enteredAcNo}`
|
||
]);
|
||
return;
|
||
}
|
||
if (!linkedAcNo) {
|
||
this.showAlert('error', 'No Linked Savings Account', [
|
||
'This Share account has no linked savings account configured — settlement has nowhere to go.'
|
||
]);
|
||
return;
|
||
}
|
||
|
||
const shares = Number(shareRow.amount) || 0;
|
||
if (shares <= 0) {
|
||
this.showAlert('error', 'Share No. Required', ['Enter the number of shares to withdraw (greater than zero).']);
|
||
return;
|
||
}
|
||
if (!shareRow.resolutionNumber?.trim()) {
|
||
this.showAlert('error', 'Board Resolution Number Required', [
|
||
'A share withdrawal must be authorised by a Board Resolution.'
|
||
]);
|
||
return;
|
||
}
|
||
if (!shareRow.meetingDate) {
|
||
this.showAlert('error', 'Date of Meeting Required', ['Enter the Board meeting date that passed the resolution.']);
|
||
return;
|
||
}
|
||
|
||
const today = businessToday();
|
||
const requestedDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
||
|
||
// Full snapshot of the CR (settlement) row exactly as the clerk filled it in — saved verbatim
|
||
// for audit purposes. Business logic still only uses settlementPaymentTypeId above; the rest of
|
||
// these fields are not re-read by the backend, they are the audit record of what was on screen.
|
||
const settlementRowSnapshot = {
|
||
branch: savingsRow.br,
|
||
accountType: savingsRow.acTyp,
|
||
accountNo: savingsRow.acNo,
|
||
ecsAccountNo: savingsRow.ecsAcNo || null,
|
||
dbCr: savingsRow.dbCr,
|
||
amount: savingsRow.amount ?? null,
|
||
chequeNo: savingsRow.chequeNo || null,
|
||
chequeDate: savingsRow.cheqDate || null,
|
||
tokenNo: savingsRow.tokNo || null,
|
||
description: savingsRow.description || null,
|
||
bankName: savingsRow.bankName || null,
|
||
routingCode: savingsRow.routingCode || null,
|
||
paymentTypeId: savingsRow.paymentTypeId,
|
||
paymentTypeName: settlementPaymentType?.name ?? null
|
||
};
|
||
|
||
const payload = {
|
||
shareAccountId,
|
||
accountNo: shareRow.acNo,
|
||
clientId: this.rowClientIds[shareRow.acNo] ?? null,
|
||
clientName: this.rowAccountNames[shareRow.acNo] ?? null,
|
||
requestedShares: shares,
|
||
requestedDate,
|
||
resolutionNumber: shareRow.resolutionNumber.trim(),
|
||
meetingDate: shareRow.meetingDate,
|
||
description: shareRow.description || null,
|
||
settlementPaymentTypeId: savingsRow.paymentTypeId,
|
||
settlementMode: 'SAVINGS',
|
||
settlementRowSnapshot
|
||
};
|
||
|
||
const sharePrice = this.rowSharePrices[shareRow.acNo] ?? 0;
|
||
const currentHolding = this.rowShareHoldings[shareRow.acNo] ?? 0;
|
||
const remainingShares = currentHolding - shares;
|
||
const willCloseAccount = remainingShares <= 0;
|
||
const confirmLines = [
|
||
`Share Account: ${shareRow.acNo} · Client: ${this.rowAccountNames[shareRow.acNo] ?? '—'}`,
|
||
`Shares to Withdraw: ${shares} @ ₹${sharePrice.toLocaleString('en-IN', { minimumFractionDigits: 2 })} = ₹${(shares * sharePrice).toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
|
||
`Remaining Shares After Withdrawal: ${Math.max(remainingShares, 0)}`,
|
||
...(willCloseAccount
|
||
? ['⚠ All shares withdrawn — this account will be CLOSED automatically when the Manager approves.']
|
||
: []),
|
||
`Board Resolution No.: ${payload.resolutionNumber}`,
|
||
`Date of Meeting: ${payload.meetingDate}`,
|
||
`Settlement Credit To: ${linkedAcNo}`,
|
||
`Settlement Payment Type: ${settlementPaymentType?.name ?? '—'}`,
|
||
...(settlementRowSnapshot.ecsAccountNo && settlementRowSnapshot.ecsAccountNo !== linkedAcNo
|
||
? [`ECS A/C No.: ${settlementRowSnapshot.ecsAccountNo}`]
|
||
: []),
|
||
...(settlementRowSnapshot.chequeNo ? [`Cheque No.: ${settlementRowSnapshot.chequeNo}`] : []),
|
||
...(settlementRowSnapshot.chequeDate ? [`Cheque Date: ${settlementRowSnapshot.chequeDate}`] : []),
|
||
...(settlementRowSnapshot.tokenNo ? [`Token No.: ${settlementRowSnapshot.tokenNo}`] : []),
|
||
...(settlementRowSnapshot.description ? [`CR Row Remarks: ${settlementRowSnapshot.description}`] : []),
|
||
'',
|
||
willCloseAccount
|
||
? 'Pending Manager approval — redemption, settlement and account closure all post together on approval.'
|
||
: 'Pending Manager approval — redemption and settlement both post together on approval.'
|
||
];
|
||
this.showShareWithdrawalConfirm(payload, confirmLines, () =>
|
||
this.doSubmitShareRedemptionTransfer(payload, shareRow, shares, linkedAcNo)
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Shows the confirm dialog with the same checklist the Manager will see when approving this
|
||
* request — fetched live from the backend (`previewValidate`) rather than re-implemented here,
|
||
* so it can never show the clerk something different from what the approver actually sees. A
|
||
* failing check is shown, not blocked: approve() enforces its own hard rules separately (e.g.
|
||
* Share Advance Collection applied), this list is advisory so problems surface to whoever can
|
||
* still fix them, before a manager's time is spent on a request that was always going to fail.
|
||
*/
|
||
private showShareWithdrawalConfirm(
|
||
payload: Record<string, unknown>,
|
||
baseLines: string[],
|
||
onConfirm: () => void
|
||
): void {
|
||
this.clerkShareWithdrawalService
|
||
.previewValidate(payload)
|
||
.pipe(
|
||
// A request that never resolves (slow network, server busy) must not leave the Submit
|
||
// button looking dead — the dialog still has to open, just without the checklist.
|
||
timeout(5000),
|
||
catchError(() => of({ __previewFailed: true }))
|
||
)
|
||
.subscribe((res: any) => {
|
||
const checks: { label: string; pass: boolean; detail: string }[] = res?.checks ?? [];
|
||
// A failed call (most likely the backend not yet carrying the /preview-validate endpoint)
|
||
// says so explicitly rather than just omitting the section — a silently empty checklist
|
||
// looks identical to "everything passed, nothing to show", which is the one reading this
|
||
// must never give.
|
||
const checkLines = res?.__previewFailed ? [
|
||
'',
|
||
'⚠ Validation checklist unavailable right now — the request can still be submitted.'
|
||
] : checks.length ? [
|
||
'',
|
||
'Validation Checks (same as Manager approval):',
|
||
...checks.map((c) => `${c.pass ? '✓' : '⚠'} ${c.label}: ${c.detail}`)
|
||
] : [];
|
||
// baseLines ends with a blank line followed by the "Pending Manager approval…" footer
|
||
// sentence, which must stay last so the modal renders it as a footnote rather than a
|
||
// key/value row — so the checklist is spliced in just before that pair, not appended after.
|
||
const footerStart =
|
||
baseLines.length >= 2 && baseLines[baseLines.length - 2] === '' ? baseLines.length - 2 : baseLines.length;
|
||
const lines = [
|
||
...baseLines.slice(0, footerStart),
|
||
...checkLines,
|
||
...baseLines.slice(footerStart)
|
||
];
|
||
this.showConfirmAlert('info', 'Confirm Share Withdrawal Request', lines, 'Submit for Approval', onConfirm);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Share redemption settled to a GL account the clerk picks on the CR row, instead of the
|
||
* member's linked savings account — mirrors submitShareRedemptionTransfer() above, but the
|
||
* credit side is any GL account (e.g. a "DD Payable" holding account), not a fixed linked
|
||
* account, so there is no "must match the linked account" check here.
|
||
*/
|
||
private submitShareGlSettlementTransfer(shareRow: TxnRow, glRow: TxnRow): void {
|
||
// Belt-and-braces: the TXN TP. dropdown already hides cash payment types for AC.TP = SHR (see
|
||
// paymentTypesForRow()), but re-check here too in case the payment type was set before that
|
||
// filter applied.
|
||
const settlementPaymentType = this.paymentTypes.find((p: any) => p.id === glRow.paymentTypeId);
|
||
if (!glRow.paymentTypeId) {
|
||
this.showAlert('error', 'Settlement Payment Type Required', [
|
||
'Select a Payment Type on the CR (GL account) row — it governs how the settlement credit posts.'
|
||
]);
|
||
return;
|
||
}
|
||
if (settlementPaymentType?.isCashPayment) {
|
||
this.showAlert('error', 'Cash Not Allowed for Share Transactions', [
|
||
'Share redemption settlement is always a book-transfer, never cash.',
|
||
'Select Transfer as the Payment Type on the CR row.'
|
||
]);
|
||
return;
|
||
}
|
||
|
||
const shareAccountId = this.rowAccountIds[shareRow.acNo];
|
||
if (!shareAccountId) {
|
||
this.showAlert('error', 'Share Account Not Loaded', [`Please load account details for: ${shareRow.acNo}`]);
|
||
return;
|
||
}
|
||
const settlementGlAccountId = this.rowAccountIds[glRow.acNo];
|
||
if (!settlementGlAccountId) {
|
||
this.showAlert('error', 'GL Account Not Loaded', [`Please load account details for: ${glRow.acNo}`]);
|
||
return;
|
||
}
|
||
|
||
const shares = Number(shareRow.amount) || 0;
|
||
if (shares <= 0) {
|
||
this.showAlert('error', 'Share No. Required', ['Enter the number of shares to withdraw (greater than zero).']);
|
||
return;
|
||
}
|
||
if (!shareRow.resolutionNumber?.trim()) {
|
||
this.showAlert('error', 'Board Resolution Number Required', [
|
||
'A share withdrawal must be authorised by a Board Resolution.'
|
||
]);
|
||
return;
|
||
}
|
||
if (!shareRow.meetingDate) {
|
||
this.showAlert('error', 'Date of Meeting Required', ['Enter the Board meeting date that passed the resolution.']);
|
||
return;
|
||
}
|
||
|
||
const today = businessToday();
|
||
const requestedDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
||
|
||
// Full snapshot of the CR (settlement) row exactly as the clerk filled it in — saved verbatim
|
||
// for audit purposes, same as the linked-savings path above.
|
||
const settlementRowSnapshot = {
|
||
branch: glRow.br,
|
||
accountType: glRow.acTyp,
|
||
accountNo: glRow.acNo,
|
||
ecsAccountNo: glRow.ecsAcNo || null,
|
||
dbCr: glRow.dbCr,
|
||
amount: glRow.amount ?? null,
|
||
chequeNo: glRow.chequeNo || null,
|
||
chequeDate: glRow.cheqDate || null,
|
||
tokenNo: glRow.tokNo || null,
|
||
description: glRow.description || null,
|
||
bankName: glRow.bankName || null,
|
||
routingCode: glRow.routingCode || null,
|
||
paymentTypeId: glRow.paymentTypeId,
|
||
paymentTypeName: settlementPaymentType?.name ?? null
|
||
};
|
||
|
||
const payload = {
|
||
shareAccountId,
|
||
accountNo: shareRow.acNo,
|
||
clientId: this.rowClientIds[shareRow.acNo] ?? null,
|
||
clientName: this.rowAccountNames[shareRow.acNo] ?? null,
|
||
requestedShares: shares,
|
||
requestedDate,
|
||
resolutionNumber: shareRow.resolutionNumber.trim(),
|
||
meetingDate: shareRow.meetingDate,
|
||
description: shareRow.description || null,
|
||
settlementPaymentTypeId: glRow.paymentTypeId,
|
||
settlementRowSnapshot,
|
||
settlementMode: 'GL',
|
||
settlementGlAccountId
|
||
};
|
||
|
||
const sharePrice = this.rowSharePrices[shareRow.acNo] ?? 0;
|
||
const currentHolding = this.rowShareHoldings[shareRow.acNo] ?? 0;
|
||
const remainingShares = currentHolding - shares;
|
||
const willCloseAccount = remainingShares <= 0;
|
||
const confirmLines = [
|
||
`Share Account: ${shareRow.acNo} · Client: ${this.rowAccountNames[shareRow.acNo] ?? '—'}`,
|
||
`Shares to Withdraw: ${shares} @ ₹${sharePrice.toLocaleString('en-IN', { minimumFractionDigits: 2 })} = ₹${(shares * sharePrice).toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
|
||
`Remaining Shares After Withdrawal: ${Math.max(remainingShares, 0)}`,
|
||
...(willCloseAccount
|
||
? ['⚠ All shares withdrawn — this account will be CLOSED automatically when the Manager approves.']
|
||
: []),
|
||
`Board Resolution No.: ${payload.resolutionNumber}`,
|
||
`Date of Meeting: ${payload.meetingDate}`,
|
||
`Settlement Credit To (GL): ${glRow.acNo}`,
|
||
`Settlement Payment Type: ${settlementPaymentType?.name ?? '—'}`,
|
||
...(settlementRowSnapshot.description ? [`CR Row Remarks: ${settlementRowSnapshot.description}`] : []),
|
||
'',
|
||
willCloseAccount
|
||
? 'Pending Manager approval — redemption, settlement and account closure all post together on approval.'
|
||
: 'Pending Manager approval — redemption and settlement both post together on approval.'
|
||
];
|
||
this.showShareWithdrawalConfirm(payload, confirmLines, () =>
|
||
this.doSubmitShareGlSettlementTransfer(payload, shareRow, shares, glRow.acNo)
|
||
);
|
||
}
|
||
|
||
private doSubmitShareGlSettlementTransfer(
|
||
payload: Record<string, unknown>,
|
||
shareRow: TxnRow,
|
||
shares: number,
|
||
glAcNo: string
|
||
): void {
|
||
this.saving = true;
|
||
this.clerkShareWithdrawalService
|
||
.submit(payload)
|
||
.pipe(
|
||
catchError((err: any) => {
|
||
const msg =
|
||
err?.error?.errors?.[0]?.defaultUserMessage ??
|
||
err?.error?.developerMessage ??
|
||
err?.error?.defaultUserMessage ??
|
||
'Share withdrawal submission failed. Please try again.';
|
||
this.showAlert('error', 'Submission Failed', [msg]);
|
||
this.saving = false;
|
||
return of(null);
|
||
})
|
||
)
|
||
.subscribe((res: any) => {
|
||
this.saving = false;
|
||
if (res) {
|
||
const willCloseAccount = (this.rowShareHoldings[shareRow.acNo] ?? 0) - shares <= 0;
|
||
this.showAlert('success', 'Share Withdrawal Submitted', [
|
||
`${shares} share(s) from ${shareRow.acNo} → settlement to GL ${glAcNo} on approval.`,
|
||
willCloseAccount
|
||
? 'Pending Manager approval — this will close the share account, since all shares will be withdrawn. Track status in "My Share Requests".'
|
||
: 'Pending Manager approval. Track status in "My Share Requests".'
|
||
]);
|
||
this.rows = [];
|
||
this.rowAccountIds = {};
|
||
this.rowAccountNames = {};
|
||
this.rowSharePrices = {};
|
||
this.rowClientIds = {};
|
||
this.rowLinkedSavingsAcNo = {};
|
||
this.rowShareHoldings = {};
|
||
this.addRow();
|
||
this.selectedRow = this.rows[0];
|
||
this.accountDetails = null;
|
||
this.clientDetails = null;
|
||
}
|
||
});
|
||
}
|
||
|
||
private doSubmitShareRedemptionTransfer(
|
||
payload: Record<string, unknown>,
|
||
shareRow: TxnRow,
|
||
shares: number,
|
||
linkedAcNo: string
|
||
): void {
|
||
this.saving = true;
|
||
this.clerkShareWithdrawalService
|
||
.submit(payload)
|
||
.pipe(
|
||
catchError((err: any) => {
|
||
const msg =
|
||
err?.error?.errors?.[0]?.defaultUserMessage ??
|
||
err?.error?.developerMessage ??
|
||
err?.error?.defaultUserMessage ??
|
||
'Share withdrawal submission failed. Please try again.';
|
||
this.showAlert('error', 'Submission Failed', [msg]);
|
||
this.saving = false;
|
||
return of(null);
|
||
})
|
||
)
|
||
.subscribe((res: any) => {
|
||
this.saving = false;
|
||
if (res) {
|
||
const willCloseAccount = (this.rowShareHoldings[shareRow.acNo] ?? 0) - shares <= 0;
|
||
this.showAlert('success', 'Share Withdrawal Submitted', [
|
||
`${shares} share(s) from ${shareRow.acNo} → settlement to ${linkedAcNo} on approval.`,
|
||
willCloseAccount
|
||
? 'Pending Manager approval — this will close the share account, since all shares will be withdrawn. Track status in "My Share Requests".'
|
||
: 'Pending Manager approval. Track status in "My Share Requests".'
|
||
]);
|
||
this.rows = [];
|
||
this.rowAccountIds = {};
|
||
this.rowAccountNames = {};
|
||
this.rowSharePrices = {};
|
||
this.rowClientIds = {};
|
||
this.rowLinkedSavingsAcNo = {};
|
||
this.rowShareHoldings = {};
|
||
this.addRow();
|
||
this.selectedRow = this.rows[0];
|
||
this.accountDetails = null;
|
||
this.clientDetails = null;
|
||
}
|
||
});
|
||
}
|
||
|
||
/** Cashier enters a token number → look up the APPROVED withdrawal request for auto-fill. */
|
||
onTokNoBlur(row: TxnRow): void {
|
||
if (this.isClerk || !row.tokNo?.trim()) return;
|
||
const token = row.tokNo.trim();
|
||
this.loadingWithdrawal = true;
|
||
this.activeWithdrawalRequest = null;
|
||
this.activeDividendClaimRequest = null;
|
||
this.activeClosureRequest = null;
|
||
this.clerkWithdrawalService
|
||
.getByToken(token)
|
||
.pipe(
|
||
catchError((err: any) => {
|
||
// Not a withdrawal-request token at all (as opposed to PENDING/WITHDRAWN/REJECTED,
|
||
// which are legitimate matches with their own specific error) — try the account-closure
|
||
// pool before giving up: both share the same TOK. NO. field at this counter. A dividend
|
||
// claim was once a third pool here, but a dividend never actually pays out in cash over
|
||
// this counter — it only ever settles through a Demand Draft, which is its own DD
|
||
// issuance request and its own token, so looking one up here could only ever 404.
|
||
if (err?.status === 404) {
|
||
return this.closureRequestService.getByToken(token).pipe(
|
||
catchError((closeErr: any) => {
|
||
this.loadingWithdrawal = false;
|
||
const msg: string =
|
||
closeErr?.error?.message ??
|
||
closeErr?.error?.errors?.[0]?.defaultUserMessage ??
|
||
`Token '${token}' could not be verified. Please try again.`;
|
||
this.showAlert('error', `Token '${token}'`, [msg]);
|
||
return of(null);
|
||
}),
|
||
map((closeReq: any) => (closeReq ? { __closureRequest: true, ...closeReq } : null))
|
||
);
|
||
}
|
||
this.loadingWithdrawal = false;
|
||
const msg: string =
|
||
err?.error?.message ??
|
||
err?.error?.errors?.[0]?.defaultUserMessage ??
|
||
`Token '${token}' could not be verified. Please try again.`;
|
||
this.showAlert('error', `Token '${token}'`, [msg]);
|
||
return of(null);
|
||
})
|
||
)
|
||
.subscribe((req: any) => {
|
||
this.loadingWithdrawal = false;
|
||
if (!req) return;
|
||
|
||
if (req.__dividendClaim) {
|
||
this.activeDividendClaimRequest = req;
|
||
// No real savings/current account backs a dividend cash claim (it's a pure GL
|
||
// liability settled at claim-approval time) — stub just enough of accountDetails
|
||
// and kycDetails for onSave()'s generic guards to pass without stale/unrelated data.
|
||
this.accountDetails = { id: null, accountNo: 'DIVIDEND-CLAIM-' + req.id, clientName: req.client_name };
|
||
this.kycDetails = null;
|
||
this.clientDetails = null;
|
||
this.accountError = '';
|
||
row.acNo = req.client_name ?? 'Dividend Claim #' + req.id;
|
||
// Marks this acNo as already resolved, the same way loadAccountDetails() itself would —
|
||
// without it, the next click into this row (onRowSelect) sees an acNo that was never
|
||
// "loaded" and re-searches for a real account matching this stub string, which can land
|
||
// on an unrelated real account whose number happens to share a suffix with it.
|
||
this.lastLoadedAcNo = row.acNo;
|
||
row.acTyp = 'SD';
|
||
// Every token-driven row is cash leaving the branch to a member, which is what the
|
||
// Payment register is for — not what this row's TYPE happened to default to before the
|
||
// token was entered.
|
||
row.typ = 'Payment';
|
||
row.amount = Number(req.amount) || null;
|
||
row.dbCr = 'Dr';
|
||
this.selectedRow = row;
|
||
return;
|
||
}
|
||
|
||
if (req.__closureRequest) {
|
||
this.activeClosureRequest = req;
|
||
// The account is already CLOSED at Fineract level by the time a cashier sees this token
|
||
// (the close command ran at manager approval), so its own detail response would show a
|
||
// closed account with a zero balance. What the cashier needs here is the rounded cash
|
||
// figure to count out, which the request itself carries.
|
||
this.accountDetails = {
|
||
id: null,
|
||
accountNo: req.account_no,
|
||
clientName: req.client_name
|
||
};
|
||
this.kycDetails = null;
|
||
this.clientDetails = null;
|
||
this.accountError = '';
|
||
row.acNo = req.account_no ?? '';
|
||
// See the same line in the dividend-claim branch above for why this is required.
|
||
this.lastLoadedAcNo = row.acNo;
|
||
row.acTyp = 'SD';
|
||
row.typ = 'Payment';
|
||
row.amount = Number(req.cashier_disbursed_amount) || null;
|
||
row.dbCr = 'Dr';
|
||
// A closure request stores payment_type_id as NULL, meaning "Cash (Default)" — resolve the
|
||
// real cash payment type here so the cashier isn't asked to pick one and the
|
||
// "Payment Type Required" guard on the disbursement path is satisfied.
|
||
row.paymentTypeId = req.payment_type_id ?? this.findCashPaymentTypeId();
|
||
row.description = 'Account closure — ' + (req.client_name ?? req.account_no);
|
||
this.selectedRow = row;
|
||
return;
|
||
}
|
||
|
||
this.activeWithdrawalRequest = req;
|
||
// A plain withdrawal-slip request carries its own real account_type (SAVINGS, CURRENT,
|
||
// FIXED_DEPOSIT or RECURRING_DEPOSIT — see ClerkWithdrawalRequestApiResource.submit()), unlike
|
||
// the closure/dividend pools above, which are never anything but a savings-shaped stub.
|
||
// Hardcoding 'SD' here searched every non-savings account on the wrong endpoint (Current
|
||
// accounts live under /currentaccounts, not /savingsaccounts) and showed it mislabelled even
|
||
// when the lookup happened to succeed.
|
||
const acTyp = TransactionEntryComponent.SEARCH_ACCOUNT_TYPE_TO_AC_TYP[req.account_type || ''] || 'SD';
|
||
row.acNo = req.account_no ?? '';
|
||
row.acTyp = acTyp;
|
||
row.typ = 'Payment';
|
||
row.amount = Number(req.amount) || null;
|
||
row.dbCr = 'Dr';
|
||
row.paymentTypeId = req.payment_type_id ?? null;
|
||
row.description = req.description ?? '';
|
||
this.selectedRow = row;
|
||
if (req.account_no) {
|
||
this.loadAccountDetails(req.account_no.trim(), acTyp);
|
||
}
|
||
});
|
||
}
|
||
|
||
/** When exactly one row on screen is a Share account with a known market price, auto-fills every
|
||
* OTHER row's amount as (shares entered on that row) × (its market price) — a calculator
|
||
* convenience for filling the paired settlement entry. The Share row itself still can never be
|
||
* saved from this screen (see the SHR guard in confirmSubmit()) — this only helps compute the
|
||
* correct amount to carry over into the separate manual Deposit Slip entry. */
|
||
syncShareLinkedAmounts(): void {
|
||
const shareRows = this.rows.filter((r) => r.acTyp?.toUpperCase() === 'SHR' && this.rowSharePrices[r.acNo]);
|
||
if (shareRows.length !== 1) return;
|
||
const shareRow = shareRows[0];
|
||
const price = this.rowSharePrices[shareRow.acNo] ?? 0;
|
||
const shares = Number(shareRow.amount) || 0;
|
||
const total = Math.round(shares * price * 100) / 100;
|
||
this.rows.forEach((r) => {
|
||
if (r !== shareRow) {
|
||
r.amount = total;
|
||
}
|
||
});
|
||
}
|
||
|
||
onAcNoBlur(row: TxnRow, index: number): void {
|
||
if (!row.acNo.trim()) return;
|
||
this.selectedRow = row;
|
||
// A token-driven row's account fields are already authoritative (see onTokNoBlur) -- merely
|
||
// clicking into and out of the A/C NO. field here must not re-search for them. Doing so used
|
||
// row.acTyp's internal stand-in value (e.g. 'SD' for a dividend claim, which is never the
|
||
// account's real type) against the account's real number, either finding nothing ("Account
|
||
// not found") or, worse, matching a completely unrelated real account that happened to share a
|
||
// number suffix.
|
||
if (this.activeWithdrawalRequest || this.activeDividendClaimRequest || this.activeClosureRequest) {
|
||
return;
|
||
}
|
||
this.loadAccountDetails(row.acNo.trim(), row.acTyp);
|
||
}
|
||
|
||
onAcNoInput(row: TxnRow, index: number, value: string): void {
|
||
// Same reasoning as the guard at the top of onAcNoBlur() -- a token-driven row's account
|
||
// fields must not be edited or re-searched via this field at all.
|
||
if (this.activeWithdrawalRequest || this.activeDividendClaimRequest || this.activeClosureRequest) {
|
||
return;
|
||
}
|
||
row.acNo = value;
|
||
this.lastLoadedAcNo = '';
|
||
this.acNoInput$.next({ row, index, value });
|
||
}
|
||
|
||
private loadCashVaultGl(): void {
|
||
// Dynamic: vault name > cash name > first ASSET type — no hardcoded IDs
|
||
this.http
|
||
.get('/financialactivityaccounts')
|
||
.pipe(catchError(() => of([])))
|
||
.subscribe((list: any) => {
|
||
const items: any[] = Array.isArray(list) ? list : [];
|
||
const byName = (re: RegExp) => items.find((a: any) => re.test(a.financialActivityData?.name ?? ''));
|
||
const found =
|
||
byName(/vault/i) ?? byName(/cash/i) ?? items.find((a: any) => a.glAccountData?.type?.value === 'ASSET');
|
||
if (found?.glAccountData?.id) {
|
||
this.cashVaultGlId = found.glAccountData.id;
|
||
this.cashVaultGlCode = found.glAccountData.glCode ?? null;
|
||
this.cashVaultGlName = found.glAccountData.name ?? found.financialActivityData?.name ?? null;
|
||
}
|
||
});
|
||
}
|
||
|
||
onRowSelect(row: TxnRow, index: number): void {
|
||
this.selectedRow = row;
|
||
const acNo = row.acNo.trim();
|
||
const alreadyLoaded = this.accountDetails && this.lastLoadedAcNo === acNo;
|
||
if (acNo && !alreadyLoaded) {
|
||
this.loadAccountDetails(acNo, row.acTyp);
|
||
}
|
||
}
|
||
|
||
private static readonly SEARCH_ACCOUNT_TYPE_TO_AC_TYP: Record<string, string> = {
|
||
SAVINGS: 'SD',
|
||
CURRENT: 'CA',
|
||
FIXED_DEPOSIT: 'FD',
|
||
RECURRING_DEPOSIT: 'RD'
|
||
};
|
||
|
||
openCustomerSearch(): void {
|
||
const dialogRef = this.dialog.open(CustomerSearchDialogComponent, {
|
||
width: '960px',
|
||
maxWidth: '95vw'
|
||
});
|
||
|
||
dialogRef.afterClosed().subscribe((result: CustomerSearchResult | undefined) => {
|
||
if (!result || !result.accountNo) return;
|
||
// selectedRow is only set once the clerk types into A/C NO. themselves - on a fresh screen
|
||
// it's still null, so fall back to the first (only) row that addRow() created at startup.
|
||
const targetRow = this.selectedRow ?? this.rows[0];
|
||
if (!targetRow) return;
|
||
|
||
const acTyp = TransactionEntryComponent.SEARCH_ACCOUNT_TYPE_TO_AC_TYP[result.accountType || ''] || 'SD';
|
||
targetRow.acTyp = acTyp;
|
||
targetRow.acNo = result.accountNo;
|
||
this.selectedRow = targetRow;
|
||
this.loadAccountDetails(result.accountNo, acTyp);
|
||
});
|
||
}
|
||
|
||
private loadAccountDetails(acNo: string, acTyp: string): void {
|
||
this.lastLoadedAcNo = acNo;
|
||
this.loadingAccount = true;
|
||
this.accountDetails = null;
|
||
this.clientDetails = null;
|
||
this.kycDetails = null;
|
||
this.nominees = [];
|
||
this.jointHolders = [];
|
||
this.clientAddresses = [];
|
||
this.clientPhotoUrl = null;
|
||
if (this.signatureUrl?.startsWith('blob:')) URL.revokeObjectURL(this.signatureUrl);
|
||
this.signatureUrl = null;
|
||
this.holdBreakdown = null;
|
||
this.accountNotes = [];
|
||
this.standingInstructions = [];
|
||
this.todayTransactions = [];
|
||
this.linkedAccounts = null;
|
||
this.sameDayNeftRtgs = 0;
|
||
this.sameDayChequeClearing = 0;
|
||
this.accountError = '';
|
||
|
||
const token = ++this.searchToken; // Every new search gets a unique token
|
||
const cfg = this.accountTypeConfig(acTyp);
|
||
|
||
// Step 1: search on the correct endpoint for this account type
|
||
this.http
|
||
.get<any>(
|
||
`${cfg.list}?${cfg.listQueryParam}=${encodeURIComponent(acNo)}${cfg.supportsPagination ? `&offset=0&limit=${cfg.maxLimit}` : ''}${cfg.extraListQueryParams ? `&${cfg.extraListQueryParams}` : ''}`
|
||
)
|
||
.pipe(catchError(() => of(null)))
|
||
.subscribe((res: any) => {
|
||
if (token !== this.searchToken) return; // stale result — newer search started
|
||
const allItems: any[] = res?.pageItems ?? (Array.isArray(res) ? res : []);
|
||
const f = cfg.matchField;
|
||
// Exact match first, then suffix/partial match. Some account types (e.g. Share — its
|
||
// list endpoint only accepts offset/limit, no accountNo filter at all) always return the
|
||
const exactMatch = allItems.find((x: any) => String(x[f] ?? '').trim() === String(acNo).trim());
|
||
const partialMatch = !exactMatch
|
||
? (allItems.find((x: any) => String(x[f] ?? '').endsWith(acNo)) ??
|
||
allItems.find((x: any) => String(x[f] ?? '').includes(acNo)))
|
||
: null;
|
||
const idMatch =
|
||
!exactMatch && !partialMatch && acTyp === 'GL'
|
||
? allItems.find((x: any) => String(x.id) === String(acNo))
|
||
: null;
|
||
const match = exactMatch ?? partialMatch ?? idMatch ?? null;
|
||
if (match?.id) {
|
||
const matchedGlCode = match[f] ?? acNo;
|
||
// Any row resolved via the partial/endsWith/id fallback above (not an exact code match)
|
||
// loads and displays the right account, but the row itself still carries whatever shorter
|
||
// string the cashier actually typed to search with -- and that's what gets submitted and
|
||
// shown everywhere downstream (vouchers, My Work, credit_entries), not the account's real
|
||
// full number. Correct the row to the account's actual code whenever the match wasn't
|
||
// already exact, for every account type -- not just the DD/GL cases this was previously
|
||
// limited to.
|
||
if (this.selectedRow && !exactMatch && matchedGlCode) {
|
||
this.selectedRow.acNo = matchedGlCode;
|
||
}
|
||
const clientIdFromSearch = acTyp !== 'GL' && match.clientId != null ? String(match.clientId) : null;
|
||
this.fetchAllDetails(match.id, matchedGlCode, cfg, token, acTyp, clientIdFromSearch);
|
||
return;
|
||
}
|
||
this.loadingAccount = false;
|
||
this.accountError = 'Account not found for: ' + acNo;
|
||
});
|
||
}
|
||
|
||
/** Maps UI account type → { listEndpoint, detailEndpoint, nomineeType, searchResource } */
|
||
/**
|
||
* The account-type label carried on a disbursement request's credit/debit entry.
|
||
*
|
||
* The backend (ClerkDisbursementRequestApiResource) only ever branches on 'GL' and 'CURRENT'; every
|
||
* other value takes the same path, and in the disbursement flow it resolves current-vs-savings from
|
||
* the database rather than from this string. So naming a fixed or recurring deposit accurately here
|
||
* changes only what the approving checker sees, never what gets posted. Previously anything that
|
||
* wasn't current or GL was labelled 'SAVINGS', which showed a checker an FD as a savings account --
|
||
* exactly the wrong information to approve a disbursement against.
|
||
*/
|
||
private creditEntryAccountType(acTyp: string | undefined | null): string {
|
||
switch ((acTyp ?? '').toUpperCase()) {
|
||
case 'GL':
|
||
return 'GL';
|
||
case 'CA':
|
||
case 'CB':
|
||
return 'CURRENT';
|
||
case 'FD':
|
||
return 'FIXED_DEPOSIT';
|
||
case 'RD':
|
||
return 'RECURRING_DEPOSIT';
|
||
case 'SHR':
|
||
return 'SHARE';
|
||
case 'LA':
|
||
return 'LOAN';
|
||
default:
|
||
return 'SAVINGS';
|
||
}
|
||
}
|
||
|
||
private accountTypeConfig(acTyp: string): {
|
||
list: string;
|
||
detail: string;
|
||
nominee: string;
|
||
search: string;
|
||
siAccountType: number;
|
||
txnBase: string;
|
||
txnSearchSupported: boolean;
|
||
/** True when this account type has no separate REST transactions sub-resource at all (e.g.
|
||
* Share accounts — Fineract returns transactions embedded on the account detail response
|
||
* itself via ?associations=all, never at /accounts/share/{id}/transactions). Skips the
|
||
* "today's transactions" HTTP call entirely instead of hitting a 404. */
|
||
txnFetchUnsupported?: boolean;
|
||
/** True when this account type has no standing-instruction "from account type" mapping on
|
||
* the backend at all (PortfolioAccountType only defines LOAN/SAVINGS/CURRENT — Share isn't
|
||
* a valid standing-instruction source account). Skips that HTTP call entirely instead of
|
||
* sending an fromAccountType value the backend's query-builder silently can't match,
|
||
* which otherwise produces a malformed "WHERE <empty>" SQL statement server-side. */
|
||
siFetchUnsupported?: boolean;
|
||
maxLimit: number;
|
||
skipGlobalSearch: boolean;
|
||
listQueryParam: string;
|
||
matchField: string;
|
||
detailParams: string;
|
||
supportsPagination: boolean;
|
||
/** Extra fixed query params (e.g. "usage=1") appended to every list-search request for this
|
||
* account type — used to keep Header GL accounts out of the results a clerk/cashier can pick
|
||
* for a transaction; a Header can never receive a posting, so it should never be offered. */
|
||
extraListQueryParams?: string;
|
||
} {
|
||
const t = acTyp?.toUpperCase();
|
||
if (t === 'LA') {
|
||
return {
|
||
list: '/loans',
|
||
detail: '/loans',
|
||
nominee: 'LA',
|
||
search: 'loan',
|
||
siAccountType: 1,
|
||
txnBase: '/loans',
|
||
txnSearchSupported: true,
|
||
maxLimit: 1,
|
||
skipGlobalSearch: true,
|
||
listQueryParam: 'accountNo',
|
||
matchField: 'accountNo',
|
||
detailParams: '?associations=all',
|
||
supportsPagination: true
|
||
};
|
||
}
|
||
if (t === 'SHR') {
|
||
return {
|
||
list: '/accounts/share',
|
||
detail: '/accounts/share',
|
||
nominee: 'SHR',
|
||
search: 'share',
|
||
siAccountType: 4,
|
||
txnBase: '/accounts/share',
|
||
txnSearchSupported: false,
|
||
txnFetchUnsupported: true,
|
||
siFetchUnsupported: true,
|
||
maxLimit: 500,
|
||
skipGlobalSearch: true,
|
||
listQueryParam: 'accountNo',
|
||
matchField: 'accountNo',
|
||
detailParams: '?associations=all',
|
||
supportsPagination: true
|
||
};
|
||
}
|
||
if (t === 'GL') {
|
||
// GL uses searchParam (not accountNo) and matches on glCode field
|
||
return {
|
||
list: '/glaccounts',
|
||
detail: '/glaccounts',
|
||
nominee: 'GL',
|
||
search: 'glaccount',
|
||
siAccountType: 0,
|
||
txnBase: '/glaccounts',
|
||
txnSearchSupported: false,
|
||
maxLimit: 500,
|
||
skipGlobalSearch: true,
|
||
listQueryParam: 'searchParam',
|
||
matchField: 'glCode',
|
||
detailParams: '?fetchRunningBalance=true',
|
||
supportsPagination: false,
|
||
// Detail-only: a Header account is a pure grouping node and can never receive a
|
||
// transaction, so it must never be a selectable match here in the first place.
|
||
extraListQueryParams: 'usage=1'
|
||
};
|
||
}
|
||
if (t === 'CA' || t === 'CB') {
|
||
return {
|
||
list: '/currentaccounts',
|
||
detail: '/currentaccounts',
|
||
nominee: 'CB',
|
||
search: 'currentaccount',
|
||
siAccountType: 3,
|
||
txnBase: '/currentaccounts',
|
||
txnSearchSupported: true,
|
||
maxLimit: 1,
|
||
skipGlobalSearch: true,
|
||
listQueryParam: 'accountNo',
|
||
matchField: 'accountNo',
|
||
detailParams: '?associations=all',
|
||
supportsPagination: true
|
||
};
|
||
}
|
||
if (t === 'FD') {
|
||
return {
|
||
list: '/savingsaccounts',
|
||
detail: '/fixeddepositaccounts',
|
||
nominee: 'FD',
|
||
search: 'fixeddeposit',
|
||
siAccountType: 2,
|
||
txnBase: '/savingsaccounts',
|
||
txnSearchSupported: true,
|
||
maxLimit: 1,
|
||
skipGlobalSearch: true,
|
||
listQueryParam: 'accountNo',
|
||
matchField: 'accountNo',
|
||
detailParams: '?associations=all',
|
||
supportsPagination: true
|
||
};
|
||
}
|
||
if (t === 'RD') {
|
||
return {
|
||
list: '/savingsaccounts',
|
||
detail: '/recurringdepositaccounts',
|
||
nominee: 'RD',
|
||
search: 'recurringdeposit',
|
||
siAccountType: 2,
|
||
txnBase: '/savingsaccounts',
|
||
txnSearchSupported: true,
|
||
maxLimit: 1,
|
||
skipGlobalSearch: true,
|
||
listQueryParam: 'accountNo',
|
||
matchField: 'accountNo',
|
||
detailParams: '?associations=all',
|
||
supportsPagination: true
|
||
};
|
||
}
|
||
// SD — savings
|
||
return {
|
||
list: '/savingsaccounts',
|
||
detail: '/savingsaccounts',
|
||
nominee: 'SB',
|
||
search: 'saving',
|
||
siAccountType: 2,
|
||
txnBase: '/savingsaccounts',
|
||
txnSearchSupported: true,
|
||
maxLimit: 1,
|
||
skipGlobalSearch: true,
|
||
listQueryParam: 'accountNo',
|
||
matchField: 'accountNo',
|
||
detailParams: '?associations=all',
|
||
supportsPagination: true
|
||
};
|
||
}
|
||
|
||
/** Maps UI account type code → backend accountType code for nominee filter */
|
||
private toNomineeAccountType(acTyp: string): string {
|
||
return this.accountTypeConfig(acTyp).nominee;
|
||
}
|
||
|
||
/** Parallel fetch: account (with associations) + client + KYC + nominees */
|
||
private fetchAllDetails(
|
||
accountId: number,
|
||
acNo: string,
|
||
cfg: {
|
||
list: string;
|
||
detail: string;
|
||
nominee: string;
|
||
search: string;
|
||
siAccountType: number;
|
||
txnBase: string;
|
||
txnSearchSupported: boolean;
|
||
txnFetchUnsupported?: boolean;
|
||
siFetchUnsupported?: boolean;
|
||
detailParams?: string;
|
||
},
|
||
token: number,
|
||
acTyp: string = '',
|
||
clientIdHint: string | null = null
|
||
): void {
|
||
const accountUrl = `${cfg.detail}/${accountId}${cfg.detailParams ?? '?associations=all'}`;
|
||
const todayStr = (() => {
|
||
const d = businessToday();
|
||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||
})();
|
||
|
||
// Shared: apply account-level fields after fetch
|
||
const applyAccountData = (acc: any): boolean => {
|
||
if (!acc) {
|
||
this.loadingAccount = false;
|
||
this.accountError = 'Could not load account details.';
|
||
return false;
|
||
}
|
||
const returnedId = acc.glCode ?? acc.accountNo;
|
||
if (returnedId && returnedId !== acNo && String(acc.id) !== acNo) {
|
||
this.loadingAccount = false;
|
||
this.accountError = 'Account mismatch. Please re-enter account number and try again.';
|
||
return false;
|
||
}
|
||
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;
|
||
// clientName must be in this chain: a deposit account's detail response carries the holder
|
||
// under clientName, not name/displayName (only GL accounts have `name`). Without it every FD
|
||
// and RD fell through to accountNo, so a disbursement request showed the checker an account
|
||
// number where the account holder's name belongs.
|
||
this.rowAccountNames[this.selectedRow.acNo] =
|
||
acc.name ?? acc.displayName ?? acc.clientName ?? acc.glAccountName ?? acc.accountNo ?? '';
|
||
this.rowClientIds[this.selectedRow.acNo] = acc.clientId ?? null;
|
||
}
|
||
if (this.selectedRow) {
|
||
// Share accounts: ECS field is the share account's own number — acc.savingsAccountNumber
|
||
// is a different, unrelated account (the linked settlement savings account) and must
|
||
// never be shown here.
|
||
this.selectedRow.ecsAcNo =
|
||
acTyp?.toUpperCase() === 'SHR'
|
||
? (acc.accountNo ?? '')
|
||
: (acc.glCode ?? acc.savingsAccountNumber ?? acc.accountNo ?? '');
|
||
}
|
||
if (this.isClerk) this.clerkUiState.setFacilities(this.facilities, acNo, acTyp);
|
||
this.computeSameDayNeftRtgs(acc.transactions);
|
||
this.computeSameDayChequeClearing(acc.transactions);
|
||
if (acTyp?.toUpperCase() === 'SHR' && this.selectedRow?.acNo) {
|
||
// Keyed by the row's own typed acNo (e.g. "0011"), not the resolved full account number
|
||
// (e.g. "000000011") — rowAccountIds/rowAccountNames above use the same key, and every
|
||
// lookup elsewhere (syncShareLinkedAmounts, submitShareRedemptionTransfer, the template's
|
||
// rowSharePrices[row.acNo]) reads via row.acNo, so this must match or every lookup misses.
|
||
this.rowSharePrices[this.selectedRow.acNo] = Number(acc.currentMarketPrice) || 0;
|
||
this.rowLinkedSavingsAcNo[this.selectedRow.acNo] = acc.savingsAccountNumber ?? '';
|
||
this.rowShareHoldings[this.selectedRow.acNo] = Number(
|
||
acc.summary?.totalApprovedShares ?? acc.totalApprovedShares ?? 0
|
||
);
|
||
this.syncShareLinkedAmounts();
|
||
}
|
||
return true;
|
||
};
|
||
|
||
// Shared: apply client-level fields after fetch
|
||
const applyClientData = (
|
||
clientId: string,
|
||
r: {
|
||
client: any;
|
||
kyc: any;
|
||
nominees: any;
|
||
addresses: any;
|
||
family: any;
|
||
photo: any;
|
||
documents: any;
|
||
notes: any;
|
||
linkedAcc: any;
|
||
si: any;
|
||
todayTxns: any;
|
||
}
|
||
): void => {
|
||
this.loadingAccount = false;
|
||
this.clientDetails = { ...r.client, familyMembers: Array.isArray(r.family) ? r.family : [] };
|
||
const kycArr: any[] = Array.isArray(r.kyc) ? r.kyc : r.kyc ? [r.kyc] : [];
|
||
this.kycDetails = kycArr[0] ?? null;
|
||
this.nominees = Array.isArray(r.nominees) ? r.nominees : [];
|
||
this.nonPersonDetails = r.client?.clientNonPersonDetails ?? null;
|
||
this.clientAddresses = Array.isArray(r.addresses) ? r.addresses : [];
|
||
this.clientPhotoUrl = r.photo ?? null;
|
||
const docs: any[] = Array.isArray(r.documents) ? r.documents : [];
|
||
// Multiple 'clientSignature' documents can exist for clients who re-cropped/replaced their
|
||
// signature before uploads became single-slot, so pick the highest id (latest) rather than
|
||
// the first unordered match.
|
||
const sigDoc = docs
|
||
.filter((d: any) => d.name === 'clientSignature' || d.name?.toLowerCase().includes('signature'))
|
||
.sort((a: any, b: any) => b.id - a.id)[0];
|
||
if (sigDoc) {
|
||
this.http
|
||
.get(`/clients/${clientId}/documents/${sigDoc.id}/attachment`, { responseType: 'blob' })
|
||
.pipe(catchError(() => of(null)))
|
||
.subscribe((blob: Blob | null) => {
|
||
if (blob) {
|
||
this.signatureUrl = URL.createObjectURL(blob);
|
||
this.persistClerkState();
|
||
}
|
||
});
|
||
}
|
||
this.accountNotes = Array.isArray(r.notes) ? r.notes : [];
|
||
this.linkedAccounts = r.linkedAcc;
|
||
this.standingInstructions = r.si?.pageItems ?? (Array.isArray(r.si) ? r.si : []);
|
||
this.todayTransactions =
|
||
r.todayTxns?.pageItems ?? r.todayTxns?.content ?? (Array.isArray(r.todayTxns) ? r.todayTxns : []);
|
||
this.persistClerkState();
|
||
};
|
||
|
||
// Build all client-side observable calls (reused in both paths)
|
||
const buildClientCalls = (clientId: string) => ({
|
||
client: this.http
|
||
.get<any>(`/clients/${clientId}?associations=clientNonPersonDetails`)
|
||
.pipe(catchError(() => of(null))),
|
||
kyc: this.http.get<any>(`/clients/${clientId}/kyc`).pipe(catchError(() => of([]))),
|
||
nominees: this.http
|
||
.get<any>(`/clients/${clientId}/nominees?accountNo=${acNo}&accountType=${cfg.nominee}`)
|
||
.pipe(catchError(() => of([]))),
|
||
addresses: this.http.get<any>(`/client/${clientId}/addresses`).pipe(catchError(() => of([]))),
|
||
family: this.http.get<any>(`/clients/${clientId}/familymembers`).pipe(catchError(() => of([]))),
|
||
photo: this.http.get(`/clients/${clientId}/images`, { responseType: 'text' }).pipe(catchError(() => of(null))),
|
||
documents: this.http.get<any[]>(`/clients/${clientId}/documents`).pipe(catchError(() => of([]))),
|
||
notes: this.http.get<any>(`/savings/${accountId}/notes`).pipe(catchError(() => of([]))),
|
||
linkedAcc: this.http.get<any>(`/clients/${clientId}/accounts`).pipe(catchError(() => of(null))),
|
||
si: cfg.siFetchUnsupported
|
||
? of({ pageItems: [] })
|
||
: this.http
|
||
.get<any>(`/standinginstructions?fromAccountType=${cfg.siAccountType}&fromAccountId=${accountId}&limit=10`)
|
||
.pipe(catchError(() => of({ pageItems: [] }))),
|
||
todayTxns: cfg.txnFetchUnsupported
|
||
? of({ pageItems: [] })
|
||
: cfg.txnSearchSupported
|
||
? this.http
|
||
.get<any>(
|
||
`${cfg.txnBase}/${accountId}/transactions/search?fromDate=${todayStr}&toDate=${todayStr}&limit=20`
|
||
)
|
||
.pipe(catchError(() => of({ pageItems: [] })))
|
||
: this.http
|
||
.get<any>(`${cfg.txnBase}/${accountId}/transactions?limit=50`)
|
||
.pipe(catchError(() => of({ pageItems: [] })))
|
||
});
|
||
|
||
if (clientIdHint) {
|
||
// FAST PATH: account detail + all client calls fire simultaneously (1 round-trip saved)
|
||
forkJoin({
|
||
account: this.http.get<any>(accountUrl).pipe(catchError(() => of(null))),
|
||
...buildClientCalls(clientIdHint)
|
||
}).subscribe((result: any) => {
|
||
if (token !== this.searchToken) return;
|
||
const { account, ...clientData } = result;
|
||
if (!applyAccountData(account)) return;
|
||
applyClientData(clientIdHint, clientData);
|
||
});
|
||
return;
|
||
}
|
||
|
||
// SEQUENTIAL PATH: for GL accounts (no clientId in search result)
|
||
this.http
|
||
.get<any>(accountUrl)
|
||
.pipe(catchError(() => of(null)))
|
||
.subscribe((acc: any) => {
|
||
if (token !== this.searchToken) return;
|
||
if (!applyAccountData(acc)) return;
|
||
const clientId = acc.clientId?.toString();
|
||
if (!clientId) {
|
||
this.loadingAccount = false;
|
||
return;
|
||
}
|
||
forkJoin(buildClientCalls(clientId)).subscribe((r: any) => {
|
||
applyClientData(clientId, r);
|
||
});
|
||
});
|
||
}
|
||
|
||
// ── Alert getters ─────────────────────────────────────────────────────────
|
||
|
||
get accountAlerts(): { type: 'danger' | 'warning' | 'info'; message: string }[] {
|
||
const alerts: { type: 'danger' | 'warning' | 'info'; message: string }[] = [];
|
||
const acc = this.accountDetails;
|
||
if (!acc) return alerts;
|
||
|
||
// Block reason
|
||
if (acc.reasonForBlock) alerts.push({ type: 'danger', message: `Account Blocked: ${acc.reasonForBlock}` });
|
||
|
||
// Dormancy
|
||
const sub = acc.subStatus?.value ?? '';
|
||
if (sub === 'Dormant') alerts.push({ type: 'danger', message: 'Account is DORMANT — restricted transactions' });
|
||
if (sub === 'Inactive') alerts.push({ type: 'warning', message: 'Account is INACTIVE' });
|
||
if (sub === 'Escheat') alerts.push({ type: 'danger', message: 'Account is ESCHEAT — refer to manager' });
|
||
|
||
// Lien
|
||
const lienAmt = Number(acc.summary?.totalSavingsAmountOnHold ?? acc.onHoldFundsDerived ?? 0);
|
||
if (lienAmt > 0)
|
||
alerts.push({
|
||
type: 'warning',
|
||
message: `Lien / Hold: ₹${lienAmt.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`
|
||
});
|
||
|
||
// TDS
|
||
if (acc.withHoldTax)
|
||
alerts.push({ type: 'info', message: `TDS Applicable — Tax Group: ${acc.taxGroupData?.name ?? ''}` });
|
||
|
||
// Sanctions (from KYC) — DB stores 'YES'/'NO' strings, not booleans
|
||
const isYes = (v: any) => v === true || v === 'YES' || v === 'yes' || v === 'Y';
|
||
if (isYes(this.kycDetails?.bannedEntityMatch))
|
||
alerts.push({ type: 'danger', message: 'Sanctions Match — DO NOT process' });
|
||
if (isYes(this.kycDetails?.willfulDefaulter))
|
||
alerts.push({ type: 'danger', message: 'Willful Defaulter — Refer to Manager' });
|
||
// No AML-suspicion banner here by design. Sanctions and willful-defaulter above are legal
|
||
// prohibitions the cashier has to act on at the counter; AML suspicion is not — showing it
|
||
// risks tipping off the customer. It stays in the AML review queue and the STR/CTR reports.
|
||
|
||
return alerts;
|
||
}
|
||
|
||
get isDormant(): boolean {
|
||
const sub = this.accountDetails?.subStatus?.value ?? '';
|
||
return sub === 'Dormant' || sub === 'Inactive' || sub === 'Escheat';
|
||
}
|
||
|
||
get lienAmount(): number {
|
||
return Number(
|
||
this.accountDetails?.summary?.totalSavingsAmountOnHold ?? this.accountDetails?.onHoldFundsDerived ?? 0
|
||
);
|
||
}
|
||
|
||
get facilities(): { label: string; active: boolean }[] {
|
||
// Savings/FD/RD: accountFacilityConfig object
|
||
const f = this.accountDetails?.accountFacilityConfig ?? this.accountDetails?.facilityConfig;
|
||
|
||
if (f) {
|
||
// Savings / FD / RD — facilityConfig object available
|
||
return [
|
||
{ label: 'ATM Facility', active: !!f.atmFacility },
|
||
{ label: 'ATM Withdrawal', active: !!f.atmWithdrawalFacility },
|
||
{ label: 'UPI Facility', active: !!f.upiFacility },
|
||
{ label: 'POS Transactions', active: !!f.posTransactionsFacility },
|
||
{ label: 'Cheque Book', active: !!f.chequeBook },
|
||
{ label: 'IMPS Facility', active: !!f.impsFacility },
|
||
{ label: 'SMS Alerts', active: !!f.smsAlerts },
|
||
{ label: 'BBPS Facility', active: !!f.bbpsFacility },
|
||
{ label: 'Insurance Cover', active: !!f.insuranceCover },
|
||
{ label: 'BEPG Facility', active: !!f.bepgFacility },
|
||
{ label: 'NEFT Facility', active: !!f.neftFacility },
|
||
{ label: 'RTGS Facility', active: !!f.rtgsFacility },
|
||
{ label: 'Inter-Bank Cap', active: !!f.interBankTotalCapFacility },
|
||
{ label: 'Deposit (KYC)', active: !!f.depositFacilityKyc },
|
||
{ label: 'Virtual Debit Card', active: !!f.virtualDebitCard },
|
||
{ label: 'OD', active: !!this.accountDetails?.allowOverdraft }
|
||
];
|
||
}
|
||
|
||
// Current account — no facilityConfig; derive from charges array
|
||
// Each charge with isActive=true represents an enabled facility
|
||
const charges: any[] = this.accountDetails?.charges ?? [];
|
||
if (charges.length > 0) {
|
||
const chargeFacilityMap: Record<string, string> = {
|
||
'ATM Facility': 'ATM Facility',
|
||
'ATM Withdrawal': 'ATM Withdrawal',
|
||
'UPI Facility': 'UPI Facility',
|
||
'POS Transaction': 'POS Transactions',
|
||
'Cheque Book': 'Cheque Book',
|
||
'IMPS Facility': 'IMPS Facility',
|
||
'SMS Alert': 'SMS Alerts',
|
||
'BBPS Facility': 'BBPS Facility',
|
||
'Insurance Cover': 'Insurance Cover',
|
||
'BEPG Facility': 'BEPG Facility',
|
||
'NEFT Facility': 'NEFT Facility',
|
||
'RTGS Facility': 'RTGS Facility',
|
||
'Inter-Bank Cap': 'Inter-Bank Cap',
|
||
'Withdrawal Facility': 'Withdrawal',
|
||
'Deposit KYC': 'Deposit (KYC)'
|
||
};
|
||
const facilityItems = charges
|
||
.filter((c: any) => c.isActive)
|
||
.map((c: any) => {
|
||
const key = Object.keys(chargeFacilityMap).find((k) => c.name?.includes(k));
|
||
return key ? { label: chargeFacilityMap[key], active: true } : null;
|
||
})
|
||
.filter((x): x is { label: string; active: boolean } => x !== null);
|
||
|
||
// Remove duplicates, add OD separately
|
||
const seen = new Set<string>();
|
||
const unique = facilityItems.filter((i) => (seen.has(i.label) ? false : !!seen.add(i.label)));
|
||
unique.push({ label: 'OD', active: !!this.accountDetails?.allowOverdraft });
|
||
return unique;
|
||
}
|
||
|
||
// Fallback: show OD only
|
||
return [{ label: 'OD', active: !!this.accountDetails?.allowOverdraft }];
|
||
}
|
||
|
||
get todayCashTotal(): number {
|
||
return this.todayTransactions
|
||
.filter((t: any) => !t.reversed)
|
||
.reduce((s: number, t: any) => {
|
||
const amt = Number(t.amount ?? 0);
|
||
return s + (t.transactionType?.deposit ? amt : -amt);
|
||
}, 0);
|
||
}
|
||
|
||
// ── Balance getters ────────────────────────────────────────────────────────
|
||
|
||
get currentBalance(): number {
|
||
if (this.selectedRow?.acTyp === 'SHR') {
|
||
const shares = Number(this.accountDetails?.summary?.totalApprovedShares ?? 0);
|
||
const unitPrice = Number(this.accountDetails?.currentMarketPrice ?? 0);
|
||
return shares * unitPrice;
|
||
}
|
||
return this.accountDetails?.summary?.accountBalance ?? this.accountDetails?.organizationRunningBalance ?? 0;
|
||
}
|
||
|
||
get clearBalance(): number {
|
||
return this.accountDetails?.summary?.availableBalance ?? this.currentBalance;
|
||
}
|
||
|
||
get todayClgAmount(): number {
|
||
return this.accountDetails?.summary?.totalDepositsPostMaturityLevel ?? 0;
|
||
}
|
||
|
||
get freezeAmount(): number {
|
||
// Savings exposes the lien/user hold as root-level `savingsAmountOnHold`; Current exposes its
|
||
// own (differently-sourced) hold as `onHoldFunds`. `summary.holdAmountBalance` never existed in
|
||
// either API response, so this always read 0 before.
|
||
const acTyp = this.selectedRow?.acTyp;
|
||
const isCurrent = acTyp === 'CA' || acTyp === 'CB';
|
||
if (isCurrent) {
|
||
return this.accountDetails?.onHoldFunds ?? this.accountDetails?.summary?.onHoldFunds ?? 0;
|
||
}
|
||
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;
|
||
const isCurrent = acTyp === 'CA' || acTyp === 'CB';
|
||
// Savings' availableBalance is already computed hold-adjusted on the backend (subtracts both
|
||
// guarantor and lien holds) — subtracting freezeAmount again here would double-count it.
|
||
// Current's availableBalance is just the raw balance and does NOT subtract its hold, so it
|
||
// still needs freezeAmount subtracted client-side.
|
||
if (isCurrent) {
|
||
return Math.max(0, avail - this.freezeAmount);
|
||
}
|
||
return Math.max(0, avail);
|
||
}
|
||
|
||
get currencySymbol(): string {
|
||
return this.accountDetails?.currency?.displaySymbol ?? '₹';
|
||
}
|
||
|
||
get todayIso(): string {
|
||
const d = businessToday();
|
||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||
}
|
||
|
||
// ── Entry grid totals ─────────────────────────────────────────────────────
|
||
get totalDebit(): number {
|
||
return this.rows.filter((r) => r.dbCr === 'Dr').reduce((s, r) => s + (Number(r.amount) || 0), 0);
|
||
}
|
||
get totalCredit(): number {
|
||
return this.rows.filter((r) => r.dbCr === 'Cr').reduce((s, r) => s + (Number(r.amount) || 0), 0);
|
||
}
|
||
get difference(): number {
|
||
return Math.abs(this.totalDebit - this.totalCredit);
|
||
}
|
||
get isBalanced(): boolean {
|
||
return this.totalDebit > 0 && this.totalDebit === this.totalCredit;
|
||
}
|
||
|
||
formatInr(n: number): string {
|
||
return new Intl.NumberFormat('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(n);
|
||
}
|
||
|
||
deleteRow(i: number): void {
|
||
if (this.rows.length <= 1) return;
|
||
this.rows.splice(i, 1);
|
||
if (this.selectedRow && !this.rows.includes(this.selectedRow)) this.selectedRow = null;
|
||
}
|
||
|
||
get isClerk(): boolean {
|
||
// clerkMode forces the clerk layout (used by the clerk dashboard and the manager review screen)
|
||
return this.clerkMode || this.authService.isClerkUser();
|
||
}
|
||
|
||
get reviewMode(): boolean {
|
||
return this.reviewRequestId != null;
|
||
}
|
||
|
||
get editMode(): boolean {
|
||
return this.editRequestId != null;
|
||
}
|
||
|
||
/** Only REJECTED requests are editable; PENDING/APPROVED open read-only. */
|
||
get editResubmittable(): boolean {
|
||
return this.editMode && this.editRequest?.status === 'REJECTED';
|
||
}
|
||
|
||
/** Form is locked in manager review, or when viewing a non-rejected request in edit mode. */
|
||
get formReadOnly(): boolean {
|
||
return this.reviewMode || (this.editMode && !this.editResubmittable);
|
||
}
|
||
|
||
get isNonIndividual(): boolean {
|
||
// legalForm.id=1 → Individual, legalForm.id=2 → Non-Individual (Entity)
|
||
const lf = this.clientDetails?.legalForm?.id ?? this.clientDetails?.legalFormId;
|
||
return lf === 2;
|
||
}
|
||
|
||
/** Returns count of mandatory joint holders */
|
||
get mandatoryHolderCount(): number {
|
||
return this.jointHolders.filter((jh: any) => (jh.signingAuthority ?? '').toLowerCase() === 'mandatory').length;
|
||
}
|
||
|
||
/** Withdrawal rule message based on joint holders + mode */
|
||
get withdrawalRule(): { icon: string; msg: string; type: 'danger' | 'warning' } | null {
|
||
if (!this.jointHolders.length) return null;
|
||
const mandatory = this.mandatoryHolderCount;
|
||
const mode = this.accountDetails?.modeOfOperation ?? '';
|
||
|
||
if (mandatory >= 2) {
|
||
return {
|
||
icon: '✍',
|
||
msg: `All ${mandatory} joint holders must sign for withdrawal`,
|
||
type: 'danger'
|
||
};
|
||
}
|
||
if (mandatory === 1 && this.jointHolders.length > 1) {
|
||
return { icon: '✍', msg: 'Mandatory holder signature required for withdrawal', type: 'warning' };
|
||
}
|
||
if (mode === 'FORMER_OR_SURVIVOR') {
|
||
return { icon: '⚠', msg: 'Former or Survivor: Only primary holder can withdraw while alive', type: 'warning' };
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** Returns true if currently selected payment type is cash */
|
||
get isCashPayment(): boolean {
|
||
const row = this.selectedRow;
|
||
if (!row?.paymentTypeId) return false;
|
||
const pt = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId);
|
||
return !!pt?.isCashPayment;
|
||
}
|
||
|
||
/** Clerk attempting a cash deposit — blocked because cash/denomination is cashier's responsibility */
|
||
get isClerkCashDeposit(): boolean {
|
||
return this.isClerk && this.isCashPayment && this.selectedRow?.dbCr === 'Cr';
|
||
}
|
||
|
||
/** For cash: amount comes ONLY from denomination total */
|
||
get effectiveAmount(): number {
|
||
if (this.isCashPayment) return this.denominationTotal;
|
||
return this.selectedRow?.amount ?? 0;
|
||
}
|
||
|
||
get modeOfOperationLabel(): string {
|
||
const modeMap: Record<string, string> = {
|
||
FORMER_OR_SURVIVOR: 'Former or Survivor',
|
||
EITHER_OR_SURVIVOR: 'Either or Survivor',
|
||
ANYONE_OR_SURVIVOR: 'Any One or Survivor',
|
||
JOINTLY: 'Both Jointly',
|
||
SINGLE: 'Single',
|
||
OTHER: 'Other'
|
||
};
|
||
const raw = this.accountDetails?.modeOfOperation;
|
||
const mode = (typeof raw === 'object' ? raw?.value : raw) ?? '';
|
||
return modeMap[mode] ?? mode.replace(/_/g, ' ');
|
||
}
|
||
|
||
// ── Client detail getters ─────────────────────────────────────────────────
|
||
|
||
get clientName(): string {
|
||
return this.clientDetails?.displayName ?? this.accountDetails?.clientName ?? '—';
|
||
}
|
||
|
||
get guardianName(): string {
|
||
// S/D/W/H/O derived from family members
|
||
// API returns: firstName, middleName, lastName, relationship
|
||
const family: any[] = this.clientDetails?.familyMembers ?? [];
|
||
if (!family.length) return this.clientDetails?.guardianClientName ?? '—';
|
||
|
||
const priority = [
|
||
'FATHER',
|
||
'MOTHER',
|
||
'HUSBAND',
|
||
'WIFE',
|
||
'SPOUSE'
|
||
];
|
||
const prefixMap: Record<string, string> = {
|
||
FATHER: 'S/O',
|
||
MOTHER: 'D/O',
|
||
HUSBAND: 'W/O',
|
||
WIFE: 'H/O',
|
||
SPOUSE: 'W/O'
|
||
};
|
||
|
||
for (const rel of priority) {
|
||
const member = family.find((m: any) => (m.relationship ?? m.relationshipName ?? '').toUpperCase() === rel);
|
||
if (member) {
|
||
// API uses camelCase: firstName, middleName, lastName
|
||
const name = [
|
||
member.firstName,
|
||
member.middleName,
|
||
member.lastName
|
||
]
|
||
.filter((v) => v && v.trim())
|
||
.join(' ')
|
||
.trim();
|
||
return name ? `${prefixMap[rel] ?? rel} ${name}` : (prefixMap[rel] ?? rel);
|
||
}
|
||
}
|
||
// Fallback: first family member
|
||
const m = family[0];
|
||
const name = [
|
||
m.firstName,
|
||
m.middleName,
|
||
m.lastName
|
||
]
|
||
.filter((v) => v && v.trim())
|
||
.join(' ')
|
||
.trim();
|
||
return name || '—';
|
||
}
|
||
|
||
get clientAddress(): string {
|
||
// From /client/{id}/addresses — AddressData fields: addressLine1, townVillage, city, stateName, postalCode
|
||
if (this.clientAddresses.length > 0) {
|
||
const a = this.clientAddresses[0];
|
||
return [
|
||
a.addressLine1,
|
||
a.addressLine2,
|
||
a.addressLine3,
|
||
a.townVillage,
|
||
a.city,
|
||
a.countyDistrict,
|
||
a.stateName ?? a.stateProvinceLabel,
|
||
a.postalCode
|
||
]
|
||
.filter((v) => v && v.toString().trim())
|
||
.join(', ');
|
||
}
|
||
return '—';
|
||
}
|
||
|
||
get clientPhone(): string {
|
||
const mob = this.clientDetails?.mobileNo ?? this.accountDetails?.clientMobileNo;
|
||
const alt = this.clientDetails?.alternateMobileNo;
|
||
return (
|
||
[
|
||
mob,
|
||
alt
|
||
]
|
||
.filter(Boolean)
|
||
.join(' / ') || '—'
|
||
);
|
||
}
|
||
|
||
get clientEmail(): string {
|
||
return this.clientDetails?.emailAddress ?? '—';
|
||
}
|
||
|
||
get officeName(): string {
|
||
return this.accountDetails?.officeName ?? this.clientDetails?.officeName ?? '—';
|
||
}
|
||
|
||
get openingDate(): string {
|
||
const raw =
|
||
this.accountDetails?.timeline?.submittedOnDate ??
|
||
this.accountDetails?.submittedOnDate ??
|
||
this.accountDetails?.activatedOnDate;
|
||
if (!raw) return '—';
|
||
// Array format [yyyy, mm, dd] — savings/FD/RD
|
||
if (Array.isArray(raw) && raw.length >= 3) {
|
||
return `${String(raw[2]).padStart(2, '0')}/${String(raw[1]).padStart(2, '0')}/${raw[0]}`;
|
||
}
|
||
if (typeof raw === 'string') {
|
||
// ISO format 'yyyy-MM-dd'
|
||
if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) {
|
||
const [
|
||
y,
|
||
m,
|
||
d
|
||
] = raw.split('-');
|
||
return `${d}/${m}/${y}`;
|
||
}
|
||
// Locale string 'Jun 5, 2026' — current account
|
||
try {
|
||
const d = new Date(raw);
|
||
if (!isNaN(d.getTime())) {
|
||
return `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}/${d.getFullYear()}`;
|
||
}
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
return String(raw);
|
||
}
|
||
|
||
get lastTransactionDate(): string {
|
||
const d = this.accountDetails?.lastActiveTransactionDate;
|
||
return d ? `${d[2]}/${d[1]}/${d[0]}` : '—';
|
||
}
|
||
|
||
get panNumber(): string {
|
||
return this.kycDetails?.panNumber ?? this.kycDetails?.pan_number ?? '—';
|
||
}
|
||
|
||
get aadhaarNumber(): string {
|
||
return this.kycDetails?.aadhaarNumberMasked ?? this.kycDetails?.aadhaar_number_masked ?? 'XXXX/XXXX/XXXX';
|
||
}
|
||
|
||
get ckycNumber(): string {
|
||
return this.kycDetails?.ckycNumber ?? this.kycDetails?.ckyc_number ?? '—';
|
||
}
|
||
|
||
get kycStatus(): string {
|
||
const s = this.kycDetails?.kycStatus ?? this.kycDetails?.kyc_status;
|
||
if (!s) return '—';
|
||
const map: Record<number, string> = { 100: 'Pending', 200: 'In Progress', 300: 'Verified', 400: 'Rejected' };
|
||
return map[s] ?? `KYC-${s}`;
|
||
}
|
||
|
||
get cifNumber(): string {
|
||
return this.clientDetails?.cifNumber ?? this.clientDetails?.cif_number ?? '—';
|
||
}
|
||
|
||
// ── Joint holder click-to-expand ──────────────────────────────────────────
|
||
|
||
toggleHolder(index: number, jh: any): void {
|
||
const clientId = jh.clientId ?? jh.client_id;
|
||
this.dialog.open(JointHolderDialogComponent, {
|
||
data: { jh, clientId },
|
||
width: '620px',
|
||
maxWidth: '95vw',
|
||
panelClass: 'jhd-panel',
|
||
autoFocus: false
|
||
});
|
||
}
|
||
|
||
/** 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
|
||
});
|
||
}
|
||
|
||
// ── Account Actions (clerk): close this account ──────────────────────────────
|
||
|
||
/** The Action button only makes sense once a real, ACTIVE savings account is on screen — a
|
||
* closure request needs an actual account id to submit against, and only a clerk is the maker
|
||
* in this flow (the cashier's side is the token disbursement, not the request). */
|
||
get canShowAccountActions(): boolean {
|
||
return (
|
||
this.isClerk &&
|
||
!this.reviewMode &&
|
||
!this.editMode &&
|
||
this.selectedRow?.acTyp === 'SD' &&
|
||
!!this.accountDetails?.id &&
|
||
this.accountDetails?.status?.value === 'Active'
|
||
);
|
||
}
|
||
|
||
openAccountActions(): void {
|
||
this.closeAccountChecked = this.pendingClosure;
|
||
this.closurePreviewError = '';
|
||
if (!this.pendingClosure) {
|
||
this.closurePreview = null;
|
||
}
|
||
this.showAccountActions = true;
|
||
}
|
||
|
||
closeAccountActions(): void {
|
||
this.showAccountActions = false;
|
||
}
|
||
|
||
/** Ticking "Close this account" posts every rupee of interest the member has earned to date onto
|
||
* the account for real — the same posting close() itself performs — and shows the resulting real
|
||
* balance. This is not an estimate: the interest is credited the moment the box is ticked, exactly
|
||
* as the user asked for ("jaise FD close/maturity ke time interest post hota tha"). Approval later
|
||
* finds this same posting already sitting at the matching period end and adds nothing more. */
|
||
onCloseAccountToggle(): void {
|
||
if (!this.closeAccountChecked) {
|
||
this.closurePreview = null;
|
||
this.closurePreviewError = '';
|
||
return;
|
||
}
|
||
const accountId = this.accountDetails?.id;
|
||
if (!accountId) return;
|
||
|
||
this.closurePreviewLoading = true;
|
||
this.closurePreviewError = '';
|
||
this.closureRequestService
|
||
.postClosureInterest(accountId)
|
||
.pipe(
|
||
catchError((err: any) => {
|
||
this.closurePreviewError =
|
||
err?.error?.message ??
|
||
err?.error?.errors?.[0]?.defaultUserMessage ??
|
||
'Could not post interest to the account. Please try again.';
|
||
return of(null);
|
||
})
|
||
)
|
||
.subscribe((res: any) => {
|
||
this.closurePreviewLoading = false;
|
||
if (!res) return;
|
||
this.closurePreview = {
|
||
balance: Number(res.balance ?? 0),
|
||
interest: Number(res.accruedInterest ?? 0),
|
||
total: Number(res.total ?? 0),
|
||
upToDate: res.interestUpToDate !== false,
|
||
asOfDate: res.asOfDate ?? null
|
||
};
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Applies the closure to the slip on screen — it does NOT submit anything. The calculated payout
|
||
* drops into the row's AMOUNT, the row is marked as a closure, and the clerk then fills TOK. NO.
|
||
* and DESCRIPTION on the slip itself and presses Save like any other entry. Save is what actually
|
||
* raises the request (see submitClosureRequest()).
|
||
*/
|
||
applyCloseAccount(): void {
|
||
if (!this.closeAccountChecked) {
|
||
// Unticking is how the clerk backs out of a closure they had applied.
|
||
this.clearPendingClosure();
|
||
this.showAccountActions = false;
|
||
return;
|
||
}
|
||
if (!this.closurePreview) return;
|
||
|
||
// Fall back to the first row: the popup is opened from the footer, so a stray click can leave
|
||
// selectedRow unset even though a row is plainly on screen — and Save reads selectedRow too.
|
||
const row = this.selectedRow ?? this.rows[0] ?? null;
|
||
if (!row) {
|
||
this.showAlert('error', 'No Transaction Row', ['Add a transaction row before closing the account.']);
|
||
return;
|
||
}
|
||
this.selectedRow = row;
|
||
row.amount = this.closurePreview.total;
|
||
row.dbCr = 'Dr';
|
||
if (!row.description) {
|
||
row.description = 'Account closure — full balance withdrawal';
|
||
}
|
||
this.pendingClosure = true;
|
||
this.showAccountActions = false;
|
||
}
|
||
|
||
/** Drops closure mode and the amount it filled in, so the slip goes back to an ordinary entry. */
|
||
clearPendingClosure(): void {
|
||
this.pendingClosure = false;
|
||
this.closeAccountChecked = false;
|
||
this.closurePreview = null;
|
||
this.closurePreviewError = '';
|
||
const row = this.selectedRow;
|
||
if (row && row.description === 'Account closure — full balance withdrawal') {
|
||
row.description = '';
|
||
}
|
||
if (row) {
|
||
row.amount = null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Raises the closure request from the slip's own fields. Reached from confirmSubmit() when the
|
||
* clerk presses Save with closure mode applied, instead of the ordinary withdrawal-request path.
|
||
*/
|
||
private submitClosureRequest(row: TxnRow): void {
|
||
const accountId = this.accountDetails?.id;
|
||
if (!accountId) return;
|
||
|
||
this.saving = true;
|
||
this.closureRequestService
|
||
.submit({
|
||
savingsAccountId: accountId,
|
||
paymentTypeId: null as number | null,
|
||
paymentTypeName: 'Cash',
|
||
description: row.description || null,
|
||
tokNo: row.tokNo?.trim() || null
|
||
})
|
||
.pipe(
|
||
catchError((err: any) => {
|
||
const msg =
|
||
err?.error?.message ??
|
||
err?.error?.errors?.[0]?.defaultUserMessage ??
|
||
'Could not submit the closure request. Please try again.';
|
||
this.showAlert('error', 'Closure Request Failed', [msg]);
|
||
this.saving = false;
|
||
return of(null);
|
||
})
|
||
)
|
||
.subscribe((res: any) => {
|
||
this.saving = false;
|
||
if (!res) return;
|
||
|
||
const preview = this.closurePreview;
|
||
// The server has now POSTED the interest and returns the account's real resulting balance.
|
||
// That, not the popup's estimate, is what the closure pays out — so report it, and say so
|
||
// outright if the two ever disagree rather than letting a paise drift pass unnoticed.
|
||
const posted = res.payableAmount != null ? Number(res.payableAmount) : null;
|
||
const rupees = (v: number) => `₹${v.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`;
|
||
const drifted = posted != null && preview != null && Math.abs(posted - preview.total) >= 0.005;
|
||
|
||
this.showAlert(
|
||
'success',
|
||
'Closure Request Submitted',
|
||
[
|
||
`Account ${this.accountDetails?.accountNo} has been sent to the Bank Manager for closure approval.`,
|
||
posted != null
|
||
? `Interest has been posted to the account. Payout: ${rupees(posted)}.`
|
||
: preview
|
||
? `Payout: ${rupees(preview.total)}.`
|
||
: '',
|
||
drifted && preview
|
||
? `Note: the posted figure ${rupees(posted!)} differs from the ${rupees(preview.total)} shown a moment ago — the posted figure is the one that will be paid.`
|
||
: '',
|
||
`Token No: ${row.tokNo?.trim()} — the cashier will hand over the cash against this token once approved.`,
|
||
'Cash is handed over rounded to whole rupees; the paise difference goes to the Round Off Account.'
|
||
].filter(Boolean)
|
||
);
|
||
|
||
this.pendingClosure = false;
|
||
this.closeAccountChecked = false;
|
||
this.closurePreview = null;
|
||
this.onCancel();
|
||
});
|
||
}
|
||
|
||
// ── Same-day compliance indicators (copied from SavingsAccountTransactionsComponent) ──
|
||
|
||
private computeSameDayNeftRtgs(transactions: any[] | undefined): void {
|
||
if (!Array.isArray(transactions) || !transactions.length) {
|
||
this.sameDayNeftRtgs = 0;
|
||
return;
|
||
}
|
||
const today = new Date();
|
||
const sameDay = (d: any) => {
|
||
if (!d) return false;
|
||
const t = Array.isArray(d) && d.length >= 3 ? new Date(d[0], d[1] - 1, d[2]) : new Date(d);
|
||
return (
|
||
t.getFullYear() === today.getFullYear() && t.getMonth() === today.getMonth() && t.getDate() === today.getDate()
|
||
);
|
||
};
|
||
this.sameDayNeftRtgs = transactions
|
||
.filter(
|
||
(t: any) =>
|
||
!t.reversed &&
|
||
sameDay(t.date ?? t.transactionDate) &&
|
||
/NEFT|RTGS/.test(
|
||
(
|
||
t.transactionType?.value ??
|
||
t.transactionType?.code ??
|
||
t.paymentDetailData?.paymentType?.name ??
|
||
''
|
||
).toUpperCase()
|
||
)
|
||
)
|
||
.reduce((s: number, t: any) => s + (Number(t.amount) || 0), 0);
|
||
}
|
||
|
||
private computeSameDayChequeClearing(transactions: any[] | undefined): void {
|
||
if (!Array.isArray(transactions) || !transactions.length) {
|
||
this.sameDayChequeClearing = 0;
|
||
return;
|
||
}
|
||
const today = businessToday();
|
||
const sameDay = (d: any) => {
|
||
if (!d) return false;
|
||
const t = Array.isArray(d) && d.length >= 3 ? new Date(d[0], d[1] - 1, d[2]) : new Date(d);
|
||
return (
|
||
t.getFullYear() === today.getFullYear() && t.getMonth() === today.getMonth() && t.getDate() === today.getDate()
|
||
);
|
||
};
|
||
this.sameDayChequeClearing = transactions
|
||
.filter(
|
||
(t: any) =>
|
||
!t.reversed &&
|
||
sameDay(t.date ?? t.transactionDate) &&
|
||
(!!t.paymentDetailData?.checkNumber ||
|
||
/CHEQUE|CHECK/.test(
|
||
(t.transactionType?.value ?? t.paymentDetailData?.paymentType?.name ?? '').toUpperCase()
|
||
))
|
||
)
|
||
.reduce((s: number, t: any) => s + (Number(t.amount) || 0), 0);
|
||
}
|
||
|
||
/** Open full-screen preview dialog for photo or signature */
|
||
previewImage(url: string | null, title: string): void {
|
||
if (!url) return;
|
||
this.dialog.open(ImagePreviewDialogComponent, {
|
||
data: { url, title },
|
||
maxWidth: '90vw',
|
||
maxHeight: '90vh',
|
||
panelClass: 'te-preview-dialog'
|
||
});
|
||
}
|
||
|
||
onSave(): void {
|
||
// Demand drafts commit here like everything else on the worksheet. Checked first because a DD
|
||
// row shares its shape with the ordinary entries below — without this it would fall through and
|
||
// post as a plain transaction, with the draft never raised and the charges silently dropped.
|
||
const ddRows = this.rows.filter((r) => this.isDdRow(r));
|
||
if (ddRows.length > 0) {
|
||
this.saveDdRows(ddRows);
|
||
return;
|
||
}
|
||
|
||
// Clerk bank transfer — N DR savings/CA + N CR savings/CA, immediate execution. 'Fund Transfer'
|
||
// is the same flow under a second TYPE label — onTypChange() already defaults it identically to
|
||
// 'Enter Bank Transfer', so submission has to recognise both or a share redemption or bank
|
||
// transfer raised under this label silently falls through to the wrong branch below.
|
||
//
|
||
// But 'Fund Transfer' rows that include a GL leg are NOT this flow — they are the
|
||
// Disbursement/Collection double-entry shape (see the TYPE options comment), just under the
|
||
// friendlier 'Fund Transfer' label so a non-loan GL movement doesn't have to be called a
|
||
// "Disbursement". 'Enter Bank Transfer' never carries a GL leg, so it always stays in this
|
||
// branch; only skip out to the Disbursement/Collection branch below when every row is 'Fund
|
||
// Transfer' and at least one of them is GL.
|
||
// Share redemption settled to a GL account instead of the linked savings account: DR=Share
|
||
// account, CR=any GL account the clerk picks (e.g. a "DD Payable" holding account, ahead of a
|
||
// second, independent manual entry that later moves the value out to an actual draft). Checked
|
||
// ahead of both branches below — the Enter Bank Transfer/Fund Transfer branch would otherwise
|
||
// treat a Fund Transfer carrying a GL leg as the Disbursement/Collection shape (see
|
||
// fundTransferRowsHaveGl below), and a Share account can never be debited by that path at all —
|
||
// no such Fineract command exists — so it would either be silently mis-posted or rejected late.
|
||
if (
|
||
this.isClerk &&
|
||
this.rows.length === 2 &&
|
||
this.rows.every((r) => r.typ === 'Fund Transfer' || r.typ === 'Enter Bank Transfer') &&
|
||
this.rows.some((r) => r.dbCr === 'Dr' && r.acTyp === 'SHR') &&
|
||
this.rows.some((r) => r.dbCr === 'Cr' && r.acTyp === 'GL')
|
||
) {
|
||
const shareRow = this.rows.find((r) => r.dbCr === 'Dr' && r.acTyp === 'SHR')!;
|
||
const glRow = this.rows.find((r) => r.dbCr === 'Cr' && r.acTyp === 'GL')!;
|
||
this.submitShareGlSettlementTransfer(shareRow, glRow);
|
||
return;
|
||
}
|
||
|
||
const fundTransferRowsHaveGl =
|
||
this.rows.every((r) => r.typ === 'Fund Transfer') && this.rows.some((r) => r.acTyp === 'GL');
|
||
if (
|
||
this.isClerk &&
|
||
this.rows.length >= 2 &&
|
||
this.rows.every((r) => r.typ === 'Enter Bank Transfer' || r.typ === 'Fund Transfer') &&
|
||
!fundTransferRowsHaveGl
|
||
) {
|
||
const drRows = this.rows.filter((r) => r.dbCr === 'Dr');
|
||
const crRows = this.rows.filter((r) => r.dbCr === 'Cr');
|
||
|
||
if (drRows.length === 0) {
|
||
this.showAlert('error', 'DR Row Missing', ['At least one DR row (debit account) is required.']);
|
||
return;
|
||
}
|
||
if (crRows.length === 0) {
|
||
this.showAlert('error', 'CR Row Missing', ['At least one CR row (credit account) is required.']);
|
||
return;
|
||
}
|
||
|
||
// Share redemption via linked-account transfer: DR=Share account, CR=its linked savings
|
||
// account. A Share account can never be debited by a plain ledger transfer (no such Fineract
|
||
// command exists) — so this exact one-DR/one-CR shape is routed to submitShareRedemptionTransfer()
|
||
// instead, which submits a governed Share Withdrawal Request through the same maker-checker
|
||
// table as the dedicated screen; approval both redeems the shares and settles the linked account.
|
||
const shareDrRows = drRows.filter((r) => r.acTyp === 'SHR');
|
||
if (shareDrRows.length > 0) {
|
||
if (drRows.length !== 1 || crRows.length !== 1) {
|
||
this.showAlert('error', 'Unsupported Share Transfer', [
|
||
'A Share redemption transfer supports exactly one DR row (the Share account) and one CR row (its linked savings account).'
|
||
]);
|
||
return;
|
||
}
|
||
this.submitShareRedemptionTransfer(shareDrRows[0], crRows[0]);
|
||
return;
|
||
}
|
||
|
||
// Bank transfer: only savings (SD) or current (CA/CB) accounts allowed
|
||
const allowedTypes = new Set([
|
||
'SD',
|
||
'CA',
|
||
'CB'
|
||
]);
|
||
const invalidAccounts = [
|
||
...drRows,
|
||
...crRows
|
||
].filter((r) => !allowedTypes.has(r.acTyp));
|
||
if (invalidAccounts.length > 0) {
|
||
this.showAlert('error', 'Invalid Account Type', [
|
||
`Account type(s) not allowed in Bank Transfer: ${invalidAccounts.map((r) => r.acTyp + ' (' + r.acNo + ')').join(', ')}`,
|
||
'Only Savings (SD) and Current (CA) accounts are permitted.',
|
||
'GL, FD, RD, SHR accounts are not allowed — use Disbursement/Collection instead.'
|
||
]);
|
||
return;
|
||
}
|
||
|
||
const totalDr = drRows.reduce((s, r) => s + (r.amount ?? 0), 0);
|
||
const totalCr = crRows.reduce((s, r) => s + (r.amount ?? 0), 0);
|
||
|
||
if (Math.abs(totalDr - totalCr) > 0.005) {
|
||
this.showAlert('error', 'Unbalanced Entry', [
|
||
`DR ₹${totalDr.toLocaleString('en-IN', { minimumFractionDigits: 2 })} ≠ CR ₹${totalCr.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
|
||
'Total DR must equal total CR for a bank transfer.'
|
||
]);
|
||
return;
|
||
}
|
||
|
||
// All accounts must be loaded
|
||
const unloaded = [
|
||
...drRows,
|
||
...crRows
|
||
].filter((r) => !this.rowAccountIds[r.acNo]);
|
||
if (unloaded.length > 0) {
|
||
this.showAlert('error', 'Account Not Loaded', [
|
||
`Please load account details for: ${unloaded.map((r) => r.acNo).join(', ')}`
|
||
]);
|
||
return;
|
||
}
|
||
|
||
const confirmLines = [
|
||
`DR (${drRows.length} account${drRows.length !== 1 ? 's' : ''}):`,
|
||
...drRows.map(
|
||
(r) => ` [${r.acTyp}] ${r.acNo} ₹${(r.amount ?? 0).toLocaleString('en-IN', { minimumFractionDigits: 2 })}`
|
||
),
|
||
`CR (${crRows.length} account${crRows.length !== 1 ? 's' : ''}):`,
|
||
...crRows.map(
|
||
(r) => ` [${r.acTyp}] ${r.acNo} ₹${(r.amount ?? 0).toLocaleString('en-IN', { minimumFractionDigits: 2 })}`
|
||
),
|
||
`Total: ₹${totalDr.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
|
||
'Transfer requires Manager approval before execution.'
|
||
];
|
||
this.showConfirmAlert('info', 'Confirm Bank Transfer', confirmLines, 'Submit for Approval', () =>
|
||
this.submitBankTransfer()
|
||
);
|
||
return;
|
||
}
|
||
|
||
// Clerk multi-row disbursement — handle before single-row guard (selected row may be incomplete).
|
||
// Fund Transfer rides the exact same double-entry flow as Disbursement (see the TYPE options
|
||
// comment above) — only the label differs.
|
||
if (
|
||
this.isClerk &&
|
||
this.rows.length >= 2 &&
|
||
this.rows.every((r) => r.typ === 'Disbursement' || r.typ === 'Fund Transfer')
|
||
) {
|
||
const drRows = this.rows.filter((r) => r.dbCr === 'Dr');
|
||
const crRows = this.rows.filter((r) => r.dbCr === 'Cr');
|
||
|
||
if (drRows.length === 0) {
|
||
this.showAlert('error', 'DR Row Missing', [
|
||
'One DR row (source GL account) is required.',
|
||
'Select DR for the source account row.'
|
||
]);
|
||
return;
|
||
}
|
||
if (crRows.length === 0) {
|
||
this.showAlert('error', 'CR Row Missing', ['At least one CR row (destination account) is required.']);
|
||
return;
|
||
}
|
||
|
||
// Detect mode
|
||
// Disbursement: exactly 1 DR [GL] + N CR accounts
|
||
// Collection: N DR [Savings/CA] + exactly 1 CR [GL]
|
||
const isCollectionMode = drRows.every((r) => r.acTyp !== 'GL') && crRows.length === 1 && crRows[0].acTyp === 'GL';
|
||
const isDisbursementMode = drRows.length === 1 && drRows[0].acTyp === 'GL';
|
||
|
||
if (!isCollectionMode && !isDisbursementMode) {
|
||
this.showAlert('error', 'Invalid Entry Structure', [
|
||
'Disbursement: 1 DR [GL] source + N CR accounts.',
|
||
'Collection: N DR [Savings/CA] + 1 CR [GL] target.',
|
||
`Found: ${drRows.length} DR row(s) — verify account types.`
|
||
]);
|
||
return;
|
||
}
|
||
|
||
const totalDr = drRows.reduce((s, r) => s + (r.amount ?? 0), 0);
|
||
const totalCr = crRows.reduce((s, r) => s + (r.amount ?? 0), 0);
|
||
|
||
if (Math.abs(totalDr - totalCr) > 0.005) {
|
||
this.showAlert('error', 'Unbalanced Entry', [
|
||
`DR ₹${totalDr.toLocaleString('en-IN', { minimumFractionDigits: 2 })} ≠ Total CR ₹${totalCr.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
|
||
'All CR amounts must sum to total DR amount.'
|
||
]);
|
||
return;
|
||
}
|
||
|
||
const isFundTransfer = this.rows[0]?.typ === 'Fund Transfer';
|
||
const title = isCollectionMode
|
||
? 'Confirm Collection Request'
|
||
: isFundTransfer
|
||
? 'Confirm Fund Transfer Request'
|
||
: 'Confirm Disbursement Request';
|
||
const confirmLines = isCollectionMode ? [
|
||
`DR (${drRows.length} account${drRows.length !== 1 ? 's' : ''} to collect):`,
|
||
...drRows.map(
|
||
(r) =>
|
||
` [${r.acTyp}] ${r.acNo || '—'} ₹${(r.amount ?? 0).toLocaleString('en-IN', { minimumFractionDigits: 2 })}`
|
||
),
|
||
`CR [${crRows[0].acTyp}] ${crRows[0].acNo || '—'} — ₹${totalCr.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
|
||
'Request will be PENDING until manager approves.'
|
||
] : [
|
||
`DR [${drRows[0].acTyp}] ${drRows[0].acNo || '—'} — ₹${totalDr.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
|
||
`CR (${crRows.length} account${crRows.length !== 1 ? 's' : ''}):`,
|
||
...crRows.map(
|
||
(r) =>
|
||
` [${r.acTyp}] ${r.acNo} ₹${(r.amount ?? 0).toLocaleString('en-IN', { minimumFractionDigits: 2 })}`
|
||
),
|
||
'Request will be PENDING until manager approves.'
|
||
];
|
||
this.showConfirmAlert('info', title, confirmLines, 'Submit for Approval', () =>
|
||
this.submitDisbursementDoubleEntry()
|
||
);
|
||
return;
|
||
}
|
||
|
||
const row = this.selectedRow;
|
||
// Never fail silently here: a Save that does literally nothing is indistinguishable from a
|
||
// broken button, and this guard is the one path in onSave() that used to return without saying
|
||
// anything.
|
||
if (!row || !row.acNo.trim() || !this.accountDetails) {
|
||
this.showAlert('error', 'Nothing to Save', [
|
||
!row
|
||
? 'No transaction row is selected. Click into a row first.'
|
||
: !row.acNo.trim()
|
||
? 'Enter an account number on the row.'
|
||
: 'Account details are not loaded yet. Re-enter the account number and wait for it to load.'
|
||
]);
|
||
return;
|
||
}
|
||
|
||
const selectedPtEarly = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId);
|
||
const isCash = !!selectedPtEarly?.isCashPayment;
|
||
const isDdCashGl = this.isDdCashGlRow(row);
|
||
// Clerk mode: always use manually entered amount; Cashier mode: cash amount comes from denomination
|
||
// DD + Cash + GL: amount is DD face value + charges + GST (rounded up), not the counted denomination
|
||
// A closure's amount is the calculated payout on the row (balance + interest to date), never the
|
||
// counted denomination: the clerk raising the request counts no cash — the cashier does that
|
||
// later, at hand-over. Keyed off pendingClosure rather than clerkMode because clerkMode is only
|
||
// set by the Clerk Dashboard wrapper, so a clerk reaching this screen by any other route would
|
||
// otherwise have the payout silently replaced by a zero denomination total.
|
||
const amount = isDdCashGl
|
||
? this.ddTotalPayableFor(row)
|
||
: isCash && !this.clerkMode && !this.pendingClosure
|
||
? this.denominationTotal
|
||
: (row.amount ?? this.denominationTotal);
|
||
|
||
if (isCash && this.denominationTotal <= 0 && !this.clerkMode && !isDdCashGl && !this.pendingClosure) {
|
||
this.showAlert('error', 'Denomination Required', [
|
||
'Cash transaction: Please enter denomination (notes/coins count) first.'
|
||
]);
|
||
return;
|
||
}
|
||
|
||
// DD + Cash + GL: physical cash counted must reconcile with Face Value + Charges + GST
|
||
if (isDdCashGl && !this.clerkMode) {
|
||
if (this.denominationTotal <= 0) {
|
||
this.showAlert('error', 'Denomination Required', [
|
||
`Cash transaction: Please count notes/coins totalling ₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2 })} (DD Face Value + Charges + GST).`
|
||
]);
|
||
return;
|
||
}
|
||
if (Math.abs(this.denominationTotal - amount) > 0.005) {
|
||
this.showAlert('error', 'Denomination Mismatch', [
|
||
`Cash counted: ₹${this.denominationTotal.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
|
||
`Total To Collect: ₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2 })} (Face Value + Charges + GST)`,
|
||
'Adjust the notes/coins breakdown to match the Total To Collect exactly.'
|
||
]);
|
||
return;
|
||
}
|
||
}
|
||
|
||
if (!amount || amount <= 0) {
|
||
this.showAlert('error', 'Amount Required', ['Please enter a valid transaction amount.']);
|
||
return;
|
||
}
|
||
|
||
// DD + Cash + GL: reject at cash intake, before the cashier even accepts the money, if the DD
|
||
// Face Value exceeds the admin-configured DD limit (same limit enforced again at DD issuance).
|
||
if (isDdCashGl && this.ddLimitAmount != null && this.ddLimitAmount > 0 && Number(row.amount) > this.ddLimitAmount) {
|
||
this.showAlert('error', 'DD Limit Exceeded', [
|
||
`DD Face Value (₹${Number(row.amount).toLocaleString('en-IN', { minimumFractionDigits: 2 })}) exceeds the configured DD limit of ₹${this.ddLimitAmount.toLocaleString('en-IN', { minimumFractionDigits: 2 })}.`,
|
||
'Reduce the amount, or issue this as separate DDs within the limit each.'
|
||
]);
|
||
return;
|
||
}
|
||
|
||
const isDeposit = row.dbCr === 'Cr';
|
||
|
||
// Clerk cash deposit is not permitted — cash handling (notes/coins) is cashier's responsibility
|
||
if (this.isClerk && isDeposit && isCash) {
|
||
this.showAlert('error', 'Cash Deposit Not Permitted', [
|
||
'Cash deposits cannot be processed by a clerk.',
|
||
'Only the cashier can accept cash deposits (notes/coins denomination is managed at cashier vault).'
|
||
]);
|
||
return;
|
||
}
|
||
|
||
// Token number is required for all withdrawals (clerk must enter, cashier must enter approved token)
|
||
if (!this.reviewMode && !isDeposit && !row.tokNo?.trim()) {
|
||
this.showAlert('error', 'Token Number Required', [
|
||
this.isClerk
|
||
? 'Please enter a Token Number (TOK. NO.) for this withdrawal request.'
|
||
: 'Please enter the approved Token Number (TOK. NO.) to process this withdrawal.'
|
||
]);
|
||
return;
|
||
}
|
||
|
||
// Cashier direct withdrawal BLOCKED — must use approved maker-checker token
|
||
if (
|
||
!this.isClerk &&
|
||
!this.reviewMode &&
|
||
!isDeposit &&
|
||
!this.activeWithdrawalRequest &&
|
||
!this.activeDividendClaimRequest &&
|
||
!this.activeClosureRequest
|
||
) {
|
||
this.showAlert('error', 'Direct Withdrawal Not Permitted', [
|
||
'Withdrawals cannot be processed directly by the cashier.',
|
||
'All withdrawals must go through the Clerk → Manager approval process.',
|
||
'Enter the approved Token Number (TOK. NO.) to disburse cash.'
|
||
]);
|
||
return;
|
||
}
|
||
|
||
// Approved withdrawal / dividend cash claim / account closure: amount/denomination must match
|
||
if (
|
||
!this.isClerk &&
|
||
(this.activeWithdrawalRequest || this.activeDividendClaimRequest || this.activeClosureRequest) &&
|
||
!isDeposit
|
||
) {
|
||
const rawApprovedAmt = Number(
|
||
this.activeWithdrawalRequest?.amount ??
|
||
this.activeDividendClaimRequest?.amount ??
|
||
this.activeClosureRequest?.cashier_disbursed_amount
|
||
);
|
||
// A dividend cash claim's liability is stored with paise precision (a proportional profit
|
||
// share), but cash can only be handed over in whole-rupee notes/coins -- compare against
|
||
// the floored amount so an exact denomination match is actually achievable. Ordinary
|
||
// withdrawal-request amounts are already whole rupees by convention, so this only changes
|
||
// behavior for dividend claims.
|
||
const approvedAmt = this.activeDividendClaimRequest ? Math.floor(rawApprovedAmt) : rawApprovedAmt;
|
||
if (Math.abs(amount - approvedAmt) > 0.005) {
|
||
if (isCash) {
|
||
this.showAlert('error', 'Denomination Mismatch — Approved Withdrawal', [
|
||
`Cash denomination total ₹${this.denominationTotal.toLocaleString('en-IN', { minimumFractionDigits: 2 })} does not match the approved withdrawal amount ₹${approvedAmt.toLocaleString('en-IN', { minimumFractionDigits: 2 })}.`,
|
||
`Adjust the notes/coins breakdown to total exactly ₹${approvedAmt.toLocaleString('en-IN', { minimumFractionDigits: 2 })}.`
|
||
]);
|
||
} else {
|
||
this.showAlert('error', 'Amount Mismatch — Approved Withdrawal', [
|
||
`Amount has been changed from the approved value.`,
|
||
`Approved: ₹${approvedAmt.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
|
||
`Entered: ₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
|
||
'Reset the amount to the approved value before proceeding.'
|
||
]);
|
||
}
|
||
return;
|
||
}
|
||
}
|
||
const isWithdrawal = !isDeposit;
|
||
|
||
// ── Validation ─────────────────────────────────────────────────────────
|
||
|
||
// 0. Cashier day-status hard blocks
|
||
if (this.previousDayUnsettled) {
|
||
this.showAlert('error', 'Transaction Blocked — Previous Day Not Settled', [
|
||
`Cash settlement is pending${this.unsettledSince ? ' since ' + this.unsettledSince : ''}.`,
|
||
'Complete Cash Settlement before processing any transactions.'
|
||
]);
|
||
return;
|
||
}
|
||
if (!this.morningTransferReceived) {
|
||
this.showAlert('error', 'Transaction Blocked — No Cash Allocated', [
|
||
'No morning cash transfer has been received for your drawer today.',
|
||
'Request a morning transfer from the vault before processing any transactions.'
|
||
]);
|
||
return;
|
||
}
|
||
|
||
// 0a. Account blocked
|
||
if (this.accountDetails.reasonForBlock) {
|
||
this.showAlert('error', 'Account Blocked', [
|
||
`Reason: ${this.accountDetails.reasonForBlock}`,
|
||
'Please contact the branch manager.'
|
||
]);
|
||
return;
|
||
}
|
||
|
||
// 0b. Dormant / Inactive / Escheat
|
||
const subStatus = this.accountDetails?.subStatus?.value ?? '';
|
||
if (subStatus === 'Dormant') {
|
||
this.showAlert('error', 'Account is Dormant', ['Reactivate the account before processing any transactions.']);
|
||
return;
|
||
}
|
||
if (subStatus === 'Escheat') {
|
||
this.showAlert('error', 'Account is Escheat', ['Refer to Branch Manager — no transactions permitted.']);
|
||
return;
|
||
}
|
||
if (subStatus === 'Inactive') {
|
||
this.showConfirmAlert(
|
||
'warning',
|
||
'Account is Inactive',
|
||
[
|
||
'Transactions may be restricted.',
|
||
'Have you obtained manager approval to proceed?'
|
||
],
|
||
'Proceed Anyway',
|
||
() => this.proceedAfterValidation(row, amount, isDeposit, selectedPtEarly)
|
||
);
|
||
return;
|
||
}
|
||
|
||
// 0c. Sanctions / Defaulter — DB stores 'YES'/'NO' strings
|
||
const isYes = (v: any) => v === true || v === 'YES' || v === 'yes' || v === 'Y';
|
||
if (isYes(this.kycDetails?.bannedEntityMatch)) {
|
||
this.showAlert('error', 'Sanctions Match — Transaction Blocked', [
|
||
'DO NOT process — contact Compliance team immediately.'
|
||
]);
|
||
return;
|
||
}
|
||
if (isYes(this.kycDetails?.willfulDefaulter)) {
|
||
this.showAlert('error', 'Willful Defaulter', ['Refer to Branch Manager.']);
|
||
return;
|
||
}
|
||
|
||
// 0d–6: remaining validations
|
||
this.proceedAfterValidation(row, amount, isDeposit, selectedPtEarly);
|
||
}
|
||
|
||
private proceedAfterValidation(row: TxnRow, amount: number, isDeposit: boolean, selectedPtEarly: any): void {
|
||
const isWithdrawal = !isDeposit;
|
||
const isCash = !!selectedPtEarly?.isCashPayment;
|
||
const isGlType = row.acTyp === 'GL';
|
||
|
||
// Dividend cash claim token disbursement: the GL entry (Dr Dividend Payable / Cr Cash at
|
||
// Main Vault) already posted at claim-approval time — there is no real savings/current
|
||
// account behind this row, so skip balance/min-balance/KYC/dormant/facility checks entirely
|
||
// (none apply to a GL-only liability payout) and go straight to the confirm dialog.
|
||
// An approved account closure is the same situation: close() already posted the interest and the
|
||
// real withdrawal at manager approval, and the account is CLOSED by the time a cashier scans the
|
||
// token — so accountDetails here is a stub with no balance and the balance/min-balance/facility
|
||
// checks below would reject the hand-over with "Withdrawable Amount: ₹0.00". Only the cash-drawer
|
||
// checks still matter, and they are the ones kept.
|
||
if (this.activeDividendClaimRequest || this.activeClosureRequest) {
|
||
if (!row.paymentTypeId) {
|
||
this.showAlert('error', 'Payment Type Required', ['Please select Payment Type (Cash / Cheque / NEFT etc.)']);
|
||
return;
|
||
}
|
||
if (isWithdrawal && this.denominationTotal > 0 && this.hasWithdrawalDenomOverdraft) {
|
||
const lines = this.denominationOverdrafts.map(
|
||
(o) => `${o.label}: Need ${o.requested}, Available ${o.available} (Short by ${o.shortfall})`
|
||
);
|
||
this.showAlert('error', 'Insufficient Denomination Stock', [
|
||
'Your cash drawer does not have enough notes/coins:',
|
||
...lines,
|
||
'Adjust denomination or request vault replenishment.'
|
||
]);
|
||
return;
|
||
}
|
||
this.openConfirmDialog(row, amount, isDeposit, selectedPtEarly);
|
||
return;
|
||
}
|
||
|
||
// Clerk raising a closure request: nothing moves yet, so the ordinary withdrawal gauntlet below
|
||
// does not apply and actively rejects a valid closure —
|
||
// · Balance: the popup already posted the accrued interest onto the account before filling this
|
||
// amount in (see onCloseAccountToggle -> postClosureInterest), so the account's own withdrawable
|
||
// balance already covers it -- this bypass exists for the checks below it, not this one.
|
||
// · Min balance: meaningless for an account that is being closed to zero.
|
||
// · Withdrawal facility: a member closing their account is not making an ordinary withdrawal.
|
||
// · Denomination: counted by the cashier at hand-over, not by the clerk here.
|
||
// Everything that genuinely matters (token number, account loaded, ACTIVE status) is already
|
||
// checked in onSave() before this point, and the backend re-validates on submit.
|
||
if (this.pendingClosure && this.isClerk && isWithdrawal) {
|
||
this.openConfirmDialog(row, amount, isDeposit, selectedPtEarly);
|
||
return;
|
||
}
|
||
|
||
// GL transactions post as journal entries — skip account-level facility/balance checks
|
||
if (isGlType) {
|
||
if (!this.cashVaultGlId) {
|
||
this.showAlert('error', 'Cash Vault GL Not Configured', [
|
||
'Financial Activity Account (CASH_AT_TELLER) is not configured.',
|
||
'Please configure it in System → Financial Activity Accounts.'
|
||
]);
|
||
return;
|
||
}
|
||
|
||
// Payment type required (same as savings)
|
||
if (!row.paymentTypeId) {
|
||
this.showAlert('error', 'Payment Type Required', ['Please select Payment Type (Cash / Cheque / NEFT etc.)']);
|
||
return;
|
||
}
|
||
|
||
// Denomination overdraft check for GL withdrawal (same as savings)
|
||
if (isWithdrawal && this.denominationTotal > 0 && this.hasWithdrawalDenomOverdraft) {
|
||
const lines = this.denominationOverdrafts.map(
|
||
(o) => `${o.label}: Need ${o.requested}, Available ${o.available} (Short by ${o.shortfall})`
|
||
);
|
||
this.showAlert('error', 'Insufficient Denomination Stock', [
|
||
'Your cash drawer does not have enough notes/coins:',
|
||
...lines,
|
||
'Adjust denomination or request vault replenishment.'
|
||
]);
|
||
return;
|
||
}
|
||
|
||
// No AML prompt on the GL path either — see the note on the savings/current path below.
|
||
this.openConfirmDialog(row, amount, isDeposit, selectedPtEarly);
|
||
return;
|
||
}
|
||
|
||
// 0d. Withdrawal facility
|
||
if (isWithdrawal) {
|
||
const withdrawalFac = this.facilities.find((f) => f.label === 'Withdrawal');
|
||
if (withdrawalFac && !withdrawalFac.active) {
|
||
this.showAlert('error', 'Withdrawal Facility Not Enabled', [
|
||
'Withdrawal facility is NOT enabled on this account.',
|
||
'Please enable the facility before processing a withdrawal.'
|
||
]);
|
||
return;
|
||
}
|
||
}
|
||
|
||
// 1. Payment type
|
||
if (!row.paymentTypeId) {
|
||
this.showAlert('error', 'Payment Type Required', ['Please select Payment Type (Cash / Cheque / NEFT etc.)']);
|
||
return;
|
||
}
|
||
|
||
// 2. Cheque number
|
||
const selectedPt = selectedPtEarly;
|
||
const isCheque = selectedPt && !selectedPt.isCashPayment;
|
||
if (isCheque && !row.chequeNo?.trim()) {
|
||
this.showAlert('error', 'Cheque Number Required', ['Cheque Number is required for cheque transactions.']);
|
||
return;
|
||
}
|
||
|
||
if (isWithdrawal) {
|
||
// Token-based cashier disbursement: the manager approval already validated the balance
|
||
// and posted the real withdrawal (money already left the account). Re-running the balance/
|
||
// min-balance checks here would compare the post-deduction balance against the same
|
||
// already-approved amount again, which fails for almost any real withdrawal.
|
||
const isTokenDisbursement = !this.isClerk && (!!this.activeWithdrawalRequest || !!this.activeClosureRequest);
|
||
|
||
if (!isTokenDisbursement) {
|
||
// 3. Balance check
|
||
const withdrawable = this.withdrawableAmount;
|
||
if (amount > withdrawable) {
|
||
this.showAlert('error', 'Insufficient Balance', [
|
||
`Withdrawable Amount: ₹${withdrawable.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
|
||
`Requested Amount: ₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
|
||
'Please reduce the withdrawal amount.'
|
||
]);
|
||
return;
|
||
}
|
||
|
||
// 4. Min balance
|
||
const enforceMin = !!this.accountDetails.enforceMinRequiredBalance;
|
||
const minBalance = Number(this.accountDetails.minRequiredBalance ?? 0);
|
||
if (enforceMin && minBalance > 0) {
|
||
const balanceAfter = this.currentBalance - amount;
|
||
if (balanceAfter < minBalance) {
|
||
this.showAlert('error', 'Minimum Balance Violation', [
|
||
`Required minimum balance: ₹${minBalance.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
|
||
`Balance after withdrawal: ₹${balanceAfter.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
|
||
'Please reduce the withdrawal amount.'
|
||
]);
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 5. Denomination overdraft — fire whenever denomination is entered for withdrawal
|
||
if (this.denominationTotal > 0 && this.hasWithdrawalDenomOverdraft) {
|
||
const lines = this.denominationOverdrafts.map(
|
||
(o) => `${o.label}: Need ${o.requested}, Available ${o.available} (Short by ${o.shortfall})`
|
||
);
|
||
this.showAlert('error', 'Insufficient Denomination Stock', [
|
||
'Your cash drawer does not have enough notes/coins:',
|
||
...lines,
|
||
'Adjust denomination or request vault replenishment.'
|
||
]);
|
||
return;
|
||
}
|
||
}
|
||
|
||
// AML monitoring is deliberately invisible here. Every posted transaction is evaluated
|
||
// server-side against the AML rule catalog (AmlRuleEvaluationService) after it succeeds, and
|
||
// anything it flags surfaces only in the AML reports — alerts, STR cases and CTR returns.
|
||
// The cashier is never prompted or warned at the counter: tipping off the customer defeats
|
||
// the purpose of suspicious-transaction monitoring, and a hardcoded client-side threshold
|
||
// duplicated none of the real rule set anyway.
|
||
this.openConfirmDialog(row, amount, isDeposit, selectedPt);
|
||
}
|
||
|
||
private openConfirmDialog(row: TxnRow, amount: number, isDeposit: boolean, selectedPt: any): void {
|
||
// Token-based cashier disbursement: balance was already deducted at manager approval
|
||
const isDisbursement =
|
||
!isDeposit &&
|
||
!this.isClerk &&
|
||
(!!this.activeWithdrawalRequest || !!this.activeDividendClaimRequest || !!this.activeClosureRequest);
|
||
// Cashier deposit (savings/current/GL) goes via maker-checker: balance credits only after manager approval
|
||
const isDepositRequest =
|
||
isDeposit &&
|
||
!this.isClerk &&
|
||
(row.acTyp === 'SD' || row.acTyp === 'CA' || row.acTyp === 'CB' || row.acTyp === 'GL');
|
||
const balanceAfterTxn =
|
||
isDisbursement || isDepositRequest
|
||
? this.currentBalance // no immediate balance change
|
||
: isDeposit
|
||
? this.currentBalance + amount
|
||
: this.currentBalance - amount;
|
||
|
||
this.confirmData = {
|
||
type: isDisbursement
|
||
? 'DISBURSEMENT'
|
||
: isDepositRequest
|
||
? 'DEPOSIT REQUEST'
|
||
: isDeposit
|
||
? 'DEPOSIT'
|
||
: this.pendingClosure && this.isClerk
|
||
? 'ACCOUNT CLOSURE'
|
||
: 'WITHDRAWAL',
|
||
isDisbursement,
|
||
isDepositRequest,
|
||
accountNo: this.accountDetails.glCode ?? this.accountDetails.accountNo,
|
||
clientName: this.clientName,
|
||
amount,
|
||
currentBalance: this.currentBalance,
|
||
balanceAfter: balanceAfterTxn,
|
||
paymentType: selectedPt?.name ?? 'Cash',
|
||
chequeNo: row.chequeNo || null,
|
||
symbol: this.currencySymbol,
|
||
tokNo:
|
||
this.activeWithdrawalRequest?.tok_no ??
|
||
this.activeDividendClaimRequest?.tok_no ??
|
||
this.activeClosureRequest?.tok_no ??
|
||
null,
|
||
isDdCashGl: this.isDdCashGlRow(row),
|
||
ddFaceValue: row.amount ?? 0,
|
||
ddCharges: row.ddCharges ?? 0,
|
||
ddGstPercentage: this.ddGstPercentage,
|
||
ddGstAmount: this.ddGstAmountFor(row)
|
||
};
|
||
this.showConfirmDialog = true;
|
||
}
|
||
|
||
confirmSubmit(): void {
|
||
this.showConfirmDialog = false;
|
||
const row = this.selectedRow!;
|
||
if (row.typ === 'DD') {
|
||
this.openIssueWalkinDd(row);
|
||
return;
|
||
}
|
||
const amount = this.isDdCashGlRow(row) ? this.ddTotalPayableFor(row) : (row.amount ?? this.denominationTotal);
|
||
const isDeposit = row.dbCr === 'Cr';
|
||
const command = isDeposit ? 'deposit' : 'withdrawal';
|
||
const accountId = this.accountDetails.id;
|
||
const cfg = this.accountTypeConfig(row.acTyp);
|
||
|
||
// Share accounts must never go through this generic Deposit/Withdrawal Slip flow — both
|
||
// buildDepositPayload() and buildWithdrawalPayload() hardcode accountType: 'SAVINGS' and send
|
||
// savingsAccountId: this.accountDetails.id, which for a Share account is the SHARE account's
|
||
// own numeric id, not a real savings account id. Approving that request would call
|
||
// savingsAccountWithdrawal/Deposit against whatever savings account happens to share that same
|
||
// numeric id — a completely unrelated account. Share purchase/redemption must go through the
|
||
// dedicated Buy Shares / Share Withdrawal Request screens, which carry the correct governance
|
||
// (resolution number, meeting date, minimum-tenure and linked-loan checks).
|
||
if (row.acTyp === 'SHR') {
|
||
this.showAlert('error', 'Not Supported for Share Accounts', [
|
||
isDeposit
|
||
? 'Share purchases must be made from the Buy Shares screen on the Share Account itself.'
|
||
: 'Share withdrawal (redeem shares) must be submitted from "New Share Withdrawal Request" in the Clerk Dashboard.',
|
||
'This generic Deposit/Withdrawal Slip does not carry the Board Resolution Number or the governance checks required for shares.'
|
||
]);
|
||
return;
|
||
}
|
||
|
||
// GL withdrawal → direct journal entry (no maker-checker)
|
||
// GL deposit → falls through to maker-checker block below
|
||
if (row.acTyp === 'GL' && !isDeposit) {
|
||
this.submitGlJournalEntry(row, amount, isDeposit);
|
||
return;
|
||
}
|
||
|
||
// Clerk applied "Close this account" from Action → raise a closure request rather than an
|
||
// ordinary withdrawal request. Must come first: a closure IS a full-balance withdrawal, so the
|
||
// plain clerk-withdrawal branch below would otherwise swallow it and the account would never
|
||
// actually close.
|
||
if (this.pendingClosure && this.isClerk && !isDeposit) {
|
||
this.submitClosureRequest(row);
|
||
return;
|
||
}
|
||
|
||
// Clerk mode + withdrawal → route through maker-checker (create PENDING request, no direct deduction)
|
||
if (this.clerkMode && !isDeposit) {
|
||
this.submitClerkWithdrawalRequest(row, amount);
|
||
return;
|
||
}
|
||
|
||
// Cashier deposit (savings/current/FD/RD/GL) → route through maker-checker (create PENDING request,
|
||
// no direct credit). FD and RD are their own account-type codes (see creditEntryAccountType()) and
|
||
// belong here just as much as a plain savings deposit -- a cashier crediting an FD/RD must not be
|
||
// able to move money in without a manager's approval either.
|
||
// Use the dialog-confirmed amount — for cash that is the denomination total
|
||
if (
|
||
!this.isClerk &&
|
||
!this.reviewMode &&
|
||
isDeposit &&
|
||
(row.acTyp === 'SD' ||
|
||
row.acTyp === 'CA' ||
|
||
row.acTyp === 'CB' ||
|
||
row.acTyp === 'FD' ||
|
||
row.acTyp === 'RD' ||
|
||
row.acTyp === 'GL')
|
||
) {
|
||
this.submitCashierDepositRequest(row, this.confirmData?.amount ?? amount);
|
||
return;
|
||
}
|
||
|
||
// Dividend cash claim token disbursement → GL entry already posted at claim-approval time.
|
||
// Do NOT post any new transaction/journal entry — just record the physical hand-over via
|
||
// DividendClaimRequestService, mirroring the activeWithdrawalRequest branch just below.
|
||
if (!this.isClerk && this.activeDividendClaimRequest && !isDeposit) {
|
||
this.saving = true;
|
||
const selectedPtDisb = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId);
|
||
const denomJson =
|
||
selectedPtDisb?.isCashPayment && this.denominationTotal > 0 ? JSON.stringify(this.denominationCounts) : null;
|
||
const req = this.activeDividendClaimRequest;
|
||
this.dividendClaimRequestService
|
||
.disburse(req.id, denomJson, amount)
|
||
.pipe(
|
||
catchError((err: any) => {
|
||
const msg =
|
||
err?.error?.message ??
|
||
err?.error?.errors?.[0]?.defaultUserMessage ??
|
||
'Disbursement recording failed. Please try again.';
|
||
this.showAlert('error', 'Disbursement Failed', [msg]);
|
||
this.saving = false;
|
||
return of(null);
|
||
})
|
||
)
|
||
.subscribe((res: any) => {
|
||
this.saving = false;
|
||
if (res) {
|
||
this.showAlert('success', 'Cash Disbursed Successfully', [
|
||
`₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2 })} disbursed to customer.`,
|
||
`Token No: ${req.tok_no} · Client: ${req.client_name}`
|
||
]);
|
||
this.activeDividendClaimRequest = null;
|
||
this.clearDenominations();
|
||
this.loadOpeningPosition();
|
||
}
|
||
});
|
||
return;
|
||
}
|
||
|
||
// Approved account closure token → the close command (interest, TDS and the real GL
|
||
// withdrawal) already ran at manager approval. Nothing new is posted here either — this only
|
||
// records the physical hand-over of the rounded cash figure, mirroring the two branches above.
|
||
if (!this.isClerk && this.activeClosureRequest && !isDeposit) {
|
||
this.saving = true;
|
||
const selectedPtDisb = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId);
|
||
const denomJson =
|
||
selectedPtDisb?.isCashPayment && this.denominationTotal > 0 ? JSON.stringify(this.denominationCounts) : null;
|
||
const req = this.activeClosureRequest;
|
||
this.closureRequestService
|
||
.disburse(req.id, denomJson, amount)
|
||
.pipe(
|
||
catchError((err: any) => {
|
||
const msg =
|
||
err?.error?.message ??
|
||
err?.error?.errors?.[0]?.defaultUserMessage ??
|
||
'Disbursement recording failed. Please try again.';
|
||
this.showAlert('error', 'Disbursement Failed', [msg]);
|
||
this.saving = false;
|
||
return of(null);
|
||
})
|
||
)
|
||
.subscribe((res: any) => {
|
||
this.saving = false;
|
||
if (res) {
|
||
const closureVoucher = res.voucherNo || req.voucher_no || '—';
|
||
this.showAlert('success', 'Account Closed & Cash Disbursed', [
|
||
`₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2 })} handed over to the customer.`,
|
||
`Account ${req.account_no} is now CLOSED.`,
|
||
`Token No: ${req.tok_no} · Client: ${req.client_name}`,
|
||
`Voucher No: ${closureVoucher}`
|
||
]);
|
||
this.activeClosureRequest = null;
|
||
this.clearDenominations();
|
||
this.loadOpeningPosition();
|
||
}
|
||
});
|
||
return;
|
||
}
|
||
|
||
// Hard block: cashier direct withdrawal never reaches here — caught in onSave()
|
||
// Extra safety net in case confirmSubmit() is ever called directly
|
||
if (
|
||
!this.isClerk &&
|
||
!this.reviewMode &&
|
||
!isDeposit &&
|
||
!this.activeWithdrawalRequest &&
|
||
!this.activeDividendClaimRequest &&
|
||
!this.activeClosureRequest
|
||
) {
|
||
this.showAlert('error', 'Direct Withdrawal Not Permitted', [
|
||
'Withdrawals must go through the maker-checker approval process.',
|
||
'Enter the approved token number to disburse.'
|
||
]);
|
||
return;
|
||
}
|
||
|
||
// Token-based approved withdrawal → balance already deducted at manager approval
|
||
// Do NOT post another savings transaction — just record disbursement
|
||
if (!this.isClerk && this.activeWithdrawalRequest && !isDeposit) {
|
||
this.saving = true;
|
||
// Send denominations only for cash payments — stale counts on a cheque
|
||
// disbursement would fail the backend denomination-total check
|
||
const selectedPtDisb = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId);
|
||
const denomJson =
|
||
selectedPtDisb?.isCashPayment && this.denominationTotal > 0 ? JSON.stringify(this.denominationCounts) : null;
|
||
const req = this.activeWithdrawalRequest;
|
||
this.clerkWithdrawalService
|
||
.disburse(req.id, denomJson, amount)
|
||
.pipe(
|
||
catchError((err: any) => {
|
||
const msg =
|
||
err?.error?.message ??
|
||
err?.error?.errors?.[0]?.defaultUserMessage ??
|
||
'Disbursement recording failed. Please try again.';
|
||
this.showAlert('error', 'Disbursement Failed', [msg]);
|
||
this.saving = false;
|
||
return of(null);
|
||
})
|
||
)
|
||
.subscribe((res: any) => {
|
||
this.saving = false;
|
||
if (res) {
|
||
const disbVoucher = res.voucherNo || req.voucher_no || '—';
|
||
this.showAlert('success', 'Cash Disbursed Successfully', [
|
||
`₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2 })} disbursed to customer.`,
|
||
`Token No: ${req.tok_no} · Client: ${req.client_name}`,
|
||
`Voucher No: ${disbVoucher}`
|
||
]);
|
||
this.activeWithdrawalRequest = null;
|
||
this.clearDenominations();
|
||
this.loadOpeningPosition();
|
||
this.loadAccountDetails(row.acNo.trim(), row.acTyp);
|
||
}
|
||
});
|
||
return;
|
||
}
|
||
|
||
const today = businessToday();
|
||
const txnDate = `${String(today.getDate()).padStart(2, '0')}/${String(today.getMonth() + 1).padStart(2, '0')}/${today.getFullYear()}`;
|
||
|
||
const payload: any = {
|
||
transactionDate: txnDate,
|
||
transactionAmount: amount,
|
||
paymentTypeId: row.paymentTypeId,
|
||
dateFormat: 'dd/MM/yyyy',
|
||
locale: 'en',
|
||
note: row.description || null
|
||
};
|
||
|
||
if (row.chequeNo) payload['checkNumber'] = row.chequeNo;
|
||
if (row.bankName) payload['bankNumber'] = row.bankName;
|
||
if (row.routingCode) payload['routingCode'] = row.routingCode;
|
||
|
||
// Only send denomination when cash payment AND denom total matches amount
|
||
const selectedPtForPayload = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId);
|
||
const isCashForPayload = !!selectedPtForPayload?.isCashPayment;
|
||
if (isCashForPayload && this.denominationTotal > 0 && this.denominationTotal === amount) {
|
||
payload['denominationBreakdown'] = [
|
||
...this.notes,
|
||
...this.coins
|
||
]
|
||
.filter((d) => this.countFor(d) > 0)
|
||
.map((d) => ({
|
||
denominationId: d.id,
|
||
denominationValue: d.value,
|
||
kind: d.kind,
|
||
count: this.countFor(d),
|
||
subtotal: this.subtotalFor(d)
|
||
}));
|
||
}
|
||
|
||
this.saving = true;
|
||
const params = new HttpParams().set('command', command);
|
||
this.http
|
||
.post(`${cfg.detail}/${accountId}/transactions`, payload, { params })
|
||
.pipe(
|
||
catchError((err: any) => {
|
||
const msg =
|
||
err?.error?.errors?.[0]?.defaultUserMessage ??
|
||
err?.error?.developerMessage ??
|
||
'Transaction failed. Please try again.';
|
||
this.showAlert('error', 'Transaction Failed', [msg]);
|
||
this.saving = false;
|
||
return of(null);
|
||
})
|
||
)
|
||
.subscribe((res: any) => {
|
||
this.saving = false;
|
||
if (res) {
|
||
const ref = res.resourceId ?? res.transactionId ?? 'N/A';
|
||
this.showAlert('success', 'Transaction Successful', [
|
||
`${isDeposit ? 'Deposit' : 'Withdrawal'} of ₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2 })} posted successfully.`,
|
||
`Reference No: ${ref}`
|
||
]);
|
||
this.clearDenominations();
|
||
this.loadOpeningPosition();
|
||
this.loadAccountDetails(row.acNo.trim(), row.acTyp);
|
||
}
|
||
});
|
||
}
|
||
|
||
// ── Manager review mode ──────────────────────────────────────────────────
|
||
|
||
/** Load a withdrawal request and pre-fill the workspace (manager review or clerk edit). */
|
||
private loadRequestIntoForm(id: number, mode: 'review' | 'edit'): void {
|
||
this.clerkWithdrawalService
|
||
.getById(id)
|
||
.pipe(catchError(() => of(null)))
|
||
.subscribe((req: any) => {
|
||
if (!req) {
|
||
this.showAlert('error', 'Request Not Found', ['This withdrawal request could not be loaded.']);
|
||
return;
|
||
}
|
||
if (mode === 'review') {
|
||
this.reviewRequest = req;
|
||
} else {
|
||
this.editRequest = req;
|
||
}
|
||
|
||
// Same bug, same fix as the token-driven withdrawal-slip lookup above (see its comment): the
|
||
// request carries its own real account_type (SAVINGS, CURRENT, FIXED_DEPOSIT or
|
||
// RECURRING_DEPOSIT), and hardcoding 'SD' here searched every non-savings account on the
|
||
// wrong endpoint, so reviewing a Current-account withdrawal always failed with "Account not
|
||
// found".
|
||
const acTyp = TransactionEntryComponent.SEARCH_ACCOUNT_TYPE_TO_AC_TYP[req.account_type || ''] || 'SD';
|
||
const row = this.rows[0];
|
||
row.acNo = req.account_no ?? '';
|
||
row.acTyp = acTyp;
|
||
row.paymentTypeId = req.payment_type_id ?? null;
|
||
row.amount = Number(req.amount) || 0;
|
||
row.description = req.description ?? '';
|
||
row.chequeNo = req.cheque_no ?? '';
|
||
row.bankName = req.bank_name ?? '';
|
||
row.tokNo = req.tok_no ?? '';
|
||
row.dbCr = 'Dr';
|
||
this.selectedRow = row;
|
||
|
||
try {
|
||
this.denominationCounts = req.denomination_data ? JSON.parse(req.denomination_data) : {};
|
||
} catch {
|
||
this.denominationCounts = {};
|
||
}
|
||
|
||
if (req.account_no) {
|
||
this.loadAccountDetails(req.account_no, acTyp);
|
||
}
|
||
});
|
||
}
|
||
|
||
/** Manager approves → backend posts the real savings withdrawal and deducts the balance. */
|
||
approveReview(): void {
|
||
if (!this.reviewRequest || this.saving) return;
|
||
this.saving = true;
|
||
this.clerkWithdrawalService
|
||
.approve(this.reviewRequest.id)
|
||
.pipe(
|
||
catchError((err: any) => {
|
||
const msg =
|
||
err?.error?.errors?.[0]?.defaultUserMessage ??
|
||
err?.error?.developerMessage ??
|
||
err?.error?.defaultUserMessage ??
|
||
'Approval failed. Please try again.';
|
||
this.showAlert('error', 'Approval Failed', [msg]);
|
||
this.saving = false;
|
||
return of(null);
|
||
})
|
||
)
|
||
.subscribe((res: any) => {
|
||
this.saving = false;
|
||
if (res) {
|
||
this.showAlert(
|
||
'success',
|
||
'Withdrawal Approved',
|
||
[
|
||
'The withdrawal has been approved and the balance has been deducted.',
|
||
res.txnId ? `Transaction Ref: ${res.txnId}` : ''
|
||
].filter(Boolean)
|
||
);
|
||
setTimeout(() => this.router.navigate(['/checker-inbox-and-tasks/withdrawal-requests']), 1200);
|
||
}
|
||
});
|
||
}
|
||
|
||
openRejectReview(): void {
|
||
this.rejectionReason = '';
|
||
this.showRejectDialog = true;
|
||
}
|
||
|
||
cancelRejectReview(): void {
|
||
this.showRejectDialog = false;
|
||
this.rejectionReason = '';
|
||
}
|
||
|
||
confirmRejectReview(): void {
|
||
if (!this.reviewRequest || this.saving) return;
|
||
if (!this.rejectionReason.trim()) {
|
||
this.showAlert('error', 'Reason Required', ['Please enter a reason for rejecting this request.']);
|
||
return;
|
||
}
|
||
this.saving = true;
|
||
this.clerkWithdrawalService
|
||
.reject(this.reviewRequest.id, this.rejectionReason.trim())
|
||
.pipe(
|
||
catchError((err: any) => {
|
||
const msg =
|
||
err?.error?.errors?.[0]?.defaultUserMessage ??
|
||
err?.error?.developerMessage ??
|
||
err?.error?.defaultUserMessage ??
|
||
'Rejection failed. Please try again.';
|
||
this.showAlert('error', 'Rejection Failed', [msg]);
|
||
this.saving = false;
|
||
return of(null);
|
||
})
|
||
)
|
||
.subscribe((res: any) => {
|
||
this.saving = false;
|
||
this.showRejectDialog = false;
|
||
if (res) {
|
||
this.showAlert('success', 'Withdrawal Rejected', [
|
||
'The request has been sent back to the clerk for correction.'
|
||
]);
|
||
setTimeout(() => this.router.navigate(['/checker-inbox-and-tasks/withdrawal-requests']), 1200);
|
||
}
|
||
});
|
||
}
|
||
|
||
backToInbox(): void {
|
||
this.router.navigate(['/checker-inbox-and-tasks/withdrawal-requests']);
|
||
}
|
||
|
||
/** Build the withdrawal-request payload from the current row + loaded account. */
|
||
private buildWithdrawalPayload(row: TxnRow, amount: number): any {
|
||
const today = businessToday();
|
||
const txnDateIso = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
||
const selectedPt = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId);
|
||
|
||
return {
|
||
accountNo: this.accountDetails.accountNo,
|
||
accountType: this.creditEntryAccountType(row.acTyp),
|
||
savingsAccountId: this.accountDetails.id,
|
||
clientId: this.accountDetails.clientId ?? this.clientDetails?.id ?? null,
|
||
clientName: this.clientDetails?.displayName ?? this.accountDetails?.clientName ?? null,
|
||
amount,
|
||
txnDate: txnDateIso,
|
||
paymentTypeId: row.paymentTypeId || null,
|
||
paymentTypeName: selectedPt?.name ?? 'Cash',
|
||
description: row.description || null,
|
||
chequeNo: row.chequeNo || null,
|
||
bankName: row.bankName || null,
|
||
tokNo: row.tokNo?.trim() || null,
|
||
denominationData: JSON.stringify(this.denominationCounts)
|
||
};
|
||
}
|
||
|
||
/** Clerk edits a rejected request → resubmit (status back to PENDING) for manager approval. */
|
||
resubmitEdit(): void {
|
||
if (!this.editRequest || this.saving) return;
|
||
const row = this.selectedRow;
|
||
if (!row || !this.accountDetails) {
|
||
this.showAlert('error', 'Account Required', ['Please load the account before resubmitting.']);
|
||
return;
|
||
}
|
||
const amount = row.amount ?? 0;
|
||
if (!amount || amount <= 0) {
|
||
this.showAlert('error', 'Amount Required', ['Please enter a valid withdrawal amount.']);
|
||
return;
|
||
}
|
||
|
||
const payload = this.buildWithdrawalPayload(row, amount);
|
||
this.saving = true;
|
||
this.clerkWithdrawalService
|
||
.update(this.editRequest.id, payload)
|
||
.pipe(
|
||
catchError((err: any) => {
|
||
const msg =
|
||
err?.error?.errors?.[0]?.defaultUserMessage ??
|
||
err?.error?.developerMessage ??
|
||
err?.error?.defaultUserMessage ??
|
||
'Failed to resubmit request. Please try again.';
|
||
this.showAlert('error', 'Resubmit Failed', [msg]);
|
||
this.saving = false;
|
||
return of(null);
|
||
})
|
||
)
|
||
.subscribe((res: any) => {
|
||
this.saving = false;
|
||
if (res) {
|
||
const tok = (this.editRequest as any)?.tok_no ?? row.tokNo?.trim() ?? '—';
|
||
this.showAlert('success', 'Withdrawal Resubmitted', [
|
||
`Withdrawal of ₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2 })} resubmitted for manager approval.`,
|
||
`Token No: ${tok}`
|
||
]);
|
||
setTimeout(() => this.router.navigate(['/clerk/withdrawal-requests']), 1200);
|
||
}
|
||
});
|
||
}
|
||
|
||
cancelEdit(): void {
|
||
this.router.navigate(['/clerk/withdrawal-requests']);
|
||
}
|
||
|
||
/** Clerk maker-checker: create a PENDING withdrawal request instead of deducting balance directly. */
|
||
private submitClerkWithdrawalRequest(row: TxnRow, amount: number): void {
|
||
const payload = this.buildWithdrawalPayload(row, amount);
|
||
|
||
this.saving = true;
|
||
this.clerkWithdrawalService
|
||
.submit(payload)
|
||
.pipe(
|
||
catchError((err: any) => {
|
||
const msg =
|
||
err?.error?.errors?.[0]?.defaultUserMessage ??
|
||
err?.error?.developerMessage ??
|
||
err?.error?.defaultUserMessage ??
|
||
'Failed to submit withdrawal request. Please try again.';
|
||
this.showAlert('error', 'Request Failed', [msg]);
|
||
this.saving = false;
|
||
return of(null);
|
||
})
|
||
)
|
||
.subscribe((res: any) => {
|
||
this.saving = false;
|
||
if (res) {
|
||
const tok = row.tokNo?.trim() ?? '—';
|
||
const voucherNo = res.voucherNo ?? '—';
|
||
this.showAlert('success', 'Withdrawal Request Submitted', [
|
||
`Withdrawal of ₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2 })} sent for manager approval.`,
|
||
'Balance will be deducted only after approval.',
|
||
`Token No: ${tok} · Voucher No: ${voucherNo}`
|
||
]);
|
||
this.onCancel();
|
||
}
|
||
});
|
||
}
|
||
|
||
/** Build the deposit-request payload from the current row + loaded account. */
|
||
private buildDepositPayload(row: TxnRow, amount: number): any {
|
||
const today = businessToday();
|
||
const txnDateIso = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
||
const selectedPt = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId);
|
||
// Send denominations only for cash payments — stale counts on a cheque
|
||
// deposit would fail the backend denomination-total check
|
||
const isCash = !!selectedPt?.isCashPayment;
|
||
|
||
const isGl = row.acTyp === 'GL';
|
||
return {
|
||
accountNo: isGl ? (this.accountDetails.glCode ?? this.accountDetails.name) : this.accountDetails.accountNo,
|
||
accountType: this.creditEntryAccountType(row.acTyp),
|
||
savingsAccountId: isGl ? null : this.accountDetails.id,
|
||
// GL-specific fields — ignored by backend for non-GL types
|
||
glAccountId: isGl ? this.accountDetails.id : null,
|
||
contraGlId: isGl ? this.cashVaultGlId : null,
|
||
officeId: isGl ? row.br || 1 : null,
|
||
currencyCode: isGl ? (this.accountDetails?.currency?.code ?? 'INR') : null,
|
||
clientId: isGl ? null : (this.accountDetails.clientId ?? this.clientDetails?.id ?? null),
|
||
clientName: isGl
|
||
? (this.accountDetails?.name ?? null)
|
||
: (this.clientDetails?.displayName ?? this.accountDetails?.clientName ?? null),
|
||
amount,
|
||
txnDate: txnDateIso,
|
||
paymentTypeId: row.paymentTypeId || null,
|
||
paymentTypeName: selectedPt?.name ?? 'Cash',
|
||
description: row.description || null,
|
||
chequeNo: row.chequeNo || null,
|
||
bankName: row.bankName || null,
|
||
denominationData: isCash && this.denominationTotal > 0 ? JSON.stringify(this.denominationCounts) : null,
|
||
// What this GL credit is actually for — see TxnRow.entryTypeCvId. Sent for every GL deposit
|
||
// (the backend defaults to "General" when the counter didn't pick one), so Share Advance
|
||
// Collection and Sponsor DD issuance can tell whose voucher this really is before adopting it.
|
||
...(isGl ? { entryTypeCvId: row.entryTypeCvId || null } : {}),
|
||
// DD + Cash + GL breakdown — persisted so the Sponsor DD issuance screen can read the face
|
||
// value/charges/GST back once this request is approved, instead of retyping them.
|
||
...(this.isDdCashGlRow(row)
|
||
? {
|
||
ddFaceValue: row.amount ?? 0,
|
||
ddCharges: row.ddCharges ?? 0,
|
||
ddGstPercentage: this.ddGstPercentage,
|
||
ddGstAmount: this.ddGstAmountFor(row)
|
||
}
|
||
: {})
|
||
};
|
||
}
|
||
|
||
/** Cashier maker-checker: create a PENDING deposit request instead of crediting balance directly. */
|
||
private submitCashierDepositRequest(row: TxnRow, amount: number): void {
|
||
const payload = this.buildDepositPayload(row, amount);
|
||
|
||
this.saving = true;
|
||
this.cashierDepositService
|
||
.submit(payload)
|
||
.pipe(
|
||
catchError((err: any) => {
|
||
const msg =
|
||
err?.error?.errors?.[0]?.defaultUserMessage ??
|
||
err?.error?.developerMessage ??
|
||
err?.error?.defaultUserMessage ??
|
||
'Failed to submit deposit request. Please try again.';
|
||
this.showAlert('error', 'Deposit Request Failed', [msg]);
|
||
this.saving = false;
|
||
return of(null);
|
||
})
|
||
)
|
||
.subscribe((res: any) => {
|
||
this.saving = false;
|
||
if (res) {
|
||
const voucherNo = res.voucherNo ?? '—';
|
||
this.showAlert('success', 'Deposit Request Submitted', [
|
||
`Deposit of ₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2 })} sent for manager approval.`,
|
||
'Balance will be credited only after approval.',
|
||
`Voucher No: ${voucherNo}`
|
||
]);
|
||
this.clearDenominations();
|
||
this.onCancel();
|
||
}
|
||
});
|
||
}
|
||
|
||
onCancel(): void {
|
||
this.clerkUiState.clear();
|
||
this.accountDetails = null;
|
||
this.clientDetails = null;
|
||
this.kycDetails = null;
|
||
this.nominees = [];
|
||
this.jointHolders = [];
|
||
this.clientAddresses = [];
|
||
this.clientPhotoUrl = null;
|
||
this.signatureUrl = null;
|
||
this.accountNotes = [];
|
||
this.standingInstructions = [];
|
||
this.todayTransactions = [];
|
||
this.linkedAccounts = null;
|
||
this.nonPersonDetails = null;
|
||
this.accountError = '';
|
||
this.selectedRow = null;
|
||
this.rows = [];
|
||
this.addRow();
|
||
}
|
||
|
||
private submitGlJournalEntry(row: TxnRow, amount: number, isDeposit: boolean): void {
|
||
if (!this.cashVaultGlId) {
|
||
this.showAlert('error', 'Cash Vault GL Not Configured', [
|
||
'Financial Activity Account (CASH_AT_TELLER) is not configured.',
|
||
'Please configure it in System → Financial Activity Accounts.'
|
||
]);
|
||
this.saving = false;
|
||
return;
|
||
}
|
||
|
||
const today = businessToday();
|
||
const txnDate = `${String(today.getDate()).padStart(2, '0')}/${String(today.getMonth() + 1).padStart(2, '0')}/${today.getFullYear()}`;
|
||
const txnDateIso = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
||
|
||
const selectedGlId = this.accountDetails.id;
|
||
const contraGlId = this.cashVaultGlId;
|
||
|
||
// isDeposit (CR button) = cashier receives cash → credit selected GL, debit contra (cash vault)
|
||
// isWithdrawal (DR button) = cashier pays out cash → debit selected GL, credit contra (cash vault)
|
||
const credits = isDeposit ? [{ glAccountId: selectedGlId, amount }] : [{ glAccountId: contraGlId, amount }];
|
||
const debits = isDeposit ? [{ glAccountId: contraGlId, amount }] : [{ glAccountId: selectedGlId, amount }];
|
||
|
||
const payload: any = {
|
||
officeId: row.br || 1,
|
||
transactionDate: txnDate,
|
||
dateFormat: 'dd/MM/yyyy',
|
||
locale: 'en',
|
||
currencyCode: this.accountDetails?.currency?.code ?? 'INR',
|
||
comments: row.description || null,
|
||
credits,
|
||
debits,
|
||
paymentTypeId: row.paymentTypeId
|
||
};
|
||
|
||
// Check if cash payment so we can record denomination after journal entry
|
||
const selectedPt = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId);
|
||
const isCashPayment = !!selectedPt?.isCashPayment;
|
||
|
||
this.saving = true;
|
||
this.http
|
||
.post('/journalentries', payload)
|
||
.pipe(
|
||
catchError((err: any) => {
|
||
const msg =
|
||
err?.error?.errors?.[0]?.defaultUserMessage ??
|
||
err?.error?.developerMessage ??
|
||
'Journal entry failed. Please try again.';
|
||
this.showAlert('error', 'Transaction Failed', [msg]);
|
||
this.saving = false;
|
||
return of(null);
|
||
})
|
||
)
|
||
.subscribe((res: any) => {
|
||
this.saving = false;
|
||
if (res) {
|
||
// Record denomination so cashier position stays accurate
|
||
// Same condition as savings: cash + denom entered + denom matches posted amount
|
||
if (isCashPayment && this.denominationTotal > 0 && this.denominationTotal === amount) {
|
||
const denomBreakdown = [
|
||
...this.notes,
|
||
...this.coins
|
||
]
|
||
.filter((d) => this.countFor(d) > 0)
|
||
.map((d) => ({
|
||
denominationId: d.id,
|
||
denominationValue: d.value,
|
||
kind: d.kind,
|
||
count: this.countFor(d),
|
||
subtotal: this.subtotalFor(d)
|
||
}));
|
||
// Filed against the Cash Vault GL, not the account the teller selected on screen: that
|
||
// selected account (e.g. Sundry Creditors) is the other leg of the journal entry, and
|
||
// never the one whose physical notes actually changed hands. Tagging the breakdown to it
|
||
// instead of the vault is why this cash never reached the cashier/vault position.
|
||
this.http
|
||
.post('/cashier/gl-denomination', {
|
||
txnId: res.transactionId ?? res.resourceId,
|
||
accountNo: this.cashVaultGlCode ?? row.acNo,
|
||
accountName: this.cashVaultGlName ?? 'Cash at Vault',
|
||
txnType: isDeposit ? 'DEPOSIT' : 'WITHDRAWAL',
|
||
txnDate: txnDateIso,
|
||
paymentTypeName: selectedPt?.name ?? 'Cash',
|
||
denominationBreakdown: denomBreakdown
|
||
})
|
||
.pipe(catchError(() => of(null)))
|
||
.subscribe();
|
||
}
|
||
|
||
const ref = res.transactionId ?? res.resourceId ?? 'N/A';
|
||
this.showAlert('success', 'GL Transaction Posted', [
|
||
`Journal entry of ₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2 })} posted successfully.`,
|
||
`Transaction ID: ${ref}`
|
||
]);
|
||
this.clearDenominations();
|
||
this.loadOpeningPosition();
|
||
this.loadAccountDetails(row.acNo.trim(), row.acTyp);
|
||
}
|
||
});
|
||
}
|
||
|
||
onSearch(): void {
|
||
if (this.selectedRow?.acNo.trim()) {
|
||
this.loadAccountDetails(this.selectedRow.acNo.trim(), this.selectedRow.acTyp);
|
||
}
|
||
}
|
||
|
||
onExit(): void {
|
||
history.back();
|
||
}
|
||
}
|