diff --git a/src/app/app.module.ts b/src/app/app.module.ts index b42ed9907..f02a44e0e 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -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, diff --git a/src/app/cashier/cash-exchange-entry/cash-exchange-entry.component.html b/src/app/cashier/cash-exchange-entry/cash-exchange-entry.component.html new file mode 100644 index 000000000..4950989e4 --- /dev/null +++ b/src/app/cashier/cash-exchange-entry/cash-exchange-entry.component.html @@ -0,0 +1,160 @@ + + +
+
+
+
CASHIER · CASH EXCHANGE
+

Cash Exchange Entry

+

Customer gives notes/coins and takes equal-value change. Net amount must be ₹0.

+
+
+ + +
+
+ +
+ +
+
+ south + Received from customer + ₹{{ receivedTotal | number: '1.2-2' }} +
+
+ @if (notes.length) { +
Bank Notes
+ } + @for (d of notes; track d.id) { +
+ {{ d.label }} +
+ + + +
+ ₹{{ subtotal('r', d) | number: '1.2-2' }} +
+ } + @if (coins.length) { +
Coins
+ } + @for (d of coins; track d.id) { +
+ {{ d.label }} +
+ + + +
+ ₹{{ subtotal('r', d) | number: '1.2-2' }} +
+ } +
+
+ + +
+
+ north + Given to customer + ₹{{ givenTotal | number: '1.2-2' }} +
+
+ @if (notes.length) { +
Bank Notes
+ } + @for (d of notes; track d.id) { +
+ {{ d.label }} +
+ + + +
+ ₹{{ subtotal('g', d) | number: '1.2-2' }} +
+ } + @if (coins.length) { +
Coins
+ } + @for (d of coins; track d.id) { +
+ {{ d.label }} +
+ + + +
+ ₹{{ subtotal('g', d) | number: '1.2-2' }} +
+ } +
+
+
+ +
+
+ Received₹{{ receivedTotal | number: '1.2-2' }} +
+ swap_horiz +
+ Given₹{{ givenTotal | number: '1.2-2' }} +
+
+ Difference + ₹{{ difference | number: '1.2-2' }} +
+
+ + @if (receivedTotal > 0 && !matches) { +
warning Received and Given totals must match (net ₹0).
+ } + +
+ + +
+
diff --git a/src/app/cashier/cash-exchange-entry/cash-exchange-entry.component.scss b/src/app/cashier/cash-exchange-entry/cash-exchange-entry.component.scss new file mode 100644 index 000000000..687d4ac0f --- /dev/null +++ b/src/app/cashier/cash-exchange-entry/cash-exchange-entry.component.scss @@ -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; + } +} diff --git a/src/app/cashier/cash-exchange-entry/cash-exchange-entry.component.ts b/src/app/cashier/cash-exchange-entry/cash-exchange-entry.component.ts new file mode 100644 index 000000000..5d1bc6428 --- /dev/null +++ b/src/app/cashier/cash-exchange-entry/cash-exchange-entry.component.ts @@ -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 = {}; + givenCounts: Record = {}; + 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; + } + }); + } +} diff --git a/src/app/cashier/cash-history/cash-history.component.html b/src/app/cashier/cash-history/cash-history.component.html new file mode 100644 index 000000000..8fe0bb7e9 --- /dev/null +++ b/src/app/cashier/cash-history/cash-history.component.html @@ -0,0 +1,106 @@ + + +
+
+
+
CASHIER · HISTORY
+

Cash Movement History

+

All transfers, settlements, and inter-cashier movements. Reverse any active transaction.

+
+ +
+ + + @if (reversingId !== null) { +
+ warning + Reverse #{{ reversingId }}? This cannot be undone. + + + +
+ } + + @if (loading) { +
Loading…
+ } + + + +
+ +
+
+ + +
+ +
+
+ + +
+ +
+
+
+
+ + + @if (rows.length === 0 && !loading) { +
No records.
+ } + @if (rows.length > 0) { +
+ + + + + + + + + + + + + + + + @for (r of rows; track r.id) { + + + + + + + + + + + + } + +
#DateFromToAmountNoteByStatusAction
{{ r.id }}{{ r.date }}{{ r.fromParty }}{{ r.toParty }}₹{{ r.amount | number: '1.2-2' }}{{ r.note || '—' }}{{ r.performedByUsername || '—' }} + + {{ r.isReversed ? 'Reversed' : 'Active' }} + + + @if (!r.isReversed) { + + } +
+
+ } +
diff --git a/src/app/cashier/cash-history/cash-history.component.scss b/src/app/cashier/cash-history/cash-history.component.scss new file mode 100644 index 000000000..610a28b5e --- /dev/null +++ b/src/app/cashier/cash-history/cash-history.component.scss @@ -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; + } +} diff --git a/src/app/cashier/cash-history/cash-history.component.ts b/src/app/cashier/cash-history/cash-history.component.ts new file mode 100644 index 000000000..2a9ada63c --- /dev/null +++ b/src/app/cashier/cash-history/cash-history.component.ts @@ -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 }); + } + }); + } +} diff --git a/src/app/cashier/cash-settlement/cash-settlement.component.html b/src/app/cashier/cash-settlement/cash-settlement.component.html new file mode 100644 index 000000000..a6b25cebf --- /dev/null +++ b/src/app/cashier/cash-settlement/cash-settlement.component.html @@ -0,0 +1,124 @@ + + +
+
+
+
CASHIER · CASH SETTLEMENT
+

