diff --git a/src/app/cashier/cash-history/cash-history.component.html b/src/app/cashier/cash-history/cash-history.component.html
index 8fe0bb7e9..7b7ec4a7c 100644
--- a/src/app/cashier/cash-history/cash-history.component.html
+++ b/src/app/cashier/cash-history/cash-history.component.html
@@ -83,7 +83,7 @@
{{ r.fromParty }}
{{ r.toParty }}
₹{{ r.amount | number: '1.2-2' }}
- {{ r.note || '—' }}
+ {{ r.note || '—' }}
{{ r.performedByUsername || '—' }}
@@ -91,11 +91,21 @@
- @if (!r.isReversed) {
-
- undo
+
+
+ visibility
- }
+ @if (!r.isReversed) {
+
+ undo
+
+ }
+
}
@@ -104,3 +114,126 @@
}
+
+
+@if (detail) {
+
+
+
+
+
+
{{ detail.row.type === 'settlement' ? 'account_balance_wallet' : 'swap_horiz' }}
+
+ {{ typeLabel(detail.row.type) }}
+ #{{ detail.row.id }}
+
+
+
+ close
+
+
+
+
+
+
+
+ @if (detail.row.isReversed) {
+
+
undo
+
+ Reversed
+ @if (detail.row.reversedAt) {
+ on {{ detail.row.reversedAt }}
+ }
+ @if (detail.row.reverseReason) {
+ — {{ detail.row.reverseReason }}
+ }
+
+
+ }
+
+
+
+ payments
+ Denomination Breakdown
+
+
+ @if (detail.loading) {
+
Loading denomination details…
+ } @else if (detail.denominations.length === 0) {
+
No denomination data recorded.
+ } @else {
+
+
+
+ Denomination
+ Count
+ Total
+
+
+
+ @for (d of detail.denominations; track d.denominationValue) {
+
+
+ ₹{{ d.denominationValue }}
+
+ {{ d.count }}
+ ₹{{ d.totalAmount | number: '1.2-2' }}
+
+ }
+
+
+
+ Total
+
+ {{ totalCount(detail.denominations) }}
+
+
+ ₹{{ detail.row.amount | number: '1.2-2' }}
+
+
+
+
+ }
+
+
+}
diff --git a/src/app/cashier/cash-history/cash-history.component.scss b/src/app/cashier/cash-history/cash-history.component.scss
index 610a28b5e..ccc7906a4 100644
--- a/src/app/cashier/cash-history/cash-history.component.scss
+++ b/src/app/cashier/cash-history/cash-history.component.scss
@@ -79,6 +79,211 @@
}
}
+/* ── Action buttons cell ── */
+.action-btns {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 4px;
+}
+
+.btn-view {
+ color: #1e40af;
+
+ &:hover {
+ background: #eff6ff;
+ }
+}
+
+.td-note {
+ max-width: 180px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+/* ── Detail overlay ── */
+.detail-overlay {
+ position: fixed;
+ inset: 0;
+ background: rgb(15 23 42 / 45%);
+ z-index: 1000;
+ display: flex;
+ align-items: flex-start;
+ justify-content: flex-end;
+ padding: 0;
+ backdrop-filter: blur(2px);
+}
+
+.detail-panel {
+ width: 480px;
+ max-width: 100vw;
+ height: 100vh;
+ background: #fff;
+ display: flex;
+ flex-direction: column;
+ overflow-y: auto;
+ box-shadow: -4px 0 24px rgb(0 0 0 / 15%);
+
+ &__head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 20px 20px 16px;
+ border-bottom: 1px solid #e2e8f0;
+ position: sticky;
+ top: 0;
+ background: #fff;
+ z-index: 1;
+ }
+
+ &__title {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+
+ mat-icon {
+ font-size: 22px;
+ height: 22px;
+ width: 22px;
+ color: #1e40af;
+ }
+ }
+
+ &__type {
+ display: block;
+ font-size: 12px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: #64748b;
+ }
+
+ &__id {
+ font-size: 18px;
+ font-weight: 800;
+ color: #0f172a;
+ }
+
+ &__close {
+ background: none;
+ border: none;
+ cursor: pointer;
+ padding: 4px;
+ border-radius: 6px;
+ display: flex;
+ align-items: center;
+ color: #64748b;
+
+ &:hover {
+ background: #f1f5f9;
+ color: #0f172a;
+ }
+
+ mat-icon {
+ font-size: 20px;
+ height: 20px;
+ width: 20px;
+ }
+ }
+}
+
+/* ── Meta grid ── */
+.detail-meta {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 0;
+ padding: 16px 20px;
+ border-bottom: 1px solid #f1f5f9;
+
+ &__item {
+ padding: 8px 4px;
+ border-bottom: 1px solid #f8fafc;
+
+ &--full {
+ grid-column: 1 / -1;
+ }
+ }
+
+ &__label {
+ display: block;
+ font-size: 10px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: #94a3b8;
+ margin-bottom: 2px;
+ }
+
+ &__val {
+ font-size: 13px;
+ font-weight: 500;
+ color: #1e293b;
+
+ &--amount {
+ font-size: 16px;
+ font-weight: 800;
+ color: #0f172a;
+ }
+ }
+}
+
+/* ── Reversal info ── */
+.detail-reversal {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 10px 20px;
+ background: #fef2f2;
+ border-bottom: 1px solid #fecaca;
+ font-size: 12px;
+ color: #991b1b;
+
+ mat-icon {
+ font-size: 18px;
+ height: 18px;
+ width: 18px;
+ color: #dc2626;
+ flex-shrink: 0;
+ }
+
+ &__reason {
+ opacity: 0.8;
+ }
+}
+
+/* ── Denomination section ── */
+.detail-denom-head {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 14px 20px 8px;
+ font-size: 11px;
+ font-weight: 800;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: #1e40af;
+
+ mat-icon {
+ font-size: 16px;
+ height: 16px;
+ width: 16px;
+ }
+}
+
+.detail-denom-table {
+ margin: 0 20px 20px;
+ width: calc(100% - 40px);
+ border: 1px solid #e2e8f0;
+ border-radius: 8px;
+ overflow: hidden;
+}
+
+.detail-denom-total td {
+ background: #f8fafc;
+ border-top: 2px solid #e2e8f0;
+}
+
.pg-btn--danger {
background: linear-gradient(135deg, #ef4444, #b91c1c);
color: #fff;
diff --git a/src/app/cashier/cash-history/cash-history.component.ts b/src/app/cashier/cash-history/cash-history.component.ts
index 2a9ada63c..e95a32d4e 100644
--- a/src/app/cashier/cash-history/cash-history.component.ts
+++ b/src/app/cashier/cash-history/cash-history.component.ts
@@ -26,10 +26,26 @@ interface TxnRow {
isReversed: boolean;
performedByUsername: string;
createdAt: string;
+ reversedBy: number | null;
+ reversedAt: string | null;
+ reverseReason: string | null;
type: 'transfer' | 'settlement' | 'inter';
}
-/** Unified history of all cash movements with reversal support. */
+interface DenomDetail {
+ denominationId: number | null;
+ denominationValue: number;
+ count: number;
+ totalAmount: number;
+}
+
+interface DetailView {
+ row: TxnRow;
+ denominations: DenomDetail[];
+ loading: boolean;
+}
+
+/** Unified history of all cash movements with reversal and detail view support. */
@Component({
selector: 'mifosx-cash-history',
templateUrl: './cash-history.component.html',
@@ -59,6 +75,8 @@ export class CashHistoryComponent implements OnInit {
reversingType: string | null = null;
reverseReason = '';
+ detail: DetailView | null = null;
+
ngOnInit(): void {
this.load();
}
@@ -80,6 +98,9 @@ export class CashHistoryComponent implements OnInit {
isReversed: t.isReversed,
performedByUsername: t.performedByUsername,
createdAt: t.createdAt,
+ reversedBy: t.reversedBy ?? null,
+ reversedAt: t.reversedAt ?? null,
+ reverseReason: t.reverseReason ?? null,
type: 'transfer'
}));
this.settlements = (settlements as any[]).map((s) => ({
@@ -92,6 +113,9 @@ export class CashHistoryComponent implements OnInit {
isReversed: s.isReversed,
performedByUsername: s.performedByUsername,
createdAt: s.createdAt,
+ reversedBy: s.reversedBy ?? null,
+ reversedAt: s.reversedAt ?? null,
+ reverseReason: s.reverseReason ?? null,
type: 'settlement'
}));
this.interCashier = (inter as any[]).map((i) => ({
@@ -104,12 +128,40 @@ export class CashHistoryComponent implements OnInit {
isReversed: i.isReversed,
performedByUsername: i.performedByUsername,
createdAt: i.createdAt,
+ reversedBy: i.reversedBy ?? null,
+ reversedAt: i.reversedAt ?? null,
+ reverseReason: i.reverseReason ?? null,
type: 'inter'
}));
this.loading = false;
});
}
+ viewDetail(row: TxnRow): void {
+ this.detail = { row, denominations: [], loading: true };
+
+ let obs$;
+ if (row.type === 'transfer') obs$ = this.service.getCashTransferById(row.id);
+ else if (row.type === 'settlement') obs$ = this.service.getCashSettlementById(row.id);
+ else obs$ = this.service.getInterCashierById(row.id);
+
+ obs$.pipe(catchError(() => of(null))).subscribe((data: any) => {
+ if (this.detail) {
+ this.detail.denominations = (data?.details || []).map((d: any) => ({
+ denominationId: d.denominationId ?? null,
+ denominationValue: d.denominationValue,
+ count: d.count,
+ totalAmount: d.totalAmount
+ }));
+ this.detail.loading = false;
+ }
+ });
+ }
+
+ closeDetail(): void {
+ this.detail = null;
+ }
+
startReverse(row: TxnRow): void {
this.reversingId = row.id;
this.reversingType = row.type;
@@ -143,4 +195,12 @@ export class CashHistoryComponent implements OnInit {
}
});
}
+
+ typeLabel(type: string): string {
+ return type === 'transfer' ? 'Cash Transfer' : type === 'settlement' ? 'Cash Settlement' : 'Inter-Cashier Transfer';
+ }
+
+ totalCount(denoms: DenomDetail[]): number {
+ return denoms.reduce((s, d) => s + d.count, 0);
+ }
}
diff --git a/src/app/cashier/cash-settlement/cash-settlement.component.html b/src/app/cashier/cash-settlement/cash-settlement.component.html
index a6b25cebf..ae6ba9116 100644
--- a/src/app/cashier/cash-settlement/cash-settlement.component.html
+++ b/src/app/cashier/cash-settlement/cash-settlement.component.html
@@ -11,114 +11,346 @@
CASHIER · CASH SETTLEMENT
Cash Settlement
-
Cashier returns denomination-wise cash to teller / vault at end of day.
+
+ End-of-day settlement — verify your denomination count against system expected balance and hand cash to vault.
+
- undo Clear
-
+ }
+
+
+ @if (pageState === 'needs-reconciliation') {
+
+
assignment_late
+
+
Reconciliation required before settlement
+
+ {{ myCashierName ? myCashierName + ' has' : 'You have' }} not completed today's cash reconciliation.
+ Settlement cannot proceed until reconciliation is done and any variance is documented.
+
+
+
+ assignment Go to Reconciliation
+
+ @if (isSupervisor) {
+
+ arrow_back Select Different Cashier
+
+ }
+
+
+
+ }
+
+
+ @if (pageState === 'already-settled') {
+
+
check_circle
+
+
{{ myCashierName ? myCashierName + ' — already' : 'Already' }} settled for {{ settlementDate }}
+
+ Cash has already been handed back to the vault for this date. To correct an error, a manager must reverse the
+ existing settlement first.
+
+ @if (isSupervisor) {
+
+ arrow_back Select Different Cashier
+
+ }
+
+
+ }
+
+
+ @if (pageState === 'form') {
+
+
+
+
+ @if (rows.length === 0) {
+
+
warning
+
+
No expected balance found
+
+ No cash has been allocated to your drawer for {{ settlementDate }}. Ensure vault-to-cashier transfer was
+ done in the morning.
+
+
+
+ }
+
+
+ @if (rows.length > 0) {
+
+
+ @if (noteRows.length > 0) {
+
Notes
+
+ }
+
+
+ @if (coinRows.length > 0) {
+
Coins
+
+ }
+
+
+
+ @if (hasVariance) {
+ 0"
+ [class.settle-variance-banner--short]="totalVariance < 0"
+ >
+
{{ totalVariance > 0 ? 'add_circle_outline' : 'remove_circle_outline' }}
+
+
+ {{ totalVariance > 0 ? 'Excess' : 'Shortage' }}: ₹{{
+ (totalVariance < 0 ? -totalVariance : totalVariance) | number: '1.2-2'
+ }}
+
+ Physical count differs from system expected. A reason is required to proceed.
+
+
+ Variance Reason *
+
+
+
+ }
+
+
+
+ }
+ }
diff --git a/src/app/cashier/cash-settlement/cash-settlement.component.scss b/src/app/cashier/cash-settlement/cash-settlement.component.scss
new file mode 100644
index 000000000..5b89967c7
--- /dev/null
+++ b/src/app/cashier/cash-settlement/cash-settlement.component.scss
@@ -0,0 +1,295 @@
+/**
+ * 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/.
+ */
+
+/* ── State screens ── */
+.settle-state {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 48px 24px;
+ gap: 12px;
+ color: #64748b;
+ font-size: 14px;
+
+ &__icon {
+ font-size: 40px;
+ height: 40px;
+ width: 40px;
+ opacity: 0.5;
+
+ &--spin {
+ animation: spin 1.4s linear infinite;
+ }
+ }
+}
+
+@keyframes spin {
+ from {
+ transform: rotate(0deg);
+ }
+
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+/* ── Supervisor selection card ── */
+.settle-supervisor-card {
+ background: #f8faff;
+ border: 1px solid #bfdbfe;
+ border-radius: 10px;
+ padding: 20px 24px 24px;
+ margin-bottom: 20px;
+}
+
+.settle-supervisor-header {
+ display: flex;
+ align-items: flex-start;
+ gap: 12px;
+
+ mat-icon {
+ font-size: 26px;
+ height: 26px;
+ width: 26px;
+ color: #1e40af;
+ flex-shrink: 0;
+ margin-top: 2px;
+ }
+
+ strong {
+ display: block;
+ font-size: 14px;
+ font-weight: 700;
+ color: #1e3a8a;
+ margin-bottom: 3px;
+ }
+
+ span {
+ font-size: 13px;
+ color: #475569;
+ line-height: 1.5;
+ }
+}
+
+/* ── Alert banners ── */
+.settle-alert {
+ display: flex;
+ align-items: flex-start;
+ gap: 14px;
+ padding: 16px 20px;
+ border-radius: 10px;
+ margin: 0 0 20px;
+ border: 1px solid;
+
+ mat-icon {
+ font-size: 24px;
+ height: 24px;
+ width: 24px;
+ flex-shrink: 0;
+ margin-top: 1px;
+ }
+
+ strong {
+ display: block;
+ font-size: 14px;
+ font-weight: 700;
+ margin-bottom: 2px;
+ }
+
+ p {
+ margin: 0;
+ font-size: 13px;
+ line-height: 1.5;
+ }
+
+ &--ok {
+ background: #f0fdf4;
+ border-color: #86efac;
+ color: #15803d;
+
+ mat-icon {
+ color: #16a34a;
+ }
+ }
+
+ &--warn {
+ background: #fffbeb;
+ border-color: #fcd34d;
+ color: #92400e;
+
+ mat-icon {
+ color: #d97706;
+ }
+ }
+}
+
+/* ── Toolbar: cashier identity badge ── */
+.settle-who {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 0 12px;
+ height: 56px;
+ border-radius: 8px;
+ border: 1px solid #bfdbfe;
+ background: #eff6ff;
+ white-space: nowrap;
+
+ mat-icon {
+ color: #1e40af;
+ font-size: 18px;
+ height: 18px;
+ width: 18px;
+ }
+
+ &__name {
+ font-size: 13px;
+ font-weight: 700;
+ color: #1e40af;
+ }
+
+ &__label {
+ font-size: 10px;
+ font-weight: 600;
+ color: #64748b;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ margin-left: 2px;
+ }
+}
+
+/* ── Denomination table ── */
+.settle-table {
+ .settle-expected {
+ color: #64748b;
+ font-weight: 600;
+ }
+}
+
+/* ── Variance column ── */
+.settle-var--pos {
+ color: #16a34a;
+ font-weight: 700;
+}
+
+.settle-var--neg {
+ color: #dc2626;
+ font-weight: 700;
+}
+
+.settle-var--zero {
+ color: #cbd5e1;
+}
+
+.settle-var-amt {
+ font-size: 10px;
+ font-weight: 500;
+ opacity: 0.8;
+ margin-left: 3px;
+}
+
+/* ── Variance reason banner ── */
+.settle-variance-banner {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ padding: 12px 16px;
+ border-radius: 8px;
+ border: 1px solid;
+ margin-bottom: 12px;
+
+ mat-icon {
+ font-size: 22px;
+ height: 22px;
+ width: 22px;
+ flex-shrink: 0;
+ }
+
+ &--excess {
+ background: #f0fdf4;
+ border-color: #86efac;
+ color: #15803d;
+
+ mat-icon {
+ color: #16a34a;
+ }
+ }
+
+ &--short {
+ background: #fef2f2;
+ border-color: #fca5a5;
+ color: #991b1b;
+
+ mat-icon {
+ color: #dc2626;
+ }
+ }
+}
+
+.settle-variance-text {
+ flex: 1;
+ min-width: 0;
+
+ strong {
+ display: block;
+ font-size: 13px;
+ font-weight: 700;
+ }
+
+ span {
+ font-size: 12px;
+ opacity: 0.85;
+ }
+}
+
+.settle-reason-field {
+ flex-shrink: 0;
+ width: 320px;
+}
+
+/* ── Footer ── */
+.settle-footer {
+ flex-direction: column;
+ gap: 10px;
+ align-items: stretch;
+
+ &__actions {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ justify-content: flex-end;
+ }
+}
+
+.settle-totals {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-wrap: wrap;
+ font-size: 13px;
+ color: #475569;
+
+ &__item strong {
+ color: #0d1b2a;
+ }
+
+ &__sep {
+ color: #cbd5e1;
+ }
+}
+
+.settle-hint {
+ font-size: 12px;
+ color: #94a3b8;
+
+ &--warn {
+ color: #d97706;
+ font-weight: 600;
+ }
+}
diff --git a/src/app/cashier/cash-settlement/cash-settlement.component.ts b/src/app/cashier/cash-settlement/cash-settlement.component.ts
index abe5e2e29..0b543c1a0 100644
--- a/src/app/cashier/cash-settlement/cash-settlement.component.ts
+++ b/src/app/cashier/cash-settlement/cash-settlement.component.ts
@@ -9,31 +9,48 @@
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
+import { Router, RouterModule } from '@angular/router';
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 { forkJoin, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
-interface Denom {
- id: number;
- value: number;
+type PageState = 'loading' | 'no-cashier' | 'needs-reconciliation' | 'already-settled' | 'form';
+
+interface SettleDenomRow {
+ denominationId: number;
+ denominationValue: number;
kind: 'note' | 'coin';
- label: string;
+ expectedCount: number;
+ physicalCount: number;
}
-/** Cashier hands back denomination-wise cash to teller/vault (closing settlement). */
+/**
+ * End-of-day cash settlement with full validation:
+ * - Auto-detects logged-in cashier via /cashreports/cashier-position/me
+ * - Supervisor/admin mode: manual teller + cashier selection when not a cashier
+ * - Blocks if reconciliation not done today
+ * - Blocks if already settled today
+ * - Pre-fills physical counts from system expected balance
+ * - Requires variance reason when physical ≠ expected
+ * - Backend enforces reconciliation-before-settlement and no duplicate per day
+ */
@Component({
selector: 'mifosx-cash-settlement',
templateUrl: './cash-settlement.component.html',
- styleUrls: ['../cashier-shared.scss'],
+ styleUrls: [
+ '../cashier-shared.scss',
+ './cash-settlement.component.scss'
+ ],
standalone: true,
imports: [
CommonModule,
FormsModule,
+ RouterModule,
MatIconModule,
MatSnackBarModule,
MatFormFieldModule,
@@ -44,102 +61,171 @@ interface Denom {
export class CashSettlementComponent implements OnInit {
private service = inject(CashierService);
private snackBar = inject(MatSnackBar);
+ private router = inject(Router);
- notes: Denom[] = [];
- coins: Denom[] = [];
- counts: Record = {};
- settlementDate = '';
- note = '';
- saving = false;
+ pageState: PageState = 'loading';
+ isSupervisor = false;
- tellers: { id: number; name: string }[] = [];
- cashiers: { id: number; label: string }[] = [];
- selectedTellerId: number | null = null;
- fromCashierId: number | null = null;
- fromParty = '';
- toParty = '';
+ // Cashier being settled (auto-detected for cashier, manually selected for admin)
+ myCashierId: number | null = null;
+ myCashierName = '';
+
+ // Supervisor mode: teller selector to filter cashiers
+ supervisorTellerId: number | null = null;
+ supervisorCashiers: { id: number; label: string }[] = [];
loadingCashiers = false;
+ supervisorChecking = false;
- get denoms(): Denom[] {
- return [
- ...this.notes,
- ...this.coins
- ];
- }
+ rows: SettleDenomRow[] = [];
+
+ settlementDate = '';
+ // Vault teller to deposit TO
+ tellers: { id: number; name: string }[] = [];
+ selectedTellerId: number | null = null;
+ toParty = '';
+ note = '';
+ varianceReason = '';
+ saving = false;
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) => {
+ .subscribe((list: any[]) => {
this.tellers = (list || []).map((t: any) => ({ id: t.id, name: t.name }));
});
+ this.loadData();
+ }
+
+ loadData(): void {
+ this.pageState = 'loading';
+ this.rows = [];
+ this.service
+ .getCashierPositionForMe(this.settlementDate)
+ .pipe(catchError(() => of(null)))
+ .subscribe((pos: any) => {
+ if (!pos?.cashierId) {
+ // Not a cashier — supervisor/admin mode
+ this.isSupervisor = true;
+ this.pageState = 'no-cashier';
+ return;
+ }
+ this.isSupervisor = false;
+ this.myCashierId = pos.cashierId;
+ this.myCashierName = pos.cashierName || '';
+ this.loadCashierChecks(pos.cashierId);
+ });
}
- 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 = [];
+ // Called after cashier is known (auto or supervisor-selected)
+ private loadCashierChecks(cashierId: number): void {
+ forkJoin({
+ expected: this.service.getExpectedBalance(cashierId, this.settlementDate).pipe(catchError(() => of(null))),
+ check: this.service
+ .checkSettlement(cashierId, this.settlementDate)
+ .pipe(catchError(() => of({ alreadySettled: false }))),
+ recon: this.service
+ .checkReconciliation(cashierId, this.settlementDate)
+ .pipe(catchError(() => of({ reconciled: false })))
+ }).subscribe(({ expected, check, recon }) => {
+ this.supervisorChecking = false;
+ if (check?.alreadySettled) {
+ this.pageState = 'already-settled';
+ return;
+ }
+ if (!recon?.reconciled) {
+ this.pageState = 'needs-reconciliation';
+ return;
+ }
+ this.rows = ((expected?.denominations as any[]) || [])
+ .filter((d: any) => (d.expectedCount ?? 0) > 0)
+ .sort((a: any, b: any) => b.denominationValue - a.denominationValue)
+ .map((d: any) => ({
+ denominationId: d.denominationId,
+ denominationValue: d.denominationValue,
+ kind: (d.kind ?? 'note') as 'note' | 'coin',
+ expectedCount: d.expectedCount,
+ physicalCount: d.expectedCount
+ }));
+ this.pageState = 'form';
+ });
+ }
+
+ // Supervisor: teller selected → load cashiers for that teller
+ onSupervisorTellerSelect(tellerId: number): void {
+ this.supervisorTellerId = tellerId;
+ this.supervisorCashiers = [];
+ this.myCashierId = null;
+ this.myCashierName = '';
+ this.rows = [];
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;
+ this.supervisorCashiers = (r?.cashiers || []).map((c: any) => ({ id: c.id, label: c.staffName }));
});
}
- onCashierSelect(cashierId: number): void {
- this.fromCashierId = cashierId;
- const c = this.cashiers.find((x) => x.id === cashierId);
- this.fromParty = c ? c.label : '';
+ // Supervisor: cashier selected → run checks
+ onSupervisorCashierSelect(cashierId: number): void {
+ const c = this.supervisorCashiers.find((x) => x.id === cashierId);
+ this.myCashierId = cashierId;
+ this.myCashierName = c ? c.label : '';
+ this.rows = [];
+ this.supervisorChecking = true;
+ this.loadCashierChecks(cashierId);
}
- countFor(d: Denom): number {
- return this.counts[d.id] || 0;
+ get noteRows(): SettleDenomRow[] {
+ return this.rows.filter((r) => r.kind !== 'coin');
}
- 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 coinRows(): SettleDenomRow[] {
+ return this.rows.filter((r) => r.kind === 'coin');
}
- get total(): number {
- return this.denoms.reduce((s, d) => s + this.subtotal(d), 0);
+ varianceFor(row: SettleDenomRow): number {
+ return row.physicalCount - row.expectedCount;
}
+
+ 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 hasVariance(): boolean {
+ return this.totalVariance !== 0;
+ }
+
+ updatePhysical(row: SettleDenomRow, raw: any): void {
+ row.physicalCount = Math.max(0, Math.floor(Number(raw) || 0));
+ }
+ bump(row: SettleDenomRow, delta: number): void {
+ row.physicalCount = Math.max(0, row.physicalCount + delta);
+ }
+
+ goToReconciliation(): void {
+ this.router.navigate(['/cashier/reconciliation']);
+ }
+
+ onTellerSelect(id: number): void {
+ this.selectedTellerId = id;
+ const t = this.tellers.find((x) => x.id === id);
+ this.toParty = t ? t.name : '';
+ }
+
get valid(): boolean {
- return this.total > 0 && !!this.fromCashierId && !!this.selectedTellerId;
- }
-
- clear(): void {
- this.counts = {};
+ return (
+ !!this.selectedTellerId && this.totalPhysical > 0 && (!this.hasVariance || this.varianceReason.trim().length > 0)
+ );
}
submit(): void {
@@ -147,28 +233,41 @@ export class CashSettlementComponent implements OnInit {
this.saving = true;
const payload = {
settlementDate: this.settlementDate,
- fromCashierId: this.fromCashierId,
+ fromCashierId: this.myCashierId,
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) }))
+ fromParty: this.myCashierName,
+ toParty: this.toParty,
+ note:
+ [
+ this.note,
+ this.hasVariance ? `Variance reason: ${this.varianceReason}` : null
+ ]
+ .filter(Boolean)
+ .join(' | ') || null,
+ denominations: this.rows
+ .filter((r) => r.physicalCount > 0)
+ .map((r) => ({
+ denominationId: r.denominationId,
+ denominationValue: r.denominationValue,
+ count: r.physicalCount
+ }))
};
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 = [];
+ next: (res: any) => {
+ if (res?.glWarning) {
+ this.snackBar.open(`Settlement saved (GL warning: ${res.glWarning})`, 'Close', { duration: 6000 });
+ } else {
+ this.snackBar.open('Cash settlement recorded successfully', 'Close', { duration: 3000 });
+ }
+ this.pageState = 'already-settled';
this.saving = false;
},
- error: () => {
+ error: (err: any) => {
+ const msg =
+ err?.error?.errors?.[0]?.defaultUserMessage ||
+ err?.error?.developerMessage ||
+ 'Settlement failed. Please try again.';
+ this.snackBar.open(msg, 'Close', { duration: 6000 });
this.saving = false;
}
});
diff --git a/src/app/cashier/cashier.service.ts b/src/app/cashier/cashier.service.ts
index beb8141a2..0d52dd4e7 100644
--- a/src/app/cashier/cashier.service.ts
+++ b/src/app/cashier/cashier.service.ts
@@ -49,6 +49,9 @@ export class CashierService {
getCashTransfers(): Observable {
return this.http.get('/cashtransfer');
}
+ getCashTransferById(id: number): Observable {
+ return this.http.get(`/cashtransfer/${id}`);
+ }
reverseCashTransfer(id: number, reason: string): Observable {
return this.http.post(`/cashtransfer/${id}/reverse`, { reason });
}
@@ -60,6 +63,9 @@ export class CashierService {
getCashSettlements(): Observable {
return this.http.get('/cashsettlement');
}
+ getCashSettlementById(id: number): Observable {
+ return this.http.get(`/cashsettlement/${id}`);
+ }
reverseCashSettlement(id: number, reason: string): Observable {
return this.http.post(`/cashsettlement/${id}/reverse`, { reason });
}
@@ -71,6 +77,9 @@ export class CashierService {
getInterCashierTransfers(): Observable {
return this.http.get('/cashintercashier');
}
+ getInterCashierById(id: number): Observable {
+ return this.http.get(`/cashintercashier/${id}`);
+ }
reverseInterCashierTransfer(id: number, reason: string): Observable {
return this.http.post(`/cashintercashier/${id}/reverse`, { reason });
}
@@ -113,4 +122,20 @@ export class CashierService {
if (asOfDate) params = params.set('asOfDate', asOfDate);
return this.http.get('/cashreports/cashier-position/me', { params });
}
+
+ checkSettlement(cashierId: number, date: string): Observable {
+ const params = new HttpParams().set('cashierId', String(cashierId)).set('date', date);
+ return this.http.get('/cashsettlement/check', { params });
+ }
+
+ checkReconciliation(cashierId: number, date: string): Observable {
+ const params = new HttpParams().set('cashierId', String(cashierId)).set('date', date);
+ return this.http.get('/cashreconciliation/check', { params });
+ }
+
+ getExpectedBalanceForMe(asOfDate?: string): Observable {
+ let params = new HttpParams();
+ if (asOfDate) params = params.set('asOfDate', asOfDate);
+ return this.http.get('/cashreconciliation/expected/me', { params });
+ }
}
diff --git a/src/app/cashier/reconciliation/reconciliation.component.html b/src/app/cashier/reconciliation/reconciliation.component.html
index ca92e23e2..6a6d90861 100644
--- a/src/app/cashier/reconciliation/reconciliation.component.html
+++ b/src/app/cashier/reconciliation/reconciliation.component.html
@@ -175,6 +175,35 @@
}
+ @if (totalVariance !== 0) {
+ 0"
+ [class.settle-variance-banner--short]="totalVariance < 0"
+ style="margin: 8px 0 0"
+ >
+
{{ totalVariance > 0 ? 'add_circle_outline' : 'remove_circle_outline' }}
+
+ {{ totalVariance > 0 ? 'Excess' : 'Shortage' }}: ₹{{
+ (totalVariance < 0 ? -totalVariance : totalVariance) | number: '1.2-2'
+ }}
+ Variance reason is required to save reconciliation.
+
+
+ Variance Reason *
+
+
+
+ }
+
Expected: ₹{{ totalExpected | number: '1.2-2' }} | Physical:
@@ -184,9 +213,14 @@
{{ statusLabel }}
-
- save Save Reconciliation
-
+
+ @if (totalVariance !== 0 && !varianceReason.trim()) {
+ Enter variance reason to save
+ }
+
+ save Save Reconciliation
+
+
}
diff --git a/src/app/cashier/reconciliation/reconciliation.component.scss b/src/app/cashier/reconciliation/reconciliation.component.scss
index 1d3011dbf..9dfc43133 100644
--- a/src/app/cashier/reconciliation/reconciliation.component.scss
+++ b/src/app/cashier/reconciliation/reconciliation.component.scss
@@ -45,3 +45,69 @@
background: #fee2e2;
color: #991b1b;
}
+
+/* Variance banner (shared style with settlement) */
+.settle-variance-banner {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ padding: 12px 16px;
+ border-radius: 8px;
+ border: 1px solid;
+
+ mat-icon {
+ font-size: 22px;
+ height: 22px;
+ width: 22px;
+ flex-shrink: 0;
+ }
+
+ &--excess {
+ background: #f0fdf4;
+ border-color: #86efac;
+ color: #15803d;
+
+ mat-icon {
+ color: #16a34a;
+ }
+ }
+
+ &--short {
+ background: #fef2f2;
+ border-color: #fca5a5;
+ color: #991b1b;
+
+ mat-icon {
+ color: #dc2626;
+ }
+ }
+}
+
+.settle-variance-text {
+ flex: 1;
+ min-width: 0;
+
+ strong {
+ display: block;
+ font-size: 13px;
+ font-weight: 700;
+ }
+
+ span {
+ font-size: 12px;
+ opacity: 0.85;
+ }
+}
+
+.pos-section-label {
+ font-size: 10px;
+ font-weight: 800;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: #1e40af;
+ padding: 10px 12px 4px;
+
+ &--coin {
+ color: #92400e;
+ }
+}
diff --git a/src/app/cashier/reconciliation/reconciliation.component.ts b/src/app/cashier/reconciliation/reconciliation.component.ts
index c71b23e30..ca2c76ec3 100644
--- a/src/app/cashier/reconciliation/reconciliation.component.ts
+++ b/src/app/cashier/reconciliation/reconciliation.component.ts
@@ -73,6 +73,7 @@ export class ReconciliationComponent implements OnInit {
cashierName = '';
reconDate = '';
note = '';
+ varianceReason = '';
rows: DenomRow[] = [];
loadingExpected = false;
saving = false;
@@ -162,7 +163,11 @@ export class ReconciliationComponent implements OnInit {
return v === 0 ? 'OK' : v > 0 ? 'EXCESS' : 'SHORT';
}
get valid(): boolean {
- return !!this.selectedCashierId && this.rows.length > 0;
+ return (
+ !!this.selectedCashierId &&
+ this.rows.length > 0 &&
+ (this.totalVariance === 0 || this.varianceReason.trim().length > 0)
+ );
}
submit(): void {
@@ -173,7 +178,13 @@ export class ReconciliationComponent implements OnInit {
tellerId: this.selectedTellerId,
cashierName: this.cashierName,
reconDate: this.reconDate,
- note: this.note || null,
+ note:
+ [
+ this.note,
+ this.totalVariance !== 0 ? `Variance reason: ${this.varianceReason}` : null
+ ]
+ .filter(Boolean)
+ .join(' | ') || null,
denominations: this.rows.map((r) => ({
denominationId: r.denominationId,
denominationValue: r.denominationValue,
@@ -190,6 +201,7 @@ export class ReconciliationComponent implements OnInit {
});
this.rows = [];
this.note = '';
+ this.varianceReason = '';
this.saving = false;
this.loadHistory();
},