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