Cash Settlement

+

Cashier returns denomination-wise cash to teller / vault at end of day.

+
+ +
+ +
+ + Teller (To / Vault) + + @for (t of tellers; track t.id) { + {{ t.name }} + } + @if (tellers.length === 0) { + No tellers configured + } + + + + + From (Cashier) + + @for (c of cashiers; track c.id) { + {{ c.label }} + } + @if (selectedTellerId && cashiers.length === 0 && !loadingCashiers) { + No cashiers for this teller + } + + + + + Date + + + + + Note + + +
+ +
+ + + + + + + + + + @if (notes.length) { + + + + } + @for (d of notes; track d.id) { + + + + + + } + @if (coins.length) { + + + + } + @for (d of coins; track d.id) { + + + + + + } + @if (denoms.length === 0) { + + + + } + +
DenominationCountAmount
Bank Notes
+ {{ d.label }} + +
+ + + +
+
₹{{ subtotal(d) | number: '1.2-2' }}
Coins
+ {{ d.label }} + +
+ + + +
+
₹{{ subtotal(d) | number: '1.2-2' }}
No active denominations. Add some in Cash Denomination.
+
+ +
+ Total to settle: ₹{{ total | number: '1.2-2' }} + +
+
diff --git a/src/app/cashier/cash-settlement/cash-settlement.component.ts b/src/app/cashier/cash-settlement/cash-settlement.component.ts new file mode 100644 index 000000000..abe5e2e29 --- /dev/null +++ b/src/app/cashier/cash-settlement/cash-settlement.component.ts @@ -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 = {}; + 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; + } + }); + } +} diff --git a/src/app/cashier/cash-transfer/cash-transfer.component.html b/src/app/cashier/cash-transfer/cash-transfer.component.html new file mode 100644 index 000000000..d7dcbb54b --- /dev/null +++ b/src/app/cashier/cash-transfer/cash-transfer.component.html @@ -0,0 +1,120 @@ + + +
+
+
+
CASHIER · CASH TRANSFER
+

Cash Transfer

+

Move cash denomination-wise from one party to another (e.g. Vault → Cashier for opening).

+
+ +
+ +
+ + From (Teller / Head) + + @for (t of tellers; track t.id) { + {{ t.name }} + } + @if (tellers.length === 0) { + No tellers configured + } + + + + + To (Cashier) + + @for (c of cashiers; track c.id) { + {{ c.label }} + } + @if (fromTellerId && cashiers.length === 0 && !loadingCashiers) { + No cashiers for this teller + } + + + + + Date + + + + + Note + + +
+ +
+ + + + + + + + + + @if (notes.length) { + + + + } + @for (d of notes; track d.id) { + + + + + + } + @if (coins.length) { + + + + } + @for (d of coins; track d.id) { + + + + + + } + @if (denoms.length === 0) { + + + + } + +
DenominationCountAmount
Bank Notes
+ {{ d.label }} + +
+ + + +
+
₹{{ subtotal(d) | number: '1.2-2' }}
Coins
+ {{ d.label }} + +
+ + + +
+
₹{{ subtotal(d) | number: '1.2-2' }}
No active denominations. Add some in Cash Denomination.
+
+ +
+ Total to transfer: ₹{{ total | number: '1.2-2' }} + +
+
diff --git a/src/app/cashier/cash-transfer/cash-transfer.component.ts b/src/app/cashier/cash-transfer/cash-transfer.component.ts new file mode 100644 index 000000000..cf6f1bb35 --- /dev/null +++ b/src/app/cashier/cash-transfer/cash-transfer.component.ts @@ -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 = {}; + 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; + } + }); + } +} diff --git a/src/app/cashier/cashier-position/cashier-position.component.html b/src/app/cashier/cashier-position/cashier-position.component.html new file mode 100644 index 000000000..fc405c40f --- /dev/null +++ b/src/app/cashier/cashier-position/cashier-position.component.html @@ -0,0 +1,123 @@ + + +
+
+
+
CASHIER · POSITION REPORT
+

Cashier Position

+

+ Denomination-wise net cash held by a cashier as of a date (allocations − settlements − inter-cashier transfers). +

