feat: cashier denomination-wise opening & closing balance panels in all deposit/withdrawal forms
OVERVIEW
--------
Added real-time cashier cash position display inside the denomination breakdown
panel of all 4 transaction entry screens: Savings, Current Account, Recurring
Deposit, and Fixed Deposit. Shows opening balance at the top and projected
closing balance at the bottom after factoring in the current transaction.
BACKEND INTEGRATION
-------------------
- New CashierService (src/app/cashier/cashier.service.ts)
getCashierPositionForMe(asOfDate?) calls GET /cashreports/cashier-position/me
Returns denomination-wise opening cash position for the logged-in cashier user.
Non-cashier users receive empty denominations array; UI hides panels silently.
OPENING BALANCE CARD (top of denomination panel)
-------------------------------------------------
- Loaded on ngOnInit() via loadOpeningPosition() in all 4 component .ts files
- Notes and Coins displayed in separate labeled pill sections
- Each pill shows: Rs{value} x{count} format
- Card is hidden entirely if the logged-in user is not a cashier
CLOSING BALANCE CARD (below tally / Apply button)
--------------------------------------------------
- Computed getter closingDenoms() merges opening position with entered denominations
deposit: closingCount = openCount + enteredCount
withdrawal: closingCount = openCount - enteredCount
- Notes and Coins in separate labeled pill sections (identical format to opening)
- Entered/changed denominations (delta != 0): full opacity + bold font weight
- Untouched denominations (delta = 0): 38% opacity, clearly dimmed, no borders
- closingTotal = openingTotal +/- denominationTotal shown in card header
- Card turns green for positive closing balance, red for negative
COMPONENTS UPDATED
------------------
- savings-account-transactions (.ts / .html / .scss)
- current-account-transactions (.ts / .html / .scss)
- deposit-recurring-deposits-account (.ts / .html / .scss)
- fixed-deposits-cash-transaction (.ts / .html / .scss)
CASHIER MODULE (new - src/app/cashier/)
-----------------------------------------
Full cashier workspace module added to the app:
- cashier.service.ts API calls (position, transfers, settlement, etc.)
- cashier.module.ts Angular module declaration
- cashier-routing.module.ts Lazy-loaded routes
- cashier-position/ Cashier cash position dashboard
- cash-exchange-entry/ Cash exchange denomination entry
- cash-history/ Transaction history per cashier
- cash-settlement/ End-of-day cash settlement
- cash-transfer/ Vault-to-cashier cash transfer
- inter-cashier-transfer/ Transfer between cashier drawers
- reconciliation/ Cash reconciliation with GL
- closing-cash-report/ Closing balance report
- currency-exchange-detail/ Currency exchange breakdown
- excess-transaction-report/ Excess/short transaction report
Module wired into app.module.ts and shell nav (shell.component.html).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
555d9fb4b7
commit
7ccaffa7f1
+80
@@ -152,6 +152,40 @@
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<!-- Opening cash position -->
|
||||
@if (openingLoaded && openingDenoms.length > 0) {
|
||||
<div class="sat__pos-card sat__pos-card--open">
|
||||
<div class="sat__pos-header">
|
||||
<span class="sat__pos-label">Opening Balance</span>
|
||||
<span class="sat__pos-total">₹{{ openingTotal | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
@if (openingNotes.length > 0) {
|
||||
<div class="sat__pos-section">
|
||||
<span class="sat__pos-section-lbl">Notes</span>
|
||||
<div class="sat__pos-pills">
|
||||
@for (d of openingNotes; track d.denominationId) {
|
||||
<span class="sat__pos-pill"
|
||||
>₹{{ d.denominationValue }}<span class="sat__pos-pill-x">×{{ d.count }}</span></span
|
||||
>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@if (openingCoins.length > 0) {
|
||||
<div class="sat__pos-section">
|
||||
<span class="sat__pos-section-lbl sat__pos-section-lbl--coin">Coins</span>
|
||||
<div class="sat__pos-pills">
|
||||
@for (d of openingCoins; track d.denominationId) {
|
||||
<span class="sat__pos-pill sat__pos-pill--coin"
|
||||
>₹{{ d.denominationValue }}<span class="sat__pos-pill-x">×{{ d.count }}</span></span
|
||||
>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Denomination table (Denom · Qty · Amount) -->
|
||||
<div class="sat__dt">
|
||||
<div class="sat__dt-head">
|
||||
@@ -292,6 +326,52 @@
|
||||
Use ₹{{ denominationTotal | number: '1.2-2' }} as Transaction Amount
|
||||
</button>
|
||||
</div>
|
||||
<!-- Closing cash position -->
|
||||
@if (openingLoaded && openingDenoms.length > 0) {
|
||||
<div
|
||||
class="sat__pos-card"
|
||||
[class.sat__pos-card--close]="closingTotal >= 0"
|
||||
[class.sat__pos-card--neg]="closingTotal < 0"
|
||||
>
|
||||
<div class="sat__pos-header">
|
||||
<span class="sat__pos-label"
|
||||
>Closing Balance
|
||||
<span class="sat__pos-label-hint"
|
||||
>(after this {{ transactionType.deposit ? 'deposit' : 'withdrawal' }})</span
|
||||
></span
|
||||
>
|
||||
<span class="sat__pos-total" [class.sat__pos-total--neg]="closingTotal < 0"
|
||||
>₹{{ closingTotal | number: '1.2-2' }}</span
|
||||
>
|
||||
</div>
|
||||
@if (denominationTotal > 0 && closingDenoms.length > 0) {
|
||||
@if (closingNotes.length > 0) {
|
||||
<div class="sat__pos-section">
|
||||
<span class="sat__pos-section-lbl">Notes</span>
|
||||
<div class="sat__pos-pills">
|
||||
@for (d of closingNotes; track d.denominationId) {
|
||||
<span class="sat__pos-pill" [class.sat__pos-pill--changed]="d.delta !== 0">
|
||||
₹{{ d.denominationValue }}<span class="sat__pos-pill-x">×{{ d.closingCount }}</span>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@if (closingCoins.length > 0) {
|
||||
<div class="sat__pos-section">
|
||||
<span class="sat__pos-section-lbl sat__pos-section-lbl--coin">Coins</span>
|
||||
<div class="sat__pos-pills">
|
||||
@for (d of closingCoins; track d.denominationId) {
|
||||
<span class="sat__pos-pill sat__pos-pill--coin" [class.sat__pos-pill--changed]="d.delta !== 0">
|
||||
₹{{ d.denominationValue }}<span class="sat__pos-pill-x">×{{ d.closingCount }}</span>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
<!-- ─── RIGHT COLUMN: transaction + payment details ────────────── -->
|
||||
|
||||
+215
@@ -827,6 +827,221 @@ $red: #b91c1c;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Opening / Closing position cards ───────────────────────────── */
|
||||
.sat__pos-card {
|
||||
margin: 0 14px 12px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #bfdbfe;
|
||||
background: #eff6ff;
|
||||
|
||||
&--open {
|
||||
border-color: #bfdbfe;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
&--close {
|
||||
border-color: #a7f3d0;
|
||||
background: #ecfdf5;
|
||||
}
|
||||
|
||||
&--neg {
|
||||
border-color: #fca5a5;
|
||||
background: #fef2f2;
|
||||
}
|
||||
}
|
||||
|
||||
.sat__pos-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.sat__pos-label {
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: #1e40af;
|
||||
|
||||
.sat__pos-card--close & {
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
.sat__pos-card--neg & {
|
||||
color: #991b1b;
|
||||
}
|
||||
}
|
||||
|
||||
.sat__pos-label-hint {
|
||||
font-size: 9px;
|
||||
font-weight: 500;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.sat__pos-total {
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
color: #1e40af;
|
||||
font-variant-numeric: tabular-nums;
|
||||
|
||||
.sat__pos-card--close & {
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
&--neg,
|
||||
.sat__pos-card--neg & {
|
||||
color: #991b1b;
|
||||
}
|
||||
}
|
||||
|
||||
.sat__pos-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 4px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.sat__pos-section-lbl {
|
||||
font-size: 9px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
color: #1e40af;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
|
||||
&--coin {
|
||||
color: #92400e;
|
||||
}
|
||||
}
|
||||
|
||||
.sat__pos-pills {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.sat__pos-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
height: 20px;
|
||||
padding: 0 7px;
|
||||
border-radius: 4px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
background: #dbeafe;
|
||||
color: #1e40af;
|
||||
border: 1px solid #93c5fd;
|
||||
|
||||
&--coin {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
border-color: #fcd34d;
|
||||
}
|
||||
}
|
||||
|
||||
.sat__pos-pill-x {
|
||||
font-size: 9.5px;
|
||||
font-weight: 600;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.sat__pos-pill--changed {
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.sat__pos-card--close,
|
||||
.sat__pos-card--neg {
|
||||
.sat__pos-pill:not(.sat__pos-pill--changed) {
|
||||
opacity: 0.38;
|
||||
}
|
||||
}
|
||||
|
||||
.sat__pos-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 11.5px;
|
||||
margin-top: 2px;
|
||||
|
||||
td {
|
||||
padding: 2px 5px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
.sat__pos-sep td {
|
||||
padding: 4px 5px 2px;
|
||||
background: #eff6ff;
|
||||
border-top: 1px solid #bfdbfe;
|
||||
}
|
||||
|
||||
.sat__pos-sep--coin td {
|
||||
background: #fffbeb;
|
||||
border-top-color: #fde68a;
|
||||
}
|
||||
|
||||
.sat__pos-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 40px;
|
||||
height: 18px;
|
||||
padding: 0 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 800;
|
||||
background: #cffafe;
|
||||
color: #155e75;
|
||||
border: 1px solid #a5f3fc;
|
||||
|
||||
&--coin {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
border-color: #fcd34d;
|
||||
border-radius: 50%;
|
||||
min-width: 26px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
padding: 0;
|
||||
font-size: 9px;
|
||||
}
|
||||
}
|
||||
|
||||
.sat__pos-eq {
|
||||
font-size: 11.5px;
|
||||
color: #475569;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sat__pos-delta--pos {
|
||||
color: #15803d;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.sat__pos-delta--neg {
|
||||
color: #dc2626;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.sat__pos-amt {
|
||||
text-align: right;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: #0d1b2a;
|
||||
}
|
||||
|
||||
/* ─── Actions ─────────────────────────────────────────────────────── */
|
||||
.sat__actions {
|
||||
display: flex;
|
||||
|
||||
+96
@@ -22,6 +22,7 @@ import { SavingsService } from '../../savings.service';
|
||||
import { DenominationMasterService } from 'app/system/denomination-master/denomination-master.service';
|
||||
import { ClientsService } from 'app/clients/clients.service';
|
||||
import { SettingsService } from 'app/settings/settings.service';
|
||||
import { CashierService } from 'app/cashier/cashier.service';
|
||||
import { Dates } from 'app/core/utils/dates';
|
||||
import { Currency } from 'app/shared/models/general.model';
|
||||
import { InputAmountComponent } from '../../../shared/input-amount/input-amount.component';
|
||||
@@ -72,6 +73,7 @@ export class SavingsAccountTransactionsComponent implements OnInit {
|
||||
private settingsService = inject(SettingsService);
|
||||
private dialog = inject(MatDialog);
|
||||
private denominationMasterService = inject(DenominationMasterService);
|
||||
private cashierService = inject(CashierService);
|
||||
|
||||
/** Minimum Due Date allowed. */
|
||||
minDate = new Date(2000, 0, 1);
|
||||
@@ -131,6 +133,26 @@ export class SavingsAccountTransactionsComponent implements OnInit {
|
||||
* at least once on this transaction; toggles off the attention pulse. */
|
||||
identityVerified = false;
|
||||
|
||||
/** Denomination-wise cashier opening position for today. */
|
||||
openingDenoms: any[] = [];
|
||||
/** Total cash amount the cashier holds at the start. */
|
||||
openingTotal = 0;
|
||||
/** True once the opening-position API has responded (success or error). */
|
||||
openingLoaded = false;
|
||||
|
||||
get openingNotes(): any[] {
|
||||
return this.openingDenoms.filter((d) => d.kind !== 'coin');
|
||||
}
|
||||
get openingCoins(): any[] {
|
||||
return this.openingDenoms.filter((d) => d.kind === 'coin');
|
||||
}
|
||||
get closingNotes(): any[] {
|
||||
return this.closingDenoms.filter((d) => d.kind !== 'coin');
|
||||
}
|
||||
get closingCoins(): any[] {
|
||||
return this.closingDenoms.filter((d) => d.kind === 'coin');
|
||||
}
|
||||
|
||||
constructor() {
|
||||
this.route.data.subscribe((data: { savingsAccountActionData: any }) => {
|
||||
this.paymentTypeOptions = data.savingsAccountActionData.paymentTypeOptions;
|
||||
@@ -148,6 +170,7 @@ export class SavingsAccountTransactionsComponent implements OnInit {
|
||||
this.createSavingAccountTransactionForm();
|
||||
this.loadDenominations();
|
||||
this.loadContextStrip();
|
||||
this.loadOpeningPosition();
|
||||
}
|
||||
|
||||
/** Load active denominations from the Denomination Master so the cash
|
||||
@@ -208,6 +231,79 @@ export class SavingsAccountTransactionsComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
private loadOpeningPosition(): void {
|
||||
const today = new Date();
|
||||
const asOfDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
||||
this.cashierService.getCashierPositionForMe(asOfDate).subscribe({
|
||||
next: (res: any) => {
|
||||
this.openingDenoms = res?.denominations || [];
|
||||
this.openingTotal = Number(res?.totalAmount || 0);
|
||||
this.openingLoaded = true;
|
||||
},
|
||||
error: () => {
|
||||
this.openingDenoms = [];
|
||||
this.openingTotal = 0;
|
||||
this.openingLoaded = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Denomination-wise projected closing position after this transaction. */
|
||||
get closingDenoms(): Array<{
|
||||
denominationId: number;
|
||||
denominationValue: number;
|
||||
kind: string;
|
||||
openCount: number;
|
||||
delta: number;
|
||||
closingCount: number;
|
||||
closingAmount: number;
|
||||
}> {
|
||||
const openMap = new Map<string, any>();
|
||||
for (const d of this.openingDenoms) {
|
||||
openMap.set(`${d.kind}:${d.denominationValue}`, d);
|
||||
}
|
||||
const keySet = new Set<string>();
|
||||
for (const d of [
|
||||
...this.notes,
|
||||
...this.coins
|
||||
]) keySet.add(this.denomKey(d));
|
||||
for (const k of openMap.keys()) keySet.add(k);
|
||||
|
||||
const sign = this.transactionType.deposit ? 1 : -1;
|
||||
const result: any[] = [];
|
||||
for (const key of keySet) {
|
||||
const open = openMap.get(key);
|
||||
const denom = [
|
||||
...this.notes,
|
||||
...this.coins
|
||||
].find((d) => this.denomKey(d) === key);
|
||||
const openCount = open?.count ?? 0;
|
||||
const enteredCount = denom ? this.countFor(denom) : 0;
|
||||
const delta = enteredCount * sign;
|
||||
const closingCount = openCount + delta;
|
||||
const denomValue = open?.denominationValue ?? denom?.value ?? 0;
|
||||
const kind = (open?.kind ?? denom?.kind ?? 'note') as string;
|
||||
if (openCount !== 0 || enteredCount !== 0) {
|
||||
result.push({
|
||||
denominationId: open?.denominationId ?? denom?.id ?? 0,
|
||||
denominationValue: denomValue,
|
||||
kind,
|
||||
openCount,
|
||||
delta,
|
||||
closingCount,
|
||||
closingAmount: closingCount * denomValue
|
||||
});
|
||||
}
|
||||
}
|
||||
return result.sort((a, b) => b.denominationValue - a.denominationValue || a.kind.localeCompare(b.kind));
|
||||
}
|
||||
|
||||
/** Projected total cash position after this transaction completes. */
|
||||
get closingTotal(): number {
|
||||
const sign = this.transactionType.deposit ? 1 : -1;
|
||||
return this.openingTotal + this.denominationTotal * sign;
|
||||
}
|
||||
|
||||
private fetchClientKyc(clientId: string): void {
|
||||
this.clientsService.getClientKyc(clientId).subscribe({
|
||||
next: (kyc: any) => {
|
||||
|
||||
Reference in New Issue
Block a user