fix: update sponsor DD approvals, cashier transaction entry, FIU and dividend declaration
This commit is contained in:
@@ -8,7 +8,7 @@
|
||||
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, FormArray, 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';
|
||||
@@ -35,6 +35,35 @@ export interface CashierIssueDdDialogData {
|
||||
accountNo?: string;
|
||||
/** The account holder, for the same reason. */
|
||||
accountHolderName?: string;
|
||||
/**
|
||||
* How many drafts this slip raises. Absent or 1 is the ordinary single draft and the dialog is
|
||||
* unchanged; 2 or more turns it into a slip, asking for a face value, charge and beneficiary per
|
||||
* draft and reporting the combined total the account will be debited for.
|
||||
*/
|
||||
ddCount?: number;
|
||||
/**
|
||||
* Whether the funding account is a GL account rather than a member's. Only affects wording: a GL
|
||||
* account has no passbook, so it is not promised one.
|
||||
*/
|
||||
glFunded?: boolean;
|
||||
/**
|
||||
* The leaflets provisionally shown against each line of a slip.
|
||||
*
|
||||
* Only ever indicative. The server allocates the real numbers inside the locked transaction that
|
||||
* submits the slip, so what is shown here is the next-available run at the moment the dialog was
|
||||
* opened — accurate unless another clerk submits first, which is precisely why the numbers cannot
|
||||
* be promised until submit returns them.
|
||||
*/
|
||||
previewDdNumbers?: string[];
|
||||
}
|
||||
|
||||
/** One captured draft on a multi-DD slip. */
|
||||
export interface DdSlipLineResult {
|
||||
beneficiaryName: string;
|
||||
payableAtBranch: string;
|
||||
payableAtCity: string;
|
||||
faceValue: number;
|
||||
chargesAmount: number;
|
||||
}
|
||||
|
||||
@Component({
|
||||
@@ -56,10 +85,20 @@ export interface CashierIssueDdDialogData {
|
||||
<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>
|
||||
<h2>
|
||||
@if (isSlip) {
|
||||
Multi-DD Slip · {{ lineCount }} Demand Drafts
|
||||
} @else {
|
||||
{{ 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' }}
|
||||
@if (isSlip) {
|
||||
One debit funds all {{ lineCount }} drafts · Enter each draft's beneficiary and amount
|
||||
} @else {
|
||||
Next available DD leaflet auto-selected ·
|
||||
{{ isAccountDebit ? 'Enter beneficiary details' : 'Enter purchaser & beneficiary details' }}
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -69,41 +108,91 @@ export interface CashierIssueDdDialogData {
|
||||
</div>
|
||||
|
||||
<mat-dialog-content class="dd-dialog-body">
|
||||
<!-- Draft & Financials Summary Card -->
|
||||
<div class="dd-summary-card">
|
||||
<!-- The number the customer will actually see printed on the instrument. "Auto-Selected
|
||||
<!--
|
||||
Slip running total.
|
||||
|
||||
A slip's figures are not known until every line is typed — different drafts carry different
|
||||
face values and different charges — so the total the account will be debited for is built
|
||||
up here as the clerk works, rather than shown as a fixed figure up front. Answering "what
|
||||
is this going to cost my member" without it would mean adding the lines by hand.
|
||||
-->
|
||||
@if (isSlip) {
|
||||
<div class="dd-summary-card">
|
||||
<div class="summary-col">
|
||||
<span class="label">Drafts On This Slip</span>
|
||||
<strong class="dd-number-highlight">{{ lineCount }}</strong>
|
||||
<span class="sublabel">{{ data.sponsorBankName || 'Sponsor Bank' }} · {{ data.branchName }}</span>
|
||||
</div>
|
||||
<div class="summary-divider"></div>
|
||||
<div class="summary-col">
|
||||
<span class="label">Total Face Value</span>
|
||||
<strong class="amount">₹{{ slipFaceValue | number: '1.2-2' }}</strong>
|
||||
<span class="sublabel">+ Charges ₹{{ slipCharges | 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">₹{{ slipGst | 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">Total To Debit</span>
|
||||
<strong class="total-amount">₹{{ slipTotal | number: '1.2-2' }}</strong>
|
||||
<span class="sublabel">{{ data.accountNo || 'Account' }} · one debit</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
Said plainly because the numbers on screen are the one thing in this dialog the clerk
|
||||
cannot rely on. They are the next-available run as it stood when the dialog opened; the
|
||||
binding allocation happens inside the submit transaction, so a slip filed after another
|
||||
clerk's gets the following numbers instead.
|
||||
-->
|
||||
<div class="dd-preview-note">
|
||||
<mat-icon>info</mat-icon>
|
||||
<div>
|
||||
DD numbers below are <strong>provisional</strong>. The final numbers are allocated when the slip is
|
||||
submitted and will be confirmed on the receipt.
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<!-- Draft & Financials Summary Card -->
|
||||
<div class="dd-summary-card">
|
||||
<!-- The number the customer will actually see printed on the instrument. "Auto-Selected
|
||||
Leaflet" named the stock item rather than the draft, so it was not obvious that this
|
||||
is the DD number being issued. -->
|
||||
<div class="summary-col">
|
||||
<span class="label">DD Number Being Issued</span>
|
||||
<strong class="dd-number-highlight">{{ data.leaflet.ddNumber }}</strong>
|
||||
<span class="sublabel">{{ data.sponsorBankName || 'Sponsor Bank' }} · {{ data.branchName }}</span>
|
||||
<div class="summary-col">
|
||||
<span class="label">DD Number Being Issued</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>
|
||||
<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.
|
||||
@@ -121,123 +210,237 @@ export interface CashierIssueDdDialogData {
|
||||
· {{ 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.
|
||||
@if (isSlip) {
|
||||
₹{{ slipTotal | number: '1.2-2' }} will be debited from this account as a
|
||||
<strong>single transaction</strong> covering all {{ lineCount }} drafts, when a checker approves the
|
||||
slip —
|
||||
<!-- A GL account has no passbook, so the reassurance a member gets is phrased for
|
||||
the ledger instead of promising a statement line that does not exist. -->
|
||||
@if (isGlFunded) {
|
||||
one journal entry against this GL account, not {{ lineCount }}.
|
||||
} @else {
|
||||
the member's passbook shows one entry, not {{ lineCount }}.
|
||||
}
|
||||
} @else {
|
||||
₹{{ 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">
|
||||
<!--
|
||||
<!--
|
||||
A slip's drafts, one card each.
|
||||
|
||||
Face value and charge sit on the line rather than on the slip because that is how the bank
|
||||
actually uses this: three drafts to three institutions are three different amounts, and the
|
||||
charge is levied per draft. The server stores and posts them per line for the same reason.
|
||||
-->
|
||||
@if (isSlip) {
|
||||
<form [formGroup]="slipForm" class="dd-form">
|
||||
<div formArrayName="lines">
|
||||
@for (lineCtrl of slipLines.controls; track $index) {
|
||||
<div class="dd-line-card" [formGroupName]="$index">
|
||||
<div class="dd-line-head">
|
||||
<span class="dd-line-badge">DD {{ $index + 1 }} of {{ lineCount }}</span>
|
||||
@if (previewNumberAt($index)) {
|
||||
<span class="dd-line-number">No. {{ previewNumberAt($index) }} <em>(provisional)</em></span>
|
||||
}
|
||||
<span class="dd-line-total"> Debit ₹{{ lineTotalAt($index) | number: '1.2-2' }} </span>
|
||||
</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="lineCtrl.get('beneficiaryName')?.hasError('required')"
|
||||
>Beneficiary name is required</mat-error
|
||||
>
|
||||
<mat-error *ngIf="lineCtrl.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="lineCtrl.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="lineCtrl.get('payableAtCity')?.hasError('required')"
|
||||
>Payable City is required</mat-error
|
||||
>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<mat-form-field appearance="outline" class="form-field-flex">
|
||||
<mat-label>Face Value *</mat-label>
|
||||
<input matInput type="number" formControlName="faceValue" placeholder="0.00" />
|
||||
<span matPrefix>₹ </span>
|
||||
<mat-error *ngIf="lineCtrl.get('faceValue')?.hasError('required')"
|
||||
>Face value is required</mat-error
|
||||
>
|
||||
<mat-error *ngIf="lineCtrl.get('faceValue')?.hasError('min')"
|
||||
>Face value must be greater than zero</mat-error
|
||||
>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="form-field-flex">
|
||||
<mat-label>Charges</mat-label>
|
||||
<input matInput type="number" formControlName="chargesAmount" placeholder="0.00" />
|
||||
<span matPrefix>₹ </span>
|
||||
<mat-hint>GST ₹{{ lineGstAt($index) | number: '1.2-2' }} at {{ data.gstPercentage }}%</mat-hint>
|
||||
<mat-error *ngIf="lineCtrl.get('chargesAmount')?.hasError('min')"
|
||||
>Charges cannot be negative</mat-error
|
||||
>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</form>
|
||||
} @else {
|
||||
<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>
|
||||
@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>Walk-in Purchaser Name *</mat-label>
|
||||
<mat-label>Payable At - Branch *</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="walkinPurchaserName"
|
||||
placeholder="e.g. RAMESH CHANDRA"
|
||||
formControlName="payableAtBranch"
|
||||
placeholder="e.g. FORT BRANCH"
|
||||
maxlength="150"
|
||||
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-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>12-Digit Aadhaar Number *</mat-label>
|
||||
<mat-label>Payable At - City *</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="walkinPurchaserAadhaar"
|
||||
placeholder="12-digit Aadhaar"
|
||||
maxlength="12"
|
||||
formControlName="payableAtCity"
|
||||
placeholder="e.g. MUMBAI"
|
||||
maxlength="100"
|
||||
style="text-transform: uppercase;"
|
||||
/>
|
||||
<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-error *ngIf="form.get('payableAtCity')?.hasError('required')">Payable City is required</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>
|
||||
</form>
|
||||
}
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions class="dd-dialog-actions" align="end">
|
||||
@if (isSlip) {
|
||||
<span class="dd-actions-total">
|
||||
{{ lineCount }} drafts · <strong>₹{{ slipTotal | number: '1.2-2' }}</strong> to debit
|
||||
</span>
|
||||
}
|
||||
<button mat-button type="button" (click)="onClose()">Cancel</button>
|
||||
<button mat-flat-button color="primary" [disabled]="form.invalid" (click)="onSubmit()">
|
||||
Save DD Details
|
||||
<button mat-flat-button color="primary" [disabled]="activeForm.invalid" (click)="onSubmit()">
|
||||
{{ isSlip ? 'Save Slip Details' : 'Save DD Details' }}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
</div>
|
||||
@@ -395,6 +598,81 @@ export interface CashierIssueDdDialogData {
|
||||
line-height: 1.5;
|
||||
color: #64748b;
|
||||
}
|
||||
/* One card per draft on a slip — the boundary is what stops a clerk typing line 2's
|
||||
beneficiary against line 1's amount. */
|
||||
.dd-line-card {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-left: 3px solid #0369a1;
|
||||
border-radius: 8px;
|
||||
padding: 14px 16px 4px;
|
||||
margin-bottom: 14px;
|
||||
background: #fcfdff;
|
||||
}
|
||||
.dd-line-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.dd-line-badge {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
color: #ffffff;
|
||||
background: #0369a1;
|
||||
padding: 3px 9px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.dd-line-number {
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
color: #475569;
|
||||
}
|
||||
.dd-line-number em {
|
||||
font-style: normal;
|
||||
color: #94a3b8;
|
||||
font-size: 11px;
|
||||
}
|
||||
.dd-line-total {
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #065f46;
|
||||
background: #ecfdf5;
|
||||
border: 1px solid #a7f3d0;
|
||||
padding: 3px 10px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.dd-preview-note {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
margin: 0 0 16px;
|
||||
padding: 9px 12px;
|
||||
background: #fffbeb;
|
||||
border: 1px solid #fde68a;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: #78350f;
|
||||
}
|
||||
.dd-preview-note mat-icon {
|
||||
color: #b45309;
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dd-actions-total {
|
||||
margin-right: auto;
|
||||
font-size: 13px;
|
||||
color: #475569;
|
||||
}
|
||||
.dd-actions-total strong {
|
||||
color: #065f46;
|
||||
}
|
||||
`
|
||||
]
|
||||
})
|
||||
@@ -404,13 +682,127 @@ export class CashierIssueDdDialogComponent implements OnInit {
|
||||
public data: CashierIssueDdDialogData = inject(MAT_DIALOG_DATA);
|
||||
|
||||
form!: FormGroup;
|
||||
/** Built only in slip mode; `form` stays untouched so the single-draft path is unchanged. */
|
||||
slipForm!: 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';
|
||||
}
|
||||
|
||||
/** How many drafts this dialog is capturing — 1 unless the clerk asked for a slip. */
|
||||
get lineCount(): number {
|
||||
const n = Number(this.data.ddCount) || 1;
|
||||
return n > 1 ? n : 1;
|
||||
}
|
||||
|
||||
/** Whether this dialog is capturing a multi-DD slip rather than a single draft. */
|
||||
get isSlip(): boolean {
|
||||
return this.lineCount > 1;
|
||||
}
|
||||
|
||||
/** Whether the debit lands on a GL account rather than a member's — wording only. */
|
||||
get isGlFunded(): boolean {
|
||||
return !!this.data.glFunded;
|
||||
}
|
||||
|
||||
/** The form the Save button validates against — whichever of the two was built. */
|
||||
get activeForm(): FormGroup {
|
||||
return this.isSlip ? this.slipForm : this.form;
|
||||
}
|
||||
|
||||
get slipLines(): FormArray {
|
||||
return this.slipForm.get('lines') as FormArray;
|
||||
}
|
||||
|
||||
/** The provisional leaflet number shown against a line, if the caller supplied a run of them. */
|
||||
previewNumberAt(index: number): string | null {
|
||||
return this.data.previewDdNumbers?.[index] ?? null;
|
||||
}
|
||||
|
||||
private lineValueAt(index: number, field: string): number {
|
||||
return Number(this.slipLines.at(index)?.get(field)?.value) || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* GST on one line's charge.
|
||||
*
|
||||
* Rounded per line, matching the server, which computes and stores GST against each draft rather
|
||||
* than once for the slip. Summing rounded lines and rounding a summed charge can differ by a
|
||||
* paisa, and it is the per-line figure that ends up on the draft.
|
||||
*/
|
||||
lineGstAt(index: number): number {
|
||||
const charges = this.lineValueAt(index, 'chargesAmount');
|
||||
if (charges <= 0) return 0;
|
||||
return Math.round(charges * (Number(this.data.gstPercentage) || 0)) / 100;
|
||||
}
|
||||
|
||||
/** What one line adds to the debit — its face value, its charge and the GST on that charge. */
|
||||
lineTotalAt(index: number): number {
|
||||
return this.lineValueAt(index, 'faceValue') + this.lineValueAt(index, 'chargesAmount') + this.lineGstAt(index);
|
||||
}
|
||||
|
||||
get slipFaceValue(): number {
|
||||
return this.slipLines.controls.reduce((sum, _c, i) => sum + this.lineValueAt(i, 'faceValue'), 0);
|
||||
}
|
||||
|
||||
get slipCharges(): number {
|
||||
return this.slipLines.controls.reduce((sum, _c, i) => sum + this.lineValueAt(i, 'chargesAmount'), 0);
|
||||
}
|
||||
|
||||
get slipGst(): number {
|
||||
return this.slipLines.controls.reduce((sum, _c, i) => sum + this.lineGstAt(i), 0);
|
||||
}
|
||||
|
||||
/** The figure the account is debited for — rounded up to the whole rupee, as the single flow does. */
|
||||
get slipTotal(): number {
|
||||
return Math.ceil(this.slipFaceValue + this.slipCharges + this.slipGst - 1e-9);
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
if (this.isSlip) {
|
||||
this.slipForm = this.fb.group({
|
||||
lines: this.fb.array(
|
||||
Array.from({ length: this.lineCount }, () =>
|
||||
this.fb.group({
|
||||
beneficiaryName: [
|
||||
'',
|
||||
[
|
||||
Validators.required,
|
||||
Validators.maxLength(120)
|
||||
]
|
||||
],
|
||||
payableAtBranch: [
|
||||
'',
|
||||
[Validators.required]
|
||||
],
|
||||
payableAtCity: [
|
||||
'',
|
||||
[Validators.required]
|
||||
],
|
||||
// Zero is rejected rather than merely warned about: the server refuses a line with a
|
||||
// face value at or below zero, and a slip that fails there has already cost the clerk
|
||||
// every other line they typed.
|
||||
faceValue: [
|
||||
null as
|
||||
| number
|
||||
| null,
|
||||
[
|
||||
Validators.required,
|
||||
Validators.min(0.01)
|
||||
]
|
||||
],
|
||||
chargesAmount: [
|
||||
0,
|
||||
[Validators.min(0)]
|
||||
]
|
||||
})
|
||||
)
|
||||
)
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -477,7 +869,25 @@ export class CashierIssueDdDialogComponent implements OnInit {
|
||||
}
|
||||
|
||||
onSubmit(): void {
|
||||
if (this.form.invalid) return;
|
||||
if (this.activeForm.invalid) return;
|
||||
|
||||
// A slip closes with its lines. There is no walk-in branch here: a slip is only ever offered on
|
||||
// a member-account debit, where the purchaser is the account holder already on record.
|
||||
if (this.isSlip) {
|
||||
const lines: DdSlipLineResult[] = this.slipLines.controls.map((c) => {
|
||||
const v = c.value;
|
||||
return {
|
||||
beneficiaryName: (v.beneficiaryName || '').trim().toUpperCase(),
|
||||
payableAtBranch: (v.payableAtBranch || '').trim().toUpperCase(),
|
||||
payableAtCity: (v.payableAtCity || '').trim().toUpperCase(),
|
||||
faceValue: Number(v.faceValue) || 0,
|
||||
chargesAmount: Number(v.chargesAmount) || 0
|
||||
};
|
||||
});
|
||||
this.dialogRef.close({ lines });
|
||||
return;
|
||||
}
|
||||
|
||||
const val = this.form.value;
|
||||
const beneficiary = {
|
||||
beneficiaryName: (val.beneficiaryName || '').trim().toUpperCase(),
|
||||
|
||||
@@ -319,9 +319,13 @@
|
||||
[value]="row.amount ?? ''"
|
||||
(input)="row.amount = +$any($event.target).value; syncShareLinkedAmounts()"
|
||||
(click)="$event.stopPropagation()"
|
||||
[placeholder]="row.acTyp === 'SHR' ? '0' : '0.00'"
|
||||
[readonly]="!isClerk && !!(activeWithdrawalRequest || activeClosureRequest)"
|
||||
[class.ws-amt-input--locked]="!isClerk && !!(activeWithdrawalRequest || activeClosureRequest)"
|
||||
[placeholder]="isMultiDdRow(row) ? 'Per draft' : row.acTyp === 'SHR' ? '0' : '0.00'"
|
||||
[readonly]="
|
||||
isMultiDdRow(row) || (!isClerk && !!(activeWithdrawalRequest || activeClosureRequest))
|
||||
"
|
||||
[class.ws-amt-input--locked]="
|
||||
isMultiDdRow(row) || (!isClerk && !!(activeWithdrawalRequest || activeClosureRequest))
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
@if (row.acTyp === 'SHR' && rowSharePrices[row.acNo]) {
|
||||
@@ -331,6 +335,19 @@
|
||||
}}/share</span
|
||||
>
|
||||
}
|
||||
<!-- On a slip the face value belongs to each draft, so the row total is the sum of
|
||||
them and is filled in by the dialog rather than typed here. A clerk-typed
|
||||
figure could disagree with the drafts, and the account would be debited for
|
||||
a total the drafts do not add up to. -->
|
||||
@if (isMultiDdRow(row)) {
|
||||
<span class="ws-f__hint">
|
||||
{{
|
||||
row.ddLines?.length
|
||||
? 'Total of ' + row.ddLines?.length + ' drafts'
|
||||
: 'Set per draft in Fill DD Details'
|
||||
}}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
@if (row.acTyp === 'SHR' && (row.typ === 'Enter Bank Transfer' || row.typ === 'Fund Transfer')) {
|
||||
<div class="ws-f ws-f--cheqno">
|
||||
@@ -410,9 +427,36 @@
|
||||
<div class="ws-group ws-group--flex">
|
||||
<span class="ws-caption">DD Charges & GST — Admin Rate {{ ddGstPercentage }}%</span>
|
||||
<div class="ws-group__fields">
|
||||
<!-- How many drafts this one debit funds. Typed before the details are filled in,
|
||||
because it is what decides how many beneficiary forms the dialog presents.
|
||||
Offered only where the server can honour it: a member savings/current
|
||||
account debit, never a GL or cash draft. -->
|
||||
@if (canRowBeMultiDd(row)) {
|
||||
<div class="ws-f ws-f--amt">
|
||||
<label class="ws-f__lbl" title="Number of demand drafts to raise against this single debit"
|
||||
>NO. OF DD</label
|
||||
>
|
||||
<div class="ws-amt-wrap">
|
||||
<input
|
||||
class="ws-f__ctl ws-amt-input"
|
||||
type="number"
|
||||
min="1"
|
||||
[max]="maxDdSlipLines"
|
||||
step="1"
|
||||
[value]="ddCountFor(row)"
|
||||
(input)="onDdCountChange(row, +$any($event.target).value)"
|
||||
(click)="$event.stopPropagation()"
|
||||
placeholder="1"
|
||||
/>
|
||||
</div>
|
||||
@if (isMultiDdRow(row)) {
|
||||
<span class="ws-f__hint">One debit · {{ ddCountFor(row) }} drafts</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<div class="ws-f ws-f--amt">
|
||||
<label class="ws-f__lbl" title="DD issuance charges / commission">CHARGES</label>
|
||||
<div class="ws-amt-wrap">
|
||||
<div class="ws-amt-wrap" [class.ws-amt-wrap--locked]="isMultiDdRow(row)">
|
||||
<span class="ws-amt-prefix">₹</span>
|
||||
<input
|
||||
class="ws-f__ctl ws-amt-input"
|
||||
@@ -421,8 +465,15 @@
|
||||
(input)="row.ddCharges = +$any($event.target).value"
|
||||
(click)="$event.stopPropagation()"
|
||||
placeholder="0.00"
|
||||
[readonly]="isMultiDdRow(row)"
|
||||
[class.ws-amt-input--locked]="isMultiDdRow(row)"
|
||||
/>
|
||||
</div>
|
||||
<!-- Charges are levied per draft and can differ between them, so on a slip they
|
||||
are entered against each draft and totalled here. -->
|
||||
@if (isMultiDdRow(row)) {
|
||||
<span class="ws-f__hint">Set per draft</span>
|
||||
}
|
||||
</div>
|
||||
<div class="ws-f ws-f--amt">
|
||||
<label class="ws-f__lbl" title="GST on charges, at the admin-configured rate">
|
||||
@@ -433,7 +484,7 @@
|
||||
<input
|
||||
class="ws-f__ctl ws-amt-input ws-amt-input--locked"
|
||||
type="text"
|
||||
[value]="ddGstAmountFor(row) | number: '1.2-2'"
|
||||
[value]="ddGstDisplayFor(row)"
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
@@ -447,7 +498,7 @@
|
||||
<input
|
||||
class="ws-f__ctl ws-amt-input ws-amt-input--locked"
|
||||
type="text"
|
||||
[value]="ddTotalPayableFor(row)"
|
||||
[value]="ddTotalDisplayFor(row)"
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
@@ -460,18 +511,27 @@
|
||||
[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'
|
||||
isMultiDdRow(row)
|
||||
? 'Opens one beneficiary form per draft; DD numbers are allocated on submission'
|
||||
: 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">{{
|
||||
row.ddDetails ? 'edit_note' : 'post_add'
|
||||
row.ddDetails || row.ddLines?.length ? 'edit_note' : 'post_add'
|
||||
}}</mat-icon>
|
||||
<!-- Names the leaflet once details are captured, so the clerk can see at a
|
||||
glance which rows are ready before pressing Save. -->
|
||||
glance which rows are ready before pressing Save. A slip names the count
|
||||
instead: its numbers are not allocated until it is submitted. -->
|
||||
<span>{{
|
||||
row.ddDetails ? 'DD ' + row.ddDetails.ddNumber + ' · Edit Details' : 'Fill DD Details'
|
||||
row.ddLines?.length
|
||||
? row.ddLines?.length + ' Drafts Captured · Edit Slip'
|
||||
: row.ddDetails
|
||||
? 'DD ' + row.ddDetails.ddNumber + ' · Edit Details'
|
||||
: isMultiDdRow(row)
|
||||
? 'Fill ' + ddCountFor(row) + ' DD Details'
|
||||
: 'Fill DD Details'
|
||||
}}</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -1694,10 +1754,12 @@
|
||||
[value]="row.amount ?? ''"
|
||||
(input)="row.amount = +$any($event.target).value; syncShareLinkedAmounts()"
|
||||
(click)="$event.stopPropagation()"
|
||||
[placeholder]="row.acTyp === 'SHR' ? '0' : '0.00'"
|
||||
[readonly]="!isClerk && !!(activeWithdrawalRequest || activeClosureRequest)"
|
||||
[placeholder]="isMultiDdRow(row) ? 'Per draft' : row.acTyp === 'SHR' ? '0' : '0.00'"
|
||||
[readonly]="
|
||||
isMultiDdRow(row) || (!isClerk && !!(activeWithdrawalRequest || activeClosureRequest))
|
||||
"
|
||||
[class.ws-amt-input--locked]="
|
||||
!isClerk && !!(activeWithdrawalRequest || activeClosureRequest)
|
||||
isMultiDdRow(row) || (!isClerk && !!(activeWithdrawalRequest || activeClosureRequest))
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
@@ -1708,6 +1770,19 @@
|
||||
}}/share</span
|
||||
>
|
||||
}
|
||||
<!-- On a slip the face value belongs to each draft, so the row total is the
|
||||
sum of them and is filled in by the dialog rather than typed here. A
|
||||
clerk-typed figure could disagree with the drafts, and the account would
|
||||
be debited for a total the drafts do not add up to. -->
|
||||
@if (isMultiDdRow(row)) {
|
||||
<span class="ws-f__hint">
|
||||
{{
|
||||
row.ddLines?.length
|
||||
? 'Total of ' + row.ddLines?.length + ' drafts'
|
||||
: 'Set per draft in Fill DD Details'
|
||||
}}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
@if (
|
||||
row.acTyp === 'SHR' && (row.typ === 'Enter Bank Transfer' || row.typ === 'Fund Transfer')
|
||||
@@ -1772,9 +1847,38 @@
|
||||
<div class="ws-group ws-group--flex">
|
||||
<span class="ws-caption">DD Charges & GST — Admin Rate {{ ddGstPercentage }}%</span>
|
||||
<div class="ws-group__fields">
|
||||
<!-- How many drafts this one debit funds. Typed before the details are filled
|
||||
in, because it is what decides how many beneficiary forms the dialog
|
||||
presents. Offered only where the server can honour it: a member
|
||||
savings/current account debit, never a GL or cash draft. -->
|
||||
@if (canRowBeMultiDd(row)) {
|
||||
<div class="ws-f ws-f--amt">
|
||||
<label
|
||||
class="ws-f__lbl"
|
||||
title="Number of demand drafts to raise against this single debit"
|
||||
>NO. OF DD</label
|
||||
>
|
||||
<div class="ws-amt-wrap">
|
||||
<input
|
||||
class="ws-f__ctl ws-amt-input"
|
||||
type="number"
|
||||
min="1"
|
||||
[max]="maxDdSlipLines"
|
||||
step="1"
|
||||
[value]="ddCountFor(row)"
|
||||
(input)="onDdCountChange(row, +$any($event.target).value)"
|
||||
(click)="$event.stopPropagation()"
|
||||
placeholder="1"
|
||||
/>
|
||||
</div>
|
||||
@if (isMultiDdRow(row)) {
|
||||
<span class="ws-f__hint">One debit · {{ ddCountFor(row) }} drafts</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<div class="ws-f ws-f--amt">
|
||||
<label class="ws-f__lbl" title="DD issuance charges / commission">CHARGES</label>
|
||||
<div class="ws-amt-wrap">
|
||||
<div class="ws-amt-wrap" [class.ws-amt-wrap--locked]="isMultiDdRow(row)">
|
||||
<span class="ws-amt-prefix">₹</span>
|
||||
<input
|
||||
class="ws-f__ctl ws-amt-input"
|
||||
@@ -1783,8 +1887,15 @@
|
||||
(input)="row.ddCharges = +$any($event.target).value"
|
||||
(click)="$event.stopPropagation()"
|
||||
placeholder="0.00"
|
||||
[readonly]="isMultiDdRow(row)"
|
||||
[class.ws-amt-input--locked]="isMultiDdRow(row)"
|
||||
/>
|
||||
</div>
|
||||
<!-- Charges are levied per draft and can differ between them, so on a slip
|
||||
they are entered against each draft and totalled here. -->
|
||||
@if (isMultiDdRow(row)) {
|
||||
<span class="ws-f__hint">Set per draft</span>
|
||||
}
|
||||
</div>
|
||||
<div class="ws-f ws-f--amt">
|
||||
<label class="ws-f__lbl" title="GST on charges, at the admin-configured rate">
|
||||
@@ -1795,7 +1906,7 @@
|
||||
<input
|
||||
class="ws-f__ctl ws-amt-input ws-amt-input--locked"
|
||||
type="text"
|
||||
[value]="ddGstAmountFor(row) | number: '1.2-2'"
|
||||
[value]="ddGstDisplayFor(row)"
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
@@ -1809,7 +1920,7 @@
|
||||
<input
|
||||
class="ws-f__ctl ws-amt-input ws-amt-input--locked"
|
||||
type="text"
|
||||
[value]="ddTotalPayableFor(row)"
|
||||
[value]="ddTotalDisplayFor(row)"
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
@@ -1822,13 +1933,28 @@
|
||||
[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'
|
||||
isMultiDdRow(row)
|
||||
? 'Opens one beneficiary form per draft; DD numbers are allocated on submission'
|
||||
: 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</span>
|
||||
<mat-icon style="font-size: 18px; width: 18px; height: 18px">{{
|
||||
row.ddDetails || row.ddLines?.length ? 'edit_note' : 'post_add'
|
||||
}}</mat-icon>
|
||||
<!-- Names what has been captured, so the clerk can see at a glance which
|
||||
rows are ready before pressing Save. A slip names its count instead:
|
||||
its numbers are not allocated until it is submitted. -->
|
||||
<span>{{
|
||||
row.ddLines?.length
|
||||
? row.ddLines?.length + ' Drafts Captured · Edit Slip'
|
||||
: row.ddDetails
|
||||
? 'DD ' + row.ddDetails.ddNumber + ' · Edit Details'
|
||||
: isMultiDdRow(row)
|
||||
? 'Fill ' + ddCountFor(row) + ' DD Details'
|
||||
: 'Fill DD Details'
|
||||
}}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -91,6 +91,40 @@ interface TxnRow {
|
||||
fundingAccountId?: number;
|
||||
fundingClientId?: number;
|
||||
};
|
||||
/**
|
||||
* How many drafts this one row raises. 1 (or unset) is the ordinary single draft; 2 or more make
|
||||
* the row a multi-DD slip — one debit funding several drafts, each to its own beneficiary.
|
||||
*
|
||||
* Typed by the clerk BEFORE opening the dialog, so the dialog knows how many beneficiary forms to
|
||||
* present. Only a member-account debit can be a slip (see canRowBeMultiDd()).
|
||||
*/
|
||||
ddCount?: number | null;
|
||||
/**
|
||||
* The per-draft lines of a multi-DD slip, captured in the dialog and held here until Save — the
|
||||
* same "fill now, commit on Save" contract as ddDetails above.
|
||||
*
|
||||
* Each line carries its OWN face value and charges: a slip's drafts are routinely for different
|
||||
* amounts to different beneficiaries, and the charge is per draft rather than per slip. That is
|
||||
* why the row's own AMOUNT and CHARGES fields are locked and derived from these lines while a
|
||||
* slip is being built — a clerk-typed row total could disagree with the lines, and the account
|
||||
* would then be debited for a figure the drafts don't add up to.
|
||||
*/
|
||||
ddLines?: DdSlipLine[];
|
||||
/** The inventory the slip's leaflets are drawn from. The batch endpoint allocates by inventory
|
||||
* rather than by leaflet, since the numbers are only picked inside its own locked transaction. */
|
||||
ddInventoryId?: number;
|
||||
/** Resolved funding account for a slip, captured alongside the lines for the same reason. */
|
||||
ddFundingAccountId?: number;
|
||||
ddFundingClientId?: number;
|
||||
}
|
||||
|
||||
/** One draft on a multi-DD slip — its own beneficiary, its own face value, its own charges. */
|
||||
interface DdSlipLine {
|
||||
beneficiaryName: string;
|
||||
payableAtBranch: string;
|
||||
payableAtCity: string;
|
||||
faceValue: number;
|
||||
chargesAmount: number;
|
||||
}
|
||||
|
||||
interface Denomination {
|
||||
@@ -2415,19 +2449,159 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
*/
|
||||
entryTypeOptions: { id: number; name: string }[] = [];
|
||||
|
||||
/** GST on DD charges only (never on the DD face value), at the admin-configured rate. */
|
||||
/**
|
||||
* Whether this row may raise more than one draft off a single debit.
|
||||
*
|
||||
* Any funding source the clerk debits: a member's savings/current account, or a GL account for an
|
||||
* institutional slip. Cash is the exception the batch endpoint still refuses, and for a real
|
||||
* reason rather than an arbitrary one — a slip is funded by one payment, and one counted pile of
|
||||
* notes cannot be apportioned across drafts without the denomination total ceasing to prove what
|
||||
* was actually taken at the counter.
|
||||
*/
|
||||
canRowBeMultiDd(row: TxnRow): boolean {
|
||||
return this.isDdAccountDebitRow(row);
|
||||
}
|
||||
|
||||
/** Whether the clerk has asked this row for more than one draft. */
|
||||
isMultiDdRow(row: TxnRow): boolean {
|
||||
return this.canRowBeMultiDd(row) && (Number(row.ddCount) || 1) > 1;
|
||||
}
|
||||
|
||||
/** How many drafts this row raises — always at least 1. */
|
||||
ddCountFor(row: TxnRow): number {
|
||||
const n = Number(row.ddCount) || 1;
|
||||
return n > 1 ? n : 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clamps a typed DD count into range and discards lines that no longer fit.
|
||||
*
|
||||
* Lowering the count after the dialog has been filled would otherwise leave lines beyond the new
|
||||
* count still on the row: invisible to the clerk, but still sent at Save and still debited for.
|
||||
*/
|
||||
onDdCountChange(row: TxnRow, value: number): void {
|
||||
const n = Math.floor(Number(value) || 1);
|
||||
const previous = this.ddCountFor(row);
|
||||
row.ddCount = n < 1 ? 1 : n > this.maxDdSlipLines ? this.maxDdSlipLines : n;
|
||||
if (row.ddLines && row.ddLines.length !== row.ddCount) {
|
||||
this.clearDdCapture(row);
|
||||
}
|
||||
// Moving onto a slip empties the row's own amount and charge, because on a slip they mean
|
||||
// nothing: each draft carries its own face value and its own charge, entered in the dialog, and
|
||||
// these two fields become the total of those. Leaving a previously typed figure sitting here
|
||||
// would read as the slip's total while describing only one draft — which is exactly what showed
|
||||
// ₹1,012 against four drafts of ₹1,000.
|
||||
if (row.ddCount > 1 && previous <= 1) {
|
||||
row.amount = null;
|
||||
row.ddCharges = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Mirrors MAX_SLIP_LINES on the server, so an over-long slip is refused here with a clear message. */
|
||||
readonly maxDdSlipLines = 20;
|
||||
|
||||
/**
|
||||
* The run of leaflet numbers a slip will probably draw, for display only.
|
||||
*
|
||||
* Counts up from the next-available number, preserving its width so 000123 reads as 000124 rather
|
||||
* than 124. Indicative by nature — the binding allocation happens inside the server's submit
|
||||
* transaction, and a slip filed after another clerk's gets the numbers following theirs — which is
|
||||
* why the dialog labels these provisional and the confirmed numbers come back on submission.
|
||||
*
|
||||
* A number that is not a plain run of digits (a prefixed or hyphenated series) cannot be counted
|
||||
* on safely, so nothing is shown rather than something wrong.
|
||||
*/
|
||||
private previewDdNumbersFrom(firstDdNumber: string, count: number): string[] | undefined {
|
||||
const first = (firstDdNumber || '').trim();
|
||||
if (!/^\d+$/.test(first)) return undefined;
|
||||
const width = first.length;
|
||||
const start = Number(first);
|
||||
if (!Number.isSafeInteger(start)) return undefined;
|
||||
return Array.from({ length: count }, (_v, i) => String(start + i).padStart(width, '0'));
|
||||
}
|
||||
|
||||
/** GST on a single charge figure, at the admin-configured rate. */
|
||||
private ddGstOn(charges: number): number {
|
||||
const c = Number(charges) || 0;
|
||||
if (c <= 0) return 0;
|
||||
return Math.round(c * this.ddGstPercentage) / 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* GST on DD charges only (never on the DD face value), at the admin-configured rate.
|
||||
*
|
||||
* On a slip this is the sum of each line's own GST rather than GST on the summed charges. The two
|
||||
* can differ by a paisa once rounding is applied per line, and it is the per-line figure that is
|
||||
* stored against each draft and posted by the server — so the row must show that same total.
|
||||
*/
|
||||
ddGstAmountFor(row: TxnRow): number {
|
||||
const charges = Number(row.ddCharges) || 0;
|
||||
if (charges <= 0) return 0;
|
||||
return Math.round(charges * this.ddGstPercentage) / 100;
|
||||
if (row.ddLines?.length) {
|
||||
return row.ddLines.reduce((sum, l) => sum + this.ddGstOn(l.chargesAmount), 0);
|
||||
}
|
||||
return this.ddGstOn(row.ddCharges ?? 0);
|
||||
}
|
||||
|
||||
/** Face value of the row — the sum of every line once a slip has been captured. */
|
||||
ddFaceValueFor(row: TxnRow): number {
|
||||
if (row.ddLines?.length) {
|
||||
return row.ddLines.reduce((sum, l) => sum + (Number(l.faceValue) || 0), 0);
|
||||
}
|
||||
return Number(row.amount) || 0;
|
||||
}
|
||||
|
||||
/** Charges on the row — the sum of every line's own charge once a slip has been captured. */
|
||||
ddChargesFor(row: TxnRow): number {
|
||||
if (row.ddLines?.length) {
|
||||
return row.ddLines.reduce((sum, l) => sum + (Number(l.chargesAmount) || 0), 0);
|
||||
}
|
||||
return Number(row.ddCharges) || 0;
|
||||
}
|
||||
|
||||
/** Amount + Charges + GST, rounded UP to the next whole rupee — the total collected from the customer. */
|
||||
ddTotalPayableFor(row: TxnRow): number {
|
||||
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);
|
||||
return Math.ceil(this.ddFaceValueFor(row) + this.ddChargesFor(row) + this.ddGstAmountFor(row) - 1e-9);
|
||||
}
|
||||
|
||||
/**
|
||||
* What the TOTAL field shows — a figure, or a dash while a slip has nothing to total yet.
|
||||
*
|
||||
* A slip whose drafts have not been entered has no total: its face values and charges live on the
|
||||
* lines, and there are no lines. Showing ₹0 there would read as a real total of zero rather than
|
||||
* as "not yet known", so the field says so instead.
|
||||
*/
|
||||
ddTotalDisplayFor(row: TxnRow): string {
|
||||
if (this.isSlipAwaitingDetails(row)) {
|
||||
return '—';
|
||||
}
|
||||
return String(this.ddTotalPayableFor(row));
|
||||
}
|
||||
|
||||
/** GST field's text, dashed for the same reason as the total while a slip is still empty. */
|
||||
ddGstDisplayFor(row: TxnRow): string {
|
||||
if (this.isSlipAwaitingDetails(row)) {
|
||||
return '—';
|
||||
}
|
||||
return this.ddGstAmountFor(row).toFixed(2);
|
||||
}
|
||||
|
||||
/** A slip the clerk has asked for but not yet filled in — nothing to total from. */
|
||||
isSlipAwaitingDetails(row: TxnRow): boolean {
|
||||
return this.isMultiDdRow(row) && !row.ddLines?.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Forgets a row's captured draft details, leaving the row itself intact.
|
||||
*
|
||||
* Both shapes are cleared together on purpose: a row carrying a stale ddDetails alongside fresh
|
||||
* ddLines (or the reverse) would be read by whichever of the two Save paths it reached first, and
|
||||
* the clerk would have no way of seeing which one went.
|
||||
*/
|
||||
private clearDdCapture(row: TxnRow): void {
|
||||
row.ddDetails = undefined;
|
||||
row.ddLines = undefined;
|
||||
row.ddInventoryId = undefined;
|
||||
row.ddFundingAccountId = undefined;
|
||||
row.ddFundingClientId = undefined;
|
||||
}
|
||||
|
||||
get isSelectedRowDdCashGl(): boolean {
|
||||
@@ -2468,11 +2642,22 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
]);
|
||||
return;
|
||||
}
|
||||
// On a slip the face value lives on each line, typed in the dialog that is about to open — the
|
||||
// row's own AMOUNT is the sum of those lines and cannot exist yet. Demanding it here would make
|
||||
// a slip impossible to start.
|
||||
const isSlip = this.isMultiDdRow(row);
|
||||
const faceValue = Number(row.amount);
|
||||
if (!faceValue || faceValue <= 0) {
|
||||
if (!isSlip && (!faceValue || faceValue <= 0)) {
|
||||
this.showAlert('error', 'Invalid Amount', ['Please enter the DD Face Value amount before proceeding.']);
|
||||
return;
|
||||
}
|
||||
if (isSlip && !this.isDdAccountDebitRow(row)) {
|
||||
this.showAlert('error', 'Multiple DDs Not Available', [
|
||||
'More than one draft can only be raised against a member savings or current account.',
|
||||
'Set NO. OF DD back to 1 for this row.'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
const chargesAmount = Number(row.ddCharges) || 0;
|
||||
const gstAmount = this.ddGstAmountFor(row);
|
||||
const totalCashPayable = this.ddTotalPayableFor(row);
|
||||
@@ -2508,8 +2693,14 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// denomination count left over from an earlier row must not be checked against it. A slip is
|
||||
// always an account debit, and its total is not known until the dialog is filled in either.
|
||||
if (
|
||||
!accountDebit &&
|
||||
!isSlip &&
|
||||
this.denominationTotal > 0 &&
|
||||
Math.abs(this.denominationTotal - totalCashPayable) > 0.005
|
||||
) {
|
||||
this.showAlert('error', 'Denomination Mismatch', [
|
||||
`Cash denomination counted (₹${this.denominationTotal}) does not match total payable cash (₹${totalCashPayable}).`,
|
||||
'Please verify and match the physical cash denomination count.'
|
||||
@@ -2532,8 +2723,19 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
const selectedBank = this.sponsorBanks.find((b: any) => b.id === row.sponsorBankId);
|
||||
const branchName = this.branchNameFor(row.br) || 'Branch ' + row.br;
|
||||
|
||||
// A slip is allocated by inventory, not leaflet: the server picks the whole run inside its
|
||||
// own locked transaction. Without the inventory behind this branch's next-available leaflet
|
||||
// there is nothing to allocate from, so the slip is refused here rather than at submit.
|
||||
if (isSlip && !leaflet.inventoryId) {
|
||||
this.showAlert('error', 'DD Inventory Not Identified', [
|
||||
'The DD inventory behind this branch and sponsor bank could not be identified, so multiple drafts cannot be allocated together.',
|
||||
'Set NO. OF DD back to 1, or ask an administrator to check the DD inventory registration.'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
const dialogRef = this.dialog.open(CashierIssueDdDialogComponent, {
|
||||
width: '600px',
|
||||
width: isSlip ? '760px' : '600px',
|
||||
disableClose: true,
|
||||
data: {
|
||||
leaflet,
|
||||
@@ -2546,7 +2748,10 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
totalCashPayable,
|
||||
paymentMethod: accountDebit ? ('ACCOUNT_DEBIT' as const) : ('CASH' as const),
|
||||
accountNo: accountDebit ? row.acNo : undefined,
|
||||
accountHolderName: accountDebit ? this.rowAccountNames[(row.acNo || '').trim()] : undefined
|
||||
accountHolderName: accountDebit ? this.rowAccountNames[(row.acNo || '').trim()] : undefined,
|
||||
ddCount: isSlip ? this.ddCountFor(row) : undefined,
|
||||
glFunded: isGlDebit,
|
||||
previewDdNumbers: isSlip ? this.previewDdNumbersFrom(leaflet.ddNumber, this.ddCountFor(row)) : undefined
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2555,6 +2760,31 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
// review the entered details, and no way to abandon them either.
|
||||
dialogRef.afterClosed().subscribe((result) => {
|
||||
if (!result) return;
|
||||
|
||||
// A slip records its lines instead of a single beneficiary, and takes the row's AMOUNT and
|
||||
// CHARGES with it: from here on those two are the sum of the lines, shown read-only, so a
|
||||
// clerk cannot leave a row total behind that disagrees with what the drafts add up to.
|
||||
if (result.lines) {
|
||||
this.clearDdCapture(row);
|
||||
row.ddLines = result.lines;
|
||||
row.ddInventoryId = leaflet.inventoryId;
|
||||
row.ddFundingAccountId = fundingAccountId!;
|
||||
// Null on a GL slip, which is raised against no member at all.
|
||||
row.ddFundingClientId = fundingClientId ?? undefined;
|
||||
row.amount = this.ddFaceValueFor(row);
|
||||
row.ddCharges = this.ddChargesFor(row);
|
||||
this.showAlert('success', 'Slip Details Captured', [
|
||||
`${result.lines.length} demand drafts captured in favour of ${result.lines
|
||||
.map((l: DdSlipLine) => l.beneficiaryName)
|
||||
.join(', ')}.`,
|
||||
`₹${this.ddTotalPayableFor(row)} will be debited from ${isGlDebit ? 'GL account ' : ''}${row.acNo} as a single ${
|
||||
isGlDebit ? 'journal entry' : 'transaction'
|
||||
} once a checker approves.`,
|
||||
'DD numbers are allocated on submission. Press Save to send this slip for approval.'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
row.ddDetails = {
|
||||
leafletId: leaflet.id,
|
||||
ddNumber: leaflet.ddNumber,
|
||||
@@ -2658,7 +2888,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
* got none has no way of telling which row was missing.
|
||||
*/
|
||||
private saveDdRows(ddRows: TxnRow[]): void {
|
||||
const unfilled = ddRows.filter((r) => !r.ddDetails);
|
||||
const unfilled = ddRows.filter((r) => !r.ddDetails && !r.ddLines?.length);
|
||||
if (unfilled.length > 0) {
|
||||
this.showAlert('error', 'DD Details Not Entered', [
|
||||
unfilled.length === 1
|
||||
@@ -2670,6 +2900,14 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
for (const row of ddRows) {
|
||||
// A slip goes to the batch endpoint, which allocates its leaflets and posts one debit for the
|
||||
// whole run. Sending its lines one at a time through the single-DD endpoint would debit the
|
||||
// member once per draft — the very thing a slip exists to avoid.
|
||||
if (row.ddLines?.length) {
|
||||
this.submitDdSlip(row);
|
||||
continue;
|
||||
}
|
||||
|
||||
const details = row.ddDetails!;
|
||||
const leaflet = { id: details.leafletId, ddNumber: details.ddNumber };
|
||||
if (this.isDdAccountDebitRow(row)) {
|
||||
@@ -2697,6 +2935,99 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Submits a multi-DD slip: several drafts raised together against one debit of the member's
|
||||
* account.
|
||||
*
|
||||
* Maker-checker like the single-draft flow, and nothing is debited here. On approval the server
|
||||
* withdraws the combined face value, charges and GST of every line as ONE transaction and posts
|
||||
* one journal entry against it, then issues each draft — so the member's passbook carries a single
|
||||
* entry naming all the DD numbers rather than one entry per draft. The checker approves or rejects
|
||||
* the slip whole; there is no approving four drafts out of five.
|
||||
*
|
||||
* The leaflets are not chosen here. The server allocates the run inside its own locked transaction
|
||||
* and returns which number went to which line, which is the only point at which the numbers are
|
||||
* real — two clerks submitting at the same moment can therefore never be handed the same one.
|
||||
*/
|
||||
private submitDdSlip(row: TxnRow): void {
|
||||
const lines = row.ddLines ?? [];
|
||||
const inventoryId = row.ddInventoryId;
|
||||
const fundingAccountId = row.ddFundingAccountId;
|
||||
const fundingClientId = row.ddFundingClientId;
|
||||
|
||||
const isGl = row.acTyp === 'GL';
|
||||
|
||||
// Re-checked at Save, not just when the details were captured: the row's TYPE, AC.TP or account
|
||||
// number can all be edited afterwards, and a slip that has drifted onto a cash row is one the
|
||||
// batch endpoint refuses outright. A GL slip is raised against no member, so it carries no
|
||||
// client id and none is required of it — the same asymmetry as the single-draft GL path.
|
||||
if (!inventoryId || !fundingAccountId || (!isGl && !fundingClientId) || !this.canRowBeMultiDd(row)) {
|
||||
this.showAlert('error', 'Slip Cannot Be Submitted', [
|
||||
'This row has changed since its drafts were captured, so the slip no longer matches it.',
|
||||
'Open "Fill DD Details" again and re-enter the drafts.'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
const d = 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';
|
||||
|
||||
// The funding source goes in exactly one of savingsAccountId, currentAccountId or glAccountId,
|
||||
// as the single-draft path does: which one is set is what decides the debit leg on approval.
|
||||
const payload = {
|
||||
clientId: isGl ? undefined : fundingClientId,
|
||||
issuingBranchId: row.br,
|
||||
issuedDate,
|
||||
paymentMethod: (isGl ? 'GL' : 'ACCOUNT_DEBIT') as 'GL' | 'ACCOUNT_DEBIT',
|
||||
savingsAccountId: isGl || isCurrent ? undefined : fundingAccountId,
|
||||
currentAccountId: !isGl && isCurrent ? fundingAccountId : undefined,
|
||||
glAccountId: isGl ? fundingAccountId : undefined,
|
||||
lines: lines.map((l) => ({
|
||||
beneficiaryName: l.beneficiaryName,
|
||||
payableAtBranch: l.payableAtBranch,
|
||||
payableAtCity: l.payableAtCity,
|
||||
faceValue: Number(l.faceValue),
|
||||
chargesAmount: Number(l.chargesAmount) || 0
|
||||
}))
|
||||
};
|
||||
|
||||
const totalDebited = this.ddTotalPayableFor(row);
|
||||
const accountNo = row.acNo;
|
||||
|
||||
this.saving = true;
|
||||
this.sponsorDDService.submitIssueRequestBatch(inventoryId, payload).subscribe({
|
||||
next: (result: any) => {
|
||||
this.saving = false;
|
||||
// The allocated numbers, named back to the clerk. Until this response they were provisional,
|
||||
// and these are what the customer's drafts will actually carry.
|
||||
const allocated = (result?.lines ?? [])
|
||||
.map((l: any) => `${l.ddNumber} · ${l.beneficiaryName} · ₹${l.faceValue}`)
|
||||
.join('\n');
|
||||
this.showAlert('success', 'DD Slip Submitted', [
|
||||
`Slip of ${lines.length} demand drafts submitted successfully.`,
|
||||
allocated || `${lines.length} drafts allocated.`,
|
||||
`₹${totalDebited} will be debited from ${accountNo} as a single transaction on approval.`,
|
||||
'Sent to Manager / Head Cashier for maker-checker approval of the whole slip.'
|
||||
]);
|
||||
row.amount = null;
|
||||
row.ddCharges = null;
|
||||
row.description = '';
|
||||
row.ddCount = 1;
|
||||
// Cleared with the row, as the single-draft paths do: leaving the lines behind would let a
|
||||
// second Save raise the same slip again against leaflets already allocated.
|
||||
this.clearDdCapture(row);
|
||||
},
|
||||
error: (err) => {
|
||||
this.saving = false;
|
||||
this.showAlert('error', 'DD Slip Submission Failed', [
|
||||
err.error?.message || err.message,
|
||||
'No drafts were issued and nothing has been debited — the whole slip was refused together.'
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private submitWalkinCashDd(row: TxnRow, leaflet: any, purchaserData: any): void {
|
||||
// Read through countFor, which knows the map is keyed by kind:value. Indexing by d.id looked
|
||||
// right but matched nothing, so every cash DD went in with no breakdown at all and its money
|
||||
|
||||
@@ -982,9 +982,16 @@ export class FiuComponent implements OnInit {
|
||||
get liveStrFiled(): number {
|
||||
return this.amlStrCases.filter((c) => c.status === 'FILED').length;
|
||||
}
|
||||
/** Still needs PO action: untouched, or concluded suspicious but not yet filed with FIU-IND. */
|
||||
/**
|
||||
* Still needs PO action: anything neither filed with FIU-IND nor concluded clear.
|
||||
*
|
||||
* Counted by what is unfinished rather than by naming the statuses that are. Adding up OPEN and CONCLUDED_SUSPICIOUS
|
||||
* gives the same figure today, but it is the shape that fails quietly: a case in any status those two do not name
|
||||
* drops out of the KPI while still sitting in the queue below it, and the dashboard then under-reports the work.
|
||||
* That is exactly how five undecided alerts hid behind a pending count of 1 on the flagged-transactions screen.
|
||||
*/
|
||||
get liveStrPending(): number {
|
||||
return this.liveStrOpen + this.liveStrAwaitingFiling;
|
||||
return this.amlStrCases.filter((c) => c?.status !== 'FILED' && c?.status !== 'CONCLUDED_CLEAR').length;
|
||||
}
|
||||
get totalCustomers(): number {
|
||||
return this.kc1.length + this.kc2.length;
|
||||
|
||||
+63
-5
@@ -21,14 +21,31 @@
|
||||
<!-- The outcome, product by product. Which products were skipped and why matters as much as
|
||||
which succeeded, so both are shown rather than just a success count. -->
|
||||
<div class="pl-card dd-panel">
|
||||
<!-- A run that skipped a product is not a clean success: those members get nothing, and saying
|
||||
only "declared" is how that goes unnoticed. The heading states it either way. -->
|
||||
<div class="dd-result-head">
|
||||
<fa-icon icon="check-circle" class="dd-result-ic"></fa-icon>
|
||||
<fa-icon
|
||||
[icon]="hasSkipped ? 'exclamation-triangle' : 'check-circle'"
|
||||
class="dd-result-ic"
|
||||
[class.dd-result-ic--warn]="hasSkipped"
|
||||
></fa-icon>
|
||||
<div>
|
||||
<h3>Dividend declared for {{ result.financialYear }}</h3>
|
||||
<h3>
|
||||
@if (hasSkipped) {
|
||||
Declared for {{ result.financialYear }} — {{ result.skipped.length }}
|
||||
{{ result.skipped.length === 1 ? 'product was' : 'products were' }} skipped
|
||||
} @else {
|
||||
Dividend declared for {{ result.financialYear }}
|
||||
}
|
||||
</h3>
|
||||
<p>
|
||||
{{ result.declarationsCreated }}
|
||||
{{ result.declarationsCreated === 1 ? 'declaration' : 'declarations' }} created. Each still needs approval
|
||||
before members can claim.
|
||||
@if (hasSkipped) {
|
||||
Members of the skipped {{ result.skipped.length === 1 ? 'product' : 'products' }} below will receive
|
||||
nothing for this year — check the reason and declare separately if that is not intended.
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -128,7 +145,22 @@
|
||||
<span class="dd-amount">− {{ preview.totalAmount | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
|
||||
@if (isUnderfunded) {
|
||||
@if (exceedsMaximumRate) {
|
||||
<!-- Checked before the year and funding notes: a rate over the cap is not payable at
|
||||
all, so what it would cost is beside the point until it is lowered. -->
|
||||
<div class="dd-note dd-note--error">
|
||||
{{ declareForm.value.ratePercentage }}% is above the society's maximum of
|
||||
{{ preview.maximumRatePercentage }}%. Lower the rate, or raise the limit in Configurations if the board
|
||||
has resolved a higher one.
|
||||
</div>
|
||||
} @else if (isPeriodNotFinished) {
|
||||
<!-- Shown ahead of the funding check: a year still running cannot be declared at any
|
||||
rate, so the shortfall below it would be advice on a choice that is not available. -->
|
||||
<div class="dd-note dd-note--error">
|
||||
{{ preview.financialYear }} has not finished yet, so its profit is not final. A dividend can only be
|
||||
declared once the financial year is over.
|
||||
</div>
|
||||
} @else if (isUnderfunded) {
|
||||
<div class="dd-note dd-note--error">
|
||||
Short by {{ preview.fundingShortfall | number: '1.2-2' }}. There is not enough in
|
||||
{{ preview.profitGlCode }} to fund this dividend — lower the rate, or close the year's profit first.
|
||||
@@ -146,16 +178,42 @@
|
||||
{{ declareForm.value.ratePercentage || 0 }}% on each member’s share investment
|
||||
</div>
|
||||
|
||||
<!-- The member count sits beside each amount because an amount alone does not say who it
|
||||
reaches. A product paying a plausible total to one member is the case worth catching
|
||||
here, while the rate can still be changed. -->
|
||||
@for (product of eligibleProducts; track product.productId) {
|
||||
<div class="dd-preview-row">
|
||||
<span>{{ product.productName }}</span>
|
||||
<span>
|
||||
{{ product.productName }}
|
||||
<span class="dd-members">
|
||||
{{ product.memberCount }} {{ product.memberCount === 1 ? 'member' : 'members' }}
|
||||
@if (product.accountCount !== product.memberCount) {
|
||||
· {{ product.accountCount }} accounts
|
||||
}
|
||||
</span>
|
||||
</span>
|
||||
<span class="dd-amount">{{ product.amount | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (eligibleProducts.length > 0) {
|
||||
<div class="dd-preview-row dd-preview-row--total">
|
||||
<span>Total to declare</span>
|
||||
<span>
|
||||
Total to declare
|
||||
@if (preview.totalMemberCount) {
|
||||
<!-- Members are distinct people, accounts are what actually gets paid: someone
|
||||
holding shares in two products is one member with two entitlements, each
|
||||
claimed on its own draft. Both are shown so the difference reads as the
|
||||
split it is rather than a miscount. -->
|
||||
<span class="dd-members">
|
||||
{{ preview.totalMemberCount }}
|
||||
{{ preview.totalMemberCount === 1 ? 'member' : 'members' }}
|
||||
@if (preview.totalAccountCount !== preview.totalMemberCount) {
|
||||
· {{ preview.totalAccountCount }} accounts
|
||||
}
|
||||
</span>
|
||||
}
|
||||
</span>
|
||||
<span class="dd-amount">{{ payableTotal | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
+13
@@ -148,6 +148,14 @@ form {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
// Who a product's amount reaches, set quietly beside the product name so it reads as a
|
||||
// qualifier on the amount rather than competing with it.
|
||||
.dd-members {
|
||||
margin-left: 8px;
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.dd-note {
|
||||
margin-top: 12px;
|
||||
padding: 10px 14px;
|
||||
@@ -194,6 +202,11 @@ form {
|
||||
font-size: 26px;
|
||||
color: #16a34a;
|
||||
margin-top: 2px;
|
||||
|
||||
// A run that left a product out is amber, not green: it succeeded, but not for everyone.
|
||||
&--warn {
|
||||
color: #d97706;
|
||||
}
|
||||
}
|
||||
|
||||
.dd-table {
|
||||
|
||||
+47
-4
@@ -73,6 +73,17 @@ export class DeclareDividendComponent implements OnInit {
|
||||
/** Set once the declaration has gone through, so the outcome per product can be shown. */
|
||||
result: any = null;
|
||||
|
||||
/**
|
||||
* Whether any product was left out of the declaration that just ran.
|
||||
*
|
||||
* Drives the outcome heading. A run that skipped a product still returns success — one product
|
||||
* being ineligible is not a reason to lose the others — but those members receive nothing for the
|
||||
* year, so the screen has to say so rather than report a plain "declared".
|
||||
*/
|
||||
get hasSkipped(): boolean {
|
||||
return (this.result?.skipped?.length ?? 0) > 0;
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.declareForm = this.formBuilder.group({
|
||||
financialYear: [
|
||||
@@ -96,9 +107,13 @@ export class DeclareDividendComponent implements OnInit {
|
||||
/**
|
||||
* The financial years a dividend can be declared for: the current one and the five before it.
|
||||
*
|
||||
* The current year is included because a society may declare an interim dividend part-way through,
|
||||
* and five back covers a declaration delayed by an AGM that ran late. Built from the business date
|
||||
* rather than the browser's clock, so the list matches the year the branch is operating in.
|
||||
* The year in progress is listed but cannot be declared — the server rejects it, and the screen
|
||||
* says so as soon as it is picked. It stays on the list so an officer looking for it finds it with
|
||||
* the reason attached, rather than wondering why the year they are in is missing. Five back covers
|
||||
* a declaration delayed by an AGM that ran late.
|
||||
*
|
||||
* Built from the business date rather than the browser's clock, so the list matches the year the
|
||||
* branch is operating in.
|
||||
*/
|
||||
private buildFinancialYears(): void {
|
||||
const businessDate: Date = this.settingsService.businessDate ?? new Date();
|
||||
@@ -171,8 +186,36 @@ export class DeclareDividendComponent implements OnInit {
|
||||
return !!this.preview?.exceedsDistributableProfit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the chosen year is still running.
|
||||
*
|
||||
* A dividend is a share of the year's profit, so it cannot be declared until the year is over and
|
||||
* that profit is final. The server enforces this; the flag simply lets the screen say so before the
|
||||
* officer commits to a rate.
|
||||
*/
|
||||
get isPeriodNotFinished(): boolean {
|
||||
return !!this.preview?.periodNotFinished;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the entered rate is above the society's configured maximum.
|
||||
*
|
||||
* The bye-laws cap what may be paid in a year, so a rate above it needs a board resolution rather
|
||||
* than a typed figure. The server enforces the same limit.
|
||||
*/
|
||||
get exceedsMaximumRate(): boolean {
|
||||
return !!this.preview?.exceedsMaximumRate;
|
||||
}
|
||||
|
||||
get canSubmit(): boolean {
|
||||
return this.declareForm.valid && !this.submitting && this.eligibleProducts.length > 0 && !this.isUnderfunded;
|
||||
return (
|
||||
this.declareForm.valid &&
|
||||
!this.submitting &&
|
||||
this.eligibleProducts.length > 0 &&
|
||||
!this.isUnderfunded &&
|
||||
!this.isPeriodNotFinished &&
|
||||
!this.exceedsMaximumRate
|
||||
);
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
|
||||
+22
-3
@@ -767,7 +767,7 @@ export class AmlFlaggedTransactionsComponent implements OnInit, AfterViewInit, O
|
||||
// The day narrows everything else: the status chips and the severity tiles are asking "of what is in view", and
|
||||
// an officer who has picked a day expects their counts to describe that day rather than the whole feed.
|
||||
const byDay = this.allAlerts.filter((a) => this.inActivePeriod(a));
|
||||
const byStatus = this.activeStatus ? byDay.filter((a) => a.status === this.activeStatus) : byDay;
|
||||
const byStatus = this.activeStatus ? byDay.filter((a) => this.matchesStatus(a, this.activeStatus)) : byDay;
|
||||
const bySeverity =
|
||||
this.activeSeverity === null ? byStatus : byStatus.filter((a) => this.severityBand(a) === this.activeSeverity);
|
||||
// Ranked last, over what the other filters left: "top 10 this month" is the ten busiest rules of that month, not
|
||||
@@ -914,7 +914,7 @@ export class AmlFlaggedTransactionsComponent implements OnInit, AfterViewInit, O
|
||||
|
||||
get summaryTotal(): number {
|
||||
const pool = this.allAlerts.filter((a) => this.inActivePeriod(a));
|
||||
return this.activeStatus ? pool.filter((a) => a.status === this.activeStatus).length : pool.length;
|
||||
return this.activeStatus ? pool.filter((a) => this.matchesStatus(a, this.activeStatus)).length : pool.length;
|
||||
}
|
||||
|
||||
private recomputeStatusCounts(): void {
|
||||
@@ -924,11 +924,30 @@ export class AmlFlaggedTransactionsComponent implements OnInit, AfterViewInit, O
|
||||
this.statusCounts = { '': pool.length };
|
||||
for (const s of this.statusFilters) {
|
||||
if (s.key) {
|
||||
this.statusCounts[s.key] = pool.filter((a) => a.status === s.key).length;
|
||||
this.statusCounts[s.key] = pool.filter((a) => this.matchesStatus(a, s.key)).length;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an alert belongs under a status chip.
|
||||
*
|
||||
* Not an equality test, deliberately. Matching the chip's key exactly meant any status the chip list did not name
|
||||
* belonged to no chip at all while still counting toward "All alerts" — so an alert could sit in the feed, need a
|
||||
* decision, and appear nowhere an officer looks for work. That is what UNDER_REVIEW did here: five undecided
|
||||
* alerts, the largest ₹18,00,000, behind a pending figure that read 1.
|
||||
*
|
||||
* Pending is therefore defined by what has not been decided rather than by one status name. Any status that is
|
||||
* neither whitelisted nor escalated surfaces under Raised on its own, which is the safe direction to fail — an
|
||||
* alert shown as work that turns out to be finished costs a glance; one hidden costs a filing.
|
||||
*/
|
||||
private matchesStatus(alert: any, key: string): boolean {
|
||||
if (key === 'RAISED') {
|
||||
return alert?.status !== 'WHITELISTED' && alert?.status !== 'ESCALATED';
|
||||
}
|
||||
return alert?.status === key;
|
||||
}
|
||||
|
||||
statusClass(status: string): string {
|
||||
return 'alert-status status-' + (status || 'raised').toLowerCase();
|
||||
}
|
||||
|
||||
@@ -45,9 +45,28 @@
|
||||
<ng-container matColumnDef="ddNumber">
|
||||
<th mat-header-cell *matHeaderCellDef>DD No.</th>
|
||||
<td mat-cell *matCellDef="let element">
|
||||
<strong>{{ element.ddNumber }}</strong>
|
||||
<div *ngIf="element.batchId" style="color: #7c3aed; font-size: 11px">
|
||||
Slip · {{ batchSiblingCount(element) }} drafts
|
||||
<!--
|
||||
A slip's rows are marked by name, not just by the fact of being on one.
|
||||
|
||||
"Slip · 3 drafts" said only that this draft belonged to some slip of three, which is no
|
||||
help when two customers each have a slip pending: the approver could not tell which three
|
||||
rows went together. The batch number names the slip, the position says how much of it is
|
||||
on screen, and the colour bar lets adjacent slips be told apart without reading either.
|
||||
-->
|
||||
<div class="slip-cell">
|
||||
<span
|
||||
class="slip-bar"
|
||||
*ngIf="element.batchId"
|
||||
[style.background]="batchColour(element)"
|
||||
[title]="'Slip #' + element.batchId"
|
||||
></span>
|
||||
<div>
|
||||
<strong>{{ element.ddNumber }}</strong>
|
||||
<div *ngIf="element.batchId" class="slip-tag" [style.color]="batchColour(element)">
|
||||
Slip #{{ element.batchId }} · draft {{ batchPositionOf(element) }} of
|
||||
{{ batchSiblingCount(element) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
@@ -64,7 +83,19 @@
|
||||
|
||||
<ng-container matColumnDef="faceValue">
|
||||
<th mat-header-cell *matHeaderCellDef>Face Value</th>
|
||||
<td mat-cell *matCellDef="let element">{{ element.faceValue | number: '1.2-2' }}</td>
|
||||
<td mat-cell *matCellDef="let element">
|
||||
{{ element.faceValue | number: '1.2-2' }}
|
||||
<!--
|
||||
The figure that actually leaves the account, stated on every row of a slip.
|
||||
|
||||
Approving any row approves the whole slip, so the amount being authorised is the slip's
|
||||
total, not this line's face value. Showing only the line invited the approver to sanction
|
||||
₹250 and debit the customer ₹6,290.
|
||||
-->
|
||||
<div *ngIf="element.batchId" class="slip-total">
|
||||
Slip debits ₹{{ batchTotalDebit(element) | number: '1.2-2' }}
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="paymentMethod">
|
||||
@@ -107,19 +138,34 @@
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef>Actions</th>
|
||||
<td mat-cell *matCellDef="let element">
|
||||
<div class="action-btn-group" *ngIf="element.status === 'PENDING'">
|
||||
<!--
|
||||
One set of buttons per slip, on its first draft only.
|
||||
|
||||
Repeating them against every line read as a per-draft decision that does not exist — an
|
||||
approver could sanction three separate things and be surprised that the first click
|
||||
settled all of them. Choosing which drafts to issue happens inside the dialog, where the
|
||||
amount changes as the choice is made.
|
||||
-->
|
||||
<div class="action-btn-group" *ngIf="element.status === 'PENDING' && isBatchLead(element)">
|
||||
<button mat-flat-button color="primary" size="small" (click)="approve(element)">
|
||||
{{ element.batchId ? 'Approve Slip' : 'Approve' }}
|
||||
{{ element.batchId ? 'Review & Approve Slip' : 'Approve' }}
|
||||
</button>
|
||||
<button mat-stroked-button color="warn" size="small" (click)="openRejectDialog(element)">
|
||||
{{ element.batchId ? 'Reject Slip' : 'Reject' }}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
*ngIf="element.status === 'PENDING' && element.batchId"
|
||||
style="color: #7c3aed; font-size: 11px; margin-top: 4px"
|
||||
*ngIf="element.status === 'PENDING' && element.batchId && isBatchLead(element)"
|
||||
class="slip-action-note"
|
||||
[style.color]="batchColour(element)"
|
||||
>
|
||||
Approving/rejecting applies to all {{ batchSiblingCount(element) }} drafts in this slip.
|
||||
Covers all {{ batchSiblingCount(element) }} drafts. You can leave out any the customer no longer wants.
|
||||
</div>
|
||||
<div
|
||||
*ngIf="element.status === 'PENDING' && element.batchId && !isBatchLead(element)"
|
||||
class="slip-action-note slip-action-note--muted"
|
||||
>
|
||||
↑ handled with Slip #{{ element.batchId }}
|
||||
</div>
|
||||
<div
|
||||
*ngIf="element.status === 'PENDING' && element.cashReceiptJournalEntryId"
|
||||
|
||||
@@ -44,6 +44,41 @@ import { SignatureSettingsDialogComponent } from '../signature-settings-dialog/s
|
||||
max-width: 1300px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
/* A slip's rows read as one block: a continuous coloured bar down the left of every draft on
|
||||
it, so two customers' slips sitting next to each other separate visually before anything is
|
||||
read. */
|
||||
.slip-cell {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 9px;
|
||||
}
|
||||
.slip-bar {
|
||||
width: 3px;
|
||||
border-radius: 2px;
|
||||
flex-shrink: 0;
|
||||
min-height: 30px;
|
||||
}
|
||||
.slip-tag {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
margin-top: 1px;
|
||||
}
|
||||
.slip-total {
|
||||
font-size: 11px;
|
||||
color: #b45309;
|
||||
font-weight: 600;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.slip-action-note {
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.slip-action-note--muted {
|
||||
color: #94a3b8;
|
||||
font-style: italic;
|
||||
}
|
||||
.header-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -171,7 +206,9 @@ export class DdIssueApprovalsComponent implements OnInit, AfterViewInit {
|
||||
loadRequests(): void {
|
||||
this.sponsorDDService.getAllIssueRequests().subscribe({
|
||||
next: (data) => {
|
||||
this.dataSource.data = data;
|
||||
// Grouped here rather than in the template: a slip's drafts have to be adjacent rows for the
|
||||
// shared colour bar and the "n of m" position to read as one block.
|
||||
this.dataSource.data = this.groupBySlip(data || []);
|
||||
this.applyFilter();
|
||||
},
|
||||
error: (err) => {
|
||||
@@ -235,6 +272,78 @@ export class DdIssueApprovalsComponent implements OnInit, AfterViewInit {
|
||||
return this.batchSiblings(request).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this row is the one that carries its slip's buttons — its first draft.
|
||||
*
|
||||
* A single (non-slip) request is always its own lead, so the ordinary case is untouched.
|
||||
*/
|
||||
isBatchLead(request: any): boolean {
|
||||
return !request.batchId || this.batchPositionOf(request) === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which draft of its slip this row is — "2 of 3".
|
||||
*
|
||||
* A slip's drafts are ordered by the request id they were created with, which is the order the
|
||||
* clerk entered them in and the order the server allocated leaflets in. Without this an approver
|
||||
* looking at three rows of one slip cannot tell whether they are seeing all of it.
|
||||
*/
|
||||
batchPositionOf(request: any): number {
|
||||
if (!request.batchId) {
|
||||
return 1;
|
||||
}
|
||||
const ordered = this.batchSiblings(request).sort((a, b) => (Number(a.id) || 0) - (Number(b.id) || 0));
|
||||
return ordered.findIndex((r) => r.id === request.id) + 1;
|
||||
}
|
||||
|
||||
/** What the whole slip debits: every draft's face value, charges and GST together. */
|
||||
batchTotalDebit(request: any): number {
|
||||
return this.batchSiblings(request).reduce(
|
||||
(sum, r) => sum + (Number(r.faceValue) || 0) + (Number(r.chargesAmount) || 0) + (Number(r.gstAmount) || 0),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A stable colour per slip, so two customers' slips sitting next to each other are told apart at a
|
||||
* glance rather than by reading every batch number.
|
||||
*
|
||||
* Keyed off the batch id itself so the same slip keeps its colour across reloads and across the
|
||||
* PENDING/APPROVED/ALL tabs — a colour that reshuffled on every refresh would be worse than none.
|
||||
*/
|
||||
batchColour(request: any): string {
|
||||
if (!request.batchId) {
|
||||
return 'transparent';
|
||||
}
|
||||
const palette = [
|
||||
'#7c3aed',
|
||||
'#0891b2',
|
||||
'#c2410c',
|
||||
'#15803d',
|
||||
'#be123c',
|
||||
'#4338ca'
|
||||
];
|
||||
return palette[Number(request.batchId) % palette.length];
|
||||
}
|
||||
|
||||
/**
|
||||
* Orders the table so a slip's drafts sit together, newest slip first.
|
||||
*
|
||||
* The list arrives in whatever order the server returns, which scattered one slip's drafts among
|
||||
* another's — an approver then had no way of seeing which rows belonged together, and the "Slip ·
|
||||
* 3 drafts" badge only said that some three rows somewhere did.
|
||||
*/
|
||||
private groupBySlip(rows: any[]): any[] {
|
||||
return [...rows].sort((a, b) => {
|
||||
const groupA = Number(a.batchId ?? a.id) || 0;
|
||||
const groupB = Number(b.batchId ?? b.id) || 0;
|
||||
if (groupA !== groupB) {
|
||||
return groupB - groupA;
|
||||
}
|
||||
return (Number(a.id) || 0) - (Number(b.id) || 0);
|
||||
});
|
||||
}
|
||||
|
||||
approve(request: any): void {
|
||||
if (request.batchId) {
|
||||
this.approveBatch(request);
|
||||
@@ -287,29 +396,51 @@ export class DdIssueApprovalsComponent implements OnInit, AfterViewInit {
|
||||
}
|
||||
|
||||
private approveBatch(request: any): void {
|
||||
const siblings = this.batchSiblings(request);
|
||||
// Ordered by request id so the checkbox list matches the "draft n of m" the table shows.
|
||||
const siblings = this.batchSiblings(request).sort((a, b) => (Number(a.id) || 0) - (Number(b.id) || 0));
|
||||
const total = siblings.reduce((sum, r) => sum + (Number(r.faceValue) || 0), 0);
|
||||
|
||||
this.dialog
|
||||
.open(ApproveIssueRequestDialogComponent, {
|
||||
width: '460px',
|
||||
width: '520px',
|
||||
maxWidth: '95vw',
|
||||
data: {
|
||||
isBatch: true,
|
||||
clientName: request.clientName,
|
||||
totalFaceValue: total,
|
||||
lines: siblings.map((r) => ({ beneficiaryName: r.beneficiaryName, faceValue: Number(r.faceValue) || 0 })),
|
||||
// id, charges and GST travel with each line: the dialog debits by what is ticked, and
|
||||
// names the rows back to the server by id rather than by position.
|
||||
lines: siblings.map((r) => ({
|
||||
id: r.id,
|
||||
ddNumber: r.ddNumber,
|
||||
beneficiaryName: r.beneficiaryName,
|
||||
faceValue: Number(r.faceValue) || 0,
|
||||
chargesAmount: Number(r.chargesAmount) || 0,
|
||||
gstAmount: Number(r.gstAmount) || 0
|
||||
})),
|
||||
effect: this.effectTextFor(request, siblings.length)
|
||||
}
|
||||
})
|
||||
.afterClosed()
|
||||
.subscribe((confirmed) => {
|
||||
if (!confirmed) {
|
||||
.subscribe((result) => {
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
this.sponsorDDService.approveIssueRequestBatch(request.batchId).subscribe({
|
||||
// true means the whole slip, so no selection is sent and the server takes its ordinary path.
|
||||
const approvedRequestIds = result === true ? undefined : result.approvedRequestIds;
|
||||
const issuing = approvedRequestIds ? approvedRequestIds.length : siblings.length;
|
||||
const dropped = siblings.length - issuing;
|
||||
this.sponsorDDService.approveIssueRequestBatch(request.batchId, approvedRequestIds).subscribe({
|
||||
next: () => {
|
||||
this.snackBar.open('DD slip approved and posted successfully', 'Close', { duration: 3000 });
|
||||
this.snackBar.open(
|
||||
dropped > 0
|
||||
? `${issuing} draft${issuing === 1 ? '' : 's'} issued · ${dropped} not required, DD number${
|
||||
dropped === 1 ? '' : 's'
|
||||
} returned to stock`
|
||||
: 'DD slip approved and posted successfully',
|
||||
'Close',
|
||||
{ duration: dropped > 0 ? 6000 : 3000 }
|
||||
);
|
||||
this.loadRequests();
|
||||
},
|
||||
error: (err) => {
|
||||
@@ -380,11 +511,35 @@ export class DdIssueApprovalsComponent implements OnInit, AfterViewInit {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- A slip is one debit for every draft on it, which is the part an approver is most likely
|
||||
to get wrong if it is not said plainly. -->
|
||||
<ul class="approve-lines" *ngIf="data.isBatch">
|
||||
<li *ngFor="let l of data.lines">{{ l.beneficiaryName }} — ₹{{ l.faceValue | number: '1.2-2' }}</li>
|
||||
</ul>
|
||||
<!--
|
||||
The slip's drafts, each one the approver's to keep or drop.
|
||||
|
||||
A customer who ordered three drafts and then wants only two is ordinary counter business, and
|
||||
the alternative — rejecting the slip so the clerk retypes it — costs the customer their place
|
||||
and the bank the work. Unticking a draft leaves it unissued and its DD number in stock, and
|
||||
the debit below follows the ticks rather than the slip.
|
||||
-->
|
||||
<div class="approve-lines" *ngIf="data.isBatch">
|
||||
<label class="approve-line" *ngFor="let l of data.lines; let i = index">
|
||||
<input type="checkbox" [checked]="isSelected(i)" (change)="toggle(i)" />
|
||||
<span class="approve-line__dd" *ngIf="l.ddNumber">{{ l.ddNumber }}</span>
|
||||
<span class="approve-line__name">{{ l.beneficiaryName }}</span>
|
||||
<span class="approve-line__amt">₹{{ lineDebitAt(i) | number: '1.2-2' }}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- What the account is actually debited, recomputed from the ticks rather than from the slip:
|
||||
the figure the approver is authorising has to be the one that moves. -->
|
||||
<div class="approve-selection" *ngIf="data.isBatch">
|
||||
<div class="approve-row approve-row--total">
|
||||
<span class="approve-label">{{ selectedCount }} of {{ data.lines.length }} drafts selected</span>
|
||||
<strong class="approve-amount">₹{{ selectedDebit | number: '1.2-2' }}</strong>
|
||||
</div>
|
||||
<p class="approve-dropped" *ngIf="droppedCount > 0">
|
||||
{{ droppedCount }} draft{{ droppedCount === 1 ? '' : 's' }} will not be issued. Their DD numbers return to
|
||||
stock for the next customer, and nothing is debited for them.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p class="approve-effect">
|
||||
{{ data.effect }}
|
||||
@@ -392,8 +547,18 @@ export class DdIssueApprovalsComponent implements OnInit, AfterViewInit {
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-stroked-button type="button" (click)="dialogRef.close(false)">Cancel</button>
|
||||
<button mat-flat-button color="primary" type="button" (click)="dialogRef.close(true)">
|
||||
{{ data.isBatch ? 'Approve Slip' : 'Approve & Issue' }}
|
||||
<button
|
||||
mat-flat-button
|
||||
color="primary"
|
||||
type="button"
|
||||
[disabled]="data.isBatch && selectedCount === 0"
|
||||
(click)="confirm()"
|
||||
>
|
||||
{{
|
||||
data.isBatch
|
||||
? 'Approve ' + selectedCount + ' Draft' + (selectedCount === 1 ? '' : 's')
|
||||
: 'Approve & Issue'
|
||||
}}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
`,
|
||||
@@ -433,11 +598,69 @@ export class DdIssueApprovalsComponent implements OnInit, AfterViewInit {
|
||||
}
|
||||
.approve-lines {
|
||||
margin: 0 0 12px;
|
||||
padding-left: 20px;
|
||||
font-size: 12.5px;
|
||||
color: #334155;
|
||||
max-height: 160px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
/* Whole row is the hit target — a checkbox alone is a small thing to aim at when the decision
|
||||
it carries is whether a customer's draft gets issued. */
|
||||
.approve-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 9px 12px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
cursor: pointer;
|
||||
}
|
||||
.approve-line:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.approve-line:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
.approve-line input {
|
||||
cursor: pointer;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.approve-line__dd {
|
||||
font-family: monospace;
|
||||
color: #0369a1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.approve-line__name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.approve-line__amt {
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
/* A draft that has been unticked is still listed, greyed, so the approver can see what they
|
||||
are leaving out rather than watching it disappear. */
|
||||
.approve-line input:not(:checked) ~ span {
|
||||
opacity: 0.45;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
.approve-selection {
|
||||
border: 1px solid #bfdbfe;
|
||||
background: #eff6ff;
|
||||
border-radius: 8px;
|
||||
padding: 2px 14px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.approve-dropped {
|
||||
margin: 0 0 10px;
|
||||
font-size: 11.5px;
|
||||
line-height: 1.5;
|
||||
color: #92400e;
|
||||
}
|
||||
.approve-effect {
|
||||
margin: 0;
|
||||
@@ -455,6 +678,75 @@ export class DdIssueApprovalsComponent implements OnInit, AfterViewInit {
|
||||
export class ApproveIssueRequestDialogComponent {
|
||||
dialogRef = inject(MatDialogRef<ApproveIssueRequestDialogComponent>);
|
||||
data = inject(MAT_DIALOG_DATA);
|
||||
|
||||
/**
|
||||
* Which of the slip's drafts are still wanted, by line index.
|
||||
*
|
||||
* Every draft starts ticked: the clerk submitted the slip for a customer who asked for all of it,
|
||||
* so approving the whole thing has to stay the single-click case. Dropping one is the exception
|
||||
* and costs one tick.
|
||||
*/
|
||||
selected = new Set<number>((this.data.lines || []).map((_l: any, i: number) => i));
|
||||
|
||||
isSelected(index: number): boolean {
|
||||
return this.selected.has(index);
|
||||
}
|
||||
|
||||
toggle(index: number): void {
|
||||
if (this.selected.has(index)) {
|
||||
this.selected.delete(index);
|
||||
} else {
|
||||
this.selected.add(index);
|
||||
}
|
||||
}
|
||||
|
||||
get selectedCount(): number {
|
||||
return this.selected.size;
|
||||
}
|
||||
|
||||
get droppedCount(): number {
|
||||
return (this.data.lines?.length || 0) - this.selected.size;
|
||||
}
|
||||
|
||||
/** What one draft costs the account: its face value, its own charge, and the GST on that charge. */
|
||||
lineDebitAt(index: number): number {
|
||||
const line = this.data.lines?.[index];
|
||||
if (!line) {
|
||||
return 0;
|
||||
}
|
||||
return (Number(line.faceValue) || 0) + (Number(line.chargesAmount) || 0) + (Number(line.gstAmount) || 0);
|
||||
}
|
||||
|
||||
/** The debit as it currently stands — only the ticked drafts. */
|
||||
get selectedDebit(): number {
|
||||
return [...this.selected].reduce((sum, i) => sum + this.lineDebitAt(i), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes with the request ids to issue, or true for "all of them".
|
||||
*
|
||||
* True rather than the full id list when nothing was dropped, so the caller sends no selection at
|
||||
* all and the server takes its ordinary whole-slip path.
|
||||
*/
|
||||
confirm(): void {
|
||||
if (!this.data.isBatch) {
|
||||
this.dialogRef.close(true);
|
||||
return;
|
||||
}
|
||||
if (this.selectedCount === 0) {
|
||||
return;
|
||||
}
|
||||
if (this.droppedCount === 0) {
|
||||
this.dialogRef.close(true);
|
||||
return;
|
||||
}
|
||||
this.dialogRef.close({
|
||||
approvedRequestIds: [...this.selected]
|
||||
.sort((a, b) => a - b)
|
||||
.map((i) => this.data.lines[i].id)
|
||||
.filter((id: any) => id != null)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── REJECT DIALOG COMPONENT ──
|
||||
|
||||
@@ -15,6 +15,7 @@ import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { SponsorDDService } from '../sponsor-dd.service';
|
||||
|
||||
@@ -30,6 +31,7 @@ import { SponsorDDService } from '../sponsor-dd.service';
|
||||
MatInputModule,
|
||||
MatSelectModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatSnackBarModule
|
||||
],
|
||||
template: `
|
||||
@@ -41,15 +43,25 @@ import { SponsorDDService } from '../sponsor-dd.service';
|
||||
</p>
|
||||
|
||||
<form [formGroup]="form" class="dialog-form" *ngIf="!loading">
|
||||
<!-- DD Payable GL Account -->
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Sponsor DD Payable GL Account *</mat-label>
|
||||
<mat-select formControlName="ddPayableGlAccountId" required placeholder="Select DD Payable Liability GL">
|
||||
<mat-option *ngFor="let acc of glAccounts" [value]="acc.id"> {{ acc.glCode }} · {{ acc.name }} </mat-option>
|
||||
</mat-select>
|
||||
<mat-hint>Credited for the DD Face Value</mat-hint>
|
||||
<mat-error *ngIf="form.get('ddPayableGlAccountId')?.hasError('required')">Required</mat-error>
|
||||
</mat-form-field>
|
||||
<!--
|
||||
DD Payable is no longer set here.
|
||||
|
||||
It belongs to the sponsor bank: a draft drawn on SBI is owed to SBI and one on HDFC to
|
||||
HDFC, and what is outstanding to either is the figure a settlement with that bank is
|
||||
reconciled against — which one shared account cannot answer. Everything remaining on this
|
||||
screen is the same whichever sponsor a draft is drawn on: this bank's own charges income,
|
||||
its GST, its cash and clearing accounts, and its DD policy.
|
||||
-->
|
||||
<div class="payable-moved-note">
|
||||
<mat-icon>account_balance</mat-icon>
|
||||
<div>
|
||||
<strong>DD Payable is now set per sponsor bank.</strong>
|
||||
<div class="payable-moved-sub">
|
||||
Each bank credits its own payable account, so what is owed to each can be settled separately. Set it in
|
||||
<em>Organization > Sponsor Banks</em>.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charges Income GL Account -->
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
@@ -131,6 +143,29 @@ import { SponsorDDService } from '../sponsor-dd.service';
|
||||
gap: 12px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
.payable-moved-note {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 18px;
|
||||
padding: 11px 14px;
|
||||
background: #eff6ff;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-left: 3px solid #1d4ed8;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
color: #334155;
|
||||
}
|
||||
.payable-moved-note mat-icon {
|
||||
color: #1d4ed8;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.payable-moved-sub {
|
||||
margin-top: 3px;
|
||||
font-size: 11.5px;
|
||||
line-height: 1.5;
|
||||
color: #64748b;
|
||||
}
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -170,10 +205,10 @@ export class GlSettingsDialogComponent implements OnInit {
|
||||
|
||||
ngOnInit(): void {
|
||||
this.form = this.fb.group({
|
||||
ddPayableGlAccountId: [
|
||||
null,
|
||||
[Validators.required]
|
||||
],
|
||||
// No longer edited here — it lives on the sponsor bank. Kept in the form, unvalidated, so
|
||||
// saving this screen round-trips the stored value instead of clearing it: existing banks were
|
||||
// seeded from it on upgrade, and nothing should quietly erase what they were seeded from.
|
||||
ddPayableGlAccountId: [null],
|
||||
chargesIncomeGlAccountId: [
|
||||
null,
|
||||
[Validators.required]
|
||||
|
||||
@@ -14,9 +14,11 @@ import { HttpClient } from '@angular/common/http';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatDialog, MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
|
||||
@Component({
|
||||
@@ -29,6 +31,7 @@ import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
MatTableModule,
|
||||
MatButtonModule,
|
||||
MatDialogModule,
|
||||
MatIconModule,
|
||||
MatSnackBarModule
|
||||
],
|
||||
template: `
|
||||
@@ -75,6 +78,27 @@ import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
<td mat-cell *matCellDef="let element">{{ element.swiftCode || '-' }}</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- A bank with no payable account cannot issue drafts at all, so it is called out here
|
||||
rather than left to fail at the counter. -->
|
||||
<ng-container matColumnDef="ddPayable">
|
||||
<th mat-header-cell *matHeaderCellDef>DD Payable GL</th>
|
||||
<td mat-cell *matCellDef="let element">
|
||||
<span *ngIf="element.ddPayableGlAccountId" class="gl-account">
|
||||
{{ element.ddPayableGlCode }} · {{ element.ddPayableGlName }}
|
||||
</span>
|
||||
<span *ngIf="!element.ddPayableGlAccountId" class="gl-missing">
|
||||
<mat-icon>warning</mat-icon> Not set — drafts cannot be issued
|
||||
</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef></th>
|
||||
<td mat-cell *matCellDef="let element">
|
||||
<button mat-stroked-button (click)="openEditDialog(element)">Edit</button>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
|
||||
</table>
|
||||
@@ -87,9 +111,27 @@ import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
`
|
||||
.sponsor-bank-container {
|
||||
padding: 24px;
|
||||
max-width: 1000px;
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.gl-account {
|
||||
font-size: 12.5px;
|
||||
color: #0f766e;
|
||||
}
|
||||
/* Loud on purpose: a bank in this state silently blocks every draft drawn on it. */
|
||||
.gl-missing {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 12px;
|
||||
color: #b45309;
|
||||
font-weight: 600;
|
||||
}
|
||||
.gl-missing mat-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
.header-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -143,7 +185,9 @@ export class SponsorBankListComponent implements OnInit {
|
||||
'description',
|
||||
'ifscCode',
|
||||
'bankCode',
|
||||
'swiftCode'
|
||||
'swiftCode',
|
||||
'ddPayable',
|
||||
'actions'
|
||||
];
|
||||
dataSource: any[] = [];
|
||||
|
||||
@@ -163,9 +207,18 @@ export class SponsorBankListComponent implements OnInit {
|
||||
}
|
||||
|
||||
openAddDialog(): void {
|
||||
this.openBankDialog(null);
|
||||
}
|
||||
|
||||
openEditDialog(bank: any): void {
|
||||
this.openBankDialog(bank);
|
||||
}
|
||||
|
||||
private openBankDialog(bank: any): void {
|
||||
const dialogRef = this.dialog.open(AddSponsorBankDialogComponent, {
|
||||
width: '450px',
|
||||
disableClose: true
|
||||
width: '480px',
|
||||
disableClose: true,
|
||||
data: { bank }
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe((result) => {
|
||||
@@ -187,10 +240,11 @@ export class SponsorBankListComponent implements OnInit {
|
||||
MatDialogModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatSelectModule,
|
||||
MatButtonModule
|
||||
],
|
||||
template: `
|
||||
<h2 mat-dialog-title>Add Sponsor Bank</h2>
|
||||
<h2 mat-dialog-title>{{ isEdit ? 'Edit Sponsor Bank' : 'Add Sponsor Bank' }}</h2>
|
||||
<mat-dialog-content>
|
||||
<form [formGroup]="form" class="dialog-form">
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
@@ -236,6 +290,29 @@ export class SponsorBankListComponent implements OnInit {
|
||||
/>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
The GL account drafts drawn on this bank are credited to.
|
||||
|
||||
Set here rather than in the global DD GL settings because it belongs to the bank: a draft
|
||||
on SBI is owed to SBI and one on HDFC to HDFC, and what is outstanding to either is the
|
||||
figure a settlement with that bank is reconciled against. A shared account would make that
|
||||
unanswerable. Charges income, GST and the clearing accounts stay in the global settings —
|
||||
those are this bank's own income and tax whichever sponsor a draft is drawn on.
|
||||
-->
|
||||
<div class="gl-section-label">Accounting</div>
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>DD Payable GL Account</mat-label>
|
||||
<mat-select formControlName="ddPayableGlAccountId">
|
||||
<mat-option *ngFor="let acc of liabilityGlAccounts" [value]="acc.id">
|
||||
{{ acc.glCode }} · {{ acc.name }}
|
||||
</mat-option>
|
||||
</mat-select>
|
||||
<mat-hint>Credited for the face value of every draft drawn on this bank</mat-hint>
|
||||
<mat-error *ngIf="form.get('ddPayableGlAccountId')?.hasError('required')">
|
||||
A DD Payable account is required — drafts on this bank cannot be issued without one
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
</form>
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions align="end">
|
||||
@@ -261,6 +338,16 @@ export class SponsorBankListComponent implements OnInit {
|
||||
.half-width {
|
||||
flex: 1;
|
||||
}
|
||||
.gl-section-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
color: #475569;
|
||||
margin: 4px 0 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
`
|
||||
]
|
||||
})
|
||||
@@ -270,22 +357,53 @@ export class AddSponsorBankDialogComponent implements OnInit {
|
||||
private dialogRef = inject(MatDialogRef<AddSponsorBankDialogComponent>);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
|
||||
/** The bank being edited, or null when adding a new one. */
|
||||
data = inject(MAT_DIALOG_DATA, { optional: true }) as any;
|
||||
|
||||
form!: FormGroup;
|
||||
loading = false;
|
||||
/**
|
||||
* Liability accounts only, and only postable, enabled ones.
|
||||
*
|
||||
* A DD payable is money owed to the sponsor until the draft is presented, so offering asset or
|
||||
* income accounts here would only invite a choice the server then refuses.
|
||||
*/
|
||||
liabilityGlAccounts: any[] = [];
|
||||
|
||||
get isEdit(): boolean {
|
||||
return !!this.data?.bank?.id;
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
const bank = this.data?.bank;
|
||||
this.form = this.fb.group({
|
||||
name: [
|
||||
'',
|
||||
bank?.name ?? '',
|
||||
Validators.required
|
||||
],
|
||||
description: ['Sponsor Bank'],
|
||||
description: [bank?.description ?? 'Sponsor Bank'],
|
||||
ifscCode: [
|
||||
'',
|
||||
bank?.ifscCode ?? '',
|
||||
Validators.pattern(/^[A-Za-z]{4}0[A-Za-z0-9]{6}$/)
|
||||
],
|
||||
bankCode: [''],
|
||||
swiftCode: ['']
|
||||
bankCode: [bank?.bankCode ?? ''],
|
||||
swiftCode: [bank?.swiftCode ?? ''],
|
||||
// Required: a bank with no payable cannot have drafts issued on it at all, so it is better
|
||||
// refused while the bank is being set up than when a clerk is standing at a counter.
|
||||
ddPayableGlAccountId: [
|
||||
bank?.ddPayableGlAccountId ?? null,
|
||||
Validators.required
|
||||
]
|
||||
});
|
||||
this.loadLiabilityGlAccounts();
|
||||
}
|
||||
|
||||
private loadLiabilityGlAccounts(): void {
|
||||
// type=2 is LIABILITY, usage=1 is DETAIL — the same filters the DD GL settings dialog uses for
|
||||
// its own postable accounts.
|
||||
this.http.get<any[]>('/glaccounts?type=2&manualEntriesAllowed=true&disabled=false&usage=1').subscribe({
|
||||
next: (accounts) => (this.liabilityGlAccounts = accounts || []),
|
||||
error: () => (this.liabilityGlAccounts = [])
|
||||
});
|
||||
}
|
||||
|
||||
@@ -297,9 +415,14 @@ export class AddSponsorBankDialogComponent implements OnInit {
|
||||
if (this.form.invalid) return;
|
||||
|
||||
this.loading = true;
|
||||
this.http.post<any>('/sponsorbanks', this.form.value).subscribe({
|
||||
const request$ = this.isEdit
|
||||
? this.http.put<any>(`/sponsorbanks/${this.data.bank.id}`, this.form.value)
|
||||
: this.http.post<any>('/sponsorbanks', this.form.value);
|
||||
request$.subscribe({
|
||||
next: () => {
|
||||
this.snackBar.open('Sponsor Bank added successfully', 'Close', { duration: 3000 });
|
||||
this.snackBar.open(this.isEdit ? 'Sponsor Bank updated' : 'Sponsor Bank added successfully', 'Close', {
|
||||
duration: 3000
|
||||
});
|
||||
this.dialogRef.close(true);
|
||||
},
|
||||
error: (err) => {
|
||||
|
||||
@@ -125,18 +125,22 @@ export class SponsorDDService {
|
||||
return this.http.get<any[]>('/sponsordd/cashier-deposit-requests/available');
|
||||
}
|
||||
|
||||
// ── Multi-DD slip (one account debit/cheque funds several drafts) ──
|
||||
// ── Multi-DD slip (one account debit / cheque / GL debit funds several drafts) ──
|
||||
// CASH is the one funding method a slip cannot use: a single counted pile of notes cannot be
|
||||
// apportioned across drafts without the denomination total ceasing to prove what was taken.
|
||||
submitIssueRequestBatch(
|
||||
inventoryId: number,
|
||||
data: {
|
||||
clientId?: number;
|
||||
issuingBranchId: number;
|
||||
issuedDate: string; // yyyy-MM-dd
|
||||
paymentMethod: 'ACCOUNT_DEBIT' | 'CHEQUE';
|
||||
paymentMethod: 'ACCOUNT_DEBIT' | 'CHEQUE' | 'GL';
|
||||
savingsAccountId?: number;
|
||||
currentAccountId?: number;
|
||||
chequeNumber?: string;
|
||||
chequeBankName?: string;
|
||||
/** The GL account a GL-funded slip is debited from; omitted for every other method. */
|
||||
glAccountId?: number;
|
||||
lines: {
|
||||
beneficiaryName: string;
|
||||
payableAtBranch: string;
|
||||
@@ -149,8 +153,12 @@ export class SponsorDDService {
|
||||
return this.http.post<any>(`/sponsordd/inventory/${inventoryId}/issue-request-batch`, data);
|
||||
}
|
||||
|
||||
approveIssueRequestBatch(batchId: number): Observable<any> {
|
||||
return this.http.post<any>(`/sponsordd/issue-requests/batch/${batchId}/approve`, {});
|
||||
/**
|
||||
* Approves a slip. Omitting approvedRequestIds issues every draft on it; supplying them issues
|
||||
* only those, debits only for them, and rejects the rest — whose DD numbers return to stock.
|
||||
*/
|
||||
approveIssueRequestBatch(batchId: number, approvedRequestIds?: number[]): Observable<any> {
|
||||
return this.http.post<any>(`/sponsordd/issue-requests/batch/${batchId}/approve`, { approvedRequestIds });
|
||||
}
|
||||
|
||||
rejectIssueRequestBatch(batchId: number, rejectionReason: string): Observable<any> {
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
/* tslint:disable */
|
||||
export default {
|
||||
'mifos_x': {
|
||||
'version': '260918',
|
||||
'hash': 'dc7f6680'
|
||||
'version': '260921',
|
||||
'hash': '3c363d03'
|
||||
},
|
||||
'allow_switching_backend_instance': true
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user