+
+
+ +
+ + Teller (optional) + + All Tellers + @for (t of tellers; track t.id) { + {{ t.name }} + } + + + + + Cashier (optional) + + All Cashiers + @for (c of cashiers; track c.id) { + {{ c.label }} + } + + + + + As of Date + + + + +
+ + @if (errorMsg) { +
{{ errorMsg }}
+ } + + @if (loading) { +
Loading…
+ } + + @for (pos of results; track pos.cashierId) { +
+
+ {{ pos.cashierName || labelForCashier(pos.cashierId) }} + ₹{{ pos.totalAmount | number: '1.2-2' }} +
+ + @if (pos.denominations.length === 0) { +
No balance.
+ } + + @if (notes(pos).length > 0) { + + + + + + + + + + + @for (d of notes(pos); track d.denominationId) { + + + + + + } + +
DenominationCountAmount
+ ₹{{ d.denominationValue }} + {{ d.count }}₹{{ d.amount | number: '1.2-2' }}
+ } + + @if (coins(pos).length > 0) { + + + + + + + + + + + @for (d of coins(pos); track d.denominationId) { + + + + + + } + +
DenominationCountAmount
+ ₹{{ d.denominationValue }} + {{ d.count }}₹{{ d.amount | number: '1.2-2' }}
+ } +
+ } + + @if (!loading && results.length === 0 && !errorMsg) { +
Select filters and click "Load Position".
+ } +
diff --git a/src/app/cashier/cashier-position/cashier-position.component.scss b/src/app/cashier/cashier-position/cashier-position.component.scss new file mode 100644 index 000000000..1807e6902 --- /dev/null +++ b/src/app/cashier/cashier-position/cashier-position.component.scss @@ -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; +} diff --git a/src/app/cashier/cashier-position/cashier-position.component.ts b/src/app/cashier/cashier-position/cashier-position.component.ts new file mode 100644 index 000000000..abb113211 --- /dev/null +++ b/src/app/cashier/cashier-position/cashier-position.component.ts @@ -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'); + } +} diff --git a/src/app/cashier/cashier-routing.module.ts b/src/app/cashier/cashier-routing.module.ts new file mode 100644 index 000000000..732655f6b --- /dev/null +++ b/src/app/cashier/cashier-routing.module.ts @@ -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 {} diff --git a/src/app/cashier/cashier-shared.scss b/src/app/cashier/cashier-shared.scss new file mode 100644 index 000000000..3fc434cb8 --- /dev/null +++ b/src/app/cashier/cashier-shared.scss @@ -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; + } +} diff --git a/src/app/cashier/cashier.module.ts b/src/app/cashier/cashier.module.ts new file mode 100644 index 000000000..ec6dcfeeb --- /dev/null +++ b/src/app/cashier/cashier.module.ts @@ -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 {} diff --git a/src/app/cashier/cashier.service.ts b/src/app/cashier/cashier.service.ts new file mode 100644 index 000000000..beb8141a2 --- /dev/null +++ b/src/app/cashier/cashier.service.ts @@ -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 { + return this.http.get('/denominationmaster/active'); + } + + // ── Cash Exchange ── + createCashExchange(data: any): Observable { + return this.http.post('/cashexchange', data); + } + getCashExchanges(): Observable { + return this.http.get('/cashexchange'); + } + getCashExchange(id: number): Observable { + return this.http.get(`/cashexchange/${id}`); + } + + // ── Tellers / Cashiers ── + getTellers(): Observable { + return this.http.get('/tellers'); + } + getAllCashiers(): Observable { + return this.http.get('/cashiers'); + } + getCashiersForTeller(tellerId: number): Observable { + return this.http.get(`/tellers/${tellerId}/cashiers`); + } + + // ── Cash Transfer ── + createCashTransfer(data: any): Observable { + return this.http.post('/cashtransfer', data); + } + getCashTransfers(): Observable { + return this.http.get('/cashtransfer'); + } + reverseCashTransfer(id: number, reason: string): Observable { + return this.http.post(`/cashtransfer/${id}/reverse`, { reason }); + } + + // ── Cash Settlement ── + createCashSettlement(data: any): Observable { + return this.http.post('/cashsettlement', data); + } + getCashSettlements(): Observable { + return this.http.get('/cashsettlement'); + } + reverseCashSettlement(id: number, reason: string): Observable { + return this.http.post(`/cashsettlement/${id}/reverse`, { reason }); + } + + // ── Inter-cashier Transfer ── + createInterCashierTransfer(data: any): Observable { + return this.http.post('/cashintercashier', data); + } + getInterCashierTransfers(): Observable { + return this.http.get('/cashintercashier'); + } + reverseInterCashierTransfer(id: number, reason: string): Observable { + return this.http.post(`/cashintercashier/${id}/reverse`, { reason }); + } + + // ── Reconciliation ── + createReconciliation(data: any): Observable { + return this.http.post('/cashreconciliation', data); + } + getReconciliations(): Observable { + return this.http.get('/cashreconciliation'); + } + getReconciliationById(id: number): Observable { + return this.http.get(`/cashreconciliation/${id}`); + } + getExpectedBalance(cashierId: number, asOfDate?: string): Observable { + 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 { + const params = new HttpParams().set('date', date); + return this.http.get('/cashreports/closing', { params }); + } + excessTransactionReport(threshold: number, fromDate?: string, toDate?: string): Observable { + 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('/cashreports/excess', { params }); + } + cashierPositionReport(cashierId?: number, asOfDate?: string): Observable { + 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 { + let params = new HttpParams(); + if (asOfDate) params = params.set('asOfDate', asOfDate); + return this.http.get('/cashreports/cashier-position/me', { params }); + } +} diff --git a/src/app/cashier/closing-cash-report/closing-cash-report.component.html b/src/app/cashier/closing-cash-report/closing-cash-report.component.html new file mode 100644 index 000000000..607d3662d --- /dev/null +++ b/src/app/cashier/closing-cash-report/closing-cash-report.component.html @@ -0,0 +1,140 @@ + + +
+
+
+
CASHIER · REPORT
+

