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.fromParty }}</td>
|
||||||
<td>{{ r.toParty }}</td>
|
<td>{{ r.toParty }}</td>
|
||||||
<td class="num">₹{{ r.amount | number: '1.2-2' }}</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>{{ r.performedByUsername || '—' }}</td>
|
||||||
<td style="text-align: center">
|
<td style="text-align: center">
|
||||||
<span class="badge" [class.badge-active]="!r.isReversed" [class.badge-reversed]="r.isReversed">
|
<span class="badge" [class.badge-active]="!r.isReversed" [class.badge-reversed]="r.isReversed">
|
||||||
@@ -91,11 +91,21 @@
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td style="text-align: center">
|
<td style="text-align: center">
|
||||||
@if (!r.isReversed) {
|
<div class="action-btns">
|
||||||
<button type="button" class="pg-btn pg-btn--ghost btn-sm" (click)="startReverse(r)">
|
<button
|
||||||
<mat-icon>undo</mat-icon>
|
type="button"
|
||||||
|
class="pg-btn pg-btn--ghost btn-sm btn-view"
|
||||||
|
title="View details"
|
||||||
|
(click)="viewDetail(r)"
|
||||||
|
>
|
||||||
|
<mat-icon>visibility</mat-icon>
|
||||||
</button>
|
</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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
@@ -104,3 +114,126 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</ng-template>
|
</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 {
|
.pg-btn--danger {
|
||||||
background: linear-gradient(135deg, #ef4444, #b91c1c);
|
background: linear-gradient(135deg, #ef4444, #b91c1c);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
|
|||||||
@@ -26,10 +26,26 @@ interface TxnRow {
|
|||||||
isReversed: boolean;
|
isReversed: boolean;
|
||||||
performedByUsername: string;
|
performedByUsername: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
reversedBy: number | null;
|
||||||
|
reversedAt: string | null;
|
||||||
|
reverseReason: string | null;
|
||||||
type: 'transfer' | 'settlement' | 'inter';
|
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({
|
@Component({
|
||||||
selector: 'mifosx-cash-history',
|
selector: 'mifosx-cash-history',
|
||||||
templateUrl: './cash-history.component.html',
|
templateUrl: './cash-history.component.html',
|
||||||
@@ -59,6 +75,8 @@ export class CashHistoryComponent implements OnInit {
|
|||||||
reversingType: string | null = null;
|
reversingType: string | null = null;
|
||||||
reverseReason = '';
|
reverseReason = '';
|
||||||
|
|
||||||
|
detail: DetailView | null = null;
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.load();
|
this.load();
|
||||||
}
|
}
|
||||||
@@ -80,6 +98,9 @@ export class CashHistoryComponent implements OnInit {
|
|||||||
isReversed: t.isReversed,
|
isReversed: t.isReversed,
|
||||||
performedByUsername: t.performedByUsername,
|
performedByUsername: t.performedByUsername,
|
||||||
createdAt: t.createdAt,
|
createdAt: t.createdAt,
|
||||||
|
reversedBy: t.reversedBy ?? null,
|
||||||
|
reversedAt: t.reversedAt ?? null,
|
||||||
|
reverseReason: t.reverseReason ?? null,
|
||||||
type: 'transfer'
|
type: 'transfer'
|
||||||
}));
|
}));
|
||||||
this.settlements = (settlements as any[]).map((s) => ({
|
this.settlements = (settlements as any[]).map((s) => ({
|
||||||
@@ -92,6 +113,9 @@ export class CashHistoryComponent implements OnInit {
|
|||||||
isReversed: s.isReversed,
|
isReversed: s.isReversed,
|
||||||
performedByUsername: s.performedByUsername,
|
performedByUsername: s.performedByUsername,
|
||||||
createdAt: s.createdAt,
|
createdAt: s.createdAt,
|
||||||
|
reversedBy: s.reversedBy ?? null,
|
||||||
|
reversedAt: s.reversedAt ?? null,
|
||||||
|
reverseReason: s.reverseReason ?? null,
|
||||||
type: 'settlement'
|
type: 'settlement'
|
||||||
}));
|
}));
|
||||||
this.interCashier = (inter as any[]).map((i) => ({
|
this.interCashier = (inter as any[]).map((i) => ({
|
||||||
@@ -104,12 +128,40 @@ export class CashHistoryComponent implements OnInit {
|
|||||||
isReversed: i.isReversed,
|
isReversed: i.isReversed,
|
||||||
performedByUsername: i.performedByUsername,
|
performedByUsername: i.performedByUsername,
|
||||||
createdAt: i.createdAt,
|
createdAt: i.createdAt,
|
||||||
|
reversedBy: i.reversedBy ?? null,
|
||||||
|
reversedAt: i.reversedAt ?? null,
|
||||||
|
reverseReason: i.reverseReason ?? null,
|
||||||
type: 'inter'
|
type: 'inter'
|
||||||
}));
|
}));
|
||||||
this.loading = false;
|
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 {
|
startReverse(row: TxnRow): void {
|
||||||
this.reversingId = row.id;
|
this.reversingId = row.id;
|
||||||
this.reversingType = row.type;
|
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>
|
||||||
<div class="pg-eyebrow">CASHIER · CASH SETTLEMENT</div>
|
<div class="pg-eyebrow">CASHIER · CASH SETTLEMENT</div>
|
||||||
<h1 class="pg-title">Cash Settlement</h1>
|
<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>
|
</div>
|
||||||
<button type="button" class="pg-btn pg-btn--ghost" (click)="clear()"><mat-icon>undo</mat-icon> Clear</button>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="pg-toolbar pg-toolbar--mat">
|
<!-- ── LOADING ── -->
|
||||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
@if (pageState === 'loading') {
|
||||||
<mat-label>Teller (To / Vault)</mat-label>
|
<div class="settle-state">
|
||||||
<mat-select [value]="selectedTellerId" (selectionChange)="onTellerSelect($event.value)">
|
<mat-icon class="settle-state__icon settle-state__icon--spin">hourglass_empty</mat-icon>
|
||||||
@for (t of tellers; track t.id) {
|
<p>Loading your cashier position…</p>
|
||||||
<mat-option [value]="t.id">{{ t.name }}</mat-option>
|
</div>
|
||||||
}
|
}
|
||||||
@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">
|
<!-- ── SUPERVISOR MODE — Select cashier to settle ── -->
|
||||||
<mat-label>From (Cashier)</mat-label>
|
@if (pageState === 'no-cashier') {
|
||||||
<mat-select
|
<div class="settle-supervisor-card">
|
||||||
[value]="fromCashierId"
|
<div class="settle-supervisor-header">
|
||||||
(selectionChange)="onCashierSelect($event.value)"
|
<mat-icon>manage_accounts</mat-icon>
|
||||||
[disabled]="!selectedTellerId"
|
<div>
|
||||||
>
|
<strong>Supervisor / Admin Settlement</strong>
|
||||||
@for (c of cashiers; track c.id) {
|
<span
|
||||||
<mat-option [value]="c.id">{{ c.label }}</mat-option>
|
>Your account is not linked to a cashier drawer. Select a teller and cashier below to process their
|
||||||
}
|
end-of-day settlement.</span
|
||||||
@if (selectedTellerId && cashiers.length === 0 && !loadingCashiers) {
|
>
|
||||||
<mat-option [value]="null" disabled>No cashiers for this teller</mat-option>
|
</div>
|
||||||
}
|
</div>
|
||||||
</mat-select>
|
|
||||||
</mat-form-field>
|
|
||||||
|
|
||||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
<div class="pg-toolbar pg-toolbar--mat" style="margin-top: 16px">
|
||||||
<mat-label>Date</mat-label>
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
<input matInput type="date" [value]="settlementDate" (change)="settlementDate = $any($event.target).value" />
|
<mat-label>Settlement Date</mat-label>
|
||||||
</mat-form-field>
|
<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-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
<mat-label>Note</mat-label>
|
<mat-label>Teller</mat-label>
|
||||||
<input matInput type="text" [value]="note" (input)="note = $any($event.target).value" placeholder="Optional" />
|
<mat-select [value]="supervisorTellerId" (selectionChange)="onSupervisorTellerSelect($event.value)">
|
||||||
</mat-form-field>
|
@for (t of tellers; track t.id) {
|
||||||
</div>
|
<mat-option [value]="t.id">{{ t.name }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
<div class="pg-card">
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
<table class="pg-table">
|
<mat-label>Cashier</mat-label>
|
||||||
<thead>
|
<mat-select
|
||||||
<tr>
|
[value]="myCashierId"
|
||||||
<th>Denomination</th>
|
(selectionChange)="onSupervisorCashierSelect($event.value)"
|
||||||
<th style="text-align: center">Count</th>
|
[disabled]="!supervisorTellerId || loadingCashiers"
|
||||||
<th class="num">Amount</th>
|
>
|
||||||
</tr>
|
@for (c of supervisorCashiers; track c.id) {
|
||||||
</thead>
|
<mat-option [value]="c.id">{{ c.label }}</mat-option>
|
||||||
<tbody>
|
}
|
||||||
@if (notes.length) {
|
@if (supervisorTellerId && supervisorCashiers.length === 0 && !loadingCashiers) {
|
||||||
<tr class="pg-section">
|
<mat-option [value]="null" disabled>No cashiers found</mat-option>
|
||||||
<td colspan="3">Bank Notes</td>
|
}
|
||||||
</tr>
|
</mat-select>
|
||||||
}
|
</mat-form-field>
|
||||||
@for (d of notes; track d.id) {
|
</div>
|
||||||
<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>
|
|
||||||
|
|
||||||
<div class="pg-total-bar">
|
@if (supervisorChecking) {
|
||||||
Total to settle: <strong>₹{{ total | number: '1.2-2' }}</strong>
|
<div class="pg-empty">Checking cashier status…</div>
|
||||||
<button type="button" class="pg-btn pg-btn--primary" [disabled]="!valid || saving" (click)="submit()">
|
}
|
||||||
<mat-icon>check_circle</mat-icon> Record Settlement
|
</div>
|
||||||
</button>
|
}
|
||||||
</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>
|
</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 { Component, OnInit, inject } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { Router, RouterModule } from '@angular/router';
|
||||||
import { MatIconModule } from '@angular/material/icon';
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
import { MatSelectModule } from '@angular/material/select';
|
import { MatSelectModule } from '@angular/material/select';
|
||||||
import { MatInputModule } from '@angular/material/input';
|
import { MatInputModule } from '@angular/material/input';
|
||||||
import { of } from 'rxjs';
|
import { forkJoin, of } from 'rxjs';
|
||||||
import { catchError } from 'rxjs/operators';
|
import { catchError } from 'rxjs/operators';
|
||||||
import { CashierService } from '../cashier.service';
|
import { CashierService } from '../cashier.service';
|
||||||
|
|
||||||
interface Denom {
|
type PageState = 'loading' | 'no-cashier' | 'needs-reconciliation' | 'already-settled' | 'form';
|
||||||
id: number;
|
|
||||||
value: number;
|
interface SettleDenomRow {
|
||||||
|
denominationId: number;
|
||||||
|
denominationValue: number;
|
||||||
kind: 'note' | 'coin';
|
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({
|
@Component({
|
||||||
selector: 'mifosx-cash-settlement',
|
selector: 'mifosx-cash-settlement',
|
||||||
templateUrl: './cash-settlement.component.html',
|
templateUrl: './cash-settlement.component.html',
|
||||||
styleUrls: ['../cashier-shared.scss'],
|
styleUrls: [
|
||||||
|
'../cashier-shared.scss',
|
||||||
|
'./cash-settlement.component.scss'
|
||||||
|
],
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
|
RouterModule,
|
||||||
MatIconModule,
|
MatIconModule,
|
||||||
MatSnackBarModule,
|
MatSnackBarModule,
|
||||||
MatFormFieldModule,
|
MatFormFieldModule,
|
||||||
@@ -44,102 +61,171 @@ interface Denom {
|
|||||||
export class CashSettlementComponent implements OnInit {
|
export class CashSettlementComponent implements OnInit {
|
||||||
private service = inject(CashierService);
|
private service = inject(CashierService);
|
||||||
private snackBar = inject(MatSnackBar);
|
private snackBar = inject(MatSnackBar);
|
||||||
|
private router = inject(Router);
|
||||||
|
|
||||||
notes: Denom[] = [];
|
pageState: PageState = 'loading';
|
||||||
coins: Denom[] = [];
|
isSupervisor = false;
|
||||||
counts: Record<number, number> = {};
|
|
||||||
settlementDate = '';
|
|
||||||
note = '';
|
|
||||||
saving = false;
|
|
||||||
|
|
||||||
tellers: { id: number; name: string }[] = [];
|
// Cashier being settled (auto-detected for cashier, manually selected for admin)
|
||||||
cashiers: { id: number; label: string }[] = [];
|
myCashierId: number | null = null;
|
||||||
selectedTellerId: number | null = null;
|
myCashierName = '';
|
||||||
fromCashierId: number | null = null;
|
|
||||||
fromParty = '';
|
// Supervisor mode: teller selector to filter cashiers
|
||||||
toParty = '';
|
supervisorTellerId: number | null = null;
|
||||||
|
supervisorCashiers: { id: number; label: string }[] = [];
|
||||||
loadingCashiers = false;
|
loadingCashiers = false;
|
||||||
|
supervisorChecking = false;
|
||||||
|
|
||||||
get denoms(): Denom[] {
|
rows: SettleDenomRow[] = [];
|
||||||
return [
|
|
||||||
...this.notes,
|
settlementDate = '';
|
||||||
...this.coins
|
// Vault teller to deposit TO
|
||||||
];
|
tellers: { id: number; name: string }[] = [];
|
||||||
}
|
selectedTellerId: number | null = null;
|
||||||
|
toParty = '';
|
||||||
|
note = '';
|
||||||
|
varianceReason = '';
|
||||||
|
saving = false;
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
const d = new Date();
|
const d = new Date();
|
||||||
this.settlementDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
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
|
this.service
|
||||||
.getTellers()
|
.getTellers()
|
||||||
.pipe(catchError(() => of([] as any[])))
|
.pipe(catchError(() => of([] as any[])))
|
||||||
.subscribe((list) => {
|
.subscribe((list: any[]) => {
|
||||||
this.tellers = (list || []).map((t: any) => ({ id: t.id, name: t.name }));
|
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 {
|
// Called after cashier is known (auto or supervisor-selected)
|
||||||
this.selectedTellerId = tellerId;
|
private loadCashierChecks(cashierId: number): void {
|
||||||
const teller = this.tellers.find((t) => t.id === tellerId);
|
forkJoin({
|
||||||
this.toParty = teller ? teller.name : '';
|
expected: this.service.getExpectedBalance(cashierId, this.settlementDate).pipe(catchError(() => of(null))),
|
||||||
this.fromCashierId = null;
|
check: this.service
|
||||||
this.fromParty = '';
|
.checkSettlement(cashierId, this.settlementDate)
|
||||||
this.cashiers = [];
|
.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;
|
if (!tellerId) return;
|
||||||
this.loadingCashiers = true;
|
this.loadingCashiers = true;
|
||||||
this.service
|
this.service
|
||||||
.getCashiersForTeller(tellerId)
|
.getCashiersForTeller(tellerId)
|
||||||
.pipe(catchError(() => of({ cashiers: [] })))
|
.pipe(catchError(() => of({ cashiers: [] })))
|
||||||
.subscribe((r: any) => {
|
.subscribe((r: any) => {
|
||||||
this.cashiers = (r?.cashiers || []).map((c: any) => ({ id: c.id, label: c.staffName }));
|
|
||||||
this.loadingCashiers = false;
|
this.loadingCashiers = false;
|
||||||
|
this.supervisorCashiers = (r?.cashiers || []).map((c: any) => ({ id: c.id, label: c.staffName }));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
onCashierSelect(cashierId: number): void {
|
// Supervisor: cashier selected → run checks
|
||||||
this.fromCashierId = cashierId;
|
onSupervisorCashierSelect(cashierId: number): void {
|
||||||
const c = this.cashiers.find((x) => x.id === cashierId);
|
const c = this.supervisorCashiers.find((x) => x.id === cashierId);
|
||||||
this.fromParty = c ? c.label : '';
|
this.myCashierId = cashierId;
|
||||||
|
this.myCashierName = c ? c.label : '';
|
||||||
|
this.rows = [];
|
||||||
|
this.supervisorChecking = true;
|
||||||
|
this.loadCashierChecks(cashierId);
|
||||||
}
|
}
|
||||||
|
|
||||||
countFor(d: Denom): number {
|
get noteRows(): SettleDenomRow[] {
|
||||||
return this.counts[d.id] || 0;
|
return this.rows.filter((r) => r.kind !== 'coin');
|
||||||
}
|
}
|
||||||
subtotal(d: Denom): number {
|
get coinRows(): SettleDenomRow[] {
|
||||||
return this.countFor(d) * d.value;
|
return this.rows.filter((r) => r.kind === 'coin');
|
||||||
}
|
|
||||||
onChange(d: Denom, raw: any): void {
|
|
||||||
this.counts[d.id] = Math.max(0, Math.floor(Number(raw) || 0));
|
|
||||||
}
|
|
||||||
bump(d: Denom, delta: number): void {
|
|
||||||
this.counts[d.id] = Math.max(0, (this.counts[d.id] || 0) + delta);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get total(): number {
|
varianceFor(row: SettleDenomRow): number {
|
||||||
return this.denoms.reduce((s, d) => s + this.subtotal(d), 0);
|
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 {
|
get valid(): boolean {
|
||||||
return this.total > 0 && !!this.fromCashierId && !!this.selectedTellerId;
|
return (
|
||||||
}
|
!!this.selectedTellerId && this.totalPhysical > 0 && (!this.hasVariance || this.varianceReason.trim().length > 0)
|
||||||
|
);
|
||||||
clear(): void {
|
|
||||||
this.counts = {};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
submit(): void {
|
submit(): void {
|
||||||
@@ -147,28 +233,41 @@ export class CashSettlementComponent implements OnInit {
|
|||||||
this.saving = true;
|
this.saving = true;
|
||||||
const payload = {
|
const payload = {
|
||||||
settlementDate: this.settlementDate,
|
settlementDate: this.settlementDate,
|
||||||
fromCashierId: this.fromCashierId,
|
fromCashierId: this.myCashierId,
|
||||||
toTellerId: this.selectedTellerId,
|
toTellerId: this.selectedTellerId,
|
||||||
fromParty: this.fromParty.trim(),
|
fromParty: this.myCashierName,
|
||||||
toParty: this.toParty.trim(),
|
toParty: this.toParty,
|
||||||
note: this.note || null,
|
note:
|
||||||
denominations: this.denoms
|
[
|
||||||
.filter((d) => this.countFor(d) > 0)
|
this.note,
|
||||||
.map((d) => ({ denominationId: d.id, denominationValue: d.value, count: this.countFor(d) }))
|
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({
|
this.service.createCashSettlement(payload).subscribe({
|
||||||
next: () => {
|
next: (res: any) => {
|
||||||
this.snackBar.open('Cash settlement recorded', 'Close', { duration: 3000 });
|
if (res?.glWarning) {
|
||||||
this.clear();
|
this.snackBar.open(`Settlement saved (GL warning: ${res.glWarning})`, 'Close', { duration: 6000 });
|
||||||
this.note = '';
|
} else {
|
||||||
this.fromCashierId = null;
|
this.snackBar.open('Cash settlement recorded successfully', 'Close', { duration: 3000 });
|
||||||
this.fromParty = '';
|
}
|
||||||
this.selectedTellerId = null;
|
this.pageState = 'already-settled';
|
||||||
this.toParty = '';
|
|
||||||
this.cashiers = [];
|
|
||||||
this.saving = false;
|
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;
|
this.saving = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -49,6 +49,9 @@ export class CashierService {
|
|||||||
getCashTransfers(): Observable<any[]> {
|
getCashTransfers(): Observable<any[]> {
|
||||||
return this.http.get<any[]>('/cashtransfer');
|
return this.http.get<any[]>('/cashtransfer');
|
||||||
}
|
}
|
||||||
|
getCashTransferById(id: number): Observable<any> {
|
||||||
|
return this.http.get(`/cashtransfer/${id}`);
|
||||||
|
}
|
||||||
reverseCashTransfer(id: number, reason: string): Observable<any> {
|
reverseCashTransfer(id: number, reason: string): Observable<any> {
|
||||||
return this.http.post(`/cashtransfer/${id}/reverse`, { reason });
|
return this.http.post(`/cashtransfer/${id}/reverse`, { reason });
|
||||||
}
|
}
|
||||||
@@ -60,6 +63,9 @@ export class CashierService {
|
|||||||
getCashSettlements(): Observable<any[]> {
|
getCashSettlements(): Observable<any[]> {
|
||||||
return this.http.get<any[]>('/cashsettlement');
|
return this.http.get<any[]>('/cashsettlement');
|
||||||
}
|
}
|
||||||
|
getCashSettlementById(id: number): Observable<any> {
|
||||||
|
return this.http.get(`/cashsettlement/${id}`);
|
||||||
|
}
|
||||||
reverseCashSettlement(id: number, reason: string): Observable<any> {
|
reverseCashSettlement(id: number, reason: string): Observable<any> {
|
||||||
return this.http.post(`/cashsettlement/${id}/reverse`, { reason });
|
return this.http.post(`/cashsettlement/${id}/reverse`, { reason });
|
||||||
}
|
}
|
||||||
@@ -71,6 +77,9 @@ export class CashierService {
|
|||||||
getInterCashierTransfers(): Observable<any[]> {
|
getInterCashierTransfers(): Observable<any[]> {
|
||||||
return this.http.get<any[]>('/cashintercashier');
|
return this.http.get<any[]>('/cashintercashier');
|
||||||
}
|
}
|
||||||
|
getInterCashierById(id: number): Observable<any> {
|
||||||
|
return this.http.get(`/cashintercashier/${id}`);
|
||||||
|
}
|
||||||
reverseInterCashierTransfer(id: number, reason: string): Observable<any> {
|
reverseInterCashierTransfer(id: number, reason: string): Observable<any> {
|
||||||
return this.http.post(`/cashintercashier/${id}/reverse`, { reason });
|
return this.http.post(`/cashintercashier/${id}/reverse`, { reason });
|
||||||
}
|
}
|
||||||
@@ -113,4 +122,20 @@ export class CashierService {
|
|||||||
if (asOfDate) params = params.set('asOfDate', asOfDate);
|
if (asOfDate) params = params.set('asOfDate', asOfDate);
|
||||||
return this.http.get('/cashreports/cashier-position/me', { params });
|
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>
|
</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">
|
<div class="pg-total-bar">
|
||||||
<span>
|
<span>
|
||||||
Expected: <strong>₹{{ totalExpected | number: '1.2-2' }}</strong> | Physical:
|
Expected: <strong>₹{{ totalExpected | number: '1.2-2' }}</strong> | Physical:
|
||||||
@@ -184,9 +213,14 @@
|
|||||||
</strong>
|
</strong>
|
||||||
<span class="badge" [ngClass]="statusClass(statusLabel)">{{ statusLabel }}</span>
|
<span class="badge" [ngClass]="statusClass(statusLabel)">{{ statusLabel }}</span>
|
||||||
</span>
|
</span>
|
||||||
<button type="button" class="pg-btn pg-btn--primary" [disabled]="!valid || saving" (click)="submit()">
|
<div style="display: flex; align-items: center; gap: 10px">
|
||||||
<mat-icon>save</mat-icon> Save Reconciliation
|
@if (totalVariance !== 0 && !varianceReason.trim()) {
|
||||||
</button>
|
<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>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,3 +45,69 @@
|
|||||||
background: #fee2e2;
|
background: #fee2e2;
|
||||||
color: #991b1b;
|
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 = '';
|
cashierName = '';
|
||||||
reconDate = '';
|
reconDate = '';
|
||||||
note = '';
|
note = '';
|
||||||
|
varianceReason = '';
|
||||||
rows: DenomRow[] = [];
|
rows: DenomRow[] = [];
|
||||||
loadingExpected = false;
|
loadingExpected = false;
|
||||||
saving = false;
|
saving = false;
|
||||||
@@ -162,7 +163,11 @@ export class ReconciliationComponent implements OnInit {
|
|||||||
return v === 0 ? 'OK' : v > 0 ? 'EXCESS' : 'SHORT';
|
return v === 0 ? 'OK' : v > 0 ? 'EXCESS' : 'SHORT';
|
||||||
}
|
}
|
||||||
get valid(): boolean {
|
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 {
|
submit(): void {
|
||||||
@@ -173,7 +178,13 @@ export class ReconciliationComponent implements OnInit {
|
|||||||
tellerId: this.selectedTellerId,
|
tellerId: this.selectedTellerId,
|
||||||
cashierName: this.cashierName,
|
cashierName: this.cashierName,
|
||||||
reconDate: this.reconDate,
|
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) => ({
|
denominations: this.rows.map((r) => ({
|
||||||
denominationId: r.denominationId,
|
denominationId: r.denominationId,
|
||||||
denominationValue: r.denominationValue,
|
denominationValue: r.denominationValue,
|
||||||
@@ -190,6 +201,7 @@ export class ReconciliationComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
this.rows = [];
|
this.rows = [];
|
||||||
this.note = '';
|
this.note = '';
|
||||||
|
this.varianceReason = '';
|
||||||
this.saving = false;
|
this.saving = false;
|
||||||
this.loadHistory();
|
this.loadHistory();
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user