fix: update sponsor DD approvals, cashier transaction entry, FIU and dividend declaration

This commit is contained in:
2026-09-21 10:24:08 +05:30
parent 3c363d0372
commit a6e6220100
14 changed files with 1745 additions and 234 deletions
@@ -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 &amp; 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 &amp; 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>₹&nbsp;</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>₹&nbsp;</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 &amp; 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 &amp; 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
+9 -2
View File
@@ -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;
@@ -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>
}
@@ -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 {
@@ -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 {
@@ -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 &amp; 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 &amp; 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 &amp; 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 &gt; 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) => {
+12 -4
View File
@@ -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> {
+2 -2
View File
@@ -9,8 +9,8 @@
/* tslint:disable */
export default {
'mifos_x': {
'version': '260918',
'hash': 'dc7f6680'
'version': '260921',
'hash': '3c363d03'
},
'allow_switching_backend_instance': true
};