Closing Cash Report

+

Denomination-wise deposit, withdrawal and net cash position for the selected day.

+
+
+ + +
+
+ + @if (rows.length > 0) { + +
+
+ Total Deposits (IN) + ₹{{ depositTotal | number: '1.2-2' }} +
+
−
+
+ Total Withdrawals (OUT) + ₹{{ withdrawalTotal | number: '1.2-2' }} +
+
=
+
+ Net Closing Cash + ₹{{ netTotal | number: '1.2-2' }} +
+
+ } + +
+ @if (noteRows.length > 0) { + + + + + + + + + + + + + + + @for (r of noteRows; track r.denominationId) { + + + + + + + + + + } + +
DenominationDeposit PcsDeposit AmtWithdrawal PcsWithdrawal AmtNet PcsNet Amount
+ ₹{{ r.denominationValue }} + {{ r.depositCount }}₹{{ r.depositAmount | number: '1.2-2' }}{{ r.withdrawalCount }}₹{{ r.withdrawalAmount | number: '1.2-2' }} + {{ r.netCount > 0 ? '+' : '' }}{{ r.netCount }} + + {{ r.netCount >= 0 ? '' : '−' }}₹{{ (r.netAmount < 0 ? -r.netAmount : r.netAmount) | number: '1.2-2' }} +
+ } + + @if (coinRows.length > 0) { + + + + + + + + + + + + + + + @for (r of coinRows; track r.denominationId) { + + + + + + + + + + } + +
DenominationDeposit PcsDeposit AmtWithdrawal PcsWithdrawal AmtNet PcsNet Amount
+ ₹{{ r.denominationValue }} + {{ r.depositCount }}₹{{ r.depositAmount | number: '1.2-2' }}{{ r.withdrawalCount }}₹{{ r.withdrawalAmount | number: '1.2-2' }} + {{ r.netCount > 0 ? '+' : '' }}{{ r.netCount }} + + {{ r.netCount >= 0 ? '' : '−' }}₹{{ (r.netAmount < 0 ? -r.netAmount : r.netAmount) | number: '1.2-2' }} +
+ } + + @if (loaded && rows.length === 0) { +
No cash denomination data for this date.
+ } +
+
diff --git a/src/app/cashier/closing-cash-report/closing-cash-report.component.ts b/src/app/cashier/closing-cash-report/closing-cash-report.component.ts new file mode 100644 index 000000000..175710636 --- /dev/null +++ b/src/app/cashier/closing-cash-report/closing-cash-report.component.ts @@ -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; + } + }); + } +} diff --git a/src/app/cashier/currency-exchange-detail/currency-exchange-detail.component.html b/src/app/cashier/currency-exchange-detail/currency-exchange-detail.component.html new file mode 100644 index 000000000..790f31737 --- /dev/null +++ b/src/app/cashier/currency-exchange-detail/currency-exchange-detail.component.html @@ -0,0 +1,52 @@ + + +
+
+
+
CASHIER · EXCHANGE
+

Currency Exchange Detail

+

All recorded cash exchanges (notes/coins swapped at the counter).

+
+ + add New Exchange + +
+ +
+ + + + + + + + + + + + + @for (e of exchanges; track e.id) { + + + + + + + + + } + @if (!loading && exchanges.length === 0) { + + + + } + +
#DateAmountNoteByRecorded
{{ e.id }}{{ e.exchangeDate || '—' }}₹{{ e.exchangeAmount | number: '1.2-2' }}{{ e.note || '—' }}{{ e.performedByUsername || '—' }}{{ e.createdAt || '—' }}
No cash exchanges recorded yet.
+
+
diff --git a/src/app/cashier/currency-exchange-detail/currency-exchange-detail.component.ts b/src/app/cashier/currency-exchange-detail/currency-exchange-detail.component.ts new file mode 100644 index 000000000..38eacb054 --- /dev/null +++ b/src/app/cashier/currency-exchange-detail/currency-exchange-detail.component.ts @@ -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; + } + }); + } +} diff --git a/src/app/cashier/excess-transaction-report/excess-transaction-report.component.html b/src/app/cashier/excess-transaction-report/excess-transaction-report.component.html new file mode 100644 index 000000000..84270cd17 --- /dev/null +++ b/src/app/cashier/excess-transaction-report/excess-transaction-report.component.html @@ -0,0 +1,64 @@ + + +
+
+
+
CASHIER · REPORT
+

