feat(cashier): integrate expected balance, settlement verification, and reconciliation checks
This commit is contained in:
@@ -83,7 +83,7 @@
|
||||
<td>{{ r.fromParty }}</td>
|
||||
<td>{{ r.toParty }}</td>
|
||||
<td class="num">₹{{ r.amount | number: '1.2-2' }}</td>
|
||||
<td>{{ r.note || '—' }}</td>
|
||||
<td class="td-note">{{ r.note || '—' }}</td>
|
||||
<td>{{ r.performedByUsername || '—' }}</td>
|
||||
<td style="text-align: center">
|
||||
<span class="badge" [class.badge-active]="!r.isReversed" [class.badge-reversed]="r.isReversed">
|
||||
@@ -91,11 +91,21 @@
|
||||
</span>
|
||||
</td>
|
||||
<td style="text-align: center">
|
||||
@if (!r.isReversed) {
|
||||
<button type="button" class="pg-btn pg-btn--ghost btn-sm" (click)="startReverse(r)">
|
||||
<mat-icon>undo</mat-icon>
|
||||
<div class="action-btns">
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost btn-sm btn-view"
|
||||
title="View details"
|
||||
(click)="viewDetail(r)"
|
||||
>
|
||||
<mat-icon>visibility</mat-icon>
|
||||
</button>
|
||||
}
|
||||
@if (!r.isReversed) {
|
||||
<button type="button" class="pg-btn pg-btn--ghost btn-sm" title="Reverse" (click)="startReverse(r)">
|
||||
<mat-icon>undo</mat-icon>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@@ -104,3 +114,126 @@
|
||||
</div>
|
||||
}
|
||||
</ng-template>
|
||||
|
||||
<!-- ── Detail Overlay ── -->
|
||||
@if (detail) {
|
||||
<div class="detail-overlay" (click)="closeDetail()">
|
||||
<div class="detail-panel" (click)="$event.stopPropagation()">
|
||||
<!-- Header -->
|
||||
<div class="detail-panel__head">
|
||||
<div class="detail-panel__title">
|
||||
<mat-icon>{{ detail.row.type === 'settlement' ? 'account_balance_wallet' : 'swap_horiz' }}</mat-icon>
|
||||
<div>
|
||||
<span class="detail-panel__type">{{ typeLabel(detail.row.type) }}</span>
|
||||
<span class="detail-panel__id">#{{ detail.row.id }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="detail-panel__close" (click)="closeDetail()">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Meta grid -->
|
||||
<div class="detail-meta">
|
||||
<div class="detail-meta__item">
|
||||
<span class="detail-meta__label">From</span>
|
||||
<span class="detail-meta__val">{{ detail.row.fromParty || '—' }}</span>
|
||||
</div>
|
||||
<div class="detail-meta__item">
|
||||
<span class="detail-meta__label">To</span>
|
||||
<span class="detail-meta__val">{{ detail.row.toParty || '—' }}</span>
|
||||
</div>
|
||||
<div class="detail-meta__item">
|
||||
<span class="detail-meta__label">Date</span>
|
||||
<span class="detail-meta__val">{{ detail.row.date }}</span>
|
||||
</div>
|
||||
<div class="detail-meta__item">
|
||||
<span class="detail-meta__label">Total Amount</span>
|
||||
<span class="detail-meta__val detail-meta__val--amount">₹{{ detail.row.amount | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
<div class="detail-meta__item">
|
||||
<span class="detail-meta__label">Performed By</span>
|
||||
<span class="detail-meta__val">{{ detail.row.performedByUsername || '—' }}</span>
|
||||
</div>
|
||||
<div class="detail-meta__item">
|
||||
<span class="detail-meta__label">Created At</span>
|
||||
<span class="detail-meta__val">{{ detail.row.createdAt || '—' }}</span>
|
||||
</div>
|
||||
<div class="detail-meta__item detail-meta__item--full">
|
||||
<span class="detail-meta__label">Note</span>
|
||||
<span class="detail-meta__val">{{ detail.row.note || '—' }}</span>
|
||||
</div>
|
||||
<div class="detail-meta__item">
|
||||
<span class="detail-meta__label">Status</span>
|
||||
<span
|
||||
class="badge"
|
||||
[class.badge-active]="!detail.row.isReversed"
|
||||
[class.badge-reversed]="detail.row.isReversed"
|
||||
>
|
||||
{{ detail.row.isReversed ? 'Reversed' : 'Active' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reversal info (if reversed) -->
|
||||
@if (detail.row.isReversed) {
|
||||
<div class="detail-reversal">
|
||||
<mat-icon>undo</mat-icon>
|
||||
<div>
|
||||
<strong>Reversed</strong>
|
||||
@if (detail.row.reversedAt) {
|
||||
<span> on {{ detail.row.reversedAt }}</span>
|
||||
}
|
||||
@if (detail.row.reverseReason) {
|
||||
<span class="detail-reversal__reason"> — {{ detail.row.reverseReason }}</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Denomination breakdown -->
|
||||
<div class="detail-denom-head">
|
||||
<mat-icon>payments</mat-icon>
|
||||
Denomination Breakdown
|
||||
</div>
|
||||
|
||||
@if (detail.loading) {
|
||||
<div class="pg-empty" style="padding: 20px 0">Loading denomination details…</div>
|
||||
} @else if (detail.denominations.length === 0) {
|
||||
<div class="pg-empty" style="padding: 20px 0">No denomination data recorded.</div>
|
||||
} @else {
|
||||
<table class="pg-table detail-denom-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Denomination</th>
|
||||
<th style="text-align: center">Count</th>
|
||||
<th class="num">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (d of detail.denominations; track d.denominationValue) {
|
||||
<tr>
|
||||
<td>
|
||||
<span class="pg-chip">₹{{ d.denominationValue }}</span>
|
||||
</td>
|
||||
<td style="text-align: center; font-weight: 600">{{ d.count }}</td>
|
||||
<td class="num">₹{{ d.totalAmount | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="detail-denom-total">
|
||||
<td><strong>Total</strong></td>
|
||||
<td style="text-align: center">
|
||||
<strong>{{ totalCount(detail.denominations) }}</strong>
|
||||
</td>
|
||||
<td class="num">
|
||||
<strong>₹{{ detail.row.amount | number: '1.2-2' }}</strong>
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -49,6 +49,9 @@ export class CashierService {
|
||||
getCashTransfers(): Observable<any[]> {
|
||||
return this.http.get<any[]>('/cashtransfer');
|
||||
}
|
||||
getCashTransferById(id: number): Observable<any> {
|
||||
return this.http.get(`/cashtransfer/${id}`);
|
||||
}
|
||||
reverseCashTransfer(id: number, reason: string): Observable<any> {
|
||||
return this.http.post(`/cashtransfer/${id}/reverse`, { reason });
|
||||
}
|
||||
@@ -60,6 +63,9 @@ export class CashierService {
|
||||
getCashSettlements(): Observable<any[]> {
|
||||
return this.http.get<any[]>('/cashsettlement');
|
||||
}
|
||||
getCashSettlementById(id: number): Observable<any> {
|
||||
return this.http.get(`/cashsettlement/${id}`);
|
||||
}
|
||||
reverseCashSettlement(id: number, reason: string): Observable<any> {
|
||||
return this.http.post(`/cashsettlement/${id}/reverse`, { reason });
|
||||
}
|
||||
@@ -71,6 +77,9 @@ export class CashierService {
|
||||
getInterCashierTransfers(): Observable<any[]> {
|
||||
return this.http.get<any[]>('/cashintercashier');
|
||||
}
|
||||
getInterCashierById(id: number): Observable<any> {
|
||||
return this.http.get(`/cashintercashier/${id}`);
|
||||
}
|
||||
reverseInterCashierTransfer(id: number, reason: string): Observable<any> {
|
||||
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<any> {
|
||||
const params = new HttpParams().set('cashierId', String(cashierId)).set('date', date);
|
||||
return this.http.get('/cashsettlement/check', { params });
|
||||
}
|
||||
|
||||
checkReconciliation(cashierId: number, date: string): Observable<any> {
|
||||
const params = new HttpParams().set('cashierId', String(cashierId)).set('date', date);
|
||||
return this.http.get('/cashreconciliation/check', { params });
|
||||
}
|
||||
|
||||
getExpectedBalanceForMe(asOfDate?: string): Observable<any> {
|
||||
let params = new HttpParams();
|
||||
if (asOfDate) params = params.set('asOfDate', asOfDate);
|
||||
return this.http.get('/cashreconciliation/expected/me', { params });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,6 +175,35 @@
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (totalVariance !== 0) {
|
||||
<div
|
||||
class="settle-variance-banner"
|
||||
[class.settle-variance-banner--excess]="totalVariance > 0"
|
||||
[class.settle-variance-banner--short]="totalVariance < 0"
|
||||
style="margin: 8px 0 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>Variance reason is required to save reconciliation.</span>
|
||||
</div>
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic" style="flex-shrink: 0; width: 300px">
|
||||
<mat-label>Variance Reason *</mat-label>
|
||||
<input
|
||||
matInput
|
||||
type="text"
|
||||
[value]="varianceReason"
|
||||
(input)="varianceReason = $any($event.target).value"
|
||||
placeholder="e.g. Rounding, soiled note returned"
|
||||
/>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="pg-total-bar">
|
||||
<span>
|
||||
Expected: <strong>₹{{ totalExpected | number: '1.2-2' }}</strong> | Physical:
|
||||
@@ -184,9 +213,14 @@
|
||||
</strong>
|
||||
<span class="badge" [ngClass]="statusClass(statusLabel)">{{ statusLabel }}</span>
|
||||
</span>
|
||||
<button type="button" class="pg-btn pg-btn--primary" [disabled]="!valid || saving" (click)="submit()">
|
||||
<mat-icon>save</mat-icon> Save Reconciliation
|
||||
</button>
|
||||
<div style="display: flex; align-items: center; gap: 10px">
|
||||
@if (totalVariance !== 0 && !varianceReason.trim()) {
|
||||
<span style="font-size: 12px; color: #d97706; font-weight: 600">Enter variance reason to save</span>
|
||||
}
|
||||
<button type="button" class="pg-btn pg-btn--primary" [disabled]="!valid || saving" (click)="submit()">
|
||||
<mat-icon>save</mat-icon> Save Reconciliation
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user