Replaces the auto-submit-closure-request behavior with an immediate account close, in the same approval action, when a withdrawal brings shares to zero — matching the backend's new single-step close-on- approval flow. Removes the now-unneeded Share Closure Request feature entirely: dedicated create/list components (Clerk Dashboard + Approval Center), routes, nav links, and the "eligible for closure" banner on the Share Account view. Also adds a clear "this will close the account" notice wherever a withdrawal would zero out the account's shares — the standalone Share Withdrawal Request page (submit + review) and Transaction Entry's Share Redemption Transfer confirm dialog — styled as an amber warning callout rather than a plain info line. Trims that confirm dialog to just the required details (dropped the redundant ECS A/C No. line and the validation-checklist block) and compacts its overall size.
3906 lines
146 KiB
TypeScript
3906 lines
146 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 } 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 } 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 { 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';
|
||
|
||
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;
|
||
/** 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;
|
||
}
|
||
|
||
interface Denomination {
|
||
id: number;
|
||
value: number;
|
||
kind: 'note' | 'coin';
|
||
label: string;
|
||
}
|
||
|
||
// ── 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;
|
||
loadingWithdrawal = 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 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 = '';
|
||
|
||
/** 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()).
|
||
* Every other account type/mode keeps the full, unfiltered list. */
|
||
paymentTypesForRow(row: TxnRow): any[] {
|
||
if (row.acTyp === 'SHR') {
|
||
return this.paymentTypes.filter((pt: any) => !pt.isCashPayment);
|
||
}
|
||
const isShareSettlementCredit =
|
||
row.typ === 'Enter Bank Transfer' &&
|
||
row.dbCr === 'Cr' &&
|
||
this.rows.some((r) => r.typ === 'Enter Bank Transfer' && r.dbCr === 'Dr' && r.acTyp === 'SHR');
|
||
if (isShareSettlementCredit) {
|
||
return this.paymentTypes.filter((pt: any) => !pt.isCashPayment);
|
||
}
|
||
return this.paymentTypes;
|
||
}
|
||
|
||
// 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;
|
||
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;
|
||
|
||
// 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'
|
||
];
|
||
dbCrOptions = [
|
||
'Dr',
|
||
'Cr'
|
||
];
|
||
trTpOptions = [
|
||
'C',
|
||
'D',
|
||
'T'
|
||
];
|
||
|
||
// ── 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 = new Date();
|
||
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 ?? []));
|
||
|
||
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();
|
||
|
||
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[]) => {
|
||
const mapping = mappings && mappings.length ? mappings[0] : null;
|
||
this.ddGstPercentage = Number(mapping?.gstPercentage ?? 18);
|
||
this.ddLimitAmount = mapping?.ddLimitAmount != null ? Number(mapping.ddLimitAmount) : null;
|
||
});
|
||
}
|
||
|
||
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 = new Date();
|
||
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/Bank Transfer: auto-add mirror CR row when active row is DR
|
||
if ((active?.typ === 'Disbursement' || 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;
|
||
}
|
||
const row: TxnRow = {
|
||
typ: '~',
|
||
br: 1,
|
||
trTp: '',
|
||
acTyp: 'SD',
|
||
acNo: '',
|
||
ecsAcNo: '',
|
||
dbCr: '',
|
||
chequeNo: '',
|
||
cheqDate: '',
|
||
amount: null,
|
||
description: '',
|
||
tokNo: '',
|
||
paymentTypeId: null,
|
||
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;
|
||
case 'Cheque':
|
||
row.dbCr = 'Dr';
|
||
row.paymentTypeId = this.findCashPaymentTypeId();
|
||
break;
|
||
case 'Disbursement':
|
||
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;
|
||
}
|
||
}
|
||
|
||
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 + AC.TP=GL — the only combination that collects DD charges + GST from the customer. */
|
||
isDdCashGlRow(row: TxnRow): boolean {
|
||
const pt = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId);
|
||
return row.typ === 'DD' && !!pt?.isCashPayment && row.acTyp === 'GL';
|
||
}
|
||
|
||
/** GST on DD charges only (never on the DD face value), at the admin-configured rate. */
|
||
ddGstAmountFor(row: TxnRow): number {
|
||
const charges = Number(row.ddCharges) || 0;
|
||
if (charges <= 0) return 0;
|
||
return Math.round(charges * this.ddGstPercentage) / 100;
|
||
}
|
||
|
||
/** Amount + Charges + GST, rounded UP to the next whole rupee — the total collected from the customer. */
|
||
ddTotalPayableFor(row: TxnRow): number {
|
||
const amount = Number(row.amount) || 0;
|
||
const charges = Number(row.ddCharges) || 0;
|
||
const gst = this.ddGstAmountFor(row);
|
||
return Math.ceil(amount + charges + gst - 1e-9);
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* 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');
|
||
|
||
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: cr.acTyp === 'CA' || cr.acTyp === 'CB' ? 'CURRENT' : cr.acTyp === 'GL' ? 'GL' : 'SAVINGS',
|
||
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: r.acTyp === 'CA' || r.acTyp === 'CB' ? 'CURRENT' : r.acTyp === 'GL' ? 'GL' : 'SAVINGS',
|
||
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)
|
||
};
|
||
|
||
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' : '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: r.acTyp === 'CA' || r.acTyp === 'CB' ? 'CURRENT' : 'SAVINGS',
|
||
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 "My Disbursement Requests".'
|
||
]);
|
||
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 a non-cash Payment Type on the CR row (e.g. Transfer or Share Redemption Transfer).'
|
||
]);
|
||
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 = new Date();
|
||
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,
|
||
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.showConfirmAlert('info', 'Confirm Share Withdrawal Request', confirmLines, 'Submit for Approval', () =>
|
||
this.doSubmitShareRedemptionTransfer(payload, shareRow, shares, linkedAcNo)
|
||
);
|
||
}
|
||
|
||
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.clerkWithdrawalService
|
||
.getByToken(token)
|
||
.pipe(
|
||
catchError((err: any) => {
|
||
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;
|
||
this.activeWithdrawalRequest = req;
|
||
row.acNo = req.account_no ?? '';
|
||
row.acTyp = 'SD';
|
||
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(), 'SD');
|
||
}
|
||
});
|
||
}
|
||
|
||
/** 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;
|
||
this.loadAccountDetails(row.acNo.trim(), row.acTyp);
|
||
}
|
||
|
||
onAcNoInput(row: TxnRow, index: number, value: string): void {
|
||
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;
|
||
}
|
||
});
|
||
}
|
||
|
||
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.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}` : ''}`
|
||
)
|
||
.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
|
||
// FULL unfiltered list rather than a server-side-narrowed one, so a partial match can't be
|
||
// gated on "exactly one row came back" — it never would be for those types.
|
||
const exactMatch = allItems.find((x: any) => x[f] === acNo);
|
||
const partialMatch = !exactMatch
|
||
? (allItems.find((x: any) => x[f]?.endsWith(acNo)) ?? allItems.find((x: any) => x[f]?.includes(acNo)))
|
||
: null;
|
||
const match = exactMatch ?? partialMatch ?? null;
|
||
if (match?.id) {
|
||
const clientIdFromSearch = acTyp !== 'GL' && match.clientId != null ? String(match.clientId) : null;
|
||
this.fetchAllDetails(match.id, match[f] ?? acNo, cfg, token, acTyp, clientIdFromSearch);
|
||
return;
|
||
}
|
||
this.loadingAccount = false;
|
||
this.accountError = 'Account not found for: ' + acNo;
|
||
});
|
||
}
|
||
|
||
/** Maps UI account type → { listEndpoint, detailEndpoint, nomineeType, searchResource } */
|
||
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;
|
||
} {
|
||
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
|
||
};
|
||
}
|
||
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 = new Date();
|
||
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) {
|
||
this.loadingAccount = false;
|
||
this.accountError = 'Account mismatch. Please re-enter account number and try again.';
|
||
return false;
|
||
}
|
||
this.accountDetails = acc;
|
||
this.jointHolders = acc.jointHolders ?? [];
|
||
// Track per-row account id/name for multi-row disbursement
|
||
if (this.selectedRow?.acNo) {
|
||
this.rowAccountIds[this.selectedRow.acNo] = acc.id;
|
||
this.rowAccountNames[this.selectedRow.acNo] =
|
||
acc.name ?? acc.displayName ?? 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' });
|
||
if (this.kycDetails?.amlCheckStatus === 'FLAGGED')
|
||
alerts.push({ type: 'danger', message: 'AML Flag — Suspicious Activity' });
|
||
|
||
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: 'Withdrawal', active: !!f.withdrawalFacility },
|
||
{ 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;
|
||
}
|
||
|
||
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 = new Date();
|
||
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
|
||
});
|
||
}
|
||
|
||
// ── 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 = 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.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 {
|
||
// Clerk bank transfer — N DR savings/CA + N CR savings/CA, immediate execution
|
||
if (this.isClerk && this.rows.length >= 2 && this.rows.every((r) => r.typ === 'Enter Bank 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', ['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)
|
||
if (this.isClerk && this.rows.length >= 2 && this.rows.every((r) => r.typ === 'Disbursement')) {
|
||
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 title = isCollectionMode ? 'Confirm Collection 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;
|
||
if (!row || !row.acNo.trim() || !this.accountDetails) 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
|
||
const amount = isDdCashGl
|
||
? this.ddTotalPayableFor(row)
|
||
: isCash && !this.clerkMode
|
||
? this.denominationTotal
|
||
: (row.amount ?? this.denominationTotal);
|
||
|
||
if (isCash && this.denominationTotal <= 0 && !this.clerkMode && !isDdCashGl) {
|
||
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.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: amount/denomination must exactly match
|
||
if (!this.isClerk && this.activeWithdrawalRequest && !isDeposit) {
|
||
const approvedAmt = Number(this.activeWithdrawalRequest.amount);
|
||
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';
|
||
|
||
// 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;
|
||
}
|
||
|
||
const AML_GL = 200000;
|
||
if (amount > AML_GL && isCash) {
|
||
this.showConfirmAlert(
|
||
'warning',
|
||
'AML Alert — Large Cash Transaction',
|
||
[
|
||
`Transaction Amount: ₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
|
||
'This transaction requires manager approval.',
|
||
'Have you obtained approval to proceed?'
|
||
],
|
||
'Yes, Proceed',
|
||
() => this.openConfirmDialog(row, amount, isDeposit, selectedPtEarly)
|
||
);
|
||
return;
|
||
}
|
||
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;
|
||
|
||
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;
|
||
}
|
||
}
|
||
|
||
// 6. AML large cash
|
||
const AML_THRESHOLD = 200000;
|
||
if (amount > AML_THRESHOLD && selectedPt?.isCashPayment) {
|
||
this.showConfirmAlert(
|
||
'warning',
|
||
'AML Alert — Large Cash Transaction',
|
||
[
|
||
`Transaction Amount: ₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
|
||
'This transaction requires manager approval.',
|
||
'Have you obtained approval to proceed?'
|
||
],
|
||
'Yes, Proceed',
|
||
() => this.openConfirmDialog(row, amount, isDeposit, selectedPt)
|
||
);
|
||
return;
|
||
}
|
||
|
||
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;
|
||
// 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'
|
||
: '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 ?? 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!;
|
||
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 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/GL) → route through maker-checker (create PENDING request, no direct credit)
|
||
// 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 === 'GL')
|
||
) {
|
||
this.submitCashierDepositRequest(row, this.confirmData?.amount ?? amount);
|
||
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.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 = new Date();
|
||
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;
|
||
}
|
||
|
||
const acTyp = '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 = new Date();
|
||
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: 'SAVINGS',
|
||
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 = new Date();
|
||
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: isGl ? 'GL' : row.acTyp === 'CA' || row.acTyp === 'CB' ? 'CURRENT' : 'SAVINGS',
|
||
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,
|
||
// 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 = new Date();
|
||
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)
|
||
}));
|
||
this.http
|
||
.post('/cashier/gl-denomination', {
|
||
txnId: res.transactionId ?? res.resourceId,
|
||
accountNo: this.accountDetails?.glCode ?? row.acNo,
|
||
accountName: this.accountDetails?.name ?? '',
|
||
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();
|
||
}
|
||
}
|