Excess Transaction Report

+

Cash transactions at or above the threshold (default ₹50,000 — CTR / AML monitoring).

+
+
+ + + + +
+
+ +
+ + + + + + + + + + + + @for (r of rows; track r.txnId) { + + + + + + + + } + @if (loaded && rows.length === 0) { + + + + } + +
Txn #Account NoCIFDateAmount
{{ r.txnId }}{{ r.accountNo || '—' }}{{ r.cifNo || '—' }}{{ r.txnDate || '—' }}₹{{ r.totalAmount | number: '1.2-2' }}
No transactions at or above ₹{{ threshold | number }}.
+
+
diff --git a/src/app/cashier/excess-transaction-report/excess-transaction-report.component.ts b/src/app/cashier/excess-transaction-report/excess-transaction-report.component.ts new file mode 100644 index 000000000..3bbf3eca1 --- /dev/null +++ b/src/app/cashier/excess-transaction-report/excess-transaction-report.component.ts @@ -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; + } + }); + } +} diff --git a/src/app/cashier/inter-cashier-transfer/inter-cashier-transfer.component.html b/src/app/cashier/inter-cashier-transfer/inter-cashier-transfer.component.html new file mode 100644 index 000000000..742ad1269 --- /dev/null +++ b/src/app/cashier/inter-cashier-transfer/inter-cashier-transfer.component.html @@ -0,0 +1,132 @@ + + +
+
+
+
CASHIER · INTER-CASHIER TRANSFER
+

Inter-Cashier Transfer

+

Move denomination-wise cash between two cashiers within the same teller.

+
+ +
+ +
+ + Teller + + @for (t of tellers; track t.id) { + {{ t.name }} + } + @if (tellers.length === 0) { + No tellers configured + } + + + + + From (Cashier) + + @for (c of availableFrom; track c.id) { + {{ c.label }} + } + @if (selectedTellerId && allCashiers.length === 0 && !loadingCashiers) { + No cashiers + } + + + + + To (Cashier) + + @for (c of availableTo; track c.id) { + {{ c.label }} + } + @if (fromCashierId && availableTo.length === 0) { + No other cashiers + } + + + + + Date + + + + + Note + + +
+ +
+ + + + + + + + + + @if (notes.length) { + + + + } + @for (d of notes; track d.id) { + + + + + + } + @if (coins.length) { + + + + } + @for (d of coins; track d.id) { + + + + + + } + @if (denoms.length === 0) { + + + + } + +
DenominationCountAmount
Bank Notes
+ {{ d.label }} + +
+ + + +
+
₹{{ subtotal(d) | number: '1.2-2' }}
Coins
+ {{ d.label }} + +
+ + + +
+
₹{{ subtotal(d) | number: '1.2-2' }}
No active denominations. Add some in Cash Denomination.
+
+ +
+ Total to transfer: ₹{{ total | number: '1.2-2' }} + +
+
diff --git a/src/app/cashier/inter-cashier-transfer/inter-cashier-transfer.component.ts b/src/app/cashier/inter-cashier-transfer/inter-cashier-transfer.component.ts new file mode 100644 index 000000000..f49f02895 --- /dev/null +++ b/src/app/cashier/inter-cashier-transfer/inter-cashier-transfer.component.ts @@ -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 = {}; + 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; + } + }); + } +} diff --git a/src/app/cashier/reconciliation/reconciliation.component.html b/src/app/cashier/reconciliation/reconciliation.component.html new file mode 100644 index 000000000..ca92e23e2 --- /dev/null +++ b/src/app/cashier/reconciliation/reconciliation.component.html @@ -0,0 +1,248 @@ + + +
+
+
+
CASHIER · RECONCILIATION
+

Cash Reconciliation

+

Compare physical denomination count with system expected balance. Highlights variance.

