diff --git a/src/app/cashier/transaction-entry/cashier-issue-dd-dialog.component.ts b/src/app/cashier/transaction-entry/cashier-issue-dd-dialog.component.ts
index fff71aee1..0c232ba68 100644
--- a/src/app/cashier/transaction-entry/cashier-issue-dd-dialog.component.ts
+++ b/src/app/cashier/transaction-entry/cashier-issue-dd-dialog.component.ts
@@ -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 {
+ }
+ @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.
+ }
}
-
+ } @else {
+
+
+ }
+ @if (isSlip) {
+
+ {{ lineCount }} drafts · ₹{{ slipTotal | number: '1.2-2' }} to debit
+
+ }
-
@@ -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(),
diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.html b/src/app/cashier/transaction-entry/transaction-entry.component.html
index ee14e4589..586117016 100644
--- a/src/app/cashier/transaction-entry/transaction-entry.component.html
+++ b/src/app/cashier/transaction-entry/transaction-entry.component.html
@@ -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))
+ "
/>
@if (row.acTyp === 'SHR' && rowSharePrices[row.acNo]) {
@@ -331,6 +335,19 @@
}}/share
}
+
+ @if (isMultiDdRow(row)) {
+
+ {{
+ row.ddLines?.length
+ ? 'Total of ' + row.ddLines?.length + ' drafts'
+ : 'Set per draft in Fill DD Details'
+ }}
+
+ }
@if (row.acTyp === 'SHR' && (row.typ === 'Enter Bank Transfer' || row.typ === 'Fund Transfer')) {
@@ -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'
"
>
- post_add
- Fill DD Details
+ {{
+ row.ddDetails || row.ddLines?.length ? 'edit_note' : 'post_add'
+ }}
+
+ {{
+ 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'
+ }}
diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.ts b/src/app/cashier/transaction-entry/transaction-entry.component.ts
index a96bf4f00..0c916bfe4 100644
--- a/src/app/cashier/transaction-entry/transaction-entry.component.ts
+++ b/src/app/cashier/transaction-entry/transaction-entry.component.ts
@@ -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
diff --git a/src/app/fiu/fiu.component.ts b/src/app/fiu/fiu.component.ts
index 245162f51..e918bce81 100644
--- a/src/app/fiu/fiu.component.ts
+++ b/src/app/fiu/fiu.component.ts
@@ -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;
diff --git a/src/app/products/share-products/all-products-dividends/declare-dividend/declare-dividend.component.html b/src/app/products/share-products/all-products-dividends/declare-dividend/declare-dividend.component.html
index daccfbafa..945e5f5a8 100644
--- a/src/app/products/share-products/all-products-dividends/declare-dividend/declare-dividend.component.html
+++ b/src/app/products/share-products/all-products-dividends/declare-dividend/declare-dividend.component.html
@@ -21,14 +21,31 @@
{{ 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.
+ }
+ {{ 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.
+
+ } @else if (isPeriodNotFinished) {
+
+
+ {{ preview.financialYear }} has not finished yet, so its profit is not final. A dividend can only be
+ declared once the financial year is over.
+
+ } @else if (isUnderfunded) {
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
+
@for (product of eligibleProducts; track product.productId) {
}
diff --git a/src/app/products/share-products/all-products-dividends/declare-dividend/declare-dividend.component.scss b/src/app/products/share-products/all-products-dividends/declare-dividend/declare-dividend.component.scss
index 334f70aa4..c95fb17a2 100644
--- a/src/app/products/share-products/all-products-dividends/declare-dividend/declare-dividend.component.scss
+++ b/src/app/products/share-products/all-products-dividends/declare-dividend/declare-dividend.component.scss
@@ -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 {
diff --git a/src/app/products/share-products/all-products-dividends/declare-dividend/declare-dividend.component.ts b/src/app/products/share-products/all-products-dividends/declare-dividend/declare-dividend.component.ts
index 83f7d52e6..5ab5cbd9c 100644
--- a/src/app/products/share-products/all-products-dividends/declare-dividend/declare-dividend.component.ts
+++ b/src/app/products/share-products/all-products-dividends/declare-dividend/declare-dividend.component.ts
@@ -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 {
diff --git a/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.ts b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.ts
index 6f5b070ea..85b49edf4 100644
--- a/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.ts
+++ b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.ts
@@ -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();
}
diff --git a/src/app/sponsor-dd/dd-issue-approvals/dd-issue-approvals.component.html b/src/app/sponsor-dd/dd-issue-approvals/dd-issue-approvals.component.html
index 49ee7aed2..15eb71f78 100644
--- a/src/app/sponsor-dd/dd-issue-approvals/dd-issue-approvals.component.html
+++ b/src/app/sponsor-dd/dd-issue-approvals/dd-issue-approvals.component.html
@@ -45,9 +45,28 @@
- 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.
+
+
+ ↑ handled with Slip #{{ element.batchId }}
{
- 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 {
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.
+
+
{{ data.effect }}
@@ -392,8 +547,18 @@ export class DdIssueApprovalsComponent implements OnInit, AfterViewInit {
Cancel
-
- {{ data.isBatch ? 'Approve Slip' : 'Approve & Issue' }}
+
+ {{
+ data.isBatch
+ ? 'Approve ' + selectedCount + ' Draft' + (selectedCount === 1 ? '' : 's')
+ : 'Approve & Issue'
+ }}
`,
@@ -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);
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((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 ──
diff --git a/src/app/sponsor-dd/gl-settings-dialog/gl-settings-dialog.component.ts b/src/app/sponsor-dd/gl-settings-dialog/gl-settings-dialog.component.ts
index b4516b3c4..d2f1315f5 100644
--- a/src/app/sponsor-dd/gl-settings-dialog/gl-settings-dialog.component.ts
+++ b/src/app/sponsor-dd/gl-settings-dialog/gl-settings-dialog.component.ts
@@ -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';