feat: cash-only registers, day book sections and DD amendments
This commit is contained in:
@@ -0,0 +1,497 @@
|
||||
/**
|
||||
* 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, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import { MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
|
||||
export interface CashierIssueDdDialogData {
|
||||
leaflet: any;
|
||||
branchName: string;
|
||||
sponsorBankName: string;
|
||||
faceValue: number;
|
||||
chargesAmount: number;
|
||||
gstPercentage: number;
|
||||
gstAmount: number;
|
||||
totalCashPayable: number;
|
||||
/**
|
||||
* How the draft is being paid for. Cash is a walk-in purchaser the bank may know nothing about,
|
||||
* so their name, address and Aadhaar have to be captured here. An account debit is a member whose
|
||||
* account is already on screen — those fields are not asked for, and must not be sent, because
|
||||
* the identity is the account holder's rather than a walk-in's.
|
||||
*/
|
||||
paymentMethod?: 'CASH' | 'ACCOUNT_DEBIT';
|
||||
/** The account being debited, shown so the clerk can see what is funding the draft. */
|
||||
accountNo?: string;
|
||||
/** The account holder, for the same reason. */
|
||||
accountHolderName?: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-cashier-issue-dd-dialog',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
MatDialogModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatButtonModule,
|
||||
MatIconModule
|
||||
],
|
||||
template: `
|
||||
<div class="dd-dialog-shell">
|
||||
<div class="dd-dialog-header">
|
||||
<div class="dd-dialog-header__title">
|
||||
<mat-icon class="dd-icon">receipt_long</mat-icon>
|
||||
<div>
|
||||
<h2>{{ isAccountDebit ? 'Account-Debit Demand Draft (DD)' : 'Walk-in Cash Demand Draft (DD)' }}</h2>
|
||||
<p>
|
||||
Next available DD leaflet auto-selected ·
|
||||
{{ isAccountDebit ? 'Enter beneficiary details' : 'Enter purchaser & beneficiary details' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button mat-icon-button type="button" (click)="onClose()" class="close-btn">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<mat-dialog-content class="dd-dialog-body">
|
||||
<!-- Draft & Financials Summary Card -->
|
||||
<div class="dd-summary-card">
|
||||
<div class="summary-col">
|
||||
<span class="label">Auto-Selected Leaflet</span>
|
||||
<strong class="dd-number-highlight">{{ data.leaflet.ddNumber }}</strong>
|
||||
<span class="sublabel">{{ data.sponsorBankName || 'Sponsor Bank' }} · {{ data.branchName }}</span>
|
||||
</div>
|
||||
<div class="summary-divider"></div>
|
||||
<div class="summary-col">
|
||||
<span class="label">Face Value</span>
|
||||
<strong class="amount">₹{{ data.faceValue | number: '1.2-2' }}</strong>
|
||||
<span class="sublabel">+ Charges ₹{{ data.chargesAmount | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
<div class="summary-divider"></div>
|
||||
<div class="summary-col">
|
||||
<span class="label">GST ({{ data.gstPercentage }}%)</span>
|
||||
<strong class="amount">₹{{ data.gstAmount | number: '1.2-2' }}</strong>
|
||||
<span class="sublabel">on charges only</span>
|
||||
</div>
|
||||
<div class="summary-divider"></div>
|
||||
<div class="summary-col highlight-col">
|
||||
<span class="label">{{ isAccountDebit ? 'Total Debited' : 'Total Cash Collected' }}</span>
|
||||
<strong class="total-amount">₹{{ data.totalCashPayable | number: '1.2-2' }}</strong>
|
||||
<span class="sublabel">
|
||||
@if (isAccountDebit) {
|
||||
{{ data.accountNo || 'Account' }}
|
||||
} @else {
|
||||
Denominations verified
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
The account funding the draft, stated before the form.
|
||||
|
||||
A clerk reaching this dialog has an account loaded on the workstation behind it, and the
|
||||
debit is irreversible once a checker approves. Naming the account and its holder here is
|
||||
what makes a wrong account visible while it can still be cancelled.
|
||||
-->
|
||||
@if (isAccountDebit) {
|
||||
<div class="dd-funding-note">
|
||||
<mat-icon>account_balance_wallet</mat-icon>
|
||||
<div>
|
||||
<strong>Debiting {{ data.accountNo }}</strong>
|
||||
@if (data.accountHolderName) {
|
||||
· {{ data.accountHolderName }}
|
||||
}
|
||||
<div class="dd-funding-sub">
|
||||
₹{{ data.totalCashPayable | number: '1.2-2' }} will be debited from this account when a checker
|
||||
approves. No cash is taken at the counter.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<form [formGroup]="form" class="dd-form">
|
||||
<!--
|
||||
Purchaser identity is asked for only when the bank has none: a walk-in paying cash. On an
|
||||
account debit the purchaser IS the account holder, already identified and KYC'd, and
|
||||
re-capturing a name and Aadhaar here would invite a mismatch against the account on record.
|
||||
-->
|
||||
@if (!isAccountDebit) {
|
||||
<div class="form-section-title">Walk-in Purchaser Details</div>
|
||||
|
||||
<div class="form-row">
|
||||
<mat-form-field appearance="outline" class="form-field-flex">
|
||||
<mat-label>Walk-in Purchaser Name *</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="walkinPurchaserName"
|
||||
placeholder="e.g. RAMESH CHANDRA"
|
||||
style="text-transform: uppercase;"
|
||||
/>
|
||||
<mat-error *ngIf="form.get('walkinPurchaserName')?.hasError('required')"
|
||||
>Purchaser name is required</mat-error
|
||||
>
|
||||
<mat-error *ngIf="form.get('walkinPurchaserName')?.hasError('minlength')"
|
||||
>Must be at least 3 characters</mat-error
|
||||
>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="form-field-flex">
|
||||
<mat-label>12-Digit Aadhaar Number *</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="walkinPurchaserAadhaar"
|
||||
placeholder="12-digit Aadhaar"
|
||||
maxlength="12"
|
||||
/>
|
||||
<mat-hint>Masked to last 4 digits before storage</mat-hint>
|
||||
<mat-error *ngIf="form.get('walkinPurchaserAadhaar')?.hasError('required')"
|
||||
>Aadhaar number is required</mat-error
|
||||
>
|
||||
<mat-error *ngIf="form.get('walkinPurchaserAadhaar')?.hasError('pattern')"
|
||||
>Must be exactly 12 numeric digits</mat-error
|
||||
>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Purchaser Address / Location *</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="walkinPurchaserAddress"
|
||||
placeholder="e.g. 14, SECTOR 5, MAIN ROAD, MUMBAI"
|
||||
style="text-transform: uppercase;"
|
||||
/>
|
||||
<mat-error *ngIf="form.get('walkinPurchaserAddress')?.hasError('required')"
|
||||
>Address is required</mat-error
|
||||
>
|
||||
</mat-form-field>
|
||||
}
|
||||
|
||||
<!-- Beneficiary Section -->
|
||||
<div class="form-section-title">Beneficiary & Payment Details</div>
|
||||
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Beneficiary Name (In Favour Of) *</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="beneficiaryName"
|
||||
placeholder="e.g. UNIVERSITY OF MUMBAI"
|
||||
maxlength="120"
|
||||
style="text-transform: uppercase;"
|
||||
/>
|
||||
<mat-error *ngIf="form.get('beneficiaryName')?.hasError('required')"
|
||||
>Beneficiary name is required</mat-error
|
||||
>
|
||||
<mat-error *ngIf="form.get('beneficiaryName')?.hasError('maxlength')">Max 120 characters</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<div class="form-row">
|
||||
<mat-form-field appearance="outline" class="form-field-flex">
|
||||
<mat-label>Payable At - Branch *</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="payableAtBranch"
|
||||
placeholder="e.g. FORT BRANCH"
|
||||
maxlength="150"
|
||||
style="text-transform: uppercase;"
|
||||
/>
|
||||
<mat-error *ngIf="form.get('payableAtBranch')?.hasError('required')"
|
||||
>Payable Branch is required</mat-error
|
||||
>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="form-field-flex">
|
||||
<mat-label>Payable At - City *</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="payableAtCity"
|
||||
placeholder="e.g. MUMBAI"
|
||||
maxlength="100"
|
||||
style="text-transform: uppercase;"
|
||||
/>
|
||||
<mat-error *ngIf="form.get('payableAtCity')?.hasError('required')">Payable City is required</mat-error>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</form>
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions class="dd-dialog-actions" align="end">
|
||||
<button mat-button type="button" (click)="onClose()">Cancel</button>
|
||||
<button mat-flat-button color="primary" [disabled]="form.invalid" (click)="onSubmit()">
|
||||
Submit for Manager Approval
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
</div>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.dd-dialog-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 90vh;
|
||||
}
|
||||
.dd-dialog-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 24px;
|
||||
background: #0f172a;
|
||||
color: #ffffff;
|
||||
border-top-left-radius: 8px;
|
||||
border-top-right-radius: 8px;
|
||||
}
|
||||
.dd-dialog-header__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.dd-dialog-header__title h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
}
|
||||
.dd-dialog-header__title p {
|
||||
margin: 2px 0 0 0;
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.dd-icon {
|
||||
color: #38bdf8;
|
||||
font-size: 28px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
.close-btn {
|
||||
color: #94a3b8;
|
||||
}
|
||||
.close-btn:hover {
|
||||
color: #ffffff;
|
||||
}
|
||||
.dd-dialog-body {
|
||||
padding: 20px 24px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.dd-summary-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 14px 18px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.summary-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.summary-col.highlight-col {
|
||||
background: #ecfdf5;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #a7f3d0;
|
||||
}
|
||||
.summary-divider {
|
||||
width: 1px;
|
||||
height: 36px;
|
||||
background: #e2e8f0;
|
||||
}
|
||||
.summary-col .label {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
color: #64748b;
|
||||
font-weight: 600;
|
||||
}
|
||||
.dd-number-highlight {
|
||||
font-size: 16px;
|
||||
color: #0369a1;
|
||||
font-family: monospace;
|
||||
}
|
||||
.summary-col .amount {
|
||||
font-size: 15px;
|
||||
color: #1e293b;
|
||||
}
|
||||
.summary-col .total-amount {
|
||||
font-size: 16px;
|
||||
color: #065f46;
|
||||
}
|
||||
.summary-col .sublabel {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.form-section-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: #475569;
|
||||
margin: 16px 0 10px 0;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
.form-section-title:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
.dd-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
.form-field-flex {
|
||||
flex: 1;
|
||||
}
|
||||
.dd-dialog-actions {
|
||||
padding: 12px 24px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
/* The account about to be debited — read before the form, so a wrong one is caught here. */
|
||||
.dd-funding-note {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
margin: 14px 0 4px;
|
||||
padding: 11px 14px;
|
||||
background: #eff6ff;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-left: 3px solid #1d4ed8;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
color: #334155;
|
||||
}
|
||||
.dd-funding-note mat-icon {
|
||||
color: #1d4ed8;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dd-funding-sub {
|
||||
margin-top: 3px;
|
||||
font-size: 11.5px;
|
||||
line-height: 1.5;
|
||||
color: #64748b;
|
||||
}
|
||||
`
|
||||
]
|
||||
})
|
||||
export class CashierIssueDdDialogComponent implements OnInit {
|
||||
private fb = inject(FormBuilder);
|
||||
private dialogRef = inject(MatDialogRef<CashierIssueDdDialogComponent>);
|
||||
public data: CashierIssueDdDialogData = inject(MAT_DIALOG_DATA);
|
||||
|
||||
form!: FormGroup;
|
||||
|
||||
/** Whether the draft is funded by debiting a member's account rather than by cash at the counter. */
|
||||
get isAccountDebit(): boolean {
|
||||
return this.data.paymentMethod === 'ACCOUNT_DEBIT';
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
// Walk-in identity is required for cash and rejected outright for an account debit — the server
|
||||
// refuses a walk-in name alongside an account — so the controls are only built when they apply.
|
||||
// Registering them unconditionally and merely hiding them would hold the form invalid on a
|
||||
// required field the clerk cannot see.
|
||||
this.form = this.fb.group(
|
||||
this.isAccountDebit
|
||||
? {
|
||||
beneficiaryName: [
|
||||
'',
|
||||
[
|
||||
Validators.required,
|
||||
Validators.maxLength(120)
|
||||
]
|
||||
],
|
||||
payableAtBranch: [
|
||||
'',
|
||||
[Validators.required]
|
||||
],
|
||||
payableAtCity: [
|
||||
'',
|
||||
[Validators.required]
|
||||
]
|
||||
}
|
||||
: {
|
||||
walkinPurchaserName: [
|
||||
'',
|
||||
[
|
||||
Validators.required,
|
||||
Validators.minLength(3)
|
||||
]
|
||||
],
|
||||
walkinPurchaserAddress: [
|
||||
'',
|
||||
[Validators.required]
|
||||
],
|
||||
walkinPurchaserAadhaar: [
|
||||
'',
|
||||
[
|
||||
Validators.required,
|
||||
Validators.pattern(/^[0-9]{12}$/)
|
||||
]
|
||||
],
|
||||
beneficiaryName: [
|
||||
'',
|
||||
[
|
||||
Validators.required,
|
||||
Validators.maxLength(120)
|
||||
]
|
||||
],
|
||||
payableAtBranch: [
|
||||
'',
|
||||
[Validators.required]
|
||||
],
|
||||
payableAtCity: [
|
||||
'',
|
||||
[Validators.required]
|
||||
]
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
|
||||
onSubmit(): void {
|
||||
if (this.form.invalid) return;
|
||||
const val = this.form.value;
|
||||
const beneficiary = {
|
||||
beneficiaryName: (val.beneficiaryName || '').trim().toUpperCase(),
|
||||
payableAtBranch: (val.payableAtBranch || '').trim().toUpperCase(),
|
||||
payableAtCity: (val.payableAtCity || '').trim().toUpperCase()
|
||||
};
|
||||
// Walk-in fields are left off entirely on an account debit rather than sent empty: the server
|
||||
// rejects a purchaser name arriving alongside an account, and '' would be sent as one.
|
||||
this.dialogRef.close(
|
||||
this.isAccountDebit
|
||||
? beneficiary
|
||||
: {
|
||||
walkinPurchaserName: (val.walkinPurchaserName || '').trim().toUpperCase(),
|
||||
walkinPurchaserAddress: (val.walkinPurchaserAddress || '').trim().toUpperCase(),
|
||||
walkinPurchaserAadhaar: (val.walkinPurchaserAadhaar || '').trim(),
|
||||
...beneficiary
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -134,6 +134,23 @@
|
||||
<option [ngValue]="27">027 · Industrial</option>
|
||||
</select>
|
||||
</div>
|
||||
@if (row.typ === 'DD') {
|
||||
<div class="ws-f ws-f--branch">
|
||||
<label class="ws-f__lbl ws-f__lbl--accent" title="Sponsor Bank">SPONSOR BANK</label>
|
||||
<select
|
||||
class="ws-f__ctl ws-f__ctl--accent"
|
||||
[(ngModel)]="row.sponsorBankId"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
@if (sponsorBanks.length === 0) {
|
||||
<option [ngValue]="null">-- Select Bank --</option>
|
||||
}
|
||||
@for (sb of sponsorBanks; track sb.id) {
|
||||
<option [ngValue]="sb.id">{{ sb.name }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
<div class="ws-f ws-f--paytype">
|
||||
<label class="ws-f__lbl ws-f__lbl--accent" title="Transaction Type">TXN TP.</label>
|
||||
<select
|
||||
@@ -182,7 +199,22 @@
|
||||
(input)="onAcNoInput(row, i, $any($event.target).value)"
|
||||
(click)="$event.stopPropagation()"
|
||||
(blur)="onAcNoBlur(row, i)"
|
||||
placeholder="Account number"
|
||||
[placeholder]="
|
||||
isDdAccountDebitRow(row)
|
||||
? 'Account to debit'
|
||||
: row.typ === 'DD'
|
||||
? ddGlMapping?.ddPayableGlCode || 'Auto GL'
|
||||
: 'Account number'
|
||||
"
|
||||
[readonly]="row.typ === 'DD' && !isDdAccountDebitRow(row)"
|
||||
[class.ws-f__ctl--readonly]="row.typ === 'DD' && !isDdAccountDebitRow(row)"
|
||||
[title]="
|
||||
isDdAccountDebitRow(row)
|
||||
? 'Member account the draft is funded from'
|
||||
: row.typ === 'DD'
|
||||
? 'Configured Sponsor DD Payable GL (Auto-selected)'
|
||||
: 'Account number'
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div class="ws-f ws-f--ecsno">
|
||||
@@ -340,7 +372,7 @@
|
||||
</div>
|
||||
|
||||
<!-- ── Row 3: DD Charges & GST — only for TYPE=DD, TXN TP=Cash, AC.TP=GL ── -->
|
||||
@if (isDdCashGlRow(row)) {
|
||||
@if (isDdRow(row)) {
|
||||
<div class="ws-group-row ws-group-row--mt">
|
||||
<div class="ws-group ws-group--flex">
|
||||
<span class="ws-caption">DD Charges & GST — Admin Rate {{ ddGstPercentage }}%</span>
|
||||
@@ -375,7 +407,7 @@
|
||||
</div>
|
||||
<div class="ws-f ws-f--amt">
|
||||
<label class="ws-f__lbl" title="Amount + Charges + GST, rounded up to the next rupee">
|
||||
TOTAL TO COLLECT
|
||||
{{ isDdAccountDebitRow(row) ? 'TOTAL TO DEBIT' : 'TOTAL TO COLLECT' }}
|
||||
</label>
|
||||
<div class="ws-amt-wrap ws-amt-wrap--locked">
|
||||
<span class="ws-amt-prefix">₹</span>
|
||||
@@ -387,6 +419,23 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ws-f" style="margin-left: auto; align-self: flex-end">
|
||||
<button
|
||||
type="button"
|
||||
class="te-dd-issue-btn"
|
||||
[disabled]="saving"
|
||||
(click)="openIssueWalkinDd(row); $event.stopPropagation()"
|
||||
[title]="
|
||||
isDdAccountDebitRow(row)
|
||||
? 'Auto-selects next available leaflet and opens the beneficiary form'
|
||||
: 'Auto-selects next available leaflet and opens walk-in purchaser form'
|
||||
"
|
||||
>
|
||||
<mat-icon style="font-size: 18px; width: 18px; height: 18px">post_add</mat-icon>
|
||||
<span>Fill DD Details & Send for Approval</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -398,6 +447,18 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ── DD GL UNCONFIGURED WARNING BANNER ────────────────────────── -->
|
||||
@if (selectedRow?.typ === 'DD' && !isDdGlMappingConfigured) {
|
||||
<div class="ws-dd-unconfigured-banner">
|
||||
<mat-icon>warning</mat-icon>
|
||||
<div class="ws-dd-unconfigured-banner__text">
|
||||
<strong>DD GL Mapping Required:</strong> Demand Draft GL accounts have not been configured yet. Please ask an
|
||||
administrator to set up the mapping under <em>Admin > Sponsor DD GST / GL Settings</em> before issuing
|
||||
drafts.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ── APPROVED WITHDRAWAL BANNER (cashier disburse flow) ─────────── -->
|
||||
@if (!isClerk && loadingWithdrawal) {
|
||||
<div class="ws-withdrawal-banner ws-withdrawal-banner--loading">
|
||||
@@ -1404,7 +1465,12 @@
|
||||
<div class="ws-group__fields">
|
||||
<div class="ws-f ws-f--type">
|
||||
<label class="ws-f__lbl">TYPE</label>
|
||||
<select class="ws-f__ctl" [(ngModel)]="row.typ">
|
||||
<select
|
||||
class="ws-f__ctl"
|
||||
[(ngModel)]="row.typ"
|
||||
(click)="$event.stopPropagation()"
|
||||
(change)="onTypChange(row)"
|
||||
>
|
||||
@for (t of typOptions; track t.value) {
|
||||
<option [value]="t.value">{{ t.label }}</option>
|
||||
}
|
||||
@@ -1412,18 +1478,36 @@
|
||||
</div>
|
||||
<div class="ws-f ws-f--branch">
|
||||
<label class="ws-f__lbl">BRANCH</label>
|
||||
<select class="ws-f__ctl" [(ngModel)]="row.br">
|
||||
<select class="ws-f__ctl" [(ngModel)]="row.br" (click)="$event.stopPropagation()">
|
||||
<option [ngValue]="1">001 · Main</option>
|
||||
<option [ngValue]="2">002 · M.G. Road</option>
|
||||
<option [ngValue]="14">014 · Station Rd</option>
|
||||
<option [ngValue]="27">027 · Industrial</option>
|
||||
</select>
|
||||
</div>
|
||||
@if (row.typ === 'DD') {
|
||||
<div class="ws-f ws-f--branch">
|
||||
<label class="ws-f__lbl ws-f__lbl--accent" title="Sponsor Bank">SPONSOR BANK</label>
|
||||
<select
|
||||
class="ws-f__ctl ws-f__ctl--accent"
|
||||
[(ngModel)]="row.sponsorBankId"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
@if (sponsorBanks.length === 0) {
|
||||
<option [ngValue]="null">-- Select Bank --</option>
|
||||
}
|
||||
@for (sb of sponsorBanks; track sb.id) {
|
||||
<option [ngValue]="sb.id">{{ sb.name }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
<div class="ws-f ws-f--paytype">
|
||||
<label class="ws-f__lbl ws-f__lbl--accent">TXN TP.</label>
|
||||
<select
|
||||
class="ws-f__ctl ws-f__ctl--accent"
|
||||
[(ngModel)]="row.paymentTypeId"
|
||||
(click)="$event.stopPropagation()"
|
||||
[class.ws-f__ctl--req]="!row.paymentTypeId"
|
||||
>
|
||||
@for (pt of paymentTypesForRow(row); track pt.id) {
|
||||
@@ -1436,6 +1520,7 @@
|
||||
<select
|
||||
class="ws-f__ctl"
|
||||
[(ngModel)]="row.acTyp"
|
||||
(click)="$event.stopPropagation()"
|
||||
(change)="
|
||||
row.acTyp === 'GL' &&
|
||||
row.typ !== 'Disbursement' &&
|
||||
@@ -1462,7 +1547,14 @@
|
||||
[value]="row.acNo"
|
||||
(input)="onAcNoInput(row, i, $any($event.target).value)"
|
||||
(blur)="onAcNoBlur(row, i)"
|
||||
placeholder="Account number"
|
||||
[placeholder]="
|
||||
row.typ === 'DD' ? ddGlMapping?.ddPayableGlCode || 'Auto GL' : 'Account number'
|
||||
"
|
||||
[readonly]="row.typ === 'DD'"
|
||||
[class.ws-f__ctl--readonly]="row.typ === 'DD'"
|
||||
[title]="
|
||||
row.typ === 'DD' ? 'Configured Sponsor DD Payable GL (Auto-selected)' : 'Account number'
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div class="ws-f ws-f--ecsno">
|
||||
@@ -1591,14 +1683,19 @@
|
||||
<div class="ws-group__fields">
|
||||
<div class="ws-f ws-f--desc">
|
||||
<label class="ws-f__lbl">DESCRIPTION</label>
|
||||
<input class="ws-f__ctl" [(ngModel)]="row.description" placeholder="Narration / remarks" />
|
||||
<input
|
||||
class="ws-f__ctl"
|
||||
[(ngModel)]="row.description"
|
||||
(click)="$event.stopPropagation()"
|
||||
placeholder="Narration / remarks"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Row 3: DD Charges & GST — only for TYPE=DD, TXN TP=Cash, AC.TP=GL ── -->
|
||||
@if (isDdCashGlRow(row)) {
|
||||
@if (isDdRow(row)) {
|
||||
<div class="ws-group-row ws-group-row--mt">
|
||||
<div class="ws-group ws-group--flex">
|
||||
<span class="ws-caption">DD Charges & GST — Admin Rate {{ ddGstPercentage }}%</span>
|
||||
@@ -1612,6 +1709,7 @@
|
||||
type="number"
|
||||
[value]="row.ddCharges ?? ''"
|
||||
(input)="row.ddCharges = +$any($event.target).value"
|
||||
(click)="$event.stopPropagation()"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
@@ -1632,7 +1730,7 @@
|
||||
</div>
|
||||
<div class="ws-f ws-f--amt">
|
||||
<label class="ws-f__lbl" title="Amount + Charges + GST, rounded up to the next rupee">
|
||||
TOTAL TO COLLECT
|
||||
{{ isDdAccountDebitRow(row) ? 'TOTAL TO DEBIT' : 'TOTAL TO COLLECT' }}
|
||||
</label>
|
||||
<div class="ws-amt-wrap ws-amt-wrap--locked">
|
||||
<span class="ws-amt-prefix">₹</span>
|
||||
@@ -1644,6 +1742,23 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ws-f" style="margin-left: auto; align-self: flex-end">
|
||||
<button
|
||||
type="button"
|
||||
class="te-dd-issue-btn"
|
||||
[disabled]="saving"
|
||||
(click)="openIssueWalkinDd(row); $event.stopPropagation()"
|
||||
[title]="
|
||||
isDdAccountDebitRow(row)
|
||||
? 'Auto-selects next available leaflet and opens the beneficiary form'
|
||||
: 'Auto-selects next available leaflet and opens walk-in purchaser form'
|
||||
"
|
||||
>
|
||||
<mat-icon style="font-size: 18px; width: 18px; height: 18px">post_add</mat-icon>
|
||||
<span>Fill DD Details & Send for Approval</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3384,3 +3384,67 @@ select.te-cell {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.ws-dd-unconfigured-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: 10px 16px;
|
||||
padding: 12px 16px;
|
||||
background: #fffbeb;
|
||||
border: 1.5px solid #fcd34d;
|
||||
border-radius: 8px;
|
||||
color: #92400e;
|
||||
font-size: 13px;
|
||||
|
||||
mat-icon {
|
||||
color: #d97706;
|
||||
font-size: 24px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__text {
|
||||
flex: 1;
|
||||
line-height: 1.4;
|
||||
|
||||
strong {
|
||||
color: #78350f;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.te-dd-issue-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: linear-gradient(135deg, #0284c7, #0ea5e9);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 7px 16px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 4px rgb(2 132 199 / 35%);
|
||||
transition: all 0.15s ease-in-out;
|
||||
letter-spacing: 0.2px;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #0369a1, #0284c7);
|
||||
box-shadow: 0 3px 8px rgb(2 132 199 / 50%);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: #cbd5e1;
|
||||
color: #64748b;
|
||||
cursor: not-allowed;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
} from '../../shared/customer-search-dialog/customer-search-dialog.component';
|
||||
import { SponsorDDService } from '../../sponsor-dd/sponsor-dd.service';
|
||||
import { OrganizationService } from '../../organization/organization.service';
|
||||
import { CashierIssueDdDialogComponent } from './cashier-issue-dd-dialog.component';
|
||||
|
||||
interface TxnRow {
|
||||
typ: string;
|
||||
@@ -51,6 +52,7 @@ interface TxnRow {
|
||||
routingCode: string;
|
||||
/** DD issuance charges/commission — only used when TYPE=DD, TXN TP=Cash, AC.TP=GL */
|
||||
ddCharges: number | null;
|
||||
sponsorBankId?: 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;
|
||||
@@ -1523,7 +1525,14 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
* the one that actually governs how the settlement credit posts (see submitShareRedemptionTransfer()).
|
||||
*/
|
||||
paymentTypesForRow(row: TxnRow): any[] {
|
||||
const tellerFacing = this.paymentTypes.filter((pt: any) => !pt.isSystemDefined);
|
||||
// Sponsor-bank payment types are excluded. They were added for the Demand Draft module, which
|
||||
// picks its bank from m_sponsor_bank on the DD row itself, and they are not counter instruments
|
||||
// — a clerk choosing "SBI" as the TXN TP. of a withdrawal says nothing about how the money
|
||||
// moves. They are recognised by their description rather than by name so that adding another
|
||||
// sponsor bank does not put it back on this list.
|
||||
const tellerFacing = this.paymentTypes.filter(
|
||||
(pt: any) => !pt.isSystemDefined && !/sponsor bank/i.test(pt.description ?? '')
|
||||
);
|
||||
if (!this.isClerk) {
|
||||
// Cashier counter takes cash only — cheque/transfer/DD instruments go through their own
|
||||
// clerk/back-office approval flows, never straight through the cash drawer.
|
||||
@@ -1541,6 +1550,23 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
if (isShareSettlementCredit) {
|
||||
return tellerFacing.filter((pt: any) => !pt.isCashPayment);
|
||||
}
|
||||
// A withdrawal slip is a cash instruction and nothing else: the clerk raises it, the cashier
|
||||
// counts the notes out against the token. There is no non-cash way to settle one, so cash is
|
||||
// the only choice offered rather than the default among several.
|
||||
if (row.typ === 'Withdrawal Slip') {
|
||||
return tellerFacing.filter((pt: any) => !!pt.isCashPayment);
|
||||
}
|
||||
// A clerk's Demand Draft is never funded by cash they take themselves. Cash for a draft is
|
||||
// counted at the counter, so it comes either from the cashier's own DD screen or from an
|
||||
// approved cashier deposit request the clerk points the draft at; the clerk's own route is to
|
||||
// debit the member's account. Offering cash here let a draft be raised against notes no drawer
|
||||
// had counted, which is exactly how the six cash DDs with no denomination breakdown arose.
|
||||
if (row.typ === 'DD') {
|
||||
return tellerFacing.filter((pt: any) => !pt.isCashPayment);
|
||||
}
|
||||
// Everything else can go either way, so both are offered and the clerk says which. A cheque or
|
||||
// a bank transfer normally settles through the instrument, a disbursement or fund transfer
|
||||
// between accounts — the type alone does not fix it to one or the other.
|
||||
return tellerFacing;
|
||||
}
|
||||
|
||||
@@ -1664,9 +1690,6 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
// wrong for crediting a GL amount into a savings/current account for any other reason (e.g.
|
||||
// funding a newly-activated account's opening balance from a GL source).
|
||||
{ value: 'Fund Transfer', label: 'Fund Transfer' },
|
||||
// NOTE: the exact same TxnRow.typ string is used at every dispatch/default-setting site below —
|
||||
// search for 'Disbursement' whenever this needs to change again, since 'Fund Transfer' always
|
||||
// rides alongside it in every one of those checks rather than having its own separate case.
|
||||
{ value: 'Enter Bank Transfer', label: 'Enter Bank Transfer' },
|
||||
{ value: 'DD', label: 'DD (Demand Draft)' }
|
||||
];
|
||||
@@ -1692,6 +1715,9 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
return this.isClerk ? this.clerkTypOptions : this.cashierTypOptions;
|
||||
}
|
||||
|
||||
sponsorBanks: any[] = [];
|
||||
ddGlMapping: any = null;
|
||||
|
||||
// ── Denomination ──────────────────────────────────────────────────────────
|
||||
notes: Denomination[] = [];
|
||||
coins: Denomination[] = [];
|
||||
@@ -1752,6 +1778,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
|
||||
this.loadCashVaultGl();
|
||||
this.loadDdGstPercentage();
|
||||
this.loadSponsorBanks();
|
||||
|
||||
if (this.reviewMode) {
|
||||
this.loadRequestIntoForm(this.reviewRequestId!, 'review');
|
||||
@@ -1842,12 +1869,85 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
.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;
|
||||
this.ddGlMapping = mappings && mappings.length ? mappings[0] : null;
|
||||
this.ddGstPercentage = Number(this.ddGlMapping?.gstPercentage ?? 18);
|
||||
this.ddLimitAmount = this.ddGlMapping?.ddLimitAmount != null ? Number(this.ddGlMapping.ddLimitAmount) : null;
|
||||
if (this.ddGlMapping?.ddPayableGlAccountId) {
|
||||
if (this.ddGlMapping.ddPayableGlCode) {
|
||||
this.applyDdGlMappingToDdRows();
|
||||
} else {
|
||||
this.http
|
||||
.get<any>(`/glaccounts/${this.ddGlMapping.ddPayableGlAccountId}`)
|
||||
.pipe(catchError(() => of(null)))
|
||||
.subscribe((glAcc: any) => {
|
||||
if (glAcc) {
|
||||
this.ddGlMapping.ddPayableGlCode = glAcc.glCode || String(glAcc.id);
|
||||
this.ddGlMapping.ddPayableGlAccountName = glAcc.name;
|
||||
}
|
||||
this.applyDdGlMappingToDdRows();
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
applyDdGlMappingToRow(row: TxnRow): void {
|
||||
if (row.typ !== 'DD' || !this.ddGlMapping) return;
|
||||
// Leaves an account-funded DD alone: its A/C NO. is the member's account the clerk typed, and
|
||||
// overwriting it with the DD Payable GL would silently turn the row back into a cash draft.
|
||||
if (this.isDdAccountDebitRow(row)) return;
|
||||
row.acTyp = 'GL';
|
||||
row.dbCr = 'Cr';
|
||||
|
||||
if (this.ddGlMapping.ddPayableGlCode) {
|
||||
row.acNo = this.ddGlMapping.ddPayableGlCode;
|
||||
this.selectedRow = row;
|
||||
this.loadAccountDetails(row.acNo, 'GL');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.ddGlMapping.ddPayableGlAccountId) {
|
||||
this.http
|
||||
.get<any>(`/glaccounts/${this.ddGlMapping.ddPayableGlAccountId}`)
|
||||
.pipe(catchError(() => of(null)))
|
||||
.subscribe((glAcc: any) => {
|
||||
if (glAcc) {
|
||||
this.ddGlMapping.ddPayableGlCode = glAcc.glCode || String(glAcc.id);
|
||||
this.ddGlMapping.ddPayableGlAccountName = glAcc.name;
|
||||
row.acNo = this.ddGlMapping.ddPayableGlCode;
|
||||
this.selectedRow = row;
|
||||
this.loadAccountDetails(row.acNo, 'GL');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private applyDdGlMappingToDdRows(): void {
|
||||
for (const row of this.rows) {
|
||||
if (row.typ === 'DD') {
|
||||
this.applyDdGlMappingToRow(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private loadSponsorBanks(): void {
|
||||
this.sponsorDDService
|
||||
.getSponsorBanks()
|
||||
.pipe(catchError(() => of([])))
|
||||
.subscribe((banks: any[]) => {
|
||||
this.sponsorBanks = banks || [];
|
||||
for (const row of this.rows) {
|
||||
if (row.typ === 'DD' && !row.sponsorBankId && this.sponsorBanks.length > 0) {
|
||||
row.sponsorBankId = this.sponsorBanks[0].id;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
get isDdGlMappingConfigured(): boolean {
|
||||
return !!(this.ddGlMapping && this.ddGlMapping.ddPayableGlAccountId && this.ddGlMapping.cashGlAccountId);
|
||||
}
|
||||
|
||||
private loadDayStatus(): void {
|
||||
this.cashierService
|
||||
.getCashierDayStatus()
|
||||
@@ -2093,9 +2193,12 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
row.chequeNo = '';
|
||||
row.cheqDate = '';
|
||||
break;
|
||||
// A cheque clears through the bank rather than the drawer, so it opens on the transfer
|
||||
// instrument. Cash is still on its dropdown — a cheque can be cashed over the counter — but
|
||||
// it is the exception, not what the field should start on.
|
||||
case 'Cheque':
|
||||
row.dbCr = 'Dr';
|
||||
row.paymentTypeId = this.findCashPaymentTypeId();
|
||||
row.paymentTypeId = this.findTransferPaymentTypeId();
|
||||
break;
|
||||
case 'Disbursement':
|
||||
case 'Fund Transfer':
|
||||
@@ -2121,16 +2224,25 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
row.chequeNo = '';
|
||||
row.cheqDate = '';
|
||||
break;
|
||||
// A DD is bought with cash: the customer hands over Face Value + Charges + GST, so it is money
|
||||
// coming IN (Cr) against the GL the draft is drawn on. All three of Cr, a cash payment type and
|
||||
// AC.TP = GL are what isDdCashGlRow() looks for -- without them the DD Charges & GST panel never
|
||||
// appears and the row would post as an ordinary entry with the charges silently dropped.
|
||||
// A DD is bought with cash: the purchaser hands over Face Value + Charges + GST, so it is
|
||||
// money coming IN (Cr) against the GL the draft is drawn on. All three of Cr, a cash payment
|
||||
// type and AC.TP = GL are what isDdCashGlRow() looks for -- without them the DD Charges & GST
|
||||
// panel never appears and the row would post as an ordinary entry with the charges silently
|
||||
// dropped.
|
||||
//
|
||||
// A clerk funds a draft through an account or an instrument instead, and their TXN TP. list no
|
||||
// longer offers cash, so defaulting to it would open the field on a value it cannot show.
|
||||
// isDdCashGlRow() still holds for them through AC.TP = GL.
|
||||
case 'DD':
|
||||
row.dbCr = 'Cr';
|
||||
row.paymentTypeId = this.findCashPaymentTypeId();
|
||||
row.acTyp = 'GL';
|
||||
row.paymentTypeId = this.isClerk ? this.findTransferPaymentTypeId() : this.findCashPaymentTypeId();
|
||||
row.chequeNo = '';
|
||||
row.cheqDate = '';
|
||||
if (this.sponsorBanks.length > 0 && !row.sponsorBankId) {
|
||||
row.sponsorBankId = this.sponsorBanks[0].id;
|
||||
}
|
||||
this.applyDdGlMappingToRow(row);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -2145,10 +2257,33 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
return pt?.id ?? null;
|
||||
}
|
||||
|
||||
/** TYPE=DD + TXN TP=Cash + AC.TP=GL — the only combination that collects DD charges + GST from the customer. */
|
||||
/** TYPE=DD + TXN TP=Cash */
|
||||
isDdCashGlRow(row: TxnRow): boolean {
|
||||
if (this.isDdAccountDebitRow(row)) return false;
|
||||
const pt = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId);
|
||||
return row.typ === 'DD' && !!pt?.isCashPayment && row.acTyp === 'GL';
|
||||
return row.typ === 'DD' && (row.acTyp === 'GL' || !row.acTyp || !!pt?.isCashPayment);
|
||||
}
|
||||
|
||||
/**
|
||||
* TYPE=DD funded by debiting a member's savings/current account rather than by cash.
|
||||
*
|
||||
* Clerk-only. A cashier's counter takes cash and nothing else, so offering them an account debit
|
||||
* would let a draft be raised against an account with no cash to reconcile it against — and the
|
||||
* clerk flow is the one that carries a maker-checker step over the member's balance.
|
||||
*/
|
||||
isDdAccountDebitRow(row: TxnRow): boolean {
|
||||
return this.isClerk && row.typ === 'DD' && (row.acTyp === 'SD' || row.acTyp === 'CA' || row.acTyp === 'CB');
|
||||
}
|
||||
|
||||
/**
|
||||
* Any DD row the charges/GST panel applies to.
|
||||
*
|
||||
* Charges and GST come from the admin's DD configuration and are collected whichever way the
|
||||
* draft is paid for — the difference is only where the money comes from, so the panel is the same
|
||||
* for both and the label on the total changes rather than the arithmetic.
|
||||
*/
|
||||
isDdRow(row: TxnRow): boolean {
|
||||
return this.isDdCashGlRow(row) || this.isDdAccountDebitRow(row);
|
||||
}
|
||||
|
||||
/** GST on DD charges only (never on the DD face value), at the admin-configured rate. */
|
||||
@@ -2179,6 +2314,258 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
return !!this.selectedRow && Math.abs(this.denominationTotal - this.ddTargetTotal) < 0.005;
|
||||
}
|
||||
|
||||
branchNameFor(branchId: number): string {
|
||||
const map: Record<number, string> = {
|
||||
1: '001 · Main',
|
||||
2: '002 · M.G. Road',
|
||||
14: '014 · Station Rd',
|
||||
27: '027 · Industrial'
|
||||
};
|
||||
return map[branchId] || `Branch ${branchId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cashier Walk-in DD creation flow:
|
||||
* 1. Validates one-time persistent GL configuration
|
||||
* 2. Auto-selects next available leaflet
|
||||
* 3. Opens dialog to capture purchaser/beneficiary details
|
||||
* 4. Submits issue request directly with physical denominations for manager approval
|
||||
*/
|
||||
openIssueWalkinDd(row: TxnRow): void {
|
||||
if (!this.isDdGlMappingConfigured) {
|
||||
this.showAlert('error', 'DD GL Mapping Not Configured', [
|
||||
'DD GL mapping is not configured.',
|
||||
'Please ask an administrator to set up the GL accounts in Admin > Sponsor DD GST / GL Settings before creating Demand Drafts.'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
const faceValue = Number(row.amount);
|
||||
if (!faceValue || faceValue <= 0) {
|
||||
this.showAlert('error', 'Invalid Amount', ['Please enter the DD Face Value amount before proceeding.']);
|
||||
return;
|
||||
}
|
||||
const chargesAmount = Number(row.ddCharges) || 0;
|
||||
const gstAmount = this.ddGstAmountFor(row);
|
||||
const totalCashPayable = this.ddTotalPayableFor(row);
|
||||
const accountDebit = this.isDdAccountDebitRow(row);
|
||||
|
||||
// An account-funded draft needs the account resolved to a Fineract id before it can be sent:
|
||||
// the server takes a savings/current account id and a client id, not the typed number. Both are
|
||||
// filled in by the A/C NO. lookup, so an unresolved account means the number was never found.
|
||||
let fundingAccountId: number | null = null;
|
||||
let fundingClientId: number | null = null;
|
||||
if (accountDebit) {
|
||||
const acNo = (row.acNo || '').trim();
|
||||
if (!acNo) {
|
||||
this.showAlert('error', 'Account Required', [
|
||||
'Enter the account number the Demand Draft is funded from.'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
fundingAccountId = this.rowAccountIds[acNo] ?? null;
|
||||
fundingClientId = this.rowClientIds[acNo] ?? null;
|
||||
if (!fundingAccountId || !fundingClientId) {
|
||||
this.showAlert('error', 'Account Not Found', [
|
||||
`Account ${acNo} could not be resolved.`,
|
||||
'Check the account number and wait for its details to load before raising the draft.'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Only a cash draft has notes to reconcile. An account debit takes no cash at the counter, so a
|
||||
// denomination count left over from an earlier row must not be checked against it.
|
||||
if (!accountDebit && this.denominationTotal > 0 && Math.abs(this.denominationTotal - totalCashPayable) > 0.005) {
|
||||
this.showAlert('error', 'Denomination Mismatch', [
|
||||
`Cash denomination counted (₹${this.denominationTotal}) does not match total payable cash (₹${totalCashPayable}).`,
|
||||
'Please verify and match the physical cash denomination count.'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
this.saving = true;
|
||||
this.sponsorDDService.getNextAvailableLeaflet(row.br, row.sponsorBankId ?? undefined).subscribe({
|
||||
next: (leaflet) => {
|
||||
this.saving = false;
|
||||
if (!leaflet) {
|
||||
this.showAlert('error', 'No Available Leaflets', [
|
||||
'No available Demand Draft leaflets found for this branch and sponsor bank.',
|
||||
'Please register a DD inventory series first.'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedBank = this.sponsorBanks.find((b: any) => b.id === row.sponsorBankId);
|
||||
const branchName = this.branchNameFor(row.br) || 'Branch ' + row.br;
|
||||
|
||||
const dialogRef = this.dialog.open(CashierIssueDdDialogComponent, {
|
||||
width: '600px',
|
||||
disableClose: true,
|
||||
data: {
|
||||
leaflet,
|
||||
branchName,
|
||||
sponsorBankName: selectedBank?.name || 'Sponsor Bank',
|
||||
faceValue,
|
||||
chargesAmount,
|
||||
gstPercentage: this.ddGstPercentage,
|
||||
gstAmount,
|
||||
totalCashPayable,
|
||||
paymentMethod: accountDebit ? ('ACCOUNT_DEBIT' as const) : ('CASH' as const),
|
||||
accountNo: accountDebit ? row.acNo : undefined,
|
||||
accountHolderName: accountDebit ? this.rowAccountNames[(row.acNo || '').trim()] : undefined
|
||||
}
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe((result) => {
|
||||
if (!result) return;
|
||||
if (accountDebit) {
|
||||
this.submitAccountDebitDd(row, leaflet, result, fundingAccountId!, fundingClientId!);
|
||||
} else {
|
||||
this.submitWalkinCashDd(row, leaflet, result);
|
||||
}
|
||||
});
|
||||
},
|
||||
error: (err) => {
|
||||
this.saving = false;
|
||||
this.showAlert('error', 'Error Auto-Selecting DD Leaflet', [err.error?.message || err.message]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Raises a Demand Draft funded by debiting a member's savings or current account.
|
||||
*
|
||||
* Clerk-only, and a maker-checker request like the cash flow: nothing is debited here. The member's
|
||||
* account is only touched when a checker approves, which is also when the balance is re-checked —
|
||||
* so a draft raised against a sufficient balance can still be refused if the balance has since
|
||||
* gone. No denominations are sent, because no cash is taken.
|
||||
*
|
||||
* The account goes in savingsAccountId or currentAccountId, never both: the server insists on
|
||||
* exactly one, since which table the account lives in decides which control GL is debited.
|
||||
*/
|
||||
private submitAccountDebitDd(
|
||||
row: TxnRow,
|
||||
leaflet: any,
|
||||
beneficiaryData: any,
|
||||
fundingAccountId: number,
|
||||
fundingClientId: number
|
||||
): void {
|
||||
const d = new Date();
|
||||
const issuedDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
const isCurrent = row.acTyp === 'CA' || row.acTyp === 'CB';
|
||||
|
||||
const payload = {
|
||||
clientId: fundingClientId,
|
||||
beneficiaryName: beneficiaryData.beneficiaryName,
|
||||
payableAtBranch: beneficiaryData.payableAtBranch,
|
||||
payableAtCity: beneficiaryData.payableAtCity,
|
||||
faceValue: Number(row.amount),
|
||||
chargesAmount: Number(row.ddCharges) || 0,
|
||||
issuingBranchId: row.br,
|
||||
issuedDate,
|
||||
paymentMethod: 'ACCOUNT_DEBIT' as const,
|
||||
savingsAccountId: isCurrent ? undefined : fundingAccountId,
|
||||
currentAccountId: isCurrent ? fundingAccountId : undefined
|
||||
};
|
||||
|
||||
this.saving = true;
|
||||
this.sponsorDDService.submitIssueRequest(leaflet.id, payload).subscribe({
|
||||
next: () => {
|
||||
this.saving = false;
|
||||
this.showAlert('success', 'DD Issuance Request Submitted', [
|
||||
`Demand Draft request for DD No. ${leaflet.ddNumber} (Face Value: ₹${row.amount}) submitted successfully!`,
|
||||
`₹${this.ddTotalPayableFor(row)} will be debited from ${row.acNo} on approval.`,
|
||||
'Sent to Manager / Head Cashier for maker-checker approval.'
|
||||
]);
|
||||
row.amount = null;
|
||||
row.ddCharges = null;
|
||||
row.description = '';
|
||||
},
|
||||
error: (err) => {
|
||||
this.saving = false;
|
||||
this.showAlert('error', 'DD Submission Failed', [err.error?.message || err.message]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private submitWalkinCashDd(row: TxnRow, leaflet: any, purchaserData: any): void {
|
||||
// Read through countFor, which knows the map is keyed by kind:value. Indexing by d.id looked
|
||||
// right but matched nothing, so every cash DD went in with no breakdown at all and its money
|
||||
// vanished from the closing report -- the notes were counted on screen and then dropped.
|
||||
const denominationMap: { [key: string]: number } = {};
|
||||
for (const d of [
|
||||
...this.notes,
|
||||
...this.coins
|
||||
]) {
|
||||
const count = this.countFor(d);
|
||||
if (count > 0) {
|
||||
denominationMap[this.denomKey(d)] = count;
|
||||
}
|
||||
}
|
||||
|
||||
// The breakdown is the only record of which notes were taken, and the server insists it add up
|
||||
// to the cash collected. Checked here so the cashier is told which figure is wrong while the
|
||||
// counts are still on screen, rather than getting a rejection after the fact.
|
||||
//
|
||||
// ddTotalPayableFor rounds up to the whole rupee actually collected, which is what the notes
|
||||
// can add to; the exact figure with paise is accepted too, for a draft that lands on a rupee.
|
||||
const totalCollected = Number(this.ddTotalPayableFor(row));
|
||||
const exactPayable = Number(row.amount || 0) + (Number(row.ddCharges) || 0) + this.ddGstAmountFor(row);
|
||||
const countedTotal = Object.entries(denominationMap).reduce(
|
||||
(sum, [
|
||||
key,
|
||||
count
|
||||
]) => sum + Number(key.split(':')[1]) * count,
|
||||
0
|
||||
);
|
||||
if (countedTotal !== totalCollected && countedTotal !== exactPayable) {
|
||||
this.showAlert('error', 'Denomination Entry Does Not Match', [
|
||||
countedTotal === 0
|
||||
? 'Enter the notes and coins taken from the purchaser before submitting.'
|
||||
: `The notes and coins entered come to ₹${countedTotal.toFixed(2)}, but ₹${totalCollected.toFixed(2)} is collected (face value + charges + GST).`,
|
||||
'The breakdown must equal the cash taken — it is what the closing report and the drawer are checked against.'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
const d = new Date();
|
||||
const issuedDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
|
||||
const payload = {
|
||||
walkinPurchaserName: purchaserData.walkinPurchaserName,
|
||||
walkinPurchaserAddress: purchaserData.walkinPurchaserAddress,
|
||||
walkinPurchaserAadhaar: purchaserData.walkinPurchaserAadhaar,
|
||||
beneficiaryName: purchaserData.beneficiaryName,
|
||||
payableAtBranch: purchaserData.payableAtBranch,
|
||||
payableAtCity: purchaserData.payableAtCity,
|
||||
faceValue: Number(row.amount),
|
||||
chargesAmount: Number(row.ddCharges) || 0,
|
||||
issuingBranchId: row.br,
|
||||
issuedDate,
|
||||
paymentMethod: 'CASH' as const,
|
||||
denominationData: Object.keys(denominationMap).length > 0 ? JSON.stringify(denominationMap) : undefined
|
||||
};
|
||||
|
||||
this.saving = true;
|
||||
this.sponsorDDService.submitIssueRequest(leaflet.id, payload).subscribe({
|
||||
next: () => {
|
||||
this.saving = false;
|
||||
this.showAlert('success', 'DD Issuance Request Submitted', [
|
||||
`Demand Draft request for DD No. ${leaflet.ddNumber} (Face Value: ₹${row.amount}, Total Cash: ₹${this.ddTotalPayableFor(row)}) submitted successfully!`,
|
||||
'Sent to Manager / Head Cashier for maker-checker approval.'
|
||||
]);
|
||||
row.amount = null;
|
||||
row.ddCharges = null;
|
||||
row.description = '';
|
||||
this.clearDenominations();
|
||||
},
|
||||
error: (err) => {
|
||||
this.saving = false;
|
||||
this.showAlert('error', 'DD Submission Failed', [err.error?.message || err.message]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Submits a clerk double-entry request (maker-checker, PENDING until manager approves).
|
||||
* Disbursement: 1 DR [GL] source → N CR savings/current/GL destinations.
|
||||
@@ -2811,16 +3198,23 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
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 exactMatch = allItems.find((x: any) => String(x[f] ?? '').trim() === String(acNo).trim());
|
||||
const partialMatch = !exactMatch
|
||||
? (allItems.find((x: any) => x[f]?.endsWith(acNo)) ?? allItems.find((x: any) => x[f]?.includes(acNo)))
|
||||
? (allItems.find((x: any) => String(x[f] ?? '').endsWith(acNo)) ??
|
||||
allItems.find((x: any) => String(x[f] ?? '').includes(acNo)))
|
||||
: null;
|
||||
const match = exactMatch ?? partialMatch ?? null;
|
||||
const idMatch =
|
||||
!exactMatch && !partialMatch && acTyp === 'GL'
|
||||
? allItems.find((x: any) => String(x.id) === String(acNo))
|
||||
: null;
|
||||
const match = exactMatch ?? partialMatch ?? idMatch ?? null;
|
||||
if (match?.id) {
|
||||
const matchedGlCode = match[f] ?? acNo;
|
||||
if (this.selectedRow && this.selectedRow.typ === 'DD' && match.glCode) {
|
||||
this.selectedRow.acNo = match.glCode;
|
||||
}
|
||||
const clientIdFromSearch = acTyp !== 'GL' && match.clientId != null ? String(match.clientId) : null;
|
||||
this.fetchAllDetails(match.id, match[f] ?? acNo, cfg, token, acTyp, clientIdFromSearch);
|
||||
this.fetchAllDetails(match.id, matchedGlCode, cfg, token, acTyp, clientIdFromSearch);
|
||||
return;
|
||||
}
|
||||
this.loadingAccount = false;
|
||||
@@ -3048,7 +3442,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
return false;
|
||||
}
|
||||
const returnedId = acc.glCode ?? acc.accountNo;
|
||||
if (returnedId && returnedId !== acNo) {
|
||||
if (returnedId && returnedId !== acNo && String(acc.id) !== acNo) {
|
||||
this.loadingAccount = false;
|
||||
this.accountError = 'Account mismatch. Please re-enter account number and try again.';
|
||||
return false;
|
||||
@@ -3294,7 +3688,6 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
{ 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 }
|
||||
@@ -4579,6 +4972,10 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
confirmSubmit(): void {
|
||||
this.showConfirmDialog = false;
|
||||
const row = this.selectedRow!;
|
||||
if (row.typ === 'DD') {
|
||||
this.openIssueWalkinDd(row);
|
||||
return;
|
||||
}
|
||||
const amount = this.isDdCashGlRow(row) ? this.ddTotalPayableFor(row) : (row.amount ?? this.denominationTotal);
|
||||
const isDeposit = row.dbCr === 'Cr';
|
||||
const command = isDeposit ? 'deposit' : 'withdrawal';
|
||||
|
||||
Reference in New Issue
Block a user