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:
Jaanu423
2026-05-28 17:39:31 +05:30
co-authored by Claude Sonnet 4.6
parent 555d9fb4b7
commit 7ccaffa7f1
43 changed files with 5302 additions and 8 deletions
+2
View File
@@ -39,6 +39,7 @@ import { AccountingModule } from './accounting/accounting.module';
import { SystemModule } from './system/system.module';
import { ProductsModule } from './products/products.module';
import { OrganizationModule } from './organization/organization.module';
import { CashierModule } from './cashier/cashier.module';
import { TemplatesModule } from './templates/templates.module';
import { UsersModule } from './users/users.module';
import { ReportsModule } from './reports/reports.module';
@@ -143,6 +144,7 @@ export function HttpLoaderFactory(http: HttpClient) {
SystemModule,
ProductsModule,
OrganizationModule,
CashierModule,
TemplatesModule,
UsersModule,
NotificationsModule,
@@ -0,0 +1,160 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="cx">
<header class="cx-head">
<div>
<div class="cx-eyebrow">CASHIER · CASH EXCHANGE</div>
<h1 class="cx-title">Cash Exchange Entry</h1>
<p class="cx-sub">Customer gives notes/coins and takes equal-value change. Net amount must be ₹0.</p>
</div>
<div class="cx-toolbar">
<label class="cx-field">
<span>Date</span>
<input type="date" [value]="exchangeDate" (change)="exchangeDate = $any($event.target).value" />
</label>
<button type="button" class="cx-btn cx-btn--ghost" (click)="clear()"><mat-icon>undo</mat-icon> Clear</button>
</div>
</header>
<div class="cx-cols">
<!-- RECEIVED -->
<section class="cx-col cx-col--received">
<header class="cx-col-head">
<mat-icon>south</mat-icon>
<span class="cx-col-title">Received from customer</span>
<span class="cx-col-total">₹{{ receivedTotal | number: '1.2-2' }}</span>
</header>
<div class="cx-list">
@if (notes.length) {
<div class="cx-subhead">Bank Notes</div>
}
@for (d of notes; track d.id) {
<div class="cx-row">
<span class="cx-chip">{{ d.label }}</span>
<div class="cx-step">
<button type="button" (click)="bump('r', d, -1)" [disabled]="countFor('r', d) === 0">−</button>
<input
type="number"
min="0"
[value]="countFor('r', d)"
(input)="onChange('r', d, $any($event.target).value)"
/>
<button type="button" (click)="bump('r', d, 1)">+</button>
</div>
<span class="cx-amt" [class.cx-amt--zero]="countFor('r', d) === 0"
>₹{{ subtotal('r', d) | number: '1.2-2' }}</span
>
</div>
}
@if (coins.length) {
<div class="cx-subhead">Coins</div>
}
@for (d of coins; track d.id) {
<div class="cx-row">
<span class="cx-chip cx-chip--coin">{{ d.label }}</span>
<div class="cx-step">
<button type="button" (click)="bump('r', d, -1)" [disabled]="countFor('r', d) === 0">−</button>
<input
type="number"
min="0"
[value]="countFor('r', d)"
(input)="onChange('r', d, $any($event.target).value)"
/>
<button type="button" (click)="bump('r', d, 1)">+</button>
</div>
<span class="cx-amt" [class.cx-amt--zero]="countFor('r', d) === 0"
>₹{{ subtotal('r', d) | number: '1.2-2' }}</span
>
</div>
}
</div>
</section>
<!-- GIVEN -->
<section class="cx-col cx-col--given">
<header class="cx-col-head">
<mat-icon>north</mat-icon>
<span class="cx-col-title">Given to customer</span>
<span class="cx-col-total">₹{{ givenTotal | number: '1.2-2' }}</span>
</header>
<div class="cx-list">
@if (notes.length) {
<div class="cx-subhead">Bank Notes</div>
}
@for (d of notes; track d.id) {
<div class="cx-row">
<span class="cx-chip">{{ d.label }}</span>
<div class="cx-step">
<button type="button" (click)="bump('g', d, -1)" [disabled]="countFor('g', d) === 0">−</button>
<input
type="number"
min="0"
[value]="countFor('g', d)"
(input)="onChange('g', d, $any($event.target).value)"
/>
<button type="button" (click)="bump('g', d, 1)">+</button>
</div>
<span class="cx-amt" [class.cx-amt--zero]="countFor('g', d) === 0"
>₹{{ subtotal('g', d) | number: '1.2-2' }}</span
>
</div>
}
@if (coins.length) {
<div class="cx-subhead">Coins</div>
}
@for (d of coins; track d.id) {
<div class="cx-row">
<span class="cx-chip cx-chip--coin">{{ d.label }}</span>
<div class="cx-step">
<button type="button" (click)="bump('g', d, -1)" [disabled]="countFor('g', d) === 0">−</button>
<input
type="number"
min="0"
[value]="countFor('g', d)"
(input)="onChange('g', d, $any($event.target).value)"
/>
<button type="button" (click)="bump('g', d, 1)">+</button>
</div>
<span class="cx-amt" [class.cx-amt--zero]="countFor('g', d) === 0"
>₹{{ subtotal('g', d) | number: '1.2-2' }}</span
>
</div>
}
</div>
</section>
</div>
<div class="cx-summary">
<div class="cx-sum">
<span>Received</span><strong>₹{{ receivedTotal | number: '1.2-2' }}</strong>
</div>
<mat-icon>swap_horiz</mat-icon>
<div class="cx-sum">
<span>Given</span><strong>₹{{ givenTotal | number: '1.2-2' }}</strong>
</div>
<div class="cx-sum">
<span>Difference</span>
<strong [class.cx-bad]="difference !== 0" [class.cx-good]="matches">₹{{ difference | number: '1.2-2' }}</strong>
</div>
</div>
@if (receivedTotal > 0 && !matches) {
<div class="cx-warn"><mat-icon>warning</mat-icon> Received and Given totals must match (net ₹0).</div>
}
<div class="cx-actions">
<label class="cx-field cx-field--note">
<span>Note</span>
<input type="text" [value]="note" (input)="note = $any($event.target).value" placeholder="Optional" />
</label>
<button type="button" class="cx-btn cx-btn--primary" [disabled]="!matches || saving" (click)="submit()">
<mat-icon>swap_horiz</mat-icon> Confirm Exchange
</button>
</div>
</div>
@@ -0,0 +1,336 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
:host {
display: block;
}
.cx {
padding: 20px 24px;
display: flex;
flex-direction: column;
gap: 16px;
}
.cx-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.cx-eyebrow {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.1em;
color: #0f4f3c;
}
.cx-title {
font-size: 26px;
font-weight: 600;
margin: 4px 0 2px;
color: #1a1a1a;
}
.cx-sub {
font-size: 13px;
color: #64748b;
margin: 0;
}
.cx-toolbar {
display: flex;
align-items: flex-end;
gap: 10px;
}
.cx-field {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 11px;
font-weight: 600;
color: #64748b;
input {
height: 36px;
padding: 0 10px;
border: 1px solid #e2e8f0;
border-radius: 8px;
font-size: 13px;
color: #0d1b2a;
}
&--note {
flex: 1 1 auto;
}
}
.cx-cols {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
.cx-col {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
overflow: hidden;
}
.cx-col-head {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
color: #fff;
mat-icon {
font-size: 18px;
height: 18px;
width: 18px;
}
.cx-col-title {
flex: 1 1 auto;
font-weight: 700;
font-size: 13px;
}
.cx-col-total {
font-weight: 800;
font-variant-numeric: tabular-nums;
}
}
.cx-col--received .cx-col-head {
background: linear-gradient(135deg, #047857, #059669);
}
.cx-col--given .cx-col-head {
background: linear-gradient(135deg, #b45309, #d97706);
}
.cx-list {
padding: 6px 0;
max-height: 360px;
overflow-y: auto;
}
.cx-subhead {
font-size: 10px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.07em;
color: #94a3b8;
padding: 8px 14px 4px;
}
.cx-row {
display: grid;
grid-template-columns: 64px 1fr 96px;
align-items: center;
gap: 8px;
padding: 6px 14px;
border-bottom: 1px dotted #eef2f7;
}
.cx-chip {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 52px;
height: 26px;
padding: 0 8px;
border-radius: 6px;
font-size: 12px;
font-weight: 700;
background: linear-gradient(135deg, #ecfeff, #cffafe);
color: #155e75;
border: 1px solid #a5f3fc;
&--coin {
border-radius: 50%;
min-width: 34px;
width: 34px;
height: 34px;
padding: 0;
background: linear-gradient(135deg, #fef9c3, #fde68a);
color: #854d0e;
border: 1px solid #fcd34d;
}
}
.cx-step {
display: flex;
align-items: stretch;
height: 30px;
width: 120px;
border: 1px solid #e2e8f0;
border-radius: 7px;
overflow: hidden;
button {
width: 30px;
background: #f8fafc;
border: none;
font-size: 16px;
font-weight: 700;
color: #475569;
cursor: pointer;
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
}
input {
flex: 1 1 auto;
width: 100%;
border: none;
outline: none;
text-align: center;
font-size: 13px;
font-weight: 700;
color: #0d1b2a;
appearance: textfield;
&::-webkit-outer-spin-button,
&::-webkit-inner-spin-button {
appearance: none;
margin: 0;
}
}
}
.cx-amt {
text-align: right;
font-size: 13px;
font-weight: 700;
color: #0d1b2a;
font-variant-numeric: tabular-nums;
&--zero {
color: #cbd5e1;
font-weight: 600;
}
}
.cx-summary {
display: flex;
align-items: center;
justify-content: center;
gap: 22px;
padding: 14px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 10px;
mat-icon {
color: #94a3b8;
}
}
.cx-sum {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
span {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #64748b;
}
strong {
font-size: 16px;
font-weight: 800;
color: #0d1b2a;
font-variant-numeric: tabular-nums;
}
}
.cx-bad {
color: #b91c1c;
}
.cx-good {
color: #047857;
}
.cx-warn {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border-radius: 8px;
background: #fffbeb;
border: 1px solid #fde68a;
color: #92400e;
font-size: 13px;
mat-icon {
font-size: 18px;
height: 18px;
width: 18px;
}
}
.cx-actions {
display: flex;
align-items: flex-end;
gap: 12px;
}
.cx-btn {
display: inline-flex;
align-items: center;
gap: 6px;
height: 40px;
padding: 0 18px;
border-radius: 999px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
border: 1px solid transparent;
mat-icon {
font-size: 18px;
height: 18px;
width: 18px;
}
&--ghost {
background: #fff;
border-color: #e2e8f0;
color: #475569;
}
&--primary {
background: linear-gradient(135deg, #1f7a4d, #0f4f3c);
color: #fff;
&:disabled {
opacity: 0.45;
cursor: not-allowed;
}
}
}
@media (width <= 820px) {
.cx-cols {
grid-template-columns: 1fr;
}
}
@@ -0,0 +1,142 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { CashierService } from '../cashier.service';
interface Denom {
id: number;
value: number;
kind: 'note' | 'coin';
label: string;
}
/** Cashier cash-exchange entry: customer gives some denominations and takes equal-value change (net 0). */
@Component({
selector: 'mifosx-cash-exchange-entry',
templateUrl: './cash-exchange-entry.component.html',
styleUrls: ['./cash-exchange-entry.component.scss'],
standalone: true,
imports: [
CommonModule,
FormsModule,
MatIconModule,
MatSnackBarModule
]
})
export class CashExchangeEntryComponent implements OnInit {
private service = inject(CashierService);
private snackBar = inject(MatSnackBar);
notes: Denom[] = [];
coins: Denom[] = [];
receivedCounts: Record<number, number> = {};
givenCounts: Record<number, number> = {};
exchangeDate = '';
note = '';
saving = false;
/** All denominations (notes then coins) — used for totals and the submit payload. */
get denoms(): Denom[] {
return [
...this.notes,
...this.coins
];
}
ngOnInit(): void {
const d = new Date();
this.exchangeDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.service.getActiveDenominations().subscribe({
next: (list) => {
const rows: Denom[] = (list || []).map((x) => ({
id: x.id,
value: x.denomination,
kind: x.kind,
label: '₹' + x.denomination
}));
this.notes = rows.filter((r) => r.kind === 'note').sort((a, b) => b.value - a.value);
this.coins = rows.filter((r) => r.kind === 'coin').sort((a, b) => b.value - a.value);
},
error: () => {
this.notes = [];
this.coins = [];
}
});
}
countFor(side: 'r' | 'g', d: Denom): number {
return (side === 'r' ? this.receivedCounts : this.givenCounts)[d.id] || 0;
}
subtotal(side: 'r' | 'g', d: Denom): number {
return this.countFor(side, d) * d.value;
}
onChange(side: 'r' | 'g', d: Denom, raw: any): void {
(side === 'r' ? this.receivedCounts : this.givenCounts)[d.id] = Math.max(0, Math.floor(Number(raw) || 0));
}
bump(side: 'r' | 'g', d: Denom, delta: number): void {
const map = side === 'r' ? this.receivedCounts : this.givenCounts;
map[d.id] = Math.max(0, (map[d.id] || 0) + delta);
}
get receivedTotal(): number {
return this.denoms.reduce((s, d) => s + this.subtotal('r', d), 0);
}
get givenTotal(): number {
return this.denoms.reduce((s, d) => s + this.subtotal('g', d), 0);
}
get difference(): number {
return this.receivedTotal - this.givenTotal;
}
get matches(): boolean {
return this.receivedTotal > 0 && this.receivedTotal === this.givenTotal;
}
clear(): void {
this.receivedCounts = {};
this.givenCounts = {};
}
submit(): void {
if (!this.matches || this.saving) {
return;
}
this.saving = true;
const build = (side: 'r' | 'g') =>
this.denoms
.filter((d) => this.countFor(side, d) > 0)
.map((d) => ({ denominationId: d.id, denominationValue: d.value, count: this.countFor(side, d) }));
const payload = {
exchangeDate: this.exchangeDate,
note: this.note || null,
received: build('r'),
given: build('g')
};
this.service.createCashExchange(payload).subscribe({
next: () => {
this.snackBar.open('Cash exchange recorded', 'Close', { duration: 3000 });
this.clear();
this.note = '';
this.saving = false;
},
error: () => {
this.saving = false;
}
});
}
}
@@ -0,0 +1,106 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="pg">
<header class="pg-head">
<div>
<div class="pg-eyebrow">CASHIER · HISTORY</div>
<h1 class="pg-title">Cash Movement History</h1>
<p class="pg-sub">All transfers, settlements, and inter-cashier movements. Reverse any active transaction.</p>
</div>
<button type="button" class="pg-btn pg-btn--ghost" (click)="load()"><mat-icon>refresh</mat-icon> Refresh</button>
</header>
<!-- Reversal confirm panel -->
@if (reversingId !== null) {
<div class="reverse-panel">
<mat-icon class="reverse-icon">warning</mat-icon>
<span
>Reverse <strong>#{{ reversingId }}</strong
>? This cannot be undone.</span
>
<input class="reverse-reason" type="text" [(ngModel)]="reverseReason" placeholder="Reason (optional)" />
<button type="button" class="pg-btn pg-btn--danger" (click)="confirmReverse()">Confirm Reversal</button>
<button type="button" class="pg-btn pg-btn--ghost" (click)="cancelReverse()">Cancel</button>
</div>
}
@if (loading) {
<div class="pg-empty">Loading…</div>
}
<mat-tab-group animationDuration="150ms">
<mat-tab label="Cash Transfers ({{ transfers.length }})">
<div class="pg-tab-body">
<ng-container *ngTemplateOutlet="txnTable; context: { rows: transfers }"></ng-container>
</div>
</mat-tab>
<mat-tab label="Settlements ({{ settlements.length }})">
<div class="pg-tab-body">
<ng-container *ngTemplateOutlet="txnTable; context: { rows: settlements }"></ng-container>
</div>
</mat-tab>
<mat-tab label="Inter-Cashier ({{ interCashier.length }})">
<div class="pg-tab-body">
<ng-container *ngTemplateOutlet="txnTable; context: { rows: interCashier }"></ng-container>
</div>
</mat-tab>
</mat-tab-group>
</div>
<ng-template #txnTable let-rows="rows">
@if (rows.length === 0 && !loading) {
<div class="pg-empty">No records.</div>
}
@if (rows.length > 0) {
<div class="pg-card" style="margin-top: 16px">
<table class="pg-table">
<thead>
<tr>
<th>#</th>
<th>Date</th>
<th>From</th>
<th>To</th>
<th class="num">Amount</th>
<th>Note</th>
<th>By</th>
<th style="text-align: center">Status</th>
<th style="text-align: center">Action</th>
</tr>
</thead>
<tbody>
@for (r of rows; track r.id) {
<tr [class.row-reversed]="r.isReversed">
<td>{{ r.id }}</td>
<td>{{ r.date }}</td>
<td>{{ r.fromParty }}</td>
<td>{{ r.toParty }}</td>
<td class="num">₹{{ r.amount | number: '1.2-2' }}</td>
<td>{{ r.note || '—' }}</td>
<td>{{ r.performedByUsername || '—' }}</td>
<td style="text-align: center">
<span class="badge" [class.badge-active]="!r.isReversed" [class.badge-reversed]="r.isReversed">
{{ r.isReversed ? 'Reversed' : 'Active' }}
</span>
</td>
<td style="text-align: center">
@if (!r.isReversed) {
<button type="button" class="pg-btn pg-btn--ghost btn-sm" (click)="startReverse(r)">
<mat-icon>undo</mat-icon>
</button>
}
</td>
</tr>
}
</tbody>
</table>
</div>
}
</ng-template>
@@ -0,0 +1,91 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
.pg-tab-body {
padding: 0;
}
.row-reversed td {
opacity: 0.5;
text-decoration: line-through;
}
.badge {
display: inline-block;
padding: 2px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.badge-active {
background: #dcfce7;
color: #15803d;
}
.badge-reversed {
background: #fee2e2;
color: #991b1b;
}
.btn-sm {
height: 28px;
padding: 0 8px;
font-size: 12px;
mat-icon {
font-size: 16px;
height: 16px;
width: 16px;
}
}
.reverse-panel {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: #fffbeb;
border: 1px solid #fcd34d;
border-radius: 10px;
flex-wrap: wrap;
font-size: 13px;
color: #92400e;
.reverse-icon {
color: #f59e0b;
font-size: 20px;
height: 20px;
width: 20px;
}
.reverse-reason {
flex: 1 1 200px;
height: 34px;
padding: 0 10px;
border: 1px solid #fcd34d;
border-radius: 7px;
font-size: 13px;
font-family: inherit;
background: #fff;
color: #1a1a1a;
}
}
.pg-btn--danger {
background: linear-gradient(135deg, #ef4444, #b91c1c);
color: #fff;
border: none;
&:disabled {
opacity: 0.45;
cursor: not-allowed;
}
}
@@ -0,0 +1,146 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { MatTabsModule } from '@angular/material/tabs';
import { forkJoin, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
interface TxnRow {
id: number;
date: string;
fromParty: string;
toParty: string;
amount: number;
note: string;
isReversed: boolean;
performedByUsername: string;
createdAt: string;
type: 'transfer' | 'settlement' | 'inter';
}
/** Unified history of all cash movements with reversal support. */
@Component({
selector: 'mifosx-cash-history',
templateUrl: './cash-history.component.html',
styleUrls: [
'../cashier-shared.scss',
'./cash-history.component.scss'
],
standalone: true,
imports: [
CommonModule,
FormsModule,
MatIconModule,
MatSnackBarModule,
MatTabsModule
]
})
export class CashHistoryComponent implements OnInit {
private service = inject(CashierService);
private snackBar = inject(MatSnackBar);
transfers: TxnRow[] = [];
settlements: TxnRow[] = [];
interCashier: TxnRow[] = [];
loading = false;
reversingId: number | null = null;
reversingType: string | null = null;
reverseReason = '';
ngOnInit(): void {
this.load();
}
load(): void {
this.loading = true;
forkJoin({
transfers: this.service.getCashTransfers().pipe(catchError(() => of([]))),
settlements: this.service.getCashSettlements().pipe(catchError(() => of([]))),
inter: this.service.getInterCashierTransfers().pipe(catchError(() => of([])))
}).subscribe(({ transfers, settlements, inter }) => {
this.transfers = (transfers as any[]).map((t) => ({
id: t.id,
date: t.transferDate,
fromParty: t.fromParty,
toParty: t.toParty,
amount: t.transferAmount,
note: t.note,
isReversed: t.isReversed,
performedByUsername: t.performedByUsername,
createdAt: t.createdAt,
type: 'transfer'
}));
this.settlements = (settlements as any[]).map((s) => ({
id: s.id,
date: s.settlementDate,
fromParty: s.fromParty,
toParty: s.toParty,
amount: s.settlementAmount,
note: s.note,
isReversed: s.isReversed,
performedByUsername: s.performedByUsername,
createdAt: s.createdAt,
type: 'settlement'
}));
this.interCashier = (inter as any[]).map((i) => ({
id: i.id,
date: i.transferDate,
fromParty: i.fromParty,
toParty: i.toParty,
amount: i.transferAmount,
note: i.note,
isReversed: i.isReversed,
performedByUsername: i.performedByUsername,
createdAt: i.createdAt,
type: 'inter'
}));
this.loading = false;
});
}
startReverse(row: TxnRow): void {
this.reversingId = row.id;
this.reversingType = row.type;
this.reverseReason = '';
}
cancelReverse(): void {
this.reversingId = null;
this.reversingType = null;
this.reverseReason = '';
}
confirmReverse(): void {
if (!this.reversingId || !this.reversingType) return;
const id = this.reversingId;
const type = this.reversingType;
const reason = this.reverseReason;
this.cancelReverse();
let obs$;
if (type === 'transfer') obs$ = this.service.reverseCashTransfer(id, reason);
else if (type === 'settlement') obs$ = this.service.reverseCashSettlement(id, reason);
else obs$ = this.service.reverseInterCashierTransfer(id, reason);
obs$.pipe(catchError(() => of(null))).subscribe((res: any) => {
if (res?.reversed) {
this.snackBar.open('Transaction reversed successfully', 'Close', { duration: 3000 });
this.load();
} else {
this.snackBar.open('Reversal failed', 'Close', { duration: 3000 });
}
});
}
}
@@ -0,0 +1,124 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="pg">
<header class="pg-head">
<div>
<div class="pg-eyebrow">CASHIER · CASH SETTLEMENT</div>
<h1 class="pg-title">Cash Settlement</h1>
<p class="pg-sub">Cashier returns denomination-wise cash to teller / vault at end of day.</p>
</div>
<button type="button" class="pg-btn pg-btn--ghost" (click)="clear()"><mat-icon>undo</mat-icon> Clear</button>
</header>
<div class="pg-toolbar pg-toolbar--mat">
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Teller (To / Vault)</mat-label>
<mat-select [value]="selectedTellerId" (selectionChange)="onTellerSelect($event.value)">
@for (t of tellers; track t.id) {
<mat-option [value]="t.id">{{ t.name }}</mat-option>
}
@if (tellers.length === 0) {
<mat-option [value]="null" disabled>No tellers configured</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>From (Cashier)</mat-label>
<mat-select
[value]="fromCashierId"
(selectionChange)="onCashierSelect($event.value)"
[disabled]="!selectedTellerId"
>
@for (c of cashiers; track c.id) {
<mat-option [value]="c.id">{{ c.label }}</mat-option>
}
@if (selectedTellerId && cashiers.length === 0 && !loadingCashiers) {
<mat-option [value]="null" disabled>No cashiers for this teller</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Date</mat-label>
<input matInput type="date" [value]="settlementDate" (change)="settlementDate = $any($event.target).value" />
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="pg-note-field">
<mat-label>Note</mat-label>
<input matInput type="text" [value]="note" (input)="note = $any($event.target).value" placeholder="Optional" />
</mat-form-field>
</div>
<div class="pg-card">
<table class="pg-table">
<thead>
<tr>
<th>Denomination</th>
<th style="text-align: center">Count</th>
<th class="num">Amount</th>
</tr>
</thead>
<tbody>
@if (notes.length) {
<tr class="pg-section">
<td colspan="3">Bank Notes</td>
</tr>
}
@for (d of notes; track d.id) {
<tr>
<td>
<span class="pg-chip">{{ d.label }}</span>
</td>
<td style="display: flex; justify-content: center">
<div class="pg-step">
<button type="button" (click)="bump(d, -1)" [disabled]="countFor(d) === 0">−</button>
<input type="number" min="0" [value]="countFor(d)" (input)="onChange(d, $any($event.target).value)" />
<button type="button" (click)="bump(d, 1)">+</button>
</div>
</td>
<td class="num">₹{{ subtotal(d) | number: '1.2-2' }}</td>
</tr>
}
@if (coins.length) {
<tr class="pg-section">
<td colspan="3">Coins</td>
</tr>
}
@for (d of coins; track d.id) {
<tr>
<td>
<span class="pg-chip pg-chip--coin">{{ d.label }}</span>
</td>
<td style="display: flex; justify-content: center">
<div class="pg-step">
<button type="button" (click)="bump(d, -1)" [disabled]="countFor(d) === 0">−</button>
<input type="number" min="0" [value]="countFor(d)" (input)="onChange(d, $any($event.target).value)" />
<button type="button" (click)="bump(d, 1)">+</button>
</div>
</td>
<td class="num">₹{{ subtotal(d) | number: '1.2-2' }}</td>
</tr>
}
@if (denoms.length === 0) {
<tr>
<td colspan="3" class="pg-empty">No active denominations. Add some in Cash Denomination.</td>
</tr>
}
</tbody>
</table>
</div>
<div class="pg-total-bar">
Total to settle: <strong>₹{{ total | number: '1.2-2' }}</strong>
<button type="button" class="pg-btn pg-btn--primary" [disabled]="!valid || saving" (click)="submit()">
<mat-icon>check_circle</mat-icon> Record Settlement
</button>
</div>
</div>
@@ -0,0 +1,176 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatSelectModule } from '@angular/material/select';
import { MatInputModule } from '@angular/material/input';
import { of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
interface Denom {
id: number;
value: number;
kind: 'note' | 'coin';
label: string;
}
/** Cashier hands back denomination-wise cash to teller/vault (closing settlement). */
@Component({
selector: 'mifosx-cash-settlement',
templateUrl: './cash-settlement.component.html',
styleUrls: ['../cashier-shared.scss'],
standalone: true,
imports: [
CommonModule,
FormsModule,
MatIconModule,
MatSnackBarModule,
MatFormFieldModule,
MatSelectModule,
MatInputModule
]
})
export class CashSettlementComponent implements OnInit {
private service = inject(CashierService);
private snackBar = inject(MatSnackBar);
notes: Denom[] = [];
coins: Denom[] = [];
counts: Record<number, number> = {};
settlementDate = '';
note = '';
saving = false;
tellers: { id: number; name: string }[] = [];
cashiers: { id: number; label: string }[] = [];
selectedTellerId: number | null = null;
fromCashierId: number | null = null;
fromParty = '';
toParty = '';
loadingCashiers = false;
get denoms(): Denom[] {
return [
...this.notes,
...this.coins
];
}
ngOnInit(): void {
const d = new Date();
this.settlementDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.service.getActiveDenominations().subscribe({
next: (list) => {
const rows: Denom[] = (list || []).map((x: any) => ({
id: x.id,
value: x.denomination,
kind: x.kind,
label: '₹' + x.denomination
}));
this.notes = rows.filter((r) => r.kind === 'note').sort((a, b) => b.value - a.value);
this.coins = rows.filter((r) => r.kind === 'coin').sort((a, b) => b.value - a.value);
},
error: () => {
this.notes = [];
this.coins = [];
}
});
this.service
.getTellers()
.pipe(catchError(() => of([] as any[])))
.subscribe((list) => {
this.tellers = (list || []).map((t: any) => ({ id: t.id, name: t.name }));
});
}
onTellerSelect(tellerId: number): void {
this.selectedTellerId = tellerId;
const teller = this.tellers.find((t) => t.id === tellerId);
this.toParty = teller ? teller.name : '';
this.fromCashierId = null;
this.fromParty = '';
this.cashiers = [];
if (!tellerId) return;
this.loadingCashiers = true;
this.service
.getCashiersForTeller(tellerId)
.pipe(catchError(() => of({ cashiers: [] })))
.subscribe((r: any) => {
this.cashiers = (r?.cashiers || []).map((c: any) => ({ id: c.id, label: c.staffName }));
this.loadingCashiers = false;
});
}
onCashierSelect(cashierId: number): void {
this.fromCashierId = cashierId;
const c = this.cashiers.find((x) => x.id === cashierId);
this.fromParty = c ? c.label : '';
}
countFor(d: Denom): number {
return this.counts[d.id] || 0;
}
subtotal(d: Denom): number {
return this.countFor(d) * d.value;
}
onChange(d: Denom, raw: any): void {
this.counts[d.id] = Math.max(0, Math.floor(Number(raw) || 0));
}
bump(d: Denom, delta: number): void {
this.counts[d.id] = Math.max(0, (this.counts[d.id] || 0) + delta);
}
get total(): number {
return this.denoms.reduce((s, d) => s + this.subtotal(d), 0);
}
get valid(): boolean {
return this.total > 0 && !!this.fromCashierId && !!this.selectedTellerId;
}
clear(): void {
this.counts = {};
}
submit(): void {
if (!this.valid || this.saving) return;
this.saving = true;
const payload = {
settlementDate: this.settlementDate,
fromCashierId: this.fromCashierId,
toTellerId: this.selectedTellerId,
fromParty: this.fromParty.trim(),
toParty: this.toParty.trim(),
note: this.note || null,
denominations: this.denoms
.filter((d) => this.countFor(d) > 0)
.map((d) => ({ denominationId: d.id, denominationValue: d.value, count: this.countFor(d) }))
};
this.service.createCashSettlement(payload).subscribe({
next: () => {
this.snackBar.open('Cash settlement recorded', 'Close', { duration: 3000 });
this.clear();
this.note = '';
this.fromCashierId = null;
this.fromParty = '';
this.selectedTellerId = null;
this.toParty = '';
this.cashiers = [];
this.saving = false;
},
error: () => {
this.saving = false;
}
});
}
}
@@ -0,0 +1,120 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="pg">
<header class="pg-head">
<div>
<div class="pg-eyebrow">CASHIER · CASH TRANSFER</div>
<h1 class="pg-title">Cash Transfer</h1>
<p class="pg-sub">Move cash denomination-wise from one party to another (e.g. Vault → Cashier for opening).</p>
</div>
<button type="button" class="pg-btn pg-btn--ghost" (click)="clear()"><mat-icon>undo</mat-icon> Clear</button>
</header>
<div class="pg-toolbar pg-toolbar--mat">
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>From (Teller / Head)</mat-label>
<mat-select [value]="fromTellerId" (selectionChange)="onTellerSelect($event.value)">
@for (t of tellers; track t.id) {
<mat-option [value]="t.id">{{ t.name }}</mat-option>
}
@if (tellers.length === 0) {
<mat-option [value]="null" disabled>No tellers configured</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>To (Cashier)</mat-label>
<mat-select [value]="toCashierId" (selectionChange)="onCashierSelect($event.value)" [disabled]="!fromTellerId">
@for (c of cashiers; track c.id) {
<mat-option [value]="c.id">{{ c.label }}</mat-option>
}
@if (fromTellerId && cashiers.length === 0 && !loadingCashiers) {
<mat-option [value]="null" disabled>No cashiers for this teller</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Date</mat-label>
<input matInput type="date" [value]="transferDate" (change)="transferDate = $any($event.target).value" />
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="pg-note-field">
<mat-label>Note</mat-label>
<input matInput type="text" [value]="note" (input)="note = $any($event.target).value" placeholder="Optional" />
</mat-form-field>
</div>
<div class="pg-card">
<table class="pg-table">
<thead>
<tr>
<th>Denomination</th>
<th style="text-align: center">Count</th>
<th class="num">Amount</th>
</tr>
</thead>
<tbody>
@if (notes.length) {
<tr class="pg-section">
<td colspan="3">Bank Notes</td>
</tr>
}
@for (d of notes; track d.id) {
<tr>
<td>
<span class="pg-chip">{{ d.label }}</span>
</td>
<td style="display: flex; justify-content: center">
<div class="pg-step">
<button type="button" (click)="bump(d, -1)" [disabled]="countFor(d) === 0">−</button>
<input type="number" min="0" [value]="countFor(d)" (input)="onChange(d, $any($event.target).value)" />
<button type="button" (click)="bump(d, 1)">+</button>
</div>
</td>
<td class="num">₹{{ subtotal(d) | number: '1.2-2' }}</td>
</tr>
}
@if (coins.length) {
<tr class="pg-section">
<td colspan="3">Coins</td>
</tr>
}
@for (d of coins; track d.id) {
<tr>
<td>
<span class="pg-chip pg-chip--coin">{{ d.label }}</span>
</td>
<td style="display: flex; justify-content: center">
<div class="pg-step">
<button type="button" (click)="bump(d, -1)" [disabled]="countFor(d) === 0">−</button>
<input type="number" min="0" [value]="countFor(d)" (input)="onChange(d, $any($event.target).value)" />
<button type="button" (click)="bump(d, 1)">+</button>
</div>
</td>
<td class="num">₹{{ subtotal(d) | number: '1.2-2' }}</td>
</tr>
}
@if (denoms.length === 0) {
<tr>
<td colspan="3" class="pg-empty">No active denominations. Add some in Cash Denomination.</td>
</tr>
}
</tbody>
</table>
</div>
<div class="pg-total-bar">
Total to transfer: <strong>₹{{ total | number: '1.2-2' }}</strong>
<button type="button" class="pg-btn pg-btn--primary" [disabled]="!valid || saving" (click)="submit()">
<mat-icon>swap_horiz</mat-icon> Record Transfer
</button>
</div>
</div>
@@ -0,0 +1,190 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatSelectModule } from '@angular/material/select';
import { MatInputModule } from '@angular/material/input';
import { of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
interface Denom {
id: number;
value: number;
kind: 'note' | 'coin';
label: string;
}
/** Denomination-wise cash transfer between two parties (e.g. Vault -> Cashier). */
@Component({
selector: 'mifosx-cash-transfer',
templateUrl: './cash-transfer.component.html',
styleUrls: ['../cashier-shared.scss'],
standalone: true,
imports: [
CommonModule,
FormsModule,
MatIconModule,
MatSnackBarModule,
MatFormFieldModule,
MatSelectModule,
MatInputModule
]
})
export class CashTransferComponent implements OnInit {
private service = inject(CashierService);
private snackBar = inject(MatSnackBar);
notes: Denom[] = [];
coins: Denom[] = [];
counts: Record<number, number> = {};
transferDate = '';
fromParty = '';
toParty = '';
note = '';
saving = false;
/** Tellers — the "From" options (cash point / head). */
tellers: { id: number; name: string }[] = [];
/** Cashiers of the currently selected From-teller — the "To" options. */
cashiers: { id: number; label: string }[] = [];
fromTellerId: number | null = null;
toCashierId: number | null = null;
loadingCashiers = false;
/** All denominations (notes then coins) — used for total and the submit payload. */
get denoms(): Denom[] {
return [
...this.notes,
...this.coins
];
}
ngOnInit(): void {
const d = new Date();
this.transferDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.service.getActiveDenominations().subscribe({
next: (list) => {
const rows: Denom[] = (list || []).map((x) => ({
id: x.id,
value: x.denomination,
kind: x.kind,
label: '₹' + x.denomination
}));
this.notes = rows.filter((r) => r.kind === 'note').sort((a, b) => b.value - a.value);
this.coins = rows.filter((r) => r.kind === 'coin').sort((a, b) => b.value - a.value);
},
error: () => {
this.notes = [];
this.coins = [];
}
});
this.service
.getTellers()
.pipe(catchError(() => of([] as any[])))
.subscribe((list) => {
this.tellers = (list || []).map((t: any) => ({ id: t.id, name: t.name }));
});
}
/** When a From-teller is chosen, load ONLY that teller's cashiers into the To dropdown. */
onTellerSelect(tellerId: number): void {
this.fromTellerId = tellerId;
const teller = this.tellers.find((t) => t.id === tellerId);
this.fromParty = teller ? teller.name : '';
this.toParty = '';
this.toCashierId = null;
this.cashiers = [];
if (!tellerId) {
return;
}
this.loadingCashiers = true;
this.service
.getCashiersForTeller(tellerId)
.pipe(catchError(() => of({ cashiers: [] })))
.subscribe((r: any) => {
this.cashiers = (r?.cashiers || []).map((c: any) => ({ id: c.id, label: c.staffName }));
this.loadingCashiers = false;
});
}
/** When a To-cashier is chosen, capture its id and name. */
onCashierSelect(cashierId: number): void {
this.toCashierId = cashierId;
const c = this.cashiers.find((x) => x.id === cashierId);
this.toParty = c ? c.label : '';
}
countFor(d: Denom): number {
return this.counts[d.id] || 0;
}
subtotal(d: Denom): number {
return this.countFor(d) * d.value;
}
onChange(d: Denom, raw: any): void {
this.counts[d.id] = Math.max(0, Math.floor(Number(raw) || 0));
}
bump(d: Denom, delta: number): void {
this.counts[d.id] = Math.max(0, (this.counts[d.id] || 0) + delta);
}
get total(): number {
return this.denoms.reduce((s, d) => s + this.subtotal(d), 0);
}
get valid(): boolean {
return this.total > 0 && !!this.fromTellerId && !!this.toCashierId;
}
clear(): void {
this.counts = {};
}
submit(): void {
if (!this.valid || this.saving) {
return;
}
this.saving = true;
const payload = {
transferDate: this.transferDate,
fromTellerId: this.fromTellerId,
toCashierId: this.toCashierId,
fromParty: this.fromParty.trim(),
toParty: this.toParty.trim(),
note: this.note || null,
denominations: this.denoms
.filter((d) => this.countFor(d) > 0)
.map((d) => ({ denominationId: d.id, denominationValue: d.value, count: this.countFor(d) }))
};
this.service.createCashTransfer(payload).subscribe({
next: () => {
this.snackBar.open('Cash transfer recorded', 'Close', { duration: 3000 });
this.clear();
this.note = '';
this.fromParty = '';
this.toParty = '';
this.fromTellerId = null;
this.toCashierId = null;
this.cashiers = [];
this.saving = false;
},
error: () => {
this.saving = false;
}
});
}
}
@@ -0,0 +1,123 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="pg">
<header class="pg-head">
<div>
<div class="pg-eyebrow">CASHIER · POSITION REPORT</div>
<h1 class="pg-title">Cashier Position</h1>
<p class="pg-sub">
Denomination-wise net cash held by a cashier as of a date (allocations − settlements − inter-cashier transfers).
</p>
</div>
</header>
<div class="pg-toolbar pg-toolbar--mat">
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Teller (optional)</mat-label>
<mat-select [value]="selectedTellerId" (selectionChange)="onTellerSelect($event.value)">
<mat-option [value]="null">All Tellers</mat-option>
@for (t of tellers; track t.id) {
<mat-option [value]="t.id">{{ t.name }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Cashier (optional)</mat-label>
<mat-select [value]="selectedCashierId" (selectionChange)="selectedCashierId = $event.value">
<mat-option [value]="null">All Cashiers</mat-option>
@for (c of cashiers; track c.id) {
<mat-option [value]="c.id">{{ c.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>As of Date</mat-label>
<input matInput type="date" [value]="asOfDate" (change)="asOfDate = $any($event.target).value" />
</mat-form-field>
<button type="button" class="pg-btn pg-btn--primary" (click)="load()" [disabled]="loading">
<mat-icon>bar_chart</mat-icon> Load Position
</button>
</div>
@if (errorMsg) {
<div class="pg-empty" style="color: #dc2626">{{ errorMsg }}</div>
}
@if (loading) {
<div class="pg-empty">Loading…</div>
}
@for (pos of results; track pos.cashierId) {
<div class="pg-card" style="margin-bottom: 0">
<div class="pos-header">
<span class="pos-cashier">{{ pos.cashierName || labelForCashier(pos.cashierId) }}</span>
<span class="pos-total">₹{{ pos.totalAmount | number: '1.2-2' }}</span>
</div>
@if (pos.denominations.length === 0) {
<div class="pg-empty">No balance.</div>
}
@if (notes(pos).length > 0) {
<div class="pos-section-label">Notes</div>
<table class="pg-table">
<thead>
<tr>
<th>Denomination</th>
<th style="text-align: center">Count</th>
<th class="num">Amount</th>
</tr>
</thead>
<tbody>
@for (d of notes(pos); track d.denominationId) {
<tr>
<td>
<span class="pg-chip">₹{{ d.denominationValue }}</span>
</td>
<td style="text-align: center; font-weight: 700">{{ d.count }}</td>
<td class="num">₹{{ d.amount | number: '1.2-2' }}</td>
</tr>
}
</tbody>
</table>
}
@if (coins(pos).length > 0) {
<div class="pos-section-label pos-section-label--coin">Coins</div>
<table class="pg-table">
<thead>
<tr>
<th>Denomination</th>
<th style="text-align: center">Count</th>
<th class="num">Amount</th>
</tr>
</thead>
<tbody>
@for (d of coins(pos); track d.denominationId) {
<tr>
<td>
<span class="pg-chip pg-chip--coin">₹{{ d.denominationValue }}</span>
</td>
<td style="text-align: center; font-weight: 700">{{ d.count }}</td>
<td class="num">₹{{ d.amount | number: '1.2-2' }}</td>
</tr>
}
</tbody>
</table>
}
</div>
}
@if (!loading && results.length === 0 && !errorMsg) {
<div class="pg-empty">Select filters and click "Load Position".</div>
}
</div>
@@ -0,0 +1,29 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
.pos-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
}
.pos-cashier {
font-size: 13px;
font-weight: 700;
color: #0f4f3c;
}
.pos-total {
font-size: 16px;
font-weight: 800;
color: #0f4f3c;
font-variant-numeric: tabular-nums;
}
@@ -0,0 +1,119 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatSelectModule } from '@angular/material/select';
import { MatInputModule } from '@angular/material/input';
import { of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
interface DenomRow {
denominationId: number;
denominationValue: number;
kind: string;
count: number;
amount: number;
}
interface CashierPos {
cashierId: number;
cashierName?: string;
asOfDate: string;
totalAmount: number;
denominations: DenomRow[];
}
/** Denomination-wise net cash position per cashier as of a given date. */
@Component({
selector: 'mifosx-cashier-position',
templateUrl: './cashier-position.component.html',
styleUrls: [
'../cashier-shared.scss',
'./cashier-position.component.scss'
],
standalone: true,
imports: [
CommonModule,
FormsModule,
MatIconModule,
MatFormFieldModule,
MatSelectModule,
MatInputModule
]
})
export class CashierPositionComponent implements OnInit {
private service = inject(CashierService);
tellers: { id: number; name: string }[] = [];
cashiers: { id: number; label: string }[] = [];
selectedTellerId: number | null = null;
selectedCashierId: number | null = null;
asOfDate = '';
loading = false;
results: CashierPos[] = [];
errorMsg = '';
ngOnInit(): void {
const d = new Date();
this.asOfDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.service
.getTellers()
.pipe(catchError(() => of([] as any[])))
.subscribe((list) => {
this.tellers = (list || []).map((t: any) => ({ id: t.id, name: t.name }));
});
}
onTellerSelect(tellerId: number): void {
this.selectedTellerId = tellerId;
this.selectedCashierId = null;
this.cashiers = [];
if (!tellerId) return;
this.service
.getCashiersForTeller(tellerId)
.pipe(catchError(() => of({ cashiers: [] })))
.subscribe((r: any) => {
this.cashiers = (r?.cashiers || []).map((c: any) => ({ id: c.id, label: c.staffName }));
});
}
load(): void {
this.loading = true;
this.results = [];
this.errorMsg = '';
this.service
.cashierPositionReport(this.selectedCashierId || undefined, this.asOfDate)
.pipe(catchError(() => of(null)))
.subscribe((data: any) => {
this.loading = false;
if (!data) {
this.errorMsg = 'Failed to load position data.';
return;
}
this.results = Array.isArray(data) ? data : [data];
});
}
labelForCashier(id: number): string {
const c = this.cashiers.find((x) => x.id === id);
return c ? c.label : `Cashier #${id}`;
}
notes(pos: CashierPos): DenomRow[] {
return pos.denominations.filter((d) => d.kind !== 'coin');
}
coins(pos: CashierPos): DenomRow[] {
return pos.denominations.filter((d) => d.kind === 'coin');
}
}
+89
View File
@@ -0,0 +1,89 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { Route } from '../core/route/route.service';
import { CashExchangeEntryComponent } from './cash-exchange-entry/cash-exchange-entry.component';
import { CurrencyExchangeDetailComponent } from './currency-exchange-detail/currency-exchange-detail.component';
import { CashTransferComponent } from './cash-transfer/cash-transfer.component';
import { CashSettlementComponent } from './cash-settlement/cash-settlement.component';
import { InterCashierTransferComponent } from './inter-cashier-transfer/inter-cashier-transfer.component';
import { CashierPositionComponent } from './cashier-position/cashier-position.component';
import { ReconciliationComponent } from './reconciliation/reconciliation.component';
import { CashHistoryComponent } from './cash-history/cash-history.component';
import { ClosingCashReportComponent } from './closing-cash-report/closing-cash-report.component';
import { ExcessTransactionReportComponent } from './excess-transaction-report/excess-transaction-report.component';
const routes: Routes = [
Route.withShell([
{
path: 'cashier',
data: { title: 'Cashier', breadcrumb: 'Cashier' },
children: [
{
path: 'cash-exchange',
component: CashExchangeEntryComponent,
data: { title: 'Cash Exchange Entry', breadcrumb: 'Cash Exchange Entry' }
},
{
path: 'exchange-detail',
component: CurrencyExchangeDetailComponent,
data: { title: 'Currency Exchange Detail', breadcrumb: 'Currency Exchange Detail' }
},
{
path: 'cash-transfer',
component: CashTransferComponent,
data: { title: 'Cash Transfer', breadcrumb: 'Cash Transfer' }
},
{
path: 'cash-settlement',
component: CashSettlementComponent,
data: { title: 'Cash Settlement', breadcrumb: 'Cash Settlement' }
},
{
path: 'inter-cashier',
component: InterCashierTransferComponent,
data: { title: 'Inter-Cashier Transfer', breadcrumb: 'Inter-Cashier Transfer' }
},
{
path: 'cashier-position',
component: CashierPositionComponent,
data: { title: 'Cashier Position', breadcrumb: 'Cashier Position' }
},
{
path: 'reconciliation',
component: ReconciliationComponent,
data: { title: 'Reconciliation', breadcrumb: 'Reconciliation' }
},
{
path: 'cash-history',
component: CashHistoryComponent,
data: { title: 'Cash History', breadcrumb: 'Cash History' }
},
{
path: 'closing-report',
component: ClosingCashReportComponent,
data: { title: 'Closing Cash Report', breadcrumb: 'Closing Cash Report' }
},
{
path: 'excess-report',
component: ExcessTransactionReportComponent,
data: { title: 'Excess Transaction Report', breadcrumb: 'Excess Transaction Report' }
}
]
}
])
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class CashierRoutingModule {}
+375
View File
@@ -0,0 +1,375 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
:host {
display: block;
}
.pg {
padding: 20px 24px;
display: flex;
flex-direction: column;
gap: 16px;
}
.pg-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.pg-eyebrow {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.1em;
color: #0f4f3c;
}
.pg-title {
font-size: 26px;
font-weight: 600;
margin: 4px 0 2px;
color: #1a1a1a;
}
.pg-sub {
font-size: 13px;
color: #64748b;
margin: 0;
max-width: 560px;
}
.pg-toolbar {
display: flex;
align-items: flex-end;
gap: 10px;
flex-wrap: wrap;
}
.pg-field {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 11px;
font-weight: 600;
color: #64748b;
input {
height: 36px;
padding: 0 10px;
border: 1px solid #e2e8f0;
border-radius: 8px;
font-size: 13px;
color: #0d1b2a;
font-family: inherit;
}
}
.pg-select {
height: 36px;
min-width: 180px;
padding: 0 10px;
border: 1px solid #e2e8f0;
border-radius: 8px;
font-size: 13px;
color: #0d1b2a;
font-family: inherit;
background: #fff;
cursor: pointer;
}
.pg-toolbar--mat {
align-items: flex-start;
gap: 12px;
mat-form-field {
flex: 0 0 210px;
}
.pg-note-field {
flex: 1 1 240px;
}
}
.pg-card {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
overflow: hidden;
}
.pg-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
th {
text-align: left;
font-size: 10px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #94a3b8;
padding: 10px 14px;
border-bottom: 1px solid #e2e8f0;
background: #f8fafc;
}
td {
padding: 9px 14px;
border-bottom: 1px dotted #eef2f7;
color: #0d1b2a;
}
tr:hover td {
background: #f8fafc;
}
.num {
text-align: right;
font-variant-numeric: tabular-nums;
font-weight: 700;
}
tr.pg-section td {
background: #f1f5f9;
font-size: 10px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.07em;
color: #64748b;
padding: 6px 14px;
}
tr.pg-section:hover td {
background: #f1f5f9;
}
}
.pg-empty {
padding: 28px;
text-align: center;
color: #94a3b8;
font-size: 13px;
}
.pg-btn {
display: inline-flex;
align-items: center;
gap: 6px;
height: 38px;
padding: 0 16px;
border-radius: 999px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
border: 1px solid transparent;
font-family: inherit;
mat-icon {
font-size: 18px;
height: 18px;
width: 18px;
}
&--ghost {
background: #fff;
border-color: #e2e8f0;
color: #475569;
}
&--primary {
background: linear-gradient(135deg, #1f7a4d, #0f4f3c);
color: #fff;
&:disabled {
opacity: 0.45;
cursor: not-allowed;
}
}
}
.pg-chip {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 52px;
height: 26px;
padding: 0 8px;
border-radius: 6px;
font-size: 12px;
font-weight: 700;
background: linear-gradient(135deg, #ecfeff, #cffafe);
color: #155e75;
border: 1px solid #a5f3fc;
&--coin {
border-radius: 50%;
min-width: 34px;
width: 34px;
height: 34px;
padding: 0;
background: linear-gradient(135deg, #fef9c3, #fde68a);
color: #854d0e;
border: 1px solid #fcd34d;
}
}
// Shared Notes / Coins section divider — used in position, reconciliation, closing report
.pos-section-label {
padding: 6px 14px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #1e40af;
background: #eff6ff;
border-bottom: 1px solid #dbeafe;
border-top: 1px solid #e2e8f0;
&--coin {
color: #92400e;
background: #fffbeb;
border-bottom-color: #fde68a;
border-top-color: #fde68a;
}
}
// Closing cash report — column colour hints
.col-in {
color: #15803d !important;
}
.col-out {
color: #dc2626 !important;
}
.col-net {
font-weight: 800 !important;
}
// Closing cash summary totals bar
.closing-summary {
display: flex;
align-items: center;
gap: 0;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
overflow: hidden;
&__item {
flex: 1;
padding: 14px 20px;
display: flex;
flex-direction: column;
gap: 4px;
border-right: 1px solid #e2e8f0;
&--in {
background: #f0fdf4;
}
&--out {
background: #fef2f2;
}
&--net-pos {
background: #f0fdf4;
}
&--net-neg {
background: #fef2f2;
}
}
&__sep {
padding: 0 12px;
font-size: 22px;
font-weight: 300;
color: #94a3b8;
}
&__label {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.07em;
color: #64748b;
}
&__value {
font-size: 20px;
font-weight: 800;
font-variant-numeric: tabular-nums;
color: #0f172a;
}
}
.pg-step {
display: flex;
align-items: stretch;
height: 30px;
width: 120px;
border: 1px solid #e2e8f0;
border-radius: 7px;
overflow: hidden;
button {
width: 30px;
background: #f8fafc;
border: none;
font-size: 16px;
font-weight: 700;
color: #475569;
cursor: pointer;
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
}
input {
flex: 1 1 auto;
width: 100%;
border: none;
outline: none;
text-align: center;
font-size: 13px;
font-weight: 700;
color: #0d1b2a;
appearance: textfield;
&::-webkit-outer-spin-button,
&::-webkit-inner-spin-button {
appearance: none;
margin: 0;
}
}
}
.pg-total-bar {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
padding: 12px 16px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 10px;
font-weight: 700;
strong {
font-size: 18px;
color: #0f4f3c;
font-variant-numeric: tabular-nums;
}
}
+40
View File
@@ -0,0 +1,40 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { NgModule } from '@angular/core';
import { SharedModule } from '../shared/shared.module';
import { CashierRoutingModule } from './cashier-routing.module';
import { CashExchangeEntryComponent } from './cash-exchange-entry/cash-exchange-entry.component';
import { CurrencyExchangeDetailComponent } from './currency-exchange-detail/currency-exchange-detail.component';
import { CashTransferComponent } from './cash-transfer/cash-transfer.component';
import { CashSettlementComponent } from './cash-settlement/cash-settlement.component';
import { InterCashierTransferComponent } from './inter-cashier-transfer/inter-cashier-transfer.component';
import { CashierPositionComponent } from './cashier-position/cashier-position.component';
import { ReconciliationComponent } from './reconciliation/reconciliation.component';
import { CashHistoryComponent } from './cash-history/cash-history.component';
import { ClosingCashReportComponent } from './closing-cash-report/closing-cash-report.component';
import { ExcessTransactionReportComponent } from './excess-transaction-report/excess-transaction-report.component';
@NgModule({
imports: [
SharedModule,
CashierRoutingModule,
CashExchangeEntryComponent,
CurrencyExchangeDetailComponent,
CashTransferComponent,
CashSettlementComponent,
InterCashierTransferComponent,
CashierPositionComponent,
ReconciliationComponent,
CashHistoryComponent,
ClosingCashReportComponent,
ExcessTransactionReportComponent
]
})
export class CashierModule {}
+116
View File
@@ -0,0 +1,116 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
/** HTTP service for the Cashier menu features. */
@Injectable({ providedIn: 'root' })
export class CashierService {
private http = inject(HttpClient);
// ── Active denominations ──
getActiveDenominations(): Observable<any[]> {
return this.http.get<any[]>('/denominationmaster/active');
}
// ── Cash Exchange ──
createCashExchange(data: any): Observable<any> {
return this.http.post('/cashexchange', data);
}
getCashExchanges(): Observable<any[]> {
return this.http.get<any[]>('/cashexchange');
}
getCashExchange(id: number): Observable<any> {
return this.http.get(`/cashexchange/${id}`);
}
// ── Tellers / Cashiers ──
getTellers(): Observable<any[]> {
return this.http.get<any[]>('/tellers');
}
getAllCashiers(): Observable<any[]> {
return this.http.get<any[]>('/cashiers');
}
getCashiersForTeller(tellerId: number): Observable<any> {
return this.http.get(`/tellers/${tellerId}/cashiers`);
}
// ── Cash Transfer ──
createCashTransfer(data: any): Observable<any> {
return this.http.post('/cashtransfer', data);
}
getCashTransfers(): Observable<any[]> {
return this.http.get<any[]>('/cashtransfer');
}
reverseCashTransfer(id: number, reason: string): Observable<any> {
return this.http.post(`/cashtransfer/${id}/reverse`, { reason });
}
// ── Cash Settlement ──
createCashSettlement(data: any): Observable<any> {
return this.http.post('/cashsettlement', data);
}
getCashSettlements(): Observable<any[]> {
return this.http.get<any[]>('/cashsettlement');
}
reverseCashSettlement(id: number, reason: string): Observable<any> {
return this.http.post(`/cashsettlement/${id}/reverse`, { reason });
}
// ── Inter-cashier Transfer ──
createInterCashierTransfer(data: any): Observable<any> {
return this.http.post('/cashintercashier', data);
}
getInterCashierTransfers(): Observable<any[]> {
return this.http.get<any[]>('/cashintercashier');
}
reverseInterCashierTransfer(id: number, reason: string): Observable<any> {
return this.http.post(`/cashintercashier/${id}/reverse`, { reason });
}
// ── Reconciliation ──
createReconciliation(data: any): Observable<any> {
return this.http.post('/cashreconciliation', data);
}
getReconciliations(): Observable<any[]> {
return this.http.get<any[]>('/cashreconciliation');
}
getReconciliationById(id: number): Observable<any> {
return this.http.get(`/cashreconciliation/${id}`);
}
getExpectedBalance(cashierId: number, asOfDate?: string): Observable<any> {
let params = new HttpParams().set('cashierId', String(cashierId));
if (asOfDate) params = params.set('asOfDate', asOfDate);
return this.http.get('/cashreconciliation/expected', { params });
}
// ── Reports ──
closingCashReport(date: string): Observable<any> {
const params = new HttpParams().set('date', date);
return this.http.get('/cashreports/closing', { params });
}
excessTransactionReport(threshold: number, fromDate?: string, toDate?: string): Observable<any[]> {
let params = new HttpParams().set('threshold', String(threshold));
if (fromDate) params = params.set('fromDate', fromDate);
if (toDate) params = params.set('toDate', toDate);
return this.http.get<any[]>('/cashreports/excess', { params });
}
cashierPositionReport(cashierId?: number, asOfDate?: string): Observable<any> {
let params = new HttpParams();
if (cashierId) params = params.set('cashierId', String(cashierId));
if (asOfDate) params = params.set('asOfDate', asOfDate);
return this.http.get('/cashreports/cashier-position', { params });
}
getCashierPositionForMe(asOfDate?: string): Observable<any> {
let params = new HttpParams();
if (asOfDate) params = params.set('asOfDate', asOfDate);
return this.http.get('/cashreports/cashier-position/me', { params });
}
}
@@ -0,0 +1,140 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="pg">
<header class="pg-head">
<div>
<div class="pg-eyebrow">CASHIER · REPORT</div>
<h1 class="pg-title">Closing Cash Report</h1>
<p class="pg-sub">Denomination-wise deposit, withdrawal and net cash position for the selected day.</p>
</div>
<div class="pg-toolbar">
<label class="pg-field">
<span>Date</span>
<input type="date" [value]="date" (change)="date = $any($event.target).value" />
</label>
<button type="button" class="pg-btn pg-btn--primary" (click)="load()" [disabled]="loading">
<mat-icon>search</mat-icon> Load
</button>
</div>
</header>
@if (rows.length > 0) {
<!-- Summary totals bar -->
<div class="closing-summary">
<div class="closing-summary__item closing-summary__item--in">
<span class="closing-summary__label">Total Deposits (IN)</span>
<span class="closing-summary__value">₹{{ depositTotal | number: '1.2-2' }}</span>
</div>
<div class="closing-summary__sep">−</div>
<div class="closing-summary__item closing-summary__item--out">
<span class="closing-summary__label">Total Withdrawals (OUT)</span>
<span class="closing-summary__value">₹{{ withdrawalTotal | number: '1.2-2' }}</span>
</div>
<div class="closing-summary__sep">=</div>
<div
class="closing-summary__item"
[class.closing-summary__item--net-pos]="netTotal >= 0"
[class.closing-summary__item--net-neg]="netTotal < 0"
>
<span class="closing-summary__label">Net Closing Cash</span>
<span class="closing-summary__value">₹{{ netTotal | number: '1.2-2' }}</span>
</div>
</div>
}
<div class="pg-card">
@if (noteRows.length > 0) {
<div class="pos-section-label">Notes</div>
<table class="pg-table">
<thead>
<tr>
<th>Denomination</th>
<th class="num col-in">Deposit Pcs</th>
<th class="num col-in">Deposit Amt</th>
<th class="num col-out">Withdrawal Pcs</th>
<th class="num col-out">Withdrawal Amt</th>
<th class="num col-net">Net Pcs</th>
<th class="num col-net">Net Amount</th>
</tr>
</thead>
<tbody>
@for (r of noteRows; track r.denominationId) {
<tr>
<td>
<span class="pg-chip">₹{{ r.denominationValue }}</span>
</td>
<td class="num col-in">{{ r.depositCount }}</td>
<td class="num col-in">₹{{ r.depositAmount | number: '1.2-2' }}</td>
<td class="num col-out">{{ r.withdrawalCount }}</td>
<td class="num col-out">₹{{ r.withdrawalAmount | number: '1.2-2' }}</td>
<td
class="num col-net"
[style.color]="r.netCount > 0 ? '#16a34a' : r.netCount < 0 ? '#dc2626' : '#94a3b8'"
>
{{ r.netCount > 0 ? '+' : '' }}{{ r.netCount }}
</td>
<td
class="num col-net"
[style.color]="r.netCount > 0 ? '#16a34a' : r.netCount < 0 ? '#dc2626' : '#94a3b8'"
>
{{ r.netCount >= 0 ? '' : '−' }}₹{{ (r.netAmount < 0 ? -r.netAmount : r.netAmount) | number: '1.2-2' }}
</td>
</tr>
}
</tbody>
</table>
}
@if (coinRows.length > 0) {
<div class="pos-section-label pos-section-label--coin">Coins</div>
<table class="pg-table">
<thead>
<tr>
<th>Denomination</th>
<th class="num col-in">Deposit Pcs</th>
<th class="num col-in">Deposit Amt</th>
<th class="num col-out">Withdrawal Pcs</th>
<th class="num col-out">Withdrawal Amt</th>
<th class="num col-net">Net Pcs</th>
<th class="num col-net">Net Amount</th>
</tr>
</thead>
<tbody>
@for (r of coinRows; track r.denominationId) {
<tr>
<td>
<span class="pg-chip pg-chip--coin">₹{{ r.denominationValue }}</span>
</td>
<td class="num col-in">{{ r.depositCount }}</td>
<td class="num col-in">₹{{ r.depositAmount | number: '1.2-2' }}</td>
<td class="num col-out">{{ r.withdrawalCount }}</td>
<td class="num col-out">₹{{ r.withdrawalAmount | number: '1.2-2' }}</td>
<td
class="num col-net"
[style.color]="r.netCount > 0 ? '#16a34a' : r.netCount < 0 ? '#dc2626' : '#94a3b8'"
>
{{ r.netCount > 0 ? '+' : '' }}{{ r.netCount }}
</td>
<td
class="num col-net"
[style.color]="r.netCount > 0 ? '#16a34a' : r.netCount < 0 ? '#dc2626' : '#94a3b8'"
>
{{ r.netCount >= 0 ? '' : '−' }}₹{{ (r.netAmount < 0 ? -r.netAmount : r.netAmount) | number: '1.2-2' }}
</td>
</tr>
}
</tbody>
</table>
}
@if (loaded && rows.length === 0) {
<div class="pg-empty">No cash denomination data for this date.</div>
}
</div>
</div>
@@ -0,0 +1,72 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { CashierService } from '../cashier.service';
/** Day-wise denomination-wise closing cash collected from deposits. */
@Component({
selector: 'mifosx-closing-cash-report',
templateUrl: './closing-cash-report.component.html',
styleUrls: ['../cashier-shared.scss'],
standalone: true,
imports: [
CommonModule,
FormsModule,
MatIconModule
]
})
export class ClosingCashReportComponent implements OnInit {
private service = inject(CashierService);
date = '';
rows: any[] = [];
depositTotal = 0;
withdrawalTotal = 0;
netTotal = 0;
loading = false;
loaded = false;
get noteRows(): any[] {
return this.rows.filter((r) => r.kind !== 'coin');
}
get coinRows(): any[] {
return this.rows.filter((r) => r.kind === 'coin');
}
ngOnInit(): void {
const d = new Date();
this.date = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.load();
}
load(): void {
this.loading = true;
this.service.closingCashReport(this.date).subscribe({
next: (res: any) => {
this.rows = res?.rows || [];
this.depositTotal = Number(res?.depositTotal || 0);
this.withdrawalTotal = Number(res?.withdrawalTotal || 0);
this.netTotal = Number(res?.netTotal || 0);
this.loading = false;
this.loaded = true;
},
error: () => {
this.rows = [];
this.depositTotal = 0;
this.withdrawalTotal = 0;
this.netTotal = 0;
this.loading = false;
this.loaded = true;
}
});
}
}
@@ -0,0 +1,52 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="pg">
<header class="pg-head">
<div>
<div class="pg-eyebrow">CASHIER · EXCHANGE</div>
<h1 class="pg-title">Currency Exchange Detail</h1>
<p class="pg-sub">All recorded cash exchanges (notes/coins swapped at the counter).</p>
</div>
<a class="pg-btn pg-btn--primary" [routerLink]="['/cashier/cash-exchange']">
<mat-icon>add</mat-icon> New Exchange
</a>
</header>
<div class="pg-card">
<table class="pg-table">
<thead>
<tr>
<th>#</th>
<th>Date</th>
<th class="num">Amount</th>
<th>Note</th>
<th>By</th>
<th>Recorded</th>
</tr>
</thead>
<tbody>
@for (e of exchanges; track e.id) {
<tr>
<td>{{ e.id }}</td>
<td>{{ e.exchangeDate || '—' }}</td>
<td class="num">₹{{ e.exchangeAmount | number: '1.2-2' }}</td>
<td>{{ e.note || '—' }}</td>
<td>{{ e.performedByUsername || '—' }}</td>
<td>{{ e.createdAt || '—' }}</td>
</tr>
}
@if (!loading && exchanges.length === 0) {
<tr>
<td colspan="6" class="pg-empty">No cash exchanges recorded yet.</td>
</tr>
}
</tbody>
</table>
</div>
</div>
@@ -0,0 +1,45 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import { MatIconModule } from '@angular/material/icon';
import { CashierService } from '../cashier.service';
/** Lists recorded cash exchanges (Currency Exchange Detail). */
@Component({
selector: 'mifosx-currency-exchange-detail',
templateUrl: './currency-exchange-detail.component.html',
styleUrls: ['../cashier-shared.scss'],
standalone: true,
imports: [
CommonModule,
RouterLink,
MatIconModule
]
})
export class CurrencyExchangeDetailComponent implements OnInit {
private service = inject(CashierService);
exchanges: any[] = [];
loading = true;
ngOnInit(): void {
this.service.getCashExchanges().subscribe({
next: (list) => {
this.exchanges = list || [];
this.loading = false;
},
error: () => {
this.exchanges = [];
this.loading = false;
}
});
}
}
@@ -0,0 +1,64 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="pg">
<header class="pg-head">
<div>
<div class="pg-eyebrow">CASHIER · REPORT</div>
<h1 class="pg-title">Excess Transaction Report</h1>
<p class="pg-sub">Cash transactions at or above the threshold (default ₹50,000 — CTR / AML monitoring).</p>
</div>
<div class="pg-toolbar">
<label class="pg-field">
<span>Threshold (₹)</span>
<input type="number" min="0" [value]="threshold" (input)="threshold = $any($event.target).value" />
</label>
<label class="pg-field">
<span>From</span>
<input type="date" [value]="fromDate" (change)="fromDate = $any($event.target).value" />
</label>
<label class="pg-field">
<span>To</span>
<input type="date" [value]="toDate" (change)="toDate = $any($event.target).value" />
</label>
<button type="button" class="pg-btn pg-btn--primary" (click)="load()" [disabled]="loading">
<mat-icon>search</mat-icon> Load
</button>
</div>
</header>
<div class="pg-card">
<table class="pg-table">
<thead>
<tr>
<th>Txn #</th>
<th>Account No</th>
<th>CIF</th>
<th>Date</th>
<th class="num">Amount</th>
</tr>
</thead>
<tbody>
@for (r of rows; track r.txnId) {
<tr>
<td>{{ r.txnId }}</td>
<td>{{ r.accountNo || '—' }}</td>
<td>{{ r.cifNo || '—' }}</td>
<td>{{ r.txnDate || '—' }}</td>
<td class="num">₹{{ r.totalAmount | number: '1.2-2' }}</td>
</tr>
}
@if (loaded && rows.length === 0) {
<tr>
<td colspan="5" class="pg-empty">No transactions at or above ₹{{ threshold | number }}.</td>
</tr>
}
</tbody>
</table>
</div>
</div>
@@ -0,0 +1,58 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { CashierService } from '../cashier.service';
/** Cash transactions whose denomination total is at/above a threshold (default ₹50,000 — CTR). */
@Component({
selector: 'mifosx-excess-transaction-report',
templateUrl: './excess-transaction-report.component.html',
styleUrls: ['../cashier-shared.scss'],
standalone: true,
imports: [
CommonModule,
FormsModule,
MatIconModule
]
})
export class ExcessTransactionReportComponent implements OnInit {
private service = inject(CashierService);
threshold = 50000;
fromDate = '';
toDate = '';
rows: any[] = [];
loading = false;
loaded = false;
ngOnInit(): void {
this.load();
}
load(): void {
this.loading = true;
this.service
.excessTransactionReport(this.threshold, this.fromDate || undefined, this.toDate || undefined)
.subscribe({
next: (list) => {
this.rows = list || [];
this.loading = false;
this.loaded = true;
},
error: () => {
this.rows = [];
this.loading = false;
this.loaded = true;
}
});
}
}
@@ -0,0 +1,132 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="pg">
<header class="pg-head">
<div>
<div class="pg-eyebrow">CASHIER · INTER-CASHIER TRANSFER</div>
<h1 class="pg-title">Inter-Cashier Transfer</h1>
<p class="pg-sub">Move denomination-wise cash between two cashiers within the same teller.</p>
</div>
<button type="button" class="pg-btn pg-btn--ghost" (click)="clear()"><mat-icon>undo</mat-icon> Clear</button>
</header>
<div class="pg-toolbar pg-toolbar--mat">
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Teller</mat-label>
<mat-select [value]="selectedTellerId" (selectionChange)="onTellerSelect($event.value)">
@for (t of tellers; track t.id) {
<mat-option [value]="t.id">{{ t.name }}</mat-option>
}
@if (tellers.length === 0) {
<mat-option [value]="null" disabled>No tellers configured</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>From (Cashier)</mat-label>
<mat-select [value]="fromCashierId" (selectionChange)="onFromSelect($event.value)" [disabled]="!selectedTellerId">
@for (c of availableFrom; track c.id) {
<mat-option [value]="c.id">{{ c.label }}</mat-option>
}
@if (selectedTellerId && allCashiers.length === 0 && !loadingCashiers) {
<mat-option [value]="null" disabled>No cashiers</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>To (Cashier)</mat-label>
<mat-select [value]="toCashierId" (selectionChange)="onToSelect($event.value)" [disabled]="!fromCashierId">
@for (c of availableTo; track c.id) {
<mat-option [value]="c.id">{{ c.label }}</mat-option>
}
@if (fromCashierId && availableTo.length === 0) {
<mat-option [value]="null" disabled>No other cashiers</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Date</mat-label>
<input matInput type="date" [value]="transferDate" (change)="transferDate = $any($event.target).value" />
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="pg-note-field">
<mat-label>Note</mat-label>
<input matInput type="text" [value]="note" (input)="note = $any($event.target).value" placeholder="Optional" />
</mat-form-field>
</div>
<div class="pg-card">
<table class="pg-table">
<thead>
<tr>
<th>Denomination</th>
<th style="text-align: center">Count</th>
<th class="num">Amount</th>
</tr>
</thead>
<tbody>
@if (notes.length) {
<tr class="pg-section">
<td colspan="3">Bank Notes</td>
</tr>
}
@for (d of notes; track d.id) {
<tr>
<td>
<span class="pg-chip">{{ d.label }}</span>
</td>
<td style="display: flex; justify-content: center">
<div class="pg-step">
<button type="button" (click)="bump(d, -1)" [disabled]="countFor(d) === 0">−</button>
<input type="number" min="0" [value]="countFor(d)" (input)="onChange(d, $any($event.target).value)" />
<button type="button" (click)="bump(d, 1)">+</button>
</div>
</td>
<td class="num">₹{{ subtotal(d) | number: '1.2-2' }}</td>
</tr>
}
@if (coins.length) {
<tr class="pg-section">
<td colspan="3">Coins</td>
</tr>
}
@for (d of coins; track d.id) {
<tr>
<td>
<span class="pg-chip pg-chip--coin">{{ d.label }}</span>
</td>
<td style="display: flex; justify-content: center">
<div class="pg-step">
<button type="button" (click)="bump(d, -1)" [disabled]="countFor(d) === 0">−</button>
<input type="number" min="0" [value]="countFor(d)" (input)="onChange(d, $any($event.target).value)" />
<button type="button" (click)="bump(d, 1)">+</button>
</div>
</td>
<td class="num">₹{{ subtotal(d) | number: '1.2-2' }}</td>
</tr>
}
@if (denoms.length === 0) {
<tr>
<td colspan="3" class="pg-empty">No active denominations. Add some in Cash Denomination.</td>
</tr>
}
</tbody>
</table>
</div>
<div class="pg-total-bar">
Total to transfer: <strong>₹{{ total | number: '1.2-2' }}</strong>
<button type="button" class="pg-btn pg-btn--primary" [disabled]="!valid || saving" (click)="submit()">
<mat-icon>swap_horiz</mat-icon> Record Transfer
</button>
</div>
</div>
@@ -0,0 +1,197 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatSelectModule } from '@angular/material/select';
import { MatInputModule } from '@angular/material/input';
import { of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
interface Denom {
id: number;
value: number;
kind: 'note' | 'coin';
label: string;
}
interface Cashier {
id: number;
label: string;
}
/** Move cash denomination-wise between two cashiers under the same teller. */
@Component({
selector: 'mifosx-inter-cashier-transfer',
templateUrl: './inter-cashier-transfer.component.html',
styleUrls: ['../cashier-shared.scss'],
standalone: true,
imports: [
CommonModule,
FormsModule,
MatIconModule,
MatSnackBarModule,
MatFormFieldModule,
MatSelectModule,
MatInputModule
]
})
export class InterCashierTransferComponent implements OnInit {
private service = inject(CashierService);
private snackBar = inject(MatSnackBar);
notes: Denom[] = [];
coins: Denom[] = [];
counts: Record<number, number> = {};
transferDate = '';
note = '';
saving = false;
tellers: { id: number; name: string }[] = [];
allCashiers: Cashier[] = [];
selectedTellerId: number | null = null;
fromCashierId: number | null = null;
toCashierId: number | null = null;
fromParty = '';
toParty = '';
loadingCashiers = false;
get denoms(): Denom[] {
return [
...this.notes,
...this.coins
];
}
get availableFrom(): Cashier[] {
return this.allCashiers;
}
get availableTo(): Cashier[] {
return this.allCashiers.filter((c) => c.id !== this.fromCashierId);
}
ngOnInit(): void {
const d = new Date();
this.transferDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.service.getActiveDenominations().subscribe({
next: (list) => {
const rows: Denom[] = (list || []).map((x: any) => ({
id: x.id,
value: x.denomination,
kind: x.kind,
label: '₹' + x.denomination
}));
this.notes = rows.filter((r) => r.kind === 'note').sort((a, b) => b.value - a.value);
this.coins = rows.filter((r) => r.kind === 'coin').sort((a, b) => b.value - a.value);
},
error: () => {
this.notes = [];
this.coins = [];
}
});
this.service
.getTellers()
.pipe(catchError(() => of([] as any[])))
.subscribe((list) => {
this.tellers = (list || []).map((t: any) => ({ id: t.id, name: t.name }));
});
}
onTellerSelect(tellerId: number): void {
this.selectedTellerId = tellerId;
this.fromCashierId = null;
this.toCashierId = null;
this.fromParty = '';
this.toParty = '';
this.allCashiers = [];
if (!tellerId) return;
this.loadingCashiers = true;
this.service
.getCashiersForTeller(tellerId)
.pipe(catchError(() => of({ cashiers: [] })))
.subscribe((r: any) => {
this.allCashiers = (r?.cashiers || []).map((c: any) => ({ id: c.id, label: c.staffName }));
this.loadingCashiers = false;
});
}
onFromSelect(cashierId: number): void {
this.fromCashierId = cashierId;
const c = this.allCashiers.find((x) => x.id === cashierId);
this.fromParty = c ? c.label : '';
if (this.toCashierId === cashierId) {
this.toCashierId = null;
this.toParty = '';
}
}
onToSelect(cashierId: number): void {
this.toCashierId = cashierId;
const c = this.allCashiers.find((x) => x.id === cashierId);
this.toParty = c ? c.label : '';
}
countFor(d: Denom): number {
return this.counts[d.id] || 0;
}
subtotal(d: Denom): number {
return this.countFor(d) * d.value;
}
onChange(d: Denom, raw: any): void {
this.counts[d.id] = Math.max(0, Math.floor(Number(raw) || 0));
}
bump(d: Denom, delta: number): void {
this.counts[d.id] = Math.max(0, (this.counts[d.id] || 0) + delta);
}
get total(): number {
return this.denoms.reduce((s, d) => s + this.subtotal(d), 0);
}
get valid(): boolean {
return this.total > 0 && !!this.selectedTellerId && !!this.fromCashierId && !!this.toCashierId;
}
clear(): void {
this.counts = {};
}
submit(): void {
if (!this.valid || this.saving) return;
this.saving = true;
const payload = {
transferDate: this.transferDate,
tellerId: this.selectedTellerId,
fromCashierId: this.fromCashierId,
toCashierId: this.toCashierId,
fromParty: this.fromParty.trim(),
toParty: this.toParty.trim(),
note: this.note || null,
denominations: this.denoms
.filter((d) => this.countFor(d) > 0)
.map((d) => ({ denominationId: d.id, denominationValue: d.value, count: this.countFor(d) }))
};
this.service.createInterCashierTransfer(payload).subscribe({
next: () => {
this.snackBar.open('Inter-cashier transfer recorded', 'Close', { duration: 3000 });
this.clear();
this.note = '';
this.fromCashierId = null;
this.toCashierId = null;
this.fromParty = '';
this.toParty = '';
this.saving = false;
},
error: () => {
this.saving = false;
}
});
}
}
@@ -0,0 +1,248 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="pg">
<header class="pg-head">
<div>
<div class="pg-eyebrow">CASHIER · RECONCILIATION</div>
<h1 class="pg-title">Cash Reconciliation</h1>
<p class="pg-sub">Compare physical denomination count with system expected balance. Highlights variance.</p>
</div>
</header>
<mat-tab-group animationDuration="150ms">
<!-- ── Entry Tab ── -->
<mat-tab label="New Reconciliation">
<div class="pg-tab-body">
<div class="pg-toolbar pg-toolbar--mat">
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Teller</mat-label>
<mat-select [value]="selectedTellerId" (selectionChange)="onTellerSelect($event.value)">
@for (t of tellers; track t.id) {
<mat-option [value]="t.id">{{ t.name }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Cashier</mat-label>
<mat-select
[value]="selectedCashierId"
(selectionChange)="onCashierSelect($event.value)"
[disabled]="!selectedTellerId"
>
@for (c of cashiers; track c.id) {
<mat-option [value]="c.id">{{ c.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Date</mat-label>
<input
matInput
type="date"
[value]="reconDate"
(change)="reconDate = $any($event.target).value; fetchExpected()"
/>
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="pg-note-field">
<mat-label>Note</mat-label>
<input
matInput
type="text"
[value]="note"
(input)="note = $any($event.target).value"
placeholder="Optional"
/>
</mat-form-field>
</div>
@if (loadingExpected) {
<div class="pg-empty">Loading expected balances…</div>
}
@if (!loadingExpected && rows.length > 0) {
<div class="pg-card">
@if (noteRows.length > 0) {
<div class="pos-section-label">Notes</div>
<table class="pg-table">
<thead>
<tr>
<th>Denomination</th>
<th style="text-align: center">Expected</th>
<th style="text-align: center">Physical</th>
<th class="num">Variance</th>
</tr>
</thead>
<tbody>
@for (row of noteRows; track row.denominationId) {
<tr [class.row-excess]="row.varianceCount > 0" [class.row-short]="row.varianceCount < 0">
<td>
<span class="pg-chip">₹{{ row.denominationValue }}</span>
</td>
<td style="text-align: center; color: #64748b; font-weight: 600">{{ row.expectedCount }}</td>
<td style="text-align: center">
<div class="pg-step" style="margin: 0 auto">
<button
type="button"
(click)="updatePhysical(row, row.physicalCount - 1)"
[disabled]="row.physicalCount === 0"
>
−
</button>
<input
type="number"
min="0"
[value]="row.physicalCount"
(input)="updatePhysical(row, $any($event.target).value)"
/>
<button type="button" (click)="updatePhysical(row, row.physicalCount + 1)">+</button>
</div>
</td>
<td
class="num"
[style.color]="
row.varianceCount > 0 ? '#16a34a' : row.varianceCount < 0 ? '#dc2626' : '#94a3b8'
"
>
{{ row.varianceCount > 0 ? '+' : '' }}{{ row.varianceCount }} (₹{{
row.varianceCount * row.denominationValue | number: '1.0-0'
}})
</td>
</tr>
}
</tbody>
</table>
}
@if (coinRows.length > 0) {
<div class="pos-section-label pos-section-label--coin">Coins</div>
<table class="pg-table">
<thead>
<tr>
<th>Denomination</th>
<th style="text-align: center">Expected</th>
<th style="text-align: center">Physical</th>
<th class="num">Variance</th>
</tr>
</thead>
<tbody>
@for (row of coinRows; track row.denominationId) {
<tr [class.row-excess]="row.varianceCount > 0" [class.row-short]="row.varianceCount < 0">
<td>
<span class="pg-chip pg-chip--coin">₹{{ row.denominationValue }}</span>
</td>
<td style="text-align: center; color: #64748b; font-weight: 600">{{ row.expectedCount }}</td>
<td style="text-align: center">
<div class="pg-step" style="margin: 0 auto">
<button
type="button"
(click)="updatePhysical(row, row.physicalCount - 1)"
[disabled]="row.physicalCount === 0"
>
−
</button>
<input
type="number"
min="0"
[value]="row.physicalCount"
(input)="updatePhysical(row, $any($event.target).value)"
/>
<button type="button" (click)="updatePhysical(row, row.physicalCount + 1)">+</button>
</div>
</td>
<td
class="num"
[style.color]="
row.varianceCount > 0 ? '#16a34a' : row.varianceCount < 0 ? '#dc2626' : '#94a3b8'
"
>
{{ row.varianceCount > 0 ? '+' : '' }}{{ row.varianceCount }} (₹{{
row.varianceCount * row.denominationValue | number: '1.0-0'
}})
</td>
</tr>
}
</tbody>
</table>
}
</div>
<div class="pg-total-bar">
<span>
Expected: <strong>₹{{ totalExpected | number: '1.2-2' }}</strong> &nbsp;|&nbsp; Physical:
<strong>₹{{ totalPhysical | number: '1.2-2' }}</strong> &nbsp;|&nbsp; Variance:
<strong [style.color]="totalVariance === 0 ? '#16a34a' : '#dc2626'">
{{ totalVariance >= 0 ? '+' : '' }}₹{{ totalVariance | number: '1.2-2' }}
</strong>
<span class="badge" [ngClass]="statusClass(statusLabel)">{{ statusLabel }}</span>
</span>
<button type="button" class="pg-btn pg-btn--primary" [disabled]="!valid || saving" (click)="submit()">
<mat-icon>save</mat-icon> Save Reconciliation
</button>
</div>
}
@if (!loadingExpected && rows.length === 0 && selectedCashierId) {
<div class="pg-empty">No balance data found for this cashier.</div>
}
@if (!selectedCashierId) {
<div class="pg-empty">Select a teller and cashier to load expected balances.</div>
}
</div>
</mat-tab>
<!-- ── History Tab ── -->
<mat-tab label="History">
<div class="pg-tab-body">
@if (loadingHistory) {
<div class="pg-empty">Loading…</div>
}
@if (!loadingHistory && history.length === 0) {
<div class="pg-empty">No reconciliation records yet.</div>
}
@if (!loadingHistory && history.length > 0) {
<div class="pg-card">
<table class="pg-table">
<thead>
<tr>
<th>Date</th>
<th>Cashier</th>
<th class="num">Expected</th>
<th class="num">Physical</th>
<th class="num">Variance</th>
<th style="text-align: center">Status</th>
<th>By</th>
</tr>
</thead>
<tbody>
@for (r of history; track r.id) {
<tr>
<td>{{ r.reconDate }}</td>
<td>{{ r.cashierName || r.cashierId }}</td>
<td class="num">₹{{ r.expectedAmount | number: '1.2-2' }}</td>
<td class="num">₹{{ r.physicalAmount | number: '1.2-2' }}</td>
<td class="num" [style.color]="r.variance > 0 ? '#16a34a' : r.variance < 0 ? '#dc2626' : '#94a3b8'">
{{ r.variance >= 0 ? '+' : '' }}₹{{ r.variance | number: '1.2-2' }}
</td>
<td style="text-align: center">
<span class="badge" [ngClass]="statusClass(r.status)">{{ r.status }}</span>
</td>
<td>{{ r.performedByUsername }}</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
</mat-tab>
</mat-tab-group>
</div>
@@ -0,0 +1,47 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
.pg-tab-body {
padding: 16px 0 0;
display: flex;
flex-direction: column;
gap: 16px;
}
.row-excess td {
background: #f0fdf4 !important;
}
.row-short td {
background: #fef2f2 !important;
}
.badge {
display: inline-block;
padding: 2px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.badge-ok {
background: #dcfce7;
color: #15803d;
}
.badge-excess {
background: #d1fae5;
color: #065f46;
}
.badge-short {
background: #fee2e2;
color: #991b1b;
}
@@ -0,0 +1,216 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatSelectModule } from '@angular/material/select';
import { MatInputModule } from '@angular/material/input';
import { MatTabsModule } from '@angular/material/tabs';
import { of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
interface DenomRow {
denominationId: number;
denominationValue: number;
kind: string;
expectedCount: number;
physicalCount: number;
varianceCount: number;
}
interface ReconRecord {
id: number;
reconDate: string;
cashierId: number;
cashierName: string;
expectedAmount: number;
physicalAmount: number;
variance: number;
status: 'OK' | 'SHORT' | 'EXCESS';
performedByUsername: string;
createdAt: string;
}
/** Denomination-wise reconciliation — expected vs physical count per cashier. */
@Component({
selector: 'mifosx-reconciliation',
templateUrl: './reconciliation.component.html',
styleUrls: [
'../cashier-shared.scss',
'./reconciliation.component.scss'
],
standalone: true,
imports: [
CommonModule,
FormsModule,
MatIconModule,
MatSnackBarModule,
MatFormFieldModule,
MatSelectModule,
MatInputModule,
MatTabsModule
]
})
export class ReconciliationComponent implements OnInit {
private service = inject(CashierService);
private snackBar = inject(MatSnackBar);
// ── Entry form ──
tellers: { id: number; name: string }[] = [];
cashiers: { id: number; label: string }[] = [];
selectedTellerId: number | null = null;
selectedCashierId: number | null = null;
cashierName = '';
reconDate = '';
note = '';
rows: DenomRow[] = [];
loadingExpected = false;
saving = false;
// ── History list ──
history: ReconRecord[] = [];
loadingHistory = false;
ngOnInit(): void {
const d = new Date();
this.reconDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.service
.getTellers()
.pipe(catchError(() => of([] as any[])))
.subscribe((list) => {
this.tellers = (list || []).map((t: any) => ({ id: t.id, name: t.name }));
});
this.loadHistory();
}
onTellerSelect(tellerId: number): void {
this.selectedTellerId = tellerId;
this.selectedCashierId = null;
this.cashierName = '';
this.cashiers = [];
this.rows = [];
if (!tellerId) return;
this.service
.getCashiersForTeller(tellerId)
.pipe(catchError(() => of({ cashiers: [] })))
.subscribe((r: any) => {
this.cashiers = (r?.cashiers || []).map((c: any) => ({ id: c.id, label: c.staffName }));
});
}
onCashierSelect(cashierId: number): void {
this.selectedCashierId = cashierId;
const c = this.cashiers.find((x) => x.id === cashierId);
this.cashierName = c ? c.label : '';
this.rows = [];
this.fetchExpected();
}
fetchExpected(): void {
if (!this.selectedCashierId) return;
this.loadingExpected = true;
this.service
.getExpectedBalance(this.selectedCashierId, this.reconDate)
.pipe(catchError(() => of(null)))
.subscribe((data: any) => {
this.loadingExpected = false;
if (!data) return;
this.rows = (data.denominations || []).map((d: any) => ({
denominationId: d.denominationId || null,
denominationValue: d.denominationValue,
kind: d.kind || 'note',
expectedCount: d.expectedCount || 0,
physicalCount: d.expectedCount || 0,
varianceCount: 0
}));
});
}
updatePhysical(row: DenomRow, raw: any): void {
row.physicalCount = Math.max(0, Math.floor(Number(raw) || 0));
row.varianceCount = row.physicalCount - row.expectedCount;
}
get noteRows(): DenomRow[] {
return this.rows.filter((r) => r.kind !== 'coin');
}
get coinRows(): DenomRow[] {
return this.rows.filter((r) => r.kind === 'coin');
}
get totalExpected(): number {
return this.rows.reduce((s, r) => s + r.denominationValue * r.expectedCount, 0);
}
get totalPhysical(): number {
return this.rows.reduce((s, r) => s + r.denominationValue * r.physicalCount, 0);
}
get totalVariance(): number {
return this.totalPhysical - this.totalExpected;
}
get statusLabel(): string {
const v = this.totalVariance;
return v === 0 ? 'OK' : v > 0 ? 'EXCESS' : 'SHORT';
}
get valid(): boolean {
return !!this.selectedCashierId && this.rows.length > 0;
}
submit(): void {
if (!this.valid || this.saving) return;
this.saving = true;
const payload = {
cashierId: this.selectedCashierId,
tellerId: this.selectedTellerId,
cashierName: this.cashierName,
reconDate: this.reconDate,
note: this.note || null,
denominations: this.rows.map((r) => ({
denominationId: r.denominationId,
denominationValue: r.denominationValue,
expectedCount: r.expectedCount,
physicalCount: r.physicalCount
}))
};
this.service.createReconciliation(payload).subscribe({
next: (res: any) => {
const status = res?.status || 'OK';
this.snackBar.open(`Reconciliation saved — Status: ${status}`, 'Close', {
duration: 4000,
panelClass: status === 'OK' ? ['snack-ok'] : ['snack-warn']
});
this.rows = [];
this.note = '';
this.saving = false;
this.loadHistory();
},
error: () => {
this.saving = false;
}
});
}
loadHistory(): void {
this.loadingHistory = true;
this.service
.getReconciliations()
.pipe(catchError(() => of([])))
.subscribe((list: any[]) => {
this.history = list || [];
this.loadingHistory = false;
});
}
statusClass(status: string): string {
return status === 'OK' ? 'badge-ok' : status === 'EXCESS' ? 'badge-excess' : 'badge-short';
}
}
+23 -6
View File
@@ -692,22 +692,39 @@
<!-- Sub Menu Dropdowns -->
<mat-menu #cashierMenu="matMenu" [overlapTrigger]="false" class="nav-dropdown">
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cash-exchange']">
<fa-icon icon="exchange" class="menu-item-icon"></fa-icon> Cash Exchange Entry
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" [routerLink]="['/system/denomination-master']">
<fa-icon icon="coins" class="menu-item-icon"></fa-icon> Cash Denomination
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/closing-report']">
<fa-icon icon="file-alt" class="menu-item-icon"></fa-icon> Closing Cash Report
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/exchange-detail']">
<fa-icon icon="globe" class="menu-item-icon"></fa-icon> Currency Exchange Detail
</button>
<button mat-menu-item tabindex="0"><fa-icon icon="random" class="menu-item-icon"></fa-icon> Cash Transfer</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cash-transfer']">
<fa-icon icon="random" class="menu-item-icon"></fa-icon> Cash Transfer
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/excess-report']">
<fa-icon icon="exclamation-circle" class="menu-item-icon"></fa-icon> Excess Transaction Report
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cash-settlement']">
<fa-icon icon="check-circle" class="menu-item-icon"></fa-icon> Cash Settlement
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/inter-cashier']">
<fa-icon icon="exchange-alt" class="menu-item-icon"></fa-icon> Inter-Cashier Transfer
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cashier-position']">
<fa-icon icon="wallet" class="menu-item-icon"></fa-icon> Cashier Position
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/reconciliation']">
<fa-icon icon="balance-scale" class="menu-item-icon"></fa-icon> Reconciliation
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cash-history']">
<fa-icon icon="history" class="menu-item-icon"></fa-icon> Cash History
</button>
</mat-menu>
<mat-menu #depositsMenu="matMenu" class="deposits-mega-panel" [overlapTrigger]="false">
@@ -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 ────────────── -->
@@ -879,3 +879,218 @@ $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;
}
@@ -22,6 +22,7 @@ import { CurrentAccountsService } from '../../current-accounts.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';
@@ -67,6 +68,7 @@ export class CurrentAccountTransactionsComponent 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);
@@ -114,6 +116,23 @@ export class CurrentAccountTransactionsComponent implements OnInit {
availableBalance = 0;
identityVerified = false;
openingDenoms: any[] = [];
openingTotal = 0;
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: { currentAccountActionData: any }) => {
this.paymentTypeOptions = data.currentAccountActionData.paymentTypeOptions;
@@ -131,6 +150,7 @@ export class CurrentAccountTransactionsComponent implements OnInit {
this.createCurrentAccountTransactionForm();
this.loadDenominations();
this.loadContextStrip();
this.loadOpeningPosition();
}
/** Load active denominations from the Denomination Master so the cash
@@ -183,6 +203,68 @@ export class CurrentAccountTransactionsComponent 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;
}
});
}
get closingDenoms(): Array<any> {
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));
}
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) => {
@@ -126,6 +126,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">
@@ -265,6 +299,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: transaction details -->
@@ -791,3 +791,218 @@ $amber-d: #b45309;
flex: 1 1 calc(50% - 14px);
}
}
/* ─── 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;
}
@@ -18,6 +18,7 @@ import { FixedDepositsService } from '../../fixed-deposits.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 { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { Currency, PaymentType } from 'app/shared/models/general.model';
@@ -61,6 +62,7 @@ export class FixedDepositsCashTransactionComponent implements OnInit {
private settingsService = inject(SettingsService);
private dialog = inject(MatDialog);
private denominationMasterService = inject(DenominationMasterService);
private cashierService = inject(CashierService);
minDate = new Date(2000, 0, 1);
maxDate = new Date();
@@ -89,6 +91,23 @@ export class FixedDepositsCashTransactionComponent implements OnInit {
availableBalance = 0;
identityVerified = false;
openingDenoms: any[] = [];
openingTotal = 0;
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: { fixedDepositsAccountActionData: any }) => {
this.currency = data.fixedDepositsAccountActionData.currency;
@@ -110,6 +129,7 @@ export class FixedDepositsCashTransactionComponent implements OnInit {
this.createSavingAccountTransactionForm();
this.loadDenominations();
this.loadContextStrip();
this.loadOpeningPosition();
}
/** Load active denominations from the Denomination Master so the cash
@@ -160,6 +180,68 @@ export class FixedDepositsCashTransactionComponent 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;
}
});
}
get closingDenoms(): Array<any> {
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));
}
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) => {
@@ -126,6 +126,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">
@@ -265,6 +299,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: transaction details -->
@@ -791,3 +791,218 @@ $amber-d: #b45309;
flex: 1 1 calc(50% - 14px);
}
}
/* ─── 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;
}
@@ -22,6 +22,7 @@ import { RecurringDepositsService } from '../../recurring-deposits.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 { TransactionCommand, TransactionTypeFlags } from '../../../transaction.model';
@@ -67,6 +68,7 @@ export class DepositRecurringDepositsAccountComponent implements OnInit {
private settingsService = inject(SettingsService);
private dialog = inject(MatDialog);
private denominationMasterService = inject(DenominationMasterService);
private cashierService = inject(CashierService);
@Input() currency: Currency;
@@ -100,6 +102,23 @@ export class DepositRecurringDepositsAccountComponent implements OnInit {
availableBalance = 0;
identityVerified = false;
openingDenoms: any[] = [];
openingTotal = 0;
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: { recurringDepositsAccountActionData: any }) => {
this.transactionAmount = data.recurringDepositsAccountActionData.amount;
@@ -128,6 +147,7 @@ export class DepositRecurringDepositsAccountComponent implements OnInit {
this.createdepositRecurringDepositForm();
this.loadDenominations();
this.loadContextStrip();
this.loadOpeningPosition();
}
/** Load active denominations from the Denomination Master so the cash
@@ -178,6 +198,68 @@ export class DepositRecurringDepositsAccountComponent 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;
}
});
}
get closingDenoms(): Array<any> {
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));
}
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) => {
@@ -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 ────────────── -->
@@ -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;
@@ -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) => {
+2 -2
View File
@@ -9,8 +9,8 @@
/* tslint:disable */
export default {
'mifos_x': {
'version': '260525',
'hash': 'ad6f629c'
'version': '260527',
'hash': '33b01d66'
},
'allow_switching_backend_instance': true
};