Merge remote-tracking branch 'nexora/uate' into merge-uate-into-dev
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s

# Conflicts:
#	src/app/core/shell/shell.component.html
#	src/app/tasks/tasks-routing.module.ts
#	src/environments/.env.ts
This commit is contained in:
2026-07-23 17:15:52 +05:30
73 changed files with 8988 additions and 107 deletions
@@ -218,6 +218,26 @@
<span class="mdr-detail__val cell-mono">{{ viewRequest.txn_id }}</span>
</div>
}
@if (viewRequest.dd_face_value != null) {
<div class="mdr-detail">
<span class="mdr-detail__lbl">DD Face Value</span>
<span class="mdr-detail__val">₹{{ viewRequest.dd_face_value | number: '1.2-2' }}</span>
</div>
<div class="mdr-detail">
<span class="mdr-detail__lbl">Charges</span>
<span class="mdr-detail__val">₹{{ viewRequest.dd_charges | number: '1.2-2' }}</span>
</div>
<div class="mdr-detail">
<span class="mdr-detail__lbl">GST ({{ viewRequest.dd_gst_percentage }}%)</span>
<span class="mdr-detail__val">₹{{ viewRequest.dd_gst_amount | number: '1.2-2' }}</span>
</div>
<div class="mdr-detail">
<span class="mdr-detail__lbl">Total Collected</span>
<span class="mdr-detail__val"
><strong>₹{{ viewRequest.amount | number: '1.2-2' }}</strong></span
>
</div>
}
@if (viewRequest.description) {
<div class="mdr-detail mdr-detail--wide">
<span class="mdr-detail__lbl">Description</span>
@@ -70,6 +70,13 @@
<span class="ws-header__title">Transaction Entry</span>
</div>
<div class="ws-header__right">
<button type="button" class="ws-btn ws-btn--ghost" (click)="openCustomerSearch()">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
Search Customer
</button>
<span class="ws-header__effdt">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="4" width="18" height="18" rx="2" />
@@ -300,6 +307,59 @@
</div>
</div>
</div>
<!-- ── Row 3: DD Charges & GST — only for TYPE=DD, TXN TP=Cash, AC.TP=GL ── -->
@if (isDdCashGlRow(row)) {
<div class="ws-group-row ws-group-row--mt">
<div class="ws-group ws-group--flex">
<span class="ws-caption">DD Charges &amp; GST — Admin Rate {{ ddGstPercentage }}%</span>
<div class="ws-group__fields">
<div class="ws-f ws-f--amt">
<label class="ws-f__lbl" title="DD issuance charges / commission">CHARGES</label>
<div class="ws-amt-wrap">
<span class="ws-amt-prefix">₹</span>
<input
class="ws-f__ctl ws-amt-input"
type="number"
[value]="row.ddCharges ?? ''"
(input)="row.ddCharges = +$any($event.target).value"
(click)="$event.stopPropagation()"
placeholder="0.00"
/>
</div>
</div>
<div class="ws-f ws-f--amt">
<label class="ws-f__lbl" title="GST on charges, at the admin-configured rate">
GST ({{ ddGstPercentage }}%)
</label>
<div class="ws-amt-wrap ws-amt-wrap--locked">
<span class="ws-amt-prefix">₹</span>
<input
class="ws-f__ctl ws-amt-input ws-amt-input--locked"
type="text"
[value]="ddGstAmountFor(row) | number: '1.2-2'"
readonly
/>
</div>
</div>
<div class="ws-f ws-f--amt">
<label class="ws-f__lbl" title="Amount + Charges + GST, rounded up to the next rupee">
TOTAL TO COLLECT
</label>
<div class="ws-amt-wrap ws-amt-wrap--locked">
<span class="ws-amt-prefix">₹</span>
<input
class="ws-f__ctl ws-amt-input ws-amt-input--locked"
type="text"
[value]="ddTotalPayableFor(row)"
readonly
/>
</div>
</div>
</div>
</div>
</div>
}
</div>
</div>
}
@@ -957,11 +1017,15 @@
<path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16" />
</svg>
Denomination Entry
@if (denominationTotal > 0) {
<span class="te-mandate-badge">₹{{ denominationTotal | number: '1.2-2' }}</span>
}
@if (isCashPayment && denominationTotal <= 0) {
<span class="te-denom-required-badge">⚠ Required for Cash</span>
@if (isSelectedRowDdCashGl) {
<span class="te-mandate-badge">Target ₹{{ ddTargetTotal | number: '1.2-2' }}</span>
} @else {
@if (denominationTotal > 0) {
<span class="te-mandate-badge">₹{{ denominationTotal | number: '1.2-2' }}</span>
}
@if (isCashPayment && denominationTotal <= 0) {
<span class="te-denom-required-badge">⚠ Required for Cash</span>
}
}
</div>
<div class="te-panel__body">
@@ -1011,7 +1075,26 @@
</tfoot>
</table>
@if (denominationTotal > 0) {
@if (isSelectedRowDdCashGl) {
<div class="te-denom-actions">
@if (denominationTotal <= 0) {
<span class="te-denom-required-badge"
>⚠ Count notes/coins totalling ₹{{ ddTargetTotal | number: '1.2-2' }}</span
>
} @else if (ddDenomMatchesTarget()) {
<span class="te-denom-match-badge"
>✓ Matches Total To Collect ₹{{ ddTargetTotal | number: '1.2-2' }}</span
>
} @else {
<span class="te-denom-required-badge">
⚠ Must total ₹{{ ddTargetTotal | number: '1.2-2' }} (counted ₹{{
denominationTotal | number: '1.2-2'
}})
</span>
}
<button class="te-btn" type="button" (click)="clearDenominations()">Clear</button>
</div>
} @else if (denominationTotal > 0) {
<div class="te-denom-actions">
<button class="te-btn te-btn--primary" type="button" (click)="applyDenominationTotal()">
Apply ₹{{ denominationTotal | number: '1.2-2' }} to Amount
@@ -1140,6 +1223,13 @@
<span class="ws-header__title">Transaction Entry</span>
</div>
<div class="ws-header__right">
<button type="button" class="ws-btn ws-btn--ghost" (click)="openCustomerSearch()">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
Search Customer
</button>
<span class="ws-header__effdt">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="4" width="18" height="18" rx="2" />
@@ -1325,6 +1415,58 @@
</div>
</div>
</div>
<!-- ── Row 3: DD Charges & GST — only for TYPE=DD, TXN TP=Cash, AC.TP=GL ── -->
@if (isDdCashGlRow(row)) {
<div class="ws-group-row ws-group-row--mt">
<div class="ws-group ws-group--flex">
<span class="ws-caption">DD Charges &amp; GST — Admin Rate {{ ddGstPercentage }}%</span>
<div class="ws-group__fields">
<div class="ws-f ws-f--amt">
<label class="ws-f__lbl" title="DD issuance charges / commission">CHARGES</label>
<div class="ws-amt-wrap">
<span class="ws-amt-prefix">₹</span>
<input
class="ws-f__ctl ws-amt-input"
type="number"
[value]="row.ddCharges ?? ''"
(input)="row.ddCharges = +$any($event.target).value"
placeholder="0.00"
/>
</div>
</div>
<div class="ws-f ws-f--amt">
<label class="ws-f__lbl" title="GST on charges, at the admin-configured rate">
GST ({{ ddGstPercentage }}%)
</label>
<div class="ws-amt-wrap ws-amt-wrap--locked">
<span class="ws-amt-prefix">₹</span>
<input
class="ws-f__ctl ws-amt-input ws-amt-input--locked"
type="text"
[value]="ddGstAmountFor(row) | number: '1.2-2'"
readonly
/>
</div>
</div>
<div class="ws-f ws-f--amt">
<label class="ws-f__lbl" title="Amount + Charges + GST, rounded up to the next rupee">
TOTAL TO COLLECT
</label>
<div class="ws-amt-wrap ws-amt-wrap--locked">
<span class="ws-amt-prefix">₹</span>
<input
class="ws-f__ctl ws-amt-input ws-amt-input--locked"
type="text"
[value]="ddTotalPayableFor(row)"
readonly
/>
</div>
</div>
</div>
</div>
</div>
}
</div>
</div>
}
@@ -1539,8 +1681,26 @@
>{{ confirmData.symbol }}{{ confirmData.currentBalance | number: '1.2-2' }}</span
>
</div>
@if (confirmData.isDdCashGl) {
<div class="te-confirm-row">
<span class="te-confirm-label">DD Face Value</span
><span class="te-confirm-val"
>{{ confirmData.symbol }}{{ confirmData.ddFaceValue | number: '1.2-2' }}</span
>
</div>
<div class="te-confirm-row">
<span class="te-confirm-label">Charges</span
><span class="te-confirm-val">{{ confirmData.symbol }}{{ confirmData.ddCharges | number: '1.2-2' }}</span>
</div>
<div class="te-confirm-row">
<span class="te-confirm-label">GST ({{ confirmData.ddGstPercentage }}%)</span
><span class="te-confirm-val"
>{{ confirmData.symbol }}{{ confirmData.ddGstAmount | number: '1.2-2' }}</span
>
</div>
}
<div class="te-confirm-row te-confirm-row--amount">
<span class="te-confirm-label">Deposit Amount</span>
<span class="te-confirm-label">{{ confirmData.isDdCashGl ? 'Total To Collect' : 'Deposit Amount' }}</span>
<span class="te-confirm-val te-cr"
>+{{ confirmData.symbol }}{{ confirmData.amount | number: '1.2-2' }}</span
>
@@ -1928,6 +1928,19 @@ select.te-cell {
animation: te-pulse 1.5s ease-in-out infinite;
}
// Denomination matches DD target badge
.te-denom-match-badge {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 999px;
font-size: 10px;
font-weight: 700;
background: #dcfce7;
color: #15803d;
border: 1px solid #86efac;
}
@keyframes te-pulse {
0%,
100% {
@@ -23,6 +23,11 @@ import { ClerkWithdrawalService } from '../clerk-withdrawal.service';
import { ClerkDisbursementService } from '../clerk-disbursement.service';
import { CashierDepositService } from '../cashier-deposit.service';
import { SettingsService } from '../../settings/settings.service';
import {
CustomerSearchDialogComponent,
CustomerSearchResult
} from '../../shared/customer-search-dialog/customer-search-dialog.component';
import { SponsorDDService } from '../../sponsor-dd/sponsor-dd.service';
interface TxnRow {
typ: string;
@@ -40,6 +45,8 @@ interface TxnRow {
paymentTypeId: number | null;
bankName: string;
routingCode: string;
/** DD issuance charges/commission — only used when TYPE=DD, TXN TP=Cash, AC.TP=GL */
ddCharges: number | null;
}
interface Denomination {
@@ -744,6 +751,12 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
private cashierDepositService = inject(CashierDepositService);
readonly router = inject(Router);
private settingsService = inject(SettingsService);
private sponsorDDService = inject(SponsorDDService);
/** GST % on DD issuance charges — loaded from the admin GST/GL Settings (sponsor-dd module). */
ddGstPercentage = 18;
/** Admin-configured DD limit (sponsor-dd module) — caps a single DD's face value. Null = no limit. */
ddLimitAmount: number | null = null;
today = '';
rows: TxnRow[] = [];
@@ -889,6 +902,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
});
this.loadCashVaultGl();
this.loadDdGstPercentage();
if (this.reviewMode) {
this.loadRequestIntoForm(this.reviewRequestId!, 'review');
@@ -973,6 +987,18 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
});
}
/** Admin GST rate (set via GST / GL Settings in the sponsor-dd module) applied to DD issuance charges. */
private loadDdGstPercentage(): void {
this.sponsorDDService
.getGlMappings()
.pipe(catchError(() => of([])))
.subscribe((mappings: any[]) => {
const mapping = mappings && mappings.length ? mappings[0] : null;
this.ddGstPercentage = Number(mapping?.gstPercentage ?? 18);
this.ddLimitAmount = mapping?.ddLimitAmount != null ? Number(mapping.ddLimitAmount) : null;
});
}
private loadDayStatus(): void {
this.cashierService
.getCashierDayStatus()
@@ -1139,7 +1165,9 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}
applyDenominationTotal(): void {
if (this.selectedRow) {
// DD + Cash + GL: amount is derived from Face Value + Charges + GST, not from counted cash —
// overwriting it here would feed back into the Charges/GST sum and compound on every click.
if (this.selectedRow && !this.isDdCashGlRow(this.selectedRow)) {
this.selectedRow.amount = this.denominationTotal;
}
}
@@ -1167,7 +1195,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
tokNo: '',
paymentTypeId: this.findTransferPaymentTypeId(),
bankName: '',
routingCode: ''
routingCode: '',
ddCharges: null
};
this.rows.push(mirror);
this.selectedRow = mirror;
@@ -1188,7 +1217,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
tokNo: '',
paymentTypeId: null,
bankName: '',
routingCode: ''
routingCode: '',
ddCharges: null
};
this.rows.push(row);
}
@@ -1227,6 +1257,40 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return pt?.id ?? null;
}
/** TYPE=DD + TXN TP=Cash + AC.TP=GL — the only combination that collects DD charges + GST from the customer. */
isDdCashGlRow(row: TxnRow): boolean {
const pt = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId);
return row.typ === 'DD' && !!pt?.isCashPayment && row.acTyp === 'GL';
}
/** GST on DD charges only (never on the DD face value), at the admin-configured rate. */
ddGstAmountFor(row: TxnRow): number {
const charges = Number(row.ddCharges) || 0;
if (charges <= 0) return 0;
return Math.round(charges * this.ddGstPercentage) / 100;
}
/** Amount + Charges + GST, rounded UP to the next whole rupee — the total collected from the customer. */
ddTotalPayableFor(row: TxnRow): number {
const amount = Number(row.amount) || 0;
const charges = Number(row.ddCharges) || 0;
const gst = this.ddGstAmountFor(row);
return Math.ceil(amount + charges + gst - 1e-9);
}
get isSelectedRowDdCashGl(): boolean {
return !!this.selectedRow && this.isDdCashGlRow(this.selectedRow);
}
/** Cash the cashier must physically count for the selected DD row — Face Value + Charges + GST. */
get ddTargetTotal(): number {
return this.selectedRow ? this.ddTotalPayableFor(this.selectedRow) : 0;
}
ddDenomMatchesTarget(): boolean {
return !!this.selectedRow && Math.abs(this.denominationTotal - this.ddTargetTotal) < 0.005;
}
/**
* Submits a clerk double-entry request (maker-checker, PENDING until manager approves).
* Disbursement: 1 DR [GL] source → N CR savings/current/GL destinations.
@@ -1503,6 +1567,34 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}
}
private static readonly SEARCH_ACCOUNT_TYPE_TO_AC_TYP: Record<string, string> = {
SAVINGS: 'SD',
CURRENT: 'CA',
FIXED_DEPOSIT: 'FD',
RECURRING_DEPOSIT: 'RD'
};
openCustomerSearch(): void {
const dialogRef = this.dialog.open(CustomerSearchDialogComponent, {
width: '960px',
maxWidth: '95vw'
});
dialogRef.afterClosed().subscribe((result: CustomerSearchResult | undefined) => {
if (!result || !result.accountNo) return;
// selectedRow is only set once the clerk types into A/C NO. themselves - on a fresh screen
// it's still null, so fall back to the first (only) row that addRow() created at startup.
const targetRow = this.selectedRow ?? this.rows[0];
if (!targetRow) return;
const acTyp = TransactionEntryComponent.SEARCH_ACCOUNT_TYPE_TO_AC_TYP[result.accountType || ''] || 'SD';
targetRow.acTyp = acTyp;
targetRow.acNo = result.accountNo;
this.selectedRow = targetRow;
this.loadAccountDetails(result.accountNo, acTyp);
});
}
private loadAccountDetails(acNo: string, acTyp: string): void {
this.lastLoadedAcNo = acNo;
this.loadingAccount = true;
@@ -2532,20 +2624,55 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
const selectedPtEarly = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId);
const isCash = !!selectedPtEarly?.isCashPayment;
const isDdCashGl = this.isDdCashGlRow(row);
// Clerk mode: always use manually entered amount; Cashier mode: cash amount comes from denomination
const amount = isCash && !this.clerkMode ? this.denominationTotal : (row.amount ?? this.denominationTotal);
// DD + Cash + GL: amount is DD face value + charges + GST (rounded up), not the counted denomination
const amount = isDdCashGl
? this.ddTotalPayableFor(row)
: isCash && !this.clerkMode
? this.denominationTotal
: (row.amount ?? this.denominationTotal);
if (isCash && this.denominationTotal <= 0 && !this.clerkMode) {
if (isCash && this.denominationTotal <= 0 && !this.clerkMode && !isDdCashGl) {
this.showAlert('error', 'Denomination Required', [
'Cash transaction: Please enter denomination (notes/coins count) first.'
]);
return;
}
// DD + Cash + GL: physical cash counted must reconcile with Face Value + Charges + GST
if (isDdCashGl && !this.clerkMode) {
if (this.denominationTotal <= 0) {
this.showAlert('error', 'Denomination Required', [
`Cash transaction: Please count notes/coins totalling ₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2 })} (DD Face Value + Charges + GST).`
]);
return;
}
if (Math.abs(this.denominationTotal - amount) > 0.005) {
this.showAlert('error', 'Denomination Mismatch', [
`Cash counted: ₹${this.denominationTotal.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
`Total To Collect: ₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2 })} (Face Value + Charges + GST)`,
'Adjust the notes/coins breakdown to match the Total To Collect exactly.'
]);
return;
}
}
if (!amount || amount <= 0) {
this.showAlert('error', 'Amount Required', ['Please enter a valid transaction amount.']);
return;
}
// DD + Cash + GL: reject at cash intake, before the cashier even accepts the money, if the DD
// Face Value exceeds the admin-configured DD limit (same limit enforced again at DD issuance).
if (isDdCashGl && this.ddLimitAmount != null && this.ddLimitAmount > 0 && Number(row.amount) > this.ddLimitAmount) {
this.showAlert('error', 'DD Limit Exceeded', [
`DD Face Value (₹${Number(row.amount).toLocaleString('en-IN', { minimumFractionDigits: 2 })}) exceeds the configured DD limit of ₹${this.ddLimitAmount.toLocaleString('en-IN', { minimumFractionDigits: 2 })}.`,
'Reduce the amount, or issue this as separate DDs within the limit each.'
]);
return;
}
const isDeposit = row.dbCr === 'Cr';
// Clerk cash deposit is not permitted — cash handling (notes/coins) is cashier's responsibility
@@ -2848,7 +2975,12 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
paymentType: selectedPt?.name ?? 'Cash',
chequeNo: row.chequeNo || null,
symbol: this.currencySymbol,
tokNo: this.activeWithdrawalRequest?.tok_no ?? null
tokNo: this.activeWithdrawalRequest?.tok_no ?? null,
isDdCashGl: this.isDdCashGlRow(row),
ddFaceValue: row.amount ?? 0,
ddCharges: row.ddCharges ?? 0,
ddGstPercentage: this.ddGstPercentage,
ddGstAmount: this.ddGstAmountFor(row)
};
this.showConfirmDialog = true;
}
@@ -2856,7 +2988,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
confirmSubmit(): void {
this.showConfirmDialog = false;
const row = this.selectedRow!;
const amount = row.amount ?? this.denominationTotal;
const amount = this.isDdCashGlRow(row) ? this.ddTotalPayableFor(row) : (row.amount ?? this.denominationTotal);
const isDeposit = row.dbCr === 'Cr';
const command = isDeposit ? 'deposit' : 'withdrawal';
const accountId = this.accountDetails.id;
@@ -3261,7 +3393,17 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
description: row.description || null,
chequeNo: row.chequeNo || null,
bankName: row.bankName || null,
denominationData: isCash && this.denominationTotal > 0 ? JSON.stringify(this.denominationCounts) : null
denominationData: isCash && this.denominationTotal > 0 ? JSON.stringify(this.denominationCounts) : null,
// DD + Cash + GL breakdown — persisted so the Sponsor DD issuance screen can read the face
// value/charges/GST back once this request is approved, instead of retyping them.
...(this.isDdCashGlRow(row)
? {
ddFaceValue: row.amount ?? 0,
ddCharges: row.ddCharges ?? 0,
ddGstPercentage: this.ddGstPercentage,
ddGstAmount: this.ddGstAmountFor(row)
}
: {})
};
}