feat(cashier): integrate expected balance, settlement verification, and reconciliation checks

This commit is contained in:
Jaanu423
2026-05-28 22:30:08 +05:30
parent 7ccaffa7f1
commit 6433ae068d
10 changed files with 1362 additions and 201 deletions
@@ -11,114 +11,346 @@
<div>
<div class="pg-eyebrow">CASHIER · CASH SETTLEMENT</div>
<h1 class="pg-title">Cash Settlement</h1>
<p class="pg-sub">Cashier returns denomination-wise cash to teller / vault at end of day.</p>
<p class="pg-sub">
End-of-day settlement — verify your denomination count against system expected balance and hand cash to vault.
</p>
</div>
<button type="button" class="pg-btn pg-btn--ghost" (click)="clear()"><mat-icon>undo</mat-icon> Clear</button>
</header>
<div class="pg-toolbar pg-toolbar--mat">
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Teller (To / Vault)</mat-label>
<mat-select [value]="selectedTellerId" (selectionChange)="onTellerSelect($event.value)">
@for (t of tellers; track t.id) {
<mat-option [value]="t.id">{{ t.name }}</mat-option>
}
@if (tellers.length === 0) {
<mat-option [value]="null" disabled>No tellers configured</mat-option>
}
</mat-select>
</mat-form-field>
<!-- ── LOADING ── -->
@if (pageState === 'loading') {
<div class="settle-state">
<mat-icon class="settle-state__icon settle-state__icon--spin">hourglass_empty</mat-icon>
<p>Loading your cashier position…</p>
</div>
}
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>From (Cashier)</mat-label>
<mat-select
[value]="fromCashierId"
(selectionChange)="onCashierSelect($event.value)"
[disabled]="!selectedTellerId"
>
@for (c of cashiers; track c.id) {
<mat-option [value]="c.id">{{ c.label }}</mat-option>
}
@if (selectedTellerId && cashiers.length === 0 && !loadingCashiers) {
<mat-option [value]="null" disabled>No cashiers for this teller</mat-option>
}
</mat-select>
</mat-form-field>
<!-- ── SUPERVISOR MODE — Select cashier to settle ── -->
@if (pageState === 'no-cashier') {
<div class="settle-supervisor-card">
<div class="settle-supervisor-header">
<mat-icon>manage_accounts</mat-icon>
<div>
<strong>Supervisor / Admin Settlement</strong>
<span
>Your account is not linked to a cashier drawer. Select a teller and cashier below to process their
end-of-day settlement.</span
>
</div>
</div>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Date</mat-label>
<input matInput type="date" [value]="settlementDate" (change)="settlementDate = $any($event.target).value" />
</mat-form-field>
<div class="pg-toolbar pg-toolbar--mat" style="margin-top: 16px">
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Settlement Date</mat-label>
<input matInput type="date" [value]="settlementDate" (change)="settlementDate = $any($event.target).value" />
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="pg-note-field">
<mat-label>Note</mat-label>
<input matInput type="text" [value]="note" (input)="note = $any($event.target).value" placeholder="Optional" />
</mat-form-field>
</div>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Teller</mat-label>
<mat-select [value]="supervisorTellerId" (selectionChange)="onSupervisorTellerSelect($event.value)">
@for (t of tellers; track t.id) {
<mat-option [value]="t.id">{{ t.name }}</mat-option>
}
</mat-select>
</mat-form-field>
<div class="pg-card">
<table class="pg-table">
<thead>
<tr>
<th>Denomination</th>
<th style="text-align: center">Count</th>
<th class="num">Amount</th>
</tr>
</thead>
<tbody>
@if (notes.length) {
<tr class="pg-section">
<td colspan="3">Bank Notes</td>
</tr>
}
@for (d of notes; track d.id) {
<tr>
<td>
<span class="pg-chip">{{ d.label }}</span>
</td>
<td style="display: flex; justify-content: center">
<div class="pg-step">
<button type="button" (click)="bump(d, -1)" [disabled]="countFor(d) === 0">−</button>
<input type="number" min="0" [value]="countFor(d)" (input)="onChange(d, $any($event.target).value)" />
<button type="button" (click)="bump(d, 1)">+</button>
</div>
</td>
<td class="num">₹{{ subtotal(d) | number: '1.2-2' }}</td>
</tr>
}
@if (coins.length) {
<tr class="pg-section">
<td colspan="3">Coins</td>
</tr>
}
@for (d of coins; track d.id) {
<tr>
<td>
<span class="pg-chip pg-chip--coin">{{ d.label }}</span>
</td>
<td style="display: flex; justify-content: center">
<div class="pg-step">
<button type="button" (click)="bump(d, -1)" [disabled]="countFor(d) === 0">−</button>
<input type="number" min="0" [value]="countFor(d)" (input)="onChange(d, $any($event.target).value)" />
<button type="button" (click)="bump(d, 1)">+</button>
</div>
</td>
<td class="num">₹{{ subtotal(d) | number: '1.2-2' }}</td>
</tr>
}
@if (denoms.length === 0) {
<tr>
<td colspan="3" class="pg-empty">No active denominations. Add some in Cash Denomination.</td>
</tr>
}
</tbody>
</table>
</div>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Cashier</mat-label>
<mat-select
[value]="myCashierId"
(selectionChange)="onSupervisorCashierSelect($event.value)"
[disabled]="!supervisorTellerId || loadingCashiers"
>
@for (c of supervisorCashiers; track c.id) {
<mat-option [value]="c.id">{{ c.label }}</mat-option>
}
@if (supervisorTellerId && supervisorCashiers.length === 0 && !loadingCashiers) {
<mat-option [value]="null" disabled>No cashiers found</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="pg-total-bar">
Total to settle: <strong>₹{{ total | number: '1.2-2' }}</strong>
<button type="button" class="pg-btn pg-btn--primary" [disabled]="!valid || saving" (click)="submit()">
<mat-icon>check_circle</mat-icon> Record Settlement
</button>
</div>
@if (supervisorChecking) {
<div class="pg-empty">Checking cashier status…</div>
}
</div>
}
<!-- ── RECONCILIATION PENDING ── -->
@if (pageState === 'needs-reconciliation') {
<div class="settle-alert settle-alert--warn">
<mat-icon>assignment_late</mat-icon>
<div>
<strong>Reconciliation required before settlement</strong>
<p>
{{ myCashierName ? myCashierName + ' has' : 'You have' }} not completed today's cash reconciliation.
Settlement cannot proceed until reconciliation is done and any variance is documented.
</p>
<div style="display: flex; gap: 10px; margin-top: 10px; flex-wrap: wrap">
<button type="button" class="pg-btn pg-btn--primary" (click)="goToReconciliation()">
<mat-icon>assignment</mat-icon> Go to Reconciliation
</button>
@if (isSupervisor) {
<button type="button" class="pg-btn" (click)="pageState = 'no-cashier'">
<mat-icon>arrow_back</mat-icon> Select Different Cashier
</button>
}
</div>
</div>
</div>
}
<!-- ── ALREADY SETTLED ── -->
@if (pageState === 'already-settled') {
<div class="settle-alert settle-alert--ok">
<mat-icon>check_circle</mat-icon>
<div>
<strong>{{ myCashierName ? myCashierName + ' — already' : 'Already' }} settled for {{ settlementDate }}</strong>
<p>
Cash has already been handed back to the vault for this date. To correct an error, a manager must reverse the
existing settlement first.
</p>
@if (isSupervisor) {
<button type="button" class="pg-btn" style="margin-top: 10px" (click)="pageState = 'no-cashier'">
<mat-icon>arrow_back</mat-icon> Select Different Cashier
</button>
}
</div>
</div>
}
<!-- ── FORM ── -->
@if (pageState === 'form') {
<!-- Cashier identity + teller + date -->
<div class="pg-toolbar pg-toolbar--mat">
<div class="settle-who">
<mat-icon>{{ isSupervisor ? 'manage_accounts' : 'person' }}</mat-icon>
<span class="settle-who__name">{{ myCashierName }}</span>
<span class="settle-who__label">{{ isSupervisor ? 'Cashier (Supervisor)' : 'Cashier (From)' }}</span>
@if (isSupervisor) {
<button
type="button"
class="pg-btn"
style="margin-left: 8px; font-size: 11px; padding: 2px 8px; height: 26px"
(click)="pageState = 'no-cashier'"
>
Change
</button>
}
</div>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Teller / Vault (To) *</mat-label>
<mat-select [value]="selectedTellerId" (selectionChange)="onTellerSelect($event.value)">
@for (t of tellers; track t.id) {
<mat-option [value]="t.id">{{ t.name }}</mat-option>
}
@if (tellers.length === 0) {
<mat-option [value]="null" disabled>No tellers configured</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Settlement Date</mat-label>
<input
matInput
type="date"
[value]="settlementDate"
(change)="settlementDate = $any($event.target).value; loadData()"
/>
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="pg-note-field">
<mat-label>Note (Optional)</mat-label>
<input matInput type="text" [value]="note" (input)="note = $any($event.target).value" />
</mat-form-field>
</div>
<!-- No expected balance -->
@if (rows.length === 0) {
<div class="settle-alert settle-alert--warn">
<mat-icon>warning</mat-icon>
<div>
<strong>No expected balance found</strong>
<p>
No cash has been allocated to your drawer for {{ settlementDate }}. Ensure vault-to-cashier transfer was
done in the morning.
</p>
</div>
</div>
}
<!-- Denomination table -->
@if (rows.length > 0) {
<div class="pg-card">
<!-- Notes -->
@if (noteRows.length > 0) {
<div class="pos-section-label">Notes</div>
<table class="pg-table settle-table">
<thead>
<tr>
<th>Denomination</th>
<th class="num">System Expected</th>
<th style="text-align: center">Physical Count</th>
<th class="num">Variance</th>
</tr>
</thead>
<tbody>
@for (row of noteRows; track row.denominationId) {
<tr [class.row-excess]="varianceFor(row) > 0" [class.row-short]="varianceFor(row) < 0">
<td>
<span class="pg-chip">₹{{ row.denominationValue }}</span>
</td>
<td class="num settle-expected">{{ row.expectedCount }}</td>
<td style="text-align: center">
<div class="pg-step" style="margin: 0 auto">
<button type="button" (click)="bump(row, -1)" [disabled]="row.physicalCount === 0">−</button>
<input
type="number"
min="0"
[value]="row.physicalCount"
(input)="updatePhysical(row, $any($event.target).value)"
/>
<button type="button" (click)="bump(row, 1)">+</button>
</div>
</td>
<td
class="num"
[class.settle-var--pos]="varianceFor(row) > 0"
[class.settle-var--neg]="varianceFor(row) < 0"
[class.settle-var--zero]="varianceFor(row) === 0"
>
{{ varianceFor(row) > 0 ? '+' : '' }}{{ varianceFor(row) }}
@if (varianceFor(row) !== 0) {
<span class="settle-var-amt">
(₹{{ varianceFor(row) * row.denominationValue | number: '1.0-0' }})
</span>
}
</td>
</tr>
}
</tbody>
</table>
}
<!-- Coins -->
@if (coinRows.length > 0) {
<div class="pos-section-label pos-section-label--coin">Coins</div>
<table class="pg-table settle-table">
<thead>
<tr>
<th>Denomination</th>
<th class="num">System Expected</th>
<th style="text-align: center">Physical Count</th>
<th class="num">Variance</th>
</tr>
</thead>
<tbody>
@for (row of coinRows; track row.denominationId) {
<tr [class.row-excess]="varianceFor(row) > 0" [class.row-short]="varianceFor(row) < 0">
<td>
<span class="pg-chip pg-chip--coin">₹{{ row.denominationValue }}</span>
</td>
<td class="num settle-expected">{{ row.expectedCount }}</td>
<td style="text-align: center">
<div class="pg-step" style="margin: 0 auto">
<button type="button" (click)="bump(row, -1)" [disabled]="row.physicalCount === 0">−</button>
<input
type="number"
min="0"
[value]="row.physicalCount"
(input)="updatePhysical(row, $any($event.target).value)"
/>
<button type="button" (click)="bump(row, 1)">+</button>
</div>
</td>
<td
class="num"
[class.settle-var--pos]="varianceFor(row) > 0"
[class.settle-var--neg]="varianceFor(row) < 0"
[class.settle-var--zero]="varianceFor(row) === 0"
>
{{ varianceFor(row) > 0 ? '+' : '' }}{{ varianceFor(row) }}
@if (varianceFor(row) !== 0) {
<span class="settle-var-amt">
(₹{{ varianceFor(row) * row.denominationValue | number: '1.0-0' }})
</span>
}
</td>
</tr>
}
</tbody>
</table>
}
</div>
<!-- Variance reason banner (only when variance exists) -->
@if (hasVariance) {
<div
class="settle-variance-banner"
[class.settle-variance-banner--excess]="totalVariance > 0"
[class.settle-variance-banner--short]="totalVariance < 0"
>
<mat-icon>{{ totalVariance > 0 ? 'add_circle_outline' : 'remove_circle_outline' }}</mat-icon>
<div class="settle-variance-text">
<strong>
{{ totalVariance > 0 ? 'Excess' : 'Shortage' }}: ₹{{
(totalVariance < 0 ? -totalVariance : totalVariance) | number: '1.2-2'
}}
</strong>
<span>Physical count differs from system expected. A reason is required to proceed.</span>
</div>
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="settle-reason-field">
<mat-label>Variance Reason *</mat-label>
<input
matInput
type="text"
[value]="varianceReason"
(input)="varianceReason = $any($event.target).value"
placeholder="e.g. Customer gave torn note, rounding difference"
/>
</mat-form-field>
</div>
}
<!-- Footer totals + submit -->
<div class="pg-total-bar settle-footer">
<div class="settle-totals">
<span class="settle-totals__item">
System Expected: <strong>₹{{ totalExpected | number: '1.2-2' }}</strong>
</span>
<span class="settle-totals__sep">|</span>
<span class="settle-totals__item">
Physical Count: <strong>₹{{ totalPhysical | number: '1.2-2' }}</strong>
</span>
@if (hasVariance) {
<span class="settle-totals__sep">|</span>
<span
class="settle-totals__item"
[class.settle-var--pos]="totalVariance > 0"
[class.settle-var--neg]="totalVariance < 0"
>
Variance:
<strong>{{ totalVariance > 0 ? '+' : '' }}₹{{ totalVariance | number: '1.2-2' }}</strong>
</span>
}
</div>
<div class="settle-footer__actions">
@if (!selectedTellerId) {
<span class="settle-hint">Select a teller / vault to enable submit</span>
}
@if (hasVariance && !varianceReason.trim()) {
<span class="settle-hint settle-hint--warn">Enter variance reason to submit</span>
}
<button type="button" class="pg-btn pg-btn--primary" [disabled]="!valid || saving" (click)="submit()">
<mat-icon>check_circle</mat-icon>
{{ saving ? 'Saving…' : 'Record Settlement' }}
</button>
</div>
</div>
}
}
</div>
@@ -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;
}
}
@@ -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<number, number> = {};
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;
}
});