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