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

This commit is contained in:
Jaanu423
2026-05-28 22:30:08 +05:30
parent 7ccaffa7f1
commit 6433ae068d
10 changed files with 1362 additions and 201 deletions
@@ -83,7 +83,7 @@
<td>{{ r.fromParty }}</td>
<td>{{ r.toParty }}</td>
<td class="num">₹{{ r.amount | number: '1.2-2' }}</td>
<td>{{ r.note || '—' }}</td>
<td class="td-note">{{ r.note || '—' }}</td>
<td>{{ r.performedByUsername || '—' }}</td>
<td style="text-align: center">
<span class="badge" [class.badge-active]="!r.isReversed" [class.badge-reversed]="r.isReversed">
@@ -91,11 +91,21 @@
</span>
</td>
<td style="text-align: center">
@if (!r.isReversed) {
<button type="button" class="pg-btn pg-btn--ghost btn-sm" (click)="startReverse(r)">
<mat-icon>undo</mat-icon>
<div class="action-btns">
<button
type="button"
class="pg-btn pg-btn--ghost btn-sm btn-view"
title="View details"
(click)="viewDetail(r)"
>
<mat-icon>visibility</mat-icon>
</button>
}
@if (!r.isReversed) {
<button type="button" class="pg-btn pg-btn--ghost btn-sm" title="Reverse" (click)="startReverse(r)">
<mat-icon>undo</mat-icon>
</button>
}
</div>
</td>
</tr>
}
@@ -104,3 +114,126 @@
</div>
}
</ng-template>
<!-- ── Detail Overlay ── -->
@if (detail) {
<div class="detail-overlay" (click)="closeDetail()">
<div class="detail-panel" (click)="$event.stopPropagation()">
<!-- Header -->
<div class="detail-panel__head">
<div class="detail-panel__title">
<mat-icon>{{ detail.row.type === 'settlement' ? 'account_balance_wallet' : 'swap_horiz' }}</mat-icon>
<div>
<span class="detail-panel__type">{{ typeLabel(detail.row.type) }}</span>
<span class="detail-panel__id">#{{ detail.row.id }}</span>
</div>
</div>
<button type="button" class="detail-panel__close" (click)="closeDetail()">
<mat-icon>close</mat-icon>
</button>
</div>
<!-- Meta grid -->
<div class="detail-meta">
<div class="detail-meta__item">
<span class="detail-meta__label">From</span>
<span class="detail-meta__val">{{ detail.row.fromParty || '—' }}</span>
</div>
<div class="detail-meta__item">
<span class="detail-meta__label">To</span>
<span class="detail-meta__val">{{ detail.row.toParty || '—' }}</span>
</div>
<div class="detail-meta__item">
<span class="detail-meta__label">Date</span>
<span class="detail-meta__val">{{ detail.row.date }}</span>
</div>
<div class="detail-meta__item">
<span class="detail-meta__label">Total Amount</span>
<span class="detail-meta__val detail-meta__val--amount">₹{{ detail.row.amount | number: '1.2-2' }}</span>
</div>
<div class="detail-meta__item">
<span class="detail-meta__label">Performed By</span>
<span class="detail-meta__val">{{ detail.row.performedByUsername || '—' }}</span>
</div>
<div class="detail-meta__item">
<span class="detail-meta__label">Created At</span>
<span class="detail-meta__val">{{ detail.row.createdAt || '—' }}</span>
</div>
<div class="detail-meta__item detail-meta__item--full">
<span class="detail-meta__label">Note</span>
<span class="detail-meta__val">{{ detail.row.note || '—' }}</span>
</div>
<div class="detail-meta__item">
<span class="detail-meta__label">Status</span>
<span
class="badge"
[class.badge-active]="!detail.row.isReversed"
[class.badge-reversed]="detail.row.isReversed"
>
{{ detail.row.isReversed ? 'Reversed' : 'Active' }}
</span>
</div>
</div>
<!-- Reversal info (if reversed) -->
@if (detail.row.isReversed) {
<div class="detail-reversal">
<mat-icon>undo</mat-icon>
<div>
<strong>Reversed</strong>
@if (detail.row.reversedAt) {
<span> on {{ detail.row.reversedAt }}</span>
}
@if (detail.row.reverseReason) {
<span class="detail-reversal__reason"> — {{ detail.row.reverseReason }}</span>
}
</div>
</div>
}
<!-- Denomination breakdown -->
<div class="detail-denom-head">
<mat-icon>payments</mat-icon>
Denomination Breakdown
</div>
@if (detail.loading) {
<div class="pg-empty" style="padding: 20px 0">Loading denomination details…</div>
} @else if (detail.denominations.length === 0) {
<div class="pg-empty" style="padding: 20px 0">No denomination data recorded.</div>
} @else {
<table class="pg-table detail-denom-table">
<thead>
<tr>
<th>Denomination</th>
<th style="text-align: center">Count</th>
<th class="num">Total</th>
</tr>
</thead>
<tbody>
@for (d of detail.denominations; track d.denominationValue) {
<tr>
<td>
<span class="pg-chip">₹{{ d.denominationValue }}</span>
</td>
<td style="text-align: center; font-weight: 600">{{ d.count }}</td>
<td class="num">₹{{ d.totalAmount | number: '1.2-2' }}</td>
</tr>
}
</tbody>
<tfoot>
<tr class="detail-denom-total">
<td><strong>Total</strong></td>
<td style="text-align: center">
<strong>{{ totalCount(detail.denominations) }}</strong>
</td>
<td class="num">
<strong>₹{{ detail.row.amount | number: '1.2-2' }}</strong>
</td>
</tr>
</tfoot>
</table>
}
</div>
</div>
}
@@ -79,6 +79,211 @@
}
}
/* ── Action buttons cell ── */
.action-btns {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
}
.btn-view {
color: #1e40af;
&:hover {
background: #eff6ff;
}
}
.td-note {
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ── Detail overlay ── */
.detail-overlay {
position: fixed;
inset: 0;
background: rgb(15 23 42 / 45%);
z-index: 1000;
display: flex;
align-items: flex-start;
justify-content: flex-end;
padding: 0;
backdrop-filter: blur(2px);
}
.detail-panel {
width: 480px;
max-width: 100vw;
height: 100vh;
background: #fff;
display: flex;
flex-direction: column;
overflow-y: auto;
box-shadow: -4px 0 24px rgb(0 0 0 / 15%);
&__head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 20px 16px;
border-bottom: 1px solid #e2e8f0;
position: sticky;
top: 0;
background: #fff;
z-index: 1;
}
&__title {
display: flex;
align-items: center;
gap: 10px;
mat-icon {
font-size: 22px;
height: 22px;
width: 22px;
color: #1e40af;
}
}
&__type {
display: block;
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #64748b;
}
&__id {
font-size: 18px;
font-weight: 800;
color: #0f172a;
}
&__close {
background: none;
border: none;
cursor: pointer;
padding: 4px;
border-radius: 6px;
display: flex;
align-items: center;
color: #64748b;
&:hover {
background: #f1f5f9;
color: #0f172a;
}
mat-icon {
font-size: 20px;
height: 20px;
width: 20px;
}
}
}
/* ── Meta grid ── */
.detail-meta {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0;
padding: 16px 20px;
border-bottom: 1px solid #f1f5f9;
&__item {
padding: 8px 4px;
border-bottom: 1px solid #f8fafc;
&--full {
grid-column: 1 / -1;
}
}
&__label {
display: block;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #94a3b8;
margin-bottom: 2px;
}
&__val {
font-size: 13px;
font-weight: 500;
color: #1e293b;
&--amount {
font-size: 16px;
font-weight: 800;
color: #0f172a;
}
}
}
/* ── Reversal info ── */
.detail-reversal {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 20px;
background: #fef2f2;
border-bottom: 1px solid #fecaca;
font-size: 12px;
color: #991b1b;
mat-icon {
font-size: 18px;
height: 18px;
width: 18px;
color: #dc2626;
flex-shrink: 0;
}
&__reason {
opacity: 0.8;
}
}
/* ── Denomination section ── */
.detail-denom-head {
display: flex;
align-items: center;
gap: 8px;
padding: 14px 20px 8px;
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #1e40af;
mat-icon {
font-size: 16px;
height: 16px;
width: 16px;
}
}
.detail-denom-table {
margin: 0 20px 20px;
width: calc(100% - 40px);
border: 1px solid #e2e8f0;
border-radius: 8px;
overflow: hidden;
}
.detail-denom-total td {
background: #f8fafc;
border-top: 2px solid #e2e8f0;
}
.pg-btn--danger {
background: linear-gradient(135deg, #ef4444, #b91c1c);
color: #fff;
@@ -26,10 +26,26 @@ interface TxnRow {
isReversed: boolean;
performedByUsername: string;
createdAt: string;
reversedBy: number | null;
reversedAt: string | null;
reverseReason: string | null;
type: 'transfer' | 'settlement' | 'inter';
}
/** Unified history of all cash movements with reversal support. */
interface DenomDetail {
denominationId: number | null;
denominationValue: number;
count: number;
totalAmount: number;
}
interface DetailView {
row: TxnRow;
denominations: DenomDetail[];
loading: boolean;
}
/** Unified history of all cash movements with reversal and detail view support. */
@Component({
selector: 'mifosx-cash-history',
templateUrl: './cash-history.component.html',
@@ -59,6 +75,8 @@ export class CashHistoryComponent implements OnInit {
reversingType: string | null = null;
reverseReason = '';
detail: DetailView | null = null;
ngOnInit(): void {
this.load();
}
@@ -80,6 +98,9 @@ export class CashHistoryComponent implements OnInit {
isReversed: t.isReversed,
performedByUsername: t.performedByUsername,
createdAt: t.createdAt,
reversedBy: t.reversedBy ?? null,
reversedAt: t.reversedAt ?? null,
reverseReason: t.reverseReason ?? null,
type: 'transfer'
}));
this.settlements = (settlements as any[]).map((s) => ({
@@ -92,6 +113,9 @@ export class CashHistoryComponent implements OnInit {
isReversed: s.isReversed,
performedByUsername: s.performedByUsername,
createdAt: s.createdAt,
reversedBy: s.reversedBy ?? null,
reversedAt: s.reversedAt ?? null,
reverseReason: s.reverseReason ?? null,
type: 'settlement'
}));
this.interCashier = (inter as any[]).map((i) => ({
@@ -104,12 +128,40 @@ export class CashHistoryComponent implements OnInit {
isReversed: i.isReversed,
performedByUsername: i.performedByUsername,
createdAt: i.createdAt,
reversedBy: i.reversedBy ?? null,
reversedAt: i.reversedAt ?? null,
reverseReason: i.reverseReason ?? null,
type: 'inter'
}));
this.loading = false;
});
}
viewDetail(row: TxnRow): void {
this.detail = { row, denominations: [], loading: true };
let obs$;
if (row.type === 'transfer') obs$ = this.service.getCashTransferById(row.id);
else if (row.type === 'settlement') obs$ = this.service.getCashSettlementById(row.id);
else obs$ = this.service.getInterCashierById(row.id);
obs$.pipe(catchError(() => of(null))).subscribe((data: any) => {
if (this.detail) {
this.detail.denominations = (data?.details || []).map((d: any) => ({
denominationId: d.denominationId ?? null,
denominationValue: d.denominationValue,
count: d.count,
totalAmount: d.totalAmount
}));
this.detail.loading = false;
}
});
}
closeDetail(): void {
this.detail = null;
}
startReverse(row: TxnRow): void {
this.reversingId = row.id;
this.reversingType = row.type;
@@ -143,4 +195,12 @@ export class CashHistoryComponent implements OnInit {
}
});
}
typeLabel(type: string): string {
return type === 'transfer' ? 'Cash Transfer' : type === 'settlement' ? 'Cash Settlement' : 'Inter-Cashier Transfer';
}
totalCount(denoms: DenomDetail[]): number {
return denoms.reduce((s, d) => s + d.count, 0);
}
}