feat(cashier): integrate expected balance, settlement verification, and reconciliation checks
This commit is contained in:
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user