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) { - - } + @if (!r.isReversed) { + + } + } @@ -104,3 +114,126 @@ } + + +@if (detail) { +
+
+ +
+
+ {{ detail.row.type === 'settlement' ? 'account_balance_wallet' : 'swap_horiz' }} +
+ {{ typeLabel(detail.row.type) }} + #{{ detail.row.id }} +
+
+ +
+ + +
+
+ From + {{ detail.row.fromParty || '—' }} +
+
+ To + {{ detail.row.toParty || '—' }} +
+
+ Date + {{ detail.row.date }} +
+
+ Total Amount + ₹{{ detail.row.amount | number: '1.2-2' }} +
+
+ Performed By + {{ detail.row.performedByUsername || '—' }} +
+
+ Created At + {{ detail.row.createdAt || '—' }} +
+
+ Note + {{ detail.row.note || '—' }} +
+
+ Status + + {{ detail.row.isReversed ? 'Reversed' : 'Active' }} + +
+
+ + + @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 { + + + + + + + + + + @for (d of detail.denominations; track d.denominationValue) { + + + + + + } + + + + + + + + +
DenominationCountTotal
+ ₹{{ 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. +

- -
- - Teller (To / Vault) - - @for (t of tellers; track t.id) { - {{ t.name }} - } - @if (tellers.length === 0) { - No tellers configured - } - - + + @if (pageState === 'loading') { +
+ hourglass_empty +

Loading your cashier position…

+
+ } - - From (Cashier) - - @for (c of cashiers; track c.id) { - {{ c.label }} - } - @if (selectedTellerId && cashiers.length === 0 && !loadingCashiers) { - No cashiers for this teller - } - - + + @if (pageState === 'no-cashier') { +
+
+ manage_accounts +
+ Supervisor / Admin Settlement + Your account is not linked to a cashier drawer. Select a teller and cashier below to process their + end-of-day settlement. +
+
- - Date - - +
+ + Settlement Date + + - - Note - - -
+ + Teller + + @for (t of tellers; track t.id) { + {{ t.name }} + } + + -
- - - - - - - - - - @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.
-
+ + Cashier + + @for (c of supervisorCashiers; track c.id) { + {{ c.label }} + } + @if (supervisorTellerId && supervisorCashiers.length === 0 && !loadingCashiers) { + No cashiers found + } + + +
-
- Total to settle: ₹{{ total | number: '1.2-2' }} - -
+ @if (supervisorChecking) { +
Checking cashier status…
+ } +
+ } + + + @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. +

+
+ + @if (isSupervisor) { + + } +
+
+
+ } + + + @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) { + + } +
+
+ } + + + @if (pageState === 'form') { + +
+
+ {{ isSupervisor ? 'manage_accounts' : 'person' }} + {{ myCashierName }} + {{ isSupervisor ? 'Cashier (Supervisor)' : 'Cashier (From)' }} + @if (isSupervisor) { + + } +
+ + + Teller / Vault (To) * + + @for (t of tellers; track t.id) { + {{ t.name }} + } + @if (tellers.length === 0) { + No tellers configured + } + + + + + Settlement Date + + + + + Note (Optional) + + +
+ + + @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
+ + + + + + + + + + + @for (row of noteRows; track row.denominationId) { + + + + + + + } + +
DenominationSystem ExpectedPhysical CountVariance
+ ₹{{ row.denominationValue }} + {{ row.expectedCount }} +
+ + + +
+
+ {{ varianceFor(row) > 0 ? '+' : '' }}{{ varianceFor(row) }} + @if (varianceFor(row) !== 0) { + + (₹{{ varianceFor(row) * row.denominationValue | number: '1.0-0' }}) + + } +
+ } + + + @if (coinRows.length > 0) { +
Coins
+ + + + + + + + + + + @for (row of coinRows; track row.denominationId) { + + + + + + + } + +
DenominationSystem ExpectedPhysical CountVariance
+ ₹{{ row.denominationValue }} + {{ row.expectedCount }} +
+ + + +
+
+ {{ varianceFor(row) > 0 ? '+' : '' }}{{ varianceFor(row) }} + @if (varianceFor(row) !== 0) { + + (₹{{ varianceFor(row) * row.denominationValue | number: '1.0-0' }}) + + } +
+ } +
+ + + @if (hasVariance) { +
+ {{ 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) { +
+ {{ 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 }} - +
+ @if (totalVariance !== 0 && !varianceReason.trim()) { + Enter variance reason to save + } + +
} 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(); },