+
+
+ + + + +
+
+ + Teller + + @for (t of tellers; track t.id) { + {{ t.name }} + } + + + + + Cashier + + @for (c of cashiers; track c.id) { + {{ c.label }} + } + + + + + Date + + + + + Note + + +
+ + @if (loadingExpected) { +
Loading expected balances…
+ } + + @if (!loadingExpected && rows.length > 0) { +
+ @if (noteRows.length > 0) { + + + + + + + + + + + + @for (row of noteRows; track row.denominationId) { + + + + + + + } + +
DenominationExpectedPhysicalVariance
+ ₹{{ row.denominationValue }} + {{ row.expectedCount }} +
+ + + +
+
+ {{ row.varianceCount > 0 ? '+' : '' }}{{ row.varianceCount }} (₹{{ + row.varianceCount * row.denominationValue | number: '1.0-0' + }}) +
+ } + + @if (coinRows.length > 0) { + + + + + + + + + + + + @for (row of coinRows; track row.denominationId) { + + + + + + + } + +
DenominationExpectedPhysicalVariance
+ ₹{{ row.denominationValue }} + {{ row.expectedCount }} +
+ + + +
+
+ {{ row.varianceCount > 0 ? '+' : '' }}{{ row.varianceCount }} (₹{{ + row.varianceCount * row.denominationValue | number: '1.0-0' + }}) +
+ } +
+ +
+ + Expected: ₹{{ totalExpected | number: '1.2-2' }}  |  Physical: + ₹{{ totalPhysical | number: '1.2-2' }}  |  Variance: + + {{ totalVariance >= 0 ? '+' : '' }}₹{{ totalVariance | number: '1.2-2' }} + + {{ statusLabel }} + + +
+ } + + @if (!loadingExpected && rows.length === 0 && selectedCashierId) { +
No balance data found for this cashier.
+ } + @if (!selectedCashierId) { +
Select a teller and cashier to load expected balances.
+ } +
+
+ + + +
+ @if (loadingHistory) { +
Loading…
+ } + @if (!loadingHistory && history.length === 0) { +
No reconciliation records yet.
+ } + @if (!loadingHistory && history.length > 0) { +
+ + + + + + + + + + + + + + @for (r of history; track r.id) { + + + + + + + + + + } + +
DateCashierExpectedPhysicalVarianceStatusBy
{{ r.reconDate }}{{ r.cashierName || r.cashierId }}₹{{ r.expectedAmount | number: '1.2-2' }}₹{{ r.physicalAmount | number: '1.2-2' }} + {{ r.variance >= 0 ? '+' : '' }}₹{{ r.variance | number: '1.2-2' }} + + {{ r.status }} + {{ r.performedByUsername }}
+
+ } +
+
+
+
diff --git a/src/app/cashier/reconciliation/reconciliation.component.scss b/src/app/cashier/reconciliation/reconciliation.component.scss new file mode 100644 index 000000000..1d3011dbf --- /dev/null +++ b/src/app/cashier/reconciliation/reconciliation.component.scss @@ -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; +} diff --git a/src/app/cashier/reconciliation/reconciliation.component.ts b/src/app/cashier/reconciliation/reconciliation.component.ts new file mode 100644 index 000000000..c71b23e30 --- /dev/null +++ b/src/app/cashier/reconciliation/reconciliation.component.ts @@ -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'; + } +} diff --git a/src/app/core/shell/shell.component.html b/src/app/core/shell/shell.component.html index 1c0fecc5b..3a2b3275f 100644 --- a/src/app/core/shell/shell.component.html +++ b/src/app/core/shell/shell.component.html @@ -692,22 +692,39 @@ - - - - - - + + + + + + diff --git a/src/app/current-accounts/current-account-actions/current-account-transactions/current-account-transactions.component.html b/src/app/current-accounts/current-account-actions/current-account-transactions/current-account-transactions.component.html index 9a610910c..d5bed2bd6 100644 --- a/src/app/current-accounts/current-account-actions/current-account-transactions/current-account-transactions.component.html +++ b/src/app/current-accounts/current-account-actions/current-account-transactions/current-account-transactions.component.html @@ -152,6 +152,40 @@ + + @if (openingLoaded && openingDenoms.length > 0) { +
+
+ Opening Balance + ₹{{ openingTotal | number: '1.2-2' }} +
+ @if (openingNotes.length > 0) { +
+ Notes +
+ @for (d of openingNotes; track d.denominationId) { + ₹{{ d.denominationValue }}×{{ d.count }} + } +
+
+ } + @if (openingCoins.length > 0) { +
+ Coins +
+ @for (d of openingCoins; track d.denominationId) { + ₹{{ d.denominationValue }}×{{ d.count }} + } +
+
+ } +
+ } +
@@ -292,6 +326,52 @@ Use ₹{{ denominationTotal | number: '1.2-2' }} as Transaction Amount
+ + @if (openingLoaded && openingDenoms.length > 0) { +
+
+ Closing Balance + (after this {{ transactionType.deposit ? 'deposit' : 'withdrawal' }}) + ₹{{ closingTotal | number: '1.2-2' }} +
+ @if (denominationTotal > 0 && closingDenoms.length > 0) { + @if (closingNotes.length > 0) { +
+ Notes +
+ @for (d of closingNotes; track d.denominationId) { + + ₹{{ d.denominationValue }}×{{ d.closingCount }} + + } +
+
+ } + @if (closingCoins.length > 0) { +
+ Coins +
+ @for (d of closingCoins; track d.denominationId) { + + ₹{{ d.denominationValue }}×{{ d.closingCount }} + + } +
+
+ } + } +
+ } diff --git a/src/app/current-accounts/current-account-actions/current-account-transactions/current-account-transactions.component.scss b/src/app/current-accounts/current-account-actions/current-account-transactions/current-account-transactions.component.scss index 1c7f11f6d..84588d28c 100644 --- a/src/app/current-accounts/current-account-actions/current-account-transactions/current-account-transactions.component.scss +++ b/src/app/current-accounts/current-account-actions/current-account-transactions/current-account-transactions.component.scss @@ -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; +} diff --git a/src/app/current-accounts/current-account-actions/current-account-transactions/current-account-transactions.component.ts b/src/app/current-accounts/current-account-actions/current-account-transactions/current-account-transactions.component.ts index 6886849b1..d18a28fc4 100644 --- a/src/app/current-accounts/current-account-actions/current-account-transactions/current-account-transactions.component.ts +++ b/src/app/current-accounts/current-account-actions/current-account-transactions/current-account-transactions.component.ts @@ -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 { + const openMap = new Map(); + for (const d of this.openingDenoms) { + openMap.set(`${d.kind}:${d.denominationValue}`, d); + } + const keySet = new Set(); + 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) => { diff --git a/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/fixed-deposits-cash-transaction/fixed-deposits-cash-transaction.component.html b/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/fixed-deposits-cash-transaction/fixed-deposits-cash-transaction.component.html index 8ba08466d..50b747955 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/fixed-deposits-cash-transaction/fixed-deposits-cash-transaction.component.html +++ b/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/fixed-deposits-cash-transaction/fixed-deposits-cash-transaction.component.html @@ -126,6 +126,40 @@ + + @if (openingLoaded && openingDenoms.length > 0) { +
+
+ Opening Balance + ₹{{ openingTotal | number: '1.2-2' }} +
+ @if (openingNotes.length > 0) { +
+ Notes +
+ @for (d of openingNotes; track d.denominationId) { + ₹{{ d.denominationValue }}×{{ d.count }} + } +
+
+ } + @if (openingCoins.length > 0) { +
+ Coins +
+ @for (d of openingCoins; track d.denominationId) { + ₹{{ d.denominationValue }}×{{ d.count }} + } +
+
+ } +
+ } +
@@ -265,6 +299,52 @@ Use ₹{{ denominationTotal | number: '1.2-2' }} as Transaction Amount
+ + @if (openingLoaded && openingDenoms.length > 0) { +
+
+ Closing Balance + (after this {{ transactionType.deposit ? 'deposit' : 'withdrawal' }}) + ₹{{ closingTotal | number: '1.2-2' }} +
+ @if (denominationTotal > 0 && closingDenoms.length > 0) { + @if (closingNotes.length > 0) { +
+ Notes +
+ @for (d of closingNotes; track d.denominationId) { + + ₹{{ d.denominationValue }}×{{ d.closingCount }} + + } +
+
+ } + @if (closingCoins.length > 0) { +
+ Coins +
+ @for (d of closingCoins; track d.denominationId) { + + ₹{{ d.denominationValue }}×{{ d.closingCount }} + + } +
+
+ } + } +
+ } diff --git a/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/fixed-deposits-cash-transaction/fixed-deposits-cash-transaction.component.scss b/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/fixed-deposits-cash-transaction/fixed-deposits-cash-transaction.component.scss index 521810253..515771824 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/fixed-deposits-cash-transaction/fixed-deposits-cash-transaction.component.scss +++ b/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/fixed-deposits-cash-transaction/fixed-deposits-cash-transaction.component.scss @@ -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; +} diff --git a/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/fixed-deposits-cash-transaction/fixed-deposits-cash-transaction.component.ts b/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/fixed-deposits-cash-transaction/fixed-deposits-cash-transaction.component.ts index 165f9f1bd..6ae14b86b 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/fixed-deposits-cash-transaction/fixed-deposits-cash-transaction.component.ts +++ b/src/app/deposits/fixed-deposits/fixed-deposits-account-actions/fixed-deposits-cash-transaction/fixed-deposits-cash-transaction.component.ts @@ -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 { + const openMap = new Map(); + for (const d of this.openingDenoms) { + openMap.set(`${d.kind}:${d.denominationValue}`, d); + } + const keySet = new Set(); + 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) => { diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/deposit-recurring-deposits-account/deposit-recurring-deposits-account.component.html b/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/deposit-recurring-deposits-account/deposit-recurring-deposits-account.component.html index ebbd9951b..6787d4a0a 100644 --- a/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/deposit-recurring-deposits-account/deposit-recurring-deposits-account.component.html +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/deposit-recurring-deposits-account/deposit-recurring-deposits-account.component.html @@ -126,6 +126,40 @@ + + @if (openingLoaded && openingDenoms.length > 0) { +
+
+ Opening Balance + ₹{{ openingTotal | number: '1.2-2' }} +
+ @if (openingNotes.length > 0) { +
+ Notes +
+ @for (d of openingNotes; track d.denominationId) { + ₹{{ d.denominationValue }}×{{ d.count }} + } +
+
+ } + @if (openingCoins.length > 0) { +
+ Coins +
+ @for (d of openingCoins; track d.denominationId) { + ₹{{ d.denominationValue }}×{{ d.count }} + } +
+
+ } +
+ } +
@@ -265,6 +299,52 @@ Use ₹{{ denominationTotal | number: '1.2-2' }} as Transaction Amount
+ + @if (openingLoaded && openingDenoms.length > 0) { +
+
+ Closing Balance + (after this {{ transactionType.deposit ? 'deposit' : 'withdrawal' }}) + ₹{{ closingTotal | number: '1.2-2' }} +
+ @if (denominationTotal > 0 && closingDenoms.length > 0) { + @if (closingNotes.length > 0) { +
+ Notes +
+ @for (d of closingNotes; track d.denominationId) { + + ₹{{ d.denominationValue }}×{{ d.closingCount }} + + } +
+
+ } + @if (closingCoins.length > 0) { +
+ Coins +
+ @for (d of closingCoins; track d.denominationId) { + + ₹{{ d.denominationValue }}×{{ d.closingCount }} + + } +
+
+ } + } +
+ } diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/deposit-recurring-deposits-account/deposit-recurring-deposits-account.component.scss b/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/deposit-recurring-deposits-account/deposit-recurring-deposits-account.component.scss index 521810253..515771824 100644 --- a/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/deposit-recurring-deposits-account/deposit-recurring-deposits-account.component.scss +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/deposit-recurring-deposits-account/deposit-recurring-deposits-account.component.scss @@ -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; +} diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/deposit-recurring-deposits-account/deposit-recurring-deposits-account.component.ts b/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/deposit-recurring-deposits-account/deposit-recurring-deposits-account.component.ts index 15804c827..98b9c593c 100644 --- a/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/deposit-recurring-deposits-account/deposit-recurring-deposits-account.component.ts +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/deposit-recurring-deposits-account/deposit-recurring-deposits-account.component.ts @@ -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 { + const openMap = new Map(); + for (const d of this.openingDenoms) { + openMap.set(`${d.kind}:${d.denominationValue}`, d); + } + const keySet = new Set(); + 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) => { diff --git a/src/app/savings/saving-account-actions/savings-account-transactions/savings-account-transactions.component.html b/src/app/savings/saving-account-actions/savings-account-transactions/savings-account-transactions.component.html index bfc2bc19a..793502a94 100644 --- a/src/app/savings/saving-account-actions/savings-account-transactions/savings-account-transactions.component.html +++ b/src/app/savings/saving-account-actions/savings-account-transactions/savings-account-transactions.component.html @@ -152,6 +152,40 @@ + + @if (openingLoaded && openingDenoms.length > 0) { +
+
+ Opening Balance + ₹{{ openingTotal | number: '1.2-2' }} +
+ @if (openingNotes.length > 0) { +
+ Notes +
+ @for (d of openingNotes; track d.denominationId) { + ₹{{ d.denominationValue }}×{{ d.count }} + } +
+
+ } + @if (openingCoins.length > 0) { +
+ Coins +
+ @for (d of openingCoins; track d.denominationId) { + ₹{{ d.denominationValue }}×{{ d.count }} + } +
+
+ } +
+ } +
@@ -292,6 +326,52 @@ Use ₹{{ denominationTotal | number: '1.2-2' }} as Transaction Amount
+ + @if (openingLoaded && openingDenoms.length > 0) { +
+
+ Closing Balance + (after this {{ transactionType.deposit ? 'deposit' : 'withdrawal' }}) + ₹{{ closingTotal | number: '1.2-2' }} +
+ @if (denominationTotal > 0 && closingDenoms.length > 0) { + @if (closingNotes.length > 0) { +
+ Notes +
+ @for (d of closingNotes; track d.denominationId) { + + ₹{{ d.denominationValue }}×{{ d.closingCount }} + + } +
+
+ } + @if (closingCoins.length > 0) { +
+ Coins +
+ @for (d of closingCoins; track d.denominationId) { + + ₹{{ d.denominationValue }}×{{ d.closingCount }} + + } +
+
+ } + } +
+ } diff --git a/src/app/savings/saving-account-actions/savings-account-transactions/savings-account-transactions.component.scss b/src/app/savings/saving-account-actions/savings-account-transactions/savings-account-transactions.component.scss index 2bce2c4d2..afca13297 100644 --- a/src/app/savings/saving-account-actions/savings-account-transactions/savings-account-transactions.component.scss +++ b/src/app/savings/saving-account-actions/savings-account-transactions/savings-account-transactions.component.scss @@ -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; diff --git a/src/app/savings/saving-account-actions/savings-account-transactions/savings-account-transactions.component.ts b/src/app/savings/saving-account-actions/savings-account-transactions/savings-account-transactions.component.ts index e2a242514..ef84b5151 100644 --- a/src/app/savings/saving-account-actions/savings-account-transactions/savings-account-transactions.component.ts +++ b/src/app/savings/saving-account-actions/savings-account-transactions/savings-account-transactions.component.ts @@ -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(); + for (const d of this.openingDenoms) { + openMap.set(`${d.kind}:${d.denominationValue}`, d); + } + const keySet = new Set(); + 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) => { diff --git a/src/environments/.env.ts b/src/environments/.env.ts index 80af90d1e..b69dd8cf8 100644 --- a/src/environments/.env.ts +++ b/src/environments/.env.ts @@ -9,8 +9,8 @@ /* tslint:disable */ export default { 'mifos_x': { - 'version': '260525', - 'hash': 'ad6f629c' + 'version': '260527', + 'hash': '33b01d66' }, 'allow_switching_backend_instance': true };